feat: extract UniFi network controller package from core/go

UniFi API wrapper for device stats, network health, VLAN config,
client tracking, and route management.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Claude 2026-02-16 15:21:37 +00:00
commit fabf31f997
No known key found for this signature in database
GPG key ID: AF404715446AEB41
11 changed files with 788 additions and 0 deletions

31
go.mod Normal file
View file

@ -0,0 +1,31 @@
module forge.lthn.ai/core/go-netops
go 1.25.5
require (
forge.lthn.ai/core/go v0.0.0
github.com/stretchr/testify v1.11.1
github.com/unpoller/unifi/v5 v5.18.0
)
require (
github.com/brianvoe/gofakeit/v6 v6.28.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/sagikazarmark/locafero v0.12.0 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/spf13/viper v1.21.0 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/net v0.50.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.34.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
replace forge.lthn.ai/core/go => ../go

50
go.sum Normal file
View file

@ -0,0 +1,50 @@
github.com/brianvoe/gofakeit/v6 v6.28.0 h1:Xib46XXuQfmlLS2EXRuJpqcw8St6qSZz75OUo0tgAW4=
github.com/brianvoe/gofakeit/v6 v6.28.0/go.mod h1:Xj58BMSnFqcn/fAQeSK+/PLtC5kSb7FJIq4JyGa8vEs=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4=
github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/unpoller/unifi/v5 v5.18.0 h1:i9xecLeI9CU6m+5++TIm+zhdGS9f8KCUz8PuuzO7sSQ=
github.com/unpoller/unifi/v5 v5.18.0/go.mod h1:vSIXIclPG9dpKxUp+pavfgENHWaTZXvDg7F036R1YCo=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

53
unifi/client.go Normal file
View file

@ -0,0 +1,53 @@
package unifi
import (
"crypto/tls"
"net/http"
uf "github.com/unpoller/unifi/v5"
"forge.lthn.ai/core/go/pkg/log"
)
// Client wraps the unpoller UniFi client with config-based auth.
type Client struct {
api *uf.Unifi
url string
}
// New creates a new UniFi API client for the given controller URL and credentials.
// TLS verification can be disabled via the insecure parameter (useful for self-signed certs on home lab controllers).
func New(url, user, pass, apikey string, insecure bool) (*Client, error) {
cfg := &uf.Config{
URL: url,
User: user,
Pass: pass,
APIKey: apikey,
}
// Skip TLS verification if requested (e.g. for self-signed certs)
httpClient := &http.Client{
Transport: &http.Transport{
TLSClientConfig: &tls.Config{
InsecureSkipVerify: insecure,
MinVersion: tls.VersionTLS12,
},
},
}
api, err := uf.NewUnifi(cfg)
if err != nil {
return nil, log.E("unifi.New", "failed to create client", err)
}
// Override the HTTP client to skip TLS verification
api.Client = httpClient
return &Client{api: api, url: url}, nil
}
// API exposes the underlying SDK client for direct access.
func (c *Client) API() *uf.Unifi { return c.api }
// URL returns the UniFi controller URL.
func (c *Client) URL() string { return c.url }

50
unifi/client_test.go Normal file
View file

@ -0,0 +1,50 @@
package unifi
import (
"fmt"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
)
func TestNew(t *testing.T) {
// Mock UniFi controller response for login/initialization
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprintln(w, `{"meta":{"rc":"ok"}, "data": []}`)
}))
defer ts.Close()
// Test basic client creation
client, err := New(ts.URL, "user", "pass", "", true)
assert.NoError(t, err)
assert.NotNil(t, client)
assert.Equal(t, ts.URL, client.URL())
assert.NotNil(t, client.API())
if client.API().Client != nil && client.API().Client.Transport != nil {
if tr, ok := client.API().Client.Transport.(*http.Transport); ok {
assert.True(t, tr.TLSClientConfig.InsecureSkipVerify)
} else {
t.Errorf("expected *http.Transport, got %T", client.API().Client.Transport)
}
} else {
t.Errorf("client or transport is nil")
}
// Test with insecure false
client, err = New(ts.URL, "user", "pass", "", false)
assert.NoError(t, err)
if tr, ok := client.API().Client.Transport.(*http.Transport); ok {
assert.False(t, tr.TLSClientConfig.InsecureSkipVerify)
}
}
func TestNew_Error(t *testing.T) {
// uf.NewUnifi fails if URL is invalid (e.g. missing scheme)
client, err := New("localhost:8443", "user", "pass", "", false)
assert.Error(t, err)
assert.Nil(t, client)
}

