refactor: no root in this container at all
Build docker image and push to registry.bitdeals.org / main-build-job (push) Successful in 2m21s

The chown at startup was the only thing that ever needed root here, and it
served a case this project does not have: keys.dat arriving from a bind mount
owned by somebody else. Every deployment uses a named volume, which takes its
ownership from the image. So the chown goes, and everything that existed to
survive it goes with it.

USER bitmessage in the Dockerfile, from PID 1 onwards. drop_privs.py is
deleted, the supervisor no longer re-executes itself with a trimmed bounding
set, run.sh has no privileged prologue and no wrapper around the eight
commands that used to run through one. What is left of run.sh differs from
the version before any of this by eleven lines: two chowns gone, seven `gosu
bitmessage` prefixes gone, one comment reworded.

keys.dat gets its mode 600 at build time instead of on every start, because
on every start there is now no root to set it. Its mode and ownership reach a
fresh volume from the image, and every volume in service already carries them
-- checked on all four live nodes: nothing under /home/bitmessage is owned by
anyone but 2000.

The setuid strip stays. It is two lines and it closes the one way a taken-over
daemon could still have climbed.

What the caller sets changes too, and in the right direction: `cap_drop: ALL`
with nothing added back, where the previous commit needed seven capabilities
handed in. Confinement that used to be split between the image and the caller
now sits in one place. The image gives up defending itself when run with no
options at all, which is the trade named in the README along with the bind
mount it costs.
This commit is contained in:
2026-09-09 13:23:28 +00:00
parent 2f1b2afcb4
commit 68eb019e59
6 changed files with 148 additions and 359 deletions
+19 -16
View File
@@ -161,21 +161,24 @@ Container images are configured using parameters passed at runtime.
practical risk is resource exhaustion rather than code execution. Keep
`BITMESSAGE_MAXTOTALCONNECTIONS` low and put memory and CPU limits on the
container.
- **The daemon holds no privilege of its own, and the container drops what it
can.** `run.sh` needs root exactly once, at startup: `keys.dat` can arrive
from a bind mount owned by anyone, so it is chowned, given mode 600 and read
before anything else. The moment that is done the script re-executes itself
with a bounding set of four capabilities -- `SETUID` and `SETGID` to start the
daemon as its own user, `KILL` for the fallback stop, `SETPCAP` to drop the
rest -- and every start of the daemon goes through `drop_privs.py`, at uid
2000, with all four capability sets empty and `no_new_privs` on. No file in the
image carries a setuid or setgid bit, so a daemon that has been taken over has
nothing left to climb.
- **There is no root in this container.** It starts as uid 2000 and stays
there, PID 1 included: `run.sh` only ever touches files that belong to that
user, and the two the daemon creates -- `knownnodes.dat` and `messages.dat` --
it creates in its own home. Nothing is dropped at run time because nothing
privileged is held in the first place. No file in the image carries a setuid
or setgid bit either, so a daemon that has been taken over has nothing left to
climb.
The price is a bind mount. `/home/bitmessage` mounted from the host has to be
owned by uid 2000, because the container can no longer chown it on the way in;
a named volume, which is what this image is meant for, takes the ownership and
the modes from the image and needs nothing done to it.
Three things only the caller can set, and all three are worth setting.
`cap_drop: ALL` with `CHOWN`, `DAC_OVERRIDE`, `FOWNER`, `SETUID`, `SETGID`,
`KILL` and `SETPCAP` added back: those seven are what the startup above needs,
and the container cannot narrow what the healthcheck and `docker exec` run
with -- only this can. `read_only: true`, with `tmpfs` for `/tmp` and `/run`;
the daemon writes only into its own home. And `pids_limit`, next to the memory
and CPU limits the note above asks for.
`cap_drop: ALL`, with nothing added back -- the image asks for no capability,
and this is also what a healthcheck and a `docker exec` run with, which the
container cannot narrow for itself. `read_only: true`, with `tmpfs` for `/tmp`
and `/run`; the daemon writes only into its own home. And `pids_limit`, next
to the memory and CPU limits the note above asks for. Add
`no-new-privileges:true` while you are there: with no setuid file left in the
image it has little to bite on, but it costs nothing.