1use std::sync::Arc;
2use std::time::Duration;
3
4use clap::Subcommand;
5
6use crate::api::HtbClient;
7use crate::cache::Cache;
8use crate::output::OutputFormat;
9
10const CTF_BASE_URL: &str = "https://ctf.hackthebox.com";
11
12#[derive(Subcommand)]
13#[command(
14 after_help = "Workflow:\n 1. htb ctf auth login Authenticate (separate token from labs)\n 2. htb ctf events Find an event\n 3. htb ctf challenges 1434 Browse challenges\n 4. htb ctf start 1434 31855 Spin up the container\n 5. htb ctf download 1434 31855 Grab challenge files\n ... hack ...\n 6. htb ctf submit 31855 'HTB{flag}' Submit your flag\n 7. htb ctf stop 1434 31855 Clean up the container\n\nOther:\n htb ctf info ctf-try-out-1434 Event details (by slug)\n htb ctf scoreboard 1434 Team rankings\n htb ctf solves 1434 Recent solves feed\n htb ctf challenge-solves 31855 Who solved a challenge"
15)]
16pub enum CtfCommand {
17 Auth {
19 #[command(subcommand)]
20 command: CtfAuthCommand,
21 },
22 Events {
24 #[arg(long, help = "Include past events")]
25 all: bool,
26 },
27 Info {
29 slug: String,
31 },
32 Challenges {
34 event_id: u64,
36 #[arg(long, help = "Filter by difficulty")]
37 difficulty: Option<String>,
38 },
39 Submit {
41 challenge_id: u64,
43 flag: String,
45 },
46 Download {
48 event_id: u64,
50 challenge_id: u64,
52 },
53 Start {
55 event_id: u64,
57 challenge_id: u64,
59 },
60 Stop {
62 event_id: u64,
64 challenge_id: u64,
66 },
67 Scoreboard {
69 event_id: u64,
71 },
72 Solves {
74 event_id: u64,
76 },
77 ChallengeSolves {
79 challenge_id: u64,
81 },
82}
83
84#[derive(Subcommand)]
85pub enum CtfAuthCommand {
86 Login,
88 Status,
90 Logout,
92}
93
94pub async fn handle(
95 cmd: CtfCommand,
96 format: OutputFormat,
97 cache: &Arc<Cache>,
98) -> anyhow::Result<()> {
99 match cmd {
100 CtfCommand::Auth { command } => handle_auth(command, format, cache).await,
101 CtfCommand::Events { all } => events(all, format, cache).await,
102 CtfCommand::Info { slug } => info(&slug, format, cache).await,
103 CtfCommand::Challenges {
104 event_id,
105 difficulty,
106 } => challenges(event_id, difficulty.as_deref(), format, cache).await,
107 CtfCommand::Submit { challenge_id, flag } => submit(challenge_id, &flag, cache).await,
108 CtfCommand::Download {
109 event_id,
110 challenge_id,
111 } => download(event_id, challenge_id, cache).await,
112 CtfCommand::Start {
113 event_id,
114 challenge_id,
115 } => start(event_id, challenge_id, cache).await,
116 CtfCommand::Stop {
117 event_id,
118 challenge_id,
119 } => stop(event_id, challenge_id, cache).await,
120 CtfCommand::Scoreboard { event_id } => scoreboard(event_id, format, cache).await,
121 CtfCommand::Solves { event_id } => solves(event_id, format, cache).await,
122 CtfCommand::ChallengeSolves { challenge_id } => {
123 challenge_solves(challenge_id, format, cache).await
124 }
125 }
126}
127
128fn ctf_client(cache: &Arc<Cache>) -> anyhow::Result<HtbClient> {
129 let token = crate::config::read_ctf_token()?;
130 Ok(HtbClient::with_base_url_and_cache(
131 token,
132 CTF_BASE_URL.to_string(),
133 Arc::clone(cache),
134 ))
135}
136
137async fn handle_auth(
138 cmd: CtfAuthCommand,
139 format: OutputFormat,
140 cache: &Arc<Cache>,
141) -> anyhow::Result<()> {
142 match cmd {
143 CtfAuthCommand::Login => login(cache).await,
144 CtfAuthCommand::Status => status(format).await,
145 CtfAuthCommand::Logout => logout(cache),
146 }
147}
148
149async fn login(cache: &Cache) -> anyhow::Result<()> {
150 println!("Enter your CTF API token (from https://ctf.hackthebox.com/settings):");
151 let token = rpassword::read_password()?;
152 let token = token.trim().to_string();
153
154 if token.is_empty() {
155 anyhow::bail!("Token cannot be empty");
156 }
157
158 let client = HtbClient::with_base_url(token.clone(), CTF_BASE_URL.to_string());
159 let user = client.ctf().profile().await?;
160
161 crate::config::save_ctf_token(&token)?;
162 cache.clear();
163 println!("Authenticated as {}", user.name);
164 Ok(())
165}
166
167async fn status(format: OutputFormat) -> anyhow::Result<()> {
168 let token = crate::config::read_ctf_token()?;
169 let client = HtbClient::with_base_url(token, CTF_BASE_URL.to_string());
170 let user = client.ctf().profile().await?;
171
172 let fields = vec![
173 ("Username", user.name.clone()),
174 ("ID", user.id.to_string()),
175 ("Email", user.email.clone().unwrap_or_default()),
176 ("Timezone", user.timezone.clone().unwrap_or_default()),
177 (
178 "Has Team",
179 if user.has_any_team { "Yes" } else { "No" }.into(),
180 ),
181 ];
182
183 crate::output::print_detail(&user, format, &fields);
184 Ok(())
185}
186
187fn logout(cache: &Cache) -> anyhow::Result<()> {
188 crate::config::remove_ctf_token()?;
189 cache.clear();
190 println!("CTF token removed.");
191 Ok(())
192}
193
194async fn events(all: bool, format: OutputFormat, cache: &Arc<Cache>) -> anyhow::Result<()> {
195 let client = ctf_client(cache)?;
196 let mut events = client.ctf().events().await?;
197
198 if !all {
199 events.retain(|e| {
200 e.status
201 .as_deref()
202 .is_some_and(|s| s == "Ongoing" || s == "Upcoming")
203 });
204 }
205
206 crate::output::print_list(&events, format);
207 Ok(())
208}
209
210async fn info(slug: &str, format: OutputFormat, cache: &Arc<Cache>) -> anyhow::Result<()> {
211 let client = ctf_client(cache)?;
212 let detail = client.ctf().event_details(slug).await?;
213
214 let fields = vec![
215 ("ID", detail.id.to_string()),
216 ("Name", detail.name.clone()),
217 ("Status", detail.status.clone().unwrap_or_default()),
218 ("Format", detail.format.clone().unwrap_or_default()),
219 ("Type", detail.event_type.clone().unwrap_or_default()),
220 ("Location", detail.location.clone().unwrap_or_default()),
221 ("Start", detail.start_date.clone().unwrap_or_default()),
222 ("End", detail.end_date.clone().unwrap_or_default()),
223 (
224 "Players",
225 detail
226 .players_joined
227 .map(|p| p.to_string())
228 .unwrap_or_default(),
229 ),
230 (
231 "Teams",
232 detail
233 .teams_joined
234 .map(|t| t.to_string())
235 .unwrap_or_default(),
236 ),
237 (
238 "Challenges",
239 detail.challenges.map(|c| c.to_string()).unwrap_or_default(),
240 ),
241 (
242 "Max Team Size",
243 detail
244 .max_team_size
245 .map(|m| m.to_string())
246 .unwrap_or_default(),
247 ),
248 (
249 "Prize Pool",
250 detail.prize_pool.clone().unwrap_or_else(|| "-".into()),
251 ),
252 ];
253
254 crate::output::print_detail(&detail, format, &fields);
255 Ok(())
256}
257
258async fn challenges(
259 event_id: u64,
260 difficulty: Option<&str>,
261 format: OutputFormat,
262 cache: &Arc<Cache>,
263) -> anyhow::Result<()> {
264 let client = ctf_client(cache)?;
265 let data = client.ctf().event_data(event_id).await?;
266
267 let mut challenges = data.challenges;
268
269 if let Some(diff_filter) = difficulty {
270 challenges.retain(|c| {
271 c.difficulty
272 .as_deref()
273 .is_some_and(|d| d.eq_ignore_ascii_case(diff_filter))
274 });
275 }
276
277 if format != OutputFormat::Json {
278 if let Some(team) = &data.participating_team {
279 crate::output::print_message(&format!(
280 "Team: {} | Rank: {} | Solved: {}/{} | Points: {}",
281 team.name,
282 team.rank
283 .map(|r| r.to_string())
284 .unwrap_or_else(|| "-".into()),
285 team.solved_challenges.unwrap_or(0),
286 team.total_challenges.unwrap_or(0),
287 team.points.unwrap_or(0),
288 ));
289 }
290 }
291
292 crate::output::print_list(&challenges, format);
293 Ok(())
294}
295
296async fn submit(challenge_id: u64, flag: &str, cache: &Arc<Cache>) -> anyhow::Result<()> {
297 let client = ctf_client(cache)?;
298 let result = client.ctf().submit_flag(challenge_id, flag).await?;
299 if let Some(points) = result.points {
300 crate::output::print_message(&format!("{} (+{} points)", result.message, points));
301 } else {
302 crate::output::print_message(&result.message);
303 }
304 Ok(())
305}
306
307async fn download(event_id: u64, challenge_id: u64, cache: &Arc<Cache>) -> anyhow::Result<()> {
308 let client = ctf_client(cache)?;
309
310 let data = client.ctf().event_data(event_id).await?;
311 let challenge = data
312 .challenges
313 .iter()
314 .find(|c| c.id == challenge_id)
315 .ok_or_else(|| anyhow::anyhow!("Challenge {challenge_id} not found in event {event_id}"))?;
316
317 let filename = match &challenge.filename {
318 Some(f) => f.clone(),
319 None => anyhow::bail!("No files available for this challenge."),
320 };
321
322 let bytes = client.ctf().download_file(challenge_id).await?;
323 let safe_name = crate::sanitize_filename(&filename, &format!("{challenge_id}.zip"));
324 std::fs::write(&safe_name, &bytes)?;
325 crate::output::print_message(&format!("Downloaded {} ({} bytes)", safe_name, bytes.len()));
326 Ok(())
327}
328
329async fn start(event_id: u64, challenge_id: u64, cache: &Arc<Cache>) -> anyhow::Result<()> {
330 let client = ctf_client(cache)?;
331
332 let data = client.ctf().event_data(event_id).await?;
333 let challenge = data
334 .challenges
335 .iter()
336 .find(|c| c.id == challenge_id)
337 .ok_or_else(|| anyhow::anyhow!("Challenge {challenge_id} not found in event {event_id}"))?;
338
339 if challenge.has_docker.unwrap_or(0) == 0 {
340 anyhow::bail!("This challenge doesn't use a container.");
341 }
342
343 let resp = client.ctf().container_start(challenge_id).await?;
344 crate::output::print_message(&resp.message);
345
346 for _ in 0..15 {
348 tokio::time::sleep(Duration::from_secs(2)).await;
349 let poll = client.ctf().event_data(event_id).await?;
350 if let Some(c) = poll.challenges.iter().find(|c| c.id == challenge_id) {
351 if c.docker_online.unwrap_or(0) > 0 {
352 let host = c.hostname.as_deref().unwrap_or("unknown");
353 let ports = c.docker_ports.as_deref().unwrap_or(&[]);
354 if ports.is_empty() {
355 crate::output::print_message(&format!("Ready: {host}"));
356 } else {
357 let port_str: Vec<_> = ports.iter().map(|p| p.to_string()).collect();
358 crate::output::print_message(&format!("Ready: {host}:{}", port_str.join(",")));
359 }
360 return Ok(());
361 }
362 }
363 }
364
365 crate::output::print_message("Container started but not ready yet. Check back shortly.");
366 Ok(())
367}
368
369async fn stop(event_id: u64, challenge_id: u64, cache: &Arc<Cache>) -> anyhow::Result<()> {
370 let client = ctf_client(cache)?;
371
372 let data = client.ctf().event_data(event_id).await?;
373 let challenge = data
374 .challenges
375 .iter()
376 .find(|c| c.id == challenge_id)
377 .ok_or_else(|| anyhow::anyhow!("Challenge {challenge_id} not found in event {event_id}"))?;
378
379 if challenge.has_docker.unwrap_or(0) == 0 {
380 anyhow::bail!("This challenge doesn't use a container.");
381 }
382
383 let resp = client.ctf().container_stop(challenge_id).await?;
384 crate::output::print_message(&resp.message);
385 Ok(())
386}
387
388async fn scoreboard(event_id: u64, format: OutputFormat, cache: &Arc<Cache>) -> anyhow::Result<()> {
389 let client = ctf_client(cache)?;
390
391 let menu = client.ctf().menu(event_id).await?;
392 if menu.user_can_view_scoreboard == Some(0) {
393 anyhow::bail!("Scoreboard is hidden for this event.");
394 }
395
396 let sb = client.ctf().scoreboard(event_id).await?;
397
398 if format != OutputFormat::Json {
399 if let Some(team) = &sb.participating_team {
400 crate::output::print_message(&format!(
401 "Your team: {} | Rank: {} | Points: {} | Flags: {} | Bloods: {}",
402 team.name,
403 team.position
404 .map(|p| p.to_string())
405 .unwrap_or_else(|| "-".into()),
406 team.points.unwrap_or(0),
407 team.owned_flags.unwrap_or(0),
408 team.first_bloods.unwrap_or(0),
409 ));
410 }
411 }
412
413 let scores: Vec<_> = sb
415 .scores
416 .iter()
417 .enumerate()
418 .map(|(i, s)| RankedScore {
419 rank: i as u32 + 1,
420 score: s,
421 })
422 .collect();
423
424 crate::output::print_list(&scores, format);
425 Ok(())
426}
427
428struct RankedScore<'a> {
429 rank: u32,
430 score: &'a crate::models::ctf::CtfTeamScore,
431}
432
433impl serde::Serialize for RankedScore<'_> {
434 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
435 self.score.serialize(serializer)
436 }
437}
438
439impl crate::output::Tabular for RankedScore<'_> {
440 fn headers() -> Vec<&'static str> {
441 vec!["#", "Team", "Country", "Points", "Flags", "Bloods"]
442 }
443
444 fn row(&self) -> Vec<String> {
445 vec![
446 self.rank.to_string(),
447 self.score.name.clone(),
448 self.score.country_code.clone().unwrap_or_default(),
449 self.score.points.map(|p| p.to_string()).unwrap_or_default(),
450 self.score
451 .owned_flags
452 .map(|f| f.to_string())
453 .unwrap_or_default(),
454 self.score
455 .first_bloods
456 .map(|b| b.to_string())
457 .unwrap_or_default(),
458 ]
459 }
460}
461
462async fn solves(event_id: u64, format: OutputFormat, cache: &Arc<Cache>) -> anyhow::Result<()> {
463 let client = ctf_client(cache)?;
464 let solves = client.ctf().solves(event_id).await?;
465 crate::output::print_list(&solves, format);
466 Ok(())
467}
468
469async fn challenge_solves(
470 challenge_id: u64,
471 format: OutputFormat,
472 cache: &Arc<Cache>,
473) -> anyhow::Result<()> {
474 let client = ctf_client(cache)?;
475 let solves = client.ctf().challenge_solves(challenge_id).await?;
476 crate::output::print_list(&solves, format);
477 Ok(())
478}