Skip to main content

unifly_api/session/
clients.rs

1// Session API client (station) endpoints
2//
3// Client management via stat/sta (read) and cmd/stamgr (commands).
4// Covers listing, blocking, kicking, forgetting, and guest authorization.
5
6use serde_json::json;
7use tracing::debug;
8
9use crate::error::Error;
10use crate::session::client::SessionClient;
11use crate::session::models::{SessionClientEntry, SessionUserEntry};
12
13impl SessionClient {
14    /// List all currently connected clients (stations).
15    ///
16    /// `GET /api/s/{site}/stat/sta`
17    pub async fn list_clients(&self) -> Result<Vec<SessionClientEntry>, Error> {
18        let url = self.site_url("stat/sta");
19        debug!("listing connected clients");
20        self.get(url).await
21    }
22
23    /// Block a client by MAC address.
24    ///
25    /// `POST /api/s/{site}/cmd/stamgr` with `{"cmd": "block-sta", "mac": "..."}`
26    pub async fn block_client(&self, mac: &str) -> Result<(), Error> {
27        let url = self.site_url("cmd/stamgr");
28        debug!(mac, "blocking client");
29        let _: Vec<serde_json::Value> = self
30            .post(
31                url,
32                &json!({
33                    "cmd": "block-sta",
34                    "mac": mac,
35                }),
36            )
37            .await?;
38        Ok(())
39    }
40
41    /// Unblock a client by MAC address.
42    ///
43    /// `POST /api/s/{site}/cmd/stamgr` with `{"cmd": "unblock-sta", "mac": "..."}`
44    pub async fn unblock_client(&self, mac: &str) -> Result<(), Error> {
45        let url = self.site_url("cmd/stamgr");
46        debug!(mac, "unblocking client");
47        let _: Vec<serde_json::Value> = self
48            .post(
49                url,
50                &json!({
51                    "cmd": "unblock-sta",
52                    "mac": mac,
53                }),
54            )
55            .await?;
56        Ok(())
57    }
58
59    /// Disconnect (kick) a client.
60    ///
61    /// `POST /api/s/{site}/cmd/stamgr` with `{"cmd": "kick-sta", "mac": "..."}`
62    pub async fn kick_client(&self, mac: &str) -> Result<(), Error> {
63        let url = self.site_url("cmd/stamgr");
64        debug!(mac, "kicking client");
65        let _: Vec<serde_json::Value> = self
66            .post(
67                url,
68                &json!({
69                    "cmd": "kick-sta",
70                    "mac": mac,
71                }),
72            )
73            .await?;
74        Ok(())
75    }
76
77    /// Forget (permanently remove) a client by MAC address.
78    ///
79    /// `POST /api/s/{site}/cmd/stamgr` with `{"cmd": "forget-sta", "macs": [...]}`
80    pub async fn forget_client(&self, mac: &str) -> Result<(), Error> {
81        let url = self.site_url("cmd/stamgr");
82        debug!(mac, "forgetting client");
83        let _: Vec<serde_json::Value> = self
84            .post(
85                url,
86                &json!({
87                    "cmd": "forget-sta",
88                    "macs": [mac],
89                }),
90            )
91            .await?;
92        Ok(())
93    }
94
95    /// Authorize a guest client on the hotspot portal.
96    ///
97    /// `POST /api/s/{site}/cmd/stamgr` with guest authorization parameters.
98    ///
99    /// - `mac`: Client MAC address
100    /// - `minutes`: Authorization duration in minutes
101    /// - `up_kbps`: Optional upload bandwidth limit (Kbps)
102    /// - `down_kbps`: Optional download bandwidth limit (Kbps)
103    /// - `quota_mb`: Optional data transfer quota (MB)
104    pub async fn authorize_guest(
105        &self,
106        mac: &str,
107        minutes: u32,
108        up_kbps: Option<u32>,
109        down_kbps: Option<u32>,
110        quota_mb: Option<u32>,
111    ) -> Result<(), Error> {
112        let url = self.site_url("cmd/stamgr");
113        debug!(mac, minutes, "authorizing guest");
114
115        let mut body = json!({
116            "cmd": "authorize-guest",
117            "mac": mac,
118            "minutes": minutes,
119        });
120
121        let obj = body
122            .as_object_mut()
123            .expect("json! macro always produces an object");
124        if let Some(up) = up_kbps {
125            obj.insert("up".into(), json!(up));
126        }
127        if let Some(down) = down_kbps {
128            obj.insert("down".into(), json!(down));
129        }
130        if let Some(quota) = quota_mb {
131            obj.insert("bytes".into(), json!(quota));
132        }
133
134        let _: Vec<serde_json::Value> = self.post(url, &body).await?;
135        Ok(())
136    }
137
138    /// Revoke guest authorization for a client.
139    ///
140    /// `POST /api/s/{site}/cmd/stamgr` with `{"cmd": "unauthorize-guest", "mac": "..."}`
141    pub async fn unauthorize_guest(&self, mac: &str) -> Result<(), Error> {
142        let url = self.site_url("cmd/stamgr");
143        debug!(mac, "revoking guest authorization");
144        let _: Vec<serde_json::Value> = self
145            .post(
146                url,
147                &json!({
148                    "cmd": "unauthorize-guest",
149                    "mac": mac,
150                }),
151            )
152            .await?;
153        Ok(())
154    }
155
156    // ── DHCP reservation (rest/user) ──────────────────────────────
157
158    /// List all known users (includes offline clients with reservations).
159    ///
160    /// `GET /api/s/{site}/rest/user`
161    pub async fn list_users(&self) -> Result<Vec<SessionUserEntry>, Error> {
162        let url = self.site_url("rest/user");
163        debug!("listing known users");
164        self.get(url).await
165    }
166
167    /// Set a fixed IP (DHCP reservation) for a client.
168    ///
169    /// Looks up the client in `rest/user` by MAC. If already known, PUTs an
170    /// update; otherwise POSTs a new user entry.
171    pub async fn set_client_fixed_ip(
172        &self,
173        mac: &str,
174        ip: &str,
175        network_id: &str,
176    ) -> Result<(), Error> {
177        debug!(mac, ip, network_id, "setting client fixed IP");
178
179        let users = self.list_users().await?;
180        let normalized_mac = mac.to_lowercase();
181        let existing = users
182            .iter()
183            .find(|u| u.mac.to_lowercase() == normalized_mac);
184
185        if let Some(user) = existing {
186            // Update existing user entry
187            let url = self.site_url(&format!("rest/user/{}", user.id));
188            let _: Vec<serde_json::Value> = self
189                .put(
190                    url,
191                    &json!({
192                        "use_fixedip": true,
193                        "fixed_ip": ip,
194                        "network_id": network_id,
195                    }),
196                )
197                .await?;
198        } else {
199            // Create new user entry
200            let url = self.site_url("rest/user");
201            let _: Vec<serde_json::Value> = self
202                .post(
203                    url,
204                    &json!({
205                        "mac": normalized_mac,
206                        "use_fixedip": true,
207                        "fixed_ip": ip,
208                        "network_id": network_id,
209                    }),
210                )
211                .await?;
212        }
213        Ok(())
214    }
215
216    /// Remove a fixed IP (DHCP reservation) from a client.
217    ///
218    /// If `network_id` is provided, only the reservation on that network
219    /// is removed. Otherwise all reservations for the MAC are cleared.
220    pub async fn remove_client_fixed_ip(
221        &self,
222        mac: &str,
223        network_id: Option<&str>,
224    ) -> Result<(), Error> {
225        debug!(mac, ?network_id, "removing client fixed IP");
226
227        let users = self.list_users().await?;
228        let normalized_mac = mac.to_lowercase();
229        let matches: Vec<&SessionUserEntry> = users
230            .iter()
231            .filter(|u| {
232                u.mac.to_lowercase() == normalized_mac
233                    && network_id.is_none_or(|nid| u.network_id.as_deref() == Some(nid))
234            })
235            .collect();
236
237        if matches.is_empty() {
238            return Err(Error::SessionApi {
239                message: if let Some(nid) = network_id {
240                    format!("no reservation for MAC {mac} on network {nid}")
241                } else {
242                    format!("no known user with MAC {mac}")
243                },
244            });
245        }
246
247        for user in matches {
248            let url = self.site_url(&format!("rest/user/{}", user.id));
249            let _: Vec<serde_json::Value> = self
250                .put(
251                    url,
252                    &json!({
253                        "use_fixedip": false,
254                    }),
255                )
256                .await?;
257        }
258        Ok(())
259    }
260}