Go to file
Fabian @ Blax Software 964bb394db feat(examples): docker-mailserver "fake VPS" scoped to one directory
A disposable jail container that bind-mounts only one directory; the
bastion's FORCE_COMMAND drops every SSH session into an interactive shell
inside it. The jail's own root fs is throwaway image data, so the only host
data reachable over the session is the mounted directory. Documents the
docker-socket tradeoff and the read-only / no-socket hardened variants.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 13:35:47 +02:00
config feat(broker): allowlist-gated command broker mode 2026-06-02 19:32:57 +02:00
examples feat(examples): docker-mailserver "fake VPS" scoped to one directory 2026-06-03 13:35:47 +02:00
scripts feat(broker): allowlist-gated command broker mode 2026-06-02 19:32:57 +02:00
.dockerignore A initial docker-bastion image 2026-05-28 10:50:06 +02:00
Dockerfile feat(broker): allowlist-gated command broker mode 2026-06-02 19:32:57 +02:00
README.md feat(examples): docker-mailserver "fake VPS" scoped to one directory 2026-06-03 13:35:47 +02:00
docker-bake.hcl A initial docker-bastion image 2026-05-28 10:50:06 +02:00

README.md

Blax Software OSS

docker-bastion

Alpine OpenSSH Docker CLI Image Size License

A minimal SSH + HTTP bastion for routing commands per authenticated session. Authenticate by SSH key or HTTP bearer token, and the container either runs one preconfigured command (FORCE_COMMANDdocker exec into a sibling, ./deploy.sh, nginx -s reload) or, in broker mode, runs whatever the client asks for as long as it matches a regex allowlist (ALLOWED_COMMANDS) — then streams the output back.

Why it exists: giving an agent or CI bot docker exec access usually means handing them the docker socket and trusting their entire toolchain not to misbehave. A bastion with a hard-coded FORCE_COMMAND is the inverse: the credential authorizes one specific thing, the surface is sshd + busybox httpd, and the same image works for a dozen different roles by varying FORCE_COMMAND.

Available Tags

Tag Base Notes
blaxsoftware/bastion:latest alpine 3.21 Default tag, follows alpine releases
blaxsoftware/bastion:alpine3.21 alpine 3.21 Pinned alpine version

Quick Start — drop into a WordPress container

The most common use: give a deploy agent SSH-shaped access into a running WordPress container. Every session lands inside the wordpress-app container's bash; clients can run WP-CLI commands, edit config, debug — same UX as ssh user@host against a VPS, but scoped to one container.

