A users.d/ drop-in directory for live-read authorized keys

sshd now consults /etc/bastion/users.d/*.pub on every authentication
attempt via AuthorizedKeysCommand, so adding or removing a user
takes effect immediately without restarting the container — just
drop `alice.pub` (or any *.pub file) into the host-bound dir,
sshd picks it up on the next login.

Implementation:
- /usr/local/bin/bastion-list-keys: minimal POSIX-sh script that
  cats $AUTHORIZED_KEYS_DIR/*.pub. Runs as the agent user (per
  AuthorizedKeysCommandUser), reads world-readable pubkeys.
- sshd_config: AuthorizedKeysCommand alongside the existing
  AuthorizedKeysFile — both checked, so the boot-merged
  file (AUTHORIZED_KEYS_HOST/_REPO) still works for single-file UX.
- start-container: 'zero key sources' is now a WARN, not a fatal.
  Bastion comes up empty; SSH attempts fail with 'publickey denied'
  until you drop a key. Lets users `docker compose up` first and
  add keys later.

Bug fix on the way through: `grep -c` exits non-zero when no
lines match, which under `set -eu` killed the boot script
silently after '[2/5] Authorized keys...'. Switched to
`awk … | wc -l` which exits 0 cleanly on empty input.

README updated with the new source priority and env var.
This commit is contained in:
Fabian @ Blax Software 2026-05-28 12:31:51 +02:00
parent 58492c80ec
commit 0262f677c1
5 changed files with 82 additions and 24 deletions

View File

@ -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

View File

@ -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 <this>`. 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

View File

@ -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

17
scripts/bastion-list-keys Normal file
View File

@ -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

View File

@ -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")"