M28 — Installable by anyone
Status: proposal for review. Scope: docs/ROADMAP.md, section M28.
Every address and name in this document is a placeholder: documentation ranges (192.0.2.0/24,
198.51.100.0/24, 203.0.113.0/24) and example.org.
1. Goal and invariants
Section titled “1. Goal and invariants”Today SuckerPot assumes one owner who reads deployment.md, writes .env by hand, runs
api.hashpw, knows which sensors his ISP box forwards, and upgrades with git pull. M28 makes it a
product: one command installs it, the dashboard finishes the setup, an owner picks the sensors
they want, and updates are safe.
| # | Invariant | Enforced by |
|---|---|---|
| P1 | The installer never overwrites an existing .env, never adopts or deletes an existing data volume, and refuses throwaway project names and linked worktrees. | scripts/install.sh preflight (§2.3) + scripts/guardrails/tests/test_install.py |
| P2 | Every secret is generated on the host, at least 24 characters, pairwise different, written only to .env (mode 0600). Nothing secret is printed except the generated admin password, once, when the owner asked for a generated one. | scripts/lib/env.sh + tests |
| P3 | The installer and the update never contact a “what is my IP” service or any SuckerPot-run service. Host traffic (git, image pulls) is out of the M21 egress scope and documented as such. | Code review + static grep test |
| P4 | The update check runs only through the egress gate, sends no version, no instance identifier, no cookie, and never downloads or runs anything. | ingest/ingest/updatecheck.py + tests (§7) |
| P5 | The dashboard never edits .env, never starts or stops containers, and gets no Docker socket. Changes that need the host are shown as a command to copy. | Unchanged API isolation; guardrails |
| P6 | External volumes, the _need-project guard, the test overlays and the egress guardrails keep working unchanged. A low-memory overlay only changes resource keys. | just guardrails (§5.3) |
| P7 | Migrations stay forward-only. Every just update takes a database dump first and records how to roll back. | scripts/update.sh (§6) |
2. The installer (scripts/install.sh)
Section titled “2. The installer (scripts/install.sh)”2.1 How a new owner gets it
Section titled “2.1 How a new owner gets it”No curl | bash. The install guide says:
git clone --depth 1 --branch v0.8.0 https://github.com/<owner>/suckerpot.gitcd suckerpot./scripts/install.shThe compose file, the scripts and the justfile come from the release tag. Images come from GHCR
(default) or are built from this checkout (--source).
2.2 Usage
Section titled “2.2 Usage”scripts/install.sh [--yes] [--source | --images] [--version vX.Y.Z] [--project NAME] [--prefix NAME] [--sensors recommended|all|ssh,telnet,...] [--dashboard-bind 127.0.0.1] [--dashboard-port 8443] [--admin-user admin] [--admin-password-stdin] [--lowmem auto|on|off] [--wg-conf PATH] [--dry-run --env-out PATH]Interactive by default; --yes takes the defaults and needs --admin-password-stdin or generates
the password. --dry-run --env-out PATH writes the would-be .env to PATH with a placeholder
admin hash and makes no Docker call (used by the tests).
2.3 Steps (each prints one line [n/10] ...; any failure stops with a named reason)
Section titled “2.3 Steps (each prints one line [n/10] ...; any failure stops with a named reason)”- Preflight.
- Linux,
x86_64oraarch64. Bash ≥ 4. - Not inside a linked git worktree (same test as
_need-project). docker versionworks for this user (or advisesudo usermod -aG docker $USER); Engine ≥ 24.docker compose version≥ 2.24.- RAM from
/proc/meminfo: < 2 GiB refuse; 2 to 6 GiB propose--lowmem on. - Free disk under
docker info --format '{{.DockerRootDir}}': < 15 GiB refuse, < 30 GiB warn. .envexists → stop: “SuckerPot is already installed here. Usejust update.”- Project and prefix (default
suckerpotfor both) must match^[a-z0-9][a-z0-9_-]{0,40}$and must not besp-it-*,sp-test,sp-ci. docker volume inspect <prefix>_db-datasucceeds → stop: “Data volumes for this prefix already exist. Restore your old .env, or pick another —prefix.” Never adopt them: the DB owner password lives inside the data.
- Linux,
- Questions (skipped with
--yes):- install mode: images (default) or source;
- sensors:
recommended(ssh, telnet, web, redis, docker, adb),all, or a custom list; - how the dashboard is reached:
a. on this machine or through an SSH tunnel (default:
DASHBOARD_BIND=127.0.0.1,COOKIE_SECURE=false); b. through an HTTPS tunnel such as cloudflared: ask the public host name and fillAPI_ALLOWED_HOSTSandAPI_ALLOWED_ORIGINS;COOKIE_SECURE=true. - admin user name; admin password (typed twice, ≥ 12 characters) or “generate one”;
- low-memory mode;
- optional path to a WireGuard
.conf(parsed intoVPN_WG_*; or upload it later in the dashboard, §4); - optional: your public address, if you know it (goes to
INDICATORS_IGNORE). The prompt says the dashboard learns it from traffic otherwise. The script never looks it up.
- Port check.
- For each chosen sensor,
ss -Hltnushows whether its host port is already in use. - Port 22 in use by the host’s own
sshd(the usual case) offers three choices: a. map the SSH sensor to host port 2222 and forward router port 22 to it (default); b. leave the SSH sensor off; c. keep 22 after moving the admin SSH yourself (the script never editssshd_config). - It also asks for
ADMIN_SSH_PORTsocheck-ports.shcan protect it.
- For each chosen sensor,
- Secrets.
scripts/lib/env.shholds the listSECRET_VARS(everyPOSTGRES_*_PASSWORD, everyREDIS_*_PASSWORD,EGRESS_PROXY_PASSWORD,SESSION_SECRET,SETTINGS_ENCRYPTION_KEY).- Generator:
LC_ALL=C tr -dc 'A-Za-z0-9' </dev/urandom | head -c 40(64 forSESSION_SECRET);SETTINGS_ENCRYPTION_KEYishead -c 32 /dev/urandom | base64. - Alphanumeric values need no quoting in
.env. The script checks that they are pairwise different and regenerates on the (impossible in practice) collision. - No
openssldependency.
- Generator:
- Admin hash. Images mode:
docker pullthe api image of--version. Source mode:docker build -f api/Dockerfile -t suckerpot-install-api .. Thenprintf '%s\n' "$pw" | docker run --rm -i --network none <api image> python -m api.hashpw --stdin(the flag already exists). The password never appears in argv, the environment or a file; the variable is unset right after. - Write
.env.umask 077. Copy.env.exampleto.env.tmpin the repo folder.- Replace each
NAME=line with awk (nosed -ion secrets). - Uncomment and set
COMPOSE_PROJECT_NAMEandSUCKERPOT_VOLUME_PREFIX. - Write
ADMIN_PASSWORD_HASH='...'in single quotes. - Set
COMPOSE_PROFILES=egress,SUCKERPOT_INSTALL_MODE,SUCKERPOT_VERSION(images mode), theSENSOR_*values and theCOMPOSE_FILEline when low-memory (§5.2). mv .env.tmp .env;chmod 600 .env.- Check: no
change-meleft, everySECRET_VARSset.
- Volumes.
scripts/ensure-volumes.sh(unchanged; creates, never deletes). - Guards.
scripts/check-ports.sh,scripts/check-vpn-env.sh. - Start. Images:
docker compose pull && docker compose up -d --wait --wait-timeout 600. Source:docker compose up -d --build --wait --wait-timeout 900. Thencurl -fsS http://<bind>:<port>/healthz. - Summary.
- The URL; for bind
127.0.0.1on a remote machine, also the hintssh -L 8443:127.0.0.1:8443 <you>@<this-host>. - The admin user name and, if generated, the password, once, with “store it in your password manager now”.
- Next steps: sign in (the setup opens by itself), forward the ports listed on the Ports
page, install the host firewall (
docs/deployment.md#host-firewall), take a snapshot.
- The URL; for bind
A log without secrets goes to ./install.log (gitignored): step names and results only.
2.4 Shared shell library scripts/lib/env.sh
Section titled “2.4 Shared shell library scripts/lib/env.sh”Sourced by install.sh, env-migrate.sh, env-set.sh, update.sh:
env_get NAME(same rules ascheck-ports.sh’sget);env_set_line FILE NAME VALUE(awk rewrite, keeps comments, appends when missing);gen_secret LEN;SECRET_VARS;SETTABLE_VARS(the allow-list forjust set, §3.3);SENSORS=(ssh telnet web rdp redis docker mysql adb mssql smb sip synology);RECOMMENDED_SENSORS;sensor_ports NAME(env variable names and protocols, the single table that §4.2 also uses).
3. Choosing sensors
Section titled “3. Choosing sensors”3.1 Mechanism: deploy.replicas, not profiles
Section titled “3.1 Mechanism: deploy.replicas, not profiles”Each sensor-<name> service gets deploy: {replicas: ${SENSOR_<NAME>:-1}} with SENSOR_SSH,
SENSOR_TELNET, … (0 or 1).
Why not one compose profile per sensor: an existing install that upgrades with git pull && docker compose up and has no sensor profile in COMPOSE_PROFILES would silently stop every
sensor. Replicas default to 1, so old .env files keep every sensor, and plain docker compose up honours the choice without just. COMPOSE_PROFILES stays for egress and tools.
Verification needed (task B, first thing): docker compose up -d --wait with a service at
replicas: 0 on Compose ≥ 2.24 creates no container and binds no port. If it does not hold, fall
back to profiles plus an env-migrate step that adds them.
Nothing depends on a sensor, so a missing sensor breaks nothing.
- Redis keeps its ACL users and the
sensors-<name>networks; an unused network is harmless. INGEST_STREAMSis unchanged: an empty stream costs nothing.- The installer still generates every
REDIS_SENSOR_*_PASSWORD, so enabling a sensor later needs no new secret.
3.2 Guards
Section titled “3.2 Guards”scripts/check-ports.sh: everySENSOR_*must be0or1. Clash checks run only between enabled sensors and the dashboard; the dashboard port is still refused on any sensor’s default port.scripts/guardrails/tests/test_compose.py: everysensor-*service hasdeploy.replicasequal to${SENSOR_<NAME>:-1}and no otherdeploykey; no non-sensor service usesdeploy.replicas.
3.3 Changing the selection: just sensors and just set
Section titled “3.3 Changing the selection: just sensors and just set”just sensors ssh,telnet,web→scripts/env-set.sh sensors ssh,telnet,websets the twelveSENSOR_*lines, runscheck-ports.sh, thendocker compose up -d --wait.just sensorsalone prints the current selection.just set KEY=VALUE [KEY=VALUE ...]→scripts/env-set.sh set ..., thendocker compose up -d --wait.- Only keys in
SETTABLE_VARSare accepted: feature flags (*_ENABLED),*_HOST_PORT,DASHBOARD_BIND,DASHBOARD_PORT,COOKIE_SECURE,API_ALLOWED_HOSTS,API_ALLOWED_ORIGINS,INDICATORS_IGNORE,SITE_*,UPDATE_CHECK_ENABLED,SENSOR_*, and (M29)NETWORK_ENABLED,NETWORK_HUB_URL,NETWORK_HUB_KEY. - Refused by pattern even if listed:
*_PASSWORD,*_SECRET,*_HASH,*_KEY(exceptNETWORK_HUB_KEY),VPN_WG_*. - Values must match
^[A-Za-z0-9._,:/@+-]{0,512}$. Prints names only.
- Only keys in
The dashboard shows these exact commands wherever an .env change is needed (sensor page, the
checklist, the setup steps), with a copy button. It never runs them.
4. First-run setup in the dashboard
Section titled “4. First-run setup in the dashboard”4.1 Data: migration 0025_setup_progress
Section titled “4.1 Data: migration 0025_setup_progress”CREATE TABLE setup_progress ( id smallint PRIMARY KEY CHECK (id = 1), started_at timestamptz NOT NULL DEFAULT now(), finished_at timestamptz, skipped_at timestamptz, finished_by text, steps jsonb NOT NULL DEFAULT '{}'::jsonb -- {"<step_id>": "done" | "skipped"});Grants: suckerpot_api SELECT, INSERT, UPDATE; suckerpot_ingest none. The steps keys are
validated by the API against a constant list (unknown keys refused); JSON capped at 4 KiB.
4.2 API (api/api/setup_wizard.py, api/api/version.py; registered in api/api/routes.py)
Section titled “4.2 API (api/api/setup_wizard.py, api/api/version.py; registered in api/api/routes.py)”| Method | Path | Who | Body / answer |
|---|---|---|---|
| GET | /api/v1/setup/progress | admin | {state: "new"|"in_progress"|"finished"|"skipped", steps: {...}, show: bool}. show is true when state is new or in_progress |
| PUT | /api/v1/setup/progress | admin, CSRF | {step: "<id>", status: "done"|"skipped"} or {finish: true} or {skip: true} or {reopen: true}. Audit setup.finish, setup.skip, setup.reopen |
| GET | /api/v1/setup/ports | admin | {dashboard_port, items: [{sensor, label, enabled, protocols: ["tcp"] | ["udp","tcp"], external_port, host_port, env_var, state: "receiving"|"waiting"|"off", last_event_at}]} |
| GET | /api/v1/version | any signed-in user | {version, build: "image"|"source", commit, latest, update_available, checked_at, check: "ok"|"off"|"no_egress"|"error"|"pending", release_url} |
- Ports:
external_portis the standard service port (22, 23, 80/443, …), the port to open on the router.host_portcomes from*_HOST_PORT.stateisreceivingwhen the sensor sent an event in the last 24 h,waitingotherwise,offwhenSENSOR_<NAME>=0.- The API gets the
SENSOR_*and*_HOST_PORTvariables in compose. - There is no “test from outside” button: an outside prober would have to be a service we run
or trust, and it would learn the home address. Bots arrive within minutes, so
receivingis the proof.
release_urlis built from a constant (UPDATE_CHECK_REPO), never taken from a response.
setup_status.py (M24) changes:
- the
sensorsitem counts only enabled sensors, so a disabled sensor is not “silent”; - new item
ports(todowhile an enabled sensor iswaitingafter 2 h of uptime, href/settings?tab=ports); - new item
updates(ok,warningwhen an update is available,offwhen the check is off).
4.3 The setup flow (web)
Section titled “4.3 The setup flow (web)”- Route.
/setupis a full-page stepper inside the app shell, admin only. - When it opens. After sign-in, when
GET /setup/progresssaysshow, the app redirects once per browser session to/setup. The M24 welcome tour is offered at its last step instead of opening on its own (viewers keep the M24 behaviour). - Controls. “Finish later” (keeps
in_progress, a banner in Settings > General offers to resume) and “Skip setup” (skip: true). Settings > General has “Open the setup again” (reopen).
Steps (ids are the steps keys):
| id | Title | Content | Done when |
|---|---|---|---|
welcome | Welcome | What SuckerPot is, the three warnings of getting-started.md (dedicated machine, no credentials on it, dashboard never public) | Next |
sensors | Your sensors | Enabled sensors as cards (sensor colour, port, protocol). Disabled ones listed with what they catch. The just sensors ... command for the current selection, editable through checkboxes that only rebuild the command text | Next |
ports | Open the ports | The ports table (§4.4) with a per-row live state | Next (never blocks) |
vpn | Anonymous egress | Gate state. The M23 upload card (reused component components/vpn/*). Why it matters in two sentences | Gate open, or “Skip for now” |
self | Your own address | The M22 self-terms card (masked, reveal for admin, add manual). Explains that reports, alerts and (M29) the network stay locked until one address is confirmed | A confirmed or manual self address, or skip |
reporting | Abuse reports (optional) | Reply contact field (M22 settings). ABUSE_REPORTS_ENABLED state with just set ABUSE_REPORTS_ENABLED=true | Saved, or skip |
alerts | Alerts (optional) | Link to the alert channel dialog | Skip or done |
network | The SuckerPot network (optional) | Reserved for M29. Hidden while GET /api/v1/network/status answers 404 | M29 |
done | You are set | The M24 checklist component, “Take the tour”, “Go to Today” | Finish |
UI rules (CLAUDE.md §4):
- each step opens with one plain sentence;
- the step indicator is keyboard reachable;
prefers-reduced-motionrespected;- no new colours (sensor hues only on sensor cards;
warningtoken for “waiting”); - mobile 390 px: one column, sticky Back/Next bar.
4.4 Ports page
Section titled “4.4 Ports page”Settings gets a tab “Sensors & ports” (/settings?tab=ports), the same table as the setup step.
| Column | Example |
|---|---|
| Sensor | SSH (sensor colour dot) |
| Open on your router | TCP 22 |
| Forward to this machine’s port | 2222 |
| Status | Receiving / Waiting for the first connection / Off |
Under the table:
- “Never forward port 8443 (dashboard) or your admin SSH port.” The dashboard port comes from the API.
- A short note on CGNAT: if the router’s WAN address is in 100.64.0.0/10, forwarding cannot work; ask the ISP for a public address.
- A note that a DMZ forwards everything (link to deployment).
No public address is ever shown: the API does not know it, and must not learn it this way.
5. Images, versions and low memory
Section titled “5. Images, versions and low memory”5.1 Compose: image names and build metadata
Section titled “5.1 Compose: image names and build metadata”Every service that is built from this repo (redis, migrate, ingest, api, the 12 sensors, collector and analyzer, demo) gets:
image: ${SUCKERPOT_IMAGE_PREFIX:-ghcr.io/selfhostr/suckerpot}-api:${SUCKERPOT_VERSION:-dev} build: context: . dockerfile: api/Dockerfile args: VERSION: ${SUCKERPOT_BUILD_VERSION:-dev} BUILD_KIND: source COMMIT: ${SUCKERPOT_BUILD_COMMIT:-unknown}-
The image suffix equals the service name; collector and analyzer share
-collector. -
Every Dockerfile’s final stage adds:
ARG VERSION=dev BUILD_KIND=source COMMIT=unknownENV SUCKERPOT_RUNNING_VERSION=$VERSION SUCKERPOT_BUILD_KIND=$BUILD_KIND SUCKERPOT_COMMIT=$COMMITLABEL org.opencontainers.image.version=$VERSION org.opencontainers.image.revision=$COMMITThe api and ingest read
SUCKERPOT_RUNNING_VERSION, so the UI shows what is really running. Compose never sets these at run time. -
Source mode:
just upexportsSUCKERPOT_BUILD_VERSION=$(git describe --tags --always --dirty)andSUCKERPOT_BUILD_COMMIT=$(git rev-parse --short HEAD)beforedocker compose up --build. -
Images mode:
.envhasSUCKERPOT_INSTALL_MODE=imagesandSUCKERPOT_VERSION=v0.8.0;just uprunsdocker compose up -d --wait --no-build, which pulls missing images. -
Guardrail: every
image:of a built service uses${SUCKERPOT_IMAGE_PREFIXand:${SUCKERPOT_VERSION; nolatesttag anywhere;vpnstays pinned by digest.
5.2 Low-memory overlay docker-compose.lowmem.yml
Section titled “5.2 Low-memory overlay docker-compose.lowmem.yml”Turned on by the installer with COMPOSE_FILE=docker-compose.yml:docker-compose.lowmem.yml in
.env, so plain compose and every just recipe use it. Target: a 2 GiB machine (for example a
Raspberry Pi 4 with 2 GiB or a small VPS) with up to 5 sensors and swap available.
| Service | Normal | Low memory |
|---|---|---|
| db | 1g, shared_buffers 256MB, effective_cache_size 768MB, max_connections 50, timescaledb workers 4 | 512m, 96MB, 256MB, 30, 2 (command replaced, shm_size: 128m) |
| redis | 384m, maxmemory 192mb | 192m, REDIS_MAXMEMORY_MB=96 (new variable read by redis/entrypoint.sh, default 192) |
| ingest | 384m | 320m |
| api | 512m | 320m |
| analyzer | 512m | 256m (and its child limit env lowered to match) |
| collector | 256m | 192m |
| each sensor | 128 to 256m | 128m, and the matching *_MEMORY_LIMIT_MB env set to 128 |
Rules:
- The overlay may only set
mem_limit,memswap_limit,cpus,shm_size,command(db and redis only) and environment keys ending in_MEMORY_LIMIT_MB,_MAXMEMORY_MBor_LIMIT_MB. scripts/guardrails/tests/test_lowmem.pymerges base + overlay and asserts that rule, that every guardrail oftest_compose.pystill passes on the merged file, and that each sensor’s*_MEMORY_LIMIT_MBequals itsmem_limit.- Docs: requirements table (2 GiB with low memory and at most 5 sensors, 4 GiB for all twelve, 6 GiB with the collector and analyzer busy).
5.3 Release workflow .github/workflows/release.yml
Section titled “5.3 Release workflow .github/workflows/release.yml”- Trigger:
push: tags: ['v*.*.*']andworkflow_dispatch(inputtag). - Permissions:
contents: read,packages: write,id-token: write(cosign keyless). Actions pinned by SHA likeci.yml. - Job
build: matriximage×arch.- Images: redis, migrate, ingest, api, collector, demo and the 12 sensors.
- Arch:
amd64onubuntu-24.04,arm64onubuntu-24.04-arm(native runners, no QEMU). - Command:
docker buildx build --platform linux/<arch> --build-arg VERSION=<tag> --build-arg BUILD_KIND=image --build-arg COMMIT=<sha> --provenance=mode=max --sbom=true --output type=image,name=<prefix>-<image>,push-by-digest=true,name-canonical=true,push=true. - The digest is uploaded as an artifact.
- Job
merge: per image,docker buildx imagetools createwith tags<tag>and<major>.<minor>(neverlatest), thencosign sign --yes <ref>@<digest>. - Job
smoke(amd64): on the runner,scripts/install.sh --yes --images --version <tag> --project sp-release-smoke --prefix sp-release-smoke --sensors ssh,web --admin-password-stdin.- Check
/healthzand/api/v1/version, thendocker compose downand remove thesp-release-smoke_*volumes. The runner is throwaway. - The installer accepts this name: it is not a reserved test name, and live recipes on a real host would never use it.
- Check
- Speed-up for arm64: in
api/Dockerfile, the web build stage usesFROM --platform=$BUILDPLATFORM node:.... The SPA is architecture-independent. - Verification:
scripts/verify-images.sh(optional,just verify-images) runs cosign in a container to check the signature of every image ofSUCKERPOT_VERSION, against the repository’s workflow identity. - Owner action: make the GHCR packages public (listed under “Blocked — needs owner”).
Build from source stays fully supported (--source, SUCKERPOT_INSTALL_MODE=source), and CI
keeps building from source on every push.
6. Safe updates: just update and just rollback
Section titled “6. Safe updates: just update and just rollback”scripts/update.sh (called by just update [vX.Y.Z], after _need-project):
- Preflight.
- No uncommitted change to tracked files (source mode: refuse; images mode: warn, the compose file comes from git too).
- The stack is up and the
backupservice healthy. - Target version: the argument, or the newest
v*tag aftergit fetch --tags(source mode on a branch:git pull --ff-onlyinstead). - Print the current and target versions and the tag’s release notes
(
git tag -l --format='%(contents)' <tag>). Ask to confirm (--yesskips).
- Backup.
docker compose exec -T backup /bin/sh /backup-service.sh now(same asjust backup). Failure stops the update unless--no-backup(which asks to typeno backup). Record the dump name. - State file. Write
.suckerpot-state/last-update.json(gitignored, mode 0600):{from_version, from_ref (commit), mode, dump, images: {service: digest}, at}. - Code.
git checkout <tag>(detached, images and source tag mode) or the--ff-onlypull. - Environment.
scripts/env-migrate.sh: adds anySECRET_VARSmissing from.envwith a generated value, and any newly required non-secret variable with its default, under a comment# added by just update <date>. It never changes an existing line. It prints the names it added and the new optional variables found in.env.example. - Images. Images mode: set
SUCKERPOT_VERSION=<tag>in.env,docker compose pull. Source:docker compose build. - Start.
docker compose up -d --wait --wait-timeout 600(migrateruns first as always). - Health.
curl -fsS /healthz, every service healthy indocker compose ps, and/api/v1/versionanswers the target version. - On failure at 6 to 8: print the exact rollback command and the dump name, exit non-zero. Never roll back automatically: a rollback replaces the database.
just rollback (scripts/rollback.sh):
- reads the state file;
- shows what it will do;
- asks to type
rollback; git checkout <from_ref>, restoresSUCKERPOT_VERSION(images) anddocker compose pullor build;just restore volume:<dump>(its own confirmation and checks, unchanged);docker compose up -d --wait.
Events received between the dump and the rollback are lost; the prompt says so.
docs/deployment.md#upgrading is replaced by these two recipes.
7. Version in the UI and the update check
Section titled “7. Version in the UI and the update check”7.1 Display
Section titled “7.1 Display”The sidebar footer shows v0.8.0 (small, muted, tabular). When update_available:
- a dot and “Update available: v0.9.0” link to Settings > General, where an “Updates” card shows
the current version, the latest, the checked time, the release page link and the command
just update; - a “Copy” button. Viewers see the version and the dot, not the command.
Development builds (git describe with a -N-g<sha> suffix, or dev) show the string and never
claim an update.
7.2 The check (ingest/ingest/updatecheck.py)
Section titled “7.2 The check (ingest/ingest/updatecheck.py)”- Switch.
UPDATE_CHECK_ENABLED(defaultfalse; the installer asks). It only ever runs through the egress gate: with novpn, or the gate closed, it does nothing (check: "no_egress"). There is no direct mode. - Schedule. At start + random 1 to 6 h, then every 24 h ± 6 h (uniform), so the request time is not a fingerprint.
- Request.
GET https://api.github.com/repos/<UPDATE_CHECK_REPO>/releases/latestthroughingest.egress.make_client,follow_redirects=False, 10 s timeout, 64 KiB body cap.- Headers:
Accept: application/vnd.github+jsonandUser-Agent: Mozilla/5.0only. NoIf-None-Match, no cookie, no token. UPDATE_CHECK_REPOis a constant default (the public repository), overridable for forks.
- Parsing. Only
tag_name(must match^v\d{1,3}\.\d{1,3}\.\d{1,3}$),published_at(ISO date) andprerelease/draft(skipped when true). Nothing else is kept. Release notes are never fetched or displayed from the response. - Storage.
enrichment_statusrowupdate_check, detail{latest, published_at, checked_at, state}. The API compares withSUCKERPOT_RUNNING_VERSION(semver). - Never downloads, pulls, or runs anything;
just updatestays a human action on the host.
7.3 What the check reveals (and to whom)
Section titled “7.3 What the check reveals (and to whom)”| Observer | Learns | Why acceptable |
|---|---|---|
| GitHub | A VPN exit address fetched the latest release of a public repo, about once a day | Same as any visitor; no version, no instance id, no cookie; the exit is shared with other VPN users |
| VPN provider | A TLS connection to api.github.com | Already sees all egress |
| Attackers | Nothing | No inbound change |
| Correlation with the collector | The same exit fetched attacker URLs and GitHub | Links “an exit address” to “a SuckerPot user”, not to a person or a home address. Turning the check off removes it |
Host traffic (git clone or fetch, GHCR pulls) leaves from the host’s own address. It is out of the M21 scope and the install guide says so. The host’s Docker daemon can be given a proxy by the owner if they care.
8. Configuration (new or changed)
Section titled “8. Configuration (new or changed)”| Variable | Default | Where | Meaning |
|---|---|---|---|
SENSOR_SSH … SENSOR_SYNOLOGY | 1 | compose, api | 1 = sensor on, 0 = off |
SUCKERPOT_INSTALL_MODE | source | just, scripts | images or source |
SUCKERPOT_VERSION | dev | compose | Image tag to run (images mode) |
SUCKERPOT_IMAGE_PREFIX | ghcr.io/selfhostr/suckerpot | compose | Registry and name prefix |
SUCKERPOT_BUILD_VERSION, SUCKERPOT_BUILD_COMMIT | set by just up | build args | Source builds only |
UPDATE_CHECK_ENABLED | false | ingest, api | Daily check through the VPN |
UPDATE_CHECK_REPO | the public repo | ingest, api | owner/name |
REDIS_MAXMEMORY_MB | 192 | redis | Low-memory overlay sets 96 |
COMPOSE_FILE | unset | compose | Set by the installer for low memory |
All documented in .env.example and docs/configuration.md.
9. Tests
Section titled “9. Tests”scripts/guardrails/tests/test_install.py:- runs
bash scripts/install.sh --yes --dry-run --env-out <tmp>/.env --sensors ssh,web --admin-password-stdinin a temp copy of the repo files it needs; - asserts mode 0600, every
SECRET_VARSpresent, ≥ 24 chars, pairwise distinct, nochange-me,COMPOSE_PROJECT_NAMEandSUCKERPOT_VOLUME_PREFIXset,SENSOR_SSH=1,SENSOR_TELNET=0, hash single-quoted; - refusal cases: existing
.env, reserved project names, bad prefix, linked worktree (a tempgit worktree); - the script source contains no
ipify,icanhazip,ifconfig.meorcurlto a non-local URL (static grep).
- runs
test_env_set.py:env-set.sh setrefuses secret-like keys and bad values, keeps comments, appends missing keys;env-migrate.shadds missing secrets, never changes an existing line.test_compose.py/test_lowmem.py: §3.2 and §5.2 rules; image naming rules.api/tests/test_setup_wizard.py: progress state machine, admin-only, CSRF, unknown step refused, audit entries;portsstates;versioncomparisons (dev build, equal, newer, check off).ingest/tests/test_updatecheck.py:- gate closed → no request;
- request headers exactly as §7.2 and no version string anywhere in the request;
- bad
tag_nameignored; prerelease skipped; jitter bounds; - static: the module uses
make_clientonly.
- Web: vitest for the stepper (step gating, skip, finish), ports table, version footer;
Playwright screenshots of
/setup(each step), the ports tab and the updates card on mocks. - CI
composejob: add one run withSENSOR_TELNET=0 SENSOR_SIP=0that asserts those containers do not exist and the stack is healthy. - CI
release.ymlsmoke job (§5.3).
10. Task split
Section titled “10. Task split”Contracts every task respects: §2.4 (scripts/lib/env.sh names), §3.1 (SENSOR_*), §4.2
(routes and JSON), §5.1 (image and build-arg names), §8.
| Task | Agent / model | Owns (files) | Wave |
|---|---|---|---|
| A. Installer and env tooling | backend-engineer, sonnet | scripts/install.sh, scripts/lib/env.sh, scripts/env-set.sh, scripts/env-migrate.sh, scripts/guardrails/tests/test_install.py, scripts/guardrails/tests/test_env_set.py, .gitignore (install.log, .suckerpot-state/) | 1 |
| B. Compose, Dockerfiles, low memory, guards | backend-engineer, sonnet (security review) | docker-compose.yml, docker-compose.lowmem.yml, docker-compose.ci.yml, every */Dockerfile (ARG/ENV/LABEL only), redis/entrypoint.sh (REDIS_MAXMEMORY_MB), vpn/entrypoint.sh (§11), scripts/check-ports.sh, scripts/check-vpn-env.sh, scripts/guardrails/tests/test_compose.py, test_lowmem.py, .github/workflows/ci.yml (compose job only) | 1 |
| C. Release workflow | backend-engineer, sonnet | .github/workflows/release.yml, scripts/verify-images.sh | 2 (after B) |
| D. Update and rollback | backend-engineer, sonnet | justfile (up mode handling, update, rollback, sensors, set, version, verify-images, install), scripts/update.sh, scripts/rollback.sh | 2 (after A’s lib/env.sh) |
| E. Backend | backend-engineer, sonnet | db/migrations/versions/0025_setup_progress.py, db/suckerpot_db/models.py (one model), db/suckerpot_db/roles.py (grants), api/api/setup_wizard.py, api/api/version.py, api/api/setup_status.py, api/api/routes.py, ingest/ingest/updatecheck.py, ingest/ingest/main.py (wiring), tests | 1 |
| F. Web | frontend-engineer, sonnet | web/src/pages/setup.tsx, web/src/components/setup/**, web/src/lib/setup-wizard.ts, web/src/lib/version.ts, web/src/app/app-shell.tsx (footer version), web/src/app/router.tsx, web/src/pages/settings.tsx (ports tab, updates card), web/src/mocks/** additions, web/src/lib/help.ts entries | 1 (mocks), 2 (wire) |
| G. Docs | docs-writer, haiku | docs/install.md (new), docs/getting-started.md, docs/deployment.md (Upgrading, ports, images), docs/configuration.md, docs/index.md (toctree), README.md (quick start), CHANGELOG.md, .env.example (new variables) | 3 |
Reviews:
security-reviewer(opus) on A, B, C, D, E: secrets handling, no IP lookup, guardrails, update check privacy, vpn idle mode.ui-designeron F screenshots.qa-runner:just check,just guardrails,just test-integration, and a dry-run install. Neverdocker composeagainst the live project.
11. The VPN when nothing is configured yet
Section titled “11. The VPN when nothing is configured yet”The installer always sets COMPOSE_PROFILES=egress so the setup step can upload a WireGuard file
(M23). Today vpn with neither .env values nor an upload makes gluetun fail in a loop. Change
(task B):
vpn/entrypoint.shgets an idle mode: when no uploadedwg.envexists andVPN_WG_PRIVATE_KEYis empty, it logsno VPN configuration yet; waiting, does not start gluetun, and watcheswg.envevery 5 s; when it appears it exits 0 and restarts on it (existing restart path).- The compose healthcheck becomes
["CMD", "/bin/sh", "/vpn/health.sh"]: healthy when the marker/tmp/vpn-idleexists, else the gluetun healthcheck. - In idle mode there is no proxy and no tunnel: every gate stays closed (
no_vpn), which is fail closed. scripts/check-vpn-env.shaccepts “allVPN_WG_*empty” (upload expected) and still fails on a partial set.- Guardrail:
vpn/health.shis mounted read-only like the entrypoint.
12. Risks and open questions
Section titled “12. Risks and open questions”deploy.replicas: 0behaviour across Compose versions (§3.1). Verify first; fallback ready.- arm64 builds of
yara-python(collector) and ofasyncssh’s crypto wheels: the native arm64 runner shows it in the first run. A sensor that cannot build on arm64 is listed as amd64-only in the install guide rather than blocking the release. - The pinned gluetun digest must be the multi-arch index digest, not the amd64 manifest.
- GHCR packages are private by default: owner action.
COOKIE_SECURE=falsefor loopback-only installs follows the existing docs. If the owner later adds an HTTPS tunnel,just set COOKIE_SECURE=true(the setup checklist warns whenAPI_ALLOWED_ORIGINSis set and cookies are not secure).- Many feature switches stay in
.env(ABUSE_REPORTS_ENABLED,COLLECTOR_ENABLED, …). Moving them to the database is possible later; M28 only shows thejust setcommand.