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 = "Examples:\n  htb ctf auth login                     Save CTF API token\n  htb ctf events                         List CTF events\n  htb ctf info ctf-try-out-1434          Event details\n  htb ctf challenges 1434                Challenges in event\n  htb ctf submit 31855 'HTB{flag}'       Submit a flag\n  htb ctf scoreboard 1434                Event scoreboard"
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 let Some(team) = &data.participating_team {
278        crate::output::print_message(&format!(
279            "Team: {} | Rank: {} | Solved: {}/{} | Points: {}",
280            team.name,
281            team.rank
282                .map(|r| r.to_string())
283                .unwrap_or_else(|| "-".into()),
284            team.solved_challenges.unwrap_or(0),
285            team.total_challenges.unwrap_or(0),
286            team.points.unwrap_or(0),
287        ));
288    }
289
290    crate::output::print_list(&challenges, format);
291    Ok(())
292}
293
294async fn submit(challenge_id: u64, flag: &str, cache: &Arc<Cache>) -> anyhow::Result<()> {
295    let client = ctf_client(cache)?;
296    let result = client.ctf().submit_flag(challenge_id, flag).await?;
297    if let Some(points) = result.points {
298        crate::output::print_message(&format!("{} (+{} points)", result.message, points));
299    } else {
300        crate::output::print_message(&result.message);
301    }
302    Ok(())
303}
304
305async fn download(event_id: u64, challenge_id: u64, cache: &Arc<Cache>) -> anyhow::Result<()> {
306    let client = ctf_client(cache)?;
307
308    let data = client.ctf().event_data(event_id).await?;
309    let challenge = data
310        .challenges
311        .iter()
312        .find(|c| c.id == challenge_id)
313        .ok_or_else(|| anyhow::anyhow!("Challenge {challenge_id} not found in event {event_id}"))?;
314
315    let filename = match &challenge.filename {
316        Some(f) => f.clone(),
317        None => anyhow::bail!("No files available for this challenge."),
318    };
319
320    let bytes = client.ctf().download_file(challenge_id).await?;
321    let safe_name = crate::sanitize_filename(&filename, &format!("{challenge_id}.zip"));
322    std::fs::write(&safe_name, &bytes)?;
323    crate::output::print_message(&format!("Downloaded {} ({} bytes)", safe_name, bytes.len()));
324    Ok(())
325}
326
327async fn start(event_id: u64, challenge_id: u64, cache: &Arc<Cache>) -> anyhow::Result<()> {
328    let client = ctf_client(cache)?;
329
330    let data = client.ctf().event_data(event_id).await?;
331    let challenge = data
332        .challenges
333        .iter()
334        .find(|c| c.id == challenge_id)
335        .ok_or_else(|| anyhow::anyhow!("Challenge {challenge_id} not found in event {event_id}"))?;
336
337    if challenge.has_docker.unwrap_or(0) == 0 {
338        anyhow::bail!("This challenge doesn't use a container.");
339    }
340
341    let resp = client.ctf().container_start(challenge_id).await?;
342    crate::output::print_message(&resp.message);
343
344    // Poll for container ready state
345    for _ in 0..15 {
346        tokio::time::sleep(Duration::from_secs(2)).await;
347        let poll = client.ctf().event_data(event_id).await?;
348        if let Some(c) = poll.challenges.iter().find(|c| c.id == challenge_id) {
349            if let Some(ref addr) = c.docker_online {
350                let port_info = c.docker_ports.as_deref().unwrap_or("");
351                if port_info.is_empty() {
352                    crate::output::print_message(&format!("Ready: {addr}"));
353                } else {
354                    crate::output::print_message(&format!("Ready: {addr}:{port_info}"));
355                }
356                return Ok(());
357            }
358        }
359    }
360
361    crate::output::print_message("Container started but not ready yet. Check back shortly.");
362    Ok(())
363}
364
365async fn stop(event_id: u64, challenge_id: u64, cache: &Arc<Cache>) -> anyhow::Result<()> {
366    let client = ctf_client(cache)?;
367
368    let data = client.ctf().event_data(event_id).await?;
369    let challenge = data
370        .challenges
371        .iter()
372        .find(|c| c.id == challenge_id)
373        .ok_or_else(|| anyhow::anyhow!("Challenge {challenge_id} not found in event {event_id}"))?;
374
375    if challenge.has_docker.unwrap_or(0) == 0 {
376        anyhow::bail!("This challenge doesn't use a container.");
377    }
378
379    let resp = client.ctf().container_stop(challenge_id).await?;
380    crate::output::print_message(&resp.message);
381    Ok(())
382}
383
384async fn scoreboard(event_id: u64, format: OutputFormat, cache: &Arc<Cache>) -> anyhow::Result<()> {
385    let client = ctf_client(cache)?;
386
387    let menu = client.ctf().menu(event_id).await?;
388    if menu.user_can_view_scoreboard == Some(0) {
389        anyhow::bail!("Scoreboard is hidden for this event.");
390    }
391
392    let sb = client.ctf().scoreboard(event_id).await?;
393
394    if let Some(team) = &sb.participating_team {
395        crate::output::print_message(&format!(
396            "Your team: {} | Rank: {} | Points: {} | Flags: {} | Bloods: {}",
397            team.name,
398            team.position
399                .map(|p| p.to_string())
400                .unwrap_or_else(|| "-".into()),
401            team.points.unwrap_or(0),
402            team.owned_flags.unwrap_or(0),
403            team.first_bloods.unwrap_or(0),
404        ));
405    }
406
407    // Add rank numbers to the score table
408    let scores: Vec<_> = sb
409        .scores
410        .iter()
411        .enumerate()
412        .map(|(i, s)| RankedScore {
413            rank: i as u32 + 1,
414            score: s,
415        })
416        .collect();
417
418    crate::output::print_list(&scores, format);
419    Ok(())
420}
421
422struct RankedScore<'a> {
423    rank: u32,
424    score: &'a crate::models::ctf::CtfTeamScore,
425}
426
427impl serde::Serialize for RankedScore<'_> {
428    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
429        self.score.serialize(serializer)
430    }
431}
432
433impl crate::output::Tabular for RankedScore<'_> {
434    fn headers() -> Vec<&'static str> {
435        vec!["#", "Team", "Country", "Points", "Flags", "Bloods"]
436    }
437
438    fn row(&self) -> Vec<String> {
439        vec![
440            self.rank.to_string(),
441            self.score.name.clone(),
442            self.score.country_code.clone().unwrap_or_default(),
443            self.score.points.map(|p| p.to_string()).unwrap_or_default(),
444            self.score
445                .owned_flags
446                .map(|f| f.to_string())
447                .unwrap_or_default(),
448            self.score
449                .first_bloods
450                .map(|b| b.to_string())
451                .unwrap_or_default(),
452        ]
453    }
454}
455
456async fn solves(event_id: u64, format: OutputFormat, cache: &Arc<Cache>) -> anyhow::Result<()> {
457    let client = ctf_client(cache)?;
458    let solves = client.ctf().solves(event_id).await?;
459    crate::output::print_list(&solves, format);
460    Ok(())
461}
462
463async fn challenge_solves(
464    challenge_id: u64,
465    format: OutputFormat,
466    cache: &Arc<Cache>,
467) -> anyhow::Result<()> {
468    let client = ctf_client(cache)?;
469    let solves = client.ctf().challenge_solves(challenge_id).await?;
470    crate::output::print_list(&solves, format);
471    Ok(())
472}