1pub mod challenges;
2pub mod ctf;
3pub mod machines;
4pub mod search;
5pub mod seasons;
6pub mod sherlocks;
7pub mod user;
8pub mod vpn;
9
10pub fn encode_path(s: &str) -> String {
11 s.bytes()
12 .flat_map(|b| {
13 if b.is_ascii_alphanumeric() || b == b'-' || b == b'_' || b == b'.' || b == b'~' {
14 vec![b as char]
15 } else {
16 format!("%{b:02X}").chars().collect()
17 }
18 })
19 .collect()
20}
21
22use std::sync::atomic::{AtomicU32, Ordering};
23use std::sync::Arc;
24use std::time::Duration;
25
26use reqwest::header::HeaderMap;
27use serde::de::DeserializeOwned;
28use serde::Serialize;
29
30use crate::cache::Cache;
31use crate::error::{ApiErrorBody, HtbError};
32
33fn extract_error_message(body: &str, status: u16) -> String {
34 if let Ok(e) = serde_json::from_str::<ApiErrorBody>(body) {
35 return e.message;
36 }
37
38 let trimmed = body.trim_start();
39 if trimmed.starts_with("<!") || trimmed.starts_with("<html") || trimmed.starts_with("<HTML") {
40 let lower = trimmed.to_lowercase();
41 if let Some(start) = lower.find("<title>") {
42 let after = &trimmed[start + 7..];
43 if let Some(end) = after.to_lowercase().find("</title>") {
44 let title = after[..end].trim();
45 if !title.is_empty() {
46 return title.to_string();
47 }
48 }
49 }
50 return format!("Server returned an error (HTTP {status})");
51 }
52
53 body.to_string()
54}
55
56const BASE_URL: &str = "https://labs.hackthebox.com";
57const USER_AGENT: &str = concat!("htb-cli/", env!("CARGO_PKG_VERSION"));
58
59#[derive(Clone)]
60pub struct HtbClient {
61 http: reqwest::Client,
62 base_url: String,
63 token: String,
64 rate_limit: Arc<RateLimitState>,
65 cache: Option<Arc<Cache>>,
66}
67
68struct RateLimitState {
69 remaining: AtomicU32,
70 limit: AtomicU32,
71}
72
73impl RateLimitState {
74 fn new() -> Self {
75 Self {
76 remaining: AtomicU32::new(u32::MAX),
77 limit: AtomicU32::new(u32::MAX),
78 }
79 }
80
81 fn update(&self, headers: &HeaderMap) {
82 if let Some(limit) = headers
83 .get("x-ratelimit-limit")
84 .and_then(|v| v.to_str().ok())
85 .and_then(|v| v.parse::<u32>().ok())
86 {
87 self.limit.store(limit, Ordering::Relaxed);
88 }
89
90 if let Some(remaining) = headers
91 .get("x-ratelimit-remaining")
92 .and_then(|v| v.to_str().ok())
93 .and_then(|v| v.parse::<u32>().ok())
94 {
95 self.remaining.store(remaining, Ordering::Relaxed);
96 }
97 }
98
99 fn remaining(&self) -> u32 {
100 self.remaining.load(Ordering::Relaxed)
101 }
102
103 fn limit(&self) -> u32 {
104 self.limit.load(Ordering::Relaxed)
105 }
106}
107
108impl HtbClient {
109 pub fn new(token: String) -> Self {
110 Self::build(token, BASE_URL.to_string(), None)
111 }
112
113 pub fn with_cache_arc(token: String, cache: Arc<Cache>) -> Self {
114 Self::build(token, BASE_URL.to_string(), Some(cache))
115 }
116
117 pub fn with_base_url(token: String, base_url: String) -> Self {
118 Self::build(token, base_url, None)
119 }
120
121 pub fn with_base_url_and_cache(token: String, base_url: String, cache: Arc<Cache>) -> Self {
122 Self::build(token, base_url, Some(cache))
123 }
124
125 fn build(token: String, base_url: String, cache: Option<Arc<Cache>>) -> Self {
126 let http = reqwest::Client::builder()
127 .user_agent(USER_AGENT)
128 .timeout(Duration::from_secs(30))
129 .build()
130 .expect("failed to build HTTP client");
131
132 Self {
133 http,
134 base_url,
135 token,
136 rate_limit: Arc::new(RateLimitState::new()),
137 cache,
138 }
139 }
140
141 pub async fn get<T: DeserializeOwned>(&self, path: &str) -> Result<T, HtbError> {
142 let url = format!("{}{}", self.base_url, path);
143 let ttl = self.ttl_for_path(path);
144
145 if let Some(max_age) = ttl {
146 if let Some(cache) = &self.cache {
147 if let Some(body) = cache.get(&url, max_age) {
148 match serde_json::from_str(&body) {
149 Ok(parsed) => return Ok(parsed),
150 Err(e) => {
151 tracing::debug!(
152 "cached response failed to deserialize, refetching: {e}"
153 );
154 }
155 }
156 }
157 }
158 }
159
160 self.wait_for_rate_limit().await;
161 tracing::debug!(url = %url, "GET");
162 let resp = self.http.get(&url).bearer_auth(&self.token).send().await?;
163 let body = self.handle_response_raw(resp).await?;
164
165 if let Some(max_age) = ttl {
166 if max_age > Duration::ZERO {
167 if let Some(cache) = &self.cache {
168 cache.set(&url, &body);
169 }
170 }
171 }
172
173 Ok(serde_json::from_str(&body)?)
174 }
175
176 pub async fn post<B: Serialize, T: DeserializeOwned>(
177 &self,
178 path: &str,
179 body: &B,
180 ) -> Result<T, HtbError> {
181 self.wait_for_rate_limit().await;
182
183 let url = format!("{}{}", self.base_url, path);
184 tracing::debug!(url = %url, "POST");
185
186 let resp = self
187 .http
188 .post(&url)
189 .bearer_auth(&self.token)
190 .json(body)
191 .send()
192 .await?;
193
194 let result = self.handle_response(resp).await?;
195 self.invalidate_after_post(path);
196 Ok(result)
197 }
198
199 pub async fn post_no_content<B: Serialize>(
200 &self,
201 path: &str,
202 body: &B,
203 ) -> Result<(), HtbError> {
204 self.wait_for_rate_limit().await;
205
206 let url = format!("{}{}", self.base_url, path);
207 tracing::debug!(url = %url, "POST (no content)");
208
209 let resp = self
210 .http
211 .post(&url)
212 .bearer_auth(&self.token)
213 .json(body)
214 .send()
215 .await?;
216
217 self.rate_limit.update(resp.headers());
218 self.log_rate_limit();
219
220 let status = resp.status();
221 if status == 401 {
222 return Err(HtbError::NotAuthenticated);
223 }
224 if status == 429 {
225 return Err(HtbError::RateLimited);
226 }
227 if !status.is_success() {
228 let body = resp.text().await.unwrap_or_default();
229 return Err(HtbError::Api {
230 status: status.as_u16(),
231 message: extract_error_message(&body, status.as_u16()),
232 });
233 }
234
235 self.invalidate_after_post(path);
236 Ok(())
237 }
238
239 fn invalidate_after_post(&self, path: &str) {
240 let Some(cache) = &self.cache else { return };
241 if path.contains("/vm/spawn")
242 || path.contains("/vm/terminate")
243 || path.contains("/vm/reset")
244 || path.contains("/machine/own")
245 || path.contains("/machine/todo")
246 {
247 cache.invalidate_pattern("api_v4_machine");
248 cache.invalidate_pattern("api_v5_machine");
249 }
250 if path.contains("/container/start")
251 || path.contains("/container/stop")
252 || path.contains("/challenge/own")
253 {
254 cache.invalidate_pattern("api_v4_challenge");
255 }
256 if path.contains("/sherlocks/") && path.contains("/flag") {
257 cache.invalidate_pattern("api_v4_sherlock");
258 }
259 if path.contains("/flags/own")
261 || path.contains("/challenges/containers/start")
262 || path.contains("/challenges/containers/stop")
263 || path.contains("/progress")
264 {
265 cache.invalidate_pattern("ctf.hackthebox.com_api_ctfs_");
266 cache.invalidate_pattern("ctf.hackthebox.com_api_challenges_");
267 }
268 }
269
270 pub async fn get_bytes(&self, url_or_path: &str) -> Result<Vec<u8>, HtbError> {
271 self.wait_for_rate_limit().await;
272
273 let is_absolute = url_or_path.starts_with("http://") || url_or_path.starts_with("https://");
274 let url = if is_absolute {
275 url_or_path.to_string()
276 } else {
277 format!("{}{}", self.base_url, url_or_path)
278 };
279 tracing::debug!(url = %url, "GET (bytes)");
280
281 let same_origin = url.starts_with(&format!("{}/", self.base_url));
282 let req = self.http.get(&url);
283 let req = if same_origin {
284 req.bearer_auth(&self.token)
285 } else {
286 req
287 };
288 let resp = req.send().await?;
289
290 self.rate_limit.update(resp.headers());
291 self.log_rate_limit();
292
293 let status = resp.status();
294 if status == 401 {
295 return Err(HtbError::NotAuthenticated);
296 }
297 if status == 429 {
298 return Err(HtbError::RateLimited);
299 }
300 if !status.is_success() {
301 let body = resp.text().await.unwrap_or_default();
302 return Err(HtbError::Api {
303 status: status.as_u16(),
304 message: extract_error_message(&body, status.as_u16()),
305 });
306 }
307
308 Ok(resp.bytes().await?.to_vec())
309 }
310
311 async fn handle_response<T: DeserializeOwned>(
312 &self,
313 resp: reqwest::Response,
314 ) -> Result<T, HtbError> {
315 let body = self.handle_response_raw(resp).await?;
316 Ok(serde_json::from_str(&body)?)
317 }
318
319 async fn handle_response_raw(&self, resp: reqwest::Response) -> Result<String, HtbError> {
320 self.rate_limit.update(resp.headers());
321 self.log_rate_limit();
322
323 let status = resp.status();
324
325 if status == 401 {
326 return Err(HtbError::NotAuthenticated);
327 }
328
329 if status == 429 {
330 return Err(HtbError::RateLimited);
331 }
332
333 if !status.is_success() {
334 let body = resp.text().await.unwrap_or_default();
335 return Err(HtbError::Api {
336 status: status.as_u16(),
337 message: extract_error_message(&body, status.as_u16()),
338 });
339 }
340
341 Ok(resp.text().await?)
342 }
343
344 async fn wait_for_rate_limit(&self) {
345 let remaining = self.rate_limit.remaining();
346 if remaining == 0 && self.rate_limit.limit() != u32::MAX {
347 tracing::warn!("Rate limit exhausted, waiting 5s before next request");
350 tokio::time::sleep(Duration::from_secs(5)).await;
351 }
352 }
353
354 fn log_rate_limit(&self) {
355 let remaining = self.rate_limit.remaining();
356 let limit = self.rate_limit.limit();
357 if limit != u32::MAX {
358 tracing::debug!(remaining, limit, "rate limit");
359 }
360 }
361
362 pub fn ctf(&self) -> ctf::CtfApi<'_> {
363 ctf::CtfApi(self)
364 }
365
366 pub fn user(&self) -> user::UserApi<'_> {
367 user::UserApi(self)
368 }
369
370 pub fn machines(&self) -> machines::MachineApi<'_> {
371 machines::MachineApi(self)
372 }
373
374 pub fn challenges(&self) -> challenges::ChallengeApi<'_> {
375 challenges::ChallengeApi(self)
376 }
377
378 pub fn sherlocks(&self) -> sherlocks::SherlockApi<'_> {
379 sherlocks::SherlockApi(self)
380 }
381
382 pub fn seasons(&self) -> seasons::SeasonApi<'_> {
383 seasons::SeasonApi(self)
384 }
385
386 pub fn vpn(&self) -> vpn::VpnApi<'_> {
387 vpn::VpnApi(self)
388 }
389
390 pub fn search(&self) -> search::SearchApi<'_> {
391 search::SearchApi(self)
392 }
393
394 fn ttl_for_path(&self, path: &str) -> Option<Duration> {
395 if path.contains("/download") {
396 return None;
397 }
398
399 let is_ctf = self.base_url.contains("ctf.hackthebox.com");
400 if is_ctf {
401 return self.ttl_for_ctf_path(path);
402 }
403
404 if path.contains("/categories/list")
406 || path.contains("/season/list")
407 || path.contains("/tags/list")
408 {
409 return Some(Duration::from_secs(1800));
410 }
411 if path.starts_with("/api/v5/machines")
413 || path.starts_with("/api/v4/challenges?")
414 || path.starts_with("/api/v4/sherlocks?")
415 {
416 return Some(Duration::from_secs(300));
417 }
418 if path.contains("/challenge/info/")
420 || path.contains("/machine/profile/")
421 || path.contains("/sherlocks/")
422 {
423 return Some(Duration::from_secs(3600));
424 }
425 if path.contains("/user/info") || path.contains("/user/profile/") {
427 return Some(Duration::from_secs(120));
428 }
429 None
430 }
431
432 fn ttl_for_ctf_path(&self, path: &str) -> Option<Duration> {
433 if path.contains("/associate/") || path.contains("/disassociate/") {
435 return None;
436 }
437 if path.starts_with("/api/public/challenge-categories") {
439 return Some(Duration::from_secs(1800));
440 }
441 if path == "/api/ctfs" || path.starts_with("/api/ctfs/details/") {
443 return Some(Duration::from_secs(300));
444 }
445 if path.starts_with("/api/users/profile") {
447 return Some(Duration::from_secs(120));
448 }
449 if path.starts_with("/api/ctfs/scores/")
451 || path.starts_with("/api/ctfs/solves/")
452 || path.starts_with("/api/ctfs/score-charts/")
453 || path.starts_with("/api/challenges/")
454 {
455 return Some(Duration::from_secs(30));
456 }
457 if path.starts_with("/api/ctfs/") {
459 return Some(Duration::from_secs(30));
460 }
461 None
462 }
463}
464
465#[cfg(test)]
466mod tests {
467 use super::*;
468
469 #[test]
470 fn rate_limit_state_parses_headers() {
471 let state = RateLimitState::new();
472 let mut headers = HeaderMap::new();
473 headers.insert("x-ratelimit-limit", "25".parse().unwrap());
474 headers.insert("x-ratelimit-remaining", "14".parse().unwrap());
475
476 state.update(&headers);
477 assert_eq!(state.limit(), 25);
478 assert_eq!(state.remaining(), 14);
479 }
480
481 #[test]
482 fn rate_limit_state_ignores_missing_headers() {
483 let state = RateLimitState::new();
484 let headers = HeaderMap::new();
485
486 state.update(&headers);
487 assert_eq!(state.limit(), u32::MAX);
488 assert_eq!(state.remaining(), u32::MAX);
489 }
490
491 #[test]
492 fn rate_limit_state_ignores_garbage() {
493 let state = RateLimitState::new();
494 let mut headers = HeaderMap::new();
495 headers.insert("x-ratelimit-limit", "not-a-number".parse().unwrap());
496 headers.insert("x-ratelimit-remaining", "".parse().unwrap());
497
498 state.update(&headers);
499 assert_eq!(state.limit(), u32::MAX);
500 assert_eq!(state.remaining(), u32::MAX);
501 }
502
503 #[test]
504 fn extract_error_message_json() {
505 let body = r#"{"message":"Incorrect flag"}"#;
506 assert_eq!(extract_error_message(body, 403), "Incorrect flag");
507 }
508
509 #[test]
510 fn extract_error_message_html_with_title() {
511 let body = r#"<!DOCTYPE html>
512<html><head><title>403 Forbidden</title></head>
513<body><h1>403 Forbidden</h1></body></html>"#;
514 assert_eq!(extract_error_message(body, 403), "403 Forbidden");
515 }
516
517 #[test]
518 fn extract_error_message_html_no_title() {
519 let body = "<!DOCTYPE html><html><body>error</body></html>";
520 assert_eq!(
521 extract_error_message(body, 403),
522 "Server returned an error (HTTP 403)"
523 );
524 }
525
526 #[test]
527 fn extract_error_message_plain_text() {
528 let body = "something went wrong";
529 assert_eq!(extract_error_message(body, 500), "something went wrong");
530 }
531}