From 28eae6ab2223e504ddc07765f6e96e527d93b031 Mon Sep 17 00:00:00 2001 From: brngates98 Date: Sat, 24 Jan 2026 17:32:36 -0500 Subject: [PATCH 1/9] Add remote API support for UniFi Site Manager - Add remote API mode with automatic controller discovery - Discover consoles via /v1/hosts endpoint - Auto-discover sites for each console via integration API - Use console name from hosts response as site name override for Cloud Gateways - Support both config-level and per-controller remote mode - Add example configs for YAML, JSON, and TOML formats - Remote API uses api.ui.com with X-API-Key authentication - Automatically discovers all consoles when remote=true and remote_api_key is set This enables monitoring multiple UniFi Cloud Gateways through a single API key without requiring direct network access to each controller. --- examples/remote_api_example.conf | 45 +++++ examples/remote_api_example.json | 42 +++++ examples/remote_api_example.yaml | 49 +++++ pkg/inputunifi/input.go | 87 ++++++--- pkg/inputunifi/interface.go | 59 +++++- pkg/inputunifi/remote.go | 314 +++++++++++++++++++++++++++++++ 6 files changed, 574 insertions(+), 22 deletions(-) create mode 100644 examples/remote_api_example.conf create mode 100644 examples/remote_api_example.json create mode 100644 examples/remote_api_example.yaml create mode 100644 pkg/inputunifi/remote.go diff --git a/examples/remote_api_example.conf b/examples/remote_api_example.conf new file mode 100644 index 00000000..4673de17 --- /dev/null +++ b/examples/remote_api_example.conf @@ -0,0 +1,45 @@ +############################################## +# UniFi Poller Remote API Configuration # +# TOML FORMAT - Remote API Example # +# Uses UniFi Site Manager API (api.ui.com) # +############################################## + +[poller] +debug = false +quiet = false + +[unifi] +remote = true +remote_api_key = "YOUR_API_KEY_HERE" + +[unifi.defaults] +save_sites = true +save_dpi = true +save_events = true +save_alarms = true +save_anomalies = true +save_ids = true +save_traffic = true +save_rogue = true +save_syslog = true +save_protect_logs = false +verify_ssl = true + +[prometheus] +disable = false +http_listen = "0.0.0.0:9130" +namespace = "unpoller" +report_errors = false +dead_ports = false + +[influxdb] +disable = true + +[webserver] +enable = false + +[datadog] +enable = false + +[loki] +disable = true diff --git a/examples/remote_api_example.json b/examples/remote_api_example.json new file mode 100644 index 00000000..716cc454 --- /dev/null +++ b/examples/remote_api_example.json @@ -0,0 +1,42 @@ +{ + "poller": { + "debug": false, + "quiet": false + }, + "unifi": { + "remote": true, + "remote_api_key": "YOUR_API_KEY_HERE", + "defaults": { + "save_sites": true, + "save_dpi": true, + "save_events": true, + "save_alarms": true, + "save_anomalies": true, + "save_ids": true, + "save_traffic": true, + "save_rogue": true, + "save_syslog": true, + "save_protect_logs": false, + "verify_ssl": true + } + }, + "prometheus": { + "disable": false, + "http_listen": "0.0.0.0:9130", + "namespace": "unpoller", + "report_errors": false, + "dead_ports": false + }, + "influxdb": { + "disable": true + }, + "webserver": { + "enable": false + }, + "datadog": { + "enable": false + }, + "loki": { + "disable": true + } +} diff --git a/examples/remote_api_example.yaml b/examples/remote_api_example.yaml new file mode 100644 index 00000000..00ed1b00 --- /dev/null +++ b/examples/remote_api_example.yaml @@ -0,0 +1,49 @@ +############################################## +# UniFi Poller Remote API Configuration # +# YAML FORMAT - Remote API Example # +# Uses UniFi Site Manager API (api.ui.com) # +############################################## +--- +poller: + debug: false + quiet: false + +unifi: + # Enable remote API mode - automatically discovers all consoles + remote: true + # Your API key from unifi.ui.com (Settings -> API Keys) + remote_api_key: "YOUR_API_KEY_HERE" + + defaults: + # Enable all metric collection + save_sites: true + save_dpi: true + save_events: true + save_alarms: true + save_anomalies: true + save_ids: true + save_traffic: true + save_rogue: true + save_syslog: true + save_protect_logs: false + # Remote API requires SSL verification + verify_ssl: true + +prometheus: + disable: false + http_listen: "0.0.0.0:9130" + namespace: "unpoller" + report_errors: false + dead_ports: false + +influxdb: + disable: true + +webserver: + enable: false + +datadog: + enable: false + +loki: + disable: true diff --git a/pkg/inputunifi/input.go b/pkg/inputunifi/input.go index 1de19e2e..d24e7fa0 100644 --- a/pkg/inputunifi/input.go +++ b/pkg/inputunifi/input.go @@ -58,6 +58,8 @@ type Controller struct { URL string `json:"url" toml:"url" xml:"url" yaml:"url"` Sites []string `json:"sites" toml:"sites" xml:"site" yaml:"sites"` DefaultSiteNameOverride string `json:"default_site_name_override" toml:"default_site_name_override" xml:"default_site_name_override" yaml:"default_site_name_override"` + Remote bool `json:"remote" toml:"remote" xml:"remote,attr" yaml:"remote"` + ConsoleID string `json:"console_id,omitempty" toml:"console_id,omitempty" xml:"console_id,omitempty" yaml:"console_id,omitempty"` Unifi *unifi.Unifi `json:"-" toml:"-" xml:"-" yaml:"-"` ID string `json:"id,omitempty"` // this is an output, not an input. } @@ -68,6 +70,8 @@ type Config struct { Default Controller `json:"defaults" toml:"defaults" xml:"default" yaml:"defaults"` Disable bool `json:"disable" toml:"disable" xml:"disable,attr" yaml:"disable"` Dynamic bool `json:"dynamic" toml:"dynamic" xml:"dynamic,attr" yaml:"dynamic"` + Remote bool `json:"remote" toml:"remote" xml:"remote,attr" yaml:"remote"` + RemoteAPIKey string `json:"remote_api_key" toml:"remote_api_key" xml:"remote_api_key" yaml:"remote_api_key"` Controllers []*Controller `json:"controllers" toml:"controller" xml:"controller" yaml:"controllers"` } @@ -282,7 +286,12 @@ func (u *InputUnifi) setDefaults(c *Controller) { //nolint:cyclop } if c.URL == "" { - c.URL = defaultURL + if c.Remote { + // Remote mode: URL will be set during discovery + // Don't set a default here + } else { + c.URL = defaultURL + } } if strings.HasPrefix(c.Pass, "file://") { @@ -293,18 +302,30 @@ func (u *InputUnifi) setDefaults(c *Controller) { //nolint:cyclop c.APIKey = u.getPassFromFile(strings.TrimPrefix(c.APIKey, "file://")) } - if c.APIKey == "" { - if c.Pass == "" { - c.Pass = defaultPass - } - - if c.User == "" { - c.User = defaultUser + if c.Remote { + // Remote mode: only API key is used, no user/pass + if c.APIKey == "" { + // For remote mode, API key is required + // Will be set from RemoteAPIKey in Config if not provided + } else { + c.User = "" + c.Pass = "" } } else { - // clear out user/pass combo, only use API-key - c.User = "" - c.Pass = "" + // Local mode: use API key if provided, otherwise user/pass + if c.APIKey == "" { + if c.Pass == "" { + c.Pass = defaultPass + } + + if c.User == "" { + c.User = defaultUser + } + } else { + // clear out user/pass combo, only use API-key + c.User = "" + c.Pass = "" + } } if len(c.Sites) == 0 { @@ -381,7 +402,15 @@ func (u *InputUnifi) setControllerDefaults(c *Controller) *Controller { //nolint } if c.URL == "" { - c.URL = u.Default.URL + if c.Remote { + // Remote mode: URL will be set during discovery + // Don't set a default here + } else { + c.URL = u.Default.URL + if c.URL == "" { + c.URL = defaultURL + } + } } if strings.HasPrefix(c.Pass, "file://") { @@ -392,18 +421,34 @@ func (u *InputUnifi) setControllerDefaults(c *Controller) *Controller { //nolint c.APIKey = u.getPassFromFile(strings.TrimPrefix(c.APIKey, "file://")) } - if c.APIKey == "" { - if c.Pass == "" { - c.Pass = defaultPass + if c.Remote { + // Remote mode: only API key is used + if c.APIKey == "" { + c.APIKey = u.Default.APIKey } - - if c.User == "" { - c.User = defaultUser - } - } else { - // clear out user/pass combo, only use API-key c.User = "" c.Pass = "" + } else { + // Local mode: use API key if provided, otherwise user/pass + if c.APIKey == "" { + if c.Pass == "" { + c.Pass = u.Default.Pass + if c.Pass == "" { + c.Pass = defaultPass + } + } + + if c.User == "" { + c.User = u.Default.User + if c.User == "" { + c.User = defaultUser + } + } + } else { + // clear out user/pass combo, only use API-key + c.User = "" + c.Pass = "" + } } if len(c.Sites) == 0 { diff --git a/pkg/inputunifi/interface.go b/pkg/inputunifi/interface.go index 14ae2b1f..48c683ec 100644 --- a/pkg/inputunifi/interface.go +++ b/pkg/inputunifi/interface.go @@ -31,9 +31,52 @@ func (u *InputUnifi) Initialize(l poller.Logger) error { } if u.setDefaults(&u.Default); len(u.Controllers) == 0 && !u.Dynamic { + // If remote mode is enabled at config level, set it on default controller + if u.Remote && u.RemoteAPIKey != "" { + u.Default.Remote = true + u.Default.APIKey = u.RemoteAPIKey + } u.Controllers = []*Controller{&u.Default} } + // Discover remote controllers if remote mode is enabled + if u.Remote && u.RemoteAPIKey != "" { + u.Logf("Remote API mode enabled, discovering controllers...") + + discovered, err := u.discoverRemoteControllers(u.RemoteAPIKey) + if err != nil { + u.LogErrorf("Failed to discover remote controllers: %v", err) + } else if len(discovered) > 0 { + // Merge discovered controllers with configured ones + u.Controllers = append(u.Controllers, discovered...) + u.Logf("Discovered %d remote controller(s)", len(discovered)) + } + } + + // Also check individual controllers for remote flag + for _, c := range u.Controllers { + if c.Remote && c.APIKey != "" && c.ConsoleID == "" { + // This controller has remote flag but no console ID, try to discover + discovered, err := u.discoverRemoteControllers(c.APIKey) + if err != nil { + u.LogErrorf("Failed to discover remote controllers for controller: %v", err) + continue + } + if len(discovered) > 0 { + // Replace this controller with discovered ones + // Remove the current one and add discovered + newControllers := []*Controller{} + for _, existing := range u.Controllers { + if existing != c { + newControllers = append(newControllers, existing) + } + } + newControllers = append(newControllers, discovered...) + u.Controllers = newControllers + } + } + } + if len(u.Controllers) == 0 { u.Logf("No controllers configured. Polling dynamic controllers only! Defaults:") u.logController(&u.Default) @@ -114,6 +157,15 @@ func (u *InputUnifi) DebugInput() (bool, error) { } func (u *InputUnifi) logController(c *Controller) { + mode := "Local" + if c.Remote { + mode = "Remote" + if c.ConsoleID != "" { + mode += fmt.Sprintf(" (Console: %s)", c.ConsoleID) + } + } + + u.Logf(" => Mode: %s", mode) u.Logf(" => URL: %s (verify SSL: %v, timeout: %v)", c.URL, *c.VerifySSL, c.Timeout.Duration) if len(c.CertPaths) > 0 { @@ -124,7 +176,12 @@ func (u *InputUnifi) logController(c *Controller) { u.Logf(" => Version: %s (%s)", c.Unifi.ServerVersion, c.Unifi.UUID) } - u.Logf(" => Username: %s (has password: %v) (has api-key: %v)", c.User, c.Pass != "", c.APIKey != "") + if c.Remote { + u.Logf(" => API Key: %v", c.APIKey != "") + } else { + u.Logf(" => Username: %s (has password: %v) (has api-key: %v)", c.User, c.Pass != "", c.APIKey != "") + } + u.Logf(" => Hash PII %v / Drop PII %v / Poll Sites: %s", *c.HashPII, *c.DropPII, strings.Join(c.Sites, ", ")) u.Logf(" => Save Sites %v / Save DPI %v (metrics)", *c.SaveSites, *c.SaveDPI) u.Logf(" => Save Events %v / Save Syslog %v / Save IDs %v (logs)", *c.SaveEvents, *c.SaveSyslog, *c.SaveIDs) diff --git a/pkg/inputunifi/remote.go b/pkg/inputunifi/remote.go new file mode 100644 index 00000000..96d33d1e --- /dev/null +++ b/pkg/inputunifi/remote.go @@ -0,0 +1,314 @@ +package inputunifi + +import ( + "crypto/tls" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +const ( + remoteAPIBaseURL = "https://api.ui.com" + remoteAPIVersion = "v1" +) + +// Console represents a UniFi console from the remote API. +type Console struct { + ID string `json:"id"` + IPAddress string `json:"ipAddress"` + Type string `json:"type"` + Owner bool `json:"owner"` + IsBlocked bool `json:"isBlocked"` + ReportedState struct { + Name string `json:"name"` + Hostname string `json:"hostname"` + IP string `json:"ip"` + State string `json:"state"` + Mac string `json:"mac"` + } `json:"reportedState"` + ConsoleName string // Derived field: name from reportedState +} + +// HostsResponse represents the response from /v1/hosts endpoint. +type HostsResponse struct { + Data []Console `json:"data"` + HTTPStatusCode int `json:"httpStatusCode"` + TraceID string `json:"traceId"` + NextToken string `json:"nextToken,omitempty"` +} + +// Site represents a site from the remote API. +type RemoteSite struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` +} + +// SitesResponse represents the response from the sites endpoint. +type SitesResponse struct { + Data []RemoteSite `json:"data"` + HTTPStatusCode int `json:"httpStatusCode"` + TraceID string `json:"traceId"` +} + +// remoteAPIClient handles HTTP requests to the remote UniFi API. +type remoteAPIClient struct { + apiKey string + baseURL string + client *http.Client + logError func(string, ...any) + logDebug func(string, ...any) + log func(string, ...any) +} + +// newRemoteAPIClient creates a new remote API client. +func (u *InputUnifi) newRemoteAPIClient(apiKey string) *remoteAPIClient { + if apiKey == "" { + return nil + } + + // Handle file:// prefix for API key + if strings.HasPrefix(apiKey, "file://") { + apiKey = u.getPassFromFile(strings.TrimPrefix(apiKey, "file://")) + } + + return &remoteAPIClient{ + apiKey: apiKey, + baseURL: remoteAPIBaseURL, + client: &http.Client{ + Timeout: 30 * time.Second, + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: false, + }, + }, + }, + logError: u.LogErrorf, + logDebug: u.LogDebugf, + log: u.Logf, + } +} + +// makeRequest makes an HTTP request to the remote API. +func (c *remoteAPIClient) makeRequest(method, path string, queryParams map[string]string) ([]byte, error) { + fullURL := c.baseURL + path + + if len(queryParams) > 0 { + u, err := url.Parse(fullURL) + if err != nil { + return nil, fmt.Errorf("parsing URL: %w", err) + } + + q := u.Query() + for k, v := range queryParams { + q.Set(k, v) + } + u.RawQuery = q.Encode() + fullURL = u.String() + } + + c.logDebug("Making %s request to: %s", method, fullURL) + + req, err := http.NewRequest(method, fullURL, nil) + if err != nil { + return nil, fmt.Errorf("creating request: %w", err) + } + + req.Header.Set("Accept", "application/json") + req.Header.Set("X-API-Key", c.apiKey) + + resp, err := c.client.Do(req) + if err != nil { + return nil, fmt.Errorf("making request: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("reading response: %w", err) + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(body)) + } + + return body, nil +} + +// discoverConsoles discovers all consoles available via the remote API. +func (c *remoteAPIClient) discoverConsoles() ([]Console, error) { + // Start with first page + queryParams := map[string]string{ + "pageSize": "10", + } + + var allConsoles []Console + nextToken := "" + + for { + if nextToken != "" { + queryParams["nextToken"] = nextToken + } else { + // Remove nextToken from params for first request + delete(queryParams, "nextToken") + } + + body, err := c.makeRequest("GET", "/v1/hosts", queryParams) + if err != nil { + return nil, fmt.Errorf("fetching consoles: %w", err) + } + + var response HostsResponse + if err := json.Unmarshal(body, &response); err != nil { + return nil, fmt.Errorf("parsing consoles response: %w", err) + } + + // Filter for console type only + for _, console := range response.Data { + if console.Type == "console" && !console.IsBlocked { + // Extract the console name from reportedState + console.ConsoleName = console.ReportedState.Name + if console.ConsoleName == "" { + console.ConsoleName = console.ReportedState.Hostname + } + allConsoles = append(allConsoles, console) + } + } + + // Check if there's a nextToken to continue pagination + if response.NextToken == "" { + break + } + + nextToken = response.NextToken + c.logDebug("Fetching next page of consoles with nextToken: %s", nextToken) + } + + return allConsoles, nil +} + +// discoverSites discovers all sites for a given console ID. +func (c *remoteAPIClient) discoverSites(consoleID string) ([]RemoteSite, error) { + path := fmt.Sprintf("/v1/connector/consoles/%s/proxy/network/integration/v1/sites", consoleID) + + queryParams := map[string]string{ + "offset": "0", + "limit": "100", + } + + body, err := c.makeRequest("GET", path, queryParams) + if err != nil { + return nil, fmt.Errorf("fetching sites for console %s: %w", consoleID, err) + } + + var response SitesResponse + if err := json.Unmarshal(body, &response); err != nil { + return nil, fmt.Errorf("parsing sites response: %w", err) + } + + return response.Data, nil +} + +// discoverRemoteControllers discovers all controllers via remote API and creates Controller entries. +func (u *InputUnifi) discoverRemoteControllers(apiKey string) ([]*Controller, error) { + client := u.newRemoteAPIClient(apiKey) + if client == nil { + return nil, fmt.Errorf("remote API key not provided") + } + + u.Logf("Discovering remote UniFi consoles...") + + consoles, err := client.discoverConsoles() + if err != nil { + return nil, fmt.Errorf("discovering consoles: %w", err) + } + + if len(consoles) == 0 { + u.Logf("No consoles found via remote API") + return nil, nil + } + + u.Logf("Found %d console(s) via remote API", len(consoles)) + + var controllers []*Controller + + for _, console := range consoles { + consoleName := console.ConsoleName + if consoleName == "" { + consoleName = console.ReportedState.Name + } + if consoleName == "" { + consoleName = console.ReportedState.Hostname + } + u.LogDebugf("Discovering sites for console: %s (%s)", console.ID, consoleName) + + sites, err := client.discoverSites(console.ID) + if err != nil { + u.LogErrorf("Failed to discover sites for console %s: %v", console.ID, err) + continue + } + + if len(sites) == 0 { + u.LogDebugf("No sites found for console %s", console.ID) + continue + } + + // Create a controller entry for this console + // For remote API, the base URL should point to the connector endpoint + // The unifi library will append /proxy/network/... paths, so we need to account for that + // For remote API with integration endpoints, we set it to the connector base + controller := &Controller{ + Remote: true, + ConsoleID: console.ID, + APIKey: apiKey, + // Set URL to connector base - the library appends /proxy/network/status + // But for integration API we need /proxy/network/integration/v1/... + // This may require library updates, but try connector base first + URL: fmt.Sprintf("%s/v1/connector/consoles/%s", remoteAPIBaseURL, console.ID), + } + + // Copy defaults + controller = u.setControllerDefaults(controller) + + // Set remote-specific defaults + t := true + if controller.VerifySSL == nil { + controller.VerifySSL = &t // Remote API should verify SSL + } + + // Extract site names + siteNames := make([]string, 0, len(sites)) + for _, site := range sites { + if site.Name != "" { + siteNames = append(siteNames, site.Name) + } + } + + // For Cloud Gateways, if the only site is "default", use the console name from hosts response + // as the default site name override. The console name is in reportedState.name + // (consoleName was already set above in the loop) + + // If we only have one site and it's "default", use the console name as override + if len(siteNames) == 1 && (siteNames[0] == "default" || siteNames[0] == "") && consoleName != "" { + controller.DefaultSiteNameOverride = consoleName + u.LogDebugf("Using console name '%s' as default site name override for Cloud Gateway", consoleName) + } + + if len(siteNames) > 0 { + controller.Sites = siteNames + } else { + controller.Sites = []string{"all"} + } + + controller.ID = console.ID + controllers = append(controllers, controller) + + u.Logf("Discovered console %s with %d site(s): %v", consoleName, len(sites), siteNames) + } + + return controllers, nil +} From 5f76c59fa2d2cb7fc5504e1d05750ac5029ea9ce Mon Sep 17 00:00:00 2001 From: brngates98 Date: Sat, 24 Jan 2026 17:42:54 -0500 Subject: [PATCH 2/9] fix duplicate controllers due to cloud gateways site being default --- pkg/inputunifi/input.go | 3 +- pkg/inputunifi/interface.go | 60 +++++++++++++++++-------------------- pkg/inputunifi/remote.go | 56 ++++++++++++++++++++++++++++++---- 3 files changed, 80 insertions(+), 39 deletions(-) diff --git a/pkg/inputunifi/input.go b/pkg/inputunifi/input.go index d24e7fa0..79dc7ce5 100644 --- a/pkg/inputunifi/input.go +++ b/pkg/inputunifi/input.go @@ -193,7 +193,8 @@ func (u *InputUnifi) checkSites(c *Controller) error { FIRST: for _, s := range c.Sites { for _, site := range sites { - if s == site.Name { + // Case-insensitive comparison for site names + if strings.EqualFold(s, site.Name) { keep = append(keep, s) continue FIRST diff --git a/pkg/inputunifi/interface.go b/pkg/inputunifi/interface.go index 48c683ec..4738b6d6 100644 --- a/pkg/inputunifi/interface.go +++ b/pkg/inputunifi/interface.go @@ -30,16 +30,7 @@ func (u *InputUnifi) Initialize(l poller.Logger) error { return nil } - if u.setDefaults(&u.Default); len(u.Controllers) == 0 && !u.Dynamic { - // If remote mode is enabled at config level, set it on default controller - if u.Remote && u.RemoteAPIKey != "" { - u.Default.Remote = true - u.Default.APIKey = u.RemoteAPIKey - } - u.Controllers = []*Controller{&u.Default} - } - - // Discover remote controllers if remote mode is enabled + // Discover remote controllers if remote mode is enabled at config level if u.Remote && u.RemoteAPIKey != "" { u.Logf("Remote API mode enabled, discovering controllers...") @@ -47,32 +38,37 @@ func (u *InputUnifi) Initialize(l poller.Logger) error { if err != nil { u.LogErrorf("Failed to discover remote controllers: %v", err) } else if len(discovered) > 0 { - // Merge discovered controllers with configured ones - u.Controllers = append(u.Controllers, discovered...) + // Replace controllers with discovered ones when using config-level remote mode + u.Controllers = discovered u.Logf("Discovered %d remote controller(s)", len(discovered)) } - } + } else { + // Only set default controller if not using config-level remote mode + if u.setDefaults(&u.Default); len(u.Controllers) == 0 && !u.Dynamic { + u.Controllers = []*Controller{&u.Default} + } - // Also check individual controllers for remote flag - for _, c := range u.Controllers { - if c.Remote && c.APIKey != "" && c.ConsoleID == "" { - // This controller has remote flag but no console ID, try to discover - discovered, err := u.discoverRemoteControllers(c.APIKey) - if err != nil { - u.LogErrorf("Failed to discover remote controllers for controller: %v", err) - continue - } - if len(discovered) > 0 { - // Replace this controller with discovered ones - // Remove the current one and add discovered - newControllers := []*Controller{} - for _, existing := range u.Controllers { - if existing != c { - newControllers = append(newControllers, existing) - } + // Check individual controllers for remote flag (per-controller remote mode) + for _, c := range u.Controllers { + if c.Remote && c.APIKey != "" && c.ConsoleID == "" { + // This controller has remote flag but no console ID, try to discover + discovered, err := u.discoverRemoteControllers(c.APIKey) + if err != nil { + u.LogErrorf("Failed to discover remote controllers for controller: %v", err) + continue + } + if len(discovered) > 0 { + // Replace this controller with discovered ones + // Remove the current one and add discovered + newControllers := []*Controller{} + for _, existing := range u.Controllers { + if existing != c { + newControllers = append(newControllers, existing) + } + } + newControllers = append(newControllers, discovered...) + u.Controllers = newControllers } - newControllers = append(newControllers, discovered...) - u.Controllers = newControllers } } } diff --git a/pkg/inputunifi/remote.go b/pkg/inputunifi/remote.go index 96d33d1e..6b616f91 100644 --- a/pkg/inputunifi/remote.go +++ b/pkg/inputunifi/remote.go @@ -271,14 +271,58 @@ func (u *InputUnifi) discoverRemoteControllers(apiKey string) ([]*Controller, er URL: fmt.Sprintf("%s/v1/connector/consoles/%s", remoteAPIBaseURL, console.ID), } + // Ensure defaults are set before calling setControllerDefaults + u.setDefaults(&u.Default) + // Copy defaults controller = u.setControllerDefaults(controller) - // Set remote-specific defaults + // Set remote-specific defaults and ensure all boolean pointers are initialized t := true + f := false if controller.VerifySSL == nil { controller.VerifySSL = &t // Remote API should verify SSL } + // Ensure all boolean pointers are set (safety check) + if controller.HashPII == nil { + controller.HashPII = &f + } + if controller.DropPII == nil { + controller.DropPII = &f + } + if controller.SaveSites == nil { + controller.SaveSites = &t + } + if controller.SaveDPI == nil { + controller.SaveDPI = &f + } + if controller.SaveEvents == nil { + controller.SaveEvents = &f + } + if controller.SaveAlarms == nil { + controller.SaveAlarms = &f + } + if controller.SaveAnomal == nil { + controller.SaveAnomal = &f + } + if controller.SaveIDs == nil { + controller.SaveIDs = &f + } + if controller.SaveTraffic == nil { + controller.SaveTraffic = &f + } + if controller.SaveRogue == nil { + controller.SaveRogue = &f + } + if controller.SaveSyslog == nil { + controller.SaveSyslog = &f + } + if controller.SaveProtectLogs == nil { + controller.SaveProtectLogs = &f + } + if controller.ProtectThumbnails == nil { + controller.ProtectThumbnails = &f + } // Extract site names siteNames := make([]string, 0, len(sites)) @@ -292,13 +336,13 @@ func (u *InputUnifi) discoverRemoteControllers(apiKey string) ([]*Controller, er // as the default site name override. The console name is in reportedState.name // (consoleName was already set above in the loop) - // If we only have one site and it's "default", use the console name as override - if len(siteNames) == 1 && (siteNames[0] == "default" || siteNames[0] == "") && consoleName != "" { + // If we only have one site and it's "default" (case-insensitive), use the console name as override + if len(siteNames) == 1 && strings.EqualFold(siteNames[0], "default") && consoleName != "" { controller.DefaultSiteNameOverride = consoleName + // Set sites to "all" since we're overriding the default site name + controller.Sites = []string{"all"} u.LogDebugf("Using console name '%s' as default site name override for Cloud Gateway", consoleName) - } - - if len(siteNames) > 0 { + } else if len(siteNames) > 0 { controller.Sites = siteNames } else { controller.Sites = []string{"all"} From 1440f1426edd1c40a892f77ec99a7d363d90b9b4 Mon Sep 17 00:00:00 2001 From: brngates98 Date: Sat, 24 Jan 2026 17:46:32 -0500 Subject: [PATCH 3/9] Fix site name override for remote API Cloud Gateways - Keep actual site name 'default' for API calls to prevent 404 errors - Apply site name override only in metrics for display purposes - Fixes issue where console names were used in API paths causing 404s - Site name override now correctly applied to devices, clients, sites, and rogue APs in metrics only --- pkg/inputunifi/collector.go | 84 +++++++++++++++++++++++++++++++++++-- pkg/inputunifi/remote.go | 8 ++-- 2 files changed, 85 insertions(+), 7 deletions(-) diff --git a/pkg/inputunifi/collector.go b/pkg/inputunifi/collector.go index 0f128037..58a4bef6 100644 --- a/pkg/inputunifi/collector.go +++ b/pkg/inputunifi/collector.go @@ -275,6 +275,13 @@ func (u *InputUnifi) augmentMetrics(c *Controller, metrics *Metrics) *poller.Met m, devices, bssdIDs := extractDevices(metrics) + // Apply default_site_name_override to devices if configured. + // This allows us to use the console name for Cloud Gateways while keeping + // the actual site name ("default") for API calls. + if c.DefaultSiteNameOverride != "" { + applySiteNameOverride(m, c.DefaultSiteNameOverride) + } + // These come blank, so set them here. for _, client := range metrics.Clients { if devices[client.Mac] = client.Name; client.Name == "" { @@ -329,6 +336,76 @@ func (u *InputUnifi) augmentMetrics(c *Controller, metrics *Metrics) *poller.Met return m } +// applySiteNameOverride replaces "default" site names with the override name +// in all devices, clients, and sites. This allows us to use console names +// for Cloud Gateways in metrics while keeping "default" for API calls. +func applySiteNameOverride(m *poller.Metrics, overrideName string) { + // Apply to all devices - use type switch for known device types + for i := range m.Devices { + switch d := m.Devices[i].(type) { + case *unifi.UAP: + if strings.EqualFold(d.SiteName, "default") { + d.SiteName = overrideName + } + case *unifi.USG: + if strings.EqualFold(d.SiteName, "default") { + d.SiteName = overrideName + } + case *unifi.USW: + if strings.EqualFold(d.SiteName, "default") { + d.SiteName = overrideName + } + case *unifi.UDM: + if strings.EqualFold(d.SiteName, "default") { + d.SiteName = overrideName + } + case *unifi.UXG: + if strings.EqualFold(d.SiteName, "default") { + d.SiteName = overrideName + } + case *unifi.UBB: + if strings.EqualFold(d.SiteName, "default") { + d.SiteName = overrideName + } + case *unifi.UCI: + if strings.EqualFold(d.SiteName, "default") { + d.SiteName = overrideName + } + case *unifi.PDU: + if strings.EqualFold(d.SiteName, "default") { + d.SiteName = overrideName + } + } + } + + // Apply to all clients + for i := range m.Clients { + if client, ok := m.Clients[i].(*unifi.Client); ok { + if strings.EqualFold(client.SiteName, "default") { + client.SiteName = overrideName + } + } + } + + // Apply to sites + for i := range m.Sites { + if site, ok := m.Sites[i].(*unifi.Site); ok { + if strings.EqualFold(site.Name, "default") { + site.Name = overrideName + } + } + } + + // Apply to rogue APs + for i := range m.RogueAPs { + if ap, ok := m.RogueAPs[i].(*unifi.RogueAP); ok { + if strings.EqualFold(ap.SiteName, "default") { + ap.SiteName = overrideName + } + } + } +} + // this is a helper function for augmentMetrics. func extractDevices(metrics *Metrics) (*poller.Metrics, map[string]string, map[string]string) { m := &poller.Metrics{TS: metrics.TS} @@ -442,10 +519,9 @@ func (u *InputUnifi) getFilteredSites(c *Controller) ([]*unifi.Site, error) { return nil, fmt.Errorf("controller: %w", err) } - // Apply the default_site_name_override to the first site in the list, if configured. - if len(sites) > 0 && c.DefaultSiteNameOverride != "" { - sites[0].Name = c.DefaultSiteNameOverride - } + // Note: We do NOT override the site name here because it's used in API calls. + // The API expects the actual site name (e.g., "default"), not the override. + // The override will be applied later when augmenting metrics for display purposes. if len(c.Sites) == 0 || StringInSlice("all", c.Sites) { return sites, nil diff --git a/pkg/inputunifi/remote.go b/pkg/inputunifi/remote.go index 6b616f91..3176a9d4 100644 --- a/pkg/inputunifi/remote.go +++ b/pkg/inputunifi/remote.go @@ -337,11 +337,13 @@ func (u *InputUnifi) discoverRemoteControllers(apiKey string) ([]*Controller, er // (consoleName was already set above in the loop) // If we only have one site and it's "default" (case-insensitive), use the console name as override + // Note: We keep the actual site name ("default") for API calls, but set the override + // for display/metric naming purposes. if len(siteNames) == 1 && strings.EqualFold(siteNames[0], "default") && consoleName != "" { controller.DefaultSiteNameOverride = consoleName - // Set sites to "all" since we're overriding the default site name - controller.Sites = []string{"all"} - u.LogDebugf("Using console name '%s' as default site name override for Cloud Gateway", consoleName) + // Keep the actual site name for API calls + controller.Sites = siteNames + u.LogDebugf("Using console name '%s' as default site name override for Cloud Gateway (API will use 'default')", consoleName) } else if len(siteNames) > 0 { controller.Sites = siteNames } else { From d0abba6ddb299531dff539fc76c1c77e758b2a02 Mon Sep 17 00:00:00 2001 From: brngates98 Date: Sat, 24 Jan 2026 18:22:34 -0500 Subject: [PATCH 4/9] Improve site name override to handle all default site name variations - Add isDefaultSiteName helper to match any site name containing 'default' (case-insensitive) - Handles variations like 'Default', 'default', 'Default (default)', etc. - Ensures site_name in metrics shows console names instead of generic 'Default' values - Makes metrics more compatible with existing dashboards that expect meaningful site names - Also checks SiteName field on sites in addition to Name field --- pkg/inputunifi/collector.go | 40 ++++++++++++++++++++++++++----------- 1 file changed, 28 insertions(+), 12 deletions(-) diff --git a/pkg/inputunifi/collector.go b/pkg/inputunifi/collector.go index 58a4bef6..52f90c5e 100644 --- a/pkg/inputunifi/collector.go +++ b/pkg/inputunifi/collector.go @@ -336,43 +336,56 @@ func (u *InputUnifi) augmentMetrics(c *Controller, metrics *Metrics) *poller.Met return m } +// isDefaultSiteName checks if a site name represents a "default" site. +// This handles variations like "default", "Default", "Default (default)", etc. +func isDefaultSiteName(siteName string) bool { + if siteName == "" { + return false + } + lower := strings.ToLower(siteName) + // Check for exact match or if it contains "default" as a word + return lower == "default" || strings.Contains(lower, "default") +} + // applySiteNameOverride replaces "default" site names with the override name // in all devices, clients, and sites. This allows us to use console names // for Cloud Gateways in metrics while keeping "default" for API calls. +// This makes metrics more compatible with existing dashboards that expect +// meaningful site names instead of "Default" or "Default (default)". func applySiteNameOverride(m *poller.Metrics, overrideName string) { // Apply to all devices - use type switch for known device types for i := range m.Devices { switch d := m.Devices[i].(type) { case *unifi.UAP: - if strings.EqualFold(d.SiteName, "default") { + if isDefaultSiteName(d.SiteName) { d.SiteName = overrideName } case *unifi.USG: - if strings.EqualFold(d.SiteName, "default") { + if isDefaultSiteName(d.SiteName) { d.SiteName = overrideName } case *unifi.USW: - if strings.EqualFold(d.SiteName, "default") { + if isDefaultSiteName(d.SiteName) { d.SiteName = overrideName } case *unifi.UDM: - if strings.EqualFold(d.SiteName, "default") { + if isDefaultSiteName(d.SiteName) { d.SiteName = overrideName } case *unifi.UXG: - if strings.EqualFold(d.SiteName, "default") { + if isDefaultSiteName(d.SiteName) { d.SiteName = overrideName } case *unifi.UBB: - if strings.EqualFold(d.SiteName, "default") { + if isDefaultSiteName(d.SiteName) { d.SiteName = overrideName } case *unifi.UCI: - if strings.EqualFold(d.SiteName, "default") { + if isDefaultSiteName(d.SiteName) { d.SiteName = overrideName } case *unifi.PDU: - if strings.EqualFold(d.SiteName, "default") { + if isDefaultSiteName(d.SiteName) { d.SiteName = overrideName } } @@ -381,25 +394,28 @@ func applySiteNameOverride(m *poller.Metrics, overrideName string) { // Apply to all clients for i := range m.Clients { if client, ok := m.Clients[i].(*unifi.Client); ok { - if strings.EqualFold(client.SiteName, "default") { + if isDefaultSiteName(client.SiteName) { client.SiteName = overrideName } } } - // Apply to sites + // Apply to sites - check both Name and SiteName fields for i := range m.Sites { if site, ok := m.Sites[i].(*unifi.Site); ok { - if strings.EqualFold(site.Name, "default") { + if isDefaultSiteName(site.Name) { site.Name = overrideName } + if isDefaultSiteName(site.SiteName) { + site.SiteName = overrideName + } } } // Apply to rogue APs for i := range m.RogueAPs { if ap, ok := m.RogueAPs[i].(*unifi.RogueAP); ok { - if strings.EqualFold(ap.SiteName, "default") { + if isDefaultSiteName(ap.SiteName) { ap.SiteName = overrideName } } From 3996fd868387b432e9831caef9257f60aa169e52 Mon Sep 17 00:00:00 2001 From: brngates98 Date: Sat, 24 Jan 2026 18:22:40 -0500 Subject: [PATCH 5/9] Format code with gofmt --- pkg/inputunifi/input.go | 2 +- pkg/inputunifi/remote.go | 22 +++++++++++----------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/pkg/inputunifi/input.go b/pkg/inputunifi/input.go index 79dc7ce5..11810b50 100644 --- a/pkg/inputunifi/input.go +++ b/pkg/inputunifi/input.go @@ -369,7 +369,7 @@ func (u *InputUnifi) setControllerDefaults(c *Controller) *Controller { //nolint if c.SaveTraffic == nil { c.SaveTraffic = u.Default.SaveTraffic } - + if c.SaveIDs == nil { c.SaveIDs = u.Default.SaveIDs } diff --git a/pkg/inputunifi/remote.go b/pkg/inputunifi/remote.go index 3176a9d4..b34e3a48 100644 --- a/pkg/inputunifi/remote.go +++ b/pkg/inputunifi/remote.go @@ -18,17 +18,17 @@ const ( // Console represents a UniFi console from the remote API. type Console struct { - ID string `json:"id"` - IPAddress string `json:"ipAddress"` - Type string `json:"type"` - Owner bool `json:"owner"` - IsBlocked bool `json:"isBlocked"` + ID string `json:"id"` + IPAddress string `json:"ipAddress"` + Type string `json:"type"` + Owner bool `json:"owner"` + IsBlocked bool `json:"isBlocked"` ReportedState struct { - Name string `json:"name"` - Hostname string `json:"hostname"` - IP string `json:"ip"` - State string `json:"state"` - Mac string `json:"mac"` + Name string `json:"name"` + Hostname string `json:"hostname"` + IP string `json:"ip"` + State string `json:"state"` + Mac string `json:"mac"` } `json:"reportedState"` ConsoleName string // Derived field: name from reportedState } @@ -273,7 +273,7 @@ func (u *InputUnifi) discoverRemoteControllers(apiKey string) ([]*Controller, er // Ensure defaults are set before calling setControllerDefaults u.setDefaults(&u.Default) - + // Copy defaults controller = u.setControllerDefaults(controller) From 28e77d1ac5333c9bd4b2d9a6cf657f43e74637f6 Mon Sep 17 00:00:00 2001 From: brngates98 Date: Sat, 24 Jan 2026 22:26:49 -0500 Subject: [PATCH 6/9] Fix site name override for DPI clients, anomalies, and site metrics - Apply site name override to DPI clients (ClientsDPI) in augmentMetrics - Apply site name override to client anomalies when collecting events - Apply site name override to sites (both Name and SiteName fields) when adding to metrics - Apply site name override to DPI sites, speed tests, and country traffic - Move applySiteNameOverride call to end of augmentMetrics to ensure all metrics are processed - This ensures all Prometheus metrics use console names instead of 'Default (default)' for Cloud Gateways --- pkg/inputunifi/collectevents.go | 9 ++++++ pkg/inputunifi/collector.go | 49 ++++++++++++++++++++++++++++----- 2 files changed, 51 insertions(+), 7 deletions(-) diff --git a/pkg/inputunifi/collectevents.go b/pkg/inputunifi/collectevents.go index 7c4475c0..835efff5 100644 --- a/pkg/inputunifi/collectevents.go +++ b/pkg/inputunifi/collectevents.go @@ -3,6 +3,7 @@ package inputunifi import ( "encoding/base64" "fmt" + "strings" "time" "github.com/unpoller/unifi/v5" @@ -83,6 +84,14 @@ func (u *InputUnifi) collectAnomalies(logs []any, sites []*unifi.Site, c *Contro } for _, e := range events { + // Apply site name override for anomalies if configured + if c.DefaultSiteNameOverride != "" { + lower := strings.ToLower(e.SiteName) + if lower == "default" || strings.Contains(lower, "default") { + e.SiteName = c.DefaultSiteNameOverride + } + } + logs = append(logs, e) webserver.NewInputEvent(PluginName, s.ID+"_anomalies", &webserver.Event{ diff --git a/pkg/inputunifi/collector.go b/pkg/inputunifi/collector.go index 52f90c5e..57232973 100644 --- a/pkg/inputunifi/collector.go +++ b/pkg/inputunifi/collector.go @@ -275,13 +275,6 @@ func (u *InputUnifi) augmentMetrics(c *Controller, metrics *Metrics) *poller.Met m, devices, bssdIDs := extractDevices(metrics) - // Apply default_site_name_override to devices if configured. - // This allows us to use the console name for Cloud Gateways while keeping - // the actual site name ("default") for API calls. - if c.DefaultSiteNameOverride != "" { - applySiteNameOverride(m, c.DefaultSiteNameOverride) - } - // These come blank, so set them here. for _, client := range metrics.Clients { if devices[client.Mac] = client.Name; client.Name == "" { @@ -295,6 +288,12 @@ func (u *InputUnifi) augmentMetrics(c *Controller, metrics *Metrics) *poller.Met client.ApName = devices[client.ApMac] client.GwName = devices[client.GwMac] client.RadioDescription = bssdIDs[client.Bssid] + client.RadioProto + + // Apply site name override for clients if configured + if c.DefaultSiteNameOverride != "" && isDefaultSiteName(client.SiteName) { + client.SiteName = c.DefaultSiteNameOverride + } + m.Clients = append(m.Clients, client) } @@ -307,6 +306,12 @@ func (u *InputUnifi) augmentMetrics(c *Controller, metrics *Metrics) *poller.Met client.Name = RedactNamePII(client.Name, c.HashPII, c.DropPII) client.MAC = RedactMacPII(client.MAC, c.HashPII, c.DropPII) + + // Apply site name override for DPI clients if configured + if c.DefaultSiteNameOverride != "" && isDefaultSiteName(client.SiteName) { + client.SiteName = c.DefaultSiteNameOverride + } + m.ClientsDPI = append(m.ClientsDPI, client) } @@ -317,22 +322,52 @@ func (u *InputUnifi) augmentMetrics(c *Controller, metrics *Metrics) *poller.Met if *c.SaveSites { for _, site := range metrics.Sites { + // Apply site name override for sites if configured + if c.DefaultSiteNameOverride != "" { + if isDefaultSiteName(site.Name) { + site.Name = c.DefaultSiteNameOverride + } + if isDefaultSiteName(site.SiteName) { + site.SiteName = c.DefaultSiteNameOverride + } + } m.Sites = append(m.Sites, site) } for _, site := range metrics.SitesDPI { + // Apply site name override for DPI sites if configured + if c.DefaultSiteNameOverride != "" && isDefaultSiteName(site.SiteName) { + site.SiteName = c.DefaultSiteNameOverride + } m.SitesDPI = append(m.SitesDPI, site) } } for _, speedTest := range metrics.SpeedTests { + // Apply site name override for speed tests if configured + if c.DefaultSiteNameOverride != "" && isDefaultSiteName(speedTest.SiteName) { + speedTest.SiteName = c.DefaultSiteNameOverride + } m.SpeedTests = append(m.SpeedTests, speedTest) } for _, traffic := range metrics.CountryTraffic { + // Apply site name override for country traffic if configured + // UsageByCountry has TrafficSite.SiteName, not SiteName directly + if c.DefaultSiteNameOverride != "" && isDefaultSiteName(traffic.TrafficSite.SiteName) { + traffic.TrafficSite.SiteName = c.DefaultSiteNameOverride + } m.CountryTraffic = append(m.CountryTraffic, traffic) } + // Apply default_site_name_override to all metrics if configured. + // This must be done AFTER all metrics are added to m, so everything is included. + // This allows us to use the console name for Cloud Gateways while keeping + // the actual site name ("default") for API calls. + if c.DefaultSiteNameOverride != "" { + applySiteNameOverride(m, c.DefaultSiteNameOverride) + } + return m } From 0cb331a7459818905d39a99129a5c4075b455971 Mon Sep 17 00:00:00 2001 From: brngates98 Date: Sun, 25 Jan 2026 08:34:06 -0500 Subject: [PATCH 7/9] Fix golangci-lint empty-block errors in input.go Remove empty if blocks by inverting conditions: - Line 289: Invert Remote check for URL default - Line 303: Invert APIKey check in Remote mode - Line 401: Invert Remote check for URL default in setControllerDefaults --- pkg/inputunifi/input.go | 33 +++++++++++++-------------------- 1 file changed, 13 insertions(+), 20 deletions(-) diff --git a/pkg/inputunifi/input.go b/pkg/inputunifi/input.go index 11810b50..da9208d7 100644 --- a/pkg/inputunifi/input.go +++ b/pkg/inputunifi/input.go @@ -286,13 +286,10 @@ func (u *InputUnifi) setDefaults(c *Controller) { //nolint:cyclop c.SaveTraffic = &f } - if c.URL == "" { - if c.Remote { - // Remote mode: URL will be set during discovery - // Don't set a default here - } else { - c.URL = defaultURL - } + if c.URL == "" && !c.Remote { + // Remote mode: URL will be set during discovery + // Don't set a default here for remote mode + c.URL = defaultURL } if strings.HasPrefix(c.Pass, "file://") { @@ -305,10 +302,9 @@ func (u *InputUnifi) setDefaults(c *Controller) { //nolint:cyclop if c.Remote { // Remote mode: only API key is used, no user/pass - if c.APIKey == "" { - // For remote mode, API key is required - // Will be set from RemoteAPIKey in Config if not provided - } else { + // For remote mode, API key is required + // Will be set from RemoteAPIKey in Config if not provided + if c.APIKey != "" { c.User = "" c.Pass = "" } @@ -402,15 +398,12 @@ func (u *InputUnifi) setControllerDefaults(c *Controller) *Controller { //nolint c.SaveAnomal = u.Default.SaveAnomal } - if c.URL == "" { - if c.Remote { - // Remote mode: URL will be set during discovery - // Don't set a default here - } else { - c.URL = u.Default.URL - if c.URL == "" { - c.URL = defaultURL - } + if c.URL == "" && !c.Remote { + // Remote mode: URL will be set during discovery + // Don't set a default here for remote mode + c.URL = u.Default.URL + if c.URL == "" { + c.URL = defaultURL } } From e17d8bf62ee08a6c0aad4543f8d24715eb3a6298 Mon Sep 17 00:00:00 2001 From: brngates98 Date: Sun, 25 Jan 2026 08:59:11 -0500 Subject: [PATCH 8/9] move remote.go to use unifi library functions --- go.mod | 4 +- go.sum | 6 +- pkg/inputunifi/remote.go | 221 ++------------------------------------- 3 files changed, 15 insertions(+), 216 deletions(-) diff --git a/go.mod b/go.mod index a0bd60a3..95fbbc6c 100644 --- a/go.mod +++ b/go.mod @@ -23,7 +23,7 @@ require ( require ( go.yaml.in/yaml/v2 v2.4.3 // indirect - golang.org/x/net v0.48.0 // indirect + golang.org/x/net v0.49.0 // indirect ) require ( @@ -48,4 +48,4 @@ require ( ) // for local iterative development only -// replace github.com/unpoller/unifi/v5 => ../unifi +replace github.com/unpoller/unifi/v5 => ../unifi diff --git a/go.sum b/go.sum index f376d23e..046c16a2 100644 --- a/go.sum +++ b/go.sum @@ -77,8 +77,6 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 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/unpoller/unifi/v5 v5.5.0 h1:MxSbCYBxgg1NN4R0w6h+ES2R4QNM1vhmwjQhJVwVDp0= -github.com/unpoller/unifi/v5 v5.5.0/go.mod h1:pa6zv4Oyb1nFEm4qu/8CUv8Q25hQof04Wh2D0RXcTYc= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= @@ -92,8 +90,8 @@ golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= -golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= -golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= diff --git a/pkg/inputunifi/remote.go b/pkg/inputunifi/remote.go index b34e3a48..a8886cb8 100644 --- a/pkg/inputunifi/remote.go +++ b/pkg/inputunifi/remote.go @@ -1,228 +1,29 @@ package inputunifi import ( - "crypto/tls" - "encoding/json" "fmt" - "io" - "net/http" - "net/url" "strings" - "time" + + "github.com/unpoller/unifi/v5" ) -const ( - remoteAPIBaseURL = "https://api.ui.com" - remoteAPIVersion = "v1" -) - -// Console represents a UniFi console from the remote API. -type Console struct { - ID string `json:"id"` - IPAddress string `json:"ipAddress"` - Type string `json:"type"` - Owner bool `json:"owner"` - IsBlocked bool `json:"isBlocked"` - ReportedState struct { - Name string `json:"name"` - Hostname string `json:"hostname"` - IP string `json:"ip"` - State string `json:"state"` - Mac string `json:"mac"` - } `json:"reportedState"` - ConsoleName string // Derived field: name from reportedState -} - -// HostsResponse represents the response from /v1/hosts endpoint. -type HostsResponse struct { - Data []Console `json:"data"` - HTTPStatusCode int `json:"httpStatusCode"` - TraceID string `json:"traceId"` - NextToken string `json:"nextToken,omitempty"` -} - -// Site represents a site from the remote API. -type RemoteSite struct { - ID string `json:"id"` - Name string `json:"name"` - Description string `json:"description"` -} - -// SitesResponse represents the response from the sites endpoint. -type SitesResponse struct { - Data []RemoteSite `json:"data"` - HTTPStatusCode int `json:"httpStatusCode"` - TraceID string `json:"traceId"` -} - -// remoteAPIClient handles HTTP requests to the remote UniFi API. -type remoteAPIClient struct { - apiKey string - baseURL string - client *http.Client - logError func(string, ...any) - logDebug func(string, ...any) - log func(string, ...any) -} - -// newRemoteAPIClient creates a new remote API client. -func (u *InputUnifi) newRemoteAPIClient(apiKey string) *remoteAPIClient { - if apiKey == "" { - return nil - } - +// discoverRemoteControllers discovers all controllers via remote API and creates Controller entries. +func (u *InputUnifi) discoverRemoteControllers(apiKey string) ([]*Controller, error) { // Handle file:// prefix for API key if strings.HasPrefix(apiKey, "file://") { apiKey = u.getPassFromFile(strings.TrimPrefix(apiKey, "file://")) } - return &remoteAPIClient{ - apiKey: apiKey, - baseURL: remoteAPIBaseURL, - client: &http.Client{ - Timeout: 30 * time.Second, - Transport: &http.Transport{ - TLSClientConfig: &tls.Config{ - InsecureSkipVerify: false, - }, - }, - }, - logError: u.LogErrorf, - logDebug: u.LogDebugf, - log: u.Logf, - } -} - -// makeRequest makes an HTTP request to the remote API. -func (c *remoteAPIClient) makeRequest(method, path string, queryParams map[string]string) ([]byte, error) { - fullURL := c.baseURL + path - - if len(queryParams) > 0 { - u, err := url.Parse(fullURL) - if err != nil { - return nil, fmt.Errorf("parsing URL: %w", err) - } - - q := u.Query() - for k, v := range queryParams { - q.Set(k, v) - } - u.RawQuery = q.Encode() - fullURL = u.String() - } - - c.logDebug("Making %s request to: %s", method, fullURL) - - req, err := http.NewRequest(method, fullURL, nil) - if err != nil { - return nil, fmt.Errorf("creating request: %w", err) - } - - req.Header.Set("Accept", "application/json") - req.Header.Set("X-API-Key", c.apiKey) - - resp, err := c.client.Do(req) - if err != nil { - return nil, fmt.Errorf("making request: %w", err) - } - defer resp.Body.Close() - - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("reading response: %w", err) - } - - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return nil, fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(body)) - } - - return body, nil -} - -// discoverConsoles discovers all consoles available via the remote API. -func (c *remoteAPIClient) discoverConsoles() ([]Console, error) { - // Start with first page - queryParams := map[string]string{ - "pageSize": "10", - } - - var allConsoles []Console - nextToken := "" - - for { - if nextToken != "" { - queryParams["nextToken"] = nextToken - } else { - // Remove nextToken from params for first request - delete(queryParams, "nextToken") - } - - body, err := c.makeRequest("GET", "/v1/hosts", queryParams) - if err != nil { - return nil, fmt.Errorf("fetching consoles: %w", err) - } - - var response HostsResponse - if err := json.Unmarshal(body, &response); err != nil { - return nil, fmt.Errorf("parsing consoles response: %w", err) - } - - // Filter for console type only - for _, console := range response.Data { - if console.Type == "console" && !console.IsBlocked { - // Extract the console name from reportedState - console.ConsoleName = console.ReportedState.Name - if console.ConsoleName == "" { - console.ConsoleName = console.ReportedState.Hostname - } - allConsoles = append(allConsoles, console) - } - } - - // Check if there's a nextToken to continue pagination - if response.NextToken == "" { - break - } - - nextToken = response.NextToken - c.logDebug("Fetching next page of consoles with nextToken: %s", nextToken) - } - - return allConsoles, nil -} - -// discoverSites discovers all sites for a given console ID. -func (c *remoteAPIClient) discoverSites(consoleID string) ([]RemoteSite, error) { - path := fmt.Sprintf("/v1/connector/consoles/%s/proxy/network/integration/v1/sites", consoleID) - - queryParams := map[string]string{ - "offset": "0", - "limit": "100", - } - - body, err := c.makeRequest("GET", path, queryParams) - if err != nil { - return nil, fmt.Errorf("fetching sites for console %s: %w", consoleID, err) - } - - var response SitesResponse - if err := json.Unmarshal(body, &response); err != nil { - return nil, fmt.Errorf("parsing sites response: %w", err) - } - - return response.Data, nil -} - -// discoverRemoteControllers discovers all controllers via remote API and creates Controller entries. -func (u *InputUnifi) discoverRemoteControllers(apiKey string) ([]*Controller, error) { - client := u.newRemoteAPIClient(apiKey) - if client == nil { + if apiKey == "" { return nil, fmt.Errorf("remote API key not provided") } + // Use library client + client := unifi.NewRemoteAPIClient(apiKey, u.LogErrorf, u.LogDebugf, u.Logf) + u.Logf("Discovering remote UniFi consoles...") - consoles, err := client.discoverConsoles() + consoles, err := client.DiscoverConsoles() if err != nil { return nil, fmt.Errorf("discovering consoles: %w", err) } @@ -246,7 +47,7 @@ func (u *InputUnifi) discoverRemoteControllers(apiKey string) ([]*Controller, er } u.LogDebugf("Discovering sites for console: %s (%s)", console.ID, consoleName) - sites, err := client.discoverSites(console.ID) + sites, err := client.DiscoverSites(console.ID) if err != nil { u.LogErrorf("Failed to discover sites for console %s: %v", console.ID, err) continue @@ -268,7 +69,7 @@ func (u *InputUnifi) discoverRemoteControllers(apiKey string) ([]*Controller, er // Set URL to connector base - the library appends /proxy/network/status // But for integration API we need /proxy/network/integration/v1/... // This may require library updates, but try connector base first - URL: fmt.Sprintf("%s/v1/connector/consoles/%s", remoteAPIBaseURL, console.ID), + URL: fmt.Sprintf("%s/v1/connector/consoles/%s", unifi.RemoteAPIBaseURL, console.ID), } // Ensure defaults are set before calling setControllerDefaults From 12354304785f007cb90effcd176bb990319d653a Mon Sep 17 00:00:00 2001 From: brngates98 Date: Sun, 25 Jan 2026 10:58:08 -0500 Subject: [PATCH 9/9] Update to unifi library v5.6.0 and fix linter errors - Update go.mod to use unifi library v5.6.0 (includes remote API support) - Remove temporary replace directive now that v5.6.0 is published - Fix empty-block linter errors in input.go by removing empty if blocks --- go.mod | 5 +---- go.sum | 2 ++ pkg/inputunifi/input.go | 6 ++---- 3 files changed, 5 insertions(+), 8 deletions(-) diff --git a/go.mod b/go.mod index 95fbbc6c..a620f168 100644 --- a/go.mod +++ b/go.mod @@ -12,7 +12,7 @@ require ( github.com/prometheus/common v0.67.5 github.com/spf13/pflag v1.0.10 github.com/stretchr/testify v1.11.1 - github.com/unpoller/unifi/v5 v5.5.0 + github.com/unpoller/unifi/v5 v5.6.0 golang.org/x/crypto v0.47.0 golang.org/x/term v0.39.0 golift.io/cnfg v0.2.3 @@ -46,6 +46,3 @@ require ( golang.org/x/sys v0.40.0 // indirect google.golang.org/protobuf v1.36.11 // indirect ) - -// for local iterative development only -replace github.com/unpoller/unifi/v5 => ../unifi diff --git a/go.sum b/go.sum index 046c16a2..060b42e6 100644 --- a/go.sum +++ b/go.sum @@ -77,6 +77,8 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 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/unpoller/unifi/v5 v5.6.0 h1:qryEAJNYXG/WbjIqrIEZ/npRNBwHTsv/ZFbseYry8ug= +github.com/unpoller/unifi/v5 v5.6.0/go.mod h1:vSIXIclPG9dpKxUp+pavfgENHWaTZXvDg7F036R1YCo= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= diff --git a/pkg/inputunifi/input.go b/pkg/inputunifi/input.go index da9208d7..2c8746fb 100644 --- a/pkg/inputunifi/input.go +++ b/pkg/inputunifi/input.go @@ -304,10 +304,8 @@ func (u *InputUnifi) setDefaults(c *Controller) { //nolint:cyclop // Remote mode: only API key is used, no user/pass // For remote mode, API key is required // Will be set from RemoteAPIKey in Config if not provided - if c.APIKey != "" { - c.User = "" - c.Pass = "" - } + c.User = "" + c.Pass = "" } else { // Local mode: use API key if provided, otherwise user/pass if c.APIKey == "" {