Installation - sparkycz1/honeypot-shelf GitHub Wiki

๐Ÿš€ Installation

One docker compose up, and the fleet-watching begins.

Quick start (Docker) โ€” recommended

git clone https://github.com/sparkycz1/honeypot-shelf.git
cd honeypotshelf
python3 scripts/setup.py

scripts/setup.py is a self-contained, pure-stdlib wizard (needs only a system python3 and Docker โ€” nothing from this project's own virtualenv): generates every secret (SECRET_KEY, ENCRYPTION_KEY, POSTGRES_PASSWORD, REDIS_PASSWORD, INFORM_TOKEN), asks a handful of questions (timezone, whether to use the bundled Caddy reverse proxy and its domain/email, whether to add the optional VPN sidecar (docker-compose.vpn.yml โ€” no provider/key/config asked here, that's all Settings โ†’ VPN once the app is running, see Architecture's "VPN connectivity" section), whether the app's port should only accept local connections, the facts/reachability check intervals, event retention, the deploy-wide default UI language (DEFAULT_LANGUAGE โ€” what a fresh account/anonymous request renders in; anyone can still switch for themselves any time in My account โ†’ Language), the superadmin password (typed twice, masked โ€” no auto-generate option any more, so it's never echoed or printed anywhere) and the host port), writes .env, applies the Alembic migration, brings the stack up, waits for it to become healthy, and creates the first superadmin account (admin). Re-running against an existing .env just tops it up with any new .env.example variables and restarts the stack (auto-detecting whether Caddy/the VPN sidecar were running, same as scripts/upgrade.sh does) โ€” never regenerates secrets or touches your data.

Once it finishes, log in and create at least one Company and one Honeypot from the Companies/Honeypots pages (both superadmin-only) โ€” nothing shows up on the Dashboard before that. Pin that honeypot's SSH host key and events start arriving automatically, no setup needed on the Pi itself โ€” see Honeypot Management.

Manual setup

If you'd rather configure everything by hand instead of using scripts/setup.py:

cp .env.example .env
python3 scripts/generate_secrets.py

Paste the printed values (SECRET_KEY, ENCRYPTION_KEY, POSTGRES_PASSWORD, REDIS_PASSWORD, INFORM_TOKEN) into .env. Optionally set TZ (e.g. Europe/Prague); defaults to UTC. Then:

docker compose up -d --build

alembic/versions/ already ships the initial schema migration; the one-shot migrate service applies it automatically (alembic upgrade head) before web/worker/beat start. Need a new migration? See Development.md.

The app listens on APP_PORT (default 8080, plain HTTP, all interfaces by default โ€” meant to sit behind a TLS-terminating reverse proxy; set APP_BIND_ADDRESS=127.0.0.1 in .env โ€” no docker-compose.yml edit needed โ€” or firewall the port off if you don't want that). Point your own nginx/Traefik/Caddy at it โ€” see the reverse-proxy guides: nginx ยท Traefik ยท Caddy (standalone). Or use the bundled Caddy (automatic HTTPS via Let's Encrypt): set DOMAIN and ACME_EMAIL in .env, point that domain's DNS at this host, open ports 80/tcp, 443/tcp and 443/udp, then docker compose -f docker-compose.yml -f docker-compose.caddy.yml up -d --build.

If a honeypot sits behind a NAT with no port forwarded to it, add -f docker-compose.vpn.yml too (combine freely with docker-compose.caddy.yml above) and connect either NetBird or WireGuard from Settings โ†’ VPN once the app is running โ€” see Architecture's "VPN connectivity" section for what this does, which of the two to pick, and why it's a separate container.

The NetBird client built into the image is pinned to an exact version by NETBIRD_VERSION in .env (see .env.example) โ€” docker-compose.yml passes it to the build, and a build without it stops with a message saying so. Bump it there deliberately, then rebuild: a newer client may not read the identity an older one registered, and then needs a fresh setup key once.

Then create the first superadmin account:

docker compose exec web python scripts/create_admin.py --username admin

Log in, then create at least one Company and one Honeypot from the Companies/Honeypots pages โ€” nothing shows up on the Dashboard before that.

Warning

The app speaks plain HTTP only. Always put TLS termination in front of it, and firewall its port off (or set APP_BIND_ADDRESS=127.0.0.1 in .env) if you don't want it reachable directly.

Warning

Two features are browser-disabled outright on plain HTTP, for any origin other than http://localhost โ€” entirely absent from window/ navigator, no server-side workaround: WebAuthn/passkeys (My account โ†’ Passkeys shows "This browser doesn't support passkeys" even in a browser that does) and the honeypot terminal's clipboard copy/paste (Ctrl+C/Ctrl+V and right-click copy; native Ctrl+V still works, since that skips the Clipboard API). Both need a real "secure context" โ€” HTTPS (an https:// reverse proxy, Caddy or otherwise) or http://localhost on the machine Honeypot Shelf runs on. A plain HTTP LAN IP/hostname (http://192.168.1.x:8080) satisfies neither, no matter how the app or host firewall is configured.

No public domain needed on a LAN-only deployment. A browser treats any https:// origin as secure regardless of certificate trust โ€” a self-signed one is enough, at the cost of a one-time "proceed anyway" click per client. The bundled Caddy can mint one itself: in ./Caddyfile, replace the site address with tls internal โ€”

:443 {
    tls internal
    reverse_proxy web:8080
}

then docker compose -f docker-compose.yml -f docker-compose.caddy.yml up -d --build and open https://<this-host's-LAN-IP>. DOMAIN/ACME_EMAIL aren't needed here. To stop the browser warning permanently, install Caddy's local CA on each client (docker compose exec caddy caddy trust prints where to find it) โ€” cosmetic only, WebAuthn/clipboard work either way once the page loads over https://.

Already have HTTPS via a reverse proxy and still seeing this? The app also needs to know the request arrived as HTTPS โ€” otherwise it builds/verifies URLs as if still plain HTTP, failing WebAuthn ("Unexpected client data origin") and OIDC login the same way. Fixed by TRUSTED_PROXY_IPS (.env.example, default *) โ€” already on for every setup above. See app/core/proxy_headers.py and Authentication & RBAC for the full story.

Audit log / rate limiter showing the proxy's IP instead of the real client's? A separate, off-by-default fix โ€” TRUST_FORWARDED_FOR (.env.example) โ€” trusting it from anyone would let an attacker defeat the login rate limiter by spoofing a new "source" every attempt. Turn it on once TRUSTED_PROXY_IPS is narrowed to your real proxy (not *).

Postgres tuning

docker-compose.yml's db service applies a handful of Postgres tuning flags from the very first docker compose up (shared_buffers, effective_cache_size, work_mem, maintenance_work_mem, and two autovacuum_*_scale_factor settings) โ€” sized for a fleet up to roughly 100 honeypots while staying RAM-conscious, well under Postgres' own stock "assume a big dedicated box" numbers. Override any of them via the matching POSTGRES_* variable in .env (see .env.example) for a bigger fleet/host, or a smaller/constrained one.

Custom logo & favicon

By default, Honeypot Shelf shows its own built-in three-hexagon mark in the nav bar, login/two-factor pages, and browser tab โ€” an outline in the brand's own amber, which reads fine on any background so it needs no light/dark switching of its own. To replace it with your own:

# In .env โ€” either works for LOGO_SOURCE and FAVICON_SOURCE independently:
LOGO_SOURCE=https://example.com/my-logo.svg   # a URL โ€” fetched by `web` itself, not the browser
LOGO_SOURCE=/app/branding/logo.svg            # a file path readable inside the `web` container

A filesystem path needs the file mounted into the container first โ€” uncomment the branding volume on the web service in docker-compose.yml, drop the file(s) into a local ./branding/ directory, and point LOGO_SOURCE/FAVICON_SOURCE at /app/branding/<filename>. Any common image format works. Restart web after changing either โ€” see .env.example/app/web/branding.py for how a URL vs. a local path is told apart.

A URL is fetched by web itself and re-served from its own origin (/branding/logo//branding/favicon), cached in memory for that process's lifetime โ€” never linked to directly. Only an image response (image/*, or no type with an image file extension) of at most 5 MB is accepted; anything else โ€” an HTML error page, a login portal โ€” is ignored and the built-in logo stays, so nothing but an image is ever served from this app's origin. This app's CSP only allows img-src 'self' data:; ("no CDN" is a deliberate stance, see Architecture), so a browser would otherwise just refuse to load a third-party image URL.

Updating

./scripts/upgrade.sh

Pulls the latest code, syncs any new .env.example variables into your .env (scripts/env_sync.py), rebuilds, and re-applies migrations. Auto-detects whether the bundled Caddy or the VPN sidecar are currently running (by their Compose service label, not an .env flag) and includes the matching overlay file(s) automatically โ€” nothing to pass by hand. Afterwards it removes the images the stack no longer uses โ€” the previous Honeypot Shelf build and any image a version bump replaced (only this stack's own, never another project's); --no-cleanup keeps them.

Stopping and starting

./scripts/stop.sh
./scripts/start.sh

stop.sh stops every container (docker compose stop โ€” nothing removed, your data stays exactly as it was); start.sh starts them again. Both auto-detect whether Caddy and/or the VPN sidecar are part of this deployment (by each container's own Compose service label, not .env), so it's the same one command regardless of which overlay file(s) you're actually running. start.sh refuses to run (pointing at scripts/ setup.py instead) if it finds no existing containers โ€” it only starts a stack already set up once, it doesn't create one.

For a one-off restart of a single service (e.g. after editing Caddyfile), plain docker compose restart <service> still works โ€” these two scripts are for stopping/starting everything together.

Backups

./scripts/backup.sh

Writes one timestamped directory under ./backups/ (override with BACKUP_DIR in .env) holding everything needed to rebuild this instance from nothing on a fresh host:

  • db.sql.gz โ€” a pg_dump of the whole database, taken live via Postgres's own MVCC snapshot (the stack does not need to be stopped).
  • env.backup โ€” a copy of .env. In particular ENCRYPTION_KEY: every encrypted secret in the database (honeypot passwords/private keys, LDAP/OIDC client secrets, NetBird/WireGuard config, TOTP secrets โ€” see Architecture) is encrypted with it, so a database restored under a different key turns those permanently unreadable โ€” no recovery after the fact, not even by hand. (Unlike debcontrol, Honeypot Shelf keeps no separate SSH identity volume to back up โ€” every honeypot's credential already lives in the DB, covered by db.sql.gz.)

Old backup directories are pruned automatically โ€” anything older than BACKUP_RETENTION_DAYS (default 14, override in .env) is deleted at the end of every run, safe to leave running unattended forever.

The backup directory holds secrets in the clear (env.backup, and every value the DB dump can decrypt combined with it) โ€” created chmod 600-ish but that only protects against other local accounts on the same host. Copy it somewhere access-controlled and off this host (object storage, another server's backup job pulling over rsync/scp, ...) rather than trusting a local disk alone; losing the host and ./backups/ together is the same as never having backed up at all.

From the web UI or the REST API

Settings โ†’ Backup & restore (superadmin) downloads one file (honeypotshelf-backup-<time>.hsbak) with everything the database holds โ€” honeypots and their credentials, companies, accounts and memberships, scheduled tasks, notification rules, settings, the SSH identity key, OpenCanary events, the audit log and all history. It is encrypted with a passphrase you choose (min. 12 characters; scrypt + AES-256-GCM in authenticated chunks โ€” a wrong passphrase or a damaged/truncated file is refused). It also carries ENCRYPTION_KEY, so it restores onto a fresh instance with another key too: every stored secret is re-encrypted under the target's key. .env is not included (the target keeps its own) โ€” for that, use scripts/backup.sh above. For scheduled off-site copies, a superadmin API token can POST /api/v1/backup with {"passphrase": "โ€ฆ"} and save the response body.

Automatic backups (same tab, superadmin): switch them on, choose "every N hours" (1โ€“744), how many to keep (1โ€“365) and a passphrase. The worker then writes the same .hsbak file to backups/ on the app_data volume (/app/data, DATA_DIR) and removes all but the newest N.

  • The passphrase is stored encrypted in the database so backups can run unattended. It is never shown again; a restore asks for it.
  • The tab lists the stored files with download and delete, shows when the last run was and why it failed, and has Back up now.
  • A failed run is recorded on the tab and in the audit log (backup.auto.run). There is no notification for it: rules here only cover honeypot alerts and availability.
  • The app_data volume is new in 0.59.0 and is mounted into web and worker by docker-compose.yml. With your own compose file, add the same mount to both, or the worker's backups are not visible to the web container.
  • The files sit next to the application. They cover a bad change or a damaged database, not the loss of the host โ€” copy them elsewhere:
# newest stored backup, through the REST API
NAME=$(curl -fsS -H "Authorization: Bearer $TOKEN" https://honeypots.example.com/api/v1/backup/auto | jq -r '.backups[0].name')
curl -fsS -H "Authorization: Bearer $TOKEN" -o "$NAME" "https://honeypots.example.com/api/v1/backup/auto/files/$NAME"

REST API (superadmin token): GET/PUT /api/v1/backup/auto (schedule; the passphrase is write-only), POST /api/v1/backup/auto/run, GET/DELETE /api/v1/backup/auto/files/{name}.

Restore (web only): upload the file, enter its passphrase and type RESTORE. Every table is emptied and refilled in one transaction โ€” nothing changes if anything fails โ€” then everyone is signed out and signs in with an account from the backup. The backup must come from the same version (database revision); restore onto that version, then upgrade. Behind a reverse proxy, raise its upload limit first (nginx: client_max_body_size). Both actions are audit-logged.

Automating it with cron

Run it daily at, say, 03:15 server time โ€” as the same user that normally runs docker compose here (needs Docker socket access), with output mailed/logged rather than silently discarded so a failure doesn't go unnoticed:

crontab -e
15 3 * * * cd /path/to/honeypotshelf && ./scripts/backup.sh >> /var/log/honeypotshelf-backup.log 2>&1

Adjust /path/to/honeypotshelf to the actual checkout path (pwd from inside it), and make sure /var/log/ (or wherever you point the log) is writable by that user โ€” touch /var/log/honeypotshelf-backup.log && chown that-user /var/log/honeypotshelf-backup.log if it isn't yet. Check the log after the first scheduled run to confirm it actually succeeded, and periodically after that โ€” a cron job that silently stopped working is worse than no backup job, since it looks like there's one until the day you need it.

If you'd rather ship backups straight off the host instead of relying on someone to sync ./backups/ separately, append a second line to the same cron entry (or a follow-up cron job a few minutes later) that rsync/scp/aws s3 syncs the freshly-created directory (or the whole BACKUP_DIR) to wherever your off-host storage is.

Restoring

./scripts/restore.sh backups/20260909T031500Z

Destructive โ€” replaces the current database and .env outright (the current .env is saved as .env.pre-restore first, never silently discarded). Requires typing restore to confirm (--yes skips that, for a scripted DR runbook). Stops web/worker/beat, drops and recreates the database from db.sql.gz, replaces .env, then starts the stack back up. Restore onto a checkout already on the version the backup was taken from โ€” run upgrade.sh afterward if you need to move it forward.

Upgrading stored secrets to AES-256-GCM

Every secret this app stores (honeypot passwords/private keys, LDAP/OIDC client secrets, NetBird/WireGuard config, TOTP secrets) has used AES-256-GCM since v0.17.0 โ€” see Architecture โ†’ FIPS alignment. A value encrypted by an older version is still read transparently forever (nothing breaks by doing nothing), but a deployment that would rather not carry any of the older AES-128 (Fernet) ciphertext going forward can upgrade every remaining one in a single optional pass:

docker compose exec web python scripts/reencrypt_secrets.py --dry-run  # see what would change
docker compose exec web python scripts/reencrypt_secrets.py            # actually upgrade it

Re-encrypts under the same ENCRYPTION_KEY โ€” this is a format upgrade, not a key rotation, and it's safe to run repeatedly (idempotent).

Locked out?

scripts/reset_account.py (console-only, same idea as debcontrol's) resets a password and/or disables TOTP for an account already locked out of the web UI:

docker compose exec web python scripts/reset_account.py --username admin

Non-interactively, set HONEYPOTSHELF_RESET_PASSWORD in the environment instead of being prompted (same reasoning as create_admin.py's HONEYPOTSHELF_ADMIN_PASSWORD โ€” it never shows up in a process listing the way a --password flag would).

See Development.md for running the test suite, linting/type-checking, and adding a migration.

โš ๏ธ **GitHub.com Fallback** โš ๏ธ