Skip to main content

ssh_cli/tls/
dial.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2#![forbid(unsafe_code)]
3//! TCP + rustls handshake for SSH-over-TLS.
4
5use std::sync::Arc;
6
7use rustls::pki_types::ServerName;
8use tokio::net::TcpStream;
9use tokio_rustls::client::TlsStream as TokioTlsStream;
10use tokio_rustls::TlsConnector;
11
12use super::client_config::{build_client_config, TlsClientOptions};
13use super::TlsConnectOptions;
14use crate::errors::{SshCliError, SshCliResult};
15
16/// Product TLS stream type (client half over TCP).
17pub type TlsStream = TokioTlsStream<TcpStream>;
18
19/// Dials `host:port`, completes TLS with SNI/`TlsConnectOptions`, returns the stream.
20///
21/// # Errors
22/// DNS/TCP failure, invalid SNI, handshake failure, or PEM/mTLS config errors.
23pub async fn dial_tls(host: &str, port: u16, opts: &TlsConnectOptions) -> SshCliResult<TlsStream> {
24    let server_name = ServerName::try_from(opts.sni.as_str())
25        .map_err(|e| SshCliError::tls_msg(format!("invalid TLS SNI '{}': {e}", opts.sni)))?
26        .to_owned();
27
28    let client_opts = TlsClientOptions {
29        client_cert: opts.client_cert.clone(),
30        client_key: opts.client_key.clone(),
31        extra_root_pem: None,
32    };
33    let config = build_client_config(&client_opts)?;
34    let connector = TlsConnector::from(config);
35
36    let tcp = crate::net::dial_tcp(host, port).await.map_err(|e| {
37        SshCliError::ConnectionFailed(format!("TCP dial failed for {host}:{port}: {e}"))
38    })?;
39    if let Err(e) = tcp.set_nodelay(true) {
40        tracing::debug!(err = %e, "set_nodelay on TLS socket failed");
41    }
42
43    tracing::info!(
44        host,
45        port,
46        sni = %opts.sni,
47        mtls = opts.client_cert.is_some(),
48        "starting TLS handshake (SSH-over-TLS)"
49    );
50
51    connector
52        .connect(server_name, tcp)
53        .await
54        .map_err(|e| SshCliError::tls_msg(format!("TLS handshake failed for {host}:{port}: {e}")))
55}
56
57/// Dials with a pre-built shared [`rustls::ClientConfig`] (library extension point).
58#[allow(dead_code)] // public extension for embedders / future CLI overrides
59pub async fn dial_tls_with_config(
60    host: &str,
61    port: u16,
62    sni: &str,
63    config: Arc<rustls::ClientConfig>,
64) -> SshCliResult<TlsStream> {
65    let server_name = ServerName::try_from(sni)
66        .map_err(|e| SshCliError::tls_msg(format!("invalid TLS SNI '{sni}': {e}")))?
67        .to_owned();
68    let connector = TlsConnector::from(config);
69    let tcp = crate::net::dial_tcp(host, port).await.map_err(|e| {
70        SshCliError::ConnectionFailed(format!("TCP dial failed for {host}:{port}: {e}"))
71    })?;
72    let _ = tcp.set_nodelay(true);
73    connector
74        .connect(server_name, tcp)
75        .await
76        .map_err(|e| SshCliError::tls_msg(format!("TLS handshake failed for {host}:{port}: {e}")))
77}