Skip to content

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.

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.

  1. 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.git
    cd suckerpot
    ./scripts/install.sh --participant

    No 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.sh shows what runs and what was sent. Signed releases install themselves once a day unless turned off.

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

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

  4. 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.
#DecisionAlternatives rejected, and why
D1A 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
D2The 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 pathA separate client factory for the network worker: two ways out of ingest, and the static egress test would need an exception
D3The 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 unchangedMaking api a profile in the base file: the owner’s .env would need a new profile value, a behaviour change for every existing install
D4Membership 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 showsAn 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
D5Hub defaults ship inside the signed images (netproto package data) and are mirrored for host scripts. An explicit .env value always winsA 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
D6Hub 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 headerIn-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
D7Auto-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 rollbackAn 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
D8A 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 reportersCounting 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
D9Verification uses new endpoints under /v1 with protocol spn/1. No existing request or answer changesAdding 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
D10Clear links revealed for verification live in verify_tasks, not in targets.value; a candidate target stays blindedWriting 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
D11Participant 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 awayRefusing to run without a VPN: the owner decided participants must not need one. Hiding the fact: dishonest

M29 §3, M30 §3 and the M21 invariants hold unless amended here.

AssetMust not be harmed or learned
Participant’s home addressLearned 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 timingLinked to the operator’s identity or home address
The verifier as a toolTurned into a fetcher of arbitrary URLs (SSRF, confused deputy, DoS amplifier, canary oracle)
Participants’ hostsTaken over through the update channel
URLhaus accuracyA benign URL submitted because of Sybil reporters or a bad attestation
#MoveMitigationResidual
M1A3 (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 setsH1 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 timePolicy, 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
M2A5 (network observer, the participant’s ISP) sees connections to suckerpot-hub.selfhostr.ioTLS. Padding (4 KiB). One batch per dayParticipation itself is visible to the ISP
M3Compromised ingest (a parser bug exploited by attack data) uses the new routehubproxy 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 443A 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
M4Compromised 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 passwordAn 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
M5Sybil participants (two fake nodes, aged 14 days) steer the verifier to fetch a URL of their choiceReporters 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 itIn 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
M6A1 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 STasks 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 bitGroup 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
M7Early reveal leaks more clear links to the hub (and to A4 on seizure): links seen by 2 nodes instead of kOnly 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 revealedA 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
M8A malicious participant fakes an attestationOnly 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 nodeNone
M9A 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 cancelToday the operator is the only verifier, so this equals “the operator lies”
M10Cloaking: the URL serves malware to the verifier and an innocent page to others, or the reverseA submission states what the verifier fetched. Serving malware to anyone makes the URL a malware URLCloaked malware that hides from the verifier is missed (no false positive)
M11Release-channel attacker (stolen GitHub account, poisoned workflow) pushes a malicious release to every auto-updating participantThe 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 commandA 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
M12A fake hub, or a key-rollover trickPinned key set from the signed release (D6); explicit .env keys win; no in-band key learningA malicious release can change the key (same as M11)
M13Self-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 modeOver-redaction only: the planted address is never shared by that node. It cannot make the guard pass a real self address
M14A participant has no GeoIP/ASN database (no internet), so research scanners are not tagged locallyThe hub never-list holds scanner ranges (M29 §8.6); consensus and the M30 rules never list scan/connectScanner sources reach the hub as observations; they never become reportable
#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)
I7hubproxy 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
N16In 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
N17A 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
N18A 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
N19The node accepts a hub answer only if one of its signature headers verifies under one of the pinned keys (≤ 3).netproto + tests
N20Auto-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
H8The 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
H9Clear 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
ServiceFull (today)Participant, no VPNParticipant with VPN
redis, db, migrate, backup, ingestyesyesyes
sensors (chosen)yesyesyes
api (dashboard)yesnono
vpnprofile egressnoyes (profile egress)
collector, analyzerprofile egress (collector idle unless COLLECTOR_ENABLED)noyes, idle unless COLLECTOR_ENABLED=true (required for a verifier)
hubproxynoyes (profile direct)no
demoprofile toolsnevernever

.env values written by the installer:

VariableFullParticipant, no VPNParticipant with VPN
SUCKERPOT_STACK_MODEfull (absent = full)participantparticipant
COMPOSE_PROFILESegressdirectegress
COMPOSE_FILEdocker-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_MODEvpn (absent = vpn)directvpn
EGRESS_PROXY_URL / EGRESS_CONTROL_URLhttp://vpn:8888 / http://vpn:8000http://hubproxy:8888 / http://hubproxy:8000http://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):

RuleDetail
Listeners:8888 proxy, :8000 control (GET /status only), both on hub-proxy
Accepted requestCONNECT 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 checkExact, lower-cased match against HUBPROXY_ALLOW entries. Not an IP literal. Port 443 only
Resolutiongetaddrinfo 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)
Answers200 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
TunnelByte relay, idle timeout 60 s, total 10 min, no TLS termination, no inspection
LoggingCounters only (allowed, refused_host, refused_auth, …). Never an address, a host name from a refused request, or a header
StartupRefuses 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
ControlGET /status → {"mode": "direct", "allow": ["suckerpot-hub.selfhostr.io:443"], "ok": true}
NetworkDocker nameinternalMembersNotes
hub-uplinksp-hubgate (fixed bridge name)nohubproxy onlyenable_icc: false, masquerade on, no IPv6
hub-proxysp-hubproxy (bridge name)yeshubproxy, 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-config volume owner was fixed by api starting first. In participant mode vpn reads WireGuard settings from .env only (the M28 installer path), so the volume stays empty; the overlay keeps it mounted read-only and scripts/ensure-volumes.sh creates 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 to true by ingest’s verifier wiring only through .env).
Section titled “4.5 Memory budget (participant, 6 recommended sensors, lowmem + participant overlays)”
ServiceLimitTypical use after a day
db512 MiB150 to 250 MiB
redis192 MiB20 to 40 MiB
ingest320 MiB150 to 200 MiB (most workers off)
backup96 MiB10 MiB (idle shell)
hubproxy48 MiB15 to 20 MiB
6 sensors × 128 MiB768 MiB40 to 60 MiB each
migrate256 MiBone-shot, exits
Steady total1,936 MiB of limitsabout 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.1 Flags (additions to scripts/install.sh)

