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:
parent
3ec02cea7b
commit
8eb57e5a77
38
Dockerfile
38
Dockerfile
|
|
@ -74,10 +74,11 @@ RUN addgroup -g ${SSH_GID} agent && \
|
|||
COPY config/sshd_config /etc/ssh/sshd_config
|
||||
COPY scripts/start-container /usr/local/bin/start-container
|
||||
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
|
||||
# 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.
|
||||
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 && \
|
||||
chmod 700 /etc/ssh/keys && \
|
||||
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
|
||||
# FORCE_COMMAND — REQUIRED. The single command run on every login.
|
||||
# Shell metacharacters are supported.
|
||||
# The bastion runs in one of two modes, decided at boot:
|
||||
#
|
||||
# • 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:
|
||||
# docker exec -it app bash
|
||||
# docker compose -f /workspace/compose.yml exec app bash
|
||||
# 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
|
||||
# auth: `Authorization: Bearer <HTTP_TOKEN>`.
|
||||
# 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.)
|
||||
# ---------------------------------------------------------------------------
|
||||
ENV FORCE_COMMAND=""
|
||||
ENV ALLOWED_COMMANDS=""
|
||||
ENV COMMAND_PREFIX=""
|
||||
ENV HTTP_TOKEN=""
|
||||
ENV HTTP_BASIC_AUTH=""
|
||||
ENV HTTP_PORT=8080
|
||||
|
|
|
|||
123
README.md
123
README.md
|
|
@ -8,7 +8,7 @@
|
|||
[](#whats-inside)
|
||||
[](#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`.
|
||||
|
||||
|
|
@ -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.
|
||||
|
||||
## 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
|
||||
|
||||
| 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 |
|
||||
|-------------------------|----------------------------------------|----------------------------------------------------------------------------|
|
||||
| `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_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). |
|
||||
|
|
@ -207,7 +292,8 @@ Zero authorized keys is now a warning, not a startup failure — the bastion run
|
|||
## What's inside
|
||||
|
||||
- **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.
|
||||
- **tini** — PID 1, signal handling, zombie reaping.
|
||||
- **bash, ca-certificates, tzdata.**
|
||||
|
|
@ -223,7 +309,7 @@ Practical checklist:
|
|||
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.
|
||||
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.
|
||||
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.
|
||||
|
|
@ -237,21 +323,26 @@ Practical checklist:
|
|||
start-container (entrypoint)
|
||||
├─ generate host keys (idempotent, persisted via /etc/ssh/keys bind mount)
|
||||
├─ 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)
|
||||
├─ 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
|
||||
|
||||
ssh client
|
||||
└─ key auth as `agent`
|
||||
└─ ForceCommand /etc/bastion/force-command
|
||||
└─ exec sh -c "$FORCE_COMMAND"
|
||||
|
||||
http client
|
||||
└─ Authorization: Bearer <HTTP_TOKEN>
|
||||
└─ /var/www/cgi-bin/run validates token
|
||||
└─ exec /etc/bastion/force-command
|
||||
└─ exec sh -c "$FORCE_COMMAND"
|
||||
FORCE_COMMAND mode broker mode
|
||||
------------------ -----------
|
||||
ssh client ssh client
|
||||
└ key auth as `agent` └ key auth as `agent`
|
||||
└ ForceCommand wrapper └ ForceCommand wrapper
|
||||
└ exec sh -c "$FORCE_COMMAND" └ exec bastion-broker "$SSH_ORIGINAL_COMMAND"
|
||||
├ match vs allowlist (whole-line, anchored)
|
||||
http client └ exec $COMMAND_PREFIX $request (no shell)
|
||||
└ Authorization header
|
||||
└ /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
|
||||
|
|
|
|||
|
|
@ -2,9 +2,12 @@
|
|||
# docker-bastion — hardened sshd config
|
||||
#
|
||||
# Every authenticated session is routed through /etc/bastion/force-command,
|
||||
# which is generated at container start from $FORCE_COMMAND. The bastion
|
||||
# user has /sbin/nologin as its shell so there is no fallback if the
|
||||
# ForceCommand wrapper is missing or fails — the session simply ends.
|
||||
# which is generated at container start. In FORCE_COMMAND mode it runs the
|
||||
# fixed command; in broker mode it hands SSH_ORIGINAL_COMMAND to
|
||||
# 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
|
||||
|
|
|
|||
|
|
@ -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:]]+
|
||||
|
|
@ -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
|
||||
|
|
@ -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 "$@"
|
||||
|
|
@ -20,18 +20,34 @@ SSH_USER="agent"
|
|||
SSH_PORT="${SSH_PORT:-22}"
|
||||
HTTP_PORT="${HTTP_PORT:-8080}"
|
||||
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_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
|
||||
# ---------------------------------------------------------------------------
|
||||
if [ -z "$FORCE_COMMAND_VALUE" ]; then
|
||||
echo "FATAL: FORCE_COMMAND must be set."
|
||||
echo " e.g. FORCE_COMMAND='docker exec -it app bash'"
|
||||
echo " or FORCE_COMMAND='cd /workspace && ./deploy.sh'"
|
||||
if [ "$BROKER_MODE" -eq 0 ] && [ -z "$FORCE_COMMAND_VALUE" ]; then
|
||||
echo "FATAL: set FORCE_COMMAND (fixed-command mode) or ALLOWED_COMMANDS (broker mode)."
|
||||
echo " FORCE_COMMAND='docker exec -it app bash'"
|
||||
echo " FORCE_COMMAND='cd /workspace && ./deploy.sh'"
|
||||
echo " ALLOWED_COMMANDS=\$'setup email list\\nsetup email (add|update) [^ ]+@[^ ]+ [^ ]+'"
|
||||
exit 1
|
||||
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
|
||||
|
|
@ -103,17 +119,54 @@ chmod 700 "$(dirname "$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
|
||||
# exec's `sh -c "$(cat ...)"`. This way:
|
||||
# - Shell metacharacters in $FORCE_COMMAND work (&&, |, redirects, cd).
|
||||
# - The wrapper itself stays static (no escaping of user input into a
|
||||
# heredoc), and the command file is read at session start so changes
|
||||
# to $FORCE_COMMAND only need a container restart, not a rebuild.
|
||||
# FORCE_COMMAND mode: write the command to a plain file + a static wrapper
|
||||
# that exec's `sh -c "$(cat ...)"`, so shell metacharacters (&&, |, cd,
|
||||
# redirects) work and the wrapper needs no escaping of user input.
|
||||
# Broker mode: snapshot the allowlist + prefix to files (sshd does not pass
|
||||
# the daemon env to a ForceCommand session) and point the wrapper at
|
||||
# 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
|
||||
|
||||
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
|
||||
|
||||
|
|
@ -145,6 +198,7 @@ exec sh -c "$(cat /etc/bastion/force-command.cmd)" sh "$@"
|
|||
WRAPPER
|
||||
chmod 0755 /etc/bastion/force-command
|
||||
echo " $FORCE_COMMAND_VALUE"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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
|
||||
echo " Added $SSH_USER to $grp_name"
|
||||
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
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -198,6 +252,11 @@ fi
|
|||
# output streams back as the command produces it.
|
||||
# ---------------------------------------------------------------------------
|
||||
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
|
||||
echo " Auth: Basic — enabling httpd on port ${HTTP_PORT}"
|
||||
mkdir -p /var/www/cgi-bin
|
||||
|
|
@ -209,6 +268,16 @@ if [ -n "${HTTP_BASIC_AUTH:-}" ]; then
|
|||
# the conf holds the plaintext password.
|
||||
chown "${SSH_USER}:${SSH_USER}" /etc/bastion/httpd.conf
|
||||
chmod 0600 /etc/bastion/httpd.conf
|
||||
if [ "$BROKER_MODE" -eq 1 ]; then
|
||||
# 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
|
||||
|
|
@ -229,6 +298,7 @@ esac
|
|||
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
|
||||
CGI
|
||||
fi
|
||||
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.
|
||||
|
|
@ -253,6 +323,22 @@ CGI
|
|||
elif [ -n "${HTTP_TOKEN:-}" ]; then
|
||||
echo " Auth: Bearer — enabling httpd on port ${HTTP_PORT}"
|
||||
mkdir -p /var/www/cgi-bin
|
||||
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
|
||||
# Auto-generated. Bearer auth handled here in the CGI.
|
||||
|
|
@ -266,6 +352,7 @@ esac
|
|||
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
|
||||
fi
|
||||
chmod 0755 /var/www/cgi-bin/run
|
||||
if [ "${HTTP_AS_ROOT:-0}" = "1" ]; then
|
||||
echo " HTTP_AS_ROOT=1 — httpd + CGI run as root"
|
||||
|
|
@ -288,9 +375,16 @@ echo "[6/6] sshd config check..."
|
|||
|
||||
echo "=========================================="
|
||||
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}"
|
||||
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 "=========================================="
|
||||
|
||||
# -D = foreground, -e = log to stderr (so docker logs picks it up).
|
||||
|
|
|
|||
Loading…
Reference in New Issue