M32 — Join the network in three commands
Status: proposal for review. Scope: docs/ROADMAP.md, section M32. Builds on M21 (anonymous
egress, collector), M28 (installer, signed images, updates), M29 (hub, spn/1, consensus, threat
model), M30 (feeds) and M31 (hub in production, abuse.ch submission).
Every address in this document is a placeholder from the documentation ranges (192.0.2.0/24,
198.51.100.0/24, 203.0.113.0/24, 2001:db8::/32) or example.org. The only real names are the
project’s public hub and feed hosts, suckerpot-hub.selfhostr.io and suckerpot-feeds.selfhostr.io. Key values are
placeholders (<HUB_PUBLIC_KEY>, <FEED_PUBLIC_KEY>); the orchestrator fills them in before the
release.
1. Goal
Section titled “1. Goal”Today a person who only wants to help the network has to install the whole product: dashboard, first-run wizard, a VPN account, then join in the UI. M32 adds a second way in.
-
Participant mode. Three commands and nothing else to set up except opening the printed ports:
Terminal window git clone --depth 1 --branch vX.Y.Z https://github.com/<owner>/suckerpot.gitcd suckerpot./scripts/install.sh --participantNo dashboard, no admin account, no wizard, no API key, no VPN needed. Sensors, database, ingest and the network node only, in about 2 GiB of memory. Choosing this mode is the explicit consent to join. A random pseudonym is generated.
./scripts/status.shshows what runs and what was sent. Signed releases install themselves once a day unless turned off. -
Full mode stays exactly as today (the owner’s install does not change behaviour). A participant can switch to full mode later and keep its data.
-
Verifier nodes. A participant without a VPN never downloads malware. It only reports the download links attackers tried. When at least two independent nodes reported the same link, the hub asks a verifier node (role granted by the hub operator and turned on by the node; at first only the operator’s own node) to fetch it through its collector and VPN, and to send back an attestation (sha256, size, file type, family). A malware-like attestation plus two reporters makes the link eligible for URLhaus.
-
Hub address and key ship in the release, with a key rollover that auto-updated participants follow without typing anything.
Hard requirements (from the owner, decided):
- Participant mode is opt-in by the act of choosing it. Full mode keeps the network off by default.
- A participant without a VPN may reach only the hub (and the feed host if feeds are on). Enrichment that needs the internet is off, the collector is off, nothing else leaves. Only one service has a route out.
- Full mode keeps “every outbound call through the VPN”.
- Nothing sent out names the operator. Nodes stay pseudonymous. The hub keeps no client addresses.
2. Decisions
Section titled “2. Decisions”| # | Decision | Alternatives rejected, and why |
|---|---|---|
| D1 | A participant without a VPN reaches the hub through hubproxy, a new tiny allow-list forward proxy (our own code, Python standard library only). It accepts only CONNECT <allowed name>:443 and is the only service with a route out in that mode. Ingest keeps no route and no external DNS; its egress client points at hubproxy instead of vpn | (a) Give ingest a non-internal network: breaks “no route out” for the biggest attack surface on the host (it parses attacker data). (b) tinyproxy or squid with a filter: one more third-party image to pin, logs by default, filter syntax mistakes open the world. (c) An SNI-routing TCP proxy with a network alias for the hub name: needs DNS tricks inside ingest, and a stray client to another name would still hit the proxy. (d) Tor: about 100 MiB more, slow, and many front ends block Tor exits; kept as a later idea |
| D2 | The egress gate gets a direct mode with a host scope: in direct mode the gate is open only for requests whose host is in the scope (hub host, feed host). Every other worker sees a closed gate (egress_direct) and idles, with no new code path | A separate client factory for the network worker: two ways out of ingest, and the static egress test would need an exception |
| D3 | The participant overlay docker-compose.participant.yml turns the base file into the participant stack (api off through a profile that is never set, vpn loses its dependency on api, ingest joins the hub-proxy network). The base file only gains the hubproxy service under the new profile direct and two networks. With COMPOSE_PROFILES=egress and no participant overlay (the owner’s install) the rendered stack is unchanged | Making api a profile in the base file: the owner’s .env would need a new profile value, a behaviour change for every existing install |
| D4 | Membership in participant mode is requested by ingest itself at start, from a consent record the installer writes to .env (NETWORK_CONSENT), after showing the same consent list the dashboard shows | An API call during install: participant mode has no API. A flag alone without a record: no proof of what was agreed to, and no versioning of the consent text |
| D5 | Hub defaults ship inside the signed images (netproto package data) and are mirrored for host scripts. An explicit .env value always wins | A defaults file read only by compose: compose cannot fall back from an empty variable to a file, and a host file is not covered by the image signatures |
| D6 | Hub key rollover by release: nodes pin a set of up to three keys; the hub signs each answer with its primary key and, during a transition, also with an alternate key in a second header. Old nodes read only the first header | In-band key announcement signed by the old key: a stolen old key (the usual reason to rotate) could announce the thief’s key. Nodes asking the hub for its key on /v1/info: trust on first use, no protection against a fake hub |
| D7 | Auto-update runs on the host from the user’s crontab (systemd user timer as a fallback), calling scripts/auto-update.sh, which verifies a signed release manifest (cosign keyless, same identity as the images) before git checkout, then runs the existing update.sh with automatic rollback | An updater container with the Docker socket: root on the host from a container, and it needs an internet route, which breaks “one service with a route out”. Watchtower-style tag following: no signature check, no backup, no rollback. A system-wide systemd timer: needs sudo (a prompt) at install; documented as an option |
| D8 | A verifier attestation never counts as a sighting. URLhaus eligibility (path B) is: ≥ 2 distinct eligible nodes reported the link, and a verifier attested malware-like content. A verifier’s own sensor may be one of the two reporters | Counting the attestation as the second node: one participant plus the verifier could then list a link that only one sensor ever saw. Requiring the verifier to be independent from the reporters: the attestation is a reproducible fact (bytes and their hash), not an opinion, and the operator vouches for the verifier; at bootstrap the operator’s node is also the busiest sensor, so excluding it would starve the queue |
| D9 | Verification uses new endpoints under /v1 with protocol spn/1. No existing request or answer changes | Adding fields to /v1/targets or /v1/info: nodes parse answers strictly (extra="forbid"), so every pre-M32 node would fail. A new protocol spn/2: far too heavy for an additive feature |
| D10 | Clear links revealed for verification live in verify_tasks, not in targets.value; a candidate target stays blinded | Writing the value into the candidate target: it would reach /v1/targets the moment the target is confirmed, before the normal reveal rules, and widen what a hub seizure gives away |
| D11 | Participant mode is not anonymous toward the hub when no VPN is set: the hub’s TCP stack (and a tunnel provider in front of it) sees the home address. Stated in the consent text, the docs and status.sh; a VPN remains one command away | Refusing to run without a VPN: the owner decided participants must not need one. Hiding the fact: dishonest |
3. Threat model delta
Section titled “3. Threat model delta”M29 §3, M30 §3 and the M21 invariants hold unless amended here.
3.1 New or changed assets
Section titled “3.1 New or changed assets”| Asset | Must not be harmed or learned |
|---|---|
| Participant’s home address | Learned by anyone except the hub operator’s TCP stack (no-VPN participants); never stored or logged by the hub |
| The verifier’s VPN exit and fetch timing | Linked to the operator’s identity or home address |
| The verifier as a tool | Turned into a fetcher of arbitrary URLs (SSRF, confused deputy, DoS amplifier, canary oracle) |
| Participants’ hosts | Taken over through the update channel |
| URLhaus accuracy | A benign URL submitted because of Sybil reporters or a bad attestation |
3.2 New adversary moves
Section titled “3.2 New adversary moves”| # | Move | Mitigation | Residual |
|---|---|---|---|
| M1 | A3 (curious hub) or the hub’s tunnel provider records the home address of a no-VPN participant and links it to the node key, the pseudonym and the observation sets | H1 unchanged: no client address stored or logged, rate limiting on an in-memory HMAC. Consent text and status.sh say it plainly. ./scripts/participant.sh vpn <file> switches to the VPN path at any time | Policy, not technique. A hostile or compromised hub operator, or the tunnel provider, can link a no-VPN participant to its home address. The sensor address is the home address, already known to every attacker who hits it. This is the price of no VPN, accepted by the owner |
| M2 | A5 (network observer, the participant’s ISP) sees connections to suckerpot-hub.selfhostr.io | TLS. Padding (4 KiB). One batch per day | Participation itself is visible to the ISP |
| M3 | Compromised ingest (a parser bug exploited by attack data) uses the new route | hubproxy allows CONNECT to the allow-listed names on 443 only; no plain HTTP, no IP literal, no other port; answers that resolve to non-global addresses are refused (no rebinding into the LAN). Sensors are not on any network with hubproxy. The host firewall rule (optional) drops everything from sp-hubgate but TCP 443 | A compromised ingest can send data to the hub only. Before M32, a no-VPN install had no way out at all; the hub is not attacker-controlled |
| M4 | Compromised hubproxy (it parses the CONNECT line from ingest) | Only ingest can reach it (internal hub-proxy network). Non-root, read-only, cap_drop: ALL, 48 MiB, 32 pids, no volume, no secret but the proxy password | An attacker who owns ingest and then hubproxy has a general route out of the host’s address. Two exploits in a row, both in small code we own; fuzz tests on the parser |
| M5 | Sybil participants (two fake nodes, aged 14 days) steer the verifier to fetch a URL of their choice | Reporters must be eligible (age 14 days, 7 active days with data, reputation ≥ 0.3). The hub validates every revealed URL (http/https, public host, no IP in private, reserved or documentation ranges, no local or numeric names, never-list, M16 shared hosts, CDN/Tor exclusions, length ≤ 1024). Caps: HUB_VERIFY_DAILY_CAP (200) tasks a day for the whole network, HUB_VERIFY_HOST_DAILY_CAP (5) per host a day, one fetch per URL ever (two attempts at most). The verifier’s collector applies all M21 guards again (SSRF validator on every hop, gluetun rebinding protection, size, time and quota caps, generic client identity, GET only, no cookies, no credentials) and never follows links found inside a verification sample (depth 0). Review mode (HUB_VERIFY_MODE=review, the default once enabled): the operator sees each URL before any verifier gets it | In auto mode two aged Sybils can make the verifier send up to 5 GET requests a day to any public host they choose. Effects: the host learns the verifier’s VPN exit; a site with state-changing GET links could be poked (confused deputy); no amplification beyond the caps. Review mode removes this until the operator trusts the flow |
| M6 | A1 probe-response with a lower threshold: the attacker sends one unique URL to a set S of suspected sensors and watches its server; a verifier fetch proves ≥ 2 nodes in S | Tasks are released after a random delay (HUB_VERIFY_DELAY_H, uniform 6 to 30 h after the reveal). The reveal itself needs two eligible reporters, and each node submits a day late (N5). One URL yields one bit | Group testing with k = 2 instead of 5 and a feedback loop of days. As in M29, it can show that an address runs a sensor (already visible to anyone who connects to it), never which pseudonym. Stated in docs/network.md |
| M7 | Early reveal leaks more clear links to the hub (and to A4 on seizure): links seen by 2 nodes instead of k | Only links reported by ≥ 2 eligible nodes, only from nodes that opted in (verify_reveals in the consent), each reveal guarded on the node (self address, owner names). Clear links are deleted with the task (14 days after its end). A per-target canary is seen by one node and is never revealed | A seized hub holds clear links of the last 14 days that 2 or more nodes saw. These are attacker-chosen download links, not node data |
| M8 | A malicious participant fakes an attestation | Only nodes with the verifier role (operator grant) can lease tasks; results are accepted only for tasks leased to that node; one result per task and node | None |
| M9 | A compromised verifier lies (malware-like attestation for a benign URL) | Two eligible reporters are still required. Path-B submissions in auto need the two reporters to span two enrolment cohorts and the network to have HUB_AUTO_MIN_COHORTS; otherwise they wait for review. hub.admin node role revoke and hub.admin abusech cancel | Today the operator is the only verifier, so this equals “the operator lies” |
| M10 | Cloaking: the URL serves malware to the verifier and an innocent page to others, or the reverse | A submission states what the verifier fetched. Serving malware to anyone makes the URL a malware URL | Cloaked malware that hides from the verifier is missed (no false positive) |
| M11 | Release-channel attacker (stolen GitHub account, poisoned workflow) pushes a malicious release to every auto-updating participant | The manifest is signed keyless by the release workflow identity on a refs/tags/v* ref; the tag’s commit must equal the signed commit; images must verify (M28); a release is installed automatically only after it is auto_min_age_h (48) hours old; never across a major version; off with one command | A compromise of the project’s GitHub identity is a compromise of every auto-updating participant. The 48-hour window gives time to delete a bad release. The same holds for any auto-updating software; stated in docs/updates.md and at install |
| M12 | A fake hub, or a key-rollover trick | Pinned key set from the signed release (D6); explicit .env keys win; no in-band key learning | A malicious release can change the key (same as M11) |
| M13 | Self-address auto-confirmation is steered (an attacker plants Host: headers naming an address) | Learning already needs ≥ 3 distinct sources from ≥ 2 networks in a week and refuses addresses that attacked the node or that reports name. Auto-confirmation is on only in participant mode | Over-redaction only: the planted address is never shared by that node. It cannot make the guard pass a real self address |
| M14 | A participant has no GeoIP/ASN database (no internet), so research scanners are not tagged locally | The hub never-list holds scanner ranges (M29 §8.6); consensus and the M30 rules never list scan/connect | Scanner sources reach the hub as observations; they never become reportable |
3.3 Invariant changes
Section titled “3.3 Invariant changes”| # | Invariant (new or amended) | Enforced by |
|---|---|---|
| I1′ | No container has a route to the internet except vpn (profile egress) or hubproxy (profile direct), never both, plus the inbound-only sp-ingress and sp-dashboard. | Compose + guardrails (§11.1) |
| I7 | hubproxy opens only TCP connections to names in HUBPROXY_ALLOW (at most 2, port 443, public DNS names, resolved to global unicast), and only after a CONNECT from an authenticated client. | hubproxy code + tests + guardrails |
| N1′ | Nothing is sent unless NETWORK_ENABLED=true and membership was requested by an admin in the UI (full mode) or by ingest from a valid NETWORK_CONSENT record (participant mode). | Worker + tests |
| N2′ | Every network call goes through suckerpot_egress: the VPN proxy (gate open), or hubproxy (gate open in direct mode, request host in scope). | Gate scope + static egress test |
| N16 | In direct mode every egress worker other than the network and feed workers sees a closed gate. The collector and analyzer never run. | Gate + compose profiles + tests |
| N17 | A node reveals a link early (before consensus) only if its consent lists verify_reveals, only for hashes the hub lists in /v1/verify/reveals, only values it holds, and only after the identity guard. | Worker + tests |
| N18 | A node fetches for the network only with NETWORK_VERIFIER=true, VPN egress (gate open in VPN mode), the collector enabled, and within NETWORK_VERIFY_DAILY_CAP / NETWORK_VERIFY_HOST_DAILY_CAP. Verification fetches never recurse (depth 0). | Worker + collector + tests |
| N19 | The node accepts a hub answer only if one of its signature headers verifies under one of the pinned keys (≤ 3). | netproto + tests |
| N20 | Auto-update installs only a release whose signed manifest names the tag’s commit, whose images verify, that is old enough, and within the same major version; a failed health check rolls back. | auto-update.sh + tests |
| H8 | The hub hands verification tasks only to nodes with the verifier role, and creates them only from links that ≥ HUB_VERIFY_MIN_REPORTERS (2) eligible nodes reported and that pass the URL checks and caps. | hub/verify.py + tests |
| H9 | Clear links held for verification are deleted 14 days after the task ends. A task row holds no node id except the current lease, cleared when the lease ends. | Retention + tests |
4. Compose design
Section titled “4. Compose design”4.1 Services per mode
Section titled “4.1 Services per mode”| Service | Full (today) | Participant, no VPN | Participant with VPN |
|---|---|---|---|
redis, db, migrate, backup, ingest | yes | yes | yes |
| sensors (chosen) | yes | yes | yes |
api (dashboard) | yes | no | no |
vpn | profile egress | no | yes (profile egress) |
collector, analyzer | profile egress (collector idle unless COLLECTOR_ENABLED) | no | yes, idle unless COLLECTOR_ENABLED=true (required for a verifier) |
hubproxy | no | yes (profile direct) | no |
demo | profile tools | never | never |
.env values written by the installer:
| Variable | Full | Participant, no VPN | Participant with VPN |
|---|---|---|---|
SUCKERPOT_STACK_MODE | full (absent = full) | participant | participant |
COMPOSE_PROFILES | egress | direct | egress |
COMPOSE_FILE | docker-compose.yml[:docker-compose.lowmem.yml] | docker-compose.yml:docker-compose.lowmem.yml:docker-compose.participant.yml (lowmem dropped only above 6 GiB) | same |
EGRESS_MODE | vpn (absent = vpn) | direct | vpn |
EGRESS_PROXY_URL / EGRESS_CONTROL_URL | http://vpn:8888 / http://vpn:8000 | http://hubproxy:8888 / http://hubproxy:8000 | http://vpn:8888 / http://vpn:8000 |
HUBPROXY_ALLOW | — | suckerpot-hub.selfhostr.io:443 (plus suckerpot-feeds.selfhostr.io:443 with feeds on) | — |
4.2 hubproxy (new service, base file, profile direct)
Section titled “4.2 hubproxy (new service, base file, profile direct)”hubproxy: profiles: [direct] image: ${SUCKERPOT_IMAGE_PREFIX:-local/${COMPOSE_PROJECT_NAME:-unset}}-hubproxy:${SUCKERPOT_VERSION:-dev} build: {context: ., dockerfile: hubproxy/Dockerfile, args: {VERSION: ..., BUILD_KIND: source, COMMIT: ...}} <<: *hardening # read_only, cap_drop ALL, no-new-privileges, logging user: "10001:10001" environment: HUBPROXY_ALLOW: ${HUBPROXY_ALLOW:?HUBPROXY_ALLOW must be set in .env} HUBPROXY_PASSWORD: ${EGRESS_PROXY_PASSWORD:?} # same secret ingest already holds HUBPROXY_MAX_CONNS: "8" tmpfs: ["/tmp:rw,noexec,nosuid,nodev,size=1m"] networks: hub-uplink: {gw_priority: 100} hub-proxy: {} mem_limit: 48m memswap_limit: 48m cpus: 0.25 pids_limit: 32 restart: unless-stopped healthcheck: {test: ["CMD", "python", "-m", "hubproxy.healthcheck"], interval: 15s, timeout: 3s, retries: 3} # no ports, no volumes, Docker's embedded DNS (needed to resolve the allowed names)Behaviour (hubproxy/hubproxy/, standard library asyncio only, about 300 lines):
| Rule | Detail |
|---|---|
| Listeners | :8888 proxy, :8000 control (GET /status only), both on hub-proxy |
| Accepted request | CONNECT host:443 HTTP/1.1 with Proxy-Authorization: Basic egress:<password> (constant-time compare). Request line ≤ 512 bytes, headers ≤ 4 KiB, 5 s to send them |
| Host check | Exact, lower-cased match against HUBPROXY_ALLOW entries. Not an IP literal. Port 443 only |
| Resolution | getaddrinfo in the proxy; every answer must be global unicast (not private, loopback, link-local, CGNAT, reserved, documentation, multicast); connect to the first good one, IPv4 only (the networks have no IPv6) |
| Answers | 200 Connection established, or 403 (host/port), 407 (auth), 405 (any method but CONNECT), 502 (resolution or connect failed), 503 (over HUBPROXY_MAX_CONNS). Constant bodies |
| Tunnel | Byte relay, idle timeout 60 s, total 10 min, no TLS termination, no inspection |
| Logging | Counters only (allowed, refused_host, refused_auth, …). Never an address, a host name from a refused request, or a header |
| Startup | Refuses to start if HUBPROXY_ALLOW is empty, has more than 2 entries, a non-443 port, an IP literal, a single-label or local-suffix name |
| Control | GET /status → {"mode": "direct", "allow": ["suckerpot-hub.selfhostr.io:443"], "ok": true} |
4.3 Networks (base file)
Section titled “4.3 Networks (base file)”| Network | Docker name | internal | Members | Notes |
|---|---|---|---|---|
hub-uplink | sp-hubgate (fixed bridge name) | no | hubproxy only | enable_icc: false, masquerade on, no IPv6 |
hub-proxy | sp-hubproxy (bridge name) | yes | hubproxy, ingest (participant overlay) | Like egress-proxy for vpn |
Host firewall (scripts/host/firewall.nft, optional, the owner’s rule set unchanged otherwise):
from iifname "sp-hubgate", accept tcp dport 443 ct state new, accept established/related,
drop the rest. docs/install.md tells participants how to apply it; without it, hubproxy itself
is the control.
4.4 Participant overlay docker-compose.participant.yml
Section titled “4.4 Participant overlay docker-compose.participant.yml”# Participant mode (M32). Structural changes only: no port, capability or volume change.services: api: profiles: [dashboard] # a profile no installer ever sets: api never starts vpn: depends_on: !reset {} # vpn used to wait for api (vpn-config owner); see below ingest: networks: [backend, egress-proxy, hub-proxy] backup: mem_limit: 96m memswap_limit: 96m- The
vpn-configvolume owner was fixed byapistarting first. In participant modevpnreads WireGuard settings from.envonly (the M28 installer path), so the volume stays empty; the overlay keeps it mounted read-only andscripts/ensure-volumes.shcreates it with uid 10001. - Ingest environment additions live in the base file (defaults keep full mode unchanged):
SUCKERPOT_STACK_MODE(full),EGRESS_MODE(vpn),NETWORK_CONSENT(empty),NETWORK_PSEUDONYM(empty),NETWORK_COUNTRY(empty),NETWORK_SHOW_COUNTRY(false),NETWORK_VERIFY_REVEALS(false),NETWORK_VERIFIER(false),NETWORK_VERIFY_DAILY_CAP(100),NETWORK_VERIFY_HOST_DAILY_CAP(3),SELF_TERMS_AUTO_CONFIRM(false),SELF_ADDRESS(empty). - Collector environment:
COLLECTOR_VERIFY_ENABLED(false, set totrueby ingest’s verifier wiring only through.env).
4.5 Memory budget (participant, 6 recommended sensors, lowmem + participant overlays)
Section titled “4.5 Memory budget (participant, 6 recommended sensors, lowmem + participant overlays)”| Service | Limit | Typical use after a day |
|---|---|---|
db | 512 MiB | 150 to 250 MiB |
redis | 192 MiB | 20 to 40 MiB |
ingest | 320 MiB | 150 to 200 MiB (most workers off) |
backup | 96 MiB | 10 MiB (idle shell) |
hubproxy | 48 MiB | 15 to 20 MiB |
| 6 sensors × 128 MiB | 768 MiB | 40 to 60 MiB each |
migrate | 256 MiB | one-shot, exits |
| Steady total | 1,936 MiB of limits | about 0.7 to 1.0 GiB |
With a VPN: vpn 256 MiB replaces hubproxy (+208 MiB), collector 192 MiB and analyzer
256 MiB only when COLLECTOR_ENABLED=true. The installer turns low memory on below 6 GiB without
asking in participant mode, and sets DB_MAX_SIZE_GB=4. The 2 GiB floor of M28 stays. Swap is
recommended on 2 GiB hosts (docs/install.md).
5. Installer UX
Section titled “5. Installer UX”5.1 Flags (additions to scripts/install.sh)
Section titled “5.1 Flags (additions to scripts/install.sh)”| Flag | Meaning |
|---|---|
--participant | Participant mode. Implies --images (unless --source), --sensors recommended, --lowmem auto (no prompt), auto-update on, feeds off |
--full | Full mode. On a fresh host, the same as today’s default. On an existing participant install, switches to full (§5.5) |
--yes | No prompt at all. With --participant, this is the consent (documented) |
--pseudonym NAME | Use this pseudonym (M29 rules, checked locally first). Default: random |
--no-pseudonym | No pseudonym: shown as Node 3f9a1c07 |
--country CC / --show-country | Optional, never detected; hidden by default (M29 k-rules apply) |
--wg-conf PATH | Use a VPN (participant with VPN: profile egress, EGRESS_MODE=vpn) |
--verifier | Ask to be a verifier: needs --wg-conf; turns on COLLECTOR_ENABLED and NETWORK_VERIFIER. The hub operator must still grant the role |
--no-auto-update | Do not schedule automatic updates |
--feeds | Also fetch the public feed (adds suckerpot-feeds.selfhostr.io:443 to HUBPROXY_ALLOW). Off by default: a participant has no dashboard to show it |
--self-address ADDR | Optional own public address (writes SELF_ADDRESS and INDICATORS_IGNORE). Otherwise learned from traffic and auto-confirmed (§8.4) |
--repair | Existing install: re-run volumes, start and schedule steps without changing settings |
Existing flags keep their meaning. In participant mode --dashboard-*, --public-host,
--admin-* and --allow-lan are refused (these flags need --full).
5.2 Steps in participant mode
Section titled “5.2 Steps in participant mode”Same ten-step frame as M28. Differences:
| Step | Participant behaviour |
|---|---|
| 1. Preflight | As today; adds crontab or systemctl --user detection (warning only) |
| 2. Settings | No question. With a terminal and no --yes: the consent screen (§5.3) and one [y/N] |
| 3. Ports | As today; with port 22 busy the SSH sensor maps to host port 2222 without asking (as --yes does today). Admin SSH port detected, never forwarded |
| 4. Secrets | As today (all SECRET_VARS, including SETTINGS_ENCRYPTION_KEY for the node key). SESSION_SECRET is generated but unused |
| 5. Admin hash | Skipped. Image verification of ingest replaces the api pre-pull |
6. .env | §4.1 values, NETWORK_ENABLED=true, hub defaults not copied (ingest reads the baked defaults; .env holds no hub URL or key unless given), NETWORK_CONSENT, pseudonym, SELF_TERMS_AUTO_CONFIRM=true, NETWORK_VERIFY_REVEALS=true, DB_MAX_SIZE_GB=4, AUTO_UPDATE_ENABLED=true |
| 7. Volumes | As today (vpn-config included) |
| 8. Guards | check-ports.sh; check-vpn-env.sh only with a VPN; a new check-participant.sh (allow list well-formed, profiles consistent, api off) |
| 9. Start | Verify images (adds hubproxy), docker compose up -d --wait. Health = every started service healthy (no /healthz call) |
| 10. Done | Ports, status command, auto-update schedule (§5.4) |
NETWORK_CONSENT format: v1:sources,payloads,c2,files,rechecks,verify_reveals:2026-11-03
(version, the M29 consent list plus verify_reveals, UTC day). Ingest refuses to auto-join on a
consent version it does not know (consent_outdated in status) and never asks again by itself:
a newer release that widens sharing ships a new version, and the participant re-runs
./scripts/participant.sh join to agree.
5.3 Consent screen (printed before the only prompt)
Section titled “5.3 Consent screen (printed before the only prompt)”SuckerPot participant mode
This machine will run honeypot sensors and share what they see with the SuckerPot network: - addresses that attacked your sensors, with the services they used, what they did and rounded counts, once a day and a few hours late; - download links attackers tried, as hashes; the clear link goes to the hub only when two or more sensors saw the same link, so a verifier can check it; - malware hashes and command-and-control addresses (only with a VPN and the collector); - takedown checks for addresses the network reported.Never shared: passwords, commands, your ports, your own address or names in any payload.
Without a VPN, the hub sees this machine's public address when it connects. It stores and logsnone, but it is not hidden. Add a VPN at any time: ./scripts/participant.sh vpn <file.conf>Updates: signed releases install themselves once a day (turn off: --no-auto-update).Leave at any time: ./scripts/participant.sh leave
Pseudonym: amber-falcon-42 (change: --pseudonym NAME, none: --no-pseudonym)
Join the SuckerPot network as described? [y/N]5.4 Final output (example)
Section titled “5.4 Final output (example)”[10/10] Done
SuckerPot is running in participant mode (v0.6.0). Pseudonym: amber-falcon-42 Hub: suckerpot-hub.selfhostr.io Path: direct (no VPN)
Open these ports on your router or firewall, all to this machine: Router port -> This machine Protocol Sensor 22 -> 2222 TCP ssh 23 -> 23 TCP telnet 80 -> 80 TCP web 443 -> 443 TCP web 6379 -> 6379 TCP redis 2375 -> 2375 TCP docker 5555 -> 5555 TCP adbNever forward your admin SSH port (22 on this machine).
Check it any time: ./scripts/status.shAutomatic updates: daily at 04:37 local time (off: ./scripts/participant.sh auto-update off)The network counts your sensor from 2026-11-17 (14 days after joining).Take a snapshot of this machine now if you can.The port table comes from sensor_ports (scripts/lib/env.sh) and the overrides; the admin SSH
line appears only when one was detected. Port 22222 and any dashboard port are never printed.
5.5 Idempotency and switching modes
Section titled “5.5 Idempotency and switching modes”| Situation | Result |
|---|---|
No .env | Fresh install in the requested mode (default --full) |
.env exists, same mode, no other flag | Prints already installed in <mode> mode, runs ./scripts/status.sh, exit 0. Nothing changes |
.env exists, same mode, --repair | Steps 7 to 10 only; secrets and settings untouched |
Participant .env, --full | Switch to full (below) |
Full .env, --participant | Refused in v1: switching a full install to participant mode would remove the dashboard; not supported |
Switch participant → full (./scripts/install.sh --full):
- Asks the dashboard questions of today (bind, port or public host, admin user and password;
--yesand--admin-password-stdinwork as today). - Rewrites only these
.envlines:SUCKERPOT_STACK_MODE=full,COMPOSE_FILEwithout the participant overlay,COMPOSE_PROFILES=egress,EGRESS_MODE=vpn, theEGRESS_*_URLlines,HUBPROXY_ALLOWremoved,SELF_TERMS_AUTO_CONFIRM=false, the dashboard and admin lines added. Secrets, volume prefix, sensors, ports, network lines and the auto-update choice are kept (one question: keep automatic updates? default yes). - Takes a backup (
backup-service.sh now preswitch), thendocker compose up -d --wait --remove-orphans(hubproxygoes away,apiandvpnstart). - Membership stays. Without a VPN file the gate is closed and the worker waits (
egress_closed) until a VPN is uploaded in Settings, exactly as in full mode today. The dashboard’s Network page shows the membership and the leave button.
5.6 scripts/participant.sh
Section titled “5.6 scripts/participant.sh”| Command | What it does |
|---|---|
status | Same as ./scripts/status.sh |
leave | docker compose exec -T ingest python -m ingest.network.cli leave, waits up to 2 min for state=off, then NETWORK_ENABLED=false in .env and restarts ingest. Sensors keep running |
join [--pseudonym NAME] | Shows the consent screen again, writes a fresh NETWORK_CONSENT, NETWORK_ENABLED=true, restarts ingest |
vpn FILE / vpn --remove | Writes or clears VPN_WG_*, switches COMPOSE_PROFILES, EGRESS_MODE and the proxy URLs, docker compose up -d --remove-orphans --wait |
auto-update on|off | Installs or removes the schedule; writes AUTO_UPDATE_ENABLED |
uninstall | leave, then docker compose down, removes the schedule, and prints the volume removal command (never runs it: deleting data is the user’s call) |
6. Protocol changes (spn/1, additive)
Section titled “6. Protocol changes (spn/1, additive)”6.1 Versioning
Section titled “6.1 Versioning”- Protocol string stays
spn/1, prefix/v1. No existing endpoint, request or answer changes, so every pre-M32 node keeps working unchanged (they never call the new endpoints; old answers are byte-identical, checked by a golden test). - New endpoints answer
404(unsigned) on a pre-M32 hub. A node treats that ashub_no_verifyand retries once a day. - New error code
not_verifier(403, signed). Old nodes never receive it. GET /v1/infois not changed (strict parsing on old nodes).
6.2 New endpoints
Section titled “6.2 New endpoints”| Method | Path | Signed | Who | Body and answer |
|---|---|---|---|---|
| GET | /v1/verify/reveals | yes | any member | → {"items": [{"kind": "url", "value_b": "<64 hex>"}]} (≤ 200). Only links this node reported in the last HUB_VERIFY_URL_MAX_AGE_DAYS (3) days that have an open task in waiting_reveal |
| GET | /v1/verify/tasks?limit=<1..20> | yes | verifier role | → {"tasks": [{"id": "v_0123456789ab", "url": "http://198.51.100.9/x.sh", "lease_until": "2026-11-03T18:00:00Z"}]}. Leases up to limit queued tasks for HUB_VERIFY_LEASE_HOURS (6). Not a verifier → not_verifier |
| POST | /v1/verify/results | yes | verifier role | {"protocol": "spn/1", "items": [Result]} (≤ 50) → {"accepted": n, "rejected": n} |
The early reveal uses the existing POST /v1/reveals with the same body. The hub accepts a reveal
when it matches a confirmed target (today) or a waiting_reveal task whose url_b the caller
reported; in the second case the clear value goes to verify_tasks.url only (D10).
Result:
{"id": "v_0123456789ab", "outcome": "fetched", "sha256": "<64 hex>", "size": 48213, "file_type": "elf-arm", "family": "mirai"}| Field | Rule |
|---|---|
id | ^v_[0-9a-f]{12}$, a task leased to the caller and not yet answered by it |
outcome | fetched, unreachable, http_error, too_large, timeout, refused (the node’s own guards said no), skipped (local cap) |
sha256, size, file_type, family | Only with fetched: sha256 hex; size 1 to 67,108,864; file_type in FILE_TYPES_V1; family in FAMILIES_V1 or null |
All bodies: padding, unknown fields refused, depth ≤ 6, 64 KiB cap, as every spn/1 request.
6.3 Limits
Section titled “6.3 Limits”| Limit | Default |
|---|---|
| Signed requests per node | 60/hour (unchanged). A participant adds 4/h (/v1/verify/reveals every 15 min); a verifier adds 8/h |
| Tasks per lease call | 20 |
| Results per call | 50 |
| Reveals per node per day | 2,000 (unchanged; early reveals count) |
6.4 Hub key set and dual signatures
Section titled “6.4 Hub key set and dual signatures”- The hub signs every answer with its primary key in
X-SPN-Hub-Signature(unchanged) and, whenHUB_SIGNING_KEY_ALT_FILEis set, with the alternate key inX-SPN-Hub-Signature-Alt, over the same canonical answer string. - A node pins a set of 1 to 3 keys (
NETWORK_HUB_KEYaccepts a comma-separated list). It accepts an answer when either header verifies under any pinned key. Pre-M32 nodes read only the first header and pin one key. GET /v1/infokeepshub_key= primary. A node checks it is in its pinned set (today: equal).
Rollover runbook (hub/README.md):
| Phase | Hub | Release | Who works |
|---|---|---|---|
| 0 | Primary K1 | Defaults [K1] | Everyone |
| 1 | hub.admin gen-signing-key makes K2; set as alternate. Answers carry K1 and K2 | Next release ships [K1, K2] | Everyone |
| 2 (≥ 30 days after the release; auto-updaters follow within about 3 days) | Swap: primary K2, alternate K1 | — | Nodes with [K1, K2]; pre-M32 nodes and nodes still pinning only K1 get hub_signature and stop (fail closed) |
| 3 (≥ 90 days) | Drop K1 | Release ships [K2] | Updated nodes |
- Host move without a key change (copy the key file): only DNS changes; no rollover needed. This is the recommended way to move the hub.
- Emergency (K1 stolen): swap at once and publish a release with
auto_min_age_h: 0in its manifest (§9.2). Nodes on the stolen key fail closed until they update. - An explicit
.envkey overrides the defaults (the owner’s install, forks).status.shand the dashboard warn when the pinned set does not contain the release default (hub_key_differs_from_release). - The feed key works the same way:
NETWORK_FEED_KEYaccepts a list; M30 already signs with the next key during a transition (.next.minisig).
6.5 Committed defaults
Section titled “6.5 Committed defaults”netproto/suckerpot_netproto/data/network_defaults.env (package data, so inside every signed
image), mirrored byte for byte at config/network-defaults.env for host scripts (a test checks
they are equal):
# Public defaults of the SuckerPot network. Shipped with each release; do not edit.# An explicit value in .env always wins.NETWORK_DEFAULTS_VERSION=1NETWORK_HUB_URL=https://suckerpot-hub.selfhostr.ioNETWORK_HUB_KEY=<HUB_PUBLIC_KEY>NETWORK_FEED_URL=https://suckerpot-feeds.selfhostr.io/v1NETWORK_FEED_KEY=<FEED_PUBLIC_KEY>suckerpot_netproto.defaults.load()parses it strictly (known keys only, the same validation asNetworkSettings.from_env), andNetworkSettings/ the feed settings use a default only when the environment value is empty.- A placeholder value (
<...>) counts as “not configured”: the network cannot be joined andstatus.shsays so. The release workflow refuses to publish a release whose defaults hold a placeholder. - Full mode: the defaults only pre-fill the hub;
NETWORK_ENABLEDand the dashboard opt-in still decide. The owner’s.env, if it sets the hub, is unaffected.
7. Hub changes
Section titled “7. Hub changes”7.1 Migration h0009_verify
Section titled “7.1 Migration h0009_verify”node_roles(node_id uuid REFERENCES nodes ON DELETE CASCADE, role text CHECK (role IN ('verifier')), granted_day date NOT NULL, PRIMARY KEY (node_id, role))verify_tasks(id text PRIMARY KEY, -- 'v_' + 6 random bytes hex url_b text NOT NULL UNIQUE, url text NULL, -- clear link, set by a reveal; nulled at retention host text NULL, -- lower-cased host of url, for the per-host cap state text NOT NULL CHECK (state IN ('waiting_reveal','pending_review','queued','leased', 'attested','failed','refused','expired')), refusal text NULL, -- constant code (scheme, private, never_list, shared_host, excluded, host_cap, daily_cap, operator) reporters int NOT NULL, reporter_cohorts int NOT NULL, -- counts only, at last evaluation last_report_day date NOT NULL, created_day date NOT NULL, revealed_day date NULL, release_at timestamptz NULL, -- random delay (M6) lease_node uuid NULL REFERENCES nodes ON DELETE SET NULL, lease_until timestamptz NULL, attempts smallint NOT NULL DEFAULT 0, ended_day date NULL)CREATE INDEX ON verify_tasks (state);CREATE INDEX ON verify_tasks (host, revealed_day);verify_attestations(task_id text REFERENCES verify_tasks ON DELETE CASCADE, node_id uuid REFERENCES nodes ON DELETE CASCADE, day date NOT NULL, outcome text NOT NULL, sha256 text NULL, size int NULL, file_type text NULL, family text NULL, malware_like bool NOT NULL DEFAULT false, PRIMARY KEY (task_id, node_id))verify_daily(day date PRIMARY KEY, created int, revealed int, refused int, queued int, attested int, malware_like int, expired int)ALTER TABLE abusech_submissions ADD COLUMN basis text NOT NULL DEFAULT 'consensus' CHECK (basis IN ('consensus','verified')), ADD COLUMN verify_task_id text NULL;- No
inetcolumn, no client address (the M29 privacy test stays strict). - The only node link in a task is the live lease (
lease_node), cleared when the lease ends. Reporters are counted, never stored per task; theurlcandidate target’starget_contributors(≤ 30 days, M29 H3) already holds who reported it, for reputation. - Roles:
hub_apigets SELECT onnode_roles, SELECT/UPDATE onverify_tasks(reveal, lease), INSERT onverify_attestations;hub_workergets the rest.
7.2 Worker job hub/hub/verify.py (hourly, after consensus)
Section titled “7.2 Worker job hub/hub/verify.py (hourly, after consensus)”- Off unless
HUB_VERIFY_MODEisrevieworauto(defaultoff: no task, no early reveal). - Create. For each
url_bobserved in the lastHUB_VERIFY_URL_MAX_AGE_DAYS(3) days by ≥HUB_VERIFY_MIN_REPORTERS(2) distinct eligible nodes, with no task yet and noabusech_submissionsrow for its clear value: insertwaiting_revealwith the counts. - On reveal (in the route, then validated here):
abusech.url_refusal, never-list, M16 shared hosts (suffix match), the M30 exclusions (allow-list, delisting, CDN, Tor; stale ranges → wait), length; then the per-host cap (HUB_VERIFY_HOST_DAILY_CAP, 5 per host per day) and the network cap (HUB_VERIFY_DAILY_CAP, 200 per day). Fail →refusedwith a code. Pass →pending_review(review) orqueuedwithrelease_at = now + uniform(6, 30) h(auto). - Leases. An expired lease returns the task to
queued(attempts + 1); afterHUB_VERIFY_MAX_ATTEMPTS(2) →failed. - Expiry.
waiting_reveal,pending_reviewandqueuedtasks whoselast_report_dayis older than 3 days →expired(stale links are rarely alive; one-shot links are mostly missed). - Own fetches count. A verifier node’s own batch row for the same
url_bwithfetched: trueand malware-like content (malware.is_malware_like) is recorded as its attestation; the task goes toattestedwithout a lease. verify_dailycounters. Retention: tasks and attestations deleted 14 days afterended_day;verify_dailykept.
7.3 abuse.ch rule change (hub/hub/abusech.py)
Section titled “7.3 abuse.ch rule change (hub/hub/abusech.py)”URLhaus candidates come from two paths:
| Path | Basis | Rule |
|---|---|---|
| A (today) | consensus | Confirmed url target with a revealed value fetched malware-like by ≥ 2 eligible nodes |
| B (new) | verified | A task in attested with at least one attestation malware_like = true from a node that still holds the verifier role, attested in the last 3 days; reporters (recomputed at queue time) ≥ 2 distinct eligible nodes; the clear URL passes every check of path A (url_refusal, never-list, exclusions) |
- Path-B rows are inserted with
basis = 'verified',verify_task_id,target_id= theurlcandidate target id (it exists: consensus keeps a candidate row for values seen by ≥ 2 eligible nodes), tags from the attestation’s family and file type (tags_for). - Approval.
HUB_ABUSECH_MODE=review: every row waits for an operator (today).auto: path-B rows are auto-approved only ifHUB_VERIFY_MODE=auto, the reporters span ≥ 2 enrolment cohorts, and the network hasHUB_AUTO_MIN_COHORTScohorts; otherwise they stay queued. - Liveness before sending (
_one): path A unchanged; path B needs the task stillattested, not refused by the operator, the verifier still holding the role, and the URL not on any exclusion. The target state check (confirmed/reported) does not apply to path B (the target is a candidate). - Withdrawals (
withdraw_target,withdraw_value) cover both paths. - Feeds and reports do not change in M32. One verifier fetch is not “fetched by 2 nodes”; using attestations for the outbound list is a later decision (§14).
7.4 Routes and auth
Section titled “7.4 Routes and auth”hub/hub/routes/verify.py: the three endpoints of §6.2.hub/hub/routes/targets.py: the reveal branch of §6.2.hub/hub/auth.py: the alternate signature header (§6.4).hub/hub/main.py: router include.
7.5 Admin CLI (python -m hub.admin, additions; each writes hub_audit)
Section titled “7.5 Admin CLI (python -m hub.admin, additions; each writes hub_audit)”node role grant|revoke <fingerprint> verifier,node roles.verify status(counts per state, caps used today),verify list [--state],verify show <id>(URL shown only inpending_review,queued,leased),verify approve <id>,verify refuse <id> --note [--abuse](--abusedisputes theurlcandidate: −0.1 to linked contributors, M29 §8.3),verify pause|resume.gen-signing-key --alt(writes the alternate key file),show-public-key --all.abusech listshowsbasis.
7.6 Configuration (hub .env.example)
Section titled “7.6 Configuration (hub .env.example)”| Variable | Default |
|---|---|
HUB_VERIFY_MODE | off (review | auto) |
HUB_VERIFY_MIN_REPORTERS | 2 (never below 2: refused at start) |
HUB_VERIFY_DAILY_CAP / HUB_VERIFY_HOST_DAILY_CAP | 200 / 5 |
HUB_VERIFY_LEASE_HOURS / HUB_VERIFY_MAX_ATTEMPTS | 6 / 2 |
HUB_VERIFY_URL_MAX_AGE_DAYS | 3 |
HUB_VERIFY_DELAY_MIN_H / _MAX_H | 6 / 30 |
HUB_SIGNING_KEY_ALT_FILE | empty |
8. Node changes
Section titled “8. Node changes”8.1 Settings (ingest)
Section titled “8.1 Settings (ingest)”| Variable | Default (full) | Participant | Meaning |
|---|---|---|---|
SUCKERPOT_STACK_MODE | full | participant | Mode |
EGRESS_MODE | vpn | direct or vpn | Gate mode (§8.2) |
NETWORK_CONSENT | empty | v1:... | Auto-join record (§5.2) |
NETWORK_PSEUDONYM / NETWORK_COUNTRY / NETWORK_SHOW_COUNTRY | empty | from flags | Used for auto-join only |
NETWORK_VERIFY_REVEALS | false | true | Answer /v1/verify/reveals (needs verify_reveals in the consent in participant mode; in full mode the env flag is the opt-in) |
NETWORK_VERIFIER | false | false (--verifier) | Lease and fetch tasks (N18) |
NETWORK_VERIFY_DAILY_CAP / _HOST_DAILY_CAP | 100 / 3 | same | Local caps, independent of the hub’s |
SELF_TERMS_AUTO_CONFIRM | false | true | Learned self addresses act without a click (§8.4) |
SELF_ADDRESS | empty | from --self-address | Upserted as a manual self term at start |
SETTABLE_VARS (just set, env-set.sh) gains NETWORK_VERIFY_REVEALS, NETWORK_VERIFIER,
AUTO_UPDATE_ENABLED; never NETWORK_CONSENT (only participant.sh join writes it).
8.2 Egress direct mode (egress/suckerpot_egress, ingest/ingest/egress.py)
Section titled “8.2 Egress direct mode (egress/suckerpot_egress, ingest/ingest/egress.py)”EgressSettings.mode:vpn|direct;scope_hosts: in direct mode, the hosts ofNETWORK_HUB_URLand (when the feed is on)NETWORK_FEED_URL, after defaults.EgressGate.check()in direct mode:GET {control_url}/statusmust answer{"mode": "direct", "ok": true, "allow": [...]}withallow⊆{h + ":443" for h in scope}; otherwise closed withdirect_allow_mismatch/no_hubproxy. No echo request (it would reach a third party from the home address), no exit-address check (there is no tunnel to check).require_open(host: str | None = None): in direct mode, raisesEgressClosed("egress_direct")unlesshostis in the scope.GateTransportpassesrequest.url.host, so every request and redirect hop is scoped.gate_open()without a host isFalsein direct mode: enrichment, intel, CVE watch, LLM, alerts, abuse mail, update check, takedown checks all idle with that code.- The network and feed workers call
gate_open(host=...). scripts/egress-check.shin direct mode checks: direct TCP from ingest fails;CONNECT example.org:443throughhubproxyis refused (403);CONNECTto an IP literal is refused;CONNECTto the hub succeeds;getaddrinfo("example.com")fails inside ingest.
8.3 Network worker additions (ingest/ingest/network/)
Section titled “8.3 Network worker additions (ingest/ingest/network/)”| Module | Role |
|---|---|
participant.py | At start and each cycle in participant mode: if NETWORK_ENABLED, consent valid, membership desired=off and state=off, and no left marker: set desired=member with the pseudonym (requested_by='installer'). A leave writes the marker (network_membership.left_day), so a restart never re-joins by itself. On pseudonym_taken, generate another random one (≤ 5 tries) instead of stopping (M29 §9.7a keeps stopping for a chosen one) |
pseudonym.py | Random adjective-noun-NN from suckerpot_netproto/data/pseudonym_words.txt (shared with the installer), checked with the M29 rules and the guard |
verify_reveal.py | Every cycle (15 min) when NETWORK_VERIFY_REVEALS: GET /v1/verify/reveals, then reveal held values through /v1/reveals after the guard (same code as M29 reveals, N17) |
verifier.py | When NETWORK_VERIFIER and the gate is open in VPN mode: lease ≤ 20 tasks, check locally (scheme, length, local never-list, caps per day and per host), insert into network_verify_tasks and fetch_targets (origin='network_verify', depth 0). Report results when the collector finished (fetch_targets.status, samples file type and family) or after 5 h (timeout). Statuses: verifier_needs_vpn, verifier_needs_collector, not_verifier (role not granted; retry daily) |
cli.py | python -m ingest.network.cli status --json | leave | join (used by host scripts; prints no key, no secret, no attacker data unless --last-batch) |
worker.py, client.py | Wiring; multi-key answer check (N19); info.hub_key in pinned |
Pull of /v1/targets stays every 6 h. Participants do answer reveal and recheck as today
(recheck answers are unknown without takedown tracking, which needs the internet).
8.4 Self address in participant mode
Section titled “8.4 Self address in participant mode”SELF_TERMS_AUTO_CONFIRM=true: a learned address (report_self_terms,source='learned') that meets the existing thresholds is storedconfirmed=trueat once. Names are not auto-confirmed (sensor-reported names already act).SELF_ADDRESS, when set, is upserted assource='manual'at ingest start.- Until a self address exists the node sends nothing (
waiting_self_address, M29 N4);status.shexplains it (“waiting to learn this machine’s address from traffic; usually within a day”).
8.5 Node migration 0030_network_participant
Section titled “8.5 Node migration 0030_network_participant”ALTER TABLE network_membership ADD COLUMN left_day date NULL;ALTER TABLE fetch_targets ADD COLUMN origin text NOT NULL DEFAULT 'local' CHECK (origin IN ('local', 'network_verify'));network_verify_tasks(hub_id text PRIMARY KEY, url text NOT NULL, host text NOT NULL, state text NOT NULL, -- received|queued|done|reported|refused|skipped fetch_target_id bigint NULL, outcome text NULL, sha256 text NULL, size int NULL, file_type text NULL, family text NULL, received_at timestamptz NOT NULL, lease_until timestamptz NOT NULL, reported_at timestamptz NULL)CREATE INDEX ON network_verify_tasks (state);Grants: suckerpot_ingest all on network_verify_tasks; suckerpot_api SELECT (the owner’s
dashboard shows counts); suckerpot_collector SELECT on fetch_targets.origin (it already reads
the table). Retention: rows deleted 30 days after reported_at; the M29 leave wipe covers the
table.
8.6 Collector (verifier only)
Section titled “8.6 Collector (verifier only)”- Rows with
origin='network_verify'are fetched like any other, with every M21 guard and the shared daily quota, but never produce child fetch targets (links inside a verification sample are not followed) and never create local indicators. COLLECTOR_VERIFY_ENABLED=falsemakes the collector skip (refused) verification rows, so the collector’s owner can stop the role without ingest.- Samples keep the M21 quarantine rules. The Malware page shows “Fetched for network verification”
on them (api field
origin).
9. Automatic updates
Section titled “9. Automatic updates”9.1 Where it runs (D7)
Section titled “9.1 Where it runs (D7)”| Option | Verdict |
|---|---|
Updater container with /var/run/docker.sock | Rejected. The socket is root on the host. The container would need an internet route (registry, GitHub), so a second service with a route out, next to sensors that are attacked all day. One bug in it and the host is gone |
| Watchtower or similar | Rejected. Follows tags, no cosign check, no database dump, no rollback, no git update of the compose file |
| System systemd timer | Allowed as a documented option; not the default because it needs sudo (a prompt) |
| User crontab of the installing user (fallback: systemd user timer when lingering is on) | Chosen. No new privilege (the user already runs docker), no new network path inside the stack, the same code path as just update, visible with crontab -l. It is host code, a narrow exception to “nothing runs on the host except Docker”, recorded in CLAUDE.md §3 by the docs task |
Schedule: one random minute between 03:00 and 05:59 local time, chosen at install
(scripts/lib/schedule.sh), plus a random sleep of 0 to 30 min, so a fleet does not update at
once. The cron line carries a marker comment (# selfhostr-auto-update <project>), so install
and removal are idempotent and never touch other lines.
9.2 Signed release manifest (.github/workflows/release.yml)
Section titled “9.2 Signed release manifest (.github/workflows/release.yml)”The release workflow adds release-manifest.json to each GitHub release:
{"schema": "selfhostr-release/1", "version": "v0.6.0", "commit": "<40 hex>", "images": {"ingest": "sha256:<64 hex>", "hubproxy": "sha256:<64 hex>"}, "network_defaults_sha256": "<64 hex>", "auto_min_age_h": 48, "major": 0}signed with cosign sign-blob --bundle release-manifest.json.sigstore.json (keyless, same
identity rule as the images: .github/workflows/release.yml@refs/tags/v*). The workflow refuses
to run if network_defaults.env holds a placeholder.
scripts/verify-release.sh vX.Y.Z (pinned cosign container, as verify-images.sh): downloads
both files from the release, cosign verify-blob --bundle with the identity, then checks
commit == git rev-parse vX.Y.Z^{commit}, version equals the tag, and the defaults hash equals
the checkout’s file. It prints the bundle’s transparency-log time (the release age). update.sh
runs it in images mode before git checkout; for a release older than v0.6.0 (no manifest) a
manual update warns and goes on, an automatic one refuses.
9.3 scripts/auto-update.sh (called by cron)
Section titled “9.3 scripts/auto-update.sh (called by cron)”flockon.suckerpot-state/update.lock; exit quietly when held.- Exit if
AUTO_UPDATE_ENABLEDis nottrue, if.suckerpot-state/last-update.jsonsays an update never completed, or if the install mode issource. git fetch --tags; target = newestvX.Y.Ztag with the same major, newer than the running version, not in.suckerpot-state/bad-versions.verify-release.sh target; refuse if the release is younger thanauto_min_age_h.update.sh --yes --auto target(backup, checkout,env-migrate, verify and pull images, up).- Health (mode-aware, in
update.sh): every started service healthy within 600 s;SUCKERPOT_RUNNING_VERSIONofingest(participant) orapi(full) equals the target;python -m ingest.network.cli status --jsonanswers. - On any failure after the code changed:
rollback.sh --yes(restores the pre-update dump and the previous commit and images; events of the last minutes are lost), add the target tobad-versions, record the outcome. No retry of that version; the next release is tried. - Write
.suckerpot-state/auto-update.json:{last_run, last_result, from, to, error_code}(constant codes).status.shreads it.
Output goes to .suckerpot-state/auto-update.log (rotated at 1 MiB, no secrets). Turning off:
./scripts/participant.sh auto-update off (removes the cron line and sets the flag). Full mode can
opt in with just set AUTO_UPDATE_ENABLED=true plus scripts/participant.sh auto-update on;
it is off by default there, so the owner’s install is unchanged.
10. Status command
Section titled “10. Status command”./scripts/status.sh [--json] [--last-batch] (also just status). It reads docker compose ps,
.env (no secret printed), the auto-update state file, and
docker compose exec -T ingest python -m ingest.network.cli status --json.
SuckerPot v0.6.0, participant mode, images OK
Sensors (6 running, all healthy) ssh router 22 -> 2222 last 24 h: 1,204 sessions telnet router 23 -> 23 last 24 h: 388 sessions web router 80, 443 last 24 h: 962 sessions ...Network Member as amber-falcon-42 (Node 3f9a1c07), standing: new Counts toward consensus from 2026-11-17 Hub contact: OK, 12 min ago (suckerpot-hub.selfhostr.io, signature verified) Path: direct (no VPN). The hub sees this machine's address. Own address: learned Sent: 2026-11-02 412 sources, 9 links (blinded), 0 files 2026-11-01 398 sources, 7 links (blinded), 0 files Links revealed for verification (7 days): 3Updates Automatic: on, next window 04:37. Last run 2026-11-03: already on v0.6.0Exit code 0 = OK, 1 = warning, 2 = error.- Warnings (exit 1): a sensor unhealthy,
waiting_self_addressfor more than 2 days, hub contact older than 26 h,consent_outdated,hub_key_differs_from_release, auto-update off or its last run failed, eligibility pending (shown, not a warning). - Errors (exit 2): ingest or db down,
hub_signature,guard_failed, stack not running. --last-batchprints the exact JSON of the last submission (network_submissions.payload, kept 7 days), which holds attacker addresses only.--jsonprints the same data as JSON for monitoring.- Full mode: the same command works (sensors, network if joined, updates); the dashboard remains the main view.
11. Tests
Section titled “11. Tests”11.1 Static guardrails (scripts/guardrails/tests/test_participant.py, test_compose.py updates)
Section titled “11.1 Static guardrails (scripts/guardrails/tests/test_participant.py, test_compose.py updates)”Render with yaml.safe_load + merge for: base; base+lowmem; base+lowmem+participant with
COMPOSE_PROFILES ∈ {direct, egress}; base+ci; base+it. Assert:
- Every network is
internal: trueexceptingress,dashboard,vpn-uplinkandhub-uplink(sp-hubgate, icc off). hub-uplinkmembers are exactly{hubproxy};hub-proxymembers ⊆{hubproxy, ingest}.hubproxy: profile exactly[direct], networks exactly{hub-uplink, hub-proxy}, noports, nocap_add, nodevices, no volumes,read_only,usernon-root, limits set.vpn,collector,analyzer: profile exactly[egress]. The profiles ofvpnandhubproxyare disjoint, and the installer never writes both (installer test).- In the participant rendering with each profile:
apiis not active; the active set is exactly{redis, db, migrate, backup, ingest, sensors…}plus{hubproxy}(direct) or{vpn, collector, analyzer}(egress);ingestnetworks are all internal; no active service but sensors hasports. HUBPROXY_ALLOWwritten by the installer (dry-run fixture) holds at most two entries, each<name>:443, and every name is the host of a URL inconfig/network-defaults.env.- The participant overlay changes nothing but
profiles,depends_on,networks(ingest only, addinghub-proxyonly) and backup limits (liketest_lowmem.py). - Mutation tests (must fail the checker):
hub-uplinkadded toingest;hubproxyonbackend;portsonhubproxy; a third allow entry;:80in the allow list;vpnwithout a profile;apidropped from the dashboard profile in the overlay butportskept; ingest on a non-internal network. - The base rendering with
COMPOSE_PROFILES=egressand no overlay equals today’s rendering except the addedhubproxyservice and two network definitions (owner unchanged; snapshot test).
11.2 Unit tests
Section titled “11.2 Unit tests”| Package | Cases |
|---|---|
hubproxy | allowed CONNECT tunnels; other host, other port, IP literal, GET/POST, missing or wrong auth refused with constant bodies; a name resolving to an RFC 1918, loopback, CGNAT or documentation address refused; header and line caps; slow-loris timeout; connection cap; bad HUBPROXY_ALLOW refused at start; no client address or refused host name in any log record; static test (no subprocess, eval, exec, no file writes); a small fuzz corpus of request lines |
egress | direct mode: gate opens only with a matching /status; gate_open() false, gate_open(host=hub) true; a request to another host raises egress_direct; a redirect hop to another host raises; extra allow entry → direct_allow_mismatch; VPN mode unchanged (all existing tests) |
netproto | defaults parsing (placeholder = unconfigured, unknown key refused); multi-key verification with either header; verify models (unknown fields, bad id, outcome without sha256, family outside the list, size bounds); golden vectors for the new bodies; the pre-M32 answer schemas byte-identical |
hub | create task only at ≥ 2 eligible reporters (ineligible, suspended or young nodes do not count); HUB_VERIFY_MODE=off creates nothing and /v1/verify/reveals answers an empty list; reveal accepted only from a reporter and only for an open task, value goes to verify_tasks.url, the target stays blinded; refusals per code; host and daily caps; random release delay; leases, expiry, max attempts; not_verifier; results only for own leased tasks, once; own fetched row counts as attestation; path B queued, auto-approved only with two cohorts and HUB_VERIFY_MODE=auto, never with a revoked verifier; path A unchanged; withdrawals; retention nulls URLs; privacy test (no client address, no node id beyond the lease) |
ingest | participant auto-join only with valid consent; never after a leave; random pseudonym retry; verify reveals only with the flag and consent, guarded; verifier refuses without VPN or collector, honours caps, depth 0; status --json holds no secret; self-term auto-confirm only in participant mode; N1′ idle tests |
collector | network_verify rows never create children or indicators; COLLECTOR_VERIFY_ENABLED=false refuses them |
scripts (test_install.py, test_update.py, test_auto_update.py, test_status.py) | dry-run participant .env (§4.1 values, no admin lines, consent format, pseudonym valid, no placeholder, no hub URL copied); --participant --dashboard-port refused; full → participant refused; participant → full keeps secrets and prefix; re-run converges; consent prompt needs a terminal without --yes; auto-update with stub git/docker/cosign: refuses unsigned, mismatched commit, too young, other major; rolls back on a failed health check and records the bad version; cron line idempotent; status exit codes |
11.3 End-to-end participant install (scripts/e2e/participant.sh, CI job participant-e2e)
Section titled “11.3 End-to-end participant install (scripts/e2e/participant.sh, CI job participant-e2e)”Never the live project. The script refuses unless COMPOSE_PROJECT_NAME and the volume prefix
both match sp-it-participant-<random>; the installer accepts sp-it-* names only with
SELFHOSTR_TEST_INSTALL=1, which also makes volumes local. The machine’s own .env is never read
or written: the test runs in a fresh clone in a temporary folder.
- Start the hub test stack (
hub/docker-compose.test.yml, Caddy withtls internal, local volumes,HUB_ALLOW_DOCUMENTATION=1,HUB_VERIFY_MODE=auto, low PoW). ./scripts/install.sh --participant --yes --sourcewith a test overlay (docker-compose.participant-it.yml) that points the defaults at the test hub, mounts its CA for ingest only, and allows its name inHUBPROXY_ALLOW.- Assert: running services are exactly the participant set;
apiabsent;status.shexit 0 or 1; enrolment visible instatus --json. - Containment: from ingest, direct TCP to
1.1.1.1:443fails;CONNECT example.org:443throughhubproxyis 403; from a sensor container,hubproxy:8888does not resolve. - A synthetic second node (the M29 driver) plus this node report the same documentation URL; the hub asks for the reveal; the node reveals; the task is queued; a synthetic verifier leases it and posts a malware-like result; a path-B submission is queued (abuse.ch stub, never the real service).
participant.sh leave→ the hub deletes the node;install.sh --full --yes --admin-password-stdin→apihealthy, data kept (session count unchanged).- Teardown removes the test project and its local volumes only.
11.4 Live checks on the owner’s node (after merge, by the orchestrator)
Section titled “11.4 Live checks on the owner’s node (after merge, by the orchestrator)”just guardrails, just egress-check (VPN mode unchanged), the verifier flow with
HUB_VERIFY_MODE=review on the real hub and the owner’s node granted the role.
12. Docs and site
Section titled “12. Docs and site”| File | Change |
|---|---|
docs/participate.md (new) | “Join the network in three commands”: requirements, the three commands, the consent in plain words, ports, status, leave, adding a VPN, auto-updates, what the hub can see without a VPN, uninstall |
docs/install.md | Two modes table (participant / full), flags, switching to full |
docs/network.md | Participants, verifiers, the early reveal, the no-VPN trade-off, the new residual risks (§3.2 M1, M5, M6, M11) |
docs/network-protocol.md | “Verification” (§6.2, §6.3), “Hub keys” (§6.4), “Defaults” (§6.5), error not_verifier, node codes hub_no_verify, consent_outdated, verifier_needs_vpn, verifier_needs_collector, egress_direct |
docs/updates.md | Automatic updates, the signed manifest, rollback, turning it off, the release-channel risk |
docs/SECURITY.md, docs/ARCHITECTURE.md | hubproxy, hub-uplink, the I1′ rule, outbound hosts table (participant row) |
docs/configuration.md | §8.1 variables, AUTO_UPDATE_ENABLED, HUBPROXY_ALLOW |
hub/README.md | Verifier role, HUB_VERIFY_MODE, review routine, key rollover runbook, host move |
CLAUDE.md | §3 table: hubproxy; §1/§3 note on the host auto-update exception |
| the product site (maintained outside this repo) | A “Help the network” section with the three commands and a link to docs/participate.md; no tracking |
README.md, CHANGELOG.md | Short participant section; v0.6.0 entry |
13. Task split
Section titled “13. Task split”Wave 0 publishes the contracts. Waves 1 and 2 run in parallel on the disjoint files listed (or in
worktrees). Placeholders only: documentation ranges, example.org, <HUB_PUBLIC_KEY>; the only
real names are suckerpot-hub.selfhostr.io and suckerpot-feeds.selfhostr.io. Never a value from a private
environment file. No live compose project, no .env, no volume.
| Task | Agent / model | Owns | Depends on | Wave |
|---|---|---|---|---|
| P0. Protocol and defaults | backend-engineer, sonnet (opus review of the key logic) | netproto/suckerpot_netproto/models.py (verify models only), sign.py (key set, alt header), defaults.py, data/network_defaults.env, data/pseudonym_words.txt, netproto/pyproject.toml (package data), config/network-defaults.env, netproto/tests/**, docs/network-protocol.md (new parts) | — | 0 |
P1. hubproxy service | backend-engineer, sonnet (opus security review) | hubproxy/** (package, Dockerfile, tests), .github/workflows/ci.yml (python job list only) | — | 1 |
| P2. Compose, overlay, guardrails, host firewall | backend-engineer, sonnet | docker-compose.yml (hubproxy service, networks, ingest and collector env lines of §4.4), docker-compose.participant.yml, docker-compose.lowmem.yml (hubproxy entry), scripts/guardrails/suckerpot_guardrails/**, scripts/guardrails/tests/test_compose.py, test_participant.py, test_lowmem.py, scripts/host/firewall.nft, scripts/verify-images.sh (IMAGES list), scripts/ensure-volumes.sh | P0 (defaults file) | 1 |
| P3. Egress direct mode | backend-engineer, sonnet | egress/suckerpot_egress/**, egress/tests/**, ingest/ingest/egress.py, scripts/egress-check.sh | — | 1 |
| P4a. Hub verification core | backend-engineer, sonnet | hub/migrations/versions/h0009_verify.py, hub/hub/verify.py, hub/hub/routes/verify.py, hub/hub/routes/targets.py (reveal branch), hub/hub/main.py, hub/hub/models.py (verify models), hub/hub/config.py, hub/hub/roles.py, hub/hub/retention.py, hub/hub/worker.py, hub/hub/auth.py (alt signature), hub/.env.example, hub/tests/test_verify*.py, hub/tests/test_auth_alt.py | P0 | 1 |
| P4b. Hub abuse.ch path B and admin | backend-engineer, sonnet | hub/hub/abusech.py, hub/hub/admin.py, hub/tests/test_abusech*.py, hub/tests/test_admin_verify.py, hub/tests/e2e/** (verify scenario) | P4a DDL | 2 |
| P5. Node participant and verifier worker | backend-engineer, sonnet | ingest/ingest/network/{participant,pseudonym,verify_reveal,verifier,cli}.py, ingest/ingest/network/{worker,client,config}.py, ingest/ingest/abuse/selfterms.py (auto-confirm, SELF_ADDRESS), ingest/ingest/main.py, ingest/ingest/features.py, ingest/tests/network/**, ingest/tests/test_selfterms*.py | P0, P3, P6 | 2 |
| P6. Node migration and collector | backend-engineer, sonnet | db/migrations/versions/0030_network_participant.py, db/suckerpot_db/models.py (three changes), db/suckerpot_db/roles.py, db/suckerpot_db/retention.py, collector/collector/** (origin rules), collector/tests/**, api/api/samples.py (origin field), api/tests/test_samples*.py | — | 1 |
| P7. Installer, status, participant helper | backend-engineer, sonnet | scripts/install.sh, scripts/status.sh, scripts/participant.sh, scripts/check-participant.sh, scripts/lib/env.sh, scripts/lib/pseudonym.sh, scripts/env-set.sh, scripts/env-migrate.sh, justfile (status recipe, hubproxy in packages), .env.example (participant section), scripts/guardrails/tests/test_install.py, test_status.py, test_scripts.py | P2 (env names), P5 (cli status --json shape, §10), P8 (schedule.sh contract) | 2 |
| P8. Auto-update and signed manifest | backend-engineer, sonnet (opus review) | scripts/update.sh, scripts/rollback.sh, scripts/auto-update.sh, scripts/verify-release.sh, scripts/lib/schedule.sh, .github/workflows/release.yml (manifest, hubproxy image, placeholder refusal), scripts/guardrails/tests/test_update.py, test_auto_update.py, test_release.py | P0 (defaults file path) | 1 |
| P9. Participant end-to-end | backend-engineer, sonnet | scripts/e2e/participant.sh, docker-compose.participant-it.yml, hub/docker-compose.test.yml (tls internal), .github/workflows/ci.yml (participant-e2e job) | all of P1 to P8 | 3 |
| P10. Malware page badge | frontend-engineer, sonnet | web/src/pages/malware*.tsx (badge only), web/src/lib/samples.ts (type), web/src/mocks/samples.ts | P6 (origin field) | 2 |
| P11. Docs and site | docs-writer, haiku | every file in §12 | all | 3 |
| P12. Release v0.6.0 | orchestrator | fill <HUB_PUBLIC_KEY> / <FEED_PUBLIC_KEY>, tag, release workflow (signed images, signed manifest), verify with verify-images.sh and verify-release.sh | P9 green, reviews passed | 4 |
Parallel groups:
- Wave 0: P0.
- Wave 1 (parallel): P1, P2, P3, P4a, P6, P8. File sets are disjoint (
ci.ymlonly in P1 at this stage;release.ymlonly in P8). - Wave 2 (parallel): P4b, P5, P7, P10.
- Wave 3: P9, then P11 (P11 can start on the design right after wave 2).
- Wave 4: reviews and P12.
Contracts that must not drift:
- §6.2 wire shapes and §6.4 headers (P0 owns; P4a and P5 code against them).
- §6.5 defaults file (P0 owns; P2, P7, P8 read it).
- §7.1 DDL (P4a); P4b codes against it.
- §8.5 DDL (P6); P5 codes against it.
- §4.1 / §8.1 variable names (P2 owns compose; P7 writes them; P5 reads them).
- §10
status --jsonshape (P5 ownscli.py; P7 renders it). scripts/lib/schedule.shfunctionsschedule_install <hour> <minute>,schedule_remove,schedule_status(P8 owns; P7 calls).
Reviews:
security-reviewer(opus) on P1, P2, P3, P4a, P4b, P5, P8: §3 (I1′, I7, N1′, N2′, N16 to N20, H8, H9), SSRF and confused-deputy paths, the update chain, key rollover.ui-designeron P10.qa-runner:just check,just guardrails,just test-integration, the hub integration job,participant-e2e.
14. Risks, open questions and later work
Section titled “14. Risks, open questions and later work”- No-VPN anonymity (M1). Accepted by the owner; must stay visible in the consent screen,
status.shanddocs/participate.md. A later option: Tor as a third egress mode. - Release channel (M11). One GitHub identity can update every participant. Later: two-person release approval (protected environment on the release workflow) and an independent transparency check of the manifest.
- k = 2 probe-response (M6). Review mode and random delays slow it; it cannot be removed while two sensors are enough to queue a fetch.
- Stale links. Most one-shot links die before a participant submits (N5 delays by design).
Verification mainly catches long-lived droppers. Measure with
verify_dailybefore tuning. - Bootstrap. A participant counts after 14 days; path B needs two eligible reporters. The owner’s node is one of the first.
- Feeds. Attestations do not feed the outbound list in M32. Candidate: count a verifier
attestation as one of the two fetches for
payload_ip, once review data shows it is safe. - Hub move. Copy the signing key with the move if possible (no rollover). Regenerating requires the §6.4 runbook and at least one release in between.
- Key values.
<HUB_PUBLIC_KEY>and<FEED_PUBLIC_KEY>are filled by the orchestrator at P12; the release workflow refuses placeholders. - Host scheduler. Some minimal hosts have neither
cronnor lingering systemd user sessions; the installer then warns andstatus.shshows “automatic updates not scheduled”.