[![Blax Software OSS](https://raw.githubusercontent.com/blax-software/laravel-workkit/master/art/oss-initiative-banner.svg)](https://github.com/blax-software) # docker-bastion [![Alpine](https://img.shields.io/badge/alpine-3.21-blue?logo=alpinelinux)](https://alpinelinux.org) [![OpenSSH](https://img.shields.io/badge/openssh-9.9-green)](https://www.openssh.com) [![Docker CLI](https://img.shields.io/badge/docker--cli-included-2496ED?logo=docker)](https://docs.docker.com/engine/reference/commandline/cli/) [![Image Size](https://img.shields.io/badge/image-~105MB-lightgrey)](#whats-inside) [![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. **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`. ## Available Tags | Tag | Base | Notes | |----------------------------------|--------------|--------------------------------------| | `blaxsoftware/bastion:latest` | alpine 3.21 | Default tag, follows alpine releases | | `blaxsoftware/bastion:alpine3.21`| alpine 3.21 | Pinned alpine version | ## Quick Start — drop into a WordPress container The most common use: give a deploy agent SSH-shaped access *into* a running WordPress container. Every session lands inside the `wordpress-app` container's bash; clients can run WP-CLI commands, edit config, debug — same UX as `ssh user@host` against a VPS, but scoped to one container. ```yaml services: # The bastion. SSH on 2222, HTTP behind traefik on https://deploy-wp.example.com. bastion: image: blaxsoftware/bastion:latest restart: unless-stopped environment: # REQUIRED — the single command that runs on every authenticated session. # Shell metacharacters work: &&, ||, pipes, cd, redirects. FORCE_COMMAND: "docker exec -it wordpress-app bash" # 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). # Mounts the host's daemon socket so docker-cli inside the bastion reaches it. - /var/run/docker.sock:/var/run/docker.sock # OPTIONAL — host-sourced authorized_keys. Your laptop's keys, or anything # outside the repo. Read-only mount. - ~/.ssh/authorized_keys:/etc/bastion/authorized_keys.host:ro # OPTIONAL — repo-sourced authorized_keys. CI / deploy-bot keys committed # alongside the project. At least one of these two must exist or the # container refuses to start. - ./docker/bastion/authorized_keys:/etc/bastion/authorized_keys.repo:ro # REQUIRED — host keys persist across rebuilds. Bind mount, NEVER a named # volume; `docker compose down -v` would wipe a named volume and clients # would see "REMOTE HOST IDENTIFICATION HAS CHANGED" after every redeploy. - ./docker-data/bastion/keys:/etc/ssh/keys ports: # OPTIONAL — expose SSH on the host directly. Skip this entirely if you # only want the HTTP path through traefik. - "2222:22" labels: # OPTIONAL — traefik HTTP route. Visit https://deploy-wp.example.com/cgi-bin/run # with `Authorization: Bearer $BASTION_HTTP_TOKEN` to invoke FORCE_COMMAND. # Remove these labels if you don't want the HTTP path published. traefik.enable: "true" traefik.docker.network: "web" traefik.http.routers.bastion.rule: "Host(`deploy-wp.example.com`)" traefik.http.routers.bastion.entrypoints: "websecure" traefik.http.routers.bastion.tls: "true" traefik.http.services.bastion.loadbalancer.server.port: "8080" networks: [web] # Your actual WordPress container — bastion's FORCE_COMMAND targets it by name. wordpress-app: image: wordpress:latest container_name: wordpress-app environment: WORDPRESS_DB_HOST: db WORDPRESS_DB_NAME: wp WORDPRESS_DB_USER: wp WORDPRESS_DB_PASSWORD: "${DB_PASSWORD}" volumes: - ./docker-data/wordpress:/var/www/html networks: [web] networks: web: external: true ``` From the client: ```bash # Interactive shell inside the wp container — feels exactly like ssh-into-vps ssh -p 2222 agent@your-host # 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 A scoped bastion that does exactly one thing: test the new nginx config and reload if it passes. The HTTP path lets a CI job (GitHub Action, Forgejo runner, anything that can `curl`) trigger a reload after pushing new configs to disk — no SSH keys to provision in CI. ```yaml services: bastion: image: blaxsoftware/bastion:latest restart: unless-stopped environment: # `nginx -t` exits non-zero on a syntax error; `&&` short-circuits so a # broken config never gets applied. The exit code propagates back to # the HTTP client (which sees the connection close mid-stream on failure). FORCE_COMMAND: "docker exec nginx-app nginx -t && docker exec nginx-app nginx -s reload" HTTP_TOKEN: "${BASTION_HTTP_TOKEN}" volumes: - /var/run/docker.sock:/var/run/docker.sock # SSH path stays available for human debugging — same key, same scope. - ~/.ssh/authorized_keys:/etc/bastion/authorized_keys.host:ro - ./docker-data/bastion/keys:/etc/ssh/keys labels: traefik.enable: "true" traefik.docker.network: "web" traefik.http.routers.bastion-nginx.rule: "Host(`reload-nginx.example.com`)" traefik.http.routers.bastion-nginx.entrypoints: "websecure" traefik.http.routers.bastion-nginx.tls: "true" traefik.http.services.bastion-nginx.loadbalancer.server.port: "8080" networks: [web] nginx-app: image: nginx:alpine container_name: nginx-app volumes: - ./nginx.conf:/etc/nginx/nginx.conf:ro - ./conf.d:/etc/nginx/conf.d:ro networks: [web] networks: web: external: true ``` CI snippet: ```bash # After updating nginx.conf on disk: curl --fail-with-body -H "Authorization: Bearer $BASTION_HTTP_TOKEN" \ https://reload-nginx.example.com/cgi-bin/run ``` `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. ## Two channels, two shapes | Channel | Best for | TTY? | Streaming? | |---------|--------------------------------|----------|--------------------| | SSH | Interactive (`docker exec -it`) **or** scripts | yes | yes | | HTTP | Scripts only — no TTY | no | yes (chunked / close-delimited) | Interactive commands (`docker exec -it app bash`) over HTTP fail because there's no TTY — use SSH for those. Both channels stream output line-by-line; both close as soon as `FORCE_COMMAND` exits and the exit code propagates (SSH: to the client; HTTP: nonzero closes the response mid-stream). The client cannot override the command. `SSH_ORIGINAL_COMMAND` and HTTP request bodies are intentionally ignored. ## Authorized keys — two sources, merged At boot the entrypoint concatenates whichever of these files exist into the agent's `authorized_keys`. **At least one must exist** or the container refuses to start with a clear error. | File | Typical mount | |---------------------------------------|------------------------------------------------| | `/etc/bastion/authorized_keys.host` | `~/.ssh/authorized_keys` from the docker host | | `/etc/bastion/authorized_keys.repo` | `./docker/bastion/authorized_keys` in the repo | Mount one, both, or neither — though neither = startup failure. ## Environment variables | Variable | Default | Description | |-------------------------|----------------------------------------|----------------------------------------------------------------------------| | `FORCE_COMMAND` | *(required)* | The command run on every authenticated session. Shell metacharacters OK. | | `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. | ## Build args | Arg | Default | Description | |------------------|---------|-----------------------------------| | `ALPINE_VERSION` | `3.21` | Alpine base image tag. | | `SSH_UID` | `1000` | UID of the bastion `agent` user. | | `SSH_GID` | `1000` | GID of the bastion `agent` group. | ## 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. - **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.** Total image: ~105 MB. Most of that is docker-cli (~50 MB) and docker-cli-compose (~25 MB). ## Security model The security boundary is **the authorized_keys file (SSH) and the `HTTP_TOKEN` (HTTP), plus the `ForceCommand` wrapper**. Once a key or bearer token authenticates, the session runs exactly one command — there is no fallback shell. The bastion holds the docker socket, which is host-root-equivalent, so the only thing standing between a remote attacker and host root is the auth layer + your key/token hygiene. 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 ""`), 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. 8. **Keep alpine + openssh patched.** An unauth RCE in sshd or httpd here means host root. `apk upgrade` in a rebuild cycle. 9. **Lock down siblings.** Anyone who can `docker exec` into the app via this bastion can also `docker exec` into `mysql`/`redis`/etc through the same socket. `cap_drop: [ALL]` and `no-new-privileges: true` on every sibling caps the blast radius. 10. **One bastion per role.** Don't reuse a single `FORCE_COMMAND` for both interactive shells and deploy automation — separate ports and separate token/key sets make audit trails meaningful. ## Architecture ``` 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 ├─ align docker socket group membership to host GID (if socket is mounted) ├─ start httpd → /var/www/cgi-bin/run (if $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 └─ /var/www/cgi-bin/run validates token └─ exec /etc/bastion/force-command └─ exec sh -c "$FORCE_COMMAND" ``` ## License MIT. ## Star History Star History Chart