From 399487862c6a00874b7367c48293aaf757daec5a Mon Sep 17 00:00:00 2001 From: Nirav Patel Date: Tue, 18 Aug 2026 19:25:11 -0400 Subject: [PATCH] Reach consumers by network, not by a second host bind The compose file published a second host port on the docker bridge IP so sibling containers could reach the relay. That is a host-reachable address for a service whose entire safety story is that it has exactly one, on loopback. It joins the consumers' existing docker network instead, and is reached there by name on the container port. Nothing about a consumer's deployment has to change to use it, and the relay gains no address outside that network. Co-Authored-By: Claude Opus 5 (1M context) --- .env.example | 8 +++++++- README.md | 16 +++++++++++----- docker-compose.yml | 41 ++++++++++++++++++++++++++++++----------- 3 files changed, 48 insertions(+), 17 deletions(-) diff --git a/.env.example b/.env.example index 7651ed6..887fad0 100644 --- a/.env.example +++ b/.env.example @@ -1,9 +1,15 @@ # Copy to .env on the host and chmod 600. Values come from the estate's # existing accounts — this service issues no credentials of its own. -# The port this is published on, from the registry at $SHRE_PORTS_PATH. +# The port this is published on — 127.0.0.1 only — from the registry at +# $SHRE_PORTS_PATH. Verify it is free with `ss -ltn` before claiming it. CHANNEL_EXIT_PORT= +# The existing docker network the containerised consumers are on. The relay +# joins it and is reachable there as http://channel-exit:8080 — note the +# container port, not CHANNEL_EXIT_PORT. Must already exist. +CHANNEL_EXIT_NETWORK= + # The shared secret every consumer presents as `Authorization: Bearer ...`. # Generate with: openssl rand -hex 32 CHANNEL_EXIT_TOKEN= diff --git a/README.md b/README.md index d57333f..5833fac 100644 --- a/README.md +++ b/README.md @@ -32,10 +32,11 @@ The shape is fixed by its first consumer, `reminders-service` ## Two rules -**It listens on loopback and the docker bridge, and nowhere else.** An +**It publishes one host port, on `127.0.0.1`, and nothing else.** An authenticated relay reachable from the internet is an open spam relay the moment the token leaks — and that token is copied into every consumer's -environment. It gets no `0.0.0.0` binding and no cloudflared route, ever. +environment. It gets no `0.0.0.0` binding, no bridge-IP binding, and no +cloudflared route, ever. **It does not log what it was asked to send.** The log records channel, a redacted address, the provider's status and message id. Never a subject, never @@ -45,9 +46,14 @@ a body, never a credential. From the host: `http://127.0.0.1:/send` -From another container: `http://172.17.0.1:/send` — a -consumer's own `127.0.0.1` is that consumer, so a loopback gateway URL fails -every delivery while looking correctly configured. +From another container: `http://channel-exit:8080/send`. The relay joins the +docker network its consumers already run on (`CHANNEL_EXIT_NETWORK`) and is +reached there by name, on the **container** port — `CHANNEL_EXIT_PORT` is a +host-side publish and does not exist inside that network. + +A consumer's own `127.0.0.1` is that consumer, so a loopback gateway URL fails +every delivery while looking correctly configured. That is the one mistake to +watch for here. ## Running it diff --git a/docker-compose.yml b/docker-compose.yml index 983dd65..95fa476 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,18 +2,25 @@ # keeps nothing, which is deliberate — an outbox of everyone's reminders is a # liability, and the providers already have delivery logs. # -# The two bind addresses below are the security boundary of this whole service. -# Neither is routable from outside the machine: +# The bind address below is the security boundary of this whole service. The +# only host port it publishes is on 127.0.0.1 — for a human on the host and for +# host-side consumers. It must never be given a 0.0.0.0 binding, a bridge-IP +# binding, or a cloudflared route. An authenticated relay on the public +# internet is an open spam relay the moment the token leaks, and that token is +# copied into every consumer's environment. # -# 127.0.0.1 — for a human on the host, and for the health check. -# 172.17.0.1 — the docker bridge, so sibling containers can reach it by an -# address that exists for them. `127.0.0.1` inside a consumer's -# container is that container, so a consumer configured with a -# loopback gateway URL fails every delivery and looks fine. +# Containerised consumers reach it a different way, and this is the part that +# is easy to get wrong: `127.0.0.1` inside a consumer's container is that +# container, so a consumer pointed at a loopback URL fails every delivery while +# looking correctly configured. Instead this joins the docker network its +# consumers already live on and is reached there by name, on the container +# port: # -# It must never be given a 0.0.0.0 binding or a cloudflared route. An -# authenticated relay on the public internet is an open spam relay the moment -# the token leaks, and that token is copied into every consumer's environment. +# from the host http://127.0.0.1:${CHANNEL_EXIT_PORT}/send +# from a sibling http://channel-exit:8080/send +# +# That is strictly tighter than publishing on the docker bridge IP: it adds no +# host-reachable address at all, and only containers on that network can see it. services: channel-exit: @@ -23,7 +30,9 @@ services: env_file: .env ports: - "127.0.0.1:${CHANNEL_EXIT_PORT:?allocate a port in the 5400-5999 range}:8080" - - "${DOCKER_BRIDGE_IP:-172.17.0.1}:${CHANNEL_EXIT_PORT:?}:8080" + networks: + consumers: + aliases: [channel-exit] healthcheck: test: ["CMD", "python", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8080/healthz', timeout=4).status == 200 else 1)"] interval: 15s @@ -37,3 +46,13 @@ services: options: max-size: "10m" max-file: "3" + +networks: + # Created by whoever owns the consumers, not by this compose file — the relay + # joins their network rather than asking them onto one of its own, so nothing + # about their deployment has to change to start using it. `external` is what + # makes that true: if the network is missing, this fails to start instead of + # silently creating an empty one nobody else is on. + consumers: + external: true + name: ${CHANNEL_EXIT_NETWORK:?the existing docker network the consumers are on}