64
unifi/clients.go Normal file
View file

@ -0,0 +1,64 @@
package unifi
import (
uf "github.com/unpoller/unifi/v5"
"forge.lthn.ai/core/go/pkg/log"
)
// ClientFilter controls which clients are returned.
type ClientFilter struct {
Site string // Filter by site name (empty = all sites)
Wired bool // Show only wired clients
Wireless bool // Show only wireless clients
}
// GetClients returns connected clients from the UniFi controller,
// optionally filtered by site and connection type.
func (c *Client) GetClients(filter ClientFilter) ([]*uf.Client, error) {
sites, err := c.getSitesForFilter(filter.Site)
if err != nil {
return nil, err
}
clients, err := c.api.GetClients(sites)
if err != nil {
return nil, log.E("unifi.GetClients", "failed to fetch clients", err)
}
// Apply wired/wireless filter
if filter.Wired || filter.Wireless {
var filtered []*uf.Client
for _, cl := range clients {
if filter.Wired && cl.IsWired.Val {
filtered = append(filtered, cl)
} else if filter.Wireless && !cl.IsWired.Val {
filtered = append(filtered, cl)
}
}
return filtered, nil
}
return clients, nil
}
// getSitesForFilter resolves sites by name or returns all sites.
func (c *Client) getSitesForFilter(siteName string) ([]*uf.Site, error) {
sites, err := c.GetSites()
if err != nil {
return nil, err
}
if siteName == "" {
return sites, nil
}
// Filter to matching site
for _, s := range sites {
if s.Name == siteName {
return []*uf.Site{s}, nil
}
}
return nil, log.E("unifi.getSitesForFilter", "site not found: "+siteName, nil)
}

145
unifi/config.go Normal file
View file

@ -0,0 +1,145 @@
// Package unifi provides a thin wrapper around the unpoller/unifi Go SDK
// for managing UniFi network controllers, devices, and connected clients.
//
// Authentication is resolved from config file, environment variables, or flag overrides:
//
// 1. ~/.core/config.yaml keys: unifi.url, unifi.user, unifi.pass, unifi.apikey
// 2. UNIFI_URL + UNIFI_USER + UNIFI_PASS + UNIFI_APIKEY environment variables (override config file)
// 3. Flag overrides via core unifi config --url/--user/--pass/--apikey (highest priority)
package unifi
import (
"os"
"forge.lthn.ai/core/go/pkg/config"
"forge.lthn.ai/core/go/pkg/log"
)
const (
// ConfigKeyURL is the config key for the UniFi controller URL.
ConfigKeyURL = "unifi.url"
// ConfigKeyUser is the config key for the UniFi username.
ConfigKeyUser = "unifi.user"
// ConfigKeyPass is the config key for the UniFi password.
ConfigKeyPass = "unifi.pass"
// ConfigKeyAPIKey is the config key for the UniFi API key.
ConfigKeyAPIKey = "unifi.apikey"
// ConfigKeyInsecure is the config key for allowing insecure TLS connections.
ConfigKeyInsecure = "unifi.insecure"
// DefaultURL is the default UniFi controller URL.
DefaultURL = "https://10.69.1.1"
)
// NewFromConfig creates a UniFi client using the standard config resolution:
//
// 1. ~/.core/config.yaml keys: unifi.url, unifi.user, unifi.pass, unifi.apikey, unifi.insecure
// 2. UNIFI_URL + UNIFI_USER + UNIFI_PASS + UNIFI_APIKEY + UNIFI_INSECURE environment variables (override config file)
// 3. Provided flag overrides (highest priority; pass nil to skip)
func NewFromConfig(flagURL, flagUser, flagPass, flagAPIKey string, flagInsecure *bool) (*Client, error) {
url, user, pass, apikey, insecure, err := ResolveConfig(flagURL, flagUser, flagPass, flagAPIKey, flagInsecure)
if err != nil {
return nil, err
}
if user == "" && apikey == "" {
return nil, log.E("unifi.NewFromConfig", "no credentials configured (set UNIFI_USER/UNIFI_PASS or UNIFI_APIKEY, or run: core unifi config)", nil)
}
return New(url, user, pass, apikey, insecure)
}
// ResolveConfig resolves the UniFi URL and credentials from all config sources.
// Flag values take highest priority, then env vars, then config file.
func ResolveConfig(flagURL, flagUser, flagPass, flagAPIKey string, flagInsecure *bool) (url, user, pass, apikey string, insecure bool, err error) {
// Start with config file values
cfg, cfgErr := config.New()
if cfgErr == nil {
_ = cfg.Get(ConfigKeyURL, &url)
_ = cfg.Get(ConfigKeyUser, &user)
_ = cfg.Get(ConfigKeyPass, &pass)
_ = cfg.Get(ConfigKeyAPIKey, &apikey)
_ = cfg.Get(ConfigKeyInsecure, &insecure)
}
// Overlay environment variables
if envURL := os.Getenv("UNIFI_URL"); envURL != "" {
url = envURL
}
if envUser := os.Getenv("UNIFI_USER"); envUser != "" {
user = envUser
}
if envPass := os.Getenv("UNIFI_PASS"); envPass != "" {
pass = envPass
}
if envAPIKey := os.Getenv("UNIFI_APIKEY"); envAPIKey != "" {
apikey = envAPIKey
}
if envInsecure := os.Getenv("UNIFI_INSECURE"); envInsecure != "" {
insecure = envInsecure == "true" || envInsecure == "1"
}
// Overlay flag values (highest priority)
if flagURL != "" {
url = flagURL
}
if flagUser != "" {
user = flagUser
}
if flagPass != "" {
pass = flagPass
}
if flagAPIKey != "" {
apikey = flagAPIKey
}
if flagInsecure != nil {
insecure = *flagInsecure
}
// Default URL if nothing configured
if url == "" {
url = DefaultURL
}
return url, user, pass, apikey, insecure, nil
}
// SaveConfig persists the UniFi URL and/or credentials to the config file.
func SaveConfig(url, user, pass, apikey string, insecure *bool) error {
cfg, err := config.New()
if err != nil {
return log.E("unifi.SaveConfig", "failed to load config", err)
}
if url != "" {
if err := cfg.Set(ConfigKeyURL, url); err != nil {
return log.E("unifi.SaveConfig", "failed to save URL", err)
}
}
if user != "" {
if err := cfg.Set(ConfigKeyUser, user); err != nil {
return log.E("unifi.SaveConfig", "failed to save user", err)
}
}
if pass != "" {
if err := cfg.Set(ConfigKeyPass, pass); err != nil {
return log.E("unifi.SaveConfig", "failed to save password", err)
}
}
if apikey != "" {
if err := cfg.Set(ConfigKeyAPIKey, apikey); err != nil {
return log.E("unifi.SaveConfig", "failed to save API key", err)
}
}
if insecure != nil {
if err := cfg.Set(ConfigKeyInsecure, *insecure); err != nil {
return log.E("unifi.SaveConfig", "failed to save insecure flag", err)
}
}
return nil
}

134
unifi/config_test.go Normal file
View file

