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
+79 -138
View File
@@ -118,142 +118,83 @@ esc() {
printf '%s' "$1" | sed -e 's/[\\&|]/\\&/g'
}
# Run something as the daemon's own user with nothing left to escalate with: no
# capability in any set, an empty bounding set so none can ever be picked up
# again, and no_new_privs so that a setuid binary could not help either. This
# replaces the earlier privilege drop, which changed the user and left
# everything else alone. What the helper does, and why it is a helper rather
# than setpriv, is at the top of drop_privs.py.
#
# The daemon is the only thing in this container exposed to the network, and
# this line is the whole of what it gets. The AppArmor profile this repository
# carried until 2022 agrees: the two capabilities it granted served the
# AppImage's fuse mount, and PyBitmessage itself asked for none.
as_bitmessage() {
/usr/bin/python /usr/local/bin/drop_privs.py --user -- "$@"
}
# --- everything that needs root, and the moment root stops being needed ----
#
# The capabilities this block uses are needed exactly once, at startup, and only
# by uid 0: keys.dat arrives owned by whoever a bind mount says (CAP_CHOWN), the
# greps below read it while it is mode 600 and owned by the daemon's user
# (CAP_DAC_OVERRIDE), and its mode is set on a file that is not ours
# (CAP_FOWNER). Measured one capability at a time: without DAC_OVERRIDE the
# greps fail silently and the configuration keys are appended a second time on
# every start; without FOWNER the chmod stops the container outright.
#
# So the block ends by re-executing this file with a bounding set that holds
# none of the three. What comes back keeps four capabilities: SETUID and SETGID
# to start the daemon as its own user, KILL for the supervisor's fallback stop,
# and SETPCAP because emptying the daemon's own bounding set takes it. Four of
# the fourteen Docker hands a container by default, and that is all there is for
# the rest of its life, whatever a compromised daemon manages to execute. The
# second pass runs the preamble above again: everything it computes is either
# read back from an exported variable or recomputed identically, and the random
# seed phrase, which is neither, was exported on the first pass.
if [ -z "${BM_SUPERVISED:-}" ]
# maxtotalconnections is the only brake on a node whose P2P port (8444) is
# published: it caps inbound sockets at the total minus maxoutboundconnections.
# The substitution below is a no-op when the key is missing, which would ship a
# node that looks capped and is not -- and the PyBitmessage clone in the
# Dockerfile is unpinned, so the stock config is whatever upstream generates
# today. Add the key rather than trust the substitution alone; line 1 is the
# [bitmessagesettings] header the daemon reads it from.
if ! grep -q "^maxtotalconnections = " keys.dat
then
# A bind mount hands keys.dat over with the host's ownership on it.
if [ -f keys.dat ]
then
chown bitmessage:bitmessage keys.dat
chmod 600 keys.dat
fi
sed -i "1a maxtotalconnections = $BITMESSAGE_MAXTOTALCONNECTIONS" keys.dat
fi
# maxtotalconnections is the only brake on a node whose P2P port (8444)
# is published: it caps inbound sockets at the total minus
# maxoutboundconnections. The substitution below is a no-op when the
# key is missing, which would ship a node that looks capped and is not
# -- and the PyBitmessage clone in the Dockerfile is unpinned, so the
# stock config is whatever upstream generates today. Add the key rather
# than trust the substitution alone; line 1 is the [bitmessagesettings]
# header the daemon reads it from.
if ! grep -q "^maxtotalconnections = " keys.dat
then
as_bitmessage sed -i "1a maxtotalconnections = $BITMESSAGE_MAXTOTALCONNECTIONS" keys.dat
fi
# trustedpeer is absent from the stock keys.dat entirely, so the substitution
# below is a no-op until the key exists -- same trap as maxtotalconnections.
# The key is added even when the value is empty, which is how it can be taken
# back off a node that was pinned before: safeGet returns "" and connectionpool
# falls back to chooseConnection. That empty case is also why the anchors here
# stop at "=" instead of "= ": with nothing to the right there is no trailing
# space to match, and the substitution would never fire again.
if ! grep -q "^trustedpeer =" keys.dat
then
sed -i "1a trustedpeer = $(esc "$BITMESSAGE_TRUSTED_PEER")" keys.dat
fi
# trustedpeer is absent from the stock keys.dat entirely, so the
# substitution below is a no-op until the key exists -- same trap as
# maxtotalconnections. The key is added even when the value is empty,
# which is how it can be taken back off a node that was pinned before:
# safeGet returns "" and connectionpool falls back to chooseConnection.
# That empty case is also why the anchors here stop at "=" instead of
# "= ": with nothing to the right there is no trailing space to match,
# and the substitution would never fire again.
if ! grep -q "^trustedpeer =" keys.dat
then
as_bitmessage sed -i "1a trustedpeer = $(esc "$BITMESSAGE_TRUSTED_PEER")" keys.dat
fi
# Set config values. Every expression is anchored to the start of the line and
# names its key in the replacement, so no backreference is involved and nothing
# in another section can match. With set -e a failure here now stops the
# container instead of leaving the daemon on its previous settings unnoticed --
# including the case of a bind mount with no keys.dat at all.
sed -i \
-e "s|^apiinterface = .*|apiinterface = 0.0.0.0|" \
-e "s|^apivariant = .*|apivariant = $(esc "$BITMESSAGE_APIVARIANT")|" \
-e "s|^apiusername = .*|apiusername = $(esc "$BITMESSAGE_API_USER")|" \
-e "s|^apipassword = .*|apipassword = $(esc "$BITMESSAGE_API_PASSWORD")|" \
-e "s|^apiport = .*|apiport = $(esc "$BITMESSAGE_API_PORT")|" \
-e "s|^apienabled = .*|apienabled = True|" \
-e "s|^ttl = .*|ttl = $(esc "$BITMESSAGE_TTL")|" \
-e "s|^stopresendingafterxdays = .*|stopresendingafterxdays = $(esc "$BITMESSAGE_STOPRESENDINGAFTERXDAYS")|" \
-e "s|^maxtotalconnections = .*|maxtotalconnections = $BITMESSAGE_MAXTOTALCONNECTIONS|" \
-e "s|^trustedpeer =.*|trustedpeer = $(esc "$BITMESSAGE_TRUSTED_PEER")|" \
-e "s|^sendoutgoingconnections = .*|sendoutgoingconnections = $BITMESSAGE_SEND_OUTGOING|" \
-e "s|^udp = .*|udp = False|" keys.dat
# Set config values. Every expression is anchored to the start of the
# line and names its key in the replacement, so no backreference is
# involved and nothing in another section can match. With set -e a
# failure here now stops the container instead of leaving the daemon on
# its previous settings unnoticed -- including the case of a bind mount
# with no keys.dat at all.
as_bitmessage sed -i \
-e "s|^apiinterface = .*|apiinterface = 0.0.0.0|" \
-e "s|^apivariant = .*|apivariant = $(esc "$BITMESSAGE_APIVARIANT")|" \
-e "s|^apiusername = .*|apiusername = $(esc "$BITMESSAGE_API_USER")|" \
-e "s|^apipassword = .*|apipassword = $(esc "$BITMESSAGE_API_PASSWORD")|" \
-e "s|^apiport = .*|apiport = $(esc "$BITMESSAGE_API_PORT")|" \
-e "s|^apienabled = .*|apienabled = True|" \
-e "s|^ttl = .*|ttl = $(esc "$BITMESSAGE_TTL")|" \
-e "s|^stopresendingafterxdays = .*|stopresendingafterxdays = $(esc "$BITMESSAGE_STOPRESENDINGAFTERXDAYS")|" \
-e "s|^maxtotalconnections = .*|maxtotalconnections = $BITMESSAGE_MAXTOTALCONNECTIONS|" \
-e "s|^trustedpeer =.*|trustedpeer = $(esc "$BITMESSAGE_TRUSTED_PEER")|" \
-e "s|^sendoutgoingconnections = .*|sendoutgoingconnections = $BITMESSAGE_SEND_OUTGOING|" \
-e "s|^udp = .*|udp = False|" keys.dat
# BITMESSAGE_KNOWN_NODES pins the peers the daemon starts from, and is
# rewritten on every start: in a private contour the seed *is* the
# topology, and a file left over from an earlier run names nodes that
# may no longer exist. Seeding it also switches off the DNS bootstrap
# -- json_deserialize_knownnodes raises knownNodesActual for any peer
# that is neither DEFAULT_NODES nor "self", and connectionpool calls
# startBootstrappers only while that flag is down, so the node never
# reaches bootstrap8080.bitmessage.org.
#
# Writing it "only when the file is missing" would have been a
# permanent no-op: the image ships a knownnodes.dat, produced by the
# `pybitmessage -t` run in the Dockerfile, and a named volume inherits
# it on first use.
if [ -n "$BITMESSAGE_KNOWN_NODES" ]
then
now="$(date +%s)"
nodes=""
oldifs="$IFS"
IFS=","
for peer in $BITMESSAGE_KNOWN_NODES
do
IFS="$oldifs"
if ! check_peer "$peer"
then
echo "BITMESSAGE_KNOWN_NODES entry '$peer' must be host:port" >&2
exit 1
fi
[ -z "$nodes" ] || nodes="$nodes,"
nodes="$nodes
# BITMESSAGE_KNOWN_NODES pins the peers the daemon starts from, and is rewritten
# on every start: in a private contour the seed *is* the topology, and a file
# left over from an earlier run names nodes that may no longer exist. Seeding it
# also switches off the DNS bootstrap -- json_deserialize_knownnodes raises
# knownNodesActual for any peer that is neither DEFAULT_NODES nor "self", and
# connectionpool calls startBootstrappers only while that flag is down, so the
# node never reaches bootstrap8080.bitmessage.org.
#
# Writing it "only when the file is missing" would have been a permanent no-op:
# the image ships a knownnodes.dat, produced by the `pybitmessage -t` run in the
# Dockerfile, and a named volume inherits it on first use.
if [ -n "$BITMESSAGE_KNOWN_NODES" ]
then
now="$(date +%s)"
nodes=""
oldifs="$IFS"
IFS=","
for peer in $BITMESSAGE_KNOWN_NODES
do
IFS="$oldifs"
if ! check_peer "$peer"
then
echo "BITMESSAGE_KNOWN_NODES entry '$peer' must be host:port" >&2
exit 1
fi
[ -z "$nodes" ] || nodes="$nodes,"
nodes="$nodes
{\"stream\": 1, \"peer\": {\"host\": \"${peer%:*}\", \"port\": ${peer##*:}},
\"info\": {\"lastseen\": $now, \"rating\": 0, \"self\": false}}"
IFS=","
done
IFS="$oldifs"
printf '[%s\n]\n' "$nodes" > knownnodes.dat
chown bitmessage:bitmessage knownnodes.dat
chmod 600 knownnodes.dat
fi
# Nothing below ever touches another user's file again, so root keeps
# only what starting and stopping the daemon takes. The marker is what
# stops the second pass repeating this block; the Dockerfile checks
# what both of these two drops actually leave behind.
BM_SUPERVISED=1
export BM_SUPERVISED
exec /usr/bin/python /usr/local/bin/drop_privs.py \
--keep=kill,setgid,setuid,setpcap -- /bin/sh "$0"
IFS=","
done
IFS="$oldifs"
printf '[%s\n]\n' "$nodes" > knownnodes.dat
chmod 600 knownnodes.dat
fi
# generate address from seed
@@ -266,16 +207,16 @@ then
for i in 1 2 3 4
do
sleep 15
as_bitmessage /usr/bin/python /usr/local/bin/seed_addr_gen.py
/usr/bin/python /usr/local/bin/seed_addr_gen.py
done &
fi
# --- the daemon, and the supervisor that owns it --------------------------
#
# This file used to end at `exec pybitmessage -d` as the daemon's user, making
# the daemon PID 1, and it is a poor PID 1. daemonize() double-forks and parks
# the grandfather in `while True: time.sleep(1)`; the final child then SIGTERMs
# it to say "ready", and PID 1 drops that signal for want of a handler. Three
# This file used to end at `exec pybitmessage -d`, which made the daemon PID 1,
# and it is a poor PID 1. daemonize() double-forks and parks the grandfather in
# `while True: time.sleep(1)`; the final child then SIGTERMs it to say "ready",
# and PID 1 drops that signal for want of a handler. Three
# things followed. The grandfather slept for ever. `docker stop` reached the
# real daemon only as the SIGKILL ten seconds later -- which is how a startup
# VACUUM gets cut in half and the node is then trapped retrying it. And a daemon
@@ -305,7 +246,7 @@ daemon_running() {
start_daemon() {
set +e
as_bitmessage pybitmessage -d
pybitmessage -d
rc=$?
set -e
# 143 is the ready signal reaching the grandfather, which is this call's
@@ -322,7 +263,7 @@ stop_daemon() {
daemon_running || return 0
# Through the daemon's own API, which runs doCleanShutdown: the database is
# closed instead of being cut off mid-write.
as_bitmessage python /usr/local/bin/watchdog.py shutdown || true
python /usr/local/bin/watchdog.py shutdown || true
waited=0
while daemon_running && [ "$waited" -lt "$STOP_TIMEOUT" ]
do
@@ -371,7 +312,7 @@ do
[ "$BITMESSAGE_WATCHDOG" = True ] || continue
set +e
as_bitmessage python /usr/local/bin/watchdog.py peers
python /usr/local/bin/watchdog.py peers
verdict=$?
set -e