From ecc161b72505b75357d7c89c406dce0da83ca894 Mon Sep 17 00:00:00 2001 From: Snider Date: Thu, 5 Feb 2026 01:13:31 +0000 Subject: [PATCH] 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 --- go.mod | 2 + go.sum | 4 + internal/cmd/unifi/cmd_clients.go | 106 +++++++++++++++++++++ internal/cmd/unifi/cmd_config.go | 130 ++++++++++++++++++++++++++ internal/cmd/unifi/cmd_devices.go | 74 +++++++++++++++ internal/cmd/unifi/cmd_networks.go | 144 +++++++++++++++++++++++++++++ internal/cmd/unifi/cmd_routes.go | 85 +++++++++++++++++ internal/cmd/unifi/cmd_sites.go | 54 +++++++++++ internal/cmd/unifi/cmd_unifi.go | 46 +++++++++ internal/variants/full.go | 2 + pkg/unifi/client.go | 50 ++++++++++ pkg/unifi/clients.go | 64 +++++++++++++ pkg/unifi/config.go | 130 ++++++++++++++++++++++++++ pkg/unifi/devices.go | 116 +++++++++++++++++++++++ pkg/unifi/networks.go | 62 +++++++++++++ pkg/unifi/routes.go | 65 +++++++++++++ pkg/unifi/sites.go | 17 ++++ 17 files changed, 1151 insertions(+) create mode 100644 internal/cmd/unifi/cmd_clients.go create mode 100644 internal/cmd/unifi/cmd_config.go create mode 100644 internal/cmd/unifi/cmd_devices.go create mode 100644 internal/cmd/unifi/cmd_networks.go create mode 100644 internal/cmd/unifi/cmd_routes.go create mode 100644 internal/cmd/unifi/cmd_sites.go create mode 100644 internal/cmd/unifi/cmd_unifi.go create mode 100644 pkg/unifi/client.go create mode 100644 pkg/unifi/clients.go create mode 100644 pkg/unifi/config.go create mode 100644 pkg/unifi/devices.go create mode 100644 pkg/unifi/networks.go create mode 100644 pkg/unifi/routes.go create mode 100644 pkg/unifi/sites.go diff --git a/go.mod b/go.mod index 768dfef6..1eba58ad 100644 --- a/go.mod +++ b/go.mod @@ -38,6 +38,7 @@ require ( github.com/adrg/xdg v0.5.3 // indirect github.com/bahlo/generic-list-go v0.2.0 // 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/cloudflare/circl v1.6.3 // indirect github.com/coder/websocket v1.8.14 // indirect @@ -96,6 +97,7 @@ require ( github.com/tidwall/sjson v1.2.5 // indirect github.com/ugorji/go/codec v1.3.0 // 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/wailsapp/go-webview2 v1.0.23 // indirect github.com/wailsapp/wails/v3 v3.0.0-alpha.64 // indirect diff --git a/go.sum b/go.sum index b796f908..d51487ee 100644 --- a/go.sum +++ b/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/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY= 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/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= 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/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/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/go.mod h1:KAEIojdQq66oJiHhDyQez2x+sRit0vIzC9KeK0yizxM= github.com/wailsapp/go-webview2 v1.0.23 h1:jmv8qhz1lHibCc79bMM/a/FqOnnzOGEisLav+a0b9P0= diff --git a/internal/cmd/unifi/cmd_clients.go b/internal/cmd/unifi/cmd_clients.go new file mode 100644 index 00000000..398aa3e2 --- /dev/null +++ b/internal/cmd/unifi/cmd_clients.go @@ -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) + } +} diff --git a/internal/cmd/unifi/cmd_config.go b/internal/cmd/unifi/cmd_config.go new file mode 100644 index 00000000..ab00e1bf --- /dev/null +++ b/internal/cmd/unifi/cmd_config.go @@ -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 +} diff --git a/internal/cmd/unifi/cmd_devices.go b/internal/cmd/unifi/cmd_devices.go new file mode 100644 index 00000000..a9dc19d8 --- /dev/null +++ b/internal/cmd/unifi/cmd_devices.go @@ -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 +} diff --git a/internal/cmd/unifi/cmd_networks.go b/internal/cmd/unifi/cmd_networks.go new file mode 100644 index 00000000..d487f20a --- /dev/null +++ b/internal/cmd/unifi/cmd_networks.go @@ -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 +} diff --git a/internal/cmd/unifi/cmd_routes.go b/internal/cmd/unifi/cmd_routes.go new file mode 100644 index 00000000..ed7faa87 --- /dev/null +++ b/internal/cmd/unifi/cmd_routes.go @@ -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 +} diff --git a/internal/cmd/unifi/cmd_sites.go b/internal/cmd/unifi/cmd_sites.go new file mode 100644 index 00000000..65c114ff --- /dev/null +++ b/internal/cmd/unifi/cmd_sites.go @@ -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 +} diff --git a/internal/cmd/unifi/cmd_unifi.go b/internal/cmd/unifi/cmd_unifi.go new file mode 100644 index 00000000..be2d2331 --- /dev/null +++ b/internal/cmd/unifi/cmd_unifi.go @@ -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) +} diff --git a/internal/variants/full.go b/internal/variants/full.go index c0172998..55ea68d3 100644 --- a/internal/variants/full.go +++ b/internal/variants/full.go @@ -21,6 +21,7 @@ // - qa: Quality assurance workflows // - monitor: Security monitoring aggregation // - gitea: Gitea instance management (repos, issues, PRs, mirrors) +// - unifi: UniFi network management (sites, devices, clients) package variants @@ -48,6 +49,7 @@ import ( _ "github.com/host-uk/core/internal/cmd/security" _ "github.com/host-uk/core/internal/cmd/setup" _ "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/vm" _ "github.com/host-uk/core/internal/cmd/workspace" diff --git a/pkg/unifi/client.go b/pkg/unifi/client.go new file mode 100644 index 00000000..2bbddb2f --- /dev/null +++ b/pkg/unifi/client.go @@ -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 } diff --git a/pkg/unifi/clients.go b/pkg/unifi/clients.go new file mode 100644 index 00000000..74e1ca2d --- /dev/null +++ b/pkg/unifi/clients.go @@ -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) +} diff --git a/pkg/unifi/config.go b/pkg/unifi/config.go new file mode 100644 index 00000000..bab65987 --- /dev/null +++ b/pkg/unifi/config.go @@ -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 +} diff --git a/pkg/unifi/devices.go b/pkg/unifi/devices.go new file mode 100644 index 00000000..0e4e1940 --- /dev/null +++ b/pkg/unifi/devices.go @@ -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 +} diff --git a/pkg/unifi/networks.go b/pkg/unifi/networks.go new file mode 100644 index 00000000..3ff33b75 --- /dev/null +++ b/pkg/unifi/networks.go @@ -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 +} diff --git a/pkg/unifi/routes.go b/pkg/unifi/routes.go new file mode 100644 index 00000000..d3a2de89 --- /dev/null +++ b/pkg/unifi/routes.go @@ -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 + } +} diff --git a/pkg/unifi/sites.go b/pkg/unifi/sites.go new file mode 100644 index 00000000..7162b791 --- /dev/null +++ b/pkg/unifi/sites.go @@ -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 +}