@ -0,0 +1,134 @@
package unifi
import (
"fmt"
"net/http"
"net/http/httptest"
"os"
"testing"
"github.com/stretchr/testify/assert"
)
func TestResolveConfig(t *testing.T) {
// Clear environment variables to start clean
os.Unsetenv("UNIFI_URL")
os.Unsetenv("UNIFI_USER")
os.Unsetenv("UNIFI_PASS")
os.Unsetenv("UNIFI_APIKEY")
os.Unsetenv("UNIFI_INSECURE")
os.Unsetenv("CORE_CONFIG_UNIFI_URL")
os.Unsetenv("CORE_CONFIG_UNIFI_USER")
os.Unsetenv("CORE_CONFIG_UNIFI_PASS")
os.Unsetenv("CORE_CONFIG_UNIFI_APIKEY")
os.Unsetenv("CORE_CONFIG_UNIFI_INSECURE")
// 1. Test defaults
url, user, pass, apikey, insecure, err := ResolveConfig("", "", "", "", nil)
assert.NoError(t, err)
assert.Equal(t, DefaultURL, url)
assert.Empty(t, user)
assert.Empty(t, pass)
assert.Empty(t, apikey)
assert.False(t, insecure)
// 2. Test environment variables
t.Setenv("UNIFI_URL", "https://env.url")
t.Setenv("UNIFI_USER", "envuser")
t.Setenv("UNIFI_PASS", "envpass")
t.Setenv("UNIFI_APIKEY", "envapikey")
t.Setenv("UNIFI_INSECURE", "true")
url, user, pass, apikey, insecure, err = ResolveConfig("", "", "", "", nil)
assert.NoError(t, err)
assert.Equal(t, "https://env.url", url)
assert.Equal(t, "envuser", user)
assert.Equal(t, "envpass", pass)
assert.Equal(t, "envapikey", apikey)
assert.True(t, insecure)
// Test alternate UNIFI_INSECURE value
t.Setenv("UNIFI_INSECURE", "1")
_, _, _, _, insecure, _ = ResolveConfig("", "", "", "", nil)
assert.True(t, insecure)
// 3. Test flags (highest priority)
trueVal := true
url, user, pass, apikey, insecure, err = ResolveConfig("https://flag.url", "flaguser", "flagpass", "flagapikey", &trueVal)
assert.NoError(t, err)
assert.Equal(t, "https://flag.url", url)
assert.Equal(t, "flaguser", user)
assert.Equal(t, "flagpass", pass)
assert.Equal(t, "flagapikey", apikey)
assert.True(t, insecure)
// 4. Flags should still override env vars
falseVal := false
url, user, pass, apikey, insecure, err = ResolveConfig("https://flag.url", "flaguser", "flagpass", "flagapikey", &falseVal)
assert.NoError(t, err)
assert.Equal(t, "https://flag.url", url)
assert.Equal(t, "flaguser", user)
assert.Equal(t, "flagpass", pass)
assert.Equal(t, "flagapikey", apikey)
assert.False(t, insecure)
}
func TestNewFromConfig(t *testing.T) {
// Mock UniFi controller
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprintln(w, `{"meta":{"rc":"ok"}, "data": []}`)
}))
defer ts.Close()
// 1. Success case
client, err := NewFromConfig(ts.URL, "user", "pass", "", nil)
assert.NoError(t, err)
assert.NotNil(t, client)
assert.Equal(t, ts.URL, client.URL())
// 2. Error case: No credentials
os.Unsetenv("UNIFI_USER")
os.Unsetenv("UNIFI_APIKEY")
client, err = NewFromConfig("", "", "", "", nil)
assert.Error(t, err)
assert.Nil(t, client)
assert.Contains(t, err.Error(), "no credentials configured")
}
func TestSaveConfig(t *testing.T) {
// Mock HOME to use temp dir for config
tmpDir := t.TempDir()
t.Setenv("HOME", tmpDir)
// Clear relevant env vars that might interfere
os.Unsetenv("UNIFI_URL")
os.Unsetenv("UNIFI_USER")
os.Unsetenv("UNIFI_PASS")
os.Unsetenv("UNIFI_APIKEY")
os.Unsetenv("UNIFI_INSECURE")
os.Unsetenv("CORE_CONFIG_UNIFI_URL")
os.Unsetenv("CORE_CONFIG_UNIFI_USER")
os.Unsetenv("CORE_CONFIG_UNIFI_PASS")
os.Unsetenv("CORE_CONFIG_UNIFI_APIKEY")
os.Unsetenv("CORE_CONFIG_UNIFI_INSECURE")
err := SaveConfig("https://save.url", "saveuser", "savepass", "saveapikey", nil)
assert.NoError(t, err)
// Verify it saved by resolving it
url, user, pass, apikey, insecure, err := ResolveConfig("", "", "", "", nil)
assert.NoError(t, err)
assert.Equal(t, "https://save.url", url)
assert.Equal(t, "saveuser", user)
assert.Equal(t, "savepass", pass)
assert.Equal(t, "saveapikey", apikey)
assert.False(t, insecure)
// Test saving insecure true
trueVal := true
err = SaveConfig("", "", "", "", &trueVal)
assert.NoError(t, err)
_, _, _, _, insecure, _ = ResolveConfig("", "", "", "", nil)
assert.True(t, insecure)
}

