Implement authenticated alert receipt acknowledgments and audit delivery
Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a0e6f1-443f-7783-9920-a16b2ffc467f
This commit is contained in:
parent
67283b66c2
commit
e7282e493d
25 changed files with 1881 additions and 0 deletions
129
scripts/alert_service.py
Normal file
129
scripts/alert_service.py
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
#!/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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue