CI / test (push) Successful in 16s
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) <[email protected]>
77 lines
2.7 KiB
Markdown
77 lines
2.7 KiB
Markdown
# channel-exit
|
|
|
|
The estate's one way out to a person's inbox or phone.
|
|
|
|
Services that need to reach a human POST here instead of carrying a SendGrid
|
|
key of their own. Credentials live in one place, one service can be turned off
|
|
or rate-limited, and a bug in some worker cannot become a bill.
|
|
|
|
## The contract
|
|
|
|
```
|
|
POST /send
|
|
Authorization: Bearer <CHANNEL_EXIT_TOKEN>
|
|
Content-Type: application/json
|
|
|
|
{"channel": "email|sms|whatsapp|push", "to": "...", "subject": "...", "body": "..."}
|
|
```
|
|
|
|
| Reply | When |
|
|
| --- | --- |
|
|
| `200` | the provider accepted it — `{"ok": true, "provider_status": 202, "id": "..."}` |
|
|
| `400` | unknown channel, or `to` is not an email / not E.164, or nothing to send |
|
|
| `401` | missing or wrong bearer token |
|
|
| `501` | `whatsapp` and `push` — no provider for them in this estate |
|
|
| `502` | the provider refused or was unreachable; its status is in the body |
|
|
| `503` | that channel's credentials are not configured here |
|
|
|
|
`GET /healthz` needs no token and reports which channels are wired.
|
|
|
|
The shape is fixed by its first consumer, `reminders-service`
|
|
(`app/alerts.py: gateway_sender`), which treats any 2xx as delivered.
|
|
|
|
## Two rules
|
|
|
|
**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, 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
|
|
a body, never a credential.
|
|
|
|
## Consumers
|
|
|
|
From the host: `http://127.0.0.1:<CHANNEL_EXIT_PORT>/send`
|
|
|
|
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
|
|
|
|
```sh
|
|
cp .env.example .env && chmod 600 .env # fill in, then
|
|
docker compose up -d --build
|
|
```
|
|
|
|
Every value in `.env` is required except `PROVIDER_TIMEOUT`. `CHANNEL_EXIT_TOKEN`
|
|
has no default: the service refuses to start without one rather than coming up
|
|
as an unauthenticated relay.
|
|
|
|
## Tests
|
|
|
|
```sh
|
|
pip install -r requirements-dev.txt && python -m pytest -q
|
|
```
|
|
|
|
The suite replaces `urllib.request.urlopen` with something that raises, so no
|
|
test can send a real message however it is written.
|