emu

package module
v0.1.0 Latest Latest
Warning

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

Go to latest
Published: Jul 23, 2026 License: MIT Imports: 15 Imported by: 0

README

unifi-emu

Fake UniFi devices that speak the inform protocol and get adopted by a real controller. A device simulator/emulator for integration testing — give a UniFi controller a fleet of deterministic, controllable APs / switches / gateways without any hardware.

emu = emulator (and a flightless bird that struts around pretending it belongs).

Status

🐓 Fully fledged. A live-proven fleet — 1 gateway, 2 switches, 2 APs — adopts all the way to CONNECTED against a real controller (ghcr.io/jamesbraid/unifi-network:sim), including a controller-requested firmware "upgrade" survived with an emulated reboot. Shipped:

  • Library (package emu) — fleet API: New/Add/Start/State/WaitState/Stop.
  • CLI (cmd/unifi-emu) — single-device flags, -devices file, or SIM_DEVICES env (YAML/JSON).
  • Container image — docker build -t unifi-emu:dev . (static, scratch, ~9MB). In-container adoption proven on a pinned docker network.
  • Adopt helpers — classic Network App (ClassicClient) and UniFi OS ucore/CSRF (UOSClient; unit-tested — no seeded UOS image exists locally yet, so that path is unproven live).
  • Consumer integrations — AdoptDevice + StartDeviceSim in go-unifi's controllertest (jamesbraid/go-unifi#16) and a compose sidecar in terraform-provider-unifi (jamesbraid/terraform-provider-unifi#11).

Not yet: the module/image aren't published anywhere (both PRs note it), and the seeded-UOS live run is blocked on a unifi-os-server:seeded image.

Quick start
go test ./...            # unit tests, no controller needed
bash scripts/itest.sh    # live proof: one gateway adopts to CONNECTED (docker)
bash scripts/itest.sh fleet   # live proof: the whole 5-device fleet
bash scripts/itest.sh docker  # live proof: sim runs inside a container
docker build -t unifi-emu:dev . && docker run --rm unifi-emu:dev -h

The Go-level live tests sit behind the integration build tag and two env vars (one live test per fresh controller — recreate between runs):

UNIFI_EMU_TEST_INFORM_URL=http://127.0.0.1:8080/inform \
UNIFI_EMU_TEST_API_URL=https://localhost:8443 \
go test -tags integration -run TestEmuAdoptsFleetLive -v .
Model registry
Model Type Firmware
UGW3 gateway 4.4.36.5146617
USWED74 switch 4.0.21.9965
USM8P switch (PoE) 4.0.21.9965
US48P750 switch (PoE) 4.0.21.9965
USWED06 switch 4.0.21.9965
USWF07D switch 4.0.21.9965
U7MP access point 4.0.21.9965
U7PRO access point 4.0.21.9965
UAPA6B0 access point 4.0.21.9965

The registry is generated, not hand-shaped. model_profiles.json is the checked-in reduced fixture and go generate ./... renders models_generated.go from it. The fixture records the source controller version and keeps the complete expanded port and radio layouts so review diffs show every hardware change.

To refresh it from a controller build, save:

  • GET /api/s/default/stat/device for model IDs, names, types, and firmware;
  • the controller UI's swai.*.js bundle, which contains its hardware database.

Then run:

go run ./cmd/modelgen \
  -input stat-device.json \
  -device-db-bundle swai.js \
  -controller-version 10.4.57
go test ./...

The reducer rejects missing models, duplicate IDs or ports, type mismatches, unknown port encodings, empty layouts, and incomplete AP radio data. The controller also exposes GET /v2/api/site/default/models; that endpoint is useful for identity/image metadata but does not include port or radio layouts. The few facts absent from both dumps (AP Ethernet speed/count and radio spatial streams) follow Ubiquiti's Tech Specs for AC Mesh Pro, U7 Pro, U7 Pro Outdoor, and Ultra.

More

  • docs/DESIGN.md — what it is, the verified inform-protocol facts, architecture, and how it plugs into go-unifi / terraform-provider-unifi.
  • docs/BUILD-PROMPT.md — the kickoff plan for the first build phase (a gateway that adopts to CONNECTED).

The one hard rule

Devices enter a controller only through the real inform/adoption lifecycle — no MongoDB/DB seeding. DB-injected devices render permanently disconnected; the whole point of this tool is real, connected, adoptable devices.

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

This section is empty.

Types

type ClassicClient

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

ClassicClient talks to a classic Network App controller API (:8443, cookie auth via /api/login). It exists so tests can drive an emulated device to adoption the way the controller UI does: login, devmgr adopt, then poll stat/device.

func NewClassicClient

