1use crate::{ssh_args, validation};
7use clap::{Arg, Command, error::ErrorKind};
8use std::ffi::OsString;
9
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub enum VaultCommand {
13 Init,
14 AddPass(String),
15 RemovePass(String),
16 List,
17 Unlock,
18 Lock,
19 Status,
20 SetMasterPassword,
21}
22
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct RdpCommandArgs {
26 pub target: String,
28 pub user: Option<String>,
30 pub domain: Option<String>,
32 pub port: Option<u16>,
34 pub extra_args: Vec<String>,
36}
37
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub struct SshCommandArgs {
41 pub ssh_args: Vec<String>,
43 pub is_non_interactive: bool,
45}
46
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub enum ProtocolCommand {
50 Ssh(SshCommandArgs),
51 Rdp(RdpCommandArgs),
52}
53
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub enum MainCommand {
57 Protocol(ProtocolCommand),
58 Vault(VaultCommand),
59 MigrateInventory,
60 CompletionHosts(CompletionProtocol),
61 AgentServe,
62}
63
64#[derive(Debug, Clone, PartialEq, Eq)]
65pub enum CompletionProtocol {
67 All,
68 Ssh,
69 Rdp,
70}
71
72#[derive(Debug, Clone)]
74pub struct MainArgs {
75 pub debug_count: u8,
77 pub ssh_logging: bool,
79 pub test_mode: bool,
81 pub profile: Option<String>,
83 pub interactive: bool,
85 pub pass_entry: Option<String>,
87 pub command: Option<MainCommand>,
89}
90
91fn build_cli_command() -> Command {
92 Command::new("cossh")
93 .version(concat!("v", env!("CARGO_PKG_VERSION")))
94 .author("@karsyboy")
95 .about("A Rust-based SSH client wrapper with syntax highlighting and logging capabilities")
96 .propagate_version(true)
97 .arg(
98 Arg::new("debug")
99 .short('d')
100 .long("debug")
101 .help("Enable debug logging to ~/.color-ssh/logs/cossh.log; repeat (-dd) for raw terminal and argument tracing")
102 .action(clap::ArgAction::Count),
103 )
104 .arg(
105 Arg::new("log")
106 .short('l')
107 .long("log")
108 .help("Enable SSH session logging to ~/.color-ssh/logs/ssh_sessions/")
109 .action(clap::ArgAction::SetTrue),
110 )
111 .arg(
112 Arg::new("profile")
113 .short('P')
114 .long("profile")
115 .help("Specify a configuration profile to use")
116 .value_parser(clap::builder::ValueParser::new(validation::parse_profile_name))
117 .num_args(1)
118 .required(false),
119 )
120 .arg(
121 Arg::new("test")
122 .short('t')
123 .long("test")
124 .help("Ignore config logging settings; only use CLI -d/-l logging flags")
125 .action(clap::ArgAction::SetTrue),
126 )
127 .arg(
128 Arg::new("pass_entry")
129 .long("pass-entry")
130 .help("Override the optional password vault entry used for a direct protocol launch")
131 .num_args(1)
132 .value_name("name")
133 .value_parser(clap::builder::ValueParser::new(validation::parse_vault_entry_name)),
134 )
135 .arg(
136 Arg::new("migrate")
137 .long("migrate")
138 .help("Migrate ~/.ssh/config host entries into ~/.color-ssh/cossh-inventory.yaml")
139 .action(clap::ArgAction::SetTrue)
140 .conflicts_with_all(["log", "profile", "test", "pass_entry"]),
141 )
142 .subcommand(
143 Command::new("ssh")
144 .about("Launch an SSH session by forwarding arguments to the SSH command")
145 .arg(
146 Arg::new("ssh_args")
147 .help("SSH arguments to forward to the SSH command")
148 .required(true)
149 .num_args(1..)
150 .trailing_var_arg(true)
151 .allow_hyphen_values(true),
152 ),
153 )
154 .subcommand(
155 Command::new("rdp")
156 .about("Launch an RDP session using xfreerdp3 or xfreerdp")
157 .arg(Arg::new("target").help("RDP target host or configured alias").required(true))
158 .arg(Arg::new("user").short('u').long("user").help("Override the RDP username").num_args(1))
159 .arg(Arg::new("domain").short('D').long("domain").help("Override the RDP domain").num_args(1))
160 .arg(
161 Arg::new("port")
162 .short('p')
163 .long("port")
164 .help("Override the RDP port")
165 .num_args(1)
166 .value_parser(clap::value_parser!(u16)),
167 )
168 .arg(
169 Arg::new("rdp_args")
170 .help("Additional xfreerdp3/xfreerdp arguments")
171 .num_args(0..)
172 .trailing_var_arg(true)
173 .allow_hyphen_values(true),
174 ),
175 )
176 .subcommand(
177 Command::new("vault")
178 .about("Manage the password vault")
179 .subcommand_required(true)
180 .arg_required_else_help(true)
181 .subcommand(Command::new("init").about("Initialize the password vault"))
182 .subcommand(
183 Command::new("add").about("Create or replace a password vault entry interactively").arg(
184 Arg::new("name")
185 .help("Password entry name")
186 .required(true)
187 .value_parser(clap::builder::ValueParser::new(validation::parse_vault_entry_name)),
188 ),
189 )
190 .subcommand(
191 Command::new("remove").about("Remove a password vault entry").arg(
192 Arg::new("name")
193 .help("Password entry name")
194 .required(true)
195 .value_parser(clap::builder::ValueParser::new(validation::parse_vault_entry_name)),
196 ),
197 )
198 .subcommand(Command::new("list").about("List password vault entries"))
199 .subcommand(Command::new("unlock").about("Unlock the shared password vault"))
200 .subcommand(Command::new("lock").about("Lock the shared password vault"))
201 .subcommand(Command::new("status").about("Show shared password vault status"))
202 .subcommand(Command::new("set-master-password").about("Create or rotate the password vault master password")),
203 )
204 .subcommand(
205 Command::new("agent")
206 .hide(true)
207 .subcommand_required(false)
208 .arg_required_else_help(false)
209 .arg(Arg::new("serve").long("serve").hide(true).action(clap::ArgAction::SetTrue)),
210 )
211 .subcommand(
212 Command::new("__complete")
213 .hide(true)
214 .subcommand_required(true)
215 .arg_required_else_help(true)
216 .subcommand(
217 Command::new("hosts").hide(true).arg(
218 Arg::new("protocol")
219 .long("protocol")
220 .num_args(1)
221 .default_value("all")
222 .value_parser(["all", "ssh", "rdp"]),
223 ),
224 ),
225 )
226 .after_help(
227 r"
228cossh # Launch interactive session manager
229cossh -d ssh user@example.com # Safe debug enabled
230cossh --pass-entry office_fw <ssh/rdp> host.example.com # Override the password entry for this launch
231cossh -l ssh user@example.com # SSH logging enabled
232cossh -l -P network ssh user@firewall.example.com # Use 'network' config profile
233cossh -l ssh user@host -p 2222 # Both modes with SSH args
234cossh ssh user@host -G # Non-interactive command
235cossh rdp desktop01 # Launch a configured RDP host
236cossh --migrate # Import ~/.ssh/config into the YAML inventory
237",
238 )
239}
240
241fn parse_completion_protocol(value: &str) -> CompletionProtocol {
242 match value.to_ascii_lowercase().as_str() {
243 "ssh" => CompletionProtocol::Ssh,
244 "rdp" => CompletionProtocol::Rdp,
245 _ => CompletionProtocol::All,
246 }
247}
248
249fn parse_completion_command(completion_matches: &clap::ArgMatches) -> Option<MainCommand> {
250 match completion_matches.subcommand() {
251 Some(("hosts", hosts_matches)) => {
252 let protocol = hosts_matches.get_one::<String>("protocol").map(|value| parse_completion_protocol(value));
253 Some(MainCommand::CompletionHosts(protocol.unwrap_or(CompletionProtocol::All)))
254 }
255 _ => None,
256 }
257}
258
259fn parse_ssh_command(ssh_matches: &clap::ArgMatches) -> Option<SshCommandArgs> {
260 let ssh_args: Vec<String> = ssh_matches
261 .get_many::<String>("ssh_args")
262 .map(|vals| vals.cloned().collect())
263 .unwrap_or_default();
264 if ssh_args.is_empty() {
265 return None;
266 }
267
268 Some(SshCommandArgs {
269 is_non_interactive: ssh_args::is_non_interactive_ssh_invocation(&ssh_args),
270 ssh_args,
271 })
272}
273
274fn parse_vault_command(vault_matches: &clap::ArgMatches) -> Option<VaultCommand> {
275 match vault_matches.subcommand() {
276 Some(("init", _)) => Some(VaultCommand::Init),
277 Some(("add", add_pass_matches)) => add_pass_matches.get_one::<String>("name").cloned().map(VaultCommand::AddPass),
278 Some(("remove", remove_pass_matches)) => remove_pass_matches.get_one::<String>("name").cloned().map(VaultCommand::RemovePass),
279 Some(("list", _)) => Some(VaultCommand::List),
280 Some(("unlock", _)) => Some(VaultCommand::Unlock),
281 Some(("lock", _)) => Some(VaultCommand::Lock),
282 Some(("status", _)) => Some(VaultCommand::Status),
283 Some(("set-master-password", _)) => Some(VaultCommand::SetMasterPassword),
284 _ => None,
285 }
286}
287
288fn parse_rdp_command(rdp_matches: &clap::ArgMatches) -> Option<RdpCommandArgs> {
289 let target = rdp_matches.get_one::<String>("target")?.trim().to_string();
290 if target.is_empty() {
291 return None;
292 }
293
294 Some(RdpCommandArgs {
295 target,
296 user: rdp_matches.get_one::<String>("user").cloned().filter(|value| !value.trim().is_empty()),
297 domain: rdp_matches.get_one::<String>("domain").cloned().filter(|value| !value.trim().is_empty()),
298 port: rdp_matches.get_one::<u16>("port").copied(),
299 extra_args: rdp_matches
300 .get_many::<String>("rdp_args")
301 .map(|values| values.cloned().collect())
302 .unwrap_or_default(),
303 })
304}
305
306fn parse_main_command(matches: &clap::ArgMatches) -> Option<MainCommand> {
307 if matches.get_flag("migrate") {
308 return Some(MainCommand::MigrateInventory);
309 }
310
311 match matches.subcommand()? {
312 ("ssh", ssh_matches) => parse_ssh_command(ssh_matches).map(ProtocolCommand::Ssh).map(MainCommand::Protocol),
313 ("rdp", rdp_matches) => parse_rdp_command(rdp_matches).map(ProtocolCommand::Rdp).map(MainCommand::Protocol),
314 ("vault", vault_matches) => parse_vault_command(vault_matches).map(MainCommand::Vault),
315 ("agent", agent_matches) if agent_matches.get_flag("serve") => Some(MainCommand::AgentServe),
316 ("__complete", completion_matches) => parse_completion_command(completion_matches),
317 _ => None,
318 }
319}
320
321fn validate_main_args(cmd: &Command, matches: &clap::ArgMatches, parsed: &MainArgs) -> Result<(), clap::Error> {
322 if matches.get_flag("migrate") && matches.subcommand_name().is_some() {
323 return Err(cmd
324 .clone()
325 .error(ErrorKind::ArgumentConflict, "`--migrate` cannot be combined with subcommands"));
326 }
327
328 if matches!(parsed.command, Some(MainCommand::MigrateInventory)) && parsed.interactive {
329 return Err(cmd
330 .clone()
331 .error(ErrorKind::ArgumentConflict, "`--migrate` cannot be combined with interactive mode"));
332 }
333
334 Ok(())
335}
336
337fn parse_main_args_from<I, T>(cmd: &Command, raw_args: I) -> MainArgs
338where
339 I: IntoIterator<Item = T>,
340 T: Into<OsString> + Clone,
341{
342 try_parse_main_args_from(cmd, raw_args).unwrap_or_else(|err| err.exit())
343}
344
345fn try_parse_main_args_from<I, T>(cmd: &Command, raw_args: I) -> Result<MainArgs, clap::Error>
346where
347 I: IntoIterator<Item = T>,
348 T: Into<OsString> + Clone,
349{
350 let raw_args: Vec<OsString> = raw_args.into_iter().map(Into::into).collect();
351
352 let matches = cmd.clone().try_get_matches_from(raw_args.clone())?;
353
354 let debug_count = matches.get_count("debug");
355 let ssh_logging = matches.get_flag("log");
356 let test_mode = matches.get_flag("test");
357 let profile = matches.get_one::<String>("profile").cloned().filter(|profile_name| !profile_name.is_empty());
358 let pass_entry = matches.get_one::<String>("pass_entry").cloned().filter(|value| !value.is_empty());
359 let command = parse_main_command(&matches);
360 let no_user_args = raw_args.len() <= 1;
361 let debug_only = debug_count > 0 && !ssh_logging && profile.is_none() && pass_entry.is_none() && command.is_none();
362 let interactive = (no_user_args || debug_only) && command.is_none();
363
364 let parsed = MainArgs {
365 debug_count,
366 ssh_logging,
367 test_mode,
368 profile,
369 interactive,
370 pass_entry,
371 command,
372 };
373 validate_main_args(cmd, &matches, &parsed)?;
374 Ok(parsed)
375}
376
377pub fn main_args() -> MainArgs {
379 let cmd = build_cli_command();
380 let parsed = parse_main_args_from(&cmd, std::env::args_os());
381
382 if matches!(parsed.command, Some(MainCommand::AgentServe) | Some(MainCommand::CompletionHosts(_))) {
383 return parsed;
384 }
385
386 if parsed.command.is_none() && !parsed.interactive {
387 let mut help_cmd = cmd;
388 let _ = help_cmd.print_long_help();
389 println!();
390 std::process::exit(2);
391 }
392
393 parsed
394}
395
396#[cfg(test)]
397#[path = "test/args.rs"]
398mod tests;