Proves a BitTorrent swarm is alive by pulling the torrent from a real peer, instead of trusting a seeder count that nothing ever checked.
0 — Cover
swarmproof ranks candidate BitTorrent swarms, proves the winner by asking a real peer for the torrent, and streams it. It is a library, an HTTP engine, and a small Cloudflare control plane that makes popular content fast without ever becoming a dependency.
The constraint everything here follows from: a health signal is either a claim or a proof, and the two cost different orders of magnitude. A tracker scrape returns counts with no addresses in about 140ms and nothing verifies them. A metadata fetch returns a torrent whose SHA1 matches the infohash you asked about, and costs a TCP connection to a stranger who may never answer.
The engine is Node 22 with node:sqlite, bittorrent-dht, bittorrent-protocol and ut_metadata, plus WebTorrent for playback. The edge is three Cloudflare Workers over KV, Durable Objects and a cron trigger, fronted by nginx on an EC2 box in Docker.
1 — A seeder count is a rumour
The obvious way to rank candidate swarms is to scrape the trackers and sort on the counts. A scrape answers in 133 to 147 milliseconds, it is one UDP round trip, and it is what every indexer in this ecosystem displays. It is also unverifiable by construction: BEP 15 returns complete and incomplete counts and no addresses, and nothing anywhere checks that the content behind the infohash exists.
Measured in one call against the live network, that ranking fails in both directions at once. Ubuntu 26.04.1 claimed 310 seeders and returned zero peer addresses at every DHT window up to fifteen seconds — a healthy, downloadable torrent with no reachable DHT presence. Sintel claimed 107 seeders, returned 202 addresses, and actually streamed. A hash that has never existed — the junk drawer broken clients announce to around the clock — claimed 45 seeders and scored 958. Score put the only provable candidate second.
So the project treats a count as a claim and introduces a second, slower signal that is not a claim. ut_metadata verifies SHA1 of the info dictionary against the infohash before it emits, which makes a successful metadata fetch the one thing on this board that cannot be faked or mistaken. The cost is that it needs a TCP connection to a stranger who may never answer, which is why it can never simply replace the fast path — it has to sit beside it.
2 — Two planes, one mute
There are two planes, and the split is forced rather than chosen. A Cloudflare Worker's connect() from cloudflare:sockets is TCP-only and outbound-only, with no UDP and no way to accept an inbound connection. BEP 5 (the DHT) and BEP 15 (UDP trackers) are UDP, and a peer wire needs inbound TCP. So a Worker can never touch the P2P network, and the engine can never be a Worker.
The edge therefore holds three Workers that only ever move JSON. worker-api/ is the public front door: 60 requests per minute per address, at most 20 candidates per request, deadlineMs capped at 30 seconds, and a 60-second edge cache keyed on a SHA-256 of the POST body. It refuses /v1/stream outright with a 501 — relaying file bytes the engine fetched from strangers through Cloudflare's network is both an AUP problem and a defeat of the point of a peer-to-peer transfer. worker/ is the control plane: shared swarm health in KV, a pool of live DHT routing nodes, a Durable Object per infohash to serialise writes, and a cron that rebuilds the pool every ten minutes. worker-docs/ serves the OpenAPI spec from its own Worker so documentation deploys without touching the API.
The origin is one real host. nginx listens on 8443, open only to Cloudflare's fifteen published IPv4 ranges, and rejects anything that does not carry the shared secret. Behind it the engine binds 127.0.0.1:8080 inside Docker on a t3.small with a 2GB swapfile, and talks UDP and TCP to the actual network.
None of the edge is required. With SWARMPROOF_API unset the engine behaves exactly as it does offline, at the same speed. That is not a fallback bolted on afterwards; it is the property cloudCache.js exists to enforce.
3 — POST /v1/assess
Assess is the call most users want, and it is two passes with one shared request. The first ranks on signals that are cheap, unverifiable and fast. The second tries to prove the ranking wrong.
Parsing comes first: parseInput() accepts a full magnet URI or a bare 40-character infohash, and converts a base32 xt=urn:btih: to hex, because the DHT, the trackers and ut_metadata all compare the same 20 raw bytes downstream. Trackers embedded in a user's magnet are merged with the known-live list, since a supplied magnet usually carries the trackers that actually have the swarm.
Then the tracker scrape and the DHT lookup fire together, and neither waits on the DHT's ready event. That is deliberate and measured: bittorrent-dht fires ready only after a full find_node populate pass, measured at 5571ms cold, while lookup() works fine against a partly-populated table. Awaiting ready put the first peer at 5810ms; not awaiting it put the first peer at 844ms and 84 peers inside 1.5 seconds.
The scrape returns on the first answer plus a 300ms grace for a higher count, because the first tracker answers in 133 to 147ms while the rest trail to 697ms and dead ones burn their whole ceiling. The DHT window on this path is 5000ms rather than the 900ms rank() uses, because verification needs addresses more than it needs a fast answer — measured on the hosted engine, Big Buck Bunny's first peer arrived at 3327ms, so the 900ms window returned nothing and every candidate came back merely claimed.
Scoring is (seeders * 10 + leechers) * meanLocality + weightedPeerCount * 4. Each observed peer contributes its own locality factor, so four rack-local peers at 3.0 each outweigh twenty cross-region ones at 0.3. With no observations the locality term is exactly 1.0 and public-internet behaviour is unchanged.
Proof is fetchFromAny: a small pool of six connections walks the address list, skipping addresses PeerTable already knows are unreachable and trying known-good ones first in order of measured connect RTT. ut_metadata verifies SHA1 of the info dictionary against the requested infohash before it emits, so a success cannot be a mismatched torrent. Then the four verdicts are assigned and the results are sorted by tier first and score second.
4 — Refuted, or merely unproven
Ranking produces a number. Verification can contradict it, and deciding when a contradiction counts is the only place this system is allowed to disbelieve what the network told it.
The motivating case is the all-zero infohash, the junk drawer broken clients announce to around the clock. Measured in one call it reported 40 seeders and 559 observed peers — more peers than any real film in the same call — and out-scored a torrent that streams. Nothing in a tracker scrape or a DHT lookup separates it from a healthy swarm. Verification does: we asked enough peers, none of them had it, and we did not run out of budget while asking. That is evidence against the tracker's number rather than an absence of evidence for it.
Three guards decide, and all three are load-bearing. Verify must have run, because a caller that passed verify: false proves nothing by not trying. The attempt must have finished rather than hit the deadline, because a timeout is our budget running out and not the swarm's answer. And at least five peers must have been asked, because roughly 87% of DHT-supplied addresses never complete a TCP connect at all and of the rest only about 63% serve metadata — one refusal is luck.
Every weaker rule was rejected against a measured counter-example. Damping on an empty DHT lookup would have damped Big Buck Bunny, which scored 2519 on a dhtCount of zero and streamed 276MB fine. Damping on a low peer count would damp small healthy swarms. Damping on a timeout would punish slow swarms for our own deadline.
When all three hold, the score is multiplied by 0.1 — damped, not zeroed. Refutation is strong evidence and not proof: a swarm whose every reachable peer is mid-handshake, choking, or holding a partial file can refuse a metadata request and still be alive. Ranking it far below anything unrefuted while leaving it above an outright dead entry is the honest ordering, and rawScore is surfaced beside it so a caller who disagrees with the policy can reconstruct the original and apply their own.
The sort then runs on tier first and score second. The subtle part is that a refuted reachable sits below claimed, which on its face inverts the verdicts. But the tiers rank evidence, not activity: claimed means we could not check, while refuted means we checked and found nothing. Having looked and found nothing is a worse sign than not having looked, and ordering them the other way is exactly what let the all-zero infohash sit above healthy tracker-only torrents.
5 — Every call owns a deadline
Two paths, two budget tables, and no number in either is a guess. Ranking promises a sub-second decision: 909ms median and 916ms at p90 on a cold client with no control plane. Verification promises a provable answer and takes seconds by construction. Sharing a budget between them is what breaks both.
On the fast path, each candidate gets 1500ms, each tracker 1200ms, and the scrape returns on the first success plus a 300ms grace in case a second tracker reports a higher count. The DHT window is 900ms. Crucially, nothing awaits the DHT's ready event, which fires only after a full populate pass measured at 5571ms — lookups work fine against a partly-populated table, so waiting cost five seconds of pure latency and bought no extra information.
On the slow path the DHT window is 5000ms, because the 900ms default routinely closes before the first peer arrives: Big Buck Bunny's first peer was measured at 3327ms on the hosted engine, so the short window returned nothing and every candidate came back merely claimed. A failure that is self-reinforcing, too — no peers means nothing cached, so the next lookup starts just as cold, while the engine reports itself healthy throughout. The connect deadline is split from the overall one at 1200ms against 6000ms, because almost all the waste is in peers that never complete a handshake at all: measured over 140 real peers, 66% never connect, 21% refuse, and the 13% that do connect are all in under about half a second.
The rule that sits underneath all of it is that an accelerator may never become a dependency. cloudCache.js caps every call, swallows every failure — unreachable host, DNS failure, timeout, non-200, malformed JSON — and resolves to null. Its circuit breaker applies to the two blocking reads and deliberately not to the fire-and-forget writes. Applied to both, a health read that overran its 300ms budget by six milliseconds tripped the breaker, and the two contributions that follow about a second later were short-circuited in zero milliseconds and never sent. Every run consumed shared state and contributed nothing back, which is why the deployed node pool sat at zero nodes while every client reported contributions.
An unreachable control plane costs +309ms once, measured directly rather than differenced between scenarios, after which the breaker closes the path for ten seconds. That is the bar: the cost of the accelerator failing must stay below the cost of simply doing the work locally.
6 — Four caches and one line
Four caches, and only one of them is shared. PeerCache is a per-machine SQLite file holding per-infohash peers and score on a ten-minute TTL, plus the DHT routing table capped at 300 nodes. It was a JSON file first, and the comment that replaced it is specific about why: holding the whole cache in memory and writeFileSync-ing the entire structure on every set() loses writes the moment a second process exists, and a reader can observe a half-written file. Neither failure appears with one process, which is why the original was fine for an MVP.
PeerTable is in memory and process-wide, and it is the piece an obvious crawler does not have. It maps host:port to a success count, a failure count and the best observed connect RTT. The insight is about what transfers across hashes and what does not: a peer serves metadata only for torrents it actually holds, so a known-good address is useless for a hash it was never associated with. Liveness, though, is a property of the address. Only about 13% of the addresses the DHT hands back accept a TCP connection at all, the same addresses recur constantly, and re-paying a 1200ms connect timeout per hash to rediscover that dominates the naming stage. Known-dead costs zero; known-good is tried first, ordered by measured RTT.
The control plane holds swarm health in KV on a 900-second TTL, coalesced to at most one write per infohash per two minutes, plus per-contributor node keys on a 1800-second TTL and one pool key that only the cron writes. A report of all zeros never overwrites a positive stored value, because a failed probe and an empty swarm both produce zero and the client cannot tell them apart.
The crawler's index is its own SQLite file, keyed on infohash, where hits counts how many independent nodes have offered the same hash — the best available proxy for a swarm being alive, and the sort key for the backlog.
The line that matters runs through the shared plane: it will never hold a peer address. A DHT routing node is infrastructure and belongs to no particular content; a peer address is evidence of who was transferring what. That line has a real cost, and it is paid in the core path. An answer from shared health arrives with an empty peer list and nothing to ask for the torrent, so every candidate the control plane covered came back merely claimed — a failure indistinguishable from a legitimately unprovable swarm. The resolution was to forbid the shortcut on the verification path rather than to relax the line.
7 — No authentication, on purpose
There is no API key anywhere in this project, and that is a decision rather than an omission. The engine is a BitTorrent client; a token on a loopback service is friction with nothing behind it, and every self-hosted copy would otherwise have to carry a credential store it does not want.
What replaces authentication is a guard about where the engine listens. Setting ENGINE_HOST to 0.0.0.0 on its own makes the process exit with an error explaining itself. Going public takes a second, explicit flag, so it happens on purpose rather than because a container passed HOST through. The reason is in the error message: bound wide, this is a torrent client that whoever finds the port can drive — they pick the content, your machine makes the connections, and your address is the one in the swarm.
The hosted deployment put the public surface somewhere else entirely. The front-door Worker owns rate limiting and request caps; nginx on the origin owns the lock. Two locks, and the reasoning is explicit that neither is sufficient alone: the security group accepts port 8443 only from Cloudflare's fifteen published IPv4 ranges, but it cannot distinguish your Worker from any other Cloudflare customer's, and the shared secret in a header is only as private as the transport carrying it.
The node pool's Sybil resistance comes from ordering, not exclusion. Nodes are scored by the number of distinct contributor keys that reported them, counted by the cron that is the pool's only writer, so a single client cannot manufacture agreement by repeating itself. Requiring two distinct sources to appear at all was the first cut and it made the pool useless: with a handful of clients almost nothing reaches two, and a five-node pool served one. A poisoner cannot outrank genuine agreement, the client pings every node before trusting it, and a public floor of two measured-live bootstrap nodes is always appended so even a fully poisoned pool cannot strand anyone.
A list of things the API refuses to do is as informative as what it does. It does not publish the stored report count, because that counts writes rather than reporters and a field reading 'reports: 15' would read exactly like fifteen independent parties agreeing — publishing a trust signal that is not one is worse than publishing nothing. It refuses hostnames in node contributions, because a routing node is an address and a name would let a contributor smuggle in a resolvable target. It refuses private and reserved IPv4 ranges, so the pool cannot be used to point a client at someone's internal network. It caps a reported count at 250,000, after an earlier ceiling of ten million let one unauthenticated POST claim ten million seeders. And it refuses to relay /v1/stream, because moving file bytes the engine fetched from strangers across Cloudflare's network is both an AUP problem and a defeat of the point of a peer-to-peer transfer.
8 — Harvesting, not searching
The crawler answers a different question from the rest of the project, and it is honest about its shape: this is harvesting, not searching. The DHT is a hash table. get_peers answers who has this infohash, and there is no way to ask what a title is called. BEP 51 adds sample_infohashes, which asks a node for a random sample of what it currently stores; fetch the metadata for each and you learn the name. Coverage grows over hours, skews toward what is announced most often, and guarantees nothing about any particular title ever appearing.
There are four stages, and the two that look like scaffolding are the ones that make it work. find_node is a supply of fresh addresses: sampling consumes nodes permanently, since one that answers is backed off for the interval it asks for and one that does not is written off as not implementing BEP 51, so a crawl fed only by sample_infohashes replies ran dry in about ninety seconds. find_node is answered by every DHT node including the majority that ignore BEP 51, which makes it a supply rather than another filter.
get_peers is the stage an obvious implementation leaves out, and its absence caps everything downstream. Handing a bare infohash to a torrent client asks it to rediscover the swarm from nothing. But the node that just offered that hash is by definition storing it — the best lead available, and free, because it is one more UDP round trip to an address already in hand. Measured on the same 60 hashes: a direct get_peers to the sampling node hits 38.3% in 298ms, while a full iterative lookup hits 5.0% in 6,013ms and finds nothing the direct probe missed.
Naming goes over a raw TCP wire rather than through a torrent client. A client builds a torrent object, a piece store, a discovery loop and a swarm in order to read a few hundred bytes of bencode, which is why a client-based implementation manages a handful of concurrent lookups where this manages three hundred. Connect, handshake, ask, hang up; nothing is downloaded and nothing is written to disk.
Backpressure sits between get_peers and naming because the rates do not match: the peer stage produces roughly 63 hashes a second where naming consumes about 10. That boundary used to be a bounded push that silently discarded the overflow, and the overflow was not marginal — 7,583 hashes that had live peers, the expensive hard-won ones, were thrown away in a five-minute run and none of them were counted. The producer now waits, which does not make naming faster but stops the peer stage burning round trips on work that will be binned, and makes the imbalance visible.
Persistence is gated rather than decorated. A BEP 51 crawler does not choose what it finds; it samples whatever the network is storing, and running one unfiltered for a few minutes was enough to resolve a title tagged for child sexual abuse material. The filter is ported from bitmagnet and keeps two properties exactly: it matches over the torrent name and every file path joined, because a torrent whose name looks innocuous routinely carries the real content in its file names, and the action is delete rather than flag, so there is no row to leak through a query that forgets its WHERE clause. A blocked row keeps its infohash so the crawl does not rediscover and refetch it, and never keeps its name.
One instance over five minutes resolves 627.6 names a minute, against the 100 to 1,000 per minute that bitmagnet, the Go reference implementation, publishes.
9 — Deployed, then taken down
The hosted deployment was one t3.small running Amazon Linux 2023, bootstrapped from EC2 user-data: install docker and nginx, build the image, run it published on loopback only. Two details in that script are easy to skip and expensive to skip. A 2GB swapfile, because the instance is under 2GiB of RAM and verification opens many sockets at once, so a spike that would OOM-kill the engine becomes a spike that is merely slow. And Docker log rotation, because the default json-file driver is unbounded and an engine that logs steadily will eventually fill the root volume.
The Dockerfile moves the working directory onto the volume rather than mounting a configured path, which looks like a quirk and is not. Both caches — the DHT routing table and the crawler's index — are written relative to the process's working directory, so mounting any other directory would have persisted nothing at all, silently.
The trap worth remembering is Cloudflare error 1003: a Workers subrequest to a bare IP literal is refused outright, so the front door's ORIGIN_URL has to be the EC2 public DNS name. Which in turn needs an Elastic IP, because without one that name changes on every stop and start and the front door quietly stops resolving to your box. Cloudflare's published IP ranges also change; a 502 from a healthy engine is the symptom, and the fix is to re-run the security group against the current list.
None of it is running now. It was live for a period and was taken down deliberately, for two reasons in order of weight. A public engine is a liability rather than a feature: it is a BitTorrent client with an HTTP API and no authentication it could reasonably have, and whoever runs it puts their address into every swarm a stranger names — fronting it with rate limits and a shared secret bounds the volume, not the exposure. And it cost about twenty dollars a month, which is not much for infrastructure and is a lot for a demo that one command reproduces locally in fifteen seconds. The code and the complete runbook stay, because how it was deployed is part of the work.
10 — What this shape earns
Four things this shape earns that a system ranking on tracker counts would not have.
A verdict a caller can act on. Four states rather than two, because reachable and claimed both look like failure and are not: collapsing them either hides a working torrent — Ubuntu lives in claimed permanently, with no reachable DHT presence at all — or puts a green badge on a hash that has never existed.
A sub-second decision whether or not the edge is there. 909ms median with nothing shared, 60ms on a control-plane hit, and +309ms once when the control plane is unreachable before a breaker takes it out of the path. The fast path never waits on the network twice, and never waits on the slow path.
A discovery transport you can swap. peerSources.js is a single method, sample(infoHash, windowMs), and everything downstream — scoring, caching, peer injection, failover — is written against that shape alone. Inside a fleet, direct connect-and-handshake probes against a known roster replace the DHT, and return a measured RTT the DHT cannot give, which is exactly the signal locality weighting needs.
And an index built from the network itself: 627.6 names a minute with no website in the loop, a content filter that gates persistence rather than decorating it, and an address table that turns the 87% of DHT-supplied addresses that never connect into a cost paid once instead of once per hash.
The honest limit is stated rather than buried. Score orders candidates and never establishes that one is alive. Comparing alternate releases of one title is what score is for; deciding whether a single torrent is alive is what verdict is for. A system that published one number for both would be simpler, smaller, and wrong in exactly the way every indexer in this ecosystem is already wrong.
swarmproof · MIT · Hassen Ben Mbarek · read-only architecture board