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 associate 31855 Assign yourself to challenge\n 6. htb ctf progress 31855 Mark as in progress\n 7. htb ctf start 31855 Spin up the container\n 8. htb ctf download 31855 Grab challenge files\n ... hack ...\n 9. htb ctf submit 31855 'HTB{flag}' Submit your flag\n 10. htb ctf stop 31855 Clean up the container\n\nTeam coordination:\n htb ctf team List team members\n htb ctf associate 31855 Assign yourself\n htb ctf associate 31855 -u 693962 Assign a teammate\n htb ctf disassociate 31855 Unassign yourself\n htb ctf progress 31855 Mark in progress\n htb ctf progress 31855 --clear Clear progress\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 Team {
95 event_id: Option<u64>,
97 },
98 Associate {
100 challenge_id: u64,
102 #[arg(short, long)]
104 user: Option<u64>,
105 },
106 Disassociate {
108 challenge_id: u64,
110 #[arg(short, long)]
112 user: Option<u64>,
113 },
114 Progress {
116 challenge_id: u64,
118 #[arg(value_enum, default_value = "in-progress")]
120 status: ProgressStatus,
121 #[arg(long)]
123 clear: bool,
124 },
125}
126
127#[derive(Clone, clap::ValueEnum)]
128pub enum ProgressStatus {
129 #[value(name = "in-progress")]
130 InProgress,
131 #[value(name = "need-help")]
132 NeedHelp,
133 #[value(name = "not-started")]
134 NotStarted,
135}
136
137impl ProgressStatus {
138 fn api_value(&self) -> &'static str {
139 match self {
140 Self::InProgress => "in progress",
141 Self::NeedHelp => "need help",
142 Self::NotStarted => "not started",
143 }
144 }
145}
146
147#[derive(Subcommand)]
148pub enum CtfAuthCommand {
149 Login,
151 Status,
153 Logout,
155}
156
157fn resolve_event_id(explicit: Option<u64>) -> anyhow::Result<u64> {
158 match explicit.or_else(crate::config::read_ctf_event) {
159 Some(id) => Ok(id),
160 None => anyhow::bail!(
161 "No event ID provided and no active event set. Run: htb ctf use <event_id>"
162 ),
163 }
164}
165
166pub async fn handle(
167 cmd: CtfCommand,
168 format: OutputFormat,
169 cache: &Arc<Cache>,
170) -> anyhow::Result<()> {
171 match cmd {
172 CtfCommand::Auth { command } => handle_auth(command, format, cache).await,
173 CtfCommand::Use { event_id, clear } => {
174 if clear {
175 crate::config::save_ctf_event(None)?;
176 crate::output::print_message("Active event cleared.");
177 Ok(())
178 } else if let Some(id) = event_id {
179 use_event(id, cache).await
180 } else {
181 match crate::config::read_ctf_event() {
182 Some(id) => {
183 crate::output::print_message(&format!("Active event: {id}"));
184 Ok(())
185 }
186 None => anyhow::bail!(
187 "No active event set. Usage: htb ctf use <event_id> or --clear"
188 ),
189 }
190 }
191 }
192 CtfCommand::Events { all } => events(all, format, cache).await,
193 CtfCommand::Info { slug } => info(&slug, format, cache).await,
194 CtfCommand::Challenges {
195 event_id,
196 difficulty,
197 } => {
198 let eid = resolve_event_id(event_id)?;
199 challenges(eid, difficulty.as_deref(), format, cache).await
200 }
201 CtfCommand::Submit { challenge_id, flag } => submit(challenge_id, &flag, cache).await,
202 CtfCommand::Download {
203 event_id,
204 challenge_id,
205 } => {
206 let eid = resolve_event_id(event_id)?;
207 download(eid, challenge_id, cache).await
208 }
209 CtfCommand::Start {
210 event_id,
211 challenge_id,
212 } => {
213 let eid = resolve_event_id(event_id)?;
214 start(eid, challenge_id, cache).await
215 }
216 CtfCommand::Stop {
217 event_id,
218 challenge_id,
219 } => {
220 let eid = resolve_event_id(event_id)?;
221 stop(eid, challenge_id, cache).await
222 }
223 CtfCommand::Scoreboard { event_id } => {
224 let eid = resolve_event_id(event_id)?;
225 scoreboard(eid, format, cache).await
226 }
227 CtfCommand::Solves { event_id } => {
228 let eid = resolve_event_id(event_id)?;
229 solves(eid, format, cache).await
230 }
231 CtfCommand::ChallengeSolves { challenge_id } => {
232 challenge_solves(challenge_id, format, cache).await
233 }
234 CtfCommand::Team { event_id } => {
235 let eid = resolve_event_id(event_id)?;
236 team(eid, format, cache).await
237 }
238 CtfCommand::Associate { challenge_id, user } => associate(challenge_id, user, cache).await,
239 CtfCommand::Disassociate { challenge_id, user } => {
240 disassociate(challenge_id, user, cache).await
241 }
242 CtfCommand::Progress {
243 challenge_id,
244 status,
245 clear,
246 } => progress(challenge_id, status, clear, cache).await,
247 }
248}
249
250fn ctf_client(cache: &Arc<Cache>) -> anyhow::Result<HtbClient> {
251 let token = crate::config::read_ctf_token()?;
252 Ok(HtbClient::with_base_url_and_cache(
253 token,
254 CTF_BASE_URL.to_string(),
255 Arc::clone(cache),
256 ))
257}
258
259async fn handle_auth(
260 cmd: CtfAuthCommand,
261 format: OutputFormat,
262 cache: &Arc<Cache>,
263) -> anyhow::Result<()> {
264 match cmd {
265 CtfAuthCommand::Login => login(cache).await,
266 CtfAuthCommand::Status => status(format).await,
267 CtfAuthCommand::Logout => logout(cache),
268 }
269}
270
271async fn use_event(event_id: u64, cache: &Arc<Cache>) -> anyhow::Result<()> {
272 let client = ctf_client(cache)?;
273 let events = client.ctf().events().await?;
274 let event = events
275 .iter()
276 .find(|e| e.id == event_id)
277 .ok_or_else(|| anyhow::anyhow!("Event {event_id} not found"))?;
278
279 crate::config::save_ctf_event(Some(event_id))?;
280 crate::output::print_message(&format!(
281 "Active event: {} ({})",
282 event.name,
283 event.status.as_deref().unwrap_or("unknown")
284 ));
285 Ok(())
286}
287
288async fn login(cache: &Cache) -> anyhow::Result<()> {
289 println!("Enter your CTF API token (from https://ctf.hackthebox.com/settings):");
290 let token = rpassword::read_password()?;
291 let token = token.trim().to_string();
292
293 if token.is_empty() {
294 anyhow::bail!("Token cannot be empty");
295 }
296
297 let client = HtbClient::with_base_url(token.clone(), CTF_BASE_URL.to_string());
298 let user = client.ctf().profile().await?;
299
300 crate::config::save_ctf_token(&token)?;
301 cache.clear();
302 println!("Authenticated as {}", user.name);
303 Ok(())
304}
305
306async fn status(format: OutputFormat) -> anyhow::Result<()> {
307 let token = crate::config::read_ctf_token()?;
308 let client = HtbClient::with_base_url(token, CTF_BASE_URL.to_string());
309 let user = client.ctf().profile().await?;
310
311 let fields = vec![
312 ("Username", user.name.clone()),
313 ("ID", user.id.to_string()),
314 ("Email", user.email.clone().unwrap_or_default()),
315 ("Timezone", user.timezone.clone().unwrap_or_default()),
316 (
317 "Has Team",
318 if user.has_any_team { "Yes" } else { "No" }.into(),
319 ),
320 ];
321
322 crate::output::print_detail(&user, format, &fields);
323 Ok(())
324}
325
326fn logout(cache: &Cache) -> anyhow::Result<()> {
327 crate::config::remove_ctf_token()?;
328 cache.clear();
329 println!("CTF token removed.");
330 Ok(())
331}
332
333async fn events(all: bool, format: OutputFormat, cache: &Arc<Cache>) -> anyhow::Result<()> {
334 let client = ctf_client(cache)?;
335 let mut events = client.ctf().events().await?;
336
337 if !all {
338 events.retain(|e| {
339 e.status
340 .as_deref()
341 .is_some_and(|s| s == "Ongoing" || s == "Upcoming")
342 });
343 }
344
345 crate::output::print_list(&events, format);
346 Ok(())
347}
348
349async fn info(slug: &str, format: OutputFormat, cache: &Arc<Cache>) -> anyhow::Result<()> {
350 let client = ctf_client(cache)?;
351 let detail = client.ctf().event_details(slug).await?;
352
353 let fields = vec![
354 ("ID", detail.id.to_string()),
355 ("Name", detail.name.clone()),
356 ("Status", detail.status.clone().unwrap_or_default()),
357 ("Format", detail.format.clone().unwrap_or_default()),
358 ("Type", detail.event_type.clone().unwrap_or_default()),
359 ("Location", detail.location.clone().unwrap_or_default()),
360 ("Start", detail.start_date.clone().unwrap_or_default()),
361 ("End", detail.end_date.clone().unwrap_or_default()),
362 (
363 "Players",
364 detail
365 .players_joined
366 .map(|p| p.to_string())
367 .unwrap_or_default(),
368 ),
369 (
370 "Teams",
371 detail
372 .teams_joined
373 .map(|t| t.to_string())
374 .unwrap_or_default(),
375 ),
376 (
377 "Challenges",
378 detail.challenges.map(|c| c.to_string()).unwrap_or_default(),
379 ),
380 (
381 "Max Team Size",
382 detail
383 .max_team_size
384 .map(|m| m.to_string())
385 .unwrap_or_default(),
386 ),
387 (
388 "Prize Pool",
389 detail.prize_pool.clone().unwrap_or_else(|| "-".into()),
390 ),
391 ];
392
393 crate::output::print_detail(&detail, format, &fields);
394 Ok(())
395}
396
397async fn challenges(
398 event_id: u64,
399 difficulty: Option<&str>,
400 format: OutputFormat,
401 cache: &Arc<Cache>,
402) -> anyhow::Result<()> {
403 let client = ctf_client(cache)?;
404 let data = client.ctf().event_data(event_id).await?;
405
406 let mut challenges = data.challenges;
407
408 if let Some(diff_filter) = difficulty {
409 challenges.retain(|c| {
410 c.difficulty
411 .as_deref()
412 .is_some_and(|d| d.eq_ignore_ascii_case(diff_filter))
413 });
414 }
415
416 if format != OutputFormat::Json {
417 if let Some(team) = &data.participating_team {
418 crate::output::print_message(&format!(
419 "Team: {} | Rank: {} | Solved: {}/{} | Points: {}",
420 team.name,
421 team.rank
422 .map(|r| r.to_string())
423 .unwrap_or_else(|| "-".into()),
424 team.solved_challenges.unwrap_or(0),
425 team.total_challenges.unwrap_or(0),
426 team.points.unwrap_or(0),
427 ));
428 }
429 }
430
431 crate::output::print_list(&challenges, format);
432 Ok(())
433}
434
435async fn submit(challenge_id: u64, flag: &str, cache: &Arc<Cache>) -> anyhow::Result<()> {
436 let client = ctf_client(cache)?;
437 let result = client.ctf().submit_flag(challenge_id, flag).await?;
438 if let Some(points) = result.points {
439 crate::output::print_message(&format!("{} (+{} points)", result.message, points));
440 } else {
441 crate::output::print_message(&result.message);
442 }
443 Ok(())
444}
445
446async fn download(event_id: u64, challenge_id: u64, cache: &Arc<Cache>) -> anyhow::Result<()> {
447 let client = ctf_client(cache)?;
448
449 let data = client.ctf().event_data(event_id).await?;
450 let challenge = data
451 .challenges
452 .iter()
453 .find(|c| c.id == challenge_id)
454 .ok_or_else(|| anyhow::anyhow!("Challenge {challenge_id} not found in event {event_id}"))?;
455
456 let filename = match &challenge.filename {
457 Some(f) => f.clone(),
458 None => anyhow::bail!("No files available for this challenge."),
459 };
460
461 let bytes = client.ctf().download_file(challenge_id).await?;
462 let safe_name = crate::sanitize_filename(&filename, &format!("{challenge_id}.zip"));
463 std::fs::write(&safe_name, &bytes)?;
464 crate::output::print_message(&format!("Downloaded {} ({} bytes)", safe_name, bytes.len()));
465 Ok(())
466}
467
468async fn start(event_id: u64, challenge_id: u64, cache: &Arc<Cache>) -> anyhow::Result<()> {
469 let client = ctf_client(cache)?;
470
471 let data = client.ctf().event_data(event_id).await?;
472 let challenge = data
473 .challenges
474 .iter()
475 .find(|c| c.id == challenge_id)
476 .ok_or_else(|| anyhow::anyhow!("Challenge {challenge_id} not found in event {event_id}"))?;
477
478 if challenge.has_docker.unwrap_or(0) == 0 {
479 anyhow::bail!("This challenge doesn't use a container.");
480 }
481
482 let resp = client.ctf().container_start(challenge_id).await?;
483 crate::output::print_message(&resp.message);
484
485 for _ in 0..15 {
487 tokio::time::sleep(Duration::from_secs(2)).await;
488 let poll = client.ctf().event_data(event_id).await?;
489 if let Some(c) = poll.challenges.iter().find(|c| c.id == challenge_id) {
490 if c.docker_online.unwrap_or(0) > 0 {
491 let host = c.hostname.as_deref().unwrap_or("unknown");
492 let ports = c.docker_ports.as_deref().unwrap_or(&[]);
493 if ports.is_empty() {
494 crate::output::print_message(&format!("Ready: {host}"));
495 } else {
496 let port_str: Vec<_> = ports.iter().map(|p| p.to_string()).collect();
497 crate::output::print_message(&format!("Ready: {host}:{}", port_str.join(",")));
498 }
499 return Ok(());
500 }
501 }
502 }
503
504 crate::output::print_message("Container started but not ready yet. Check back shortly.");
505 Ok(())
506}
507
508async fn stop(event_id: u64, challenge_id: u64, cache: &Arc<Cache>) -> anyhow::Result<()> {
509 let client = ctf_client(cache)?;
510
511 let data = client.ctf().event_data(event_id).await?;
512 let challenge = data
513 .challenges
514 .iter()
515 .find(|c| c.id == challenge_id)
516 .ok_or_else(|| anyhow::anyhow!("Challenge {challenge_id} not found in event {event_id}"))?;
517
518 if challenge.has_docker.unwrap_or(0) == 0 {
519 anyhow::bail!("This challenge doesn't use a container.");
520 }
521
522 let resp = client.ctf().container_stop(challenge_id).await?;
523 crate::output::print_message(&resp.message);
524 Ok(())
525}
526
527async fn scoreboard(event_id: u64, format: OutputFormat, cache: &Arc<Cache>) -> anyhow::Result<()> {
528 let client = ctf_client(cache)?;
529
530 let menu = client.ctf().menu(event_id).await?;
531 if menu.user_can_view_scoreboard == Some(0) {
532 anyhow::bail!("Scoreboard is hidden for this event.");
533 }
534
535 let sb = client.ctf().scoreboard(event_id).await?;
536
537 if format != OutputFormat::Json {
538 if let Some(team) = &sb.participating_team {
539 crate::output::print_message(&format!(
540 "Your team: {} | Rank: {} | Points: {} | Flags: {} | Bloods: {}",
541 team.name,
542 team.position
543 .map(|p| p.to_string())
544 .unwrap_or_else(|| "-".into()),
545 team.points.unwrap_or(0),
546 team.owned_flags.unwrap_or(0),
547 team.first_bloods.unwrap_or(0),
548 ));
549 }
550 }
551
552 let scores: Vec<_> = sb
554 .scores
555 .iter()
556 .enumerate()
557 .map(|(i, s)| RankedScore {
558 rank: i as u32 + 1,
559 score: s,
560 })
561 .collect();
562
563 crate::output::print_list(&scores, format);
564 Ok(())
565}
566
567struct RankedScore<'a> {
568 rank: u32,
569 score: &'a crate::models::ctf::CtfTeamScore,
570}
571
572impl serde::Serialize for RankedScore<'_> {
573 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
574 self.score.serialize(serializer)
575 }
576}
577
578impl crate::output::Tabular for RankedScore<'_> {
579 fn headers() -> Vec<&'static str> {
580 vec!["#", "Team", "Country", "Points", "Flags", "Bloods"]
581 }
582
583 fn row(&self) -> Vec<String> {
584 vec![
585 self.rank.to_string(),
586 self.score.name.clone(),
587 self.score.country_code.clone().unwrap_or_default(),
588 self.score.points.map(|p| p.to_string()).unwrap_or_default(),
589 self.score
590 .owned_flags
591 .map(|f| f.to_string())
592 .unwrap_or_default(),
593 self.score
594 .first_bloods
595 .map(|b| b.to_string())
596 .unwrap_or_default(),
597 ]
598 }
599}
600
601async fn solves(event_id: u64, format: OutputFormat, cache: &Arc<Cache>) -> anyhow::Result<()> {
602 let client = ctf_client(cache)?;
603 let solves = client.ctf().solves(event_id).await?;
604 crate::output::print_list(&solves, format);
605 Ok(())
606}
607
608async fn challenge_solves(
609 challenge_id: u64,
610 format: OutputFormat,
611 cache: &Arc<Cache>,
612) -> anyhow::Result<()> {
613 let client = ctf_client(cache)?;
614 let solves = client.ctf().challenge_solves(challenge_id).await?;
615 crate::output::print_list(&solves, format);
616 Ok(())
617}
618
619async fn team(event_id: u64, format: OutputFormat, cache: &Arc<Cache>) -> anyhow::Result<()> {
620 let client = ctf_client(cache)?;
621 let data = client.ctf().event_data(event_id).await?;
622 let team = data
623 .participating_team
624 .ok_or_else(|| anyhow::anyhow!("Not participating in this event as a team"))?;
625 let overview = client.ctf().team_overview(team.id).await?;
626
627 if format != OutputFormat::Json {
628 crate::output::print_message(&format!("Team: {}", overview.name));
629 }
630 crate::output::print_list(&overview.members, format);
631 Ok(())
632}
633
634async fn associate(
635 challenge_id: u64,
636 user_id: Option<u64>,
637 cache: &Arc<Cache>,
638) -> anyhow::Result<()> {
639 let client = ctf_client(cache)?;
640 let uid = match user_id {
641 Some(id) => id,
642 None => client.ctf().profile().await?.id,
643 };
644 let resp = client.ctf().associate_challenge(challenge_id, uid).await?;
645 cache.invalidate_pattern("ctf.hackthebox.com_api_ctfs_");
646 crate::output::print_message(&resp.message);
647 Ok(())
648}
649
650async fn disassociate(
651 challenge_id: u64,
652 user_id: Option<u64>,
653 cache: &Arc<Cache>,
654) -> anyhow::Result<()> {
655 let client = ctf_client(cache)?;
656 let uid = match user_id {
657 Some(id) => id,
658 None => client.ctf().profile().await?.id,
659 };
660 let resp = client
661 .ctf()
662 .disassociate_challenge(challenge_id, uid)
663 .await?;
664 cache.invalidate_pattern("ctf.hackthebox.com_api_ctfs_");
665 crate::output::print_message(&resp.message);
666 Ok(())
667}
668
669async fn progress(
670 challenge_id: u64,
671 status: ProgressStatus,
672 clear: bool,
673 cache: &Arc<Cache>,
674) -> anyhow::Result<()> {
675 let client = ctf_client(cache)?;
676 let api_status = if clear {
677 None
678 } else {
679 Some(status.api_value())
680 };
681 client
682 .ctf()
683 .set_challenge_progress(challenge_id, api_status)
684 .await?;
685 cache.invalidate_pattern("ctf.hackthebox.com_api_ctfs_");
686 if clear {
687 crate::output::print_message("Progress cleared.");
688 } else {
689 crate::output::print_message(&format!("Challenge marked as \"{}\".", status.api_value()));
690 }
691 Ok(())
692}