aboutsummaryrefslogtreecommitdiffstats
path: root/src/nat.rs
blob: cfd57294da835cdbc3aba8e4f8760c72bebf498a (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
//! NAT type detection (STUN-like echo protocol).
//!
//! Detects whether
//! this node has a public IP, is behind a cone NAT, or is
//! behind a symmetric NAT. The result determines how the
//! node communicates:
//!
//! - **Global**: direct communication.
//! - **ConeNat**: hole-punching via DTUN.
//! - **SymmetricNat**: relay via proxy.
//!
//! ## Detection protocol
//!
//! 1. Send NatEcho to a known node.
//! 2. Receive NatEchoReply with our observed external
//!    address and port.
//! 3. If observed == local → Global.
//! 4. If different → we're behind NAT. Send
//!    NatEchoRedirect asking the peer to have a *third*
//!    node echo us from a different port.
//! 5. Receive NatEchoRedirectReply with observed port
//!    from the third node.
//! 6. If ports match → ConeNat (same external port for
//!    different destinations).
//! 7. If ports differ → SymmetricNat (external port
//!    changes per destination).

use std::collections::HashMap;
use std::net::SocketAddr;
use std::time::{Duration, Instant};

use crate::id::NodeId;

/// NAT detection echo timeout.
pub const ECHO_TIMEOUT: Duration = Duration::from_secs(3);

/// Periodic re-detection interval.
pub const NAT_TIMER_INTERVAL: Duration = Duration::from_secs(30);

/// Node's NAT state (visible to the application).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NatState {
    /// Not yet determined.
    Unknown,

    /// Public IP — direct communication.
    Global,

    /// Behind NAT, type not yet classified.
    Nat,

    /// Cone NAT — hole-punching works.
    ConeNat,

    /// Symmetric NAT — must use a proxy relay.
    SymmetricNat,
}

/// Internal state machine for the detection protocol.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum DetectorState {
    /// Initial / idle.
    Undefined,

    /// Sent first echo, waiting for reply.
    EchoWait1,

    /// Behind NAT, sent redirect request, waiting.
    EchoRedirectWait,

    /// Confirmed global (public IP).
    Global,

    /// Behind NAT, type unknown yet.
    Nat,

    /// Sent second echo (via redirected node), waiting.
    EchoWait2,

    /// Confirmed cone NAT.
    ConeNat,

    /// Confirmed symmetric NAT.
    SymmetricNat,
}

/// Pending echo request tracked by nonce.
struct EchoPending {
    sent_at: Instant,
}

/// NAT type detector.
///
/// Drives the echo protocol state machine. The caller
/// feeds received messages via `recv_*` methods and
/// reads the result via `state()`.
pub struct NatDetector {
    state: DetectorState,

    /// Our detected external address (if behind NAT).
    global_addr: Option<SocketAddr>,

    /// Port observed in the first echo reply.
    echo1_port: Option<u16>,

    /// Pending echo requests keyed by nonce.
    pending: HashMap<u32, EchoPending>,
}

impl NatDetector {
    pub fn new(_local_id: NodeId) -> Self {
        Self {
            state: DetectorState::Undefined,
            global_addr: None,
            echo1_port: None,
            pending: HashMap::new(),
        }
    }

    /// Current NAT state as seen by the application.
    pub fn state(&self) -> NatState {
        match self.state {
            DetectorState::Undefined
            | DetectorState::EchoWait1
            | DetectorState::EchoRedirectWait
            | DetectorState::EchoWait2 => NatState::Unknown,
            DetectorState::Global => NatState::Global,
            DetectorState::Nat => NatState::Nat,
            DetectorState::ConeNat => NatState::ConeNat,
            DetectorState::SymmetricNat => NatState::SymmetricNat,
        }
    }

    /// Our detected global address, if known.
    pub fn global_addr(&self) -> Option<SocketAddr> {
        self.global_addr
    }

