main
Raw Download raw file
  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}