Auto-refresh Matrix token on 401 instead of silently dropping alerts
This commit is contained in:
+72
-34
@@ -11,6 +11,7 @@ import socket
|
|||||||
import sqlite3
|
import sqlite3
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
import urllib.parse
|
||||||
import urllib.request
|
import urllib.request
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -19,7 +20,10 @@ from pathlib import Path
|
|||||||
MATRIX_HOMESERVER = os.getenv("MATRIX_HOMESERVER", "https://m.example.org")
|
MATRIX_HOMESERVER = os.getenv("MATRIX_HOMESERVER", "https://m.example.org")
|
||||||
MATRIX_ACCESS_TOKEN = os.getenv("MATRIX_ACCESS_TOKEN", "")
|
MATRIX_ACCESS_TOKEN = os.getenv("MATRIX_ACCESS_TOKEN", "")
|
||||||
MATRIX_ROOM_ID = os.getenv("MATRIX_ROOM_ID", "!REDACTED:example.org")
|
MATRIX_ROOM_ID = os.getenv("MATRIX_ROOM_ID", "!REDACTED:example.org")
|
||||||
|
MATRIX_USER = os.getenv("MATRIX_USER", "")
|
||||||
|
MATRIX_PASSWORD = os.getenv("MATRIX_PASSWORD", "")
|
||||||
DB_PATH = Path(os.getenv("NET_ALERTER_DB", "/opt/net_alerter/devices.db"))
|
DB_PATH = Path(os.getenv("NET_ALERTER_DB", "/opt/net_alerter/devices.db"))
|
||||||
|
ENV_PATH = Path(os.getenv("NET_ALERTER_ENV", "/opt/net_alerter/.env"))
|
||||||
|
|
||||||
|
|
||||||
def get_db() -> sqlite3.Connection:
|
def get_db() -> sqlite3.Connection:
|
||||||
@@ -38,8 +42,75 @@ def get_db() -> sqlite3.Connection:
|
|||||||
return conn
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
def refresh_token() -> str:
|
||||||
|
if not MATRIX_USER or not MATRIX_PASSWORD:
|
||||||
|
print("[error] Cannot refresh token: MATRIX_USER/MATRIX_PASSWORD not set", file=sys.stderr)
|
||||||
|
return ""
|
||||||
|
payload = json.dumps({
|
||||||
|
"type": "m.login.password",
|
||||||
|
"identifier": {"type": "m.id.user", "user": MATRIX_USER},
|
||||||
|
"password": MATRIX_PASSWORD,
|
||||||
|
"initial_device_display_name": "net-alerter",
|
||||||
|
}).encode()
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f"{MATRIX_HOMESERVER}/_matrix/client/v3/login",
|
||||||
|
data=payload,
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=10) as r:
|
||||||
|
data = json.loads(r.read())
|
||||||
|
token = data.get("access_token", "")
|
||||||
|
if token and ENV_PATH.exists():
|
||||||
|
lines = ENV_PATH.read_text().splitlines()
|
||||||
|
lines = [l if not l.startswith("MATRIX_ACCESS_TOKEN=") else f"MATRIX_ACCESS_TOKEN={token}" for l in lines]
|
||||||
|
ENV_PATH.write_text("\n".join(lines) + "\n")
|
||||||
|
return token
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[error] Token refresh failed: {e}", file=sys.stderr)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _do_send(token: str, msg: str) -> int:
|
||||||
|
url = (
|
||||||
|
f"{MATRIX_HOMESERVER}/_matrix/client/v3/rooms/"
|
||||||
|
f"{urllib.parse.quote(MATRIX_ROOM_ID, safe='')}/send/m.room.message"
|
||||||
|
)
|
||||||
|
payload = json.dumps({"msgtype": "m.text", "body": msg}).encode()
|
||||||
|
req = urllib.request.Request(
|
||||||
|
url,
|
||||||
|
data=payload,
|
||||||
|
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
|
||||||
|
method="POST",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
urllib.request.urlopen(req, timeout=10)
|
||||||
|
return 200
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
return e.code
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[error] Matrix send failed: {e}", file=sys.stderr)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def send_matrix_alert(msg: str) -> None:
|
||||||
|
global MATRIX_ACCESS_TOKEN
|
||||||
|
if not MATRIX_ACCESS_TOKEN:
|
||||||
|
print(f"[warn] No MATRIX_ACCESS_TOKEN set, skipping alert", file=sys.stderr)
|
||||||
|
return
|
||||||
|
code = _do_send(MATRIX_ACCESS_TOKEN, msg)
|
||||||
|
if code in (401, 403):
|
||||||
|
print("[info] Token expired, refreshing...", file=sys.stderr)
|
||||||
|
new_token = refresh_token()
|
||||||
|
if new_token:
|
||||||
|
MATRIX_ACCESS_TOKEN = new_token
|
||||||
|
_do_send(MATRIX_ACCESS_TOKEN, msg)
|
||||||
|
else:
|
||||||
|
print("[error] Could not refresh token, alert dropped", file=sys.stderr)
|
||||||
|
|
||||||
|
|
||||||
def get_local_subnets() -> list[str]:
|
def get_local_subnets() -> list[str]:
|
||||||
"""Return CIDR subnets for non-loopback, non-tailscale, non-wireguard interfaces."""
|
|
||||||
try:
|
try:
|
||||||
out = subprocess.check_output(["ip", "-o", "-4", "addr", "show"], text=True)
|
out = subprocess.check_output(["ip", "-o", "-4", "addr", "show"], text=True)
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -47,13 +118,11 @@ def get_local_subnets() -> list[str]:
|
|||||||
|
|
||||||
subnets = []
|
subnets = []
|
||||||
for line in out.splitlines():
|
for line in out.splitlines():
|
||||||
# Fields: index iface inet ip/prefix ...
|
|
||||||
parts = line.split()
|
parts = line.split()
|
||||||
if len(parts) < 4:
|
if len(parts) < 4:
|
||||||
continue
|
continue
|
||||||
iface = parts[1]
|
iface = parts[1]
|
||||||
cidr = parts[3]
|
cidr = parts[3]
|
||||||
# Skip loopback, tailscale, wireguard, docker
|
|
||||||
if any(iface.startswith(p) for p in ("lo", "tailscale", "wg", "tun", "docker", "br-")):
|
if any(iface.startswith(p) for p in ("lo", "tailscale", "wg", "tun", "docker", "br-")):
|
||||||
continue
|
continue
|
||||||
subnets.append(cidr)
|
subnets.append(cidr)
|
||||||
@@ -61,7 +130,6 @@ def get_local_subnets() -> list[str]:
|
|||||||
|
|
||||||
|
|
||||||
def scan_subnet(cidr: str) -> list[dict]:
|
def scan_subnet(cidr: str) -> list[dict]:
|
||||||
"""Run nmap ping scan, return list of {ip, mac, name}."""
|
|
||||||
try:
|
try:
|
||||||
out = subprocess.check_output(
|
out = subprocess.check_output(
|
||||||
["sudo", "nmap", "-sn", "-PR", "--host-timeout", "5s", cidr],
|
["sudo", "nmap", "-sn", "-PR", "--host-timeout", "5s", cidr],
|
||||||
@@ -76,7 +144,6 @@ def scan_subnet(cidr: str) -> list[dict]:
|
|||||||
if line.startswith("Nmap scan report for"):
|
if line.startswith("Nmap scan report for"):
|
||||||
if current.get("mac"):
|
if current.get("mac"):
|
||||||
devices.append(current)
|
devices.append(current)
|
||||||
# "Nmap scan report for hostname (ip)" or "... for ip"
|
|
||||||
m = re.search(r"for (.+?)(?:\s+\((.+?)\))?$", line)
|
m = re.search(r"for (.+?)(?:\s+\((.+?)\))?$", line)
|
||||||
if m:
|
if m:
|
||||||
if m.group(2):
|
if m.group(2):
|
||||||
@@ -94,35 +161,6 @@ def scan_subnet(cidr: str) -> list[dict]:
|
|||||||
return devices
|
return devices
|
||||||
|
|
||||||
|
|
||||||
def send_matrix_alert(msg: str) -> None:
|
|
||||||
if not MATRIX_ACCESS_TOKEN:
|
|
||||||
print(f"[warn] No MATRIX_ACCESS_TOKEN set, skipping alert: {msg}", file=sys.stderr)
|
|
||||||
return
|
|
||||||
|
|
||||||
url = (
|
|
||||||
f"{MATRIX_HOMESERVER}/_matrix/client/v3/rooms/"
|
|
||||||
f"{urllib.parse.quote(MATRIX_ROOM_ID, safe='')}/send/m.room.message"
|
|
||||||
)
|
|
||||||
payload = json.dumps({"msgtype": "m.text", "body": msg}).encode()
|
|
||||||
req = urllib.request.Request(
|
|
||||||
url,
|
|
||||||
data=payload,
|
|
||||||
headers={
|
|
||||||
"Authorization": f"Bearer {MATRIX_ACCESS_TOKEN}",
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
method="POST",
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
urllib.request.urlopen(req, timeout=10)
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[error] Matrix send failed: {e}", file=sys.stderr)
|
|
||||||
|
|
||||||
|
|
||||||
# urllib.parse needed for quote
|
|
||||||
import urllib.parse # noqa: E402
|
|
||||||
|
|
||||||
|
|
||||||
def run() -> None:
|
def run() -> None:
|
||||||
conn = get_db()
|
conn = get_db()
|
||||||
now = datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ")
|
now = datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
|||||||
Reference in New Issue
Block a user