#!/usr/bin/env python3 """Alertmanager-to-Signal relay for the homelab (Dankclaw). Listens on 192.168.0.143:18093 (the host's LAN IP) and receives Alertmanager's webhook payload from the container as host.containers.internal:18093 — netavark delivers that address to the host's LAN IP, which is the one bind a bridge container can reach (a loopback bind refuses it, and 169.254.1.2 is not a local address here). The group is formatted into a short message and handed to signal-cli under the existing account on this host. Runs as a systemd user unit (scripts/signal-relay.service); logs go to the journal. The phone number and account name are numbers, not credentials — the credential is the signal-cli account state under ~/.local/share/signal-cli, which never leaves the host. Stdlib only: this box's user units run the system python3. """ import json import shutil import subprocess import sys import time from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer # The bind address is the one detail this design gets wrong twice # (2026-09-17): a loopback bind refuses container traffic ("dial tcp # 169.254.1.2:18093: connection refused"), and 169.254.1.2 itself is not a # local address on this host ("Cannot assign requested address") — netavark # runs the bridges in aardvark-dns' netns, not the host's. Traffic that # arrives as 169.254.1.2 lands on the host's LAN IP, so bind THAT. The # deployer installs an iptables INPUT rule that admits 18093/tcp from the # podman bridge subnet (10.89.0.0/16) only, keeping the unauthenticated # endpoint off the rest of the LAN. LISTEN_ADDR = ("192.168.0.143", 18093) # The account is addressed by phone number: signal-cli 0.14.7's -a flag # parses an all-digit value as a phone number and never falls back to the # accounts.json name (the numeric name 318311 dies with "Invalid account # (phone number)"). The number is a number, not a credential — the # credential is the account state under ~/.local/share/signal-cli. SIGNAL_ACCOUNT = "+12062476336" SIGNAL_TO = "+12062476336" SIGNAL_CLI = "/usr/local/bin/signal-cli" # The relay is a safety net, not the rate limiter: Alertmanager already # repeats on repeat_interval. This suppresses exact duplicates that arrive # inside the window (config reloads, double-fires) so a misconfiguration # cannot fill the phone. DEDUPE_SECONDS = 15 * 60 _last_sent = {} # Cap on the instances listed per group; the count is always stated. MAX_INSTANCES = 3 def log(msg): print(f"signal-relay: {msg}", file=sys.stderr, flush=True) def format_group(alerts): """One message per webhook delivery (Alertmanager groups by alertname).""" if not alerts: return None first = alerts[0] name = first.get("labels", {}).get("alertname", "unknown") state = "firing" if first.get("status") == "firing" else "resolved" severity = first.get("labels", {}).get("severity", "info").upper() lines = [f"[{severity}] {name} ({len(alerts)} {state})"] for a in alerts[:MAX_INSTANCES]: labels = a.get("labels", {}) ident = labels.get("instance") or labels.get("job") or "?" summary = (a.get("annotations") or {}).get("summary") line = f" {ident}" if summary: line += f": {summary}" lines.append(line) if len(alerts) > MAX_INSTANCES: lines.append(f" … and {len(alerts) - MAX_INSTANCES} more") return "\n".join(lines) def dedupe_key(alerts): first = alerts[0] labels = tuple(sorted(first.get("labels", {}).items())) return (first.get("status"),) + labels def send_signal(text): if shutil.which(SIGNAL_CLI) is None: log(f"signal-cli not found at {SIGNAL_CLI}") return False try: proc = subprocess.run( [SIGNAL_CLI, "-a", SIGNAL_ACCOUNT, "-o", "json", "send", SIGNAL_TO, "-m", text], capture_output=True, text=True, timeout=30, ) except subprocess.TimeoutExpired: log("signal-cli timed out after 30s") return False if proc.returncode != 0: log(f"signal-cli failed rc={proc.returncode}: {proc.stderr.strip()[:300]}") return False return True def handle_alertmanager(payload): alerts = payload.get("alerts", []) text = format_group(alerts) if text is None: log("empty alert group, nothing sent") return now = time.monotonic() key = dedupe_key(alerts) # Prune in place: rebinding _last_sent here would make it local to this # function and the read above the assignment raises UnboundLocalError # (measured 2026-09-17: every webhook died on it). stale = [k for k, v in _last_sent.items() if now - v >= DEDUPE_SECONDS] for k in stale: del _last_sent[k] if key in _last_sent: log(f"suppressed duplicate: {key[0]}") return if send_signal(text): _last_sent[key] = now log(f"sent to {SIGNAL_TO}: {text.splitlines()[0]}") else: # Do not mark a failed send: the next repeat should retry. log("send failed; will retry on the next delivery") class Handler(BaseHTTPRequestHandler): def do_GET(self): if self.path == "/health": self.send_response(200) self.end_headers() self.wfile.write(b"ok") else: self.send_response(404) self.end_headers() def do_POST(self): if self.path != "/alertmanager": self.send_response(404) self.end_headers() return length = int(self.headers.get("Content-Length", 0)) try: payload = json.loads(self.rfile.read(length)) except (ValueError, UnicodeDecodeError) as e: log(f"bad payload: {e}") self.send_response(400) self.end_headers() return try: handle_alertmanager(payload) except Exception as e: # a relay bug must not 500 Alertmanager log(f"handler error: {e!r}") self.send_response(200) self.end_headers() self.wfile.write(b"ok") def log_message(self, fmt, *args): # Default access log is per-request noise; the journal keeps the # send/suppress lines, which are the ones that matter. pass def main(): server = ThreadingHTTPServer(LISTEN_ADDR, Handler) log(f"listening on {'.'.join(map(str, LISTEN_ADDR))}, account {SIGNAL_ACCOUNT}") server.serve_forever() if __name__ == "__main__": main()