Files
bitmessage/docker/run.sh
T
bitdeals c66cea2009
Build docker image and push to registry.bitdeals.org / main-build-job (push) Failing after 3m41s
feat: the daemon keeps no privilege, and the container drops what it can
PyBitmessage is a Python 2 daemon parsing untrusted data from an open port,
so the question is not whether it can be taken over but what is left once it
has been. Until now: uid 2000, but the full fourteen capabilities Docker
hands a container in the bounding set, no_new_privs off, and a root PID 1
holding all fourteen for the life of the container.

run.sh now needs root exactly once. keys.dat can arrive from a bind mount
owned by anyone, so it is chowned, given mode 600 and read first; the block
that does it ends by re-executing the file with a bounding set of four --
SETUID and SETGID to start the daemon as its own user, KILL for the fallback
stop, SETPCAP to drop the rest. Every start of the daemon then goes through
setpriv rather than gosu: uid 2000, every capability set empty, no_new_privs
on. gosu changed the user and left everything else alone; moreutils went with
it, nothing here ever called any of its tools.

No file in the image carries a setuid or setgid bit any more, which is what
makes no_new_privs worth having: there is nothing left to climb.

Both setpriv lines are checked at build time, in the arrangement run.sh uses,
against uid, capability sets and no_new_privs read back from /proc. The base
image and the PyBitmessage clone are both unpinned, so an option that
quietly changed meaning would otherwise ship as a container that looks
confined and is not. Two things that check caught while it was being written:
Ubuntu 18.04's setpriv refuses the "all" keyword under a kernel that knows
more capabilities than its headers did (40 against 37), and capability names
there carry no cap_ prefix. Hence the lists written out by hand.

A caller that sets cap_drop: ALL now needs seven back, not six: SETPCAP joins
CHOWN, DAC_OVERRIDE, FOWNER, SETUID, SETGID and KILL, because dropping a
bounding set takes it. The example compose file and both READMEs say so, and
say what else only a caller can set: read_only with tmpfs, and pids_limit.
2026-09-09 10:29:32 +00:00

420 lines
16 KiB
Bash

