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::{mtls_import, mtls_list, mtls_remove, mtls_show, resolve_mtls_paths, MtlsIdentity};
50#[cfg(feature = "tls")]
51pub use paths::{
52    acme_account_path, acme_domain_dir, mtls_identity_dir, resolve_tls_root, tls_root_dir,
53};
54#[cfg(feature = "tls")]
55pub use provider::{install_default_provider, provider_is_installed, provider_name};
56
57/// Options for wrapping the SSH TCP path in TLS (SSH-over-TLS).
58///
59/// When present on [`crate::ssh::ConnectionConfig`], the client dials TCP,
60/// completes a rustls handshake (optional mTLS), then runs SSH on the TLS stream.
61#[derive(Debug, Clone)]
62pub struct TlsConnectOptions {
63    /// DNS name for SNI + certificate verification (usually the VPS host).
64    pub sni: String,
65    /// Optional client certificate PEM path (mTLS).
66    pub client_cert: Option<std::path::PathBuf>,
67    /// Optional client private key PEM path (mTLS; required with cert).
68    pub client_key: Option<std::path::PathBuf>,
69}
70
71impl TlsConnectOptions {
72    /// Builds options from host + optional mTLS paths.
73    ///
74    /// # Errors
75    /// Returns [`crate::errors::SshCliError::InvalidArgument`] when only one of
76    /// cert/key is set or SNI is empty.
77    pub fn try_new(
78        sni: impl Into<String>,
79        client_cert: Option<std::path::PathBuf>,
80        client_key: Option<std::path::PathBuf>,
81    ) -> crate::errors::SshCliResult<Self> {
82        let sni = sni.into();
83        let sni_trim = sni.trim();
84        if sni_trim.is_empty() {
85            return Err(crate::errors::SshCliError::InvalidArgument(
86                "TLS SNI cannot be empty".into(),
87            ));
88        }
89        match (&client_cert, &client_key) {
90            (Some(_), None) | (None, Some(_)) => {
91                return Err(crate::errors::SshCliError::InvalidArgument(
92                    "mTLS requires both client cert and key paths".into(),
93                ));
94            }
95            _ => {}
96        }
97        Ok(Self {
98            sni: sni_trim.to_owned(),
99            client_cert,
100            client_key,
101        })
102    }
103}
104
105/// Stub when feature `tls` is disabled: install is a no-op; dials fail closed.
106#[cfg(not(feature = "tls"))]
107pub mod disabled {
108    use crate::errors::SshCliResult;
109
110    /// No-op without the TLS feature (binary still runs plain SSH).
111    pub fn install_default_provider() -> SshCliResult<()> {
112        Ok(())
113    }
114
115    /// Always false without the feature.
116    #[must_use]
117    pub fn provider_is_installed() -> bool {
118        false
119    }
120
121    /// Placeholder name when TLS is not compiled in.
122    #[must_use]
123    pub fn provider_name() -> &'static str {
124        "disabled"
125    }
126}
127
128#[cfg(not(feature = "tls"))]
129pub use disabled::{install_default_provider, provider_is_installed, provider_name};
130
131#[cfg(all(test, feature = "tls"))]
132mod tests {
133    use super::*;
134
135    #[test]
136    fn tls_options_reject_partial_mtls() {
137        let err = TlsConnectOptions::try_new(
138            "example.com",
139            Some(std::path::PathBuf::from("c.pem")),
140            None,
141        )
142        .unwrap_err();
143        assert!(err.to_string().contains("mTLS"));
144    }
145
146    #[test]
147    fn tls_options_reject_empty_sni() {
148        let err = TlsConnectOptions::try_new("  ", None, None).unwrap_err();
149        assert!(err.to_string().contains("SNI"));
150    }
151
152    #[test]
153    fn tls_options_ok() {
154        let o = TlsConnectOptions::try_new("host.example", None, None).unwrap();
155        assert_eq!(o.sni, "host.example");
156    }
157}