    /// Force the NAT state (e.g. from configuration).
    pub fn set_state(&mut self, s: NatState) {
        self.state = match s {
            NatState::Unknown => DetectorState::Undefined,
            NatState::Global => DetectorState::Global,
            NatState::Nat => DetectorState::Nat,
            NatState::ConeNat => DetectorState::ConeNat,
            NatState::SymmetricNat => DetectorState::SymmetricNat,
        };
    }

    /// Start detection: prepare a NatEcho to send to a
    /// known peer.
    ///
    /// Returns the nonce to include in the echo message.
    pub fn start_detect(&mut self, nonce: u32) -> u32 {
        self.state = DetectorState::EchoWait1;
        self.pending.insert(
            nonce,
            EchoPending {
                sent_at: Instant::now(),
            },
        );
        nonce
    }

    /// Handle a NatEchoReply: the peer tells us our
    /// observed external address and port.
    /// Replies in unexpected states are silently ignored
    /// (catch-all returns `EchoReplyAction::Ignore`).
    pub fn recv_echo_reply(
        &mut self,
        nonce: u32,
        observed_addr: SocketAddr,
        local_addr: SocketAddr,
    ) -> EchoReplyAction {
        if self.pending.remove(&nonce).is_none() {
            return EchoReplyAction::Ignore;
        }

        match self.state {
            DetectorState::EchoWait1 => {
                if observed_addr.port() == local_addr.port()
                    && observed_addr.ip() == local_addr.ip()
                {
                    // Our address matches → we have a public IP
                    self.state = DetectorState::Global;
                    self.global_addr = Some(observed_addr);
                    EchoReplyAction::DetectionComplete(NatState::Global)
                } else {
                    // Behind NAT — need redirect test
                    self.state = DetectorState::Nat;
                    self.global_addr = Some(observed_addr);
                    self.echo1_port = Some(observed_addr.port());
                    EchoReplyAction::NeedRedirect
                }
            }
            DetectorState::EchoWait2 => {
                // Reply from redirected third node
                let port2 = observed_addr.port();
                if Some(port2) == self.echo1_port {
                    // Same external port → Cone NAT
                    self.state = DetectorState::ConeNat;
                    EchoReplyAction::DetectionComplete(NatState::ConeNat)
                } else {
                    // Different port → Symmetric NAT
                    self.state = DetectorState::SymmetricNat;
                    EchoReplyAction::DetectionComplete(NatState::SymmetricNat)
                }
            }
            _ => EchoReplyAction::Ignore,
        }
    }

    /// After receiving NeedRedirect, start the redirect
    /// phase with a new nonce.
    pub fn start_redirect(&mut self, nonce: u32) {
        self.state = DetectorState::EchoRedirectWait;
        self.pending.insert(
            nonce,
            EchoPending {
                sent_at: Instant::now(),
            },
        );
    }

    /// The redirect was forwarded and a third node will
    /// send us an echo. Transition to EchoWait2.
    pub fn redirect_sent(&mut self, nonce: u32) {
        self.state = DetectorState::EchoWait2;
        self.pending.insert(
            nonce,
            EchoPending {
                sent_at: Instant::now(),
            },
        );
    }

    /// Expire timed-out echo requests.
    ///
    /// If all pending requests timed out and we haven't
    /// completed detection, reset to Undefined.
    pub fn expire_pending(&mut self) {
        self.pending
            .retain(|_, p| p.sent_at.elapsed() < ECHO_TIMEOUT);

        if self.pending.is_empty() {
            match self.state {
                DetectorState::EchoWait1
                | DetectorState::EchoRedirectWait
                | DetectorState::EchoWait2 => {
                    log::debug!("NAT detection timed out, resetting");
                    self.state = DetectorState::Undefined;
                }
                _ => {}
            }
        }
    }

