← Kevin Yoder
Personal, Family & Home

UniFi IoT VLAN Automation

PowerShell tooling that provisions an isolated IoT VLAN, a 2.4 GHz SSID, and a zone-based stateful firewall on a UniFi gateway β€” so a 3D printer can be reached from the main LAN but can't reach back.

Built May 2026 Write-up Β· no live demo Single session Β· now dormant

βš™ How it works 🧩 Challenges πŸ“Š Results

01 Overview

A small "mini-Terraform" written in PowerShell 7 that stands up an isolated IoT segment on a UniFi UDM: one firewall zone, one VLAN-20 network (192.168.20.0/24 with its own DHCP scope), one 2.4 GHz WPA2-PSK SSID, and six zone-based firewall policies. It applies these as nine idempotent, dry-run-by-default steps against the UniFi Network integration API, tracks what it created in a JSON state file, can read every resource back to confirm it, and can tear the whole thing down in dependency-safe reverse order.

It is a personal homelab tool and a write-up, not a product β€” the value here is the infrastructure-as-code discipline applied to a consumer router, and the network-engineering reasoning behind the firewall.

dry-run

plan

Loops all nine steps with no -Apply switch: prints a risk-banner and the full request body for each, and writes nothing.

commit

apply

Runs a step with -Apply, records the returned resource id in state.json, and skips steps already applied β€” so re-runs are idempotent.

read-back

verify

Does a GET on every id in the state file and reports OK / FAIL per resource β€” a drift smoke-test against the live controller.

teardown

destroy

Deletes in reverse, FK-safe order (network before its zone), printing a delete plan first and removing keys one-by-one so partial failures are retryable.

02 Why I built it

I wanted to put an Anycubic 3D printer, and other IoT gear, on the network without exposing the main LAN to it. The firewall is the actual point: an explicit ALLOW for Internal→IoT, a stateful return-allow for established replies, and a BLOCK on new IoT→Internal connections — so I can reach the printer from my laptop, but the printer can never initiate a connection back into the house. Doing that by hand in the UniFi UI is easy to get subtly wrong and hard to reproduce, so I treated the router like infrastructure: a repeatable plan I could dry-run, apply, verify, and cleanly destroy.

03 What I built & how it works

Client-side scripts only β€” no daemon, no listening port. Everything is REST calls to the gateway's local API, gated behind a state file.

Config inputs .env Β· API key Β· URL Β· site Β· config.ps1 Β· all tunables plan.ps1 β†’ apply.ps1 -Step 1..9 [-Apply] banner β–Έ print JSON body β–Έ dry-run OR POST Invoke-UnifiApi Β· X-API-Key Β· SkipCertCheck POST UniFi UDM Β· UniFi OS / Network app /proxy/network/integration/v1 POST /sites/{site}/… β†’ response.id response.id state.json records each created resource id Β· JSON state file read ids verify.ps1 Β· destroy.ps1 verify Β· GET each id β†’ OK / FAIL destroy Β· DELETE Β· reverse Β· FK-safe

Fig. 1 β€” a shared PowerShell module loads the environment, signs each call with the local API key over a self-signed cert, and pretty-prints every request body before it is (optionally) sent. Nothing mutates the controller unless -Apply is passed.

  1. Zone & network β€” create an empty IoT firewall zone, then the VLAN-20 network; the controller back-populates the zone's network list, sidestepping a chicken-and-egg dependency.
  2. SSID β€” a 2.4 GHz WPA2-PSK network on the IoT VLAN (named to look like an ISP default, as light camouflage).
  3. Egress rules — allow IoT→Internet, allow IoT→gateway only on the ports it needs (DHCP / DNS / NTP), and block the management ports (SSH / HTTP / HTTPS / the admin port).
  4. The boundary — allow Internal→IoT, allow the established/related return path, and block new IoT→Internal connections. This last rule is the whole exercise.
  5. Verify or destroy β€” read every created resource back by id to confirm it exists, or tear the stack down in reverse dependency order when done.

