455 lines
16 KiB
Python
455 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 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["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")
|
|
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 ===", rule_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": rule_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": rule_name, "action": "failed", "ip": public_ip, "error": error_msg}
|
|
|
|
# Find existing policy by name
|
|
try:
|
|
existing_policies = list_policies(session, host, site_id, name_filter=rule_name)
|
|
except Exception as e: # noqa: BLE001
|
|
error_msg = f"Failed to list policies: {e}"
|
|
logger.error(error_msg)
|
|
return {"rule_name": rule_name, "action": "failed", "ip": public_ip, "error": error_msg}
|
|
|
|
existing_policy = existing_policies[0] if existing_policies else None
|
|
|
|
# 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", rule_name)
|
|
return {"rule_name": rule_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",
|
|
rule_name, existing_ip, public_ip)
|
|
try:
|
|
payload = build_policy_payload(
|
|
name=rule_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", rule_name)
|
|
return {"rule_name": rule_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": rule_name, "action": "failed", "ip": public_ip, "error": error_msg}
|
|
|
|
# Create new policy
|
|
logger.info("Rule '%s' does not exist, creating with IP %s", rule_name, public_ip)
|
|
try:
|
|
payload = build_policy_payload(
|
|
name=rule_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", rule_name)
|
|
return {"rule_name": rule_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": rule_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()
|