1#![forbid(unsafe_code)]
4use super::config_io::{load, lock_config, resolve_config_path, validate_key_path_exists};
7use super::doctor::run_doctor_with_optional_probe;
8use super::health::run_health_check;
9use super::import_export::{run_export, run_import};
10use super::model::{self, VpsRecord};
11use super::secrets_cmd::take_auto_key_meta;
12use super::selection::HostSelection;
13use super::{read_secret_stdin, use_json};
14use crate::cli::{OutputFormat, VpsAction};
15use crate::errors::SshCliError;
16use anyhow::Result;
17use secrecy::SecretString;
18use std::path::{Path, PathBuf};
19
20pub async fn run_vps_command(
22 action: VpsAction,
23 config_override: Option<PathBuf>,
24 format: OutputFormat,
25) -> Result<()> {
26 let path = resolve_config_path(config_override.as_deref())?;
27
28 match action {
29 VpsAction::Add {
30 name,
31 host,
32 port,
33 user,
34 password,
35 password_stdin,
36 key,
37 key_passphrase,
38 key_passphrase_stdin,
39 use_agent,
40 agent_socket,
41 timeout,
42 max_command_chars,
43 max_output_chars,
44 max_chars,
45 sudo_password,
46 sudo_password_stdin,
47 su_password,
48 su_password_stdin,
49 disable_sudo,
50 tags,
51 tls,
52 tls_sni,
53 tls_client_cert,
54 tls_client_key,
55 check,
56 } => {
57 let name = crate::paths::validate_and_normalize(&name)
59 .map_err(|e| SshCliError::InvalidArgument(format!("invalid VPS name: {e}")))?;
60 let name_key = name.as_str().to_owned();
61 if load(&path)?.hosts.contains_key(&name_key) {
64 return Err(SshCliError::VpsDuplicate(name_key).into());
65 }
66 let stdin_secrets = usize::from(password_stdin)
72 + usize::from(key_passphrase_stdin)
73 + usize::from(sudo_password_stdin)
74 + usize::from(su_password_stdin);
75 if stdin_secrets > 1 {
76 return Err(SshCliError::InvalidArgument(
77 "only one --*-stdin per one-shot invocation (stdin is drained once); \
78 use vps edit for the remaining secrets"
79 .into(),
80 )
81 .into());
82 }
83 let password = if password_stdin {
84 read_secret_stdin()?
85 } else {
86 SecretString::from(password.unwrap_or_default())
87 };
88 let key_passphrase = if key_passphrase_stdin {
89 Some(read_secret_stdin()?)
90 } else {
91 key_passphrase.map(SecretString::from)
92 };
93 let sudo_s = if sudo_password_stdin {
94 Some(read_secret_stdin()?)
95 } else {
96 sudo_password.map(SecretString::from)
97 };
98 let su_s = if su_password_stdin {
99 Some(read_secret_stdin()?)
100 } else {
101 su_password.map(SecretString::from)
102 };
103 let key = key.map(|p| p.to_string_lossy().into_owned());
104 if let Some(ref k) = key {
105 validate_key_path_exists(k)?;
106 }
107 let max_cmd = max_command_chars
110 .or(max_chars)
111 .unwrap_or(model::DEFAULT_MAX_COMMAND_CHARS);
112 let max_out = max_output_chars.unwrap_or(model::DEFAULT_MAX_OUTPUT_CHARS);
113 if timeout > 0 && timeout < 1000 {
115 crate::output::print_warning_fmt(format_args!(
116 "--timeout {timeout} is only {timeout}ms (< 1s); did you mean seconds? Use e.g. --timeout 5000 for 5s"
117 ));
118 }
119 let mut record = VpsRecord::try_new(
120 name.as_str(),
121 host,
122 port,
123 user,
124 password,
125 key,
126 key_passphrase,
127 Some(timeout),
128 Some(max_cmd),
129 Some(max_out),
130 sudo_s,
131 su_s,
132 disable_sudo,
133 )
134 .map_err(SshCliError::InvalidArgument)?;
135 if use_agent {
137 record.use_agent = true;
138 record.password = SecretString::from(String::new());
139 record.key_path = None;
140 record.key_passphrase = None;
141 record.agent_socket = agent_socket.map(|p| p.to_string_lossy().into_owned());
142 }
143 let tag_list = crate::vps::selection::dedupe_host_names(tags);
145 record
146 .set_tags_from_raw(tag_list)
147 .map_err(SshCliError::from)?;
148 record.tls = tls;
149 record.tls_sni = tls_sni;
150 record.tls_client_cert = tls_client_cert.map(|p| p.to_string_lossy().into_owned());
151 record.tls_client_key = tls_client_key.map(|p| p.to_string_lossy().into_owned());
152 if record.tls {
153 let sni = record
155 .tls_sni
156 .as_deref()
157 .filter(|s| !s.trim().is_empty())
158 .unwrap_or(record.host.as_str());
159 let _ = crate::tls::TlsConnectOptions::try_new(
160 sni,
161 record
162 .tls_client_cert
163 .as_ref()
164 .map(std::path::PathBuf::from),
165 record.tls_client_key.as_ref().map(std::path::PathBuf::from),
166 )?;
167 }
168 record.validate().map_err(SshCliError::from)?;
170 let guard = lock_config(&path)?;
173 let mut file = load(&path)?;
174 if file.hosts.contains_key(&name_key) {
175 return Err(SshCliError::VpsDuplicate(name_key).into());
176 }
177 file.hosts.insert(name_key.clone(), record);
178 file.schema_version = model::CURRENT_SCHEMA_VERSION;
179 guard.save(&path, &file)?;
180 drop(guard);
182 let auto_key = take_auto_key_meta();
185 let mut data = serde_json::json!({ "name": name_key });
186 if let Some(ref meta) = auto_key {
187 data["secrets_key_auto_created"] = serde_json::Value::Bool(true);
188 data["key_file"] = serde_json::Value::String(meta.key_file.clone());
189 data["key_source"] = serde_json::Value::String(meta.key_source.to_owned());
190 } else {
191 data["secrets_key_auto_created"] = serde_json::Value::Bool(false);
192 }
193 let msg = if let Some(ref meta) = auto_key {
194 format!(
195 "{}; primary-key auto-created at {}",
196 crate::i18n::t(crate::i18n::Message::VpsAdded {
197 name: name_key.clone(),
198 }),
199 meta.key_file
200 )
201 } else {
202 crate::i18n::t(crate::i18n::Message::VpsAdded {
203 name: name_key.clone(),
204 })
205 };
206 crate::output::emit_success("vps-added", data, &msg, format == OutputFormat::Json)?;
207 if check {
208 run_health_check(crate::vps::HealthCheckRequest {
209 selection: HostSelection::Single(name.clone()),
210 config_override,
211 format,
212 json_local: false,
213 password_override: None,
214 timeout_override: None,
215 key_override: None,
216 key_passphrase_override: None,
217 replace_host_key: false,
218 })
219 .await?;
220 }
221 }
222 VpsAction::List { json, tags } => {
223 let file = load(&path)?;
224 let records: Vec<_> = if tags.is_empty() {
225 file.hosts.values().cloned().collect()
226 } else {
227 {
228 let wanted = crate::domain::try_tags(&tags)
229 .map_err(|e| SshCliError::InvalidArgument(e.to_string()))?;
230 file.hosts
231 .values()
232 .filter(|r| r.has_any_tag(&wanted))
233 .cloned()
234 .collect()
235 }
236 };
237 if use_json(json, format) {
239 crate::output::print_list_json(&records)?;
240 } else {
241 crate::output::print_list_text(&records);
242 }
243 }
244 VpsAction::Remove { name } => {
245 let guard = lock_config(&path)?;
247 let mut file = load(&path)?;
248 if !file.hosts.contains_key(&name) {
249 return Err(SshCliError::VpsNotFound(name).into());
250 }
251 if crate::cli::dry_run_stop(
256 "vps-remove",
257 &[
258 ("name", serde_json::json!(name)),
259 ("config_path", serde_json::json!(path.display().to_string())),
260 ],
261 )? {
262 return Ok(());
263 }
264 file.hosts.remove(&name);
265 guard.save(&path, &file)?;
266 drop(guard);
267 clear_active_if_name(&path, &name)?;
269 crate::output::emit_success(
270 "vps-removed",
271 serde_json::json!({ "name": name }),
272 &crate::i18n::t(crate::i18n::Message::VpsRemoved { name: name.clone() }),
273 format == OutputFormat::Json,
274 )?;
275 }
276 VpsAction::Edit {
277 name,
278 host,
279 port,
280 user,
281 password,
282 password_stdin,
283 key,
284 key_passphrase,
285 key_passphrase_stdin,
286 use_agent,
287 agent_socket,
288 timeout,
289 max_command_chars,
290 max_output_chars,
291 max_chars,
292 sudo_password,
293 sudo_password_stdin,
294 su_password,
295 su_password_stdin,
296 disable_sudo,
297 enable_sudo,
298 tls,
299 no_tls,
300 tls_sni,
301 tls_client_cert,
302 tls_client_key,
303 } => {
304 let stdin_secrets = usize::from(password_stdin)
308 + usize::from(key_passphrase_stdin)
309 + usize::from(sudo_password_stdin)
310 + usize::from(su_password_stdin);
311 if stdin_secrets > 1 {
312 return Err(SshCliError::InvalidArgument(
313 "only one --*-stdin per one-shot invocation (stdin is drained once); \
314 run vps edit again for the remaining secrets"
315 .into(),
316 )
317 .into());
318 }
319 let password_stdin_value = if password_stdin {
322 Some(read_secret_stdin()?)
323 } else {
324 None
325 };
326 let key_passphrase_stdin_value = if key_passphrase_stdin {
327 Some(read_secret_stdin()?)
328 } else {
329 None
330 };
331 let sudo_stdin_value = if sudo_password_stdin {
332 Some(read_secret_stdin()?)
333 } else {
334 None
335 };
336 let su_stdin_value = if su_password_stdin {
337 Some(read_secret_stdin()?)
338 } else {
339 None
340 };
341 let guard = lock_config(&path)?;
343 let mut file = load(&path)?;
344 let record = file
345 .hosts
346 .get_mut(&name)
347 .ok_or(SshCliError::VpsNotFound(name.clone()))?;
348 use crate::domain::{CharLimit, KeyPath, SshHost, SshPort, SshUser, TimeoutMs};
349 if let Some(h) = host {
350 record.host =
351 SshHost::try_new(h).map_err(|e| SshCliError::InvalidArgument(e.to_string()))?;
352 }
353 if let Some(p) = port {
354 record.port =
355 SshPort::try_new(p).map_err(|e| SshCliError::InvalidArgument(e.to_string()))?;
356 }
357 if let Some(u) = user {
358 record.username =
359 SshUser::try_new(u).map_err(|e| SshCliError::InvalidArgument(e.to_string()))?;
360 }
361 if use_agent {
362 record.use_agent = true;
363 record.password = SecretString::from(String::new());
364 record.key_path = None;
365 record.key_passphrase = None;
366 if let Some(s) = agent_socket {
367 record.agent_socket = Some(s.to_string_lossy().into_owned());
368 }
369 } else {
370 if let Some(pw) = password_stdin_value {
371 record.password = pw;
372 record.use_agent = false;
373 } else if let Some(pw) = password {
374 record.password = SecretString::from(pw);
375 record.use_agent = false;
376 }
377 if let Some(k) = key {
378 let k = k.to_string_lossy().into_owned();
379 validate_key_path_exists(&k)?;
380 record.key_path = Some(
381 KeyPath::try_new(k)
382 .map_err(|e| SshCliError::InvalidArgument(e.to_string()))?,
383 );
384 record.use_agent = false;
385 }
386 if let Some(kp) = key_passphrase_stdin_value {
387 record.key_passphrase = Some(kp);
388 } else if let Some(kp) = key_passphrase {
389 record.key_passphrase = Some(SecretString::from(kp));
390 }
391 if let Some(s) = agent_socket {
392 record.agent_socket = Some(s.to_string_lossy().into_owned());
393 }
394 }
395 if let Some(t) = timeout {
396 record.timeout_ms = TimeoutMs::try_new(t)
397 .map_err(|e| SshCliError::InvalidArgument(e.to_string()))?;
398 }
399 if let Some(m) = max_command_chars.or(max_chars) {
400 record.max_command_chars = CharLimit::try_new(m)
401 .map_err(|e| SshCliError::InvalidArgument(e.to_string()))?;
402 }
403 if let Some(m) = max_output_chars {
404 record.max_output_chars = CharLimit::try_new(m)
405 .map_err(|e| SshCliError::InvalidArgument(e.to_string()))?;
406 }
407 if let Some(sp) = sudo_stdin_value {
408 record.sudo_password = Some(sp);
409 } else if let Some(sp) = sudo_password {
410 record.sudo_password = Some(SecretString::from(sp));
411 }
412 if let Some(sp) = su_stdin_value {
413 record.su_password = Some(sp);
414 } else if let Some(sp) = su_password {
415 record.su_password = Some(SecretString::from(sp));
416 }
417 if disable_sudo {
419 record.disable_sudo = true;
420 } else if enable_sudo {
421 record.disable_sudo = false;
422 }
423 if tls {
424 record.tls = true;
425 } else if no_tls {
426 record.tls = false;
427 }
428 if let Some(sni) = tls_sni {
429 record.tls_sni = Some(sni);
430 }
431 if let Some(c) = tls_client_cert {
432 record.tls_client_cert = Some(c.to_string_lossy().into_owned());
433 }
434 if let Some(k) = tls_client_key {
435 record.tls_client_key = Some(k.to_string_lossy().into_owned());
436 }
437 if record.tls {
438 let sni = record
439 .tls_sni
440 .as_deref()
441 .filter(|s| !s.trim().is_empty())
442 .unwrap_or(record.host.as_str());
443 let _ = crate::tls::TlsConnectOptions::try_new(
444 sni,
445 record
446 .tls_client_cert
447 .as_ref()
448 .map(std::path::PathBuf::from),
449 record.tls_client_key.as_ref().map(std::path::PathBuf::from),
450 )?;
451 }
452 record.validate().map_err(SshCliError::from)?;
453 guard.save(&path, &file)?;
454 drop(guard);
455 crate::output::emit_success(
456 "vps-edited",
457 serde_json::json!({ "name": name }),
458 &crate::i18n::t(crate::i18n::Message::VpsEdited { name: name.clone() }),
459 format == OutputFormat::Json,
460 )?;
461 }
462 VpsAction::Show { name, json } => {
463 let file = load(&path)?;
464 let record = file
465 .hosts
466 .get(&name)
467 .ok_or(SshCliError::VpsNotFound(name.clone()))?;
468 if use_json(json, format) {
469 crate::output::print_details_json(record)?;
470 } else {
471 crate::output::print_details_text(record);
472 }
473 }
474 VpsAction::Path => {
475 if use_json(false, format) {
477 let path_s = path.display().to_string();
478 crate::output::emit_success(
479 "vps-path",
480 serde_json::json!({ "path": path_s }),
481 &path_s,
482 true,
483 )?;
484 } else {
485 crate::output::write_line_fmt(format_args!("{}", path.display()))?;
487 }
488 }
489 VpsAction::Doctor {
490 json,
491 probe_ssh,
492 hosts,
493 } => {
494 let as_json = use_json(json, format);
496 if hosts.is_some() && !probe_ssh {
497 return Err(SshCliError::InvalidArgument(
498 "--hosts on vps doctor requires --probe-ssh".into(),
499 )
500 .into());
501 }
502 let selection = if probe_ssh {
503 match hosts {
504 None => HostSelection::All,
505 Some(raw) => {
506 let names = crate::cli::parse_hosts_list(&raw);
507 if names.is_empty() {
508 return Err(SshCliError::InvalidArgument(
509 "--hosts requires at least one host name".into(),
510 )
511 .into());
512 }
513 let names = names
514 .into_iter()
515 .map(crate::domain::VpsName::try_new)
516 .collect::<Result<Vec<_>, _>>()
517 .map_err(|e| SshCliError::InvalidArgument(e.to_string()))?;
518 HostSelection::Named(names)
519 }
520 }
521 } else {
522 HostSelection::All
524 };
525 run_doctor_with_optional_probe(
526 config_override.as_deref(),
527 as_json,
528 probe_ssh,
529 if probe_ssh { Some(selection) } else { None },
530 )
531 .await?;
532 }
533 VpsAction::Export {
534 include_secrets,
535 output,
536 json,
537 i_understand_secrets_on_stdout,
538 } => {
539 run_export(
541 &path,
542 include_secrets,
543 output.as_deref(),
544 json,
545 i_understand_secrets_on_stdout,
546 format,
547 )?;
548 }
549 VpsAction::Import {
550 file,
551 allow_incomplete,
552 } => {
553 run_import(&path, &file, allow_incomplete, format)?;
554 }
555 }
556 Ok(())
557}
558
559fn clear_active_if_name(config_path: &Path, name: &str) -> Result<()> {
561 let active = config_path
562 .parent()
563 .map(|p| p.join(crate::constants::ACTIVE_VPS_FILE_NAME))
564 .unwrap_or_else(|| PathBuf::from(crate::constants::ACTIVE_VPS_FILE_NAME));
565 if !active.exists() {
566 return Ok(());
567 }
568 let content = std::fs::read_to_string(&active).unwrap_or_default();
569 if content.trim() == name {
570 let _ = std::fs::remove_file(&active);
571 }
572 Ok(())
573}