Metadata-Version: 2.4
Name: waypoint-sni
Version: 0.1.0
Summary: Turn client-side reachability measurements into a reviewable front-rotation plan. Decides, never acts. Pure Python, zero dependencies.
License: MIT
Project-URL: Homepage, https://github.com/Canavalny/waypoint
Project-URL: Source, https://github.com/Canavalny/waypoint
Project-URL: Issues, https://github.com/Canavalny/waypoint/issues
Keywords: censorship-circumvention,reachability,front-rotation,sni,reality,xray,decision-tool,operator-tooling,zero-dependency
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: System Administrators
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.8
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Internet
Classifier: Topic :: System :: Networking
Requires-Python: >=3.8
Description-Content-Type: text/markdown
License-File: LICENSE
Provides-Extra: dev
Requires-Dist: pytest>=7; extra == "dev"
Dynamic: license-file

# waypoint

**Turn client-side reachability measurements into a reviewable plan for which users move to which front when one gets blocked.** Pure Python, zero dependencies.

It is a **decision tool**, not a proxy and not a panel. It runs no tunnel, serves no traffic, holds no obfuscation core, and generates no load. It reads an aggregated reachability summary and emits a **plan** — a list of recommended rotations with reasons and confidence — that the operator reads and applies deliberately. It is the "hands" to [`sniprobe`](https://github.com/Canavalny/sniprobe)'s "eyes."

```console
$ waypoint plan --endpoints fleet.json --reachability agg.json

Recommended rotations (1):
  AS 12389: move A -> S  [confirmed]  front a.example.com confirmed interfered on AS 12389 (interfered>=3, clear=0); moving cohort to clear standby s.example.com

Warnings (1):
  - no clear standby for AS 8359; users of B left in place
```

## Decide, don't act

The engine emits a plan; applying it is a separate, explicit step behind an actuator boundary. The **only** actuator shipped here is a **dry run** — it renders what would happen and touches nothing.

The reason is a safety property. A bug in the decision logic must produce a *wrong recommendation the operator can inspect and reject*, never an automatic reconfiguration that breaks everyone's access at once. There is no flag in this version that touches real infrastructure; `--apply` runs the dry-run actuator and says so loudly.

## What it does *not* rebuild

The circumvention ecosystem already solves, well and audited, everything but one thing. `waypoint` rebuilds none of it:

- **The tunnel and obfuscation core** — Xray / REALITY do that.
- **One-click server install** — existing installers (Amnezia and others) do that.
- **SNI selection from a server vantage** — Hiddify-Reality-Scanner, RealityChecker and others do that.
- **Per-user config / subscription / QR / revocation** — panels like 3x-ui and Marzban do that.

The gap none of them fill is the one this fills: **consume client-side reachability and decide, per operator, when to rotate a front or activate a warm standby, and for whom.** Until there was an input to decide on, the gap could not be filled.

## Input: an aggregate, never raw reports

The engine consumes the **aggregated, k-anonymity-thresholded** reachability summary that `sniprobe aggregate --json` produces — keyed by candidate-SNI × AS, never raw per-volunteer reports. It never handles a volunteer's identity or address, and never handles a **user** list either: it decides at endpoint × operator-AS granularity ("for users of endpoint E on AS 12389, move to standby S"), and which real users that is stays local to the operator.

`waypoint` does **not** import `sniprobe`; it consumes the documented JSON shape:

```json
{
  "networks": [8359, 12389],
  "candidates": {
    "front.example.com": {
      "overall": "interfered",
      "totals": {"clear": 0, "interfered": 2, "unreachable": 0, "inconclusive": 0},
      "by_network": {
        "8359":  {"clear": 3, "interfered": 0, "unreachable": 0, "inconclusive": 0},
        "12389": {"clear": 0, "interfered": 3, "unreachable": 0, "inconclusive": 0}
      },
      "suppressed_networks": []
    }
  }
}
```

A cell absent from `by_network` — suppressed by k-anonymity, or simply never observed — means **no usable data** for that `(sni, AS)`. The engine treats it as inconclusive and never acts on it.

The endpoint inventory is a JSON list (or `{"endpoints": [...]}`):

```json
[
  {"id": "A", "sni": "a.example.com", "role": "active"},
  {"id": "S", "sni": "s.example.com", "role": "standby", "note": "warm"}
]
```

## The rules the engine enforces

- **Hysteresis / no-flap.** It recommends a switch only on *confirmed, sustained* interference — at least `--min-reports` interfered observations with **no** clear observation in that cell. A mixed cell (any clear) is ambiguous and never actionable. Given an optional list of recent changes it will not re-switch a front it just moved.
- **Smallest blast radius.** An action targets exactly one `(endpoint, AS)` cohort. A front blocked on one operator and clear on three triggers a move only for the blocked one — never "rotate everyone because one operator changed." Only real, numeric ASes are acted on; `sniprobe`'s `"unknown"` catch-all bucket (unresolved operators) is never actionable and surfaces as a warning.
- **Fail-safe.** It moves a cohort only onto a standby that is *confirmed clear* on that AS — meaning `interfered == 0`, `clear >= --min-reports`, **and** `clear` is the dominant non-interfered outcome (`clear >= unreachable + inconclusive`). A standby that clears the threshold but is mostly *unreachable* is a worse front, not a safe one, and is refused. If no confirmed-clear standby exists it emits a **warning**, not an action — churning users from one blocked front onto a blocked or unreachable one is worse than leaving them.
- **Diversity floor.** It refuses to collapse the fleet onto a single SNI: a move that would drop the number of distinct in-service SNIs on an AS below `--diversity-floor` is held back, and when several fronts are blocked at once it spreads their cohorts across distinct standbys rather than funnelling them onto one.
- **No load, ever.** Its only outputs are rotation recommendations for the operator's own fronts. There is no code path that recommends generating traffic or acting against the filter.

Output is deterministic — actions are sorted by AS then endpoint id — so a reviewer, and a test, can pin the plan exactly.

## The actuator is a boundary; the panel adapter is yours

A real adapter — one that talks to 3x-ui, Marzban, or another panel to activate a standby or move a cohort — is a separate, operator-supplied integration and is deliberately **not** shipped here. It is the operator's own infrastructure and choice, and those panel API surfaces must not be guessed into the core. Implement the `Actuator` contract (`src/waypoint/actuator.py`) against your own panel if and when you want to apply plans; the engine's correctness does not depend on any panel.

## Install

Zero runtime dependencies; Python 3.8+.

```bash
pip install waypoint-sni          # once published
waypoint plan --endpoints fleet.json --reachability agg.json
```

`--json` emits the machine-readable plan. `--min-reports` and `--diversity-floor` tune the two thresholds above.

## Development

```bash
git clone https://github.com/Canavalny/waypoint
cd waypoint
python3 -m pytest -q          # 44 tests, no dependencies beyond pytest
```

The engine is a pure function, so every rule above is tested offline against constructed reachability summaries — no network, no clock, no panel.

## Applying a plan: the Marzban adapter

The engine only *decides*. `waypoint.marzban.MarzbanActuator` is the first real
adapter that *applies* — it talks to a [Marzban](https://github.com/Gozargah/Marzban)
panel to make a plan's standby fronts available. It authenticates, reads the
panel's hosts, and adds a host for each standby SNI a plan wants to move users
onto (`GET`/`PUT /api/hosts`). It **never removes** a host, and it defaults to a
previewable, operator-in-the-loop flow.

One honest limit is built in: **Marzban serves every user one subscription**, so
there is no per-AS-cohort steering to automate — and `waypoint` never holds the
volunteer→AS mapping that would be needed anyway (privacy). The adapter does the
part it can (ensure the standby is *available*) and emits a precise **operator
worklist** for the part it cannot (move the specific cohort). It is a library
integration you wire to your own panel and credentials; the CLI stays dry-run.

> **Verified how far:** the adapter's request-building and response-parsing are
> unit-tested against a fake transport (auth, ensure-if-missing, idempotence,
> never-remove, worklist, warnings). It has **not** been run against a live
> Marzban panel, so treat live-API compatibility as unverified until you point
> it at your own instance.

> **Optional before publishing to PyPI:** decide whether to fill in `authors`
> in `pyproject.toml` (currently commented out) and the `LICENSE` copyright line
> (currently "waypoint contributors"). The repository URLs are already set.

## License

MIT.
