1#![forbid(unsafe_code)]
4use super::config_io::{load, resolve_config_path, save, validate_key_path_exists};
7use super::doctor::run_doctor_with_optional_probe;
8use super::import_export::{run_export, run_import};
9use super::health::run_health_check;
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 use_agent,
39 agent_socket,
40 timeout,
41 max_command_chars,
42 max_output_chars,
43 max_chars,
44 sudo_password,
45 sudo_password_stdin,
46 su_password,
47 su_password_stdin,
48 disable_sudo,
49 tags,
50 tls,
51 tls_sni,
52 tls_client_cert,
53 tls_client_key,
54 check,
55 } => {
56 let name = crate::paths::validate_and_normalize(&name)
58 .map_err(|e| SshCliError::InvalidArgument(format!("invalid VPS name: {e}")))?;
59 let name_key = name.as_str().to_owned();
60 let mut file = load(&path)?;
61 if file.hosts.contains_key(&name_key) {
62 return Err(SshCliError::VpsDuplicate(name_key).into());
63 }
64 if password_stdin && (sudo_password_stdin || su_password_stdin) {
65 return Err(SshCliError::InvalidArgument(
66 "only one --*-stdin per one-shot invocation; use vps edit for sudo/su".into(),
67 )
68 .into());
69 }
70 let password = if password_stdin {
71 read_secret_stdin()?
72 } else {
73 SecretString::from(password.unwrap_or_default())
74 };
75 let sudo_s = if sudo_password_stdin {
76 Some(read_secret_stdin()?)
77 } else {
78 sudo_password.map(SecretString::from)
79 };
80 let su_s = if su_password_stdin {
81 Some(read_secret_stdin()?)
82 } else {
83 su_password.map(SecretString::from)
84 };
85 let key = key.map(|p| p.to_string_lossy().into_owned());
86 if let Some(ref k) = key {
87 validate_key_path_exists(k)?;
88 }
89 let max_cmd = max_command_chars
92 .or(max_chars)
93 .unwrap_or(model::DEFAULT_MAX_COMMAND_CHARS);
94 let max_out = max_output_chars.unwrap_or(model::DEFAULT_MAX_OUTPUT_CHARS);
95 if timeout > 0 && timeout < 1000 {
97 crate::output::print_warning_fmt(format_args!(
98 "--timeout {timeout} is only {timeout}ms (< 1s); did you mean seconds? Use e.g. --timeout 5000 for 5s"
99 ));
100 }
101 let mut record = VpsRecord::try_new(
102 name.as_str(),
103 host,
104 port,
105 user,
106 password,
107 key,
108 key_passphrase.map(SecretString::from),
109 Some(timeout),
110 Some(max_cmd),
111 Some(max_out),
112 sudo_s,
113 su_s,
114 disable_sudo,
115 )
116 .map_err(SshCliError::InvalidArgument)?;
117 if use_agent {
119 record.use_agent = true;
120 record.password = SecretString::from(String::new());
121 record.key_path = None;
122 record.key_passphrase = None;
123 record.agent_socket = agent_socket.map(|p| p.to_string_lossy().into_owned());
124 }
125 let tag_list = crate::vps::selection::dedupe_host_names(tags);
127 record
128 .set_tags_from_raw(tag_list).map_err(SshCliError::from)?;
129 record.tls = tls;
130 record.tls_sni = tls_sni;
131 record.tls_client_cert = tls_client_cert
132 .map(|p| p.to_string_lossy().into_owned());
133 record.tls_client_key = tls_client_key
134 .map(|p| p.to_string_lossy().into_owned());
135 if record.tls {
136 let sni = record
138 .tls_sni
139 .as_deref()
140 .filter(|s| !s.trim().is_empty())
141 .unwrap_or(record.host.as_str());
142 let _ = crate::tls::TlsConnectOptions::try_new(
143 sni,
144 record.tls_client_cert.as_ref().map(std::path::PathBuf::from),
145 record.tls_client_key.as_ref().map(std::path::PathBuf::from),
146 )?;
147 }
148 record.validate().map_err(SshCliError::from)?;
150 file.hosts.insert(name_key.clone(), record);
151 file.schema_version = model::CURRENT_SCHEMA_VERSION;
152 save(&path, &file)?;
153 let auto_key = take_auto_key_meta();
156 let mut data = serde_json::json!({ "name": name_key });
157 if let Some(ref meta) = auto_key {
158 data["secrets_key_auto_created"] = serde_json::Value::Bool(true);
159 data["key_file"] = serde_json::Value::String(meta.key_file.clone());
160 data["key_source"] = serde_json::Value::String(meta.key_source.to_owned());
161 } else {
162 data["secrets_key_auto_created"] = serde_json::Value::Bool(false);
163 }
164 let msg = if let Some(ref meta) = auto_key {
165 format!(
166 "{}; primary-key auto-created at {}",
167 crate::i18n::t(crate::i18n::Message::VpsAdded {
168 name: name_key.clone(),
169 }),
170 meta.key_file
171 )
172 } else {
173 crate::i18n::t(crate::i18n::Message::VpsAdded {
174 name: name_key.clone(),
175 })
176 };
177 crate::output::emit_success(
178 "vps-added",
179 data,
180 &msg,
181 format == OutputFormat::Json,
182 )?;
183 if check {
184 run_health_check(
185 HostSelection::Single(name.clone()),
186 config_override,
187 format,
188 false,
189 None,
190 None,
191 None,
192 None,
193 false,
194 )
195 .await?;
196 }
197 }
198 VpsAction::List { json, tags } => {
199 let file = load(&path)?;
200 let records: Vec<_> = if tags.is_empty() {
201 file.hosts.values().cloned().collect()
202 } else {
203 {
204 let wanted = crate::domain::try_tags(&tags)
205 .map_err(|e| SshCliError::InvalidArgument(e.to_string()))?;
206 file.hosts
207 .values()
208 .filter(|r| r.has_any_tag(&wanted))
209 .cloned()
210 .collect()
211 }
212 };
213 if use_json(json, format) {
215 crate::output::print_list_json(&records)?;
216 } else {
217 crate::output::print_list_text(&records);
218 }
219 }
220 VpsAction::Remove { name } => {
221 let mut file = load(&path)?;
222 if file.hosts.remove(&name).is_none() {
223 return Err(SshCliError::VpsNotFound(name).into());
224 }
225 save(&path, &file)?;
226 clear_active_if_name(&path, &name)?;
228 crate::output::emit_success(
229 "vps-removed",
230 serde_json::json!({ "name": name }),
231 &crate::i18n::t(crate::i18n::Message::VpsRemoved { name: name.clone() }),
232 format == OutputFormat::Json,
233 )?;
234 }
235 VpsAction::Edit {
236 name,
237 host,
238 port,
239 user,
240 password,
241 password_stdin,
242 key,
243 key_passphrase,
244 use_agent,
245 agent_socket,
246 timeout,
247 max_command_chars,
248 max_output_chars,
249 max_chars,
250 sudo_password,
251 sudo_password_stdin,
252 su_password,
253 su_password_stdin,
254 disable_sudo,
255 enable_sudo,
256 tls,
257 no_tls,
258 tls_sni,
259 tls_client_cert,
260 tls_client_key,
261 } => {
262 let mut file = load(&path)?;
263 let record = file
264 .hosts
265 .get_mut(&name)
266 .ok_or(SshCliError::VpsNotFound(name.clone()))?;
267 use crate::domain::{CharLimit, KeyPath, SshHost, SshPort, SshUser, TimeoutMs};
268 if let Some(h) = host {
269 record.host = SshHost::try_new(h).map_err(|e| SshCliError::InvalidArgument(e.to_string()))?;
270 }
271 if let Some(p) = port {
272 record.port = SshPort::try_new(p).map_err(|e| SshCliError::InvalidArgument(e.to_string()))?;
273 }
274 if let Some(u) = user {
275 record.username = SshUser::try_new(u).map_err(|e| SshCliError::InvalidArgument(e.to_string()))?;
276 }
277 if use_agent {
278 record.use_agent = true;
279 record.password = SecretString::from(String::new());
280 record.key_path = None;
281 record.key_passphrase = None;
282 if let Some(s) = agent_socket {
283 record.agent_socket = Some(s.to_string_lossy().into_owned());
284 }
285 } else {
286 if password_stdin {
287 record.password = read_secret_stdin()?;
288 record.use_agent = false;
289 } else if let Some(pw) = password {
290 record.password = SecretString::from(pw);
291 record.use_agent = false;
292 }
293 if let Some(k) = key {
294 let k = k.to_string_lossy().into_owned();
295 validate_key_path_exists(&k)?;
296 record.key_path = Some(
297 KeyPath::try_new(k).map_err(|e| SshCliError::InvalidArgument(e.to_string()))?,
298 );
299 record.use_agent = false;
300 }
301 if let Some(kp) = key_passphrase {
302 record.key_passphrase = Some(SecretString::from(kp));
303 }
304 if let Some(s) = agent_socket {
305 record.agent_socket = Some(s.to_string_lossy().into_owned());
306 }
307 }
308 if let Some(t) = timeout {
309 record.timeout_ms =
310 TimeoutMs::try_new(t).map_err(|e| SshCliError::InvalidArgument(e.to_string()))?;
311 }
312 if let Some(m) = max_command_chars.or(max_chars) {
313 record.max_command_chars =
314 CharLimit::try_new(m).map_err(|e| SshCliError::InvalidArgument(e.to_string()))?;
315 }
316 if let Some(m) = max_output_chars {
317 record.max_output_chars =
318 CharLimit::try_new(m).map_err(|e| SshCliError::InvalidArgument(e.to_string()))?;
319 }
320 if sudo_password_stdin {
321 record.sudo_password = Some(read_secret_stdin()?);
322 } else if let Some(sp) = sudo_password {
323 record.sudo_password = Some(SecretString::from(sp));
324 }
325 if su_password_stdin {
326 record.su_password = Some(read_secret_stdin()?);
327 } else if let Some(sp) = su_password {
328 record.su_password = Some(SecretString::from(sp));
329 }
330 if disable_sudo {
332 record.disable_sudo = true;
333 } else if enable_sudo {
334 record.disable_sudo = false;
335 }
336 if tls {
337 record.tls = true;
338 } else if no_tls {
339 record.tls = false;
340 }
341 if let Some(sni) = tls_sni {
342 record.tls_sni = Some(sni);
343 }
344 if let Some(c) = tls_client_cert {
345 record.tls_client_cert = Some(c.to_string_lossy().into_owned());
346 }
347 if let Some(k) = tls_client_key {
348 record.tls_client_key = Some(k.to_string_lossy().into_owned());
349 }
350 if record.tls {
351 let sni = record
352 .tls_sni
353 .as_deref()
354 .filter(|s| !s.trim().is_empty())
355 .unwrap_or(record.host.as_str());
356 let _ = crate::tls::TlsConnectOptions::try_new(
357 sni,
358 record.tls_client_cert.as_ref().map(std::path::PathBuf::from),
359 record.tls_client_key.as_ref().map(std::path::PathBuf::from),
360 )?;
361 }
362 record.validate().map_err(SshCliError::from)?;
363 save(&path, &file)?;
364 crate::output::emit_success(
365 "vps-edited",
366 serde_json::json!({ "name": name }),
367 &crate::i18n::t(crate::i18n::Message::VpsEdited { name: name.clone() }),
368 format == OutputFormat::Json,
369 )?;
370 }
371 VpsAction::Show { name, json } => {
372 let file = load(&path)?;
373 let record = file
374 .hosts
375 .get(&name)
376 .ok_or(SshCliError::VpsNotFound(name.clone()))?;
377 if use_json(json, format) {
378 crate::output::print_details_json(record)?;
379 } else {
380 crate::output::print_details_text(record);
381 }
382 }
383 VpsAction::Path => {
384 if use_json(false, format) {
386 let path_s = path.display().to_string();
387 crate::output::emit_success(
388 "vps-path",
389 serde_json::json!({ "path": path_s }),
390 &path_s,
391 true,
392 )?;
393 } else {
394 crate::output::write_line_fmt(format_args!("{}", path.display()))?;
396 }
397 }
398 VpsAction::Doctor {
399 json,
400 probe_ssh,
401 hosts,
402 } => {
403 let as_json = use_json(json, format);
405 if hosts.is_some() && !probe_ssh {
406 return Err(SshCliError::InvalidArgument(
407 "--hosts on vps doctor requires --probe-ssh".into(),
408 )
409 .into());
410 }
411 let selection = if probe_ssh {
412 match hosts {
413 None => HostSelection::All,
414 Some(raw) => {
415 let names = crate::cli::parse_hosts_list(&raw);
416 if names.is_empty() {
417 return Err(SshCliError::InvalidArgument(
418 "--hosts requires at least one host name".into(),
419 )
420 .into());
421 }
422 let names = names
423 .into_iter()
424 .map(crate::domain::VpsName::try_new)
425 .collect::<Result<Vec<_>, _>>()
426 .map_err(|e| SshCliError::InvalidArgument(e.to_string()))?;
427 HostSelection::Named(names)
428 }
429 }
430 } else {
431 HostSelection::All
433 };
434 run_doctor_with_optional_probe(
435 config_override.as_deref(),
436 as_json,
437 probe_ssh,
438 if probe_ssh { Some(selection) } else { None },
439 )
440 .await?;
441 }
442 VpsAction::Export {
443 include_secrets,
444 output,
445 json,
446 i_understand_secrets_on_stdout,
447 } => {
448 run_export(
450 &path,
451 include_secrets,
452 output.as_deref(),
453 json,
454 i_understand_secrets_on_stdout,
455 format,
456 )?;
457 }
458 VpsAction::Import {
459 file,
460 allow_incomplete,
461 } => {
462 run_import(&path, &file, allow_incomplete, format)?;
463 }
464 }
465 Ok(())
466}
467
468fn clear_active_if_name(config_path: &Path, name: &str) -> Result<()> {
470 let active = config_path
471 .parent()
472 .map(|p| p.join(crate::constants::ACTIVE_VPS_FILE_NAME))
473 .unwrap_or_else(|| PathBuf::from(crate::constants::ACTIVE_VPS_FILE_NAME));
474 if !active.exists() {
475 return Ok(());
476 }
477 let content = std::fs::read_to_string(&active).unwrap_or_default();
478 if content.trim() == name {
479 let _ = std::fs::remove_file(&active);
480 }
481 Ok(())
482}