    /// Check if detection is still in progress.
    pub fn is_detecting(&self) -> bool {
        matches!(
            self.state,
            DetectorState::EchoWait1
                | DetectorState::EchoRedirectWait
                | DetectorState::EchoWait2
        )
    }

    /// Check if detection is complete (any terminal state).
    pub fn is_complete(&self) -> bool {
        matches!(
            self.state,
            DetectorState::Global
                | DetectorState::ConeNat
                | DetectorState::SymmetricNat
        )
    }
}

/// Action to take after processing an echo reply.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EchoReplyAction {
    /// Ignore (unknown nonce or wrong state).
    Ignore,

    /// Detection complete with the given NAT state.
    DetectionComplete(NatState),

    /// Need to send a NatEchoRedirect to determine
    /// NAT type (cone vs symmetric).
    NeedRedirect,
}

#[cfg(test)]
mod tests {
    use super::*;

    fn local() -> SocketAddr {
        "192.168.1.100:5000".parse().unwrap()
    }

    #[test]
    fn detect_global() {
        let mut nd = NatDetector::new(NodeId::from_bytes([0x01; 32]));
        let nonce = nd.start_detect(1);
        assert!(nd.is_detecting());

        let action = nd.recv_echo_reply(nonce, local(), local());
        assert_eq!(
            action,
            EchoReplyAction::DetectionComplete(NatState::Global)
        );
        assert_eq!(nd.state(), NatState::Global);
        assert!(nd.is_complete());
    }

    #[test]
    fn detect_cone_nat() {
        let mut nd = NatDetector::new(NodeId::from_bytes([0x01; 32]));

        // Phase 1: echo shows different address
        let n1 = nd.start_detect(1);
        let observed: SocketAddr = "1.2.3.4:5000".parse().unwrap();
        let action = nd.recv_echo_reply(n1, observed, local());
        assert_eq!(action, EchoReplyAction::NeedRedirect);
        assert_eq!(nd.state(), NatState::Nat);

        // Phase 2: redirect → third node echoes with
        // same port
        nd.redirect_sent(2);
        let observed2: SocketAddr = "1.2.3.4:5000".parse().unwrap();
        let action = nd.recv_echo_reply(2, observed2, local());
        assert_eq!(
            action,
            EchoReplyAction::DetectionComplete(NatState::ConeNat)
        );
        assert_eq!(nd.state(), NatState::ConeNat);
    }

    #[test]
    fn detect_symmetric_nat() {
        let mut nd = NatDetector::new(NodeId::from_bytes([0x01; 32]));

        // Phase 1: behind NAT
        let n1 = nd.start_detect(1);
        let observed: SocketAddr = "1.2.3.4:5000".parse().unwrap();
        nd.recv_echo_reply(n1, observed, local());

        // Phase 2: different port from third node
        nd.redirect_sent(2);
        let observed2: SocketAddr = "1.2.3.4:6000".parse().unwrap();
        let action = nd.recv_echo_reply(2, observed2, local());
        assert_eq!(
            action,
            EchoReplyAction::DetectionComplete(NatState::SymmetricNat)
        );
    }

    #[test]
    fn unknown_nonce_ignored() {
        let mut nd = NatDetector::new(NodeId::from_bytes([0x01; 32]));
        nd.start_detect(1);
        let action = nd.recv_echo_reply(999, local(), local());
        assert_eq!(action, EchoReplyAction::Ignore);
    }

    #[test]
    fn force_state() {
        let mut nd = NatDetector::new(NodeId::from_bytes([0x01; 32]));
        nd.set_state(NatState::Global);
        assert_eq!(nd.state(), NatState::Global);
        assert!(nd.is_complete());
    }

    #[test]
    fn timeout_resets() {
        let mut nd = NatDetector::new(NodeId::from_bytes([0x01; 32]));
        nd.start_detect(1);

        // Clear pending manually to simulate timeout
        nd.pending.clear();
        nd.expire_pending();
        assert_eq!(nd.state(), NatState::Unknown);
    }
}