feat(broker): allowlist-gated command broker mode

Add a second operating mode alongside FORCE_COMMAND: the client supplies
the command and it runs only if it matches a regex allowlist
(ALLOWED_COMMANDS / mounted allowed-commands.list), optionally behind a
trusted COMMAND_PREFIX. Matching is whole-line anchored (grep -Eqx) and
multi-line requests are rejected; execution is shell-free word-split, so
; | & $() are literal args and a sloppy rule can't become injection.

Works over SSH (SSH_ORIGINAL_COMMAND) and HTTP (X-Bastion-Command).
FORCE_COMMAND mode is unchanged and remains the default when no allowlist
is set; config is read from boot-written files since sshd does not pass
the daemon env to a ForceCommand session.

- scripts/bastion-broker: the allowlist gate + no-shell exec
- scripts/start-container: mode detection, broker wrapper + CGI, banner
- Dockerfile / config/sshd_config: wire in the broker, document env vars
- examples/docker-mailserver: ready-to-run broker config + allowlist
This commit is contained in:
Fabian @ Blax Software 2026-06-02 19:32:57 +02:00
parent 3ec02cea7b
commit 8eb57e5a77
7 changed files with 505 additions and 45 deletions

View File

@ -74,10 +74,11 @@ 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
COPY scripts/bastion-list-keys /usr/local/bin/bastion-list-keys COPY scripts/bastion-list-keys /usr/local/bin/bastion-list-keys
COPY scripts/bastion-broker /usr/local/bin/bastion-broker
# AuthorizedKeysCommand requires its script and *every* parent dir to be # AuthorizedKeysCommand requires its script and *every* parent dir to be
# root-owned and not group/world-writable. /usr/local/bin satisfies that # 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. # 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 && \ RUN chmod 0755 /usr/local/bin/start-container /usr/local/bin/bastion-list-keys /usr/local/bin/bastion-broker && \
mkdir -p /etc/bastion /etc/bastion/users.d /etc/ssh/keys /var/empty && \ 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 755 /etc/bastion/users.d && \
@ -85,12 +86,41 @@ RUN chmod 0755 /usr/local/bin/start-container /usr/local/bin/bastion-list-keys &
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Environment # Environment
# FORCE_COMMAND — REQUIRED. The single command run on every login. # The bastion runs in one of two modes, decided at boot:
# Shell metacharacters are supported. #
# • FORCE_COMMAND mode (default) — one fixed command per session; client
# input is ignored. Set FORCE_COMMAND.
# • Broker mode — the client SUPPLIES the command and it is validated
# against a regex allowlist before running. Set ALLOWED_COMMANDS (and/or
# mount /etc/bastion/allowed-commands.list). When the allowlist is set,
# FORCE_COMMAND is not required and is ignored.
#
# FORCE_COMMAND — FORCE_COMMAND mode: the single command run on
# every login. Shell metacharacters are supported.
# Examples: # Examples:
# docker exec -it app bash # docker exec -it app bash
# docker compose -f /workspace/compose.yml exec app bash # docker compose -f /workspace/compose.yml exec app bash
# cd /workspace && ./deploy.sh # cd /workspace && ./deploy.sh
# ALLOWED_COMMANDS — Broker mode: newline-separated list of extended
# regex (ERE) rules. A client request is permitted
# only if it matches one rule WHOLE-LINE (anchored).
# In compose, a YAML block scalar reads like an
# array — one regex per line:
# ALLOWED_COMMANDS: |
# setup email (add|update) [^ ]+@[^ ]+ [^ ]+
# setup email list
# Matched commands are word-split and exec'd with
# NO shell (; | & are literal args, not operators).
# Lines starting with # and blank lines are ignored.
# COMMAND_PREFIX — Broker mode, optional: a trusted prefix prepended
# to every validated request, e.g.
# "docker exec -i mailserver setup" so clients send
# just "email add a@b pw" and never see the docker
# plumbing. Operator-set, not validated.
# /etc/bastion/allowed-commands.list (mount) — Broker mode, optional: same
# one-rule-per-line format as ALLOWED_COMMANDS, but
# re-read every session (edit without a restart).
# Additive with ALLOWED_COMMANDS.
# HTTP_TOKEN — optional. Enables HTTP listener with Bearer # HTTP_TOKEN — optional. Enables HTTP listener with Bearer
# auth: `Authorization: Bearer <HTTP_TOKEN>`. # auth: `Authorization: Bearer <HTTP_TOKEN>`.
# HTTP_BASIC_AUTH — optional. Enables HTTP listener with Basic # HTTP_BASIC_AUTH — optional. Enables HTTP listener with Basic
@ -113,6 +143,8 @@ RUN chmod 0755 /usr/local/bin/start-container /usr/local/bin/bastion-list-keys &
# denied" until you drop a key in.) # denied" until you drop a key in.)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
ENV FORCE_COMMAND="" ENV FORCE_COMMAND=""
ENV ALLOWED_COMMANDS=""
ENV COMMAND_PREFIX=""
ENV HTTP_TOKEN="" ENV HTTP_TOKEN=""
ENV HTTP_BASIC_AUTH="" ENV HTTP_BASIC_AUTH=""
ENV HTTP_PORT=8080 ENV HTTP_PORT=8080

123
README.md
View File

