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:
Claude
2026-03-28 00:55:29 +00:00
parent 7f6fa75aff
commit 7928f6f769
3 changed files with 1495 additions and 10 deletions
+793
View File
@@ -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}