Metadata-Version: 2.4
Name: sniprobe
Version: 0.1.0
Summary: Measure from the inside whether a TLS destination is reachable, and classify how it fails. Pure Python, zero dependencies.
License: MIT
Keywords: tls,sni,network-measurement,reachability,diagnostics,censorship-measurement,clienthello,serverhello,zero-dependency
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Intended Audience :: System Administrators
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 :: Monitoring
Requires-Python: >=3.8
Description-Content-Type: text/markdown
License-File: LICENSE
Provides-Extra: dev
Requires-Dist: pytest>=7; extra == "dev"
Dynamic: license-file

# sniprobe

**Measure, from the inside, whether a TLS destination is reachable — and classify *how* it fails.** Pure Python, zero dependencies.

```console
$ sniprobe check cloudflare.com www.microsoft.com nonexistent.invalid

sniprobe  2026-08-06T17:31:16Z  network not identified
network: ok
  local    yandex.ru=ok, vk.com=ok
  foreign  cloudflare.com=ok, www.microsoft.com=ok

  clear        ok            cloudflare.com        TLSv1.3 x25519
  clear        ok            www.microsoft.com     TLSv1.3 x25519
  unreachable  dns_failure   nonexistent.invalid   -- no session established; may be the host itself
```

It is a **measurement tool**. It establishes no tunnel, proxies nothing, and carries no traffic.

**[SPEC.md](SPEC.md)** is the reimplementation contract: the verdict vocabulary, the interpretation rules, the report schema field by field, and the privacy requirements as normative MUST/MUST NOT. Read it if you are writing a probe in another language, consuming reports, or reviewing what a report discloses.

**[RISKS.md](RISKS.md)** — if you might run a probe, or ask anyone else to, read this first. A probe sends real, observable traffic, legal exposure varies by jurisdiction, and the tool measures the network without protecting the person running it.

**[docs/TSPU-signals.md](docs/TSPU-signals.md)** — a grounded reference on which TSPU-style interference signatures a client-side probe can and cannot observe, why the volume-based blocking is out of scope by principle (never load, per Decision 9), and what a future mobile probe could add.

---

## Why

