diff --git a/README.md b/README.md index 87c4ac2..830dfc0 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/README.ru-RU.md b/README.ru-RU.md index 7b4a2dc..d845458 100644 --- a/README.ru-RU.md +++ b/README.ru-RU.md @@ -160,21 +160,22 @@ docker run -d \ кому угодно, а реальный риск — исчерпание ресурсов, а не выполнение кода. Держите `BITMESSAGE_MAXTOTALCONNECTIONS` низким и ограничьте контейнер по памяти и CPU. -- **У демона нет собственных прав, а контейнер снимает с себя остальное.** - `run.sh` нужен root ровно один раз, на старте: `keys.dat` может приехать из - bind-монтирования с чужим владельцем, поэтому сначала ему меняют владельца, - ставят режим 600 и читают. Сразу после этого скрипт перезапускает сам себя с - bounding set из четырёх capability — `SETUID` и `SETGID`, чтобы запускать - демона под его собственным пользователем, `KILL` для запасной остановки и - `SETPCAP`, чтобы снять остальные, — а каждый запуск демона идёт через - `drop_privs.py`: uid 2000, все четыре набора capability пустые, - `no_new_privs` включён. Ни на одном файле образа нет бита setuid или setgid, так что - захваченному демону не по чему подниматься. +- **Root'а в этом контейнере нет.** Он стартует под uid 2000 и остаётся под ним, + включая PID 1: `run.sh` работает только с файлами этого пользователя, а два, + которые заводит сам демон — `knownnodes.dat` и `messages.dat`, — он заводит в + своём домашнем каталоге. Ничего не понижается на старте, потому что повышенных + прав изначально нет. Ни на одном файле образа нет бита setuid или setgid, так + что захваченному демону не по чему подниматься. + + Плата за это — bind-монтирование. Каталог `/home/bitmessage`, приехавший с + хоста, должен принадлежать uid 2000: сменить владельца на входе контейнер + больше не может. Именованный том, ради которого образ и сделан, наследует + владельца и режимы от образа, и делать с ним ничего не надо. Три вещи может задать только вызывающая сторона, и все три стоит задать. - `cap_drop: ALL` с возвращёнными `CHOWN`, `DAC_OVERRIDE`, `FOWNER`, `SETUID`, - `SETGID`, `KILL` и `SETPCAP`: эти семь нужны описанному выше старту, а сузить - набор, с которым работают healthcheck и `docker exec`, контейнер сам не может - — только это. `read_only: true` с `tmpfs` под `/tmp` и `/run`: демон пишет - только в свой домашний каталог. И `pids_limit` — рядом с ограничениями по - памяти и CPU, о которых просит предыдущее замечание. + `cap_drop: ALL`, ничего не возвращая: образу не нужна ни одна capability, и + этот же набор получают healthcheck и `docker exec`, а сузить его сам контейнер + не может. `read_only: true` с `tmpfs` под `/tmp` и `/run`: демон пишет только в + свой домашний каталог. И `pids_limit` — рядом с ограничениями по памяти и CPU, + о которых просит предыдущее замечание. Заодно `no-new-privileges:true`: без + единого setuid-файла в образе кусать ему почти нечего, но и стоит он ничего. diff --git a/docker-compose.yml b/docker-compose.yml index 44ea980..414659d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -23,14 +23,12 @@ services: volumes: - bitmessage:/home/bitmessage # A clean shutdown does not fit in Docker's default ten seconds; the last - # notes in the README explain this line and the six below it. The container - # takes away every privilege it can reach from inside, but the set a - # healthcheck and `docker exec` run with, the writability of the root - # filesystem and the process count are not among them -- only the caller - # can set those, and this is what setting them looks like. + # notes in the README explain this line and the five below it. The image + # runs as its own unprivileged user and needs no capability at all, so the + # drop is unconditional -- but what the root filesystem and the process + # count allow is the caller's to set, and nobody else's. stop_grace_period: 90s cap_drop: [ALL] - cap_add: [CHOWN, DAC_OVERRIDE, FOWNER, SETUID, SETGID, KILL, SETPCAP] security_opt: - no-new-privileges:true read_only: true diff --git a/docker/Dockerfile b/docker/Dockerfile index 2c3032e..0dd220d 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -60,13 +60,12 @@ COPY --from=0 /usr/local/ /usr/local/ COPY ./docker/healthy_check.py /usr/local/bin/ COPY ./docker/seed_addr_gen.py /usr/local/bin/ COPY ./docker/watchdog.py /usr/local/bin/ -COPY ./docker/drop_privs.py /usr/local/bin/ COPY ./docker/run.sh /usr/local/bin/ -# Install dependencies. gosu is gone: run.sh drops privileges through -# drop_privs.py, which does the whole job rather than only the user and needs -# nothing beyond the Python already here. moreutils went with it -- nothing in -# this repository ever called any of its tools. +# Install dependencies. gosu is gone with the last thing that needed it: the +# container starts as the daemon's user and never changes user, so there is no +# privilege to drop at run time. moreutils went with it -- nothing in this +# repository ever called any of its tools. RUN apt-get update \ && apt-get install -yq --no-install-suggests --no-install-recommends python-setuptools \ && rm -rf /var/lib/apt/lists/* @@ -80,42 +79,36 @@ WORKDIR ${HOME} # Generate default config RUN su bitmessage -c "pybitmessage -t" -# Prove that the two privilege drops in run.sh do what the script leans on, in -# the arrangement run.sh uses: the supervisor's on the outside, the daemon's on -# the inside. This base image and the PyBitmessage clone above are both -# unpinned, so a helper that quietly stopped working would otherwise ship as a -# container that looks confined and is not -- the trap the sql_timeout greps in -# the first stage avoid the same way, by failing the build instead. -# -# Expected: the daemon at uid 2000 with every capability set empty and -# no_new_privs on, and the supervisor holding 00000000000001e0 -- CAP_KILL, -# CAP_SETGID, CAP_SETUID and CAP_SETPCAP, bits 5 to 8, and nothing else. -RUN set -eu \ - && drop=/usr/local/bin/drop_privs.py \ - && d="$(python "$drop" --keep=kill,setgid,setuid,setpcap -- \ - python "$drop" --user -- \ - grep -E '^(Uid|Gid|CapPrm|CapEff|CapBnd|NoNewPrivs):' /proc/self/status)" \ - && printf '%s\n' "$d" \ - && printf '%s\n' "$d" | grep -qE '^Uid:[[:space:]]+2000[[:space:]]+2000[[:space:]]+2000' \ - && printf '%s\n' "$d" | grep -qE '^Gid:[[:space:]]+2000[[:space:]]+2000[[:space:]]+2000' \ - && printf '%s\n' "$d" | grep -qE '^CapPrm:[[:space:]]+0{16}$' \ - && printf '%s\n' "$d" | grep -qE '^CapEff:[[:space:]]+0{16}$' \ - && printf '%s\n' "$d" | grep -qE '^CapBnd:[[:space:]]+0{16}$' \ - && printf '%s\n' "$d" | grep -qE '^NoNewPrivs:[[:space:]]+1$' \ - && s="$(python "$drop" --keep=kill,setgid,setuid,setpcap -- \ - grep -E '^CapBnd:' /proc/self/status)" \ - && printf '%s\n' "$s" \ - && printf '%s\n' "$s" | grep -qE '^CapBnd:[[:space:]]+0{13}1e0$' +# keys.dat holds the API password and the node's private keys, and the daemon +# writes it back at that mode anyway. Set here rather than on every start, +# because on every start there would be no root to do it: a named volume takes +# its first contents, and their ownership and modes, from the image. +RUN chmod 600 keys.dat # Nothing here needs a setuid or setgid bit at run time, and every one of them -# is a way back for a daemon that has been taken over -- the more so because the -# daemon now runs with no_new_privs, which makes them the one thing that could -# still have raised its privileges. The privilege drop is not among them: it is -# an ordinary script that root runs. Strip them all, then insist none is left, -# so a package added here later cannot bring one back unnoticed. +# is a way back to root for a daemon that has been taken over -- the more so +# under a caller that adds no-new-privileges, which leaves them as the one thing +# that could still have raised it. Strip them all, then insist none is left, so +# a package added here later cannot bring one back unnoticed. Last root step in +# the file, and it has to be: after USER below there is no chmod to be had. RUN find / -xdev -type f -perm /6000 -exec chmod -s {} + \ && [ -z "$(find / -xdev -type f -perm /6000)" ] +# The daemon's own user, from PID 1 onwards. Nothing in this image needs root +# once it is built: the files it works on are its own, and the only two it +# creates -- knownnodes.dat and messages.dat -- it creates in its home. That +# also settles what a `docker exec` and the healthcheck below run as. +# +# The caller no longer has to hand any capability back after cap_drop: ALL. In +# exchange, a bind mount over /home/bitmessage has to be owned by this uid; a +# named volume, which is what this image is meant for, inherits it from the +# image and needs nothing. +USER bitmessage + +# One line, because a wrong USER is a whole class of surprise and this is where +# it is cheapest to find out. +RUN [ "$(id -u)" = "$USER_UID" ] && [ "$(id -g)" = "$USER_GID" ] + CMD ["sh", "/usr/local/bin/run.sh"] ## Check PyBitmessage active network connections. diff --git a/docker/drop_privs.py b/docker/drop_privs.py deleted file mode 100644 index 0bc6c6a..0000000 --- a/docker/drop_privs.py +++ /dev/null @@ -1,147 +0,0 @@ -#!/usr/bin/python - -"""Drop privileges, then exec -- the one errand run.sh cannot do in shell. - -Two shapes, and both of them are in run.sh: - - drop_privs.py --keep=kill,setgid,setuid,setpcap -- sh /usr/local/bin/run.sh - drop_privs.py --user -- pybitmessage -d - -The first stays root and shrinks the capability bounding set to what starting -and stopping the daemon takes. Nothing execed below it can hold more than that -for the rest of the container's life. The second keeps nothing and becomes the -daemon's own user: an empty bounding set, no capability in any set, and -no_new_privs, so not even a setuid binary could raise it -- and after the strip -in the Dockerfile the image has none left anyway. - -setpriv would have been the obvious tool for this. Ubuntu 18.04 does not have -it: the program exists in util-linux 2.31 upstream, but Debian only began -installing it at 2.32, and this image is built on bionic because PyBitmessage is -Python 2. gosu, which the image used before, only ever changed the user. - -The bounding set is walked up to the kernel's own cap_last_cap rather than a -list written down here, so a capability this image has never heard of is dropped -just the same. Everything that fails is fatal: a privilege drop that half worked -would leave a container looking confined and not being it. -""" - -import ctypes -import os -import sys - -PR_CAPBSET_DROP = 24 -PR_SET_NO_NEW_PRIVS = 38 - -# Only the ones a caller here ever keeps. A number is accepted too, for a -# capability this table does not name. -CAP_NUMBERS = { - "kill": 5, - "setgid": 6, - "setuid": 7, - "setpcap": 8, -} - -USAGE = "usage: drop_privs.py [--user] [--keep=cap,...] -- program [args]" - - -def die(message): - sys.stderr.write("drop_privs: %s\n" % message) - sys.exit(1) - - -libc = ctypes.CDLL("libc.so.6", use_errno=True) - - -def prctl(option, arg2): - ctypes.set_errno(0) - if libc.prctl(option, arg2, 0, 0, 0) != 0: - die("prctl(%d, %d): %s" - % (option, arg2, os.strerror(ctypes.get_errno()))) - - -def cap_last_cap(): - """The highest capability this kernel knows. - - Read rather than guessed, and a failure to read is fatal: a guess that came - in low would silently leave the capabilities above it in the bounding set. - """ - try: - handle = open("/proc/sys/kernel/cap_last_cap") - except IOError as exc: - die("cannot open /proc/sys/kernel/cap_last_cap: %s" % exc) - try: - try: - return int(handle.read().strip()) - except ValueError as exc: - die("cannot read /proc/sys/kernel/cap_last_cap: %s" % exc) - finally: - handle.close() - - -def parse_keep(value): - keep = set() - for name in value.split(","): - name = name.strip() - if not name: - continue - if name in CAP_NUMBERS: - keep.add(CAP_NUMBERS[name]) - else: - try: - keep.add(int(name)) - except ValueError: - die("unknown capability %r" % name) - return keep - - -def main(argv): - keep = set() - become_user = False - args = argv[1:] - - while args: - if args[0] == "--": - args = args[1:] - break - elif args[0] == "--user": - become_user = True - args = args[1:] - elif args[0].startswith("--keep="): - keep |= parse_keep(args[0][len("--keep="):]) - args = args[1:] - else: - die("unknown option %r\n%s" % (args[0], USAGE)) - - if not args: - die("nothing to execute\n%s" % USAGE) - - # Before anything else, so that everything below inherits it. - prctl(PR_SET_NO_NEW_PRIVS, 1) - - # Dropping from the bounding set takes CAP_SETPCAP, which is still held - # here; it does not touch the sets this process is using, so the uid change - # below still works after every capability has gone out of the set. - for cap in range(0, cap_last_cap() + 1): - if cap not in keep: - prctl(PR_CAPBSET_DROP, cap) - - if become_user: - uid = int(os.environ.get("USER_UID", "2000")) - gid = int(os.environ.get("USER_GID", "2000")) - # Groups first, then gid, then uid: each of the three needs a - # privilege the next one gives up. - try: - os.setgroups([]) - os.setresgid(gid, gid, gid) - os.setresuid(uid, uid, uid) - except OSError as exc: - die("cannot become %d:%d: %s" % (uid, gid, exc)) - - try: - os.execvp(args[0], args) - except OSError as exc: - die("cannot execute %r: %s" % (args[0], exc)) - - -if __name__ == "__main__": - main(sys.argv) diff --git a/docker/run.sh b/docker/run.sh index 440323d..a86d11f 100644 --- a/docker/run.sh +++ b/docker/run.sh @@ -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