emu

package module
v0.5.5 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 2, 2026 License: MIT Imports: 17 Imported by: 0

README

unifi-emu

Fake UniFi devices that speak the real inform protocol and get adopted by a real controller. It gives an integration test a deterministic fleet of APs, switches, and gateways — controllable, reproducible, and without any hardware.

Why it exists

Anything that drives a UniFi controller — go-unifi, the Terraform provider, your own tooling — needs devices on that controller that are genuinely connected and adoptable. Real hardware is slow and non-deterministic. Seeding the controller's database doesn't work either: a device injected that way shows up permanently disconnected, because the controller derives connection state from a live inform heartbeat.

The only way in is the real path: a device that informs, gets adopted, and keeps informing to stay connected. unifi-emu is that device, in software — a whole fleet of them, deterministic and scriptable.

What you get

  • A Go library (package emu) — build a fleet and drive it: New, Add, Start, State, WaitState, Stop.
  • A CLI (unifi-emu) — run devices from flags, a fleet file, or a terse model list like U7PRO,USM8P:2,UGW3.
  • A container image — docker run a fleet against a controller, and optionally have it adopt the fleet itself, so nothing outside needs an adopt client.
  • A herder (unifi-emu-herder) — start a fleet as containers on a Docker network you already own, and read back their MACs, serials, and addresses as NDJSON on stdout.

It covers the current UniFi AP, switch, and gateway lineup, adopts all the way to connected against a real controller, and survives a controller-requested firmware upgrade by faking the reboot.

Quick start

go test ./...                                  # unit tests, no runtime needed

docker build -t unifi-emu:dev .
docker run --rm unifi-emu:dev -inform http://CONTROLLER:8080/inform

The image bakes a default fleet, so a bare docker run boots it. Pick models with -e SIM_MODELS=U7PRO,USM8P:2,UGW3 (MAC and IP are derived), or hand it a full fleet with -e SIM_DEVICES="$(cat fleet.yaml)".

Pull the image or install the herder:

docker pull ghcr.io/jamesbraid/unifi-emu:latest
go install github.com/jamesbraid/unifi-emu/cmd/unifi-emu-herder@latest

The usage guide covers self-adoption, running as a CI service, and the integration suite.

The one rule

A device enters a controller only through the real inform and adoption lifecycle — never by seeding the database. A database-injected device renders permanently disconnected. The whole point is real, connected, adoptable devices.

Documentation

  • Usage guide — CLI flags, self-adoption settings, running as a CI service, and the integration suite.
  • Protocol spec — the wire-level UniFi device protocol (inform and L2 discovery), for reusing or porting it to other firmware.
  • Design — architecture, and how it plugs into go-unifi and the Terraform provider.

License

MIT — see LICENSE.

Documentation

Overview

Package emu emulates UniFi devices (UAP/USW/UGW) against a real UniFi controller using the inform protocol.

Index

Constants

View Source
const DefaultKey = inform.DefaultKey

DefaultKey is the inform authkey of unadopted devices.

Variables

This section is empty.

Functions

func Models added in v0.2.0

func Models() []string

Models returns the names of every model in the generated registry, sorted for a stable order. Read-only view for callers that enumerate the known models, such as a UI listing what the emulator can pretend to be.

func ResolveInformURL added in v0.3.1

func ResolveInformURL(ctx context.Context, raw string) (string, error)

ResolveInformURL rewrites raw's host to its resolved IPv4 address so the inform_url a device reports survives controller-side validation. Recent controllers reject an inform whose host is not an IP they recognize ("invalid inform_ip <host>", HTTP 400 once adoption starts), which deadlocks the device in ADOPTING when the fleet is pointed at a DNS name such as http://unifi:8080/inform. Dialing the resolved IP is equivalent and reports an inform_url the controller accepts.

IP literals (v4 or v6) pass through unchanged. A malformed URL, a missing host, or a hostname with no IPv4 address is an error, so a caller can fail fast before starting the fleet rather than stall at adoption. New keeps its informURL verbatim, so a caller handing it a hostname should resolve here first; the CLI does exactly this at startup. The caller owns the timeout via ctx.

Types

type DeviceSpec