Tools that evaluate a candidate TLS destination run **on a server**, from an unfiltered vantage point, and answer *"is this technically valid?"* — TLS 1.3, X25519, HTTP/2, certificate matches. That question is necessary and already well covered by [RealityChecker](https://github.com/V2RaySSR/RealityChecker), [Hiddify-Reality-Scanner](https://github.com/hiddify/Hiddify-Reality-Scanner) and others.

The question they cannot answer is *"does the path survive?"* — because that depends on whatever sits in front of the **client**, and it differs by operator, by region and by hour. There is no vantage point but the client's own.

`sniprobe` measures from there, and reports not just failure but its shape.

## What it distinguishes

"It didn't work" is not a signal. A hotel network, a dead server and an in-path filter all produce a failed connection, and only one of them means the destination is being interfered with.

| Verdict | What happened | What it implies |
|---|---|---|
| `ok` / `hello_retry` | ServerHello came back | Path works |
| `tls_alert` | Server answered in protocol and declined | Path works, candidate unsuitable |
| `reset_after_hello` | Connection accepted, ClientHello sent, **RST** | Something in the path acted |
| `timeout_after_hello` | Connection accepted, ClientHello sent, silence | Blackhole |
| `reset_on_connect` | **RST** during the TCP handshake, before any SNI was sent | Host down or port closed; cannot be an SNI reaction |
| `refused` / `connect_timeout` | No session at all | Usually the host; weak evidence |
| `dns_failure` | Name did not resolve | Resolver or the name itself |

Nothing between accepting a TCP connection and reading the first record makes a healthy server send a reset. That row is the one worth acting on, and separating it from the others is the reason this exists.

## Controls: what makes it evidence rather than an anecdote

Every run probes two control groups before the candidates:

- **Local controls** — large domestic services. If these fail, the machine has no working internet and nothing else in the run means anything.
- **Foreign controls** — large services outside the jurisdiction, chosen to be dull rather than political. If local passes and foreign fails, the whole cross-border path is degraded and a candidate's failure says nothing specific about that candidate.

Only when both groups pass does a candidate failure carry information. The interpretation is reported explicitly as `clear`, `interfered`, `unreachable` or `inconclusive`, and `inconclusive` is used freely — an optimistic verdict here puts people on a path that quietly fails for some of them.

## Install

```bash
pip install sniprobe
```

Python 3.8+, nothing else. The package imports only `socket`, `ssl`, `struct`, `hashlib`, `json`, `re`, `time`, `urllib` and `argparse`.

## Use

```bash
# Are these candidates reachable from here?
sniprobe check www.example.com cdn.example.net

# Connect to a specific address while presenting a different SNI —
# how a TLS front is actually used, and therefore how it must be measured.
sniprobe check host.example.com=front.example.com --address 203.0.113.10

# Save a report to send to whoever is deciding.
sniprobe check www.example.com --out my-network.json

# Identify the network so reports can be compared across operators.
# Costs two extra outbound requests. See "Privacy" below.
sniprobe check www.example.com --asn --out my-network.json

# Passive recovery check (opt-in): after the run, wait ~60s and re-probe each
# interfered candidate EXACTLY ONCE. A stateful filter recovers a frozen flow on
# a timer; a dead server does not. One extra probe per interfered candidate.
sniprobe check www.example.com --recovery-after 60

# Combine reports from several networks.
sniprobe aggregate reports/*.json

# Reduce a report on-device before sending it to a shared pool of strangers.
sniprobe reduce my-network.json --shared-candidates cdn.example.net,www.example.com
```

```
reports: 7 (7 usable, 1 without network identity)
networks: AS8359, AS12389, AS31133
counts are of reports, not of people

candidate                        overall            AS8359       AS12389       AS31133
------------------------------------------------------------------------------------
cdn.example.net                  clear                 2/2           2/2           1/1
www.example.com                  mixed                 2/2           0/2           1/1
```

That second row is the entire point: a candidate that every server-side scanner calls valid, failing on one operator and not the others.

As a library:

```python
from sniprobe import control, report

run = control.run(["www.example.com"], repeats=3)
print(report.to_text(report.build(run)))
```

## Privacy

The report is what leaves the user's machine, so its shape is fixed in one function, `report.build()`, and reviewing that one function is enough to know what is disclosed.

**Goes out:** timestamp, network state, per-candidate verdict and the TLS parameters that came back. Optionally an AS number and country, only with `--asn`.

**Does not go out:** the machine's address, resolved addresses (unless `--addresses` is passed, and never for controls), anything about what the user browses — never collected, since the probe only touches the list it was given — and any persistent identifier that would let two reports be linked to one person.

`--asn` resolves the egress address to an AS number via [Team Cymru](https://team-cymru.com/community-services/ip-asn-mapping/) and **discards the address inside the lookup**, before it can reach a report object. The AS *name* is dropped too: it narrows the reporter further than the number already does and changes no decision.

The absent identifier has a cost, and the tool states it rather than hiding it: without one you cannot tell one reporter from two, so aggregation counts **reports, not people**, and says so in its own output.

Read a report before sending it. `sniprobe check ...` prints the human-readable form by default for exactly that reason.

A **personal collector** — someone you know — gets the full report. A **shared pool** of strangers gets a reduced one, and the reduction happens on your device so you can verify what leaves rather than trusting a remote party to minimise. `sniprobe reduce report.json --shared-candidates a.com,b.com` builds the shared report from an **allowlist**: it keeps only `schema`, the day-rounded timestamp, `network_state`, the AS number, and — per surviving candidate — `sni`, `verdict`, `meaning`, and the TLS parameters (`version`, `cipher_suite`, `group`, `alpn`). Everything else is dropped by construction: the country, any resolved addresses, per-candidate notes, and the whole controls subtree, along with any unknown field a report happened to carry. Only candidates in the pool's published set survive. It prints the reduced JSON and shows you the human-readable form of exactly that payload first. This is one of two layers: the collector then applies the k-anonymity threshold (`aggregate --min-reports`) that a single sender cannot. See [`SPEC.md`](SPEC.md) §10.

## Limits

- **It measures reachability, not safety.** A `clear` verdict means packets arrived, nothing more. It says nothing about whether using a destination is wise, legal where you are, or unobserved.
- **Absence of interference is not proof of absence.** Filtering is often probabilistic, time-varying and applied to a subset of traffic. Repeat runs; `--repeats` exists for that, and any single reset in a set is reported rather than averaged away.
- **Recovery is a corroborating signal, not a verdict.** A stateful filter (Russia's TSPU among them) recovers a frozen or reset flow on a timer of roughly a minute, where a dead server does not come back on a clock. `--recovery-after SECONDS` (opt-in, off by default) waits and re-probes each interfered candidate **exactly once** — passive measurement, never load (design [Decision 9](docs/plans/2026-08-07-collection-architecture-design.md)). It records `recovered` and `recovery_after_s`, and the `~60s` is *reported, not asserted*: recovery can have other causes, so it strengthens an `interfered` read without changing it.
- **The probe is observable.** It opens TCP connections and sends ClientHellos. It looks like ordinary TLS, and the ClientHello is deliberately conventional, but it is traffic and it is visible.
- **This is not a TLS implementation.** It parses just enough of a ServerHello to read version, cipher suite, key-share group and ALPN. Nothing here verifies anything — do not reuse `sniprobe.tls` as a security boundary.
- **One machine is one vantage point.** Conclusions about an operator need several reports from that operator.

## Related work

[OONI](https://ooni.org/) is the serious, long-running project for measuring network interference, with a published methodology and a public dataset. If you want to contribute to the public record of censorship measurement, contribute there — not here.

`sniprobe` is narrower on purpose: a small, auditable, dependency-free tool for one operator deciding between a handful of specific destinations, producing a report a non-technical person can read before sending it.

## Development

```bash
git clone https://github.com/Canavalny/sniprobe && cd sniprobe
python3 -m pytest -q       # 196 tests
```

The failure-mode classification is tested against local sockets that actually produce each behaviour — a real RST from a zero-linger close, a real blackhole, a real alert record — rather than against mocked exceptions. Mocks would only prove the code handles the errors someone imagined.

## License

MIT.
