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