Skip to main content

htb_cli/cli/
machines.rs

1use clap::Subcommand;
2
3use crate::api::HtbClient;
4use crate::output::{self, OutputFormat};
5
6#[derive(Subcommand)]
7#[command(
8    after_help = "Examples:\n  htb machines list --os linux          Linux machines only\n  htb machines list --difficulty easy    Easy machines only\n  htb machines info Bedside             Machine details\n  htb machines start Bedside            Spawn a machine\n  htb machines submit Bedside 'HTB{f}'  Submit a flag\n  htb machines active                   Current machine\n  htb machines todo                     Your todo list"
9)]
10pub enum MachineCommand {
11    /// List machines
12    List {
13        #[arg(long, help = "Include retired machines")]
14        retired: bool,
15        #[arg(long, help = "Filter by OS (linux, windows)")]
16        os: Option<String>,
17        #[arg(long, help = "Filter by difficulty (easy, medium, hard, insane)")]
18        difficulty: Option<String>,
19        #[arg(long, help = "Page number")]
20        page: Option<u32>,
21        #[arg(long, help = "Fetch all pages")]
22        all: bool,
23    },
24    /// Show machine details
25    Info {
26        /// Machine name or ID
27        name_or_id: String,
28    },
29    /// Spawn a machine
30    Start {
31        /// Machine name or ID
32        name_or_id: String,
33    },
34    /// Stop the active machine (or specify by name)
35    Stop {
36        /// Machine name or ID (defaults to active machine)
37        name_or_id: Option<String>,
38    },
39    /// Reset a machine
40    Reset {
41        /// Machine name or ID
42        name_or_id: String,
43    },
44    /// Submit a flag
45    Submit {
46        /// Machine name or ID
47        name_or_id: String,
48        /// The flag
49        flag: String,
50    },
51    /// Extend the active machine timer
52    Extend {
53        /// Machine name or ID (defaults to active machine)
54        name_or_id: Option<String>,
55    },
56    /// Show currently active machine
57    Active,
58    /// Manage todo list
59    Todo {
60        #[command(subcommand)]
61        command: Option<TodoCommand>,
62    },
63}
64
65#[derive(Subcommand)]
66pub enum TodoCommand {
67    /// Add a machine to todo list
68    Add {
69        /// Machine name or ID
70        name_or_id: String,
71    },
72    /// Remove a machine from todo list
73    Remove {
74        /// Machine name or ID
75        name_or_id: String,
76    },
77}
78
79pub async fn handle(
80    client: &HtbClient,
81    cmd: MachineCommand,
82    format: OutputFormat,
83) -> anyhow::Result<()> {
84    match cmd {
85        MachineCommand::List {
86            retired,
87            os,
88            difficulty,
89            page,
90            all,
91        } => {
92            if retired {
93                tracing::warn!(
94                    "--retired flag is not yet supported by the v5 API; showing all machines"
95                );
96            }
97            let per_page = 100;
98            let start_page = page.unwrap_or(1);
99
100            let result = client.machines().list(start_page, per_page).await?;
101            let mut machines = result.data;
102
103            if all {
104                let mut next = result.links.next;
105                let mut current = start_page;
106                while next.is_some() {
107                    current += 1;
108                    let page_result = client.machines().list(current, per_page).await?;
109                    next = page_result.links.next;
110                    machines.extend(page_result.data);
111                }
112            }
113
114            if let Some(ref os_filter) = os {
115                machines.retain(|m| m.os.eq_ignore_ascii_case(os_filter));
116            }
117            if let Some(ref diff_filter) = difficulty {
118                machines.retain(|m| {
119                    m.difficulty_text
120                        .as_ref()
121                        .is_some_and(|d| d.eq_ignore_ascii_case(diff_filter))
122                });
123            }
124
125            output::print_list(&machines, format);
126            if !all {
127                output::print_pagination(
128                    result.meta.current_page,
129                    result.meta.last_page,
130                    result.meta.total,
131                    format,
132                );
133            }
134        }
135
136        MachineCommand::Info { name_or_id } => {
137            let machine = client.machines().profile(&name_or_id).await?;
138            let fields = vec![
139                ("ID", machine.id.to_string()),
140                ("Name", machine.name.clone()),
141                ("OS", machine.os.clone()),
142                (
143                    "Difficulty",
144                    machine.difficulty_text.clone().unwrap_or_default(),
145                ),
146                (
147                    "Rating",
148                    machine
149                        .rating
150                        .map(|r| format!("{r:.1}"))
151                        .unwrap_or_default(),
152                ),
153                ("Points", machine.points.to_string()),
154                ("State", machine.state.clone().unwrap_or_default()),
155                ("User Owns", machine.user_owns_count.to_string()),
156                ("Root Owns", machine.root_owns_count.to_string()),
157                ("IP", machine.ip.clone().unwrap_or_else(|| "-".into())),
158                (
159                    "Creator",
160                    machine
161                        .first_creator
162                        .as_ref()
163                        .map(|c| c.name.clone())
164                        .unwrap_or_default(),
165                ),
166            ];
167            output::print_detail(&machine, format, &fields);
168        }
169
170        MachineCommand::Start { name_or_id } => {
171            let machine = client.machines().profile(&name_or_id).await?;
172            let resp = client.machines().start(machine.id).await?;
173            output::print_message(&resp.message);
174        }
175
176        MachineCommand::Stop { name_or_id } => {
177            let machine_id = match name_or_id {
178                Some(name) => client.machines().profile(&name).await?.id,
179                None => {
180                    client
181                        .machines()
182                        .active()
183                        .await?
184                        .ok_or_else(|| anyhow::anyhow!("No active machine. Specify a name or ID."))?
185                        .id
186                }
187            };
188            let resp = client.machines().stop(machine_id).await?;
189            output::print_message(&resp.message);
190        }
191
192        MachineCommand::Reset { name_or_id } => {
193            let machine = client.machines().profile(&name_or_id).await?;
194            eprint!("Reset {}? [y/N] ", machine.name);
195            let mut input = String::new();
196            std::io::stdin().read_line(&mut input)?;
197            if input.trim().eq_ignore_ascii_case("y") {
198                let resp = client.machines().reset(machine.id).await?;
199                output::print_message(&resp.message);
200            } else {
201                output::print_message("Cancelled.");
202            }
203        }
204
205        MachineCommand::Submit { name_or_id, flag } => {
206            let machine = client.machines().profile(&name_or_id).await?;
207            // difficulty field from the machine profile (0-100 scale)
208            let difficulty = machine.difficulty.unwrap_or(50);
209            let resp = client
210                .machines()
211                .submit_flag(machine.id, &flag, difficulty)
212                .await?;
213            output::print_message(&resp.message);
214        }
215
216        MachineCommand::Extend { name_or_id } => {
217            let machine_id = match name_or_id {
218                Some(name) => client.machines().profile(&name).await?.id,
219                None => {
220                    client
221                        .machines()
222                        .active()
223                        .await?
224                        .ok_or_else(|| anyhow::anyhow!("No active machine. Specify a name or ID."))?
225                        .id
226                }
227            };
228            let resp = client.machines().extend(machine_id).await?;
229            output::print_message(&resp.message);
230        }
231
232        MachineCommand::Active => {
233            let active = client.machines().active().await?;
234            match active {
235                Some(vm) => {
236                    let fields = vec![
237                        ("ID", vm.id.to_string()),
238                        ("Name", vm.name.clone()),
239                        ("Type", vm.vm_type.clone().unwrap_or_default()),
240                        (
241                            "Expires",
242                            vm.expires_at.clone().unwrap_or_else(|| "-".into()),
243                        ),
244                    ];
245                    output::print_detail(&vm, format, &fields);
246                }
247                None => output::print_message("No active machine."),
248            }
249        }
250
251        MachineCommand::Todo { command } => match command {
252            None => {
253                let todos = client.machines().todo_list().await?;
254                if todos.is_empty() {
255                    output::print_message("No machines in todo list.");
256                } else {
257                    output::print_list(&todos, format);
258                }
259            }
260            Some(TodoCommand::Add { name_or_id }) => {
261                let machine = client.machines().profile(&name_or_id).await?;
262                let todos = client.machines().todo_list().await?;
263                if todos.iter().any(|m| m.id == machine.id) {
264                    output::print_message(&format!(
265                        "{} is already in your todo list.",
266                        machine.name
267                    ));
268                } else {
269                    let resp = client.machines().todo_toggle(machine.id).await?;
270                    output::print_message(&resp.message);
271                }
272            }
273            Some(TodoCommand::Remove { name_or_id }) => {
274                let machine = client.machines().profile(&name_or_id).await?;
275                let todos = client.machines().todo_list().await?;
276                if todos.iter().any(|m| m.id == machine.id) {
277                    let resp = client.machines().todo_toggle(machine.id).await?;
278                    output::print_message(&resp.message);
279                } else {
280                    output::print_message(&format!("{} is not in your todo list.", machine.name));
281                }
282            }
283        },
284    }
285    Ok(())
286}