services:
  # The bastion. SSH on 2222, HTTP behind traefik on https://deploy-wp.example.com.
  bastion:
    image: blaxsoftware/bastion:latest
    restart: unless-stopped
    environment:
      # REQUIRED — the single command that runs on every authenticated session.
      # Shell metacharacters work: &&, ||, pipes, cd, redirects.
      FORCE_COMMAND: "docker exec -it wordpress-app bash"

      # OPTIONAL — enables the HTTP endpoint at /cgi-bin/run with basic auth.
      # Value is "user:password". Works with `curl https://user:pass@host/…`.
      # Without this (and without HTTP_TOKEN), the bastion is SSH-only.
      HTTP_BASIC_AUTH: "${BASTION_HTTP_BASIC_AUTH}"
      # Alternative: HTTP_TOKEN="$(openssl rand -hex 32)" for Bearer auth.
      # Pick one — they're mutually exclusive (Basic wins if both are set).

    volumes:
      # REQUIRED when FORCE_COMMAND talks to docker (docker exec, docker compose, etc).
      # Mounts the host's daemon socket so docker-cli inside the bastion reaches it.
      - /var/run/docker.sock:/var/run/docker.sock

      # OPTIONAL — host-sourced authorized_keys. Your laptop's keys, or anything
      # outside the repo. Read-only mount.
      - ~/.ssh/authorized_keys:/etc/bastion/authorized_keys.host:ro

      # OPTIONAL — repo-sourced authorized_keys. CI / deploy-bot keys committed
      # alongside the project. At least one of these two must exist or the
      # container refuses to start.
      - ./docker/bastion/authorized_keys:/etc/bastion/authorized_keys.repo:ro

      # REQUIRED — host keys persist across rebuilds. Bind mount, NEVER a named
      # volume; `docker compose down -v` would wipe a named volume and clients
      # would see "REMOTE HOST IDENTIFICATION HAS CHANGED" after every redeploy.
      - ./docker-data/bastion/keys:/etc/ssh/keys

    ports:
      # OPTIONAL — expose SSH on the host directly. Skip this entirely if you
      # only want the HTTP path through traefik.
      - "2222:22"

    labels:
      # OPTIONAL — traefik HTTP route. Visit https://deploy-wp.example.com/cgi-bin/run
      # with `Authorization: Bearer $BASTION_HTTP_TOKEN` to invoke FORCE_COMMAND.
      # Remove these labels if you don't want the HTTP path published.
      traefik.enable: "true"
      traefik.docker.network: "web"
      traefik.http.routers.bastion.rule: "Host(`deploy-wp.example.com`)"
      traefik.http.routers.bastion.entrypoints: "websecure"
      traefik.http.routers.bastion.tls: "true"
      traefik.http.services.bastion.loadbalancer.server.port: "8080"

    networks: [web]

  # Your actual WordPress container — bastion's FORCE_COMMAND targets it by name.
  wordpress-app:
    image: wordpress:latest
    container_name: wordpress-app
    environment:
      WORDPRESS_DB_HOST: db
      WORDPRESS_DB_NAME: wp
      WORDPRESS_DB_USER: wp
      WORDPRESS_DB_PASSWORD: "${DB_PASSWORD}"
    volumes:
      - ./docker-data/wordpress:/var/www/html
    networks: [web]

networks:
  web:
    external: true

From the client:

# Interactive shell inside the wp container — feels exactly like ssh-into-vps
ssh -p 2222 agent@your-host

# Or trigger from a URL — basic-auth protected, output streams back
curl https://user:pass@deploy-wp.example.com/cgi-bin/run

Quick Start — reload nginx on a webhook

A scoped bastion that does exactly one thing: test the new nginx config and reload if it passes. The HTTP path lets a CI job (GitHub Action, Forgejo runner, anything that can curl) trigger a reload after pushing new configs to disk — no SSH keys to provision in CI.

services:
  bastion:
    image: blaxsoftware/bastion:latest
    restart: unless-stopped
    environment:
      # `nginx -t` exits non-zero on a syntax error; `&&` short-circuits so a
      # broken config never gets applied. The exit code propagates back to
      # the HTTP client (which sees the connection close mid-stream on failure).
      FORCE_COMMAND: "docker exec nginx-app nginx -t && docker exec nginx-app nginx -s reload"
      HTTP_TOKEN: "${BASTION_HTTP_TOKEN}"
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
      # SSH path stays available for human debugging — same key, same scope.
      - ~/.ssh/authorized_keys:/etc/bastion/authorized_keys.host:ro
      - ./docker-data/bastion/keys:/etc/ssh/keys
    labels:
      traefik.enable: "true"
      traefik.docker.network: "web"
      traefik.http.routers.bastion-nginx.rule: "Host(`reload-nginx.example.com`)"
      traefik.http.routers.bastion-nginx.entrypoints: "websecure"
      traefik.http.routers.bastion-nginx.tls: "true"
      traefik.http.services.bastion-nginx.loadbalancer.server.port: "8080"
    networks: [web]

  nginx-app:
    image: nginx:alpine
    container_name: nginx-app
    volumes:
      - ./nginx.conf:/etc/nginx/nginx.conf:ro
      - ./conf.d:/etc/nginx/conf.d:ro
    networks: [web]

networks:
  web:
    external: true

CI snippet:

