docker-bastion/Dockerfile

159 lines
8.3 KiB
Docker

# ===========================================================================
# 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 `<user-shell> -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_TOKEN>`.
# 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"]