Add firewall policy matrix, service proxy, ntfy alerts, and scheduler
Firewall inter-VLAN policy matrix: - Visual VLAN-to-VLAN matrix with click-to-set policies - Policy types: block, allow, one-way, printer, service-ports - Printer template: other VLANs reach ports 9100/631/443/515, printers cannot initiate back — solves the "printer VLAN" use case - Generates both switch ACLs AND OPNsense firewall rules - Preview commands before pushing, auto-backup before changes Service proxy (LAN services via FQDN without inter-VLAN access): - Register services with FQDN + backend URL + allowed VLANs - Deploy generates Caddyfile entries, Unbound DNS overrides, and firewall rules allowing only port 443 to the proxy - Pattern: device on VLAN 30 → DNS resolves to mgmt box → Caddy proxies to actual LAN server — no VLAN-to-VLAN access needed ntfy push notifications: - Configure ntfy.sh or self-hosted ntfy server - Alert events: connectivity lost/restored, PoE budget >85%, backup failures, push failures - Integrated into poll loop — alerts fire on state transitions - Test notification button Scheduled operations: - Cron-like scheduler for automated backups and connectivity checks - Background thread checks every 60 seconds - Per-schedule: name, action, hour, minute, days (mon,wed,fri or *) - Run-now button for manual trigger - ntfy notifications on scheduled task completion/failure https://claude.ai/code/session_01Do9bsN39MTuy2GVv7yzSrE
This commit is contained in:
@@ -510,6 +510,12 @@ def _poll_loop():
|
||||
_cache["poll_error"] = str(e)
|
||||
log.warning(f"Poll error: {e}")
|
||||
|
||||
# Check alert conditions after each poll
|
||||
try:
|
||||
_check_and_alert()
|
||||
except Exception:
|
||||
pass # alerts are best-effort, never crash the poller
|
||||
|
||||
interval = POLL_ACTIVE_S if mode == "active" else POLL_BG_S
|
||||
time.sleep(interval)
|
||||
|
||||
@@ -4141,3 +4147,790 @@ def push_safe(body: PushBatch):
|
||||
result["backup"] = backup
|
||||
result["post_connectivity"] = post_conn
|
||||
return result
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════
|
||||
# FIREWALL POLICY MATRIX — inter-VLAN access control
|
||||
# ══════════════════════════════════════════════════════════════════════
|
||||
|
||||
POLICIES_FILE = _Path("/etc/switch-manager/vlan-policies.json")
|
||||
|
||||
# Policy types:
|
||||
# "block" — deny all traffic between VLANs
|
||||
# "allow" — permit all traffic between VLANs
|
||||
# "one-way" — src VLAN can reach dst VLAN, but not reverse
|
||||
# "services" — src can reach dst on specific ports only
|
||||
# "printer" — other VLANs can print (reach ports 9100,631,443), printer can't initiate
|
||||
|
||||
POLICY_PRESETS = {
|
||||
"block": {
|
||||
"label": "Blocked",
|
||||
"description": "No traffic allowed between these VLANs",
|
||||
},
|
||||
"allow": {
|
||||
"label": "Full Access",
|
||||
"description": "All traffic permitted between these VLANs",
|
||||
},
|
||||
"one-way": {
|
||||
"label": "One-Way Access",
|
||||
"description": "Source VLAN can reach destination, but not reverse",
|
||||
},
|
||||
"printer": {
|
||||
"label": "Printer Access",
|
||||
"description": "Other VLANs can reach printers (ports 9100/631/443/515), printers cannot initiate connections back",
|
||||
"ports": [9100, 631, 443, 515],
|
||||
},
|
||||
"services": {
|
||||
"label": "Service Ports Only",
|
||||
"description": "Access limited to specified TCP/UDP ports",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _load_policies() -> list:
|
||||
if POLICIES_FILE.exists():
|
||||
try: return _json.loads(POLICIES_FILE.read_text())
|
||||
except: pass
|
||||
return []
|
||||
|
||||
|
||||
def _save_policies(policies: list):
|
||||
POLICIES_FILE.write_text(_json.dumps(policies, indent=2))
|
||||
POLICIES_FILE.chmod(0o600)
|
||||
|
||||
|
||||
def _build_policy_acls(policy: dict) -> dict:
|
||||
"""
|
||||
Generate switch ACL commands AND OPNsense firewall rule payloads for a policy.
|
||||
|
||||
Returns {switch_cmds: [...], opnsense_rules: [...], description: str}
|
||||
"""
|
||||
ptype = policy.get("type", "block")
|
||||
src_vid = policy.get("src_vlan")
|
||||
dst_vid = policy.get("dst_vlan")
|
||||
ports = policy.get("ports", [])
|
||||
src_sub = f"192.168.{src_vid}.0"
|
||||
dst_sub = f"192.168.{dst_vid}.0"
|
||||
mask = "0.0.0.255"
|
||||
acl_name = f"POLICY-V{src_vid}-V{dst_vid}"
|
||||
|
||||
switch_cmds = []
|
||||
opnsense_rules = []
|
||||
|
||||
if ptype == "block":
|
||||
switch_cmds = [
|
||||
f"ip access-list extended {acl_name}",
|
||||
f" 1 deny ip {src_sub} {mask} {dst_sub} {mask}",
|
||||
f" 2 permit ip any any",
|
||||
f"interface vlan {src_vid}",
|
||||
f" ip access-group {acl_name} in",
|
||||
]
|
||||
opnsense_rules.append({
|
||||
"rule": {
|
||||
"enabled": "1", "action": "block",
|
||||
"ipprotocol": "inet", "protocol": "any",
|
||||
"source": {"network": f"{src_sub}/{24}"},
|
||||
"destination": {"network": f"{dst_sub}/24"},
|
||||
"descr": f"Block VLAN {src_vid} → VLAN {dst_vid}",
|
||||
}
|
||||
})
|
||||
|
||||
elif ptype == "allow":
|
||||
switch_cmds = [
|
||||
f"ip access-list extended {acl_name}",
|
||||
f" 1 permit ip {src_sub} {mask} {dst_sub} {mask}",
|
||||
f" 2 permit ip any any",
|
||||
f"interface vlan {src_vid}",
|
||||
f" ip access-group {acl_name} in",
|
||||
]
|
||||
# OPNsense: explicit allow (usually default, but good to be explicit)
|
||||
opnsense_rules.append({
|
||||
"rule": {
|
||||
"enabled": "1", "action": "pass",
|
||||
"ipprotocol": "inet", "protocol": "any",
|
||||
"source": {"network": f"{src_sub}/24"},
|
||||
"destination": {"network": f"{dst_sub}/24"},
|
||||
"descr": f"Allow VLAN {src_vid} → VLAN {dst_vid}",
|
||||
}
|
||||
})
|
||||
|
||||
elif ptype == "one-way":
|
||||
# Allow src→dst, block dst→src (reverse ACL on dst VLAN)
|
||||
switch_cmds = [
|
||||
f"ip access-list extended {acl_name}",
|
||||
f" 1 permit ip {src_sub} {mask} {dst_sub} {mask}",
|
||||
f" 2 permit ip any any",
|
||||
f"interface vlan {src_vid}",
|
||||
f" ip access-group {acl_name} in",
|
||||
f"ip access-list extended {acl_name}-REV",
|
||||
f" 1 deny ip {dst_sub} {mask} {src_sub} {mask}",
|
||||
f" 2 permit ip any any",
|
||||
f"interface vlan {dst_vid}",
|
||||
f" ip access-group {acl_name}-REV in",
|
||||
]
|
||||
opnsense_rules.append({
|
||||
"rule": {
|
||||
"enabled": "1", "action": "pass",
|
||||
"ipprotocol": "inet", "protocol": "any",
|
||||
"source": {"network": f"{src_sub}/24"},
|
||||
"destination": {"network": f"{dst_sub}/24"},
|
||||
"descr": f"Allow VLAN {src_vid} → VLAN {dst_vid} (one-way)",
|
||||
}
|
||||
})
|
||||
opnsense_rules.append({
|
||||
"rule": {
|
||||
"enabled": "1", "action": "block",
|
||||
"ipprotocol": "inet", "protocol": "any",
|
||||
"source": {"network": f"{dst_sub}/24"},
|
||||
"destination": {"network": f"{src_sub}/24"},
|
||||
"descr": f"Block VLAN {dst_vid} → VLAN {src_vid} (one-way reverse)",
|
||||
}
|
||||
})
|
||||
|
||||
elif ptype == "printer":
|
||||
# Other VLANs can reach printer VLAN on print ports; printers can't initiate
|
||||
printer_ports = ports or [9100, 631, 443, 515]
|
||||
rule_num = 1
|
||||
switch_cmds = [f"ip access-list extended {acl_name}"]
|
||||
for port in printer_ports:
|
||||
switch_cmds.append(
|
||||
f" {rule_num} permit tcp {src_sub} {mask} {dst_sub} {mask} eq {port}")
|
||||
rule_num += 1
|
||||
switch_cmds += [
|
||||
f" {rule_num} deny ip {src_sub} {mask} {dst_sub} {mask}",
|
||||
f" {rule_num+1} permit ip any any",
|
||||
f"interface vlan {src_vid}",
|
||||
f" ip access-group {acl_name} in",
|
||||
]
|
||||
# Reverse: block printers from initiating to src VLAN
|
||||
switch_cmds += [
|
||||
f"ip access-list extended {acl_name}-REV",
|
||||
f" 1 deny ip {dst_sub} {mask} {src_sub} {mask}",
|
||||
f" 2 permit ip any any",
|
||||
f"interface vlan {dst_vid}",
|
||||
f" ip access-group {acl_name}-REV in",
|
||||
]
|
||||
# OPNsense rules
|
||||
for port in printer_ports:
|
||||
opnsense_rules.append({
|
||||
"rule": {
|
||||
"enabled": "1", "action": "pass",
|
||||
"ipprotocol": "inet", "protocol": "tcp",
|
||||
"source": {"network": f"{src_sub}/24"},
|
||||
"destination": {"network": f"{dst_sub}/24", "port": str(port)},
|
||||
"descr": f"VLAN {src_vid} → printer VLAN {dst_vid} port {port}",
|
||||
}
|
||||
})
|
||||
opnsense_rules.append({
|
||||
"rule": {
|
||||
"enabled": "1", "action": "block",
|
||||
"ipprotocol": "inet", "protocol": "any",
|
||||
"source": {"network": f"{dst_sub}/24"},
|
||||
"destination": {"network": f"{src_sub}/24"},
|
||||
"descr": f"Block printer VLAN {dst_vid} → VLAN {src_vid}",
|
||||
}
|
||||
})
|
||||
|
||||
elif ptype == "services":
|
||||
rule_num = 1
|
||||
switch_cmds = [f"ip access-list extended {acl_name}"]
|
||||
for port in ports:
|
||||
switch_cmds.append(
|
||||
f" {rule_num} permit tcp {src_sub} {mask} {dst_sub} {mask} eq {port}")
|
||||
rule_num += 1
|
||||
switch_cmds += [
|
||||
f" {rule_num} deny ip {src_sub} {mask} {dst_sub} {mask}",
|
||||
f" {rule_num+1} permit ip any any",
|
||||
f"interface vlan {src_vid}",
|
||||
f" ip access-group {acl_name} in",
|
||||
]
|
||||
for port in ports:
|
||||
opnsense_rules.append({
|
||||
"rule": {
|
||||
"enabled": "1", "action": "pass",
|
||||
"ipprotocol": "inet", "protocol": "tcp",
|
||||
"source": {"network": f"{src_sub}/24"},
|
||||
"destination": {"network": f"{dst_sub}/24", "port": str(port)},
|
||||
"descr": f"VLAN {src_vid} → VLAN {dst_vid} port {port}",
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
"switch_cmds": switch_cmds,
|
||||
"opnsense_rules": opnsense_rules,
|
||||
"acl_name": acl_name,
|
||||
"description": f"{POLICY_PRESETS.get(ptype,{}).get('label','Custom')} — "
|
||||
f"VLAN {src_vid} → VLAN {dst_vid}",
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/firewall/policies")
|
||||
def get_policies():
|
||||
"""Return saved inter-VLAN policies and available presets."""
|
||||
return {"policies": _load_policies(), "presets": POLICY_PRESETS}
|
||||
|
||||
|
||||
@app.post("/api/firewall/policies")
|
||||
def save_policy(body: dict):
|
||||
"""Save or update an inter-VLAN policy."""
|
||||
require_session(body.get("token", ""))
|
||||
policy = body.get("policy", {})
|
||||
if not policy.get("src_vlan") or not policy.get("dst_vlan") or not policy.get("type"):
|
||||
raise HTTPException(400, "src_vlan, dst_vlan, and type required")
|
||||
|
||||
policies = _load_policies()
|
||||
# Replace existing policy for this VLAN pair
|
||||
policies = [p for p in policies
|
||||
if not (p["src_vlan"] == policy["src_vlan"] and p["dst_vlan"] == policy["dst_vlan"])]
|
||||
policies.append(policy)
|
||||
_save_policies(policies)
|
||||
return {"success": True, "policies": policies}
|
||||
|
||||
|
||||
@app.delete("/api/firewall/policies")
|
||||
def delete_policy(body: dict):
|
||||
"""Remove an inter-VLAN policy."""
|
||||
require_session(body.get("token", ""))
|
||||
src = body.get("src_vlan")
|
||||
dst = body.get("dst_vlan")
|
||||
policies = _load_policies()
|
||||
policies = [p for p in policies if not (p["src_vlan"] == src and p["dst_vlan"] == dst)]
|
||||
_save_policies(policies)
|
||||
return {"success": True, "policies": policies}
|
||||
|
||||
|
||||
@app.post("/api/firewall/preview")
|
||||
def preview_policy(body: dict):
|
||||
"""Preview generated ACLs/rules for a policy without pushing."""
|
||||
policy = body.get("policy", {})
|
||||
if not policy.get("src_vlan") or not policy.get("dst_vlan") or not policy.get("type"):
|
||||
raise HTTPException(400, "src_vlan, dst_vlan, and type required")
|
||||
return _build_policy_acls(policy)
|
||||
|
||||
|
||||
@app.post("/api/firewall/push")
|
||||
def push_policy(body: dict):
|
||||
"""Push a firewall policy to both switch and OPNsense."""
|
||||
require_session(body.get("token", ""))
|
||||
policy = body.get("policy", {})
|
||||
if not policy.get("src_vlan") or not policy.get("dst_vlan") or not policy.get("type"):
|
||||
raise HTTPException(400, "src_vlan, dst_vlan, and type required")
|
||||
|
||||
generated = _build_policy_acls(policy)
|
||||
steps_done = []
|
||||
errors = []
|
||||
|
||||
# Pre-backup
|
||||
backup = _pre_change_backup(
|
||||
reason=f"pre-policy VLAN {policy['src_vlan']}→{policy['dst_vlan']} ({policy['type']})")
|
||||
|
||||
# Push switch ACLs
|
||||
if generated["switch_cmds"]:
|
||||
danger = check_danger(generated["switch_cmds"])
|
||||
if danger["has_hard_block"]:
|
||||
raise HTTPException(400, {"message": "Hard-blocked", "blocked": danger["hard_blocked"]})
|
||||
result = push_one_by_one(generated["switch_cmds"])
|
||||
if result.get("success"):
|
||||
steps_done.append(f"switch: ACL {generated['acl_name']} applied")
|
||||
else:
|
||||
errors.append(f"switch: {result.get('error', 'push failed')}")
|
||||
|
||||
# Push OPNsense rules
|
||||
cfg = _load_opnsense_cfg()
|
||||
if cfg.get("key") and generated["opnsense_rules"]:
|
||||
vmap = _load_vlan_if_map()
|
||||
src_if = vmap.get(str(policy["src_vlan"]), "")
|
||||
for rule_data in generated["opnsense_rules"]:
|
||||
if src_if:
|
||||
rule_data["rule"]["interface"] = src_if
|
||||
rule_data["rule"]["direction"] = "in"
|
||||
try:
|
||||
_opnsense_request(cfg, "firewall/filter/addRule", "POST", rule_data)
|
||||
steps_done.append(f"OPNsense: {rule_data['rule']['descr']}")
|
||||
except ValueError as e:
|
||||
errors.append(f"OPNsense: {e}")
|
||||
try:
|
||||
_opnsense_request(cfg, "firewall/filter/apply", "POST")
|
||||
steps_done.append("OPNsense: firewall rules applied")
|
||||
except ValueError as e:
|
||||
errors.append(f"OPNsense apply: {e}")
|
||||
|
||||
# Save policy to local state
|
||||
policies = _load_policies()
|
||||
policies = [p for p in policies
|
||||
if not (p["src_vlan"] == policy["src_vlan"] and p["dst_vlan"] == policy["dst_vlan"])]
|
||||
policy["pushed"] = True
|
||||
policy["pushed_at"] = _ts()
|
||||
policies.append(policy)
|
||||
_save_policies(policies)
|
||||
|
||||
return {
|
||||
"success": len(errors) == 0,
|
||||
"steps_done": steps_done,
|
||||
"errors": errors,
|
||||
"backup": backup,
|
||||
"generated": generated,
|
||||
}
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════
|
||||
# SERVICE PROXY — expose LAN services to other VLANs via Caddy + DNS
|
||||
# ══════════════════════════════════════════════════════════════════════
|
||||
|
||||
SERVICES_FILE = _Path("/etc/switch-manager/service-proxies.json")
|
||||
CADDYFILE_EXTRA = _Path("/etc/switch-manager/Caddyfile.services")
|
||||
|
||||
def _load_services() -> list:
|
||||
if SERVICES_FILE.exists():
|
||||
try: return _json.loads(SERVICES_FILE.read_text())
|
||||
except: pass
|
||||
return []
|
||||
|
||||
def _save_services(services: list):
|
||||
SERVICES_FILE.write_text(_json.dumps(services, indent=2))
|
||||
SERVICES_FILE.chmod(0o600)
|
||||
|
||||
|
||||
def _generate_caddyfile_services(services: list) -> str:
|
||||
"""Generate Caddyfile blocks for service reverse proxies."""
|
||||
blocks = ["# Auto-generated by switch-manager — do not edit manually\n"]
|
||||
for svc in services:
|
||||
fqdn = svc.get("fqdn", "")
|
||||
backend_url = svc.get("backend_url", "")
|
||||
if not fqdn or not backend_url:
|
||||
continue
|
||||
blocks.append(f"{fqdn} {{")
|
||||
blocks.append(f" reverse_proxy {backend_url}")
|
||||
blocks.append(f" tls internal")
|
||||
blocks.append(f"}}\n")
|
||||
return "\n".join(blocks)
|
||||
|
||||
|
||||
def _generate_unbound_overrides(services: list, mgmt_ip: str) -> str:
|
||||
"""Generate Unbound local-data lines for service FQDN → management box IP."""
|
||||
lines = ["# Auto-generated by switch-manager\n"]
|
||||
for svc in services:
|
||||
fqdn = svc.get("fqdn", "")
|
||||
target_ip = svc.get("proxy_ip", mgmt_ip)
|
||||
if fqdn:
|
||||
lines.append(f'local-data: "{fqdn}. IN A {target_ip}"')
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
@app.get("/api/services")
|
||||
def get_services():
|
||||
"""List configured service proxies."""
|
||||
return {"services": _load_services()}
|
||||
|
||||
|
||||
@app.post("/api/services")
|
||||
def save_service(body: dict):
|
||||
"""Add or update a service proxy."""
|
||||
require_session(body.get("token", ""))
|
||||
svc = body.get("service", {})
|
||||
if not svc.get("fqdn") or not svc.get("backend_url"):
|
||||
raise HTTPException(400, "fqdn and backend_url required")
|
||||
|
||||
services = _load_services()
|
||||
services = [s for s in services if s["fqdn"] != svc["fqdn"]]
|
||||
services.append(svc)
|
||||
_save_services(services)
|
||||
return {"success": True, "services": services}
|
||||
|
||||
|
||||
@app.delete("/api/services")
|
||||
def delete_service(body: dict):
|
||||
"""Remove a service proxy."""
|
||||
require_session(body.get("token", ""))
|
||||
fqdn = body.get("fqdn", "")
|
||||
services = _load_services()
|
||||
services = [s for s in services if s["fqdn"] != fqdn]
|
||||
_save_services(services)
|
||||
return {"success": True, "services": services}
|
||||
|
||||
|
||||
@app.post("/api/services/deploy")
|
||||
def deploy_services(body: dict):
|
||||
"""
|
||||
Deploy service proxies: write Caddyfile, push DNS overrides to Unbound,
|
||||
add firewall rules to allow other VLANs to reach the proxy.
|
||||
"""
|
||||
require_session(body.get("token", ""))
|
||||
services = _load_services()
|
||||
if not services:
|
||||
raise HTTPException(400, "No services configured")
|
||||
|
||||
steps_done = []
|
||||
errors = []
|
||||
|
||||
# Determine management box IP
|
||||
import socket as _sock
|
||||
try:
|
||||
mgmt_ip = _sock.gethostbyname(_sock.gethostname())
|
||||
except Exception:
|
||||
mgmt_ip = SWITCH_HOST.rsplit('.', 1)[0] + '.50'
|
||||
|
||||
backup = _pre_change_backup(reason="pre-service-proxy deploy")
|
||||
|
||||
# 1. Write Caddyfile.services
|
||||
caddy_content = _generate_caddyfile_services(services)
|
||||
try:
|
||||
CADDYFILE_EXTRA.write_text(caddy_content)
|
||||
steps_done.append(f"Wrote {CADDYFILE_EXTRA} ({len(services)} services)")
|
||||
except Exception as e:
|
||||
errors.append(f"Caddyfile write: {e}")
|
||||
|
||||
# 2. Push DNS overrides to OPNsense Unbound
|
||||
cfg = _load_opnsense_cfg()
|
||||
if cfg.get("ssh_key_path"):
|
||||
dns_content = _generate_unbound_overrides(services, mgmt_ip)
|
||||
try:
|
||||
_opnsense_sftp_write(cfg, f"{UNBOUND_ETC}/service-proxies.conf", dns_content)
|
||||
steps_done.append(f"Wrote Unbound overrides: {len(services)} service FQDNs → {mgmt_ip}")
|
||||
except Exception as e:
|
||||
errors.append(f"Unbound DNS write: {e}")
|
||||
|
||||
# Validate and reload Unbound
|
||||
out, err, code = _opnsense_ssh_run(cfg, "unbound-checkconf 2>&1")
|
||||
if code != 0:
|
||||
errors.append(f"unbound-checkconf failed: {err or out}")
|
||||
else:
|
||||
_opnsense_ssh_run(cfg, "unbound-control reload 2>&1")
|
||||
steps_done.append("Unbound reloaded with service DNS overrides")
|
||||
else:
|
||||
errors.append("OPNsense SSH not configured — DNS overrides not deployed. "
|
||||
"Add service FQDNs to your DNS manually.")
|
||||
|
||||
# 3. Add firewall rules: allow each VLAN to reach mgmt_ip on 443
|
||||
if cfg.get("key"):
|
||||
allowed_vlans = set()
|
||||
for svc in services:
|
||||
for vid in svc.get("allowed_vlans", []):
|
||||
allowed_vlans.add(vid)
|
||||
vmap = _load_vlan_if_map()
|
||||
for vid in allowed_vlans:
|
||||
iface = vmap.get(str(vid), "")
|
||||
if not iface:
|
||||
errors.append(f"VLAN {vid}: no OPNsense interface mapped — skip firewall rule")
|
||||
continue
|
||||
try:
|
||||
_opnsense_request(cfg, "firewall/filter/addRule", "POST", {
|
||||
"rule": {
|
||||
"enabled": "1", "action": "pass",
|
||||
"interface": iface, "direction": "in",
|
||||
"ipprotocol": "inet", "protocol": "tcp",
|
||||
"source": {"network": f"{iface}net"},
|
||||
"destination": {"address": mgmt_ip, "port": "443"},
|
||||
"descr": f"VLAN {vid} → service proxy ({mgmt_ip}:443)",
|
||||
}
|
||||
})
|
||||
steps_done.append(f"Firewall: VLAN {vid} → {mgmt_ip}:443 allowed")
|
||||
except ValueError as e:
|
||||
errors.append(f"Firewall VLAN {vid}: {e}")
|
||||
if allowed_vlans:
|
||||
try:
|
||||
_opnsense_request(cfg, "firewall/filter/apply", "POST")
|
||||
except ValueError as e:
|
||||
errors.append(f"Firewall apply: {e}")
|
||||
|
||||
return {
|
||||
"success": len(errors) == 0,
|
||||
"steps_done": steps_done,
|
||||
"errors": errors,
|
||||
"backup": backup,
|
||||
"caddy_content": caddy_content,
|
||||
"mgmt_ip": mgmt_ip,
|
||||
"note": "Restart Caddy to pick up new Caddyfile.services: "
|
||||
"docker compose restart caddy (or systemctl restart caddy)",
|
||||
}
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════
|
||||
# NTFY ALERTS — push notifications for network events
|
||||
# ══════════════════════════════════════════════════════════════════════
|
||||
|
||||
NTFY_FILE = _Path("/etc/switch-manager/ntfy.json")
|
||||
|
||||
def _load_ntfy_cfg() -> dict:
|
||||
if NTFY_FILE.exists():
|
||||
try: return _json.loads(NTFY_FILE.read_text())
|
||||
except: pass
|
||||
return {}
|
||||
|
||||
def _save_ntfy_cfg(cfg: dict):
|
||||
NTFY_FILE.write_text(_json.dumps(cfg, indent=2))
|
||||
NTFY_FILE.chmod(0o600)
|
||||
|
||||
|
||||
def _ntfy_send(title: str, message: str, priority: str = "default", tags: str = ""):
|
||||
"""Send a notification via ntfy. Non-blocking, fire-and-forget."""
|
||||
cfg = _load_ntfy_cfg()
|
||||
url = cfg.get("url", "")
|
||||
topic = cfg.get("topic", "")
|
||||
if not url or not topic:
|
||||
return
|
||||
try:
|
||||
full_url = f"{url.rstrip('/')}/{topic}"
|
||||
headers = {
|
||||
"Title": title,
|
||||
"Priority": priority,
|
||||
}
|
||||
if tags:
|
||||
headers["Tags"] = tags
|
||||
token = cfg.get("token", "")
|
||||
if token:
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
data = message.encode("utf-8")
|
||||
req = _urlreq.Request(full_url, data=data, headers=headers, method="POST")
|
||||
ctx = _ssl.create_default_context()
|
||||
ctx.check_hostname = False
|
||||
ctx.verify_mode = _ssl.CERT_NONE
|
||||
_urlreq.urlopen(req, timeout=5, context=ctx)
|
||||
log.info(f"ntfy alert sent: {title}")
|
||||
except Exception as e:
|
||||
log.warning(f"ntfy send failed: {e}")
|
||||
|
||||
|
||||
@app.get("/api/alerts/config")
|
||||
def get_ntfy_config():
|
||||
"""Return ntfy configuration (without token)."""
|
||||
cfg = _load_ntfy_cfg()
|
||||
return {
|
||||
"url": cfg.get("url", ""),
|
||||
"topic": cfg.get("topic", ""),
|
||||
"has_token": bool(cfg.get("token", "")),
|
||||
"enabled": cfg.get("enabled", False),
|
||||
"events": cfg.get("events", {
|
||||
"connectivity_lost": True,
|
||||
"backup_failed": True,
|
||||
"push_failed": True,
|
||||
"poe_budget_warning": True,
|
||||
"port_down": False,
|
||||
}),
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/alerts/config")
|
||||
def save_ntfy_config(body: dict):
|
||||
"""Save ntfy configuration."""
|
||||
require_session(body.get("token_session", body.get("token", "")))
|
||||
cfg = {
|
||||
"url": body.get("url", "https://ntfy.sh"),
|
||||
"topic": body.get("topic", ""),
|
||||
"token": body.get("ntfy_token", ""),
|
||||
"enabled": body.get("enabled", False),
|
||||
"events": body.get("events", {}),
|
||||
}
|
||||
_save_ntfy_cfg(cfg)
|
||||
return {"success": True}
|
||||
|
||||
|
||||
@app.post("/api/alerts/test")
|
||||
def test_ntfy(body: dict):
|
||||
"""Send a test notification."""
|
||||
require_session(body.get("token", ""))
|
||||
_ntfy_send(
|
||||
title="Switch Manager Test",
|
||||
message="If you see this, ntfy alerts are working!",
|
||||
priority="low",
|
||||
tags="white_check_mark,test_tube",
|
||||
)
|
||||
return {"success": True}
|
||||
|
||||
|
||||
# ── Alert integration into polling ───────────────────────────────────
|
||||
|
||||
_last_alert_state: dict = {}
|
||||
|
||||
def _check_and_alert():
|
||||
"""Called from the poll loop to detect alertable conditions."""
|
||||
cfg = _load_ntfy_cfg()
|
||||
if not cfg.get("enabled"):
|
||||
return
|
||||
events = cfg.get("events", {})
|
||||
global _last_alert_state
|
||||
|
||||
with _cache_lock:
|
||||
poll_err = _cache.get("poll_error")
|
||||
port_status = _cache.get("port_status", "")
|
||||
poe_status = _cache.get("poe_status", "")
|
||||
|
||||
# Connectivity lost
|
||||
if events.get("connectivity_lost") and poll_err:
|
||||
if not _last_alert_state.get("conn_lost"):
|
||||
_ntfy_send("Switch Offline", f"Cannot reach switch: {poll_err}",
|
||||
priority="urgent", tags="rotating_light,warning")
|
||||
_last_alert_state["conn_lost"] = True
|
||||
else:
|
||||
if _last_alert_state.get("conn_lost"):
|
||||
_ntfy_send("Switch Back Online", "Connectivity restored",
|
||||
priority="default", tags="white_check_mark")
|
||||
_last_alert_state["conn_lost"] = False
|
||||
|
||||
# PoE budget warning (parse from poe_status if available)
|
||||
if events.get("poe_budget_warning") and poe_status:
|
||||
import re as _re_alert
|
||||
watts_match = _re_alert.findall(r'(\d+)\s*[Ww]atts?\s*(used|consumed)', poe_status)
|
||||
budget_match = _re_alert.findall(r'(\d+)\s*[Ww]atts?\s*(available|budget|maximum)', poe_status)
|
||||
if watts_match and budget_match:
|
||||
try:
|
||||
used = int(watts_match[0][0])
|
||||
budget = int(budget_match[0][0])
|
||||
pct = (used / budget * 100) if budget > 0 else 0
|
||||
if pct > 85 and not _last_alert_state.get("poe_warn"):
|
||||
_ntfy_send("PoE Budget Warning",
|
||||
f"PoE usage at {pct:.0f}% ({used}W / {budget}W)",
|
||||
priority="high", tags="zap,warning")
|
||||
_last_alert_state["poe_warn"] = True
|
||||
elif pct <= 80:
|
||||
_last_alert_state["poe_warn"] = False
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════
|
||||
# SCHEDULED OPERATIONS — cron-like scheduler for backups and VLAN ops
|
||||
# ══════════════════════════════════════════════════════════════════════
|
||||
|
||||
SCHEDULES_FILE = _Path("/etc/switch-manager/schedules.json")
|
||||
_scheduler_thread = None
|
||||
|
||||
def _load_schedules() -> list:
|
||||
if SCHEDULES_FILE.exists():
|
||||
try: return _json.loads(SCHEDULES_FILE.read_text())
|
||||
except: pass
|
||||
return []
|
||||
|
||||
def _save_schedules(schedules: list):
|
||||
SCHEDULES_FILE.write_text(_json.dumps(schedules, indent=2))
|
||||
SCHEDULES_FILE.chmod(0o600)
|
||||
|
||||
|
||||
def _should_run_now(schedule: dict) -> bool:
|
||||
"""Check if a schedule should run based on current time and its cron-like fields."""
|
||||
now = _dt.datetime.now()
|
||||
hour = schedule.get("hour", "*")
|
||||
minute = schedule.get("minute", "0")
|
||||
days = schedule.get("days", "*") # "mon,tue,wed" or "*"
|
||||
|
||||
if hour != "*" and now.hour != int(hour):
|
||||
return False
|
||||
if minute != "*" and now.minute != int(minute):
|
||||
return False
|
||||
if days != "*":
|
||||
day_names = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"]
|
||||
today = day_names[now.weekday()]
|
||||
if today not in days.lower().split(","):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _run_scheduled_task(schedule: dict):
|
||||
"""Execute a scheduled task."""
|
||||
action = schedule.get("action", "")
|
||||
name = schedule.get("name", "unnamed")
|
||||
log.info(f"Scheduler: running '{name}' (action={action})")
|
||||
|
||||
try:
|
||||
if action == "backup":
|
||||
device = schedule.get("device", "both")
|
||||
result = {}
|
||||
if device in ("switch", "both"):
|
||||
result["switch"] = _switch_backup(reason=f"scheduled: {name}")
|
||||
if device in ("opnsense", "both"):
|
||||
cfg = _load_opnsense_cfg()
|
||||
if cfg.get("key"):
|
||||
result["opnsense"] = _opnsense_backup(cfg, reason=f"scheduled: {name}")
|
||||
log.info(f"Scheduled backup '{name}': {result}")
|
||||
_ntfy_send(f"Scheduled Backup: {name}",
|
||||
f"Switch: {'OK' if result.get('switch',{}).get('ok') else 'FAIL'}, "
|
||||
f"OPNsense: {'OK' if result.get('opnsense',{}).get('ok') else 'N/A'}",
|
||||
tags="floppy_disk")
|
||||
|
||||
elif action == "connectivity_check":
|
||||
conn = _check_connectivity()
|
||||
if not conn["switch"]["ok"]:
|
||||
_ntfy_send("Scheduled Check: Switch Offline",
|
||||
f"Switch unreachable: {conn['switch'].get('error','')}",
|
||||
priority="urgent", tags="rotating_light")
|
||||
|
||||
except Exception as e:
|
||||
log.warning(f"Scheduled task '{name}' failed: {e}")
|
||||
_ntfy_send(f"Scheduled Task Failed: {name}", str(e),
|
||||
priority="high", tags="x")
|
||||
|
||||
|
||||
def _scheduler_loop():
|
||||
"""Background thread: check schedules every 60 seconds."""
|
||||
log.info("Scheduler thread started")
|
||||
last_runs: dict[str, str] = {} # {schedule_name: "YYYYMMDD-HHMM"}
|
||||
while True:
|
||||
time.sleep(60)
|
||||
schedules = _load_schedules()
|
||||
now_key = _dt.datetime.now().strftime("%Y%m%d-%H%M")
|
||||
for sched in schedules:
|
||||
if not sched.get("enabled", True):
|
||||
continue
|
||||
name = sched.get("name", "")
|
||||
# Don't run the same schedule twice in the same minute
|
||||
if last_runs.get(name) == now_key:
|
||||
continue
|
||||
if _should_run_now(sched):
|
||||
last_runs[name] = now_key
|
||||
try:
|
||||
_run_scheduled_task(sched)
|
||||
except Exception as e:
|
||||
log.warning(f"Scheduler error for '{name}': {e}")
|
||||
|
||||
|
||||
def start_scheduler():
|
||||
global _scheduler_thread
|
||||
if _scheduler_thread is None or not _scheduler_thread.is_alive():
|
||||
_scheduler_thread = threading.Thread(target=_scheduler_loop, daemon=True, name="scheduler")
|
||||
_scheduler_thread.start()
|
||||
|
||||
|
||||
# Start scheduler on import (alongside poller)
|
||||
start_scheduler()
|
||||
|
||||
|
||||
@app.get("/api/schedules")
|
||||
def get_schedules():
|
||||
return {"schedules": _load_schedules()}
|
||||
|
||||
|
||||
@app.post("/api/schedules")
|
||||
def save_schedule(body: dict):
|
||||
require_session(body.get("token", ""))
|
||||
sched = body.get("schedule", {})
|
||||
if not sched.get("name") or not sched.get("action"):
|
||||
raise HTTPException(400, "name and action required")
|
||||
|
||||
schedules = _load_schedules()
|
||||
schedules = [s for s in schedules if s["name"] != sched["name"]]
|
||||
schedules.append(sched)
|
||||
_save_schedules(schedules)
|
||||
return {"success": True, "schedules": schedules}
|
||||
|
||||
|
||||
@app.delete("/api/schedules")
|
||||
def delete_schedule(body: dict):
|
||||
require_session(body.get("token", ""))
|
||||
name = body.get("name", "")
|
||||
schedules = _load_schedules()
|
||||
schedules = [s for s in schedules if s["name"] != name]
|
||||
_save_schedules(schedules)
|
||||
return {"success": True, "schedules": schedules}
|
||||
|
||||
|
||||
@app.post("/api/schedules/run-now")
|
||||
def run_schedule_now(body: dict):
|
||||
"""Manually trigger a scheduled task immediately."""
|
||||
require_session(body.get("token", ""))
|
||||
name = body.get("name", "")
|
||||
schedules = _load_schedules()
|
||||
sched = next((s for s in schedules if s["name"] == name), None)
|
||||
if not sched:
|
||||
raise HTTPException(404, f"Schedule '{name}' not found")
|
||||
_run_scheduled_task(sched)
|
||||
return {"success": True, "ran": name}
|
||||
|
||||
Reference in New Issue
Block a user