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>
Add a second operating mode alongside FORCE_COMMAND: the client supplies
the command and it runs only if it matches a regex allowlist
(ALLOWED_COMMANDS / mounted allowed-commands.list), optionally behind a
trusted COMMAND_PREFIX. Matching is whole-line anchored (grep -Eqx) and
multi-line requests are rejected; execution is shell-free word-split, so
; | & $() are literal args and a sloppy rule can't become injection.
Works over SSH (SSH_ORIGINAL_COMMAND) and HTTP (X-Bastion-Command).
FORCE_COMMAND mode is unchanged and remains the default when no allowlist
is set; config is read from boot-written files since sshd does not pass
the daemon env to a ForceCommand session.
- scripts/bastion-broker: the allowlist gate + no-shell exec
- scripts/start-container: mode detection, broker wrapper + CGI, banner
- Dockerfile / config/sshd_config: wire in the broker, document env vars
- examples/docker-mailserver: ready-to-run broker config + allowlist
Back-fills into git what is already live in blaxsoftware/bastion:latest
(the deployed image was built 2026-05-29 from this then-uncommitted working
tree; git HEAD was behind it).
- ForceCommand wrapper forwards positional args: exec sh -c "..." sh "$@"
- CGI maps X-Deploy-Bump: patch|minor|major -> --patch|--minor|--major and
passes it as one positional arg to the FORCE_COMMAND.
This is the server side of the learn-atc deploy '"$@"' passthrough and the
/<service>/minor-style URL-suffix version bump.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Caught while wiring up an HTTP-driven deploy that runs the user's
canonical deploy.sh from a CGI:
- HTTP_AS_ROOT=1 — opt out of busybox httpd's `-u USER` drop so the
CGI (and the deploy.sh it runs) keep root supplementary groups.
Required because busybox httpd does setuid/setgid but not
setgroups; dropping to agent loses the dockerhost group and the
CGI can't reach /var/run/docker.sock. Bastion already has the
socket = host root, so this doesn't widen the envelope.
- chown -R …/.ssh — make it best-effort. With ssh creds mounted
read-only (id_rsa, known_hosts), the chown -R failed under
`set -e` and killed boot. The dir + the file we wrote are what
matter; anything bind-mounted in is the caller's business.
- git config --system --add safe.directory '*' — silence
'detected dubious ownership' when the CGI runs as root over a
host-uid-owned repo (standard bind-mount-into-bastion case).
- GIT_SSH_COMMAND auto-export — when a key is mounted at
/home/agent/.ssh/id_rsa, the wrapper sets git's ssh invocation
to point at it explicitly. Required because setting HOME alone
doesn't make root-uid ssh follow ~/.ssh/{id_rsa,known_hosts}.
sshd now consults /etc/bastion/users.d/*.pub on every authentication
attempt via AuthorizedKeysCommand, so adding or removing a user
takes effect immediately without restarting the container — just
drop `alice.pub` (or any *.pub file) into the host-bound dir,
sshd picks it up on the next login.
Implementation:
- /usr/local/bin/bastion-list-keys: minimal POSIX-sh script that
cats $AUTHORIZED_KEYS_DIR/*.pub. Runs as the agent user (per
AuthorizedKeysCommandUser), reads world-readable pubkeys.
- sshd_config: AuthorizedKeysCommand alongside the existing
AuthorizedKeysFile — both checked, so the boot-merged
file (AUTHORIZED_KEYS_HOST/_REPO) still works for single-file UX.
- start-container: 'zero key sources' is now a WARN, not a fatal.
Bastion comes up empty; SSH attempts fail with 'publickey denied'
until you drop a key. Lets users `docker compose up` first and
add keys later.
Bug fix on the way through: `grep -c` exits non-zero when no
lines match, which under `set -eu` killed the boot script
silently after '[2/5] Authorized keys...'. Switched to
`awk … | wc -l` which exits 0 cleanly on empty input.
README updated with the new source priority and env var.
HTTP basic auth via HTTP_BASIC_AUTH=user:password. Mutually
exclusive with HTTP_TOKEN (basic takes precedence if both set).
Implementation note: busybox httpd strips Authorization: Basic
headers before invoking CGI scripts (it expects to handle basic
auth itself), so a CGI-side check for Basic doesn't work. We let
busybox handle Basic via its -c httpd.conf rule (`/cgi-bin/:user:pass`)
and keep the CGI-side Bearer check for HTTP_TOKEN. httpd.conf is
chowned to the agent user because httpd drops privileges before
reading -c.
Image additions for canonical deploy.sh patterns:
- git (apk add git) — for git pull/tag/push.
- openssh-client (apk add openssh-client) — provides /usr/bin/ssh,
which git invokes for ssh:// remote transports. Without it
`git push origin` fails with 'error: cannot run ssh: No such
file or directory'.
- HOME=/home/agent exported in the force-command wrapper — busybox
httpd doesn't set HOME for CGI, leaving git/ssh/xdg lookups
pointing at /root and producing 'Permission denied' warnings.
README updated with HTTP_BASIC_AUTH env var, URL syntax examples,
and the mutual-exclusion note.
Two OpenSSH-on-alpine quirks caught by a real ssh attempt:
1) alpine's `adduser -D` leaves shadow password as `!`, which
OpenSSH 9.x treats as 'account locked' and refuses even for
pubkey auth (logs: 'User agent not allowed because account is
locked'). Sed-replace `!` with `*` post-create — no password
set, but account NOT locked.
2) Setting the login shell to /sbin/nologin defeats ForceCommand,
because sshd executes the forced command as
`<login-shell> -c "<command>"`. nologin then prints
'This account is not available' and exits. Use /bin/sh instead;
the security boundary is ForceCommand + sshd_config, not the
shell — clients cannot bypass ForceCommand to ask for an
interactive shell.
README security section updated to reflect both points.
- HTTP path: opt-in via $HTTP_TOKEN; busybox httpd binds $HTTP_PORT
(default 8080) and serves /cgi-bin/run, which validates the
'Authorization: Bearer …' header and exec's the same force-command
wrapper SSH uses. Output streams chunked. GET and POST both work.
Without HTTP_TOKEN the bastion stays SSH-only.
- README rewritten with shields.io badges, two complete quickstart
examples (WordPress drop-in + nginx config-reload webhook), inline
comments on every yaml line marking required/optional, traefik
integration in both examples, and star-history footer matching
Blax OSS convention.
- Dockerfile: add busybox-extras (the httpd applet was split out of
the core busybox binary in alpine 3.21); EXPOSE 8080; document
HTTP_TOKEN/HTTP_PORT env vars.
Named volumes get wiped by 'docker compose down -v' — that command
is in too many people's muscle memory for ssh host keys to live
behind it. Bind-mount /etc/ssh/keys to ./docker-data/bastion-*/keys
instead, matching the laravel-workkit §6 convention.
Minimal SSH bastion (alpine + openssh-server + docker-cli) that
authenticates by key and runs exactly one preconfigured command
(FORCE_COMMAND) per session. authorized_keys can be merged from
both a host-mounted source and a repo-mounted source. Host keys
persist via /etc/ssh/keys volume; docker socket group membership
is aligned at boot.