This project provides an example configuration how we deploy our eHacking platform on e-hacking.de.
.
├── .env # committed (hostnames, ports, base paths)
├── docker-compose.yml # committed (base — works on docker & podman)
├── compose.podman.yml # committed (podman overlay: cap_add, DOCKER_HOST)
├── Justfile # committed (operator recipes)
├── bin/ # committed (helper scripts)
│ ├── compose # runtime-agnostic compose wrapper
│ ├── runtime-env.sh # detects podman/docker, emits ENV
│ ├── make-auth.sh
│ ├── make-bot-env.sh
│ ├── make-credentials.sh
│ ├── make-flags.sh
│ ├── backup.sh # snapshot secrets + volumes → tarball
│ ├── restore.sh # restore a snapshot (runtime-agnostic)
│ ├── init-podman.sh # bare-server prep for rootless podman
│ └── update.sh
├── traefik/dynamic/basicauth.yml # gitignored — bcrypt hashes
├── cloudflare.env # gitignored — DNS-01 token
├── bot.env # gitignored — recruiting bot secret
├── credentials.env # gitignored — WildFly + catcher passwords
├── modules.env # gitignored, optional — COMPOSE_PROFILES override
├── flags_<module>.env # gitignored — per-module CTF flags
├── flag_xxe1.txt / xxe2.txt / xslt1.xml # gitignored — bind-mounted into xml-sec
└── (letsencrypt is a named docker volume, not a host directory)
Hostnames, ports and paths live in .env (committed). Everything else
is gitignored; here's how to create each:
| File | Purpose | How to create |
|---|---|---|
cloudflare.env |
Cloudflare API token used by Traefik for DNS-01 ACME. | echo 'CF_DNS_API_TOKEN=<token with Zone:DNS:Edit on your zone>' > cloudflare.env |
traefik/dynamic/basicauth.yml |
Shared basicauth middleware (recruiting-instructor + the dashboard router). Hot-reloaded by Traefik's file provider — rotate without restarting. |
just add-basicauth-user <name> (just init calls this for the first user) |
bot.env |
Internal secret for recruiting-bot ↔ recruiting-challenge auth. | ./bin/make-bot-env.sh |
credentials.env |
WildFly application principals (attacker, victim, admin, oemmes) and catcher access gates (/__catcher signup + /__instructor). |
./bin/make-credentials.sh |
flags_*.env |
Per-module challenge flags. Format: FLAG_<KEY>=<value> matching the ENV FLAG_* lines in each module's Dockerfile. Modules in scope: json-sec, oidc, rest-api-sec, saml, soap-sec, xml-sec, axis2-flag. crawling-maze is a separate project — its flags_crawling-maze.env is hand-managed. |
./bin/make-flags.sh (reads each published image's FLAG_* defaults, swaps each _dummy marker for a random token; same script also writes flag_xslt1.xml, flag_xxe1.txt, flag_xxe2.txt which xml-sec reads as bind-mounted files) |
The stack runs on either Podman or Docker. just and the helper
scripts auto-detect via bin/runtime-env.sh, in this preference order:
- Rootless Podman (best — no daemon, no root)
- Rootful Podman (acceptable fallback)
- Docker (last resort)
The detection probes for podman compose (the compose-go-based
wrapper). The legacy Python podman-compose is rejected because it
groups every service into a single pod, which collapses Traefik's
hostname-based routing.
Setup once per host:
# Rootless podman (recommended):
systemctl --user enable --now podman.socket
# Let traefik bind 80/443 without root:
echo 'net.ipv4.ip_unprivileged_port_start=80' | sudo tee /etc/sysctl.d/podman-lowports.conf
sudo sysctl --system
sudo loginctl enable-linger "$USER" # keep the user manager alive past logout
# Rootful podman (only if rootless is impractical):
sudo systemctl enable --now podman.socket
# Docker: nothing extra — used automatically if no podman socket is reachable.Force a specific runtime/socket by exporting before just:
RUNTIME=docker just up
CONTAINER_SOCKET=/run/podman/podman.sock just up # e.g. force rootful when both are runningjust up brings up every CTF module by default. To run only a subset,
create a gitignored modules.env listing the profiles you want:
# modules.env — skip soap, maze, passkeys
COMPOSE_PROFILES=json,oidc,rest,saml,web,xml,rookies,recruiting,catcherAvailable profiles (omit to skip):
| Profile | Services |
|---|---|
json |
json-sec |
oidc |
oidc, victim-bot |
rest |
rest-api-sec, couchdb |
saml |
saml |
soap |
soap-sec, axis2-flag, axis2-fake |
web |
web-sec |
xml |
xml-sec |
passkeys |
passkeys-app, passkeys-mongo |
maze |
crawling-maze |
rookies |
rookies |
recruiting |
recruiting-challenge, recruiting-bot, recruiting-instructor |
catcher |
catcher (also auto-starts when oidc or saml is enabled) |
Infrastructure (traefik, root, watchtower) is untagged and always
runs.
Preview which services would start without doing it:
./bin/compose config --services# One-off: drop in cloudflare.env, then bootstrap the rest.
echo 'CF_DNS_API_TOKEN=<token>' > cloudflare.env
just init
just upRotate every flag + credential (new semester etc.):
just reset # wipes managed secret + flag files, re-runs init
just upcloudflare.env, the letsencrypt docker volume, and
flags_crawling-maze.env are preserved by reset.
Day-to-day:
just update # git pull + compose pull + up -d
just add-basicauth-user X # append a user to traefik/dynamic/basicauth.yml
just logs # tail logs for all services
just logs catcher
just ps
just restart oidcIf just is not installed, the equivalent flat script works:
./bin/update.sh is the legacy one-shot of just update.
Everything that makes a deployment unique lives outside git: the gitignored secret/flag files and the stateful named volumes. Two recipes bundle and restore all of it, so moving e-hacking.de to a new server is copy-one-file-and-go.
just backup # → backups/ehacking-backup-<UTC>.tar.gzThe archive contains:
| Group | Contents |
|---|---|
| Files | cloudflare.env, bot.env, credentials.env, flags_*.env, flag_*.{txt,xml}, traefik/dynamic/basicauth.yml, optional modules.env / auth.env / .envrc |
| Volumes | catcher-data, recruiting-data, letsencrypt (certs — kept to dodge ACME rate limits), passkeys-instance-data, passkeys-mongo-data |
crawling-maze-sessions is deliberately excluded — ephemeral per-visitor
crawl state, regenerated on demand. Adjust the lists at the top of
bin/backup.sh if the deployment grows new stateful volumes.
Restore on the target host (idempotent; prompts before clobbering). It
lists the archives in backups/ and asks which one to use — and if the
host already holds deployment data, it offers to snapshot that first
before overwriting:
just restoreVolume data is streamed through a throwaway container on both ends, so a backup taken under Docker restores cleanly under rootless Podman and vice-versa — the in-container uids are reapplied via the user namespace on the target, not copied raw off the host.
On a fresh server with neither podman nor docker, init-podman automates
the whole "Container runtime" setup below (rootless podman, the compose
provider, subuid/linger/low-ports) and then restores a backup if one is
sitting in backups/:
git clone <repo> e-Hacking.de && cd e-Hacking.de
# drop your backup in: scp ehacking-backup-*.tar.gz server:e-Hacking.de/backups/
just init-podman # interactive; may ask for sudo
just upWith no backup present it stops short and tells you what a first-time
bring-up still needs (chiefly cloudflare.env, then just init).
The catcher service is mapped onto e-attacker.de — both the bare
host and any *.e-attacker.de subdomain. DNS-01 issues a wildcard cert
in one go, so no per-subdomain configuration is required. The wildcard
A-record (*.e-attacker.de → server IP) must exist in Cloudflare for
the wildcard cert to issue.
The legacy mendhak/http-https-echo:31 attacker container has been
replaced by the catcher; the bare e-attacker.de host still serves a
mendhak-compatible echo response so old attacker_url-style consumers
keep working.
bin/make-credentials.sh writes random passwords for oemmes, the
WildFly management user, and the catcher's three access gates
(CATCHER_SIGNUP_PASSWORD, CATCHER_SUPERUSER_PASSWORD,
CATCHER_INSTRUCTOR_PASSWORD). Look them up in credentials.env when
you need to log into /__instructor.
Student-facing CTF accounts (attacker, victim) keep intentional
weak defaults so the same login forms work as in the local-dev stack.
admin gets a random password (only used internally).
cd ~/e-Hacking.de
git pull # picks up bin/, Justfile, new compose
# (Once) migrate the old ./letsencrypt host directory into the named volume.
# Use whichever runtime your stack uses (docker or podman):
docker run --rm -v letsencrypt:/dst -v "$PWD/letsencrypt":/src \
alpine cp -a /src/. /dst/
# podman equivalent:
# podman run --rm -v letsencrypt:/dst -v "$PWD/letsencrypt":/src \
# alpine cp -a /src/. /dst/
# Old auth.env is unused; create the new basicauth file:
just add-basicauth-user admin
# Add a flags_oidc.env (OIDC has flag ENVs since feat-catcher-midp):
./bin/make-flags.sh
# Optional: rotate everything in one go
# just reset
just update # pulls catcher + victim-bot, recreates stack