Section titled “5.1 Flags (additions to scripts/install.sh)”
FlagMeaning
--participantParticipant mode. Implies --images (unless --source), --sensors recommended, --lowmem auto (no prompt), auto-update on, feeds off
--fullFull mode. On a fresh host, the same as today’s default. On an existing participant install, switches to full (§5.5)
--yesNo prompt at all. With --participant, this is the consent (documented)
--pseudonym NAMEUse this pseudonym (M29 rules, checked locally first). Default: random
--no-pseudonymNo pseudonym: shown as Node 3f9a1c07
--country CC / --show-countryOptional, never detected; hidden by default (M29 k-rules apply)
--wg-conf PATHUse a VPN (participant with VPN: profile egress, EGRESS_MODE=vpn)
--verifierAsk to be a verifier: needs --wg-conf; turns on COLLECTOR_ENABLED and NETWORK_VERIFIER. The hub operator must still grant the role
--no-auto-updateDo not schedule automatic updates
--feedsAlso 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 ADDROptional own public address (writes SELF_ADDRESS and INDICATORS_IGNORE). Otherwise learned from traffic and auto-confirmed (§8.4)
--repairExisting 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).

Same ten-step frame as M28. Differences:

StepParticipant behaviour
1. PreflightAs today; adds crontab or systemctl --user detection (warning only)
2. SettingsNo question. With a terminal and no --yes: the consent screen (§5.3) and one [y/N]
3. PortsAs 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. SecretsAs today (all SECRET_VARS, including SETTINGS_ENCRYPTION_KEY for the node key). SESSION_SECRET is generated but unused
5. Admin hashSkipped. 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. VolumesAs today (vpn-config included)
8. Guardscheck-ports.sh; check-vpn-env.sh only with a VPN; a new check-participant.sh (allow list well-formed, profiles consistent, api off)
9. StartVerify images (adds hubproxy), docker compose up -d --wait. Health = every started service healthy (no /healthz call)
10. DonePorts, 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.

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 logs
none, 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]
[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 adb
Never forward your admin SSH port (22 on this machine).
Check it any time: ./scripts/status.sh
Automatic 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.

SituationResult
No .envFresh install in the requested mode (default --full)
.env exists, same mode, no other flagPrints already installed in <mode> mode, runs ./scripts/status.sh, exit 0. Nothing changes
.env exists, same mode, --repairSteps 7 to 10 only; secrets and settings untouched
Participant .env, --fullSwitch to full (below)
Full .env, --participantRefused in v1: switching a full install to participant mode would remove the dashboard; not supported

Switch participant → full (./scripts/install.sh --full):

  1. Asks the dashboard questions of today (bind, port or public host, admin user and password; --yes and --admin-password-stdin work as today).
  2. Rewrites only these .env lines: SUCKERPOT_STACK_MODE=full, COMPOSE_FILE without the participant overlay, COMPOSE_PROFILES=egress, EGRESS_MODE=vpn, the EGRESS_*_URL lines, HUBPROXY_ALLOW removed, 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).
  3. Takes a backup (backup-service.sh now preswitch), then docker compose up -d --wait --remove-orphans (hubproxy goes away, api and vpn start).
  4. 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.
