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:
commit
fabf31f997
11 changed files with 788 additions and 0 deletions
31
go.mod
Normal file
31
go.mod
Normal 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
50
go.sum
Normal 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
53
unifi/client.go
Normal 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
50
unifi/client_test.go
Normal 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
64
unifi/clients.go
Normal 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
145
unifi/config.go
Normal 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
134
unifi/config_test.go
Normal 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
116
unifi/devices.go
Normal 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
62
unifi/networks.go
Normal 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
66
unifi/routes.go
Normal 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
17
unifi/sites.go
Normal 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
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue