Declarative firewall management for the UniFi Dream Machine Pro. Maintain zones and firewall policies in a single YAML file, diff against the live UDM, and apply changes through generated Ansible playbooks.
Operates like terraform plan / apply:
- You edit
desired.yml reconcile.py planpulls live state, diffs, generates an Ansible playbook- You review the playbook
reconcile.py applyruns it
Python handles diffing and playbook generation. Ansible (via the official
ubiquiti.unifi_api collection) handles execution against the UDM API.
- Python 3.12+
- Ansible with the
ubiquiti.unifi_apicollection installed - A UI API key generated on the UDM Pro (Settings > API)
- A
.envfile in the repo root:cp .env.example .env # edit .env with your API key
pip install requests pyyaml ansible
ansible-galaxy collection install ubiquiti.unifi_apiAll commands are run from the iac/ directory.
# See what would change (default subcommand)
python reconcile.py plan
# Diff only — no playbook generated
python reconcile.py diff
# Apply the latest generated playbook
python reconcile.py apply
# Dump current UDM state
python reconcile.py pull| Flag | Description |
|---|---|
--config PATH / -c PATH |
Path to desired state YAML (default: desired.yml) |
--force |
Proceed despite bulk-deletion safety warnings |
desired.yml ──────────┐
├── reconcile.py plan ──> generated/execution_<ts>.yml
UDM Pro (live state) ──┘ │
│ review the playbook
v
reconcile.py apply ──> ansible-playbook runs it
│
v
reconcile.py plan ──> "No changes needed" (convergence)
udm:
host: "https://192.168.0.1"
site_id: "default" # auto-discovers the real UUIDEach key is a zone name. Only zones listed here are managed; everything else on the UDM is left untouched.
zones:
Internal:
system: true # SYSTEM_DEFINED — only manage network assignments
networks: [infrastructure]
Server:
networks: [server] # USER_DEFINED — created if missing
Rental:
networks: [Rental]| Field | Type | Default | Description |
|---|---|---|---|
system |
bool | false |
If true, the zone is UniFi-managed. IaC will never create or delete it, only update network assignments. |
networks |
list of strings | [] |
Network names to assign to the zone. Names are resolved to UUIDs at runtime. |
Policies are evaluated in list order. The diff engine assigns each policy an index computed from its position:
index = index_base + (position * index_step)
policies:
index_base: 10000
index_step: 1
rules:
- name: "Admin Allow All to Server"
action: allow # "allow" or "block"
source: Admin # zone name
destination: Server # zone name
protocol: all # "all", "tcp", "udp", or "tcp_udp"
- name: "Family Allow Plex"
action: allow
source: Family
destination: Server
protocol: tcp
destination_port: "32400" # comma-separated port numbers| Field | Type | Default | Description |
|---|---|---|---|
name |
string | required | Policy name (must be unique). Used to match against existing policies. |
action |
string | "allow" |
"allow" or "block". |
source |
string | required | Source zone name. |
destination |
string | required | Destination zone name. |
protocol |
string | "all" |
"all" (no filter), "tcp", "udp", or "tcp_udp". |
destination_port |
string | — | Comma-separated port numbers (e.g. "80,443"). Omit for allow-all. |
enabled |
bool | true |
Whether the policy is active. |
allow_return_traffic |
bool | true |
Create matching return-traffic rule. |
Explicitly mark resources that should not exist. If present on the UDM, they are deleted. If already absent, no action is taken.
absent:
zones:
- "OldTestZone"
policies:
- "Old Duplicate Policy"System zones cannot be listed here (will error). Only USER_DEFINED policies are matched.
The diff engine runs safety checks before generating a playbook:
| Check | Severity | Description |
|---|---|---|
| Admin zone deletion | ERROR (blocks) | Refuses to delete the Admin zone |
| All Admin allow-all deleted | ERROR (blocks) | Refuses to delete every Admin allow-all policy at once |
| Bulk policy delete (>5) | WARNING | Requires --force |
| Bulk zone delete (>2) | WARNING | Requires --force |
| System zone in absent | ERROR (blocks) | Cannot delete SYSTEM_DEFINED zones |
The generated Ansible playbook orders operations for safety:
- Zone creates — new zones with empty network assignments
- Admin safety policies — Admin allow-all rules (lockout prevention)
- Zone network updates — assign networks to new and existing zones
- Policy creates — remaining new policies
- Policy updates — changed policies
- Policy deletes — in reverse order
- Zone deletes — in reverse order
- Policy reorder — per zone-pair ordering adjustments
iac/
desired.yml # Source of truth
reconcile.py # CLI entry point
api_client.py # UDM API v1 client (API-key auth)
pull_state.py # Fetch + normalize current state
diff_engine.py # Desired vs actual comparison
generate_playbook.py # Changeset -> Ansible playbook
generated/ # Output playbooks (one per plan run)
plans/completed/ # Archived design plans
- Networks are not fully managed. The reconciler controls zone membership (which networks belong to which zone) but does not create/modify network DHCP, subnet, or VLAN configuration.
- Policy reorder with new policies requires two passes. When new
policies are created in a zone-pair that also needs reordering, the
first
applycreates the policies and the secondplan/applyfixes the ordering (newly created policy IDs aren't known until after creation). - Predefined and derived policies (SYSTEM_DEFINED, DERIVED) are never modified. Only USER_DEFINED policies participate in diffing.