Skip to content

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.

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.

#InvariantEnforced by
P1The 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
P2Every 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
P3The 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
P4The 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)
P5The 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
P6External 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)
P7Migrations stay forward-only. Every just update takes a database dump first and records how to roll back.scripts/update.sh (§6)

No curl | bash. The install guide says:

Terminal window
git clone --depth 1 --branch v0.8.0 https://github.com/<owner>/suckerpot.git
cd suckerpot
./scripts/install.sh

The compose file, the scripts and the justfile come from the release tag. Images come from GHCR (default) or are built from this checkout (--source).

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)”
  1. Preflight.
    • Linux, x86_64 or aarch64. Bash ≥ 4.
    • Not inside a linked git worktree (same test as _need-project).
    • docker version works for this user (or advise sudo 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.
    • .env exists → stop: “SuckerPot is already installed here. Use just update.”
    • Project and prefix (default suckerpot for both) must match ^[a-z0-9][a-z0-9_-]{0,40}$ and must not be sp-it-*, sp-test, sp-ci.
    • docker volume inspect <prefix>_db-data succeeds → 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.
  2. 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 fill API_ALLOWED_HOSTS and API_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 into VPN_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.
  3. Port check.
    • For each chosen sensor, ss -Hltnu shows 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 edits sshd_config).
    • It also asks for ADMIN_SSH_PORT so check-ports.sh can protect it.
  4. Secrets. scripts/lib/env.sh holds the list SECRET_VARS (every POSTGRES_*_PASSWORD, every REDIS_*_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 for SESSION_SECRET); SETTINGS_ENCRYPTION_KEY is head -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 openssl dependency.
  5. Admin hash. Images mode: docker pull the api image of --version. Source mode: docker build -f api/Dockerfile -t suckerpot-install-api .. Then printf '%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.
  6. Write .env.
    • umask 077. Copy .env.example to .env.tmp in the repo folder.
    • Replace each NAME= line with awk (no sed -i on secrets).
    • Uncomment and set COMPOSE_PROJECT_NAME and SUCKERPOT_VOLUME_PREFIX.
    • Write ADMIN_PASSWORD_HASH='...' in single quotes.
    • Set COMPOSE_PROFILES=egress, SUCKERPOT_INSTALL_MODE, SUCKERPOT_VERSION (images mode), the SENSOR_* values and the COMPOSE_FILE line when low-memory (§5.2).
    • mv .env.tmp .env; chmod 600 .env.
    • Check: no change-me left, every SECRET_VARS set.
  7. Volumes. scripts/ensure-volumes.sh (unchanged; creates, never deletes).
  8. Guards. scripts/check-ports.sh, scripts/check-vpn-env.sh.
  9. Start. Images: docker compose pull && docker compose up -d --wait --wait-timeout 600. Source: docker compose up -d --build --wait --wait-timeout 900. Then curl -fsS http://<bind>:<port>/healthz.
  10. Summary.
    • The URL; for bind 127.0.0.1 on a remote machine, also the hint ssh -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.

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 as check-ports.sh’s get);
  • env_set_line FILE NAME VALUE (awk rewrite, keeps comments, appends when missing);
  • gen_secret LEN;
  • SECRET_VARS;
  • SETTABLE_VARS (the allow-list for just 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.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_STREAMS is unchanged: an empty stream costs nothing.
  • The installer still generates every REDIS_SENSOR_*_PASSWORD, so enabling a sensor later needs no new secret.
  • scripts/check-ports.sh: every SENSOR_* must be 0 or 1. 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: every sensor-* service has deploy.replicas equal to ${SENSOR_<NAME>:-1} and no other deploy key; no non-sensor service uses deploy.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,web sets the twelve SENSOR_* lines, runs check-ports.sh, then docker compose up -d --wait.
  • just sensors alone prints the current selection.
  • just set KEY=VALUE [KEY=VALUE ...] → scripts/env-set.sh set ..., then docker compose up -d --wait.
    • Only keys in SETTABLE_VARS are 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 (except NETWORK_HUB_KEY), VPN_WG_*.
    • Values must match ^[A-Za-z0-9._,:/@+-]{0,512}$. Prints names only.

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.

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)”
MethodPathWhoBody / answer
GET/api/v1/setup/progressadmin{state: "new"|"in_progress"|"finished"|"skipped", steps: {...}, show: bool}. show is true when state is new or in_progress
PUT/api/v1/setup/progressadmin, 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/portsadmin{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/versionany signed-in user{version, build: "image"|"source", commit, latest, update_available, checked_at, check: "ok"|"off"|"no_egress"|"error"|"pending", release_url}
  • Ports:
    • external_port is the standard service port (22, 23, 80/443, …), the port to open on the router.
    • host_port comes from *_HOST_PORT.
    • state is receiving when the sensor sent an event in the last 24 h, waiting otherwise, off when SENSOR_<NAME>=0.
    • The API gets the SENSOR_* and *_HOST_PORT variables 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 receiving is the proof.
  • release_url is built from a constant (UPDATE_CHECK_REPO), never taken from a response.

setup_status.py (M24) changes:

  • the sensors item counts only enabled sensors, so a disabled sensor is not “silent”;
  • new item ports (todo while an enabled sensor is waiting after 2 h of uptime, href /settings?tab=ports);
  • new item updates (ok, warning when an update is available, off when the check is off).
  • Route. /setup is a full-page stepper inside the app shell, admin only.
  • When it opens. After sign-in, when GET /setup/progress says show, 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):

