1#![forbid(unsafe_code)]
7
8use super::{
9 command_tree_json, effective_timeout_ms, generate_completions, parse_exec_target,
10 parse_hosts_list, parse_remote_steps, read_stdin_if, warn_if_password_argv, CliArgs, Command,
11 LocaleAction, OutputFormat, ScpAction, SftpAction,
12};
13use anyhow::Result;
14
15pub async fn dispatch(args: CliArgs) -> Result<()> {
20 dispatch_impl(args).await
21}
22
23pub async fn dispatch_impl(args: CliArgs) -> Result<()> {
25 let config_override = args.config_dir.clone();
26 crate::secrets::set_config_dir(config_override.clone());
28 crate::secrets::set_runtime_flags(
29 args.allow_plaintext_secrets,
30 args.secrets_key_file.clone(),
31 args.use_keyring,
32 );
33 let limit = crate::concurrency::resolve_limit(args.max_concurrency.map(usize::from));
35 crate::concurrency::install_process_limit(limit);
36 crate::concurrency::install_fail_fast(args.fail_fast);
37 if let Some(n) = args.scp_file_concurrency {
38 crate::concurrency::install_scp_file_concurrency(usize::from(n));
39 }
40 let formato = super::resolve_format_from_cli(args.json, args.output_format)?;
42 crate::output::set_quiet(args.quiet);
44 crate::output::set_json_errors(formato == OutputFormat::Json);
45 let disable_sudo = args.disable_sudo;
46 let replace_host_key = args.replace_host_key;
47 let global_timeout = args.timeout;
49
50 warn_if_password_argv(&args);
52
53 super::guard_dry_run_supported(&args.command)?;
58
59 match args.command {
60 Command::Vps { action } => {
61 crate::vps::run_vps_command(action, config_override, formato).await
64 }
65 Command::Connect { name } => {
66 crate::vps::run_connect(&name, config_override, formato).await
68 }
69 Command::Exec {
70 all,
71 hosts,
72 tags,
73 target,
74 steps,
75 json,
76 auth,
77 timeout,
78 description,
79 } => {
80 let (selection, command) = parse_exec_target(
81 all,
82 hosts,
83 tags,
84 target,
85 crate::vps::read_active_vps(config_override.as_deref())?,
86 )
87 .map_err(|s| {
88 if s.starts_with("no active VPS") {
89 crate::errors::SshCliError::NoActiveVps
90 } else {
91 crate::errors::SshCliError::InvalidArgument(s)
92 }
93 })?;
94 let key = auth.key_path_string();
95 let password = read_stdin_if(auth.password_stdin, auth.password)?;
96 let key_passphrase = read_stdin_if(auth.key_passphrase_stdin, auth.key_passphrase)?;
97 let steps =
98 parse_remote_steps(steps).map_err(crate::errors::SshCliError::InvalidArgument)?;
99 let opts = crate::vps::ExecOptions {
100 password,
101 key,
102 key_passphrase,
103 timeout: effective_timeout_ms(timeout, global_timeout)
104 .map_err(crate::errors::SshCliError::InvalidArgument)?,
105 description,
106 replace_host_key,
107 disable_sudo,
108 steps,
109 use_agent: auth.use_agent,
110 agent_socket: auth
111 .agent_socket
112 .as_ref()
113 .map(|p| p.to_string_lossy().into_owned()),
114 ..Default::default()
115 };
116 crate::vps::run_exec(selection, &command, config_override, formato, json, opts).await
117 }
118 Command::SudoExec {
119 all,
120 hosts,
121 tags,
122 target,
123 steps,
124 json,
125 auth,
126 sudo_password,
127 sudo_password_stdin,
128 timeout,
129 description,
130 } => {
131 let (selection, command) = parse_exec_target(
132 all,
133 hosts,
134 tags,
135 target,
136 crate::vps::read_active_vps(config_override.as_deref())?,
137 )
138 .map_err(|s| {
139 if s.starts_with("no active VPS") {
140 crate::errors::SshCliError::NoActiveVps
141 } else {
142 crate::errors::SshCliError::InvalidArgument(s)
143 }
144 })?;
145 let key = auth.key_path_string();
146 let password = read_stdin_if(auth.password_stdin, auth.password)?;
147 let sudo_password = read_stdin_if(sudo_password_stdin, sudo_password)?;
148 let key_passphrase = read_stdin_if(auth.key_passphrase_stdin, auth.key_passphrase)?;
149 let steps =
150 parse_remote_steps(steps).map_err(crate::errors::SshCliError::InvalidArgument)?;
151 let opts = crate::vps::ExecOptions {
152 password,
153 sudo_password,
154 key,
155 key_passphrase,
156 timeout: effective_timeout_ms(timeout, global_timeout)
157 .map_err(crate::errors::SshCliError::InvalidArgument)?,
158 description,
159 replace_host_key,
160 disable_sudo,
161 steps,
162 use_agent: auth.use_agent,
163 agent_socket: auth
164 .agent_socket
165 .as_ref()
166 .map(|p| p.to_string_lossy().into_owned()),
167 ..Default::default()
168 };
169 crate::vps::run_sudo_exec(selection, &command, config_override, formato, json, opts)
170 .await
171 }
172 Command::SuExec {
173 all,
174 hosts,
175 tags,
176 target,
177 steps,
178 json,
179 auth,
180 su_password,
181 su_password_stdin,
182 timeout,
183 description,
184 } => {
185 let (selection, command) = parse_exec_target(
186 all,
187 hosts,
188 tags,
189 target,
190 crate::vps::read_active_vps(config_override.as_deref())?,
191 )
192 .map_err(|s| {
193 if s.starts_with("no active VPS") {
194 crate::errors::SshCliError::NoActiveVps
195 } else {
196 crate::errors::SshCliError::InvalidArgument(s)
197 }
198 })?;
199 let key = auth.key_path_string();
200 let password = read_stdin_if(auth.password_stdin, auth.password)?;
201 let su_password = read_stdin_if(su_password_stdin, su_password)?;
202 let key_passphrase = read_stdin_if(auth.key_passphrase_stdin, auth.key_passphrase)?;
203 let steps =
204 parse_remote_steps(steps).map_err(crate::errors::SshCliError::InvalidArgument)?;
205 let opts = crate::vps::ExecOptions {
206 password,
207 su_password,
208 key,
209 key_passphrase,
210 timeout: effective_timeout_ms(timeout, global_timeout)
211 .map_err(crate::errors::SshCliError::InvalidArgument)?,
212 description,
213 replace_host_key,
214 disable_sudo,
215 steps,
216 use_agent: auth.use_agent,
217 agent_socket: auth
218 .agent_socket
219 .as_ref()
220 .map(|p| p.to_string_lossy().into_owned()),
221 ..Default::default()
222 };
223 crate::vps::run_su_exec(selection, &command, config_override, formato, json, opts).await
224 }
225 Command::Scp { action } => {
226 let (auth, timeout, json_local) = match &action {
227 ScpAction::Upload {
228 auth,
229 timeout,
230 json,
231 ..
232 }
233 | ScpAction::Download {
234 auth,
235 timeout,
236 json,
237 ..
238 } => (auth.clone(), *timeout, *json),
239 };
240 let key = auth.key_path_string();
241 let password = read_stdin_if(auth.password_stdin, auth.password)?;
242 let key_passphrase = read_stdin_if(auth.key_passphrase_stdin, auth.key_passphrase)?;
243 let json_efetivo = json_local || formato == OutputFormat::Json;
245 if json_efetivo {
246 crate::output::set_json_errors(true);
247 }
248 crate::scp::run_scp(
249 action,
250 config_override,
251 crate::scp::ScpOptions {
252 password,
253 key,
254 key_passphrase,
255 timeout: effective_timeout_ms(timeout, global_timeout)
256 .map_err(crate::errors::SshCliError::InvalidArgument)?,
257 replace_host_key,
258 json: json_efetivo,
259 use_agent: auth.use_agent,
260 agent_socket: auth
261 .agent_socket
262 .as_ref()
263 .map(|p| p.to_string_lossy().into_owned()),
264 },
265 )
266 .await
267 }
268 Command::Sftp { action } => {
269 let (auth, timeout, json_local) = match &action {
270 SftpAction::Upload {
271 auth,
272 timeout,
273 json,
274 ..
275 }
276 | SftpAction::Download {
277 auth,
278 timeout,
279 json,
280 ..
281 }
282 | SftpAction::Ls {
283 auth,
284 timeout,
285 json,
286 ..
287 }
288 | SftpAction::Mkdir {
289 auth,
290 timeout,
291 json,
292 ..
293 }
294 | SftpAction::Rmdir {
295 auth,
296 timeout,
297 json,
298 ..
299 }
300 | SftpAction::Rm {
301 auth,
302 timeout,
303 json,
304 ..
305 }
306 | SftpAction::Stat {
307 auth,
308 timeout,
309 json,
310 ..
311 }
312 | SftpAction::Rename {
313 auth,
314 timeout,
315 json,
316 ..
317 } => (auth.clone(), *timeout, *json),
318 };
319 let key = auth.key_path_string();
320 let password = read_stdin_if(auth.password_stdin, auth.password)?;
321 let key_passphrase = read_stdin_if(auth.key_passphrase_stdin, auth.key_passphrase)?;
322 let json_efetivo = json_local || formato == OutputFormat::Json;
323 if json_efetivo {
324 crate::output::set_json_errors(true);
325 }
326 #[cfg(not(feature = "ssh-real"))]
331 {
332 let _ = (
333 action,
334 config_override,
335 password,
336 key,
337 key_passphrase,
338 timeout,
339 );
340 return Err(crate::errors::SshCliError::InvalidArgument(
341 "this binary was built without the `ssh-real` feature; sftp is unavailable"
342 .to_string(),
343 )
344 .into());
345 }
346 #[cfg(feature = "ssh-real")]
347 crate::sftp::run_sftp(
348 action,
349 config_override,
350 crate::sftp::SftpOptions {
351 password,
352 key,
353 key_passphrase,
354 timeout: effective_timeout_ms(timeout, global_timeout)
355 .map_err(crate::errors::SshCliError::InvalidArgument)?,
356 replace_host_key,
357 json: json_efetivo,
358 use_agent: auth.use_agent,
359 agent_socket: auth
360 .agent_socket
361 .as_ref()
362 .map(|p| p.to_string_lossy().into_owned()),
363 recursive: false, },
365 )
366 .await
367 }
368 Command::Tunnel {
369 vps_name,
370 local_port,
371 remote_host,
372 remote_port,
373 socks5,
374 remote_socket,
375 reverse,
376 timeout_ms,
377 auth,
378 json,
379 bind,
380 i_accept_network_exposure,
381 } => {
382 let json_efetivo = json || formato == OutputFormat::Json;
384 if json_efetivo {
385 crate::output::set_json_errors(true);
386 }
387 let key = auth.key_path_string();
391 let password = read_stdin_if(auth.password_stdin, auth.password)?;
392 let key_passphrase = read_stdin_if(auth.key_passphrase_stdin, auth.key_passphrase)?;
393 let mode = super::resolve_tunnel_mode(
397 socks5,
398 remote_socket,
399 reverse,
400 remote_host,
401 remote_port,
402 )?;
403 crate::tunnel::run_tunnel(crate::tunnel::TunnelRequest {
404 vps_name,
405 local_port,
406 mode,
407 config_override,
408 auth: crate::tunnel::TunnelAuth {
409 password,
410 key,
411 key_passphrase,
412 use_agent: auth.use_agent,
413 agent_socket: auth
414 .agent_socket
415 .as_ref()
416 .map(|p| p.to_string_lossy().into_owned()),
417 },
418 timeout_ms,
419 replace_host_key,
420 json: json_efetivo,
421 bind_addr: bind.to_string(),
422 accept_network_exposure: i_accept_network_exposure,
423 })
424 .await
425 }
426 Command::HealthCheck {
427 vps_name,
428 all,
429 hosts,
430 json,
431 auth,
432 timeout,
433 } => {
434 let key = auth.key_path_string();
436 let password = read_stdin_if(auth.password_stdin, auth.password)?;
437 let key_passphrase = read_stdin_if(auth.key_passphrase_stdin, auth.key_passphrase)?;
438 let selection = if all {
439 crate::vps::HostSelection::All
440 } else if let Some(h) = hosts {
441 let names = parse_hosts_list(&h);
442 if names.is_empty() {
443 return Err(crate::errors::SshCliError::InvalidArgument(
444 "--hosts requires at least one host name".into(),
445 )
446 .into());
447 }
448 let names = names
449 .into_iter()
450 .map(crate::domain::VpsName::try_new)
451 .collect::<Result<Vec<_>, _>>()
452 .map_err(|e| crate::errors::SshCliError::InvalidArgument(e.to_string()))?;
453 crate::vps::HostSelection::Named(names)
454 } else {
455 let name = match vps_name {
456 Some(n) => n,
457 None => {
458 let active = crate::vps::read_active_vps(config_override.as_deref())?;
460 active.ok_or(crate::errors::SshCliError::NoActiveVps)?
461 }
462 };
463 let name = crate::domain::VpsName::try_new(name)
464 .map_err(|e| crate::errors::SshCliError::InvalidArgument(e.to_string()))?;
465 crate::vps::HostSelection::Single(name)
466 };
467 crate::vps::run_health_check(crate::vps::HealthCheckRequest {
468 selection,
469 config_override,
470 format: formato,
471 json_local: json,
472 password_override: password,
473 timeout_override: effective_timeout_ms(timeout, global_timeout)
474 .map_err(crate::errors::SshCliError::InvalidArgument)?,
475 key_override: key,
476 key_passphrase_override: key_passphrase,
477 replace_host_key,
478 })
479 .await
480 }
481 Command::Secrets { action } => {
482 crate::vps::run_secrets_command(action, config_override, formato).await
484 }
485 Command::Completions { shell } => {
486 generate_completions(shell)
488 }
489 Command::Commands { json: _ } => {
490 crate::output::print_json_value(&command_tree_json())?;
493 Ok(())
494 }
495 Command::Schema { name, json } => {
496 let wants_json = json || formato == OutputFormat::Json;
498 crate::cli::run_schema(name.as_deref(), wants_json).map_err(Into::into)
499 }
500 Command::Doctor {
501 json,
502 probe_ssh,
503 hosts,
504 } => {
505 crate::vps::run_vps_command(
507 crate::cli::VpsAction::Doctor {
508 json,
509 probe_ssh,
510 hosts,
511 },
512 config_override,
513 formato,
514 )
515 .await
516 }
517 Command::Tls { json, action } => {
518 #[cfg(feature = "tls")]
519 {
520 crate::tls::commands::run_tls_command(action, config_override, formato, json).await
521 }
522 #[cfg(not(feature = "tls"))]
523 {
524 let _ = (action, json);
525 Err(crate::errors::SshCliError::tls_msg(
526 "TLS feature disabled; rebuild with --features tls (default)",
527 )
528 .into())
529 }
530 }
531 Command::Locale { json, action } => {
532 run_locale_command(
534 action,
535 config_override.as_deref(),
536 formato,
537 args.lang.as_deref(),
538 json,
539 )
540 }
541 }
542}
543
544pub(crate) fn run_locale_command(
546 action: Option<LocaleAction>,
547 config_override: Option<&std::path::Path>,
548 formato: OutputFormat,
549 force_lang: Option<&str>,
550 json_flag: bool,
551) -> Result<()> {
552 use crate::i18n::{self, Message};
553 use crate::locale::{
554 clear_persisted_lang, current_language, lang_preference_path, negotiate_code,
555 resolve_language_detailed, write_persisted_lang,
556 };
557
558 let action = action.unwrap_or(LocaleAction::Show);
559 let override_ref = config_override;
560 let wants_json = json_flag || formato == OutputFormat::Json;
561
562 match action {
563 LocaleAction::Show => {
564 let detailed = resolve_language_detailed(force_lang, override_ref);
566 let available: Vec<&str> = crate::i18n::Language::AVAILABLE
567 .iter()
568 .map(|l| l.bcp47())
569 .collect();
570 let pref_path = lang_preference_path(override_ref)
571 .map(|p| p.display().to_string())
572 .unwrap_or_else(|| String::from("(unavailable)"));
573
574 if wants_json {
575 let body = serde_json::json!({
576 "resolved": detailed.language.bcp47(),
577 "current": current_language().bcp47(),
578 "source": detailed.source.as_str(),
579 "available": available,
580 "system_raw": detailed.system_raw,
581 "persisted_raw": detailed.persisted_raw,
582 "preference_path": pref_path,
583 "direction": match detailed.language.direction() {
584 crate::i18n::TextDirection::Ltr => "ltr",
585 crate::i18n::TextDirection::Rtl => "rtl",
586 },
587 "script": detailed.language.script(),
588 });
589 crate::output::print_json_value(&body)?;
590 } else {
591 crate::output::write_line(&i18n::t(Message::LocaleStatusTitle))?;
592 crate::output::write_line_fmt(format_args!(
593 " resolved: {} ({})",
594 detailed.language.bcp47(),
595 detailed.source.as_str()
596 ))?;
597 crate::output::write_line_fmt(format_args!(
598 " current: {}",
599 current_language().bcp47()
600 ))?;
601 crate::output::write_line_fmt(format_args!(
602 " available: {}",
603 available.join(", ")
604 ))?;
605 crate::output::write_line_fmt(format_args!(
606 " system: {}",
607 detailed.system_raw.as_deref().unwrap_or("(none)")
608 ))?;
609 crate::output::write_line_fmt(format_args!(
610 " persisted: {}",
611 detailed.persisted_raw.as_deref().unwrap_or("(none)")
612 ))?;
613 crate::output::write_line_fmt(format_args!(" pref_path: {pref_path}"))?;
614 }
615 Ok(())
616 }
617 LocaleAction::Set { lang } => {
618 let language = negotiate_code(&lang)
619 .ok_or_else(|| anyhow::anyhow!("unsupported language after validation: {lang}"))?;
620 let path = write_persisted_lang(language, override_ref)?;
621 if wants_json {
624 crate::output::print_json_value(&serde_json::json!({
625 "ok": true,
626 "lang": language.bcp47(),
627 "path": path.display().to_string(),
628 "applies": "next_invocation_unless_overridden",
629 }))?;
630 } else {
631 crate::output::print_success(&i18n::t(Message::LocalePreferenceSaved {
632 lang: language.bcp47().to_string(),
633 path: path.display().to_string(),
634 }));
635 }
636 Ok(())
637 }
638 LocaleAction::Clear => {
639 clear_persisted_lang(override_ref)?;
640 if wants_json {
641 crate::output::print_json_value(&serde_json::json!({
642 "ok": true,
643 "cleared": true,
644 }))?;
645 } else {
646 crate::output::print_success(&i18n::t(Message::LocalePreferenceCleared));
647 }
648 Ok(())
649 }
650 }
651}