CommandWhat it does
statusSame as ./scripts/status.sh
leavedocker 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 --removeWrites or clears VPN_WG_*, switches COMPOSE_PROFILES, EGRESS_MODE and the proxy URLs, docker compose up -d --remove-orphans --wait
auto-update on|offInstalls or removes the schedule; writes AUTO_UPDATE_ENABLED
uninstallleave, then docker compose down, removes the schedule, and prints the volume removal command (never runs it: deleting data is the user’s call)
  • 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 as hub_no_verify and retries once a day.
  • New error code not_verifier (403, signed). Old nodes never receive it.
  • GET /v1/info is not changed (strict parsing on old nodes).
MethodPathSignedWhoBody and answer
GET/v1/verify/revealsyesany 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>yesverifier 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/resultsyesverifier 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"}
FieldRule
id^v_[0-9a-f]{12}$, a task leased to the caller and not yet answered by it
outcomefetched, unreachable, http_error, too_large, timeout, refused (the node’s own guards said no), skipped (local cap)
sha256, size, file_type, familyOnly 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.

LimitDefault
Signed requests per node60/hour (unchanged). A participant adds 4/h (/v1/verify/reveals every 15 min); a verifier adds 8/h
Tasks per lease call20
Results per call50
Reveals per node per day2,000 (unchanged; early reveals count)
  • The hub signs every answer with its primary key in X-SPN-Hub-Signature (unchanged) and, when HUB_SIGNING_KEY_ALT_FILE is set, with the alternate key in X-SPN-Hub-Signature-Alt, over the same canonical answer string.
  • A node pins a set of 1 to 3 keys (NETWORK_HUB_KEY accepts 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/info keeps hub_key = primary. A node checks it is in its pinned set (today: equal).

Rollover runbook (hub/README.md):

PhaseHubReleaseWho works
0Primary K1Defaults [K1]Everyone
1hub.admin gen-signing-key makes K2; set as alternate. Answers carry K1 and K2Next 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 K1Release 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: 0 in its manifest (§9.2). Nodes on the stolen key fail closed until they update.
  • An explicit .env key overrides the defaults (the owner’s install, forks). status.sh and 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_KEY accepts a list; M30 already signs with the next key during a transition (.next.minisig).

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=1
NETWORK_HUB_URL=https://suckerpot-hub.selfhostr.io
NETWORK_HUB_KEY=<HUB_PUBLIC_KEY>
NETWORK_FEED_URL=https://suckerpot-feeds.selfhostr.io/v1
NETWORK_FEED_KEY=<FEED_PUBLIC_KEY>
  • suckerpot_netproto.defaults.load() parses it strictly (known keys only, the same validation as NetworkSettings.from_env), and NetworkSettings / 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 and status.sh says so. The release workflow refuses to publish a release whose defaults hold a placeholder.
  • Full mode: the defaults only pre-fill the hub; NETWORK_ENABLED and the dashboard opt-in still decide. The owner’s .env, if it sets the hub, is unaffected.
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 inet column, 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; the url candidate target’s target_contributors (≤ 30 days, M29 H3) already holds who reported it, for reputation.
  • Roles: hub_api gets SELECT on node_roles, SELECT/UPDATE on verify_tasks (reveal, lease), INSERT on verify_attestations; hub_worker gets 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)”
  1. Off unless HUB_VERIFY_MODE is review or auto (default off: no task, no early reveal).
  2. Create. For each url_b observed in the last HUB_VERIFY_URL_MAX_AGE_DAYS (3) days by ≥ HUB_VERIFY_MIN_REPORTERS (2) distinct eligible nodes, with no task yet and no abusech_submissions row for its clear value: insert waiting_reveal with the counts.
  3. 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 → refused with a code. Pass → pending_review (review) or queued with release_at = now + uniform(6, 30) h (auto).
  4. Leases. An expired lease returns the task to queued (attempts + 1); after HUB_VERIFY_MAX_ATTEMPTS (2) → failed.
  5. Expiry. waiting_reveal, pending_review and queued tasks whose last_report_day is older than 3 days → expired (stale links are rarely alive; one-shot links are mostly missed).
  6. Own fetches count. A verifier node’s own batch row for the same url_b with fetched: true and malware-like content (malware.is_malware_like) is recorded as its attestation; the task goes to attested without a lease.
  7. verify_daily counters. Retention: tasks and attestations deleted 14 days after ended_day; verify_daily kept.

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:

