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,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]
|
||||
Reference in New Issue
Block a user