#!/bin/sh set -eu echo "==========================================" echo " docker-bastion starting" echo "==========================================" # Trust every git repo regardless of UID — the bastion is a single-tenant # isolated container, and ownership-mismatch on a bind-mounted repo # (host uid != bastion uid) is the normal case here, not an attack vector. # Without this, git refuses with "fatal: detected dubious ownership". git config --system --add safe.directory '*' 2>/dev/null || true echo "Date: $(date)" echo "Hostname: $(hostname)" echo "OpenSSH: $(/usr/sbin/sshd -V 2>&1 | head -1 || echo n/a)" echo "Docker CLI: $(docker --version 2>/dev/null || echo n/a)" echo "==========================================" SSH_USER="agent" SSH_PORT="${SSH_PORT:-22}" HTTP_PORT="${HTTP_PORT:-8080}" FORCE_COMMAND_VALUE="${FORCE_COMMAND:-}" ALLOWED_COMMANDS_VALUE="${ALLOWED_COMMANDS:-}" COMMAND_PREFIX_VALUE="${COMMAND_PREFIX:-}" AUTHORIZED_KEYS_HOST="${AUTHORIZED_KEYS_HOST:-/etc/bastion/authorized_keys.host}" AUTHORIZED_KEYS_REPO="${AUTHORIZED_KEYS_REPO:-/etc/bastion/authorized_keys.repo}" # Broker mode is active when an allowlist exists from either source: the # ALLOWED_COMMANDS env var, or a bind-mounted /etc/bastion/allowed-commands.list. # In broker mode the client supplies the command (validated by bastion-broker); # FORCE_COMMAND is not required and is ignored. ALLOWED_LIST_FILE=/etc/bastion/allowed-commands.list BROKER_MODE=0 if [ -n "$ALLOWED_COMMANDS_VALUE" ] || [ -f "$ALLOWED_LIST_FILE" ]; then BROKER_MODE=1 fi # --------------------------------------------------------------------------- # 1) Validate config # --------------------------------------------------------------------------- if [ "$BROKER_MODE" -eq 0 ] && [ -z "$FORCE_COMMAND_VALUE" ]; then echo "FATAL: set FORCE_COMMAND (fixed-command mode) or ALLOWED_COMMANDS (broker mode)." echo " FORCE_COMMAND='docker exec -it app bash'" echo " FORCE_COMMAND='cd /workspace && ./deploy.sh'" echo " ALLOWED_COMMANDS=\$'setup email list\\nsetup email (add|update) [^ ]+@[^ ]+ [^ ]+'" exit 1 fi if [ "$BROKER_MODE" -eq 1 ] && [ -n "$FORCE_COMMAND_VALUE" ]; then echo "NOTE: ALLOWED_COMMANDS is set — running in broker mode; FORCE_COMMAND is ignored." fi # --------------------------------------------------------------------------- # 2) Host keys — generate on first boot, persist via /etc/ssh/keys volume # --------------------------------------------------------------------------- echo "[1/5] Host keys..." mkdir -p /etc/ssh/keys chmod 700 /etc/ssh/keys for keytype in ed25519 rsa; do keyfile="/etc/ssh/keys/ssh_host_${keytype}_key" if [ ! -f "$keyfile" ]; then echo " Generating new $keytype host key" ssh-keygen -t "$keytype" -f "$keyfile" -N "" -q else echo " Reusing existing $keytype host key" fi chmod 600 "$keyfile" [ -f "${keyfile}.pub" ] && chmod 644 "${keyfile}.pub" done # --------------------------------------------------------------------------- # 3) Merge authorized_keys sources # --------------------------------------------------------------------------- echo "[2/5] Authorized keys..." AUTH_FILE="/home/${SSH_USER}/.ssh/authorized_keys" mkdir -p "$(dirname "$AUTH_FILE")" : > "$AUTH_FILE" added=0 for src in "$AUTHORIZED_KEYS_HOST" "$AUTHORIZED_KEYS_REPO"; do if [ -f "$src" ]; then echo " + Merging $src" cat "$src" >> "$AUTH_FILE" # Force newline between sources (final file may not end with one). printf '\n' >> "$AUTH_FILE" added=$((added + 1)) fi done # awk emits matching lines (non-blank, non-comment), wc -l counts them. # Both exit 0 even on empty input — important under `set -e`, which # `grep -c` would have triggered (exits 1 when nothing matches). file_keys=$(awk 'NF && $0 !~ /^[[:space:]]*#/' "$AUTH_FILE" 2>/dev/null | wc -l) file_keys=$(echo "$file_keys" | tr -d ' ') # wc on busybox pads with spaces echo " Boot-merged: $file_keys key(s) from $added file source(s)" # Live-read directory — sshd reads this via AuthorizedKeysCommand on every # auth attempt. We just report the current count for visibility; new files # dropped in later take effect immediately, no restart needed. dir_keys=0 if [ -d "${AUTHORIZED_KEYS_DIR:-/etc/bastion/users.d}" ]; then dir_keys=$(find "${AUTHORIZED_KEYS_DIR}" -maxdepth 1 -name '*.pub' -type f 2>/dev/null | wc -l) fi echo " Live drop-in: $dir_keys key file(s) in $AUTHORIZED_KEYS_DIR" if [ "$file_keys" -eq 0 ] && [ "$dir_keys" -eq 0 ]; then echo " WARN: zero authorized keys configured." echo " Drop *.pub files into $AUTHORIZED_KEYS_DIR on the host" echo " (or mount AUTHORIZED_KEYS_HOST / AUTHORIZED_KEYS_REPO);" echo " until then every SSH attempt will fail with 'publickey denied'." fi # chown -R is best-effort: if the caller bind-mounts read-only files into # /home/agent/.ssh/ (e.g. an id_rsa for git push from FORCE_COMMAND), chown # on those will fail with "Read-only file system" — which under `set -e` # previously killed the boot script. The dir itself + the file we just # wrote are what matter; everything else is the caller's business. chown -R "${SSH_USER}:${SSH_USER}" "$(dirname "$AUTH_FILE")" 2>/dev/null || true chmod 700 "$(dirname "$AUTH_FILE")" chmod 600 "$AUTH_FILE" # --------------------------------------------------------------------------- # 4) Command routing — generate /etc/bastion/force-command for the active mode # # FORCE_COMMAND mode: write the command to a plain file + a static wrapper # that exec's `sh -c "$(cat ...)"`, so shell metacharacters (&&, |, cd, # redirects) work and the wrapper needs no escaping of user input. # Broker mode: snapshot the allowlist + prefix to files (sshd does not pass # the daemon env to a ForceCommand session) and point the wrapper at # bastion-broker, which validates the client's command before running it. # Either way the command file is read at session start, so changes need only # a container restart, not a rebuild. # --------------------------------------------------------------------------- echo "[3/5] Command routing..." mkdir -p /etc/bastion if [ "$BROKER_MODE" -eq 1 ]; then # ---- Broker mode ----------------------------------------------------- # Snapshot the env allowlist to a file the broker reads at session time # (sshd does not pass the daemon env to a ForceCommand session). The # optional /etc/bastion/allowed-commands.list bind-mount is read live by # the broker in addition to this snapshot. printf '%s\n' "$ALLOWED_COMMANDS_VALUE" > /etc/bastion/allowed-commands.env chmod 0644 /etc/bastion/allowed-commands.env printf '%s' "$COMMAND_PREFIX_VALUE" > /etc/bastion/command-prefix chmod 0644 /etc/bastion/command-prefix # The wrapper hands the client-requested command to the broker, which # validates it against the allowlist and either exec's it or refuses. cat > /etc/bastion/force-command <<'WRAPPER' #!/bin/sh # Auto-generated by docker-bastion start-container (broker mode). # Routes the client-requested command through the allowlist gate. sshd sets # SSH_ORIGINAL_COMMAND to whatever the client asked to run; the broker # decides whether it is permitted. There is no fallback command. export HOME=/home/agent exec /usr/local/bin/bastion-broker "${SSH_ORIGINAL_COMMAND:-}" WRAPPER chmod 0755 /etc/bastion/force-command rule_count=$(awk '{ sub(/^[[:space:]]+/, ""); sub(/[[:space:]]+$/, "") } NF && $0 !~ /^#/' \ /etc/bastion/allowed-commands.env "$ALLOWED_LIST_FILE" 2>/dev/null | wc -l | tr -d ' ') echo " Broker mode: $rule_count allow-rule(s)" [ -n "$COMMAND_PREFIX_VALUE" ] && echo " Command prefix: $COMMAND_PREFIX_VALUE" [ -f "$ALLOWED_LIST_FILE" ] && echo " Live rules file: $ALLOWED_LIST_FILE (re-read each session)" if [ "$rule_count" -eq 0 ]; then echo " WARN: broker mode active but zero allow-rules — every request will be refused." fi else # ---- FORCE_COMMAND mode (default, unchanged) ------------------------- printf '%s\n' "$FORCE_COMMAND_VALUE" > /etc/bastion/force-command.cmd chmod 0644 /etc/bastion/force-command.cmd cat > /etc/bastion/force-command <<'WRAPPER' #!/bin/sh # Auto-generated by docker-bastion start-container. # sshd invokes this script for every authenticated session; the HTTP CGI # exec's it after auth. # SSH_ORIGINAL_COMMAND is intentionally ignored — clients cannot override. # # Force HOME for the agent user so git / ssh / xdg lookups land in the # right place. sshd sets this already; busybox httpd's CGI doesn't, so # without this fix `git push` complains about /root/.config/git/* perms. export HOME=/home/agent # If a deploy SSH key is mounted at the conventional location, point git # at it explicitly. Setting HOME alone isn't enough when the CGI runs as # root (HTTP_AS_ROOT=1) — ssh ignores HOME-based ~/.ssh/ lookup in that # path and silently uses /root/.ssh/, which is empty. if [ -f /home/agent/.ssh/id_rsa ]; then export GIT_SSH_COMMAND="ssh -o IdentityFile=/home/agent/.ssh/id_rsa -o UserKnownHostsFile=/home/agent/.ssh/known_hosts -o StrictHostKeyChecking=accept-new" fi # Forward args from the caller (CGI passes one optional --patch|--minor| # --major arg; SSH ForceCommand passes none). The user's FORCE_COMMAND in # compose can reference "$@" to thread these through to deploy.sh. With # no args, "$@" expands to nothing and behavior is identical to before. exec sh -c "$(cat /etc/bastion/force-command.cmd)" sh "$@" WRAPPER chmod 0755 /etc/bastion/force-command echo " $FORCE_COMMAND_VALUE" fi # --------------------------------------------------------------------------- # 5) Docker socket — if mounted, align group membership so the agent user # can talk to dockerd without --privileged. # --------------------------------------------------------------------------- echo "[4/5] Docker socket..." if [ -S /var/run/docker.sock ]; then sock_gid=$(stat -c '%g' /var/run/docker.sock) echo " Socket present, host gid=$sock_gid" grp_name=$(getent group "$sock_gid" | cut -d: -f1 || true) if [ -z "$grp_name" ]; then grp_name="dockerhost" addgroup -g "$sock_gid" "$grp_name" 2>/dev/null || true echo " Created group $grp_name (gid=$sock_gid)" else echo " Reusing existing group $grp_name (gid=$sock_gid)" fi addgroup "$SSH_USER" "$grp_name" 2>/dev/null || true echo " Added $SSH_USER to $grp_name" else echo " WARN: /var/run/docker.sock not mounted — any docker-based command (FORCE_COMMAND or broker) will fail." fi # --------------------------------------------------------------------------- # 6) Adjust sshd_config port if non-default # --------------------------------------------------------------------------- if [ "$SSH_PORT" != "22" ]; then sed -i "s/^Port 22\$/Port ${SSH_PORT}/" /etc/ssh/sshd_config fi # --------------------------------------------------------------------------- # 7) Optional HTTP listener (opt-in via $HTTP_BASIC_AUTH or $HTTP_TOKEN) # # Two mutually-exclusive auth modes. Pick one and set the matching env var: # # HTTP_BASIC_AUTH=user:pass → Basic auth. Works with browser URL bars, # `curl -u user:pass`, and the URL syntax # `https://user:pass@host/cgi-bin/run`. # Handled by busybox's built-in -c auth. # HTTP_TOKEN=secret → Bearer auth via Authorization header. # `curl -H 'Authorization: Bearer secret'`. # Handled by the CGI script. # # Why mutually exclusive: busybox httpd *strips* `Authorization: Basic` from # the CGI env (it expects to handle basic auth itself), so a CGI-side check # for Basic doesn't work. Conversely, when busybox is enforcing Basic via # its conf file, Bearer requests get rejected by busybox before the CGI runs. # # Both modes exec the same /etc/bastion/force-command wrapper SSH uses; # output streams back as the command produces it. # --------------------------------------------------------------------------- echo "[5/6] HTTP listener..." # In broker mode the HTTP CGI takes the command to run from the # X-Bastion-Command request header and routes it through bastion-broker # (same allowlist as SSH). In FORCE_COMMAND mode it exec's the fixed # wrapper as before. busybox httpd forwards request headers to CGI as # HTTP_, so X-Bastion-Command → HTTP_X_BASTION_COMMAND. if [ -n "${HTTP_BASIC_AUTH:-}" ]; then echo " Auth: Basic — enabling httpd on port ${HTTP_PORT}" mkdir -p /var/www/cgi-bin # httpd.conf format for basic auth: `/path:user:password` per line. # Plaintext is supported; for crypt hashes use $1$/$5$/$6$ prefixes. printf '/cgi-bin/:%s\n' "$HTTP_BASIC_AUTH" > /etc/bastion/httpd.conf # httpd drops to $SSH_USER before reading -c CONFFILE, so the file # must be readable by that user. chown rather than world-read because # the conf holds the plaintext password. chown "${SSH_USER}:${SSH_USER}" /etc/bastion/httpd.conf chmod 0600 /etc/bastion/httpd.conf if [ "$BROKER_MODE" -eq 1 ]; then # CGI: httpd validated the password; the command comes from the # X-Bastion-Command header and is gated by the broker. cat > /var/www/cgi-bin/run <<'CGI' #!/bin/sh # Auto-generated (broker mode, basic auth). printf 'Content-Type: text/plain\r\nCache-Control: no-cache\r\nX-Accel-Buffering: no\r\n\r\n' exec /usr/local/bin/bastion-broker "${HTTP_X_BASTION_COMMAND:-}" 2>&1 CGI else # CGI: auth already done by httpd before we got here. cat > /var/www/cgi-bin/run <<'CGI' #!/bin/sh # Auto-generated. Auth was validated by busybox httpd via httpd.conf # before this script ran — REMOTE_USER holds the authenticated username. # # Optional X-Deploy-Bump header (set by upstream nginx capturing the URL # suffix /patch|/minor|/major) is validated here and forwarded to the # FORCE_COMMAND wrapper as a single positional arg. Anything else # (missing header, unknown value) passes through with no arg, leaving # the caller's deploy.sh to apply its own default. BUMP_ARG="" case "${HTTP_X_DEPLOY_BUMP:-}" in patch) BUMP_ARG="--patch" ;; minor) BUMP_ARG="--minor" ;; major) BUMP_ARG="--major" ;; esac printf 'Content-Type: text/plain\r\nCache-Control: no-cache\r\nX-Accel-Buffering: no\r\n\r\n' exec /etc/bastion/force-command $BUMP_ARG 2>&1 CGI fi chmod 0755 /var/www/cgi-bin/run # -c CONFFILE = auth + content-type rules; httpd reads it as root before # dropping to -u USER. CGI scripts then run as USER. # # Set HTTP_AS_ROOT=1 to skip the -u drop, so httpd (and the CGI it # spawns) run as root. Use this when FORCE_COMMAND is a deploy-style # script that needs full filesystem write + docker-socket access + # arbitrary chown — busybox httpd's `-u USER` drop does setuid/setgid # but not setgroups, so supplementary groups (e.g. dockerhost for the # mounted /var/run/docker.sock) don't reach the CGI even when the user # is a member on paper. Bastion already has socket = host root, so # this doesn't enlarge the trust envelope. if [ "${HTTP_AS_ROOT:-0}" = "1" ]; then echo " HTTP_AS_ROOT=1 — httpd + CGI run as root" httpd -f -p "${HTTP_PORT}" -h /var/www -c /etc/bastion/httpd.conf & else httpd -f -p "${HTTP_PORT}" -h /var/www -u "${SSH_USER}" -c /etc/bastion/httpd.conf & fi HTTP_PID=$! echo " httpd PID ${HTTP_PID}, endpoint: /cgi-bin/run (basic auth)" elif [ -n "${HTTP_TOKEN:-}" ]; then echo " Auth: Bearer — enabling httpd on port ${HTTP_PORT}" mkdir -p /var/www/cgi-bin if [ "$BROKER_MODE" -eq 1 ]; then cat > /var/www/cgi-bin/run <<'CGI' #!/bin/sh # Auto-generated (broker mode, bearer auth). Bearer check first, then the # X-Bastion-Command header is routed through the allowlist gate. case "${HTTP_AUTHORIZATION:-}" in "Bearer ${HTTP_TOKEN}") ;; *) printf 'Status: 401 Unauthorized\r\nContent-Type: text/plain\r\nWWW-Authenticate: Bearer\r\n\r\nUnauthorized\n' exit 0 ;; esac printf 'Content-Type: text/plain\r\nCache-Control: no-cache\r\nX-Accel-Buffering: no\r\n\r\n' exec /usr/local/bin/bastion-broker "${HTTP_X_BASTION_COMMAND:-}" 2>&1 CGI else cat > /var/www/cgi-bin/run <<'CGI' #!/bin/sh # Auto-generated. Bearer auth handled here in the CGI. case "${HTTP_AUTHORIZATION:-}" in "Bearer ${HTTP_TOKEN}") ;; *) printf 'Status: 401 Unauthorized\r\nContent-Type: text/plain\r\nWWW-Authenticate: Bearer\r\n\r\nUnauthorized\n' exit 0 ;; esac printf 'Content-Type: text/plain\r\nCache-Control: no-cache\r\nX-Accel-Buffering: no\r\n\r\n' exec /etc/bastion/force-command 2>&1 CGI fi chmod 0755 /var/www/cgi-bin/run if [ "${HTTP_AS_ROOT:-0}" = "1" ]; then echo " HTTP_AS_ROOT=1 — httpd + CGI run as root" httpd -f -p "${HTTP_PORT}" -h /var/www & else httpd -f -p "${HTTP_PORT}" -h /var/www -u "${SSH_USER}" & fi HTTP_PID=$! echo " httpd PID ${HTTP_PID}, endpoint: /cgi-bin/run (bearer token)" else echo " HTTP disabled (set HTTP_BASIC_AUTH or HTTP_TOKEN to enable)" fi # --------------------------------------------------------------------------- # 8) sshd config sanity check + launch # --------------------------------------------------------------------------- echo "[6/6] sshd config check..." /usr/sbin/sshd -t -f /etc/ssh/sshd_config echo "==========================================" echo " SSH: port ${SSH_PORT}" [ -n "${HTTP_BASIC_AUTH:-}" ] && echo " HTTP: port ${HTTP_PORT} (basic auth)" [ -n "${HTTP_TOKEN:-}" ] && echo " HTTP: port ${HTTP_PORT} (bearer token)" echo " User: ${SSH_USER}" if [ "$BROKER_MODE" -eq 1 ]; then echo " Mode: broker (client command, allowlist-gated)" [ -n "$COMMAND_PREFIX_VALUE" ] && echo " Prefix: ${COMMAND_PREFIX_VALUE}" else echo " Mode: force-command (fixed)" echo " ForceCommand: ${FORCE_COMMAND_VALUE}" fi echo "==========================================" # -D = foreground, -e = log to stderr (so docker logs picks it up). exec /usr/sbin/sshd -D -e