1use std::time::{SystemTime, UNIX_EPOCH};
5
6use reqwest::Method;
7use serde_json::{json, Value};
8
9use crate::api::{internal::InternalNetworkApi, path, ApiSourceFamily};
10use crate::capabilities::Capability;
11use crate::error::{Result, UnifiError};
12use crate::util::truncate_data_array;
13use crate::UnifiClient;
14
15pub async fn execute(
27 client: &UnifiClient,
28 capability: &Capability,
29 params: &Value,
30) -> Result<Value> {
31 if capability.source != ApiSourceFamily::Internal {
32 return Err(UnifiError::InvalidRequest {
33 context: capability.action.clone(),
34 message: "not an internal API action".to_string(),
35 });
36 }
37
38 match capability.action.as_str() {
39 "clients" => client.clients().await,
40 "devices" => client.devices().await,
41 "wlans" => client.wlans().await,
42 "health" => client.health().await,
43 "alarms" => client.alarms().await,
44 "events" => {
45 let mut events = execute_generic(client, capability, params).await?;
46 truncate_data_array(
47 &mut events,
48 params
49 .get("limit")
50 .and_then(Value::as_u64)
51 .map(|value| value as usize),
52 );
53 Ok(events)
54 }
55 "sysinfo" => client.sysinfo().await,
56 "me" => client.me().await,
57 _ => execute_generic(client, capability, params).await,
58 }
59}
60
61async fn execute_generic(
62 client: &UnifiClient,
63 capability: &Capability,
64 params: &Value,
65) -> Result<Value> {
66 let mut method = capability
67 .method
68 .as_deref()
69 .unwrap_or("GET")
70 .parse::<Method>()
71 .map_err(|_| UnifiError::InvalidRequest {
72 context: capability.action.clone(),
73 message: format!("invalid HTTP method {:?}", capability.method),
74 })?;
75 let mut path = capability
76 .path
77 .as_deref()
78 .ok_or_else(|| UnifiError::InvalidRequest {
79 context: capability.action.clone(),
80 message: "internal action has no path configured".to_string(),
81 })?;
82 let mut effective_params = params.clone();
83 normalize_internal_request(
84 capability.action.as_str(),
85 &mut method,
86 &mut path,
87 &mut effective_params,
88 );
89 let path = path::substitute_path(path, &effective_params, &[])?;
96 let api = InternalNetworkApi::new(&client.url, client.site(), client.legacy());
97 let full_path = resolve_internal_path(&api, &path, client.legacy());
98 let mut value = client
99 .request_json(
100 capability.action.as_str(),
101 method,
102 &full_path,
103 effective_params.get("query"),
104 effective_params.get("body"),
105 )
106 .await?;
107 if capability.action == "unifi_get_ips_events" {
108 retain_security_events(&mut value);
109 }
110 Ok(value)
111}
112
113fn resolve_internal_path(api: &InternalNetworkApi, path: &str, legacy: bool) -> String {
119 if path == "/api/self" {
120 if legacy {
121 path.to_string()
122 } else {
123 "/proxy/network/api/self".to_string()
124 }
125 } else if path.starts_with("/api/") {
126 if legacy {
127 path.to_string()
128 } else {
129 format!("/proxy/network{path}")
130 }
131 } else if let Some(suffix) = path.strip_prefix("/v2/") {
132 api.v2_site_path(suffix)
133 } else {
134 api.v1_site_path(path)
135 }
136}
137
138fn normalize_internal_request(
144 action: &str,
145 method: &mut Method,
146 path: &mut &str,
147 params: &mut Value,
148) {
149 match action {
150 "events" | "unifi_recent_events" | "unifi_get_ips_events" => {
151 *method = Method::POST;
152 *path = "/v2/system-log/all";
153 ensure_body(params, json!({}));
154 }
155 "unifi_get_client_sessions" => {
156 ensure_body(params, default_session_body());
157 }
158 "unifi_get_alerts"
159 | "unifi_get_event_types"
160 | "unifi_get_traffic_flows"
161 | "unifi_list_alarms"
162 | "unifi_list_events" => {
163 ensure_body(params, json!({}));
164 }
165 _ => {}
166 }
167}
168
169fn ensure_body(params: &mut Value, body: Value) {
170 if params.get("body").is_some() {
171 return;
172 }
173 if !params.is_object() {
174 *params = json!({});
175 }
176 if let Some(object) = params.as_object_mut() {
177 object.insert("body".to_string(), body);
178 }
179}
180
181fn default_session_body() -> Value {
185 let end = SystemTime::now()
186 .duration_since(UNIX_EPOCH)
187 .map(|duration| duration.as_millis() as u64)
188 .unwrap_or(0);
189 let start = end.saturating_sub(24 * 60 * 60 * 1000);
190 json!({ "start": start, "end": end })
191}
192
193fn retain_security_events(value: &mut Value) {
196 let Some(items) = value.get_mut("data").and_then(Value::as_array_mut) else {
197 return;
198 };
199 items.retain(|item| {
200 matches!(
201 item.get("category").and_then(Value::as_str),
202 Some("SECURITY")
203 ) || item
204 .get("key")
205 .and_then(Value::as_str)
206 .is_some_and(|key| key.contains("THREAT") || key.contains("IPS"))
207 || item
208 .get("subcategory")
209 .and_then(Value::as_str)
210 .is_some_and(|subcategory| subcategory.contains("SECURITY"))
211 });
212}
213
214#[cfg(test)]
215mod tests {
216 use super::*;
217
218 #[test]
219 fn resolve_internal_path_maps_self_endpoint() {
220 let api = InternalNetworkApi::new("https://unifi.local", "default", false);
221
222 assert_eq!(
223 resolve_internal_path(&api, "/api/self", false),
224 "/proxy/network/api/self"
225 );
226 assert_eq!(resolve_internal_path(&api, "/api/self", true), "/api/self");
227 }
228
229 #[test]
230 fn resolve_internal_path_maps_legacy_api_endpoints() {
231 let api = InternalNetworkApi::new("https://unifi.local", "default", false);
232
233 assert_eq!(
234 resolve_internal_path(&api, "/api/stat/sta", false),
235 "/proxy/network/api/stat/sta"
236 );
237 assert_eq!(
238 resolve_internal_path(&api, "/api/stat/sta", true),
239 "/api/stat/sta"
240 );
241 }
242
243 #[test]
244 fn resolve_internal_path_maps_v2_endpoints_through_the_site_prefix() {
245 let api = InternalNetworkApi::new("https://unifi.local", "default", false);
246
247 assert_eq!(
248 resolve_internal_path(&api, "/v2/system-log/all", false),
249 "/proxy/network/v2/api/site/default/system-log/all"
250 );
251 }
252
253 #[test]
254 fn resolve_internal_path_falls_back_to_v1_site_path() {
255 let api = InternalNetworkApi::new("https://unifi.local", "default", false);
256
257 assert_eq!(
258 resolve_internal_path(&api, "stat/sta", false),
259 "/proxy/network/api/s/default/stat/sta"
260 );
261 }
262
263 #[test]
264 fn normalize_internal_request_rewrites_events_to_a_post() {
265 let mut method = Method::GET;
266 let mut path = "/rest/event";
267 let mut params = Value::Null;
268
269 normalize_internal_request("events", &mut method, &mut path, &mut params);
270
271 assert_eq!(method, Method::POST);
272 assert_eq!(path, "/v2/system-log/all");
273 assert_eq!(params.get("body"), Some(&json!({})));
274 }
275
276 #[test]
277 fn normalize_internal_request_preserves_a_caller_supplied_body() {
278 let mut method = Method::GET;
279 let mut path = "/rest/event";
280 let mut params = json!({ "body": { "start": 1 } });
281
282 normalize_internal_request("events", &mut method, &mut path, &mut params);
283
284 assert_eq!(params.get("body"), Some(&json!({ "start": 1 })));
285 }
286
287 #[test]
288 fn normalize_internal_request_leaves_unmapped_actions_alone() {
289 let mut method = Method::GET;
290 let mut path = "/stat/sta";
291 let mut params = Value::Null;
292
293 normalize_internal_request("clients", &mut method, &mut path, &mut params);
294
295 assert_eq!(method, Method::GET);
296 assert_eq!(path, "/stat/sta");
297 assert_eq!(params, Value::Null);
298 }
299
300 #[test]
301 fn normalize_internal_request_does_not_reroute_traffic_flow_statistics_to_the_flows_listing() {
302 let mut method = Method::GET;
307 let mut path = "/v2/traffic-flow-latest-statistics";
308 let mut params = Value::Null;
309
310 normalize_internal_request(
311 "unifi_get_traffic_flow_statistics",
312 &mut method,
313 &mut path,
314 &mut params,
315 );
316
317 assert_eq!(method, Method::GET);
318 assert_eq!(path, "/v2/traffic-flow-latest-statistics");
319 }
320
321 #[test]
322 fn normalize_internal_request_does_not_reroute_gateway_settings_to_mgmt_settings() {
323 let mut method = Method::GET;
327 let mut path = "/get/setting/gateway";
328 let mut params = Value::Null;
329
330 normalize_internal_request(
331 "unifi_get_gateway_settings",
332 &mut method,
333 &mut path,
334 &mut params,
335 );
336
337 assert_eq!(path, "/get/setting/gateway");
338 }
339
340 #[test]
341 fn default_session_body_covers_a_24_hour_window() {
342 let body = default_session_body();
343
344 let start = body["start"].as_u64().unwrap();
345 let end = body["end"].as_u64().unwrap();
346
347 assert_eq!(end - start, 24 * 60 * 60 * 1000);
348 }
349
350 #[test]
351 fn retain_security_events_keeps_only_security_flagged_items() {
352 let mut value = json!({
353 "data": [
354 { "category": "SECURITY", "key": "x" },
355 { "category": "OTHER", "key": "THREAT_DETECTED" },
356 { "category": "OTHER", "subcategory": "SECURITY_ALERT" },
357 { "category": "OTHER", "key": "noise" },
358 ]
359 });
360
361 retain_security_events(&mut value);
362
363 assert_eq!(value["data"].as_array().unwrap().len(), 3);
364 }
365}