#!/bin/sh
set -eu
export BITMESSAGE_API_USER="${BITMESSAGE_API_USER:-bitmessage_api_user}"
export BITMESSAGE_API_PASSWORD="${BITMESSAGE_API_PASSWORD:-bitmessage_api_password}"
export BITMESSAGE_SEED_ADDRESSES="${BITMESSAGE_SEED_ADDRESSES:-0}"
export BITMESSAGE_API_PORT="${BITMESSAGE_API_PORT:-8442}"
export BITMESSAGE_TTL="${BITMESSAGE_TTL:-172800}"
export BITMESSAGE_STOPRESENDINGAFTERXDAYS="${BITMESSAGE_STOPRESENDINGAFTERXDAYS:-30}"
export BITMESSAGE_APIVARIANT="${BITMESSAGE_APIVARIANT:-legacy}"
export BITMESSAGE_MAXTOTALCONNECTIONS="${BITMESSAGE_MAXTOTALCONNECTIONS:-200}"
export BITMESSAGE_TRUSTED_PEER="${BITMESSAGE_TRUSTED_PEER:-}"
export BITMESSAGE_SEND_OUTGOING="${BITMESSAGE_SEND_OUTGOING:-True}"
export BITMESSAGE_KNOWN_NODES="${BITMESSAGE_KNOWN_NODES:-}"
# The watchdog at the end of this file. A daemon that has lost every peer does
# not find its way back on its own, while one that has just started dials hard
# and does -- so a restart is the cure, and noticing is the whole difference.
# On by default; False leaves the supervisor holding the daemon and stops it
# acting. PERIOD is seconds between checks, AFTER how many peerless checks in a
# row it takes to act, COOLDOWN the floor between two restarts.
export BITMESSAGE_WATCHDOG="${BITMESSAGE_WATCHDOG:-True}"
export BITMESSAGE_WATCHDOG_PERIOD="${BITMESSAGE_WATCHDOG_PERIOD:-60}"
export BITMESSAGE_WATCHDOG_AFTER="${BITMESSAGE_WATCHDOG_AFTER:-5}"
export BITMESSAGE_WATCHDOG_COOLDOWN="${BITMESSAGE_WATCHDOG_COOLDOWN:-900}"
# Reject anything but a plain number: this value is written into keys.dat, and
# unlike the credentials below it has no business containing characters that
# esc() would have to neutralise. A typo here would otherwise land in the config
# as a key the daemon silently ignores.
case "$BITMESSAGE_MAXTOTALCONNECTIONS" in
'' | *[!0-9]*)
echo "BITMESSAGE_MAXTOTALCONNECTIONS must be a positive integer" >&2
exit 1
;;
esac
# sendoutgoingconnections is read with safeGetBoolean, which would take "yes" or
# "1" too; keys.dat is written by hand often enough that it is worth keeping one
# spelling in it. Anything else is a typo, and a typo here reads as False --
# a node that quietly never dials out.
case "$BITMESSAGE_SEND_OUTGOING" in
[Tt]rue) BITMESSAGE_SEND_OUTGOING=True ;;
[Ff]alse) BITMESSAGE_SEND_OUTGOING=False ;;
*)
echo "BITMESSAGE_SEND_OUTGOING must be True or False" >&2
exit 1
;;
esac
# The same two rules again, for the watchdog. Checked here rather than in the
# loop because a typo would otherwise surface hours later as a supervisor that
# spins, or one that never acts -- and both look like a working container.
case "$BITMESSAGE_WATCHDOG" in
[Tt]rue) BITMESSAGE_WATCHDOG=True ;;
[Ff]alse) BITMESSAGE_WATCHDOG=False ;;
*)
echo "BITMESSAGE_WATCHDOG must be True or False" >&2
exit 1
;;
esac
case "$BITMESSAGE_WATCHDOG_PERIOD" in
'' | *[!0-9]* | 0)
echo "BITMESSAGE_WATCHDOG_PERIOD must be a positive integer" >&2
exit 1
;;
esac
case "$BITMESSAGE_WATCHDOG_AFTER" in
'' | *[!0-9]* | 0)
echo "BITMESSAGE_WATCHDOG_AFTER must be a positive integer" >&2
exit 1
;;
esac
# Zero is allowed here and means "no floor": restart on every verdict.
case "$BITMESSAGE_WATCHDOG_COOLDOWN" in
'' | *[!0-9]*)
echo "BITMESSAGE_WATCHDOG_COOLDOWN must be a non-negative integer" >&2
exit 1
;;
esac
# host:port with a numeric port -- the form both consumers need. PyBitmessage
# does check trustedpeer itself, but by sys.exit() from a constructor deep in
# the network thread: the container dies with the reason buried in the daemon
# log. Fail here, where the message is the first thing in `docker logs`.
check_peer() {
case "$1" in
*:*) ;;
*) return 1 ;;
esac
[ -n "${1%:*}" ] || return 1
case "${1##*:}" in
'' | *[!0-9]*) return 1 ;;
esac
}
if [ -n "$BITMESSAGE_TRUSTED_PEER" ] && ! check_peer "$BITMESSAGE_TRUSTED_PEER"
then
echo "BITMESSAGE_TRUSTED_PEER must be host:port" >&2
exit 1
fi
if [ -z "${BITMESSAGE_SEED_PHRASE:-}" ]
then
BITMESSAGE_SEED_PHRASE="$(cat /dev/random | tr -dc "a-z" | head -c32)"
export BITMESSAGE_SEED_PHRASE
fi
# Escape a value for use on the right-hand side of the sed expressions below.
# There, a backslash starts an escape, "&" stands for the whole match, and "|"
# ends the replacement because it is the delimiter. Unescaped, a password
# containing "&" was silently rewritten into something else and one containing
# "|" made sed fail outright.
esc() {
printf '%s' "$1" | sed -e 's/[\\&|]/\\&/g'
}
# The fourteen capabilities Docker hands a container by default, written out
# rather than as "-all". The keyword is unusable in this image: setpriv refuses
# it whenever the running kernel knows more capabilities than the headers
# setpriv was built against, and Ubuntu 18.04 (CAP_LAST_CAP 37) under a current
# kernel (40) is exactly that case -- it would exit 127 with "libcap-ng is too
# old". A capability a compose file adds beyond the default therefore survives,
# which is the honest behaviour: nothing here silently drops what it was never
# told about. Names carry no cap_ prefix; that spelling is a parse error.
BM_CAPS_NONE="-chown,-dac_override,-fowner,-fsetid,-kill,-setgid,-setuid"
BM_CAPS_NONE="$BM_CAPS_NONE,-setpcap,-net_bind_service,-net_raw,-sys_chroot"
BM_CAPS_NONE="$BM_CAPS_NONE,-mknod,-audit_write,-setfcap"
# The same list less the four the supervisor keeps: SETUID and SETGID start the
# daemon as its own user, KILL is the fallback stop, and SETPCAP is what lets
# the daemon's own line above run at all -- dropping a bounding set takes it.
BM_CAPS_SUPERVISOR="-chown,-dac_override,-fowner,-fsetid,-net_bind_service"
BM_CAPS_SUPERVISOR="$BM_CAPS_SUPERVISOR,-net_raw,-sys_chroot,-mknod"
BM_CAPS_SUPERVISOR="$BM_CAPS_SUPERVISOR,-audit_write,-setfcap"
# 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.
#
# 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() {
setpriv --reuid="${USER_UID:-2000}" --regid="${USER_GID:-2000}" \
--clear-groups --bounding-set="$BM_CAPS_NONE" \
--no-new-privs "$@"
}
# --- 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 needs SETUID and SETGID to start the daemon
# as its own user, and KILL for the supervisor's fallback stop -- three of the
# fourteen capabilities 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:-}" ]
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
# 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
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.
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
{\"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 setpriv lines actually leave behind.
BM_SUPERVISED=1
export BM_SUPERVISED
exec setpriv --bounding-set="$BM_CAPS_SUPERVISOR" /bin/sh "$0"
fi
# generate address from seed
if [ "$BITMESSAGE_SEED_ADDRESSES" -gt 0 ]
then
# Four attempts, not a bash {1..4}: this runs under dash, where brace
# expansion is literal and the loop would have run once. The call is
# idempotent (createDeterministicAddresses returns nothing for an address
# that already exists), so these are retries while the API comes up.
for i in 1 2 3 4
do
sleep 15
as_bitmessage /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
# 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
# that died on its own left the container Up around a corpse, because what PID 1
# was doing had nothing to do with whether the daemon was alive.
#
# Away from PID 1 that grandfather does die on the ready signal. Measured in
# this image: the call returns immediately with status 143 and leaves exactly
# one pybitmessage process behind. So starting the daemon is an ordinary
# blocking call, and everything below is ordinary shell.
#
# What the supervisor does NOT do is act on a daemon whose API is not answering
# at all. That is the trapped-VACUUM node, a restart does not cure it, and
# restarting anyway drops the next VACUUM half-done too. watchdog.py reports
# that case as its own exit code so this loop can leave it alone.
#: How long to wait for a daemon to go away before insisting, in seconds. Twice
#: this is the worst case for a stop, which is what `stop_grace_period` has to
#: cover -- see the note in the README: a clean PyBitmessage shutdown does not
#: fit in Docker's default ten seconds, so a compose file that does not raise
#: the grace period gets the SIGKILL this supervisor exists to avoid.
STOP_TIMEOUT=30
daemon_running() {
pgrep -f pybitmessage >/dev/null 2>&1
}
start_daemon() {
set +e
as_bitmessage pybitmessage -d
rc=$?
set -e
# 143 is the ready signal reaching the grandfather, which is this call's
# ordinary end. Anything but that or a plain 0 never daemonized.
if [ "$rc" -ne 143 ] && [ "$rc" -ne 0 ]
then
echo "watchdog: the daemon did not start (status $rc)" >&2
return 1
fi
echo "watchdog: daemon started"
}
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
waited=0
while daemon_running && [ "$waited" -lt "$STOP_TIMEOUT" ]
do
sleep 1
waited=$((waited + 1))
done
daemon_running || return 0
# The API would not answer. TERM, and never KILL: the daemon installs a
# handler for TERM (setSignalHandler) and shuts down properly on it.
echo "watchdog: the API did not stop the daemon, sending TERM" >&2
pkill -TERM -f pybitmessage || true
waited=0
while daemon_running && [ "$waited" -lt "$STOP_TIMEOUT" ]
do
sleep 1
waited=$((waited + 1))
done
}
on_signal() {
echo "watchdog: stopping"
stop_daemon
exit 0
}
trap on_signal TERM INT
start_daemon || exit 1
streak=0
last_restart=0
while :
do
sleep "$BITMESSAGE_WATCHDOG_PERIOD"
if ! daemon_running
then
echo "watchdog: the daemon is gone, starting it again" >&2
start_daemon || exit 1
streak=0
last_restart="$(date +%s)"
continue
fi
[ "$BITMESSAGE_WATCHDOG" = True ] || continue
set +e
as_bitmessage python /usr/local/bin/watchdog.py peers
verdict=$?
set -e
# 1 is "answered, and has no peers" -- the only verdict worth acting on.
# 2 is "did not answer", and the streak starts over rather than carrying an
# interrupted observation forward.
if [ "$verdict" -ne 1 ]
then
streak=0
continue
fi
streak=$((streak + 1))
[ "$streak" -ge "$BITMESSAGE_WATCHDOG_AFTER" ] || continue
now="$(date +%s)"
if [ "$((now - last_restart))" -lt "$BITMESSAGE_WATCHDOG_COOLDOWN" ]
then
continue
fi
echo "watchdog: no peers for $streak checks, restarting the daemon" >&2
stop_daemon
start_daemon || exit 1
streak=0
last_restart="$(date +%s)"
done