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:
parent
58492c80ec
commit
0262f677c1
26
Dockerfile
26
Dockerfile
|
|
@ -73,9 +73,14 @@ RUN addgroup -g ${SSH_GID} agent && \
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
COPY config/sshd_config /etc/ssh/sshd_config
|
COPY config/sshd_config /etc/ssh/sshd_config
|
||||||
COPY scripts/start-container /usr/local/bin/start-container
|
COPY scripts/start-container /usr/local/bin/start-container
|
||||||
RUN chmod 0755 /usr/local/bin/start-container && \
|
COPY scripts/bastion-list-keys /usr/local/bin/bastion-list-keys
|
||||||
mkdir -p /etc/bastion /etc/ssh/keys /var/empty && \
|
# 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 700 /etc/ssh/keys && \
|
||||||
|
chmod 755 /etc/bastion/users.d && \
|
||||||
chmod 711 /var/empty
|
chmod 711 /var/empty
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -94,16 +99,25 @@ RUN chmod 0755 /usr/local/bin/start-container && \
|
||||||
# (Either, both, or neither — neither = SSH only.)
|
# (Either, both, or neither — neither = SSH only.)
|
||||||
# HTTP_PORT — HTTP listen port (default 8080).
|
# HTTP_PORT — HTTP listen port (default 8080).
|
||||||
# SSH_PORT — sshd listen port inside the container (default 22).
|
# SSH_PORT — sshd listen port inside the container (default 22).
|
||||||
# AUTHORIZED_KEYS_HOST — file path; merged into authorized_keys if present
|
# AUTHORIZED_KEYS_DIR — directory of *.pub files (default
|
||||||
# AUTHORIZED_KEYS_REPO — file path; merged into authorized_keys if present
|
# /etc/bastion/users.d). Read LIVE by sshd via
|
||||||
# (mount either, both, or neither — at least one
|
# AuthorizedKeysCommand on every auth attempt —
|
||||||
# must exist or the container refuses to start)
|
# 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 FORCE_COMMAND=""
|
||||||
ENV HTTP_TOKEN=""
|
ENV HTTP_TOKEN=""
|
||||||
ENV HTTP_BASIC_AUTH=""
|
ENV HTTP_BASIC_AUTH=""
|
||||||
ENV HTTP_PORT=8080
|
ENV HTTP_PORT=8080
|
||||||
ENV SSH_PORT=22
|
ENV SSH_PORT=22
|
||||||
|
ENV AUTHORIZED_KEYS_DIR=/etc/bastion/users.d
|
||||||
ENV AUTHORIZED_KEYS_HOST=/etc/bastion/authorized_keys.host
|
ENV AUTHORIZED_KEYS_HOST=/etc/bastion/authorized_keys.host
|
||||||
ENV AUTHORIZED_KEYS_REPO=/etc/bastion/authorized_keys.repo
|
ENV AUTHORIZED_KEYS_REPO=/etc/bastion/authorized_keys.repo
|
||||||
|
|
||||||
|
|
|
||||||
22
README.md
22
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.
|
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 |
|
| Source | Read when | Mount UX |
|
||||||
|---------------------------------------|------------------------------------------------|
|
|-----------------------------------------|----------------------|--------------------------------------------------------------------------|
|
||||||
| `/etc/bastion/authorized_keys.host` | `~/.ssh/authorized_keys` from the docker host |
|
| `/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.repo` | `./docker/bastion/authorized_keys` in the repo |
|
| `/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
|
## 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_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). |
|
| `HTTP_PORT` | `8080` | Port for the HTTP listener (when either auth var is set). |
|
||||||
| `SSH_PORT` | `22` | Port for sshd inside the container. |
|
| `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_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_REPO` | `/etc/bastion/authorized_keys.repo` | Path of the repo-sourced authorized_keys to merge. |
|
| `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
|
## Build args
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,18 @@ PermitRootLogin no
|
||||||
PasswordAuthentication no
|
PasswordAuthentication no
|
||||||
KbdInteractiveAuthentication no
|
KbdInteractiveAuthentication no
|
||||||
PubkeyAuthentication yes
|
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
|
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.
|
# Surface reduction — no forwarding, no tunnels, no user env / rc files.
|
||||||
AllowAgentForwarding no
|
AllowAgentForwarding no
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -64,16 +64,28 @@ for src in "$AUTHORIZED_KEYS_HOST" "$AUTHORIZED_KEYS_REPO"; do
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
if [ "$added" -eq 0 ]; then
|
# awk emits matching lines (non-blank, non-comment), wc -l counts them.
|
||||||
echo "FATAL: no authorized_keys source found."
|
# Both exit 0 even on empty input — important under `set -e`, which
|
||||||
echo " Mount at least one of:"
|
# `grep -c` would have triggered (exits 1 when nothing matches).
|
||||||
echo " $AUTHORIZED_KEYS_HOST (typically ~/.ssh/authorized_keys from host)"
|
file_keys=$(awk 'NF && $0 !~ /^[[:space:]]*#/' "$AUTH_FILE" 2>/dev/null | wc -l)
|
||||||
echo " $AUTHORIZED_KEYS_REPO (typically ./docker/bastion/authorized_keys)"
|
file_keys=$(echo "$file_keys" | tr -d ' ') # wc on busybox pads with spaces
|
||||||
exit 1
|
echo " Boot-merged: $file_keys key(s) from $added file source(s)"
|
||||||
fi
|
|
||||||
|
|
||||||
key_count=$(grep -cvE '^[[:space:]]*(#|$)' "$AUTH_FILE" 2>/dev/null || echo 0)
|
# Live-read directory — sshd reads this via AuthorizedKeysCommand on every
|
||||||
echo " Loaded $key_count authorized key(s) from $added source(s)"
|
# 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")"
|
chown -R "${SSH_USER}:${SSH_USER}" "$(dirname "$AUTH_FILE")"
|
||||||
chmod 700 "$(dirname "$AUTH_FILE")"
|
chmod 700 "$(dirname "$AUTH_FILE")"
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue