Skip to content

Commit d2ce6ef

Browse files
committed
🏖️ producer/unifiapi: new package
The new producer source uses the official UniFi network API to query the device IPv4 address.
1 parent 0aacee3 commit d2ce6ef

5 files changed

Lines changed: 275 additions & 0 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
- Multiple IP address sources
1212
- `"asusrouter"`: Obtain WAN IPv4 address from ASUS router.
13+
- `"unifiapi"`: Obtain device IPv4 address from Ubiquiti UniFi API.
1314
- `"ipapi"`: Obtain public IPv4 and IPv6 addresses from IP address APIs.
1415
- `"ssh"`: Obtain IPv4 and IPv6 addresses from a remote host via SSH.
1516
- Monitor network interface IPv4 and IPv6 addresses

docs/config.json

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,21 @@
1111
"poll_interval": "5m"
1212
}
1313
},
14+
{
15+
"name": "unifi",
16+
"type": "unifiapi",
17+
"unifiapi": {
18+
"base_url": "https://192.168.1.1",
19+
"api_key": "",
20+
"site_id": "",
21+
"device_id": "",
22+
"root_ca_paths": [
23+
"unifi-local-chain.pem"
24+
],
25+
"server_name": "unifi.local",
26+
"poll_interval": "5m"
27+
}
28+
},
1429
{
1530
"name": "ipify4",
1631
"type": "ipapi",

producer/unifiapi/client.go

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
package unifiapi
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"encoding/json"
7+
"fmt"
8+
"net/http"
9+
"net/netip"
10+
"net/url"
11+
"time"
12+
13+
"github.com/database64128/ddns-go/internal/httpreq"
14+
)
15+
16+
// Client is the Ubiquiti UniFi API client.
17+
type Client struct {
18+
client *http.Client
19+
sitesURL string
20+
apiKey string
21+
}
22+
23+
// NewClient returns a new Ubiquiti UniFi API client.
24+
//
25+
// - If client is nil, [http.DefaultClient] is used.
26+
// - If baseURL is empty, it defaults to "https://unifi.local".
27+
func NewClient(client *http.Client, baseURL, apiKey string) (*Client, error) {
28+
if client == nil {
29+
client = http.DefaultClient
30+
}
31+
if baseURL == "" {
32+
baseURL = "https://unifi.local"
33+
}
34+
35+
sitesURL, err := url.JoinPath(baseURL, "/proxy/network/integration/v1/sites")
36+
if err != nil {
37+
return nil, fmt.Errorf("failed to join sites URL: %w", err)
38+
}
39+
40+
return &Client{
41+
client: client,
42+
sitesURL: sitesURL,
43+
apiKey: apiKey,
44+
}, nil
45+
}
46+
47+
// GetDeviceIPAddress returns the IP address of the specified device in the specified site.
48+
func (c *Client) GetDeviceIPAddress(ctx context.Context, siteID, deviceID string) (netip.Addr, error) {
49+
deviceURL, err := url.JoinPath(c.sitesURL, siteID, "devices", deviceID)
50+
if err != nil {
51+
return netip.Addr{}, fmt.Errorf("failed to join device URL: %w", err)
52+
}
53+
54+
var device struct {
55+
IPAddress netip.Addr `json:"ipAddress"`
56+
}
57+
58+
if err := clientDo(c.client, c.apiKey, func() (*http.Request, error) {
59+
return http.NewRequestWithContext(ctx, http.MethodGet, deviceURL, nil)
60+
}, &device); err != nil {
61+
return netip.Addr{}, fmt.Errorf("failed to get device info: %w", err)
62+
}
63+
64+
return device.IPAddress, nil
65+
}
66+
67+
func clientDo(client *http.Client, apiKey string, newRequest func() (*http.Request, error), v any) error {
68+
req, err := newRequest()
69+
if err != nil {
70+
return fmt.Errorf("failed to create request: %w", err)
71+
}
72+
73+
req.Header["X-API-KEY"] = []string{apiKey}
74+
req.Header["Accept"] = []string{"application/json"}
75+
req.Header["User-Agent"] = []string{httpreq.DefaultUserAgent}
76+
77+
resp, err := client.Do(req)
78+
if err != nil {
79+
return fmt.Errorf("failed to send request: %w", err)
80+
}
81+
defer resp.Body.Close()
82+
83+
const maxResponseBodySize = 128 * 1024 * 1024 // 128 MiB
84+
var buf bytes.Buffer
85+
if err := httpreq.ReadResponseBody(&buf, resp, maxResponseBodySize); err != nil {
86+
return fmt.Errorf("failed to read response: %w", err)
87+
}
88+
bodyBytes := buf.Bytes()
89+
90+
if resp.StatusCode != http.StatusOK {
91+
var apiErr Error
92+
if err := json.Unmarshal(bodyBytes, &apiErr); err != nil {
93+
return fmt.Errorf("failed to unmarshal API error response: %w", err)
94+
}
95+
return &apiErr
96+
}
97+
98+
if err := json.Unmarshal(bodyBytes, v); err != nil {
99+
return fmt.Errorf("failed to unmarshal response: %w", err)
100+
}
101+
102+
return nil
103+
}
104+
105+
// Error is the standard API error response.
106+
type Error struct {
107+
StatusCode int `json:"statusCode"`
108+
StatusName string `json:"statusName"`
109+
Code string `json:"code"`
110+
Message string `json:"message"`
111+
Timestamp time.Time `json:"timestamp"`
112+
RequestPath string `json:"requestPath"`
113+
RequestID string `json:"requestId"`
114+
}
115+
116+
func (e *Error) Error() string {
117+
return fmt.Sprintf("status: %d %s, code: %s, message: %s, timestamp: %s, requestPath: %s, requestId: %s",
118+
e.StatusCode, e.StatusName, e.Code, e.Message, e.Timestamp.Format(time.RFC3339), e.RequestPath, e.RequestID)
119+
}

