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.
This commit is contained in:
brngates98
2026-01-24 17:32:36 -05:00
parent 1df4ba9932
commit 28eae6ab22
6 changed files with 574 additions and 22 deletions

View File

@@ -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

View File

@@ -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
}
}

View File

@@ -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

View File

@@ -58,6 +58,8 @@ type Controller struct {
URL string `json:"url" toml:"url" xml:"url" yaml:"url"` URL string `json:"url" toml:"url" xml:"url" yaml:"url"`
Sites []string `json:"sites" toml:"sites" xml:"site" yaml:"sites"` 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"` 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:"-"` Unifi *unifi.Unifi `json:"-" toml:"-" xml:"-" yaml:"-"`
ID string `json:"id,omitempty"` // this is an output, not an input. 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"` Default Controller `json:"defaults" toml:"defaults" xml:"default" yaml:"defaults"`
Disable bool `json:"disable" toml:"disable" xml:"disable,attr" yaml:"disable"` Disable bool `json:"disable" toml:"disable" xml:"disable,attr" yaml:"disable"`
Dynamic bool `json:"dynamic" toml:"dynamic" xml:"dynamic,attr" yaml:"dynamic"` 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"` 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 == "" { 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://") { 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://")) c.APIKey = u.getPassFromFile(strings.TrimPrefix(c.APIKey, "file://"))
} }
if c.APIKey == "" { if c.Remote {
if c.Pass == "" { // Remote mode: only API key is used, no user/pass
c.Pass = defaultPass if c.APIKey == "" {
} // For remote mode, API key is required
// Will be set from RemoteAPIKey in Config if not provided
if c.User == "" { } else {
c.User = defaultUser c.User = ""
c.Pass = ""
} }
} else { } else {
// clear out user/pass combo, only use API-key // Local mode: use API key if provided, otherwise user/pass
c.User = "" if c.APIKey == "" {
c.Pass = "" 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 { if len(c.Sites) == 0 {
@@ -381,7 +402,15 @@ func (u *InputUnifi) setControllerDefaults(c *Controller) *Controller { //nolint
} }
if c.URL == "" { 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://") { 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://")) c.APIKey = u.getPassFromFile(strings.TrimPrefix(c.APIKey, "file://"))
} }
if c.APIKey == "" { if c.Remote {
if c.Pass == "" { // Remote mode: only API key is used
c.Pass = defaultPass 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.User = ""
c.Pass = "" 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 { if len(c.Sites) == 0 {

View File

@@ -31,9 +31,52 @@ func (u *InputUnifi) Initialize(l poller.Logger) error {
} }
if u.setDefaults(&u.Default); len(u.Controllers) == 0 && !u.Dynamic { 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} 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 { if len(u.Controllers) == 0 {
u.Logf("No controllers configured. Polling dynamic controllers only! Defaults:") u.Logf("No controllers configured. Polling dynamic controllers only! Defaults:")
u.logController(&u.Default) u.logController(&u.Default)
@@ -114,6 +157,15 @@ func (u *InputUnifi) DebugInput() (bool, error) {
} }
func (u *InputUnifi) logController(c *Controller) { 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) u.Logf(" => URL: %s (verify SSL: %v, timeout: %v)", c.URL, *c.VerifySSL, c.Timeout.Duration)
if len(c.CertPaths) > 0 { 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(" => 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(" => 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 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) u.Logf(" => Save Events %v / Save Syslog %v / Save IDs %v (logs)", *c.SaveEvents, *c.SaveSyslog, *c.SaveIDs)

314
pkg/inputunifi/remote.go Normal file
View File

@@ -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
}