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, 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.
73pub fn read_secret_stdin() -> SshCliResult<SecretString> {
74    use std::io::Read;
75    use zeroize::Zeroizing;
76    let mut limited = std::io::stdin().take(MAX_SECRET_STDIN_BYTES + 1);
77    let mut buf = Zeroizing::new(String::new());
78    limited.read_to_string(&mut buf)?;
79    if buf.len() as u64 > MAX_SECRET_STDIN_BYTES {
80        return Err(SshCliError::InvalidArgument(format!(
81            "stdin secret exceeds max size of {MAX_SECRET_STDIN_BYTES} bytes"
82        )));
83    }
84    let trimmed = buf.trim_end_matches(['\r', '\n']);
85    Ok(SecretString::from(trimmed.to_owned()))
86}
87
88/// Applies runtime overrides onto a cloned `VpsRecord`.
89///
90/// Parameter order: password, sudo, su, timeout, key_path, key_passphrase, use_agent, agent_socket.
91///
92/// G-SECDEV-02: secret overrides are already [`SecretString`] (zeroize-on-drop);
93/// never re-accept bare password `String` past the CLI boundary.
94#[allow(clippy::too_many_arguments)]
95pub(crate) fn apply_overrides(
96    vps: &mut VpsRecord,
97    password_override: Option<SecretString>,
98    sudo_password_override: Option<SecretString>,
99    su_password_override: Option<SecretString>,
100    timeout_override: Option<crate::domain::TimeoutMs>,
101    key_path_override: Option<String>,
102    key_passphrase_override: Option<SecretString>,
103    use_agent: bool,
104    agent_socket: Option<String>,
105) {
106    use crate::domain::KeyPath;
107    if let Some(pwd) = password_override {
108        vps.password = pwd;
109    }
110    if let Some(spwd) = sudo_password_override {
111        vps.sudo_password = Some(spwd);
112    }
113    if let Some(sp) = su_password_override {
114        vps.su_password = Some(sp);
115    }
116    // G-TYPE-18: timeout already refined at the CLI / options boundary.
117    if let Some(t) = timeout_override {
118        vps.timeout_ms = t;
119    }
120    if let Some(k) = key_path_override {
121        if let Ok(kp) = KeyPath::try_new(k) {
122            vps.key_path = Some(kp);
123        }
124    }
125    if let Some(kp) = key_passphrase_override {
126        vps.key_passphrase = Some(kp);
127    }
128    if use_agent {
129        vps.use_agent = true;
130    }
131    if let Some(sock) = agent_socket {
132        vps.agent_socket = Some(sock);
133        vps.use_agent = true;
134    }
135}
136
137pub(crate) fn validate_command_length(command: &str, max_command_chars: usize) -> SshCliResult<()> {
138    let lim = effective_limit(max_command_chars);
139    let len = command.chars().count();
140    if len > lim {
141        return Err(SshCliError::CommandTooLong {
142            max: max_command_chars,
143            len,
144        });
145    }
146    if command.trim().is_empty() {
147        return Err(SshCliError::InvalidArgument("empty command".to_string()));
148    }
149    // G-PROC-03: reject NUL in remote shell payloads. C-string / argv truncation
150    // and opaque binary injection must not reach `channel.exec` / `sh -c` packing.
151    // (CR/LF are allowed — multi-line remote scripts are intentional.)
152    if command.as_bytes().contains(&0) {
153        return Err(SshCliError::InvalidArgument(
154            "command contains null byte".to_string(),
155        ));
156    }
157    Ok(())
158}
159
160/// Sets the active VPS by writing its name to `<config_dir>/active` (sibling file).
161///
162/// Workload: **local marker file**. Sequential justified: single write; no host fan-out.
163pub async fn run_connect(
164    name: &str,
165    config_override: Option<PathBuf>,
166    format: OutputFormat,
167) -> Result<()> {
168    let path = resolve_config_path(config_override.as_deref())?;
169    let file = load(&path)?;
170    if !file.hosts.contains_key(name) {
171        return Err(SshCliError::VpsNotFound(name.to_string()).into());
172    }
173
174    let active_file = path
175        .parent()
176        .map(|p| p.join(crate::constants::ACTIVE_VPS_FILE_NAME))
177        .unwrap_or_else(|| PathBuf::from(crate::constants::ACTIVE_VPS_FILE_NAME));
178    if let Some(parent_dir) = active_file.parent() {
179        std::fs::create_dir_all(parent_dir)?;
180    }
181    // atomic write of active marker
182    let parent_dir = active_file
183        .parent()
184        .map(Path::to_path_buf)
185        .unwrap_or_else(|| PathBuf::from("."));
186    let mut tmp = tempfile::NamedTempFile::new_in(&parent_dir)?;
187    tmp.write_all(name.as_bytes())?;
188    tmp.as_file().sync_data()?;
189    tmp.persist(&active_file)
190        .map_err(|e| SshCliError::Io(e.error))?;
191    crate::output::emit_success(
192        "vps-connected",
193        serde_json::json!({ "name": name }),
194        &crate::i18n::t(crate::i18n::Message::VpsActiveSelected {
195            name: name.to_string(),
196        }),
197        format == OutputFormat::Json,
198    )?;
199    Ok(())
200}
201
202/// Looks up a VPS record by name.
203///
204/// Borrows the config override; returns an owned [`VpsRecord`] (cloned from the
205/// on-disk map) so the caller can mutate without holding the file open.
206pub fn find_by_name(config_override: Option<&Path>, name: &str) -> SshCliResult<Option<VpsRecord>> {
207    let path = resolve_config_path(config_override)?;
208    let file = load(&path)?;
209    Ok(file.hosts.get(name).cloned())
210}
211
212/// Reads the active VPS name.
213pub fn read_active_vps(config_override: Option<&Path>) -> SshCliResult<Option<String>> {
214    let path = resolve_config_path(config_override)?;
215    let active_file = path
216        .parent()
217        .map(|p| p.join(crate::constants::ACTIVE_VPS_FILE_NAME))
218        .unwrap_or_else(|| PathBuf::from(crate::constants::ACTIVE_VPS_FILE_NAME));
219    if !active_file.exists() {
220        return Ok(None);
221    }
222    let name = std::fs::read_to_string(&active_file)?;
223    Ok(Some(name.trim().to_string()))
224}
225
226/// Builds `ConnectionConfig` from a `VpsRecord`.
227pub fn build_connection_config(
228    vps: &VpsRecord,
229    config_toml: Option<&Path>,
230    replace_host_key: bool,
231) -> ConnectionConfig {
232    let known_hosts_path = config_toml.map(KnownHosts::path_beside_config);
233    let tls = if vps.tls {
234        let sni = vps
235            .tls_sni
236            .as_deref()
237            .filter(|s| !s.trim().is_empty())
238            .unwrap_or_else(|| vps.host.as_str());
239        let client_cert = vps
240            .tls_client_cert
241            .as_ref()
242            .map(|p| std::path::PathBuf::from(p.as_str()));
243        let client_key = vps
244            .tls_client_key
245            .as_ref()
246            .map(|p| std::path::PathBuf::from(p.as_str()));
247        match crate::tls::TlsConnectOptions::try_new(sni, client_cert, client_key) {
248            Ok(o) => Some(o),
249            Err(e) => {
250                tracing::warn!(err = %e, "invalid TLS options on VPS record; plain SSH");
251                None
252            }
253        }
254    } else {
255        None
256    };
257    ConnectionConfig {
258        host: vps.host.clone(),
259        port: vps.port,
260        username: vps.username.clone(),
261        password: vps.password.clone(),
262        key_path: vps.key_path.clone(),
263        key_passphrase: vps.key_passphrase.clone(),
264        timeout_ms: vps.timeout_ms,
265        known_hosts_path,
266        replace_host_key,
267        tls,
268        use_agent: vps.use_agent,
269        agent_socket: vps.agent_socket.as_ref().map(std::path::PathBuf::from),
270    }
271}
272
273// Exec family: see `exec_ops` module (G-COMP-05 + G-DRY-01).
274
275// Health-check: see `health` module (G-COMP-04).
276
277#[cfg(test)]
278#[path = "tests.rs"]
279mod tests;