main
1package protect
2
3import (
4 "bytes"
5 "context"
6 "crypto/tls"
7 "encoding/json"
8 "errors"
9 "fmt"
10 "io"
11 "log/slog"
12 "net/http"
13 "net/http/cookiejar"
14 "time"
15)
16
17// Sentinel errors for API operations.
18var (
19 ErrUnauthorized = errors.New("unauthorized: invalid credentials")
20 ErrNotFound = errors.New("resource not found")
21 ErrRateLimited = errors.New("rate limited")
22 ErrServerError = errors.New("server error")
23 ErrCameraNotFound = errors.New("camera not found")
24 ErrMultipleCameras = errors.New("multiple cameras match name")
25)
26
27// Client is a UniFi Protect API client.
28type Client struct {
29 baseURL string
30 apiPath string // "/proxy/protect/api" or "/api"
31 csrfToken string
32 httpClient *http.Client
33}
34
35// ClientOption configures a Client.
36type ClientOption func(*Client)
37
38// WithTLSInsecure disables TLS certificate verification.
39func WithTLSInsecure() ClientOption {
40 return func(c *Client) {
41 if c.httpClient.Transport == nil {
42 c.httpClient.Transport = &http.Transport{}
43 }
44 if t, ok := c.httpClient.Transport.(*http.Transport); ok {
45 t.TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
46 }
47 }
48}
49
50// WithTimeout sets the HTTP client timeout.
51func WithTimeout(d time.Duration) ClientOption {
52 return func(c *Client) {
53 c.httpClient.Timeout = d
54 }
55}
56
57// WithDirectAPI uses /api path instead of /proxy/protect/api.
58// Use this when connecting directly to a Protect NVR/Cloud Key
59// rather than through UniFi OS gateway.
60func WithDirectAPI() ClientOption {
61 return func(c *Client) {
62 c.apiPath = "/api"
63 }
64}
65
66// NewClient creates a new UniFi Protect API client.
67func NewClient(baseURL string, opts ...ClientOption) *Client {
68 jar, _ := cookiejar.New(nil)
69 c := &Client{
70 baseURL: baseURL,
71 apiPath: "/proxy/protect/api", // Default for UniFi OS
72 httpClient: &http.Client{
73 Timeout: 30 * time.Second,
74 Jar: jar,
75 },
76 }
77 for _, opt := range opts {
78 opt(c)
79 }
80 return c
81}
82
83// APIPath returns the current API path prefix.
84func (c *Client) APIPath() string {
85 return c.apiPath
86}
87
88// Login authenticates with UniFi Protect using username/password.
89// This obtains a session cookie that will be used for subsequent requests.
90func (c *Client) Login(ctx context.Context, username, password string) error {
91 creds := map[string]any{
92 "username": username,
93 "password": password,
94 "rememberMe": false,
95 "token": "",
96 }
97 body, err := json.Marshal(creds)
98 if err != nil {
99 return fmt.Errorf("encoding credentials: %w", err)
100 }
101
102 url := c.baseURL + "/api/auth/login"
103 req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
104 if err != nil {
105 return fmt.Errorf("creating login request: %w", err)
106 }
107 req.Header.Set("Content-Type", "application/json")
108
109 slog.Debug("http request",
110 slog.String("method", req.Method),
111 slog.String("url", req.URL.String()))
112
113 resp, err := c.httpClient.Do(req)
114 if err != nil {
115 return fmt.Errorf("login request: %w", err)
116 }
117 defer resp.Body.Close()
118
119 slog.Debug("http response",
120 slog.Int("status", resp.StatusCode))
121
122 if resp.StatusCode != http.StatusOK {
123 respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
124 slog.Debug("login response body", slog.String("body", string(respBody)))
125 if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
126 return fmt.Errorf("%w: %s", ErrUnauthorized, string(respBody))
127 }
128 return fmt.Errorf("login failed with status %d: %s", resp.StatusCode, string(respBody))
129 }
130
131 // Extract CSRF token from response header
132 if token := resp.Header.Get("X-Csrf-Token"); token != "" {
133 c.csrfToken = token
134 slog.Debug("obtained CSRF token")
135 }
136
137 slog.Debug("login successful")
138 return nil
139}
140
141// newRequest creates an HTTP request with session cookies and CSRF token.
142// The path should be relative (e.g., "/bootstrap", not "/api/bootstrap").
143func (c *Client) newRequest(ctx context.Context, method, path string, body io.Reader) (*http.Request, error) {
144 url := c.baseURL + c.apiPath + path
145 req, err := http.NewRequestWithContext(ctx, method, url, body)
146 if err != nil {
147 return nil, err
148 }
149 req.Header.Set("Accept", "application/json")
150 if c.csrfToken != "" {
151 req.Header.Set("X-Csrf-Token", c.csrfToken)
152 }
153 return req, nil
154}
155
156// do executes a request and handles common error responses.
157func (c *Client) do(req *http.Request) (*http.Response, error) {
158 slog.Debug("http request",
159 slog.String("method", req.Method),
160 slog.String("url", req.URL.String()))
161
162 resp, err := c.httpClient.Do(req)
163 if err != nil {
164 return nil, err
165 }
166
167 slog.Debug("http response",
168 slog.Int("status", resp.StatusCode))
169
170 switch resp.StatusCode {
171 case http.StatusOK, http.StatusPartialContent:
172 return resp, nil
173 case http.StatusUnauthorized, http.StatusForbidden:
174 resp.Body.Close()
175 return nil, ErrUnauthorized
176 case http.StatusNotFound:
177 resp.Body.Close()
178 return nil, ErrNotFound
179 case http.StatusTooManyRequests:
180 resp.Body.Close()
181 return nil, ErrRateLimited
182 default:
183 body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
184 resp.Body.Close()
185 if resp.StatusCode >= 500 {
186 return nil, fmt.Errorf("%w: %d: %s", ErrServerError, resp.StatusCode, string(body))
187 }
188 return nil, fmt.Errorf("unexpected status %d: %s", resp.StatusCode, string(body))
189 }
190}
191
192// doJSON executes a request and decodes the JSON response.
193func (c *Client) doJSON(req *http.Request, v any) error {
194 resp, err := c.do(req)
195 if err != nil {
196 return err
197 }
198 defer resp.Body.Close()
199
200 err = json.NewDecoder(resp.Body).Decode(v)
201 if err != nil {
202 return fmt.Errorf("decoding response: %w", err)
203 }
204 return nil
205}