A lightweight, self-hosted controller for UniFi access points. It replaces the UniFi Network Application on sites that only have UniFi APs. apctl is a single Rust binary with an embedded web UI and stores everything in SQLite. It speaks the APs' own inform protocol, so the APs need no changes.
Not affiliated with Ubiquiti Inc. UniFi is a trademark of Ubiquiti Inc.
- Monitoring of APs, radios, networks and clients, with a year of history
- Events, browser alerts, a connection log with per-stage timings, and roaming analysis
- AP syslog with full-text search
- Neighbouring networks, channel planning and nightly auto-channel
- SSID, AP and site settings editors with per-AP previews, plus schedules, speed limits and SNMP
- Adoption, firmware upgrades, device and client actions, a browser terminal on the APs (SSH), and encrypted backups
Tested with U6-Mesh APs on firmware 8.8, against Network Application 10.6.
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
These screenshots use mock data from informdump demo.
- Wireless meshing. apctl always sends the APs meshing off, whatever the imported settings say. Don't switch a site whose APs mesh (or need to) over to apctl yet.
- A tagged management VLAN, guest portals, Hotspot 2.0, and 6 GHz / Wi-Fi 7 features.
- Shadow (default): learns everything from a packet capture of an existing controller's traffic and never talks to the APs. A dry run compares that controller's config pushes with apctl's.
- Active (
--active): apctl is the controller. It uses 8080/tcp (inform), 5514/udp (syslog) and 3478/udp (STUN), and takes over the APs' keys frominformdump import. The configs it pushes name the ports it listens on, so APs sending syslog "to this controller" use the--syslog-listenport.
printf '%s' 'a long password' | docker run --rm -i ghcr.io/trexx/rust-apctl hash-password | sed 's/^/admin:/' > users
docker run -d -v apctl-data:/data -v $PWD/users:/users:ro -v $PWD/import.json:/import.json:ro -e APCTL_USERS_FILE=/users \
-e APCTL_SYSLOG_LISTEN=0.0.0.0:5514 -p 8090:8090 -p 8080:8080 -p 5514:5514/udp -p 3478:3478/udp \
ghcr.io/trexx/rust-apctl serve --active --keys /import.jsonimport.json is the snapshot informdump import wrote, readable only by its owner. The
container runs as uid 65532, which must be able to read it: sudo chown 65532 import.json
for Docker, podman unshare chown 65532 import.json for rootless Podman. Uploading the
same file on the Settings page brings the SSIDs, the settings editor and the client
names: an import names the clients apctl has not named after the other controller's
names, and keeps the names set in apctl. Of the other controller's clients, an import
keeps only those with a name, a note or a setting (speed limit, block, AP lock, fixed
address); the rest are only the MACs it ever saw.
On Kubernetes (active mode), with the APs' ports on the node and the data on local storage:
# kubectl create secret generic apctl --from-file=import.json --from-file=users
apiVersion: apps/v1
kind: StatefulSet
metadata: { name: apctl }
spec:
serviceName: apctl
selector: { matchLabels: { app: apctl } }
template:
metadata: { labels: { app: apctl } }
spec:
securityContext: { fsGroup: 65532 }
containers:
- name: apctl
image: ghcr.io/trexx/rust-apctl:0.17.0
args: [serve, --active, --keys, /secret/import.json, --users-file, /secret/users]
env:
- { name: APCTL_SYSLOG_LISTEN, value: "0.0.0.0:5514" }
ports:
- { name: http, containerPort: 8090 }
- { name: inform, containerPort: 8080, hostPort: 8080 }
- { name: syslog, containerPort: 5514, hostPort: 5514, protocol: UDP }
- { name: stun, containerPort: 3478, hostPort: 3478, protocol: UDP }
readinessProbe: { httpGet: { path: /healthz, port: http } }
volumeMounts:
- { name: data, mountPath: /data }
- { name: secret, mountPath: /secret, readOnly: true }
volumes:
- { name: secret, secret: { secretName: apctl } }
volumeClaimTemplates:
- metadata: { name: data }
spec: { accessModes: [ReadWriteOnce], resources: { requests: { storage: 2Gi } } }Expose port 8090 with a Service for the web UI. For shadow mode, drop --active and the
host ports, and feed it a capture with --shadow or --shadow-cmd. The
ghcr.io/trexx/rust-apctl-capture image is a tcpdump sidecar for that. It writes into the
pipe its FIFO names (creating it), capturing what FILTER selects (all traffic when
empty), and needs the NET_RAW and NET_ADMIN capabilities. Run it next to apctl in a pod
that sees the other controller's traffic (hostNetwork on its node), with an emptyDir
mounted at /capture in both containers, FIFO=/capture/pcap and
FILTER='tcp port 8080 or udp port 5514' (informs and syslog) on the sidecar, and
--shadow /capture/pcap on apctl. apctl serve --help lists every option.
Without --users-file (APCTL_USERS_FILE), apctl is read-only and needs no login: anyone
who can reach port 8090 sees everything except the Wi-Fi passphrases, the SNMP community
and backup downloads.
Behind a reverse proxy or ingress, list its addresses with --trusted-proxies
(APCTL_TRUSTED_PROXIES, IPs or CIDR ranges). Only then is its X-Forwarded-For header
used, so the login throttle sees each client rather than the proxy.
Try the UI without any APs. The demo is a made-up site with three APs and about twenty clients:
scripts/check.sh --web # builds the web UI (web/dist, with docker or podman); apctl embeds it
cargo build --release # apctl
cargo build --release -p informdump # on its own: it turns on serde_json features apctl is not built with
mkdir -p data # git-ignored, and kept out of image builds
target/release/informdump demo-state > data/demo.json
printf '%s' 'demo-password' | target/release/apctl hash-password | sed 's/^/admin:/' > data/users
target/release/apctl serve --data-dir data/demo --keys data/demo.json --users-file data/users \
--shadow-cmd "target/release/informdump demo --live" # charts fill in after a minute or twoOpen http://127.0.0.1:8090 and log in as admin with demo-password. Then upload
data/demo.json on the Settings page for the SSIDs, the client names and the settings
editor.
scripts/check.sh --web # lints and builds web/dist in the Dockerfile's Node image
cargo build --release -p controller # or: podman build --target runtime .crates/informdump decodes, imports, sanitises and simulates inform traffic. Run
informdump --help for its commands. informdump import reads the official controller's
MongoDB and needs cargo build --release -p informdump --features mongo.
Pushing a version tag (e.g. 0.15.1) makes CI publish the image; nothing else pushes it.
scripts/check.sh runs CI's Rust checks and scripts/check.sh --web its web check
(with APCTL_ENGINE, else docker, else podman; the files it writes are yours either way).
scripts/release.sh 0.15.1 runs them all and builds and checks the image locally before
the tag goes out (crates/controller/Cargo.toml and the Kubernetes example above must name
that version), and scripts/release.sh 0.15.1 --pin <manifest> afterwards waits for
CI's image and pins its digest in a manifest.






