go-netops/docs/architecture.md
Snider 38819660ef
All checks were successful
Security Scan / security (pull_request) Successful in 7s
Test / test (pull_request) Successful in 1m38s
fix(unifi): DX audit — fix tests, add missing Commit(), improve coverage
- Fix SaveConfig: add cfg.Commit() so credentials actually persist to disk
- Fix TestResolveConfig and TestNewFromConfig: isolate from real config
  file by setting HOME to temp dir, preventing env/config bleed
- Add RouteTypeName, GetRoutes, and GetNetworks unit tests with httptest
  mocks (coverage 39% → 55%)
- Update CLAUDE.md: add error handling and test isolation conventions
- Update docs: fix Go version (1.25 → 1.26), remove stale replace
  directive references, add cmd/unifi/ to architecture diagram

Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-17 08:44:07 +00:00

8.3 KiB

Architecture

Module: forge.lthn.ai/core/go-netops

Overview

go-netops is a thin Go wrapper around the unpoller/unifi SDK that provides a simplified interface for querying UniFi network controllers. It handles authentication, TLS configuration, multi-site filtering, and config resolution from file, environment, and CLI flags.

Package Structure

go-netops/
├── cmd/unifi/             # CLI commands (cobra)
│   ├── cmd_unifi.go       # Root 'unifi' command registration
│   ├── cmd_clients.go     # 'unifi clients' command
│   ├── cmd_config.go      # 'unifi config' command
│   ├── cmd_devices.go     # 'unifi devices' command
│   ├── cmd_networks.go    # 'unifi networks' command
│   ├── cmd_routes.go      # 'unifi routes' command
│   └── cmd_sites.go       # 'unifi sites' command
├── unifi/
│   ├── client.go          # Client struct and constructor (New)
│   ├── clients.go         # Connected client queries with filtering
│   ├── config.go          # Config resolution + persistence (NewFromConfig, ResolveConfig, SaveConfig)
│   ├── devices.go         # Infrastructure device queries (UAP, USW, USG, UDM, UXG)
│   ├── networks.go        # Network configuration queries (VLANs, DHCP, firewall zones)
│   ├── routes.go          # Gateway routing table queries
│   ├── sites.go           # Site listing
│   ├── client_test.go     # Client creation tests
│   ├── config_test.go     # Config resolution and persistence tests
│   ├── networks_test.go   # Network query tests
│   └── routes_test.go     # Route query and type name tests
├── go.mod
└── go.sum

All exported types live in the unifi package under the unifi/ subdirectory.

Key Types

Client

The central type. Wraps the unpoller SDK client with config-based authentication.

type Client struct {
    api *uf.Unifi
    url string
}

func New(url, user, pass, apikey string, insecure bool) (*Client, error)
func NewFromConfig(flagURL, flagUser, flagPass, flagAPIKey string, flagInsecure *bool) (*Client, error)
func (c *Client) API() *uf.Unifi
func (c *Client) URL() string

Two constructors are provided:

  • New — direct credentials, useful for tests and scripts.
  • NewFromConfig — three-tier config resolution (file, env, flags), used by CLI commands.

ClientFilter

Controls which connected clients are returned from a query.

type ClientFilter struct {
    Site     string // Filter by site name (empty = all sites)
    Wired    bool   // Show only wired clients
    Wireless bool   // Show only wireless clients
}

func (c *Client) GetClients(filter ClientFilter) ([]*uf.Client, error)

DeviceInfo

Flat representation of any UniFi infrastructure device, abstracting over the SDK's separate UAP/USW/USG/UDM/UXG containers.

type DeviceInfo struct {
    Name    string
    IP      string
    Mac     string
    Model   string
    Version string
    Type    string // uap, usw, usg, udm, uxg
    Status  int    // 1 = online
}

func (c *Client) GetDevices(siteName string) (*uf.Devices, error)
func (c *Client) GetDeviceList(siteName, deviceType string) ([]DeviceInfo, error)
  • GetDevices returns the raw SDK container (for callers that need full detail).
  • GetDeviceList returns a flat, uniform slice with optional type filtering.

NetworkConf

Network configuration entry covering VLANs, DHCP, DNS, firewall zones, and WAN settings.