producer/unifiapi/unifiapi.go

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
// Package unifiapi provides the Ubiquiti UniFi API client producer.
2+
package unifiapi
3+
4+
import (
5+
"context"
6+
"crypto/tls"
7+
"crypto/x509"
8+
"fmt"
9+
"net/http"
10+
"os"
11+
"time"
12+
13+
"github.com/database64128/ddns-go/internal/httpreq"
14+
"github.com/database64128/ddns-go/jsoncfg"
15+
"github.com/database64128/ddns-go/producer"
16+
"github.com/database64128/ddns-go/producer/internal/poller"
17+
"github.com/database64128/ddns-go/tslog"
18+
)
19+
20+
// Source obtains the IPv4 address of a device from the Ubiquiti UniFi API.
21+
//
22+
// Source implements [producer.Source].
23+
type Source struct {
24+
client *Client
25+
siteID string
26+
deviceID string
27+
}
28+
29+
// NewSource creates a new [Source].
30+
//
31+
// - If client is nil, [http.DefaultClient] is used.
32+
// - If baseURL is empty, it defaults to "https://unifi.local".
33+
func NewSource(client *http.Client, baseURL, apiKey, siteID, deviceID string) (*Source, error) {
34+
c, err := NewClient(client, baseURL, apiKey)
35+
if err != nil {
36+
return nil, fmt.Errorf("failed to create UniFi API client: %w", err)
37+
}
38+
39+
return &Source{
40+
client: c,
41+
siteID: siteID,
42+
deviceID: deviceID,
43+
}, nil
44+
}
45+
46+
var _ producer.Source = (*Source)(nil)
47+
48+
// Snapshot returns the current IPv4 address of the device.
49+
//
50+
// Snapshot implements [producer.Source.Snapshot].
51+
func (s *Source) Snapshot(ctx context.Context) (producer.Message, error) {
52+
addr, err := s.client.GetDeviceIPAddress(ctx, s.siteID, s.deviceID)
53+
if err != nil {
54+
return producer.Message{}, fmt.Errorf("failed to get device IP address: %w", err)
55+
}
56+
if !addr.Is4() {
57+
return producer.Message{}, fmt.Errorf("not an IPv4 address: %s", addr)
58+
}
59+
return producer.Message{IPv4: addr}, nil
60+
}
61+
62+
// ProducerConfig contains configuration options for the Ubiquiti UniFi API producer.
63+
type ProducerConfig struct {
64+
// BaseURL is the base URL of the UniFi API endpoints.
65+
//
66+
// If empty, it defaults to "https://unifi.local".
67+
BaseURL string `json:"base_url,omitzero"`
68+
69+
// APIKey is the API key for authenticating API requests.
70+
APIKey string `json:"api_key"`
71+
72+
// SiteID is the site ID.
73+
SiteID string `json:"site_id"`
74+
75+
// DeviceID is the device ID.
76+
DeviceID string `json:"device_id"`
77+
78+
// RootCAPaths is a list of paths to PEM-encoded root CA certificates for verifying TLS server certificates.
79+
//
80+
// To trust the self-signed certificate, download the certificate and specify its path here.
81+
//
82+
// If empty, the system root CAs are used.
83+
RootCAPaths []string `json:"root_ca_paths,omitzero"`
84+
85+
// ServerName is the server name to use when initializing a TLS connection.
86+
//
87+
// If empty, it is inferred from BaseURL.
88+
ServerName string `json:"server_name,omitzero"`
89+
90+
// PollInterval is the interval between polling the UniFi API for the device IP address.
91+
//
92+
// If not positive, it defaults to 5 minutes.
93+
PollInterval jsoncfg.Duration `json:"poll_interval,omitzero"`
94+
}
95+
96+
// NewProducer creates a new [producer.Producer] that monitors the IPv4 address of a device from the Ubiquiti UniFi API.
97+
func (cfg *ProducerConfig) NewProducer(client *http.Client, logger *tslog.Logger) (producer.Producer, error) {
98+
if client == nil && (len(cfg.RootCAPaths) > 0 || cfg.ServerName != "") {
99+
var rootCAs *x509.CertPool
100+
if len(cfg.RootCAPaths) > 0 {
101+
rootCAs = x509.NewCertPool()
102+
for _, path := range cfg.RootCAPaths {
103+
cert, err := os.ReadFile(path)
104+
if err != nil {
105+
return nil, fmt.Errorf("failed to read root CA file %q: %w", path, err)
106+
}
107+
if !rootCAs.AppendCertsFromPEM(cert) {
108+
return nil, fmt.Errorf("failed to append root CA from file %q", path)
109+
}
110+
}
111+
}
112+
transport := httpreq.DefaultHttpTransportClone()
113+
transport.TLSClientConfig = &tls.Config{
114+
RootCAs: rootCAs,
115+
ServerName: cfg.ServerName,
116+
}
117+
client = &http.Client{
118+
Transport: transport,
119+
}
120+
}
121+
122+
source, err := NewSource(client, cfg.BaseURL, cfg.APIKey, cfg.SiteID, cfg.DeviceID)
123+
if err != nil {
124+
return nil, fmt.Errorf("failed to create source: %w", err)
125+
}
126+
127+
pollInterval := cfg.PollInterval.Value()
128+
if pollInterval <= 0 {
129+
pollInterval = 5 * time.Minute
130+
}
131+
132+
return poller.New(pollInterval, source, logger), nil
133+
}

