From 58492c80eccb844efd874be2f11387dd1a15b040 Mon Sep 17 00:00:00 2001 From: "Fabian @ Blax Software" Date: Thu, 28 May 2026 12:11:20 +0200 Subject: [PATCH] A HTTP basic auth + git/ssh tooling for deploy.sh-shape FORCE_COMMANDs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- Dockerfile | 20 ++++++--- README.md | 22 +++++----- scripts/start-container | 89 +++++++++++++++++++++++++++++------------ 3 files changed, 90 insertions(+), 41 deletions(-) diff --git a/Dockerfile b/Dockerfile index d0c0e58..9cc2a3e 100644 --- a/Dockerfile +++ b/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 `. -# Leave unset for SSH-only mode. +# HTTP_TOKEN — optional. Enables HTTP listener with Bearer +# auth: `Authorization: Bearer `. +# 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 diff --git a/README.md b/README.md index 8c0ac0c..3389950 100644 --- a/README.md +++ b/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 `. | -| `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 `. 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 ""`), but it has no path to anything other than the wrapper. 5. **`PermitUserEnvironment no`, `PermitUserRC no`** — clients cannot inject env vars or rc files. diff --git a/scripts/start-container b/scripts/start-container index 79fe478..dbb9ddb 100644 --- a/scripts/start-container +++ b/scripts/start-container @@ -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 ` 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 " + 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 # ---------------------------------------------------------------------------