Go to file
Fabian @ Blax Software 256a8c4571 fix(force-command): serialize deploys with a per-container flock
busybox httpd forks a CGI child per HTTP request, so two near-simultaneous
deploy calls (an upstream proxy retrying a slow cold deploy, a browser
double-fire, an overlapping webhook) ran two `git reset/pull` in the same
repo at once and collided on .git/index.lock or a remote-tracking ref
("cannot lock ref ... is at X but expected Y"). Nothing in the chain
HTTP -> CGI -> wrapper -> deploy.sh serialized them.

Hold a non-blocking flock on fd 9 in the generated FORCE_COMMAND wrapper,
which is exec'd by BOTH the HTTP CGI and sshd ForceCommand. A second
concurrent request returns a friendly 200 and leaves the in-flight winner
alone, so an upstream proxy won't retry-storm and connections don't pile
up on a stuck build (busybox flock has no -w timeout). fd 9 stays open
across the exec, so the lock is held for the whole command and releases
when the process tree exits -- even on SIGKILL.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 10:02:18 +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 fix(force-command): serialize deploys with a per-container flock 2026-06-16 10:02:18 +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