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 Auth {
19 #[command(subcommand)]
20 command: CtfAuthCommand,
21 },
22 Events {
24 #[arg(long, help = "Include past events")]
25 all: bool,
26 },
27 Info {
29 slug: String,
31 },
32 Challenges {
34 event_id: u64,
36 #[arg(long, help = "Filter by difficulty")]
37 difficulty: Option<String>,
38 },
39 Submit {
41 challenge_id: u64,
43 flag: String,
45 },
46 Download {
48 event_id: u64,
50 challenge_id: u64,
52 },
53 Start {
55 event_id: u64,
57 challenge_id: u64,
59 },
60 Stop {
62 event_id: u64,
64 challenge_id: u64,
66 },
67 Scoreboard {
69 event_id: u64,
71 },
72 Solves {
74 event_id: u64,
76 },
77 ChallengeSolves {
79 challenge_id: u64,
81 },
82}
83
84#[derive(Subcommand)]
85pub enum CtfAuthCommand {
86 Login,
88 Status,
90 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 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 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}