#!/usr/bin/env python3 # Webhook receiver for Terminal alert deliveries. # Header: Terminal-Signature: t=,v1=.")>. # Refuse a missing or bad signature, refuse a t more than 300 seconds off, drop a seen id, # and log a sequence gap. handle_event only logs. This file does not place an order or call the API. # The same checks, in the same order, live in ../node/receiver.mjs. # An id counts as seen only after handle_event returned, and the answer is sent after that, so a # handler that fails answers 500 and Terminal sends the event again. import argparse import hashlib import hmac import json import math import os import re import sys import threading import time from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer TOLERANCE_SECONDS = 300 SEEN_CAP = 10000 BODY_LIMIT = 1000000 VERDICTS = { "accepted": 200, "accepted_gap": 200, "dropped_duplicate": 200, "refused_missing_header": 401, "refused_signature": 401, "refused_stale": 401, "refused_body": 400, "handler_failed": 500, } T_PATTERN = re.compile(r"[0-9]{1,15}", re.ASCII) V1_PATTERN = re.compile(r"[0-9a-f]{64}", re.ASCII) def handle_event(event): sequence = "null" if event.get("sequence") is None else event.get("sequence") print(f"event {event.get('id')} sequence {sequence} type {event.get('type')}", file=sys.stderr) def new_state(cap=SEEN_CAP): return {"seen": {}, "last": {}, "cap": cap} def done(verdict): return {"verdict": verdict, "status": VERDICTS[verdict], "event": None, "gap": None} def header_value(headers, name): if isinstance(headers, dict): for key, value in headers.items(): if str(key).lower() == name.lower(): if isinstance(value, list): value = value[0] if value else "" return "" if value is None else str(value) return None value = headers.get(name) return None if value is None else str(value) def signature_fields(header): # t=,v1=. A part named twice is refused, so a second v1 # cannot stand in for the first. fields = {} for part in str(header).split(","): if "=" not in part: return None key, value = part.split("=", 1) key = key.strip() value = value.strip() if not key or key in fields: return None fields[key] = value return fields def digest_hex(secret, raw, t): message = f"{t}.".encode("utf-8") + raw return hmac.new(secret.encode("utf-8"), message, hashlib.sha256).hexdigest() def hex_matches(expected, given): # Terminal sends 64 lowercase hex characters. Anything else is refused. if not isinstance(given, str) or not V1_PATTERN.fullmatch(given): return False return hmac.compare_digest(expected, given) def unix_seconds(value): if not isinstance(value, str) or not T_PATTERN.fullmatch(value): return None return int(value) def sequence_number(value): if isinstance(value, bool) or not isinstance(value, int): return None return value def remember(state, event_id): seen = state["seen"] seen.pop(event_id, None) seen[event_id] = True while len(seen) > state["cap"]: oldest = next(iter(seen)) del seen[oldest] def destination_of(url): path = str(url or "/").split("?", 1)[0] return path or "/" def judge(secret, headers, body, now_ms, state, destination="/"): # Order: header, signature, window, duplicate id, then sequence. judge only # reads the state: commit records the id and the sequence once the event # was handled. header = header_value(headers, "Terminal-Signature") if not header: return done("refused_missing_header") fields = signature_fields(header) t = unix_seconds(fields.get("t")) if fields else None if not fields or not fields.get("t") or not fields.get("v1") or t is None: return done("refused_signature") raw = body.encode("utf-8") if isinstance(body, str) else body expected = digest_hex(secret, raw, t) if not hex_matches(expected, fields.get("v1")): return done("refused_signature") if not math.isfinite(now_ms) or abs(now_ms / 1000 - t) > TOLERANCE_SECONDS: return done("refused_stale") try: event = json.loads(raw.decode("utf-8")) except (UnicodeDecodeError, json.JSONDecodeError): return done("refused_body") if not isinstance(event, dict): return done("refused_body") event_id = event.get("id") if not isinstance(event_id, str) or not event_id or len(event_id) > 200: return done("refused_body") if event_id in state["seen"]: return done("dropped_duplicate") sequence = sequence_number(event.get("sequence")) gap = None if sequence is not None: last = state["last"].get(destination) # A lower number is a late send of an earlier alert, not a new gap. if last is not None and sequence > last + 1: gap = f"sequence gap on {destination}: had {last}, got {sequence}. Will read GET /v1/events to fill it." verdict = "accepted_gap" if gap else "accepted" return {"verdict": verdict, "status": 200, "event": event, "gap": gap, "sequence": sequence, "destination": destination} def commit(state, result): # Records a handled event: its id as seen, and the highest sequence of its path. remember(state, result["event"]["id"]) sequence = result["sequence"] if sequence is not None: last = state["last"].get(result["destination"]) if last is None or sequence > last: state["last"][result["destination"]] = sequence def receive(secret, headers, body, now_ms, state, destination="/"): # judge, then handle_event, then commit. A handler that raises leaves the # id unseen and answers 500, so Terminal's retry brings the event back. result = judge(secret, headers, body, now_ms, state, destination) if result["event"] is None: return result if result["gap"]: print(result["gap"], file=sys.stderr) try: handle_event(result["event"]) except Exception: print(f"handle_event failed for {result['event']['id']}. Answered 500, so Terminal sends it again.", file=sys.stderr) return done("handler_failed") commit(state, result) return result class Handler(BaseHTTPRequestHandler): protocol_version = "HTTP/1.1" # A connection that sends nothing is closed after 10 seconds. Terminal # waits 8 seconds for an answer. timeout = 10 secret = "" state = None # One delivery at a time through judge, handle_event and commit, so two # copies of one id cannot both be handled. lock = threading.Lock() def do_POST(self): raw_length = self.headers.get("Content-Length") if raw_length is None: self.reply(400, "refused_body") return try: length = int(raw_length) except ValueError: self.reply(400, "refused_body") return if length < 0 or length > BODY_LIMIT: self.reply(400, "refused_body") return body = self.rfile.read(length) if len(body) != length: self.reply(400, "refused_body") return # Terminal sends one signature header. A second one is refused. if len(self.headers.get_all("Terminal-Signature") or []) > 1: self.reply(401, "refused_signature") return with self.lock: result = receive(self.secret, self.headers, body, time.time() * 1000, self.state, destination_of(self.path)) self.reply(result["status"], result["verdict"]) def reply(self, status, verdict): data = verdict.encode("utf-8") self.send_response(status) self.send_header("Content-Type", "text/plain; charset=utf-8") self.send_header("Content-Length", str(len(data))) self.end_headers() if self.command != "HEAD": self.wfile.write(data) def reject_method(self): self.reply(405, "method_not_allowed") def __getattr__(self, name): # Every method but POST, including ones http.server does not know, is 405. if name.startswith("do_"): return self.reject_method raise AttributeError(name) def log_message(self, fmt, *args): return def dry_run(path, secret): state = new_state() try: handle = open(path, encoding="utf-8") except OSError as error: print(error, file=sys.stderr) return 1 with handle: for raw in handle: line = raw.strip() if not line: continue try: record = json.loads(line) except json.JSONDecodeError: print("refused_body") continue if not isinstance(record, dict): print("refused_body") continue body = record.get("body") headers = record.get("headers") if not isinstance(body, str) or not isinstance(headers, dict): print("refused_body") continue now_value = record.get("now") try: now_ms = time.time() * 1000 if now_value is None else float(now_value) * 1000 except (TypeError, ValueError): now_ms = float("nan") path_key = record.get("path") destination = path_key if isinstance(path_key, str) and path_key else "/" result = receive(secret, headers, body.encode("utf-8"), now_ms, state, destination) print(result["verdict"]) return 0 def main(argv): parser = argparse.ArgumentParser(description="Receive Terminal alert webhooks.") parser.add_argument("--dry-run", metavar="FILE", help="Replay a file of { headers, body } lines and print a verdict for each") args = parser.parse_args(argv) secret = os.environ.get("TERMINAL_WEBHOOK_SECRET", "") if not secret: print("Set TERMINAL_WEBHOOK_SECRET to the signing secret from the webhook destination.", file=sys.stderr) return 1 if args.dry_run: return dry_run(args.dry_run, secret) host = os.environ.get("HOST", "127.0.0.1") try: port = int(os.environ.get("PORT", "8080")) except ValueError: print("PORT must be a whole number from 0 to 65535.", file=sys.stderr) return 1 if port < 0 or port > 65535: print("PORT must be a whole number from 0 to 65535.", file=sys.stderr) return 1 Handler.secret = secret Handler.state = new_state() server = ThreadingHTTPServer((host, port), Handler) server.daemon_threads = True print(f"listening on http://{host}:{server.server_address[1]}/", file=sys.stderr, flush=True) try: server.serve_forever() except KeyboardInterrupt: return 0 return 0 if __name__ == "__main__": sys.exit(main(sys.argv[1:]))