Skip to main content

ssh_cli/ssh/
connection.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2// G-COMP-R / G-TYPE-11/15 / G-SSH: ConnectionConfig uses domain newtypes (SRP — connect params).
3#![forbid(unsafe_code)]
4//! SSH connection configuration (auth + timeout + TOFU paths).
5//!
6//! # Auth policy (G-SSH-15)
7//!
8//! - **Public key file** (`key_path`) is preferred when present.
9//! - **ssh-agent** when `use_agent` is true (socket from CLI/XDG — never env-as-store).
10//! - **Password** is an opt-in fallback: a non-empty `password` SecretString in the
11//!   XDG inventory (or CLI override) enables password auth after key/agent failure.
12//!   An empty password does **not** enable password auth.
13
14use crate::domain::{secret_nonempty, KeyPath, SshHost, SshPort, SshUser, TimeoutMs};
15use crate::errors::{SshCliError, SshCliResult};
16use crate::tls::TlsConnectOptions;
17use secrecy::SecretString;
18use std::path::PathBuf;
19
20/// SSH connection configuration.
21///
22/// Built from a [`crate::vps::model::VpsRecord`] at the time
23/// of the call. Auth: private key (preferred), optional agent, and/or password.
24///
25/// Host, port, user, and timeout are domain-proven — empty host / port 0 are
26/// unrepresentable (G-TYPE-11: no redundant field checks).
27///
28/// When [`Self::tls`] is `Some`, the client dials TCP then completes a rustls
29/// handshake (optional mTLS) before the SSH protocol (SSH-over-TLS).
30#[derive(Clone)]
31pub struct ConnectionConfig {
32    /// SSH server hostname or IP.
33    pub host: SshHost,
34    /// SSH server TCP port (always ≥ 1).
35    pub port: SshPort,
36    /// SSH username.
37    pub username: SshUser,
38    /// SSH password (`SecretString` for automatic zeroize); may be empty for key-only.
39    pub password: SecretString,
40    /// OpenSSH private key path (optional).
41    pub key_path: Option<KeyPath>,
42    /// Key passphrase (optional).
43    pub key_passphrase: Option<SecretString>,
44    /// Total timeout for connect + handshake + authentication + exec, in ms.
45    pub timeout_ms: TimeoutMs,
46    /// Path to known_hosts file (TOFU). `None` = test-only always-trust / product fail-closed.
47    pub known_hosts_path: Option<PathBuf>,
48    /// When true, allow replacing a divergent host key fingerprint.
49    pub replace_host_key: bool,
50    /// Optional TLS wrapper (SSH-over-TLS / mTLS). `None` = plain TCP SSH.
51    pub tls: Option<TlsConnectOptions>,
52    /// Attempt ssh-agent publickey auth (G-SSH-04). Socket from [`Self::agent_socket`]
53    /// or platform default (Windows named pipe only — Unix requires explicit path).
54    pub use_agent: bool,
55    /// Agent socket / named-pipe path (CLI or XDG). Never read from env store.
56    pub agent_socket: Option<PathBuf>,
57}
58
59impl std::fmt::Debug for ConnectionConfig {
60    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
61        f.debug_struct("ConnectionConfig")
62            .field("host", &self.host.as_str())
63            .field("port", &self.port.get())
64            .field("username", &self.username.as_str())
65            .field("password", &"<redacted>")
66            .field("key_path", &self.key_path.as_ref().map(|k| k.as_path()))
67            .field(
68                "key_passphrase",
69                &self.key_passphrase.as_ref().map(|_| "<redacted>"),
70            )
71            .field("timeout_ms", &self.timeout_ms.get())
72            .field("known_hosts_path", &self.known_hosts_path)
73            .field("replace_host_key", &self.replace_host_key)
74            .field("tls", &self.tls)
75            .field("use_agent", &self.use_agent)
76            .field("agent_socket", &self.agent_socket)
77            .finish()
78    }
79}
80
81impl ConnectionConfig {
82    /// Validates authentication material only (host/port/user proven by types).
83    ///
84    /// Accepts password, key path, and/or agent (G-SSH-17).
85    pub fn validate(&self) -> SshCliResult<()> {
86        let has_password = secret_nonempty(&self.password);
87        let has_key = self.key_path.is_some();
88        let has_agent = self.use_agent;
89        if !has_password && !has_key && !has_agent {
90            return Err(SshCliError::InvalidArgument(
91                "auth requires password, key_path, or --use-agent".to_string(),
92            ));
93        }
94        if has_agent {
95            // Fail closed early when Unix agent has no socket path.
96            #[cfg(unix)]
97            if self.agent_socket.is_none() {
98                return Err(SshCliError::InvalidArgument(
99                    "use_agent requires --agent-socket PATH (or agent_socket in XDG VPS record); \
100                     env SSH_AUTH_SOCK is not used as product store"
101                        .into(),
102                ));
103            }
104        }
105        Ok(())
106    }
107
108    /// Resolved agent endpoint path (CLI/XDG or Windows platform default).
109    #[must_use]
110    pub fn resolved_agent_socket(&self) -> Option<PathBuf> {
111        if !self.use_agent {
112            return None;
113        }
114        if let Some(p) = &self.agent_socket {
115            return Some(p.clone());
116        }
117        #[cfg(windows)]
118        {
119            return Some(PathBuf::from(crate::constants::WINDOWS_SSH_AGENT_PIPE));
120        }
121        #[cfg(not(windows))]
122        {
123            None
124        }
125    }
126}