The estate's one channel exit

Services that need to reach a person hand the message here instead of each
carrying a SendGrid key. Email goes to SendGrid, SMS to Twilio; whatsapp and
push answer 501 rather than pretending.

It binds loopback and the docker bridge only. 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.

The log names a channel, a redacted address and the provider's answer. Never
the subject, never the body.
This commit is contained in:
nirpa
2026-08-18 15:41:28 -04:00
commit e4d6230dd1
17 changed files with 913 additions and 0 deletions
+39
View File
@@ -0,0 +1,39 @@
# How this runs on the host. There is one service and no database: the relay
# 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:
#
# 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.
#
# 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.
services:
channel-exit:
image: ${CHANNEL_EXIT_IMAGE:-channel-exit:local}
build: .
restart: unless-stopped
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"
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
timeout: 5s
start_period: 10s
retries: 3
logging:
# Bounded, because this log names who was messaged and when. It should
# not accumulate on disk indefinitely.
driver: json-file
options:
max-size: "10m"
max-file: "3"