thermograph/push.py

130 lines
5.3 KiB
Python
Raw Normal View History

"""Web Push (VAPID) — key management and the low-level send.
This is the transport under the notification engine: notify.py decides *what* to
say and *to whom*; this module signs and delivers it to a browser push service.
Keys are resolved once, in this order (mirrors the self-signed-cert flow in
run.sh generated on first use, reused thereafter):
1. env ``THERMOGRAPH_VAPID_PRIVATE_KEY`` + ``THERMOGRAPH_VAPID_PUBLIC_KEY``,
both base64url-raw (the private key is the 32-byte scalar, the public key the
65-byte uncompressed point the standard VAPID interchange format). Set both
in production so every worker/deploy signs with the same identity.
2. ``data/vapid.json`` a gitignored keypair written on first run.
3. freshly generated, then written to (2).
The private key signs the VAPID JWT; the public key is the ``applicationServerKey``
the browser needs at subscribe time (served via GET /push/vapid-key).
Nothing here touches the database ``send()`` returns a status string and the
caller (which owns the session) prunes rows the push service says are gone.
"""
import base64
import json
import logging
import os
import threading
import audit
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import ec
from pywebpush import WebPushException, webpush
log = logging.getLogger("thermograph.push")
_DATA_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "data"))
_VAPID_PATH = os.environ.get("THERMOGRAPH_VAPID_FILE") or os.path.join(_DATA_DIR, "vapid.json")
# The VAPID "sub" claim — a contact the push service can reach about our traffic.
_CONTACT = os.environ.get("THERMOGRAPH_VAPID_CONTACT", "mailto:admin@thermograph.app")
_lock = threading.Lock()
_keys = None # cached {"private_key": str, "public_key": str} (base64url-raw)
def _b64url(raw: bytes) -> str:
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode()
def _generate() -> dict:
"""A fresh P-256 keypair as base64url-raw: 32-byte private scalar + 65-byte
uncompressed public point (the format pywebpush's from_string and the browser
applicationServerKey both accept)."""
priv = ec.generate_private_key(ec.SECP256R1())
scalar = priv.private_numbers().private_value.to_bytes(32, "big")
raw_pub = priv.public_key().public_bytes(
serialization.Encoding.X962,
serialization.PublicFormat.UncompressedPoint,
)
return {"private_key": _b64url(scalar), "public_key": _b64url(raw_pub)}
def _load() -> dict:
"""Resolve the keypair once (env → file → generate) and cache it."""
global _keys
if _keys is not None:
return _keys
with _lock:
if _keys is not None:
return _keys
env_priv = os.environ.get("THERMOGRAPH_VAPID_PRIVATE_KEY")
env_pub = os.environ.get("THERMOGRAPH_VAPID_PUBLIC_KEY")
if env_priv and env_pub:
_keys = {"private_key": env_priv.strip(), "public_key": env_pub.strip()}
return _keys
try:
with open(_VAPID_PATH, encoding="utf-8") as f:
data = json.load(f)
if data.get("private_key") and data.get("public_key"):
_keys = {"private_key": data["private_key"], "public_key": data["public_key"]}
return _keys
except (OSError, ValueError):
pass
_keys = _generate()
try:
os.makedirs(_DATA_DIR, exist_ok=True)
with open(_VAPID_PATH, "w", encoding="utf-8") as f:
json.dump(_keys, f)
os.chmod(_VAPID_PATH, 0o600)
except OSError:
log.warning("could not persist VAPID keys to %s; using an in-memory pair", _VAPID_PATH)
return _keys
def public_key() -> str:
"""The applicationServerKey (base64url raw point) the browser subscribes with."""
return _load()["public_key"]
def send(subscription_info: dict, payload: dict) -> str:
"""Deliver one push. Returns 'ok', 'gone' (prune the row), or 'error'.
`subscription_info` is the browser shape: {endpoint, keys:{p256dh, auth}}.
Never raises a bad send must not break the caller's transaction.
"""
keys = _load()
try:
webpush(
subscription_info=subscription_info,
data=json.dumps(payload),
vapid_private_key=keys["private_key"],
vapid_claims={"sub": _CONTACT},
ttl=86400,
)
return "ok"
except WebPushException as e:
status = getattr(getattr(e, "response", None), "status_code", None)
if status in (404, 410):
return "gone" # endpoint retired — caller should delete it
# 401/403 = VAPID key mismatch, etc. Record it where it's visible (the errors
# JSONL / dashboard), not just the system journal.
log.warning("web push failed (status=%s): %s", status, e)
audit.log_event("error", {"phase": "push", "status": status,
"endpoint": (subscription_info.get("endpoint") or "")[:120]})
return "error"
except Exception as e: # noqa: BLE001 - network/encoding errors must not propagate
log.warning("web push send raised", exc_info=True)
audit.log_event("error", {"phase": "push", "error": repr(e),
"endpoint": (subscription_info.get("endpoint") or "")[:120]})
return "error"