type DeviceSpec struct {
	MAC string `json:"mac" yaml:"mac"`
	// Serial is the reported serial number. Empty derives it from the MAC
	// the way the emulator always has. It exists because a caller that
	// allocates identities up front (the device-container herder) has
	// already chosen the serial and has to see the same one come back:
	// a MAC-derived serial would silently disagree with its own records.
	Serial       string `json:"serial" yaml:"serial"`
	Type         string `json:"type" yaml:"type"`
	Model        string `json:"model" yaml:"model"`
	ModelDisplay string `json:"modeldisplay" yaml:"modeldisplay"`
	Version      string `json:"version" yaml:"version"`
	Name         string `json:"name" yaml:"name"`
	IP           string `json:"ip" yaml:"ip"`
	Ports        int    `json:"ports" yaml:"ports"` // overrides the profile port layout when > 0
	// SSIDs opts the AP into emitting vaps. Empty by default: this
	// controller build rejects default vaps with log noise until a
	// setstate provisions real WLAN config (the setstate echo path
	// overlays vap_table), so devices inform with an empty vap_table.
	SSIDs []string `json:"ssids" yaml:"ssids"`
	// FWCaps overrides the firmware capability bitmap. Every device
	// reports the same fw_caps today, and the value is a placeholder: the
	// controller tests 22 distinct bits against fw_caps and not one of
	// them is a bit the default sets, so what ships is a claim to nothing
	// rather than a conservative claim. This exists to measure what a
	// faithful value would change before any is adopted as a default.
	// Nil leaves the built-in alone; 0 reports the key as zero.
	FWCaps *int `json:"fwcaps" yaml:"fwcaps"`
}

DeviceSpec describes one emulated device. Type, ModelDisplay and Version default from the model profile when empty; Name defaults to "UBNT". An explicit Type must equal the profile's: the profile drives the payload shape, so a mismatched Type would describe an incoherent device and is an error, not an override.

The json/yaml tags are the fleet-file contract (unifi-emu -devices, SIM_DEVICES); keep the two families identical so either format names the same keys.

func (*DeviceSpec) UnmarshalYAML added in v0.2.0

func (d *DeviceSpec) UnmarshalYAML(node *yaml.Node) error

UnmarshalYAML lets a fleet-list entry be a bare model string ("U7PRO") or a full mapping. A scalar becomes {Model: scalar}; a mapping decodes the known keys and rejects any other. JSON files parse through the same YAML decoder, so both formats get this behaviour.

type DeviceState

type DeviceState int

DeviceState is the adoption state of an emulated device.

const (
	StatePending DeviceState = iota
	StateAdopting
	StateConnected
)

func (DeviceState) String

func (s DeviceState) String() string

type Emu

type Emu struct {
	// contains filtered or unexported fields
}

Emu is a fleet of emulated UniFi devices informing one controller.

func New

func New(informURL string, opts ...Option) *Emu

New builds a fleet whose devices will inform informURL. informURL is kept verbatim and reported to the controller as each device's inform_url. A controller rejects an inform whose host is not an IP ("invalid inform_ip <host>") once adoption starts, so a caller passing a hostname should resolve it to an IP first with ResolveInformURL; IP literals need no such step.

func (*Emu) Add

func (e *Emu) Add(specs ...DeviceSpec) error

Add validates specs and adds them to the fleet. MACs are normalized before keying, so the same device added twice errors however it was spelled. The first invalid spec aborts the call; earlier specs stay added. Add errors once Start has been called: a running fleet is fixed, and devices added after Start would never be launched.

func (*Emu) Start

func (e *Emu) Start(ctx context.Context) error

Start launches one inform goroutine per device, all tied to ctx. Start is one-shot: a second Start errors "emu: already started" even after Stop — that is intended, build a fresh fleet with New to restart. Starting an empty fleet errors rather than welding it shut: once started, Add rejects new devices.

func (*Emu) State

func (e *Emu) State(mac string) (DeviceState, bool)

State reports the adoption state of one device, ok=false when mac is unknown to the fleet or unparseable.

func (*Emu) Stop

func (e *Emu) Stop()

Stop cancels every device loop and waits for them to return. It is safe to call more than once.

func (*Emu) WaitState

