Menu
Commit 67ca9028
67ca9028c2fad783b70c10cd9fac111ce5b10de1 by Claude Code
- CHANGELOG.md +14 -1
- Cargo.lock +10 -10
- Cargo.toml +1 -1
- HANDOFF.md +11 -1
- VERSION +1 -1
- crates/akurai-common/src/frame.rs +1 -0
- crates/akurai-control/src/heartbeat.rs +1 -4
- crates/akurai-control/src/listener.rs +21 -10
- crates/akurai-control/src/peermap.rs +60 -9
- crates/akurai-node/src/acl.rs +1 -0
- crates/akurai-node/src/heartbeat.rs +62 -6
- crates/akurai-node/src/main.rs +25 -23
- crates/akurai-node/src/peers.rs +55 -7
- crates/akurai-node/src/tunnel.rs +100 -12
- docs/architecture.md +35 -27
- docs/networking.md +26 -10
- docs/protocol.md +18 -2
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