# =========================================================================== # 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 # --------------------------------------------------------------------------- # 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 RUN chmod 0755 /usr/local/bin/start-container && \ mkdir -p /etc/bastion /etc/ssh/keys /var/empty && \ chmod 700 /etc/ssh/keys && \ chmod 711 /var/empty # --------------------------------------------------------------------------- # Environment # FORCE_COMMAND — REQUIRED. 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 # HTTP_TOKEN — optional. When set, enables an HTTP listener on # $HTTP_PORT serving /cgi-bin/run. Clients must # send `Authorization: Bearer `. # Leave unset for SSH-only mode. # HTTP_PORT — HTTP listen port (default 8080). # SSH_PORT — sshd listen port inside the container (default 22). # AUTHORIZED_KEYS_HOST — file path; merged into authorized_keys if present # AUTHORIZED_KEYS_REPO — file path; merged into authorized_keys if present # (mount either, both, or neither — at least one # must exist or the container refuses to start) # --------------------------------------------------------------------------- ENV FORCE_COMMAND="" ENV HTTP_TOKEN="" ENV HTTP_PORT=8080 ENV SSH_PORT=22 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"]