From 86b896613027dc87bacc3a20674295081ffed308 Mon Sep 17 00:00:00 2001 From: "Fabian @ Blax Software" Date: Thu, 28 May 2026 10:50:06 +0200 Subject: [PATCH] A initial docker-bastion image Minimal SSH bastion (alpine + openssh-server + docker-cli) that authenticates by key and runs exactly one preconfigured command (FORCE_COMMAND) per session. authorized_keys can be merged from both a host-mounted source and a repo-mounted source. Host keys persist via /etc/ssh/keys volume; docker socket group membership is aligned at boot. --- .dockerignore | 11 +++ Dockerfile | 82 ++++++++++++++++++++++ README.md | 134 ++++++++++++++++++++++++++++++++++++ config/sshd_config | 47 +++++++++++++ docker-bake.hcl | 37 ++++++++++ scripts/start-container | 149 ++++++++++++++++++++++++++++++++++++++++ 6 files changed, 460 insertions(+) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 config/sshd_config create mode 100644 docker-bake.hcl create mode 100644 scripts/start-container diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..042b667 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +.git +.github +.gitignore +*.md +!README.md +LICENSE +docker-bake.hcl +docker-compose*.yml +.env* +.idea +.vscode diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..8f4fa3f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,82 @@ +# =========================================================================== +# docker-bastion — Minimal SSH bastion for ForceCommand routing +# +# Public sshd → key-only auth → runs ONE preconfigured command (set via +# $FORCE_COMMAND). The bastion user's login shell is /sbin/nologin, so +# there is no fallback shell even if ForceCommand somehow fails to fire. +# +# Build args: +# ALPINE_VERSION — Alpine base version (default: 3.21) +# =========================================================================== +ARG ALPINE_VERSION=3.21 +FROM alpine:${ALPINE_VERSION} + +LABEL maintainer="docker-bastion" +LABEL description="Minimal SSH bastion: public ssh → ForceCommand → docker exec (or anything else)" + +ENV TZ=UTC +RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone + +# --------------------------------------------------------------------------- +# Packages +# openssh-server / openssh-keygen — the daemon + ssh-keygen for host keys +# docker-cli / docker-cli-compose — so FORCE_COMMAND can target containers +# tini — proper PID 1 / signal handling +# bash — startup script + interactive sessions +# --------------------------------------------------------------------------- +RUN apk add --no-cache \ + openssh-server \ + openssh-keygen \ + docker-cli \ + docker-cli-compose \ + bash \ + tini \ + ca-certificates \ + tzdata + +# --------------------------------------------------------------------------- +# Bastion user — UID/GID 1000, /sbin/nologin shell (ForceCommand is the only path) +# --------------------------------------------------------------------------- +ARG SSH_UID=1000 +ARG SSH_GID=1000 +RUN addgroup -g ${SSH_GID} agent && \ + adduser -D -u ${SSH_UID} -G agent -s /sbin/nologin agent && \ + mkdir -p /home/agent/.ssh && \ + chown -R agent:agent /home/agent/.ssh && \ + chmod 700 /home/agent/.ssh + +# --------------------------------------------------------------------------- +# sshd config + entrypoint +# /etc/ssh/keys/ — host keys (generated on first boot; mount as a +# volume to persist them across rebuilds) +# /etc/bastion/ — runtime-generated ForceCommand wrapper +# --------------------------------------------------------------------------- +COPY config/sshd_config /etc/ssh/sshd_config +COPY scripts/start-container /usr/local/bin/start-container +RUN chmod 0755 /usr/local/bin/start-container && \ + mkdir -p /etc/bastion /etc/ssh/keys /var/empty && \ + chmod 700 /etc/ssh/keys && \ + chmod 711 /var/empty + +# --------------------------------------------------------------------------- +# Environment +# FORCE_COMMAND — REQUIRED. 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 +# AUTHORIZED_KEYS_HOST — file path; merged into authorized_keys if present +# AUTHORIZED_KEYS_REPO — file path; merged into authorized_keys if present +# (mount either, both, or neither — at least one +# must exist or the container refuses to start) +# SSH_PORT — sshd listen port inside the container (default 22) +# --------------------------------------------------------------------------- +ENV FORCE_COMMAND="" +ENV AUTHORIZED_KEYS_HOST=/etc/bastion/authorized_keys.host +ENV AUTHORIZED_KEYS_REPO=/etc/bastion/authorized_keys.repo +ENV SSH_PORT=22 + +EXPOSE 22 + +ENTRYPOINT ["/sbin/tini", "--", "/usr/local/bin/start-container"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..a9a6f8e --- /dev/null +++ b/README.md @@ -0,0 +1,134 @@ +[![Blax Software OSS](https://raw.githubusercontent.com/blax-software/laravel-workkit/master/art/oss-initiative-banner.svg)](https://github.com/blax-software) + +# 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 + +```yaml +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 + - bastion-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 + - bastion-keys-deploy:/etc/ssh/keys + restart: unless-stopped + +volumes: + bastion-keys: + bastion-keys-deploy: +``` + +Then from the client: + +```bash +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 patched** — `apk 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" +``` diff --git a/config/sshd_config b/config/sshd_config new file mode 100644 index 0000000..c4a114f --- /dev/null +++ b/config/sshd_config @@ -0,0 +1,47 @@ +# =========================================================================== +# 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. +# =========================================================================== + +Port 22 +AddressFamily any +ListenAddress 0.0.0.0 +ListenAddress :: + +# Host keys live in /etc/ssh/keys/ so they can survive image rebuilds via +# a named volume. start-container generates them on first boot if missing. +HostKey /etc/ssh/keys/ssh_host_ed25519_key +HostKey /etc/ssh/keys/ssh_host_rsa_key + +# Privilege separation directory (Alpine default is /var/empty). +StrictModes yes + +# Auth — public keys only, no passwords, no interactive prompts. +# (UsePAM is omitted: Alpine's openssh-server is built without PAM support +# and rejects the directive entirely. Without PAM compiled in, the no-PAM +# behavior is already the default — nothing to disable.) +PermitRootLogin no +PasswordAuthentication no +KbdInteractiveAuthentication no +PubkeyAuthentication yes +AuthorizedKeysFile /home/%u/.ssh/authorized_keys + +# Surface reduction — no forwarding, no tunnels, no user env / rc files. +AllowAgentForwarding no +AllowTcpForwarding no +X11Forwarding no +GatewayPorts no +PermitTunnel no +PermitUserEnvironment no +PermitUserRC no + +# Logging. +LogLevel VERBOSE +SyslogFacility AUTH + +# Belt + suspenders: applies even if per-key command="" is missing. +ForceCommand /etc/bastion/force-command diff --git a/docker-bake.hcl b/docker-bake.hcl new file mode 100644 index 0000000..bf54fc3 --- /dev/null +++ b/docker-bake.hcl @@ -0,0 +1,37 @@ +# =========================================================================== +# docker-bastion — multi-platform build +# +# Usage: +# docker buildx bake +# docker buildx bake --set "*.platform=linux/amd64,linux/arm64" +# +# Override registry / name: +# REGISTRY=ghcr.io/myorg IMAGE_NAME=docker-bastion docker buildx bake +# =========================================================================== + +variable "REGISTRY" { + default = "" +} + +variable "IMAGE_NAME" { + default = "docker-bastion" +} + +variable "ALPINE_VERSION" { + default = "3.21" +} + +function "tag" { + params = [name] + result = REGISTRY != "" ? "${REGISTRY}/${IMAGE_NAME}:${name}" : "${IMAGE_NAME}:${name}" +} + +target "default" { + context = "." + dockerfile = "Dockerfile" + args = { + ALPINE_VERSION = "${ALPINE_VERSION}" + } + platforms = ["linux/amd64", "linux/arm64"] + tags = [tag("latest"), tag("alpine${ALPINE_VERSION}")] +} diff --git a/scripts/start-container b/scripts/start-container new file mode 100644 index 0000000..edc23aa --- /dev/null +++ b/scripts/start-container @@ -0,0 +1,149 @@ +#!/bin/sh +set -eu + +echo "==========================================" +echo " docker-bastion starting" +echo "==========================================" +echo "Date: $(date)" +echo "Hostname: $(hostname)" +echo "OpenSSH: $(/usr/sbin/sshd -V 2>&1 | head -1 || echo n/a)" +echo "Docker CLI: $(docker --version 2>/dev/null || echo n/a)" +echo "==========================================" + +SSH_USER="agent" +SSH_PORT="${SSH_PORT:-22}" +FORCE_COMMAND_VALUE="${FORCE_COMMAND:-}" +AUTHORIZED_KEYS_HOST="${AUTHORIZED_KEYS_HOST:-/etc/bastion/authorized_keys.host}" +AUTHORIZED_KEYS_REPO="${AUTHORIZED_KEYS_REPO:-/etc/bastion/authorized_keys.repo}" + +# --------------------------------------------------------------------------- +# 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'" + exit 1 +fi + +# --------------------------------------------------------------------------- +# 2) Host keys — generate on first boot, persist via /etc/ssh/keys volume +# --------------------------------------------------------------------------- +echo "[1/5] Host keys..." +mkdir -p /etc/ssh/keys +chmod 700 /etc/ssh/keys +for keytype in ed25519 rsa; do + keyfile="/etc/ssh/keys/ssh_host_${keytype}_key" + if [ ! -f "$keyfile" ]; then + echo " Generating new $keytype host key" + ssh-keygen -t "$keytype" -f "$keyfile" -N "" -q + else + echo " Reusing existing $keytype host key" + fi + chmod 600 "$keyfile" + [ -f "${keyfile}.pub" ] && chmod 644 "${keyfile}.pub" +done + +# --------------------------------------------------------------------------- +# 3) Merge authorized_keys sources +# --------------------------------------------------------------------------- +echo "[2/5] Authorized keys..." +AUTH_FILE="/home/${SSH_USER}/.ssh/authorized_keys" +mkdir -p "$(dirname "$AUTH_FILE")" +: > "$AUTH_FILE" + +added=0 +for src in "$AUTHORIZED_KEYS_HOST" "$AUTHORIZED_KEYS_REPO"; do + if [ -f "$src" ]; then + echo " + Merging $src" + cat "$src" >> "$AUTH_FILE" + # Force newline between sources (final file may not end with one). + printf '\n' >> "$AUTH_FILE" + added=$((added + 1)) + fi +done + +if [ "$added" -eq 0 ]; then + echo "FATAL: no authorized_keys source found." + echo " Mount at least one of:" + echo " $AUTHORIZED_KEYS_HOST (typically ~/.ssh/authorized_keys from host)" + echo " $AUTHORIZED_KEYS_REPO (typically ./docker/bastion/authorized_keys)" + exit 1 +fi + +key_count=$(grep -cvE '^[[:space:]]*(#|$)' "$AUTH_FILE" 2>/dev/null || echo 0) +echo " Loaded $key_count authorized key(s) from $added source(s)" + +chown -R "${SSH_USER}:${SSH_USER}" "$(dirname "$AUTH_FILE")" +chmod 700 "$(dirname "$AUTH_FILE")" +chmod 600 "$AUTH_FILE" + +# --------------------------------------------------------------------------- +# 4) ForceCommand wrapper +# +# 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. +# --------------------------------------------------------------------------- +echo "[3/5] ForceCommand..." +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' +#!/bin/sh +# Auto-generated by docker-bastion start-container. +# sshd invokes this script for every authenticated session. +# SSH_ORIGINAL_COMMAND is intentionally ignored — clients cannot override. +exec sh -c "$(cat /etc/bastion/force-command.cmd)" +WRAPPER +chmod 0755 /etc/bastion/force-command +echo " $FORCE_COMMAND_VALUE" + +# --------------------------------------------------------------------------- +# 5) Docker socket — if mounted, align group membership so the agent user +# can talk to dockerd without --privileged. +# --------------------------------------------------------------------------- +echo "[4/5] Docker socket..." +if [ -S /var/run/docker.sock ]; then + sock_gid=$(stat -c '%g' /var/run/docker.sock) + echo " Socket present, host gid=$sock_gid" + + grp_name=$(getent group "$sock_gid" | cut -d: -f1 || true) + if [ -z "$grp_name" ]; then + grp_name="dockerhost" + addgroup -g "$sock_gid" "$grp_name" 2>/dev/null || true + echo " Created group $grp_name (gid=$sock_gid)" + else + echo " Reusing existing group $grp_name (gid=$sock_gid)" + fi + 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." +fi + +# --------------------------------------------------------------------------- +# 6) Adjust sshd_config port if non-default +# --------------------------------------------------------------------------- +if [ "$SSH_PORT" != "22" ]; then + sed -i "s/^Port 22\$/Port ${SSH_PORT}/" /etc/ssh/sshd_config +fi + +# --------------------------------------------------------------------------- +# 7) sshd config sanity check + launch +# --------------------------------------------------------------------------- +echo "[5/5] sshd config check..." +/usr/sbin/sshd -t -f /etc/ssh/sshd_config + +echo "==========================================" +echo " Listening on port ${SSH_PORT}" +echo " User: ${SSH_USER}" +echo " ForceCommand: ${FORCE_COMMAND_VALUE}" +echo "==========================================" + +# -D = foreground, -e = log to stderr (so docker logs picks it up). +exec /usr/sbin/sshd -D -e