# =========================================================================== # docker-bastion — Minimal SSH bastion for ForceCommand routing # # Public sshd → key-only auth → runs ONE preconfigured command (set via # $FORCE_COMMAND). The bastion user's login shell is /sbin/nologin, so # there is no fallback shell even if ForceCommand somehow fails to fire. # # Build args: # ALPINE_VERSION — Alpine base version (default: 3.21) # =========================================================================== ARG ALPINE_VERSION=3.21 FROM alpine:${ALPINE_VERSION} LABEL maintainer="docker-bastion" LABEL description="Minimal SSH bastion: public ssh → ForceCommand → docker exec (or anything else)" ENV TZ=UTC RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone # --------------------------------------------------------------------------- # Packages # openssh-server / openssh-keygen — the daemon + ssh-keygen for host keys # docker-cli / docker-cli-compose — so FORCE_COMMAND can target containers # tini — proper PID 1 / signal handling # bash — startup script + interactive sessions # --------------------------------------------------------------------------- RUN apk add --no-cache \ openssh-server \ openssh-keygen \ docker-cli \ docker-cli-compose \ busybox-extras \ bash \ tini \ ca-certificates \ tzdata \ # git so FORCE_COMMAND scripts can run `git pull` / `git tag` as # canonical deploy.sh patterns expect. git \ # openssh-client provides the `ssh` binary git uses for # `git push origin` over the ssh:// transport. Without it, # deploy.sh fails with `error: cannot run ssh: No such file…`. openssh-client # --------------------------------------------------------------------------- # Bastion user — UID/GID 1000. Login shell = /bin/sh. # # Why not /sbin/nologin? # sshd's ForceCommand wraps the command in ` -c "..."`. With # nologin as the shell, every ForceCommand invocation just prints # "This account is not available" and exits — defeating the whole point. # Security comes from ForceCommand + sshd_config, not from the shell; # clients cannot bypass ForceCommand to ask for an interactive shell. # --------------------------------------------------------------------------- ARG SSH_UID=1000 ARG SSH_GID=1000 RUN addgroup -g ${SSH_GID} agent && \ adduser -D -u ${SSH_UID} -G agent -s /bin/sh agent && \ # Alpine's `adduser -D` leaves the shadow password field as `!`, which # OpenSSH 9.x interprets as "account locked" and refuses even for pubkey # auth (logs: "User agent not allowed because account is locked"). # Replace with `*` — no password set, but account NOT locked. sed -i 's/^agent:!:/agent:*:/' /etc/shadow && \ mkdir -p /home/agent/.ssh && \ chown -R agent:agent /home/agent/.ssh && \ chmod 700 /home/agent/.ssh # --------------------------------------------------------------------------- # sshd config + entrypoint # /etc/ssh/keys/ — host keys (generated on first boot; mount as a # volume to persist them across rebuilds) # /etc/bastion/ — runtime-generated ForceCommand wrapper # --------------------------------------------------------------------------- COPY config/sshd_config /etc/ssh/sshd_config COPY scripts/start-container /usr/local/bin/start-container COPY scripts/bastion-list-keys /usr/local/bin/bastion-list-keys COPY scripts/bastion-broker /usr/local/bin/bastion-broker # AuthorizedKeysCommand requires its script and *every* parent dir to be # root-owned and not group/world-writable. /usr/local/bin satisfies that # by default; chmod 0755 is the safe canonical mode for the script itself. RUN chmod 0755 /usr/local/bin/start-container /usr/local/bin/bastion-list-keys /usr/local/bin/bastion-broker && \ mkdir -p /etc/bastion /etc/bastion/users.d /etc/ssh/keys /var/empty && \ chmod 700 /etc/ssh/keys && \ chmod 755 /etc/bastion/users.d && \ chmod 711 /var/empty # --------------------------------------------------------------------------- # Environment # The bastion runs in one of two modes, decided at boot: # # • FORCE_COMMAND mode (default) — one fixed command per session; client # input is ignored. Set FORCE_COMMAND. # • Broker mode — the client SUPPLIES the command and it is validated # against a regex allowlist before running. Set ALLOWED_COMMANDS (and/or # mount /etc/bastion/allowed-commands.list). When the allowlist is set, # FORCE_COMMAND is not required and is ignored. # # FORCE_COMMAND — FORCE_COMMAND mode: the single command run on # every login. Shell metacharacters are supported. # Examples: # docker exec -it app bash # docker compose -f /workspace/compose.yml exec app bash # cd /workspace && ./deploy.sh # ALLOWED_COMMANDS — Broker mode: newline-separated list of extended # regex (ERE) rules. A client request is permitted # only if it matches one rule WHOLE-LINE (anchored). # In compose, a YAML block scalar reads like an # array — one regex per line: # ALLOWED_COMMANDS: | # setup email (add|update) [^ ]+@[^ ]+ [^ ]+ # setup email list # Matched commands are word-split and exec'd with # NO shell (; | & are literal args, not operators). # Lines starting with # and blank lines are ignored. # COMMAND_PREFIX — Broker mode, optional: a trusted prefix prepended # to every validated request, e.g. # "docker exec -i mailserver setup" so clients send # just "email add a@b pw" and never see the docker # plumbing. Operator-set, not validated. # /etc/bastion/allowed-commands.list (mount) — Broker mode, optional: same # one-rule-per-line format as ALLOWED_COMMANDS, but # re-read every session (edit without a restart). # Additive with ALLOWED_COMMANDS. # HTTP_TOKEN — optional. Enables HTTP listener with Bearer # auth: `Authorization: Bearer `. # HTTP_BASIC_AUTH — optional. Enables HTTP listener with Basic # auth. Value is "user:password". Works with # `curl https://user:password@host/…`. # (Either, both, or neither — neither = SSH only.) # HTTP_PORT — HTTP listen port (default 8080). # SSH_PORT — sshd listen port inside the container (default 22). # AUTHORIZED_KEYS_DIR — directory of *.pub files (default # /etc/bastion/users.d). Read LIVE by sshd via # AuthorizedKeysCommand on every auth attempt — # drop a file, the next login picks it up # without restarting the container. # AUTHORIZED_KEYS_HOST — single-file path; merged into authorized_keys # at boot if present (backward-compat / single-file UX). # AUTHORIZED_KEYS_REPO — same, second source for boot-time merge. # (All three sources are additive. Mount any # combination — none = bastion still starts, # but every SSH attempt fails with "publickey # denied" until you drop a key in.) # --------------------------------------------------------------------------- ENV FORCE_COMMAND="" ENV ALLOWED_COMMANDS="" ENV COMMAND_PREFIX="" ENV HTTP_TOKEN="" ENV HTTP_BASIC_AUTH="" ENV HTTP_PORT=8080 ENV SSH_PORT=22 ENV AUTHORIZED_KEYS_DIR=/etc/bastion/users.d ENV AUTHORIZED_KEYS_HOST=/etc/bastion/authorized_keys.host ENV AUTHORIZED_KEYS_REPO=/etc/bastion/authorized_keys.repo EXPOSE 22 8080 ENTRYPOINT ["/sbin/tini", "--", "/usr/local/bin/start-container"]