116
unifi/devices.go Normal file
View file

@ -0,0 +1,116 @@
package unifi
import (
uf "github.com/unpoller/unifi/v5"
"forge.lthn.ai/core/go/pkg/log"
)
// DeviceInfo is a flat representation of any UniFi infrastructure device.
type DeviceInfo struct {
Name string
IP string
Mac string
Model string
Version string
Type string // uap, usw, usg, udm, uxg
Status int // 1 = online
}
// GetDevices returns the raw device container for a site (or all sites).
func (c *Client) GetDevices(siteName string) (*uf.Devices, error) {
sites, err := c.getSitesForFilter(siteName)
if err != nil {
return nil, err
}
devices, err := c.api.GetDevices(sites)
if err != nil {
return nil, log.E("unifi.GetDevices", "failed to fetch devices", err)
}
return devices, nil
}
// GetDeviceList returns a flat list of all infrastructure devices,
// optionally filtered by device type (uap, usw, usg, udm, uxg).
func (c *Client) GetDeviceList(siteName, deviceType string) ([]DeviceInfo, error) {
devices, err := c.GetDevices(siteName)
if err != nil {
return nil, err
}
var list []DeviceInfo
if deviceType == "" || deviceType == "uap" {
for _, d := range devices.UAPs {
list = append(list, DeviceInfo{
Name: d.Name,
IP: d.IP,
Mac: d.Mac,
Model: d.Model,
Version: d.Version,
Type: "uap",
Status: d.State.Int(),
})
}
}
if deviceType == "" || deviceType == "usw" {
for _, d := range devices.USWs {
list = append(list, DeviceInfo{
Name: d.Name,
IP: d.IP,
Mac: d.Mac,
Model: d.Model,
Version: d.Version,
Type: "usw",
Status: d.State.Int(),
})
}
}
if deviceType == "" || deviceType == "usg" {
for _, d := range devices.USGs {
list = append(list, DeviceInfo{
Name: d.Name,
IP: d.IP,
Mac: d.Mac,
Model: d.Model,
Version: d.Version,
Type: "usg",
Status: d.State.Int(),
})
}
}
if deviceType == "" || deviceType == "udm" {
for _, d := range devices.UDMs {
list = append(list, DeviceInfo{
Name: d.Name,
IP: d.IP,
Mac: d.Mac,
Model: d.Model,
Version: d.Version,
Type: "udm",
Status: d.State.Int(),
})
}
}
if deviceType == "" || deviceType == "uxg" {
for _, d := range devices.UXGs {
list = append(list, DeviceInfo{
Name: d.Name,
IP: d.IP,
Mac: d.Mac,
Model: d.Model,
Version: d.Version,
Type: "uxg",
Status: d.State.Int(),
})
}
}
return list, nil
}

