135 lines
6.6 KiB
Markdown
135 lines
6.6 KiB
Markdown
[](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"
|
|
```
|