diff --git a/README.md b/README.md index 99cb016..a3de94a 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,9 @@ Container images are configured using parameters passed at runtime. |-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| # Notes @@ -103,6 +106,23 @@ Container images are configured using parameters passed at runtime. 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 explicit `onionhostname`. +- **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=False` + so 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 with + `BITMESSAGE_TRUSTED_PEER=:8444`, and objects travel spoke → hub → + spokes. Use IP addresses, not service names: the sybil check parses the host + as an IP, and the `addr` exchange between nodes carries IPs anyway. +- **`BITMESSAGE_KNOWN_NODES` is what keeps a private contour private.** A node + whose `knownnodes.dat` names a peer outside PyBitmessage's built-in default + list stops asking `bootstrap8080.bitmessage.org` for 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 diff --git a/README.ru-RU.md b/README.ru-RU.md index 91a1c26..b88ddf6 100644 --- a/README.ru-RU.md +++ b/README.ru-RU.md @@ -81,6 +81,9 @@ docker run -d \ |-e BITMESSAGE_STOPRESENDINGAFTERXDAYS|Прекратить повторную отправку недоставленного сообщения через X дней. По умолчанию: `30`| |-e BITMESSAGE_APIVARIANT|Предоставляемый API: xml или json-RPC. По умолчанию: `legacy`| |-e BITMESSAGE_MAXTOTALCONNECTIONS|Предел одновременных соединений, входящих и исходящих вместе (`maxoutboundconnections` равен 8, то есть это значение минус 8 — запас на входящие). По умолчанию: `200`, штатное значение PyBitmessage — снижайте, если P2P-порт опубликован| +|-e BITMESSAGE_TRUSTED_PEER|`host:port` единственного пира, к которому узел подключается наружу; больше ни к кому. По умолчанию: пусто — узел выбирает пиров сам. См. «Замечания»| +|-e BITMESSAGE_SEND_OUTGOING|Подключается ли узел наружу вообще: `True` или `False`. По умолчанию: `True`. При `False` получается узел, который только принимает входящие, — центр приватного контура| +|-e BITMESSAGE_KNOWN_NODES|Список `host:port` через запятую; записывается в `knownnodes.dat` при каждом старте вместо того, что там было. По умолчанию: пусто — файл не трогается. Заодно выключает бутстрап по DNS, см. «Замечания»| # Замечания @@ -104,6 +107,23 @@ docker run -d \ опубликованным 8444 сеть находит сама, как только он подключится наружу: ни IP, ни DNS-имя хоста задавать негде. Единственное исключение — скрытый сервис Tor, которому нужен явный `onionhostname`. +- **Приватному контуру нужны назначенные пиры и звезда, в которую их назначать.** + PyBitmessage отвергает кандидата, чья сетевая группа (для IPv4 — /16) уже + представлена среди его исходящих соединений. Все контейнеры одного + compose-проекта живут в одной /16, поэтому каждый узел удерживает ровно одно + исходящее соединение — со случайно выбранным пиром, и контур чаще всего + распадается на компоненты. Задайте одному узлу + `BITMESSAGE_SEND_OUTGOING=False`, и он станет хабом, который только принимает + (проверка смотрит лишь на исходящие, входящие она не ограничивает), остальные + направьте на него через `BITMESSAGE_TRUSTED_PEER=:8444` — объекты + пойдут спица → хаб → спицы. Адреса задавайте IP, а не именами сервисов: + проверка разбирает хост как IP, да и обмен `addr` между узлами оперирует IP. +- **`BITMESSAGE_KNOWN_NODES` — то, что делает приватный контур приватным.** Узел, + у которого в `knownnodes.dat` есть пир не из встроенного списка PyBitmessage, + перестаёт спрашивать адреса у `bootstrap8080.bitmessage.org`; без этого даже + узел с назначенным пиром при каждом старте резолвит публичный бутстрап-хост. + Файл переписывается на каждом старте, поэтому во что узел верит при загрузке, + определяет переменная, а не история контейнера. - **Публикация 8444 — осознанный компромисс по безопасности.** Демон работает на Python 2 и уже разбирает недоверенные данные от своих исходящих пиров, так что открытый порт эту поверхность не создаёт — он меняет то, *кто* может diff --git a/docker/run.sh b/docker/run.sh index 7dacc0e..3876e02 100644 --- a/docker/run.sh +++ b/docker/run.sh @@ -10,6 +10,9 @@ 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:-}" # 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 @@ -22,6 +25,40 @@ case "$BITMESSAGE_MAXTOTALCONNECTIONS" in ;; 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 + +# 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)" @@ -56,6 +93,18 @@ then gosu 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 + gosu 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 @@ -71,8 +120,47 @@ gosu bitmessage sed -i \ -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 + # generate address from seed if [ "$BITMESSAGE_SEED_ADDRESSES" -gt 0 ] then