2026-05-28 08:50:06 +00:00
|
|
|
#!/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}"
|
2026-05-28 09:25:34 +00:00
|
|
|
HTTP_PORT="${HTTP_PORT:-8080}"
|
2026-05-28 08:50:06 +00:00
|
|
|
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.
|
2026-05-28 10:11:20 +00:00
|
|
|
# sshd invokes this script for every authenticated session; the HTTP CGI
|
|
|
|
|
# exec's it after auth.
|
2026-05-28 08:50:06 +00:00
|
|
|
# SSH_ORIGINAL_COMMAND is intentionally ignored — clients cannot override.
|
2026-05-28 10:11:20 +00:00
|
|
|
#
|
|
|
|
|
# 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
|
2026-05-28 08:50:06 +00:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
2026-05-28 10:11:20 +00:00
|
|
|
# 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:
|
2026-05-28 09:25:34 +00:00
|
|
|
#
|
2026-05-28 10:11:20 +00:00
|
|
|
# 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.
|
2026-05-28 09:25:34 +00:00
|
|
|
#
|
2026-05-28 10:11:20 +00:00
|
|
|
# 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.
|
2026-05-28 09:25:34 +00:00
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
echo "[5/6] HTTP listener..."
|
2026-05-28 10:11:20 +00:00
|
|
|
if [ -n "${HTTP_BASIC_AUTH:-}" ]; then
|
|
|
|
|
echo " Auth: Basic — enabling httpd on port ${HTTP_PORT}"
|
2026-05-28 09:25:34 +00:00
|
|
|
mkdir -p /var/www/cgi-bin
|
2026-05-28 10:11:20 +00:00
|
|
|
# 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
|
|
|
|
|
# CGI: auth already done by httpd before we got here.
|
2026-05-28 09:25:34 +00:00
|
|
|
cat > /var/www/cgi-bin/run <<'CGI'
|
|
|
|
|
#!/bin/sh
|
2026-05-28 10:11:20 +00:00
|
|
|
# Auto-generated. Auth was validated by busybox httpd via httpd.conf
|
|
|
|
|
# before this script ran — REMOTE_USER holds the authenticated username.
|
|
|
|
|
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
|
|
|
|
|
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.
|
|
|
|
|
httpd -f -p "${HTTP_PORT}" -h /var/www -u "${SSH_USER}" -c /etc/bastion/httpd.conf &
|
|
|
|
|
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
|
|
|
|
|
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
|
2026-05-28 09:25:34 +00:00
|
|
|
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
|
|
|
|
|
chmod 0755 /var/www/cgi-bin/run
|
|
|
|
|
httpd -f -p "${HTTP_PORT}" -h /var/www -u "${SSH_USER}" &
|
|
|
|
|
HTTP_PID=$!
|
2026-05-28 10:11:20 +00:00
|
|
|
echo " httpd PID ${HTTP_PID}, endpoint: /cgi-bin/run (bearer token)"
|
|
|
|
|
|
2026-05-28 09:25:34 +00:00
|
|
|
else
|
2026-05-28 10:11:20 +00:00
|
|
|
echo " HTTP disabled (set HTTP_BASIC_AUTH or HTTP_TOKEN to enable)"
|
2026-05-28 09:25:34 +00:00
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# 8) sshd config sanity check + launch
|
2026-05-28 08:50:06 +00:00
|
|
|
# ---------------------------------------------------------------------------
|
2026-05-28 09:25:34 +00:00
|
|
|
echo "[6/6] sshd config check..."
|
2026-05-28 08:50:06 +00:00
|
|
|
/usr/sbin/sshd -t -f /etc/ssh/sshd_config
|
|
|
|
|
|
|
|
|
|
echo "=========================================="
|
2026-05-28 09:25:34 +00:00
|
|
|
echo " SSH: port ${SSH_PORT}"
|
|
|
|
|
[ -n "${HTTP_TOKEN:-}" ] && echo " HTTP: port ${HTTP_PORT} (token-protected)"
|
2026-05-28 08:50:06 +00:00
|
|
|
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
|