diff --git a/Dockerfile b/Dockerfile index 8dd0368..d0c0e58 100644 --- a/Dockerfile +++ b/Dockerfile @@ -36,12 +36,24 @@ RUN apk add --no-cache \ tzdata # --------------------------------------------------------------------------- -# Bastion user — UID/GID 1000, /sbin/nologin shell (ForceCommand is the only path) +# Bastion user — UID/GID 1000. Login shell = /bin/sh. +# +# Why not /sbin/nologin? +# sshd's ForceCommand wraps the command in ` -c "..."`. With +# nologin as the shell, every ForceCommand invocation just prints +# "This account is not available" and exits — defeating the whole point. +# Security comes from ForceCommand + sshd_config, not from the shell; +# clients cannot bypass ForceCommand to ask for an interactive shell. # --------------------------------------------------------------------------- ARG SSH_UID=1000 ARG SSH_GID=1000 RUN addgroup -g ${SSH_GID} agent && \ - adduser -D -u ${SSH_UID} -G agent -s /sbin/nologin agent && \ + adduser -D -u ${SSH_UID} -G agent -s /bin/sh agent && \ + # Alpine's `adduser -D` leaves the shadow password field as `!`, which + # OpenSSH 9.x interprets as "account locked" and refuses even for pubkey + # auth (logs: "User agent not allowed because account is locked"). + # Replace with `*` — no password set, but account NOT locked. + sed -i 's/^agent:!:/agent:*:/' /etc/shadow && \ mkdir -p /home/agent/.ssh && \ chown -R agent:agent /home/agent/.ssh && \ chmod 700 /home/agent/.ssh diff --git a/README.md b/README.md index 5e37329..8c0ac0c 100644 --- a/README.md +++ b/README.md @@ -200,7 +200,7 @@ Mount one, both, or neither — though neither = startup failure. ## What's inside -- **openssh-server** — hardened config: key-only auth, no forwarding, no PAM, no user env, `/sbin/nologin` login shell, global `ForceCommand` directive. +- **openssh-server** — hardened config: key-only auth, no forwarding, no PAM, no user env, global `ForceCommand` directive (clients cannot bypass). - **busybox httpd** (busybox-extras) — minimal HTTP listener for the URL path; CGI-driven; only starts when `HTTP_TOKEN` is set. - **docker-cli + docker-cli-compose** — so `FORCE_COMMAND` can target containers through a mounted docker socket. Group membership is auto-aligned to the host socket's GID at boot. - **tini** — PID 1, signal handling, zombie reaping. @@ -217,7 +217,7 @@ Practical checklist: 1. **Key-only SSH, no passwords** — enforced in `sshd_config`. 2. **Token-only HTTP** — no path is open without `Authorization: Bearer`. 3. **No agent / TCP / X11 forwarding, no port tunnels** — enforced in `sshd_config`. -4. **Login shell is `/sbin/nologin`** — no fallback if `ForceCommand` somehow misfires. +4. **ForceCommand cannot be bypassed.** Clients can request any command (`ssh user@host arbitrary-thing`); sshd ignores it and runs `/etc/bastion/force-command`. `SSH_ORIGINAL_COMMAND` is dropped. The bastion user's login shell is `/bin/sh` (not `nologin` — that would break ForceCommand itself, since sshd invokes the user's shell as `shell -c ""`), but it has no path to anything other than the wrapper. 5. **`PermitUserEnvironment no`, `PermitUserRC no`** — clients cannot inject env vars or rc files. 6. **Bind host ports to `127.0.0.1` or hide them behind traefik+TLS unless you genuinely need them publicly open on raw TCP.** The traefik path with `entrypoints: websecure` and `tls: true` is the recommended public exposure. 7. **Rotate `HTTP_TOKEN` regularly.** Generate with `openssl rand -hex 32`, store in `.env`, never commit.