Files
bitmessage/docker/Dockerfile
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

132 lines
6.0 KiB
Docker

# A container for PyBitmessage daemon
FROM ubuntu:bionic
SHELL ["/bin/bash", "-exo", "pipefail", "-c"]
# Install dependencies. update and install share a layer on purpose: split
# across two, a cached update feeds install package lists that may be months
# stale, and the install then fails or pulls something unintended.
RUN apt-get update \
&& apt-get install -yq --no-install-suggests --no-install-recommends \
build-essential libcap-dev libssl-dev \
python-all-dev python-msgpack python-pip python-setuptools \
git
## Do not use cache when building next layers of the image.
ARG NOCACHE=0
WORKDIR /root/PyBitmessage
RUN git clone https://github.com/Bitmessage/PyBitmessage .
# Install
RUN pip2 install jsonrpclib .
# Raise the SQL-thread startup timeout from the stock 60 s.
#
# PyBitmessage kills the daemon outright if the SQL thread is not ready within
# sql_timeout seconds (class_objectProcessor.py -> os._exit(1)). The startup
# VACUUM of a messages.dat that has grown to a few hundred MB does not fit in
# 60 s, and since the process dies mid-VACUUM lastvacuumtime is never updated,
# so every later start retries the same doomed VACUUM and the node never comes
# back. Measured: 26 s for a 264 MB database on an idle host, and the last
# start that did survive used 36 s of the 60.
#
# The greps are load-bearing: the clone above is unpinned, so if upstream ever
# moves or renames the constant, a silent no-op sed would ship an image that
# looks fixed and is not. Fail the build instead. The .pyc is refreshed because
# at runtime /usr/local is root-owned while the daemon runs as bitmessage, so a
# stale one can only be recompiled to memory on every start.
RUN f=/usr/local/lib/python2.7/dist-packages/pybitmessage/helper_sql.py \
&& grep -q '^sql_timeout = 60$' "$f" \
&& sed -i 's/^sql_timeout = 60$/sql_timeout = 600/' "$f" \
&& grep -q '^sql_timeout = 600$' "$f" \
&& rm -f "${f}c" \
&& python -c "import py_compile; py_compile.compile('$f')"
FROM ubuntu:bionic
# 8442 is the XML-RPC API (keep it on loopback), 8444 the Bitmessage P2P port.
# The daemon listens on both regardless; publishing 8444 is what makes the node
# reachable for inbound peers.
EXPOSE 8442/tcp
EXPOSE 8444/tcp
ENV USER_UID=2000
ENV USER_GID=2000
ENV HOME=/home/bitmessage
ENV BITMESSAGE_HOME=${HOME}
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/run.sh /usr/local/bin/
# Install dependencies. util-linux carries setpriv, which run.sh uses to drop
# to the daemon's user; gosu, which used to do that, is gone because it dropped
# the user and nothing else, and moreutils went with it because 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 util-linux \
&& rm -rf /var/lib/apt/lists/*
# Create a user
RUN addgroup --gid $USER_GID bitmessage ;\
useradd --uid $USER_UID --gid $USER_GID --skel /dev/null --create-home --home-dir $HOME bitmessage
WORKDIR ${HOME}
# Generate default config
RUN su bitmessage -c "pybitmessage -t"
# Prove that the two setpriv lines in run.sh do what the script leans on, in
# the arrangement run.sh actually uses: the supervisor's set on the outside, the
# daemon's on the inside. This base image and the PyBitmessage clone above are
# both unpinned, so an option that quietly changed meaning 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.
#
# The two lists are the ones run.sh carries, and they have to stay in step with
# it. 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 \
&& none="-chown,-dac_override,-fowner,-fsetid,-kill,-setgid,-setuid" \
&& none="$none,-setpcap,-net_bind_service,-net_raw,-sys_chroot" \
&& none="$none,-mknod,-audit_write,-setfcap" \
&& sup="-chown,-dac_override,-fowner,-fsetid,-net_bind_service" \
&& sup="$sup,-net_raw,-sys_chroot,-mknod,-audit_write,-setfcap" \
&& d="$(setpriv --bounding-set="$sup" \
setpriv --reuid=2000 --regid=2000 --clear-groups --bounding-set="$none" \
--no-new-privs \
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="$(setpriv --bounding-set="$sup" grep -E '^CapBnd:' /proc/self/status)" \
&& printf '%s\n' "$s" \
&& printf '%s\n' "$s" | grep -qE '^CapBnd:[[:space:]]+0{13}1e0$'
# 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. setpriv is not among them: it is an ordinary
# binary that root execs. Strip them all, then insist none is left, so a package
# added here later cannot bring one back unnoticed.
RUN find / -xdev -type f -perm /6000 -exec chmod -s {} + \
&& [ -z "$(find / -xdev -type f -perm /6000)" ]
CMD ["sh", "/usr/local/bin/run.sh"]
## Check PyBitmessage active network connections.
## The start period covers the startup VACUUM of messages.dat, which takes tens
## of seconds once the database reaches a few hundred MB; without it the
## container reports unhealthy for that whole window on every restart.
HEALTHCHECK --retries=0 --interval=15s --start-period=180s \
CMD ["python", "/usr/local/bin/healthy_check.py"]