railiance-telemetry/scripts/alert_service.py
tegwick e7282e493d Implement authenticated alert receipt acknowledgments and audit delivery
Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a0e6f1-443f-7783-9920-a16b2ffc467f
2026-09-28 11:11:33 +02:00

129 lines
6.1 KiB
Python

#!/usr/bin/env python3
"""Single-process private acknowledgment runtime, packaged by rapp-telemetry."""
import argparse
from http.cookies import SimpleCookie
import json
import os
from pathlib import Path
import secrets
import signal
import threading
import time
from urllib.parse import parse_qs
from alert_ack import Application, Store
from alert_audit import AuditTransport
from alert_identity import Login
from alert_policy import Policy
def credential(path):
# Kubernetes projected credentials use symlinks; mount ownership is the
# package trust boundary. Resolve once per call so rotations are picked up.
with Path(path).open('rb') as source:
raw = source.read(32769)
value = raw.decode().strip()
if not 32 <= len(value) <= 32768 or any(ord(c) < 33 or ord(c) > 126 for c in value):
raise ValueError('invalid credential projection')
return value
class Router:
def __init__(self, config, store, *, login=None, policy=None):
self.config, self.store = config, store
self.login = login or Login(config['issuer'], config['origin'])
self.policy = policy or Policy(config['policy']['origin'],
lambda: credential(config['policy']['token_file']), config['policy']['package'],
config['policy']['version'], config['policy']['digest'])
self.application = Application(store, config['origin'], credential(config['webhook_token_file']),
self.actor, self.policy)
self.last_drain = 0
@staticmethod
def cookie(env, name):
cookies = SimpleCookie()
try:
cookies.load(env.get('HTTP_COOKIE', ''))
return cookies[name].value if name in cookies else ''
except Exception:
return ''
def actor(self, env):
return self.login.session(self.cookie(env, '__Host-rtel-session'))
def __call__(self, env, start):
def reply(status, text='', headers=()):
start(status, [('Content-Type', 'text/plain; charset=utf-8'), ('Cache-Control', 'no-store'),
('Referrer-Policy', 'no-referrer'), ('X-Content-Type-Options', 'nosniff'),
('Content-Security-Policy', "default-src 'none'; frame-ancestors 'none'")] + list(headers))
return [text.encode()]
def cookie(name, value, age):
return ('Set-Cookie', f'{name}={value}; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age={age}')
path, method = env.get('PATH_INFO'), env.get('REQUEST_METHOD')
try:
if method == 'GET' and path in ('/healthz', '/readyz'):
ready = path == '/healthz' or time.time() - self.last_drain < 90 and not self.store.audit_debt()
return reply('200 OK' if ready else '503 Service Unavailable', 'ready' if ready else 'audit delivery pending')
if path == '/ack/auth/callback' and method == 'GET':
params = parse_qs(env.get('QUERY_STRING', ''), strict_parsing=True, max_num_fields=4)
if any(len(v) != 1 for v in params.values()) or not {'state', 'code'} <= set(params):
raise ValueError('invalid callback')
sid, target = self.login.finish(params['state'][0], self.cookie(env, '__Host-rtel-login'), params['code'][0])
return reply('303 See Other', headers=[('Location', target), cookie('__Host-rtel-session', sid, 900),
cookie('__Host-rtel-login', '', 0)])
if path == '/ack/logout' and method == 'POST':
actor = self.actor(env)
form = parse_qs(Application.body(env).decode(), max_num_fields=1, strict_parsing=True)
if (not actor or env.get('HTTP_ORIGIN') != self.config['origin'] or set(form) != {'csrf'}
or len(form['csrf']) != 1 or not secrets.compare_digest(actor.csrf, form['csrf'][0])):
return reply('403 Forbidden', 'Invalid sign-out.')
self.login.logout(self.cookie(env, '__Host-rtel-session'))
return reply('200 OK', 'Signed out.', [cookie('__Host-rtel-session', '', 0)])
if path == '/ack/alerts' and method == 'GET' and self.actor(env) is None:
target = path + '?' + env.get('QUERY_STRING', '')
url, browser = self.login.start(target)
return reply('303 See Other', headers=[('Location', url), cookie('__Host-rtel-login', browser, 300)])
# Refresh the webhook credential for projected rotation; never store it in SQLite.
if path == '/webhook':
self.application.webhook_token = credential(self.config['webhook_token_file'])
return self.application(env, start)
except (ValueError, OSError, KeyError, TypeError):
return reply('503 Service Unavailable', 'Identity or service unavailable. Retry later.')
def main():
from waitress import serve
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--config', required=True, type=Path)
args = parser.parse_args()
os.umask(0o077)
config = json.loads(args.config.read_text())
Path(config['database']).parent.mkdir(mode=0o700, exist_ok=True)
store = Store(config['database'])
router = Router(config, store)
transport = AuditTransport(config['audit']['origin'], lambda: credential(config['audit']['token_file']),
allow_internal_http=True)
stop = threading.Event()
def drain():
while not stop.is_set():
try:
store.drain(transport)
router.last_drain = time.time()
except (OSError, ValueError):
router.last_drain = 0
stop.wait(30)
worker = threading.Thread(target=drain, daemon=True)
worker.start()
def shutdown(*args):
raise SystemExit(0)
signal.signal(signal.SIGTERM, shutdown)
try:
serve(router, host='0.0.0.0', port=8080, threads=4, max_request_body_size=32768,
clear_untrusted_proxy_headers=True)
finally:
stop.set()
worker.join(timeout=6)
if __name__ == '__main__':
main()