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:
parent
8c5b89b5af
commit
58492c80ec
20
Dockerfile
20
Dockerfile
|
|
@ -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
|
||||
|
|
|
|||
22
README.md
22
README.md
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Reference in New Issue