signal-cli 0.14.7 parses an all-digit -a value as a phone number (the numeric account name never resolves), and the message is a -m option, not a positional (a positional is parsed as a second recipient). Verified live: corrected invocation returns type SUCCESS.
176 lines
6.3 KiB
Python
Executable File
176 lines
6.3 KiB
Python
Executable File
#!/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"
|
|
|
|
# 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()
|