PathBasisRule
A (today)consensusConfirmed url target with a revealed value fetched malware-like by ≥ 2 eligible nodes
B (new)verifiedA 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 = the url candidate 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 if HUB_VERIFY_MODE=auto, the reporters span ≥ 2 enrolment cohorts, and the network has HUB_AUTO_MIN_COHORTS cohorts; otherwise they stay queued.
  • Liveness before sending (_one): path A unchanged; path B needs the task still attested, 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).
  • 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 in pending_review, queued, leased), verify approve <id>, verify refuse <id> --note [--abuse] (--abuse disputes the url candidate: −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 list shows basis.
VariableDefault
HUB_VERIFY_MODEoff (review | auto)
HUB_VERIFY_MIN_REPORTERS2 (never below 2: refused at start)
HUB_VERIFY_DAILY_CAP / HUB_VERIFY_HOST_DAILY_CAP200 / 5
HUB_VERIFY_LEASE_HOURS / HUB_VERIFY_MAX_ATTEMPTS6 / 2
HUB_VERIFY_URL_MAX_AGE_DAYS3
HUB_VERIFY_DELAY_MIN_H / _MAX_H6 / 30
HUB_SIGNING_KEY_ALT_FILEempty
VariableDefault (full)ParticipantMeaning
SUCKERPOT_STACK_MODEfullparticipantMode
EGRESS_MODEvpndirect or vpnGate mode (§8.2)
NETWORK_CONSENTemptyv1:...Auto-join record (§5.2)
NETWORK_PSEUDONYM / NETWORK_COUNTRY / NETWORK_SHOW_COUNTRYemptyfrom flagsUsed for auto-join only
NETWORK_VERIFY_REVEALSfalsetrueAnswer /v1/verify/reveals (needs verify_reveals in the consent in participant mode; in full mode the env flag is the opt-in)
NETWORK_VERIFIERfalsefalse (--verifier)Lease and fetch tasks (N18)
NETWORK_VERIFY_DAILY_CAP / _HOST_DAILY_CAP100 / 3sameLocal caps, independent of the hub’s
SELF_TERMS_AUTO_CONFIRMfalsetrueLearned self addresses act without a click (§8.4)
SELF_ADDRESSemptyfrom --self-addressUpserted 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 of NETWORK_HUB_URL and (when the feed is on) NETWORK_FEED_URL, after defaults.
  • EgressGate.check() in direct mode: GET {control_url}/status must answer {"mode": "direct", "ok": true, "allow": [...]} with allow ⊆ {h + ":443" for h in scope}; otherwise closed with direct_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, raises EgressClosed("egress_direct") unless host is in the scope. GateTransport passes request.url.host, so every request and redirect hop is scoped. gate_open() without a host is False in 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.sh in direct mode checks: direct TCP from ingest fails; CONNECT example.org:443 through hubproxy is refused (403); CONNECT to an IP literal is refused; CONNECT to 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/)”
