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 host_source: crate::json_wire::TargetSource::Argv,
221 })
222 .await?;
223 }
224 }
225 VpsAction::List { json, tags } => {
226 let file = load(&path)?;
227 let records: Vec<_> = if tags.is_empty() {
228 file.hosts.values().cloned().collect()
229 } else {
230 {
231 let wanted = crate::domain::try_tags(&tags)
232 .map_err(|e| SshCliError::InvalidArgument(e.to_string()))?;
233 file.hosts
234 .values()
235 .filter(|r| r.has_any_tag(&wanted))
236 .cloned()
237 .collect()
238 }
239 };
240 if use_json(json, format) {
242 crate::output::print_list_json(&records)?;
243 } else {
244 crate::output::print_list_text(&records);
245 }
246 }
247 VpsAction::Remove { name } => {
248 let guard = lock_config(&path)?;
250 let mut file = load(&path)?;
251 if !file.hosts.contains_key(&name) {
252 return Err(SshCliError::VpsNotFound(name).into());
253 }
254 if crate::cli::dry_run_stop(
259 "vps-remove",
260 &[
261 ("name", serde_json::json!(name)),
262 ("config_path", serde_json::json!(path.display().to_string())),
263 ],
264 )? {
265 return Ok(());
266 }
267 file.hosts.remove(&name);
268 guard.save(&path, &file)?;
269 drop(guard);
270 clear_active_if_name(&path, &name)?;
272 crate::output::emit_success(
273 "vps-removed",
274 serde_json::json!({ "name": name }),
275 &crate::i18n::t(crate::i18n::Message::VpsRemoved { name: name.clone() }),
276 format == OutputFormat::Json,
277 )?;
278 }
279 VpsAction::Edit {
280 name,
281 host,
282 port,
283 user,
284 password,
285 password_stdin,
286 key,
287 key_passphrase,
288 key_passphrase_stdin,
289 use_agent,
290 agent_socket,
291 timeout,
292 max_command_chars,
293 max_output_chars,
294 max_chars,
295 sudo_password,
296 sudo_password_stdin,
297 su_password,
298 su_password_stdin,
299 disable_sudo,
300 enable_sudo,
301 tls,
302 no_tls,
303 tls_sni,
304 tls_client_cert,
305 tls_client_key,
306 } => {
307 let stdin_secrets = usize::from(password_stdin)
311 + usize::from(key_passphrase_stdin)
312 + usize::from(sudo_password_stdin)
313 + usize::from(su_password_stdin);
314 if stdin_secrets > 1 {
315 return Err(SshCliError::InvalidArgument(
316 "only one --*-stdin per one-shot invocation (stdin is drained once); \
317 run vps edit again for the remaining secrets"
318 .into(),
319 )
320 .into());
321 }
322 let password_stdin_value = if password_stdin {
325 Some(read_secret_stdin()?)
326 } else {
327 None
328 };
329 let key_passphrase_stdin_value = if key_passphrase_stdin {
330 Some(read_secret_stdin()?)
331 } else {
332 None
333 };
334 let sudo_stdin_value = if sudo_password_stdin {
335 Some(read_secret_stdin()?)
336 } else {
337 None
338 };
339 let su_stdin_value = if su_password_stdin {
340 Some(read_secret_stdin()?)
341 } else {
342 None
343 };
344 let guard = lock_config(&path)?;
346 let mut file = load(&path)?;
347 let record = file
348 .hosts
349 .get_mut(&name)
350 .ok_or(SshCliError::VpsNotFound(name.clone()))?;
351 use crate::domain::{CharLimit, KeyPath, SshHost, SshPort, SshUser, TimeoutMs};
352 if let Some(h) = host {
353 record.host =
354 SshHost::try_new(h).map_err(|e| SshCliError::InvalidArgument(e.to_string()))?;
355 }
356 if let Some(p) = port {
357 record.port =
358 SshPort::try_new(p).map_err(|e| SshCliError::InvalidArgument(e.to_string()))?;
359 }
360 if let Some(u) = user {
361 record.username =
362 SshUser::try_new(u).map_err(|e| SshCliError::InvalidArgument(e.to_string()))?;
363 }
364 if use_agent {
365 record.use_agent = true;
366 record.password = SecretString::from(String::new());
367 record.key_path = None;
368 record.key_passphrase = None;
369 if let Some(s) = agent_socket {
370 record.agent_socket = Some(s.to_string_lossy().into_owned());
371 }
372 } else {
373 if let Some(pw) = password_stdin_value {
374 record.password = pw;
375 record.use_agent = false;
376 } else if let Some(pw) = password {
377 record.password = SecretString::from(pw);
378 record.use_agent = false;
379 }
380 if let Some(k) = key {
381 let k = k.to_string_lossy().into_owned();
382 validate_key_path_exists(&k)?;
383 record.key_path = Some(
384 KeyPath::try_new(k)
385 .map_err(|e| SshCliError::InvalidArgument(e.to_string()))?,
386 );
387 record.use_agent = false;
388 }
389 if let Some(kp) = key_passphrase_stdin_value {
390 record.key_passphrase = Some(kp);
391 } else if let Some(kp) = key_passphrase {
392 record.key_passphrase = Some(SecretString::from(kp));
393 }
394 if let Some(s) = agent_socket {
395 record.agent_socket = Some(s.to_string_lossy().into_owned());
396 }
397 }
398 if let Some(t) = timeout {
399 record.timeout_ms = TimeoutMs::try_new(t)
400 .map_err(|e| SshCliError::InvalidArgument(e.to_string()))?;
401 }
402 if let Some(m) = max_command_chars.or(max_chars) {
403 record.max_command_chars = CharLimit::try_new(m)
404 .map_err(|e| SshCliError::InvalidArgument(e.to_string()))?;
405 }
406 if let Some(m) = max_output_chars {
407 record.max_output_chars = CharLimit::try_new(m)
408 .map_err(|e| SshCliError::InvalidArgument(e.to_string()))?;
409 }
410 if let Some(sp) = sudo_stdin_value {
411 record.sudo_password = Some(sp);
412 } else if let Some(sp) = sudo_password {
413 record.sudo_password = Some(SecretString::from(sp));
414 }
415 if let Some(sp) = su_stdin_value {
416 record.su_password = Some(sp);
417 } else if let Some(sp) = su_password {
418 record.su_password = Some(SecretString::from(sp));
419 }
420 if disable_sudo {
422 record.disable_sudo = true;
423 } else if enable_sudo {
424 record.disable_sudo = false;
425 }
426 if tls {
427 record.tls = true;
428 } else if no_tls {
429 record.tls = false;
430 }
431 if let Some(sni) = tls_sni {
432 record.tls_sni = Some(sni);
433 }
434 if let Some(c) = tls_client_cert {
435 record.tls_client_cert = Some(c.to_string_lossy().into_owned());
436 }
437 if let Some(k) = tls_client_key {
438 record.tls_client_key = Some(k.to_string_lossy().into_owned());
439 }
440 if record.tls {
441 let sni = record
442 .tls_sni
443 .as_deref()
444 .filter(|s| !s.trim().is_empty())
445 .unwrap_or(record.host.as_str());
446 let _ = crate::tls::TlsConnectOptions::try_new(
447 sni,
448 record
449 .tls_client_cert
450 .as_ref()
451 .map(std::path::PathBuf::from),
452 record.tls_client_key.as_ref().map(std::path::PathBuf::from),
453 )?;
454 }
455 record.validate().map_err(SshCliError::from)?;
456 guard.save(&path, &file)?;
457 drop(guard);
458 crate::output::emit_success(
459 "vps-edited",
460 serde_json::json!({ "name": name }),
461 &crate::i18n::t(crate::i18n::Message::VpsEdited { name: name.clone() }),
462 format == OutputFormat::Json,
463 )?;
464 }
465 VpsAction::Show { name, json } => {
466 let file = load(&path)?;
467 let record = file
468 .hosts
469 .get(&name)
470 .ok_or(SshCliError::VpsNotFound(name.clone()))?;
471 if use_json(json, format) {
472 crate::output::print_details_json(record)?;
473 } else {
474 crate::output::print_details_text(record);
475 }
476 }
477 VpsAction::Path => {
478 if use_json(false, format) {
480 let path_s = path.display().to_string();
481 crate::output::emit_success(
482 "vps-path",
483 serde_json::json!({ "path": path_s }),
484 &path_s,
485 true,
486 )?;
487 } else {
488 crate::output::write_line_fmt(format_args!("{}", path.display()))?;
490 }
491 }
492 VpsAction::Doctor {
493 json,
494 probe_ssh,
495 hosts,
496 } => {
497 let as_json = use_json(json, format);
499 if hosts.is_some() && !probe_ssh {
500 return Err(SshCliError::InvalidArgument(
501 "--hosts on vps doctor requires --probe-ssh".into(),
502 )
503 .into());
504 }
505 let selection = if probe_ssh {
506 match hosts {
507 None => HostSelection::All,
508 Some(raw) => {
509 let names = crate::cli::parse_hosts_list(&raw);
510 if names.is_empty() {
511 return Err(SshCliError::InvalidArgument(
512 "--hosts requires at least one host name".into(),
513 )
514 .into());
515 }
516 let names = names
517 .into_iter()
518 .map(crate::domain::VpsName::try_new)
519 .collect::<Result<Vec<_>, _>>()
520 .map_err(|e| SshCliError::InvalidArgument(e.to_string()))?;
521 HostSelection::Named(names)
522 }
523 }
524 } else {
525 HostSelection::All
527 };
528 run_doctor_with_optional_probe(
529 config_override.as_deref(),
530 as_json,
531 probe_ssh,
532 if probe_ssh { Some(selection) } else { None },
533 )
534 .await?;
535 }
536 VpsAction::Export {
537 include_secrets,
538 output,
539 json,
540 i_understand_secrets_on_stdout,
541 } => {
542 run_export(
544 &path,
545 include_secrets,
546 output.as_deref(),
547 json,
548 i_understand_secrets_on_stdout,
549 format,
550 )?;
551 }
552 VpsAction::Import {
553 file,
554 allow_incomplete,
555 } => {
556 run_import(&path, &file, allow_incomplete, format)?;
557 }
558 }
559 Ok(())
560}
561
562fn clear_active_if_name(config_path: &Path, name: &str) -> Result<()> {
564 let active = config_path
565 .parent()
566 .map(|p| p.join(crate::constants::ACTIVE_VPS_FILE_NAME))
567 .unwrap_or_else(|| PathBuf::from(crate::constants::ACTIVE_VPS_FILE_NAME));
568 if !active.exists() {
569 return Ok(());
570 }
571 let content = std::fs::read_to_string(&active).unwrap_or_default();
572 if content.trim() == name {
573 let _ = std::fs::remove_file(&active);
574 }
575 Ok(())
576}