Skip to content

Repository files navigation

ShellShift

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.

ci publish license: GPL-3.0-or-later go

Live dashboard → · Subscriptions · Русская версия


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.

Contents

What this is

A single static Go binary that runs one collection cycle end to end:

  1. Fetch subscription feeds over HTTPS under hard byte, line and time limits.
  2. Decode every line into a validated node, or reject it with a machine-countable reason. Tolerant of malformed input, never silent about a repair.
  3. 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.
  4. 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.
  5. Classify it — whether its Reality masking target is real, checked directly rather than taken from a list.
  6. 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-00INV-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.

What this is not

  • 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.

Quick start

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 runs

A 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.json

Verify and report without writing anything:

go run -tags with_utls ./cmd/shellshift run -config config/sources.json -dry-run

Render 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/ShellShift

Decode URIs from stdin and see what was rejected and why:

go run -tags with_utls ./cmd/shellshift decode -rejects < some-feed.txt

Before 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 doctor

doctor 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.

Subcommands

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

Diagnostics that live outside the binary

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-transport

paramdiff 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".

The build tag is not optional

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

What gets published

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.

The endpoint is in the payload, not in the page

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.

How it works

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.

What "proven" means, precisely

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.

Protocol surface

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

Configuration

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

The source list and the environment

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.

Architecture

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

Load-bearing decisions

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.

Testing and gates

./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 hook

verify.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 ImportError and pytest -q stayed green with zero tests.
  • An invariant counts as ported only when a test names its number. tools/hygiene.sh fails when a declared INV-nn is 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.txt is 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.

Deployment

Two workflows, both with permissions: {} at the top level and per-job grants:

  • ci.yml — on push and pull request to main: 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 hands dist/ to deploy-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.

Security posture

  • 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, insecure and allow_insecure are 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 means frame-ancestors is unavailable. That limitation is recorded rather than papered over.
  • GOFLAGS: -mod=readonly in CI: no dependency resolution at runtime.
  • govulncheck is a gate, run at @latest rather 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.

Known gaps

Ranked, and none of it is hidden behind a working-looking surface.

  1. 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.
  2. The feed list is whatever the operator puts there. config/sources.json is not curated by this repository, and the example config is a template rather than a runnable configuration.
  3. 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.
  4. immutable caching is unavailable on Pages, for the same reason the CSP lives in a <meta> tag.

Contributing

Read internal/decode, internal/verify and internal/tunnel before changing them; the guards in there are not stylistic. Then:

  • Run ./tools/verify.sh before 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.

Commit identity

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.

License

GNU General Public License v3.0 or laterGPL-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.

Third-party notices

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.

Topics

#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

About

Self-verifying aggregator of public proxy configurations — VLESS Reality, VMess, Trojan, Shadowsocks, Hysteria2. Only what proved it carries traffic gets published.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages