Skip to main content

dig_nat/
dialer.rs

1//! The production [`Dialer`] — performs the real rustls **mTLS** dial to a reachable address and
2//! returns a [`PeerConnection`] whose remote `peer_id` has been verified.
3//!
4//! This is the single place transport detail lives: (happy-eyeballs) TCP connect → rustls client
5//! handshake presenting THIS node's certificate (mutual TLS) → the [`PeerIdPinningVerifier`]
6//! captures the peer's leaf cert, derives `peer_id = SHA-256(SPKI DER)`, and rejects the handshake
7//! unless it matches the [`PeerTarget::peer_id`] the caller asked for. On success the caller gets an
8//! authenticated, encrypted [`tokio_rustls::client::TlsStream`].
9//!
10//! ## IPv6-first, IPv4-fallback — delegated to `dig-ip` (CLAUDE.md §5.2)
11//!
12//! The family-selection + happy-eyeballs racing that used to live here is now the `dig-ip` crate's
13//! single ecosystem responsibility. [`MtlsDialer::dial`] aggregates a [`MethodOutcome`]'s candidate
14//! addresses into a [`dig_ip::PeerCandidates`] and calls [`dig_ip::connect`], handing it the local
15//! host's [`dig_ip::LocalStack`] and a closure that performs one candidate's raw TCP connect.
16//! `dig-ip` then dials over the **local∩peer family intersection** (never a family the local host or
17//! the peer lacks — its structural guarantee), IPv6-first with graceful IPv4 fallback. Once a TCP
18//! connection wins, the single mTLS handshake runs over it — the identity/cert behaviour below is
19//! unchanged; only the family selection + racing moved out. See `dig-ip`'s `SPEC.md`.
20
21use std::sync::Arc;
22
23use async_trait::async_trait;
24use dig_ip::{CandidateSource, DialConfig, LocalStack, PeerCandidates};
25use rustls::pki_types::{CertificateDer, PrivateKeyDer, ServerName};
26use rustls::ClientConfig;
27use tokio::net::TcpStream;
28use tokio_rustls::TlsConnector;
29
30use crate::cert_binding::BindingPolicy;
31use crate::config::LocalIdentity;
32use crate::error::MethodError;
33use crate::method::{MethodOutcome, TraversalKind};
34use crate::mtls::{CapturedBlsPub, CapturedPeerId, PeerIdPinningVerifier};
35use crate::peer::{PeerConnection, PeerTarget};
36use crate::strategy::Dialer;
37
38/// Tuning for the happy-eyeballs candidate race: how long each candidate connect may take, and how
39/// long to wait before ALSO starting the next (lower-priority) candidate. A thin dig-nat-facing view
40/// of [`dig_ip::DialConfig`] (which the dial converts it into) so the public dialer API stays stable.
41#[derive(Debug, Clone, Copy)]
42pub struct HappyEyeballsConfig {
43    /// Hard timeout for a single candidate's connect attempt.
44    pub per_attempt_timeout: std::time::Duration,
45    /// Delay before starting the next candidate while the current one is still in flight (RFC 8305
46    /// "Connection Attempt Delay"). A small value (tens of ms) hedges a stalled IPv6 without racing
47    /// so hard that IPv4 routinely beats a viable IPv6.
48    pub stagger: std::time::Duration,
49}
50
51impl Default for HappyEyeballsConfig {
52    fn default() -> Self {
53        // RFC 8305 recommends a ~250ms connection-attempt delay; the per-attempt timeout is kept
54        // generous (the strategy's per-method timeout is the real outer bound).
55        HappyEyeballsConfig {
56            per_attempt_timeout: std::time::Duration::from_secs(10),
57            stagger: std::time::Duration::from_millis(250),
58        }
59    }
60}
61
62impl From<HappyEyeballsConfig> for DialConfig {
63    fn from(cfg: HappyEyeballsConfig) -> DialConfig {
64        DialConfig {
65            per_attempt_timeout: cfg.per_attempt_timeout,
66            attempt_delay: cfg.stagger,
67        }
68    }
69}
70
71/// Aggregate a traversal [`MethodOutcome`]'s dial addresses into a family-tagged
72/// [`dig_ip::PeerCandidates`] — the input `dig_ip::connect` filters by the local∩peer intersection.
73///
74/// The candidate ordering + IPv6-first preference is `dig-ip`'s job, so the addresses are added in
75/// the order the traversal produced them; `dig-ip` derives each family and orders IPv6-first. The
76/// [`CandidateSource`] tag is provenance/observability only (it never influences the intersection
77/// rule): a relay-coordinated or relayed endpoint is tagged [`CandidateSource::RelayIntroduction`],
78/// every direct/port-mapping endpoint the peer's advertised [`CandidateSource::ListenAddr`].
79pub fn candidates_from_outcome(outcome: &MethodOutcome) -> PeerCandidates {
80    let source = match outcome.kind {
81        TraversalKind::HolePunch | TraversalKind::Relayed => CandidateSource::RelayIntroduction,
82        TraversalKind::Direct
83        | TraversalKind::Upnp
84        | TraversalKind::NatPmp
85        | TraversalKind::Pcp => CandidateSource::ListenAddr,
86    };
87    let mut candidates = PeerCandidates::new();
88    candidates.extend(outcome.dial_addrs.iter().copied(), source);
89    candidates
90}
91
92/// The production mTLS dialer. Holds this node's [`LocalIdentity`] (its client certificate for
93/// mutual TLS) and builds a fresh pinning verifier per dial. The candidate race is tuned by
94/// [`MtlsDialer::happy_eyeballs`], and the local stack it dials from by [`MtlsDialer::local_stack`].
95#[derive(Debug, Clone)]
96pub struct MtlsDialer {
97    identity: LocalIdentity,
98    happy_eyeballs: HappyEyeballsConfig,
99    /// The BLS cert-binding verification stance for the peer's cert (#1204). Defaults to
100    /// [`BindingPolicy::Opportunistic`] — the rollout default: verify a binding when the peer
101    /// presents one, tolerate a legacy peer that does not. A node that requires sealing sets
102    /// [`BindingPolicy::Required`] via [`MtlsDialer::with_binding_policy`].
103    binding_policy: BindingPolicy,
104    /// The local host's address-family capability used to filter the dial (`dig-ip`'s G1 gate).
105    /// `None` = probe the real host per dial via [`LocalStack::cached`]; `Some` pins a deterministic
106    /// stack (used by tests to exercise the intersection matrix without a host dependency).
107    local_stack: Option<LocalStack>,
108}
109
110impl MtlsDialer {
111    /// Build a dialer that authenticates as `identity` (presents its cert as the mTLS client cert),
112    /// using the default happy-eyeballs tuning, the real host's detected address-family stack, and
113    /// the default [`BindingPolicy::Opportunistic`] cert-binding stance.
114    pub fn new(identity: LocalIdentity) -> Self {
115        MtlsDialer {
116            identity,
117            happy_eyeballs: HappyEyeballsConfig::default(),
118            binding_policy: BindingPolicy::default(),
119            local_stack: None,
120        }
121    }
122
123    /// Override the happy-eyeballs (IPv6-first candidate race) tuning.
124    pub fn with_happy_eyeballs(mut self, config: HappyEyeballsConfig) -> Self {
125        self.happy_eyeballs = config;
126        self
127    }
128
129    /// Set the BLS cert-binding verification stance (#1204) for peer certs this dialer verifies.
130    pub fn with_binding_policy(mut self, policy: BindingPolicy) -> Self {
131        self.binding_policy = policy;
132        self
133    }
134
135    /// Pin the local address-family stack the dial filters against, instead of probing the real host.
136    /// The dial NEVER attempts a family this stack lacks (`dig-ip`'s G1 guarantee) — this seam lets a
137    /// test drive the intersection deterministically (`LocalStack::from_flags`).
138    pub fn with_local_stack(mut self, stack: LocalStack) -> Self {
139        self.local_stack = Some(stack);
140        self
141    }
142
143    /// Construct the rustls [`ClientConfig`] for one dial: present our client cert, and verify the
144    /// server (peer) via the [`PeerIdPinningVerifier`] pinned to `expected` (the peer we want).
145    fn client_config(
146        &self,
147        expected: crate::identity::PeerId,
148        captured: CapturedPeerId,
149        captured_bls: CapturedBlsPub,
150    ) -> Result<ClientConfig, String> {
151        let cert = CertificateDer::from(self.identity.cert_der.clone());
152        // `key_der` is `Zeroizing<Vec<u8>>` (#179 finding 4); `.to_vec()` copies the bytes out into
153        // a plain `Vec<u8>` for rustls (which takes ownership into its own `PrivateKeyDer`) — the
154        // `Zeroizing` original still scrubs itself on drop as normal.
155        let key = PrivateKeyDer::try_from(self.identity.key_der.to_vec())
156            .map_err(|e| format!("invalid private key: {e}"))?;
157
158        let verifier = Arc::new(
159            PeerIdPinningVerifier::new(Some(expected), captured)
160                .with_binding(self.binding_policy, captured_bls),
161        );
162        ClientConfig::builder()
163            .dangerous()
164            .with_custom_certificate_verifier(verifier)
165            .with_client_auth_cert(vec![cert], key)
166            .map_err(|e| format!("client cert config: {e}"))
167    }
168}
169
170#[async_trait]
171impl Dialer for MtlsDialer {
172    async fn dial(
173        &self,
174        peer: &PeerTarget,
175        outcome: &MethodOutcome,
176    ) -> Result<PeerConnection, MethodError> {
177        let kind = outcome.kind;
178
179        // Delegate family selection + racing to dig-ip: it dials only the local∩peer family
180        // intersection (never a family the local host or the peer lacks), IPv6-first with graceful
181        // IPv4 fallback. A disjoint pair fails immediately with `NoCommonFamily` — no doomed attempt
182        // that can only time out. The winning stream carries its own peer address, so `remote_addr`
183        // reflects the family actually used.
184        let local = self.local_stack.unwrap_or_else(LocalStack::cached);
185        let candidates = candidates_from_outcome(outcome);
186        let winner = dig_ip::connect(
187            &local,
188            &candidates,
189            self.happy_eyeballs.into(),
190            |addr| async move {
191                TcpStream::connect(addr)
192                    .await
193                    .map_err(|e| format!("tcp connect {addr}: {e}"))
194            },
195        )
196        .await
197        .map_err(|e| MethodError::failed(kind, e.to_string()))?;
198        let tcp = winner.conn;
199        let addr = winner.addr;
200
201        let captured = CapturedPeerId::default();
202        let captured_bls = CapturedBlsPub::default();
203        let config = self
204            .client_config(peer.peer_id, captured.clone(), captured_bls.clone())
205            .map_err(|e| MethodError::failed(kind, e))?;
206        let connector = TlsConnector::from(Arc::new(config));
207
208        // The server name is irrelevant to identity here (we verify by peer_id via the pinning
209        // verifier, not by hostname/CA), but rustls requires a syntactically valid SNI. A peer_id
210        // hex (64 chars) is not a valid DNS label (>63), so we use a fixed, well-formed placeholder.
211        let server_name = ServerName::try_from("peer.dig.invalid")
212            .map_err(|e| MethodError::failed(kind, format!("server name: {e}")))?;
213
214        let tls = connector
215            .connect(server_name, tcp)
216            .await
217            .map_err(|e| classify_tls_error(kind, &e))?;
218
219        // The pinning verifier already rejected a mismatch; this is the authenticated identity.
220        let verified = captured
221            .get()
222            .ok_or_else(|| MethodError::failed(kind, "peer presented no certificate"))?;
223
224        // Wrap the single mTLS byte stream in yamux so the caller can open many concurrent
225        // (range-)streams over it — the streaming-first, multiplexed transport is uniform across
226        // every traversal tier.
227        let session = crate::mux::PeerSession::client(tls);
228
229        Ok(PeerConnection {
230            peer_id: verified,
231            method: kind,
232            remote_addr: addr,
233            peer_bls_pub: captured_bls.get(),
234            session,
235        })
236    }
237}
238
239/// Map a rustls handshake error to a [`MethodError`], surfacing a peer_id mismatch clearly (it
240/// arrives as a general error from the verifier).
241fn classify_tls_error(kind: TraversalKind, e: &std::io::Error) -> MethodError {
242    let msg = e.to_string();
243    MethodError::failed(kind, format!("mtls handshake: {msg}"))
244}