#!/bin/sh set -eu echo "==========================================" echo " docker-bastion starting" echo "==========================================" 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:-}" AUTHORIZED_KEYS_HOST="${AUTHORIZED_KEYS_HOST:-/etc/bastion/authorized_keys.host}" AUTHORIZED_KEYS_REPO="${AUTHORIZED_KEYS_REPO:-/etc/bastion/authorized_keys.repo}" # --------------------------------------------------------------------------- # 1) Validate config # --------------------------------------------------------------------------- if [ -z "$FORCE_COMMAND_VALUE" ]; then echo "FATAL: FORCE_COMMAND must be set." echo " e.g. FORCE_COMMAND='docker exec -it app bash'" echo " or FORCE_COMMAND='cd /workspace && ./deploy.sh'" exit 1 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 if [ "$added" -eq 0 ]; then echo "FATAL: no authorized_keys source found." echo " Mount at least one of:" echo " $AUTHORIZED_KEYS_HOST (typically ~/.ssh/authorized_keys from host)" echo " $AUTHORIZED_KEYS_REPO (typically ./docker/bastion/authorized_keys)" exit 1 fi key_count=$(grep -cvE '^[[:space:]]*(#|$)' "$AUTH_FILE" 2>/dev/null || echo 0) echo " Loaded $key_count authorized key(s) from $added source(s)" chown -R "${SSH_USER}:${SSH_USER}" "$(dirname "$AUTH_FILE")" chmod 700 "$(dirname "$AUTH_FILE")" chmod 600 "$AUTH_FILE" # --------------------------------------------------------------------------- # 4) ForceCommand wrapper # # Write the configured command to a plain file, then a small wrapper that # exec's `sh -c "$(cat ...)"`. This way: # - Shell metacharacters in $FORCE_COMMAND work (&&, |, redirects, cd). # - The wrapper itself stays static (no escaping of user input into a # heredoc), and the command file is read at session start so changes # to $FORCE_COMMAND only need a container restart, not a rebuild. # --------------------------------------------------------------------------- echo "[3/5] ForceCommand..." mkdir -p /etc/bastion 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. # SSH_ORIGINAL_COMMAND is intentionally ignored — clients cannot override. exec sh -c "$(cat /etc/bastion/force-command.cmd)" WRAPPER chmod 0755 /etc/bastion/force-command echo " $FORCE_COMMAND_VALUE" # --------------------------------------------------------------------------- # 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 — docker-based FORCE_COMMAND 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_TOKEN) # # When $HTTP_TOKEN is set, busybox httpd binds $HTTP_PORT and serves a # single CGI endpoint at /cgi-bin/run. Clients authenticate by sending # `Authorization: Bearer ` and the script exec's the same # /etc/bastion/force-command wrapper SSH uses — so the output streams # back (httpd uses Connection: close on CGI without Content-Length, # meaning the client sees bytes as they arrive). # # Leave $HTTP_TOKEN unset to keep the bastion SSH-only. # --------------------------------------------------------------------------- echo "[5/6] HTTP listener..." if [ -n "${HTTP_TOKEN:-}" ]; then echo " HTTP_TOKEN set — enabling httpd on port ${HTTP_PORT}" mkdir -p /var/www/cgi-bin cat > /var/www/cgi-bin/run <<'CGI' #!/bin/sh # Auto-generated by docker-bastion start-container. # busybox httpd places the Authorization header in $HTTP_AUTHORIZATION. expected="${HTTP_TOKEN:-}" got="${HTTP_AUTHORIZATION#Bearer }" if [ -z "$expected" ] || [ "$got" != "$expected" ]; then printf 'Status: 401 Unauthorized\r\nContent-Type: text/plain\r\nWWW-Authenticate: Bearer\r\n\r\nUnauthorized\n' exit 0 fi printf 'Content-Type: text/plain\r\nCache-Control: no-cache\r\nX-Accel-Buffering: no\r\n\r\n' # Merge stderr into stdout so failures are visible to the client. exec /etc/bastion/force-command 2>&1 CGI chmod 0755 /var/www/cgi-bin/run # `httpd` here is the busybox-extras applet (symlink → /bin/busybox-extras). # `busybox httpd` would fail — the core busybox binary in alpine doesn't # include the httpd applet anymore; only busybox-extras does. # -f = foreground (we background with &); -p PORT; -h DOCROOT; -u USER. httpd -f -p "${HTTP_PORT}" -h /var/www -u "${SSH_USER}" & HTTP_PID=$! echo " httpd PID ${HTTP_PID}, endpoint: POST /cgi-bin/run with Authorization: Bearer " else echo " HTTP_TOKEN unset — HTTP listener disabled (SSH-only mode)" 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_TOKEN:-}" ] && echo " HTTP: port ${HTTP_PORT} (token-protected)" echo " User: ${SSH_USER}" echo " ForceCommand: ${FORCE_COMMAND_VALUE}" echo "==========================================" # -D = foreground, -e = log to stderr (so docker logs picks it up). exec /usr/sbin/sshd -D -e