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 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    /// Manage CTF authentication
18    Auth {
19        #[command(subcommand)]
20        command: CtfAuthCommand,
21    },
22    /// List CTF events
23    Events {
24        #[arg(long, help = "Include past events")]
25        all: bool,
26    },
27    /// Show CTF event details
28    Info {
29        /// Event slug (e.g. ctf-try-out-1434)
30        slug: String,
31    },
32    /// List challenges in a CTF event
33    Challenges {
34        /// Event ID
35        event_id: u64,
36        #[arg(long, help = "Filter by difficulty")]
37        difficulty: Option<String>,
38    },
39    /// Submit a flag
40    Submit {
41        /// Challenge ID
42        challenge_id: u64,
43        /// The flag
44        flag: String,
45    },
46    /// Download challenge files
47    Download {
48        /// Event ID (for validation)
49        event_id: u64,
50        /// Challenge ID
51        challenge_id: u64,
52    },
53    /// Start a challenge container
54    Start {
55        /// Event ID (for status polling)
56        event_id: u64,
57        /// Challenge ID
58        challenge_id: u64,
59    },
60    /// Stop a challenge container
61    Stop {
62        /// Event ID
63        event_id: u64,
64        /// Challenge ID
65        challenge_id: u64,
66    },
67    /// Show event scoreboard
68    Scoreboard {
69        /// Event ID
70        event_id: u64,
71    },
72    /// Show recent solves for an event
73    Solves {
74        /// Event ID
75        event_id: u64,
76    },
77    /// Show solves for a specific challenge
78    ChallengeSolves {
79        /// Challenge ID
80        challenge_id: u64,
81    },
82}
83
84#[derive(Subcommand)]
85pub enum CtfAuthCommand {
86    /// Save your CTF API token
87    Login,
88    /// Show CTF auth status
89    Status,
90    /// Remove stored CTF token
91    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    // Poll for container ready state
347    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    // Add rank numbers to the score table
414    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}