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