Skip to main content

ssh_cli/vps/
mod.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2// G-SECDEV-05: pure module — no `unsafe`.
3#![forbid(unsafe_code)]
4//! VPS record CRUD and persistence (XDG + atomic TOML + flock).
5//!
6//! No `.env` at runtime. Schema v3 English wire (dual-read PT legacy).
7
8/// Config path/load/save/permissions (SRP extract — G-UNSAFE-10).
9mod config_io;
10/// VPS CRUD dispatcher (SRP extract — G-COMP-06).
11mod crud;
12/// Local doctor + optional SSH probe (SRP extract — G-COMP-02).
13mod doctor;
14/// Remote exec / sudo / su (SRP extract — G-COMP-05).
15mod exec_ops;
16/// SSH health-check fan-out (SRP extract — G-COMP-04).
17mod health;
18/// Inventory import/export (SRP extract — G-COMP-03).
19mod import_export;
20pub mod model;
21/// Secrets primary-key commands (SRP extract — G-COMP-07).
22mod secrets_cmd;
23/// Multi-host selection resolution (SRP extract — G-COMP-01).
24pub mod selection;
25
26pub(crate) use config_io::validate_key_path_exists;
27pub use config_io::{
28    default_config_path, load, resolve_config_path, save, winning_layer, write_atomic, ConfigFile,
29    ConfigLayer,
30};
31pub use crud::run_vps_command;
32pub use exec_ops::{
33    run_exec, run_exec_with_client, run_su_exec, run_sudo_exec, run_sudo_exec_with_client,
34    ExecOptions, HostExecResult,
35};
36pub use health::{run_health_check, HealthCheckRequest, HostHealthResult};
37pub use import_export::parse_import_payload;
38pub use secrets_cmd::run_secrets_command;
39pub use selection::{dedupe_host_names, resolve_host_jobs, HostSelection};
40
41use crate::cli::OutputFormat;
42use crate::errors::{SshCliError, SshCliResult};
43use crate::ssh::client::ConnectionConfig;
44use crate::ssh::known_hosts::KnownHosts;
45use anyhow::Result;
46use model::{effective_limit, VpsRecord};
47use secrecy::SecretString;
48use std::io::Write;
49use std::path::{Path, PathBuf};
50
51/// JSON efetivo a partir de flag local e format global (IO-001/002).
52#[must_use]
53pub fn use_json(json_local: bool, format: OutputFormat) -> bool {
54    json_local || format == OutputFormat::Json
55}
56
57/// Hard cap for secret payloads on stdin (agent hardening / DoS guard).
58///
59/// Passwords and key passphrases must not be multi-megabyte streams.
60pub const MAX_SECRET_STDIN_BYTES: u64 = 64 * 1024;
61
62const _: () = assert!(MAX_SECRET_STDIN_BYTES >= 1024);
63const _: () = assert!(MAX_SECRET_STDIN_BYTES <= 1024 * 1024);
64
65/// Reads a password line from stdin (no extra echo).
66///
67/// G-IO-06: rejects payloads larger than [`MAX_SECRET_STDIN_BYTES`] with
68/// `EX_DATAERR` semantics via [`SshCliError::InvalidArgument`].
69///
70/// G-SECDEV-01: returns [`SecretString`] immediately (rules: never keep
71/// credentials in bare `String` after the trust boundary). The read buffer is
72/// [`zeroize::Zeroizing`] so leftover CR/LF bytes are scrubbed on drop.
73///
74/// C2: the `--no-input` refusal lives *here*, not in the callers. Guarding
75/// `read_stdin_if` alone covered only the exec/scp/tunnel override path — `vps add`
76/// and `vps edit` call this function directly, so `--no-input` silently did nothing
77/// on the two commands most likely to be scripted unattended.
78///
79/// # Errors
80/// [`SshCliError::InvalidArgument`] when `--no-input` is in effect, or when the
81/// payload exceeds [`MAX_SECRET_STDIN_BYTES`].
82pub fn read_secret_stdin() -> SshCliResult<SecretString> {
83    if crate::cli::is_no_input() {
84        return Err(SshCliError::InvalidArgument(
85            "--no-input forbids reading secrets from stdin; pass the value via flag \
86             or drop --no-input"
87                .to_string(),
88        ));
89    }
90    use std::io::Read;
91    use zeroize::Zeroizing;
92    let mut limited = std::io::stdin().take(MAX_SECRET_STDIN_BYTES + 1);
93    let mut buf = Zeroizing::new(String::new());
94    limited.read_to_string(&mut buf)?;
95    if buf.len() as u64 > MAX_SECRET_STDIN_BYTES {
96        return Err(SshCliError::InvalidArgument(format!(
97            "stdin secret exceeds max size of {MAX_SECRET_STDIN_BYTES} bytes"
98        )));
99    }
100    let trimmed = buf.trim_end_matches(['\r', '\n']);
101    Ok(SecretString::from(trimmed.to_owned()))
102}
103
104/// Per-invocation credential overrides applied on top of a stored `VpsRecord`.
105///
106/// # Why a struct (B3)
107///
108/// This cluster travelled as eight positional parameters through `exec`, `scp`,
109/// `sftp`, `tunnel` and `health-check`. Six of the eight are
110/// `Option<SecretString>` / `Option<String>` / `bool`, so transposing password
111/// with sudo-password — or key path with agent socket — compiled cleanly and
112/// only surfaced as an authentication failure against a real host. Naming the
113/// fields makes that class of mistake a compile error.
114///
115/// G-SECDEV-02: secret overrides arrive already wrapped in [`SecretString`]
116/// (zeroize-on-drop); never re-accept a bare password `String` past the CLI
117/// boundary.
118#[derive(Debug, Default, Clone)]
119pub(crate) struct AuthOverrides {
120    /// SSH password override.
121    pub password: Option<SecretString>,
122    /// `sudo` password override.
123    pub sudo_password: Option<SecretString>,
124    /// `su -` password override.
125    pub su_password: Option<SecretString>,
126    /// Connection timeout override (already refined at the CLI boundary).
127    pub timeout: Option<crate::domain::TimeoutMs>,
128    /// Private key path override.
129    pub key_path: Option<String>,
130    /// Key passphrase override.
131    pub key_passphrase: Option<SecretString>,
132    /// Force ssh-agent authentication.
133    pub use_agent: bool,
134    /// Explicit agent socket (implies [`Self::use_agent`]).
135    pub agent_socket: Option<String>,
136}
137
138/// Applies runtime overrides onto a cloned `VpsRecord`.
139pub(crate) fn apply_overrides(vps: &mut VpsRecord, overrides: AuthOverrides) {
140    use crate::domain::KeyPath;
141    let AuthOverrides {
142        password,
143        sudo_password,
144        su_password,
145        timeout,
146        key_path,
147        key_passphrase,
148        use_agent,
149        agent_socket,
150    } = overrides;
151    if let Some(pwd) = password {
152        vps.password = pwd;
153    }
154    if let Some(spwd) = sudo_password {
155        vps.sudo_password = Some(spwd);
156    }
157    if let Some(sp) = su_password {
158        vps.su_password = Some(sp);
159    }
160    // G-TYPE-18: timeout already refined at the CLI / options boundary.
161    if let Some(t) = timeout {
162        vps.timeout_ms = t;
163    }
164    if let Some(k) = key_path {
165        if let Ok(kp) = KeyPath::try_new(k) {
166            vps.key_path = Some(kp);
167        }
168    }
169    if let Some(kp) = key_passphrase {
170        vps.key_passphrase = Some(kp);
171    }
172    if use_agent {
173        vps.use_agent = true;
174    }
175    if let Some(sock) = agent_socket {
176        vps.agent_socket = Some(sock);
177        vps.use_agent = true;
178    }
179}
180
181pub(crate) fn validate_command_length(command: &str, max_command_chars: usize) -> SshCliResult<()> {
182    let lim = effective_limit(max_command_chars);
183    let len = command.chars().count();
184    if len > lim {
185        return Err(SshCliError::CommandTooLong {
186            max: max_command_chars,
187            len,
188        });
189    }
190    if command.trim().is_empty() {
191        return Err(SshCliError::InvalidArgument("empty command".to_string()));
192    }
193    // G-PROC-03: reject NUL in remote shell payloads. C-string / argv truncation
194    // and opaque binary injection must not reach `channel.exec` / `sh -c` packing.
195    // (CR/LF are allowed — multi-line remote scripts are intentional.)
196    if command.as_bytes().contains(&0) {
197        return Err(SshCliError::InvalidArgument(
198            "command contains null byte".to_string(),
199        ));
200    }
201    Ok(())
202}
203
204/// Sets the active VPS by writing its name to `<config_dir>/active` (sibling file).
205///
206/// Workload: **local marker file**. Sequential justified: single write; no host fan-out.
207pub async fn run_connect(
208    name: &str,
209    config_override: Option<PathBuf>,
210    format: OutputFormat,
211) -> Result<()> {
212    let path = resolve_config_path(config_override.as_deref())?;
213    let file = load(&path)?;
214    if !file.hosts.contains_key(name) {
215        return Err(SshCliError::VpsNotFound(name.to_string()).into());
216    }
217
218    let active_file = path
219        .parent()
220        .map(|p| p.join(crate::constants::ACTIVE_VPS_FILE_NAME))
221        .unwrap_or_else(|| PathBuf::from(crate::constants::ACTIVE_VPS_FILE_NAME));
222    if let Some(parent_dir) = active_file.parent() {
223        std::fs::create_dir_all(parent_dir)?;
224    }
225    // atomic write of active marker
226    let parent_dir = active_file
227        .parent()
228        .map(Path::to_path_buf)
229        .unwrap_or_else(|| PathBuf::from("."));
230    let mut tmp = tempfile::NamedTempFile::new_in(&parent_dir)?;
231    tmp.write_all(name.as_bytes())?;
232    tmp.as_file().sync_data()?;
233    tmp.persist(&active_file)
234        .map_err(|e| SshCliError::Io(e.error))?;
235    crate::output::emit_success(
236        "vps-connected",
237        serde_json::json!({ "name": name }),
238        &crate::i18n::t(crate::i18n::Message::VpsActiveSelected {
239            name: name.to_string(),
240        }),
241        format == OutputFormat::Json,
242    )?;
243    Ok(())
244}
245
246/// Looks up a VPS record by name.
247///
248/// Borrows the config override; returns an owned [`VpsRecord`] (cloned from the
249/// on-disk map) so the caller can mutate without holding the file open.
250pub fn find_by_name(config_override: Option<&Path>, name: &str) -> SshCliResult<Option<VpsRecord>> {
251    let path = resolve_config_path(config_override)?;
252    let file = load(&path)?;
253    Ok(file.hosts.get(name).cloned())
254}
255
256/// Reads the active VPS name.
257pub fn read_active_vps(config_override: Option<&Path>) -> SshCliResult<Option<String>> {
258    let path = resolve_config_path(config_override)?;
259    let active_file = path
260        .parent()
261        .map(|p| p.join(crate::constants::ACTIVE_VPS_FILE_NAME))
262        .unwrap_or_else(|| PathBuf::from(crate::constants::ACTIVE_VPS_FILE_NAME));
263    if !active_file.exists() {
264        return Ok(None);
265    }
266    let name = std::fs::read_to_string(&active_file)?;
267    Ok(Some(name.trim().to_string()))
268}
269
270/// Builds `ConnectionConfig` from a `VpsRecord`.
271pub fn build_connection_config(
272    vps: &VpsRecord,
273    config_toml: Option<&Path>,
274    replace_host_key: bool,
275) -> ConnectionConfig {
276    let known_hosts_path = config_toml.map(KnownHosts::path_beside_config);
277    let tls = if vps.tls {
278        let sni = vps
279            .tls_sni
280            .as_deref()
281            .filter(|s| !s.trim().is_empty())
282            .unwrap_or_else(|| vps.host.as_str());
283        let client_cert = vps
284            .tls_client_cert
285            .as_ref()
286            .map(|p| std::path::PathBuf::from(p.as_str()));
287        let client_key = vps
288            .tls_client_key
289            .as_ref()
290            .map(|p| std::path::PathBuf::from(p.as_str()));
291        match crate::tls::TlsConnectOptions::try_new(sni, client_cert, client_key) {
292            Ok(o) => Some(o),
293            Err(e) => {
294                tracing::warn!(err = %e, "invalid TLS options on VPS record; plain SSH");
295                None
296            }
297        }
298    } else {
299        None
300    };
301    ConnectionConfig {
302        host: vps.host.clone(),
303        port: vps.port,
304        username: vps.username.clone(),
305        password: vps.password.clone(),
306        key_path: vps.key_path.clone(),
307        key_passphrase: vps.key_passphrase.clone(),
308        timeout_ms: vps.timeout_ms,
309        known_hosts_path,
310        replace_host_key,
311        tls,
312        use_agent: vps.use_agent,
313        agent_socket: vps.agent_socket.as_ref().map(std::path::PathBuf::from),
314    }
315}
316
317// Exec family: see `exec_ops` module (G-COMP-05 + G-DRY-01).
318
319// Health-check: see `health` module (G-COMP-04).
320
321#[cfg(test)]
322#[path = "tests.rs"]
323mod tests;