#!/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

# Serialize invocations: only one FORCE_COMMAND runs at a time in this
# container. Two near-simultaneous deploy requests (an upstream proxy
# retrying a slow cold deploy, a browser double-fire, an overlapping
# webhook) otherwise spawn two `git reset/pull` runs that collide on
# .git/index.lock or a remote-tracking ref ("cannot lock ref"). busybox
# httpd forks a CGI child per request, so nothing upstream serializes them.
# Non-blocking: a second concurrent request returns a friendly 200 and
# leaves the in-flight winner alone, so an upstream proxy won't retry-storm
# and connections don't pile up on a stuck build (busybox flock has no -w).
# fd 9 stays open across the exec, so the lock is held for the whole command
# and releases when the process tree exits — even on SIGKILL.
exec 9>/tmp/bastion-force-command.lock
if ! flock -n 9; then
    echo "==> A deploy is already running in this bastion — skipping this request."
    exit 0
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_<UPPERCASE_WITH_UNDERSCORES>, 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