idTitleContentDone when
welcomeWelcomeWhat SuckerPot is, the three warnings of getting-started.md (dedicated machine, no credentials on it, dashboard never public)Next
sensorsYour sensorsEnabled 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 textNext
portsOpen the portsThe ports table (§4.4) with a per-row live stateNext (never blocks)
vpnAnonymous egressGate state. The M23 upload card (reused component components/vpn/*). Why it matters in two sentencesGate open, or “Skip for now”
selfYour own addressThe M22 self-terms card (masked, reveal for admin, add manual). Explains that reports, alerts and (M29) the network stay locked until one address is confirmedA confirmed or manual self address, or skip
reportingAbuse reports (optional)Reply contact field (M22 settings). ABUSE_REPORTS_ENABLED state with just set ABUSE_REPORTS_ENABLED=trueSaved, or skip
alertsAlerts (optional)Link to the alert channel dialogSkip or done
networkThe SuckerPot network (optional)Reserved for M29. Hidden while GET /api/v1/network/status answers 404M29
doneYou are setThe 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-motion respected;
  • no new colours (sensor hues only on sensor cards; warning token for “waiting”);
  • mobile 390 px: one column, sticky Back/Next bar.

Settings gets a tab “Sensors & ports” (/settings?tab=ports), the same table as the setup step.

ColumnExample
SensorSSH (sensor colour dot)
Open on your routerTCP 22
Forward to this machine’s port2222
StatusReceiving / 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.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=unknown
    ENV SUCKERPOT_RUNNING_VERSION=$VERSION SUCKERPOT_BUILD_KIND=$BUILD_KIND SUCKERPOT_COMMIT=$COMMIT
    LABEL org.opencontainers.image.version=$VERSION org.opencontainers.image.revision=$COMMIT

    The 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 up exports SUCKERPOT_BUILD_VERSION=$(git describe --tags --always --dirty) and SUCKERPOT_BUILD_COMMIT=$(git rev-parse --short HEAD) before docker compose up --build.

  • Images mode: .env has SUCKERPOT_INSTALL_MODE=images and SUCKERPOT_VERSION=v0.8.0; just up runs docker compose up -d --wait --no-build, which pulls missing images.

  • Guardrail: every image: of a built service uses ${SUCKERPOT_IMAGE_PREFIX and :${SUCKERPOT_VERSION; no latest tag anywhere; vpn stays 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.

ServiceNormalLow memory
db1g, shared_buffers 256MB, effective_cache_size 768MB, max_connections 50, timescaledb workers 4512m, 96MB, 256MB, 30, 2 (command replaced, shm_size: 128m)
redis384m, maxmemory 192mb192m, REDIS_MAXMEMORY_MB=96 (new variable read by redis/entrypoint.sh, default 192)
ingest384m320m
api512m320m
analyzer512m256m (and its child limit env lowered to match)
collector256m192m
each sensor128 to 256m128m, 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_MB or _LIMIT_MB.
  • scripts/guardrails/tests/test_lowmem.py merges base + overlay and asserts that rule, that every guardrail of test_compose.py still passes on the merged file, and that each sensor’s *_MEMORY_LIMIT_MB equals its mem_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*.*.*'] and workflow_dispatch (input tag).
  • Permissions: contents: read, packages: write, id-token: write (cosign keyless). Actions pinned by SHA like ci.yml.
  • Job build: matrix image × arch.
    • Images: redis, migrate, ingest, api, collector, demo and the 12 sensors.
    • Arch: amd64 on ubuntu-24.04, arm64 on ubuntu-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 create with tags <tag> and <major>.<minor> (never latest), then cosign 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 /healthz and /api/v1/version, then docker compose down and remove the sp-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.
  • Speed-up for arm64: in api/Dockerfile, the web build stage uses FROM --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 of SUCKERPOT_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):

  1. 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 backup service healthy.
    • Target version: the argument, or the newest v* tag after git fetch --tags (source mode on a branch: git pull --ff-only instead).
    • Print the current and target versions and the tag’s release notes (git tag -l --format='%(contents)' <tag>). Ask to confirm (--yes skips).
  2. Backup. docker compose exec -T backup /bin/sh /backup-service.sh now (same as just backup). Failure stops the update unless --no-backup (which asks to type no backup). Record the dump name.
  3. State file. Write .suckerpot-state/last-update.json (gitignored, mode 0600): {from_version, from_ref (commit), mode, dump, images: {service: digest}, at}.
  4. Code. git checkout <tag> (detached, images and source tag mode) or the --ff-only pull.
  5. Environment. scripts/env-migrate.sh: adds any SECRET_VARS missing from .env with 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.
  6. Images. Images mode: set SUCKERPOT_VERSION=<tag> in .env, docker compose pull. Source: docker compose build.
  7. Start. docker compose up -d --wait --wait-timeout 600 (migrate runs first as always).
  8. Health. curl -fsS /healthz, every service healthy in docker compose ps, and /api/v1/version answers the target version.
  9. 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>, restores SUCKERPOT_VERSION (images) and docker compose pull or 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.

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 (default false; the installer asks). It only ever runs through the egress gate: with no vpn, 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/latest through ingest.egress.make_client, follow_redirects=False, 10 s timeout, 64 KiB body cap.
    • Headers: Accept: application/vnd.github+json and User-Agent: Mozilla/5.0 only. No If-None-Match, no cookie, no token.
    • UPDATE_CHECK_REPO is 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) and prerelease/draft (skipped when true). Nothing else is kept. Release notes are never fetched or displayed from the response.
  • Storage. enrichment_status row update_check, detail {latest, published_at, checked_at, state}. The API compares with SUCKERPOT_RUNNING_VERSION (semver).
  • Never downloads, pulls, or runs anything; just update stays a human action on the host.
ObserverLearnsWhy acceptable
GitHubA VPN exit address fetched the latest release of a public repo, about once a daySame as any visitor; no version, no instance id, no cookie; the exit is shared with other VPN users
VPN providerA TLS connection to api.github.comAlready sees all egress
AttackersNothingNo inbound change
Correlation with the collectorThe same exit fetched attacker URLs and GitHubLinks “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.

VariableDefaultWhereMeaning
SENSOR_SSH … SENSOR_SYNOLOGY1compose, api1 = sensor on, 0 = off
SUCKERPOT_INSTALL_MODEsourcejust, scriptsimages or source
SUCKERPOT_VERSIONdevcomposeImage tag to run (images mode)
SUCKERPOT_IMAGE_PREFIXghcr.io/selfhostr/suckerpotcomposeRegistry and name prefix
SUCKERPOT_BUILD_VERSION, SUCKERPOT_BUILD_COMMITset by just upbuild argsSource builds only
UPDATE_CHECK_ENABLEDfalseingest, apiDaily check through the VPN
UPDATE_CHECK_REPOthe public repoingest, apiowner/name
REDIS_MAXMEMORY_MB192redisLow-memory overlay sets 96
COMPOSE_FILEunsetcomposeSet by the installer for low memory

All documented in .env.example and docs/configuration.md.

  • scripts/guardrails/tests/test_install.py:
    • runs bash scripts/install.sh --yes --dry-run --env-out <tmp>/.env --sensors ssh,web --admin-password-stdin in a temp copy of the repo files it needs;
    • asserts mode 0600, every SECRET_VARS present, ≥ 24 chars, pairwise distinct, no change-me, COMPOSE_PROJECT_NAME and SUCKERPOT_VOLUME_PREFIX set, SENSOR_SSH=1, SENSOR_TELNET=0, hash single-quoted;
    • refusal cases: existing .env, reserved project names, bad prefix, linked worktree (a temp git worktree);
    • the script source contains no ipify, icanhazip, ifconfig.me or curl to a non-local URL (static grep).
  • test_env_set.py: env-set.sh set refuses secret-like keys and bad values, keeps comments, appends missing keys; env-migrate.sh adds 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; ports states; version comparisons (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_name ignored; prerelease skipped; jitter bounds;
    • static: the module uses make_client only.
  • 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 compose job: add one run with SENSOR_TELNET=0 SENSOR_SIP=0 that asserts those containers do not exist and the stack is healthy.
  • CI release.yml smoke job (§5.3).

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.

TaskAgent / modelOwns (files)Wave
A. Installer and env toolingbackend-engineer, sonnetscripts/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, guardsbackend-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 workflowbackend-engineer, sonnet.github/workflows/release.yml, scripts/verify-images.sh2 (after B)
D. Update and rollbackbackend-engineer, sonnetjustfile (up mode handling, update, rollback, sensors, set, version, verify-images, install), scripts/update.sh, scripts/rollback.sh2 (after A’s lib/env.sh)
E. Backendbackend-engineer, sonnetdb/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), tests1
F. Webfrontend-engineer, sonnetweb/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 entries1 (mocks), 2 (wire)
G. Docsdocs-writer, haikudocs/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-designer on F screenshots.
  • qa-runner: just check, just guardrails, just test-integration, and a dry-run install. Never docker compose against 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.sh gets an idle mode: when no uploaded wg.env exists and VPN_WG_PRIVATE_KEY is empty, it logs no VPN configuration yet; waiting, does not start gluetun, and watches wg.env every 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-idle exists, 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.sh accepts “all VPN_WG_* empty” (upload expected) and still fails on a partial set.
  • Guardrail: vpn/health.sh is mounted read-only like the entrypoint.
  1. deploy.replicas: 0 behaviour across Compose versions (§3.1). Verify first; fallback ready.
  2. arm64 builds of yara-python (collector) and of asyncssh’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.
  3. The pinned gluetun digest must be the multi-arch index digest, not the amd64 manifest.
  4. GHCR packages are private by default: owner action.
  5. COOKIE_SECURE=false for loopback-only installs follows the existing docs. If the owner later adds an HTTPS tunnel, just set COOKIE_SECURE=true (the setup checklist warns when API_ALLOWED_ORIGINS is set and cookies are not secure).
  6. Many feature switches stay in .env (ABUSE_REPORTS_ENABLED, COLLECTOR_ENABLED, …). Moving them to the database is possible later; M28 only shows the just set command.