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    // Auto-discover site UUID from Integration API
41    async fn ensure_site_id(&mut self) -> Result<&str, ApiError> {
42        if self.site_id.is_none() {
43            let resp: PaginatedResponse<Site> = self
44                .get_integration("/proxy/network/integration/v1/sites")
45                .await?;
46            let site = resp
47                .data
48                .into_iter()
49                .next()
50                .ok_or_else(|| ApiError::Other("No sites found on controller".into()))?;
51            self.site_id = Some(site.id);
52        }
53        Ok(self.site_id.as_deref().unwrap())
54    }
55
56    async fn get_integration<T: DeserializeOwned>(&self, path: &str) -> Result<T, ApiError> {
57        let url = format!("{}{path}", self.base_url);
58        let resp = self.http.get(&url).send().await?;
59        let status = resp.status().as_u16();
60        if !resp.status().is_success() {
61            let body = resp.text().await.unwrap_or_default();
62            return Err(ApiError::Api {
63                status,
64                message: body,
65            });
66        }
67        Ok(resp.json().await?)
68    }
69
70    async fn get_legacy<T: DeserializeOwned>(&self, path: &str) -> Result<Vec<T>, ApiError> {
71        let url = format!("{}/proxy/network/api/s/default{path}", self.base_url);
72        let resp = self.http.get(&url).send().await?;
73        let status = resp.status().as_u16();
74        if !resp.status().is_success() {
75            let body = resp.text().await.unwrap_or_default();
76            return Err(ApiError::Api {
77                status,
78                message: body,
79            });
80        }
81        let legacy: LegacyResponse<T> = resp.json().await?;
82        if legacy.meta.rc != "ok" {
83            return Err(ApiError::Api {
84                status: 200,
85                message: legacy.meta.msg.unwrap_or_else(|| "unknown error".into()),
86            });
87        }
88        Ok(legacy.data)
89    }
90
91    async fn post_legacy_cmd(
92        &self,
93        manager: &str,
94        body: serde_json::Value,
95    ) -> Result<serde_json::Value, ApiError> {
96        let url = format!(
97            "{}/proxy/network/api/s/default/cmd/{manager}",
98            self.base_url
99        );
100        let resp = self.http.post(&url).json(&body).send().await?;
101        let status = resp.status().as_u16();
102        if !resp.status().is_success() {
103            let body = resp.text().await.unwrap_or_default();
104            return Err(ApiError::Api {
105                status,
106                message: body,
107            });
108        }
109        Ok(resp.json().await?)
110    }
111
112    async fn put_legacy<T: serde::Serialize>(
113        &self,
114        path: &str,
115        body: &T,
116    ) -> Result<serde_json::Value, ApiError> {
117        let url = format!("{}/proxy/network/api/s/default{path}", self.base_url);
118        let resp = self.http.put(&url).json(body).send().await?;
119        let status = resp.status().as_u16();
120        if !resp.status().is_success() {
121            let body = resp.text().await.unwrap_or_default();
122            return Err(ApiError::Api {
123                status,
124                message: body,
125            });
126        }
127        Ok(resp.json().await?)
128    }
129
130    async fn post_legacy<T: serde::Serialize>(
131        &self,
132        path: &str,
133        body: &T,
134    ) -> Result<serde_json::Value, ApiError> {
135        let url = format!("{}/proxy/network/api/s/default{path}", self.base_url);
136        let resp = self.http.post(&url).json(body).send().await?;
137        let status = resp.status().as_u16();
138        if !resp.status().is_success() {
139            let body = resp.text().await.unwrap_or_default();
140            return Err(ApiError::Api {
141                status,
142                message: body,
143            });
144        }
145        Ok(resp.json().await?)
146    }
147
148    // Paginate through all results from Integration API
149    async fn paginate_all<T: DeserializeOwned>(&self, base_path: &str) -> Result<Vec<T>, ApiError> {
150        let mut all = Vec::new();
151        let mut offset = 0;
152        let limit = 200;
153
154        loop {
155            let separator = if base_path.contains('?') { '&' } else { '?' };
156            let path = format!("{base_path}{separator}offset={offset}&limit={limit}");
157            let resp: PaginatedResponse<T> = self.get_integration(&path).await?;
158            let count = resp.data.len();
159            all.extend(resp.data);
160
161            if all.len() >= resp.total_count || count < limit {
162                break;
163            }
164            offset += count;
165        }
166
167        Ok(all)
168    }
169
170    // --- Public API ---
171
172    // Clients
173    pub async fn list_clients(&mut self) -> Result<Vec<Client>, ApiError> {
174        let site_id = self.ensure_site_id().await?.to_string();
175        self.paginate_all(&format!(
176            "/proxy/network/integration/v1/sites/{site_id}/clients"
177        ))
178        .await
179    }
180
181    pub async fn get_client_detail(&self, mac: &str) -> Result<LegacyClient, ApiError> {
182        let normalized = normalize_mac(mac);
183        let clients: Vec<LegacyClient> = self.get_legacy("/stat/sta").await?;
184        clients
185            .into_iter()
186            .find(|c| {
187                c.mac
188                    .as_deref()
189                    .is_some_and(|m| normalize_mac(m) == normalized)
190            })
191            .ok_or_else(|| ApiError::NotFound(format!("Client with MAC {mac}")))
192    }
193
194    pub async fn set_fixed_ip(
195        &self,
196        mac: &str,
197        ip: &str,
198        name: Option<&str>,
199    ) -> Result<(), ApiError> {
200        let normalized = normalize_mac(mac);
201
202        // Find client _id from legacy stat/sta
203        let clients: Vec<LegacyClient> = self.get_legacy("/stat/sta").await?;
204        let client = clients
205            .into_iter()
206            .find(|c| {
207                c.mac
208                    .as_deref()
209                    .is_some_and(|m| normalize_mac(m) == normalized)
210            })
211            .ok_or_else(|| ApiError::NotFound(format!("Client with MAC {mac}")))?;
212
213        let mut payload = serde_json::json!({
214            "mac": format_mac(&normalized),
215            "use_fixedip": true,
216            "fixed_ip": ip,
217        });
218
219        if let Some(n) = name {
220            payload["name"] = serde_json::Value::String(n.to_string());
221            payload["noted"] = serde_json::Value::Bool(true);
222        }
223
224        let path = format!("/rest/user/{}", client.id);
225        match self.put_legacy(&path, &payload).await {
226            Ok(_) => Ok(()),
227            Err(ApiError::Api { status: 404, .. }) => {
228                // Client doesn't have a user entry yet, create one
229                self.post_legacy("/rest/user", &payload).await?;
230                Ok(())
231            }
232            Err(e) => Err(e),
233        }
234    }
235
236    pub async fn block_client(&self, mac: &str) -> Result<(), ApiError> {
237        let formatted = format_mac(&normalize_mac(mac));
238        self.post_legacy_cmd(
239            "stamgr",
240            serde_json::json!({"cmd": "block-sta", "mac": formatted}),
241        )
242        .await?;
243        Ok(())
244    }
245
246    pub async fn unblock_client(&self, mac: &str) -> Result<(), ApiError> {
247        let formatted = format_mac(&normalize_mac(mac));
248        self.post_legacy_cmd(
249            "stamgr",
250            serde_json::json!({"cmd": "unblock-sta", "mac": formatted}),
251        )
252        .await?;
253        Ok(())
254    }
255
256    pub async fn kick_client(&self, mac: &str) -> Result<(), ApiError> {
257        let formatted = format_mac(&normalize_mac(mac));
258        self.post_legacy_cmd(
259            "stamgr",
260            serde_json::json!({"cmd": "kick-sta", "mac": formatted}),
261        )
262        .await?;
263        Ok(())
264    }
265
266    // Devices
267    pub async fn list_devices(&mut self) -> Result<Vec<Device>, ApiError> {
268        let site_id = self.ensure_site_id().await?.to_string();
269        self.paginate_all(&format!(
270            "/proxy/network/integration/v1/sites/{site_id}/devices"
271        ))
272        .await
273    }
274
275    pub async fn restart_device(&self, mac: &str) -> Result<(), ApiError> {
276        let formatted = format_mac(&normalize_mac(mac));
277        self.post_legacy_cmd(
278            "devmgr",
279            serde_json::json!({"cmd": "restart", "mac": formatted}),
280        )
281        .await?;
282        Ok(())
283    }
284
285    pub async fn locate_device(&self, mac: &str, enable: bool) -> Result<(), ApiError> {
286        let formatted = format_mac(&normalize_mac(mac));
287        let cmd = if enable { "set-locate" } else { "unset-locate" };
288        self.post_legacy_cmd("devmgr", serde_json::json!({"cmd": cmd, "mac": formatted}))
289            .await?;
290        Ok(())
291    }
292
293    // Networks
294    pub async fn list_networks(&mut self) -> Result<Vec<Network>, ApiError> {
295        let site_id = self.ensure_site_id().await?.to_string();
296        self.paginate_all(&format!(
297            "/proxy/network/integration/v1/sites/{site_id}/networks"
298        ))
299        .await
300    }
301
302    // System
303    pub async fn get_health(&self) -> Result<Vec<HealthSubsystem>, ApiError> {
304        self.get_legacy("/stat/health").await
305    }
306
307    pub async fn get_sysinfo(&self) -> Result<SysInfo, ApiError> {
308        let mut data: Vec<SysInfo> = self.get_legacy("/stat/sysinfo").await?;
309        data.pop()
310            .ok_or_else(|| ApiError::Other("No sysinfo returned".into()))
311    }
312}