Build docker image and push to registry.bitdeals.org / main-build-job (push) Successful in 1m26s
Ubuntu 18.04 does not ship setpriv. The program exists in util-linux 2.31, but Debian only began installing it at 2.32, and this image is on bionic because PyBitmessage is Python 2. The build assertion added in the last commit caught it, which is what it is for: `/bin/sh: 1: setpriv: not found`, exit 127, no image pushed. drop_privs.py does the same work with what the image already has. It sets no_new_privs, drops the bounding set with PR_CAPBSET_DROP while CAP_SETPCAP is still held, then optionally becomes the daemon's user. Two shapes, both in run.sh: keep four capabilities and stay root, for the supervisor; keep none and become uid 2000, for the daemon and everything run on its behalf. It is better than setpriv would have been in one respect. The bounding set is walked up to the kernel's own cap_last_cap instead of a list of names, so a capability this image has never heard of goes too -- and the "-all" spelling that bionic's setpriv refuses under a newer kernel is not needed at all. Verified in a user namespace, in the arrangement run.sh uses: the outer drop leaves 00000000000001e0, the inner leaves every capability set at zero with no_new_privs set. The build assertion checks the same two things.
402 lines
15 KiB
Bash
402 lines
15 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'
|
|
}
|
|
|
|
# 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:-}" ]
|
|
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 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"
|
|
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
|