add prefix to rules for easy identification
Build and Push Container / build-and-push (push) Successful in 18s
Build and Push Container / build-and-push (push) Successful in 18s
This commit is contained in:
@@ -3,7 +3,7 @@
|
|||||||
## Dev commands (always run all three)
|
## Dev commands (always run all three)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv run pytest -v # 35 tests, all mocked (no live API calls)
|
uv run pytest -v # 50 tests, all mocked (no live API calls)
|
||||||
uv run ruff check # lint
|
uv run ruff check # lint
|
||||||
uv run pyright # strict type checking
|
uv run pyright # strict type checking
|
||||||
```
|
```
|
||||||
@@ -13,12 +13,15 @@ Order matters: fix lint/typecheck errors before touching tests.
|
|||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
- `main.py` — entry point: load config → fetch public IPs → process rules → NTFY notify
|
- `main.py` — entry point: load config → fetch public IPs → process rules → NTFY notify
|
||||||
|
- `config.py` — app constants (e.g., `RULE_NAME_PREFIX`)
|
||||||
- `unifi_firewall.py` — UniFi Network API v1 client (TypedDicts match `network_v10.4.57_openapi.json`)
|
- `unifi_firewall.py` — UniFi Network API v1 client (TypedDicts match `network_v10.4.57_openapi.json`)
|
||||||
- `ip_lookup.py` — fetches public IPv4/IPv6 via `curl ifconfig.me`
|
- `ip_lookup.py` — fetches public IPv4/IPv6 via `curl ifconfig.me`
|
||||||
- `config/rules.yaml` — rule definitions (zones, IPs, ports, actions)
|
- `config/rules.yaml` — rule definitions (zones, IPs, ports, actions)
|
||||||
|
|
||||||
API base: `{UNIFI_HOST}/proxy/network/integration/v1/sites/{UNIFI_SITE_ID}`. Auth: `X-API-Key` header.
|
API base: `{UNIFI_HOST}/proxy/network/integration/v1/sites/{UNIFI_SITE_ID}`. Auth: `X-API-Key` header.
|
||||||
|
|
||||||
|
Rule names from `config/rules.yaml` are prefixed with `AUTO-` at runtime (`config.py:RULE_NAME_PREFIX`). First run migrates any existing unprefixed rules by renaming them.
|
||||||
|
|
||||||
## Env vars
|
## Env vars
|
||||||
|
|
||||||
Required: `UNIFI_HOST`, `UNIFI_SITE_ID`, `UNIFI_API_TOKEN`
|
Required: `UNIFI_HOST`, `UNIFI_SITE_ID`, `UNIFI_API_TOKEN`
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
"""Application configuration constants."""
|
||||||
|
|
||||||
|
RULE_NAME_PREFIX = "AUTO-"
|
||||||
@@ -28,6 +28,7 @@ import requests
|
|||||||
import yaml
|
import yaml
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
from config import RULE_NAME_PREFIX
|
||||||
from ip_lookup import get_ipv4, get_ipv6
|
from ip_lookup import get_ipv4, get_ipv6
|
||||||
from unifi_firewall import (
|
from unifi_firewall import (
|
||||||
FirewallPolicy,
|
FirewallPolicy,
|
||||||
@@ -260,11 +261,12 @@ def process_rule(
|
|||||||
) -> RuleChange:
|
) -> RuleChange:
|
||||||
"""Process a single firewall rule: create, update, or skip."""
|
"""Process a single firewall rule: create, update, or skip."""
|
||||||
rule_name = rule.get("name", "unnamed")
|
rule_name = rule.get("name", "unnamed")
|
||||||
|
prefixed_name = f"{RULE_NAME_PREFIX}{rule_name}"
|
||||||
ip_version = rule.get("ip_version", "IPV4")
|
ip_version = rule.get("ip_version", "IPV4")
|
||||||
source_zone = rule.get("source_zone", "WAN")
|
source_zone = rule.get("source_zone", "WAN")
|
||||||
dest_zone = rule.get("dest_zone", "LAN")
|
dest_zone = rule.get("dest_zone", "LAN")
|
||||||
|
|
||||||
logger.info("=== Processing rule: %s ===", rule_name)
|
logger.info("=== Processing rule: %s ===", prefixed_name)
|
||||||
|
|
||||||
# Get zone IDs
|
# Get zone IDs
|
||||||
src_zone_id = zone_map.get(source_zone)
|
src_zone_id = zone_map.get(source_zone)
|
||||||
@@ -273,36 +275,46 @@ def process_rule(
|
|||||||
if not src_zone_id:
|
if not src_zone_id:
|
||||||
error_msg = f"Source zone '{source_zone}' not found"
|
error_msg = f"Source zone '{source_zone}' not found"
|
||||||
logger.error(error_msg)
|
logger.error(error_msg)
|
||||||
return {"rule_name": rule_name, "action": "failed", "ip": public_ip, "error": error_msg}
|
return {"rule_name": prefixed_name, "action": "failed", "ip": public_ip, "error": error_msg}
|
||||||
|
|
||||||
if not dst_zone_id:
|
if not dst_zone_id:
|
||||||
error_msg = f"Destination zone '{dest_zone}' not found"
|
error_msg = f"Destination zone '{dest_zone}' not found"
|
||||||
logger.error(error_msg)
|
logger.error(error_msg)
|
||||||
return {"rule_name": rule_name, "action": "failed", "ip": public_ip, "error": error_msg}
|
return {"rule_name": prefixed_name, "action": "failed", "ip": public_ip, "error": error_msg}
|
||||||
|
|
||||||
# Find existing policy by name
|
# Find existing policy by prefixed name
|
||||||
try:
|
try:
|
||||||
existing_policies = list_policies(session, host, site_id, name_filter=rule_name)
|
existing_policies = list_policies(session, host, site_id, name_filter=prefixed_name)
|
||||||
except Exception as e: # noqa: BLE001
|
except Exception as e: # noqa: BLE001
|
||||||
error_msg = f"Failed to list policies: {e}"
|
error_msg = f"Failed to list policies: {e}"
|
||||||
logger.error(error_msg)
|
logger.error(error_msg)
|
||||||
return {"rule_name": rule_name, "action": "failed", "ip": public_ip, "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
|
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
|
# Check if policy exists and matches config
|
||||||
if existing_policy:
|
if existing_policy:
|
||||||
if policy_matches_config(existing_policy, rule, zone_map, public_ip):
|
if policy_matches_config(existing_policy, rule, zone_map, public_ip):
|
||||||
logger.info("Rule '%s' already matches config, skipping", rule_name)
|
logger.info("Rule '%s' already matches config, skipping", prefixed_name)
|
||||||
return {"rule_name": rule_name, "action": "skipped", "ip": public_ip, "error": None}
|
return {"rule_name": prefixed_name, "action": "skipped", "ip": public_ip, "error": None}
|
||||||
|
|
||||||
# Update existing policy (IP or other attributes changed)
|
# Update existing policy (IP or other attributes changed)
|
||||||
existing_ip = get_source_ip_from_policy(existing_policy)
|
existing_ip = get_source_ip_from_policy(existing_policy)
|
||||||
logger.info("Rule '%s' exists but config changed (current IP: %s, desired IP: %s), updating",
|
logger.info("Rule '%s' exists but config changed (current IP: %s, desired IP: %s), updating",
|
||||||
rule_name, existing_ip, public_ip)
|
prefixed_name, existing_ip, public_ip)
|
||||||
try:
|
try:
|
||||||
payload = build_policy_payload(
|
payload = build_policy_payload(
|
||||||
name=rule_name,
|
name=prefixed_name,
|
||||||
source_ip=public_ip,
|
source_ip=public_ip,
|
||||||
ip_version=ip_version, # type: ignore[arg-type]
|
ip_version=ip_version, # type: ignore[arg-type]
|
||||||
src_zone_id=src_zone_id,
|
src_zone_id=src_zone_id,
|
||||||
@@ -316,18 +328,18 @@ def process_rule(
|
|||||||
enabled=rule.get("enabled", True),
|
enabled=rule.get("enabled", True),
|
||||||
)
|
)
|
||||||
update_policy(session, host, site_id, existing_policy["id"], payload)
|
update_policy(session, host, site_id, existing_policy["id"], payload)
|
||||||
logger.info("Rule '%s' updated successfully", rule_name)
|
logger.info("Rule '%s' updated successfully", prefixed_name)
|
||||||
return {"rule_name": rule_name, "action": "updated", "ip": public_ip, "error": None}
|
return {"rule_name": prefixed_name, "action": "updated", "ip": public_ip, "error": None}
|
||||||
except Exception as e: # noqa: BLE001
|
except Exception as e: # noqa: BLE001
|
||||||
error_msg = f"Failed to update rule: {e}"
|
error_msg = f"Failed to update rule: {e}"
|
||||||
logger.error(error_msg)
|
logger.error(error_msg)
|
||||||
return {"rule_name": rule_name, "action": "failed", "ip": public_ip, "error": error_msg}
|
return {"rule_name": prefixed_name, "action": "failed", "ip": public_ip, "error": error_msg}
|
||||||
|
|
||||||
# Create new policy
|
# Create new policy
|
||||||
logger.info("Rule '%s' does not exist, creating with IP %s", rule_name, public_ip)
|
logger.info("Rule '%s' does not exist, creating with IP %s", prefixed_name, public_ip)
|
||||||
try:
|
try:
|
||||||
payload = build_policy_payload(
|
payload = build_policy_payload(
|
||||||
name=rule_name,
|
name=prefixed_name,
|
||||||
source_ip=public_ip,
|
source_ip=public_ip,
|
||||||
ip_version=ip_version, # type: ignore[arg-type]
|
ip_version=ip_version, # type: ignore[arg-type]
|
||||||
src_zone_id=src_zone_id,
|
src_zone_id=src_zone_id,
|
||||||
@@ -341,12 +353,12 @@ def process_rule(
|
|||||||
enabled=rule.get("enabled", True),
|
enabled=rule.get("enabled", True),
|
||||||
)
|
)
|
||||||
create_policy(session, host, site_id, payload)
|
create_policy(session, host, site_id, payload)
|
||||||
logger.info("Rule '%s' created successfully", rule_name)
|
logger.info("Rule '%s' created successfully", prefixed_name)
|
||||||
return {"rule_name": rule_name, "action": "created", "ip": public_ip, "error": None}
|
return {"rule_name": prefixed_name, "action": "created", "ip": public_ip, "error": None}
|
||||||
except Exception as e: # noqa: BLE001
|
except Exception as e: # noqa: BLE001
|
||||||
error_msg = f"Failed to create rule: {e}"
|
error_msg = f"Failed to create rule: {e}"
|
||||||
logger.error(error_msg)
|
logger.error(error_msg)
|
||||||
return {"rule_name": rule_name, "action": "failed", "ip": public_ip, "error": error_msg}
|
return {"rule_name": prefixed_name, "action": "failed", "ip": public_ip, "error": error_msg}
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
|
|||||||
+1
-1
@@ -126,7 +126,7 @@ class TestProcessRule:
|
|||||||
change = process_rule(mock_session, "https://unifi.local", "site-1", rule, zone_map, "1.2.3.4")
|
change = process_rule(mock_session, "https://unifi.local", "site-1", rule, zone_map, "1.2.3.4")
|
||||||
|
|
||||||
assert change["action"] == "created"
|
assert change["action"] == "created"
|
||||||
assert change["rule_name"] == "Test Rule"
|
assert change["rule_name"] == "AUTO-Test Rule"
|
||||||
assert change["error"] is None
|
assert change["error"] is None
|
||||||
|
|
||||||
def test_skips_rule_with_matching_config(self) -> None:
|
def test_skips_rule_with_matching_config(self) -> None:
|
||||||
|
|||||||
Reference in New Issue
Block a user