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}