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 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 c.docker_online.unwrap_or(0) > 0 {
350                let host = c.hostname.as_deref().unwrap_or("unknown");
351                let ports = c.docker_ports.as_deref().unwrap_or(&[]);
352                if ports.is_empty() {
353                    crate::output::print_message(&format!("Ready: {host}"));
354                } else {
355                    let port_str: Vec<_> = ports.iter().map(|p| p.to_string()).collect();
356                    crate::output::print_message(&format!("Ready: {host}:{}", port_str.join(",")));
357                }
358                return Ok(());
359            }
360        }
361    }
362
363    crate::output::print_message("Container started but not ready yet. Check back shortly.");
364    Ok(())
365}
366
367async fn stop(event_id: u64, challenge_id: u64, cache: &Arc<Cache>) -> anyhow::Result<()> {
368    let client = ctf_client(cache)?;
369
370    let data = client.ctf().event_data(event_id).await?;
371    let challenge = data
372        .challenges
373        .iter()
374        .find(|c| c.id == challenge_id)
375        .ok_or_else(|| anyhow::anyhow!("Challenge {challenge_id} not found in event {event_id}"))?;
376
377    if challenge.has_docker.unwrap_or(0) == 0 {
378        anyhow::bail!("This challenge doesn't use a container.");
379    }
380
381    let resp = client.ctf().container_stop(challenge_id).await?;
382    crate::output::print_message(&resp.message);
383    Ok(())
384}
385
386async fn scoreboard(event_id: u64, format: OutputFormat, cache: &Arc<Cache>) -> anyhow::Result<()> {
387    let client = ctf_client(cache)?;
388
389    let menu = client.ctf().menu(event_id).await?;
390    if menu.user_can_view_scoreboard == Some(0) {
391        anyhow::bail!("Scoreboard is hidden for this event.");
392    }
393
394    let sb = client.ctf().scoreboard(event_id).await?;
395
396    if let Some(team) = &sb.participating_team {
397        crate::output::print_message(&format!(
398            "Your team: {} | Rank: {} | Points: {} | Flags: {} | Bloods: {}",
399            team.name,
400            team.position
401                .map(|p| p.to_string())
402                .unwrap_or_else(|| "-".into()),
403            team.points.unwrap_or(0),
404            team.owned_flags.unwrap_or(0),
405            team.first_bloods.unwrap_or(0),
406        ));
407    }
408
409    // Add rank numbers to the score table
410    let scores: Vec<_> = sb
411        .scores
412        .iter()
413        .enumerate()
414        .map(|(i, s)| RankedScore {
415            rank: i as u32 + 1,
416            score: s,
417        })
418        .collect();
419
420    crate::output::print_list(&scores, format);
421    Ok(())
422}
423
424struct RankedScore<'a> {
425    rank: u32,
426    score: &'a crate::models::ctf::CtfTeamScore,
427}
428
429impl serde::Serialize for RankedScore<'_> {
430    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
431        self.score.serialize(serializer)
432    }
433}
434
435impl crate::output::Tabular for RankedScore<'_> {
436    fn headers() -> Vec<&'static str> {
437        vec!["#", "Team", "Country", "Points", "Flags", "Bloods"]
438    }
439
440    fn row(&self) -> Vec<String> {
441        vec![
442            self.rank.to_string(),
443            self.score.name.clone(),
444            self.score.country_code.clone().unwrap_or_default(),
445            self.score.points.map(|p| p.to_string()).unwrap_or_default(),
446            self.score
447                .owned_flags
448                .map(|f| f.to_string())
449                .unwrap_or_default(),
450            self.score
451                .first_bloods
452                .map(|b| b.to_string())
453                .unwrap_or_default(),
454        ]
455    }
456}
457
458async fn solves(event_id: u64, format: OutputFormat, cache: &Arc<Cache>) -> anyhow::Result<()> {
459    let client = ctf_client(cache)?;
460    let solves = client.ctf().solves(event_id).await?;
461    crate::output::print_list(&solves, format);
462    Ok(())
463}
464
465async fn challenge_solves(
466    challenge_id: u64,
467    format: OutputFormat,
468    cache: &Arc<Cache>,
469) -> anyhow::Result<()> {
470    let client = ctf_client(cache)?;
471    let solves = client.ctf().challenge_solves(challenge_id).await?;
472    crate::output::print_list(&solves, format);
473    Ok(())
474}