type NetworkConf struct {
    ID                      string `json:"_id"`
    Name                    string `json:"name"`
    Purpose                 string `json:"purpose"`      // wan, corporate, remote-user-vpn
    IPSubnet                string `json:"ip_subnet"`    // CIDR
    VLAN                    int    `json:"vlan"`
    VLANEnabled             bool   `json:"vlan_enabled"`
    Enabled                 bool   `json:"enabled"`
    NetworkGroup            string `json:"networkgroup"` // LAN, WAN, WAN2
    NetworkIsolationEnabled bool   `json:"network_isolation_enabled"`
    InternetAccessEnabled   bool   `json:"internet_access_enabled"`
    IsNAT                   bool   `json:"is_nat"`
    DHCPEnabled             bool   `json:"dhcpd_enabled"`
    DHCPStart               string `json:"dhcpd_start"`
    DHCPStop                string `json:"dhcpd_stop"`
    DHCPDNS1                string `json:"dhcpd_dns_1"`
    DHCPDNS2                string `json:"dhcpd_dns_2"`
    DHCPDNSEnabled          bool   `json:"dhcpd_dns_enabled"`
    MDNSEnabled             bool   `json:"mdns_enabled"`
    FirewallZoneID          string `json:"firewall_zone_id"`
    GatewayType             string `json:"gateway_type"`
    VPNType                 string `json:"vpn_type"`
    WANType                 string `json:"wan_type"` // pppoe, dhcp, static
    WANNetworkGroup         string `json:"wan_networkgroup"`
}

func (c *Client) GetNetworks(siteName string) ([]NetworkConf, error)

Uses the raw controller REST API (/api/s/{site}/rest/networkconf) since the unpoller SDK does not wrap this endpoint.

Route

Gateway routing table entry with prefix, next-hop, interface, type, and metrics.

type Route struct {
    Network   string `json:"pfx"`      // CIDR prefix
    NextHop   string `json:"nh"`       // Next-hop address or interface
    Interface string `json:"intf"`     // Interface name (e.g. "br0", "eth4")
    Type      string `json:"type"`     // S=static, C=connected, K=kernel, B=bgp, O=ospf
    Distance  int    `json:"distance"` // Administrative distance
    Metric    int    `json:"metric"`
    Uptime    int    `json:"uptime"`   // Seconds
    Selected  bool   `json:"fib"`      // In forwarding table
}

func (c *Client) GetRoutes(siteName string) ([]Route, error)
func RouteTypeName(code string) string

Also uses the raw controller API (/api/s/{site}/stat/routing).

Design Decisions

Thin Wrapper

The package deliberately stays thin. It delegates transport, session management, and cookie handling to the unpoller SDK, adding only:

  • Config resolution — three-tier priority (config file, env vars, CLI flags).
  • TLS flexibilityinsecure flag for self-signed certificates on home lab controllers.
  • Flat typesDeviceInfo and NetworkConf flatten the SDK's nested/typed structures into uniform slices for easier consumption by CLI commands and templates.
  • Raw API accessGetNetworks and GetRoutes call controller endpoints the SDK does not wrap, parsing the JSON directly.

Config Resolution Order

All credential resolution follows the same three-tier pattern used across the core framework:

  1. Config file~/.core/config.yaml (keys: unifi.url, unifi.user, unifi.pass, unifi.apikey, unifi.insecure)
  2. Environment variablesUNIFI_URL, UNIFI_USER, UNIFI_PASS, UNIFI_APIKEY, UNIFI_INSECURE
  3. CLI flags — highest priority, override everything

Site Filtering

Most query methods accept a siteName parameter. An empty string queries all sites. The getSitesForFilter helper resolves site names and returns an error for unknown sites, preventing silent data omission.

TLS Configuration

The client enforces TLS 1.2 as the minimum version regardless of the insecure flag. The insecure flag only controls certificate verification, not protocol version.

API Patterns

Constructor Pattern

// Direct credentials
client, err := unifi.New("https://10.69.1.1", "admin", "pass", "", true)

// Config-resolved (CLI usage)
client, err := unifi.NewFromConfig(flagURL, flagUser, flagPass, flagAPIKey, &flagInsecure)

Query Pattern

All query methods follow the same shape: accept optional filters, return typed slices, wrap errors with log.E.

clients, err := client.GetClients(unifi.ClientFilter{Site: "default", Wireless: true})
devices, err := client.GetDeviceList("default", "uap")
networks, err := client.GetNetworks("default")
routes, err := client.GetRoutes("default")
sites, err := client.GetSites()

Config Persistence

// Save credentials
err := unifi.SaveConfig("https://10.69.1.1", "admin", "pass", "", nil)

// Resolve credentials (without creating a client)
url, user, pass, apikey, insecure, err := unifi.ResolveConfig("", "", "", "", nil)