Skip to main content

ssh_cli/vps/
crud.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2// G-COMP: VPS CRUD dispatcher extracted from vps/mod (SRP; line budget).
3#![forbid(unsafe_code)]
4//! Dispatcher for `ssh-cli vps …` subcommands.
5
6use 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
20/// Dispatcher dos subcomandos `vps`.
21pub 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            // GAP-SSH-VAL-001: validate na fronteira de escrita.
58            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            // Early, advisory duplicate check: fails before any stdin prompt. The
62            // authoritative check happens again under the config lock, below.
63            if load(&path)?.hosts.contains_key(&name_key) {
64                return Err(SshCliError::VpsDuplicate(name_key).into());
65            }
66            // Stdin can only be drained once, so ANY two `--*-stdin` flags conflict.
67            // D13: the old guard was `password_stdin && (sudo || su)`, which let
68            // `--sudo-password-stdin --su-password-stdin` through: the first read
69            // consumed stdin and the second silently produced an empty secret.
70            // Counting is the invariant; enumerating pairs is not.
71            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            // legacy max_chars → command if max_command was not set explicitly
108            // (clap already parses `none`/`0`/decimal via parse_cli_char_limit)
109            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            // GAP-AUD-009: timeout is milliseconds; warn agents that use "5" meaning seconds.
114            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            // G-E2E-19: registry auth triplo (password | key | agent).
136            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            // G-O2: tags for fleet selection (dedupe preserve order).
144            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                // Validate options early (SNI empty / partial mTLS).
154                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            // GAP-SSH-VAL-002 / VAL-003: full domain validation on the write-path.
169            record.validate().map_err(SshCliError::from)?;
170            // Read-modify-write under one lock: a concurrent `vps add` that loaded the
171            // same snapshot would otherwise overwrite this host on save.
172            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            // Release before `--check`: the lock must never span an SSH round trip.
181            drop(guard);
182            // G-E2E-04 / one-shot: single stdout document (fold auto-key into vps-added).
183            // Workload: local single-file CRUD — sequential justified (≪ SSH RTT).
184            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            // GAP-SSH-IO-001: respeitar format global.
238            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            // Lock spans load → mutate → save so a concurrent edit is not resurrected.
246            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            // C2: previewed *after* the existence check, so the plan never promises
252            // a removal that the real run would reject with exit 66. A dry-run that
253            // reports success for a host that does not exist is worse than no
254            // preview, because the agent then treats the failure as a regression.
255            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            // GAP-SSH-STATE-001: clear orphan active marker.
268            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            // D13: `edit` had NO mutual-exclusion guard at all and read stdin up to
305            // three times in a row. Only the first read saw data; the rest silently
306            // stored empty secrets. Same invariant as `add`: stdin drains once.
307            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            // Stdin secrets are read *before* the lock: a blocking read must never hold
320            // it, or a concurrent one-shot would wait on the operator's terminal.
321            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            // Lock spans load → mutate → save (lost-update on concurrent edits).
342            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            // G-10: tri-state edit without Option<bool> — exclusive SetTrue flags.
418            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            // G-AUD-02: JSON envelope when format is Json; plain path in Text.
476            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                // G-MAC-01: format_args + write_fmt — no intermediate String for Display.
486                crate::output::write_line_fmt(format_args!("{}", path.display()))?;
487            }
488        }
489        VpsAction::Doctor {
490            json,
491            probe_ssh,
492            hosts,
493        } => {
494            // G-PAR-38/42: single envelope (local + optional ssh_probe); no dual JSON roots.
495            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                // unused when !probe_ssh
523                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            // G-AUD-03: export body JSON when local --json or global format Json.
540            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
559/// Removes the `active` file if its content matches the removed name (STATE-001).
560fn 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}