Skip to main content

unifi_cli/api/
client.rs

1use reqwest::header::{HeaderMap, HeaderValue};
2use serde::de::DeserializeOwned;
3
4use super::types::*;
5
6pub fn error_for_status(status: u16, message: String) -> ApiError {
7    match status {
8        401 | 403 => ApiError::Auth(message),
9        404 => ApiError::NotFound(message),
10        _ => ApiError::Api { status, message },
11    }
12}
13
14/// True when a legacy API error means the endpoint itself is not there.
15///
16/// UniFi Network answers an unknown `stat/` path with 404 `api.err.NotFound`
17/// and an unknown `rest/` resource with 400 `api.err.InvalidObject`, verified
18/// against a UniFi OS 9 controller by control: a nonsense resource and
19/// `rest/alarm` produce byte-identical 400s while `rest/networkconf` returns
20/// records.
21///
22/// A 400 alone does not carry that meaning, since the same status reports a
23/// genuinely malformed request, so the marker string is required too and this
24/// is only consulted for a request that carries no caller-supplied body or
25/// parameters that could be the invalid object.
26fn is_absent_legacy_endpoint(err: &ApiError) -> bool {
27    match err {
28        ApiError::NotFound(_) => true,
29        ApiError::Api {
30            status: 400,
31            message,
32        } => message.contains("api.err.InvalidObject"),
33        _ => false,
34    }
35}
36
37/// Decode a successful response as JSON, naming what answered when it is not.
38///
39/// UniFi OS answers a request for an application the controller does not have
40/// by proxying it to the web UI, which returns 200 with an HTML page. Decoding
41/// that as JSON produces "error decoding response body", which names neither
42/// the endpoint nor the reason, so the caller cannot tell a missing application
43/// from a transport fault worth retrying.
44///
45/// The body is decoded first and the content type only chooses the error for a
46/// body that did not decode: a controller or proxy that serves JSON under
47/// `text/plain` or under no content type at all is still answering the request,
48/// so it must not be reported as an application that is not there.
49async fn json_or_unsupported<T: DeserializeOwned>(
50    resp: reqwest::Response,
51    endpoint: &str,
52) -> Result<T, ApiError> {
53    let raw = resp
54        .headers()
55        .get(reqwest::header::CONTENT_TYPE)
56        .and_then(|v| v.to_str().ok())
57        .unwrap_or("")
58        .to_string();
59    let bytes = resp.bytes().await?;
60    match serde_json::from_slice(&bytes) {
61        Ok(value) => Ok(value),
62        // A body that claims to be JSON and is not is a fault in a controller
63        // that does serve this endpoint, which is a different thing entirely.
64        Err(e) if raw.to_ascii_lowercase().contains("json") => Err(ApiError::Other(format!(
65            "Failed to decode the response from {endpoint}: {e}"
66        ))),
67        Err(_) => {
68            let content_type = match raw.split(';').next().map(str::trim) {
69                Some(t) if !t.is_empty() => t.to_string(),
70                _ => "no content type".to_string(),
71            };
72            Err(ApiError::Unsupported {
73                endpoint: endpoint.to_string(),
74                reason: UnsupportedReason::NotJson { content_type },
75            })
76        }
77    }
78}
79
80/// Valid RTSPS quality levels accepted by the Protect API.
81const VALID_QUALITIES: &[&str] = &["high", "medium", "low", "package"];
82
83/// Validate quality values against the Protect API allowlist.
84pub fn validate_qualities(qualities: &[String]) -> Result<(), ApiError> {
85    for q in qualities {
86        if !VALID_QUALITIES.contains(&q.as_str()) {
87            return Err(ApiError::Other(format!(
88                "Invalid quality '{q}'. Valid values: {}",
89                VALID_QUALITIES.join(", ")
90            )));
91        }
92    }
93    Ok(())
94}
95
96#[derive(Debug, Clone, Copy, Default)]
97pub struct ClientOptions {
98    pub accept_invalid_certs: bool,
99}
100
101fn normalize_base_url(host: &str) -> Result<String, ApiError> {
102    // A bare host (no scheme) defaults to https; an explicit scheme is kept and
103    // validated after parsing so only http/https are accepted. Parsing also
104    // rejects an empty host, since the url crate requires one for http/https.
105    let candidate = if host.contains("://") {
106        host.trim_end_matches('/').to_string()
107    } else {
108        format!("https://{}", host.trim_end_matches('/'))
109    };
110
111    let url = reqwest::Url::parse(&candidate)
112        .map_err(|e| ApiError::Other(format!("Invalid controller host: {e}")))?;
113    if !matches!(url.scheme(), "http" | "https") {
114        return Err(ApiError::Other(
115            "Controller host must use http:// or https://".into(),
116        ));
117    }
118    Ok(candidate)
119}
120
121pub struct UnifiClient {
122    http: reqwest::Client,
123    base_url: String,
124    site_id: Option<String>,
125}
126
127impl UnifiClient {
128    pub fn new(host: &str, api_key: &str) -> Result<Self, ApiError> {
129        Self::new_with_options(host, api_key, ClientOptions::default())
130    }
131
132    pub fn new_with_options(
133        host: &str,
134        api_key: &str,
135        options: ClientOptions,
136    ) -> Result<Self, ApiError> {
137        let mut headers = HeaderMap::new();
138        headers.insert(
139            "X-API-KEY",
140            HeaderValue::from_str(api_key).map_err(|e| ApiError::Other(e.to_string()))?,
141        );
142
143        let http = reqwest::Client::builder()
144            .danger_accept_invalid_certs(options.accept_invalid_certs)
145            .default_headers(headers)
146            .timeout(std::time::Duration::from_secs(30))
147            .build()
148            .map_err(ApiError::Http)?;
149
150        let base_url = normalize_base_url(host)?;
151
152        Ok(Self {
153            http,
154            base_url,
155            site_id: None,
156        })
157    }
158
159    pub fn clone_http(&self) -> reqwest::Client {
160        self.http.clone()
161    }
162
163    pub fn base_url(&self) -> &str {
164        &self.base_url
165    }
166
167    // Auto-discover site UUID from Integration API
168    async fn ensure_site_id(&mut self) -> Result<&str, ApiError> {
169        if self.site_id.is_none() {
170            let resp: PaginatedResponse<Site> = self
171                .get_integration("/proxy/network/integration/v1/sites")
172                .await?;
173            let site = resp.data.into_iter().next().ok_or_else(|| {
174                ApiError::Other("No sites found. Check that the API key has site access".into())
175            })?;
176            self.site_id = Some(site.id);
177        }
178        Ok(self.site_id.as_deref().unwrap())
179    }
180
181    async fn get_integration<T: DeserializeOwned>(&self, path: &str) -> Result<T, ApiError> {
182        let url = format!("{}{path}", self.base_url);
183        let resp = self.http.get(&url).send().await?;
184        let status = resp.status().as_u16();
185        if !resp.status().is_success() {
186            let body = resp.text().await.unwrap_or_default();
187            return Err(error_for_status(status, body));
188        }
189        json_or_unsupported(resp, path).await
190    }
191
192    async fn get_legacy<T: DeserializeOwned>(&self, path: &str) -> Result<Vec<T>, ApiError> {
193        let endpoint = format!("/proxy/network/api/s/default{path}");
194        let url = format!("{}{endpoint}", self.base_url);
195        let resp = self.http.get(&url).send().await?;
196        let status = resp.status().as_u16();
197        if !resp.status().is_success() {
198            let body = resp.text().await.unwrap_or_default();
199            return Err(error_for_status(status, body));
200        }
201        let legacy: LegacyResponse<T> = json_or_unsupported(resp, &endpoint).await?;
202        if legacy.meta.rc != "ok" {
203            return Err(ApiError::Api {
204                status: 200,
205                message: legacy.meta.msg.unwrap_or_else(|| "unknown error".into()),
206            });
207        }
208        Ok(legacy.data)
209    }
210
211    async fn post_legacy_cmd(
212        &self,
213        manager: &str,
214        body: serde_json::Value,
215    ) -> Result<serde_json::Value, ApiError> {
216        let endpoint = format!("/proxy/network/api/s/default/cmd/{manager}");
217        let url = format!("{}{endpoint}", self.base_url);
218        let resp = self.http.post(&url).json(&body).send().await?;
219        let status = resp.status().as_u16();
220        if !resp.status().is_success() {
221            let body = resp.text().await.unwrap_or_default();
222            return Err(error_for_status(status, body));
223        }
224        json_or_unsupported(resp, &endpoint).await
225    }
226
227    async fn put_legacy<T: serde::Serialize>(
228        &self,
229        path: &str,
230        body: &T,
231    ) -> Result<serde_json::Value, ApiError> {
232        let endpoint = format!("/proxy/network/api/s/default{path}");
233        let url = format!("{}{endpoint}", self.base_url);
234        let resp = self.http.put(&url).json(body).send().await?;
235        let status = resp.status().as_u16();
236        if !resp.status().is_success() {
237            let body = resp.text().await.unwrap_or_default();
238            return Err(error_for_status(status, body));
239        }
240        json_or_unsupported(resp, &endpoint).await
241    }
242
243    async fn post_legacy<T: serde::Serialize>(
244        &self,
245        path: &str,
246        body: &T,
247    ) -> Result<serde_json::Value, ApiError> {
248        let endpoint = format!("/proxy/network/api/s/default{path}");
249        let url = format!("{}{endpoint}", self.base_url);
250        let resp = self.http.post(&url).json(body).send().await?;
251        let status = resp.status().as_u16();
252        if !resp.status().is_success() {
253            let body = resp.text().await.unwrap_or_default();
254            return Err(error_for_status(status, body));
255        }
256        json_or_unsupported(resp, &endpoint).await
257    }
258
259    // Paginate through all results from Integration API
260    async fn paginate_all<T: DeserializeOwned>(&self, base_path: &str) -> Result<Vec<T>, ApiError> {
261        let mut all = Vec::new();
262        let mut offset = 0;
263        let limit = 200;
264
265        loop {
266            let separator = if base_path.contains('?') { '&' } else { '?' };
267            let path = format!("{base_path}{separator}offset={offset}&limit={limit}");
268            let resp: PaginatedResponse<T> = self.get_integration(&path).await?;
269            let count = resp.data.len();
270            all.extend(resp.data);
271
272            if all.len() >= resp.total_count || count < limit {
273                break;
274            }
275            offset += count;
276        }
277
278        Ok(all)
279    }
280
281    // --- Public API ---
282
283    // Clients
284    pub async fn list_clients(&mut self) -> Result<Vec<Client>, ApiError> {
285        let site_id = self.ensure_site_id().await?.to_string();
286        self.paginate_all(&format!(
287            "/proxy/network/integration/v1/sites/{site_id}/clients"
288        ))
289        .await
290    }
291
292    pub async fn get_client_detail(&self, mac: &str) -> Result<LegacyClient, ApiError> {
293        let normalized = normalize_mac(mac);
294        let clients: Vec<LegacyClient> = self.get_legacy("/stat/sta").await?;
295        clients
296            .into_iter()
297            .find(|c| {
298                c.mac
299                    .as_deref()
300                    .is_some_and(|m| normalize_mac(m) == normalized)
301            })
302            .ok_or_else(|| ApiError::NotFound(format!("Client with MAC {mac}")))
303    }
304
305    pub async fn set_fixed_ip(
306        &self,
307        mac: &str,
308        ip: &str,
309        name: Option<&str>,
310    ) -> Result<(), ApiError> {
311        let normalized = normalize_mac(mac);
312
313        // Find client _id from legacy stat/sta
314        let clients: Vec<LegacyClient> = self.get_legacy("/stat/sta").await?;
315        let client = clients
316            .into_iter()
317            .find(|c| {
318                c.mac
319                    .as_deref()
320                    .is_some_and(|m| normalize_mac(m) == normalized)
321            })
322            .ok_or_else(|| ApiError::NotFound(format!("Client with MAC {mac}")))?;
323
324        let mut payload = serde_json::json!({
325            "mac": format_mac(&normalized),
326            "use_fixedip": true,
327            "fixed_ip": ip,
328        });
329
330        if let Some(n) = name {
331            payload["name"] = serde_json::Value::String(n.to_string());
332            payload["noted"] = serde_json::Value::Bool(true);
333        }
334
335        let path = format!("/rest/user/{}", client.id);
336        match self.put_legacy(&path, &payload).await {
337            Ok(_) => Ok(()),
338            Err(ApiError::NotFound(_)) => {
339                // Client doesn't have a user entry yet, create one
340                self.post_legacy("/rest/user", &payload).await?;
341                Ok(())
342            }
343            Err(e) => Err(e),
344        }
345    }
346
347    pub async fn block_client(&self, mac: &str) -> Result<(), ApiError> {
348        let formatted = format_mac(&normalize_mac(mac));
349        self.post_legacy_cmd(
350            "stamgr",
351            serde_json::json!({"cmd": "block-sta", "mac": formatted}),
352        )
353        .await?;
354        Ok(())
355    }
356
357    pub async fn unblock_client(&self, mac: &str) -> Result<(), ApiError> {
358        let formatted = format_mac(&normalize_mac(mac));
359        self.post_legacy_cmd(
360            "stamgr",
361            serde_json::json!({"cmd": "unblock-sta", "mac": formatted}),
362        )
363        .await?;
364        Ok(())
365    }
366
367    pub async fn kick_client(&self, mac: &str) -> Result<(), ApiError> {
368        let formatted = format_mac(&normalize_mac(mac));
369        self.post_legacy_cmd(
370            "stamgr",
371            serde_json::json!({"cmd": "kick-sta", "mac": formatted}),
372        )
373        .await?;
374        Ok(())
375    }
376
377    // Devices
378    pub async fn list_devices(&mut self) -> Result<Vec<Device>, ApiError> {
379        let site_id = self.ensure_site_id().await?.to_string();
380        self.paginate_all(&format!(
381            "/proxy/network/integration/v1/sites/{site_id}/devices"
382        ))
383        .await
384    }
385
386    pub async fn get_device_detail(&self, mac: &str) -> Result<LegacyDevice, ApiError> {
387        let normalized = normalize_mac(mac);
388        let devices: Vec<LegacyDevice> = self.get_legacy("/stat/device").await?;
389        devices
390            .into_iter()
391            .find(|d| {
392                d.mac
393                    .as_deref()
394                    .is_some_and(|m| normalize_mac(m) == normalized)
395            })
396            .ok_or_else(|| ApiError::NotFound(format!("Device with MAC {mac}")))
397    }
398
399    pub async fn restart_device(&self, mac: &str) -> Result<(), ApiError> {
400        let formatted = format_mac(&normalize_mac(mac));
401        self.post_legacy_cmd(
402            "devmgr",
403            serde_json::json!({"cmd": "restart", "mac": formatted}),
404        )
405        .await?;
406        Ok(())
407    }
408
409    /// Power-cycle a single PoE port. `mac` is the **switch's** MAC, not the
410    /// attached device's.
411    pub async fn power_cycle_port(&self, mac: &str, port_idx: u32) -> Result<(), ApiError> {
412        let formatted = format_mac(&normalize_mac(mac));
413        self.post_legacy_cmd(
414            "devmgr",
415            serde_json::json!({"cmd": "power-cycle", "mac": formatted, "port_idx": port_idx}),
416        )
417        .await?;
418        Ok(())
419    }
420
421    pub async fn upgrade_device(&self, mac: &str) -> Result<(), ApiError> {
422        let formatted = format_mac(&normalize_mac(mac));
423        self.post_legacy_cmd(
424            "devmgr",
425            serde_json::json!({"cmd": "upgrade", "mac": formatted}),
426        )
427        .await?;
428        Ok(())
429    }
430
431    pub async fn locate_device(&self, mac: &str, enable: bool) -> Result<(), ApiError> {
432        let formatted = format_mac(&normalize_mac(mac));
433        let cmd = if enable { "set-locate" } else { "unset-locate" };
434        self.post_legacy_cmd("devmgr", serde_json::json!({"cmd": cmd, "mac": formatted}))
435            .await?;
436        Ok(())
437    }
438
439    // Networks
440    pub async fn list_networks(&mut self) -> Result<Vec<Network>, ApiError> {
441        let site_id = self.ensure_site_id().await?.to_string();
442        self.paginate_all(&format!(
443            "/proxy/network/integration/v1/sites/{site_id}/networks"
444        ))
445        .await
446    }
447
448    // Events
449    //
450    // Legacy `stat/event` was removed in UniFi Network 9+ (UniFi OS) and now
451    // returns api.err.NotFound (404). On those controllers the surviving REST
452    // surface for notable events is `rest/alarm`, whose records share this
453    // `Event` shape, so fall back to it. (The full live event stream on newer
454    // controllers is only exposed over the events WebSocket, which this REST
455    // client does not consume.)
456    //
457    // Some UniFi Network 9 builds serve neither. Reporting the fallback's own
458    // rejection verbatim would tell a caller that its request was malformed and
459    // invite it to retry with different parameters, when in truth the whole
460    // event surface is gone and nothing it can send would work.
461    pub async fn list_events(&self, limit: usize) -> Result<Vec<Event>, ApiError> {
462        let events_path = format!("/stat/event?_limit={limit}");
463        match self.get_legacy::<Event>(&events_path).await {
464            Ok(events) => Ok(events),
465            Err(ApiError::NotFound(_)) => match self.get_legacy::<Event>("/rest/alarm").await {
466                // `rest/alarm` is neither time-ordered nor limited server-side;
467                // present the most recent `limit` records to match the
468                // semantics `stat/event?_limit=` provided on older controllers.
469                Ok(mut alarms) => {
470                    alarms.sort_by_key(|e| std::cmp::Reverse(e.time));
471                    alarms.truncate(limit);
472                    Ok(alarms)
473                }
474                Err(e) if is_absent_legacy_endpoint(&e) => Err(ApiError::Unsupported {
475                    endpoint: format!("/proxy/network/api/s/default{events_path}"),
476                    reason: UnsupportedReason::Removed,
477                }),
478                Err(e) => Err(e),
479            },
480            Err(e) => Err(e),
481        }
482    }
483
484    // Port table for a specific device
485    pub async fn get_device_ports(&self, mac: &str) -> Result<DeviceWithPorts, ApiError> {
486        let normalized = normalize_mac(mac);
487        let devices: Vec<DeviceWithPorts> = self.get_legacy("/stat/device").await?;
488        devices
489            .into_iter()
490            .find(|d| {
491                d.mac
492                    .as_deref()
493                    .is_some_and(|m| normalize_mac(m) == normalized)
494            })
495            .ok_or_else(|| ApiError::NotFound(format!("Device with MAC {mac}")))
496    }
497
498    /// Every device that reports a port table, in one request. `/stat/device`
499    /// already returns all devices with their port tables, so the unfiltered
500    /// listing costs no more than the filtered one.
501    pub async fn list_all_device_ports(&self) -> Result<Vec<DeviceWithPorts>, ApiError> {
502        self.get_legacy("/stat/device").await
503    }
504
505    // All clients with bandwidth data (legacy endpoint for richer stats)
506    pub async fn list_clients_legacy(&self) -> Result<Vec<LegacyClient>, ApiError> {
507        self.get_legacy("/stat/sta").await
508    }
509
510    // All devices with full detail (legacy endpoint)
511    pub async fn get_legacy_devices(&self) -> Result<Vec<LegacyDevice>, ApiError> {
512        self.get_legacy("/stat/device").await
513    }
514
515    // --- Protect API ---
516
517    /// List all cameras from the Protect Integration API.
518    pub async fn list_protect_cameras(&self) -> Result<Vec<ProtectCamera>, ApiError> {
519        let resp: Vec<ProtectCamera> = self
520            .get_integration("/proxy/protect/integration/v1/cameras")
521            .await?;
522        Ok(resp)
523    }
524
525    /// Get a single camera by ID from the Protect Integration API.
526    pub async fn get_protect_camera(&self, id: &str) -> Result<ProtectCamera, ApiError> {
527        self.get_integration(&format!("/proxy/protect/integration/v1/cameras/{id}"))
528            .await
529    }
530
531    /// Get existing RTSPS stream URLs for a camera.
532    pub async fn get_rtsps_streams(&self, camera_id: &str) -> Result<RtspsStreams, ApiError> {
533        self.get_integration(&format!(
534            "/proxy/protect/integration/v1/cameras/{camera_id}/rtsps-stream"
535        ))
536        .await
537    }
538
539    /// Create new RTSPS streams for a camera at the specified quality levels.
540    pub async fn create_rtsps_streams(
541        &self,
542        camera_id: &str,
543        qualities: &[String],
544    ) -> Result<RtspsStreams, ApiError> {
545        validate_qualities(qualities)?;
546        let endpoint = format!("/proxy/protect/integration/v1/cameras/{camera_id}/rtsps-stream");
547        let url = format!("{}{endpoint}", self.base_url);
548        let body = serde_json::json!({ "qualities": qualities });
549        let resp = self.http.post(&url).json(&body).send().await?;
550        let status = resp.status().as_u16();
551        if !resp.status().is_success() {
552            let body = resp.text().await.unwrap_or_default();
553            return Err(error_for_status(status, body));
554        }
555        json_or_unsupported(resp, &endpoint).await
556    }
557
558    /// Delete RTSPS streams for a camera at the specified quality levels.
559    pub async fn delete_rtsps_streams(
560        &self,
561        camera_id: &str,
562        qualities: &[String],
563    ) -> Result<(), ApiError> {
564        validate_qualities(qualities)?;
565        let query: String = qualities
566            .iter()
567            .map(|q| format!("qualities={q}"))
568            .collect::<Vec<_>>()
569            .join("&");
570        let url = format!(
571            "{}/proxy/protect/integration/v1/cameras/{camera_id}/rtsps-stream?{query}",
572            self.base_url
573        );
574        let resp = self.http.delete(&url).send().await?;
575        let status = resp.status().as_u16();
576        if !resp.status().is_success() {
577            let body = resp.text().await.unwrap_or_default();
578            return Err(error_for_status(status, body));
579        }
580        Ok(())
581    }
582
583    /// Resolve a camera identifier (ID or name) to a camera ID.
584    /// If the input is a 24-char hex string, treats it as an ID.
585    /// Otherwise, searches by name (case-insensitive).
586    ///
587    /// A name that fits more than one camera is an error rather than a choice
588    /// made silently: the caller may be about to delete that camera's streams,
589    /// and picking whichever the controller happened to list first would act on
590    /// a different camera than the one the user was asked to confirm.
591    pub async fn resolve_camera_id(&self, id_or_name: &str) -> Result<String, ApiError> {
592        // If it looks like a Protect camera ID (24 hex chars), use it directly
593        if id_or_name.len() == 24 && id_or_name.chars().all(|c| c.is_ascii_hexdigit()) {
594            return Ok(id_or_name.to_string());
595        }
596        // Otherwise, search by name
597        let cameras = self.list_protect_cameras().await?;
598        let needle = id_or_name.to_lowercase();
599        let mut matches: Vec<ProtectCamera> = cameras
600            .into_iter()
601            .filter(|c| {
602                c.name
603                    .as_deref()
604                    .is_some_and(|n| n.trim().to_lowercase() == needle)
605            })
606            .collect();
607
608        match matches.len() {
609            0 => Err(ApiError::NotFound(format!("Camera '{id_or_name}'"))),
610            1 => Ok(matches.pop().expect("checked len == 1 above").id),
611            _ => {
612                let list = matches
613                    .iter()
614                    .map(|c| c.id.as_str())
615                    .collect::<Vec<_>>()
616                    .join(", ");
617                Err(ApiError::Conflict(format!(
618                    "'{id_or_name}' matches {} cameras: {list}. Use the ID.",
619                    matches.len()
620                )))
621            }
622        }
623    }
624
625    // System
626    pub async fn get_health(&self) -> Result<Vec<HealthSubsystem>, ApiError> {
627        self.get_legacy("/stat/health").await
628    }
629
630    pub async fn get_sysinfo(&self) -> Result<SysInfo, ApiError> {
631        let mut data: Vec<SysInfo> = self.get_legacy("/stat/sysinfo").await?;
632        data.pop()
633            .ok_or_else(|| ApiError::Other("No sysinfo returned".into()))
634    }
635
636    pub async fn get_host_system(&self) -> Result<HostSystem, ApiError> {
637        let url = format!("{}/api/system", self.base_url);
638        let resp = self.http.get(&url).send().await?;
639        let status = resp.status().as_u16();
640        if !resp.status().is_success() {
641            let body = resp.text().await.unwrap_or_default();
642            return Err(error_for_status(status, body));
643        }
644        json_or_unsupported(resp, "/api/system").await
645    }
646}
647
648/// Session-based client for the direct Protect API.
649///
650/// Uses username/password login to get a session cookie, then hits
651/// `/proxy/protect/api/` endpoints which return full camera objects
652/// (IP, firmware, channels, stats, WiFi, ISP settings, etc).
653pub struct ProtectSession {
654    http: reqwest::Client,
655    base_url: String,
656    token: String,
657    csrf_token: Option<String>,
658}
659
660impl ProtectSession {
661    /// Login to UniFi OS and return a session with cookie auth.
662    pub async fn login(host: &str, username: &str, password: &str) -> Result<Self, ApiError> {
663        Self::login_with_options(host, username, password, ClientOptions::default()).await
664    }
665
666    pub async fn login_with_options(
667        host: &str,
668        username: &str,
669        password: &str,
670        options: ClientOptions,
671    ) -> Result<Self, ApiError> {
672        let base_url = normalize_base_url(host)?;
673
674        // Don't use cookie_provider: the `partitioned` cookie attribute
675        // isn't handled by reqwest's jar. We extract the token manually.
676        let http = reqwest::Client::builder()
677            .danger_accept_invalid_certs(options.accept_invalid_certs)
678            .timeout(std::time::Duration::from_secs(30))
679            .build()
680            .map_err(ApiError::Http)?;
681
682        let url = format!("{base_url}/api/auth/login");
683        let body = serde_json::json!({
684            "username": username,
685            "password": password,
686        });
687
688        let resp = http.post(&url).json(&body).send().await?;
689        let status = resp.status().as_u16();
690
691        if !resp.status().is_success() {
692            let body = resp.text().await.unwrap_or_default();
693            return Err(error_for_status(status, body));
694        }
695
696        // Extract TOKEN from Set-Cookie header
697        let token = resp
698            .headers()
699            .get_all("set-cookie")
700            .iter()
701            .find_map(|v| {
702                let s = v.to_str().ok()?;
703                if s.starts_with("TOKEN=") {
704                    s.split(';')
705                        .next()?
706                        .strip_prefix("TOKEN=")
707                        .map(String::from)
708                } else {
709                    None
710                }
711            })
712            .ok_or_else(|| ApiError::Auth("Login succeeded but no TOKEN cookie returned".into()))?;
713
714        // Extract CSRF token from response headers
715        let csrf_token = resp
716            .headers()
717            .get("x-csrf-token")
718            .and_then(|v| v.to_str().ok())
719            .map(String::from);
720
721        // Consume body to finalize the response
722        let _ = resp.text().await;
723
724        Ok(Self {
725            http,
726            base_url,
727            token,
728            csrf_token,
729        })
730    }
731
732    /// GET from the direct Protect API (cookie-authenticated).
733    pub async fn get<T: DeserializeOwned>(&self, path: &str) -> Result<T, ApiError> {
734        let endpoint = format!("/proxy/protect/api{path}");
735        let url = format!("{}{endpoint}", self.base_url);
736        let mut req = self
737            .http
738            .get(&url)
739            .header("cookie", format!("TOKEN={}", self.token));
740        if let Some(ref token) = self.csrf_token {
741            req = req.header("x-csrf-token", token);
742        }
743        let resp = req.send().await?;
744        let status = resp.status().as_u16();
745        if !resp.status().is_success() {
746            let body = resp.text().await.unwrap_or_default();
747            return Err(error_for_status(status, body));
748        }
749        json_or_unsupported(resp, &endpoint).await
750    }
751
752    /// List all cameras from the direct Protect API (full objects).
753    pub async fn list_cameras_full(&self) -> Result<Vec<ProtectCameraFull>, ApiError> {
754        self.get("/cameras").await
755    }
756
757    /// Get a single camera by ID (full object).
758    pub async fn get_camera_full(&self, id: &str) -> Result<ProtectCameraFull, ApiError> {
759        self.get(&format!("/cameras/{id}")).await
760    }
761}