commit fabf31f997debf2ed4133fe35c3050c4dadab04c Author: Claude Date: Mon Feb 16 15:21:37 2026 +0000 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 diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..554aed5 --- /dev/null +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..7b38a7f --- /dev/null +++ b/go.sum @@ -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= diff --git a/unifi/client.go b/unifi/client.go new file mode 100644 index 0000000..932c79d --- /dev/null +++ b/unifi/client.go @@ -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 } diff --git a/unifi/client_test.go b/unifi/client_test.go new file mode 100644 index 0000000..7b04d29 --- /dev/null +++ b/unifi/client_test.go @@ -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) +} diff --git a/unifi/clients.go b/unifi/clients.go new file mode 100644 index 0000000..ee6a71e --- /dev/null +++ b/unifi/clients.go @@ -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) +} diff --git a/unifi/config.go b/unifi/config.go new file mode 100644 index 0000000..5aef53d --- /dev/null +++ b/unifi/config.go @@ -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 +} diff --git a/unifi/config_test.go b/unifi/config_test.go new file mode 100644 index 0000000..1827a8b --- /dev/null +++ b/unifi/config_test.go @@ -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) +} diff --git a/unifi/devices.go b/unifi/devices.go new file mode 100644 index 0000000..09b269d --- /dev/null +++ b/unifi/devices.go @@ -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 +} diff --git a/unifi/networks.go b/unifi/networks.go new file mode 100644 index 0000000..654f0ac --- /dev/null +++ b/unifi/networks.go @@ -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 +} diff --git a/unifi/routes.go b/unifi/routes.go new file mode 100644 index 0000000..53f4219 --- /dev/null +++ b/unifi/routes.go @@ -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 + } +} diff --git a/unifi/sites.go b/unifi/sites.go new file mode 100644 index 0000000..30ceaef --- /dev/null +++ b/unifi/sites.go @@ -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 +}