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 = "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"
)]
pub enum CtfCommand {
Auth {
#[command(subcommand)]
command: CtfAuthCommand,
},
Events {
#[arg(long, help = "Include past events")]
all: bool,
},
Info {
slug: String,
},
Challenges {
event_id: u64,
#[arg(long, help = "Filter by difficulty")]
difficulty: Option<String>,
},
Submit {
challenge_id: u64,
flag: String,
},
Download {
event_id: u64,
challenge_id: u64,
},
Start {
event_id: u64,
challenge_id: u64,
},
Stop {
event_id: u64,
challenge_id: u64,
},
Scoreboard {
event_id: u64,
},
Solves {
event_id: u64,
},
ChallengeSolves {
challenge_id: u64,
},
}
#[derive(Subcommand)]
pub enum CtfAuthCommand {
Login,
Status,
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);
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 let Some(ref addr) = c.docker_online {
let port_info = c.docker_ports.as_deref().unwrap_or("");
if port_info.is_empty() {
crate::output::print_message(&format!("Ready: {addr}"));
} else {
crate::output::print_message(&format!("Ready: {addr}:{port_info}"));
}
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),
));
}
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(())
}