AkurAI Build
Menu

AkurAI-VPNCommit 67ca9028

Commit 67ca9028

67ca9028c2fad783b70c10cd9fac111ce5b10de1 by Claude Code

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9a2baa0..28a95a4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,20 @@ All notable changes to this project are documented here. Format: Keep a Changelo
 
 ## [Unreleased]
 
+## [0.3.4] - 2026-07-01
+
+- LAN-local direct path candidates
+
+
+### Added
+- LAN-local direct path candidates: running nodes now heartbeat their bound UDP
+  endpoint, `/api/peermap` surfaces fresh peer endpoints, and the node probes
+  those candidates before falling back to the relay.
+
+### Fixed
+- Same-LAN peers can upgrade to the local UDP path instead of keeping encrypted
+  overlay traffic on the central relay path.
+
 ## [0.3.3] - 2026-06-29
 
 ### Added
@@ -152,4 +166,3 @@ All notable changes to this project are documented here. Format: Keep a Changelo
 - migrate to akurai-notes MCP; CLAUDE.md->@AGENTS.md; AGENTS.md->pointer
 - initial commit
 
-
diff --git a/Cargo.lock b/Cargo.lock
index cb56feb..de18a18 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -4,37 +4,37 @@ version = 4
 
 [[package]]
 name = "akurai-admin"
-version = "0.3.3"
+version = "0.3.4"
 dependencies = [
  "akurai-common",
 ]
 
 [[package]]
 name = "akurai-common"
-version = "0.3.3"
+version = "0.3.4"
 
 [[package]]
 name = "akurai-control"
-version = "0.3.3"
+version = "0.3.4"
 dependencies = [
  "akurai-common",
 ]
 
 [[package]]
 name = "akurai-crypto"
-version = "0.3.3"
+version = "0.3.4"
 
 [[package]]
 name = "akurai-dns"
-version = "0.3.3"
+version = "0.3.4"
 
 [[package]]
 name = "akurai-ingress"
-version = "0.3.3"
+version = "0.3.4"
 
 [[package]]
 name = "akurai-node"
