docker-bastion/README.md

6.8 KiB

Blax Software OSS

docker-bastion

A minimal SSH bastion that authenticates by key and runs exactly one preconfigured command on every login. No fallback shell, no interactive choice — the agent connecting via SSH gets dropped straight into whatever you point FORCE_COMMAND at.

Typical uses:

  • Give an agent (or a human) an SSH-shaped door into a running container — connection lands inside docker exec -it app bash and behaves like SSH'ing into the app.
  • Give a deploy bot an SSH-shaped door that runs ./deploy.sh on connect, streams output, and disconnects when the script exits.

Same image, different FORCE_COMMAND per service.

Image

Tag Base Size (est.)
blaxsoftware/bastion:latest alpine 3.21 ~65 MB

Quick Start

services:
  ssh-app:
    image: blaxsoftware/bastion:latest
    ports:
      - "2222:22"
    environment:
      FORCE_COMMAND: "docker exec -it learnatc-app-1 bash"
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
      - ~/.ssh/authorized_keys:/etc/bastion/authorized_keys.host:ro
      - ./docker/bastion/authorized_keys:/etc/bastion/authorized_keys.repo:ro
      - ./docker-data/bastion-app/keys:/etc/ssh/keys
    restart: unless-stopped

  ssh-deploy:
    image: blaxsoftware/bastion:latest
    ports:
      - "2223:22"
    environment:
      FORCE_COMMAND: "cd /workspace && ./deploy.sh"
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
      - .:/workspace:ro
      - ~/.ssh/authorized_keys:/etc/bastion/authorized_keys.host:ro
      - ./docker-data/bastion-deploy/keys:/etc/ssh/keys
    restart: unless-stopped

Host keys live in ./docker-data/bastion-*/keys/ as bind mounts — never named volumes. docker compose down -v then can't wipe them, and the client doesn't see "REMOTE HOST IDENTIFICATION HAS CHANGED" after a rebuild. Gitignore docker-data/ in the surrounding repo.

Then from the client:

ssh -p 2222 agent@your-host        # → drops into bash inside the app container
ssh -p 2223 agent@your-host        # → streams ./deploy.sh, disconnects on exit

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.

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 either, both, or neither — but neither = container exits at startup with a clear error.

How FORCE_COMMAND behaves

FORCE_COMMAND is run via sh -c, so shell metacharacters work — &&, ||, pipes, cd, redirects.

  • Interactive command (docker exec -it app bash) — SSH allocates a PTY by default for ssh user@host, docker exec -it inherits it, the inner bash is interactive. Session ends when the user types exit.
  • Script command (cd /workspace && ./deploy.sh) — output streams back over SSH, the session closes the moment the script exits. Exit code propagates to the SSH client.

The client cannot override the command. SSH_ORIGINAL_COMMAND is ignored.

Environment Variables

Variable Default Description
FORCE_COMMAND (required) The command run on every authenticated session. Shell metacharacters OK.
AUTHORIZED_KEYS_HOST /etc/bastion/authorized_keys.host Path to the host-sourced authorized_keys (mount it here).
AUTHORIZED_KEYS_REPO /etc/bastion/authorized_keys.repo Path to the repo-sourced authorized_keys (mount it here).
SSH_PORT 22 Port sshd listens on inside the container.

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, /sbin/nologin shell, global ForceCommand.
  • docker-cli + docker-cli-compose — so FORCE_COMMAND can target containers via a mounted docker socket. Group membership is auto-aligned at boot to the host socket's GID.
  • tini — PID 1, signal handling.
  • bash, ca-certificates, tzdata.

Security Model

The security boundary is the authorized_keys file and the ForceCommand wrapper. Once a key authenticates, the session is hard-pinned to exactly one command. The bastion has the docker socket — equivalent to host root — so the only thing standing between a remote attacker and host root is sshd + your key hygiene.

Practical checklist:

  1. Key-only auth, no passwords — enforced in sshd_config.
  2. No agent / tcp / x11 forwarding, no port tunnels — enforced in sshd_config.
  3. Login shell is /sbin/nologin — no fallback if ForceCommand somehow misfires.
  4. PermitUserEnvironment no, PermitUserRC no — clients cannot inject env or rc files.
  5. Bind the host port to 127.0.0.1 or behind a firewall / VPN unless you actually need it public.
  6. Keep openssh patchedapk upgrade in a rebuild cycle; an unauth sshd RCE here would mean host root.
  7. Lock down siblings — anyone who can docker exec into the app can also docker exec into the mysql/redis container via the same socket. cap_drop: [ALL] and no-new-privileges on siblings caps the blast radius.

Architecture

start-container (entrypoint)
  ├─ generate host keys (idempotent, persisted via /etc/ssh/keys volume)
  ├─ merge AUTHORIZED_KEYS_HOST + AUTHORIZED_KEYS_REPO into authorized_keys
  ├─ write /etc/bastion/force-command wrapper from $FORCE_COMMAND
  ├─ align docker socket group membership (if socket is mounted)
  └─ exec sshd -D -e

ssh client
  └─ key auth as `agent`
       └─ ForceCommand /etc/bastion/force-command
            └─ exec sh -c "$FORCE_COMMAND"