Declarative reconciler + observability CLI for UniFi Network controllers (UDM Pro and similar). Folds an append-only event log into desired controller state and converges the controller toward it.
unictl is built to be useful to anyone running a UniFi Network controller. It is JSON-first and agent-friendly by default: every successful response is wrapped as {"$schema": "unifi-v1", "data": ...}, every failure is a structured error on stderr with stable exit codes. A single unictl --help-all call dumps the full command tree, flag list, exit-code matrix, and error envelope so an agent can learn the whole surface in one shot.
unictl sync— plans and applies both events (timestamped block/unblock actions) and declarative resources (VLANs, WLANs, firewall rules, port forwards) against the controller.unictl schema— dumps the CUE schemas for events, derived state, and resources.unictl version,unictl --help-all— release identifiers + machine-readable discovery.- Local API-key auth (
X-API-KEY) for UniFi Network 8.x+.
Read commands (list, get) and ephemeral writes (do kick, do reboot-port) are intentionally out of scope; they follow in later PRs.
Go:
go install github.com/gunk-dev/unictl/cmd/unictl@latest
Nix flake:
nix run github:gunk-dev/unictl -- version
nix profile install github:gunk-dev/unictl
A dev shell with Go, golangci-lint, and cue is available via nix develop.
In the UniFi OS console (UDM, UDR, Cloud Key, UniFi OS Server, etc.):
- Open Settings → Control Plane → Integrations.
- Click Create API Key, name it, copy it.
Older UniFi Network versions surfaced this under Settings → Admins & Users → API Keys; either path lands at the same key. Local API keys require Network 8.x+.
export UNIFI_HOST="https://10.0.0.1" # your controller URL
export UNIFI_API_KEY="...key..."
# Most home UDMs ship a self-signed cert; you'll likely need this:
export UNIFI_INSECURE=1 # or pass --insecure
This is the same TLS posture you've already accepted for kubectl and argocd against self-signed clusters: trust the host you can reach on your LAN, opt out of verification, or pin the cert yourself.
unictl sync examples/events.cue --dry-run
Sample output:
{
"$schema": "unifi-v1",
"data": {
"events": {
"plan": [
{
"op": "block",
"mac": "aa:bb:cc:dd:ee:01",
"reason": "Unpatched IoT camera, isolate until firmware update lands",
"before": "unblocked",
"after": "blocked",
"status": "planned"
}
],
"dry_run": true,
"applied": false,
"site": "default"
}
}
}
When the input also declares resources, the envelope adds a resources sub-plan next to events; either field may be absent if that axis has nothing to do.
Happy with the plan? Run it for real:
unictl sync examples/events.cue --apply
State changes are modeled as timestamped, declarative events:
events: [
{at: "2026-01-01T00:00:00Z", type: "block", mac: "aa:bb:cc:dd:ee:01", reason: "..."},
{at: "2026-01-02T00:00:00Z", type: "unblock", mac: "aa:bb:cc:dd:ee:01"},
]Events are append-only — they live in the log forever. The log is your audit trail by construction.
To answer "what should the controller look like right now?", unictl sorts the log by at, replays it event-by-event, and produces a DesiredState — currently a list of MACs that should be blocked, with reasons and expiries. Expired blocks lift themselves without needing a counter-event.
unictl sync diffs the folded desired state against the live controller and emits a plan. With --apply, the plan runs.
The other persistence model is persistent declarative resources — long-lived controller objects (VLANs/subnets, WLANs, firewall rules, port forwards) that you want to exist in a particular shape. Resources are matched by their user-stable name, not by controller-internal IDs. Anything unictl creates carries an app=unictl marker in the resource's note field so pruning can distinguish managed from unmanaged objects.
The split is intentional: events model time-bounded, action-shaped state ("block this MAC at 14:03 for the next 24h"); resources model the standing config of the network. Both live in the same unictl sync flow.
networks: [
{name: "iot", purpose: "corporate", subnet: "10.0.42.0/24", vlan: 42},
]
wlans: [
{name: "home-iot", network: "iot", security: "wpapsk", password_env: "WLAN_HOME_PSK", band: "both"},
]
firewall_rules: [
{name: "drop-iot-to-lan", action: "drop", ruleset: "LAN_IN", src_address: "10.0.42.0/24", dst_address: "10.0.0.0/24"},
]
port_forwards: [
{name: "home-assistant", source: "any", src_port: "8123", dst_address: "10.0.42.10", dst_port: "8123", protocol: "tcp"},
]WLAN passwords are never inline. Each WLAN names a password_env — an env var the reconciler reads at apply time. Dry-runs render the reference as @env:WLAN_HOME_PSK; unictl sync --apply fails loud if the env var is unset.
Pruning is non-destructive by default. Resources marked managed (app=unictl) but absent from the declared config stay in place unless you pass --prune. Unmanaged resources are never deleted.
unictl sync examples/ # dry-run: plans both events and resources
unictl sync examples/ --apply # applies the plan
unictl sync examples/ --apply --prune # also deletes managed-but-undeclared resources
| Command | Description |
|---|---|
unictl version |
Version + git SHA as JSON. |
unictl schema |
Dump the embedded CUE schemas. |
unictl --help-all |
Full command tree + flags + schemas + exit-code matrix as one JSON document. |
unictl sync <path> |
Load events (events*.cue) and/or resources (resources*.cue) from <path> and emit a plan. Defaults to --dry-run; pass --apply to mutate the controller. |
Flags common to sync:
--apply— execute the plan (default: dry-run).--prune— delete controller resources marked managed (app=unictl) but absent from the declared config. Default false.--insecure— skip TLS verification (or setUNIFI_INSECURE=1).--site— UniFi site short-name. Defaultdefault.
| Code | Meaning |
|---|---|
0 |
Success |
1 |
User error (bad input, auth) |
2 |
System error (network, controller unreachable) |
3 |
Partial success (some sync ops succeeded, some failed) |
Errors are JSON on stderr:
{
"error": {
"code": "NETWORK",
"message": "unifi: controller unreachable: ...",
"hint": "Check UNIFI_HOST reachability and TLS settings (most home UDMs need --insecure)"
}
}
Codes: AUTH, NETWORK, VALIDATION, CONTROLLER, INTERNAL.
- The
$schemadiscriminator on every successful response is the contract. Today it's"unifi-v1". - New fields may be appended to existing payloads. They will not be reordered or silently renamed.
- New error codes may be added; existing codes will not be repurposed.
- Exit code meanings are stable.
- A bump from
unifi-v1tounifi-v2will only happen for incompatible changes and will be called out in the release notes.
go test ./...to run the test suite.go build ./...to verify the binary compiles.cue vet ./schema/...to validate the schemas.- If you edit
schema/*.cue, rungo generate ./internal/embedschema/...to refresh the embedded copies.