diff --git a/README.md b/README.md index 2b09dd4..f288792 100644 --- a/README.md +++ b/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/). +## 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 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. diff --git a/examples/docker-mailserver-vps/Dockerfile b/examples/docker-mailserver-vps/Dockerfile new file mode 100644 index 0000000..e158259 --- /dev/null +++ b/examples/docker-mailserver-vps/Dockerfile @@ -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"] diff --git a/examples/docker-mailserver-vps/README.md b/examples/docker-mailserver-vps/README.md new file mode 100644 index 0000000..42784d2 --- /dev/null +++ b/examples/docker-mailserver-vps/README.md @@ -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. diff --git a/examples/docker-mailserver-vps/docker-compose.yml b/examples/docker-mailserver-vps/docker-compose.yml new file mode 100644 index 0000000..74d84b0 --- /dev/null +++ b/examples/docker-mailserver-vps/docker-compose.yml @@ -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