Skip to main content

unifi/actions/
internal.rs

1//! Dispatch for [`ApiSourceFamily::Internal`] capabilities: the UniFi
2//! controller's own (undocumented, but stable in practice) web-UI API.
3
4use 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
15/// Runs one internal-API `capability` against `client`.
16///
17/// Known actions (`clients`, `devices`, `wlans`, `health`, `alarms`,
18/// `events`, `sysinfo`, `me`) go through [`UnifiClient`]'s named methods;
19/// everything else is dispatched generically from the capability's
20/// `method`/`path`.
21///
22/// # Errors
23/// Returns [`UnifiError::InvalidRequest`] if `capability` isn't an
24/// internal-API capability; see [`UnifiError`] for the other failure cases
25/// this can return.
26pub 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    // Substitute against `effective_params`, not the original `params` —
90    // `normalize_internal_request` above can rewrite params (it currently
91    // only ever injects `body`, never a path parameter, so this was latent
92    // rather than live), and path/query/body must all read from the one
93    // post-normalization source or a future normalization rule that does
94    // touch a path parameter would be silently ignored here.
95    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
113/// Internal-API paths come in three shapes that each map onto the controller
114/// URL differently: the fixed `/api/self` endpoint, other legacy `/api/...`
115/// endpoints, and the `/v2/...` endpoints that live under a per-site prefix
116/// supplied by `api` (never by the capability's own path template — none of
117/// the bundled inventory's `/v2/...` paths embed a site segment themselves).
118fn 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
138/// A handful of internal actions don't map 1:1 onto their capability's
139/// declared method/path/body — the controller's web UI issues them as `POST`
140/// with a JSON body even though the capability catalog (built for
141/// discoverability) lists them as simple lookups. Patch the request here
142/// rather than special-casing the catalog.
143fn 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
181/// A 24-hour window ending now, in epoch milliseconds — the default range
182/// the controller's client-session endpoint expects when the caller didn't
183/// supply one.
184fn 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
193/// `unifi_get_ips_events` shares its endpoint with the general system-log
194/// feed; keep only entries that are actually IPS/threat security events.
195fn 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        // Regression test: this used to overwrite the inventory-declared
303        // `GET /v2/traffic-flow-latest-statistics` with the unrelated
304        // `POST /v2/traffic-flows` (the same rewrite `unifi_get_traffic_flows`
305        // gets), silently returning the wrong data.
306        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        // Regression test: this used to overwrite the inventory-declared
324        // `/get/setting/gateway` with `/get/setting/mgmt`, a different
325        // settings object.
326        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}