Skip to main content

ssh_cli/tls/
mod.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2// G-TLS product stack — pure module: no `unsafe`.
3#![forbid(unsafe_code)]
4//! Product TLS via **rustls** (aws_lc_rs only): SSH-over-TLS, mTLS, ACME.
5//!
6//! # Design (Rules Rust — rustls)
7//!
8//! | Concern | Rule |
9//! |---------|------|
10//! | Provider | `CryptoProvider::install_default` **once** in binary `main` |
11//! | Libraries | use `ClientConfig::builder` / `get_default` — never reinstall |
12//! | Stack | rustls ≥ 0.23.18 only; no `native-tls` / OpenSSL / `ring` |
13//! | Storage | XDG under `tls/` — no product env vars for cert material |
14//! | Secrets | PEM keys 0o600; never log private key material |
15//!
16//! # Workload
17//!
18//! I/O-bound (TCP + TLS handshake). No Rayon. Multi-host fan-out stays in callers.
19
20#[cfg(feature = "tls")]
21mod acme;
22#[cfg(feature = "tls")]
23mod acme_error_map;
24#[cfg(feature = "tls")]
25mod client_config;
26#[cfg(feature = "tls")]
27pub mod commands;
28#[cfg(feature = "tls")]
29mod dial;
30#[cfg(feature = "tls")]
31mod mtls;
32#[cfg(feature = "tls")]
33mod paths;
34#[cfg(feature = "tls")]
35mod pem;
36#[cfg(feature = "tls")]
37mod provider;
38
39#[cfg(feature = "tls")]
40pub use acme::{
41    acme_complete, acme_issue_print_challenge, acme_list, acme_status, create_account,
42    load_account_status, AcmeDirectory,
43};
44#[cfg(feature = "tls")]
45pub use client_config::{build_client_config, TlsClientOptions};
46#[cfg(feature = "tls")]
47pub use dial::{dial_tls, TlsStream};
48#[cfg(feature = "tls")]
49pub use mtls::{
50    mtls_import, mtls_list, mtls_remove, mtls_show, resolve_mtls_paths, MtlsIdentity,
51};
52#[cfg(feature = "tls")]
53pub use paths::{
54    acme_account_path, acme_domain_dir, mtls_identity_dir, resolve_tls_root, tls_root_dir,
55};
56#[cfg(feature = "tls")]
57pub use provider::{install_default_provider, provider_is_installed, provider_name};
58
59/// Options for wrapping the SSH TCP path in TLS (SSH-over-TLS).
60///
61/// When present on [`crate::ssh::ConnectionConfig`], the client dials TCP,
62/// completes a rustls handshake (optional mTLS), then runs SSH on the TLS stream.
63#[derive(Debug, Clone)]
64pub struct TlsConnectOptions {
65    /// DNS name for SNI + certificate verification (usually the VPS host).
66    pub sni: String,
67    /// Optional client certificate PEM path (mTLS).
68    pub client_cert: Option<std::path::PathBuf>,
69    /// Optional client private key PEM path (mTLS; required with cert).
70    pub client_key: Option<std::path::PathBuf>,
71}
72
73impl TlsConnectOptions {
74    /// Builds options from host + optional mTLS paths.
75    ///
76    /// # Errors
77    /// Returns [`crate::errors::SshCliError::InvalidArgument`] when only one of
78    /// cert/key is set or SNI is empty.
79    pub fn try_new(
80        sni: impl Into<String>,
81        client_cert: Option<std::path::PathBuf>,
82        client_key: Option<std::path::PathBuf>,
83    ) -> crate::errors::SshCliResult<Self> {
84        let sni = sni.into();
85        let sni_trim = sni.trim();
86        if sni_trim.is_empty() {
87            return Err(crate::errors::SshCliError::InvalidArgument(
88                "TLS SNI cannot be empty".into(),
89            ));
90        }
91        match (&client_cert, &client_key) {
92            (Some(_), None) | (None, Some(_)) => {
93                return Err(crate::errors::SshCliError::InvalidArgument(
94                    "mTLS requires both client cert and key paths".into(),
95                ));
96            }
97            _ => {}
98        }
99        Ok(Self {
100            sni: sni_trim.to_owned(),
101            client_cert,
102            client_key,
103        })
104    }
105}
106
107/// Stub when feature `tls` is disabled: install is a no-op; dials fail closed.
108#[cfg(not(feature = "tls"))]
109pub mod disabled {
110    use crate::errors::SshCliResult;
111
112    /// No-op without the TLS feature (binary still runs plain SSH).
113    pub fn install_default_provider() -> SshCliResult<()> {
114        Ok(())
115    }
116
117    /// Always false without the feature.
118    #[must_use]
119    pub fn provider_is_installed() -> bool {
120        false
121    }
122
123    /// Placeholder name when TLS is not compiled in.
124    #[must_use]
125    pub fn provider_name() -> &'static str {
126        "disabled"
127    }
128}
129
130#[cfg(not(feature = "tls"))]
131pub use disabled::{install_default_provider, provider_is_installed, provider_name};
132
133#[cfg(all(test, feature = "tls"))]
134mod tests {
135    use super::*;
136
137    #[test]
138    fn tls_options_reject_partial_mtls() {
139        let err = TlsConnectOptions::try_new(
140            "example.com",
141            Some(std::path::PathBuf::from("c.pem")),
142            None,
143        )
144        .unwrap_err();
145        assert!(err.to_string().contains("mTLS"));
146    }
147
148    #[test]
149    fn tls_options_reject_empty_sni() {
150        let err = TlsConnectOptions::try_new("  ", None, None).unwrap_err();
151        assert!(err.to_string().contains("SNI"));
152    }
153
154    #[test]
155    fn tls_options_ok() {
156        let o = TlsConnectOptions::try_new("host.example", None, None).unwrap();
157        assert_eq!(o.sni, "host.example");
158    }
159}