Skip to main content

unifi_cli/api/
client.rs

1use reqwest::header::{HeaderMap, HeaderValue};
2use serde::de::DeserializeOwned;
3
4use super::types::*;
5
6pub struct UnifiClient {
7    http: reqwest::Client,
8    base_url: String,
9    site_id: Option<String>,
10}
11
12impl UnifiClient {
13    pub fn new(host: &str, api_key: &str) -> Result<Self, ApiError> {
14        let mut headers = HeaderMap::new();
15        headers.insert(
16            "X-API-KEY",
17            HeaderValue::from_str(api_key).map_err(|e| ApiError::Other(e.to_string()))?,
18        );
19
20        let http = reqwest::Client::builder()
21            .danger_accept_invalid_certs(true)
22            .default_headers(headers)
23            .timeout(std::time::Duration::from_secs(30))
24            .build()
25            .map_err(ApiError::Http)?;
26
27        let base_url = if host.starts_with("http") {
28            host.trim_end_matches('/').to_string()
29        } else {
30            format!("https://{host}")
31        };
32
33        Ok(Self {
34            http,
35            base_url,
36            site_id: None,
37        })
38    }
39
40    pub fn clone_http(&self) -> reqwest::Client {
41        self.http.clone()
42    }
43
44    pub fn base_url(&self) -> &str {
45        &self.base_url
46    }
47
48    fn error_for_status(status: u16, message: String) -> ApiError {
49        match status {
50            401 | 403 => ApiError::Auth(message),
51            404 => ApiError::NotFound(message),
52            _ => ApiError::Api { status, message },
53        }
54    }
55
56    pub fn error_for_status_pub(status: u16, message: String) -> ApiError {
57        Self::error_for_status(status, message)
58    }
59
60    // Auto-discover site UUID from Integration API
61    async fn ensure_site_id(&mut self) -> Result<&str, ApiError> {
62        if self.site_id.is_none() {
63            let resp: PaginatedResponse<Site> = self
64                .get_integration("/proxy/network/integration/v1/sites")
65                .await?;
66            let site = resp.data.into_iter().next().ok_or_else(|| {
67                ApiError::Other("No sites found — check that the API key has site access".into())
68            })?;
69            self.site_id = Some(site.id);
70        }
71        Ok(self.site_id.as_deref().unwrap())
72    }
73
74    async fn get_integration<T: DeserializeOwned>(&self, path: &str) -> Result<T, ApiError> {
75        let url = format!("{}{path}", self.base_url);
76        let resp = self.http.get(&url).send().await?;
77        let status = resp.status().as_u16();
78        if !resp.status().is_success() {
79            let body = resp.text().await.unwrap_or_default();
80            return Err(Self::error_for_status(status, body));
81        }
82        Ok(resp.json().await?)
83    }
84
85    async fn get_legacy<T: DeserializeOwned>(&self, path: &str) -> Result<Vec<T>, ApiError> {
86        let url = format!("{}/proxy/network/api/s/default{path}", self.base_url);
87        let resp = self.http.get(&url).send().await?;
88        let status = resp.status().as_u16();
89        if !resp.status().is_success() {
90            let body = resp.text().await.unwrap_or_default();
91            return Err(Self::error_for_status(status, body));
92        }
93        let legacy: LegacyResponse<T> = resp.json().await?;
94        if legacy.meta.rc != "ok" {
95            return Err(ApiError::Api {
96                status: 200,
97                message: legacy.meta.msg.unwrap_or_else(|| "unknown error".into()),
98            });
99        }
100        Ok(legacy.data)
101    }
102
103    async fn post_legacy_cmd(
104        &self,
105        manager: &str,
106        body: serde_json::Value,
107    ) -> Result<serde_json::Value, ApiError> {
108        let url = format!(
109            "{}/proxy/network/api/s/default/cmd/{manager}",
110            self.base_url
111        );
112        let resp = self.http.post(&url).json(&body).send().await?;
113        let status = resp.status().as_u16();
114        if !resp.status().is_success() {
115            let body = resp.text().await.unwrap_or_default();
116            return Err(Self::error_for_status(status, body));
117        }
118        Ok(resp.json().await?)
119    }
120
121    async fn put_legacy<T: serde::Serialize>(
122        &self,
123        path: &str,
124        body: &T,
125    ) -> Result<serde_json::Value, ApiError> {
126        let url = format!("{}/proxy/network/api/s/default{path}", self.base_url);
127        let resp = self.http.put(&url).json(body).send().await?;
128        let status = resp.status().as_u16();
129        if !resp.status().is_success() {
130            let body = resp.text().await.unwrap_or_default();
131            return Err(Self::error_for_status(status, body));
132        }
133        Ok(resp.json().await?)
134    }
135
136    async fn post_legacy<T: serde::Serialize>(
137        &self,
138        path: &str,
139        body: &T,
140    ) -> Result<serde_json::Value, ApiError> {
141        let url = format!("{}/proxy/network/api/s/default{path}", self.base_url);
142        let resp = self.http.post(&url).json(body).send().await?;
143        let status = resp.status().as_u16();
144        if !resp.status().is_success() {
145            let body = resp.text().await.unwrap_or_default();
146            return Err(Self::error_for_status(status, body));
147        }
148        Ok(resp.json().await?)
149    }
150
151    // Paginate through all results from Integration API
152    async fn paginate_all<T: DeserializeOwned>(&self, base_path: &str) -> Result<Vec<T>, ApiError> {
153        let mut all = Vec::new();
154        let mut offset = 0;
155        let limit = 200;
156
157        loop {
158            let separator = if base_path.contains('?') { '&' } else { '?' };
159            let path = format!("{base_path}{separator}offset={offset}&limit={limit}");
160            let resp: PaginatedResponse<T> = self.get_integration(&path).await?;
161            let count = resp.data.len();
162            all.extend(resp.data);
163
164            if all.len() >= resp.total_count || count < limit {
165                break;
166            }
167            offset += count;
168        }
169
170        Ok(all)
171    }
172
173    // --- Public API ---
174
175    // Clients
176    pub async fn list_clients(&mut self) -> Result<Vec<Client>, ApiError> {
177        let site_id = self.ensure_site_id().await?.to_string();
178        self.paginate_all(&format!(
179            "/proxy/network/integration/v1/sites/{site_id}/clients"
180        ))
181        .await
182    }
183
184    pub async fn get_client_detail(&self, mac: &str) -> Result<LegacyClient, ApiError> {
185        let normalized = normalize_mac(mac);
186        let clients: Vec<LegacyClient> = self.get_legacy("/stat/sta").await?;
187        clients
188            .into_iter()
189            .find(|c| {
190                c.mac
191                    .as_deref()
192                    .is_some_and(|m| normalize_mac(m) == normalized)
193            })
194            .ok_or_else(|| ApiError::NotFound(format!("Client with MAC {mac}")))
195    }
196
197    pub async fn set_fixed_ip(
198        &self,
199        mac: &str,
200        ip: &str,
201        name: Option<&str>,
202    ) -> Result<(), ApiError> {
203        let normalized = normalize_mac(mac);
204
205        // Find client _id from legacy stat/sta
206        let clients: Vec<LegacyClient> = self.get_legacy("/stat/sta").await?;
207        let client = clients
208            .into_iter()
209            .find(|c| {
210                c.mac
211                    .as_deref()
212                    .is_some_and(|m| normalize_mac(m) == normalized)
213            })
214            .ok_or_else(|| ApiError::NotFound(format!("Client with MAC {mac}")))?;
215
216        let mut payload = serde_json::json!({
217            "mac": format_mac(&normalized),
218            "use_fixedip": true,
219            "fixed_ip": ip,
220        });
221
222        if let Some(n) = name {
223            payload["name"] = serde_json::Value::String(n.to_string());
224            payload["noted"] = serde_json::Value::Bool(true);
225        }
226
227        let path = format!("/rest/user/{}", client.id);
228        match self.put_legacy(&path, &payload).await {
229            Ok(_) => Ok(()),
230            Err(ApiError::NotFound(_)) => {
231                // Client doesn't have a user entry yet, create one
232                self.post_legacy("/rest/user", &payload).await?;
233                Ok(())
234            }
235            Err(e) => Err(e),
236        }
237    }
238
239    pub async fn block_client(&self, mac: &str) -> Result<(), ApiError> {
240        let formatted = format_mac(&normalize_mac(mac));
241        self.post_legacy_cmd(
242            "stamgr",
243            serde_json::json!({"cmd": "block-sta", "mac": formatted}),
244        )
245        .await?;
246        Ok(())
247    }
248
249    pub async fn unblock_client(&self, mac: &str) -> Result<(), ApiError> {
250        let formatted = format_mac(&normalize_mac(mac));
251        self.post_legacy_cmd(
252            "stamgr",
253            serde_json::json!({"cmd": "unblock-sta", "mac": formatted}),
254        )
255        .await?;
256        Ok(())
257    }
258
259    pub async fn kick_client(&self, mac: &str) -> Result<(), ApiError> {
260        let formatted = format_mac(&normalize_mac(mac));
261        self.post_legacy_cmd(
262            "stamgr",
263            serde_json::json!({"cmd": "kick-sta", "mac": formatted}),
264        )
265        .await?;
266        Ok(())
267    }
268
269    // Devices
270    pub async fn list_devices(&mut self) -> Result<Vec<Device>, ApiError> {
271        let site_id = self.ensure_site_id().await?.to_string();
272        self.paginate_all(&format!(
273            "/proxy/network/integration/v1/sites/{site_id}/devices"
274        ))
275        .await
276    }
277
278    pub async fn get_device_detail(&self, mac: &str) -> Result<LegacyDevice, ApiError> {
279        let normalized = normalize_mac(mac);
280        let devices: Vec<LegacyDevice> = self.get_legacy("/stat/device").await?;
281        devices
282            .into_iter()
283            .find(|d| {
284                d.mac
285                    .as_deref()
286                    .is_some_and(|m| normalize_mac(m) == normalized)
287            })
288            .ok_or_else(|| ApiError::NotFound(format!("Device with MAC {mac}")))
289    }
290
291    pub async fn restart_device(&self, mac: &str) -> Result<(), ApiError> {
292        let formatted = format_mac(&normalize_mac(mac));
293        self.post_legacy_cmd(
294            "devmgr",
295            serde_json::json!({"cmd": "restart", "mac": formatted}),
296        )
297        .await?;
298        Ok(())
299    }
300
301    pub async fn upgrade_device(&self, mac: &str) -> Result<(), ApiError> {
302        let formatted = format_mac(&normalize_mac(mac));
303        self.post_legacy_cmd(
304            "devmgr",
305            serde_json::json!({"cmd": "upgrade", "mac": formatted}),
306        )
307        .await?;
308        Ok(())
309    }
310
311    pub async fn locate_device(&self, mac: &str, enable: bool) -> Result<(), ApiError> {
312        let formatted = format_mac(&normalize_mac(mac));
313        let cmd = if enable { "set-locate" } else { "unset-locate" };
314        self.post_legacy_cmd("devmgr", serde_json::json!({"cmd": cmd, "mac": formatted}))
315            .await?;
316        Ok(())
317    }
318
319    // Networks
320    pub async fn list_networks(&mut self) -> Result<Vec<Network>, ApiError> {
321        let site_id = self.ensure_site_id().await?.to_string();
322        self.paginate_all(&format!(
323            "/proxy/network/integration/v1/sites/{site_id}/networks"
324        ))
325        .await
326    }
327
328    // Events
329    pub async fn list_events(&self, limit: usize) -> Result<Vec<Event>, ApiError> {
330        let events: Vec<Event> = self
331            .get_legacy(&format!("/stat/event?_limit={limit}"))
332            .await?;
333        Ok(events)
334    }
335
336    // Port table for a specific device
337    pub async fn get_device_ports(&self, mac: &str) -> Result<DeviceWithPorts, ApiError> {
338        let normalized = normalize_mac(mac);
339        let devices: Vec<DeviceWithPorts> = self.get_legacy("/stat/device").await?;
340        devices
341            .into_iter()
342            .find(|d| {
343                d.mac
344                    .as_deref()
345                    .is_some_and(|m| normalize_mac(m) == normalized)
346            })
347            .ok_or_else(|| ApiError::NotFound(format!("Device with MAC {mac}")))
348    }
349
350    // All clients with bandwidth data (legacy endpoint for richer stats)
351    pub async fn list_clients_legacy(&self) -> Result<Vec<LegacyClient>, ApiError> {
352        self.get_legacy("/stat/sta").await
353    }
354
355    // All devices with full detail (legacy endpoint)
356    pub async fn get_legacy_devices(&self) -> Result<Vec<LegacyDevice>, ApiError> {
357        self.get_legacy("/stat/device").await
358    }
359
360    // System
361    pub async fn get_health(&self) -> Result<Vec<HealthSubsystem>, ApiError> {
362        self.get_legacy("/stat/health").await
363    }
364
365    pub async fn get_sysinfo(&self) -> Result<SysInfo, ApiError> {
366        let mut data: Vec<SysInfo> = self.get_legacy("/stat/sysinfo").await?;
367        data.pop()
368            .ok_or_else(|| ApiError::Other("No sysinfo returned".into()))
369    }
370
371    pub async fn get_host_system(&self) -> Result<HostSystem, ApiError> {
372        let url = format!("{}/api/system", self.base_url);
373        let resp = self.http.get(&url).send().await?;
374        let status = resp.status().as_u16();
375        if !resp.status().is_success() {
376            let body = resp.text().await.unwrap_or_default();
377            return Err(Self::error_for_status(status, body));
378        }
379        Ok(resp.json().await?)
380    }
381}