1use std::net::IpAddr;
2use std::time::{SystemTime, UNIX_EPOCH};
3
4use anyhow::{Context, Result, bail};
5use bon::Builder;
6use reqwest::Client as HttpClient;
7use tokio::sync::OnceCell;
8
9use crate::config::Config;
10use crate::crypto::{SRUN_N, SRUN_TYPE, checksum, dm_logout_sign, hmd5, login_info};
11use crate::models::{Challenge, LoginState, PortalResponse};
12
13const JSONP_CALLBACK: &str = "jsonp";
14const LOGIN_ACTION: &str = "login";
15const LOGOUT_ACTION: &str = "logout";
16
17#[derive(Debug, Builder)]
18pub struct Client {
19 #[builder(default = HttpClient::new())]
20 http_client: HttpClient,
21 #[builder(default)]
22 config: Config,
23 #[builder(skip = OnceCell::const_new())]
24 ac_id: OnceCell<String>,
25}
26
27impl Default for Client {
28 fn default() -> Self {
29 Self::new()
30 }
31}
32
33impl Client {
34 #[must_use]
35 pub fn new() -> Self {
36 Self::builder().build()
37 }
38
39 pub async fn get_login_state(&self) -> Result<LoginState> {
40 let params = [("callback", JSONP_CALLBACK)];
41 let raw_text = self
42 .http_client
43 .get(self.endpoint("/cgi-bin/rad_user_info"))
44 .query(¶ms)
45 .send()
46 .await
47 .context("failed to get login state")?
48 .text()
49 .await
50 .context("failed to read login state response")?;
51
52 let raw_json = jsonp_json(&raw_text, "login status")?;
53
54 serde_json::from_str::<LoginState>(raw_json)
55 .with_context(|| format!("failed to parse login status response: {raw_json}"))
56 }
57
58 pub async fn login(&self, username: &str, password: &str) -> Result<PortalResponse> {
59 self._login(username, password, false).await
60 }
61
62 pub async fn force_login(&self, username: &str, password: &str) -> Result<PortalResponse> {
63 self._login(username, password, true).await
64 }
65
66 pub async fn logout(&self, username: &str) -> Result<PortalResponse> {
67 self._logout(username, false).await
68 }
69
70 pub async fn force_logout(&self, username: &str) -> Result<PortalResponse> {
71 self._logout(username, true).await
72 }
73
74 async fn _login(&self, username: &str, password: &str, force: bool) -> Result<PortalResponse> {
75 let login_state = self.get_login_state().await?;
76 if login_state.error == "ok" && !force {
77 bail!("{} already logged in", login_state.online_ip);
78 }
79
80 let ip = self.request_ip(&login_state);
81 let ip_string = ip.to_string();
82 let ac_id = self.ac_id().await?.to_string();
83 let token = self.challenge(username, ip).await?;
84 let info = login_info(username, password, &ip_string, &ac_id, &token)?;
85 let hmd5 = hmd5(&token)?;
86 let chksum = checksum(&token, username, &hmd5, &ac_id, &ip_string, &info);
87 let encoded_password = format!("{}{}", "{MD5}", hmd5);
88 let params = [
89 ("callback", JSONP_CALLBACK),
90 ("action", LOGIN_ACTION),
91 ("username", username),
92 ("password", encoded_password.as_str()),
93 ("chksum", chksum.as_str()),
94 ("info", info.as_str()),
95 ("ac_id", ac_id.as_str()),
96 ("ip", ip_string.as_str()),
97 ("type", SRUN_TYPE),
98 ("n", SRUN_N),
99 ];
100 let raw_text = self
101 .http_client
102 .get(self.endpoint("/cgi-bin/srun_portal"))
103 .query(¶ms)
104 .send()
105 .await
106 .context("failed to send login request")?
107 .text()
108 .await
109 .context("failed to read login response")?;
110
111 let raw_json = jsonp_json(&raw_text, "login")?;
112
113 serde_json::from_str::<PortalResponse>(raw_json)
114 .with_context(|| format!("failed to parse login response: {raw_json}"))
115 }
116
117 async fn _logout(&self, username: &str, force: bool) -> Result<PortalResponse> {
118 let login_state = self.get_login_state().await?;
119 if login_state.error == "not_online_error" && !force {
120 bail!("{} already logged out", login_state.online_ip);
121 }
122
123 let logged_in_username = login_state
124 .user_name
125 .clone()
126 .unwrap_or_else(|| username.to_string());
127 let ip = self.request_ip(&login_state);
128 let ip_string = ip.to_string();
129 let mut params = vec![
130 ("callback", JSONP_CALLBACK.to_string()),
131 ("ip", ip_string.clone()),
132 ("username", logged_in_username.clone()),
133 ];
134 let endpoint = if self.config.dumb_terminal() {
135 let timestamp = current_timestamp();
136 let unbind = "1".to_string();
137 let sign = dm_logout_sign(×tamp, &logged_in_username, &ip_string, &unbind);
138
139 params.push(("time", timestamp));
140 params.push(("unbind", unbind));
141 params.push(("sign", sign));
142
143 "/cgi-bin/rad_user_dm"
144 } else {
145 params.push(("action", LOGOUT_ACTION.to_string()));
146 params.push(("ac_id", self.ac_id().await?.to_string()));
147
148 "/cgi-bin/srun_portal"
149 };
150 let raw_text = self
151 .http_client
152 .get(self.endpoint(endpoint))
153 .query(¶ms)
154 .send()
155 .await
156 .context("failed to send logout request")?
157 .text()
158 .await
159 .context("failed to read logout response")?;
160
161 let raw_json = jsonp_json(&raw_text, "logout")?;
162
163 serde_json::from_str::<PortalResponse>(raw_json)
164 .with_context(|| format!("failed to parse logout response: {raw_json}"))
165 }
166
167 async fn ac_id(&self) -> Result<&str> {
168 let ac_id = self
169 .ac_id
170 .get_or_try_init(|| async { self.discover_ac_id().await })
171 .await?;
172
173 Ok(ac_id)
174 }
175
176 async fn discover_ac_id(&self) -> Result<String> {
177 match self.ac_id_by_url(self.config.captive_portal_url()).await {
178 Ok(ac_id) => Ok(ac_id),
179 Err(_) => self.ac_id_by_url(self.config.portal_url()).await,
180 }
181 }
182
183 async fn ac_id_by_url(&self, url: &str) -> Result<String> {
184 let response = self
185 .http_client
186 .get(url)
187 .send()
188 .await
189 .with_context(|| format!("failed to get ac_id from `{url}`"))?;
190
191 let redirect_url = response.url();
192 let Some((_, ac_id)) = redirect_url.query_pairs().find(|(key, _)| key == "ac_id") else {
193 bail!("failed to get ac_id from `{redirect_url}`");
194 };
195
196 Ok(ac_id.into_owned())
197 }
198
199 async fn challenge(&self, username: &str, ip: IpAddr) -> Result<String> {
200 let ip_string = ip.to_string();
201 let params = [
202 ("callback", JSONP_CALLBACK),
203 ("username", username),
204 ("ip", ip_string.as_str()),
205 ];
206 let raw_text = self
207 .http_client
208 .get(self.endpoint("/cgi-bin/get_challenge"))
209 .query(¶ms)
210 .send()
211 .await
212 .context("failed to get challenge")?
213 .text()
214 .await
215 .context("failed to read challenge response")?;
216
217 let raw_json = jsonp_json(&raw_text, "challenge")?;
218 let challenge = serde_json::from_str::<Challenge>(raw_json)
219 .with_context(|| format!("failed to parse challenge response: {raw_json}"))?;
220
221 Ok(challenge.challenge)
222 }
223
224 fn endpoint(&self, path: &str) -> String {
225 format!("{}{}", self.config.portal_url().trim_end_matches('/'), path)
226 }
227
228 fn request_ip(&self, login_state: &LoginState) -> IpAddr {
229 self.config.ip().unwrap_or(login_state.online_ip)
230 }
231}
232
233fn jsonp_json<'a>(raw_text: &'a str, label: &str) -> Result<&'a str> {
234 let Some(raw_json) = raw_text
235 .strip_prefix("jsonp(")
236 .and_then(|text| text.strip_suffix(')'))
237 else {
238 bail!("{label} response is not valid jsonp: `{raw_text}`");
239 };
240
241 Ok(raw_json)
242}
243
244fn current_timestamp() -> String {
245 SystemTime::now()
246 .duration_since(UNIX_EPOCH)
247 .unwrap()
248 .as_secs()
249 .to_string()
250}