04 πŸ›  Skills & tech used

Languages
PowerShell 7.5module authoring (Export-ModuleMember Β· ValidateSet / ValidateRange)JSONHCL / Terraform (drafted, then abandoned)
Networking
VLAN segmentationDHCP scope designzone-based firewall policyconntrack (NEW vs ESTABLISHED / RELATED)WPA2-PSK Β· PMF Β· band selectionmDNS forwarding
Infra / API
UniFi Network integration API v1legacy /api/s/default endpointsOpenAPI 3.1 spec miningself-signed cert handling
Techniques
hand-rolled IaC (plan / apply / verify / destroy)state file + idempotencyFK-ordered teardowndry-run safety engineeringAPI reverse-engineering by 400-error probingTLS-interception root-cause analysissecret hygiene (gitignore Β· rotation checklist)

05 Notable challenges & decisions

Most of the work was not writing the scripts β€” it was learning what a consumer gateway's undocumented API actually demands, and getting the firewall's state semantics right.

The pivot

Antivirus killed the Terraform provider

This started as Terraform via a community UniFi provider. The provider talks to Terraform over a localhost gRPC channel with mutual TLS, and the machine's antivirus was intercepting that loopback traffic β€” the plugin failed with x509: certificate signed by unknown authority, and disabling Terraform's auto-mTLS didn't help. Rather than fight the AV, I dropped Terraform and rewrote the whole thing as direct REST against the gateway's local API. The IaC discipline survived; the tool did not need Terraform to have it.

The firewall

"Allow return traffic" is not a stateful return

The obvious-looking allowReturnTraffic flag is rejected outright on a LAN→WAN rule, and on a LAN→LAN rule it is accepted but does not create the reverse allow you expect. Getting a printer reachable from the LAN, while still blocking printer-initiated connections, needed two explicit rules — one allowing the ESTABLISHED / RELATED return path, one blocking only NEW connections in the other direction — rather than trusting a single flag to do it.

The API

The published spec understates what the controller wants

The gateway's own OpenAPI spec lists far fewer required fields than the controller actually enforces β€” a network needs several extra booleans and a zone reference the spec never mentions, and Wi-Fi and policy objects each hide their own required fields. I reverse-engineered the real shape by sending requests and reading the 400 responses, one field at a time, plus a run of enum fixes (the values the docs give are frequently not the values the controller accepts).

Debugging

"The printer drops ping" was the firewall eating replies

After the rules went in, pings to the printer failed and it looked like the device was dropping ICMP. It wasn't β€” the NEW-only block was catching the echo replies on their way back. The fix followed a plain method: disable the suspect BLOCK rule, retest, and confirm the rule β€” not the printer β€” was the cause. A controller "force-provision" was also needed after firewall edits, because the controller's stored config and the gateway's live rules can quietly drift apart.

Designed around the bugs, not through them. The endpoint that reorders firewall policies returns a 500, so instead of relying on rule ordering I made the two boundary rules non-overlapping on connection state β€” NEW versus ESTABLISHED / RELATED β€” so their order never matters. And because the controller and gateway can diverge, an edit is followed by a force-provision that settles in about eight seconds.

06 Results

9
resources created & read back by id
6
zone-based firewall policies
0
controller writes without -Apply (dry-run by default)
4/4 Β· 2/2
Internal→IoT ping (phone · printer)
280
lines in the apply engine
2
commits β€” one build session

Sources: the session summary's live-resource inventory and verification log, and git log. A phone on the IoT SSID reached the internet but could not reach the main LAN β€” the boundary held in the direction that matters.

07 Screenshots

No live demo is published: the tool runs only from a machine on the home LAN, needs a real API key, and its destroy path would tear down the live printer VLAN. These are the surfaces a capture would show.

