M29 — The SuckerPot network (opt-in, anonymous)
Status: proposal for review. Scope: docs/ROADMAP.md, section M29.
Every address and name in this document is a placeholder: documentation ranges (192.0.2.0/24,
198.51.100.0/24, 203.0.113.0/24), documentation ASNs and example.org. The hub has no real domain
yet: hub.example.org is a placeholder.
1. Goal
Section titled “1. Goal”Many people run SuckerPot. Together their sensors see the same scanners, droppers and C2 servers from many places. The network pools what they see, confirms a target only when several independent sensors agree, sends one strong abuse report per target instead of many weak ones, follows the takedown, and shows every member what the network achieved.
Hard requirements (from the owner):
- Opt-in only. Off by default, never forced, one click to leave, and leaving deletes the node’s data at the hub.
- Anonymous in every direction. Nobody (hub operator, members, attackers, abuse desks, the public) can trace a node to a person, a home address, a domain or a host name. A node chooses at most a pseudonym and, if it wants, a country.
- Members see who participates: pseudonyms, coarse country (opt-in, k-anonymous), activity and contribution, network totals.
2. Pieces
Section titled “2. Pieces”| Piece | Where | Runs on |
|---|---|---|
Protocol library suckerpot_netproto | new package netproto/ (pure, no I/O) | imported by hub and ingest |
| Hub | new top-level hub/ (own Dockerfile, own hub/docker-compose.yml, own Alembic) | project infrastructure, never a member’s home connection |
| Node worker | ingest/ingest/network/ | each member’s ingest, through the VPN egress gate only |
| Node API | api/api/network.py | each member’s api (no route out) |
| Network UI | web/src/pages/network*.tsx | dashboard |
| Protocol spec | docs/network-protocol.md (normative copy of §5) | docs |
node ingest ──(egress gate, vpn proxy, HTTPS, signed)──► hub.example.org (Caddy) ──► hub-api ──► hub-db hub-worker ─┘ │ RDAP, SMTP (project mailbox) ▼ abuse desks3. Threat model
Section titled “3. Threat model”3.1 Assets
Section titled “3.1 Assets”| Asset | Must not be learned by |
|---|---|
| Link node ↔ person (name, mailbox, payment, account) | everyone. There is no account, no mail, no payment |
| Link node ↔ home address (the sensor’s public IP) | everyone, including the hub |
| Link node ↔ domain or host name (dashboard URL, DynDNS) | everyone |
| “Address X runs a SuckerPot sensor” | attackers and the public (reduced, not eliminated: §3.3) |
| Which node saw which attacker at what time | attackers, members, the public. The hub knows it at day granularity for at most 14 days |
3.2 Adversaries
Section titled “3.2 Adversaries”| # | Adversary | Capabilities |
|---|---|---|
| A1 | Scanning attacker | Controls source IPs and payload URLs, knows which addresses it hit and when, can be a member, reads everything a member can read |
| A2 | Malicious member / Sybil farm | Enrols many nodes, submits forged observations to get an innocent address reported or to poison the list |
| A3 | Curious hub operator | Reads the hub database and the hub’s traffic |
| A4 | Hub compromise or seizure | Full copy of hub database and disk at one point in time |
| A5 | Network observer | Sees node ↔ VPN and VPN ↔ hub traffic |
| A6 | Report recipient | Abuse desk, often forwards reports to its customer (the attacker) |
| A7 | Fake hub / MITM | Tries to impersonate the hub to nodes |
3.3 Mitigations and residual risk
Section titled “3.3 Mitigations and residual risk”| Attack | Mitigation | Residual |
|---|---|---|
| A1 publishes “node X saw A at T” → locates X | Never published per node. Public and member views show only consensus aggregates (≥ k nodes). Times rounded to the UTC day; a day is sent only after it ends + random 2 to 20 h | None from published data |
| A1 per-target canaries (one unique source IP, URL or file per target) | A canary is seen by one node → never reaches consensus → never published. URLs and domains are sent blinded (hashed) and revealed only after consensus (§5.6), so a per-target URL never leaves the node in clear | A canary IP or file hash sits in the hub DB for ≤ 14 days (matters only under A4) |
| A1 group testing (one source IP hits a set S of targets; listed ⇒ S holds ≥ k nodes) | k = 5 eligible nodes, at least 2 distinct days, severity rule (scans alone are never reportable), randomised publication delay | Many probe IPs over weeks can reveal that an address is a SuckerPot node, never which pseudonym. Stated in the docs. The sensor address is already exposed to attackers who connect to it |
| A1 correlates a pseudonym’s counters with a burst it caused | Member stats are coarse tiers (0 to 4, log scale) and active days of the last full week, published a week late. No per-node counts, no real-time stats | Negligible |
| Small-country pseudonym + country | A country is shown on the member list only if the member opted in and ≥ 3 members share that country; otherwise ”—”. Country counts in reports only when ≥ 3 | None |
| A3 learns node source IPs | Nodes connect through their VPN exits. The hub stores no IP, Caddy logs nothing, the app never logs or stores client addresses (rate limiting keys on an in-memory HMAC with a daily random key) | The VPN exit address passes the hub’s TCP stack (it is a VPN exit) |
| A3 + A1 collude: the collector’s fetch of URL U from exit E at time T, and E seen at the hub | Hub keeps no IP. Submissions are daily with random delay, unrelated to fetch times | If the hub operator logged IPs against policy and colluded with an attacker, an exit address could be linked to a pseudonym. Still a VPN exit, not a home address |
| A4 seizes hub DB | Observations kept 14 days at day granularity; URLs and domains blinded until consensus; no IP, no mail, no free text except pseudonyms; enrolment and activity stored as days | With an attacker’s own target logs, the 14-day observation sets could narrow a node’s address. Accepted for v1. Unlinkable submissions (anonymous credentials) are listed for protocol v2 (§12) |
| A5 | WireGuard to the VPN, TLS 1.3 to the hub. Requests are padded to a 4 KiB multiple | Volume and timing of one daily batch |
| A6 / attacker receiving a forwarded report | Hub reports carry day ranges, services, counts in buckets, number of sensors and countries; never node addresses, ports of a node, pseudonyms or node ids | None new |
| A2 poisoning | §8 (consensus, age, reputation, cohorts, caps, PoW, never-list, human review in the first release) | A patient, well-funded Sybil farm with ≥ 5 aged, well-behaved nodes. Hub review mode and dispute handling bound the damage |
| A7 | Hub public key pinned in NETWORK_HUB_KEY; every hub answer signed and bound to the request nonce; TLS with normal certificate checks | Pin rotation needs a node configuration change (by design) |
3.4 Node invariants
Section titled “3.4 Node invariants”| # | Invariant | Enforced by |
|---|---|---|
| N1 | Nothing is sent unless NETWORK_ENABLED=true and an admin opted in in the UI (password re-entered). Default: off. | Worker idle check + tests |
| N2 | Every network call goes through suckerpot_egress (gate open, VPN proxy), only to the host of NETWORK_HUB_URL, HTTPS, no redirects. | ingest.egress.make_client + static egress test |
| N3 | Payloads are built from an allow-list of columns, never from events.data or raw requests. No credential, port, URL path of the node, Host header, SNI or banner is ever read by the builder. | Builder reads only listed columns; planted-leak tests (§10) |
| N4 | Every item and every whole batch passes the M22 identity guard (suckerpot_reporting.guard, fmt="json"). Item failure drops the item; batch failure sends nothing. No confirmed self address → nothing is sent. | Builder + tests |
| N5 | Times are UTC days. A day is submitted only after it ended plus a random 2 to 20 h delay. | Scheduler + tests |
| N6 | URLs and domain names leave the node only as blinded hashes, and in clear only when the hub asks for a reveal of a hash already confirmed by ≥ k nodes, after the guard. | Builder + reveal handler |
| N7 | The node’s private key is generated in ingest, stored encrypted (SecretBox), and never readable by the API role. | Column grants + test |
| N8 | Leaving deletes the local identity and every network_* row, and asks the hub to delete the node (retried up to 7 days). | Worker + tests |
| N9 | Hub answers are accepted only with a valid hub signature. Pulled data is validated (enums, IP and hash formats, sizes); nothing pulled is executed or triggers a connection to a target. | netproto.verify_response + strict models |
| N10 | Demo data and documentation ranges never leave (demo = true rows skipped; the guard forbids documentation ranges). | Builder + guard |
3.5 Hub invariants
Section titled “3.5 Hub invariants”| # | Invariant |
|---|---|
| H1 | No client IP address is stored or logged anywhere (Caddy log disabled, uvicorn --no-access-log, app log filter, no IP column in any table). Rate limits use an in-memory HMAC of the address with a random key rotated daily. |
| H2 | No analytics, no third-party resource, no cookies. |
| H3 | Per-node observations deleted after HUB_OBS_RETENTION_DAYS (14). Nonces after 10 min. Challenges after 10 min. Contributor links when the target is resolved or after 30 days. Inactive nodes (no request for 30 days) deleted with their data. |
| H4 | Timestamps about nodes are stored as dates (enrolment day, last active day), never times. |
| H5 | Published views (member pulls and any future public page) contain only consensus aggregates and coarse member stats (§7). |
| H6 | Every request is signed, size-capped and rate-limited; replays are refused. |
| H7 | Reports go out only for confirmed targets that are reportable (§8.4), deduplicated, under caps, and in the first release only after hub-operator approval. |
4. What is shared (protocol v1)
Section titled “4. What is shared (protocol v1)”| Shared | Form | Why |
|---|---|---|
| Attacker source IP | clear (IPv4/IPv6 global unicast) | Needed for consensus; hashing an IPv4 address protects nothing (2^32) |
| Services seen | enum (sensor names: ssh, telnet, web, rdp, redis, docker, mysql, adb, mssql, smb, sip, synology) | Not ports: a node’s host ports are a fingerprint |
| Activity | enum connect, scan, login, login_success, command, download, exploit, http | Severity rules |
| Tags | allow-list TAGS_V1 in netproto (behaviour tags from ingest/rules: mirai, crypto-miner, malware-drop, credential-stuffing, php2shell, eternalblue, bluekeep, sql-rce, container-escape, db-ransom, sip-fraud, persistence, recon, exec-attempt, synology-exploit, c3rb3r, and cve-* matching ^cve-\d{4}-\d{4,7}$). Any other tag is dropped. No tag may contain a project term | Context |
| Counts | buckets 0: 1, 1: 2–9, 2: 10–99, 3: 100–999, 4: ≥1000 for sessions and login attempts | Precise counts fingerprint a node |
| Day | YYYY-MM-DD UTC, one day per batch | Time bucketing |
| Payload URL | blinded url_b (§5.6); payload host IP in clear when the URL host is an IP literal; blinded host_b when it is a name | Per-target URLs |
| File hash | sha256 (only of files the collector fetched, or hashes sensors computed: trusted = true), with family from the YARA allow-list FAMILIES_V1 and coarse file_type enum | Malware takedowns |
| C2 | IP + port in clear, or blinded domain + port, with family and via_sha256; only confidence = 'high' | C2 takedowns |
| Takedown checks | per hub target id: up, down, unknown | Takedown tracking |
Not shared in v1:
- credentials (attackers derive usernames and passwords from the target’s names, a leak risk, and of little value for takedowns);
- commands and HTTP requests;
- source ports; destination ports; precise times;
- anything from research-scanner sessions (
scanner_name IS NOT NULL) or benign sessions; - private, reserved, CGNAT, documentation addresses;
- anything in the never-block list;
- any self term.
Credentials may come back in a later protocol version as a separate opt-in. The field name
credentials is reserved and the hub refuses it in v1.
5. Protocol spn/1 (normative; copied to docs/network-protocol.md)
Section titled “5. Protocol spn/1 (normative; copied to docs/network-protocol.md)”5.1 Transport
Section titled “5.1 Transport”- HTTPS to
NETWORK_HUB_URL(for examplehttps://hub.example.org), base path/v1. JSON, UTF-8,Content-Type: application/json. No compression (no decompression bombs). - Request bodies ≤ 1 MiB for
/v1/observations, ≤ 64 KiB elsewhere. Every JSON object rejects unknown fields; JSON depth ≤ 6. - Client
User-Agent: spn-node/1. No cookies. - The request body is padded: field
"pad"with spaces so the body length is a multiple of 4096.
5.2 Keys and signatures
Section titled “5.2 Keys and signatures”-
Node identity: an Ed25519 key pair made on the node.
node_key= base64url (no padding) of the 32-byte public key. Fingerprint = first 8 hex characters of sha256(public key), shown asNode 3f9a1c07. -
Request headers:
X-SPN-Key:node_key;X-SPN-Timestamp: integer Unix seconds;X-SPN-Nonce: base64url of 16 random bytes;X-SPN-Signature: base64url Ed25519 signature of the canonical request string.
-
Canonical request string (lines joined with
\n, no trailing newline):SPN1-REQ<METHOD uppercase><path with query exactly as sent, e.g. /v1/targets?since=c_12&limit=1000><X-SPN-Timestamp><X-SPN-Nonce><lowercase hex sha256 of the raw body bytes (of the empty string for GET/DELETE)> -
Hub checks, in this order, before parsing the body:
- size;
- headers present and well-formed;
- |now − timestamp| ≤ 300 s (
stale_timestamp); - key known, except for enrolment (
unknown_node); - signature (
bad_signature); - nonce unseen for this key in 10 min (
replayed_nonce, stored innonces); - rate limit.
-
Response signature: every answer to a signed request carries
X-SPN-Hub-Signature: Ed25519 by the hub key overSPN1-RESP<request X-SPN-Nonce><HTTP status code><lowercase hex sha256 of the response body>The node verifies it against
NETWORK_HUB_KEY(base64url public key) and refuses the answer otherwise (hub_signature). -
Golden test vectors (fixed keys, timestamp, nonce, body → canonical string and signature) live in
netproto/tests/vectors.jsonand in the spec.
5.3 Errors
Section titled “5.3 Errors”Status 4xx/5xx with body {"error": "<code>"}, codes:
| Code | Meaning |
|---|---|
too_large | Body over the size cap |
invalid_payload | Schema or value validation failed |
unsupported_protocol | Unknown protocol value |
stale_timestamp, replayed_nonce, bad_signature, unknown_node | §5.2 checks |
pow_invalid, challenge_expired | Enrolment proof |
pseudonym_invalid, pseudonym_taken | Pseudonym rules |
enrolment_closed | Global enrolment cap reached |
rate_limited | With Retry-After seconds |
day_not_closed | Day not finished yet |
day_too_old | Day older than 3 days |
server_error | Anything else |
A soft-suspended node is never told: its requests succeed and its data is ignored.
5.4 Endpoints
Section titled “5.4 Endpoints”| Method | Path | Signed | Body → answer |
|---|---|---|---|
| GET | /v1/info | no | {protocols: ["spn/1"], hub_key, pow: {algorithm: "sha256-leading-zero-bits", difficulty}, limits: {...}, thresholds: {min_nodes, min_age_days}, today} |
| POST | /v1/enrol/challenge | no | {protocol, node_key} → {challenge (b64url 32 bytes), difficulty, expires_in: 600} |
| POST | /v1/enrol | yes, by the new key | {protocol, challenge, pow_nonce (b64url ≤ 16 bytes), pseudonym?: str, country?: "CH", show_country: bool} → {node_id, fingerprint, enrolled_day, eligible_from_day} |
| GET | /v1/node | yes | → {node_id, fingerprint, pseudonym, country, show_country, enrolled_day, eligible_from_day, standing: "new"|"good"|"low", active_days_14} (standing is coarse; the reputation number is never sent) |
| PATCH | /v1/node | yes | {protocol, pseudonym?, country?, show_country?} → node as above |
| DELETE | /v1/node | yes | → {deleted: true}. Deletes the node, its observations, contributor links, takedown votes, stats and nonces |
| POST | /v1/observations | yes | batch (§5.5) → {accepted: {sources, payloads, c2, files}, rejected: {...counts by reason}, duplicate: bool} |
| GET | /v1/targets?since=<cursor>&limit=<1..1000> | yes | → {cursor, targets: [Target], reveal: [{kind, value_b}], recheck: [{id, kind, value}]} |
| POST | /v1/reveals | yes | {protocol, items: [{kind: "url"|"domain", value_b, value}]} (≤ 200) → {accepted, rejected} |
| POST | /v1/rechecks | yes | {protocol, day, items: [{id, state: "up"|"down"|"unknown"}]} (≤ 500) → {accepted} |
| GET | /v1/network | yes | → summary (§7.1) |
Target:
{"id": "t_8c1d2e", "kind": "ip", "value": "198.51.100.23", "value_b": null, "state": "confirmed", "nodes": 1, "countries": 4, "first_day": "2026-10-26", "last_day": "2026-10-30", "services": ["ssh", "telnet"], "activity": ["login", "command", "download"], "tags": ["mirai"], "families": ["mirai"], "reported_day": null, "taken_down_day": null}Field rules:
kind:ip,url,domain,sha256,c2_ip,c2_domain.valueis null for a blinded kind not yet revealed.nodesis a bucket:0: 5–9,1: 10–24,2: ≥ 25.countriesis null below 3.state:confirmed,reported,taken_down,disputed,expired.- The cursor is opaque.
rechecklists confirmed or reported targets the node is asked to check (at most 50 per pull).
5.5 Observation batch
Section titled “5.5 Observation batch”{ "protocol": "spn/1", "batch_id": "q8V0b3...", // b64url 16 random bytes; idempotency key per node and day "day": "2026-10-30", "sources": [ {"ip": "198.51.100.23", "services": ["ssh"], "activity": ["login", "command", "download"], "tags": ["mirai"], "sessions": 1, "attempts": 3} ], "payloads": [ {"url_b": "9f2c...64hex", "host_ip": "198.51.100.9", "host_b": null, "sha256": "e3b0...64hex", "family": "mirai", "file_type": "elf-arm", "fetched": true} ], "c2": [ {"ip": "198.51.100.50", "domain_b": null, "port": 23, "family": "mirai", "via_sha256": "e3b0...64hex"} ], "files": [ {"sha256": "e3b0...64hex", "family": "mirai", "file_type": "elf-arm"} ], "pad": " "}- Caps: sources ≤ 5000, payloads ≤ 1000, c2 ≤ 1000, files ≤ 1000.
daymust be closed (hub time) and ≤ 3 days old. - Idempotency: a second batch with the same
batch_idanswersduplicate: trueand changes nothing; a differentbatch_idfor an already submitted day isinvalid_payload. One batch per node per day. - Validation on both sides: IPs global unicast (documentation ranges allowed only with
HUB_ALLOW_DOCUMENTATION=1, test overlay only); hex lengths; enums; port 1–65535; every list deduplicated.
5.6 Blinding
Section titled “5.6 Blinding”value_b = lowercase hex sha256( "spn/1|" + kind + "|" + canonical(value) ), where:
- url: scheme and host lowercased, IDNA host, default port removed, fragment removed, path and query kept byte for byte;
- domain: lowercased IDNA, no trailing dot.
The node can recompute value_b for its own recent indicators. The hub counts nodes per
value_b. When a blinded value is confirmed (§8), the hub puts it in reveal. Any member node
that has the clear value sends it to /v1/reveals after the guard. The hub checks
sha256(...) == value_b, then validates: URL parse, host global or a name, not on the
never-list.
5.7 Proof of work
Section titled “5.7 Proof of work”- Find
pow_noncesuch thatsha256(challenge || pubkey || pow_nonce)has at leastdifficultyleading zero bits. - Default difficulty 27 (about a minute of one CPU core in Python). The node solves it in a
separate, low-priority child process (
os.nice), so ingest is not slowed. The hub raises it for the requesting address prefix only: one more bit aboveHUB_ENROL_SOFT_CAPchallenges of that prefix in the current 10-minute window, then one more perHUB_ENROL_STEP(at most +10). - It is a speed bump; age and reputation are the real defence (§8).
5.8 Versioning
Section titled “5.8 Versioning”protocolin every body and the/v1prefix. Additive fields need no new version (receivers that see an unknown field reject it, so a field is added to the hub first, then to nodes).- A breaking change is
spn/2under/v2. The hub serves both for at least 6 months, and/v1/infolists them.
6. Hub service (hub/)
Section titled “6. Hub service (hub/)”6.1 Layout
Section titled “6.1 Layout”hub/ Dockerfile # multi-stage, python:3.13 slim pinned, uid 10001, read-only docker-compose.yml # hub stack, deployed separately by the project Caddyfile .env.example # HUB_* placeholders only pyproject.toml uv.lock alembic.ini migrations/ # own history: h0001_initial, ... hub/ main.py config.py logging.py auth.py # §5.2 checks, nonce store, response signing ratelimit.py # in-memory token buckets; the ONLY module that reads the client address models.py db.py routes/{info,enrol,node,observations,targets,network}.py consensus.py reputation.py stats.py retention.py never.py reports/{select,render,rdap,send}.py worker.py # python -m hub.worker: hourly consensus, daily stats, retention, reports admin.py # python -m hub.admin: CLI only (no admin web UI) healthcheck.py tests/The hub is added to the justfile packages list and the CI python job. Its own CI job
hub-integration runs the hub tests against a Postgres service and an end-to-end test (§10).
6.2 Compose (hub/docker-compose.yml)
Section titled “6.2 Compose (hub/docker-compose.yml)”| Service | Image | Networks | Notes |
|---|---|---|---|
caddy | caddy:2.x pinned by digest | hub-edge, hub-public | Ports 80, 443. TLS via ACME for HUB_DOMAIN. log output discarded globally; request_body max_size 1MB; header limits; only /v1/* proxied; HSTS |
hub-api | built from hub/Dockerfile | hub-edge, hub-internal | uvicorn, --no-access-log --no-server-header --limit-concurrency 200, hardened like the main stack |
hub-worker | same image, python -m hub.worker | hub-internal, hub-out | Only service with outbound (RDAP, SMTP) |
hub-db | postgres:17.x pinned | hub-internal | External volume ${HUB_VOLUME_PREFIX:?}_db-data, log_connections=off, log_statement=none |
hub-migrate | same image, alembic upgrade head then role passwords | hub-internal | One-shot |
hub-backup | postgres image | hub-internal | Daily dump, 7 kept (same script shape as the main stack) |
hub-internalisinternal: true.- DB roles:
hub_api(INSERT/SELECT on nodes, nonces, challenges, observations; SELECT on targets, stats) andhub_worker(everything except schema). - The hub signing key is a Docker secret file (
HUB_SIGNING_KEY_FILE), made bypython -m hub.admin gen-signing-key(prints the public key to publish asNETWORK_HUB_KEY).
6.3 Tables (migration h0001_initial)
Section titled “6.3 Tables (migration h0001_initial)”nodes(id uuid PK DEFAULT gen_random_uuid(), pubkey bytea UNIQUE NOT NULL CHECK (length(pubkey)=32), pseudonym text NULL, pseudonym_key text UNIQUE NULL, -- lower-cased for uniqueness country char(2) NULL, show_country bool NOT NULL DEFAULT false, enrolled_day date NOT NULL, last_active_day date NOT NULL, reputation real NOT NULL DEFAULT 0.6, standing text NOT NULL DEFAULT 'active', -- active|soft_suspended protocol text NOT NULL)enrol_challenges(challenge bytea PK, pubkey bytea NOT NULL, difficulty int NOT NULL, expires_at timestamptz NOT NULL)nonces(node_id uuid REFERENCES nodes ON DELETE CASCADE, nonce bytea, expires_at timestamptz, PRIMARY KEY(node_id, nonce))batches(node_id uuid REFERENCES nodes ON DELETE CASCADE, day date, batch_id bytea, PRIMARY KEY(node_id, day))observations(id bigint identity PK, node_id uuid REFERENCES nodes ON DELETE CASCADE, day date NOT NULL, kind text NOT NULL, -- ip|url|domain|sha256|c2_ip|c2_domain value text NOT NULL, -- clear ip, sha256 hex, ip:port, or value_b hex for blinded kinds services text[], activity text[], tags text[], families text[], sessions smallint, attempts smallint, UNIQUE(node_id, day, kind, value))targets(id text PK, -- 't_' + 6 random bytes hex kind text, value text NULL, value_b text NULL, UNIQUE(kind, coalesce(value_b, value)), state text NOT NULL, -- candidate|confirmed|reported|taken_down|disputed|expired node_count int, weight real, country_count int, first_day date, last_day date, services text[], activity text[], tags text[], families text[], confirmed_day date, reported_day date, taken_down_day date, report_id bigint NULL, updated_at timestamptz)target_contributors(target_id text REFERENCES targets ON DELETE CASCADE, node_id uuid REFERENCES nodes ON DELETE CASCADE, PRIMARY KEY(target_id, node_id)) -- deleted when resolved or after 30 daystakedown_votes(target_id text REFERENCES targets ON DELETE CASCADE, node_id uuid REFERENCES nodes ON DELETE CASCADE, day date, state text, PRIMARY KEY(target_id, node_id, day)) -- 30 daysreports(id bigint identity PK, recipient text, kind text, target_ids text[], state text, -- queued|approved|sent|failed|cancelled body_text text, xarf text, created_day date, approved_by text NULL, sent_at timestamptz NULL, error_code text NULL)recipient_log(recipient text, day date, count int, PRIMARY KEY(recipient, day))node_stats_weekly(node_id uuid REFERENCES nodes ON DELETE CASCADE, week date, active_days smallint, tier smallint, PRIMARY KEY(node_id, week))network_daily(day date PK, active_nodes int, members int, confirmed int, reported int, taken_down int)never_list(value text PK, kind text, note text)hub_audit(id bigint identity PK, at timestamptz, actor text, action text, detail jsonb) -- admin CLI only, no node dataNo table has an IP-address column about a client. pubkey is the only node identifier.
6.4 Rate limits and caps (env, defaults)
Section titled “6.4 Rate limits and caps (env, defaults)”| Limit | Default |
|---|---|
| Unsigned endpoints per client address (in-memory HMAC key) | 10/min, 100/day |
| Signed requests per node | 60/hour |
| Observation batches | 1 per node per day |
| Distinct new values per node per day | 20,000 |
| Reveals per node per day | 2,000 |
| Enrolment difficulty, per address prefix | HUB_ENROL_SOFT_CAP=3 challenges per window (+1 bit above, then +1 per HUB_ENROL_STEP=3) |
| Enrolments, global | HUB_ENROL_HARD_CAP=5000/day (enrolment_closed), set far above any honest day |
| Pseudonym changes | 1 per node per 7 days |
6.5 Admin CLI (python -m hub.admin)
Section titled “6.5 Admin CLI (python -m hub.admin)”Subcommands:
gen-signing-key;reports list|show|approve <id>|cancel <id>;target dispute <id> --note;never add|remove <value>;node suspend|unsuspend <fingerprint>;stats.
Each writes hub_audit. No web admin: smaller attack surface.
7. What members see
Section titled “7. What members see”7.1 GET /v1/network
Section titled “7.1 GET /v1/network”{"generated_day": "2026-11-01", "totals": {"members": 37, "active_7d": 29, "countries": 9, "confirmed_30d": 412, "reported_30d": 318, "taken_down_30d": 141, "median_days_to_takedown": 4}, "weekly": [{"week": "2026-10-19", "confirmed": 96, "reported": 81, "taken_down": 33}], "countries": [{"cc": "CH", "members": 4}], "members": [{"fingerprint": "3f9a1c07", "pseudonym": "night-owl", "country": "CH", "member_since": "2026-09", "active_days": 6, "tier": 3}]}Rules:
countriesonly lists countries with ≥ 3 members who set them.- A member’s
countryis non-null only if they opted to show it and their country has ≥ 3 showing members. member_sinceis a month.active_daysandtierrefer to the last full ISO week.tieris 0 to 4 by distinct values submitted:0: 0,1: 1–99,2: 100–999,3: 1k–9.9k,4: ≥ 10k.- Members are listed in random order per day (no ranking by activity).
- Computed once a day by the worker.
7.2 Takedown status
Section titled “7.2 Takedown status”A reported target becomes taken_down when, after reported_day:
- at least 2 eligible nodes vote
downfor it on 3 different days; - and no eligible node votes
upon the last of those days.
It goes back to reported if up votes come back within 14 days. Node votes come from M27’s
local takedown re-checks (§9.5). The hub itself never connects to targets in v1.
8. Trust: consensus, reputation and reports
Section titled “8. Trust: consensus, reputation and reports”8.1 Eligibility (hourly, worker)
Section titled “8.1 Eligibility (hourly, worker)”A node’s observations count toward consensus only when all of these hold:
standing = active;enrolled_day ≤ today − HUB_NODE_MIN_AGE_DAYS(14);- it submitted batches on ≥
HUB_NODE_MIN_ACTIVE_DAYS(7) of the last 14 days; reputation ≥ HUB_MIN_REPUTATION(0.3).
8.2 Confirmation
Section titled “8.2 Confirmation”For each (kind, value) with observations in the last HUB_WINDOW_DAYS (7) days, let E be the
set of eligible contributing nodes. The target is confirmed when all of these hold:
|E| ≥ HUB_MIN_NODES(5) and Σ min(reputation, 1) over E ≥HUB_MIN_WEIGHT(3.0);- observed on ≥ 2 distinct days, or
|E| ≥ 2 × HUB_MIN_NODES; - cohort cap: no single enrolment ISO week supplies more than
HUB_COHORT_MAX_SHARE(0.5) of E (a Sybil farm usually enrols at once); - not on the never-list (public resolvers, large CDNs and SaaS, research scanners by ASN from
ingest/rules/scanners.yamlcopied into the image, reserved ranges, entries added by the admin); - corroboration: for
ip, if no external source (the hub’s own optional AbuseIPDB, GreyNoise or abuse.ch keys) knows it, condition 1 needs2 × HUB_MIN_NODESnodes.
Otherwise the target stays candidate (never published). Contributor links are kept in
target_contributors for reputation.
8.3 Reputation (daily)
Section titled “8.3 Reputation (daily)”| Event | Change |
|---|---|
| Start | 0.6 (5 eligible nodes at start weigh exactly 3.0, the confirmation threshold; reputation otherwise rises only through confirmed targets, so a lower start would deadlock a new network) |
| Each confirmed target the node contributed to | +0.01 (max +0.05/day), cap 1.0 |
| A target it contributed to is disputed (admin, abuse desk proved false, or later hits the never-list) | −0.1 |
| Over 14 days > 500 distinct values and > 95 % never corroborated by anyone | −0.05/day |
Contributed to a taken_down target | +0.02 |
- Below 0.2 the node is
soft_suspended: requests succeed, data is ignored,standingshowslow. - The exact number is never sent to anyone; the node sees
new,goodorlow.
8.4 Who sends the report: the hub (recommended)
Section titled “8.4 Who sends the report: the hub (recommended)”| Hub sends | Each node sends | |
|---|---|---|
| Strength | One report: “observed by 15 independent sensors in 8 countries between 26 and 30 October” | 15 separate weak reports |
| Abuse-desk load | One mail per desk per week, deduplicated | Spam; desks blacklist the sender |
| Member anonymity | Members send nothing, store no mail credentials, own no mailbox | Each member needs an anonymous mailbox and the M22/M25 workflow |
| Evidence detail | Day ranges and buckets only (no per-event lines) | Exact times and ports, but each one leaks more about the node |
| Central risk | The hub’s mailbox is a target and a single point of failure; the project needs an anonymous identity | None central |
| Legal / abuse handling | Replies go to one project mailbox | Scattered |
Recommendation: the hub sends. Members keep M22 for their own local reports. When a node is a
member, its M22 worker skips subjects that the network has confirmed or reported in the last
30 days (§9.4), so desks never get both.
8.5 Hub reporting
Section titled “8.5 Hub reporting”- Reportable:
ipwith activity in {login_success,command,download,exploit} from ≥ min nodes, orattemptsbucket ≥ 3 from ≥ min nodes;url/sha256fetched (fetched: true) by ≥ 2 nodes;c2_ip/c2_domainconfirmed.- Scans and connects alone are never reportable.
- Contact: RDAP (ported from
ingest/ingest/abuse/rdap.pywith the same size, time and depth limits); domains go to the registrar. - Grouping: one report per (recipient, kind) per 7 days; a target is never reported twice
within 30 days unless it came back
upaftertaken_down. - Caps:
HUB_REPORT_DAILY_CAP(100) mails per day globally, 1 per recipient per day. - Modes (
HUB_REPORT_MODE):review(default for the first release): reports wait forhub.admin reports approve;auto: sent afterHUB_REPORT_HOLD_HOURS(24) unless cancelled;off.
- Rendering:
- plain text and X-ARF v0.2 (category
abuse, report type per kind), usingsuckerpot_reportinghelpers where they fit (the X-ARF YAML writer); - evidence = day range, services, activity, tags, families, bucketed counts, number of sensors (bucket) and countries (only ≥ 3), sha256 and revealed URLs;
- never node ids, pseudonyms or fingerprints.
- plain text and X-ARF v0.2 (category
- Sending: SMTP over TLS from the project mailbox (
HUB_SMTP_*), neutral headers as in M25.reports.stateandtargets.reported_dayare updated.
8.6 Hardening from the security review
Section titled “8.6 Hardening from the security review”Additions to sections 8.1 to 8.5, implemented in hub/:
- Active means data. A node counts as active on a day only if that day’s batch held at least
one observation (
batches.items > 0); empty batches keep nobody eligible. - Twice the nodes for more kinds. The “2 ×
HUB_MIN_NODESunless an external source knows it” rule applies toip,payload_ip(payload hosts given as an address; M30),c2_ip,url,domainandc2_domain. - Payload hosts need a fetched file (M30). A
payload_iptarget is reportable (as a malware download location) only when ≥ 2 eligible nodes fetched a file from it and ≥ k eligible nodes saw it, the same fetched rule asurlanddomain. Typingwget http://<victim>/xinto honeypots never mails the victim’s abuse contact. Only addresses can be looked up; a blinded value always needs twice the nodes. - C2 needs a dropper. A
c2_*target is confirmed only with avia_sha256that is itself confirmed and was fetched by at least two eligible nodes. - Reportability uses eligible nodes only and the target’s k-supported fields; fresh keys cannot make a scan reportable as an exploit or fake a fetched payload.
- k-supported publication.
last_day,first_day,services,activity,tags,familiesfollow what at least k eligible nodes support.nodesandcountriesare frozen when the target is confirmed. A single sighting on a listed target changes nothing a member sees. - Reputation bonus for a confirmation only if the contributors span two enrolment cohorts.
- Auto mode is closed until
HUB_AUTO_MIN_COHORTS(5) independent cohorts exist among eligible nodes, and never approves C2 reports (an operator does). - Member tier counts contributions to values that reached consensus in the week, capped per kind and with a stable jitter of up to 25 %. What a node merely sent never moves it.
- Enrolment cost. One more proof-of-work bit per
HUB_ENROL_STEPchallenges of the same address prefix above the soft cap (at most +10), so a flood prices out the flooder only; pending challenges are capped per address prefix (IPv6 by /64). - Research-scanner ASNs (section 8.2, rule 4) cannot be applied: the hub has no ASN database and the protocol carries no ASN. Scanner address ranges go on the never-list by the operator.
- Responses are bound to the whole request (see
docs/network-protocol.md).
9. Node side
Section titled “9. Node side”9.1 Configuration
Section titled “9.1 Configuration”| Variable | Default | Meaning |
|---|---|---|
NETWORK_ENABLED | false | Master switch (env). Without it the UI explains and shows just set NETWORK_ENABLED=true |
NETWORK_HUB_URL | empty | https://hub.example.org placeholder. Empty = “no hub configured” |
NETWORK_HUB_KEY | empty | Hub public key (base64url). Required with the URL |
NETWORK_INTERVAL_S | 900 | Worker cycle |
NETWORK_SUBMIT_DELAY_MIN_H / _MAX_H | 2 / 20 | Random delay after the day ends |
NETWORK_MAX_SOURCES | 5000 | Batch cap (top by severity, then by sessions) |
Passed to ingest (all) and api (NETWORK_ENABLED, and whether the URL and key are set, not their
values).
9.2 Migration 0028_network (node database)
Section titled “9.2 Migration 0028_network (node database)”network_membership(id smallint PK CHECK (id=1), desired text NOT NULL DEFAULT 'off', -- off|member|leave state text NOT NULL DEFAULT 'off', -- off|enrolling|member|leaving|error pseudonym text, country char(2), show_country bool NOT NULL DEFAULT false, node_id uuid, fingerprint text, pubkey bytea, privkey_enc bytea, enrolled_day date, eligible_from_day date, standing text, next_submit_at timestamptz, last_submit_day date, last_pull_at timestamptz, pull_cursor text, leave_requested_at timestamptz, last_error_code text, requested_by text, requested_at timestamptz, updated_at timestamptz NOT NULL DEFAULT now())network_submissions(id bigint identity PK, day date UNIQUE, batch_id text, sent_at timestamptz, status text, -- sent|failed|skipped counts jsonb, dropped jsonb, -- {"guard": 3, "cap": 120, "self": 1} error_code text, payload jsonb) -- exact JSON sent, ≤ 256 KiB, kept 7 days (transparency)network_targets(kind text, value text, value_b text, hub_id text UNIQUE, state text, nodes smallint, countries smallint, first_day date, last_day date, services text[], activity text[], tags text[], families text[], reported_day date, taken_down_day date, mine bool NOT NULL DEFAULT false, updated_at timestamptz, PRIMARY KEY(kind, coalesce(value, value_b)))network_summary(id smallint PK CHECK (id=1), data jsonb, fetched_at timestamptz)Grants:
suckerpot_ingest: all onnetwork_*.suckerpot_api:- SELECT on
network_submissions,network_targets,network_summary; - column-level SELECT on
network_membershipexcludingprivkey_enc(andpubkeyis fine); - UPDATE only
(desired, pseudonym, country, show_country, requested_by, requested_at).
- SELECT on
- Retention:
network_submissions.payloadnulled after 7 days, rows after 90;network_targetsexpired after 180 days.
9.3 Worker (ingest/ingest/network/)
Section titled “9.3 Worker (ingest/ingest/network/)”Modules:
config.py;identity.py(key generation, SecretBox, sign);client.py(signed requests viamake_client, response verification);builder.py(§9.4);scheduler.py(day selection and delay);sync.py(targets, reveals, rechecks, summary);takedown.py(M27 adapter, §9.5);worker.py(IntelWorkershape:run(publish),status(),aclose()).
Wired in ingest/ingest/main.py like the other *_ENABLED workers. Status goes to
enrichment_status row network (state and reason codes only).
Each cycle:
- If
NETWORK_ENABLEDis false, ordesired = offandstate = off: idle. - Gate closed (
gate_open()false): wait, state unchanged (egress_closed). desired = leave:DELETE /v1/node;- on success or
unknown_node: wipenetwork_membership(back to defaults), delete allnetwork_targets,network_submissionsandnetwork_summaryrows; - on a network error: retry each cycle for 7 days, then wipe locally anyway and record
leave_unconfirmed. The hub deletes inactive nodes after 30 days.
desired = member,stateofforerror:- generate an Ed25519 key (
cryptography), storeprivkey_encwith the SecretBox (refuse withno_settings_keyifSETTINGS_ENCRYPTION_KEYis unset); GET /v1/info;- challenge; PoW in
asyncio.to_threadwith a 15-minute cap; POST /v1/enrol;- store
node_id,fingerprint, days. Statemember.
- generate an Ed25519 key (
- Pseudonym or country changed in the row since the last sync →
PATCH /v1/node. - Submit: if
now ≥ next_submit_atand the previous UTC day is not innetwork_submissions:- require a confirmed self address (
terms.has_self_address), elsewaiting_self_address; - build (§9.4),
POST /v1/observations, record; - set
next_submit_at= next day 00:00 UTC + uniform(min, max) hours. - At most the last 2 closed days are ever submitted (older days are skipped, never back-filled).
- require a confirmed self address (
- Pull: every 6 h,
GET /v1/targetsuntil the cursor is current, upsertnetwork_targets(mine= value orvalue_bis in this node’s submitted values of the last 14 days, computed from the DB). Then:- answer
revealitems the node has (guarded per item); - answer
recheckitems throughtakedown.local_states.
- answer
- Summary: daily
GET /v1/network→network_summary;GET /v1/node→standing.
9.4 Batch builder (builder.py)
Section titled “9.4 Batch builder (builder.py)”-
Inputs: only these columns, for rows with
demo = false.sessions:src_ip,sensor,tags,benign,scanner_name, counters (login attempts, commands, downloads),first_seen,last_seenwithin the UTC day.indicators(type,value,role,host,trusted) seen that day.samples,sample_links(kind IN ('c2_ip','c2_domain'),confidence = 'high').fetch_targets(status = 'fetched').
-
Never read:
events.data, credentials, commands, HTTP fields. -
Activity mapping:
Session fact Activity connect only connectprobe or scan tag scanlogin attempts loginaccepted login login_successcommands commanddownload attempt downloadexploit or CVE tags exploitweb requests http -
Exclusions:
benignandscanner_name IS NOT NULLsessions;- non-global addresses;
- the never-block list (
suckerpot_db.blocklist_values); - self terms (
SelfTerms); - tags not in
TAGS_V1, families not inFAMILIES_V1.
-
Blinding: URLs and domain hosts are blinded (§5.6).
-
Guard: every item is serialised and checked with
guard(item, terms, fmt="json", ctx=GuardContext()). A failing item is dropped (dropped.guard += 1). Then the full batch body is guarded once more; failure meansstatus = failed, error_code = guard_failed, nothing sent. -
Caps: sources sorted by severity (exploit > download > command > login_success > login > others), then sessions bucket; cut at
NETWORK_MAX_SOURCES(dropped.cap). -
The exact JSON sent is stored in
network_submissions.payload(shown in the UI, §9.6).
9.5 Links to other features
Section titled “9.5 Links to other features”- M22 dedupe (
ingest/ingest/abuse/candidates.py): when membershipstate = member, skip subjects whose value is innetwork_targetswithstate IN ('confirmed','reported', 'taken_down')andlast_dayin the last 30 days. The Abuse reports UI shows a chip “Reported by the network” on such subjects in the candidate list (API addsnetwork_stateto the subject JSON). - Blocklists (
ingest/ingest/blocklist/): a network-confirmedip,url,domainorsha256gets a fixed +2 score with the reason “Confirmed by the SuckerPot network (5 or more sensors)”. Overrides still win. - M27 takedown tracking:
- Adapter
takedown.local_states(session, items: list[tuple[str, str]]) -> dict[str, Literal["up","down","unknown"]], keyed by hub target id. It reads M27’s local re-check results. Until M27 is merged it answersunknownfor everything, and nothing is sent. - The node’s Impact page (M27) gets network figures from
GET /api/v1/network/impact.
- Adapter
9.6 Node API (api/api/network.py)
Section titled “9.6 Node API (api/api/network.py)”| Method | Path | Who | Answer |
|---|---|---|---|
| GET | /api/v1/network/status | any user | {enabled_env, hub_configured, desired, state, pseudonym, country, show_country, fingerprint, enrolled_day, eligible_from_day, standing, last_submit_day, next_submit_at, last_error_code, waiting: null|"self_address"|"egress"|"settings_key"}. 404 never (M28’s setup step hides itself when enabled_env is false and no hub is configured) |
| PUT | /api/v1/network/membership | admin, CSRF, password again | {join: true, pseudonym?, country?, show_country?, accept: ["sources","payloads","c2","files","rechecks"]}. All five must be listed (the explicit consent list). Pseudonym validated locally (§9.7) and through the guard. Audit network.join (no pseudonym in the audit detail, only pseudonym_set: bool) |
| PATCH | /api/v1/network/membership | admin, CSRF | {pseudonym?, country?, show_country?} → audit network.update |
| DELETE | /api/v1/network/membership | admin, CSRF, password again | Sets desired = leave. Audit network.leave |
| GET | /api/v1/network/preview | admin | The batch that would be sent for yesterday, built by the same builder code? No: the API has no builder. Instead the API returns the last stored network_submissions.payload plus counts, and a “preview” requested through network_preview_requests? Keep it simple: v1 shows the last sent batch, and before joining, a static example built from the user’s own data is not offered. The explanation lists every field |
| GET | /api/v1/network/submissions?limit=30 | admin | Rows of network_submissions (payload only on GET /api/v1/network/submissions/{day}) |
| GET | /api/v1/network/targets?state=&kind=&mine=&cursor= | any user | Paginated network_targets |
| GET | /api/v1/network/summary | any user | network_summary.data plus fetched_at |
| GET | /api/v1/network/impact | any user | {network: {confirmed_30d, reported_30d, taken_down_30d, median_days_to_takedown}, mine: {helped_confirm_30d, helped_takedown_30d}} computed from network_targets (mine) and the summary |
setup_status.py: new item network: off when not a member, ok when member and last
submission ≤ 2 days ago, warning with the waiting reason otherwise.
9.7 Pseudonym rules
Section titled “9.7 Pseudonym rules”- 3 to 24 characters,
^[A-Za-z0-9][A-Za-z0-9 _.-]{1,22}[A-Za-z0-9]$, no://, no@, no sequence of 4+ digits (avoids postcodes, years and addresses). - Passes the identity guard (owner names and domains refused) and a reserved list (
admin,hub,official,moderator, and the project terms). - The UI says: “Do not use a name you use anywhere else.” Empty = shown as “Node 3f9a1c07”.
9.7a Enrolment that cannot finish now
Section titled “9.7a Enrolment that cannot finish now”enrolment_closed and pow_timeout are temporary. The node keeps desired = member, stays in
enrolling, reports last_error_code = waiting_enrolment (the API shows waiting: "enrolment")
and retries with exponential backoff (15 minutes, doubling, at most 6 hours). Only
pseudonym_taken and pseudonym_invalid end the attempt.
9.8 Web UI
Section titled “9.8 Web UI”Navigation. New entry in the Defend space: “Network” (lucide Share2), hub with tabs
“Overview” (/network) and “Members” (/network/members). Settings gets a “Network” tab
(/settings?tab=network) for joining and leaving. M28’s setup step network reuses the join
card.
Not a member. The Overview page explains in three short paragraphs:
- what the network does;
- exactly what is shared (the §4 table in plain words, with “never shared” listed as well);
- the residual risk sentence (“an attacker who probes many addresses could learn that your address runs a sensor, never who you are”).
It shows network totals from the hub only if the node was once a member (otherwise no hub contact at all; non-members never call the hub). Admin sees “Join the network” opening the join dialog:
- five consent checkboxes;
- the pseudonym field with the warning;
- optional country select (prefilled empty, never auto-detected), “Show my country next to my pseudonym” off by default;
- the password field.
Viewers see the explanation only.
Member.
- Overview opens with a sentence: “The network took down 141 malware hosts and attack sources in the last 30 days. Your sensor helped confirm 23 of them.”
- KPI row (StatTile): members active this week, confirmed (30 d), reported (30 d), taken down (30 d), median days to takedown.
- Charts:
- weekly bars confirmed / reported / taken down (Recharts, existing chart tokens; red is not
used: takedowns are
success); - a country choropleth on the existing flat map component, only countries with ≥ 3.
- weekly bars confirmed / reported / taken down (Recharts, existing chart tokens; red is not
used: takedowns are
- Recently taken down table: target (IPChip opens the local attacker page when the address
was seen locally, else a filtered
/network?target=), kind, families (TagBadge), days to takedown, sensors bucket (“5–9 sensors”), “You helped” badge whenmine. - Your node card:
- fingerprint, pseudonym (edit), country, member since, eligibility (“Counts toward consensus from 15 Nov”);
- standing (new / good / low with a sentence);
- last batch day and counts;
- “See exactly what was sent” (a drawer with the stored JSON, copy button);
- “Leave the network” (danger button, confirmation dialog stating what is deleted where, then password).
- Members tab: a virtualised list showing pseudonym or “Node 3f9a1c07”, CountryFlag or ”—”, member since, seven dots for active days last week, tier as a small meter (0–4) with a text label (“1,000 to 9,999 items”). No ranking; random order from the hub, sorted by pseudonym in the UI. Header sentence: “37 members in 9 countries; 29 active this week.”
Rules (CLAUDE.md §4).
- A sentence before every chart.
- Filters in the URL (
state,kind,mine). - Tabular numbers; times shown in the browser time zone (days only here).
- No new hue.
- WCAG AA; keyboard; screen-reader text for the dot rows and the tier meter.
- Mock data in
web/src/mocks/network.tswith documentation IPs and invented pseudonyms. help.tsentries for both pages; glossary terms “consensus”, “blinded indicator”, “eligible node”.
9.9 Notes for the user guide
Section titled “9.9 Notes for the user guide”- Test traffic you send yourself from an outside address that is not in your own address terms (Settings, Reporting) is submitted like any attacker’s. Test from an address you have listed, or expect your test host to appear in a batch.
10. Tests
Section titled “10. Tests”netproto/tests:
- golden vectors (canonical strings, signatures, response signatures, blinding of URLs and domains with IDNA and default ports);
- PoW verify (valid, one bit short, wrong challenge);
- bucket functions;
- models reject unknown fields, private and documentation IPs (unless allowed), bad enums,
over-cap lists, the
credentialsfield; - padding.
hub/tests (unit):
- auth order and every error code;
- nonce replay; timestamp skew; enrol with a bad PoW, an expired challenge, a taken pseudonym;
- idempotent batch;
day_not_closed,day_too_old; caps; rate limits; - privacy:
- an AST test that only
ratelimit.pyreadsrequest.clientorX-Forwarded-For; - a log-capture test that no record contains the test client address or a full pubkey;
- the Caddyfile contains
output discard; - no model has an
inetcolumn;
- an AST test that only
- k-rules: countries < 3 hidden, member country hidden below 3, no per-node data in
/v1/targetsor/v1/network(scan the JSON for every node id and fingerprint except the member list’s fingerprints); - retention: observations at 15 days deleted, nonces expired, inactive nodes deleted after 30
days,
DELETE /v1/nodeleaves no row with the node id in any table (query every table).
hub/tests (consensus and reputation):
| Case | Expected |
|---|---|
| 5 aged nodes on 2 days | confirmed |
| 4 nodes | candidate |
| 5 nodes, all enrolled the same week | candidate (cohort) |
| 5 new nodes (< 14 days) | candidate |
| Uncorroborated IP with 5 nodes | candidate, with 10 nodes confirmed |
| Never-list value | never confirmed |
| Dispute | lowers contributors’ reputation; below 0.2 is soft-suspended and the node is not told |
Takedown voting state changes are tested too.
hub/tests (end-to-end, -m integration): hub/docker-compose.test.yml (local volumes,
HUB_ALLOW_DOCUMENTATION=1, no Caddy TLS) and a driver that enrols 7 synthetic nodes with low
PoW difficulty, ages them by moving enrolled_day back, submits batches with documentation IPs,
runs the worker once, and checks targets, reveal flow, rechecks, a report rendered in review
mode (sent to a local SMTP sink container), then leaves one node and checks deletion.
ingest/tests/network:
- N1: worker idle without env or opt-in (a transport that fails the test on any call).
- N2: gate closed → no call; host must equal the configured hub host.
- N3/N4 planted leaks:
- session
dataand events carrying the self address in Host, SNI, URL query and username are present in the DB, and the batch never contains them; - a payload URL with the self address in its query is dropped with
dropped.guard; - a pseudonym equal to an owner domain is refused;
- no confirmed self address →
waiting_self_address, nothing sent.
- session
- N5: schedule within bounds; never submits the current day; skips days older than 2.
- N6: URLs only blinded in batches; reveal only for requested hashes and guarded.
- N7: the API role cannot select
privkey_enc(integration test). - N8: the leave flow wipes every
network_*row; the 7-day retry ends withleave_unconfirmed. - N9: a bad hub signature is refused; unknown fields refused.
- N10: demo rows never included.
- Static:
ingest/ingest/networkuses onlymake_client; noevents.datareference inbuilder.py(AST test).
api/tests/test_network.py: roles (viewer read-only), CSRF, re-authentication on join and
leave, the consent list required, pseudonym validation, audit details without values, impact
figures.
Web: vitest for join dialog validation, leave dialog, overview sentence and empty states,
members list; Playwright screenshots of /network (non-member, member), /network/members,
Settings > Network, desktop and mobile, dark and light.
11. Task split
Section titled “11. Task split”Wave 0 publishes the contract. Wave 1 tasks run in parallel against it (worktrees or the strictly
separate files listed). Every task uses documentation IPs, documentation ASNs and example.org
only.
| Task | Agent / model | Owns | Depends on | Wave |
|---|---|---|---|---|
| T0. Protocol library and spec | backend-engineer, sonnet (opus review of the crypto) | netproto/** (suckerpot_netproto: models.py, sign.py, blind.py, pow.py, buckets.py, enums.py with TAGS_V1, FAMILIES_V1, tests/vectors.json), docs/network-protocol.md, justfile packages list, .github/workflows/ci.yml python job list | — | 0 |
| T1a. Hub API and storage | backend-engineer, sonnet | hub/Dockerfile, hub/docker-compose.yml, hub/docker-compose.test.yml, hub/Caddyfile, hub/.env.example, hub/pyproject.toml, hub/migrations/**, hub/hub/{main,config,logging,auth,ratelimit,models,db,healthcheck}.py, hub/hub/routes/**, hub/tests/test_api_*.py, test_privacy.py | T0 | 1 |
| T1b. Hub worker | backend-engineer, sonnet | hub/hub/{consensus,reputation,stats,retention,never,worker,admin}.py, hub/hub/reports/**, hub/hub/data/**, hub/tests/test_consensus*.py, test_reports*.py, test_retention.py, hub/tests/e2e/**, .github/workflows/ci.yml (hub-integration job only) | T0; codes against the §6.3 DDL; merges after T1a | 1 |
| T2. Node migration and roles | backend-engineer, sonnet | db/migrations/versions/0028_network.py, db/suckerpot_db/models.py (four models), db/suckerpot_db/roles.py, db/suckerpot_db/retention.py (network retention) | — | 1 |
| T3. Node worker | backend-engineer, sonnet | ingest/ingest/network/**, ingest/ingest/main.py (wiring), ingest/ingest/features.py (settings), ingest/tests/network/**, docker-compose.yml (ingest and api env only), .env.example (network section) | T0, T2 (can stub models) | 2 |
| T4. Node API | backend-engineer, sonnet | api/api/network.py, api/api/routes.py, api/api/setup_status.py (network item), api/tests/test_network.py | T2 | 2 |
| T5. Web | frontend-engineer, sonnet | web/src/pages/network.tsx, web/src/pages/network-members.tsx, web/src/components/network/**, web/src/lib/network.ts, web/src/mocks/network.ts, web/src/app/nav.ts, web/src/app/router.tsx, web/src/pages/settings.tsx (Network tab), web/src/components/setup/network-step.tsx, web/src/lib/help.ts, web/src/lib/glossary.ts | §9.6 JSON (mocks first) | 1 (mocks), 2 (wire) |
| T6. Dedupe and blocklist source | backend-engineer, sonnet | ingest/ingest/abuse/candidates.py, ingest/ingest/blocklist/**, api/api/abuse.py (network_state on subjects), tests | T2 | 2 |
| T7. Docs | docs-writer, haiku | docs/network.md (user guide: what is shared, joining, leaving, risks), docs/ARCHITECTURE.md (pieces, outbound hosts row: the hub host, network worker row), docs/SECURITY.md (threat model summary, §3), docs/configuration.md, docs/api.md, docs/index.md, CHANGELOG.md, hub/README.md (operator guide: deploy, keys, admin CLI, retention) | all | 3 |
Contracts that must not drift:
- §5 (wire format, owned by T0; any change goes through T0 and the spec first);
- §6.3 DDL (T1a owns the migration; T1b codes against it);
- §9.2 DDL (T2);
- §9.6 JSON (T4 owns; T5 mocks match it exactly; a vitest checks the mock against a shared
TypeScript type in
lib/network.ts).
Reviews:
security-reviewer(opus) on T0, T1a, T1b, T3, T4: threat model §3, invariants N1–N10 and H1–H7, signatures, replay, poisoning.ui-designeron T5.qa-runner:just check,just guardrails,just test-integration, the hub integration job.
12. Risks, open questions and later work
Section titled “12. Risks, open questions and later work”- Project identity. The hub needs a domain, a VPS paid for anonymously, a mailbox and a
signing key. Until then
NETWORK_HUB_URLandNETWORK_HUB_KEYship empty and the feature cannot be joined. Owner action. - Hub as a trusted party for linkage. The hub knows, for 14 days and at day granularity, which pubkey saw which attacker IPs. A seizure plus an attacker’s own target logs could narrow a node’s address. Protocol v2 candidate: anonymous credentials (blind-signed per-day tokens) so submissions are unlinkable to the node key, with consensus counting tokens issued one per eligible node per day.
- Probe-response attacks cannot be eliminated; documented.
- Bootstrap. With fewer than 5 eligible members nothing is ever confirmed. The page says “The network needs 5 active members before it confirms anything” with the current count.
- Hub report wording lives in the hub configuration, not in this public repo (same reason as M22: searchable wording). The hub ships no default text: an empty template sends the facts only.
- Legal. The hub operator receives replies and disputes; the dispute path (§8.3) is manual in v1.
- M27 interface. The
takedown.local_statesadapter assumes M27 keeps per-target re-check results locally. Confirm with the M27 design before T3 wires it. - Migration numbers. M28 uses 0025, M26 and M27 use 0026 and 0027 (deployed first), so M29
is
0028, chained on0027.