Files
serverAppConfigs/scripts/signal-relay.py
T
Frank 8f747a2104 relay: bind 169.254.1.2 (host.containers.internal), not loopback
A socket bound to 127.0.0.1 refuses container traffic that arrives via
the podman bridge: the first live webhook failed with 'dial tcp
169.254.1.2:18093: connection refused'. The relay now binds the
host-gateway endpoint — link-local to the podman bridge, off the LAN —
and the deployer's health probe follows it there.
2026-09-17 13:26:24 -07:00

160 lines
5.3 KiB
Python
Executable File

#!/usr/bin/env python3
"""Alertmanager-to-Signal relay for the homelab (Dankclaw).
Listens on 169.254.1.2:18093 — the host side of host.containers.internal,
the only address a bridge container can use to reach a host-local process —
receives Alertmanager's webhook payload, formats
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
# 169.254.1.2 is the host endpoint of host.containers.internal: a socket
# bound to 127.0.0.1 refuses container traffic that arrives via the podman
# bridge (measured 2026-09-17: "dial tcp 169.254.1.2:18093: connection
# refused"). The address is link-local to the podman bridge — not on any
# LAN interface, not on loopback, reachable only from this host's
# containers.
LISTEN_ADDR = ("169.254.1.2", 18093)
SIGNAL_CLI = "/usr/local/bin/signal-cli"
SIGNAL_ACCOUNT = "318311"
SIGNAL_TO = "+12062476336"
# 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, 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)
_last_sent = {k: v for k, v in _last_sent.items() if now - v < DEDUPE_SECONDS}
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()