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}