ModuleRole
participant.pyAt 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.pyRandom adjective-noun-NN from suckerpot_netproto/data/pseudonym_words.txt (shared with the installer), checked with the M29 rules and the guard
verify_reveal.pyEvery 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.pyWhen 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.pypython -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.pyWiring; 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).

  • SELF_TERMS_AUTO_CONFIRM=true: a learned address (report_self_terms, source='learned') that meets the existing thresholds is stored confirmed=true at once. Names are not auto-confirmed (sensor-reported names already act).
  • SELF_ADDRESS, when set, is upserted as source='manual' at ingest start.
  • Until a self address exists the node sends nothing (waiting_self_address, M29 N4); status.sh explains 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.

  • 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=false makes 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).
OptionVerdict
Updater container with /var/run/docker.sockRejected. 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 similarRejected. Follows tags, no cosign check, no database dump, no rollback, no git update of the compose file
System systemd timerAllowed 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)”
  1. flock on .suckerpot-state/update.lock; exit quietly when held.
  2. Exit if AUTO_UPDATE_ENABLED is not true, if .suckerpot-state/last-update.json says an update never completed, or if the install mode is source.
  3. git fetch --tags; target = newest vX.Y.Z tag with the same major, newer than the running version, not in .suckerpot-state/bad-versions.
  4. verify-release.sh target; refuse if the release is younger than auto_min_age_h.
  5. update.sh --yes --auto target (backup, checkout, env-migrate, verify and pull images, up).
  6. Health (mode-aware, in update.sh): every started service healthy within 600 s; SUCKERPOT_RUNNING_VERSION of ingest (participant) or api (full) equals the target; python -m ingest.network.cli status --json answers.
  7. 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 to bad-versions, record the outcome. No retry of that version; the next release is tried.
  8. Write .suckerpot-state/auto-update.json: {last_run, last_result, from, to, error_code} (constant codes). status.sh reads 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.

./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): 3
Updates
Automatic: on, next window 04:37. Last run 2026-11-03: already on v0.6.0
Exit code 0 = OK, 1 = warning, 2 = error.
  • Warnings (exit 1): a sensor unhealthy, waiting_self_address for 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-batch prints the exact JSON of the last submission (network_submissions.payload, kept 7 days), which holds attacker addresses only. --json prints 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.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:

  1. Every network is internal: true except ingress, dashboard, vpn-uplink and hub-uplink (sp-hubgate, icc off).
  2. hub-uplink members are exactly {hubproxy}; hub-proxy members ⊆ {hubproxy, ingest}.
  3. hubproxy: profile exactly [direct], networks exactly {hub-uplink, hub-proxy}, no ports, no cap_add, no devices, no volumes, read_only, user non-root, limits set.
  4. vpn, collector, analyzer: profile exactly [egress]. The profiles of vpn and hubproxy are disjoint, and the installer never writes both (installer test).
  5. In the participant rendering with each profile: api is not active; the active set is exactly {redis, db, migrate, backup, ingest, sensors…} plus {hubproxy} (direct) or {vpn, collector, analyzer} (egress); ingest networks are all internal; no active service but sensors has ports.
  6. HUBPROXY_ALLOW written by the installer (dry-run fixture) holds at most two entries, each <name>:443, and every name is the host of a URL in config/network-defaults.env.
  7. The participant overlay changes nothing but profiles, depends_on, networks (ingest only, adding hub-proxy only) and backup limits (like test_lowmem.py).
  8. Mutation tests (must fail the checker): hub-uplink added to ingest; hubproxy on backend; ports on hubproxy; a third allow entry; :80 in the allow list; vpn without a profile; api dropped from the dashboard profile in the overlay but ports kept; ingest on a non-internal network.
  9. The base rendering with COMPOSE_PROFILES=egress and no overlay equals today’s rendering except the added hubproxy service and two network definitions (owner unchanged; snapshot test).