[ plan.ps1 terminal output ]
nine color-banded step headers Β· risk banners Β· pretty-printed JSON bodies
The dry-run: each step prints a LOW / MED / HIGH connectivity-risk banner and the exact request body it would send β€” and writes nothing.
[ verify.ps1 output ]
nine green "OK β€” <name>" lines against the live controller
Read-back verification: every state-file id resolved to a live resource.
[ UniFi firewall matrix ]
the IoT zone + its six policies Β· the printer on VLAN 20
The result in the controller UI: an isolated IoT zone with its policy set, and the printer sitting on the new VLAN.

08 Honest status

This was built in a single session in May 2026 β€” two commits, laptop-only, with no remote and therefore no off-machine backup of the repo. It provisioned the VLAN and firewall successfully and was verified working at the time, but it has not been re-run since, so whether the setup is still live today is unconfirmed. There are no automated tests, and the improvement backlog it lists for itself (Pester tests, drift detection, auto force-provision) is entirely unstarted. One documentation note still says "8 steps" where the tool actually runs 9 β€” a stale-doc bug worth fixing.

A caution I'm keeping honest: the working tree still contains live home-network credentials β€” an API key, a Wi-Fi passphrase, and a couple of others β€” that the project's own rotation checklist flags for replacement. Whether they were rotated is unverified, and none of them are reproduced anywhere on this page or in these screenshots. It is a personal homelab utility, presented as a write-up, not something wired up to run from here.

unifi-vlan β€” Build Recipe

Stand up an isolated IoT VLAN on a UniFi gateway from the command line: one firewall zone, one VLAN-20 network with DHCP, one 2.4 GHz WPA2 SSID, and six zone-based firewall policies β€” applied as 9 idempotent, dry-run-by-default steps (a small "mini-Terraform" whose apply engine is ~280 lines of PowerShell, plus helper scripts), with a JSON state file, a verify pass, and an FK-safe destroy.

Recipe-only project β€” this deviates from the usual 3-tier container template on purpose: these are client-side scripts that talk to a physical gateway, so there is nothing to containerize. The structure below is organized by what you can actually do, starting with a path that needs no hardware at all.

Status: Assembled and de-identified 2026-08-02 from the original working scripts; not re-executed for this recipe (no UniFi gateway on the build machine). What was previously verified: these same scripts (before de-identification of config.ps1) were applied to a live UDM (gen 1, Network 10.3.58, UniFi OS 5.0.146) on 2026-05-16/17 β€” verify.ps1 reported OK on all 9 created resources, 4/4 pings from the main LAN into the IoT VLAN succeeded, and IoT-initiated connections back into the main LAN were confirmed blocked. The zero-hardware dry-run path below was executed today from these exact shipped copies (dummy .env, no gateway): all 9 steps rendered their risk banners and full JSON bodies offline with no errors, and code inspection confirms no mutating code path touches the network without -Apply.

Sensitive data: the original config.ps1 contained a live SSID name, Wi-Fi passphrase, and three controller-specific zone UUIDs β€” all replaced with placeholders here. API keys were kept out of the scripts (.env-only, gitignored) β€” though two were once quoted in an original-repo notes file, which is why that build ended with a key-rotation checklist. Nothing key-shaped ships here; the shipped .env.example has placeholder values only.


What it is

