dig_nat/strategy.rs
1//! The traversal strategy — orders the enabled methods (direct-first, relay-last), tries each with
2//! a bounded timeout, and returns the FIRST connection that establishes. This is where "the caller
3//! doesn't choose the method" is realised.
4//!
5//! ## Order
6//!
7//! Methods are always attempted in [`TraversalKind::rank`] order — Direct → Upnp → NatPmp → Pcp →
8//! HolePunch → Relayed — regardless of the order the caller listed them, so the cheapest/most-direct
9//! path is preferred and the fully-relayed transport is genuinely the LAST resort.
10//!
11//! ## Two-stage attempt
12//!
13//! A method yields a [`MethodOutcome`] (a dial address + which technique). The strategy then asks
14//! the [`Dialer`] to establish the mTLS session to that address. Both stages are bounded by the
15//! per-method timeout, so a hung method OR a hung dial can never block `connect`. If both stages
16//! fail for every method, the strategy returns [`NatError::AllMethodsFailed`] with the ordered
17//! per-method reasons.
18//!
19//! ## Testability
20//!
21//! [`Dialer`] abstracts the mTLS dial (real impl in [`crate::dialer`]; tests inject a fake that
22//! returns a canned outcome), and the methods are [`TraversalMethod`] trait objects (tests inject
23//! mocks). So the ordering, first-success-wins, relay-last, and all-fail→error behaviour are all
24//! tested with NO real network.
25
26use std::sync::Arc;
27use std::time::Duration;
28
29use async_trait::async_trait;
30
31use crate::error::{MethodError, NatError};
32use crate::method::{MethodOutcome, TraversalKind, TraversalMethod};
33use crate::peer::{PeerConnection, PeerTarget};
34
35/// Establishes the actual mTLS peer connection once a method has produced reachable address(es).
36///
37/// For the direct/mapping/hole-punch methods this is a rustls mTLS dial that races
38/// `outcome.dial_addrs` **IPv6-first with IPv4 fallback** (happy eyeballs); for the relayed method it
39/// opens the mTLS session tunnelled through the relay. Abstracted so the strategy is testable and the
40/// transport detail lives in one place ([`crate::dialer`]).
41#[async_trait]
42pub trait Dialer: Send + Sync {
43 /// Establish an mTLS connection to `peer` using the reachable address in `outcome`, verifying
44 /// the remote's `peer_id` matches [`PeerTarget::peer_id`]. `Err` = the dial or verification
45 /// failed (the strategy falls through to the next method).
46 async fn dial(
47 &self,
48 peer: &PeerTarget,
49 outcome: &MethodOutcome,
50 ) -> Result<PeerConnection, MethodError>;
51}
52
53/// Run the traversal strategy: try each enabled method in rank order; the first that produces a
54/// verified mTLS [`PeerConnection`] wins. `methods` may be listed in any order — they are sorted by
55/// [`TraversalKind::rank`] here so ordering is guaranteed independent of caller input.
56///
57/// Returns [`NatError::NoMethodsEnabled`] if `methods` is empty, else the first success, else
58/// [`NatError::AllMethodsFailed`] with every method's reason in attempt order.
59pub async fn connect_with_strategy(
60 peer: &PeerTarget,
61 methods: Vec<Arc<dyn TraversalMethod>>,
62 dialer: &dyn Dialer,
63 per_method_timeout: Duration,
64) -> Result<PeerConnection, NatError> {
65 if methods.is_empty() {
66 return Err(NatError::NoMethodsEnabled);
67 }
68
69 // Guarantee direct-first, relay-last regardless of how the caller ordered `methods`.
70 let mut ordered = methods;
71 ordered.sort_by_key(|m| m.kind().rank());
72
73 let mut failures: Vec<MethodError> = Vec::with_capacity(ordered.len());
74
75 for method in ordered {
76 let kind = method.kind();
77 // Stage 1: the method produces a reachable address (bounded).
78 let outcome = match run_bounded(per_method_timeout, kind, method.attempt(peer)).await {
79 Ok(outcome) => outcome,
80 Err(e) => {
81 tracing::debug!(?kind, reason = %e.reason, "traversal method did not produce an address");
82 failures.push(e);
83 continue;
84 }
85 };
86 // Stage 2: dial + mTLS-verify to that address (bounded).
87 match run_bounded(per_method_timeout, kind, dialer.dial(peer, &outcome)).await {
88 Ok(conn) => {
89 tracing::info!(?kind, remote = %conn.remote_addr, "peer connection established");
90 return Ok(conn);
91 }
92 Err(e) => {
93 tracing::debug!(?kind, reason = %e.reason, "dial failed; falling through");
94 failures.push(e);
95 }
96 }
97 }
98
99 Err(NatError::AllMethodsFailed(failures))
100}
101
102/// Run one method/dial future under a hard timeout, mapping a timeout to [`MethodError::timeout`].
103/// This is the guarantee that a stuck method can never hang `connect`.
104async fn run_bounded<T, F>(timeout: Duration, kind: TraversalKind, fut: F) -> Result<T, MethodError>
105where
106 F: std::future::Future<Output = Result<T, MethodError>>,
107{
108 match tokio::time::timeout(timeout, fut).await {
109 Ok(res) => res,
110 Err(_) => Err(MethodError::timeout(kind)),
111 }
112}