Verified against live UniFi controller on 2026-03-28. Controller firmware: UniFi Network Application (Dream Machine Pro).
The original spec and data-model.md assumptions were partially wrong. Key corrections:
- DNS v2 API supports PUT — the bash scripts use delete+recreate but that's a choice, not a constraint
- Firewall policies support PUT and PATCH (per OpenAPI spec) — but
indexis managed through a separate ordering endpoint - Integration API PUT requires stripping read-only fields (
id,metadata) from the request body - User-defined policies may have null IDs when listed — origin unclear, but Terraform-created policies will get UUIDs from POST
- DNS v2 has no GET-by-ID (405) — Read must list all and filter client-side
Base: /proxy/network/v2/api/site/{site}/static-dns
No OpenAPI spec — v2 API is undocumented.
| Operation | Method | Endpoint | Status | Notes |
|---|---|---|---|---|
| List | GET | /static-dns |
200 | Returns flat JSON array (not paginated) |
| Create | POST | /static-dns |
200 | Body: {enabled, key, record_type, value, port, priority, ttl, weight}. Returns created record with _id. |
| Read | GET | /static-dns/{_id} |
405 | Not supported. Must list all and filter by _id. |
| Update | PUT | /static-dns/{_id} |
200 | Full-object PUT. Body includes _id. Returns updated record. Verified via browser DevTools. |
| Delete | DELETE | /static-dns/{_id} |
200 | Deletes by _id. |
| Patch | PATCH | /static-dns/{_id} |
405 | Not supported. |
Terraform Read strategy: List all records, find by _id. Cache the list if multiple data sources reference DNS records in the same plan.
ID field: _id (MongoDB ObjectId format, e.g., 69a9e2bd51dbbbedd7525d5c). Included in both PUT request and response body.
PUT request body (verified):
{
"_id": "69a9e2bd51dbbbedd7525d5c",
"enabled": true,
"key": "talos.hyperfluid.dev",
"record_type": "A",
"value": "192.168.10.55",
"port": 0,
"priority": 0,
"ttl": 0,
"weight": 0
}Base: /proxy/network/api/s/{site}/rest/networkconf
No OpenAPI spec — legacy REST API is undocumented.
| Operation | Method | Endpoint | Status | Notes |
|---|---|---|---|---|
| List | GET | /rest/networkconf |
200 | Returns {data: [...], meta: {rc: "ok"}} |
| Create | POST | /rest/networkconf |
200 | Full-object POST. |
| Read | GET | /rest/networkconf/{_id} |
200 | Returns single network in {data: [...]} wrapper. |
| Update | PUT | /rest/networkconf/{_id} |
200 | Full-object PUT. Send current object with modifications. |
| Delete | DELETE | /rest/networkconf/{_id} |
200 | |
| Patch | PATCH | /rest/networkconf/{_id} |
404 | Not supported. |
Dual ID system:
_id: Internal MongoDB ObjectId (e.g.,6038209ad71e2805faf1c468). Used in REST API paths.external_id: UUID (e.g.,cc212f02-2406-432e-a394-d46dd8358955). Used by Integration API to reference networks (zonenetworkIds).
Key fields (from live LAN network):
_id, external_id, name, purpose, ip_subnet, vlan_enabled, networkgroup,
firewall_zone_id, dhcpd_enabled, dhcpd_start, dhcpd_stop, dhcpd_leasetime,
dhcpd_dns_1, dhcpd_dns_enabled, domain_name, enabled, is_nat, mdns_enabled,
igmp_snooping, internet_access_enabled, setting_preference, attr_no_delete,
attr_hidden_id, ipv6_enabled, ...
Terraform lifecycle notes:
- Create/Delete are technically supported but risky on production networks
- Primary use case: import existing networks, update settings (e.g., mDNS toggle)
attr_no_delete: trueon system networks (LAN, WAN) prevents deletion
Base: /proxy/network/api/s/{site}/rest/firewallgroup
No OpenAPI spec — legacy REST API is undocumented.
| Operation | Method | Endpoint | Status | Notes |
|---|---|---|---|---|
| List | GET | /rest/firewallgroup |
200 | Returns {data: [...]} |
| Create | POST | /rest/firewallgroup |
200 | Standard creation. |
| Read | GET | /rest/firewallgroup/{_id} |
200 | Single group in {data: [...]} wrapper. |
| Update | PUT | /rest/firewallgroup/{_id} |
200 | Full-object PUT. Verified live. |
| Delete | DELETE | /rest/firewallgroup/{_id} |
200 | Standard deletion. |
| Patch | PATCH | /rest/firewallgroup/{_id} |
404 | Not supported. |
Dual ID system (same pattern as networks):
_id: MongoDB ObjectId (e.g.,6039a6d8d71e2805faf1f025)external_id: UUID (e.g.,007f375c-1e74-440d-b898-56595f633ac0). Referenced by Integration API policies astrafficMatchingListId.
Response schema (from live):
{
"_id": "6039a6d8d71e2805faf1f025",
"external_id": "007f375c-1e74-440d-b898-56595f633ac0",
"site_id": "60382095d71e2805faf1c455",
"name": "K8s Pi",
"group_type": "address-group",
"group_members": ["192.168.1.30", "192.168.1.31", "192.168.1.32", "192.168.1.33"]
}group_type values: address-group (IP addresses), port-group (ports).
Base: /proxy/network/integration/v1/sites/{siteId}/firewall/zones
OpenAPI spec available at /proxy/network/api-docs/integration.json.
| Operation | Method | Endpoint | Status | Notes |
|---|---|---|---|---|
| List | GET | /firewall/zones |
200 | Paginated: {data: [...], offset, limit, count, totalCount} |
| Create | POST | /firewall/zones |
200 | Body: {name, networkIds} |
| Read | GET | /firewall/zones/{id} |
200 | Returns zone with id, name, networkIds, metadata. |
| Update | PUT | /firewall/zones/{id} |
200 | Body: {name, networkIds} only. Must strip id and metadata or 400. |
| Delete | DELETE | /firewall/zones/{id} |
200 | System-defined zones cannot be deleted. |
| Patch | — | — | 405 | Not in OpenAPI spec. Confirmed 405 on live. |
PUT request schema ("Create or update firewall zone"):
{
"name": "Trusted",
"networkIds": ["cc212f02-2406-432e-a394-d46dd8358955"]
}GET response schema ("Firewall zone"):
{
"id": "947ad1bc-fa12-46dd-a78b-801fbf7b15b1",
"name": "Trusted",
"networkIds": ["cc212f02-2406-432e-a394-d46dd8358955", "17b07c27-e8ab-40ee-a01d-29d617fe5f85"],
"metadata": {"origin": "USER_DEFINED"}
}Read-only fields (not accepted in PUT/POST):
id— assigned by controllermetadata—{origin: "SYSTEM_DEFINED"|"USER_DEFINED", configurable: bool}
System-defined zones (from live controller):
| Zone | Origin | Configurable | Notes |
|---|---|---|---|
| Gateway | SYSTEM_DEFINED | false | Cannot PUT or DELETE. 500 on PUT. |
| External | SYSTEM_DEFINED | false | Cannot PUT or DELETE. |
| Vpn | SYSTEM_DEFINED | false | Cannot PUT or DELETE. |
| Internal | SYSTEM_DEFINED | true | Can PUT (modify networkIds). Cannot DELETE. |
| Dmz | SYSTEM_DEFINED | true | Can PUT. Cannot DELETE. |
| Hotspot | SYSTEM_DEFINED | true | Can PUT. Cannot DELETE. |
| Trusted | USER_DEFINED | — | Full CRUD. |
| IoT | USER_DEFINED | — | Full CRUD. |
| Servers | USER_DEFINED | — | Full CRUD. |
Terraform lifecycle notes:
- System zones (
configurable: false): data source only. Cannot be managed as resources. - System zones (
configurable: true): import + update only. Create/Delete will fail. UseRequiresReplacecarefully. - User zones: full CRUD lifecycle.
Base: /proxy/network/integration/v1/sites/{siteId}/firewall/policies
OpenAPI spec available.
| Operation | Method | Endpoint | Status | Notes |
|---|---|---|---|---|
| List | GET | /firewall/policies |
200 | Paginated. Returns all policies (system + user). |
| Create | POST | /firewall/policies |
200 | Body uses "Create or update" schema. Returns created policy with id. |
| Read | GET | /firewall/policies/{id} |
200 | Returns full policy with id, index, metadata. |
| Update | PUT | /firewall/policies/{id} |
200 | Body: same schema as POST. Must strip id, metadata, index. |
| Delete | DELETE | /firewall/policies/{id} |
200 | System-defined policies cannot be deleted. |
| Patch | PATCH | /firewall/policies/{id} |
200 | Very limited: only {loggingEnabled: bool} per OpenAPI schema. |
| Get Order | GET | /firewall/policies/ordering?src={zoneId}&dst={zoneId} |
200 | Returns ordered policy IDs for a zone pair. |
| Set Order | PUT | /firewall/policies/ordering?src={zoneId}&dst={zoneId} |
200 | Body: {orderedFirewallPolicyIds: [...]} |
PUT request schema ("Create or update firewall policy"):
{
"enabled": true,
"name": "Allow Trusted→Servers",
"description": "optional description",
"action": {"type": "ALLOW", "allowReturnTraffic": true},
"source": {"zoneId": "...", "trafficFilter": null},
"destination": {"zoneId": "...", "trafficFilter": {"type": "PORT", ...}},
"ipProtocolScope": {"ipVersion": "IPV4_AND_IPV6"},
"connectionStateFilter": ["NEW", "ESTABLISHED"],
"ipsecFilter": null,
"loggingEnabled": false,
"schedule": null
}Required PUT/POST fields: enabled, name, action, source, destination, ipProtocolScope, loggingEnabled
Read-only fields (rejected with 400 if included in PUT/POST):
id—"Unknown request body property '$.id'"metadata—"Unknown request body property '$.metadata'"index—"Unknown request body property '$.index'"(managed via ordering endpoint)
System-defined policy restriction:
{
"code": "api.firewall.policy.operation.system-defined-firewall-policy-update-forbidden",
"message": "Modifications of system-defined firewall policy is not allowed"
}null for the id field. These appear to be policies created through the UI or migrated from the legacy firewall rules system. They cannot be individually addressed via the Integration API (no GET/PUT/DELETE by ID). Terraform-managed policies created via POST through the Integration API will receive proper UUIDs.
Evaluation order is managed through a separate endpoint, not through the policy resource itself.
GET /firewall/policies/ordering?sourceFirewallZoneId={id}&destinationFirewallZoneId={id}— returns ordered list of policy IDs for a zone pairPUT /firewall/policies/ordering?sourceFirewallZoneId={id}&destinationFirewallZoneId={id}— reorders policies for a zone pair. Body:{"orderedFirewallPolicyIds": ["id1", "id2", ...]}
Terraform implication: The index attribute should be Computed (read-only in Terraform) and populated from the GET response. To manage ordering, either:
- Create a separate
unifi_firewall_policy_orderingresource (cleaner, manages the ordering for a zone pair) - Handle index changes via the ordering endpoint inside the policy resource's Update method (more complex, risk of conflicts when multiple policies change order)
Per OpenAPI discriminator:
| Type | Fields | Notes |
|---|---|---|
ALLOW |
type, allowReturnTraffic (bool, required) |
allowReturnTraffic creates a derived policy for the mirrored zone pair |
BLOCK |
type only |
|
REJECT |
type only |
Source filters (discriminated by type):
PORT, NETWORK, MAC_ADDRESS, IP_ADDRESS, IPV6_IID, REGION, VPN_SERVER, SITE_TO_SITE_VPN_TUNNEL
Destination filters (discriminated by type):
PORT, NETWORK, IP_ADDRESS, IPV6_IID, REGION, VPN_SERVER, SITE_TO_SITE_VPN_TUNNEL, DOMAIN, APPLICATION, APPLICATION_CATEGORY
Currently used in Munhall deployment: IP_ADDRESS with TRAFFIC_MATCHING_LIST sub-type (referencing firewall groups by external_id), and PORT filters.
IP Address filter sub-types:
IP_ADDRESSES— inline IP listTRAFFIC_MATCHING_LIST— references a firewall group bytrafficMatchingListId(the group'sexternal_id)
Port filter sub-types:
PORTS— inline port listTRAFFIC_MATCHING_LIST— references a port group bytrafficMatchingListId
Both have matchOpposite: bool for "all except" logic.
Every resource MUST implement all 6 methods — this is a compile-time requirement:
| Method | When Called | Must Do |
|---|---|---|
| Metadata | Always | Set resp.TypeName |
| Schema | Always | Define attributes, types, plan modifiers |
| Create | terraform apply (new resource) |
API call → set full state via resp.State.Set() |
| Read | plan, apply, refresh |
API call → update state. Call resp.State.RemoveResource() if gone. |
| Update | terraform apply (changed resource) |
API call → set updated state |
| Delete | terraform apply (destroyed) |
API call → no state to set |
| Interface | Method | Use Case |
|---|---|---|
ResourceWithImportState |
ImportState |
Enable terraform import. Parse ID → set minimal state → framework calls Read. |
ResourceWithConfigure |
Configure |
Receive provider-level API client. |
ResourceWithModifyPlan |
ModifyPlan |
Custom plan-time logic, warnings. |
ResourceWithValidateConfig |
ValidateConfig |
Cross-attribute validation. |
Only 3 methods: Metadata, Schema, Read. No lifecycle management.
When the API has no PUT/PATCH (or when it's not desirable to update in-place):
- Mark all mutable attributes with
RequiresReplace()plan modifier - Leave
Update()as an error stub (never called since all changes force replacement) - Terraform handles destroy→create (or create-before-destroy) automatically
func (r *MyResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
resp.Diagnostics.AddError("Update Not Supported",
"All attribute changes require resource replacement.")
}For resources where the backing API supports Create but it's dangerous (e.g., creating VLANs on a production network):
- Implement
Create()fully (framework requires it) - Document the risk in the resource's schema description
- Consider adding a
lifecycle { prevent_destroy = true }recommendation in examples - ImportState is the primary onboarding path
| TF Method | API Call | Notes |
|---|---|---|
| Create | POST /static-dns |
Returns _id |
| Read | GET /static-dns (list) + filter by _id |
No single-record GET endpoint |
| Update | PUT /static-dns/{_id} |
Full-object PUT including _id in body |
| Delete | DELETE /static-dns/{_id} |
|
| ImportState | Passthrough _id |
All attributes mutable — no RequiresReplace needed.
| TF Method | API Call | Notes |
|---|---|---|
| Create | POST /rest/networkconf |
|
| Read | GET /rest/networkconf/{_id} |
Single-record GET works. |
| Update | PUT /rest/networkconf/{_id} |
Full-object PUT. |
| Delete | DELETE /rest/networkconf/{_id} |
attr_no_delete blocks system nets. |
| ImportState | Passthrough _id |
RequiresReplace on: purpose, vlan_enabled (changing these fundamentally alters the network).
| TF Method | API Call | Notes |
|---|---|---|
| Create | POST /rest/firewallgroup |
|
| Read | GET /rest/firewallgroup/{_id} |
|
| Update | PUT /rest/firewallgroup/{_id} |
Full-object PUT. |
| Delete | DELETE /rest/firewallgroup/{_id} |
|
| ImportState | Passthrough _id |
RequiresReplace on: group_type (can't change address-group → port-group).
| TF Method | API Call | Notes |
|---|---|---|
| Create | POST /firewall/zones |
Body: {name, networkIds} |
| Read | GET /firewall/zones/{id} |
|
| Update | PUT /firewall/zones/{id} |
Body: {name, networkIds}. Strip id, metadata. |
| Delete | DELETE /firewall/zones/{id} |
System zones: delete blocked server-side. |
| ImportState | Passthrough id (UUID) |
System zone handling: Provider should check metadata.origin and metadata.configurable on Read. If origin == "SYSTEM_DEFINED" && configurable == false, Create/Update/Delete should return clear errors. Consider using a data source for non-configurable system zones.
| TF Method | API Call | Notes |
|---|---|---|
| Create | POST /firewall/policies |
Returns id (UUID). |
| Read | GET /firewall/policies/{id} |
Includes index, metadata. |
| Update | PUT /firewall/policies/{id} |
Strip id, metadata, index from body. |
| Delete | DELETE /firewall/policies/{id} |
System policies: blocked server-side. |
| ImportState | Passthrough id (UUID) |
index handling: Computed attribute, read-only. Managed via ordering endpoint.
PATCH limitation: Only loggingEnabled can be patched. Not useful for Terraform — use PUT for all updates.
Network._id ──(legacy)──> Network
Network.external_id ──(Integration API)──> Zone.networkIds[]
FirewallGroup._id ──(legacy)──> FirewallGroup
FirewallGroup.external_id ──(Integration API)──> Policy.source/destination.trafficFilter.trafficMatchingListId
Zone.id ──(Integration API)──> Policy.source.zoneId / Policy.destination.zoneId
Terraform cross-references:
unifi_firewall_policy.source_zone_id→unifi_firewall_zone.idunifi_firewall_policy.destination_zone_id→unifi_firewall_zone.id- Policy traffic filters reference firewall groups by
external_id(not_id) - Zone
networkIdsreference networks byexternal_id(not_id)
Implication: Both unifi_network and unifi_firewall_group must expose external_id as a Computed attribute so other resources can reference it.
| Artifact | Claim | Correction |
|---|---|---|
| data-model.md L133 | "No PUT endpoint — delete + recreate for updates" (for DNS) | Wrong. PUT works on DNS v2 API. _id is included in body. |
| data-model.md L133 | "verify this against live API" (for policies) | Resolved. PUT works. Must strip id, metadata, index. Ordering managed separately. |
| spec.md FR-009 | "CRUD + import" (implies simple CRUD) | Nuance: No GET-by-ID for DNS. Read requires list+filter. |
| spec.md FR-013 | "evaluation ordering" | Nuance: index is read-only on the policy. Ordering managed via separate endpoint per zone-pair. |
| tasks.md T026 | "Document API quirks: no PUT (delete+recreate for updates)" | Wrong. PUT works. Remove delete+recreate note. |
| tasks.md T045 | Lists only List/Create/Get/Delete (no Update) | Add UpdateFirewallPolicy() — PUT is confirmed working. |
| contracts/unifi_dns_record.hcl L2 | "POST/GET/DELETE" | Add PUT. Full CRUD supported. |
- Why do some user-defined policies have null IDs? Possibly created via UI or migrated from legacy firewall rules. Needs investigation — does POST always return an
id? - Policy ordering resource: Should this be a separate Terraform resource (
unifi_firewall_policy_ordering) or handled inline? Separate resource is cleaner but requires managing dependencies between policies and their ordering. - Network
firewall_zone_id: The network response includes afirewall_zone_idfield (69aad3dc51dbbbedd752883e) that doesn't match any Integration API zone UUID. This appears to be an internal reference. Should the provider expose this or ignore it?