# After updating nginx.conf on disk:
curl --fail-with-body -H "Authorization: Bearer $BASTION_HTTP_TOKEN" \
     https://reload-nginx.example.com/cgi-bin/run

curl --fail-with-body makes the CI step fail (non-zero exit) if the bastion returns 4xx/5xx, with the body printed — so a nginx -t syntax error in the new config shows up in the CI log without extra wiring.

Quick Start — manage docker-mailserver (broker mode)

The two examples above hard-code one command. Broker mode is the inverse: the client supplies the command and the bastion runs it only if it matches a regex allowlist. This is how you give a management UI (a Nuxt "mail manager", a cron job, a bot) a menu of allowed operations without handing it a shell.

Here one bastion fronts docker-mailserver's setup CLI. The app sends email add jane@example.com <pw> over SSH on the internal network; the bastion validates it and runs docker exec -i mailserver setup email add jane@example.com <pw>.

services:
  bastion-mail:
    image: blaxsoftware/bastion:latest
    restart: unless-stopped
    environment:
      # Trusted prefix prepended to every validated request, so clients
      # send clean `email add …` and never see the docker plumbing.
      COMMAND_PREFIX: "docker exec -i mailserver setup"

      # The whitelist. A YAML block scalar reads like a string array:
      # one extended-regex (ERE) rule per line. A request is allowed only
      # if it matches a rule WHOLE-LINE (anchored). Matched commands run
      # WITHOUT a shell — ; | & $() are literal args, never operators —
      # so keep argument classes tight ([^ ]+, not .*).
      ALLOWED_COMMANDS: |
        email add [^ ]+@[^ ]+ [^ ]+
        email update [^ ]+@[^ ]+ [^ ]+
        email del [^ ]+@[^ ]+
        email list
        alias add [^ ]+@[^ ]+ [^ ]+
        alias del [^ ]+@[^ ]+ [^ ]+
        alias list
        quota set [^ ]+@[^ ]+ [0-9]+[KMGT]?
        quota del [^ ]+@[^ ]+        
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
      - ./docker-data/bastion/users.d:/etc/bastion/users.d
      - ./docker-data/bastion/keys:/etc/ssh/keys
    networks: [web]   # same network as the `mailserver` container

networks:
  web:
    external: true

From the mail-manager (same docker network, no host port needed):

# allowed → runs `docker exec -i mailserver setup email add …`
ssh agent@bastion-mail "email add jane@example.com $(openssl rand -base64 18 | tr -d /+=)"

ssh agent@bastion-mail "email list"

# refused → exits 126, nothing runs
ssh agent@bastion-mail "email del jane@example.com; rm -rf /"

A complete copy-paste setup (compose + a live-editable allowed-commands.list) is in examples/docker-mailserver/.

Quick Start — a "fake VPS" scoped to one directory

Broker mode hands out a menu of commands. Sometimes you want the opposite: a real interactive shell that feels like a VPS, but where the only thing on the "server" is one directory — e.g. hand someone your docker-mailserver directory to manage, and nothing else of the host.

The trick is a disposable jail container whose only real mount is that one directory; the bastion's FORCE_COMMAND drops every session into a shell inside it. The jail's own root filesystem is throwaway image data, not your server — so the only host data reachable over SSH is the directory you mounted.

services:
  # Disposable shell box. ONLY real host data inside it = the one directory.
  dms-jail:
    image: docker:27-cli          # or build a richer image — see the example
    container_name: dms-jail
    restart: unless-stopped
    working_dir: /opt/docker-mailserver
    entrypoint: ["tail", "-f", "/dev/null"]   # stay alive for `docker exec`
    volumes:
      # THE one directory. Keep host path == in-jail path so `docker compose`
      # inside the jail resolves the stack's bind mounts to host paths.
      - /opt/docker-mailserver:/opt/docker-mailserver
      # Lets the shell drive `docker compose`/restart. Host-root-equivalent —
      # drop this mount (here and below) for a true no-escape jail.
      - /var/run/docker.sock:/var/run/docker.sock
    networks: [web]

  bastion-vps:
    image: blaxsoftware/bastion:latest
    restart: unless-stopped
    depends_on: [dms-jail]
    environment:
      # Every SSH session becomes an interactive shell in the jail, in the
      # directory. `-it` = TTY → use SSH (not the HTTP path) for this.
      FORCE_COMMAND: "docker exec -it -w /opt/docker-mailserver dms-jail sh"
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock   # only to exec into the jail
      - ./docker-data/bastion/users.d:/etc/bastion/users.d
      - ./docker-data/bastion/keys:/etc/ssh/keys
    ports:
      - "2222:22"
    networks: [web]