File (in scripts/) Role
unifi.psm1 Helpers: .env loading with required-var checks, Invoke-UnifiApi (X-API-Key over the gateway's self-signed cert), state.json read/write, color-coded risk banner
config.ps1 The tunables in one place: zone UUIDs, VLAN/DHCP values, SSID, policy names (all but step 8's, which is fixed in apply.ps1)
apply.ps1 The engine β€” -Step 1..9 [-Apply]. Prints a connectivity-risk banner, the endpoint, and the full JSON body; without -Apply it stops there
plan.ps1 Dry-runs all 9 steps in order (prints every request body, sends nothing)
verify.ps1 GETs each resource ID recorded in state.json; reports OK/FAIL per resource
destroy.ps1 Deletes everything in reverse FK-safe order (policies β†’ wifi β†’ network β†’ zone); dry-run by default
.env.example Template for the gateway URL, API key, and site UUID
.gitignore Keeps .env, state.json, and raw API dumps out of git

Design points worth noticing: applied steps record their controller-assigned UUID in state.json and are skipped on re-run; later steps gate on earlier state (Require-State) and substitute <from-prior-step:key> placeholders in dry-run; a 4xx response exits with state untouched, since the request was rejected before any resource changed.

What gets created (the 9 steps)

  1. Zone β€” an empty IoT firewall zone (created empty first so the network can reference it; the controller back-fills the zone's network list)
  2. Network β€” IoT VLAN 20 on 192.168.20.0/24, DHCP .100–.250 (86400 s lease), mDNS forwarding on, attached to the zone from step 1
  3. SSID β€” 2.4 GHz-only WPA2-PSK broadcast bound to the new network
  4. Policy β€” IoT β†’ External ALLOW (internet access)
  5. Policy β€” IoT β†’ Gateway ALLOW, restricted to DHCP/DNS/NTP ports (67/68/53/123)
  6. Policy β€” IoT β†’ Gateway BLOCK on management ports (22/80/443/8443)
  7. Policy β€” Internal β†’ IoT ALLOW with allowReturnTraffic: true
  8. Policy β€” IoT β†’ Internal ALLOW for connectionStateFilter: [ESTABLISHED, RELATED] (the explicit stateful return path β€” see "Why the firewall is shaped this way")
  9. Policy β€” IoT β†’ Internal BLOCK for connectionStateFilter: [NEW] (the security boundary)

Prerequisites

Honestly hardware-gated β€” split by path:

Path You need
A β€” dry-run only (no hardware) PowerShell 7+. Nothing else β€” no gateway, no account, no key.
B β€” real apply PowerShell 7+, a UniFi gateway running Network app 10.x+ with the zone-based firewall already migrated (a one-time, non-reversible UI action), a local Network-app "Integrations" API key (⚠ not a cloud unifi.ui.com key β€” the two are different namespaces and not interchangeable), and LAN reach to the gateway. A wired connection is recommended for the apply phase, since you are editing Wi-Fi and firewall config.

Path A β€” zero hardware: dry-run the whole plan

This is the part any reviewer can do in about a minute:

cd scripts
Copy-Item .env.example .env      # fill with ANY dummy values β€” e.g.:
#   UNIFI_API=https://192.0.2.1
#   UNIFI_API_KEY=dummy
#   UNIFI_SITE=dummy
.\plan.ps1

You get all nine steps rendered in full: each one's color-coded connectivity-risk banner (LOW/MED/HIGH, with what changes and which devices are affected), the endpoint, and the exact JSON request body β€” including the <from-prior-step:...> placeholders where a later step would consume an earlier step's UUID.

Why this is safe to run: in dry-run mode Send-Or-Preview returns before Invoke-UnifiApi is ever called, the .env loader only checks that the three variables are set (it never contacts anything), and missing state is substituted with placeholders. No mutating code path touches the network without -Apply (verified by inspection β€” the one script that sends anything without it is the read-only verify.ps1, which only GETs, and with no state.json present it exits before making any call). This exact sequence was run offline on 2026-08-02 as part of assembling the recipe.

Path B β€” against a real UniFi gateway

  1. Migrate to the zone-based firewall (one-time, in the UI). Network 10.x ships ZBF but does not auto-enable it; the integration API returns api.firewall.zone-based-firewall-not-configured (400) until you migrate. There is no API endpoint for the migration β€” it is a banner/button under Settings β†’ Security. Note it is non-reversible (Ubiquiti auto-saves a pre-migration backup; know where your backups are before you start).
  2. Create a local API key. In the local Network app, the Integrations page is its own left-sidebar item (plug icon) in 10.x β€” not under Settings β†’ Control Plane, where older guides point. A key from unifi.ui.com will not work here: the local API returns a uniform 401 for cloud keys, with no useful error.
  3. Fill .env from .env.example: gateway URL (https://<gateway-ip>), the key from step 2, and your site UUID (Appendix A shows how to list sites).
  4. Discover your three built-in zone UUIDs (Internal / External / Gateway β€” every controller assigns its own; Appendix A) and paste them into config.ps1. Set your SSID name and a real passphrase there too.
  5. Plan, then apply one step at a time, reading each banner before committing:

pwsh .\plan.ps1 # full dry-run pass first .\apply.ps1 -Step 1 # dry-run a single step .\apply.ps1 -Step 1 -Apply # send it .\apply.ps1 -Step 2 -Apply # ... through -Step 9

  1. Verify: .\verify.ps1 GETs every ID in state.json and prints OK/FAIL.
  2. Force-provision if needed. The controller can accept a firewall change without immediately pushing it to the gateway's running packet filter (symptom: the API confirms the policy, but traffic still behaves as before). The legacy command endpoint accepts the same API key:

pwsh Import-Module .\unifi.psm1 -Force; Import-DotEnv # needed in a fresh shell β€” apply.ps1's import is script-scoped Invoke-UnifiApi -Method POST -Path '/proxy/network/api/s/default/cmd/devmgr' ` -Body @{ cmd = 'force-provision'; mac = '<your-gateway-mac>' }

Wait ~8 seconds for the gateway to settle, then re-test. 8. Test the boundary. Join a device to the new SSID, then: ping it from the main LAN (should answer, via steps 7+8), and from the IoT device try to open something on the main LAN (should fail, via step 9). This is the same check the original build used.

Teardown

.\destroy.ps1            # prints the delete plan (dry-run)
.\destroy.ps1 -Apply     # deletes in reverse order

Order matters and the script encodes it: the network must be deleted before the zone (the network holds a zoneId reference). A failed delete stays in state.json so it can be retried.

Why the firewall is shaped this way (steps 7/8/9)

UniFi's action.allowReturnTraffic: true flag looks like it should make a policy stateful, but on the tested controller it did not auto-create the reverse-direction allow β€” pings from the main LAN into the IoT VLAN timed out because the IoT β†’ Internal BLOCK dropped the reply packets. The working pattern (verified with live ping tests on the original build):

Internal β†’ IoT   ALLOW   allowReturnTraffic: true        (step 7)
IoT β†’ Internal   ALLOW   state: [ESTABLISHED, RELATED]   (step 8 β€” explicit return path)
IoT β†’ Internal   BLOCK   state: [NEW]                    (step 9 β€” boundary)

A side benefit: because steps 8 and 9 are non-overlapping on connection state, policy ordering never matters β€” which also side-steps the /firewall/policies/ordering PUT endpoint, which returned 500 on the tested controller.

Troubleshooting (field-tested against Network 10.3.58)

Most of these were reverse-engineered from 400 responses, because the controller's own OpenAPI spec understates what is required.

Symptom Cause / fix
POST β†’ 400 must not be null, one field at a time The OpenAPI spec is incomplete on required fields. Beyond the spec: networks also need cellularBackupEnabled, ipv4Configuration.autoScaleEnabled, dhcpConfiguration.pingConflictDetectionEnabled, and zoneId (when ZBF is on); WPA wifi broadcasts also need several extra flags β€” advertiseDeviceName, arpProxyEnabled, broadcastingFrequenciesGHz, bssTransitionEnabled, securityConfiguration.fastRoamingEnabled, and possibly others among the boolean toggles the shipped body carries (hideName, clientIsolationEnabled, multicastToUnicastConversionEnabled, uapsdEnabled); policies also need loggingEnabled and an explicit boolean action.allowReturnTraffic. The shipped bodies already include all of these.
Invalid $.type value 'CUSTOM' on wifi create Binding an SSID to a non-default network uses network: { type: 'SPECIFIC', networkId: ... } β€” SPECIFIC, not CUSTOM. Similarly the field is hideName, not hidden.
property-type-mismatch on pmfMode: 'DISABLED' The enum is only [REQUIRED, OPTIONAL] β€” to disable PMF, omit the field entirely.
Port filters rejected Discriminators are portFilter.type: 'PORTS' with items of type: 'PORT_NUMBER' (not VALUE/NUMBER, despite the schema names). Port items carry no protocol field β€” protocol matching lives at ipProtocolScope.protocolFilter.
cant-allow-return-traffic on an ALLOW policy allowReturnTraffic: true is rejected on WAN-bound (LAN β†’ External) policies β€” NAT handles replies there. Use false for that direction; the flag is accepted LAN-to-LAN.
LAN β†’ IoT pings time out even though policies look right The return path is missing β€” see steps 8/9 above. In general, distinguish "target drops ICMP" from "firewall drops the reply" by temporarily disabling the BLOCK and re-testing.
PUT /firewall/policies/ordering β†’ 500 Observed on the tested controller even with a schema-valid body. Avoid needing it: keep user policies non-overlapping on connectionStateFilter.
Uniform 401 on every call You are using a cloud (unifi.ui.com) key against the local endpoint. Create a key on the local Integrations page instead.
400 zone-based-firewall-not-configured The ZBF migration hasn't been run β€” Path B step 1.
Policy created, but traffic unchanged Controller/gateway config drift β€” force-provision (Path B step 7), wait ~8 s, re-test. This recurs; always re-test after firewall edits.
Integration API shows 0 clients on the new VLAN Its client list lags. The legacy endpoints (/proxy/network/api/s/default/stat/sta, /list/user) are the authoritative real-time view while debugging.

Known limitations (stated honestly)

  • Tested against exactly one controller: UDM gen 1, Network 10.3.58, UniFi OS 5.0.146, in May 2026. The required-field lists above were reverse-engineered from that controller's 400 responses; other hardware/versions may differ, and Ubiquiti moves UI items between releases.
  • No automated tests. Verification is the live verify.ps1 pass plus manual ping tests. The scripts were built and used in a single session.
  • -Apply mutates real network infrastructure. Dry-run first, apply one step at a time, and prefer a wired connection. The ZBF migration prerequisite is itself non-reversible.
  • The API client skips TLS verification unconditionally (SkipCertificateCheck) because the gateway serves a self-signed cert on the LAN. Reasonable for this use; not a pattern to copy for anything internet-facing.
  • Scope is deliberately small: one VLAN, one SSID, six policies. It is a worked example of driving the UniFi integration API idempotently, not a general-purpose UniFi provisioning tool.

Appendix A β€” discovering your controller's IDs

All of these run after Import-DotEnv with a filled .env (for the first one, any placeholder UNIFI_SITE value is fine β€” it isn't used yet):

Import-Module .\unifi.psm1 -Force; Import-DotEnv

# 1) Your site UUID (put it in .env as UNIFI_SITE)
Invoke-UnifiApi -Method GET -Path '/proxy/network/integration/v1/sites' |
  ConvertTo-Json -Depth 10

# 2) The built-in zone UUIDs (put Internal/External/Gateway into config.ps1)
$site = $env:UNIFI_SITE
Invoke-UnifiApi -Method GET -Path "/proxy/network/integration/v1/sites/$site/firewall/zones" |
  ConvertTo-Json -Depth 10

# 3) Optional: dump the full OpenAPI spec for the local integration API
#    (useful when extending apply.ps1 β€” but note it understates required fields)
Invoke-WebRequest "$($env:UNIFI_API)/proxy/network/api-docs/integration.json" `
  -Headers @{ 'X-API-Key' = $env:UNIFI_API_KEY; 'Accept' = 'application/json' } `
  -SkipCertificateCheck | Select-Object -ExpandProperty Content |
  Out-File -Encoding utf8 openapi_network_integration.json

# 4) Optional: existing networks (sanity-check before picking a VLAN ID/subnet)
Invoke-UnifiApi -Method GET -Path "/proxy/network/integration/v1/sites/$site/networks" |
  ConvertTo-Json -Depth 20

If you save raw dumps like these, keep them out of git β€” some legacy endpoints return live credentials in plaintext (the shipped .gitignore already excludes a discovery/ folder for exactly this reason).