-version = "0.3.3"
+version = "0.3.4"
 dependencies = [
  "akurai-common",
  "akurai-crypto",
@@ -45,18 +45,18 @@ dependencies = [
 
 [[package]]
 name = "akurai-relay"
-version = "0.3.3"
+version = "0.3.4"
 dependencies = [
  "akurai-common",
 ]
 
 [[package]]
 name = "akurai-sys"
-version = "0.3.3"
+version = "0.3.4"
 
 [[package]]
 name = "akurai-transport"
-version = "0.3.3"
+version = "0.3.4"
 dependencies = [
  "akurai-crypto",
 ]
diff --git a/Cargo.toml b/Cargo.toml
index cf4c199..bc5c1bc 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -14,7 +14,7 @@ members = ["crates/akurai-common", "crates/akurai-crypto", "crates/akurai-transp
 # official RFC test vectors. The hand-rolled-crypto risk is accepted deliberately.
 
 [workspace.package]
-version = "0.3.3"
+version = "0.3.4"
 edition = "2021"
 license = "MIT"
 repository = "https://github.com/olibuijr/AkurAI-VPN"
diff --git a/HANDOFF.md b/HANDOFF.md
index dc4f91a..933fddc 100644
--- a/HANDOFF.md
+++ b/HANDOFF.md
@@ -4,6 +4,17 @@ Date: 2026-06-28
 
 This document is the current working handoff for the AkurAI-VPN effort. It captures the live state of the system, what was changed, what remains, and the constraints another agent must preserve while continuing.
 
+## Update — 2026-07-01: LAN-local direct path candidates
+
+Same-LAN peers no longer have to rely on the central relay's observed public/NAT
+endpoint. While `akurai-node tunnel` is running, the node reports its bound UDP port
+plus the local source address used to reach the relay in `/api/heartbeat`; the control
+plane includes that fresh endpoint in `/api/peermap`; and the node probes peer-map
+endpoints before using the relay fallback. A candidate is promoted only after a valid
+frame arrives from a configured peer. Verified locally with `cargo test`, `cargo build
+-p akurai-node -p akurai-relay`, `sudo -n tests/netns/direct.sh`, and `sudo -n
+tests/netns/symmetric.sh`.
+
 ## Update — 2026-06-29 (v0.3.1): self-healing nodes + durable node auth tokens — production hardening
 
 Two production-hardening milestones on top of v0.2.0, both **verified live** on the two Linux nodes (ec2-peer + midget):
@@ -360,4 +371,3 @@ Expected status characteristics:
 - `AkurAI-VPN/CHANGELOG.md`
 - `akurai-vpn-site/frontend/install.sh`
 - `akurai-vpn-site/frontend/index.html`
-
diff --git a/VERSION b/VERSION
index 1c09c74..42045ac 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-0.3.3
+0.3.4
diff --git a/crates/akurai-common/src/frame.rs b/crates/akurai-common/src/frame.rs
index 2d0d244..8b267a9 100644
--- a/crates/akurai-common/src/frame.rs
+++ b/crates/akurai-common/src/frame.rs
@@ -162,6 +162,7 @@ mod tests {
             FrameKind::HandshakeInit,
             FrameKind::HandshakeResp,
             FrameKind::Data,
+            FrameKind::PeerAddr,
         ] {
             let f = Frame::new(kind, ip(100, 88, 0, 9), ip(0, 0, 0, 0), vec![1, 2, 3]).unwrap();
             assert_eq!(Frame::decode(&f.encode()).unwrap().kind, kind);
diff --git a/crates/akurai-control/src/heartbeat.rs b/crates/akurai-control/src/heartbeat.rs
index feaad6c..ec14cb9 100644
--- a/crates/akurai-control/src/heartbeat.rs
+++ b/crates/akurai-control/src/heartbeat.rs
@@ -14,10 +14,7 @@ pub const HEARTBEAT_TTL_SECS: u64 = 120;
 pub struct Heartbeat {
     /// Last reported UDP socket address (may be empty if the node reported none).
     ///
-    /// Recorded now so relay assignment can consume it later; the peer-map
-    /// snapshot does not yet surface it, hence `allow(dead_code)` (matching the
-    /// crate's convention for not-yet-wired forward-facing fields).
-    #[allow(dead_code)]
+    /// Surfaced through the peer map as a direct-path candidate while fresh.
     pub endpoint: String,
     /// UNIX timestamp (seconds) of the most recent heartbeat.
     pub last_seen: u64,
diff --git a/crates/akurai-control/src/listener.rs b/crates/akurai-control/src/listener.rs
index e9dda44..001f03c 100644
--- a/crates/akurai-control/src/listener.rs
+++ b/crates/akurai-control/src/listener.rs
@@ -313,14 +313,14 @@ fn route(req: &Request, state: &SharedState) -> Response {
         }
         (Method::Get, "/api/peermap") => {
             if let Some(session) = require_auth(req, state) {
-                handle_peermap(req, &session, state)
-            } else if let Some((user, _ep_id)) = auth_node(req, state) {
+                handle_peermap(req, &session, state, None)
+            } else if let Some((user, ep_id)) = auth_node(req, state) {
                 // Token-auth: build a synthetic AuthSession (csrf_token unused for peermap).
                 let session = AuthSession {
                     user,
                     csrf_token: String::new(),
                 };
-                handle_peermap(req, &session, state)
+                handle_peermap(req, &session, state, Some(&ep_id))
             } else {
                 Response {
                     status: "401 Unauthorized",
@@ -529,9 +529,14 @@ fn handle_list_endpoints(session: &AuthSession, state: &SharedState) -> Response
 /// node, identified by an optional `?self=<node_id>`), each with overlay IPv4,
 /// public key, name, and liveness. Generation is delegated to [`peermap`] so the
 /// pure mapping logic stays unit-testable without an HTTP request.
-fn handle_peermap(req: &Request, session: &AuthSession, state: &SharedState) -> Response {
+fn handle_peermap(
+    req: &Request,
+    session: &AuthSession,
+    state: &SharedState,
+    default_self_id: Option<&str>,
+) -> Response {
     let params = parse_query(&req.query);
-    let self_id = params.get("self").map(String::as_str);
+    let self_id = params.get("self").map(String::as_str).or(default_self_id);
     let st = match state.lock() {
         Ok(s) => s,
         Err(_) => return Response::error_html("Internal state lock error"),
@@ -1335,7 +1340,7 @@ mod tests {
             headers: std::collections::HashMap::new(),
             body: String::new(),
         };
-        let resp = handle_peermap(&req, &session, &state);
+        let resp = handle_peermap(&req, &session, &state, None);
         assert_eq!(resp.status, "200 OK");
         // The node named by ?self= is excluded; the peer is present.
         assert!(!resp.body.contains("name-self-node"));
@@ -1372,7 +1377,7 @@ mod tests {
             headers: std::collections::HashMap::new(),
             body: String::new(),
         };
-        let resp = handle_peermap(&req, &session, &state);
+        let resp = handle_peermap(&req, &session, &state, None);
         assert!(resp.body.contains("name-mine"));
         assert!(resp.body.contains("\"online\":true"));
         // Another tenant's node is never disclosed.
@@ -1450,7 +1455,7 @@ mod tests {
         assert!(auth_node(&req, &state).is_none());
     }
 
-    /// `GET /api/peermap` with a valid bearer token returns the user's own peers.
+    /// `GET /api/peermap` with a valid bearer token returns the user's other peers.
     #[test]
     fn peermap_via_token_header_returns_user_peers() {
         let state = crate::state::new_shared();
@@ -1460,6 +1465,8 @@ mod tests {
             let mut my_ep = ep("mine", "user@example.com", &["100.88.0.2/32"]);
             my_ep.node_token = tok.clone();
             st.endpoints.push(my_ep);
+            st.endpoints
+                .push(ep("peer", "user@example.com", &["100.88.0.4/32"]));
             // Another tenant's endpoint — must not appear.
             st.endpoints
                 .push(ep("theirs", "other@example.com", &["100.88.0.3/32"]));
@@ -1476,8 +1483,12 @@ mod tests {
         let resp = route(&req, &state);
         assert_eq!(resp.status, "200 OK");
         assert!(
-            resp.body.contains("name-mine"),
-            "own peer should be present"
+            !resp.body.contains("name-mine"),
+            "token-auth peermap should exclude the requesting node"
+        );
+        assert!(
+            resp.body.contains("name-peer"),
+            "same-tenant peer should be present"
         );
         assert!(
             !resp.body.contains("name-theirs"),
diff --git a/crates/akurai-control/src/peermap.rs b/crates/akurai-control/src/peermap.rs
index 2bb80e8..5dee2ca 100644
--- a/crates/akurai-control/src/peermap.rs
+++ b/crates/akurai-control/src/peermap.rs
@@ -2,9 +2,10 @@
 //!
 //! Builds the per-node view of the network — for a given user, the set of their
 //! own [`VpnEndpoint`]s a node may reach, minus the requesting node itself, each
-//! tagged with its overlay IPv4 and current liveness. The data-plane node polls
-//! this to learn its peers. The ACL-filtered watch stream is not implemented in
-//! 0.0.1; this is the snapshot endpoint that backs it.
+//! tagged with its overlay IPv4, current liveness, and best direct UDP endpoint
+//! candidate. The data-plane node polls this to learn its peers. The ACL-filtered
+//! watch stream is not implemented in 0.0.1; this is the snapshot endpoint that
+//! backs it.
 
 use std::collections::HashMap;
 
@@ -19,9 +20,11 @@ use crate::vpn_endpoint::VpnEndpoint;
 /// * the caller's own node, named by `self_id`, is excluded — when `self_id` is
 ///   `None`, nothing is excluded on that basis.
 ///
-/// Each element is `{"overlay_ipv4","public_key","name","online"}`. `online` is
-/// `true` when a fresh heartbeat exists for that node id (see
-/// [`heartbeat::is_online`]). Hand-rolled JSON — no serde.
+/// Each element is `{"overlay_ipv4","public_key","name","online","endpoint"}`.
+/// `online` is `true` when a fresh heartbeat exists for that node id (see
+/// [`heartbeat::is_online`]). `endpoint` is the fresh heartbeat endpoint when
+/// present, otherwise the endpoint saved on the enrolled device record. Hand-rolled
+/// JSON — no serde.
 pub fn build_peermap_json(
     endpoints: &[VpnEndpoint],
     heartbeats: &HashMap<String, Heartbeat>,
@@ -42,16 +45,27 @@ pub fn build_peermap_json(
 /// Serialize one peer to a JSON object string.
 fn peer_json(e: &VpnEndpoint, heartbeats: &HashMap<String, Heartbeat>, now: u64) -> String {
     let overlay = ipam::overlay_addr_string(&e.allowed_ips).unwrap_or_default();
-    let online = heartbeats
-        .get(&e.id)
+    let hb = heartbeats.get(&e.id);
+    let online = hb
         .map(|hb| heartbeat::is_online(hb.last_seen, now))
         .unwrap_or(false);
+    let endpoint = if online {
+        hb.map(|h| h.endpoint.trim()).filter(|s| !s.is_empty())
+    } else {
+        None
+    }
+    .or_else(|| {
+        let saved = e.endpoint_addr.trim();
+        (!saved.is_empty()).then_some(saved)
+    })
+    .unwrap_or("");
     format!(
-        "{{\"overlay_ipv4\":\"{}\",\"public_key\":\"{}\",\"name\":\"{}\",\"online\":{}}}",
+        "{{\"overlay_ipv4\":\"{}\",\"public_key\":\"{}\",\"name\":\"{}\",\"online\":{},\"endpoint\":\"{}\"}}",
         json_esc(&overlay),
         json_esc(&e.public_key),
         json_esc(&e.name),
         online,
+        json_esc(endpoint),
     )
 }
 
@@ -146,6 +160,43 @@ mod tests {
         assert!(json.contains("\"online\":true"));
     }
 
+    #[test]
+    fn fresh_heartbeat_endpoint_is_included() {
+        let endpoints = vec![ep("a", "user@example.com", &["100.88.0.2/32"])];
+        let now = 1_000;
+        let mut hbs = HashMap::new();
+        hbs.insert(
+            "a".to_string(),
+            Heartbeat {
+                endpoint: "192.168.1.44:51399".to_string(),
+                last_seen: now,
+            },
+        );
+        let json = build_peermap_json(&endpoints, &hbs, "user@example.com", None, now);
+        assert!(json.contains("\"endpoint\":\"192.168.1.44:51399\""));
+    }
+
+    #[test]
+    fn stale_heartbeat_endpoint_is_not_included() {
+        let endpoints = vec![ep("a", "user@example.com", &["100.88.0.2/32"])];
+        let mut hbs = HashMap::new();
+        hbs.insert(
+            "a".to_string(),
+            Heartbeat {
+                endpoint: "192.168.1.44:51399".to_string(),
+                last_seen: 1_000,
+            },
+        );
+        let json = build_peermap_json(
+            &endpoints,
+            &hbs,
+            "user@example.com",
+            None,
+            1_001 + heartbeat::HEARTBEAT_TTL_SECS,
+        );
+        assert!(json.contains("\"endpoint\":\"\""));
+    }
+
     #[test]
     fn online_flips_false_when_heartbeat_is_older_than_ttl() {
         let endpoints = vec![ep("a", "user@example.com", &["100.88.0.2/32"])];
diff --git a/crates/akurai-node/src/acl.rs b/crates/akurai-node/src/acl.rs
index 1b45a4c..7581724 100644
--- a/crates/akurai-node/src/acl.rs
+++ b/crates/akurai-node/src/acl.rs
@@ -110,6 +110,7 @@ mod tests {
             overlay_ip: Ipv4Addr::new(100, 88, 0, 9),
             public_key: [0u8; 32],
             name: name.into(),
+            endpoint: None,
             advertised: vec![],
             tags: tags.iter().map(|t| Tag((*t).to_string())).collect(),
         }
diff --git a/crates/akurai-node/src/heartbeat.rs b/crates/akurai-node/src/heartbeat.rs
index 5dde1b6..5679928 100644
--- a/crates/akurai-node/src/heartbeat.rs
+++ b/crates/akurai-node/src/heartbeat.rs
@@ -13,6 +13,7 @@
 //!
 //! Both paths use `curl` — pure-std has no TLS client.
 
+use std::net::{SocketAddr, UdpSocket};
 use std::path::{Path, PathBuf};
 use std::process::Command;
 use std::thread;
@@ -44,7 +45,7 @@ pub fn fetch_node_id(control_url: &str, cookie_jar: &Path, pubkey_b64: &str) ->
 }
 
 /// POST a single heartbeat: fetch a fresh dashboard `csrf_token`, then submit our id.
-fn beat(control_url: &str, cookie_jar: &Path, node_id: &str) -> bool {
+fn beat(control_url: &str, cookie_jar: &Path, node_id: &str, endpoint: &str) -> bool {
     let base = control_url.trim_end_matches('/');
     let jar = cookie_jar.to_string_lossy().into_owned();
     let dash = Command::new("curl")
@@ -71,7 +72,7 @@ fn beat(control_url: &str, cookie_jar: &Path, node_id: &str) -> bool {
             "-H",
             "Content-Type: application/json",
             "--data",
-            &format!("{{\"id\":\"{node_id}\",\"endpoint\":\"\"}}"),
+            &heartbeat_body(node_id, endpoint),
             &format!("{base}/api/heartbeat"),
         ])
         .status()
@@ -106,7 +107,7 @@ pub fn fetch_node_id_with_token(
 }
 
 /// POST a single heartbeat using a bearer token — no CSRF fetch required.
-fn beat_with_token(control_url: &str, token: &str, node_id: &str) -> bool {
+fn beat_with_token(control_url: &str, token: &str, node_id: &str, endpoint: &str) -> bool {
     let base = control_url.trim_end_matches('/');
     Command::new("curl")
         .args([
@@ -118,7 +119,7 @@ fn beat_with_token(control_url: &str, token: &str, node_id: &str) -> bool {
             "-H",
             "Content-Type: application/json",
             "--data",
-            &format!("{{\"id\":\"{node_id}\",\"endpoint\":\"\"}}"),
+            &heartbeat_body(node_id, endpoint),
             &format!("{base}/api/heartbeat"),
         ])
         .status()
@@ -136,6 +137,8 @@ pub fn spawn(
     cookie_jar: PathBuf,
     pubkey_b64: String,
     node_token: Option<String>,
+    relay: SocketAddr,
+    tunnel_port: u16,
 ) {
     thread::spawn(move || {
         let resolved = if let Some(ref tok) = node_token {
@@ -156,16 +159,53 @@ pub fn spawn(
         };
         eprintln!("akurai-node: heartbeat to {control_url} as node {id}");
         loop {
+            let endpoint = advertised_endpoint(relay, tunnel_port).unwrap_or_default();
             if let Some(ref tok) = node_token {
-                let _ = beat_with_token(&control_url, tok, &id);
+                let _ = beat_with_token(&control_url, tok, &id, &endpoint);
             } else {
-                let _ = beat(&control_url, &cookie_jar, &id);
+                let _ = beat(&control_url, &cookie_jar, &id, &endpoint);
             }
             thread::sleep(HEARTBEAT_INTERVAL);
         }
     });
 }
 
+/// Resolve the local underlay address that would reach the relay, then replace
+/// its random probe port with the tunnel socket's actual listening port.
+fn advertised_endpoint(relay: SocketAddr, tunnel_port: u16) -> Option<String> {
+    let bind = match relay {
+        SocketAddr::V4(_) => "0.0.0.0:0",
+        SocketAddr::V6(_) => "[::]:0",
+    };
+    let sock = UdpSocket::bind(bind).ok()?;
+    sock.connect(relay).ok()?;
+    let local = sock.local_addr().ok()?;
+    match local {
+        SocketAddr::V4(v4) if !v4.ip().is_unspecified() => {
+            Some(SocketAddr::from((*v4.ip(), tunnel_port)).to_string())
+        }
+        SocketAddr::V6(v6) if !v6.ip().is_unspecified() => {
+            Some(SocketAddr::from((*v6.ip(), tunnel_port)).to_string())
+        }
+        _ => None,
+    }
+}
+
+fn heartbeat_body(node_id: &str, endpoint: &str) -> String {
+    format!(
+        "{{\"id\":\"{}\",\"endpoint\":\"{}\"}}",
+        json_esc(node_id),
+        json_esc(endpoint)
+    )
+}
+
+fn json_esc(s: &str) -> String {
+    s.replace('\\', "\\\\")
+        .replace('"', "\\\"")
+        .replace('\n', "\\n")
+        .replace('\r', "\\r")
+}
+
 /// Extract a `"key":"value"` or `"key":<number>` JSON field (hand-rolled).
 fn json_field(obj: &str, key: &str) -> Option<String> {
     let needle = format!("\"{key}\"");
@@ -210,4 +250,20 @@ mod tests {
         let html = r#"<input name="csrf_token" value="tok-9f8e" type="hidden">"#;
         assert_eq!(csrf_token(html).as_deref(), Some("tok-9f8e"));
     }
+
+    #[test]
+    fn heartbeat_body_includes_endpoint() {
+        assert_eq!(
+            heartbeat_body("node-a", "192.168.1.44:51399"),
+            r#"{"id":"node-a","endpoint":"192.168.1.44:51399"}"#
+        );
+    }
+
+    #[test]
+    fn heartbeat_body_escapes_json_values() {
+        assert_eq!(
+            heartbeat_body("node\"a", "192.168.1.44:51399"),
+            r#"{"id":"node\"a","endpoint":"192.168.1.44:51399"}"#
+        );
+    }
 }
diff --git a/crates/akurai-node/src/main.rs b/crates/akurai-node/src/main.rs
index f0fe901..e2308f9 100644
--- a/crates/akurai-node/src/main.rs
+++ b/crates/akurai-node/src/main.rs
@@ -100,12 +100,13 @@ fn install(args: &[String]) -> Result<(), NodeError> {
         &dirs.config.join("identity.key"),
         &dirs.config.join("identity.pub"),
     )?;
+    let pubkey_b64 = id.public_b64();
 
     println!("installed {NAME} {VERSION}");
     println!("  home : {}", dirs.home.display());
     println!("  bin  : {}", installed_exe.display());
     println!("  mode : host-only (routing disabled)");
-    println!("  id   : {}", id.public_b64());
+    println!("  id   : {pubkey_b64}");
     Ok(())
 }
 
@@ -146,6 +147,7 @@ fn tunnel_cmd(args: &[String]) -> Result<(), NodeError> {
         &dirs.config.join("identity.key"),
         &dirs.config.join("identity.pub"),
     )?;
+    let pubkey_b64 = id.public_b64();
 
     // network.conf (written by the installer) supplies overlay_ip/relay/control;
     // explicit flags override it. This lets the systemd unit run a bare
@@ -177,28 +179,30 @@ fn tunnel_cmd(args: &[String]) -> Result<(), NodeError> {
     let peers_path = arg_value(args, "--peers")
         .map(PathBuf::from)
         .unwrap_or_else(|| dirs.config.join("peers"));
+    let control_url = from("--control", "control");
+    let cookie_path = dirs.config.join("cookies.txt");
     // Self-bootstrap: load the durable node token from disk, or acquire it from
     // the control plane via the saved OIDC session cookie and write it to disk
     // so future restarts survive cookie expiry.  Falls back to None when no
     // control URL is configured or the cookie jar is absent.
-    let node_token: Option<String> = from("--control", "control").and_then(|url| {
+    let node_token: Option<String> = control_url.as_ref().and_then(|url| {
         load_or_bootstrap_token(
             &dirs.config.join("node.token"),
-            &url,
-            &dirs.config.join("cookies.txt"),
-            &id.public_b64(),
+            url,
+            &cookie_path,
+            &pubkey_b64,
         )
     });
 
     // Source the peer map from the control plane (`--control <url>`) when
     // available.  Prefer the bearer token so the fetch survives cookie expiry;
     // fall back to the cookie jar when no token is available yet.
-    let peers = match from("--control", "control") {
+    let peers = match control_url.as_ref() {
         Some(url) => {
             let fetched = if let Some(ref tok) = node_token {
-                peers::PeerTable::fetch_with_token(&url, tok)
+                peers::PeerTable::fetch_with_token(url, tok)
             } else {
-                peers::PeerTable::fetch(&url, &dirs.config.join("cookies.txt"))
+                peers::PeerTable::fetch(url, &cookie_path)
             };
             if fetched.is_empty() {
                 peers::PeerTable::load_file(&peers_path)
@@ -247,20 +251,8 @@ fn tunnel_cmd(args: &[String]) -> Result<(), NodeError> {
     eprintln!(
         "{NAME}: tunnel up — overlay {overlay_ip} on {iface}, relay {relay}, {} peer(s), id {}",
         peers.len(),
-        id.public_b64()
+        pubkey_b64
     );
-    // Report liveness to the control plane so peers see this node ONLINE.
-    // Pass the node token (if bootstrapped) so the heartbeat loop uses bearer
-    // auth and keeps working after the session cookie expires.
-    if let Some(control) = from("--control", "control") {
-        heartbeat::spawn(
-            control,
-            dirs.config.join("cookies.txt"),
-            id.public_b64(),
-            node_token.clone(),
-        );
-    }
-
     let cfg = tunnel::TunnelConfig {
         iface,
         overlay_ip,
@@ -272,9 +264,17 @@ fn tunnel_cmd(args: &[String]) -> Result<(), NodeError> {
         advertise,
         exit_node,
         acl,
-        control_url: from("--control", "control"),
-        cookie_jar: Some(dirs.config.join("cookies.txt")),
+        control_url: control_url.clone(),
+        cookie_jar: Some(cookie_path.clone()),
         node_token: node_token.clone(),
+        heartbeat: control_url
+            .clone()
+            .map(|control_url| tunnel::HeartbeatConfig {
+                control_url,
+                cookie_jar: cookie_path.clone(),
+                pubkey_b64,
+                node_token: node_token.clone(),
+            }),
     };
     tunnel::run(cfg)?;
     Ok(())
@@ -320,6 +320,7 @@ fn selftest_cmd(args: &[String]) -> Result<(), NodeError> {
         control_url: None,
         cookie_jar: None,
         node_token: None,
+        heartbeat: None,
     };
     tunnel::selftest(cfg, secs)?;
     Ok(())
@@ -383,6 +384,7 @@ fn echo_peer_cmd(args: &[String]) -> Result<(), NodeError> {
         control_url: None,
         cookie_jar: None,
         node_token: None,
+        heartbeat: None,
     };
     tunnel::echo_peer(cfg, secs)?;
     Ok(())
diff --git a/crates/akurai-node/src/peers.rs b/crates/akurai-node/src/peers.rs
index d23045d..a3e3a98 100644
--- a/crates/akurai-node/src/peers.rs
+++ b/crates/akurai-node/src/peers.rs
@@ -12,7 +12,7 @@
 //! destination not in the table is dropped by the data plane.
 
 use std::collections::HashMap;
-use std::net::{IpAddr, Ipv4Addr};
+use std::net::{IpAddr, Ipv4Addr, SocketAddr};
 use std::path::Path;
 use std::process::Command;
 
@@ -25,6 +25,10 @@ pub struct Peer {
     pub overlay_ip: Ipv4Addr,
     pub public_key: [u8; 32],
     pub name: String,
+    /// Direct UDP candidate from the control plane or static peer file. This is
+    /// only a candidate: the data plane confirms it by receiving an authenticated
+    /// frame from this peer before sending data on it.
+    pub endpoint: Option<SocketAddr>,
     /// Subnets this peer is an approved gateway for (MVP2 subnet routing). A
     /// packet whose destination falls in one of these is tunnelled to this peer,
     /// which forwards it to the real subnet behind it.
@@ -111,6 +115,14 @@ impl PeerTable {
             .collect()
     }
 
+    /// Every peer endpoint advertised as a direct UDP candidate.
+    pub fn direct_endpoints(&self) -> Vec<(Ipv4Addr, SocketAddr)> {
+        self.by_ip
+            .values()
+            .filter_map(|p| p.endpoint.map(|e| (p.overlay_ip, e)))
+            .collect()
+    }
+
     pub fn len(&self) -> usize {
         self.by_ip.len()
     }
@@ -168,13 +180,13 @@ impl PeerTable {
 }
 
 /// Parse a static peers file: one
-/// `<overlay_ip> <pubkey_b64> [name] [advertised] [tags]` per line, e.g.
-/// `100.88.0.3 <pubkey> nodeb 192.168.50.0/24 tag:server,tag:trusted`.
+/// `<overlay_ip> <pubkey_b64> [name] [advertised] [tags] [endpoint]` per line, e.g.
+/// `100.88.0.3 <pubkey> nodeb 192.168.50.0/24 tag:server,tag:trusted 192.168.1.9:51820`.
 /// The 4th field is comma-separated advertised CIDRs (`-` or any non-CIDR
 /// token ⇒ none, which lets a tagged peer with no subnets keep the 5th field
 /// positional). The 5th field is comma-separated ACL tags (`tag:` prefix
-/// optional). Blank lines and `#` comments are ignored; unparseable lines are
-/// skipped.
+/// optional; use `-` when the 6th endpoint field is present with no tags).
+/// Blank lines and `#` comments are ignored; unparseable lines are skipped.
 pub fn parse_peers_file(content: &str) -> Vec<Peer> {
     let mut peers = Vec::new();
     for line in content.lines() {
@@ -190,15 +202,23 @@ pub fn parse_peers_file(content: &str) -> Vec<Peer> {
         // Optional 4th field: comma-separated advertised subnets (gateway peer).
         let advertised = parts
             .next()
+            .filter(|s| *s != "-")
             .map(|s| s.split(',').filter_map(parse_cidr).collect())
             .unwrap_or_default();
         // Optional 5th field: comma-separated ACL tags.
-        let tags = parts.next().map(parse_tags).unwrap_or_default();
+        let tags = parts
+            .next()
+            .filter(|s| *s != "-")
+            .map(parse_tags)
+            .unwrap_or_default();
+        // Optional 6th field: direct UDP endpoint candidate.
+        let endpoint = parts.next().and_then(|s| s.parse::<SocketAddr>().ok());
         if let (Ok(ip), Some(pk)) = (ip_s.parse::<Ipv4Addr>(), b64::decode_array::<32>(pk_s)) {
             peers.push(Peer {
                 overlay_ip: ip,
                 public_key: pk,
                 name,
+                endpoint,
                 advertised,
                 tags,
             });
@@ -229,7 +249,7 @@ pub fn parse_cidr(s: &str) -> Option<Cidr> {
 }
 
 /// Parse the control plane's peer-map JSON array
-/// (`[{"overlay_ipv4":"..","public_key":"..","name":"..","online":..}]`).
+/// (`[{"overlay_ipv4":"..","public_key":"..","name":"..","online":..,"endpoint":".."}]`).
 /// Hand-rolled, dependency-free; skips malformed objects.
 pub fn parse_peermap_json(json: &str) -> Vec<Peer> {
     let mut peers = Vec::new();
@@ -252,11 +272,15 @@ pub fn parse_peermap_json(json: &str) -> Vec<Peer> {
         let tags = json_str_field(obj, "tags")
             .map(|s| parse_tags(&s))
             .unwrap_or_default();
+        let endpoint = json_str_field(obj, "endpoint")
+            .and_then(|s| (!s.trim().is_empty()).then_some(s))
+            .and_then(|s| s.parse::<SocketAddr>().ok());
         if let (Ok(ip), Some(pk)) = (ip_s.parse::<Ipv4Addr>(), b64::decode_array::<32>(&pk_s)) {
             peers.push(Peer {
                 overlay_ip: ip,
                 public_key: pk,
                 name,
+                endpoint,
                 advertised,
                 tags,
             });
@@ -315,6 +339,20 @@ mod tests {
         assert_eq!(peers[0].overlay_ip, Ipv4Addr::new(100, 88, 0, 5));
         assert_eq!(peers[0].public_key, pk(9));
         assert_eq!(peers[0].name, "phone");
+        assert_eq!(peers[0].endpoint, None);
+    }
+
+    #[test]
+    fn parses_control_peermap_endpoint() {
+        let pk_b64 = b64::encode(&pk(9));
+        let json = format!(
+            "[{{\"overlay_ipv4\":\"100.88.0.5\",\"public_key\":\"{pk_b64}\",\"name\":\"phone\",\"online\":true,\"endpoint\":\"192.168.1.44:51820\"}}]"
+        );
+        let peers = parse_peermap_json(&json);
+        assert_eq!(
+            peers[0].endpoint,
+            Some("192.168.1.44:51820".parse().unwrap())
+        );
     }
 
     #[test]
@@ -323,6 +361,7 @@ mod tests {
             overlay_ip: Ipv4Addr::new(100, 88, 0, 3),
             public_key: pk(1),
             name: "b".into(),
+            endpoint: None,
             advertised: vec![],
             tags: vec![],
         }]);
@@ -346,6 +385,7 @@ mod tests {
             overlay_ip: Ipv4Addr::new(100, 88, 0, 3),
             public_key: pk(1),
             name: "gw".into(),
+            endpoint: None,
             advertised: vec![parse_cidr("192.168.50.0/24").unwrap()],
             tags: vec![],
         }]);
@@ -385,12 +425,20 @@ mod tests {
         assert!(bare[0].tags.is_empty());
     }
 
+    #[test]
+    fn parses_static_peer_endpoint_with_placeholders() {
+        let pk_b64 = b64::encode(&pk(5));
+        let peers = parse_peers_file(&format!("100.88.0.3 {pk_b64} laptop - - 10.0.0.3:51820\n"));
+        assert_eq!(peers[0].endpoint, Some("10.0.0.3:51820".parse().unwrap()));
+    }
+
     #[test]
     fn peer_principals_are_tag_principals() {
         let p = Peer {
             overlay_ip: Ipv4Addr::new(100, 88, 0, 3),
             public_key: pk(1),
             name: "x".into(),
+            endpoint: None,
             advertised: vec![],
             tags: vec![Tag("server".into()), Tag("trusted".into())],
         };
diff --git a/crates/akurai-node/src/tunnel.rs b/crates/akurai-node/src/tunnel.rs
index dacef73..df342cd 100644
--- a/crates/akurai-node/src/tunnel.rs
+++ b/crates/akurai-node/src/tunnel.rs
@@ -39,6 +39,16 @@ use akurai_transport::Keypair;
 use crate::acl::Acl;
 use crate::peers::PeerTable;
 
+/// Control-plane heartbeat settings. `tunnel::run` starts the heartbeat after
+/// the UDP socket is bound, so the reported endpoint uses the real tunnel port.
+#[derive(Clone)]
+pub struct HeartbeatConfig {
+    pub control_url: String,
+    pub cookie_jar: PathBuf,
+    pub pubkey_b64: String,
+    pub node_token: Option<String>,
+}
+
 /// Runtime configuration for the data-plane daemon.
 // Several fields drive interface/route setup, which is fully exercised only on the
 // Linux gateway path; the macOS client path uses a subset and the Windows stub none.
@@ -75,6 +85,9 @@ pub struct TunnelConfig {
     /// session cookie — so the refresh survives cookie expiry. `None` ⇒ fall
     /// back to cookie-jar auth (requires a valid session cookie).
     pub node_token: Option<String>,
+    /// Optional control-plane heartbeat. Started after the UDP socket is bound
+    /// so the advertised endpoint can carry the current direct UDP candidate.
+    pub heartbeat: Option<HeartbeatConfig>,
 }
 
 /// Per-peer session + path state, shared between the pump threads.
@@ -125,6 +138,30 @@ fn parse_addr(payload: &[u8]) -> Option<SocketAddr> {
     Some(SocketAddr::from((ip, port)))
 }
 
+/// Pick outbound addresses for first-contact handshakes. A confirmed direct path
+/// is used by itself. A candidate path is tried first with relay fallback. When
+/// the control plane advertises a peer endpoint, seed it as the candidate.
+fn handshake_paths(
+    st: &mut PeerState,
+    relay: SocketAddr,
+    advertised: Option<SocketAddr>,
+) -> Vec<SocketAddr> {
+    if let Some(direct) = st.direct {
+        return vec![direct];
+    }
+    if st.candidate.is_none() {
+        st.candidate = advertised;
+    }
+    let mut paths = Vec::new();
+    if let Some(candidate) = st.candidate {
+        if candidate != relay {
+            paths.push(candidate);
+        }
+    }
+    paths.push(relay);
+    paths
+}
+
 /// Bring up the overlay interface. Platform-specific: Linux uses `ip`/`sysctl`,
 /// macOS uses `ifconfig`/`route`, Windows uses `netsh`. `iface` is the REAL
 /// interface name (the requested name on Linux and Windows, the kernel-assigned
@@ -312,6 +349,7 @@ pub fn run(cfg: TunnelConfig) -> io::Result<()> {
 
     let sock = Arc::new(UdpSocket::bind(("0.0.0.0", 0))?);
     let relay = cfg.relay;
+    let heartbeat = cfg.heartbeat.clone();
     let table: Arc<Mutex<Table>> = Arc::new(Mutex::new(HashMap::new()));
     let control_url = cfg.control_url;
     let cookie_jar = cfg.cookie_jar;
@@ -323,6 +361,18 @@ pub fn run(cfg: TunnelConfig) -> io::Result<()> {
     // thread, so it need not be shared with the UDP pump.
     let acl = cfg.acl;
 
+    if let Some(hb) = heartbeat {
+        let port = sock.local_addr()?.port();
+        crate::heartbeat::spawn(
+            hb.control_url,
+            hb.cookie_jar,
+            hb.pubkey_b64,
+            hb.node_token,
+            relay,
+            port,
+        );
+    }
+
     // MagicDNS: resolve `<peer-name>.akurai` (and bare `<peer-name>`) to a peer\'s
     // overlay IP, served on the node\'s own overlay IP:53.
     {
@@ -343,6 +393,7 @@ pub fn run(cfg: TunnelConfig) -> io::Result<()> {
     {
         let sock = Arc::clone(&sock);
         let table = Arc::clone(&table);
+        let peers = Arc::clone(&peers);
         thread::spawn(move || loop {
             if let Some(f) = Frame::new(
                 FrameKind::Keepalive,
@@ -352,12 +403,31 @@ pub fn run(cfg: TunnelConfig) -> io::Result<()> {
             ) {
                 let bytes = f.encode();
                 let _ = sock.send_to(&bytes, relay);
-                if let Ok(t) = table.lock() {
-                    for st in t.values() {
-                        if let Some(a) = st.direct.or(st.candidate) {
-                            let _ = sock.send_to(&bytes, a);
+                let addrs = {
+                    let advertised = {
+                        let pt = Arc::clone(&*peers.read().unwrap());
+                        pt.direct_endpoints()
+                    };
+                    let mut addrs = Vec::new();
+                    if let Ok(mut t) = table.lock() {
+                        for (peer_ip, endpoint) in advertised {
+                            let st = t.entry(peer_ip).or_default();
+                            if st.direct.is_none() && st.candidate != Some(endpoint) {
+                                st.candidate = Some(endpoint);
+                            }
+                        }
+                        for st in t.values() {
+                            if let Some(a) = st.direct.or(st.candidate) {
+                                if a != relay {
+                                    addrs.push(a);
+                                }
+                            }
                         }
                     }
+                    addrs
+                };
+                for addr in addrs {
+                    let _ = sock.send_to(&bytes, addr);
                 }
             }
             thread::sleep(Duration::from_secs(15));
@@ -372,7 +442,6 @@ pub fn run(cfg: TunnelConfig) -> io::Result<()> {
     if let Some(url) = control_url {
         let peers_rw = Arc::clone(&peers);
         thread::spawn(move || loop {
-            thread::sleep(Duration::from_secs(30));
             let fetched = if let Some(ref tok) = node_token {
                 PeerTable::fetch_with_token(&url, tok)
             } else if let Some(ref jar) = cookie_jar {
@@ -385,6 +454,7 @@ pub fn run(cfg: TunnelConfig) -> io::Result<()> {
                 *peers_rw.write().unwrap() = Arc::new(fetched);
                 eprintln!("akurai-node: peers refreshed — {n} peer(s)");
             }
+            thread::sleep(Duration::from_secs(30));
         });
     }
 
@@ -670,29 +740,36 @@ fn tun_pump(pump: Pump, acl: Option<Acl>) -> io::Result<()> {
         }
         let peer_ip = peer.overlay_ip;
         let peer_pub = peer.public_key;
+        let peer_endpoint = peer.endpoint;
         // pt (and the peer borrow) are no longer needed past this point.
         drop(pt);
 
-        let mut out: Option<(Vec<u8>, SocketAddr)> = None;
+        let mut out: Vec<(Vec<u8>, SocketAddr)> = Vec::new();
         {
             let mut t = table.lock().unwrap();
             let st = t.entry(peer_ip).or_default();
             if let Some(sess) = st.session.as_mut() {
                 let ct = sess.encrypt(pkt);
                 let addr = st.direct.unwrap_or(relay);
-                out = Frame::new(FrameKind::Data, my_ip, peer_ip, ct).map(|f| (f.encode(), addr));
+                if let Some(f) = Frame::new(FrameKind::Data, my_ip, peer_ip, ct) {
+                    out.push((f.encode(), addr));
+                }
             } else if st.pending.is_none() {
                 // First contact: initiate via the relay and drop this packet (the
                 // upper layer retransmits once a session exists).
                 if let Ok(eph) = rand32() {
                     let (state, msg1) = noise::initiate(&kp, &peer_pub, &eph);
                     st.pending = Some(state);
-                    out = Frame::new(FrameKind::HandshakeInit, my_ip, peer_ip, msg1)
-                        .map(|f| (f.encode(), relay));
+                    if let Some(f) = Frame::new(FrameKind::HandshakeInit, my_ip, peer_ip, msg1) {
+                        let bytes = f.encode();
+                        for addr in handshake_paths(st, relay, peer_endpoint) {
+                            out.push((bytes.clone(), addr));
+                        }
+                    }
                 }
             }
         }
-        if let Some((bytes, addr)) = out {
+        for (bytes, addr) in out {
             let _ = sock.send_to(&bytes, addr);
         }
     }
@@ -773,8 +850,19 @@ fn udp_pump(pump: Pump) {
                 if let Some((keys, msg2, init_pub)) = noise::respond(&kp, &eph, &frame.payload) {
                     match ptab.get(&frame.src) {
                         Some(p) if p.public_key == init_pub => {
-                            table.lock().unwrap().entry(frame.src).or_default().session =
-                                Some(Session::new(keys));
+                            let install = {
+                                let mut t = table.lock().unwrap();
+                                let st = t.entry(frame.src).or_default();
+                                if st.session.is_none() {
+                                    st.session = Some(Session::new(keys));
+                                    true
+                                } else {
+                                    false
+                                }
+                            };
+                            if !install {
+                                continue;
+                            }
                             if let Some(reply) =
                                 Frame::new(FrameKind::HandshakeResp, my_ip, frame.src, msg2)
                             {
diff --git a/docs/architecture.md b/docs/architecture.md
index 4f3466b..68cb8fc 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -1,10 +1,12 @@
 # AkurAI VPN — Architecture (data plane)
 
-AkurAI VPN is a private, encrypted L3 overlay network. In MVP1 it is **hub-routed**:
-every node sends all overlay traffic to a single relay, which forwards each datagram
-to the destination's last-known UDP endpoint. The relay never holds payload keys — it
-moves end-to-end ciphertext. This document describes what is actually built and
-deployed; aspirational features are explicitly marked **NOT YET BUILT**.
+AkurAI VPN is a private, encrypted L3 overlay network. The data plane uses direct
+peer paths with relay fallback: peers prefer a confirmed direct UDP address
+(including same-LAN private addresses advertised through heartbeat), and use the
+central relay only until a direct path is proven or when direct UDP cannot work. The
+relay never holds payload keys — it moves end-to-end ciphertext. This document
+describes what is actually built and deployed; aspirational features are explicitly
+marked **NOT YET BUILT**.
 
 The whole shipped data plane is **pure Rust, `std`-only, zero runtime dependencies**.
 The single `unsafe` in the codebase is one `ioctl` to create the TUN device
@@ -13,8 +15,9 @@ safe `std`. Data-plane cryptography is hand-rolled in `akurai-crypto` against RF
 vectors (decision resolved 2026-06-27: keep the zero-dependency identity rather than
 link rustls/quinn/snow/ed25519; the hand-rolled-crypto risk is accepted deliberately).
 
-Workspace version: `0.1.0`. The first working encrypted overlay mesh landed in the
-`0.1.0` changelog entry (2026-06-29).
+Workspace version: `0.3.3` plus current unreleased LAN-local direct-path work. The
+first working encrypted overlay mesh landed in the `0.1.0` changelog entry
+(2026-06-29).
 
 ## Components (crates)
 
@@ -24,9 +27,9 @@ Workspace version: `0.1.0`. The first working encrypted overlay mesh landed in t
 | `akurai-crypto` | Zero-dep crypto primitives | `x25519` (RFC 7748), `chacha20poly1305` (RFC 8439), `blake2s` (RFC 7693), `hkdf`; each verified against official RFC vectors in-crate |
 | `akurai-transport` | Secure channel | `noise` (WireGuard-style `Noise_IK` handshake), `session` (counter-nonce data packets + 64-entry replay window), `Keypair`/`TransportKeys` |
 | `akurai-sys` | The OS seam | `raw` (the only `unsafe` — one `ioctl(TUNSETIFF)` via a direct `syscall`), `tun` (`create` → `/dev/net/tun` handle, `IFF_TUN | IFF_NO_PI`) |
-| `akurai-node` | Node daemon | `identity` (persistent X25519 static keypair), `peers` (overlay-IP-indexed peer table), `tunnel` (the TUN↔UDP pump), `main` (CLI: `install`/`up`/`down`/`tunnel`/`status`/`path`/`version`) |
-| `akurai-relay` | Ciphertext-only hub | `forward` (learn src endpoint, forward by dest overlay IP, drop unknown; holds no keys, links no crypto crate) |
-| `akurai-control` | Control plane (HTTP) | `ipam` (overlay IP allocation), `peermap` (`/api/peermap`), `heartbeat` (`/api/heartbeat`, liveness TTL), `listener` (auth + CSRF + endpoint CRUD) |
+| `akurai-node` | Node daemon | `identity` (persistent X25519 static keypair), `peers` (overlay-IP-indexed peer table + direct endpoint candidates), `tunnel` (the TUN↔UDP pump with direct path + relay fallback), `main` (CLI: `install`/`up`/`down`/`tunnel`/`status`/`path`/`version`) |
+| `akurai-relay` | Ciphertext-only fallback hub | `forward` (learn src endpoint, forward by dest overlay IP, send `PeerAddr` hints during handshakes, drop unknown; holds no keys, links no crypto crate) |
+| `akurai-control` | Control plane (HTTP) | `ipam` (overlay IP allocation), `peermap` (`/api/peermap` with fresh endpoint candidates), `heartbeat` (`/api/heartbeat`, liveness TTL + node UDP endpoint), `listener` (auth + CSRF + endpoint CRUD) |
 | `akurai-admin` | Admin CLI | users/devices/ACLs/routes (early; data-plane policy NOT YET wired) |
 | `akurai-dns` | Internal/MagicDNS | placeholder — **NOT YET BUILT** (folded into control plane later) |
 
@@ -55,31 +58,34 @@ akurai-control  (separate process: IPAM + peer map + heartbeat over HTTP; uses
 - The **relay** sees only the `Frame` envelope. The **node** is the only place that
   holds session keys and does encryption/decryption.
 
-## The hub-routed MVP1 model
+## Direct Path With Relay Fallback
 
 ```text
- node A (100.88.0.2)                relay (hub)                node B (100.88.0.3)
+ node A (100.88.0.2)              direct UDP               node B (100.88.0.3)
    app → akurai0 TUN                 forward.rs                 akurai0 TUN → app
         │                          (no keys held)                     ▲
         │  UDP: AkurAI Frame                                          │
         │  {Data, src=.2, dest=.3, ciphertext}                        │
-        └───────────────────────────►  learn src endpoint  ──────────┘
-                                       forward by dest IP
+        ├─────────────────────────────────────────────────────────────┘
+        │
+        └──────── relay fallback until direct is confirmed ──────────►
 ```
 
-1. Each node connects a UDP socket to the relay and periodically sends a `Keepalive`
-   frame (every 20 s). The relay learns "overlay IP X is reachable at UDP address Y".
-2. To reach a peer, the node runs a `Noise_IK` handshake **end to end** (the handshake
-   messages travel as relay frames the relay forwards blindly), then encrypts each
-   inner IPv4 packet into a `Data` frame.
-3. The relay forwards each frame to the destination overlay IP's last-known endpoint.
-   It never decrypts; for `Data` frames the payload is ciphertext it cannot read.
-4. **Fail-closed** throughout: a packet to an unknown destination (node side: not in
+1. Each node binds one UDP socket and sends `Keepalive` frames to the relay so the
+   relay can forward fallback traffic.
+2. The node also heartbeats its current local UDP endpoint to the control plane.
+   `/api/peermap` returns fresh same-user peers with overlay IP, public key,
+   liveness, and the best direct endpoint candidate.
+3. To reach a peer, the node runs a `Noise_IK` handshake **end to end**. It tries a
+   direct candidate first when one is known and keeps relay fallback available. The
+   relay additionally sends `PeerAddr` hints during handshakes with the addresses it
+   observes.
+4. A direct address is only promoted after a valid frame arrives from a configured
+   peer. Once confirmed, `Data` frames go peer-to-peer; if no direct path forms, the
+   relay forwards ciphertext by destination overlay IP.
+5. **Fail-closed** throughout: a packet to an unknown destination (node side: not in
    the peer table; relay side: endpoint not yet learned) is dropped, never buffered.
 
-There is no direct node-to-node path and no NAT traversal in MVP1 — all traffic
-transits the hub.
-
 ## What is deployed live
 
 - **Control plane:** `vpn.olibuijr.com` (control listener on port **8104**) — OIDC login
@@ -99,12 +105,14 @@ transits the hub.
 | **MVP0** | Planning, threat model, protocol decision (Noise-over-UDP, hand-rolled), DNS/deploy plan | **DONE** |
 | **MVP1** | Single-hub internal VPN: enrollment + static/served peer map, TUN creation, end-to-end-encrypted node↔node tunnel via the relay, overlay IP allocation, netns e2e proof | **DONE** |
 | **MVP2** | Gateway routes: subnet advertisement, admin route approval, route push to clients, exit-gateway opt-in, basic DNS names | NOT YET BUILT |
-| **MVP3** | Direct peer mesh: endpoint discovery, direct UDP attempts, NAT traversal, relay fallback, path-health scoring, roaming | NOT YET BUILT |
+| **MVP3** | Direct peer mesh: endpoint discovery, direct UDP attempts, NAT traversal, relay fallback, path-health scoring, roaming | **PARTIAL/DONE** for LAN/cone-NAT direct paths + symmetric-NAT relay fallback; advanced scoring remains future work |
 | **MVP4** | Public ingress: HTTPS ingress on the control host to internal services, TLS automation, identity-aware access | NOT YET BUILT |
 
 ### Explicitly not yet built
 
-- **Direct mesh / NAT traversal** — MVP3. Every packet currently transits the relay.
+- **Advanced path scoring / ICE-style NAT traversal** — current direct paths use
+  heartbeat endpoint candidates plus relay `PeerAddr` hints, with symmetric-NAT
+  relay fallback; richer scoring and candidate sets are future work.
 - **Subnet / exit / gateway routes** — MVP2. The node installs an overlay-only route
   and *never* a default route.
 - **MagicDNS / internal DNS** (`akurai-dns`) — placeholder crate.
diff --git a/docs/networking.md b/docs/networking.md
index d393eed..8877f2e 100644
--- a/docs/networking.md
+++ b/docs/networking.md
@@ -70,16 +70,18 @@ gateway advertisement (those are MVP2, **NOT YET BUILT**).
    ├─ dest = packet bytes [16..20]
    ├─ peer = peers.get(dest)           ── unknown dest → DROP (fail-closed)
    ├─ if session established:  ct = session.encrypt(packet)
-   │                           send Frame{Data, src=self, dest, ct} to relay
+   │                           send Frame{Data, src=self, dest, ct}
+   │                           to confirmed direct path, else relay
    └─ else (first contact):    noise::initiate → send Frame{HandshakeInit,…}
+                               to direct candidate first + relay fallback
                                drop this packet (TCP/ICMP retransmits later)
    │
-   ▼  UDP socket (connected to the relay; src endpoint kept fresh by a
-   │             Keepalive frame every 20 s)
+   ▼  UDP socket (bound once; `send_to` relay or direct peer endpoint)
    ▼
  akurai-relay (forward.rs): decode envelope → learn src endpoint →
                             forward datagram to dest overlay IP's learned UDP endpoint
                             (unknown dest → DROP). Never decrypts; holds no keys.
+                            On handshakes, send PeerAddr hints to both ends.
    │
    ▼  UDP at peer
    ▼  udp_pump (peer's akurai-node/src/tunnel.rs)
@@ -88,26 +90,33 @@ gateway advertisement (those are MVP2, **NOT YET BUILT**).
    │                  Frame{HandshakeResp,…}
    ├─ HandshakeResp → noise::finalize the pending handshake → session established
    ├─ Data          → session.decrypt(payload) → write inner IPv4 packet to akurai0
-   └─ Keepalive     → ignored (only the relay learns from these)
+   ├─ PeerAddr      → remember/probe candidate endpoint for a direct path
+   └─ Keepalive     → direct-path confirmation handled before dispatch;
+                      relay keepalives otherwise only teach the relay
    │
    ▼
  akurai0 TUN → local app on the peer
 ```
 
 The two pumps (`tun_pump` on the main thread, `udp_pump` on a second thread) share a
-per-peer `SessionTable` (`established` + `pending` handshakes) under a `Mutex`. A third
-thread sends the 20-second keepalive. The node binds `0.0.0.0:0` and `connect()`s the
-relay, so all node↔relay traffic is one UDP flow. End to end, the relay only ever
-forwards ciphertext.
+per-peer `PeerState` (`session`, `pending`, `direct`, `candidate`) under a `Mutex`. A
+third thread sends keepalives to the relay and to direct candidates/paths. The node
+binds `0.0.0.0:0`; while running it reports the bound UDP port plus the local source
+address used to reach the relay in `/api/heartbeat`. `/api/peermap` returns that fresh
+endpoint to same-user peers, which lets two devices on the same LAN probe each other
+directly instead of keeping traffic on the central relay path. End to end, the relay
+only ever forwards ciphertext.
 
 ### Node sources of the peer table (`akurai-node/src/peers.rs`)
 
-- Static file `config/peers`: one `<overlay_ip> <pubkey_b64> [name]` per line; blank
+- Static file `config/peers`: one
+  `<overlay_ip> <pubkey_b64> [name] [advertised] [tags] [endpoint]` per line; blank
   lines and `#` comments ignored; unparseable lines skipped (used for tests and offline
   bring-up).
 - Control plane `GET /api/peermap` via `curl` with the saved cookie jar
   (`--control <url>`); pure-`std` has no TLS client, and the node already shells `ip`,
   so `curl` is consistent. JSON is parsed by a hand-rolled, dependency-free reader.
+  Peer-map entries include an optional `endpoint` direct-path candidate.
 - Fail-closed: an entry that does not parse is skipped; a packet to a destination not in
   the table is dropped.
 
@@ -155,6 +164,12 @@ cargo build -p akurai-node -p akurai-relay
 
 # End-to-end overlay proof (needs root for netns + TUN; touches no host interface):
 sudo tests/netns/e2e.sh
+
+# Direct path proof on one LAN:
+sudo tests/netns/direct.sh
+
+# Symmetric NAT proof: direct path does not form, relay fallback still works:
+sudo tests/netns/symmetric.sh
 ```
 
 A clean run prints `OVERLAY_PING: PASS`, `CIPHERTEXT_CHECK: PASS`, and
@@ -162,7 +177,8 @@ A clean run prints `OVERLAY_PING: PASS`, `CIPHERTEXT_CHECK: PASS`, and
 
 ## Not yet built
 
-- **Direct peer mesh / NAT traversal** (MVP3) — every packet transits the relay today.
+- **Advanced path scoring / ICE-style NAT traversal** — LAN/cone-NAT direct paths and
+  symmetric-NAT relay fallback exist; richer candidate scoring remains future work.
 - **Subnet / exit / gateway routes** (MVP2) — the node installs the overlay-only route
   and never a default route.
 - **MagicDNS / internal DNS names** (`akurai-dns`) — placeholder.
diff --git a/docs/protocol.md b/docs/protocol.md
index 12a13bc..6385b36 100644
--- a/docs/protocol.md
+++ b/docs/protocol.md
@@ -42,7 +42,8 @@ Constants: `HEADER_LEN = 14`, `MAX_PAYLOAD = 2048`.
 | 1 | `Keepalive` | Node→relay liveness; teaches the relay this `src` overlay IP's UDP endpoint. Payload empty. **Not forwarded.** |
 | 2 | `HandshakeInit` | Noise_IK message 1 (initiator → responder), payload-opaque (96 B) |
 | 3 | `HandshakeResp` | Noise_IK message 2 (responder → initiator), payload-opaque (48 B) |
-| 4 | `Data` | An end-to-end-encrypted inner IPv4 packet (ciphertext to the relay) |
+| 4 | `Data` | An end-to-end-encrypted inner IPv4 packet (ciphertext to the relay or direct peer path) |
+| 5 | `PeerAddr` | Relay→node direct-path hint. Payload is IPv4 socket address: 4 octets + 2-byte big-endian UDP port. |
 
 `Frame::encode()` lays the bytes out exactly as above. `Frame::decode()` is
 **fail-closed** and never panics — it returns `None` on any of: buffer shorter than
@@ -172,7 +173,7 @@ A 64-entry sliding window (`WINDOW = 64`) over `recv_max` / `recv_mask`:
 window, or a packet shorter than 8 bytes. In-window reordering is accepted; each packet
 decrypts exactly once.
 
-## Endpoint learning (relay)
+## Endpoint Learning And Direct Candidates
 
 The relay keeps `HashMap<overlay_ip, UDP_endpoint>`. For every received frame whose
 `src` is not `0.0.0.0`, it records "this `src` is reachable at the UDP address the
@@ -182,12 +183,27 @@ or data frame. `Keepalive` frames only teach the relay and are not forwarded. A
 (`dropped_unknown`); the relay never buffers unbounded state. Counters tracked:
 `learned`, `forwarded`, `dropped_unknown`, `malformed`.
 
+During `HandshakeInit` / `HandshakeResp` forwarding, the relay sends each side a
+`PeerAddr` hint containing the other side's relay-observed IPv4 UDP endpoint. Nodes
+store the hint as an unconfirmed candidate and probe it with keepalives. Separately,
+while the tunnel is running, each node heartbeats the local UDP endpoint it would use
+to reach the relay; `/api/peermap` surfaces that fresh endpoint to the user's other
+nodes. This is what lets same-LAN devices try their private LAN path even though the
+central relay only observes their public/NAT path.
+
+A candidate is never trusted just because the control plane or relay supplied it. The
+node only promotes a direct path after a valid frame arrives from a configured peer
+overlay IP; until then, encrypted traffic keeps relay fallback.
+
 ## Fail-closed rules (summary)
 
 - **Frame decode:** any malformed envelope → dropped (`Frame::decode` returns `None`).
 - **Node, outbound:** a TUN packet whose destination overlay IP is not in the peer table
   is dropped. First contact triggers a handshake and the triggering packet is dropped
   (upper-layer TCP/ICMP retransmits once a session exists).
+- **Node, direct candidates:** a heartbeat or `PeerAddr` endpoint is only a probe
+  target. It becomes the data path only after a frame from the configured peer arrives
+  and the normal peer/static-key checks pass.
 - **Node, inbound `HandshakeInit`:** the responder accepts only if the Noise-recovered
   initiator static key matches the peer-table entry for `frame.src`
   (`p.public_key == init_pub`); otherwise the handshake is dropped — no session, no