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::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 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)
129 .map_err(SshCliError::from)?;
130 record.tls = tls;
131 record.tls_sni = tls_sni;
132 record.tls_client_cert = tls_client_cert.map(|p| p.to_string_lossy().into_owned());
133 record.tls_client_key = tls_client_key.map(|p| p.to_string_lossy().into_owned());
134 if record.tls {
135 let sni = record
137 .tls_sni
138 .as_deref()
139 .filter(|s| !s.trim().is_empty())
140 .unwrap_or(record.host.as_str());
141 let _ = crate::tls::TlsConnectOptions::try_new(
142 sni,
143 record
144 .tls_client_cert
145 .as_ref()
146 .map(std::path::PathBuf::from),
147 record.tls_client_key.as_ref().map(std::path::PathBuf::from),
148 )?;
149 }
150 record.validate().map_err(SshCliError::from)?;
152 file.hosts.insert(name_key.clone(), record);
153 file.schema_version = model::CURRENT_SCHEMA_VERSION;
154 save(&path, &file)?;
155 let auto_key = take_auto_key_meta();
158 let mut data = serde_json::json!({ "name": name_key });
159 if let Some(ref meta) = auto_key {
160 data["secrets_key_auto_created"] = serde_json::Value::Bool(true);
161 data["key_file"] = serde_json::Value::String(meta.key_file.clone());
162 data["key_source"] = serde_json::Value::String(meta.key_source.to_owned());
163 } else {
164 data["secrets_key_auto_created"] = serde_json::Value::Bool(false);
165 }
166 let msg = if let Some(ref meta) = auto_key {
167 format!(
168 "{}; primary-key auto-created at {}",
169 crate::i18n::t(crate::i18n::Message::VpsAdded {
170 name: name_key.clone(),
171 }),
172 meta.key_file
173 )
174 } else {
175 crate::i18n::t(crate::i18n::Message::VpsAdded {
176 name: name_key.clone(),
177 })
178 };
179 crate::output::emit_success("vps-added", data, &msg, format == OutputFormat::Json)?;
180 if check {
181 run_health_check(
182 HostSelection::Single(name.clone()),
183 config_override,
184 format,
185 false,
186 None,
187 None,
188 None,
189 None,
190 false,
191 )
192 .await?;
193 }
194 }
195 VpsAction::List { json, tags } => {
196 let file = load(&path)?;
197 let records: Vec<_> = if tags.is_empty() {
198 file.hosts.values().cloned().collect()
199 } else {
200 {
201 let wanted = crate::domain::try_tags(&tags)
202 .map_err(|e| SshCliError::InvalidArgument(e.to_string()))?;
203 file.hosts
204 .values()
205 .filter(|r| r.has_any_tag(&wanted))
206 .cloned()
207 .collect()
208 }
209 };
210 if use_json(json, format) {
212 crate::output::print_list_json(&records)?;
213 } else {
214 crate::output::print_list_text(&records);
215 }
216 }
217 VpsAction::Remove { name } => {
218 let mut file = load(&path)?;
219 if file.hosts.remove(&name).is_none() {
220 return Err(SshCliError::VpsNotFound(name).into());
221 }
222 save(&path, &file)?;
223 clear_active_if_name(&path, &name)?;
225 crate::output::emit_success(
226 "vps-removed",
227 serde_json::json!({ "name": name }),
228 &crate::i18n::t(crate::i18n::Message::VpsRemoved { name: name.clone() }),
229 format == OutputFormat::Json,
230 )?;
231 }
232 VpsAction::Edit {
233 name,
234 host,
235 port,
236 user,
237 password,
238 password_stdin,
239 key,
240 key_passphrase,
241 use_agent,
242 agent_socket,
243 timeout,
244 max_command_chars,
245 max_output_chars,
246 max_chars,
247 sudo_password,
248 sudo_password_stdin,
249 su_password,
250 su_password_stdin,
251 disable_sudo,
252 enable_sudo,
253 tls,
254 no_tls,
255 tls_sni,
256 tls_client_cert,
257 tls_client_key,
258 } => {
259 let mut file = load(&path)?;
260 let record = file
261 .hosts
262 .get_mut(&name)
263 .ok_or(SshCliError::VpsNotFound(name.clone()))?;
264 use crate::domain::{CharLimit, KeyPath, SshHost, SshPort, SshUser, TimeoutMs};
265 if let Some(h) = host {
266 record.host =
267 SshHost::try_new(h).map_err(|e| SshCliError::InvalidArgument(e.to_string()))?;
268 }
269 if let Some(p) = port {
270 record.port =
271 SshPort::try_new(p).map_err(|e| SshCliError::InvalidArgument(e.to_string()))?;
272 }
273 if let Some(u) = user {
274 record.username =
275 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)
298 .map_err(|e| SshCliError::InvalidArgument(e.to_string()))?,
299 );
300 record.use_agent = false;
301 }
302 if let Some(kp) = key_passphrase {
303 record.key_passphrase = Some(SecretString::from(kp));
304 }
305 if let Some(s) = agent_socket {
306 record.agent_socket = Some(s.to_string_lossy().into_owned());
307 }
308 }
309 if let Some(t) = timeout {
310 record.timeout_ms = TimeoutMs::try_new(t)
311 .map_err(|e| SshCliError::InvalidArgument(e.to_string()))?;
312 }
313 if let Some(m) = max_command_chars.or(max_chars) {
314 record.max_command_chars = CharLimit::try_new(m)
315 .map_err(|e| SshCliError::InvalidArgument(e.to_string()))?;
316 }
317 if let Some(m) = max_output_chars {
318 record.max_output_chars = CharLimit::try_new(m)
319 .map_err(|e| SshCliError::InvalidArgument(e.to_string()))?;
320 }
321 if sudo_password_stdin {
322 record.sudo_password = Some(read_secret_stdin()?);
323 } else if let Some(sp) = sudo_password {
324 record.sudo_password = Some(SecretString::from(sp));
325 }
326 if su_password_stdin {
327 record.su_password = Some(read_secret_stdin()?);
328 } else if let Some(sp) = su_password {
329 record.su_password = Some(SecretString::from(sp));
330 }
331 if disable_sudo {
333 record.disable_sudo = true;
334 } else if enable_sudo {
335 record.disable_sudo = false;
336 }
337 if tls {
338 record.tls = true;
339 } else if no_tls {
340 record.tls = false;
341 }
342 if let Some(sni) = tls_sni {
343 record.tls_sni = Some(sni);
344 }
345 if let Some(c) = tls_client_cert {
346 record.tls_client_cert = Some(c.to_string_lossy().into_owned());
347 }
348 if let Some(k) = tls_client_key {
349 record.tls_client_key = Some(k.to_string_lossy().into_owned());
350 }
351 if record.tls {
352 let sni = record
353 .tls_sni
354 .as_deref()
355 .filter(|s| !s.trim().is_empty())
356 .unwrap_or(record.host.as_str());
357 let _ = crate::tls::TlsConnectOptions::try_new(
358 sni,
359 record
360 .tls_client_cert
361 .as_ref()
362 .map(std::path::PathBuf::from),
363 record.tls_client_key.as_ref().map(std::path::PathBuf::from),
364 )?;
365 }
366 record.validate().map_err(SshCliError::from)?;
367 save(&path, &file)?;
368 crate::output::emit_success(
369 "vps-edited",
370 serde_json::json!({ "name": name }),
371 &crate::i18n::t(crate::i18n::Message::VpsEdited { name: name.clone() }),
372 format == OutputFormat::Json,
373 )?;
374 }
375 VpsAction::Show { name, json } => {
376 let file = load(&path)?;
377 let record = file
378 .hosts
379 .get(&name)
380 .ok_or(SshCliError::VpsNotFound(name.clone()))?;
381 if use_json(json, format) {
382 crate::output::print_details_json(record)?;
383 } else {
384 crate::output::print_details_text(record);
385 }
386 }
387 VpsAction::Path => {
388 if use_json(false, format) {
390 let path_s = path.display().to_string();
391 crate::output::emit_success(
392 "vps-path",
393 serde_json::json!({ "path": path_s }),
394 &path_s,
395 true,
396 )?;
397 } else {
398 crate::output::write_line_fmt(format_args!("{}", path.display()))?;
400 }
401 }
402 VpsAction::Doctor {
403 json,
404 probe_ssh,
405 hosts,
406 } => {
407 let as_json = use_json(json, format);
409 if hosts.is_some() && !probe_ssh {
410 return Err(SshCliError::InvalidArgument(
411 "--hosts on vps doctor requires --probe-ssh".into(),
412 )
413 .into());
414 }
415 let selection = if probe_ssh {
416 match hosts {
417 None => HostSelection::All,
418 Some(raw) => {
419 let names = crate::cli::parse_hosts_list(&raw);
420 if names.is_empty() {
421 return Err(SshCliError::InvalidArgument(
422 "--hosts requires at least one host name".into(),
423 )
424 .into());
425 }
426 let names = names
427 .into_iter()
428 .map(crate::domain::VpsName::try_new)
429 .collect::<Result<Vec<_>, _>>()
430 .map_err(|e| SshCliError::InvalidArgument(e.to_string()))?;
431 HostSelection::Named(names)
432 }
433 }
434 } else {
435 HostSelection::All
437 };
438 run_doctor_with_optional_probe(
439 config_override.as_deref(),
440 as_json,
441 probe_ssh,
442 if probe_ssh { Some(selection) } else { None },
443 )
444 .await?;
445 }
446 VpsAction::Export {
447 include_secrets,
448 output,
449 json,
450 i_understand_secrets_on_stdout,
451 } => {
452 run_export(
454 &path,
455 include_secrets,
456 output.as_deref(),
457 json,
458 i_understand_secrets_on_stdout,
459 format,
460 )?;
461 }
462 VpsAction::Import {
463 file,
464 allow_incomplete,
465 } => {
466 run_import(&path, &file, allow_incomplete, format)?;
467 }
468 }
469 Ok(())
470}
471
472fn clear_active_if_name(config_path: &Path, name: &str) -> Result<()> {
474 let active = config_path
475 .parent()
476 .map(|p| p.join(crate::constants::ACTIVE_VPS_FILE_NAME))
477 .unwrap_or_else(|| PathBuf::from(crate::constants::ACTIVE_VPS_FILE_NAME));
478 if !active.exists() {
479 return Ok(());
480 }
481 let content = std::fs::read_to_string(&active).unwrap_or_default();
482 if content.trim() == name {
483 let _ = std::fs::remove_file(&active);
484 }
485 Ok(())
486}