use std::sync::Arc;
use std::time::Duration;
use clap::Subcommand;
use crate::api::HtbClient;
use crate::cache::Cache;
use crate::output::OutputFormat;
const CTF_BASE_URL: &str = "https://ctf.hackthebox.com";
#[derive(Subcommand)]
#[command(
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"
)]
pub enum CtfCommand {
/// Manage CTF authentication
Auth {
#[command(subcommand)]
command: CtfAuthCommand,
},
/// List CTF events
Events {
#[arg(long, help = "Include past events")]
all: bool,
},
/// Show CTF event details
Info {
/// Event slug (e.g. ctf-try-out-1434)
slug: String,
},
/// List challenges in a CTF event
Challenges {
/// Event ID
event_id: u64,
#[arg(long, help = "Filter by difficulty")]
difficulty: Option<String>,
},
/// Submit a flag
Submit {
/// Challenge ID
challenge_id: u64,
/// The flag
flag: String,
},
/// Download challenge files
Download {
/// Event ID (for validation)
event_id: u64,
/// Challenge ID
challenge_id: u64,
},
/// Start a challenge container
Start {
/// Event ID (for status polling)
event_id: u64,
/// Challenge ID
challenge_id: u64,
},
/// Stop a challenge container
Stop {
/// Event ID
event_id: u64,
/// Challenge ID
challenge_id: u64,
},
/// Show event scoreboard
Scoreboard {
/// Event ID
event_id: u64,
},
/// Show recent solves for an event
Solves {
/// Event ID
event_id: u64,
},
/// Show solves for a specific challenge
ChallengeSolves {
/// Challenge ID
challenge_id: u64,
},
}
#[derive(Subcommand)]
pub enum CtfAuthCommand {
/// Save your CTF API token
Login,
/// Show CTF auth status
Status,
/// Remove stored CTF token
Logout,
}
pub async fn handle(
cmd: CtfCommand,
format: OutputFormat,
cache: &Arc<Cache>,
) -> anyhow::Result<()> {
match cmd {
CtfCommand::Auth { command } => handle_auth(command, format, cache).await,
CtfCommand::Events { all } => events(all, format, cache).await,
CtfCommand::Info { slug } => info(&slug, format, cache).await,
CtfCommand::Challenges {
event_id,
difficulty,
} => challenges(event_id, difficulty.as_deref(), format, cache).await,
CtfCommand::Submit { challenge_id, flag } => submit(challenge_id, &flag, cache).await,
CtfCommand::Download {
event_id,
challenge_id,
} => download(event_id, challenge_id, cache).await,
CtfCommand::Start {
event_id,
challenge_id,
} => start(event_id, challenge_id, cache).await,
CtfCommand::Stop {
event_id,
challenge_id,
} => stop(event_id, challenge_id, cache).await,
CtfCommand::Scoreboard { event_id } => scoreboard(event_id, format, cache).await,
CtfCommand::Solves { event_id } => solves(event_id, format, cache).await,
CtfCommand::ChallengeSolves { challenge_id } => {
challenge_solves(challenge_id, format, cache).await
}
}
}
fn ctf_client(cache: &Arc<Cache>) -> anyhow::Result<HtbClient> {
let token = crate::config::read_ctf_token()?;
Ok(HtbClient::with_base_url_and_cache(
token,
CTF_BASE_URL.to_string(),
Arc::clone(cache),
))
}
async fn handle_auth(
cmd: CtfAuthCommand,
format: OutputFormat,
cache: &Arc<Cache>,
) -> anyhow::Result<()> {
match cmd {
CtfAuthCommand::Login => login(cache).await,
CtfAuthCommand::Status => status(format).await,
CtfAuthCommand::Logout => logout(cache),
}
}
async fn login(cache: &Cache) -> anyhow::Result<()> {
println!("Enter your CTF API token (from https://ctf.hackthebox.com/settings):");
let token = rpassword::read_password()?;
let token = token.trim().to_string();
if token.is_empty() {
anyhow::bail!("Token cannot be empty");
}
let client = HtbClient::with_base_url(token.clone(), CTF_BASE_URL.to_string());
let user = client.ctf().profile().await?;
crate::config::save_ctf_token(&token)?;
cache.clear();
println!("Authenticated as {}", user.name);
Ok(())
}
async fn status(format: OutputFormat) -> anyhow::Result<()> {
let token = crate::config::read_ctf_token()?;
let client = HtbClient::with_base_url(token, CTF_BASE_URL.to_string());
let user = client.ctf().profile().await?;
let fields = vec![
("Username", user.name.clone()),
("ID", user.id.to_string()),
("Email", user.email.clone().unwrap_or_default()),
("Timezone", user.timezone.clone().unwrap_or_default()),
(
"Has Team",
if user.has_any_team { "Yes" } else { "No" }.into(),
),
];
crate::output::print_detail(&user, format, &fields);
Ok(())
}
fn logout(cache: &Cache) -> anyhow::Result<()> {
crate::config::remove_ctf_token()?;
cache.clear();
println!("CTF token removed.");
Ok(())
}
async fn events(all: bool, format: OutputFormat, cache: &Arc<Cache>) -> anyhow::Result<()> {
let client = ctf_client(cache)?;
let mut events = client.ctf().events().await?;
if !all {
events.retain(|e| {
e.status
.as_deref()
.is_some_and(|s| s == "Ongoing" || s == "Upcoming")
});
}
crate::output::print_list(&events, format);
Ok(())
}
async fn info(slug: &str, format: OutputFormat, cache: &Arc<Cache>) -> anyhow::Result<()> {
let client = ctf_client(cache)?;
let detail = client.ctf().event_details(slug).await?;
let fields = vec![
("ID", detail.id.to_string()),
("Name", detail.name.clone()),
("Status", detail.status.clone().unwrap_or_default()),
("Format", detail.format.clone().unwrap_or_default()),
("Type", detail.event_type.clone().unwrap_or_default()),
("Location", detail.location.clone().unwrap_or_default()),
("Start", detail.start_date.clone().unwrap_or_default()),
("End", detail.end_date.clone().unwrap_or_default()),
(
"Players",
detail
.players_joined
.map(|p| p.to_string())
.unwrap_or_default(),
),
(
"Teams",
detail
.teams_joined
.map(|t| t.to_string())
.unwrap_or_default(),
),
(
"Challenges",
detail.challenges.map(|c| c.to_string()).unwrap_or_default(),
),
(
"Max Team Size",
detail
.max_team_size
.map(|m| m.to_string())
.unwrap_or_default(),
),
(
"Prize Pool",
detail.prize_pool.clone().unwrap_or_else(|| "-".into()),
),
];
crate::output::print_detail(&detail, format, &fields);
Ok(())
}
async fn challenges(
event_id: u64,
difficulty: Option<&str>,
format: OutputFormat,
cache: &Arc<Cache>,
) -> anyhow::Result<()> {
let client = ctf_client(cache)?;
let data = client.ctf().event_data(event_id).await?;
let mut challenges = data.challenges;
if let Some(diff_filter) = difficulty {
challenges.retain(|c| {
c.difficulty
.as_deref()
.is_some_and(|d| d.eq_ignore_ascii_case(diff_filter))
});
}
if let Some(team) = &data.participating_team {
crate::output::print_message(&format!(
"Team: {} | Rank: {} | Solved: {}/{} | Points: {}",
team.name,
team.rank
.map(|r| r.to_string())
.unwrap_or_else(|| "-".into()),
team.solved_challenges.unwrap_or(0),
team.total_challenges.unwrap_or(0),
team.points.unwrap_or(0),
));
}
crate::output::print_list(&challenges, format);
Ok(())
}
async fn submit(challenge_id: u64, flag: &str, cache: &Arc<Cache>) -> anyhow::Result<()> {
let client = ctf_client(cache)?;
let result = client.ctf().submit_flag(challenge_id, flag).await?;
if let Some(points) = result.points {
crate::output::print_message(&format!("{} (+{} points)", result.message, points));
} else {
crate::output::print_message(&result.message);
}
Ok(())
}
async fn download(event_id: u64, challenge_id: u64, cache: &Arc<Cache>) -> anyhow::Result<()> {
let client = ctf_client(cache)?;
let data = client.ctf().event_data(event_id).await?;
let challenge = data
.challenges
.iter()
.find(|c| c.id == challenge_id)
.ok_or_else(|| anyhow::anyhow!("Challenge {challenge_id} not found in event {event_id}"))?;
let filename = match &challenge.filename {
Some(f) => f.clone(),
None => anyhow::bail!("No files available for this challenge."),
};
let bytes = client.ctf().download_file(challenge_id).await?;
let safe_name = crate::sanitize_filename(&filename, &format!("{challenge_id}.zip"));
std::fs::write(&safe_name, &bytes)?;
crate::output::print_message(&format!("Downloaded {} ({} bytes)", safe_name, bytes.len()));
Ok(())
}
async fn start(event_id: u64, challenge_id: u64, cache: &Arc<Cache>) -> anyhow::Result<()> {
let client = ctf_client(cache)?;
let data = client.ctf().event_data(event_id).await?;
let challenge = data
.challenges
.iter()
.find(|c| c.id == challenge_id)
.ok_or_else(|| anyhow::anyhow!("Challenge {challenge_id} not found in event {event_id}"))?;
if challenge.has_docker.unwrap_or(0) == 0 {
anyhow::bail!("This challenge doesn't use a container.");
}
let resp = client.ctf().container_start(challenge_id).await?;
crate::output::print_message(&resp.message);
// Poll for container ready state
for _ in 0..15 {
tokio::time::sleep(Duration::from_secs(2)).await;
let poll = client.ctf().event_data(event_id).await?;
if let Some(c) = poll.challenges.iter().find(|c| c.id == challenge_id) {
if c.docker_online.unwrap_or(0) > 0 {
let host = c.hostname.as_deref().unwrap_or("unknown");
let ports = c.docker_ports.as_deref().unwrap_or(&[]);
if ports.is_empty() {
crate::output::print_message(&format!("Ready: {host}"));
} else {
let port_str: Vec<_> = ports.iter().map(|p| p.to_string()).collect();
crate::output::print_message(&format!("Ready: {host}:{}", port_str.join(",")));
}
return Ok(());
}
}
}
crate::output::print_message("Container started but not ready yet. Check back shortly.");
Ok(())
}
async fn stop(event_id: u64, challenge_id: u64, cache: &Arc<Cache>) -> anyhow::Result<()> {
let client = ctf_client(cache)?;
let data = client.ctf().event_data(event_id).await?;
let challenge = data
.challenges
.iter()
.find(|c| c.id == challenge_id)
.ok_or_else(|| anyhow::anyhow!("Challenge {challenge_id} not found in event {event_id}"))?;
if challenge.has_docker.unwrap_or(0) == 0 {
anyhow::bail!("This challenge doesn't use a container.");
}
let resp = client.ctf().container_stop(challenge_id).await?;
crate::output::print_message(&resp.message);
Ok(())
}
async fn scoreboard(event_id: u64, format: OutputFormat, cache: &Arc<Cache>) -> anyhow::Result<()> {
let client = ctf_client(cache)?;
let menu = client.ctf().menu(event_id).await?;
if menu.user_can_view_scoreboard == Some(0) {
anyhow::bail!("Scoreboard is hidden for this event.");
}
let sb = client.ctf().scoreboard(event_id).await?;
if let Some(team) = &sb.participating_team {
crate::output::print_message(&format!(
"Your team: {} | Rank: {} | Points: {} | Flags: {} | Bloods: {}",
team.name,
team.position
.map(|p| p.to_string())
.unwrap_or_else(|| "-".into()),
team.points.unwrap_or(0),
team.owned_flags.unwrap_or(0),
team.first_bloods.unwrap_or(0),
));
}
// Add rank numbers to the score table
let scores: Vec<_> = sb
.scores
.iter()
.enumerate()
.map(|(i, s)| RankedScore {
rank: i as u32 + 1,
score: s,
})
.collect();
crate::output::print_list(&scores, format);
Ok(())
}
struct RankedScore<'a> {
rank: u32,
score: &'a crate::models::ctf::CtfTeamScore,
}
impl serde::Serialize for RankedScore<'_> {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
self.score.serialize(serializer)
}
}
impl crate::output::Tabular for RankedScore<'_> {
fn headers() -> Vec<&'static str> {
vec!["#", "Team", "Country", "Points", "Flags", "Bloods"]
}
fn row(&self) -> Vec<String> {
vec![
self.rank.to_string(),
self.score.name.clone(),
self.score.country_code.clone().unwrap_or_default(),
self.score.points.map(|p| p.to_string()).unwrap_or_default(),
self.score
.owned_flags
.map(|f| f.to_string())
.unwrap_or_default(),
self.score
.first_bloods
.map(|b| b.to_string())
.unwrap_or_default(),
]
}
}
async fn solves(event_id: u64, format: OutputFormat, cache: &Arc<Cache>) -> anyhow::Result<()> {
let client = ctf_client(cache)?;
let solves = client.ctf().solves(event_id).await?;
crate::output::print_list(&solves, format);
Ok(())
}
async fn challenge_solves(
challenge_id: u64,
format: OutputFormat,
cache: &Arc<Cache>,
) -> anyhow::Result<()> {
let client = ctf_client(cache)?;
let solves = client.ctf().challenge_solves(challenge_id).await?;
crate::output::print_list(&solves, format);
Ok(())
}