feat(unifi): add UniFi Go SDK integration and CLI commands
- Add pkg/unifi SDK wrapping unpoller/unifi with TLS, config resolution, and typed accessors for sites, clients, devices, networks, and routes - Add CLI commands: unifi sites, clients, devices, networks, routes, config - Register unifi commands in full variant build Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
12fe1cff4e
commit
ecc161b725
17 changed files with 1151 additions and 0 deletions
2
go.mod
2
go.mod
|
|
@ -38,6 +38,7 @@ require (
|
||||||
github.com/adrg/xdg v0.5.3 // indirect
|
github.com/adrg/xdg v0.5.3 // indirect
|
||||||
github.com/bahlo/generic-list-go v0.2.0 // indirect
|
github.com/bahlo/generic-list-go v0.2.0 // indirect
|
||||||
github.com/bep/debounce v1.2.1 // indirect
|
github.com/bep/debounce v1.2.1 // indirect
|
||||||
|
github.com/brianvoe/gofakeit/v6 v6.28.0 // indirect
|
||||||
github.com/buger/jsonparser v1.1.1 // indirect
|
github.com/buger/jsonparser v1.1.1 // indirect
|
||||||
github.com/cloudflare/circl v1.6.3 // indirect
|
github.com/cloudflare/circl v1.6.3 // indirect
|
||||||
github.com/coder/websocket v1.8.14 // indirect
|
github.com/coder/websocket v1.8.14 // indirect
|
||||||
|
|
@ -96,6 +97,7 @@ require (
|
||||||
github.com/tidwall/sjson v1.2.5 // indirect
|
github.com/tidwall/sjson v1.2.5 // indirect
|
||||||
github.com/ugorji/go/codec v1.3.0 // indirect
|
github.com/ugorji/go/codec v1.3.0 // indirect
|
||||||
github.com/ulikunitz/xz v0.5.15 // indirect
|
github.com/ulikunitz/xz v0.5.15 // indirect
|
||||||
|
github.com/unpoller/unifi/v5 v5.17.0 // indirect
|
||||||
github.com/wI2L/jsondiff v0.7.0 // indirect
|
github.com/wI2L/jsondiff v0.7.0 // indirect
|
||||||
github.com/wailsapp/go-webview2 v1.0.23 // indirect
|
github.com/wailsapp/go-webview2 v1.0.23 // indirect
|
||||||
github.com/wailsapp/wails/v3 v3.0.0-alpha.64 // indirect
|
github.com/wailsapp/wails/v3 v3.0.0-alpha.64 // indirect
|
||||||
|
|
|
||||||
4
go.sum
4
go.sum
|
|
@ -32,6 +32,8 @@ github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPn
|
||||||
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
|
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
|
||||||
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
|
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
|
||||||
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
|
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
|
||||||
|
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/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
|
github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
|
||||||
github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
|
github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
|
||||||
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
|
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
|
||||||
|
|
@ -234,6 +236,8 @@ github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA
|
||||||
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||||
github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY=
|
github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY=
|
||||||
github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
|
github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
|
||||||
|
github.com/unpoller/unifi/v5 v5.17.0 h1:e2yES/35+/Ddd6BsXOjXRhsO663uqI99PKleS9plF/w=
|
||||||
|
github.com/unpoller/unifi/v5 v5.17.0/go.mod h1:vSIXIclPG9dpKxUp+pavfgENHWaTZXvDg7F036R1YCo=
|
||||||
github.com/wI2L/jsondiff v0.7.0 h1:1lH1G37GhBPqCfp/lrs91rf/2j3DktX6qYAKZkLuCQQ=
|
github.com/wI2L/jsondiff v0.7.0 h1:1lH1G37GhBPqCfp/lrs91rf/2j3DktX6qYAKZkLuCQQ=
|
||||||
github.com/wI2L/jsondiff v0.7.0/go.mod h1:KAEIojdQq66oJiHhDyQez2x+sRit0vIzC9KeK0yizxM=
|
github.com/wI2L/jsondiff v0.7.0/go.mod h1:KAEIojdQq66oJiHhDyQez2x+sRit0vIzC9KeK0yizxM=
|
||||||
github.com/wailsapp/go-webview2 v1.0.23 h1:jmv8qhz1lHibCc79bMM/a/FqOnnzOGEisLav+a0b9P0=
|
github.com/wailsapp/go-webview2 v1.0.23 h1:jmv8qhz1lHibCc79bMM/a/FqOnnzOGEisLav+a0b9P0=
|
||||||
|
|
|
||||||
106
internal/cmd/unifi/cmd_clients.go
Normal file
106
internal/cmd/unifi/cmd_clients.go
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
package unifi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/host-uk/core/pkg/cli"
|
||||||
|
uf "github.com/host-uk/core/pkg/unifi"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Clients command flags.
|
||||||
|
var (
|
||||||
|
clientsSite string
|
||||||
|
clientsWired bool
|
||||||
|
clientsWireless bool
|
||||||
|
)
|
||||||
|
|
||||||
|
// addClientsCommand adds the 'clients' subcommand for listing connected clients.
|
||||||
|
func addClientsCommand(parent *cli.Command) {
|
||||||
|
cmd := &cli.Command{
|
||||||
|
Use: "clients",
|
||||||
|
Short: "List connected clients",
|
||||||
|
Long: "List all connected clients on the UniFi network, optionally filtered by site or connection type.",
|
||||||
|
RunE: func(cmd *cli.Command, args []string) error {
|
||||||
|
return runClients()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Flags().StringVar(&clientsSite, "site", "", "Filter by site name")
|
||||||
|
cmd.Flags().BoolVar(&clientsWired, "wired", false, "Show only wired clients")
|
||||||
|
cmd.Flags().BoolVar(&clientsWireless, "wireless", false, "Show only wireless clients")
|
||||||
|
|
||||||
|
parent.AddCommand(cmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runClients() error {
|
||||||
|
client, err := uf.NewFromConfig("", "", "", "")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
clients, err := client.GetClients(uf.ClientFilter{
|
||||||
|
Site: clientsSite,
|
||||||
|
Wired: clientsWired,
|
||||||
|
Wireless: clientsWireless,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(clients) == 0 {
|
||||||
|
cli.Text("No clients found.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
table := cli.NewTable("Name", "IP", "MAC", "Network", "Type", "Uptime")
|
||||||
|
|
||||||
|
for _, cl := range clients {
|
||||||
|
name := cl.Name
|
||||||
|
if name == "" {
|
||||||
|
name = cl.Hostname
|
||||||
|
}
|
||||||
|
if name == "" {
|
||||||
|
name = "(unknown)"
|
||||||
|
}
|
||||||
|
|
||||||
|
connType := cl.Essid
|
||||||
|
if cl.IsWired.Val {
|
||||||
|
connType = "wired"
|
||||||
|
}
|
||||||
|
|
||||||
|
table.AddRow(
|
||||||
|
valueStyle.Render(name),
|
||||||
|
cl.IP,
|
||||||
|
dimStyle.Render(cl.Mac),
|
||||||
|
cl.Network,
|
||||||
|
dimStyle.Render(connType),
|
||||||
|
dimStyle.Render(formatUptime(cl.Uptime.Int())),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
cli.Blank()
|
||||||
|
cli.Print(" %s\n\n", fmt.Sprintf("%d clients", len(clients)))
|
||||||
|
table.Render()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatUptime converts seconds to a human-readable duration string.
|
||||||
|
func formatUptime(seconds int) string {
|
||||||
|
if seconds <= 0 {
|
||||||
|
return "-"
|
||||||
|
}
|
||||||
|
|
||||||
|
days := seconds / 86400
|
||||||
|
hours := (seconds % 86400) / 3600
|
||||||
|
minutes := (seconds % 3600) / 60
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case days > 0:
|
||||||
|
return fmt.Sprintf("%dd %dh %dm", days, hours, minutes)
|
||||||
|
case hours > 0:
|
||||||
|
return fmt.Sprintf("%dh %dm", hours, minutes)
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("%dm", minutes)
|
||||||
|
}
|
||||||
|
}
|
||||||
130
internal/cmd/unifi/cmd_config.go
Normal file
130
internal/cmd/unifi/cmd_config.go
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
package unifi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/host-uk/core/pkg/cli"
|
||||||
|
uf "github.com/host-uk/core/pkg/unifi"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config command flags.
|
||||||
|
var (
|
||||||
|
configURL string
|
||||||
|
configUser string
|
||||||
|
configPass string
|
||||||
|
configAPIKey string
|
||||||
|
configTest bool
|
||||||
|
)
|
||||||
|
|
||||||
|
// addConfigCommand adds the 'config' subcommand for UniFi connection setup.
|
||||||
|
func addConfigCommand(parent *cli.Command) {
|
||||||
|
cmd := &cli.Command{
|
||||||
|
Use: "config",
|
||||||
|
Short: "Configure UniFi connection",
|
||||||
|
Long: "Set the UniFi controller URL and credentials, or test the current connection.",
|
||||||
|
RunE: func(cmd *cli.Command, args []string) error {
|
||||||
|
return runConfig()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Flags().StringVar(&configURL, "url", "", "UniFi controller URL")
|
||||||
|
cmd.Flags().StringVar(&configUser, "user", "", "UniFi username")
|
||||||
|
cmd.Flags().StringVar(&configPass, "pass", "", "UniFi password")
|
||||||
|
cmd.Flags().StringVar(&configAPIKey, "apikey", "", "UniFi API key")
|
||||||
|
cmd.Flags().BoolVar(&configTest, "test", false, "Test the current connection")
|
||||||
|
|
||||||
|
parent.AddCommand(cmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runConfig() error {
|
||||||
|
// If setting values, save them first
|
||||||
|
if configURL != "" || configUser != "" || configPass != "" || configAPIKey != "" {
|
||||||
|
if err := uf.SaveConfig(configURL, configUser, configPass, configAPIKey); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if configURL != "" {
|
||||||
|
cli.Success(fmt.Sprintf("UniFi URL set to %s", configURL))
|
||||||
|
}
|
||||||
|
if configUser != "" {
|
||||||
|
cli.Success("UniFi username saved")
|
||||||
|
}
|
||||||
|
if configPass != "" {
|
||||||
|
cli.Success("UniFi password saved")
|
||||||
|
}
|
||||||
|
if configAPIKey != "" {
|
||||||
|
cli.Success("UniFi API key saved")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If testing, verify the connection
|
||||||
|
if configTest {
|
||||||
|
return runConfigTest()
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no flags, show current config
|
||||||
|
if configURL == "" && configUser == "" && configPass == "" && configAPIKey == "" && !configTest {
|
||||||
|
return showConfig()
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func showConfig() error {
|
||||||
|
url, user, pass, apikey, err := uf.ResolveConfig("", "", "", "")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
cli.Blank()
|
||||||
|
cli.Print(" %s %s\n", dimStyle.Render("URL:"), valueStyle.Render(url))
|
||||||
|
|
||||||
|
if user != "" {
|
||||||
|
cli.Print(" %s %s\n", dimStyle.Render("User:"), valueStyle.Render(user))
|
||||||
|
} else {
|
||||||
|
cli.Print(" %s %s\n", dimStyle.Render("User:"), warningStyle.Render("not set"))
|
||||||
|
}
|
||||||
|
|
||||||
|
if pass != "" {
|
||||||
|
cli.Print(" %s %s\n", dimStyle.Render("Pass:"), valueStyle.Render("****"))
|
||||||
|
} else {
|
||||||
|
cli.Print(" %s %s\n", dimStyle.Render("Pass:"), warningStyle.Render("not set"))
|
||||||
|
}
|
||||||
|
|
||||||
|
if apikey != "" {
|
||||||
|
masked := apikey
|
||||||
|
if len(apikey) >= 8 {
|
||||||
|
masked = apikey[:4] + "..." + apikey[len(apikey)-4:]
|
||||||
|
}
|
||||||
|
cli.Print(" %s %s\n", dimStyle.Render("API Key:"), valueStyle.Render(masked))
|
||||||
|
} else {
|
||||||
|
cli.Print(" %s %s\n", dimStyle.Render("API Key:"), warningStyle.Render("not set"))
|
||||||
|
}
|
||||||
|
|
||||||
|
cli.Blank()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func runConfigTest() error {
|
||||||
|
client, err := uf.NewFromConfig(configURL, configUser, configPass, configAPIKey)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
sites, err := client.GetSites()
|
||||||
|
if err != nil {
|
||||||
|
cli.Error("Connection failed")
|
||||||
|
return cli.WrapVerb(err, "connect to", "UniFi controller")
|
||||||
|
}
|
||||||
|
|
||||||
|
cli.Blank()
|
||||||
|
cli.Success(fmt.Sprintf("Connected to %s", client.URL()))
|
||||||
|
cli.Print(" %s %s\n", dimStyle.Render("Sites:"), numberStyle.Render(fmt.Sprintf("%d", len(sites))))
|
||||||
|
for _, s := range sites {
|
||||||
|
cli.Print(" %s %s\n", valueStyle.Render(s.Name), dimStyle.Render(s.Desc))
|
||||||
|
}
|
||||||
|
cli.Blank()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
74
internal/cmd/unifi/cmd_devices.go
Normal file
74
internal/cmd/unifi/cmd_devices.go
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
package unifi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/host-uk/core/pkg/cli"
|
||||||
|
uf "github.com/host-uk/core/pkg/unifi"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Devices command flags.
|
||||||
|
var (
|
||||||
|
devicesSite string
|
||||||
|
devicesType string
|
||||||
|
)
|
||||||
|
|
||||||
|
// addDevicesCommand adds the 'devices' subcommand for listing infrastructure devices.
|
||||||
|
func addDevicesCommand(parent *cli.Command) {
|
||||||
|
cmd := &cli.Command{
|
||||||
|
Use: "devices",
|
||||||
|
Short: "List infrastructure devices",
|
||||||
|
Long: "List all infrastructure devices (APs, switches, gateways) on the UniFi network.",
|
||||||
|
RunE: func(cmd *cli.Command, args []string) error {
|
||||||
|
return runDevices()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Flags().StringVar(&devicesSite, "site", "", "Filter by site name")
|
||||||
|
cmd.Flags().StringVar(&devicesType, "type", "", "Filter by device type (uap, usw, usg, udm, uxg)")
|
||||||
|
|
||||||
|
parent.AddCommand(cmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runDevices() error {
|
||||||
|
client, err := uf.NewFromConfig("", "", "", "")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
devices, err := client.GetDeviceList(devicesSite, strings.ToLower(devicesType))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(devices) == 0 {
|
||||||
|
cli.Text("No devices found.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
table := cli.NewTable("Name", "IP", "MAC", "Model", "Type", "Version", "Status")
|
||||||
|
|
||||||
|
for _, d := range devices {
|
||||||
|
status := successStyle.Render("online")
|
||||||
|
if d.Status != 1 {
|
||||||
|
status = errorStyle.Render("offline")
|
||||||
|
}
|
||||||
|
|
||||||
|
table.AddRow(
|
||||||
|
valueStyle.Render(d.Name),
|
||||||
|
d.IP,
|
||||||
|
dimStyle.Render(d.Mac),
|
||||||
|
d.Model,
|
||||||
|
dimStyle.Render(d.Type),
|
||||||
|
dimStyle.Render(d.Version),
|
||||||
|
status,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
cli.Blank()
|
||||||
|
cli.Print(" %s\n\n", fmt.Sprintf("%d devices", len(devices)))
|
||||||
|
table.Render()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
144
internal/cmd/unifi/cmd_networks.go
Normal file
144
internal/cmd/unifi/cmd_networks.go
Normal file
|
|
@ -0,0 +1,144 @@
|
||||||
|
package unifi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/host-uk/core/pkg/cli"
|
||||||
|
uf "github.com/host-uk/core/pkg/unifi"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Networks command flags.
|
||||||
|
var (
|
||||||
|
networksSite string
|
||||||
|
)
|
||||||
|
|
||||||
|
// addNetworksCommand adds the 'networks' subcommand for listing network segments.
|
||||||
|
func addNetworksCommand(parent *cli.Command) {
|
||||||
|
cmd := &cli.Command{
|
||||||
|
Use: "networks",
|
||||||
|
Short: "List network segments",
|
||||||
|
Long: "List all network segments configured on the UniFi controller, showing VLANs, subnets, isolation, and DHCP.",
|
||||||
|
RunE: func(cmd *cli.Command, args []string) error {
|
||||||
|
return runNetworks()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Flags().StringVar(&networksSite, "site", "", "Site name (default: \"default\")")
|
||||||
|
|
||||||
|
parent.AddCommand(cmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runNetworks() error {
|
||||||
|
client, err := uf.NewFromConfig("", "", "", "")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
networks, err := client.GetNetworks(networksSite)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(networks) == 0 {
|
||||||
|
cli.Text("No networks found.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Separate WANs, LANs, and VPNs
|
||||||
|
var wans, lans, vpns []uf.NetworkConf
|
||||||
|
for _, n := range networks {
|
||||||
|
switch n.Purpose {
|
||||||
|
case "wan":
|
||||||
|
wans = append(wans, n)
|
||||||
|
case "remote-user-vpn":
|
||||||
|
vpns = append(vpns, n)
|
||||||
|
default:
|
||||||
|
lans = append(lans, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cli.Blank()
|
||||||
|
|
||||||
|
// WANs
|
||||||
|
if len(wans) > 0 {
|
||||||
|
cli.Print(" %s\n\n", infoStyle.Render("WAN Interfaces"))
|
||||||
|
wanTable := cli.NewTable("Name", "Type", "Group", "Status")
|
||||||
|
for _, w := range wans {
|
||||||
|
status := successStyle.Render("enabled")
|
||||||
|
if !w.Enabled {
|
||||||
|
status = errorStyle.Render("disabled")
|
||||||
|
}
|
||||||
|
wanTable.AddRow(
|
||||||
|
valueStyle.Render(w.Name),
|
||||||
|
dimStyle.Render(w.WANType),
|
||||||
|
dimStyle.Render(w.WANNetworkGroup),
|
||||||
|
status,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
wanTable.Render()
|
||||||
|
cli.Blank()
|
||||||
|
}
|
||||||
|
|
||||||
|
// LANs
|
||||||
|
if len(lans) > 0 {
|
||||||
|
cli.Print(" %s\n\n", infoStyle.Render("LAN Networks"))
|
||||||
|
lanTable := cli.NewTable("Name", "Subnet", "VLAN", "Isolated", "Internet", "DHCP", "mDNS")
|
||||||
|
for _, n := range lans {
|
||||||
|
vlan := dimStyle.Render("-")
|
||||||
|
if n.VLANEnabled {
|
||||||
|
vlan = numberStyle.Render(fmt.Sprintf("%d", n.VLAN))
|
||||||
|
}
|
||||||
|
|
||||||
|
isolated := successStyle.Render("no")
|
||||||
|
if n.NetworkIsolationEnabled {
|
||||||
|
isolated = warningStyle.Render("yes")
|
||||||
|
}
|
||||||
|
|
||||||
|
internet := successStyle.Render("yes")
|
||||||
|
if !n.InternetAccessEnabled {
|
||||||
|
internet = errorStyle.Render("no")
|
||||||
|
}
|
||||||
|
|
||||||
|
dhcp := dimStyle.Render("off")
|
||||||
|
if n.DHCPEnabled {
|
||||||
|
dhcp = fmt.Sprintf("%s - %s", n.DHCPStart, n.DHCPStop)
|
||||||
|
}
|
||||||
|
|
||||||
|
mdns := dimStyle.Render("off")
|
||||||
|
if n.MDNSEnabled {
|
||||||
|
mdns = successStyle.Render("on")
|
||||||
|
}
|
||||||
|
|
||||||
|
lanTable.AddRow(
|
||||||
|
valueStyle.Render(n.Name),
|
||||||
|
n.IPSubnet,
|
||||||
|
vlan,
|
||||||
|
isolated,
|
||||||
|
internet,
|
||||||
|
dhcp,
|
||||||
|
mdns,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
lanTable.Render()
|
||||||
|
cli.Blank()
|
||||||
|
}
|
||||||
|
|
||||||
|
// VPNs
|
||||||
|
if len(vpns) > 0 {
|
||||||
|
cli.Print(" %s\n\n", infoStyle.Render("VPN Networks"))
|
||||||
|
vpnTable := cli.NewTable("Name", "Subnet", "Type")
|
||||||
|
for _, v := range vpns {
|
||||||
|
vpnTable.AddRow(
|
||||||
|
valueStyle.Render(v.Name),
|
||||||
|
v.IPSubnet,
|
||||||
|
dimStyle.Render(v.VPNType),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
vpnTable.Render()
|
||||||
|
cli.Blank()
|
||||||
|
}
|
||||||
|
|
||||||
|
cli.Print(" %s\n\n", dimStyle.Render(fmt.Sprintf("%d networks total", len(networks))))
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
85
internal/cmd/unifi/cmd_routes.go
Normal file
85
internal/cmd/unifi/cmd_routes.go
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
package unifi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/host-uk/core/pkg/cli"
|
||||||
|
uf "github.com/host-uk/core/pkg/unifi"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Routes command flags.
|
||||||
|
var (
|
||||||
|
routesSite string
|
||||||
|
routesType string
|
||||||
|
)
|
||||||
|
|
||||||
|
// addRoutesCommand adds the 'routes' subcommand for listing the gateway routing table.
|
||||||
|
func addRoutesCommand(parent *cli.Command) {
|
||||||
|
cmd := &cli.Command{
|
||||||
|
Use: "routes",
|
||||||
|
Short: "List gateway routing table",
|
||||||
|
Long: "List the active routing table from the UniFi gateway, showing network segments and next-hop destinations.",
|
||||||
|
RunE: func(cmd *cli.Command, args []string) error {
|
||||||
|
return runRoutes()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Flags().StringVar(&routesSite, "site", "", "Site name (default: \"default\")")
|
||||||
|
cmd.Flags().StringVar(&routesType, "type", "", "Filter by route type (static, connected, kernel)")
|
||||||
|
|
||||||
|
parent.AddCommand(cmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runRoutes() error {
|
||||||
|
client, err := uf.NewFromConfig("", "", "", "")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
routes, err := client.GetRoutes(routesSite)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by type if requested
|
||||||
|
if routesType != "" {
|
||||||
|
var filtered []uf.Route
|
||||||
|
for _, r := range routes {
|
||||||
|
if uf.RouteTypeName(r.Type) == routesType || r.Type == routesType {
|
||||||
|
filtered = append(filtered, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
routes = filtered
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(routes) == 0 {
|
||||||
|
cli.Text("No routes found.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
table := cli.NewTable("Network", "Next Hop", "Interface", "Type", "Distance", "FIB")
|
||||||
|
|
||||||
|
for _, r := range routes {
|
||||||
|
typeName := uf.RouteTypeName(r.Type)
|
||||||
|
|
||||||
|
fib := dimStyle.Render("no")
|
||||||
|
if r.Selected {
|
||||||
|
fib = successStyle.Render("yes")
|
||||||
|
}
|
||||||
|
|
||||||
|
table.AddRow(
|
||||||
|
valueStyle.Render(r.Network),
|
||||||
|
r.NextHop,
|
||||||
|
dimStyle.Render(r.Interface),
|
||||||
|
dimStyle.Render(typeName),
|
||||||
|
fmt.Sprintf("%d", r.Distance),
|
||||||
|
fib,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
cli.Blank()
|
||||||
|
cli.Print(" %s\n\n", fmt.Sprintf("%d routes", len(routes)))
|
||||||
|
table.Render()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
54
internal/cmd/unifi/cmd_sites.go
Normal file
54
internal/cmd/unifi/cmd_sites.go
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
package unifi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/host-uk/core/pkg/cli"
|
||||||
|
uf "github.com/host-uk/core/pkg/unifi"
|
||||||
|
)
|
||||||
|
|
||||||
|
// addSitesCommand adds the 'sites' subcommand for listing UniFi sites.
|
||||||
|
func addSitesCommand(parent *cli.Command) {
|
||||||
|
cmd := &cli.Command{
|
||||||
|
Use: "sites",
|
||||||
|
Short: "List controller sites",
|
||||||
|
Long: "List all sites configured on the UniFi controller.",
|
||||||
|
RunE: func(cmd *cli.Command, args []string) error {
|
||||||
|
return runSites()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
parent.AddCommand(cmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runSites() error {
|
||||||
|
client, err := uf.NewFromConfig("", "", "", "")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
sites, err := client.GetSites()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(sites) == 0 {
|
||||||
|
cli.Text("No sites found.")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
table := cli.NewTable("Name", "Description")
|
||||||
|
|
||||||
|
for _, s := range sites {
|
||||||
|
table.AddRow(
|
||||||
|
valueStyle.Render(s.Name),
|
||||||
|
dimStyle.Render(s.Desc),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
cli.Blank()
|
||||||
|
cli.Print(" %s\n\n", fmt.Sprintf("%d sites", len(sites)))
|
||||||
|
table.Render()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
46
internal/cmd/unifi/cmd_unifi.go
Normal file
46
internal/cmd/unifi/cmd_unifi.go
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
// Package unifi provides CLI commands for managing a UniFi network controller.
|
||||||
|
//
|
||||||
|
// Commands:
|
||||||
|
// - config: Configure UniFi connection (URL, credentials)
|
||||||
|
// - clients: List connected clients
|
||||||
|
// - devices: List infrastructure devices
|
||||||
|
// - sites: List controller sites
|
||||||
|
// - networks: List network segments and VLANs
|
||||||
|
// - routes: List gateway routing table
|
||||||
|
package unifi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/host-uk/core/pkg/cli"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
cli.RegisterCommands(AddUniFiCommands)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Style aliases from shared package.
|
||||||
|
var (
|
||||||
|
successStyle = cli.SuccessStyle
|
||||||
|
errorStyle = cli.ErrorStyle
|
||||||
|
warningStyle = cli.WarningStyle
|
||||||
|
dimStyle = cli.DimStyle
|
||||||
|
valueStyle = cli.ValueStyle
|
||||||
|
numberStyle = cli.NumberStyle
|
||||||
|
infoStyle = cli.InfoStyle
|
||||||
|
)
|
||||||
|
|
||||||
|
// AddUniFiCommands registers the 'unifi' command and all subcommands.
|
||||||
|
func AddUniFiCommands(root *cli.Command) {
|
||||||
|
unifiCmd := &cli.Command{
|
||||||
|
Use: "unifi",
|
||||||
|
Short: "UniFi network management",
|
||||||
|
Long: "Manage sites, devices, and connected clients on your UniFi controller.",
|
||||||
|
}
|
||||||
|
root.AddCommand(unifiCmd)
|
||||||
|
|
||||||
|
addConfigCommand(unifiCmd)
|
||||||
|
addClientsCommand(unifiCmd)
|
||||||
|
addDevicesCommand(unifiCmd)
|
||||||
|
addNetworksCommand(unifiCmd)
|
||||||
|
addRoutesCommand(unifiCmd)
|
||||||
|
addSitesCommand(unifiCmd)
|
||||||
|
}
|
||||||
|
|
@ -21,6 +21,7 @@
|
||||||
// - qa: Quality assurance workflows
|
// - qa: Quality assurance workflows
|
||||||
// - monitor: Security monitoring aggregation
|
// - monitor: Security monitoring aggregation
|
||||||
// - gitea: Gitea instance management (repos, issues, PRs, mirrors)
|
// - gitea: Gitea instance management (repos, issues, PRs, mirrors)
|
||||||
|
// - unifi: UniFi network management (sites, devices, clients)
|
||||||
|
|
||||||
package variants
|
package variants
|
||||||
|
|
||||||
|
|
@ -48,6 +49,7 @@ import (
|
||||||
_ "github.com/host-uk/core/internal/cmd/security"
|
_ "github.com/host-uk/core/internal/cmd/security"
|
||||||
_ "github.com/host-uk/core/internal/cmd/setup"
|
_ "github.com/host-uk/core/internal/cmd/setup"
|
||||||
_ "github.com/host-uk/core/internal/cmd/test"
|
_ "github.com/host-uk/core/internal/cmd/test"
|
||||||
|
_ "github.com/host-uk/core/internal/cmd/unifi"
|
||||||
_ "github.com/host-uk/core/internal/cmd/updater"
|
_ "github.com/host-uk/core/internal/cmd/updater"
|
||||||
_ "github.com/host-uk/core/internal/cmd/vm"
|
_ "github.com/host-uk/core/internal/cmd/vm"
|
||||||
_ "github.com/host-uk/core/internal/cmd/workspace"
|
_ "github.com/host-uk/core/internal/cmd/workspace"
|
||||||
|
|
|
||||||
50
pkg/unifi/client.go
Normal file
50
pkg/unifi/client.go
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
package unifi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/tls"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
uf "github.com/unpoller/unifi/v5"
|
||||||
|
|
||||||
|
"github.com/host-uk/core/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 is disabled by default (self-signed certs on home lab controllers).
|
||||||
|
func New(url, user, pass, apikey string) (*Client, error) {
|
||||||
|
cfg := &uf.Config{
|
||||||
|
URL: url,
|
||||||
|
User: user,
|
||||||
|
Pass: pass,
|
||||||
|
APIKey: apikey,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip TLS verification for self-signed certs
|
||||||
|
httpClient := &http.Client{
|
||||||
|
Transport: &http.Transport{
|
||||||
|
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
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 }
|
||||||
64
pkg/unifi/clients.go
Normal file
64
pkg/unifi/clients.go
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
package unifi
|
||||||
|
|
||||||
|
import (
|
||||||
|
uf "github.com/unpoller/unifi/v5"
|
||||||
|
|
||||||
|
"github.com/host-uk/core/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)
|
||||||
|
}
|
||||||
130
pkg/unifi/config.go
Normal file
130
pkg/unifi/config.go
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
// 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"
|
||||||
|
|
||||||
|
"github.com/host-uk/core/pkg/config"
|
||||||
|
"github.com/host-uk/core/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"
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// 2. UNIFI_URL + UNIFI_USER + UNIFI_PASS + UNIFI_APIKEY environment variables (override config file)
|
||||||
|
// 3. Provided flag overrides (highest priority; pass empty to skip)
|
||||||
|
func NewFromConfig(flagURL, flagUser, flagPass, flagAPIKey string) (*Client, error) {
|
||||||
|
url, user, pass, apikey, err := ResolveConfig(flagURL, flagUser, flagPass, flagAPIKey)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) (url, user, pass, apikey string, 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// Overlay flag values (highest priority)
|
||||||
|
if flagURL != "" {
|
||||||
|
url = flagURL
|
||||||
|
}
|
||||||
|
if flagUser != "" {
|
||||||
|
user = flagUser
|
||||||
|
}
|
||||||
|
if flagPass != "" {
|
||||||
|
pass = flagPass
|
||||||
|
}
|
||||||
|
if flagAPIKey != "" {
|
||||||
|
apikey = flagAPIKey
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default URL if nothing configured
|
||||||
|
if url == "" {
|
||||||
|
url = DefaultURL
|
||||||
|
}
|
||||||
|
|
||||||
|
return url, user, pass, apikey, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveConfig persists the UniFi URL and/or credentials to the config file.
|
||||||
|
func SaveConfig(url, user, pass, apikey string) 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
116
pkg/unifi/devices.go
Normal file
116
pkg/unifi/devices.go
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
package unifi
|
||||||
|
|
||||||
|
import (
|
||||||
|
uf "github.com/unpoller/unifi/v5"
|
||||||
|
|
||||||
|
"github.com/host-uk/core/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
pkg/unifi/networks.go
Normal file
62
pkg/unifi/networks.go
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
package unifi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/host-uk/core/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
|
||||||
|
}
|
||||||
65
pkg/unifi/routes.go
Normal file
65
pkg/unifi/routes.go
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
package unifi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/host-uk/core/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", 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
pkg/unifi/sites.go
Normal file
17
pkg/unifi/sites.go
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
package unifi
|
||||||
|
|
||||||
|
import (
|
||||||
|
uf "github.com/unpoller/unifi/v5"
|
||||||
|
|
||||||
|
"github.com/host-uk/core/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