A HTTP basic auth + git/ssh tooling for deploy.sh-shape FORCE_COMMANDs

HTTP basic auth via HTTP_BASIC_AUTH=user:password. Mutually
exclusive with HTTP_TOKEN (basic takes precedence if both set).

Implementation note: busybox httpd strips Authorization: Basic
headers before invoking CGI scripts (it expects to handle basic
auth itself), so a CGI-side check for Basic doesn't work. We let
busybox handle Basic via its -c httpd.conf rule (`/cgi-bin/:user:pass`)
and keep the CGI-side Bearer check for HTTP_TOKEN. httpd.conf is
chowned to the agent user because httpd drops privileges before
reading -c.

Image additions for canonical deploy.sh patterns:
- git (apk add git) — for git pull/tag/push.
- openssh-client (apk add openssh-client) — provides /usr/bin/ssh,
  which git invokes for ssh:// remote transports. Without it
  `git push origin` fails with 'error: cannot run ssh: No such
  file or directory'.
- HOME=/home/agent exported in the force-command wrapper — busybox
  httpd doesn't set HOME for CGI, leaving git/ssh/xdg lookups
  pointing at /root and producing 'Permission denied' warnings.

README updated with HTTP_BASIC_AUTH env var, URL syntax examples,
and the mutual-exclusion note.
This commit is contained in:
Fabian @ Blax Software 2026-05-28 12:11:20 +02:00
parent 8c5b89b5af
commit 58492c80ec
3 changed files with 90 additions and 41 deletions

View File

@ -33,7 +33,14 @@ RUN apk add --no-cache \
bash \
tini \
ca-certificates \
tzdata
tzdata \
# git so FORCE_COMMAND scripts can run `git pull` / `git tag` as
# canonical deploy.sh patterns expect.
git \
# openssh-client provides the `ssh` binary git uses for
# `git push origin` over the ssh:// transport. Without it,
# deploy.sh fails with `error: cannot run ssh: No such file…`.
openssh-client
# ---------------------------------------------------------------------------
# Bastion user — UID/GID 1000. Login shell = /bin/sh.
@ -79,10 +86,12 @@ RUN chmod 0755 /usr/local/bin/start-container && \
# docker exec -it app bash
# docker compose -f /workspace/compose.yml exec app bash
# cd /workspace && ./deploy.sh
# HTTP_TOKEN — optional. When set, enables an HTTP listener on
# $HTTP_PORT serving /cgi-bin/run. Clients must
# send `Authorization: Bearer <HTTP_TOKEN>`.
# Leave unset for SSH-only mode.
# HTTP_TOKEN — optional. Enables HTTP listener with Bearer
# auth: `Authorization: Bearer <HTTP_TOKEN>`.
# HTTP_BASIC_AUTH — optional. Enables HTTP listener with Basic
# auth. Value is "user:password". Works with
# `curl https://user:password@host/…`.
# (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
@ -92,6 +101,7 @@ RUN chmod 0755 /usr/local/bin/start-container && \
# ---------------------------------------------------------------------------
ENV FORCE_COMMAND=""
ENV HTTP_TOKEN=""
ENV HTTP_BASIC_AUTH=""
ENV HTTP_PORT=8080
ENV SSH_PORT=22
ENV AUTHORIZED_KEYS_HOST=/etc/bastion/authorized_keys.host

View File

@ -34,10 +34,12 @@ services:
# Shell metacharacters work: &&, ||, pipes, cd, redirects.
FORCE_COMMAND: "docker exec -it wordpress-app bash"
# OPTIONAL — enables the HTTP endpoint at /cgi-bin/run.
# Without HTTP_TOKEN set, the bastion is SSH-only.
# Generate with: openssl rand -hex 32
HTTP_TOKEN: "${BASTION_HTTP_TOKEN}"
# OPTIONAL — enables the HTTP endpoint at /cgi-bin/run with basic auth.
# Value is "user:password". Works with `curl https://user:pass@host/…`.
# Without this (and without HTTP_TOKEN), the bastion is SSH-only.
HTTP_BASIC_AUTH: "${BASTION_HTTP_BASIC_AUTH}"
# Alternative: HTTP_TOKEN="$(openssl rand -hex 32)" for Bearer auth.
# Pick one — they're mutually exclusive (Basic wins if both are set).
volumes:
# REQUIRED when FORCE_COMMAND talks to docker (docker exec, docker compose, etc).
@ -100,9 +102,8 @@ From the client:
# Interactive shell inside the wp container — feels exactly like ssh-into-vps
ssh -p 2222 agent@your-host
# Or trigger from a URL — token-protected, output streams back
curl -H "Authorization: Bearer $BASTION_HTTP_TOKEN" \
https://deploy-wp.example.com/cgi-bin/run
# Or trigger from a URL — basic-auth protected, output streams back
curl https://user:pass@deploy-wp.example.com/cgi-bin/run
```
## Quick Start — reload nginx on a webhook
@ -184,8 +185,9 @@ Mount one, both, or neither — though neither = startup failure.
| Variable | Default | Description |
|-------------------------|----------------------------------------|----------------------------------------------------------------------------|
| `FORCE_COMMAND` | *(required)* | The command run on every authenticated session. Shell metacharacters OK. |
| `HTTP_TOKEN` | *(unset → HTTP disabled)* | Enables the HTTP listener. Clients send `Authorization: Bearer <this>`. |
| `HTTP_PORT` | `8080` | Port for the HTTP listener (only when `HTTP_TOKEN` is set). |
| `HTTP_BASIC_AUTH` | *(unset)* | Enables HTTP with Basic auth. Value is `user:password`. Works with `curl -u`, `curl https://user:pass@host/…`, and browser URL bars. |
| `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. |
@ -215,7 +217,7 @@ The security boundary is **the authorized_keys file (SSH) and the `HTTP_TOKEN` (
Practical checklist:
1. **Key-only SSH, no passwords** — enforced in `sshd_config`.
2. **Token-only HTTP** — no path is open without `Authorization: Bearer`.
2. **HTTP requires auth** — either basic auth (via busybox httpd's `-c` conf, with `REMOTE_USER` set on the authenticated CGI) or bearer token (validated by the CGI script). No anonymous path.
3. **No agent / TCP / X11 forwarding, no port tunnels** — enforced in `sshd_config`.
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 "<forced-command>"`), but it has no path to anything other than the wrapper.
5. **`PermitUserEnvironment no`, `PermitUserRC no`** — clients cannot inject env vars or rc files.

View File

@ -97,8 +97,14 @@ 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.
# 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
exec sh -c "$(cat /etc/bastion/force-command.cmd)"
WRAPPER
chmod 0755 /etc/bastion/force-command
@ -135,45 +141,76 @@ if [ "$SSH_PORT" != "22" ]; then
fi
# ---------------------------------------------------------------------------
# 7) Optional HTTP listener (opt-in via $HTTP_TOKEN)
# 7) Optional HTTP listener (opt-in via $HTTP_BASIC_AUTH or $HTTP_TOKEN)
#
# When $HTTP_TOKEN is set, busybox httpd binds $HTTP_PORT and serves a
# single CGI endpoint at /cgi-bin/run. Clients authenticate by sending
# `Authorization: Bearer <HTTP_TOKEN>` and the script exec's the same
# /etc/bastion/force-command wrapper SSH uses — so the output streams
# back (httpd uses Connection: close on CGI without Content-Length,
# meaning the client sees bytes as they arrive).
# Two mutually-exclusive auth modes. Pick one and set the matching env var:
#
# Leave $HTTP_TOKEN unset to keep the bastion SSH-only.
# 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..."
if [ -n "${HTTP_TOKEN:-}" ]; then
echo " HTTP_TOKEN set — enabling httpd on port ${HTTP_PORT}"
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
# CGI: auth already done by httpd before we got here.
cat > /var/www/cgi-bin/run <<'CGI'
#!/bin/sh
# Auto-generated by docker-bastion start-container.
# busybox httpd places the Authorization header in $HTTP_AUTHORIZATION.
expected="${HTTP_TOKEN:-}"
got="${HTTP_AUTHORIZATION#Bearer }"
if [ -z "$expected" ] || [ "$got" != "$expected" ]; then
printf 'Status: 401 Unauthorized\r\nContent-Type: text/plain\r\nWWW-Authenticate: Bearer\r\n\r\nUnauthorized\n'
exit 0
fi
# Auto-generated. Auth was validated by busybox httpd via httpd.conf
# before this script ran — REMOTE_USER holds the authenticated username.
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
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.
httpd -f -p "${HTTP_PORT}" -h /var/www -u "${SSH_USER}" -c /etc/bastion/httpd.conf &
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
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'
# Merge stderr into stdout so failures are visible to the client.
exec /etc/bastion/force-command 2>&1
CGI
chmod 0755 /var/www/cgi-bin/run
# `httpd` here is the busybox-extras applet (symlink → /bin/busybox-extras).
# `busybox httpd` would fail — the core busybox binary in alpine doesn't
# include the httpd applet anymore; only busybox-extras does.
# -f = foreground (we background with &); -p PORT; -h DOCROOT; -u USER.
httpd -f -p "${HTTP_PORT}" -h /var/www -u "${SSH_USER}" &
HTTP_PID=$!
echo " httpd PID ${HTTP_PID}, endpoint: POST /cgi-bin/run with Authorization: Bearer <token>"
echo " httpd PID ${HTTP_PID}, endpoint: /cgi-bin/run (bearer token)"
else
echo " HTTP_TOKEN unset — HTTP listener disabled (SSH-only mode)"
echo " HTTP disabled (set HTTP_BASIC_AUTH or HTTP_TOKEN to enable)"
fi
# ---------------------------------------------------------------------------