Skip to main content

gate4agent_node_wire/mesh_underlay/
mod.rs

1//! Minimal Linux-first mesh **underlay** slice (design tips 5–6).
2//!
3//! Not a WireGuard daemon product. Provides:
4//! - peer dial/accept role lock (C2+node DialOrAccept; HQ DialOnly)
5//! - userspace UDP + AEAD path on Linux (encrypted pipe)
6//! - **separate** authorization token barrier for probe actions
7//!   (transport crypto ≠ authorization)
8//! - clear refuse on Win/mac and for HQ underlay accept
9//! - tip 6: TCP bridge-reach over underlay (HTTP+WS dialect unchanged)
10//!
11//! Cite:
12//! - `mesh-connectivity-daemon-design-2026-10-02.md` §1.2 / §1.5 / tips 5–6
13//! - `mesh-underlay-linux-tun-wg-vs-win-mac-2026-10-02.md`
14//! - hatchery `mesh_role` DialOnly lock
15//!
16//! Feature: `mesh-underlay` (default on). Disable to omit this module from
17//! dependents that do not need tip-5/6 surfaces.
18
19use std::fmt;
20
21#[cfg(target_os = "linux")]
22mod linux;
23mod bridge_reach;
24#[cfg(target_os = "linux")]
25pub use linux::{
26    accept_peer, accept_peer_on, dial_peer, probe_tun_surface, LinuxUnderlayListener,
27    LinuxUnderlaySession, TunSurfaceReport,
28};
29pub use bridge_reach::{serve_bridge_tcp_relay, underlay_to_io, BridgeUnderlayClient};
30
31/// Who participates on the underlay (mirrors hatchery `MeshParticipantRole`).
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
33pub enum MeshUnderlayRole {
34    C2Peer,
35    NodePeer,
36    /// Hatchery HQ — always dial-only; never underlay accept.
37    HqClientAdmin,
38}
39
40/// Dial capability derived from role.
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
42pub enum MeshUnderlayDialCapability {
43    DialOnly,
44    DialOrAccept,
45}
46
47impl MeshUnderlayRole {
48    pub const fn dial_capability(self) -> MeshUnderlayDialCapability {
49        match self {
50            Self::C2Peer | Self::NodePeer => MeshUnderlayDialCapability::DialOrAccept,
51            Self::HqClientAdmin => MeshUnderlayDialCapability::DialOnly,
52        }
53    }
54
55    pub const fn may_accept_underlay(self) -> bool {
56        matches!(
57            self.dial_capability(),
58            MeshUnderlayDialCapability::DialOrAccept
59        )
60    }
61}
62
63impl fmt::Display for MeshUnderlayRole {
64    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
65        f.write_str(match self {
66            Self::C2Peer => "c2-peer",
67            Self::NodePeer => "node-peer",
68            Self::HqClientAdmin => "hq-client-admin",
69        })
70    }
71}
72
73/// Errors for the tip-5 underlay slice (never carry token material).
74#[derive(Debug, Clone, PartialEq, Eq)]
75pub enum MeshUnderlayError {
76    /// HQ / DialOnly must not accept underlay peers.
77    HqMustNotAcceptUnderlay,
78    /// Win/mac (and non-Linux) underlay not implemented this tip.
79    PlatformUnsupported {
80        os: &'static str,
81        hint: &'static str,
82    },
83    /// Probe / action refused — token barrier failed (crypto session may still be up).
84    Unauthorized,
85    /// Transport key rejected (length / empty).
86    InvalidTransportKey,
87    /// Auth token rejected at configuration time (empty / oversized).
88    InvalidAuthToken,
89    /// I/O or framing failure on the underlay path.
90    Path(String),
91    /// Full WireGuard / kernel TUN datapath not opened this tip.
92    WireGuardDaemonNotInThisTip,
93}
94
95impl fmt::Display for MeshUnderlayError {
96    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
97        match self {
98            Self::HqMustNotAcceptUnderlay => {
99                f.write_str("HQ mesh role is dial-only; underlay accept is refused")
100            }
101            Self::PlatformUnsupported { os, hint } => {
102                write!(f, "mesh underlay unsupported on {os}: {hint}")
103            }
104            Self::Unauthorized => {
105                f.write_str("underlay probe unauthorized: token barrier failed (crypto ≠ auth)")
106            }
107            Self::InvalidTransportKey => {
108                f.write_str("underlay transport key must be exactly 32 bytes")
109            }
110            Self::InvalidAuthToken => {
111                f.write_str("underlay auth token empty or longer than 4096 bytes")
112            }
113            Self::Path(msg) => write!(f, "underlay path error: {msg}"),
114            Self::WireGuardDaemonNotInThisTip => f.write_str(
115                "no WireGuard/kernel TUN daemon in tips 5–6; userspace UDP+AEAD + bridge TCP relay only",
116            ),
117        }
118    }
119}
120
121impl std::error::Error for MeshUnderlayError {}
122
123/// 32-byte transport key for underlay AEAD (encrypts the pipe — **not** auth).
124#[derive(Clone)]
125pub struct UnderlayTransportKey {
126    bytes: [u8; 32],
127}
128
129impl UnderlayTransportKey {
130    pub fn from_bytes(bytes: [u8; 32]) -> Self {
131        Self { bytes }
132    }
133
134    pub fn try_from_slice(bytes: &[u8]) -> Result<Self, MeshUnderlayError> {
135        let Ok(array) = <[u8; 32]>::try_from(bytes) else {
136            return Err(MeshUnderlayError::InvalidTransportKey);
137        };
138        Ok(Self { bytes: array })
139    }
140
141    pub(crate) fn as_bytes(&self) -> &[u8; 32] {
142        &self.bytes
143    }
144}
145
146impl fmt::Debug for UnderlayTransportKey {
147    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
148        f.write_str("UnderlayTransportKey([redacted])")
149    }
150}
151
152/// Authorization token barrier **inside** the encrypted underlay path.
153/// Distinct from [`UnderlayTransportKey`]. Never logged.
154#[derive(Clone)]
155pub struct UnderlayAuthToken {
156    value: String,
157}
158
159impl UnderlayAuthToken {
160    pub fn new(value: impl Into<String>) -> Result<Self, MeshUnderlayError> {
161        let value = value.into();
162        if value.is_empty() || value.len() > 4_096 {
163            return Err(MeshUnderlayError::InvalidAuthToken);
164        }
165        Ok(Self { value })
166    }
167
168    pub(crate) fn as_str(&self) -> &str {
169        &self.value
170    }
171}
172
173impl fmt::Debug for UnderlayAuthToken {
174    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
175        f.write_str("UnderlayAuthToken([redacted])")
176    }
177}
178
179/// Constant-time equality for token barriers (spirit of C2/bridge gates).
180pub fn tokens_match(provided: &str, expected: &str) -> bool {
181    let left = provided.as_bytes();
182    let right = expected.as_bytes();
183    if left.len() != right.len() {
184        return false;
185    }
186    left.iter()
187        .zip(right)
188        .fold(0_u8, |acc, (a, b)| acc | (a ^ b))
189        == 0
190}
191
192/// Check authorization for a probe action. Encrypted path being up is irrelevant.
193pub fn authorize_probe(
194    configured: &UnderlayAuthToken,
195    provided: Option<&str>,
196) -> Result<(), MeshUnderlayError> {
197    match provided {
198        Some(got) if tokens_match(got, configured.as_str()) => Ok(()),
199        _ => Err(MeshUnderlayError::Unauthorized),
200    }
201}
202
203/// Refuse underlay accept when role is dial-only (HQ).
204pub fn assert_accept_allowed(role: MeshUnderlayRole) -> Result<(), MeshUnderlayError> {
205    if role.may_accept_underlay() {
206        Ok(())
207    } else {
208        Err(MeshUnderlayError::HqMustNotAcceptUnderlay)
209    }
210}
211
212/// Non-Linux entry points: clear platform refuse (Win/mac stub).
213#[cfg(not(target_os = "linux"))]
214pub fn accept_peer(
215    role: MeshUnderlayRole,
216    _transport_key: &UnderlayTransportKey,
217    _auth_token: &UnderlayAuthToken,
218) -> Result<UnsupportedUnderlayHandle, MeshUnderlayError> {
219    assert_accept_allowed(role)?;
220    Err(platform_unsupported())
221}
222
223#[cfg(not(target_os = "linux"))]
224pub fn accept_peer_on(
225    role: MeshUnderlayRole,
226    transport_key: &UnderlayTransportKey,
227    auth_token: &UnderlayAuthToken,
228    _bind: std::net::SocketAddr,
229) -> Result<UnsupportedUnderlayHandle, MeshUnderlayError> {
230    accept_peer(role, transport_key, auth_token)
231}
232
233#[cfg(not(target_os = "linux"))]
234pub fn dial_peer(
235    _role: MeshUnderlayRole,
236    _transport_key: &UnderlayTransportKey,
237    _auth_token: &UnderlayAuthToken,
238    _peer: &str,
239) -> Result<UnsupportedUnderlayHandle, MeshUnderlayError> {
240    Err(platform_unsupported())
241}
242
243#[cfg(not(target_os = "linux"))]
244pub fn probe_tun_surface() -> TunSurfaceReport {
245    TunSurfaceReport {
246        device_present: false,
247        open_attempt: "skipped-non-linux",
248        note: "Win/mac underlay deferred; see recon mesh-underlay-linux-tun-wg-vs-win-mac",
249    }
250}
251
252#[cfg(not(target_os = "linux"))]
253fn platform_unsupported() -> MeshUnderlayError {
254    MeshUnderlayError::PlatformUnsupported {
255        os: std::env::consts::OS,
256        hint: "Linux-first tip 5; Win/mac underlay later (WireGuardNT/utun) — see recon doc",
257    }
258}
259
260/// Placeholder handle type on non-Linux so signatures stay symmetrical.
261#[cfg(not(target_os = "linux"))]
262#[derive(Debug)]
263pub struct UnsupportedUnderlayHandle;
264
265/// TUN surface report (informational; tip 5 does not require CAP_NET_ADMIN).
266#[cfg(not(target_os = "linux"))]
267#[derive(Debug, Clone, PartialEq, Eq)]
268pub struct TunSurfaceReport {
269    pub device_present: bool,
270    pub open_attempt: &'static str,
271    pub note: &'static str,
272}
273
274/// Explicit refuse of full WG daemon product this tip.
275pub fn open_wireguard_daemon_stub() -> Result<(), MeshUnderlayError> {
276    Err(MeshUnderlayError::WireGuardDaemonNotInThisTip)
277}
278
279#[cfg(test)]
280mod tests {
281    use super::*;
282
283    #[test]
284    fn hq_is_dial_only_accept_refused() {
285        assert!(!MeshUnderlayRole::HqClientAdmin.may_accept_underlay());
286        assert_eq!(
287            assert_accept_allowed(MeshUnderlayRole::HqClientAdmin),
288            Err(MeshUnderlayError::HqMustNotAcceptUnderlay)
289        );
290        for role in [MeshUnderlayRole::C2Peer, MeshUnderlayRole::NodePeer] {
291            assert!(role.may_accept_underlay());
292            assert_eq!(assert_accept_allowed(role), Ok(()));
293        }
294    }
295
296    #[test]
297    fn token_barrier_independent_of_transport_key() {
298        let token = UnderlayAuthToken::new("probe-secret").unwrap();
299        assert!(authorize_probe(&token, Some("probe-secret")).is_ok());
300        assert_eq!(
301            authorize_probe(&token, Some("wrong-secret!!")),
302            Err(MeshUnderlayError::Unauthorized)
303        );
304        assert_eq!(
305            authorize_probe(&token, None),
306            Err(MeshUnderlayError::Unauthorized)
307        );
308        // Transport key presence is orthogonal — constructing one does not authorize.
309        let _key = UnderlayTransportKey::from_bytes([7u8; 32]);
310        assert_eq!(
311            authorize_probe(&token, Some("still-wrong")),
312            Err(MeshUnderlayError::Unauthorized)
313        );
314    }
315
316    #[test]
317    fn wireguard_daemon_stub_refuses() {
318        assert_eq!(
319            open_wireguard_daemon_stub(),
320            Err(MeshUnderlayError::WireGuardDaemonNotInThisTip)
321        );
322    }
323
324    #[test]
325    fn secrets_redacted_in_debug() {
326        let key = UnderlayTransportKey::from_bytes([1u8; 32]);
327        let token = UnderlayAuthToken::new("super-secret-token").unwrap();
328        let key_dbg = format!("{key:?}");
329        let token_dbg = format!("{token:?}");
330        assert!(!key_dbg.contains("1, 1, 1"));
331        assert!(!token_dbg.contains("super-secret"));
332        assert!(key_dbg.contains("redacted"));
333        assert!(token_dbg.contains("redacted"));
334    }
335
336    #[cfg(target_os = "linux")]
337    #[tokio::test]
338    async fn linux_peer_encrypted_path_still_requires_token_for_probe() {
339        let transport = UnderlayTransportKey::from_bytes([9u8; 32]);
340        let auth = UnderlayAuthToken::new("underlay-probe-token").unwrap();
341
342        // HQ must not accept even on Linux.
343        assert_eq!(
344            accept_peer(MeshUnderlayRole::HqClientAdmin, &transport, &auth)
345                .await
346                .err(),
347            Some(MeshUnderlayError::HqMustNotAcceptUnderlay)
348        );
349
350        let listener = accept_peer(MeshUnderlayRole::C2Peer, &transport, &auth)
351            .await
352            .expect("c2 peer may accept");
353        let addr = listener.local_addr().expect("bound");
354
355        let dial_task = {
356            let transport = transport.clone();
357            let auth = auth.clone();
358            tokio::spawn(async move {
359                dial_peer(
360                    MeshUnderlayRole::NodePeer,
361                    &transport,
362                    &auth,
363                    &addr.to_string(),
364                )
365                .await
366            })
367        };
368
369        let server = listener
370            .accept()
371            .await
372            .expect("accept encrypted peer session");
373        let mut client = dial_task.await.expect("join").expect("dial ok");
374
375        // Encrypted round-trip hello (crypto up).
376        client
377            .send_encrypted(b"hello-from-node")
378            .await
379            .expect("client send");
380        let got = server.recv_encrypted().await.expect("server recv");
381        assert_eq!(got, b"hello-from-node");
382
383        // Probe without / wrong token fails even though crypto session is live.
384        assert_eq!(
385            client.probe(None),
386            Err(MeshUnderlayError::Unauthorized)
387        );
388        assert_eq!(
389            client.probe(Some("wrong")),
390            Err(MeshUnderlayError::Unauthorized)
391        );
392        client
393            .probe(Some("underlay-probe-token"))
394            .expect("probe authorized");
395        server
396            .probe(Some("underlay-probe-token"))
397            .expect("server probe authorized");
398    }
399
400    #[cfg(target_os = "linux")]
401    #[test]
402    fn linux_tun_surface_report_is_informative() {
403        let report = probe_tun_surface();
404        // /dev/net/tun often exists in containers; open may still need CAP_NET_ADMIN.
405        assert!(
406            report.open_attempt == "ok"
407                || report.open_attempt == "permission-denied"
408                || report.open_attempt == "error"
409                || report.open_attempt == "missing-device"
410        );
411        assert!(report.note.contains("tip 5") || report.note.contains("CAP_NET_ADMIN") || report.note.contains("userspace"));
412    }
413
414
415    #[cfg(target_os = "linux")]
416    #[tokio::test]
417    async fn tip6_bridge_health_over_underlay_requires_token_barriers() {
418        use crate::mesh_underlay::{serve_bridge_tcp_relay, BridgeUnderlayClient};
419        use tokio::io::{AsyncReadExt, AsyncWriteExt};
420        use tokio::net::TcpListener;
421
422        let transport = UnderlayTransportKey::from_bytes([11u8; 32]);
423        let underlay_auth = UnderlayAuthToken::new("underlay-tip6-token").unwrap();
424
425        let tcp = TcpListener::bind("127.0.0.1:0").await.unwrap();
426        let bridge_addr = tcp.local_addr().unwrap();
427        let app_task = tokio::spawn(async move {
428            let (mut stream, _) = tcp.accept().await.unwrap();
429            let mut buf = vec![0u8; 1024];
430            let _ = stream.read(&mut buf).await;
431            let body = br#"{"ok":true,"door":"node-envelope-bridge","tip":6}"#;
432            let resp = format!(
433                "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
434                body.len(),
435                std::str::from_utf8(body).unwrap()
436            );
437            stream.write_all(resp.as_bytes()).await.unwrap();
438        });
439
440        assert_eq!(
441            accept_peer(MeshUnderlayRole::HqClientAdmin, &transport, &underlay_auth)
442                .await
443                .err(),
444            Some(MeshUnderlayError::HqMustNotAcceptUnderlay)
445        );
446
447        // --- unauthorized path ---
448        let listener = accept_peer(MeshUnderlayRole::NodePeer, &transport, &underlay_auth)
449            .await
450            .unwrap();
451        let addr = listener.local_addr().unwrap();
452        let client_bad = {
453            let transport = transport.clone();
454            let underlay_auth = underlay_auth.clone();
455            tokio::spawn(async move {
456                let session = dial_peer(
457                    MeshUnderlayRole::C2Peer,
458                    &transport,
459                    &underlay_auth,
460                    &addr.to_string(),
461                )
462                .await
463                .unwrap();
464                BridgeUnderlayClient::open(session, "wrong-token").await
465            })
466        };
467        let session = listener.accept().await.unwrap();
468        let server_err = serve_bridge_tcp_relay(session, &underlay_auth, bridge_addr).await;
469        assert!(matches!(server_err, Err(MeshUnderlayError::Unauthorized)));
470        let client_err = client_bad.await.unwrap();
471        assert!(matches!(client_err, Err(MeshUnderlayError::Unauthorized)));
472
473        // --- authorized path ---
474        let listener = accept_peer(MeshUnderlayRole::NodePeer, &transport, &underlay_auth)
475            .await
476            .unwrap();
477        let addr = listener.local_addr().unwrap();
478        let client_ok = {
479            let transport = transport.clone();
480            let underlay_auth = underlay_auth.clone();
481            tokio::spawn(async move {
482                let session = dial_peer(
483                    MeshUnderlayRole::C2Peer,
484                    &transport,
485                    &underlay_auth,
486                    &addr.to_string(),
487                )
488                .await
489                .unwrap();
490                let mut client = BridgeUnderlayClient::open(session, "underlay-tip6-token")
491                    .await
492                    .unwrap();
493                client
494                    .write_all(b"GET /bridge/health HTTP/1.1\r\nHost: localhost\r\n\r\n")
495                    .await
496                    .unwrap();
497                let resp = client.read_at_least(12).await.unwrap();
498                let text = String::from_utf8_lossy(&resp);
499                assert!(text.contains("200 OK"), "got {text}");
500                assert!(text.contains("node-envelope-bridge"));
501                client.close().await.unwrap();
502            })
503        };
504        let session = listener.accept().await.unwrap();
505        serve_bridge_tcp_relay(session, &underlay_auth, bridge_addr)
506            .await
507            .expect("authorized relay");
508        client_ok.await.unwrap();
509        app_task.await.unwrap();
510    }
511
512
513    #[cfg(target_os = "linux")]
514    #[tokio::test]
515    async fn tip6_bridge_multi_chunk_relay_over_underlay() {
516        use crate::mesh_underlay::{serve_bridge_tcp_relay, BridgeUnderlayClient};
517        use tokio::io::{AsyncReadExt, AsyncWriteExt};
518        use tokio::net::TcpListener;
519
520        let transport = UnderlayTransportKey::from_bytes([13u8; 32]);
521        let underlay_auth = UnderlayAuthToken::new("underlay-tip6-chunk").unwrap();
522
523        // Local app door echoes a large body so client must reassemble chunks.
524        let tcp = TcpListener::bind("127.0.0.1:0").await.unwrap();
525        let bridge_addr = tcp.local_addr().unwrap();
526        let payload = vec![0x5Au8; 9_000]; // > CHUNK_MAX (4090) — forces multi-frame
527        let app_task = {
528            let payload = payload.clone();
529            tokio::spawn(async move {
530                let (mut stream, _) = tcp.accept().await.unwrap();
531                let mut buf = vec![0u8; 64];
532                let _ = stream.read(&mut buf).await;
533                stream.write_all(&payload).await.unwrap();
534                let _ = stream.shutdown().await;
535            })
536        };
537
538        let listener = accept_peer(MeshUnderlayRole::NodePeer, &transport, &underlay_auth)
539            .await
540            .unwrap();
541        let addr = listener.local_addr().unwrap();
542        let client_task = {
543            let transport = transport.clone();
544            let underlay_auth = underlay_auth.clone();
545            let expect = payload.clone();
546            tokio::spawn(async move {
547                let session = dial_peer(
548                    MeshUnderlayRole::C2Peer,
549                    &transport,
550                    &underlay_auth,
551                    &addr.to_string(),
552                )
553                .await
554                .unwrap();
555                let mut client = BridgeUnderlayClient::open(session, "underlay-tip6-chunk")
556                    .await
557                    .unwrap();
558                client.write_all(b"GET /echo HTTP/1.1\r\n\r\n").await.unwrap();
559                let got = client.read_at_least(expect.len()).await.unwrap();
560                assert_eq!(got, expect, "multi-chunk reassembly mismatch");
561                client.close().await.unwrap();
562            })
563        };
564        let session = listener.accept().await.unwrap();
565        serve_bridge_tcp_relay(session, &underlay_auth, bridge_addr)
566            .await
567            .expect("authorized multi-chunk relay");
568        client_task.await.unwrap();
569        app_task.await.unwrap();
570    }
571
572    #[cfg(not(target_os = "linux"))]
573    #[test]
574    fn non_linux_accept_refuses_with_clear_platform_error() {
575        let transport = UnderlayTransportKey::from_bytes([3u8; 32]);
576        let auth = UnderlayAuthToken::new("t").unwrap();
577        let err = accept_peer(MeshUnderlayRole::C2Peer, &transport, &auth).unwrap_err();
578        match err {
579            MeshUnderlayError::PlatformUnsupported { hint, .. } => {
580                assert!(hint.contains("Linux-first"));
581            }
582            other => panic!("expected PlatformUnsupported, got {other:?}"),
583        }
584    }
585}