Skip to main content

htb_cli/cli/
ctf.rs

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    /// Manage CTF authentication
18    Auth {
19        #[command(subcommand)]
20        command: CtfAuthCommand,
21    },
22    /// Set the active CTF event (persists to config)
23    Use {
24        /// Event ID to set as active
25        event_id: Option<u64>,
26        /// Clear the active event
27        #[arg(long)]
28        clear: bool,
29    },
30    /// List CTF events
31    Events {
32        #[arg(long, help = "Include past events")]
33        all: bool,
34    },
35    /// Show CTF event details
36    Info {
37        /// Event slug (e.g. ctf-try-out-1434)
38        slug: String,
39    },
40    /// List challenges in a CTF event
41    Challenges {
42        /// Event ID (uses active event if omitted)
43        event_id: Option<u64>,
44        #[arg(long, help = "Filter by difficulty")]
45        difficulty: Option<String>,
46    },
47    /// Submit a flag
48    Submit {
49        /// Challenge ID
50        challenge_id: u64,
51        /// The flag
52        flag: String,
53    },
54    /// Download challenge files
55    Download {
56        /// Challenge ID
57        challenge_id: u64,
58        /// Event ID (uses active event if omitted)
59        #[arg(short = 'e', long = "event")]
60        event_id: Option<u64>,
61    },
62    /// Start a challenge container
63    Start {
64        /// Challenge ID
65        challenge_id: u64,
66        /// Event ID (uses active event if omitted)
67        #[arg(short = 'e', long = "event")]
68        event_id: Option<u64>,
69    },
70    /// Stop a challenge container
71    Stop {
72        /// Challenge ID
73        challenge_id: u64,
74        /// Event ID (uses active event if omitted)
75        #[arg(short = 'e', long = "event")]
76        event_id: Option<u64>,
77    },
78    /// Show event scoreboard
79    Scoreboard {
80        /// Event ID (uses active event if omitted)
81        event_id: Option<u64>,
82    },
83    /// Show recent solves for an event
84    Solves {
85        /// Event ID (uses active event if omitted)
86        event_id: Option<u64>,
87    },
88    /// Show solves for a specific challenge
89    ChallengeSolves {
90        /// Challenge ID
91        challenge_id: u64,
92    },
93    /// List team members for the active event
94    Team {
95        /// Event ID (uses active event if omitted)
96        event_id: Option<u64>,
97    },
98    /// Assign a member to a challenge (defaults to yourself)
99    Associate {
100        /// Challenge ID
101        challenge_id: u64,
102        /// User ID (defaults to yourself)
103        #[arg(short, long)]
104        user: Option<u64>,
105    },
106    /// Unassign a member from a challenge (defaults to yourself)
107    Disassociate {
108        /// Challenge ID
109        challenge_id: u64,
110        /// User ID (defaults to yourself)
111        #[arg(short, long)]
112        user: Option<u64>,
113    },
114    /// Set challenge progress status
115    Progress {
116        /// Challenge ID
117        challenge_id: u64,
118        /// Status to set [possible values: in-progress, need-help, not-started]
119        #[arg(value_enum, default_value = "in-progress")]
120        status: ProgressStatus,
121        /// Clear the progress status
122        #[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    /// Save your CTF API token
150    Login,
151    /// Show CTF auth status
152    Status,
153    /// Remove stored CTF token
154    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    // Poll for container ready state
486    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    // Add rank numbers to the score table
553    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}