Skip to main content

gate4agent_node_wire/mesh_underlay/
linux.rs

1//! Linux userspace underlay path: UDP + ChaCha20-Poly1305.
2//!
3//! Tip 5 deliberately does **not** open in-kernel WireGuard or require
4//! CAP_NET_ADMIN. TUN surface is probed for operator honesty only.
5
6use super::{
7    assert_accept_allowed, authorize_probe, MeshUnderlayError, MeshUnderlayRole, UnderlayAuthToken,
8    UnderlayTransportKey,
9};
10use ring::aead::{Aad, LessSafeKey, Nonce, UnboundKey, CHACHA20_POLY1305};
11use std::net::SocketAddr;
12use std::path::Path;
13use tokio::net::UdpSocket;
14
15const NONCE_LEN: usize = 12;
16const TAG_LEN: usize = 16;
17const MAX_PLAINTEXT: usize = 4_096;
18
19/// Listener side (C2 or node accept-peer).
20pub struct LinuxUnderlayListener {
21    socket: UdpSocket,
22    key: LessSafeKey,
23    auth: UnderlayAuthToken,
24    /// Monotonic send counter for nonce uniqueness on accepted sessions.
25    send_counter: u64,
26}
27
28/// Established encrypted underlay session (either dial or accept side).
29pub struct LinuxUnderlaySession {
30    socket: UdpSocket,
31    #[allow(dead_code)]
32    peer: SocketAddr,
33    key: LessSafeKey,
34    auth: UnderlayAuthToken,
35    send_counter: u64,
36}
37
38/// Informational TUN/CAP report — not required for the UDP+AEAD path.
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct TunSurfaceReport {
41    pub device_present: bool,
42    pub open_attempt: &'static str,
43    pub note: &'static str,
44}
45
46pub fn probe_tun_surface() -> TunSurfaceReport {
47    let device_present = Path::new("/dev/net/tun").exists();
48    if !device_present {
49        return TunSurfaceReport {
50            device_present: false,
51            open_attempt: "missing-device",
52            note: "tip 5 uses userspace UDP+AEAD; kernel TUN/WG deferred",
53        };
54    }
55    // Best-effort open; do not claim a named iface (needs CAP_NET_ADMIN + ioctl).
56    match std::fs::OpenOptions::new()
57        .read(true)
58        .write(true)
59        .open("/dev/net/tun")
60    {
61        Ok(_f) => TunSurfaceReport {
62            device_present: true,
63            open_attempt: "ok",
64            note: "tip 5 does not configure WireGuard; CAP_NET_ADMIN may still be needed for iface",
65        },
66        Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied => TunSurfaceReport {
67            device_present: true,
68            open_attempt: "permission-denied",
69            note: "CAP_NET_ADMIN / device cgroup likely required for TUN; tip 5 UDP path does not need it",
70        },
71        Err(_) => TunSurfaceReport {
72            device_present: true,
73            open_attempt: "error",
74            note: "TUN present but open failed; tip 5 continues on userspace UDP+AEAD",
75        },
76    }
77}
78
79fn sealing_key(transport: &UnderlayTransportKey) -> Result<LessSafeKey, MeshUnderlayError> {
80    let unbound = UnboundKey::new(&CHACHA20_POLY1305, transport.as_bytes())
81        .map_err(|_| MeshUnderlayError::InvalidTransportKey)?;
82    Ok(LessSafeKey::new(unbound))
83}
84
85
86pub async fn accept_peer(
87    role: MeshUnderlayRole,
88    transport_key: &UnderlayTransportKey,
89    auth_token: &UnderlayAuthToken,
90) -> Result<LinuxUnderlayListener, MeshUnderlayError> {
91    accept_peer_on(
92        role,
93        transport_key,
94        auth_token,
95        "127.0.0.1:0".parse().expect("loopback ephemeral"),
96    )
97    .await
98}
99
100/// Tip 6: underlay UDP accept on an explicit bind (may be non-loopback for
101/// mesh peers). Application HTTP+WS stays on loopback `--bridge-listen`;
102/// this only accepts the encrypted underlay path. HQ DialOnly still refused.
103pub async fn accept_peer_on(
104    role: MeshUnderlayRole,
105    transport_key: &UnderlayTransportKey,
106    auth_token: &UnderlayAuthToken,
107    bind: SocketAddr,
108) -> Result<LinuxUnderlayListener, MeshUnderlayError> {
109    assert_accept_allowed(role)?;
110    let key = sealing_key(transport_key)?;
111    let socket = UdpSocket::bind(bind)
112        .await
113        .map_err(|e| MeshUnderlayError::Path(e.to_string()))?;
114    Ok(LinuxUnderlayListener {
115        socket,
116        key,
117        auth: auth_token.clone(),
118        send_counter: 0,
119    })
120}
121
122pub async fn dial_peer(
123    role: MeshUnderlayRole,
124    transport_key: &UnderlayTransportKey,
125    auth_token: &UnderlayAuthToken,
126    peer: &str,
127) -> Result<LinuxUnderlaySession, MeshUnderlayError> {
128    // Dial is allowed for all roles including HQ (HQ always dials).
129    let _ = role;
130    let key = sealing_key(transport_key)?;
131    let peer: SocketAddr = peer
132        .parse()
133        .map_err(|e: std::net::AddrParseError| MeshUnderlayError::Path(e.to_string()))?;
134    let socket = UdpSocket::bind("127.0.0.1:0")
135        .await
136        .map_err(|e| MeshUnderlayError::Path(e.to_string()))?;
137    socket
138        .connect(peer)
139        .await
140        .map_err(|e| MeshUnderlayError::Path(e.to_string()))?;
141
142    // Handshake: dialer sends encrypted "mesh-underlay-v1-hello".
143    let mut session = LinuxUnderlaySession {
144        socket,
145        peer,
146        key,
147        auth: auth_token.clone(),
148        send_counter: 0,
149    };
150    session.send_encrypted(b"mesh-underlay-v1-hello").await?;
151    let reply = session.recv_encrypted().await?;
152    if reply != b"mesh-underlay-v1-hello-ack" {
153        return Err(MeshUnderlayError::Path(
154            "underlay hello ack mismatch".into(),
155        ));
156    }
157    Ok(session)
158}
159
160impl LinuxUnderlayListener {
161    pub fn local_addr(&self) -> Result<SocketAddr, MeshUnderlayError> {
162        self.socket
163            .local_addr()
164            .map_err(|e| MeshUnderlayError::Path(e.to_string()))
165    }
166
167    pub async fn accept(self) -> Result<LinuxUnderlaySession, MeshUnderlayError> {
168        // First datagram completes handshake from dialer.
169        let mut buf = vec![0u8; NONCE_LEN + MAX_PLAINTEXT + TAG_LEN];
170        let (n, peer) = self
171            .socket
172            .recv_from(&mut buf)
173            .await
174            .map_err(|e| MeshUnderlayError::Path(e.to_string()))?;
175        buf.truncate(n);
176
177        let mut session = LinuxUnderlaySession {
178            socket: self.socket,
179            peer,
180            key: self.key,
181            auth: self.auth,
182            send_counter: self.send_counter,
183        };
184        // Connect so subsequent send/recv are peer-scoped.
185        session
186            .socket
187            .connect(peer)
188            .await
189            .map_err(|e| MeshUnderlayError::Path(e.to_string()))?;
190
191        let plain = decrypt(&session.key, &buf)?;
192        if plain != b"mesh-underlay-v1-hello" {
193            return Err(MeshUnderlayError::Path(
194                "underlay hello mismatch".into(),
195            ));
196        }
197        session
198            .send_encrypted(b"mesh-underlay-v1-hello-ack")
199            .await?;
200        Ok(session)
201    }
202}
203
204impl LinuxUnderlaySession {
205    pub async fn send_encrypted(&mut self, plaintext: &[u8]) -> Result<(), MeshUnderlayError> {
206        if plaintext.len() > MAX_PLAINTEXT {
207            return Err(MeshUnderlayError::Path("plaintext too large".into()));
208        }
209        let counter = self.send_counter;
210        self.send_counter = self
211            .send_counter
212            .checked_add(1)
213            .ok_or_else(|| MeshUnderlayError::Path("nonce counter overflow".into()))?;
214        let mut nonce_bytes = [0u8; NONCE_LEN];
215        nonce_bytes[4..].copy_from_slice(&counter.to_be_bytes());
216        let mut out = Vec::with_capacity(NONCE_LEN + plaintext.len() + TAG_LEN);
217        out.extend_from_slice(&nonce_bytes);
218        let nonce = Nonce::assume_unique_for_key(nonce_bytes);
219        let mut body = plaintext.to_vec();
220        self.key
221            .seal_in_place_append_tag(nonce, Aad::empty(), &mut body)
222            .map_err(|_| MeshUnderlayError::Path("seal failed".into()))?;
223        out.extend_from_slice(&body);
224        self.socket
225            .send(&out)
226            .await
227            .map_err(|e| MeshUnderlayError::Path(e.to_string()))?;
228        Ok(())
229    }
230
231    pub async fn recv_encrypted(&self) -> Result<Vec<u8>, MeshUnderlayError> {
232        let mut buf = vec![0u8; NONCE_LEN + MAX_PLAINTEXT + TAG_LEN];
233        let n = self
234            .socket
235            .recv(&mut buf)
236            .await
237            .map_err(|e| MeshUnderlayError::Path(e.to_string()))?;
238        buf.truncate(n);
239        decrypt(&self.key, &buf)
240    }
241
242    /// Probe action: token barrier still required even when crypto session is live.
243    pub fn probe(&self, provided_token: Option<&str>) -> Result<(), MeshUnderlayError> {
244        authorize_probe(&self.auth, provided_token)
245    }
246}
247
248fn decrypt(key: &LessSafeKey, packet: &[u8]) -> Result<Vec<u8>, MeshUnderlayError> {
249    if packet.len() < NONCE_LEN + TAG_LEN {
250        return Err(MeshUnderlayError::Path("datagram too short".into()));
251    }
252    let (nonce_bytes, ct) = packet.split_at(NONCE_LEN);
253    let mut nonce_arr = [0u8; NONCE_LEN];
254    nonce_arr.copy_from_slice(nonce_bytes);
255    let nonce = Nonce::assume_unique_for_key(nonce_arr);
256    let mut body = ct.to_vec();
257    let plain = key
258        .open_in_place(nonce, Aad::empty(), &mut body)
259        .map_err(|_| MeshUnderlayError::Path("open failed".into()))?;
260    Ok(plain.to_vec())
261}