func (e *Emu) WaitState(ctx context.Context, mac string, want DeviceState) error

WaitState polls every 10ms until mac reaches want or ctx is done. The timeout error names the last observed state so a stalled adoption tells the caller where it stalled.

type ModelProfile

type ModelProfile struct {
	Model        string `json:"model"`
	ModelDisplay string `json:"model_display"`
	Type         string `json:"type"` // "ugw", "uxg", "usw", "uap"
	Version      string `json:"version"`
	// UDAPIVersion and UDAPICaps describe the UDAPI config plane, and a
	// model either has both or neither: a device reporting the bitmap
	// with no version has its whole capability update dropped by the
	// controller. Set only for models Ubiquiti documents as having the
	// capability, so most profiles carry neither.
	UDAPIVersion string `json:"udapi_version,omitempty"`
	UDAPICaps    int    `json:"udapi_caps,omitempty"`
	// FWCaps is the firmware capability bitmap, captured from real
	// hardware on this model's firmware. Unset for a firmware nobody has
	// captured, which leaves the device on the built-in placeholder --
	// the controller reads an absent bitmap as 0 and the placeholder sets
	// only bits it never tests, so the two are equivalent to it.
	FWCaps int         `json:"fw_caps,omitempty"`
	Ports  []PortSpec  `json:"ports"`  // usw + ugw + uxg + uap (eth port)
	Radios []RadioSpec `json:"radios"` // uap only
}

ModelProfile is the per-model shape the controller expects to see: identity strings plus the port/radio/SSID layout tables are built from.

func Profile added in v0.2.0

func Profile(model string) (ModelProfile, bool)

Profile returns the model profile for a known model. The bool is false for a model the generated registry does not contain. Read-only: callers must not mutate the returned slices.

type Option

type Option func(*Emu)

Option customizes an Emu fleet.

func WithInformInterval

func WithInformInterval(d time.Duration) Option

WithInformInterval sets the inform interval every added device starts with. Controller responses can still retune it per device later.

type PortSpec

type PortSpec = inform.Port

PortSpec and RadioSpec are the emulator's public names for the model-shape types, now owned by the inform package so the protocol travels with them.

type RadioSpec

type RadioSpec = inform.Radio

Directories

Path Synopsis
cmd
modelgen command
Command modelgen reduces an adopted UniFi simulation fleet (or a harvested controller hardware database bundle) to model_profiles.json, the model catalog the emulator embeds at build time.
Command modelgen reduces an adopted UniFi simulation fleet (or a harvested controller hardware database bundle) to model_profiles.json, the model catalog the emulator embeds at build time.
unifi-emu command
Command unifi-emu runs a fleet of emulated UniFi devices informing a real controller until interrupted.
Command unifi-emu runs a fleet of emulated UniFi devices informing a real controller until interrupted.
unifi-emu-herder command
Command unifi-emu-herder starts a fleet of fake UniFi devices as Docker containers on a network the caller already owns, and reports what it started as versioned NDJSON on stdout.
Command unifi-emu-herder starts a fleet of fake UniFi devices as Docker containers on a network the caller already owns, and reports what it started as versioned NDJSON on stdout.
Package discovery implements the UniFi L2 device-discovery protocol: the UDP :10001 "Ubiquiti Discovery" packet a device broadcasts so a controller finds it on the local segment.
Package discovery implements the UniFi L2 device-discovery protocol: the UDP :10001 "Ubiquiti Discovery" packet a device broadcasts so a controller finds it on the local segment.
Package inform implements the UniFi inform wire protocol: the TNBU binary packet, AES-128-CBC/GCM encryption, and zlib/snappy compression.
Package inform implements the UniFi inform wire protocol: the TNBU binary packet, AES-128-CBC/GCM encryption, and zlib/snappy compression.
internal
adopt
Package adopt drives a device to adoption against a real controller the way the controller UI does — login, devmgr adopt, poll stat/device.
Package adopt drives a device to adoption against a real controller the way the controller UI does — login, devmgr adopt, poll stat/device.
herder
Package herder plans and runs fake UniFi devices in Docker containers on behalf of a downstream test harness.
Package herder plans and runs fake UniFi devices in Docker containers on behalf of a downstream test harness.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL