diff --git a/Dockerfile b/Dockerfile index 9cc2a3e..ff71b54 100644 --- a/Dockerfile +++ b/Dockerfile @@ -73,9 +73,14 @@ RUN addgroup -g ${SSH_GID} agent && \ # --------------------------------------------------------------------------- COPY config/sshd_config /etc/ssh/sshd_config COPY scripts/start-container /usr/local/bin/start-container -RUN chmod 0755 /usr/local/bin/start-container && \ - mkdir -p /etc/bastion /etc/ssh/keys /var/empty && \ +COPY scripts/bastion-list-keys /usr/local/bin/bastion-list-keys +# 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 && \ + 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 # --------------------------------------------------------------------------- @@ -94,16 +99,25 @@ RUN chmod 0755 /usr/local/bin/start-container && \ # (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_HOST — file path; merged into authorized_keys if present -# AUTHORIZED_KEYS_REPO — file path; merged into authorized_keys if present -# (mount either, both, or neither — at least one -# must exist or the container refuses to start) +# 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 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 diff --git a/README.md b/README.md index 3389950..5478072 100644 --- a/README.md +++ b/README.md @@ -169,16 +169,19 @@ Interactive commands (`docker exec -it app bash`) over HTTP fail because there's The client cannot override the command. `SSH_ORIGINAL_COMMAND` and HTTP request bodies are intentionally ignored. -## Authorized keys — two sources, merged +## Authorized keys — three sources, two flavors -At boot the entrypoint concatenates whichever of these files exist into the agent's `authorized_keys`. **At least one must exist** or the container refuses to start with a clear error. +sshd consults three sources on every authentication attempt: -| File | Typical mount | -|---------------------------------------|------------------------------------------------| -| `/etc/bastion/authorized_keys.host` | `~/.ssh/authorized_keys` from the docker host | -| `/etc/bastion/authorized_keys.repo` | `./docker/bastion/authorized_keys` in the repo | +| Source | Read when | Mount UX | +|-----------------------------------------|----------------------|--------------------------------------------------------------------------| +| `/etc/bastion/users.d/*.pub` | **live, every login** | Drop one `.pub` file per user — `users.d/alice.pub`, `users.d/bob.pub`. No restart to add/revoke. | +| `/etc/bastion/authorized_keys.host` | merged at boot | Single file from the host — `~/.ssh/authorized_keys`. | +| `/etc/bastion/authorized_keys.repo` | merged at boot | Single file from the repo — `./docker/bastion/authorized_keys`. | -Mount one, both, or neither — though neither = startup failure. +**Recommended: `users.d/`** — one file per identity, dropped in via a host bind mount, adds and revokes immediately. The two file-based sources stay for backward compatibility and for the "one big committed file" pattern. + +Zero authorized keys is now a warning, not a startup failure — the bastion runs but every SSH attempt fails with `publickey denied` until you drop a key in. ## Environment variables @@ -189,8 +192,9 @@ Mount one, both, or neither — though neither = startup failure. | `HTTP_TOKEN` | *(unset)* | Enables HTTP with Bearer auth. Clients send `Authorization: Bearer `. Mutually exclusive with `HTTP_BASIC_AUTH` (basic takes precedence). | | `HTTP_PORT` | `8080` | Port for the HTTP listener (when either auth var is set). | | `SSH_PORT` | `22` | Port for sshd inside the container. | -| `AUTHORIZED_KEYS_HOST` | `/etc/bastion/authorized_keys.host` | Path of the host-sourced authorized_keys to merge. | -| `AUTHORIZED_KEYS_REPO` | `/etc/bastion/authorized_keys.repo` | Path of the repo-sourced authorized_keys to merge. | +| `AUTHORIZED_KEYS_DIR` | `/etc/bastion/users.d` | Directory of `*.pub` files, **live-read** by sshd via `AuthorizedKeysCommand`. Drop a file → next login picks it up. | +| `AUTHORIZED_KEYS_HOST` | `/etc/bastion/authorized_keys.host` | Single-file source, merged at boot. Optional / legacy. | +| `AUTHORIZED_KEYS_REPO` | `/etc/bastion/authorized_keys.repo` | Single-file source, merged at boot. Optional / legacy. | ## Build args diff --git a/config/sshd_config b/config/sshd_config index c4a114f..b0ddd01 100644 --- a/config/sshd_config +++ b/config/sshd_config @@ -28,7 +28,18 @@ PermitRootLogin no PasswordAuthentication no KbdInteractiveAuthentication no PubkeyAuthentication yes + +# Two key sources sshd consults on every auth attempt: +# 1. The boot-time merged file at /home/%u/.ssh/authorized_keys — +# populated from $AUTHORIZED_KEYS_HOST / $AUTHORIZED_KEYS_REPO file +# mounts. Static after container start. +# 2. AuthorizedKeysCommand /usr/local/bin/bastion-list-keys — +# enumerates *.pub files in $AUTHORIZED_KEYS_DIR (default +# /etc/bastion/users.d). Runs on every login, so adding or removing +# a pubkey file takes effect immediately without a container restart. AuthorizedKeysFile /home/%u/.ssh/authorized_keys +AuthorizedKeysCommand /usr/local/bin/bastion-list-keys +AuthorizedKeysCommandUser agent # Surface reduction — no forwarding, no tunnels, no user env / rc files. AllowAgentForwarding no diff --git a/scripts/bastion-list-keys b/scripts/bastion-list-keys new file mode 100644 index 0000000..f22a01d --- /dev/null +++ b/scripts/bastion-list-keys @@ -0,0 +1,17 @@ +#!/bin/sh +# Lists all authorized public keys from $AUTHORIZED_KEYS_DIR for sshd. +# +# sshd invokes this script via AuthorizedKeysCommand on every auth attempt, +# so adding/removing a *.pub file is picked up live — no container restart. +# +# Exit 0 with no output if there are no keys (sshd treats this the same +# as "no AuthorizedKeysCommand matches" and falls through to AuthorizedKeysFile, +# which we keep for the boot-time-merged file from AUTHORIZED_KEYS_HOST/_REPO). +DIR="${AUTHORIZED_KEYS_DIR:-/etc/bastion/users.d}" +for f in "$DIR"/*.pub; do + [ -f "$f" ] || continue + cat "$f" + # ensure newline between keys (most .pub files end with one anyway) + echo +done +exit 0 diff --git a/scripts/start-container b/scripts/start-container index dbb9ddb..b00870f 100644 --- a/scripts/start-container +++ b/scripts/start-container @@ -64,16 +64,28 @@ for src in "$AUTHORIZED_KEYS_HOST" "$AUTHORIZED_KEYS_REPO"; do 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 +# 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)" -key_count=$(grep -cvE '^[[:space:]]*(#|$)' "$AUTH_FILE" 2>/dev/null || echo 0) -echo " Loaded $key_count authorized key(s) from $added 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 "${SSH_USER}:${SSH_USER}" "$(dirname "$AUTH_FILE")" chmod 700 "$(dirname "$AUTH_FILE")"