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/// Valid RTSPS quality levels accepted by the Protect API.
15const VALID_QUALITIES: &[&str] = &["high", "medium", "low", "package"];
16
17/// Validate quality values against the Protect API allowlist.
18pub fn validate_qualities(qualities: &[String]) -> Result<(), ApiError> {
19    for q in qualities {
20        if !VALID_QUALITIES.contains(&q.as_str()) {
21            return Err(ApiError::Other(format!(
22                "Invalid quality '{q}'. Valid values: {}",
23                VALID_QUALITIES.join(", ")
24            )));
25        }
26    }
27    Ok(())
28}
29
30pub struct UnifiClient {
31    http: reqwest::Client,
32    base_url: String,
33    site_id: Option<String>,
34}
35
36impl UnifiClient {
37    pub fn new(host: &str, api_key: &str) -> Result<Self, ApiError> {
38        let mut headers = HeaderMap::new();
39        headers.insert(
40            "X-API-KEY",
41            HeaderValue::from_str(api_key).map_err(|e| ApiError::Other(e.to_string()))?,
42        );
43
44        let http = reqwest::Client::builder()
45            .danger_accept_invalid_certs(true)
46            .default_headers(headers)
47            .timeout(std::time::Duration::from_secs(30))
48            .build()
49            .map_err(ApiError::Http)?;
50
51        let base_url = if host.starts_with("http") {
52            host.trim_end_matches('/').to_string()
53        } else {
54            format!("https://{host}")
55        };
56
57        Ok(Self {
58            http,
59            base_url,
60            site_id: None,
61        })
62    }
63
64    pub fn clone_http(&self) -> reqwest::Client {
65        self.http.clone()
66    }
67
68    pub fn base_url(&self) -> &str {
69        &self.base_url
70    }
71
72    // Auto-discover site UUID from Integration API
73    async fn ensure_site_id(&mut self) -> Result<&str, ApiError> {
74        if self.site_id.is_none() {
75            let resp: PaginatedResponse<Site> = self
76                .get_integration("/proxy/network/integration/v1/sites")
77                .await?;
78            let site = resp.data.into_iter().next().ok_or_else(|| {
79                ApiError::Other("No sites found — check that the API key has site access".into())
80            })?;
81            self.site_id = Some(site.id);
82        }
83        Ok(self.site_id.as_deref().unwrap())
84    }
85
86    async fn get_integration<T: DeserializeOwned>(&self, path: &str) -> Result<T, ApiError> {
87        let url = format!("{}{path}", self.base_url);
88        let resp = self.http.get(&url).send().await?;
89        let status = resp.status().as_u16();
90        if !resp.status().is_success() {
91            let body = resp.text().await.unwrap_or_default();
92            return Err(error_for_status(status, body));
93        }
94        Ok(resp.json().await?)
95    }
96
97    async fn get_legacy<T: DeserializeOwned>(&self, path: &str) -> Result<Vec<T>, ApiError> {
98        let url = format!("{}/proxy/network/api/s/default{path}", self.base_url);
99        let resp = self.http.get(&url).send().await?;
100        let status = resp.status().as_u16();
101        if !resp.status().is_success() {
102            let body = resp.text().await.unwrap_or_default();
103            return Err(error_for_status(status, body));
104        }
105        let legacy: LegacyResponse<T> = resp.json().await?;
106        if legacy.meta.rc != "ok" {
107            return Err(ApiError::Api {
108                status: 200,
109                message: legacy.meta.msg.unwrap_or_else(|| "unknown error".into()),
110            });
111        }
112        Ok(legacy.data)
113    }
114
115    async fn post_legacy_cmd(
116        &self,
117        manager: &str,
118        body: serde_json::Value,
119    ) -> Result<serde_json::Value, ApiError> {
120        let url = format!(
121            "{}/proxy/network/api/s/default/cmd/{manager}",
122            self.base_url
123        );
124        let resp = self.http.post(&url).json(&body).send().await?;
125        let status = resp.status().as_u16();
126        if !resp.status().is_success() {
127            let body = resp.text().await.unwrap_or_default();
128            return Err(error_for_status(status, body));
129        }
130        Ok(resp.json().await?)
131    }
132
133    async fn put_legacy<T: serde::Serialize>(
134        &self,
135        path: &str,
136        body: &T,
137    ) -> Result<serde_json::Value, ApiError> {
138        let url = format!("{}/proxy/network/api/s/default{path}", self.base_url);
139        let resp = self.http.put(&url).json(body).send().await?;
140        let status = resp.status().as_u16();
141        if !resp.status().is_success() {
142            let body = resp.text().await.unwrap_or_default();
143            return Err(error_for_status(status, body));
144        }
145        Ok(resp.json().await?)
146    }
147
148    async fn post_legacy<T: serde::Serialize>(
149        &self,
150        path: &str,
151        body: &T,
152    ) -> Result<serde_json::Value, ApiError> {
153        let url = format!("{}/proxy/network/api/s/default{path}", self.base_url);
154        let resp = self.http.post(&url).json(body).send().await?;
155        let status = resp.status().as_u16();
156        if !resp.status().is_success() {
157            let body = resp.text().await.unwrap_or_default();
158            return Err(error_for_status(status, body));
159        }
160        Ok(resp.json().await?)
161    }
162
163    // Paginate through all results from Integration API
164    async fn paginate_all<T: DeserializeOwned>(&self, base_path: &str) -> Result<Vec<T>, ApiError> {
165        let mut all = Vec::new();
166        let mut offset = 0;
167        let limit = 200;
168
169        loop {
170            let separator = if base_path.contains('?') { '&' } else { '?' };
171            let path = format!("{base_path}{separator}offset={offset}&limit={limit}");
172            let resp: PaginatedResponse<T> = self.get_integration(&path).await?;
173            let count = resp.data.len();
174            all.extend(resp.data);
175
176            if all.len() >= resp.total_count || count < limit {
177                break;
178            }
179            offset += count;
180        }
181
182        Ok(all)
183    }
184
185    // --- Public API ---
186
187    // Clients
188    pub async fn list_clients(&mut self) -> Result<Vec<Client>, ApiError> {
189        let site_id = self.ensure_site_id().await?.to_string();
190        self.paginate_all(&format!(
191            "/proxy/network/integration/v1/sites/{site_id}/clients"
192        ))
193        .await
194    }
195
196    pub async fn get_client_detail(&self, mac: &str) -> Result<LegacyClient, ApiError> {
197        let normalized = normalize_mac(mac);
198        let clients: Vec<LegacyClient> = self.get_legacy("/stat/sta").await?;
199        clients
200            .into_iter()
201            .find(|c| {
202                c.mac
203                    .as_deref()
204                    .is_some_and(|m| normalize_mac(m) == normalized)
205            })
206            .ok_or_else(|| ApiError::NotFound(format!("Client with MAC {mac}")))
207    }
208
209    pub async fn set_fixed_ip(
210        &self,
211        mac: &str,
212        ip: &str,
213        name: Option<&str>,
214    ) -> Result<(), ApiError> {
215        let normalized = normalize_mac(mac);
216
217        // Find client _id from legacy stat/sta
218        let clients: Vec<LegacyClient> = self.get_legacy("/stat/sta").await?;
219        let client = clients
220            .into_iter()
221            .find(|c| {
222                c.mac
223                    .as_deref()
224                    .is_some_and(|m| normalize_mac(m) == normalized)
225            })
226            .ok_or_else(|| ApiError::NotFound(format!("Client with MAC {mac}")))?;
227
228        let mut payload = serde_json::json!({
229            "mac": format_mac(&normalized),
230            "use_fixedip": true,
231            "fixed_ip": ip,
232        });
233
234        if let Some(n) = name {
235            payload["name"] = serde_json::Value::String(n.to_string());
236            payload["noted"] = serde_json::Value::Bool(true);
237        }
238
239        let path = format!("/rest/user/{}", client.id);
240        match self.put_legacy(&path, &payload).await {
241            Ok(_) => Ok(()),
242            Err(ApiError::NotFound(_)) => {
243                // Client doesn't have a user entry yet, create one
244                self.post_legacy("/rest/user", &payload).await?;
245                Ok(())
246            }
247            Err(e) => Err(e),
248        }
249    }
250
251    pub async fn block_client(&self, mac: &str) -> Result<(), ApiError> {
252        let formatted = format_mac(&normalize_mac(mac));
253        self.post_legacy_cmd(
254            "stamgr",
255            serde_json::json!({"cmd": "block-sta", "mac": formatted}),
256        )
257        .await?;
258        Ok(())
259    }
260
261    pub async fn unblock_client(&self, mac: &str) -> Result<(), ApiError> {
262        let formatted = format_mac(&normalize_mac(mac));
263        self.post_legacy_cmd(
264            "stamgr",
265            serde_json::json!({"cmd": "unblock-sta", "mac": formatted}),
266        )
267        .await?;
268        Ok(())
269    }
270
271    pub async fn kick_client(&self, mac: &str) -> Result<(), ApiError> {
272        let formatted = format_mac(&normalize_mac(mac));
273        self.post_legacy_cmd(
274            "stamgr",
275            serde_json::json!({"cmd": "kick-sta", "mac": formatted}),
276        )
277        .await?;
278        Ok(())
279    }
280
281    // Devices
282    pub async fn list_devices(&mut self) -> Result<Vec<Device>, ApiError> {
283        let site_id = self.ensure_site_id().await?.to_string();
284        self.paginate_all(&format!(
285            "/proxy/network/integration/v1/sites/{site_id}/devices"
286        ))
287        .await
288    }
289
290    pub async fn get_device_detail(&self, mac: &str) -> Result<LegacyDevice, ApiError> {
291        let normalized = normalize_mac(mac);
292        let devices: Vec<LegacyDevice> = self.get_legacy("/stat/device").await?;
293        devices
294            .into_iter()
295            .find(|d| {
296                d.mac
297                    .as_deref()
298                    .is_some_and(|m| normalize_mac(m) == normalized)
299            })
300            .ok_or_else(|| ApiError::NotFound(format!("Device with MAC {mac}")))
301    }
302
303    pub async fn restart_device(&self, mac: &str) -> Result<(), ApiError> {
304        let formatted = format_mac(&normalize_mac(mac));
305        self.post_legacy_cmd(
306            "devmgr",
307            serde_json::json!({"cmd": "restart", "mac": formatted}),
308        )
309        .await?;
310        Ok(())
311    }
312
313    pub async fn upgrade_device(&self, mac: &str) -> Result<(), ApiError> {
314        let formatted = format_mac(&normalize_mac(mac));
315        self.post_legacy_cmd(
316            "devmgr",
317            serde_json::json!({"cmd": "upgrade", "mac": formatted}),
318        )
319        .await?;
320        Ok(())
321    }
322
323    pub async fn locate_device(&self, mac: &str, enable: bool) -> Result<(), ApiError> {
324        let formatted = format_mac(&normalize_mac(mac));
325        let cmd = if enable { "set-locate" } else { "unset-locate" };
326        self.post_legacy_cmd("devmgr", serde_json::json!({"cmd": cmd, "mac": formatted}))
327            .await?;
328        Ok(())
329    }
330
331    // Networks
332    pub async fn list_networks(&mut self) -> Result<Vec<Network>, ApiError> {
333        let site_id = self.ensure_site_id().await?.to_string();
334        self.paginate_all(&format!(
335            "/proxy/network/integration/v1/sites/{site_id}/networks"
336        ))
337        .await
338    }
339
340    // Events
341    pub async fn list_events(&self, limit: usize) -> Result<Vec<Event>, ApiError> {
342        let events: Vec<Event> = self
343            .get_legacy(&format!("/stat/event?_limit={limit}"))
344            .await?;
345        Ok(events)
346    }
347
348    // Port table for a specific device
349    pub async fn get_device_ports(&self, mac: &str) -> Result<DeviceWithPorts, ApiError> {
350        let normalized = normalize_mac(mac);
351        let devices: Vec<DeviceWithPorts> = self.get_legacy("/stat/device").await?;
352        devices
353            .into_iter()
354            .find(|d| {
355                d.mac
356                    .as_deref()
357                    .is_some_and(|m| normalize_mac(m) == normalized)
358            })
359            .ok_or_else(|| ApiError::NotFound(format!("Device with MAC {mac}")))
360    }
361
362    // All clients with bandwidth data (legacy endpoint for richer stats)
363    pub async fn list_clients_legacy(&self) -> Result<Vec<LegacyClient>, ApiError> {
364        self.get_legacy("/stat/sta").await
365    }
366
367    // All devices with full detail (legacy endpoint)
368    pub async fn get_legacy_devices(&self) -> Result<Vec<LegacyDevice>, ApiError> {
369        self.get_legacy("/stat/device").await
370    }
371
372    // --- Protect API ---
373
374    /// List all cameras from the Protect Integration API.
375    pub async fn list_protect_cameras(&self) -> Result<Vec<ProtectCamera>, ApiError> {
376        let resp: Vec<ProtectCamera> = self
377            .get_integration("/proxy/protect/integration/v1/cameras")
378            .await?;
379        Ok(resp)
380    }
381
382    /// Get a single camera by ID from the Protect Integration API.
383    pub async fn get_protect_camera(&self, id: &str) -> Result<ProtectCamera, ApiError> {
384        self.get_integration(&format!("/proxy/protect/integration/v1/cameras/{id}"))
385            .await
386    }
387
388    /// Get existing RTSPS stream URLs for a camera.
389    pub async fn get_rtsps_streams(&self, camera_id: &str) -> Result<RtspsStreams, ApiError> {
390        self.get_integration(&format!(
391            "/proxy/protect/integration/v1/cameras/{camera_id}/rtsps-stream"
392        ))
393        .await
394    }
395
396    /// Create new RTSPS streams for a camera at the specified quality levels.
397    pub async fn create_rtsps_streams(
398        &self,
399        camera_id: &str,
400        qualities: &[String],
401    ) -> Result<RtspsStreams, ApiError> {
402        validate_qualities(qualities)?;
403        let url = format!(
404            "{}/proxy/protect/integration/v1/cameras/{camera_id}/rtsps-stream",
405            self.base_url
406        );
407        let body = serde_json::json!({ "qualities": qualities });
408        let resp = self.http.post(&url).json(&body).send().await?;
409        let status = resp.status().as_u16();
410        if !resp.status().is_success() {
411            let body = resp.text().await.unwrap_or_default();
412            return Err(error_for_status(status, body));
413        }
414        Ok(resp.json().await?)
415    }
416
417    /// Delete RTSPS streams for a camera at the specified quality levels.
418    pub async fn delete_rtsps_streams(
419        &self,
420        camera_id: &str,
421        qualities: &[String],
422    ) -> Result<(), ApiError> {
423        validate_qualities(qualities)?;
424        let query: String = qualities
425            .iter()
426            .map(|q| format!("qualities={q}"))
427            .collect::<Vec<_>>()
428            .join("&");
429        let url = format!(
430            "{}/proxy/protect/integration/v1/cameras/{camera_id}/rtsps-stream?{query}",
431            self.base_url
432        );
433        let resp = self.http.delete(&url).send().await?;
434        let status = resp.status().as_u16();
435        if !resp.status().is_success() {
436            let body = resp.text().await.unwrap_or_default();
437            return Err(error_for_status(status, body));
438        }
439        Ok(())
440    }
441
442    /// Resolve a camera identifier (ID or name) to a camera ID.
443    /// If the input is a 24-char hex string, treats it as an ID.
444    /// Otherwise, searches by name (case-insensitive).
445    pub async fn resolve_camera_id(&self, id_or_name: &str) -> Result<String, ApiError> {
446        // If it looks like a Protect camera ID (24 hex chars), use it directly
447        if id_or_name.len() == 24 && id_or_name.chars().all(|c| c.is_ascii_hexdigit()) {
448            return Ok(id_or_name.to_string());
449        }
450        // Otherwise, search by name
451        let cameras = self.list_protect_cameras().await?;
452        let needle = id_or_name.to_lowercase();
453        cameras
454            .into_iter()
455            .find(|c| {
456                c.name
457                    .as_deref()
458                    .is_some_and(|n| n.trim().to_lowercase() == needle)
459            })
460            .map(|c| c.id)
461            .ok_or_else(|| ApiError::NotFound(format!("Camera '{id_or_name}'")))
462    }
463
464    // System
465    pub async fn get_health(&self) -> Result<Vec<HealthSubsystem>, ApiError> {
466        self.get_legacy("/stat/health").await
467    }
468
469    pub async fn get_sysinfo(&self) -> Result<SysInfo, ApiError> {
470        let mut data: Vec<SysInfo> = self.get_legacy("/stat/sysinfo").await?;
471        data.pop()
472            .ok_or_else(|| ApiError::Other("No sysinfo returned".into()))
473    }
474
475    pub async fn get_host_system(&self) -> Result<HostSystem, ApiError> {
476        let url = format!("{}/api/system", self.base_url);
477        let resp = self.http.get(&url).send().await?;
478        let status = resp.status().as_u16();
479        if !resp.status().is_success() {
480            let body = resp.text().await.unwrap_or_default();
481            return Err(error_for_status(status, body));
482        }
483        Ok(resp.json().await?)
484    }
485}
486
487/// Session-based client for the direct Protect API.
488///
489/// Uses username/password login to get a session cookie, then hits
490/// `/proxy/protect/api/` endpoints which return full camera objects
491/// (IP, firmware, channels, stats, WiFi, ISP settings, etc).
492pub struct ProtectSession {
493    http: reqwest::Client,
494    base_url: String,
495    token: String,
496    csrf_token: Option<String>,
497}
498
499impl ProtectSession {
500    /// Login to UniFi OS and return a session with cookie auth.
501    pub async fn login(host: &str, username: &str, password: &str) -> Result<Self, ApiError> {
502        let base_url = if host.starts_with("http") {
503            host.trim_end_matches('/').to_string()
504        } else {
505            format!("https://{host}")
506        };
507
508        // Don't use cookie_provider — the `partitioned` cookie attribute
509        // isn't handled by reqwest's jar. We extract the token manually.
510        let http = reqwest::Client::builder()
511            .danger_accept_invalid_certs(true)
512            .timeout(std::time::Duration::from_secs(30))
513            .build()
514            .map_err(ApiError::Http)?;
515
516        let url = format!("{base_url}/api/auth/login");
517        let body = serde_json::json!({
518            "username": username,
519            "password": password,
520        });
521
522        let resp = http.post(&url).json(&body).send().await?;
523        let status = resp.status().as_u16();
524
525        if !resp.status().is_success() {
526            let body = resp.text().await.unwrap_or_default();
527            return Err(error_for_status(status, body));
528        }
529
530        // Extract TOKEN from Set-Cookie header
531        let token = resp
532            .headers()
533            .get_all("set-cookie")
534            .iter()
535            .find_map(|v| {
536                let s = v.to_str().ok()?;
537                if s.starts_with("TOKEN=") {
538                    s.split(';')
539                        .next()?
540                        .strip_prefix("TOKEN=")
541                        .map(String::from)
542                } else {
543                    None
544                }
545            })
546            .ok_or_else(|| ApiError::Auth("Login succeeded but no TOKEN cookie returned".into()))?;
547
548        // Extract CSRF token from response headers
549        let csrf_token = resp
550            .headers()
551            .get("x-csrf-token")
552            .and_then(|v| v.to_str().ok())
553            .map(String::from);
554
555        // Consume body to finalize the response
556        let _ = resp.text().await;
557
558        Ok(Self {
559            http,
560            base_url,
561            token,
562            csrf_token,
563        })
564    }
565
566    /// GET from the direct Protect API (cookie-authenticated).
567    pub async fn get<T: DeserializeOwned>(&self, path: &str) -> Result<T, ApiError> {
568        let url = format!("{}/proxy/protect/api{path}", self.base_url);
569        let mut req = self
570            .http
571            .get(&url)
572            .header("cookie", format!("TOKEN={}", self.token));
573        if let Some(ref token) = self.csrf_token {
574            req = req.header("x-csrf-token", token);
575        }
576        let resp = req.send().await?;
577        let status = resp.status().as_u16();
578        if !resp.status().is_success() {
579            let body = resp.text().await.unwrap_or_default();
580            return Err(error_for_status(status, body));
581        }
582        let bytes = resp.bytes().await.map_err(ApiError::Http)?;
583        serde_json::from_slice(&bytes)
584            .map_err(|e| ApiError::Other(format!("JSON parse error: {e}")))
585    }
586
587    /// List all cameras from the direct Protect API (full objects).
588    pub async fn list_cameras_full(&self) -> Result<Vec<ProtectCameraFull>, ApiError> {
589        self.get("/cameras").await
590    }
591
592    /// Get a single camera by ID (full object).
593    pub async fn get_camera_full(&self, id: &str) -> Result<ProtectCameraFull, ApiError> {
594        self.get(&format!("/cameras/{id}")).await
595    }
596}