Skip to main content

bitgateway_client/
client.rs

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(&params)
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(&params)
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(&timestamp, &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(&params)
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(&params)
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}