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.
11 KiB
Intro
Русская версия: README.ru-RU.md
PyBitmessage is a client of the Bitmessages P2P communication protocol used to send encrypted messages to another person or to many subscribers.
PyBitmessage client running as a daemon in docker container with XML-RPC API enabled.
This repository covers the docker deployment only.
Usage
The container generates a Bitmessage Deterministic Addresses based on a BITMESSAGE_SEED_PHRASE variable.
Here are some example snippets to help you get started creating a container.
The container has two ports and they are not interchangeable. 8442 is the XML-RPC API: it controls the daemon completely and has no TLS, so set your own credentials and keep it on loopback. 8444 is the Bitmessage P2P port: publish it to let other nodes connect in, leave it unpublished to stay outbound-only. The daemon listens on both inside the container either way.
docker-compose
services:
pybitmessage:
build:
context: https://git.bitdeals.org/private/bitmessage.git
dockerfile: ./docker/Dockerfile
image: registry.bitdeals.org/bitmessage
environment:
- BITMESSAGE_API_USER=CHANGE_ME
- BITMESSAGE_API_PASSWORD=CHANGE_ME
- BITMESSAGE_SEED_PHRASE=bitmessage_seed_phrase
- BITMESSAGE_SEED_ADDRESSES=1
- BITMESSAGE_TTL=172800
- BITMESSAGE_STOPRESENDINGAFTERXDAYS=60
- BITMESSAGE_MAXTOTALCONNECTIONS=40
ports:
- 127.0.0.1:8442:8442 # API — loopback only
- 8444:8444 # P2P — omit this line to stay outbound-only
volumes:
- bitmessage:/home/bitmessage
volumes:
bitmessage:
docker cli
docker run -d \
-e BITMESSAGE_API_USER=CHANGE_ME \
-e BITMESSAGE_API_PASSWORD=CHANGE_ME \
-e BITMESSAGE_SEED_PHRASE=bitmessage_seed_phrase \
-e BITMESSAGE_SEED_ADDRESSES=1 \
-e BITMESSAGE_TTL=172800 \
-e BITMESSAGE_STOPRESENDINGAFTERXDAYS=60 \
-e BITMESSAGE_MAXTOTALCONNECTIONS=40 \
-p 127.0.0.1:8442:8442 \
-p 8444:8444 \
-v bitmessage:/home/bitmessage \
registry.bitdeals.org/bitmessage
Parameters
Container images are configured using parameters passed at runtime.
| Parameter | Function |
|---|---|
| -p 127.0.0.1:8442 | API port. The daemon always binds 0.0.0.0 inside the container, so what you publish decides who reaches it |
| -p 8444 | Bitmessage P2P port. Optional: without it the node still connects out to peers, it just cannot be connected to. Must be published as 8444:8444 — see Notes |
| -v /home/bitmessage | Data directory: keys.dat (identity, settings) and messages.dat. Without it the node is a new node after every update |
| -e BITMESSAGE_API_USER | XML-RPC API user. Default: bitmessage_api_user — change it |
| -e BITMESSAGE_API_PASSWORD | XML-RPC API password. Default: bitmessage_api_password — change it, see Notes |
| -e BITMESSAGE_SEED_PHRASE | Create Deterministic Addresses password. Default: regenerated on every start, giving different addresses each time. Only used when BITMESSAGE_SEED_ADDRESSES is above 0 |
| -e BITMESSAGE_SEED_ADDRESSES | Number of Deterministic Addresses to generate. Default: 0 |
| -e BITMESSAGE_TTL | The expiration of newly send messages, in seconds. Default: 172800 |
| -e BITMESSAGE_STOPRESENDINGAFTERXDAYS | Stop resending unreceived message after X days. Default: 30 |
| -e BITMESSAGE_APIVARIANT | provides xml or json-RPC API. Default: legacy |
| -e BITMESSAGE_MAXTOTALCONNECTIONS | Cap on all connections at once, inbound and outbound together (maxoutboundconnections is 8, so this minus 8 is the inbound headroom). Default: 200, the PyBitmessage stock value — lower it when the P2P port is published |
| -e BITMESSAGE_TRUSTED_PEER | host:port of the one peer this node may connect out to; it dials nothing else. Default: empty — the node chooses its own peers. See Notes |
| -e BITMESSAGE_SEND_OUTGOING | Whether the node dials out at all, True or False. Default: True. False gives a node that only accepts inbound connections — the hub of a private contour |
| -e BITMESSAGE_KNOWN_NODES | Comma-separated host:port list, written into knownnodes.dat on every start in place of whatever was there. Default: empty — the file is left as it is. Also switches the DNS bootstrap off, see Notes |
| -e BITMESSAGE_WATCHDOG | Whether to restart a daemon that has lost every peer, True or False. Default: True. See Notes |
| -e BITMESSAGE_WATCHDOG_PERIOD | Seconds between peer checks. Default: 60 |
| -e BITMESSAGE_WATCHDOG_AFTER | How many peerless checks in a row it takes to act. Default: 5, so five minutes at the default period |
| -e BITMESSAGE_WATCHDOG_COOLDOWN | Floor between two restarts, in seconds. Default: 900. 0 removes it |
Notes
-
%cannot be used in the API password: it breaks PyBitmessage's own config reader, and every API call then returns500whilekeys.datlooks correct. Other characters are fine, the container escapes them. -
Clients must percent-encode the credentials — they go into
http://user:password@host:port/, where@,#,/and:change how the URL parses. The scripts in this image do; yours has to as well. -
The container turns healthy once the daemon has a network connection, which on a new node takes a few minutes. The start period also covers the startup
VACUUMofmessages.dat. -
A peerless daemon is restarted; a silent one is not. A daemon that has lost every peer does not find its way back — it can sit at zero connections for as long as you leave it — while one that has just started dials hard and does. So the container supervises its own daemon: it asks the API for the connection count every
BITMESSAGE_WATCHDOG_PERIOD, and afterBITMESSAGE_WATCHDOG_AFTERanswers of zero in a row it stops the daemon through its own API and starts it again. The container is not restarted and nothing outside it is involved, so a node that depends on this one keeps its addresses and only sees the API blink.A daemon whose API does not answer at all is left alone, deliberately. That is the node trapped retrying a startup
VACUUMit cannot finish, restarting does not cure it, and restarting anyway cuts the nextVACUUMin half too. The healthcheck reports both cases as unhealthy; only one of them is something to do anything about, and a person has to look at the other.Give the container
stop_grace_period: 90s(compose) or--stop-timeout 90(docker run). Closingmessages.datproperly takes longer than Docker's default ten seconds — measured at about thirteen on a small database, and it grows with the file — so without this the daemon is SIGKILLed mid-write, which is the very thing the supervisor is there to prevent. An image cannot set this for itself; only the caller can.BITMESSAGE_WATCHDOG=Falseturns the peer rule off. The supervisor stays either way — it is also what stops the daemon cleanly ondocker stop, and what restarts one that died outright rather than leaving the container up around a corpse. -
The P2P port must be published as
8444:8444. The daemon tells peers the port from its own config (portinkeys.dat), not the port you mapped it to, so8555:8444advertises a port nobody can reach. A different host port needsextportinkeys.dat, which this container does not template. -
Your own address is never configured. The version message carries a hardcoded
127.0.0.1that every peer discards in favour of the IP it sees on the socket, and it takes the port from that same message. So a node with 8444 published is found by the network on its own, as soon as it connects out — there is no host IP or DNS name to set anywhere. The one exception is a Tor hidden service, which needs an explicitonionhostname. -
A private contour needs its peers pinned, and a star to pin them into. PyBitmessage refuses a candidate whose network group (the /16 for IPv4) is already represented among its outbound connections. Every container of one compose project lives in a single /16, so each node keeps exactly one outbound connection, to a peer it picked at random — which as often as not leaves the contour split into components. Give one node
BITMESSAGE_SEND_OUTGOING=Falseso it becomes a hub that only accepts (the check looks at outbound connections only, so inbound are not capped by it), point the rest at it withBITMESSAGE_TRUSTED_PEER=<hub-ip>:8444, and objects travel spoke → hub → spokes. Use IP addresses, not service names: the sybil check parses the host as an IP, and theaddrexchange between nodes carries IPs anyway. -
BITMESSAGE_KNOWN_NODESis what keeps a private contour private. A node whoseknownnodes.datnames a peer outside PyBitmessage's built-in default list stops askingbootstrap8080.bitmessage.orgfor more; without it even a pinned node resolves the public bootstrap host on every start. The file is rewritten on each start, so the variable, not the container's history, is what the node believes on boot. -
Publishing 8444 is a deliberate security trade-off. The daemon runs on Python 2 and already parses untrusted data from its outbound peers, so an open port does not create that exposure — it changes who may connect, when, and how many. The pre-handshake parser becomes reachable by anyone, and the practical risk is resource exhaustion rather than code execution. Keep
BITMESSAGE_MAXTOTALCONNECTIONSlow 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.shneeds root exactly once, at startup:keys.datcan 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 --SETUIDandSETGIDto start the daemon as its own user,KILLfor the fallback stop,SETPCAPto drop the rest -- and every start of the daemon goes throughsetpriv, at uid 2000, with all four capability sets empty andno_new_privson. No file in the image carries a setuid or setgid bit, so a daemon that has been taken over has nothing left to climb.Three things only the caller can set, and all three are worth setting.
cap_drop: ALLwithCHOWN,DAC_OVERRIDE,FOWNER,SETUID,SETGID,KILLandSETPCAPadded back: those seven are what the startup above needs, and the container cannot narrow what the healthcheck anddocker execrun with -- only this can.read_only: true, withtmpfsfor/tmpand/run; the daemon writes only into its own home. Andpids_limit, next to the memory and CPU limits the note above asks for.