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
clearverdict 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;
--repeatsexists 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 recordsrecoveredandrecovery_after_s, and the~60sis reported, not asserted: recovery can have other causes, so it strengthens aninterferedread 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.tlsas 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
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
7444cf44fca58789b7e3f5292e9e04b2e33ee92eb055d802aa0918ad212c5ef8
|
|
| MD5 |
6cc1e50eca3dcb173b4e20a08db0ca59
|
|
| BLAKE2b-256 |
eeb3d60b48e51c0e9602dabd602f084e7a260404b886ec4206d9b4c969324eec
|
Provenance
The following attestation bundles were made for sniprobe-0.1.0.tar.gz:
Publisher:
release.yml on Canavalny/sniprobe
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
sniprobe-0.1.0.tar.gz -
Subject digest:
7444cf44fca58789b7e3f5292e9e04b2e33ee92eb055d802aa0918ad212c5ef8 - Sigstore transparency entry: 2367603274
- Sigstore integration time:
-
Permalink:
Canavalny/sniprobe@245b308ea60de617e3866d40a3b20ed73ab9e841 -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/Canavalny
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@245b308ea60de617e3866d40a3b20ed73ab9e841 -
Trigger Event:
push
-
Statement type:
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
72b0b870edf690c500ac7b385e0ac6c2a4479150b78a49105bfc5c77b25a66ea
|
|
| MD5 |
6fa57125153d51cf73a023191a633d61
|
|
| BLAKE2b-256 |
36c2a07831b050ea49ee979337a4696b0a8c93afd493e1d6537c4401ebdc2b8e
|
Provenance
The following attestation bundles were made for sniprobe-0.1.0-py3-none-any.whl:
Publisher:
release.yml on Canavalny/sniprobe
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
sniprobe-0.1.0-py3-none-any.whl -
Subject digest:
72b0b870edf690c500ac7b385e0ac6c2a4479150b78a49105bfc5c77b25a66ea - Sigstore transparency entry: 2367603337
- Sigstore integration time:
-
Permalink:
Canavalny/sniprobe@245b308ea60de617e3866d40a3b20ed73ab9e841 -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/Canavalny
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@245b308ea60de617e3866d40a3b20ed73ab9e841 -
Trigger Event:
push
-
Statement type: