feat(examples): docker-mailserver "fake VPS" scoped to one directory
A disposable jail container that bind-mounts only one directory; the bastion's FORCE_COMMAND drops every SSH session into an interactive shell inside it. The jail's own root fs is throwaway image data, so the only host data reachable over the session is the mounted directory. Documents the docker-socket tradeoff and the read-only / no-socket hardened variants. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
8eb57e5a77
commit
964bb394db
66
README.md
66
README.md
|
|
@ -214,6 +214,72 @@ 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/).
|
A complete copy-paste setup (compose + a live-editable `allowed-commands.list`) is in [`examples/docker-mailserver/`](examples/docker-mailserver/).
|
||||||
|
|
||||||
|
## Quick Start — a "fake VPS" scoped to one directory
|
||||||
|
|
||||||
|
Broker mode hands out a *menu* of commands. Sometimes you want the opposite: a
|
||||||
|
real interactive shell that feels like a VPS, but where the only thing on the
|
||||||
|
"server" is **one directory** — e.g. hand someone your `docker-mailserver`
|
||||||
|
directory to manage, and nothing else of the host.
|
||||||
|
|
||||||
|
The trick is a **disposable jail container** whose only real mount is that one
|
||||||
|
directory; the bastion's `FORCE_COMMAND` drops every session into a shell
|
||||||
|
inside it. The jail's own root filesystem is throwaway image data, *not* your
|
||||||
|
server — so the only host data reachable over SSH is the directory you mounted.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
# Disposable shell box. ONLY real host data inside it = the one directory.
|
||||||
|
dms-jail:
|
||||||
|
image: docker:27-cli # or build a richer image — see the example
|
||||||
|
container_name: dms-jail
|
||||||
|
restart: unless-stopped
|
||||||
|
working_dir: /opt/docker-mailserver
|
||||||
|
entrypoint: ["tail", "-f", "/dev/null"] # stay alive for `docker exec`
|
||||||
|
volumes:
|
||||||
|
# THE one directory. Keep host path == in-jail path so `docker compose`
|
||||||
|
# inside the jail resolves the stack's bind mounts to host paths.
|
||||||
|
- /opt/docker-mailserver:/opt/docker-mailserver
|
||||||
|
# Lets the shell drive `docker compose`/restart. Host-root-equivalent —
|
||||||
|
# drop this mount (here and below) for a true no-escape jail.
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
networks: [web]
|
||||||
|
|
||||||
|
bastion-vps:
|
||||||
|
image: blaxsoftware/bastion:latest
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on: [dms-jail]
|
||||||
|
environment:
|
||||||
|
# Every SSH session becomes an interactive shell in the jail, in the
|
||||||
|
# directory. `-it` = TTY → use SSH (not the HTTP path) for this.
|
||||||
|
FORCE_COMMAND: "docker exec -it -w /opt/docker-mailserver dms-jail sh"
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock # only to exec into the jail
|
||||||
|
- ./docker-data/bastion/users.d:/etc/bastion/users.d
|
||||||
|
- ./docker-data/bastion/keys:/etc/ssh/keys
|
||||||
|
ports:
|
||||||
|
- "2222:22"
|
||||||
|
networks: [web]
|
||||||
|
|
||||||
|
networks:
|
||||||
|
web:
|
||||||
|
external: true
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh -p 2222 agent@your-host # lands you in the directory, real shell
|
||||||
|
```
|
||||||
|
|
||||||
|
**The docker-socket tradeoff:** giving the jail the socket (so `docker compose`
|
||||||
|
works) is host-root-equivalent — from that shell, `docker run -v /:/host …`
|
||||||
|
reaches the whole host. It's a *practical* one-directory VPS for trusted
|
||||||
|
operators, not a hard sandbox. For a true no-escape boundary, remove the
|
||||||
|
`docker.sock` mount from both services (and manage the stack via a separate
|
||||||
|
broker-mode bastion).
|
||||||
|
|
||||||
|
A complete copy-paste setup — with a richer jail image (bash, editors, git,
|
||||||
|
compose) and the read-only / no-socket variants spelled out — is in
|
||||||
|
[`examples/docker-mailserver-vps/`](examples/docker-mailserver-vps/).
|
||||||
|
|
||||||
## Command broker mode
|
## 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.
|
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.
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
# ===========================================================================
|
||||||
|
# "Fake VPS" jail image for managing docker-mailserver
|
||||||
|
# ===========================================================================
|
||||||
|
# A disposable shell box. The ONLY real host data an SSH session can reach is
|
||||||
|
# the single directory bind-mounted into it (see docker-compose.yml). This
|
||||||
|
# image's own filesystem is throwaway alpine + tooling — NOT your server.
|
||||||
|
#
|
||||||
|
# docker:cli gives us the docker client; we add the compose plugin + a few
|
||||||
|
# niceties so the shell feels like a real box (bash, editors, pager, git).
|
||||||
|
# ===========================================================================
|
||||||
|
FROM docker:27-cli
|
||||||
|
|
||||||
|
RUN apk add --no-cache \
|
||||||
|
bash bash-completion \
|
||||||
|
docker-cli-compose \
|
||||||
|
vim nano less git curl ca-certificates \
|
||||||
|
tini
|
||||||
|
|
||||||
|
# Land here; matches the bind-mount path so `docker compose` resolves the
|
||||||
|
# stack's relative bind paths to the same absolute paths the host daemon sees.
|
||||||
|
WORKDIR /srv/docker-mailserver
|
||||||
|
|
||||||
|
# Stay alive so the bastion can `docker exec` into us on each SSH session.
|
||||||
|
# tini reaps the zombie shells those sessions leave behind.
|
||||||
|
ENTRYPOINT ["/sbin/tini", "--"]
|
||||||
|
CMD ["tail", "-f", "/dev/null"]
|
||||||
|
|
@ -0,0 +1,63 @@
|
||||||
|
# docker-mailserver "fake VPS" (one directory, nothing else)
|
||||||
|
|
||||||
|
A copy-paste setup that gives an operator (or an agent) an **interactive SSH
|
||||||
|
shell that feels like a VPS** — but the only real host data on it is your
|
||||||
|
`docker-mailserver` directory. The rest of the server's filesystem isn't
|
||||||
|
mounted, so there's nothing else to see.
|
||||||
|
|
||||||
|
Contrast with the sibling [`../docker-mailserver/`](../docker-mailserver/)
|
||||||
|
example: that one is **broker mode** — a fixed *menu* of `setup email …`
|
||||||
|
commands, no shell. This one is the opposite — a **full shell**, scoped to one
|
||||||
|
directory, for when you want to edit configs and drive `docker compose`
|
||||||
|
yourself.
|
||||||
|
|
||||||
|
## What you get
|
||||||
|
|
||||||
|
```
|
||||||
|
ssh -p 2222 agent@your-host
|
||||||
|
└─ bastion-vps key auth; forces every session into the jail
|
||||||
|
└─ docker exec -it dms-jail bash
|
||||||
|
└─ dms-jail disposable alpine + docker/compose/editors/git
|
||||||
|
ONLY real mount: your docker-mailserver directory
|
||||||
|
you land in it, read-write
|
||||||
|
```
|
||||||
|
|
||||||
|
Inside the shell you can `vim mailserver.env`, `docker compose up -d`,
|
||||||
|
`docker exec mailserver setup email add …`, `git pull`, etc.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
1. Paths are preset to `/srv/docker-mailserver` (host and in-jail, identical).
|
||||||
|
If your directory is elsewhere, edit the **two** paths in
|
||||||
|
[`docker-compose.yml`](docker-compose.yml) and keep both sides of the `:`
|
||||||
|
identical — a different in-jail path still lets you read/edit files, but
|
||||||
|
`docker compose` from the jail would resolve the stack's bind mounts to a
|
||||||
|
host path that doesn't exist and bring the mailserver up with empty data.
|
||||||
|
2. Add your key:
|
||||||
|
```bash
|
||||||
|
mkdir -p docker-data/bastion/users.d
|
||||||
|
cp ~/.ssh/id_ed25519.pub docker-data/bastion/users.d/me.pub
|
||||||
|
```
|
||||||
|
3. Launch:
|
||||||
|
```bash
|
||||||
|
docker compose up -d --build
|
||||||
|
ssh -p 2222 agent@your-host
|
||||||
|
```
|
||||||
|
|
||||||
|
## Security note — the docker-socket tradeoff
|
||||||
|
|
||||||
|
To let you run `docker compose` / restart the mailserver from the shell, the
|
||||||
|
jail is given the host docker socket. **A docker socket is host-root
|
||||||
|
equivalent** — from inside the shell, `docker run -v /:/host alpine sh` would
|
||||||
|
expose the whole host. So this is a *practical* one-directory VPS for **trusted
|
||||||
|
operators**, not a hard sandbox an adversary can't escape.
|
||||||
|
|
||||||
|
Want a real "only this directory exists" boundary instead? Remove the
|
||||||
|
`/var/run/docker.sock` mount from **both** services in the compose file. You
|
||||||
|
lose in-shell `docker compose`/restart (manage the stack from a separate
|
||||||
|
broker-mode bastion — see [`../docker-mailserver/`](../docker-mailserver/)),
|
||||||
|
but the shell then genuinely cannot reach anything but the mounted directory.
|
||||||
|
|
||||||
|
The auth boundary is your SSH key (`users.d/*.pub`) plus the bastion's
|
||||||
|
`ForceCommand`, which clients cannot bypass — a session can only ever become
|
||||||
|
the jail shell, never anything else.
|
||||||
|
|
@ -0,0 +1,93 @@
|
||||||
|
# ===========================================================================
|
||||||
|
# docker-mailserver "fake VPS" — one directory, nothing else
|
||||||
|
# ===========================================================================
|
||||||
|
# Goal: hand someone (or an agent) an SSH session that *feels* like a VPS, but
|
||||||
|
# the only real thing on it is your docker-mailserver directory. The rest of
|
||||||
|
# the host filesystem is simply not there — there is nothing else mounted to
|
||||||
|
# see.
|
||||||
|
#
|
||||||
|
# How it works:
|
||||||
|
# ssh agent@your-host
|
||||||
|
# └─ bastion-vps (key auth; forces every session into the jail)
|
||||||
|
# └─ docker exec -it dms-jail bash (interactive login shell)
|
||||||
|
# └─ dms-jail container
|
||||||
|
# • base image = disposable alpine + tooling (NOT your host)
|
||||||
|
# • ONLY host data mounted: the docker-mailserver directory
|
||||||
|
# • lands you in that directory with a real shell
|
||||||
|
#
|
||||||
|
# ─── SECURITY NOTE — READ THIS ────────────────────────────────────────────
|
||||||
|
# This setup gives the jail the docker socket so you can run `docker compose`
|
||||||
|
# / restart the mailserver from inside. A docker socket is *host-root
|
||||||
|
# equivalent*: from that shell, `docker run -v /:/host alpine ...` would expose
|
||||||
|
# the entire host. So this is a *practical* one-directory VPS for trusted
|
||||||
|
# operators, NOT a hard security sandbox. If you ever want a true "only this
|
||||||
|
# directory exists, no escape" jail, drop the docker.sock mount from BOTH
|
||||||
|
# services below — you lose `docker compose`/restart but gain a real boundary,
|
||||||
|
# and manage the stack from a separate broker-mode bastion instead (see
|
||||||
|
# ../docker-mailserver/).
|
||||||
|
# ───────────────────────────────────────────────────────────────────────────
|
||||||
|
#
|
||||||
|
# Setup:
|
||||||
|
# 1. Paths are preset to /srv/docker-mailserver (host AND in-jail, identical).
|
||||||
|
# If your directory lives elsewhere, change BOTH occurrences below and keep
|
||||||
|
# the two sides of the colon IDENTICAL — that is what lets `docker compose`
|
||||||
|
# inside the jail find the stack's bind mounts at the paths the host daemon
|
||||||
|
# expects. A different in-jail path (e.g. .../dms-vps) still lets you read
|
||||||
|
# and edit files, but `docker compose up` from the jail would create empty
|
||||||
|
# dirs on the host at the wrong path and bring the stack up with no data.
|
||||||
|
# 2. Drop your SSH public key:
|
||||||
|
# mkdir -p docker-data/bastion/users.d
|
||||||
|
# cp ~/.ssh/id_ed25519.pub docker-data/bastion/users.d/me.pub
|
||||||
|
# 3. docker compose up -d --build
|
||||||
|
# 4. ssh -p 2222 agent@your-host # you land in the directory, in bash
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
services:
|
||||||
|
# ---- The jail: a disposable shell box -----------------------------------
|
||||||
|
dms-jail:
|
||||||
|
build: .
|
||||||
|
image: dms-vps-jail:latest
|
||||||
|
container_name: dms-jail
|
||||||
|
restart: unless-stopped
|
||||||
|
working_dir: /srv/docker-mailserver
|
||||||
|
volumes:
|
||||||
|
# THE one directory. Host path : in-jail path — keep them identical.
|
||||||
|
# This is the ONLY real host data the SSH session can touch (read-write).
|
||||||
|
- /srv/docker-mailserver:/srv/docker-mailserver
|
||||||
|
|
||||||
|
# Docker control: the host daemon socket, so `docker compose up/down`,
|
||||||
|
# `docker exec mailserver setup …`, logs, restarts all work from the
|
||||||
|
# shell. Host-root-equivalent — see the SECURITY NOTE above.
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
networks: [web]
|
||||||
|
|
||||||
|
# ---- The bastion: SSH front door, forces every session into the jail ----
|
||||||
|
bastion-vps:
|
||||||
|
image: blaxsoftware/bastion:latest
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on: [dms-jail]
|
||||||
|
environment:
|
||||||
|
# Every authenticated SSH session becomes an interactive bash shell
|
||||||
|
# inside the jail, in the docker-mailserver directory. `-it` allocates a
|
||||||
|
# TTY (this is an interactive shell → use SSH, not the HTTP path).
|
||||||
|
FORCE_COMMAND: "docker exec -it -w /srv/docker-mailserver dms-jail bash"
|
||||||
|
volumes:
|
||||||
|
# The bastion needs the socket too — only to `docker exec` into the jail.
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
|
||||||
|
# Authorized clients — drop one *.pub per identity. Read live, no restart.
|
||||||
|
- ./docker-data/bastion/users.d:/etc/bastion/users.d
|
||||||
|
|
||||||
|
# Persist the 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
|
||||||
|
ports:
|
||||||
|
# SSH on host port 2222. Bind to 127.0.0.1 and front with a tunnel/VPN
|
||||||
|
# if you don't want it on the public internet.
|
||||||
|
- "2222:22"
|
||||||
|
networks: [web]
|
||||||
|
|
||||||
|
networks:
|
||||||
|
web:
|
||||||
|
external: true
|
||||||
Loading…
Reference in New Issue