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 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    /// 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}
94
95#[derive(Subcommand)]
96pub enum CtfAuthCommand {
97    /// Save your CTF API token
98    Login,
99    /// Show CTF auth status
100    Status,
101    /// Remove stored CTF token
102    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    // Poll for container ready state
421    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    // Add rank numbers to the score table
488    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}