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    //
371    // Legacy `stat/event` was removed in UniFi Network 9+ (UniFi OS) and now
372    // returns api.err.NotFound (404). On those controllers the surviving REST
373    // surface for notable events is `rest/alarm`, whose records share this
374    // `Event` shape, so fall back to it. (The full live event stream on newer
375    // controllers is only exposed over the events WebSocket, which this REST
376    // client does not consume.)
377    pub async fn list_events(&self, limit: usize) -> Result<Vec<Event>, ApiError> {
378        match self
379            .get_legacy::<Event>(&format!("/stat/event?_limit={limit}"))
380            .await
381        {
382            Ok(events) => Ok(events),
383            Err(ApiError::NotFound(_)) => {
384                let mut alarms: Vec<Event> = self.get_legacy("/rest/alarm").await?;
385                // `rest/alarm` is neither time-ordered nor limited server-side;
386                // present the most recent `limit` records to match the
387                // semantics `stat/event?_limit=` provided on older controllers.
388                alarms.sort_by_key(|e| std::cmp::Reverse(e.time));
389                alarms.truncate(limit);
390                Ok(alarms)
391            }
392            Err(e) => Err(e),
393        }
394    }
395
396    // Port table for a specific device
397    pub async fn get_device_ports(&self, mac: &str) -> Result<DeviceWithPorts, ApiError> {
398        let normalized = normalize_mac(mac);
399        let devices: Vec<DeviceWithPorts> = self.get_legacy("/stat/device").await?;
400        devices
401            .into_iter()
402            .find(|d| {
403                d.mac
404                    .as_deref()
405                    .is_some_and(|m| normalize_mac(m) == normalized)
406            })
407            .ok_or_else(|| ApiError::NotFound(format!("Device with MAC {mac}")))
408    }
409
410    // All clients with bandwidth data (legacy endpoint for richer stats)
411    pub async fn list_clients_legacy(&self) -> Result<Vec<LegacyClient>, ApiError> {
412        self.get_legacy("/stat/sta").await
413    }
414
415    // All devices with full detail (legacy endpoint)
416    pub async fn get_legacy_devices(&self) -> Result<Vec<LegacyDevice>, ApiError> {
417        self.get_legacy("/stat/device").await
418    }
419
420    // --- Protect API ---
421
422    /// List all cameras from the Protect Integration API.
423    pub async fn list_protect_cameras(&self) -> Result<Vec<ProtectCamera>, ApiError> {
424        let resp: Vec<ProtectCamera> = self
425            .get_integration("/proxy/protect/integration/v1/cameras")
426            .await?;
427        Ok(resp)
428    }
429
430    /// Get a single camera by ID from the Protect Integration API.
431    pub async fn get_protect_camera(&self, id: &str) -> Result<ProtectCamera, ApiError> {
432        self.get_integration(&format!("/proxy/protect/integration/v1/cameras/{id}"))
433            .await
434    }
435
436    /// Get existing RTSPS stream URLs for a camera.
437    pub async fn get_rtsps_streams(&self, camera_id: &str) -> Result<RtspsStreams, ApiError> {
438        self.get_integration(&format!(
439            "/proxy/protect/integration/v1/cameras/{camera_id}/rtsps-stream"
440        ))
441        .await
442    }
443
444    /// Create new RTSPS streams for a camera at the specified quality levels.
445    pub async fn create_rtsps_streams(
446        &self,
447        camera_id: &str,
448        qualities: &[String],
449    ) -> Result<RtspsStreams, ApiError> {
450        validate_qualities(qualities)?;
451        let url = format!(
452            "{}/proxy/protect/integration/v1/cameras/{camera_id}/rtsps-stream",
453            self.base_url
454        );
455        let body = serde_json::json!({ "qualities": qualities });
456        let resp = self.http.post(&url).json(&body).send().await?;
457        let status = resp.status().as_u16();
458        if !resp.status().is_success() {
459            let body = resp.text().await.unwrap_or_default();
460            return Err(error_for_status(status, body));
461        }
462        Ok(resp.json().await?)
463    }
464
465    /// Delete RTSPS streams for a camera at the specified quality levels.
466    pub async fn delete_rtsps_streams(
467        &self,
468        camera_id: &str,
469        qualities: &[String],
470    ) -> Result<(), ApiError> {
471        validate_qualities(qualities)?;
472        let query: String = qualities
473            .iter()
474            .map(|q| format!("qualities={q}"))
475            .collect::<Vec<_>>()
476            .join("&");
477        let url = format!(
478            "{}/proxy/protect/integration/v1/cameras/{camera_id}/rtsps-stream?{query}",
479            self.base_url
480        );
481        let resp = self.http.delete(&url).send().await?;
482        let status = resp.status().as_u16();
483        if !resp.status().is_success() {
484            let body = resp.text().await.unwrap_or_default();
485            return Err(error_for_status(status, body));
486        }
487        Ok(())
488    }
489
490    /// Resolve a camera identifier (ID or name) to a camera ID.
491    /// If the input is a 24-char hex string, treats it as an ID.
492    /// Otherwise, searches by name (case-insensitive).
493    pub async fn resolve_camera_id(&self, id_or_name: &str) -> Result<String, ApiError> {
494        // If it looks like a Protect camera ID (24 hex chars), use it directly
495        if id_or_name.len() == 24 && id_or_name.chars().all(|c| c.is_ascii_hexdigit()) {
496            return Ok(id_or_name.to_string());
497        }
498        // Otherwise, search by name
499        let cameras = self.list_protect_cameras().await?;
500        let needle = id_or_name.to_lowercase();
501        cameras
502            .into_iter()
503            .find(|c| {
504                c.name
505                    .as_deref()
506                    .is_some_and(|n| n.trim().to_lowercase() == needle)
507            })
508            .map(|c| c.id)
509            .ok_or_else(|| ApiError::NotFound(format!("Camera '{id_or_name}'")))
510    }
511
512    // System
513    pub async fn get_health(&self) -> Result<Vec<HealthSubsystem>, ApiError> {
514        self.get_legacy("/stat/health").await
515    }
516
517    pub async fn get_sysinfo(&self) -> Result<SysInfo, ApiError> {
518        let mut data: Vec<SysInfo> = self.get_legacy("/stat/sysinfo").await?;
519        data.pop()
520            .ok_or_else(|| ApiError::Other("No sysinfo returned".into()))
521    }
522
523    pub async fn get_host_system(&self) -> Result<HostSystem, ApiError> {
524        let url = format!("{}/api/system", self.base_url);
525        let resp = self.http.get(&url).send().await?;
526        let status = resp.status().as_u16();
527        if !resp.status().is_success() {
528            let body = resp.text().await.unwrap_or_default();
529            return Err(error_for_status(status, body));
530        }
531        Ok(resp.json().await?)
532    }
533}
534
535/// Session-based client for the direct Protect API.
536///
537/// Uses username/password login to get a session cookie, then hits
538/// `/proxy/protect/api/` endpoints which return full camera objects
539/// (IP, firmware, channels, stats, WiFi, ISP settings, etc).
540pub struct ProtectSession {
541    http: reqwest::Client,
542    base_url: String,
543    token: String,
544    csrf_token: Option<String>,
545}
546
547impl ProtectSession {
548    /// Login to UniFi OS and return a session with cookie auth.
549    pub async fn login(host: &str, username: &str, password: &str) -> Result<Self, ApiError> {
550        Self::login_with_options(host, username, password, ClientOptions::default()).await
551    }
552
553    pub async fn login_with_options(
554        host: &str,
555        username: &str,
556        password: &str,
557        options: ClientOptions,
558    ) -> Result<Self, ApiError> {
559        let base_url = normalize_base_url(host)?;
560
561        // Don't use cookie_provider — the `partitioned` cookie attribute
562        // isn't handled by reqwest's jar. We extract the token manually.
563        let http = reqwest::Client::builder()
564            .danger_accept_invalid_certs(options.accept_invalid_certs)
565            .timeout(std::time::Duration::from_secs(30))
566            .build()
567            .map_err(ApiError::Http)?;
568
569        let url = format!("{base_url}/api/auth/login");
570        let body = serde_json::json!({
571            "username": username,
572            "password": password,
573        });
574
575        let resp = http.post(&url).json(&body).send().await?;
576        let status = resp.status().as_u16();
577
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
583        // Extract TOKEN from Set-Cookie header
584        let token = resp
585            .headers()
586            .get_all("set-cookie")
587            .iter()
588            .find_map(|v| {
589                let s = v.to_str().ok()?;
590                if s.starts_with("TOKEN=") {
591                    s.split(';')
592                        .next()?
593                        .strip_prefix("TOKEN=")
594                        .map(String::from)
595                } else {
596                    None
597                }
598            })
599            .ok_or_else(|| ApiError::Auth("Login succeeded but no TOKEN cookie returned".into()))?;
600
601        // Extract CSRF token from response headers
602        let csrf_token = resp
603            .headers()
604            .get("x-csrf-token")
605            .and_then(|v| v.to_str().ok())
606            .map(String::from);
607
608        // Consume body to finalize the response
609        let _ = resp.text().await;
610
611        Ok(Self {
612            http,
613            base_url,
614            token,
615            csrf_token,
616        })
617    }
618
619    /// GET from the direct Protect API (cookie-authenticated).
620    pub async fn get<T: DeserializeOwned>(&self, path: &str) -> Result<T, ApiError> {
621        let url = format!("{}/proxy/protect/api{path}", self.base_url);
622        let mut req = self
623            .http
624            .get(&url)
625            .header("cookie", format!("TOKEN={}", self.token));
626        if let Some(ref token) = self.csrf_token {
627            req = req.header("x-csrf-token", token);
628        }
629        let resp = req.send().await?;
630        let status = resp.status().as_u16();
631        if !resp.status().is_success() {
632            let body = resp.text().await.unwrap_or_default();
633            return Err(error_for_status(status, body));
634        }
635        let bytes = resp.bytes().await.map_err(ApiError::Http)?;
636        serde_json::from_slice(&bytes)
637            .map_err(|e| ApiError::Other(format!("JSON parse error: {e}")))
638    }
639
640    /// List all cameras from the direct Protect API (full objects).
641    pub async fn list_cameras_full(&self) -> Result<Vec<ProtectCameraFull>, ApiError> {
642        self.get("/cameras").await
643    }
644
645    /// Get a single camera by ID (full object).
646    pub async fn get_camera_full(&self, id: &str) -> Result<ProtectCameraFull, ApiError> {
647        self.get(&format!("/cameras/{id}")).await
648    }
649}