networks:
  web:
    external: true
ssh -p 2222 agent@your-host       # lands you in the directory, real shell

The docker-socket tradeoff: giving the jail the socket (so docker compose works) is host-root-equivalent — from that shell, docker run -v /:/host … reaches the whole host. It's a practical one-directory VPS for trusted operators, not a hard sandbox. For a true no-escape boundary, remove the docker.sock mount from both services (and manage the stack via a separate broker-mode bastion).

A complete copy-paste setup — with a richer jail image (bash, editors, git, compose) and the read-only / no-socket variants spelled out — is in examples/docker-mailserver-vps/.

Command broker mode

Set ALLOWED_COMMANDS (or mount /etc/bastion/allowed-commands.list) and the bastion switches from "one fixed command" to "any command the client asks for, if it passes the allowlist". FORCE_COMMAND is then optional and ignored.

How a request is judged

  1. The client-supplied command (SSH SSH_ORIGINAL_COMMAND, or the HTTP X-Bastion-Command header) is taken verbatim.
  2. Multi-line requests are rejected outright (so a benign first line can't smuggle a second).
  3. It is matched whole-line, anchored against each rule (grep -Eqx). First match wins.
  4. On a match: the (optional) COMMAND_PREFIX and the request are word-split with globbing off and exec'd directly — there is no sh -c. ; | & ` $() < > become literal arguments, never shell operators. On no match: refused, exit 126, logged as [broker] DENY: ….

Why shell-free execution matters. It means the regex is a containment boundary, not just a filter. Even a careless rule like email .* cannot escalate to command injection — the worst case is that setup receives a weird argument. (Contrast the sh -c path used by FORCE_COMMAND mode, where a metacharacter would be interpreted.)

Writing rules

  • One ERE per line; # comments and blank lines ignored; surrounding whitespace trimmed (so indented YAML block lines work).
  • Anchoring is implicit — write email list, not ^email list$ (both work).
  • Use tight argument classes: [^ ]+ ("a run of non-spaces") beats .*. Add alternation for fixed verbs: email (add|update|del|list).
  • Values that must reach the target intact cannot contain whitespace (word-splitting) or be quoted (no shell). Generate passwords/tokens from a space-free alphabet — e.g. openssl rand -base64 24 | tr -d /+=.

Two sources, additive: ALLOWED_COMMANDS (env, snapshot at boot) + /etc/bastion/allowed-commands.list (bind mount, re-read every request — edit without a restart).

SSH vs HTTP. SSH is the recommended transport (clean stdout/stderr separation). The HTTP path works too — POST/GET with Authorization + X-Bastion-Command: <cmd> — but merges the broker's stderr into the response body (the CGI uses 2>&1, same as the deploy examples).

Where the audit line goes. The broker prints [broker] ALLOW: … / [broker] DENY: … on the command's stderr — so over SSH it returns to the client on the channel's stderr stream, and over HTTP it is folded into the response body (2>&1). It does not land in the bastion container's own docker logs (sshd does not redirect a ForceCommand's stderr to the daemon log). If you want a container-side audit trail, tee it — e.g. have COMMAND_PREFIX/the wrapper append to a logfile, or run a syslog sidecar.

Two channels, two shapes

Channel Best for TTY? Streaming?
SSH Interactive (docker exec -it) or scripts yes yes
HTTP Scripts only — no TTY no yes (chunked / close-delimited)

