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