service/service.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import (
1818
"github.com/database64128/ddns-go/producer/ipapi"
1919
"github.com/database64128/ddns-go/producer/netlink"
2020
"github.com/database64128/ddns-go/producer/sshclient"
21+
"github.com/database64128/ddns-go/producer/unifiapi"
2122
"github.com/database64128/ddns-go/producer/win32iphlp"
2223
"github.com/database64128/ddns-go/provider"
2324
"github.com/database64128/ddns-go/provider/cloudflare"
@@ -184,6 +185,7 @@ type SourceConfig struct {
184185
// Type is the type of the source.
185186
//
186187
// - "asusrouter": ASUS router.
188+
// - "unifiapi": Ubiquiti UniFi API.
187189
// - "ipapi": IP address API.
188190
// - "ssh": SSH client.
189191
// - "iface": Network interface (generic).
@@ -195,6 +197,9 @@ type SourceConfig struct {
195197
// ASUSRouter is the producer configuration for an ASUS router source.
196198
ASUSRouter asusrouter.ProducerConfig `json:"asusrouter,omitzero"`
197199

200+
// UniFiAPI is the producer configuration for a Ubiquiti UniFi API source.
201+
UniFiAPI unifiapi.ProducerConfig `json:"unifiapi,omitzero"`
202+
198203
// IPAPI is the producer configuration for an IP address API source.
199204
IPAPI ipapi.ProducerConfig `json:"ipapi,omitzero"`
200205

@@ -219,6 +224,8 @@ func (cfg *SourceConfig) NewProducer(client *http.Client, logger *tslog.Logger)
219224
switch cfg.Type {
220225
case "asusrouter":
221226
return cfg.ASUSRouter.NewProducer(client, logger)
227+
case "unifiapi":
228+
return cfg.UniFiAPI.NewProducer(client, logger)
222229
case "ipapi":
223230
return cfg.IPAPI.NewProducer(client, logger)
224231
case "ssh":

0 commit comments

Comments
 (0)