Interactive commands (docker exec -it app bash) over HTTP fail because there's no TTY — use SSH for those. Both channels stream output line-by-line; both close as soon as FORCE_COMMAND exits and the exit code propagates (SSH: to the client; HTTP: nonzero closes the response mid-stream).

The client cannot override the command. SSH_ORIGINAL_COMMAND and HTTP request bodies are intentionally ignored.

Authorized keys — three sources, two flavors

sshd consults three sources on every authentication attempt:

Source Read when Mount UX
/etc/bastion/users.d/*.pub live, every login Drop one .pub file per user — users.d/alice.pub, users.d/bob.pub. No restart to add/revoke.
/etc/bastion/authorized_keys.host merged at boot Single file from the host — ~/.ssh/authorized_keys.
/etc/bastion/authorized_keys.repo merged at boot Single file from the repo — ./docker/bastion/authorized_keys.

Recommended: users.d/ — one file per identity, dropped in via a host bind mount, adds and revokes immediately. The two file-based sources stay for backward compatibility and for the "one big committed file" pattern.

Zero authorized keys is now a warning, not a startup failure — the bastion runs but every SSH attempt fails with publickey denied until you drop a key in.

Environment variables

Variable Default Description
FORCE_COMMAND (required unless ALLOWED_COMMANDS set) FORCE_COMMAND mode: the command run on every authenticated session. Shell metacharacters OK. Ignored in broker mode.
ALLOWED_COMMANDS (unset) Broker mode: newline-separated ERE allowlist. Set this (or mount the file below) to enable broker mode. Each rule must match a request whole-line.
COMMAND_PREFIX (unset) Broker mode, optional: trusted prefix prepended to every validated request (e.g. docker exec -i mailserver setup).
(mount) /etc/bastion/allowed-commands.list (unset) Broker mode, optional: same format as ALLOWED_COMMANDS, re-read every request (live edits, no restart). Additive.
HTTP_BASIC_AUTH (unset) Enables HTTP with Basic auth. Value is user:password. Works with curl -u, curl https://user:pass@host/…, and browser URL bars.
HTTP_TOKEN (unset) Enables HTTP with Bearer auth. Clients send Authorization: Bearer <this>. Mutually exclusive with HTTP_BASIC_AUTH (basic takes precedence).
HTTP_PORT 8080 Port for the HTTP listener (when either auth var is set).
SSH_PORT 22 Port for sshd inside the container.
AUTHORIZED_KEYS_DIR /etc/bastion/users.d Directory of *.pub files, live-read by sshd via AuthorizedKeysCommand. Drop a file → next login picks it up.
AUTHORIZED_KEYS_HOST /etc/bastion/authorized_keys.host Single-file source, merged at boot. Optional / legacy.
AUTHORIZED_KEYS_REPO /etc/bastion/authorized_keys.repo Single-file source, merged at boot. Optional / legacy.

Build args

Arg Default Description
ALPINE_VERSION 3.21 Alpine base image tag.
SSH_UID 1000 UID of the bastion agent user.
SSH_GID 1000 GID of the bastion agent group.

What's inside

  • openssh-server — hardened config: key-only auth, no forwarding, no PAM, no user env, global ForceCommand directive (clients cannot bypass).
  • bastion-broker — the allowlist gate for broker mode: whole-line ERE match, then shell-free word-split exec. Shared by the SSH and HTTP paths.
  • busybox httpd (busybox-extras) — minimal HTTP listener for the URL path; CGI-driven; only starts when HTTP_TOKEN or HTTP_BASIC_AUTH is set.
  • docker-cli + docker-cli-compose — so FORCE_COMMAND can target containers through a mounted docker socket. Group membership is auto-aligned to the host socket's GID at boot.
  • tini — PID 1, signal handling, zombie reaping.
  • bash, ca-certificates, tzdata.

Total image: ~105 MB. Most of that is docker-cli (~50 MB) and docker-cli-compose (~25 MB).

Security model

The security boundary is the authorized_keys file (SSH) and the HTTP_TOKEN (HTTP), plus the ForceCommand wrapper. Once a key or bearer token authenticates, the session runs exactly one command — there is no fallback shell. The bastion holds the docker socket, which is host-root-equivalent, so the only thing standing between a remote attacker and host root is the auth layer + your key/token hygiene.

Practical checklist:

  1. Key-only SSH, no passwords — enforced in sshd_config.
  2. HTTP requires auth — either basic auth (via busybox httpd's -c conf, with REMOTE_USER set on the authenticated CGI) or bearer token (validated by the CGI script). No anonymous path.
  3. No agent / TCP / X11 forwarding, no port tunnels — enforced in sshd_config.
  4. ForceCommand cannot be bypassed. Clients can request any command (ssh user@host arbitrary-thing); sshd ignores it and runs /etc/bastion/force-command. In FORCE_COMMAND mode SSH_ORIGINAL_COMMAND is dropped entirely. In broker mode it is read but only ever run if it matches the allowlist whole-line, and even then via shell-free word-split exec (no sh -c), so the regex is a containment boundary, not just a filter — see Command broker mode. The bastion user's login shell is /bin/sh (not nologin — that would break ForceCommand itself, since sshd invokes the user's shell as shell -c "<forced-command>"), but it has no path to anything other than the wrapper.
  5. PermitUserEnvironment no, PermitUserRC no — clients cannot inject env vars or rc files.
  6. Bind host ports to 127.0.0.1 or hide them behind traefik+TLS unless you genuinely need them publicly open on raw TCP. The traefik path with entrypoints: websecure and tls: true is the recommended public exposure.
  7. Rotate HTTP_TOKEN regularly. Generate with openssl rand -hex 32, store in .env, never commit.
  8. Keep alpine + openssh patched. An unauth RCE in sshd or httpd here means host root. apk upgrade in a rebuild cycle.
  9. Lock down siblings. Anyone who can docker exec into the app via this bastion can also docker exec into mysql/redis/etc through the same socket. cap_drop: [ALL] and no-new-privileges: true on every sibling caps the blast radius.
  10. One bastion per role. Don't reuse a single FORCE_COMMAND for both interactive shells and deploy automation — separate ports and separate token/key sets make audit trails meaningful.

Architecture

start-container (entrypoint)
  ├─ generate host keys (idempotent, persisted via /etc/ssh/keys bind mount)
  ├─ merge AUTHORIZED_KEYS_HOST + AUTHORIZED_KEYS_REPO into authorized_keys
  ├─ pick mode: broker if $ALLOWED_COMMANDS / allowed-commands.list, else force-command
  │    ├─ broker:        snapshot allowlist + prefix; wrapper → bastion-broker
  │    └─ force-command: write /etc/bastion/force-command wrapper from $FORCE_COMMAND
  ├─ align docker socket group membership to host GID (if socket is mounted)
  ├─ start httpd → /var/www/cgi-bin/run (if $HTTP_BASIC_AUTH or $HTTP_TOKEN is set)
  └─ exec sshd -D -e

FORCE_COMMAND mode                      broker mode
------------------                      -----------
ssh client                              ssh client
  └ key auth as `agent`                   └ key auth as `agent`
     └ ForceCommand wrapper                  └ ForceCommand wrapper
        └ exec sh -c "$FORCE_COMMAND"           └ exec bastion-broker "$SSH_ORIGINAL_COMMAND"
                                                    ├ match vs allowlist (whole-line, anchored)
http client                                         └ exec $COMMAND_PREFIX $request  (no shell)
  └ Authorization header
     └ /cgi-bin/run validates auth       http client
        └ exec force-command wrapper        └ Authorization header
           └ exec sh -c "$FORCE_COMMAND"       └ /cgi-bin/run validates auth
                                                  └ exec bastion-broker "$X-Bastion-Command"

License

MIT.

Star History

Star History Chart