159 lines
8.3 KiB
Docker
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"]
|