@ -8,7 +8,7 @@
[![Image Size](https://img.shields.io/badge/image-~105MB-lightgrey)](#whats-inside) [![Image Size](https://img.shields.io/badge/image-~105MB-lightgrey)](#whats-inside)
[![License](https://img.shields.io/badge/license-MIT-lightgrey)](#license) [![License](https://img.shields.io/badge/license-MIT-lightgrey)](#license)
A **minimal SSH + HTTP bastion** for routing one preconfigured command per authenticated session. Authenticate by SSH key or HTTP bearer token, the container runs whatever you point `FORCE_COMMAND` at — `docker exec` into a sibling container, `./deploy.sh`, `nginx -s reload`, anything — and streams the output back. A **minimal SSH + HTTP bastion** for routing commands per authenticated session. Authenticate by SSH key or HTTP bearer token, and the container either runs one preconfigured command (`FORCE_COMMAND` — `docker exec` into a sibling, `./deploy.sh`, `nginx -s reload`) or, in **broker mode**, runs whatever the client asks for *as long as it matches a regex allowlist* (`ALLOWED_COMMANDS`) — then streams the output back.
**Why it exists:** giving an agent or CI bot `docker exec` access usually means handing them the docker socket and trusting their entire toolchain not to misbehave. A bastion with a hard-coded `FORCE_COMMAND` is the inverse: the credential authorizes *one specific thing*, the surface is sshd + busybox httpd, and the same image works for a dozen different roles by varying `FORCE_COMMAND`. **Why it exists:** giving an agent or CI bot `docker exec` access usually means handing them the docker socket and trusting their entire toolchain not to misbehave. A bastion with a hard-coded `FORCE_COMMAND` is the inverse: the credential authorizes *one specific thing*, the surface is sshd + busybox httpd, and the same image works for a dozen different roles by varying `FORCE_COMMAND`.
@ -158,6 +158,88 @@ curl --fail-with-body -H "Authorization: Bearer $BASTION_HTTP_TOKEN" \
`curl --fail-with-body` makes the CI step fail (non-zero exit) if the bastion returns 4xx/5xx, with the body printed — so a `nginx -t` syntax error in the new config shows up in the CI log without extra wiring. `curl --fail-with-body` makes the CI step fail (non-zero exit) if the bastion returns 4xx/5xx, with the body printed — so a `nginx -t` syntax error in the new config shows up in the CI log without extra wiring.
## Quick Start — manage docker-mailserver (broker mode)
The two examples above hard-code *one* command. **Broker mode** is the inverse: the client supplies the command and the bastion runs it only if it matches a regex allowlist. This is how you give a management UI (a Nuxt "mail manager", a cron job, a bot) a *menu* of allowed operations without handing it a shell.
Here one bastion fronts [docker-mailserver](https://github.com/docker-mailserver/docker-mailserver)'s `setup` CLI. The app sends `email add jane@example.com <pw>` over SSH on the internal network; the bastion validates it and runs `docker exec -i mailserver setup email add jane@example.com <pw>`.
```yaml
services:
bastion-mail:
image: blaxsoftware/bastion:latest
restart: unless-stopped
environment:
# Trusted prefix prepended to every validated request, so clients
# send clean `email add …` and never see the docker plumbing.
COMMAND_PREFIX: "docker exec -i mailserver setup"
# The whitelist. A YAML block scalar reads like a string array:
# one extended-regex (ERE) rule per line. A request is allowed only
# if it matches a rule WHOLE-LINE (anchored). Matched commands run
# WITHOUT a shell — ; | & $() are literal args, never operators —
# so keep argument classes tight ([^ ]+, not .*).
ALLOWED_COMMANDS: |
email add [^ ]+@[^ ]+ [^ ]+
email update [^ ]+@[^ ]+ [^ ]+
email del [^ ]+@[^ ]+
email list
alias add [^ ]+@[^ ]+ [^ ]+
alias del [^ ]+@[^ ]+ [^ ]+
alias list
quota set [^ ]+@[^ ]+ [0-9]+[KMGT]?
quota del [^ ]+@[^ ]+
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ./docker-data/bastion/users.d:/etc/bastion/users.d
- ./docker-data/bastion/keys:/etc/ssh/keys
networks: [web] # same network as the `mailserver` container
networks:
web:
external: true
```
From the mail-manager (same docker network, no host port needed):
```bash
# allowed → runs `docker exec -i mailserver setup email add …`
ssh agent@bastion-mail "email add jane@example.com $(openssl rand -base64 18 | tr -d /+=)"
ssh agent@bastion-mail "email list"
# refused → exits 126, nothing runs
ssh agent@bastion-mail "email del jane@example.com; rm -rf /"
```
A complete copy-paste setup (compose + a live-editable `allowed-commands.list`) is in [`examples/docker-mailserver/`](examples/docker-mailserver/).
## Command broker mode
Set `ALLOWED_COMMANDS` (or mount `/etc/bastion/allowed-commands.list`) and the bastion switches from "one fixed command" to "any command the client asks for, **if** it passes the allowlist". `FORCE_COMMAND` is then optional and ignored.
**How a request is judged**
1. The client-supplied command (SSH `SSH_ORIGINAL_COMMAND`, or the HTTP `X-Bastion-Command` header) is taken verbatim.
2. Multi-line requests are rejected outright (so a benign first line can't smuggle a second).
3. It is matched **whole-line, anchored** against each rule (`grep -Eqx`). First match wins.
4. On a match: the (optional) `COMMAND_PREFIX` and the request are **word-split with globbing off and `exec`'d directly — there is no `sh -c`.** `;` `|` `&` `` ` `` `$()` `<` `>` become literal arguments, never shell operators. On no match: refused, exit `126`, logged as `[broker] DENY: …`.
**Why shell-free execution matters.** It means the regex is a *containment* boundary, not just a *filter*. Even a careless rule like `email .*` cannot escalate to command injection — the worst case is that `setup` receives a weird argument. (Contrast the `sh -c` path used by `FORCE_COMMAND` mode, where a metacharacter *would* be interpreted.)
**Writing rules**
- One ERE per line; `#` comments and blank lines ignored; surrounding whitespace trimmed (so indented YAML block lines work).
- Anchoring is implicit — write `email list`, not `^email list$` (both work).
- Use tight argument classes: `[^ ]+` ("a run of non-spaces") beats `.*`. Add alternation for fixed verbs: `email (add|update|del|list)`.
- Values that must reach the target intact **cannot contain whitespace** (word-splitting) or be quoted (no shell). Generate passwords/tokens from a space-free alphabet — e.g. `openssl rand -base64 24 | tr -d /+=`.
**Two sources, additive:** `ALLOWED_COMMANDS` (env, snapshot at boot) **+** `/etc/bastion/allowed-commands.list` (bind mount, re-read every request — edit without a restart).
**SSH vs HTTP.** SSH is the recommended transport (clean stdout/stderr separation). The HTTP path works too — `POST`/`GET` with `Authorization` + `X-Bastion-Command: <cmd>` — but merges the broker's stderr into the response body (the CGI uses `2>&1`, same as the deploy examples).
**Where the audit line goes.** The broker prints `[broker] ALLOW: …` / `[broker] DENY: …` on the *command's* stderr — so over SSH it returns to the client on the channel's stderr stream, and over HTTP it is folded into the response body (`2>&1`). It does **not** land in the bastion container's own `docker logs` (sshd does not redirect a ForceCommand's stderr to the daemon log). If you want a container-side audit trail, tee it — e.g. have `COMMAND_PREFIX`/the wrapper append to a logfile, or run a syslog sidecar.
## Two channels, two shapes ## Two channels, two shapes
| Channel | Best for | TTY? | Streaming? | | Channel | Best for | TTY? | Streaming? |
@ -187,7 +269,10 @@ Zero authorized keys is now a warning, not a startup failure — the bastion run
| Variable | Default | Description | | Variable | Default | Description |
|-------------------------|----------------------------------------|----------------------------------------------------------------------------| |-------------------------|----------------------------------------|----------------------------------------------------------------------------|
| `FORCE_COMMAND` | *(required)* | The command run on every authenticated session. Shell metacharacters OK. | | `FORCE_COMMAND` | *(required unless `ALLOWED_COMMANDS` set)* | FORCE_COMMAND mode: the command run on every authenticated session. Shell metacharacters OK. Ignored in broker mode. |
| `ALLOWED_COMMANDS` | *(unset)* | Broker mode: newline-separated ERE allowlist. Set this (or mount the file below) to enable broker mode. Each rule must match a request whole-line. |
| `COMMAND_PREFIX` | *(unset)* | Broker mode, optional: trusted prefix prepended to every validated request (e.g. `docker exec -i mailserver setup`). |
| *(mount)* `/etc/bastion/allowed-commands.list` | *(unset)* | Broker mode, optional: same format as `ALLOWED_COMMANDS`, re-read every request (live edits, no restart). Additive. |
| `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_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_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). |
@ -207,7 +292,8 @@ Zero authorized keys is now a warning, not a startup failure — the bastion run
## What's inside ## What's inside
- **openssh-server** — hardened config: key-only auth, no forwarding, no PAM, no user env, global `ForceCommand` directive (clients cannot bypass). - **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. - **bastion-broker** — the allowlist gate for broker mode: whole-line ERE match, then shell-free word-split `exec`. Shared by the SSH and HTTP paths.
- **busybox httpd** (busybox-extras) — minimal HTTP listener for the URL path; CGI-driven; only starts when `HTTP_TOKEN` or `HTTP_BASIC_AUTH` 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. - **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. - **tini** — PID 1, signal handling, zombie reaping.
- **bash, ca-certificates, tzdata.** - **bash, ca-certificates, tzdata.**
@ -223,7 +309,7 @@ Practical checklist:
1. **Key-only SSH, no passwords** — enforced in `sshd_config`. 1. **Key-only SSH, no passwords** — enforced in `sshd_config`.
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. 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`. 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. 4. **ForceCommand cannot be bypassed.** Clients can request any command (`ssh user@host arbitrary-thing`); sshd ignores it and runs `/etc/bastion/force-command`. In FORCE_COMMAND mode `SSH_ORIGINAL_COMMAND` is dropped entirely. In **broker mode** it is *read* but only ever run if it matches the allowlist whole-line, and even then via shell-free word-split `exec` (no `sh -c`), so the regex is a containment boundary, not just a filter — see [Command broker mode](#command-broker-mode). 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. 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. 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. 7. **Rotate `HTTP_TOKEN` regularly.** Generate with `openssl rand -hex 32`, store in `.env`, never commit.
@ -237,21 +323,26 @@ Practical checklist:
start-container (entrypoint) start-container (entrypoint)
├─ generate host keys (idempotent, persisted via /etc/ssh/keys bind mount) ├─ generate host keys (idempotent, persisted via /etc/ssh/keys bind mount)
├─ merge AUTHORIZED_KEYS_HOST + AUTHORIZED_KEYS_REPO into authorized_keys ├─ merge AUTHORIZED_KEYS_HOST + AUTHORIZED_KEYS_REPO into authorized_keys
├─ write /etc/bastion/force-command wrapper from $FORCE_COMMAND ├─ pick mode: broker if $ALLOWED_COMMANDS / allowed-commands.list, else force-command
│ ├─ broker: snapshot allowlist + prefix; wrapper → bastion-broker
│ └─ force-command: write /etc/bastion/force-command wrapper from $FORCE_COMMAND
├─ align docker socket group membership to host GID (if socket is mounted) ├─ align docker socket group membership to host GID (if socket is mounted)
├─ start httpd → /var/www/cgi-bin/run (if $HTTP_TOKEN is set) ├─ start httpd → /var/www/cgi-bin/run (if $HTTP_BASIC_AUTH or $HTTP_TOKEN is set)
└─ exec sshd -D -e └─ exec sshd -D -e
ssh client FORCE_COMMAND mode broker mode
└─ key auth as `agent` ------------------ -----------
└─ ForceCommand /etc/bastion/force-command ssh client ssh client
└─ exec sh -c "$FORCE_COMMAND" └ key auth as `agent` └ key auth as `agent`
└ ForceCommand wrapper └ ForceCommand wrapper
http client └ exec sh -c "$FORCE_COMMAND" └ exec bastion-broker "$SSH_ORIGINAL_COMMAND"
└─ Authorization: Bearer <HTTP_TOKEN> ├ match vs allowlist (whole-line, anchored)
└─ /var/www/cgi-bin/run validates token http client └ exec $COMMAND_PREFIX $request (no shell)
└─ exec /etc/bastion/force-command └ Authorization header
└─ exec sh -c "$FORCE_COMMAND" └ /cgi-bin/run validates auth http client
└ exec force-command wrapper └ Authorization header
└ exec sh -c "$FORCE_COMMAND" └ /cgi-bin/run validates auth
└ exec bastion-broker "$X-Bastion-Command"
``` ```
## License ## License

View File

@ -2,9 +2,12 @@
# docker-bastion — hardened sshd config # docker-bastion — hardened sshd config
# #
# Every authenticated session is routed through /etc/bastion/force-command, # Every authenticated session is routed through /etc/bastion/force-command,
# which is generated at container start from $FORCE_COMMAND. The bastion # which is generated at container start. In FORCE_COMMAND mode it runs the
# user has /sbin/nologin as its shell so there is no fallback if the # fixed command; in broker mode it hands SSH_ORIGINAL_COMMAND to
# ForceCommand wrapper is missing or fails — the session simply ends. # bastion-broker, which runs it only if it matches the regex allowlist.
# (The bastion user's shell is /bin/sh, not nologin — sshd invokes the
# ForceCommand as `shell -c "<cmd>"`, so nologin would break it. Security
# comes from ForceCommand + this config, not from the login shell.)
# =========================================================================== # ===========================================================================
Port 22 Port 22

View File

@ -0,0 +1,39 @@
# ===========================================================================
# docker-mailserver broker allowlist
# ===========================================================================
# One extended-regex (ERE) rule per line. A client request is permitted only
# if it matches a rule WHOLE-LINE (anchored ^…$). Blank lines and lines
# starting with # are ignored. This file is re-read on every request, so
# edits take effect without restarting the bastion.
#
# COMMAND_PREFIX in the compose file prepends "docker exec -i mailserver
# setup", so the rules below describe only the `setup` sub-commands — clients
# send e.g. email add jane@example.com <password> and never see docker.
#
# Matched commands are word-split and run WITHOUT a shell, so ; | & $() are
# literal arguments, not operators. Values that must arrive intact cannot
# contain spaces — generate passwords from a space-free alphabet (hex /
# base64url) on the caller side.
#
# Argument classes use [^[:space:]] ("any non-space run") rather than .* so a
# rule can never match trailing junk. Tighten further to taste.
# ===========================================================================
# ---- email accounts -------------------------------------------------------
# add / update require an address and a password argument (no interactive
# prompt is possible over a non-TTY transport, so the password is mandatory).
email add [^[:space:]]+@[^[:space:]]+ [^[:space:]]+
email update [^[:space:]]+@[^[:space:]]+ [^[:space:]]+
email del [^[:space:]]+@[^[:space:]]+
email list
email restrict (add|del|list) (send|receive)( [^[:space:]]+@[^[:space:]]+)?
# ---- aliases --------------------------------------------------------------
alias add [^[:space:]]+@[^[:space:]]+ [^[:space:]]+
alias del [^[:space:]]+@[^[:space:]]+ [^[:space:]]+
alias list
# ---- quotas ---------------------------------------------------------------
# QUOTA is a size like 1G / 512M / 0 (0 = unlimited).
quota set [^[:space:]]+@[^[:space:]]+ [0-9]+[KMGT]?
quota del [^[:space:]]+@[^[:space:]]+

View File

@ -0,0 +1,95 @@
# ===========================================================================
# docker-mailserver management bastion (broker mode)
# ===========================================================================
# A single bastion that lets a sidecar app (e.g. a Nuxt "mail manager") run a
# WHITELIST of docker-mailserver `setup` sub-commands — and nothing else.
#
# The app talks to the bastion over the internal docker network via SSH:
# ssh agent@bastion-mail "email add jane@example.com <password>"
# The bastion validates the request against ALLOWED_COMMANDS, and on a match
# runs docker exec -i mailserver setup email add jane@example.com <password>
# against the host docker socket. No match → refused, nothing runs.
#
# Drop this `bastion-mail` service into the same compose project as your
# `mailserver` container (or any compose project on the same external
# network), then `docker compose up -d bastion-mail`.
# ===========================================================================
services:
bastion-mail:
image: blaxsoftware/bastion:latest
restart: unless-stopped
environment:
# ----------------------------------------------------------------
# COMMAND_PREFIX — trusted, operator-set. Prepended to every
# validated request so clients send clean `email add …` commands
# and never see the docker plumbing. `setup` is docker-mailserver's
# in-container admin CLI; `-i` (not `-it`) because the caller has no
# TTY over a scripted SSH/HTTP call.
# ----------------------------------------------------------------
COMMAND_PREFIX: "docker exec -i mailserver setup"
# ----------------------------------------------------------------
# ALLOWED_COMMANDS — the whitelist. A YAML block scalar (`|`) reads
# like a string array: one extended-regex (ERE) rule per line. A
# request is permitted only if it matches a rule WHOLE-LINE.
#
# Matched commands run WITHOUT a shell (; | & $() are literal args),
# so the regex is the entire authorization boundary — keep argument
# classes tight ([^ ]+ rather than .*). Values that must arrive
# intact can't contain spaces; generate passwords space-free.
#
# Prefer the mounted file (see volumes) if you'd rather edit rules
# without redeploying — the two sources are additive.
# ----------------------------------------------------------------
ALLOWED_COMMANDS: |
email add [^ ]+@[^ ]+ [^ ]+
email update [^ ]+@[^ ]+ [^ ]+
email del [^ ]+@[^ ]+
email list
alias add [^ ]+@[^ ]+ [^ ]+
alias del [^ ]+@[^ ]+ [^ ]+
alias list
quota set [^ ]+@[^ ]+ [0-9]+[KMGT]?
quota del [^ ]+@[^ ]+
volumes:
# REQUIRED — the host docker socket, so `docker exec` can reach the
# mailserver container. This is host-root-equivalent; the allowlist
# is what keeps it scoped.
- /var/run/docker.sock:/var/run/docker.sock
# Authorized clients — drop one pubkey per identity. The mail-manager
# app's key goes here; read live, no restart to add/revoke.
- ./docker-data/bastion/users.d:/etc/bastion/users.d
# Persist the bastion's SSH host identity across rebuilds (bind mount,
# never a named volume — `down -v` would wipe it and clients would see
# a changed host key).
- ./docker-data/bastion/keys:/etc/ssh/keys
# OPTIONAL — live-editable allowlist (additive with ALLOWED_COMMANDS
# above). Edit the file and the next request picks it up; no restart.
# - ./allowed-commands.list:/etc/bastion/allowed-commands.list:ro
# No host port published: the mail-manager reaches the bastion by
# service name on the shared network (ssh agent@bastion-mail). Uncomment
# to also expose SSH on the host for debugging — bind to localhost.
# ports:
# - "127.0.0.1:2222:22"
networks: [web]
# Your docker-mailserver container — referenced by name in COMMAND_PREFIX.
# Shown here for context; usually it already lives in its own compose file.
# mailserver:
# image: ghcr.io/docker-mailserver/docker-mailserver:latest
# container_name: mailserver
# hostname: mail.example.com
# networks: [web]
# # …ports/volumes/env per the docker-mailserver docs…
networks:
web:
external: true

106
scripts/bastion-broker Normal file
View File

@ -0,0 +1,106 @@
#!/bin/sh
# ===========================================================================
# bastion-broker — command-allowlist gate for docker-bastion "broker mode".
#
# Invoked once per session with the client-requested command:
# • SSH: the force-command wrapper passes "$SSH_ORIGINAL_COMMAND".
# • HTTP: the CGI passes the X-Bastion-Command request header.
#
# The requested command is matched — anchored, WHOLE-LINE — against a set of
# extended-regex (ERE) rules. On a match it is word-split (NO shell, so the
# metacharacters ; | & ` $( ) < > are literal arguments, never operators)
# and exec'd, optionally behind a trusted COMMAND_PREFIX. No match → refused.
#
# ---------------------------------------------------------------------------
# Security model
# ---------------------------------------------------------------------------
# The regex rules are the ENTIRE authorization boundary for what a client may
# run. Two properties keep that boundary tight:
#
# 1. Whole-line anchoring (grep -x). A rule must match the request from
# first character to last; it can never match a substring.
# 2. Shell-free execution. The validated request is split on whitespace and
# exec'd directly — there is no `sh -c`. A sloppy rule therefore cannot
# escalate to command injection; the worst case is that an allowed
# program receives an odd-looking argument.
#
# Still: write rules with restrictive argument classes (e.g. [^[:space:]]+),
# never a bare `.*`. Values that must reach the target program intact cannot
# contain whitespace (word-splitting) — generate passwords/tokens from a
# space-free alphabet (hex, base64url) on the caller side.
#
# Config is read from boot-written FILES, never the environment: sshd does
# not propagate the daemon's env to a ForceCommand session, so anything the
# broker needs at session time must live on disk.
# ===========================================================================
set -u
export HOME=/home/agent
export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
ENV_RULES=/etc/bastion/allowed-commands.env # snapshot of $ALLOWED_COMMANDS (written at boot)
LIVE_RULES=/etc/bastion/allowed-commands.list # optional live bind-mount (re-read every session)
PREFIX_FILE=/etc/bastion/command-prefix # optional trusted prefix (written at boot)
# $1 wins (SSH wrapper / HTTP CGI pass it explicitly); fall back to the env
# var sshd sets so the broker also works as a bare ForceCommand target.
REQ="${1:-${SSH_ORIGINAL_COMMAND:-}}"
log() { printf '[broker] %s\n' "$1" >&2; }
refuse() {
# $1 = reason for the audit log (may include the request),
# $2 = sanitized message shown to the client.
log "DENY: $1"
printf 'bastion: %s\n' "$2" >&2
exit 126
}
# --- 1) Empty request -----------------------------------------------------
[ -n "$REQ" ] || refuse "<empty request>" "no command supplied (broker mode expects a command)"
# --- 2) Reject anything spanning more than one line -----------------------
# grep matches line-by-line; a multi-line request could slip a benign first
# line past the allowlist while carrying a second, malicious line that the
# shell-free exec would still pass along. Refuse outright.
if [ "$(printf '%s' "$REQ" | tr -dc '\n\r' | wc -c)" -ne 0 ]; then
refuse "<multiline request>" "command not permitted"
fi
# --- 3) Match against the rule set ---------------------------------------
# Sources are concatenated; comments (#…) and blank lines are dropped and
# each rule is trimmed of surrounding whitespace (so indented YAML block
# lines work). grep -Eqx => ERE, whole-line (implicit ^…$ anchoring), quiet.
collect_rules() {
for src in "$ENV_RULES" "$LIVE_RULES"; do
[ -f "$src" ] || continue
awk '{ sub(/^[[:space:]]+/, ""); sub(/[[:space:]]+$/, "") } NF && $0 !~ /^#/' "$src"
done
}
matched=0
while IFS= read -r pat; do
[ -n "$pat" ] || continue
if printf '%s' "$REQ" | grep -Eqx -- "$pat"; then
matched=1
break
fi
done <<RULES
$(collect_rules)
RULES
[ "$matched" -eq 1 ] || refuse "$REQ" "command not permitted"
# --- 4) Execute -----------------------------------------------------------
log "ALLOW: $REQ"
PREFIX=""
[ -f "$PREFIX_FILE" ] && PREFIX="$(cat "$PREFIX_FILE")"
set -f # no pathname expansion when we word-split below
# Intentional, unquoted word-splitting of the trusted prefix + validated
# request. set -f above means * ? [ are NOT globbed; IFS does the splitting.
# shellcheck disable=SC2086
set -- $PREFIX $REQ
[ "$#" -ge 1 ] || refuse "$REQ" "command not permitted"
exec "$@"

View File

@ -20,18 +20,34 @@ SSH_USER="agent"
SSH_PORT="${SSH_PORT:-22}" SSH_PORT="${SSH_PORT:-22}"
HTTP_PORT="${HTTP_PORT:-8080}" HTTP_PORT="${HTTP_PORT:-8080}"
FORCE_COMMAND_VALUE="${FORCE_COMMAND:-}" FORCE_COMMAND_VALUE="${FORCE_COMMAND:-}"
ALLOWED_COMMANDS_VALUE="${ALLOWED_COMMANDS:-}"
COMMAND_PREFIX_VALUE="${COMMAND_PREFIX:-}"
AUTHORIZED_KEYS_HOST="${AUTHORIZED_KEYS_HOST:-/etc/bastion/authorized_keys.host}" AUTHORIZED_KEYS_HOST="${AUTHORIZED_KEYS_HOST:-/etc/bastion/authorized_keys.host}"
AUTHORIZED_KEYS_REPO="${AUTHORIZED_KEYS_REPO:-/etc/bastion/authorized_keys.repo}" AUTHORIZED_KEYS_REPO="${AUTHORIZED_KEYS_REPO:-/etc/bastion/authorized_keys.repo}"
# Broker mode is active when an allowlist exists from either source: the
# ALLOWED_COMMANDS env var, or a bind-mounted /etc/bastion/allowed-commands.list.
# In broker mode the client supplies the command (validated by bastion-broker);
# FORCE_COMMAND is not required and is ignored.
ALLOWED_LIST_FILE=/etc/bastion/allowed-commands.list
BROKER_MODE=0
if [ -n "$ALLOWED_COMMANDS_VALUE" ] || [ -f "$ALLOWED_LIST_FILE" ]; then
BROKER_MODE=1
fi
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 1) Validate config # 1) Validate config
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
if [ -z "$FORCE_COMMAND_VALUE" ]; then if [ "$BROKER_MODE" -eq 0 ] && [ -z "$FORCE_COMMAND_VALUE" ]; then
echo "FATAL: FORCE_COMMAND must be set." echo "FATAL: set FORCE_COMMAND (fixed-command mode) or ALLOWED_COMMANDS (broker mode)."
echo " e.g. FORCE_COMMAND='docker exec -it app bash'" echo " FORCE_COMMAND='docker exec -it app bash'"
echo " or FORCE_COMMAND='cd /workspace && ./deploy.sh'" echo " FORCE_COMMAND='cd /workspace && ./deploy.sh'"
echo " ALLOWED_COMMANDS=\$'setup email list\\nsetup email (add|update) [^ ]+@[^ ]+ [^ ]+'"
exit 1 exit 1
fi fi
if [ "$BROKER_MODE" -eq 1 ] && [ -n "$FORCE_COMMAND_VALUE" ]; then
echo "NOTE: ALLOWED_COMMANDS is set — running in broker mode; FORCE_COMMAND is ignored."
fi
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 2) Host keys — generate on first boot, persist via /etc/ssh/keys volume # 2) Host keys — generate on first boot, persist via /etc/ssh/keys volume
@ -103,21 +119,58 @@ chmod 700 "$(dirname "$AUTH_FILE")"
chmod 600 "$AUTH_FILE" chmod 600 "$AUTH_FILE"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 4) ForceCommand wrapper # 4) Command routing — generate /etc/bastion/force-command for the active mode
# #
# Write the configured command to a plain file, then a small wrapper that # FORCE_COMMAND mode: write the command to a plain file + a static wrapper
# exec's `sh -c "$(cat ...)"`. This way: # that exec's `sh -c "$(cat ...)"`, so shell metacharacters (&&, |, cd,
# - Shell metacharacters in $FORCE_COMMAND work (&&, |, redirects, cd). # redirects) work and the wrapper needs no escaping of user input.
# - The wrapper itself stays static (no escaping of user input into a # Broker mode: snapshot the allowlist + prefix to files (sshd does not pass
# heredoc), and the command file is read at session start so changes # the daemon env to a ForceCommand session) and point the wrapper at
# to $FORCE_COMMAND only need a container restart, not a rebuild. # bastion-broker, which validates the client's command before running it.
# Either way the command file is read at session start, so changes need only
# a container restart, not a rebuild.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
echo "[3/5] ForceCommand..." echo "[3/5] Command routing..."
mkdir -p /etc/bastion mkdir -p /etc/bastion
printf '%s\n' "$FORCE_COMMAND_VALUE" > /etc/bastion/force-command.cmd
chmod 0644 /etc/bastion/force-command.cmd
cat > /etc/bastion/force-command <<'WRAPPER' if [ "$BROKER_MODE" -eq 1 ]; then
# ---- Broker mode -----------------------------------------------------
# Snapshot the env allowlist to a file the broker reads at session time
# (sshd does not pass the daemon env to a ForceCommand session). The
# optional /etc/bastion/allowed-commands.list bind-mount is read live by
# the broker in addition to this snapshot.
printf '%s\n' "$ALLOWED_COMMANDS_VALUE" > /etc/bastion/allowed-commands.env
chmod 0644 /etc/bastion/allowed-commands.env
printf '%s' "$COMMAND_PREFIX_VALUE" > /etc/bastion/command-prefix
chmod 0644 /etc/bastion/command-prefix
# The wrapper hands the client-requested command to the broker, which
# validates it against the allowlist and either exec's it or refuses.
cat > /etc/bastion/force-command <<'WRAPPER'
#!/bin/sh
# Auto-generated by docker-bastion start-container (broker mode).
# Routes the client-requested command through the allowlist gate. sshd sets
# SSH_ORIGINAL_COMMAND to whatever the client asked to run; the broker
# decides whether it is permitted. There is no fallback command.
export HOME=/home/agent
exec /usr/local/bin/bastion-broker "${SSH_ORIGINAL_COMMAND:-}"
WRAPPER
chmod 0755 /etc/bastion/force-command
rule_count=$(awk '{ sub(/^[[:space:]]+/, ""); sub(/[[:space:]]+$/, "") } NF && $0 !~ /^#/' \
/etc/bastion/allowed-commands.env "$ALLOWED_LIST_FILE" 2>/dev/null | wc -l | tr -d ' ')
echo " Broker mode: $rule_count allow-rule(s)"
[ -n "$COMMAND_PREFIX_VALUE" ] && echo " Command prefix: $COMMAND_PREFIX_VALUE"
[ -f "$ALLOWED_LIST_FILE" ] && echo " Live rules file: $ALLOWED_LIST_FILE (re-read each session)"
if [ "$rule_count" -eq 0 ]; then
echo " WARN: broker mode active but zero allow-rules — every request will be refused."
fi
else
# ---- FORCE_COMMAND mode (default, unchanged) -------------------------
printf '%s\n' "$FORCE_COMMAND_VALUE" > /etc/bastion/force-command.cmd
chmod 0644 /etc/bastion/force-command.cmd
cat > /etc/bastion/force-command <<'WRAPPER'
#!/bin/sh #!/bin/sh
# Auto-generated by docker-bastion start-container. # Auto-generated by docker-bastion start-container.
# sshd invokes this script for every authenticated session; the HTTP CGI # sshd invokes this script for every authenticated session; the HTTP CGI
@ -143,8 +196,9 @@ fi
# no args, "$@" expands to nothing and behavior is identical to before. # no args, "$@" expands to nothing and behavior is identical to before.
exec sh -c "$(cat /etc/bastion/force-command.cmd)" sh "$@" exec sh -c "$(cat /etc/bastion/force-command.cmd)" sh "$@"
WRAPPER WRAPPER
chmod 0755 /etc/bastion/force-command chmod 0755 /etc/bastion/force-command
echo " $FORCE_COMMAND_VALUE" echo " $FORCE_COMMAND_VALUE"
fi
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 5) Docker socket — if mounted, align group membership so the agent user # 5) Docker socket — if mounted, align group membership so the agent user
@ -166,7 +220,7 @@ if [ -S /var/run/docker.sock ]; then
addgroup "$SSH_USER" "$grp_name" 2>/dev/null || true addgroup "$SSH_USER" "$grp_name" 2>/dev/null || true
echo " Added $SSH_USER to $grp_name" echo " Added $SSH_USER to $grp_name"
else else
echo " WARN: /var/run/docker.sock not mounted — docker-based FORCE_COMMAND will fail." echo " WARN: /var/run/docker.sock not mounted — any docker-based command (FORCE_COMMAND or broker) will fail."
fi fi
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -198,6 +252,11 @@ fi
# output streams back as the command produces it. # output streams back as the command produces it.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
echo "[5/6] HTTP listener..." echo "[5/6] HTTP listener..."
# In broker mode the HTTP CGI takes the command to run from the
# X-Bastion-Command request header and routes it through bastion-broker
# (same allowlist as SSH). In FORCE_COMMAND mode it exec's the fixed
# wrapper as before. busybox httpd forwards request headers to CGI as
# HTTP_<UPPERCASE_WITH_UNDERSCORES>, so X-Bastion-Command → HTTP_X_BASTION_COMMAND.
if [ -n "${HTTP_BASIC_AUTH:-}" ]; then if [ -n "${HTTP_BASIC_AUTH:-}" ]; then
echo " Auth: Basic — enabling httpd on port ${HTTP_PORT}" echo " Auth: Basic — enabling httpd on port ${HTTP_PORT}"
mkdir -p /var/www/cgi-bin mkdir -p /var/www/cgi-bin
@ -209,8 +268,18 @@ if [ -n "${HTTP_BASIC_AUTH:-}" ]; then
# the conf holds the plaintext password. # the conf holds the plaintext password.
chown "${SSH_USER}:${SSH_USER}" /etc/bastion/httpd.conf chown "${SSH_USER}:${SSH_USER}" /etc/bastion/httpd.conf
chmod 0600 /etc/bastion/httpd.conf chmod 0600 /etc/bastion/httpd.conf
# CGI: auth already done by httpd before we got here. if [ "$BROKER_MODE" -eq 1 ]; then
cat > /var/www/cgi-bin/run <<'CGI' # CGI: httpd validated the password; the command comes from the
# X-Bastion-Command header and is gated by the broker.
cat > /var/www/cgi-bin/run <<'CGI'
#!/bin/sh
# Auto-generated (broker mode, basic auth).
printf 'Content-Type: text/plain\r\nCache-Control: no-cache\r\nX-Accel-Buffering: no\r\n\r\n'
exec /usr/local/bin/bastion-broker "${HTTP_X_BASTION_COMMAND:-}" 2>&1
CGI
else
# CGI: auth already done by httpd before we got here.
cat > /var/www/cgi-bin/run <<'CGI'
#!/bin/sh #!/bin/sh
# Auto-generated. Auth was validated by busybox httpd via httpd.conf # Auto-generated. Auth was validated by busybox httpd via httpd.conf
# before this script ran — REMOTE_USER holds the authenticated username. # before this script ran — REMOTE_USER holds the authenticated username.
@ -229,6 +298,7 @@ esac
printf 'Content-Type: text/plain\r\nCache-Control: no-cache\r\nX-Accel-Buffering: no\r\n\r\n' printf 'Content-Type: text/plain\r\nCache-Control: no-cache\r\nX-Accel-Buffering: no\r\n\r\n'
exec /etc/bastion/force-command $BUMP_ARG 2>&1 exec /etc/bastion/force-command $BUMP_ARG 2>&1
CGI CGI
fi
chmod 0755 /var/www/cgi-bin/run chmod 0755 /var/www/cgi-bin/run
# -c CONFFILE = auth + content-type rules; httpd reads it as root before # -c CONFFILE = auth + content-type rules; httpd reads it as root before
# dropping to -u USER. CGI scripts then run as USER. # dropping to -u USER. CGI scripts then run as USER.
@ -253,7 +323,23 @@ CGI
elif [ -n "${HTTP_TOKEN:-}" ]; then elif [ -n "${HTTP_TOKEN:-}" ]; then
echo " Auth: Bearer — enabling httpd on port ${HTTP_PORT}" echo " Auth: Bearer — enabling httpd on port ${HTTP_PORT}"
mkdir -p /var/www/cgi-bin mkdir -p /var/www/cgi-bin
cat > /var/www/cgi-bin/run <<'CGI' if [ "$BROKER_MODE" -eq 1 ]; then
cat > /var/www/cgi-bin/run <<'CGI'
#!/bin/sh
# Auto-generated (broker mode, bearer auth). Bearer check first, then the
# X-Bastion-Command header is routed through the allowlist gate.
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'
exec /usr/local/bin/bastion-broker "${HTTP_X_BASTION_COMMAND:-}" 2>&1
CGI
else
cat > /var/www/cgi-bin/run <<'CGI'
#!/bin/sh #!/bin/sh
# Auto-generated. Bearer auth handled here in the CGI. # Auto-generated. Bearer auth handled here in the CGI.
case "${HTTP_AUTHORIZATION:-}" in case "${HTTP_AUTHORIZATION:-}" in
@ -266,6 +352,7 @@ esac
printf 'Content-Type: text/plain\r\nCache-Control: no-cache\r\nX-Accel-Buffering: no\r\n\r\n' 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 exec /etc/bastion/force-command 2>&1
CGI CGI
fi
chmod 0755 /var/www/cgi-bin/run chmod 0755 /var/www/cgi-bin/run
if [ "${HTTP_AS_ROOT:-0}" = "1" ]; then if [ "${HTTP_AS_ROOT:-0}" = "1" ]; then
echo " HTTP_AS_ROOT=1 — httpd + CGI run as root" echo " HTTP_AS_ROOT=1 — httpd + CGI run as root"
@ -288,9 +375,16 @@ echo "[6/6] sshd config check..."
echo "==========================================" echo "=========================================="
echo " SSH: port ${SSH_PORT}" echo " SSH: port ${SSH_PORT}"
[ -n "${HTTP_TOKEN:-}" ] && echo " HTTP: port ${HTTP_PORT} (token-protected)" [ -n "${HTTP_BASIC_AUTH:-}" ] && echo " HTTP: port ${HTTP_PORT} (basic auth)"
[ -n "${HTTP_TOKEN:-}" ] && echo " HTTP: port ${HTTP_PORT} (bearer token)"
echo " User: ${SSH_USER}" echo " User: ${SSH_USER}"
echo " ForceCommand: ${FORCE_COMMAND_VALUE}" if [ "$BROKER_MODE" -eq 1 ]; then
echo " Mode: broker (client command, allowlist-gated)"
[ -n "$COMMAND_PREFIX_VALUE" ] && echo " Prefix: ${COMMAND_PREFIX_VALUE}"
else
echo " Mode: force-command (fixed)"
echo " ForceCommand: ${FORCE_COMMAND_VALUE}"
fi
echo "==========================================" echo "=========================================="
# -D = foreground, -e = log to stderr (so docker logs picks it up). # -D = foreground, -e = log to stderr (so docker logs picks it up).