Skip to main content

sniprobe

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

$ 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 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 — 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 — 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, 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

pip install sniprobe

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

Use

# 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:

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 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 §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). 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 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

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.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

sniprobe-0.1.0.tar.gz (60.6 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

sniprobe-0.1.0-py3-none-any.whl (40.8 kB view details)

Uploaded Python 3

File details

Details for the file sniprobe-0.1.0.tar.gz.

File metadata

  • Download URL: sniprobe-0.1.0.tar.gz
  • Upload date:
  • Size: 60.6 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for sniprobe-0.1.0.tar.gz
Algorithm Hash digest
SHA256 7444cf44fca58789b7e3f5292e9e04b2e33ee92eb055d802aa0918ad212c5ef8
MD5 6cc1e50eca3dcb173b4e20a08db0ca59
BLAKE2b-256 eeb3d60b48e51c0e9602dabd602f084e7a260404b886ec4206d9b4c969324eec

See more details on using hashes here.

Provenance

The following attestation bundles were made for sniprobe-0.1.0.tar.gz:

Publisher: release.yml on Canavalny/sniprobe

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file sniprobe-0.1.0-py3-none-any.whl.

File metadata

  • Download URL: sniprobe-0.1.0-py3-none-any.whl
  • Upload date:
  • Size: 40.8 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for sniprobe-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 72b0b870edf690c500ac7b385e0ac6c2a4479150b78a49105bfc5c77b25a66ea
MD5 6fa57125153d51cf73a023191a633d61
BLAKE2b-256 36c2a07831b050ea49ee979337a4696b0a8c93afd493e1d6537c4401ebdc2b8e

See more details on using hashes here.

Provenance

The following attestation bundles were made for sniprobe-0.1.0-py3-none-any.whl:

Publisher: release.yml on Canavalny/sniprobe

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page