func NewClassicClient(baseURL string) *ClassicClient

NewClassicClient returns a client for the controller at baseURL.

func (*ClassicClient) Adopt

func (c *ClassicClient) Adopt(ctx context.Context, site, mac string) error

Adopt issues the devmgr adopt command for mac in site, the same call the controller UI makes when the user clicks Adopt.

func (*ClassicClient) DeviceByMAC

func (c *ClassicClient) DeviceByMAC(ctx context.Context, site, mac string) (Device, error)

DeviceByMAC returns the stat/device doc for mac in site, or a "device not found" error when the controller does not list it.

func (*ClassicClient) Login

func (c *ClassicClient) Login(ctx context.Context, user, pass string) error

Login authenticates against /api/login; the session cookie rides in the jar from then on. Non-200 (bad credentials) is an error.

func (*ClassicClient) WaitAdopted

func (c *ClassicClient) WaitAdopted(ctx context.Context, site, mac string) (Device, error)

WaitAdopted polls stat/device every 2s until the device reports state 1 and adopted. On ctx timeout it returns the last seen device and an error naming it plus the last poll error, so a stalled adoption says where it stalled.

type Device

type Device struct {
	MAC     string `json:"mac"`
	State   int    `json:"state"` // 1=connected, 2=pending, 7=adopt-failed
	Adopted bool   `json:"adopted"`
	Model   string `json:"model"`
	IP      string `json:"ip"`
	Name    string `json:"name"`
}

Device is the subset of a stat/device document the adoption flow reads; both ClassicClient and UOSClient decode it. The documents carry many more fields; they are ignored.

type DeviceSpec

type DeviceSpec struct {
	MAC          string `json:"mac" yaml:"mac"`
	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"`
}

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.

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.

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
	ModelDisplay string
	Type         string // "ugw", "usw", "uap"
	Version      string
	Ports        []PortSpec  // usw + ugw + uap (eth port)
	Radios       []RadioSpec // 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.

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 struct {
	IfName   string
	Name     string
	PortIdx  int
	Media    string // "GE", "SFP+"
	PoECaps  int
	IsUplink bool
}

PortSpec is one switch/gateway/ethernet port in a model's layout.

type RadioSpec

type RadioSpec struct {
	Name        string // "wifi-ng", "wifi-na"
	Radio       string // "ng", "na"
	Channel     int
	HT          string // "20", "40"
	MinTxPower  int
	MaxTxPower  int
	NSS         int
	RadioCaps   int
	AntennaGain int
}

RadioSpec is one wireless radio in an AP model's layout.

type UOSClient

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

UOSClient talks to a UniFi OS controller through the ucore proxy (:443). Login at /api/auth/login yields a session cookie plus a CSRF token in the x-updated-csrf-token response header, and every call under /proxy/network must carry that token in X-CSRF-Token or ucore answers 403. The token rotates mid-session; the transport follows the rotation (see csrfSniffer). The Network App API behind the proxy is the classic one, so the paths below mirror ClassicClient's under the /proxy/network prefix.

func NewUOSClient

func NewUOSClient(baseURL string) *UOSClient

NewUOSClient returns a client for the UniFi OS controller at baseURL; see newSessionClient for the TLS and timeout rationale.

func (*UOSClient) Adopt

func (c *UOSClient) Adopt(ctx context.Context, site, mac string) error

Adopt issues the devmgr adopt command for mac in site through the proxy, the same call the Network App UI makes when the user clicks Adopt.

func (*UOSClient) DeviceByMAC

func (c *UOSClient) DeviceByMAC(ctx context.Context, site, mac string) (Device, error)

DeviceByMAC returns the stat/device doc for mac in site, or a "device not found" error when the controller does not list it.

func (*UOSClient) Login

func (c *UOSClient) Login(ctx context.Context, user, pass string) error

Login authenticates against /api/auth/login. The session cookie rides in the jar; the CSRF token comes back in the x-updated-csrf-token response header. A 200 without that header is still an error: without the token every proxied call would 403, so a tokenless login is no login.

func (*UOSClient) WaitAdopted

func (c *UOSClient) WaitAdopted(ctx context.Context, site, mac string) (Device, error)

WaitAdopted polls stat/device every 2s until the device reports state 1 and adopted; same semantics as ClassicClient.WaitAdopted.

Directories

Path Synopsis
cmd
modelgen command
Command modelgen reduces an adopted UniFi simulation fleet to the model facts used by the emulator and generates the corresponding Go registry.
Command modelgen reduces an adopted UniFi simulation fleet to the model facts used by the emulator and generates the corresponding Go registry.
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.
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.

Jump to

Keyboard shortcuts

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