Files
firewall/main.py
T
ducoterra 870c5cc363
Build and Push Container / build-and-push (push) Successful in 18s
add prefix to rules for easy identification
2026-08-02 11:09:54 -04:00

467 lines
16 KiB
Python

"""
UniFi firewall rule updater.
Updates UniFi Dream Machine firewall rules with the host machine's public
IPv4 and IPv6 addresses via the UniFi Network API.
Exported env vars:
UNIFI_HOST - UniFi controller URL (e.g., https://10.1.0.1)
UNIFI_SITE_ID - UniFi site ID
UNIFI_API_TOKEN - UniFi API token
UNIFI_VERIFY_SSL - verify SSL certificates (default: false)
CONFIG_FILE - path to YAML file with firewall rules (default: config/rules.yaml)
LOG_LEVEL - logging level (DEBUG, INFO, WARNING, ERROR; default: INFO)
DEBUG - if true, starts debugpy and waits for a remote debugger connection on port 5678
NTFY_URL - NTFY notification server URL
NTFY_TOPIC - NTFY topic to send notifications to
NTFY_API_KEY - NTFY API key for authentication
"""
from __future__ import annotations
import logging
import os
import sys
from typing import Literal, TypedDict
import requests
import yaml
from dotenv import load_dotenv
from config import RULE_NAME_PREFIX
from ip_lookup import get_ipv4, get_ipv6
from unifi_firewall import (
FirewallPolicy,
FirewallZone,
build_policy_payload,
create_policy,
get_action_from_policy,
get_dest_ports_from_policy,
get_protocol_from_policy,
get_session,
get_source_ip_from_policy,
list_policies,
list_zones,
update_policy,
)
load_dotenv()
DEBUG = os.getenv("DEBUG", "false").lower() == "true"
if DEBUG:
import debugpy # noqa: T100
debugpy.listen(("0.0.0.0", 5678)) # noqa: T100
print("DEBUG: debugpy listening on 0.0.0.0:5678, waiting for debugger to attach...")
debugpy.wait_for_client() # noqa: T100
print("DEBUG: debugger attached, resuming execution...")
try:
from yaml import CLoader as Loader
except ImportError:
from yaml import Loader # type: ignore[attr-defined]
log_level = os.getenv("LOG_LEVEL", "INFO").upper()
logging.basicConfig(
level=log_level,
format="%(asctime)s.%(msecs)03d %(levelname)s %(module)s - %(funcName)s: %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
logger = logging.getLogger(__name__)
logger.setLevel(log_level)
UNIFI_HOST = os.getenv("UNIFI_HOST")
UNIFI_SITE_ID = os.getenv("UNIFI_SITE_ID")
UNIFI_API_TOKEN = os.getenv("UNIFI_API_TOKEN")
UNIFI_VERIFY_SSL = os.getenv("UNIFI_VERIFY_SSL", "false").lower() == "true"
CONFIG_FILE = os.getenv("CONFIG_FILE", "config/rules.yaml")
NTFY_URL = os.getenv("NTFY_URL", "")
NTFY_TOPIC = os.getenv("NTFY_TOPIC", "")
NTFY_API_KEY = os.getenv("NTFY_API_KEY", "")
class PortRange(TypedDict):
"""Port range definition."""
start: int
stop: int
class RuleConfig(TypedDict, total=False):
"""Firewall rule configuration from YAML."""
name: str
source_zone: str
dest_zone: str
ip_version: Literal["IPV4", "IPV6"]
action: Literal["ALLOW", "BLOCK", "REJECT"]
allow_return_traffic: bool
protocol: str | None
dest_ports: list[int] | None
dest_port_ranges: list[PortRange] | None
logging_enabled: bool
enabled: bool
description: str | None
class RulesConfig(TypedDict):
"""Top-level rules configuration."""
rules: list[RuleConfig]
class RuleChange(TypedDict):
"""Record of a rule change."""
rule_name: str
action: Literal["created", "updated", "skipped", "failed"]
ip: str
error: str | None
def send_ntfy_notification(title: str, message: str, priority: int = 3) -> None:
"""Send an NTFY notification."""
if not NTFY_URL or not NTFY_TOPIC:
return
try:
headers = {
"Title": title,
"Priority": str(priority),
}
if NTFY_API_KEY:
headers["Authorization"] = f"Bearer {NTFY_API_KEY}"
logger.info("Sending NTFY notification: %s", title)
response = requests.post(
f"{NTFY_URL}/{NTFY_TOPIC}",
data=message.encode(),
headers=headers,
timeout=10,
)
response.raise_for_status()
logger.info("NTFY notification sent: %s", title)
except requests.RequestException as e:
logger.warning("Failed to send NTFY notification: %s", e)
def load_config(path: str) -> list[RuleConfig]:
"""Load firewall rules from YAML config file."""
logger.info("Loading config file: %s", path)
try:
with open(path) as f:
config: RulesConfig = yaml.load(f, Loader)
rules = config.get("rules", [])
logger.debug("Loaded %d rule(s) from config", len(rules))
return rules
except FileNotFoundError as e:
logger.error("Config file not found: %s", e)
sys.exit(1)
except yaml.YAMLError as e:
logger.error("Failed to parse config file: %s", e)
sys.exit(1)
def build_zone_map(zones: list[FirewallZone]) -> dict[str, str]:
"""Build a mapping of zone names to zone IDs."""
zone_map: dict[str, str] = {}
for zone in zones:
zone_map[zone["name"]] = zone["id"]
logger.debug("Zone map: %s", zone_map)
return zone_map
def policy_matches_config(
existing_policy: FirewallPolicy,
rule: RuleConfig,
zone_map: dict[str, str],
public_ip: str,
) -> bool:
"""Check if an existing policy matches the desired config exactly."""
# 1. Source IP
existing_ip = get_source_ip_from_policy(existing_policy)
if existing_ip != public_ip:
return False
# 2. Dest ports (compare as sorted lists)
existing_ports_result = get_dest_ports_from_policy(existing_policy)
rule_dest_ports = rule.get("dest_ports") or []
if existing_ports_result is None:
if rule_dest_ports:
return False
else:
existing_ports, existing_ranges = existing_ports_result
if sorted(existing_ports) != sorted(rule_dest_ports):
return False
# 3. Dest port ranges
rule_dest_ranges = rule.get("dest_port_ranges") or []
if len(existing_ranges) != len(rule_dest_ranges):
return False
for er, rr in zip(sorted(existing_ranges, key=lambda x: x["start"]), sorted(rule_dest_ranges, key=lambda x: x["start"])):
if er["start"] != rr["start"] or er["stop"] != rr["stop"]:
return False
# 4. Protocol (case-insensitive)
existing_protocol = get_protocol_from_policy(existing_policy)
rule_protocol = rule.get("protocol")
if existing_protocol is not None and rule_protocol is not None:
if existing_protocol.upper() != rule_protocol.upper():
return False
elif existing_protocol is not None or rule_protocol is not None:
return False
# 5. Action type
existing_action_result = get_action_from_policy(existing_policy)
if existing_action_result is None:
return False
existing_action_type, existing_allow_return = existing_action_result
if existing_action_type != rule.get("action", "ALLOW"):
return False
# 6. allowReturnTraffic
if existing_action_type == "ALLOW" and existing_allow_return != rule.get("allow_return_traffic", True):
return False
# 7. Source zone ID
rule_source_zone = rule.get("source_zone", "WAN")
if existing_policy["source"]["zoneId"] != zone_map.get(rule_source_zone):
return False
# 8. Dest zone ID
rule_dest_zone = rule.get("dest_zone", "LAN")
if existing_policy["destination"]["zoneId"] != zone_map.get(rule_dest_zone):
return False
# 9. IP version
if existing_policy["ipProtocolScope"]["ipVersion"] != rule.get("ip_version", "IPV4"):
return False
# 10. enabled
if existing_policy["enabled"] != rule.get("enabled", True):
return False
# 11. loggingEnabled
if existing_policy["loggingEnabled"] != rule.get("logging_enabled", False):
return False
# 12. description
return existing_policy.get("description") == rule.get("description")
def process_rule(
session: requests.Session,
host: str,
site_id: str,
rule: RuleConfig,
zone_map: dict[str, str],
public_ip: str,
) -> RuleChange:
"""Process a single firewall rule: create, update, or skip."""
rule_name = rule.get("name", "unnamed")
prefixed_name = f"{RULE_NAME_PREFIX}{rule_name}"
ip_version = rule.get("ip_version", "IPV4")
source_zone = rule.get("source_zone", "WAN")
dest_zone = rule.get("dest_zone", "LAN")
logger.info("=== Processing rule: %s ===", prefixed_name)
# Get zone IDs
src_zone_id = zone_map.get(source_zone)
dst_zone_id = zone_map.get(dest_zone)
if not src_zone_id:
error_msg = f"Source zone '{source_zone}' not found"
logger.error(error_msg)
return {"rule_name": prefixed_name, "action": "failed", "ip": public_ip, "error": error_msg}
if not dst_zone_id:
error_msg = f"Destination zone '{dest_zone}' not found"
logger.error(error_msg)
return {"rule_name": prefixed_name, "action": "failed", "ip": public_ip, "error": error_msg}
# Find existing policy by prefixed name
try:
existing_policies = list_policies(session, host, site_id, name_filter=prefixed_name)
except Exception as e: # noqa: BLE001
error_msg = f"Failed to list policies: {e}"
logger.error(error_msg)
return {"rule_name": prefixed_name, "action": "failed", "ip": public_ip, "error": error_msg}
existing_policy = existing_policies[0] if existing_policies else None
# Migration: if not found by prefixed name, search for unprefixed name
if not existing_policy:
try:
existing_policies = list_policies(session, host, site_id, name_filter=rule_name)
existing_policy = existing_policies[0] if existing_policies else None
except Exception as e: # noqa: BLE001
error_msg = f"Failed to list policies: {e}"
logger.error(error_msg)
return {"rule_name": prefixed_name, "action": "failed", "ip": public_ip, "error": error_msg}
# Check if policy exists and matches config
if existing_policy:
if policy_matches_config(existing_policy, rule, zone_map, public_ip):
logger.info("Rule '%s' already matches config, skipping", prefixed_name)
return {"rule_name": prefixed_name, "action": "skipped", "ip": public_ip, "error": None}
# Update existing policy (IP or other attributes changed)
existing_ip = get_source_ip_from_policy(existing_policy)
logger.info("Rule '%s' exists but config changed (current IP: %s, desired IP: %s), updating",
prefixed_name, existing_ip, public_ip)
try:
payload = build_policy_payload(
name=prefixed_name,
source_ip=public_ip,
ip_version=ip_version, # type: ignore[arg-type]
src_zone_id=src_zone_id,
dst_zone_id=dst_zone_id,
action_type=rule.get("action", "ALLOW"), # type: ignore[arg-type]
allow_return_traffic=rule.get("allow_return_traffic", True),
protocol=rule.get("protocol"),
dest_ports=rule.get("dest_ports"),
dest_port_ranges=rule.get("dest_port_ranges"), # type: ignore[arg-type]
logging_enabled=rule.get("logging_enabled", False),
enabled=rule.get("enabled", True),
)
update_policy(session, host, site_id, existing_policy["id"], payload)
logger.info("Rule '%s' updated successfully", prefixed_name)
return {"rule_name": prefixed_name, "action": "updated", "ip": public_ip, "error": None}
except Exception as e: # noqa: BLE001
error_msg = f"Failed to update rule: {e}"
logger.error(error_msg)
return {"rule_name": prefixed_name, "action": "failed", "ip": public_ip, "error": error_msg}
# Create new policy
logger.info("Rule '%s' does not exist, creating with IP %s", prefixed_name, public_ip)
try:
payload = build_policy_payload(
name=prefixed_name,
source_ip=public_ip,
ip_version=ip_version, # type: ignore[arg-type]
src_zone_id=src_zone_id,
dst_zone_id=dst_zone_id,
action_type=rule.get("action", "ALLOW"), # type: ignore[arg-type]
allow_return_traffic=rule.get("allow_return_traffic", True),
protocol=rule.get("protocol"),
dest_ports=rule.get("dest_ports"),
dest_port_ranges=rule.get("dest_port_ranges"), # type: ignore[arg-type]
logging_enabled=rule.get("logging_enabled", False),
enabled=rule.get("enabled", True),
)
create_policy(session, host, site_id, payload)
logger.info("Rule '%s' created successfully", prefixed_name)
return {"rule_name": prefixed_name, "action": "created", "ip": public_ip, "error": None}
except Exception as e: # noqa: BLE001
error_msg = f"Failed to create rule: {e}"
logger.error(error_msg)
return {"rule_name": prefixed_name, "action": "failed", "ip": public_ip, "error": error_msg}
def main() -> None:
"""Main entry point."""
logger.info("=== UniFi Firewall Update Starting ===")
logger.debug("Log level: %s", log_level)
logger.debug("UNIFI_HOST: %s", UNIFI_HOST)
logger.debug("UNIFI_SITE_ID: %s", UNIFI_SITE_ID)
logger.debug("UNIFI_API_TOKEN: %s", "****" if UNIFI_API_TOKEN else "not set")
logger.debug("UNIFI_VERIFY_SSL: %s", UNIFI_VERIFY_SSL)
logger.debug("CONFIG_FILE: %s", CONFIG_FILE)
# Validate required env vars
if not all([UNIFI_HOST, UNIFI_SITE_ID, UNIFI_API_TOKEN]):
logger.error("UNIFI_HOST, UNIFI_SITE_ID, and UNIFI_API_TOKEN must be set!")
sys.exit(1)
assert UNIFI_HOST is not None
assert UNIFI_SITE_ID is not None
assert UNIFI_API_TOKEN is not None
# Load config
rules = load_config(CONFIG_FILE)
if not rules:
logger.warning("No rules found in config file")
return
# Fetch public IPs
public_ipv4 = get_ipv4()
if not public_ipv4:
logger.error("Failed to fetch public IPv4 address")
sys.exit(1)
logger.info("Public IPv4: %s", public_ipv4)
public_ipv6 = get_ipv6()
if not public_ipv6:
logger.error("Failed to fetch public IPv6 address")
sys.exit(1)
logger.info("Public IPv6: %s", public_ipv6)
# Create session and list zones
session = get_session(UNIFI_HOST, UNIFI_API_TOKEN, UNIFI_VERIFY_SSL)
try:
zones = list_zones(session, UNIFI_HOST, UNIFI_SITE_ID)
except Exception as e: # noqa: BLE001
logger.error("Failed to list firewall zones: %s", e)
sys.exit(1)
zone_map = build_zone_map(zones)
# Process rules
changes: list[RuleChange] = []
for rule in rules:
ip_version = rule.get("ip_version", "IPV4")
public_ip = public_ipv4 if ip_version == "IPV4" else public_ipv6
if not public_ip:
logger.warning("Skipping rule '%s': no %s address available", rule.get("name"), ip_version)
continue
change = process_rule(session, UNIFI_HOST, UNIFI_SITE_ID, rule, zone_map, public_ip)
changes.append(change)
# Summary
created = [c for c in changes if c["action"] == "created"]
updated = [c for c in changes if c["action"] == "updated"]
skipped = [c for c in changes if c["action"] == "skipped"]
failed = [c for c in changes if c["action"] == "failed"]
logger.info("=== Summary ===")
logger.info("Created: %d", len(created))
logger.info("Updated: %d", len(updated))
logger.info("Skipped: %d", len(skipped))
logger.info("Failed: %d", len(failed))
# Build NTFY message
ntfy_lines: list[str] = []
for c in created:
ntfy_lines.append(f"+ {c['rule_name']}: {c['ip']}")
for c in updated:
ntfy_lines.append(f"~ {c['rule_name']}: {c['ip']}")
for c in failed:
ntfy_lines.append(f"! {c['rule_name']}: {c['error']}")
if ntfy_lines:
ntfy_message = "\n".join(ntfy_lines)
has_changes = bool(created or updated)
has_failures = bool(failed)
if has_failures:
ntfy_title = "Firewall Update Failed"
ntfy_priority = 4
elif has_changes:
ntfy_title = "Firewall Rules Updated"
ntfy_priority = 4
else:
ntfy_title = "Firewall Rules Unchanged"
ntfy_priority = 2
send_ntfy_notification(ntfy_title, ntfy_message, priority=ntfy_priority)
logger.info("=== UniFi Firewall Update Complete ===")
if __name__ == "__main__":
main()