PackageCases
hubproxyallowed 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
egressdirect 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)
netprotodefaults 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
hubcreate 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)
ingestparticipant 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
collectornetwork_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.

  1. Start the hub test stack (hub/docker-compose.test.yml, Caddy with tls internal, local volumes, HUB_ALLOW_DOCUMENTATION=1, HUB_VERIFY_MODE=auto, low PoW).
  2. ./scripts/install.sh --participant --yes --source with 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 in HUBPROXY_ALLOW.
  3. Assert: running services are exactly the participant set; api absent; status.sh exit 0 or 1; enrolment visible in status --json.
  4. Containment: from ingest, direct TCP to 1.1.1.1:443 fails; CONNECT example.org:443 through hubproxy is 403; from a sensor container, hubproxy:8888 does not resolve.
  5. 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).
  6. participant.sh leave → the hub deletes the node; install.sh --full --yes --admin-password-stdin → api healthy, data kept (session count unchanged).
  7. 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.

FileChange
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.mdTwo modes table (participant / full), flags, switching to full
docs/network.mdParticipants, 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.mdAutomatic updates, the signed manifest, rollback, turning it off, the release-channel risk
docs/SECURITY.md, docs/ARCHITECTURE.mdhubproxy, hub-uplink, the I1′ rule, outbound hosts table (participant row)
docs/configuration.md§8.1 variables, AUTO_UPDATE_ENABLED, HUBPROXY_ALLOW
hub/README.mdVerifier 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.mdShort participant section; v0.6.0 entry

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.

TaskAgent / modelOwnsDepends onWave
P0. Protocol and defaultsbackend-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 servicebackend-engineer, sonnet (opus security review)hubproxy/** (package, Dockerfile, tests), .github/workflows/ci.yml (python job list only)—1
P2. Compose, overlay, guardrails, host firewallbackend-engineer, sonnetdocker-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.shP0 (defaults file)1
P3. Egress direct modebackend-engineer, sonnetegress/suckerpot_egress/**, egress/tests/**, ingest/ingest/egress.py, scripts/egress-check.sh—1
P4a. Hub verification corebackend-engineer, sonnethub/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.pyP01
P4b. Hub abuse.ch path B and adminbackend-engineer, sonnethub/hub/abusech.py, hub/hub/admin.py, hub/tests/test_abusech*.py, hub/tests/test_admin_verify.py, hub/tests/e2e/** (verify scenario)P4a DDL2
P5. Node participant and verifier workerbackend-engineer, sonnetingest/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*.pyP0, P3, P62
P6. Node migration and collectorbackend-engineer, sonnetdb/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 helperbackend-engineer, sonnetscripts/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.pyP2 (env names), P5 (cli status --json shape, §10), P8 (schedule.sh contract)2
P8. Auto-update and signed manifestbackend-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.pyP0 (defaults file path)1
P9. Participant end-to-endbackend-engineer, sonnetscripts/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 P83
P10. Malware page badgefrontend-engineer, sonnetweb/src/pages/malware*.tsx (badge only), web/src/lib/samples.ts (type), web/src/mocks/samples.tsP6 (origin field)2
P11. Docs and sitedocs-writer, haikuevery file in §12all3
P12. Release v0.6.0orchestratorfill <HUB_PUBLIC_KEY> / <FEED_PUBLIC_KEY>, tag, release workflow (signed images, signed manifest), verify with verify-images.sh and verify-release.shP9 green, reviews passed4

Parallel groups:

  • Wave 0: P0.
  • Wave 1 (parallel): P1, P2, P3, P4a, P6, P8. File sets are disjoint (ci.yml only in P1 at this stage; release.yml only 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 --json shape (P5 owns cli.py; P7 renders it).
  • scripts/lib/schedule.sh functions schedule_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-designer on P10.
  • qa-runner: just check, just guardrails, just test-integration, the hub integration job, participant-e2e.
  1. No-VPN anonymity (M1). Accepted by the owner; must stay visible in the consent screen, status.sh and docs/participate.md. A later option: Tor as a third egress mode.
  2. 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.
  3. 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.
  4. Stale links. Most one-shot links die before a participant submits (N5 delays by design). Verification mainly catches long-lived droppers. Measure with verify_daily before tuning.
  5. Bootstrap. A participant counts after 14 days; path B needs two eligible reporters. The owner’s node is one of the first.
  6. 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.
  7. 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.
  8. Key values. <HUB_PUBLIC_KEY> and <FEED_PUBLIC_KEY> are filled by the orchestrator at P12; the release workflow refuses placeholders.
  9. Host scheduler. Some minimal hosts have neither cron nor lingering systemd user sessions; the installer then warns and status.sh shows “automatic updates not scheduled”.