62
unifi/networks.go Normal file
View file

@ -0,0 +1,62 @@
package unifi
import (
"encoding/json"
"fmt"
"forge.lthn.ai/core/go/pkg/log"
)
// NetworkConf represents a UniFi network configuration entry.
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 (e.g. "10.69.1.1/24")
VLAN int `json:"vlan"` // VLAN ID (0 = untagged)
VLANEnabled bool `json:"vlan_enabled"` // Whether VLAN tagging is active
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"`
}
// networkConfResponse is the raw API response wrapper.
type networkConfResponse struct {
Data []NetworkConf `json:"data"`
}
// GetNetworks returns all network configurations from the controller.
// Uses the raw controller API for the full networkconf data.
func (c *Client) GetNetworks(siteName string) ([]NetworkConf, error) {
if siteName == "" {
siteName = "default"
}
path := fmt.Sprintf("/api/s/%s/rest/networkconf", siteName)
raw, err := c.api.GetJSON(path)
if err != nil {
return nil, log.E("unifi.GetNetworks", "failed to fetch networks", err)
}
var resp networkConfResponse
if err := json.Unmarshal(raw, &resp); err != nil {
return nil, log.E("unifi.GetNetworks", "failed to parse networks", err)
}
return resp.Data, nil
}

66
unifi/routes.go Normal file
View file

@ -0,0 +1,66 @@
package unifi
import (
"encoding/json"
"fmt"
"net/url"
"forge.lthn.ai/core/go/pkg/log"
)
// Route represents a single entry in the UniFi gateway routing table.
type Route struct {
Network string `json:"pfx"` // CIDR prefix (e.g. "10.69.1.0/24")
NextHop string `json:"nh"` // Next-hop address or interface
Interface string `json:"intf"` // Interface name (e.g. "br0", "eth4")
Type string `json:"type"` // Route type (e.g. "S" static, "C" connected, "K" kernel)
Distance int `json:"distance"` // Administrative distance
Metric int `json:"metric"` // Route metric
Uptime int `json:"uptime"` // Uptime in seconds
Selected bool `json:"fib"` // Whether route is in the forwarding table
}
// routeResponse is the raw API response wrapper.
type routeResponse struct {
Data []Route `json:"data"`
}
// GetRoutes returns the active routing table from the gateway for the given site.
// Uses the raw controller API since unpoller doesn't wrap this endpoint.
func (c *Client) GetRoutes(siteName string) ([]Route, error) {
if siteName == "" {
siteName = "default"
}
path := fmt.Sprintf("/api/s/%s/stat/routing", url.PathEscape(siteName))
raw, err := c.api.GetJSON(path)
if err != nil {
return nil, log.E("unifi.GetRoutes", "failed to fetch routing table", err)
}
var resp routeResponse
if err := json.Unmarshal(raw, &resp); err != nil {
return nil, log.E("unifi.GetRoutes", "failed to parse routing table", err)
}
return resp.Data, nil
}
// RouteTypeName returns a human-readable name for the route type code.
func RouteTypeName(code string) string {
switch code {
case "S":
return "static"
case "C":
return "connected"
case "K":
return "kernel"
case "B":
return "bgp"
case "O":
return "ospf"
default:
return code
}
}

17
unifi/sites.go Normal file
View file

@ -0,0 +1,17 @@
package unifi
import (
uf "github.com/unpoller/unifi/v5"
"forge.lthn.ai/core/go/pkg/log"
)
// GetSites returns all sites from the UniFi controller.
func (c *Client) GetSites() ([]*uf.Site, error) {
sites, err := c.api.GetSites()
if err != nil {
return nil, log.E("unifi.GetSites", "failed to fetch sites", err)
}
return sites, nil
}