A self-verifying aggregator of public proxy configurations.
It collects published VLESS (Reality), VMess, Trojan, Shadowsocks and Hysteria2
configurations from open feeds, proves that each one actually carries traffic, and
publishes only the survivors — as text/plain subscription files and a static dashboard.
The product is the proof loop, not the aggregation.
Nothing reaches a subscription file until it has completed an HTTPS request with a verified certificate chain, through a tunnel dialled in this process. A node that answers a plaintext
204, or that only works with certificate verification disabled, is not published.
- What this is
- What this is not
- Quick start
- The build tag is not optional
- What gets published
- How it works
- Configuration
- Architecture
- Testing and gates
- Deployment
- Security posture
- Known gaps
- Contributing
- License
- Third-party notices
- Topics
A single static Go binary that runs one collection cycle end to end:
- Fetch subscription feeds over HTTPS under hard byte, line and time limits.
- Decode every line into a validated node, or reject it with a machine-countable reason. Tolerant of malformed input, never silent about a repair.
- Vet each node at a Zero-Trust boundary: allowlisted fields, one DNS resolution, and a pinned address the dialer is handed instead of a hostname.
- Prove it by constructing a sing-box outbound for that one node, in-process, and making it complete an HTTPS request whose certificate chain is verified.
- Classify it — whether its Reality masking target is real, checked directly rather than taken from a list.
- Publish the survivors, but only if the result is plausible enough to replace what is already live.
Every stage counts what it dropped, and the counts add up: the dashboard's funnel is an identity, not an illustration.
It is a from-scratch rebuild of an earlier project. The rebuild carries 56 invariants
(INV-00 … INV-55), each one closing a production failure the predecessor actually paid
for, and each one named by number in a test — see Testing and gates.
- Not a VPN service. The project runs and operates no servers. Traffic goes straight from your client to a node somebody else operates.
- Not a statement about the operators. They are unknown to us and can see what passes through them. Exactly one thing has been established about each published node: that HTTPS with a valid certificate chain completes through it.
- Not a tracker. The published output is static files. Nothing about visitors is collected or stored.
- Not an endorsement of any node. A node proven at 19:00 can be gone at 19:05. The dashboard states when each node was last verified and how long it has been passing.
git clone https://github.com/TuftaTech/ShellShift.git
cd ShellShift
./tools/install-hooks.sh # once: installs the pre-commit hygiene hook
go build -tags with_utls ./... # the tag is not optional — see below
./tools/verify.sh # every gate CI runsA run needs a configuration with a feed list. config/sources.json is deliberately not in
the repository — copy config/sources.example.json, add a sources array, or supply the
list through SHELLSHIFT_SOURCES instead. See Configuration.
One full cycle — fetch, verify, classify, publish:
go run -tags with_utls ./cmd/shellshift run \
-config config/sources.json -out dist -state .state/ledger.jsonVerify and report without writing anything:
go run -tags with_utls ./cmd/shellshift run -config config/sources.json -dry-runRender the site from an already-published payload — no network, no verification:
go run -tags with_utls ./cmd/shellshift site \
-data dist/network.json -out dist -origin https://tuftatech.github.io/ShellShiftDecode URIs from stdin and see what was rejected and why:
go run -tags with_utls ./cmd/shellshift decode -rejects < some-feed.txtBefore concluding that a run with no survivors means the feeds are dead, get the positive
controls first (INV-55):
go run -tags with_utls ./cmd/shellshift doctordoctor requests each proof endpoint directly with no tunnel, then runs the prover itself
over a path known to work. A live run once looked exactly like the endpoints had started
refusing us; they had not, and these two checks are what established that in two minutes
instead of an afternoon.
| Command | What it does |
|---|---|
run |
one full cycle: collect, verify, classify, publish |
site |
render the static site from a published network.json |
decode |
decode proxy URIs from stdin and report what was rejected |
doctor |
establish the positive controls a diagnosis needs (INV-55) |
version |
print the build version |
Both are meant to be run by hand rather than wired into a pipeline:
# republish a corpus and report which query parameters publication drops, adds or changes
go run -tags with_utls ./tools/paramdiff path/to/uris.txt
# raw proof-error distribution under several budget and concurrency settings
go run -tags with_utls ./tools/proofbench -feed <url> -sample 500 -by-transportparamdiff takes a file of URIs and touches no network at all: it decodes each line,
re-encodes it, and diffs the two.
proofbench does make real requests to third parties, which is why it is a tool rather than
a subcommand. It exists because the published funnel folds every proof failure into one
bucket — right for a dashboard, useless for tuning. -raw stops bucketing; -by-transport
keys the buckets by transport, which is what separates "the node is dead" from "our
construction is wrong for this transport".
go build -tags with_utls ./...
go test -tags with_utls ./...sing-box gates uTLS behind with_utls, and the Reality client is behind the same tag.
Without it every Reality node fails to construct and is silently dropped — in one real
corpus that was 540 of 865 nodes, and it looks like the feeds degraded rather than like a
build mistake. tunnel.CheckBuild() refuses to start a run in that state (INV-49).
Seven text/plain subscription files, a machine-readable payload, and the dashboard. All
of it is served from GitHub Pages at
tuftatech.github.io/ShellShift.
| File | Contents |
|---|---|
sub_all.txt |
every proven node, sorted by latency |
sub_wl.txt |
Reality nodes whose SNI is on a censor whitelist and whose masking was proven: the site named in the SNI exists and serves a valid certificate for its own name, checked directly rather than through the node |
sub_vless.txt |
VLESS with Reality or TLS — the most common protocol in this sample, and usually the most durable against DPI |
sub_vmess.txt |
VMess |
sub_trojan.txt |
Trojan |
sub_ss.txt |
Shadowsocks |
sub_hy2.txt |
Hysteria2 |
network.json |
the machine file beside the subscriptions: funnel, per-node attestation, and everything the dashboard's detail card shows |
state/ledger.json |
the run's memory, published so the next run can fetch it back |
A bundle nothing qualified for still renders, header and all, rather than being absent
(INV-45). The predecessor staged its outputs with one command that returned a failure when
any one path did not exist — sub_ru.txt, whenever the RU probe found nothing — and that
took the whole publication down with it, losing the eight bundles that were fine.
Paste a subscription URL into sing-box, Hiddify, v2rayNG, Streisand or YukariBox. There is no account, no token and no per-user URL — the same bytes are served to everybody.
network.json carries the server, port, credential and SNI. The rendered HTML carries none
of it: the detail card is assembled by script from the fetched payload, so a crawler indexes
nothing and a screenshot leaks nothing. In the interface the credential and the server stay
masked until a reader presses Показать / Show.
The funnel published with each run is an accounting identity, not a summary:
Collected == Rejected + Duplicates + Deferred + NotAttempted + Failed + Proven
| Stage | Meaning |
|---|---|
| collected | lines fetched from every feed |
| rejected | lines decoding refused, counted by reason (unknown_scheme, bad_uuid, unsupported_cipher, bad_psk_length, bad_reality_key, …) |
| decoded | lines that produced a valid node |
| duplicates | decoded nodes another feed had already named |
| candidates | distinct nodes after deduplication |
| deferred | candidates the ledger said were not worth a full verification this run |
| policy | nodes that passed the Zero-Trust boundary |
| reached | nodes that answered a cheap reachability signal |
| failed | nodes verification attempted and rejected |
| not_attempted | nodes that were due but never reached, because the run budget ran out first |
| proven | nodes published |
Counting rejections by reason is what makes a parser regression visible instead of looking like feed churn — the distinction the predecessor could not make.
An HTTPS GET to one of three well-known captive-portal endpoints
(www.gstatic.com, cp.cloudflare.com, connectivitycheck.gstatic.com), completed
through the node's own outbound, with the certificate chain verified. Not a plaintext
204, which a hostile or captive node can forge (INV-31); and never with verification
disabled (INV-30, INV-38) — a published URI never tells a client to skip it.
Scheduling is evidence-driven: a cheap reachability signal first, per-node deadlines adapted from the observed p95 connect time, probes raced concurrently under a single hard deadline, early abort. There is no unconditional sleep anywhere in that path.
| Supported | |
|---|---|
| Protocols | vless · vmess · trojan · ss (Shadowsocks) · hy2 / hysteria2 |
| Transports | tcp (raw, none) · ws · grpc · httpupgrade (xhttp) · http / h2 · quic |
| Security | none · tls · reality |
| uTLS fingerprints | chrome, firefox, edge, safari, 360, qq, ios, android, random(ized) |
| Shadowsocks ciphers | AES-128/192/256-GCM, (x)chacha20-ietf-poly1305, and the three 2022-blake3 ciphers |
| SIP003 plugins | obfs-local (simple-obfs) · v2ray-plugin |
The sets are closed. An unrecognised scheme, cipher or plugin is rejected at the boundary
rather than carried forward as a string and discovered later. Hysteria v1 is rejected
everywhere, deliberately and consistently: the predecessor accepted it in one code path and
ignored it in another, so v1 links were dropped from feeds but admitted from the pool and
treated as v2 (INV-12).
One JSON file, loaded explicitly. config/sources.example.json shows the shape and
deliberately carries no sources key — a line that looks like a feed URL in a public
tree is a line somebody copies.
| Field | Meaning |
|---|---|
sources |
feed URLs to collect from |
whitelist_domains |
URLs of domain lists used for whitelist classification |
whitelist_networks |
URLs of CIDR lists used for the same |
site_url |
published origin, used for subscription links |
run_budget |
wall-clock ceiling on the verification phase (e.g. "6m") |
concurrency |
verification fan-out |
proof_concurrency |
fan-out for the proof requests specifically |
geoip |
resolve node country |
measure_throughput |
measure throughput as well as latency |
Three variables replace the corresponding lists after the file is read and before it is validated:
| Variable | Replaces |
|---|---|
SHELLSHIFT_SOURCES |
sources |
SHELLSHIFT_WHITELIST_DOMAINS |
whitelist_domains |
SHELLSHIFT_WHITELIST_NETWORKS |
whitelist_networks |
One URL per line; blank lines and # comments ignored. Lines rather than JSON because
GitHub Actions masks a multi-line secret line by line, which is what keeps the feed URLs
out of the log even though the run prints per-feed yield.
Two properties are deliberate and both have tests:
- A variable replaces its list rather than merging into it. An operator who supplies a list means that list, and a merge would silently keep feeds they had removed.
- A variable that is set but lists nothing is an error, not a fall back to the file. That case is a misconfigured secret, and falling back would run the committed list while the operator believed their private one was in use.
One static Go binary. No companion interpreter, no sidecar process, nothing read at import time. Every subcommand is a pure function of its flags and the files it is pointed at.
cmd/shellshift run · site · decode · doctor · version
internal/model canonical types; structural node identity
internal/decode URI -> validated node; INV-01..29 live here
internal/intake feed fetching with hard limits, yield-weighted ordering
internal/policy Zero-Trust boundary: egress guard, resolver, whitelist, masking, geoip
internal/tunnel the only dialing interface; sing-box as a library, one outbound per node
internal/verify verdict: HTTPS with a verified chain; adaptive budgets; throughput
internal/ledger memory: pool with decay, source yield
internal/publish bundles, network.json, the static site (html/template + embedded assets)
internal/pipeline one collection cycle; the concurrency scheduler
internal/obs structured logs with secret redaction
internal/config explicit configuration loading
sing-box is a library, not a subprocess. One outbound is constructed per node and dialled
in-process, so a malformed node can only fail itself. The predecessor ran a sing-box process
per batch of fifty sharing one configuration, where a single rejected outbound prevented the
process from binding and killed all fifty (INV-00).
The dialer is handed a resolved address, never a hostname. Given a name, an in-process
outbound asks the context for a DNS transport — and providing one means instantiating
sing-box DNS, routing and service registries, reachable from hostile input. Resolving once
and dialling the pinned address is also what closes the DNS-rebinding window
(INV-33, INV-48).
Identity is structural and hashed. Everything affecting how a node is dialled is in it,
and the key is a SHA-256 of the canonical form — because the tuple contains a credential and
the key is written to logs, state files and the dashboard (INV-21).
Assembling a result and publishing it are separate. pipeline.Runner never writes; a
publish.Guard decides whether a result may replace what is live. The predecessor entangled
the two, and a run with no survivors emptied every subscription file on its way out
(INV-43).
The pool decays. Re-check intervals widen with observed stability, consecutive failures
back off exponentially, entries age out. The predecessor wrote a last_seen timestamp it
never read, so a node surviving every third run lived forever and 30% of the pool was
re-verified at full cost while failing.
Feed scheduling is yield-weighted, not round-robin. Measured history showed one feed producing 48% of all live nodes from 3% of the parse volume, while 85 of 118 feeds had never produced a single live node.
Artifacts are never committed. Publication goes through the Pages artifact mechanism. The predecessor committed ~406 KB of regenerated files per run, six times a day.
./tools/verify.sh # every gate CI runs — use this, not ad-hoc go commands
./tools/hygiene.sh # repository hygiene gates alone
./tools/install-hooks.sh # once after cloning: installs the pre-commit hookverify.sh runs gofmt, go build, go vet, the test suite, a no-skipped-tests check,
govulncheck, the hygiene gates and a CLI smoke test. It reports the one place it differs
from CI — the race detector needs cgo, which is absent on Windows without a C toolchain —
rather than pretending, and it refuses to report a gate that did not run as a gate that
passed.
Conventions that are enforced rather than encouraged:
- A skipped test fails CI. The predecessor's parser suite could vanish entirely through
an
ImportErrorandpytest -qstayed green with zero tests. - An invariant counts as ported only when a test names its number.
tools/hygiene.shfails when a declaredINV-nnis named in no_test.go. All 56 are currently named. Porting "by meaning" without a test is how the predecessor ended up with the same protocol rule implemented three times, disagreeing. - Every action a workflow uses is pinned to a 40-character commit SHA, never a movable tag.
- No generated artifact, binary or credential-shaped string may enter the index.
- Two fidelity gates guard publication. One compares the sing-box options built from the original URI against those built from ours; the other fails if publication starts losing a query parameter with no stated reason.
internal/tunnel/testdata/corpus.txtis a sanitised structural corpus: one line per distinct parameter shape from a real 865-node catalogue, every other parameter verbatim. No real credentials and no real endpoints. What stays verbatim, deliberately, is the Reality masking targets that are major public sites — those are camouflage domains rather than anybody's infrastructure, and which sites the ecosystem masks as is the signal the file exists to hold.
The 56 invariants themselves are documented outside this repository. That document is working
memory addressed to maintainers rather than to readers of the code, and it contained
operational URLs, so it is not in the public tree. The consequence is stated rather than
hidden: on a clean checkout the invariants gate reports НЕ ПРОВЕРЕНО — not run —
instead of passing, and tools/hygiene.sh says so in its final line. The enforceable half of
the rule, an INV-nn label in a _test.go, is still in the tree, so a grep for a number
across *_test.go finds both the rule and its check.
Two workflows, both with permissions: {} at the top level and per-job grants:
ci.yml— on push and pull request tomain:go,vulnerabilities,hygiene.publish.yml— every four hours at:17, plus manual dispatch. Builds, recovers the previous ledger by fetching the live site's published copy, runs one cycle, renders the site, and handsdist/todeploy-pages.
The ledger round-trip is deliberate: it holds only identity hashes and statistics, no credentials, so the previous run can publish it and the next one can fetch it back. That keeps generated state out of the source history, needs no extra branch, and cannot silently expire the way a cache can. Losing it degrades gracefully — every node simply becomes due again.
The scheduled run refuses to start without SHELLSHIFT_SOURCES, and says so by name rather
than reporting a missing config key: the list is not absent from a file, it is absent from
the secret. A missing whitelist list is a warning rather than an error, but it is announced —
otherwise a class nobody qualified for looks identical to a class nobody could qualify for.
Operator setup, once:
| Secret | Purpose |
|---|---|
SHELLSHIFT_SOURCES |
the feed list (required) |
SHELLSHIFT_WHITELIST_DOMAINS |
domain lists for whitelist classification (optional) |
SHELLSHIFT_WHITELIST_NETWORKS |
CIDR lists for the same (optional) |
Then enable Pages with Source: GitHub Actions.
- Zero-Trust boundary. Nothing reaches the dialer without passing
internal/policy: schema-first field validation against explicit allowlists — never blocklists. - Egress guard, no exemptions. Resolve once, vet the resolved address, dial that pinned
address. Loopback,
0.0.0.0/8, RFC 1918,100.64/10,169.254/16(including the cloud metadata address), multicast, reserved and the IPv6 equivalents are denied — for every protocol and transport (INV-33). The predecessor returned early from the whole check for UDP protocols, leaving the guard unenforced for them. - Certificate verification is never disabled, and a published URI never asks a client to
disable it.
allowinsecure,insecureandallow_insecureare dropped on publication, with that reason recorded in a test. - Secrets are redacted in logs by
internal/obs, and the node key written to logs and state is a hash rather than the credential tuple it derives from. - The dashboard ships a Content-Security-Policy in a
<meta>tag — GitHub Pages cannot set HTTP headers, which also meansframe-ancestorsis unavailable. That limitation is recorded rather than papered over. GOFLAGS: -mod=readonlyin CI: no dependency resolution at runtime.govulncheckis a gate, run at@latestrather than pinned — a vulnerability gate reporting last month's advisories is not a gate.
Found something? Open an issue at TuftaTech/ShellShift/issues. Node operators and rights holders can use the same route; well-founded requests are acted on.
Ranked, and none of it is hidden behind a working-looking surface.
- No checked-in browser test suite. The site is covered by Go render tests plus a driven pass under the real policy — light and dark, both languages, 1440 px and 390 px — which is what caught most of the layout defects this project has had. The gap is that none of that is checked in or runs in CI.
- The feed list is whatever the operator puts there.
config/sources.jsonis not curated by this repository, and the example config is a template rather than a runnable configuration. - No probe from inside Russia. GitHub runners sit outside it, so "proven" and "works for an MTS subscriber" are different claims. Out of scope for v1 by decision, and still the largest unrealised lever.
immutablecaching is unavailable on Pages, for the same reason the CSP lives in a<meta>tag.
Read internal/decode, internal/verify and internal/tunnel before changing them; the
guards in there are not stylistic. Then:
- Run
./tools/verify.shbefore pushing. It is the same set of gates CI runs. - Name the invariant a test enforces (
INV-nn) or the rule it encodes (GHOST-nnn). - Comments explain why, and cite the failure a guard exists for. A comment restating the code is noise; a comment naming the outage is the reason the code survives review.
- Errors carry a machine-countable reason, not just a message. Distinguishing "the source served garbage" from "we failed to parse valid input" is what makes a parser regression visible instead of looking like feed churn.
- An absent value is absent, never a placeholder. The predecessor wrote
"UN"for an unknown country and then filtered it out again downstream. - A gate that cannot fail is worse than no gate.
Commits are authored as TuftaTech <283188384+hereTUFTA@users.noreply.github.com> and carry
no Co-Authored-By trailer — not for any tool, in any form. GitHub builds the
Contributors list from the author address and from co-author trailers, so both halves
matter, and both have been wrong here before. A fresh clone needs this set before the first
commit:
git config user.name "TuftaTech"
git config user.email "283188384+hereTUFTA@users.noreply.github.com"Two checks before pushing: the author of HEAD prints that address, and a search of every
commit message for a co-author trailer prints nothing.
GNU General Public License v3.0 or later — GPL-3.0-or-later.
This is not a preference. ShellShift links github.com/sagernet/sing-box and
github.com/sagernet/sing as libraries, statically, into one binary — that is the
load-bearing architectural decision described above — and both are GPL-3.0-or-later. The
combined work is therefore a derivative work and has to be distributed under the same terms.
An MIT or Apache header on this repository would be a licence violation, not a choice.
Copyright © 2026 TuftaTech and the ShellShift contributors.
sing-box and sing are © 2022 nekohasekai, GPL-3.0-or-later, with an additional term under section 7 of the GPL: no derivative work may use the name or imply association with this application without prior consent. That term travels with this work.
ShellShift is not affiliated with, endorsed by or associated with the sing-box project or its authors. It is a downstream consumer of their library. Protocol names — VLESS, VMess, Trojan, Shadowsocks, Hysteria2, Reality — and client names are used descriptively; all trademarks belong to their respective owners.
#proxy #proxy-aggregator #proxy-list #vless #vmess #trojan #shadowsocks
#hysteria2 #reality #sing-box #censorship-circumvention #anti-censorship
#dpi-bypass #subscription #golang #github-actions #github-pages #zero-trust
#v2ray #xray