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:
@@ -0,0 +1,7 @@
|
||||
.env
|
||||
.git
|
||||
.venv
|
||||
tests
|
||||
__pycache__
|
||||
.pytest_cache
|
||||
README.md
|
||||
@@ -0,0 +1,19 @@
|
||||
# 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.
|
||||
CHANNEL_EXIT_PORT=
|
||||
|
||||
# The shared secret every consumer presents as `Authorization: Bearer ...`.
|
||||
# Generate with: openssl rand -hex 32
|
||||
CHANNEL_EXIT_TOKEN=
|
||||
|
||||
# SendGrid. The key needs mail.send and nothing more.
|
||||
SENDGRID_API_KEY=
|
||||
# Must be a verified sender on that SendGrid account or every send 403s.
|
||||
EMAIL_FROM=
|
||||
|
||||
# Twilio.
|
||||
TWILIO_ACCOUNT_SID=
|
||||
TWILIO_AUTH_TOKEN=
|
||||
TWILIO_FROM=
|
||||
@@ -0,0 +1,28 @@
|
||||
# The delivery gate. Nothing reaches main that has not passed it.
|
||||
name: CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest # label maps to act-22.04 on this estate's runner
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Install
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements-dev.txt
|
||||
|
||||
- name: Test
|
||||
run: python -m pytest -q
|
||||
|
||||
- name: The image has to build too
|
||||
run: docker build -t channel-exit:ci .
|
||||
@@ -0,0 +1,5 @@
|
||||
.env
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.pytest_cache/
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
# The provider calls are stdlib urllib, so the image carries nothing beyond the
|
||||
# web server itself. A service that holds the estate's SendGrid and Twilio
|
||||
# credentials is the last place to want a wide dependency tree.
|
||||
|
||||
FROM python:3.11-slim
|
||||
|
||||
ENV PYTHONUNBUFFERED=1 \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
PIP_NO_CACHE_DIR=1
|
||||
|
||||
WORKDIR /srv
|
||||
|
||||
COPY requirements.txt ./
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY app ./app
|
||||
|
||||
# Nothing here needs root, and this process can send mail as the whole estate.
|
||||
RUN useradd --system --uid 10002 --home /srv channelexit \
|
||||
&& chown -R channelexit:channelexit /srv
|
||||
USER channelexit
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
HEALTHCHECK --interval=15s --timeout=5s --start-period=10s --retries=3 \
|
||||
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)"
|
||||
|
||||
# 0.0.0.0 inside the container only. What keeps this off the internet is the
|
||||
# published bind address in the compose file, not this line.
|
||||
CMD ["uvicorn", "--factory", "app.main:asgi", "--host", "0.0.0.0", "--port", "8080"]
|
||||
@@ -0,0 +1,70 @@
|
||||
# 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 listens on loopback and the docker bridge, and nowhere 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.
|
||||
|
||||
**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://172.17.0.1:<CHANNEL_EXIT_PORT>/send` — a
|
||||
consumer's own `127.0.0.1` is that consumer, so a loopback gateway URL fails
|
||||
every delivery while looking correctly configured.
|
||||
|
||||
## 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.
|
||||
@@ -0,0 +1,51 @@
|
||||
"""What the relay needs to know, and what it refuses to start without.
|
||||
|
||||
The bearer token is the only thing standing between this service and an open
|
||||
spam relay, so an unset token is a startup failure rather than a service that
|
||||
accepts everything. Provider credentials are the opposite: a missing SendGrid
|
||||
key must not stop SMS working, so each channel is asked at send time whether it
|
||||
is configured and answers for itself.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
|
||||
def _env(name: str, default: str = "") -> str:
|
||||
return (os.environ.get(name, default) or "").strip()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Settings:
|
||||
# The shared secret every caller must present. No default: a relay that
|
||||
# falls back to a well-known token is worse than one that will not boot.
|
||||
token: str = field(default_factory=lambda: _env("CHANNEL_EXIT_TOKEN"))
|
||||
|
||||
sendgrid_key: str = field(default_factory=lambda: _env("SENDGRID_API_KEY"))
|
||||
# SendGrid rejects a From that is not a verified sender, so this is an
|
||||
# operator setting and never anything a caller can influence.
|
||||
email_from: str = field(default_factory=lambda: _env("EMAIL_FROM"))
|
||||
|
||||
twilio_sid: str = field(default_factory=lambda: _env("TWILIO_ACCOUNT_SID"))
|
||||
twilio_token: str = field(default_factory=lambda: _env("TWILIO_AUTH_TOKEN"))
|
||||
twilio_from: str = field(default_factory=lambda: _env("TWILIO_FROM"))
|
||||
|
||||
timeout: float = field(default_factory=lambda: float(_env("PROVIDER_TIMEOUT", "15") or 15))
|
||||
|
||||
@property
|
||||
def email_ready(self) -> bool:
|
||||
return bool(self.sendgrid_key and self.email_from)
|
||||
|
||||
@property
|
||||
def sms_ready(self) -> bool:
|
||||
return bool(self.twilio_sid and self.twilio_token and self.twilio_from)
|
||||
|
||||
def require_token(self) -> None:
|
||||
if not self.token:
|
||||
raise RuntimeError("CHANNEL_EXIT_TOKEN is unset; refusing to run an unauthenticated relay")
|
||||
|
||||
|
||||
def get_settings() -> Settings:
|
||||
return Settings()
|
||||
+176
@@ -0,0 +1,176 @@
|
||||
"""The estate's one way out to a person's inbox or phone.
|
||||
|
||||
Every service that needs to reach a human hands the message here instead of
|
||||
carrying a SendGrid key of its own. That is the whole point: the credentials
|
||||
live in one place, one service can be rate-limited or turned off, and a bug in
|
||||
some reminder worker cannot become a bill.
|
||||
|
||||
Which makes the two rules below non-negotiable.
|
||||
|
||||
1. **It never listens anywhere but loopback.** An authenticated relay reachable
|
||||
from the internet is an open relay the moment the token leaks, and the token
|
||||
travels in plaintext to every consumer. The bind address, not the token, is
|
||||
what keeps this off the public internet — so it is not published through any
|
||||
tunnel, and the compose file binds 127.0.0.1 explicitly.
|
||||
|
||||
2. **It never logs what it was asked to send.** Reminders are private. The log
|
||||
records that a message went to a redacted address on a channel and what the
|
||||
provider said, which is everything needed to debug a delivery and nothing
|
||||
that would put someone's private reminder in a log file.
|
||||
|
||||
The request shape is fixed by its first consumer, reminders-service:
|
||||
|
||||
{"channel": "email|sms|whatsapp|push", "to": ..., "subject": ..., "body": ...}
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app import providers
|
||||
from app.config import Settings, get_settings
|
||||
|
||||
log = logging.getLogger("channel-exit")
|
||||
|
||||
CHANNELS = {"email", "sms", "whatsapp", "push"}
|
||||
|
||||
# Deliberately narrow. This is not RFC 5322 — it is the subset SendGrid will
|
||||
# accept, and rejecting an odd-but-legal address is a better failure than
|
||||
# handing a provider something that makes it 400 the whole request.
|
||||
EMAIL = re.compile(r"^[^@\s,;<>\"]+@[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)+$")
|
||||
|
||||
# E.164: a plus, a non-zero country digit, up to fifteen digits in total.
|
||||
# Anything looser and a typo becomes a message to a stranger.
|
||||
E164 = re.compile(r"^\+[1-9]\d{7,14}$")
|
||||
|
||||
MAX_BODY = 100_000
|
||||
|
||||
|
||||
def redact(channel: str, address: str) -> str:
|
||||
"""Enough of an address to recognise a delivery, not enough to reach it."""
|
||||
if channel == "email" and "@" in address:
|
||||
local, _, domain = address.partition("@")
|
||||
return f"{local[:1]}***@{domain}"
|
||||
if len(address) > 4:
|
||||
return f"***{address[-4:]}"
|
||||
return "***"
|
||||
|
||||
|
||||
def _error(status: int, message: str) -> JSONResponse:
|
||||
return JSONResponse({"ok": False, "error": message}, status_code=status)
|
||||
|
||||
|
||||
def _authorised(request: Request, settings: Settings) -> bool:
|
||||
header = request.headers.get("authorization", "")
|
||||
scheme, _, presented = header.partition(" ")
|
||||
if scheme.lower() != "bearer" or not presented:
|
||||
return False
|
||||
# Constant-time: a plain == leaks the token one character at a time to
|
||||
# anyone who can measure the reply.
|
||||
return hmac.compare_digest(presented.strip(), settings.token)
|
||||
|
||||
|
||||
def create_app(settings: Settings | None = None) -> FastAPI:
|
||||
settings = settings or get_settings()
|
||||
settings.require_token()
|
||||
app = FastAPI(title="channel-exit", docs_url=None, redoc_url=None, openapi_url=None)
|
||||
|
||||
@app.get("/healthz")
|
||||
def healthz() -> dict[str, Any]:
|
||||
# Which channels are wired is operational fact, not a secret, and it is
|
||||
# the first thing anyone debugging a silent delivery wants to know.
|
||||
return {"ok": True, "channels": {"email": settings.email_ready, "sms": settings.sms_ready}}
|
||||
|
||||
@app.post("/send")
|
||||
async def send(request: Request) -> Any:
|
||||
if not _authorised(request, settings):
|
||||
return _error(401, "a bearer token is required")
|
||||
|
||||
raw = await request.body()
|
||||
if len(raw) > MAX_BODY:
|
||||
return _error(413, "message too large")
|
||||
try:
|
||||
payload = json.loads(raw or b"{}")
|
||||
except ValueError:
|
||||
return _error(400, "body must be JSON")
|
||||
if not isinstance(payload, dict):
|
||||
return _error(400, "body must be a JSON object")
|
||||
|
||||
channel = str(payload.get("channel") or "").strip().lower()
|
||||
to = str(payload.get("to") or "").strip()
|
||||
subject = str(payload.get("subject") or "").strip()
|
||||
body = str(payload.get("body") or "")
|
||||
|
||||
if channel not in CHANNELS:
|
||||
return _error(400, f"unknown channel; expected one of {', '.join(sorted(CHANNELS))}")
|
||||
if channel in {"whatsapp", "push"}:
|
||||
# 501 and not 503: these are not misconfigured here, they have no
|
||||
# provider in this estate at all. A consumer should stop asking.
|
||||
return _error(501, f"{channel} is not configured on this gateway; only email and sms are wired")
|
||||
if not to:
|
||||
return _error(400, "'to' is required")
|
||||
if not subject and not body:
|
||||
return _error(400, "nothing to send: 'subject' and 'body' are both empty")
|
||||
|
||||
if channel == "email":
|
||||
if not EMAIL.match(to) or len(to) > 254:
|
||||
return _error(400, "'to' must be an email address for channel 'email'")
|
||||
if not settings.email_ready:
|
||||
return _error(503, "email is not configured on this gateway")
|
||||
outcome = providers.send_email(
|
||||
to=to,
|
||||
subject=subject,
|
||||
body=body,
|
||||
api_key=settings.sendgrid_key,
|
||||
sender=settings.email_from,
|
||||
timeout=settings.timeout,
|
||||
)
|
||||
else:
|
||||
if not E164.match(to):
|
||||
return _error(400, "'to' must be an E.164 phone number (e.g. +15551234567) for channel 'sms'")
|
||||
if not settings.sms_ready:
|
||||
return _error(503, "sms is not configured on this gateway")
|
||||
outcome = providers.send_sms(
|
||||
to=to,
|
||||
subject=subject,
|
||||
body=body,
|
||||
account_sid=settings.twilio_sid,
|
||||
auth_token=settings.twilio_token,
|
||||
sender=settings.twilio_from,
|
||||
timeout=settings.timeout,
|
||||
)
|
||||
|
||||
log.info(
|
||||
"channel=%s to=%s ok=%s provider_status=%s id=%s detail=%s",
|
||||
channel,
|
||||
redact(channel, to),
|
||||
outcome.ok,
|
||||
outcome.status,
|
||||
outcome.message_id or "-",
|
||||
outcome.detail if not outcome.ok else "-",
|
||||
)
|
||||
|
||||
if not outcome.ok:
|
||||
# 502, because the failure is upstream: the caller's request was
|
||||
# fine and retrying it here would be pointless. The provider's
|
||||
# status travels in the body so the caller can tell a bad address
|
||||
# from an outage without reading our logs.
|
||||
return JSONResponse(
|
||||
{"ok": False, "provider_status": outcome.status, "error": outcome.detail},
|
||||
status_code=502,
|
||||
)
|
||||
return {"ok": True, "channel": channel, "provider_status": outcome.status, "id": outcome.message_id}
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def asgi() -> FastAPI:
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s")
|
||||
return create_app()
|
||||
@@ -0,0 +1,153 @@
|
||||
"""Talking to SendGrid and Twilio.
|
||||
|
||||
Kept behind one tiny HTTP seam (`post`) for two reasons. It is the only thing
|
||||
tests need to replace, so a test can prove the right thing was sent without a
|
||||
network or a live credential — and nothing in this estate should be able to
|
||||
send a real message because someone ran the suite.
|
||||
|
||||
Nothing here raises for a provider refusal. A refusal is data: the caller has
|
||||
to log it and tell its consumer, and an exception carrying a provider payload
|
||||
is exactly the kind of thing that ends up in a log with a key in it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable
|
||||
|
||||
SENDGRID_URL = "https://api.sendgrid.com/v3/mail/send"
|
||||
TWILIO_URL = "https://api.twilio.com/2010-04-01/Accounts/{sid}/Messages.json"
|
||||
|
||||
# An SMS is billed per 160-character segment, so a caller with a runaway body
|
||||
# is a bill, not just a long message. Truncating is the kinder failure: the
|
||||
# person still gets the reminder, and nobody pays for a novel.
|
||||
SMS_LIMIT = 1200
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Response:
|
||||
status: int
|
||||
body: str
|
||||
headers: dict[str, str]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Outcome:
|
||||
"""What happened, in terms safe to log."""
|
||||
|
||||
ok: bool
|
||||
status: int
|
||||
detail: str
|
||||
message_id: str = ""
|
||||
|
||||
|
||||
Post = Callable[..., Response]
|
||||
|
||||
|
||||
def post(url: str, *, data: bytes, headers: dict[str, str], timeout: float) -> Response:
|
||||
request = urllib.request.Request(url, method="POST", data=data, headers=headers)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=timeout) as reply:
|
||||
return Response(reply.status, reply.read().decode("utf-8", "replace"), dict(reply.headers))
|
||||
except urllib.error.HTTPError as error:
|
||||
# A 4xx from a provider is an answer, not a crash: it carries the
|
||||
# reason the message was rejected, which is what we want to report.
|
||||
return Response(error.code, error.read().decode("utf-8", "replace"), dict(error.headers or {}))
|
||||
|
||||
|
||||
def send_email(
|
||||
*,
|
||||
to: str,
|
||||
subject: str,
|
||||
body: str,
|
||||
api_key: str,
|
||||
sender: str,
|
||||
timeout: float,
|
||||
post: Post = post,
|
||||
) -> Outcome:
|
||||
payload = {
|
||||
"personalizations": [{"to": [{"email": to}]}],
|
||||
"from": {"email": sender},
|
||||
# SendGrid rejects an empty subject outright. A reminder with no
|
||||
# subject is still worth delivering, so it gets a neutral one rather
|
||||
# than a 400 the caller cannot act on.
|
||||
"subject": subject or "Notification",
|
||||
"content": [{"type": "text/plain", "value": body}],
|
||||
}
|
||||
reply = post(
|
||||
SENDGRID_URL,
|
||||
data=json.dumps(payload).encode(),
|
||||
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
|
||||
timeout=timeout,
|
||||
)
|
||||
ok = 200 <= reply.status < 300
|
||||
return Outcome(
|
||||
ok=ok,
|
||||
status=reply.status,
|
||||
detail="accepted" if ok else _reason(reply.body),
|
||||
message_id=reply.headers.get("X-Message-Id", ""),
|
||||
)
|
||||
|
||||
|
||||
def send_sms(
|
||||
*,
|
||||
to: str,
|
||||
subject: str,
|
||||
body: str,
|
||||
account_sid: str,
|
||||
auth_token: str,
|
||||
sender: str,
|
||||
timeout: float,
|
||||
post: Post = post,
|
||||
) -> Outcome:
|
||||
# One channel contract, two shapes of message: callers compose a subject
|
||||
# and a body because email has both. An SMS has neither, so they are joined
|
||||
# rather than one of them being silently dropped.
|
||||
text = f"{subject}\n\n{body}" if subject and body else (subject or body)
|
||||
text = text[:SMS_LIMIT]
|
||||
|
||||
reply = post(
|
||||
TWILIO_URL.format(sid=urllib.parse.quote(account_sid, safe="")),
|
||||
data=urllib.parse.urlencode({"To": to, "From": sender, "Body": text}).encode(),
|
||||
headers={
|
||||
"Authorization": "Basic " + base64.b64encode(f"{account_sid}:{auth_token}".encode()).decode(),
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
timeout=timeout,
|
||||
)
|
||||
ok = 200 <= reply.status < 300
|
||||
message_id = ""
|
||||
if ok:
|
||||
try:
|
||||
message_id = json.loads(reply.body).get("sid", "") or ""
|
||||
except (ValueError, AttributeError):
|
||||
pass
|
||||
return Outcome(ok=ok, status=reply.status, detail="accepted" if ok else _reason(reply.body), message_id=message_id)
|
||||
|
||||
|
||||
def _reason(body: str) -> str:
|
||||
"""The provider's own words, trimmed — never the body we tried to send.
|
||||
|
||||
Both providers answer a rejection in JSON, and both put something usable in
|
||||
`message`. Anything else is truncated rather than logged whole, because an
|
||||
error body is the one place a provider might echo back what we sent it.
|
||||
"""
|
||||
try:
|
||||
parsed = json.loads(body)
|
||||
except ValueError:
|
||||
return body.strip()[:200]
|
||||
if isinstance(parsed, dict):
|
||||
if parsed.get("message"):
|
||||
code = parsed.get("code")
|
||||
return f"{parsed['message']}" + (f" ({code})" if code else "")
|
||||
errors = parsed.get("errors")
|
||||
if isinstance(errors, list) and errors:
|
||||
first = errors[0]
|
||||
if isinstance(first, dict) and first.get("message"):
|
||||
return str(first["message"])[:200]
|
||||
return str(parsed)[:200]
|
||||
@@ -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"
|
||||
@@ -0,0 +1,3 @@
|
||||
-r requirements.txt
|
||||
pytest>=8.0
|
||||
httpx>=0.27
|
||||
@@ -0,0 +1,2 @@
|
||||
fastapi>=0.115
|
||||
uvicorn[standard]>=0.30
|
||||
@@ -0,0 +1,50 @@
|
||||
"""Shared fixtures, and one guarantee.
|
||||
|
||||
The autouse fixture below is the important part: a suite for a service whose
|
||||
entire job is sending messages to real people must not be able to send one.
|
||||
Anything that reaches for a socket fails loudly instead of quietly costing
|
||||
money or waking someone up.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import urllib.request
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.config import Settings
|
||||
from app.main import create_app
|
||||
|
||||
TOKEN = "test-token"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def no_network(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def refuse(*args, **kwargs):
|
||||
raise AssertionError("a test tried to reach the network")
|
||||
|
||||
monkeypatch.setattr(urllib.request, "urlopen", refuse)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def settings() -> Settings:
|
||||
return Settings(
|
||||
token=TOKEN,
|
||||
sendgrid_key="sg-key",
|
||||
email_from="[email protected]",
|
||||
twilio_sid="AC123",
|
||||
twilio_token="twilio-secret",
|
||||
twilio_from="+15550001111",
|
||||
timeout=1.0,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(settings: Settings) -> TestClient:
|
||||
return TestClient(create_app(settings))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def auth() -> dict[str, str]:
|
||||
return {"Authorization": f"Bearer {TOKEN}"}
|
||||
@@ -0,0 +1,118 @@
|
||||
"""What actually goes on the wire to SendGrid and Twilio.
|
||||
|
||||
Asserted at the seam rather than mocked at the module boundary, because the
|
||||
shape of these two requests is the part a provider silently rejects: a From
|
||||
that is not the verified sender, a field Twilio spells `To` and not `to`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import urllib.parse
|
||||
|
||||
from app import providers
|
||||
|
||||
|
||||
def recorder(status: int, body: str = "{}", reply_headers: dict | None = None):
|
||||
calls: list[dict] = []
|
||||
|
||||
def fake_post(url: str, *, data: bytes, headers: dict, timeout: float):
|
||||
calls.append({"url": url, "data": data, "headers": headers, "timeout": timeout})
|
||||
return providers.Response(status, body, reply_headers or {})
|
||||
|
||||
fake_post.calls = calls # type: ignore[attr-defined]
|
||||
return fake_post
|
||||
|
||||
|
||||
def test_email_is_addressed_from_the_verified_sender() -> None:
|
||||
post = recorder(202, "", {"X-Message-Id": "abc"})
|
||||
outcome = providers.send_email(
|
||||
to="[email protected]",
|
||||
subject="Due",
|
||||
body="Bins",
|
||||
api_key="sg-key",
|
||||
sender="[email protected]",
|
||||
timeout=5,
|
||||
post=post,
|
||||
)
|
||||
sent = json.loads(post.calls[0]["data"])
|
||||
assert post.calls[0]["url"] == providers.SENDGRID_URL
|
||||
assert sent["from"]["email"] == "[email protected]"
|
||||
assert sent["personalizations"][0]["to"] == [{"email": "[email protected]"}]
|
||||
assert sent["content"][0]["type"] == "text/plain"
|
||||
assert post.calls[0]["headers"]["Authorization"] == "Bearer sg-key"
|
||||
assert outcome.ok and outcome.status == 202 and outcome.message_id == "abc"
|
||||
|
||||
|
||||
def test_an_email_with_no_subject_still_sends() -> None:
|
||||
post = recorder(202)
|
||||
providers.send_email(
|
||||
to="[email protected]", subject="", body="Bins", api_key="k", sender="[email protected]", timeout=5, post=post
|
||||
)
|
||||
assert json.loads(post.calls[0]["data"])["subject"] == "Notification"
|
||||
|
||||
|
||||
def test_sendgrid_refusal_is_reported_not_raised() -> None:
|
||||
post = recorder(403, json.dumps({"errors": [{"message": "The from address does not match a verified Sender"}]}))
|
||||
outcome = providers.send_email(
|
||||
to="[email protected]", subject="s", body="b", api_key="k", sender="[email protected]", timeout=5, post=post
|
||||
)
|
||||
assert not outcome.ok
|
||||
assert outcome.status == 403
|
||||
assert "verified Sender" in outcome.detail
|
||||
|
||||
|
||||
def test_sms_is_form_encoded_with_twilio_field_names() -> None:
|
||||
post = recorder(201, json.dumps({"sid": "SM9"}))
|
||||
outcome = providers.send_sms(
|
||||
to="+15551234567",
|
||||
subject="Due",
|
||||
body="Bins",
|
||||
account_sid="AC123",
|
||||
auth_token="secret",
|
||||
sender="+15550001111",
|
||||
timeout=5,
|
||||
post=post,
|
||||
)
|
||||
fields = dict(urllib.parse.parse_qsl(post.calls[0]["data"].decode()))
|
||||
assert post.calls[0]["url"].endswith("/Accounts/AC123/Messages.json")
|
||||
assert fields == {"To": "+15551234567", "From": "+15550001111", "Body": "Due\n\nBins"}
|
||||
expected = "Basic " + base64.b64encode(b"AC123:secret").decode()
|
||||
assert post.calls[0]["headers"]["Authorization"] == expected
|
||||
assert outcome.ok and outcome.message_id == "SM9"
|
||||
|
||||
|
||||
def test_a_runaway_body_is_truncated_rather_than_billed_by_the_segment() -> None:
|
||||
post = recorder(201, json.dumps({"sid": "SM9"}))
|
||||
providers.send_sms(
|
||||
to="+15551234567",
|
||||
subject="",
|
||||
body="x" * 50_000,
|
||||
account_sid="AC1",
|
||||
auth_token="t",
|
||||
sender="+1555",
|
||||
timeout=5,
|
||||
post=post,
|
||||
)
|
||||
fields = dict(urllib.parse.parse_qsl(post.calls[0]["data"].decode()))
|
||||
assert len(fields["Body"]) == providers.SMS_LIMIT
|
||||
|
||||
|
||||
def test_twilio_refusal_carries_its_code() -> None:
|
||||
post = recorder(400, json.dumps({"message": "To and From cannot be the same", "code": 21266}))
|
||||
outcome = providers.send_sms(
|
||||
to="+1555", subject="s", body="b", account_sid="AC1", auth_token="t", sender="+1555", timeout=5, post=post
|
||||
)
|
||||
assert not outcome.ok
|
||||
assert outcome.status == 400
|
||||
assert "21266" in outcome.detail
|
||||
|
||||
|
||||
def test_an_unparseable_error_body_is_truncated_not_dumped() -> None:
|
||||
post = recorder(500, "<html>" + "x" * 5000)
|
||||
outcome = providers.send_email(
|
||||
to="[email protected]", subject="s", body="b", api_key="k", sender="[email protected]", timeout=5, post=post
|
||||
)
|
||||
assert not outcome.ok
|
||||
assert len(outcome.detail) <= 200
|
||||
@@ -0,0 +1,162 @@
|
||||
"""The gate: who may send, on what channel, to what address.
|
||||
|
||||
Every case here is a way this service could become an open relay, a bill, or a
|
||||
message to the wrong person.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app import providers
|
||||
from app.config import Settings
|
||||
from app.main import create_app, redact
|
||||
from tests.conftest import TOKEN
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sent(monkeypatch: pytest.MonkeyPatch) -> list[dict]:
|
||||
"""Record what would have gone to a provider, and send nothing."""
|
||||
calls: list[dict] = []
|
||||
|
||||
def fake_email(**kwargs):
|
||||
calls.append({"provider": "sendgrid", **kwargs})
|
||||
return providers.Outcome(ok=True, status=202, detail="accepted", message_id="msg-1")
|
||||
|
||||
def fake_sms(**kwargs):
|
||||
calls.append({"provider": "twilio", **kwargs})
|
||||
return providers.Outcome(ok=True, status=201, detail="accepted", message_id="SM1")
|
||||
|
||||
monkeypatch.setattr(providers, "send_email", fake_email)
|
||||
monkeypatch.setattr(providers, "send_sms", fake_sms)
|
||||
return calls
|
||||
|
||||
|
||||
def body(**over) -> dict:
|
||||
payload = {"channel": "email", "to": "[email protected]", "subject": "Due", "body": "Take the bins out"}
|
||||
payload.update(over)
|
||||
return payload
|
||||
|
||||
|
||||
def test_healthz_needs_no_token_and_reports_wiring(client: TestClient) -> None:
|
||||
reply = client.get("/healthz")
|
||||
assert reply.status_code == 200
|
||||
assert reply.json()["channels"] == {"email": True, "sms": True}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"headers",
|
||||
[{}, {"Authorization": "Bearer "}, {"Authorization": f"Bearer {TOKEN}x"}, {"Authorization": TOKEN}],
|
||||
ids=["absent", "empty", "wrong", "unprefixed"],
|
||||
)
|
||||
def test_a_send_without_the_right_bearer_is_refused(client: TestClient, sent: list, headers: dict) -> None:
|
||||
reply = client.post("/send", json=body(), headers=headers)
|
||||
assert reply.status_code == 401
|
||||
assert sent == [] # and nothing was handed to a provider
|
||||
|
||||
|
||||
def test_service_refuses_to_start_without_a_token() -> None:
|
||||
with pytest.raises(RuntimeError):
|
||||
create_app(Settings(token=""))
|
||||
|
||||
|
||||
def test_email_goes_to_sendgrid_from_the_operator_sender(client: TestClient, auth: dict, sent: list) -> None:
|
||||
reply = client.post("/send", json=body(), headers=auth)
|
||||
assert reply.status_code == 200
|
||||
assert reply.json() == {"ok": True, "channel": "email", "provider_status": 202, "id": "msg-1"}
|
||||
assert sent[0]["provider"] == "sendgrid"
|
||||
assert sent[0]["to"] == "[email protected]"
|
||||
assert sent[0]["sender"] == "[email protected]"
|
||||
|
||||
|
||||
def test_sms_goes_to_twilio(client: TestClient, auth: dict, sent: list) -> None:
|
||||
reply = client.post("/send", json=body(channel="sms", to="+15551234567"), headers=auth)
|
||||
assert reply.status_code == 200
|
||||
assert sent[0]["provider"] == "twilio"
|
||||
assert sent[0]["sender"] == "+15550001111"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("channel", ["whatsapp", "push"])
|
||||
def test_unwired_channels_say_so_rather_than_pretending(client: TestClient, auth: dict, sent: list, channel: str) -> None:
|
||||
reply = client.post("/send", json=body(channel=channel, to="+15551234567"), headers=auth)
|
||||
assert reply.status_code == 501
|
||||
assert channel in reply.json()["error"]
|
||||
assert sent == []
|
||||
|
||||
|
||||
def test_an_unknown_channel_is_rejected(client: TestClient, auth: dict, sent: list) -> None:
|
||||
reply = client.post("/send", json=body(channel="carrier-pigeon"), headers=auth)
|
||||
assert reply.status_code == 400
|
||||
assert sent == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"address",
|
||||
["+15551234567", "person", "person@", "@example.test", "a [email protected]", "person@example", "[email protected]", ""],
|
||||
)
|
||||
def test_email_channel_rejects_anything_that_is_not_an_email(client: TestClient, auth: dict, sent: list, address: str) -> None:
|
||||
reply = client.post("/send", json=body(to=address), headers=auth)
|
||||
assert reply.status_code == 400
|
||||
assert sent == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"number",
|
||||
["15551234567", "+0155512345", "+1555", "[email protected]", "+1555123456789012", "+1 555 123 4567", ""],
|
||||
)
|
||||
def test_sms_channel_rejects_anything_that_is_not_e164(client: TestClient, auth: dict, sent: list, number: str) -> None:
|
||||
reply = client.post("/send", json=body(channel="sms", to=number), headers=auth)
|
||||
assert reply.status_code == 400
|
||||
assert sent == []
|
||||
|
||||
|
||||
def test_an_empty_message_is_not_worth_sending(client: TestClient, auth: dict, sent: list) -> None:
|
||||
reply = client.post("/send", json=body(subject="", body=""), headers=auth)
|
||||
assert reply.status_code == 400
|
||||
assert sent == []
|
||||
|
||||
|
||||
def test_a_body_that_is_not_json_is_rejected(client: TestClient, auth: dict) -> None:
|
||||
reply = client.post("/send", content=b"not json", headers={**auth, "Content-Type": "application/json"})
|
||||
assert reply.status_code == 400
|
||||
|
||||
|
||||
def test_a_channel_without_credentials_answers_503(auth: dict, sent: list) -> None:
|
||||
client = TestClient(create_app(Settings(token=TOKEN, sendgrid_key="", email_from="")))
|
||||
reply = client.post("/send", json=body(), headers=auth)
|
||||
assert reply.status_code == 503
|
||||
assert sent == []
|
||||
|
||||
|
||||
def test_a_provider_refusal_surfaces_as_502_with_its_status(client: TestClient, auth: dict, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(
|
||||
providers,
|
||||
"send_email",
|
||||
lambda **_: providers.Outcome(ok=False, status=403, detail="sender not verified"),
|
||||
)
|
||||
reply = client.post("/send", json=body(), headers=auth)
|
||||
assert reply.status_code == 502
|
||||
assert reply.json()["provider_status"] == 403
|
||||
|
||||
|
||||
def test_the_message_never_reaches_the_log(client: TestClient, auth: dict, sent: list, caplog) -> None:
|
||||
with caplog.at_level("INFO"):
|
||||
client.post("/send", json=body(subject="Divorce hearing", body="10am, courthouse"), headers=auth)
|
||||
logged = caplog.text
|
||||
assert "Divorce hearing" not in logged
|
||||
assert "courthouse" not in logged
|
||||
assert "[email protected]" not in logged # nor the full address
|
||||
assert "p***@example.test" in logged
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("channel", "address", "expected"),
|
||||
[
|
||||
("email", "[email protected]", "p***@example.test"),
|
||||
("sms", "+15551234567", "***4567"),
|
||||
("sms", "+123", "***"),
|
||||
],
|
||||
)
|
||||
def test_redaction_keeps_enough_to_recognise_a_delivery(channel: str, address: str, expected: str) -> None:
|
||||
assert redact(channel, address) == expected
|
||||
Reference in New Issue
Block a user