From 92906484786285b3ec0b1f289040d247cc984629 Mon Sep 17 00:00:00 2001 From: UYUYANMARUL Date: Sat, 4 Apr 2026 20:08:11 +0200 Subject: [PATCH 1/9] test ws --- .gitignore | 1 + Cargo.lock | 100 +++++++++++-- boringtun-cli/src/main.rs | 34 +++++ boringtun/Cargo.toml | 3 +- boringtun/src/device/mod.rs | 2 + boringtun/src/device/ws_proxy.rs | 233 +++++++++++++++++++++++++++++++ scripts/README.md | 41 ++++++ scripts/gen-keys.sh | 34 +++++ scripts/keys/client.key | 1 + scripts/keys/client.pub | 1 + scripts/keys/server.key | 1 + scripts/keys/server.pub | 1 + scripts/package-lock.json | 37 +++++ scripts/package.json | 16 +++ scripts/test-client.sh | 115 +++++++++++++++ scripts/test-server.sh | 173 +++++++++++++++++++++++ scripts/ws-bridge.js | 144 +++++++++++++++++++ 17 files changed, 925 insertions(+), 12 deletions(-) create mode 100644 boringtun/src/device/ws_proxy.rs create mode 100644 scripts/README.md create mode 100755 scripts/gen-keys.sh create mode 100644 scripts/keys/client.key create mode 100644 scripts/keys/client.pub create mode 100644 scripts/keys/server.key create mode 100644 scripts/keys/server.pub create mode 100644 scripts/package-lock.json create mode 100644 scripts/package.json create mode 100755 scripts/test-client.sh create mode 100755 scripts/test-server.sh create mode 100644 scripts/ws-bridge.js diff --git a/.gitignore b/.gitignore index b4e53ce..2c6acc6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ /target **/*.rs.bk .vscode/ +*/node_modules diff --git a/Cargo.lock b/Cargo.lock index 49f7ce7..a12a29e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -152,6 +152,7 @@ dependencies = [ "tiny_http", "tracing", "tracing-subscriber", + "tungstenite", "untrusted", "x25519-dalek", ] @@ -180,6 +181,12 @@ version = "3.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d261e256854913907f67ed06efbc3338dfe6179796deefc1ff763fc1aee5535" +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "bytes" version = "1.1.0" @@ -500,6 +507,12 @@ dependencies = [ "libc", ] +[[package]] +name = "data-encoding" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" + [[package]] name = "der" version = "0.7.10" @@ -512,9 +525,9 @@ dependencies = [ [[package]] name = "deranged" -version = "0.5.8" +version = "0.3.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4" dependencies = [ "powerfmt", ] @@ -1044,9 +1057,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.2.1" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" [[package]] name = "num-traits" @@ -1211,6 +1224,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -1229,6 +1251,27 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + [[package]] name = "rand_core" version = "0.6.4" @@ -1500,6 +1543,17 @@ dependencies = [ "serde", ] +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sha2" version = "0.10.9" @@ -1686,30 +1740,30 @@ dependencies = [ [[package]] name = "time" -version = "0.3.47" +version = "0.3.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +checksum = "5dfd88e563464686c916c7e46e623e520ddc6d79fa6641390f2e3fa86e83e885" dependencies = [ "deranged", "itoa", "num-conv", "powerfmt", - "serde_core", + "serde", "time-core", "time-macros", ] [[package]] name = "time-core" -version = "0.1.8" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" +checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" [[package]] name = "time-macros" -version = "0.2.27" +version = "0.2.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +checksum = "3f252a68540fde3a3877aeea552b832b40ab9a69e318efd078774a01ddee1ccf" dependencies = [ "num-conv", "time-core", @@ -1883,6 +1937,24 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand", + "sha1", + "thiserror", + "utf-8", +] + [[package]] name = "typenum" version = "1.15.0" @@ -1943,6 +2015,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + [[package]] name = "valuable" version = "0.1.0" diff --git a/boringtun-cli/src/main.rs b/boringtun-cli/src/main.rs index 37439f2..208c4dc 100644 --- a/boringtun-cli/src/main.rs +++ b/boringtun-cli/src/main.rs @@ -92,6 +92,12 @@ fn main() { Arg::new("disable-connected-udp") .long("disable-connected-udp") .help("Disable connected UDP sockets to each peer"), + Arg::new("ws-bind") + .long("ws-bind") + .takes_value(true) + .env("BT_WS_BIND") + .help("WebSocket proxy bind address (e.g. 0.0.0.0:8443)") + .default_value(""), #[cfg(target_os = "linux")] Arg::new("disable-multi-queue") .long("disable-multi-queue") @@ -252,6 +258,34 @@ fn main() { .expect("Failed to spawn reaper thread"); tracing::info!("Registration API on {}", bind_addr); + + // WebSocket proxy thread + let ws_bind = std::env::var("BT_WS_BIND") + .unwrap_or_else(|_| matches.value_of("ws-bind").unwrap_or("").to_string()); + if !ws_bind.is_empty() { + let wg_port: u16 = std::env::var("BT_WG_PORT") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(51820); + let shutdown_ws = Arc::clone(&shutdown_flag); + let ws_bind_log = ws_bind.clone(); + std::thread::Builder::new() + .name("ws-proxy".into()) + .spawn(move || { + if let Err(e) = std::panic::catch_unwind(|| { + boringtun::device::ws_proxy::run_ws_proxy( + &ws_bind, + wg_port, + shutdown_ws, + ); + }) { + tracing::error!("WS proxy thread panicked: {:?}", e); + } + }) + .expect("Failed to spawn WS proxy thread"); + + tracing::info!("WebSocket proxy on {}", ws_bind_log); + } } } diff --git a/boringtun/Cargo.toml b/boringtun/Cargo.toml index e7792d1..dc48e4e 100644 --- a/boringtun/Cargo.toml +++ b/boringtun/Cargo.toml @@ -16,7 +16,7 @@ edition = "2018" [features] default = [] device = ["socket2", "thiserror"] -payment = ["device", "k256", "hkdf", "sha2", "sha3", "reqwest", "serde", "serde_json", "tiny_http"] +payment = ["device", "k256", "hkdf", "sha2", "sha3", "reqwest", "serde", "serde_json", "tiny_http", "tungstenite"] jni-bindings = ["ffi-bindings", "jni"] ffi-bindings = ["tracing-subscriber"] # mocks std::time::Instant with mock_instant @@ -54,6 +54,7 @@ reqwest = { version = "0.12", default-features = false, features = ["json", "rus serde = { version = "1", features = ["derive"], optional = true } serde_json = { version = "1", optional = true } tiny_http = { version = "0.12", optional = true } +tungstenite = { version = "0.24", optional = true } [target.'cfg(unix)'.dependencies] nix = { version = "0.25", default-features = false, features = [ diff --git a/boringtun/src/device/mod.rs b/boringtun/src/device/mod.rs index 64c0702..4b852c7 100644 --- a/boringtun/src/device/mod.rs +++ b/boringtun/src/device/mod.rs @@ -5,6 +5,8 @@ pub mod allowed_ips; pub mod api; #[cfg(feature = "payment")] pub mod http_api; +#[cfg(feature = "payment")] +pub mod ws_proxy; mod dev_lock; pub mod drop_privileges; #[cfg(test)] diff --git a/boringtun/src/device/ws_proxy.rs b/boringtun/src/device/ws_proxy.rs new file mode 100644 index 0000000..b98d1f8 --- /dev/null +++ b/boringtun/src/device/ws_proxy.rs @@ -0,0 +1,233 @@ +use std::net::{TcpListener, TcpStream, UdpSocket, SocketAddr}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +use tungstenite::protocol::Message; +use tungstenite::accept; + +const WS_READ_TIMEOUT_MS: u64 = 100; +const UDP_READ_TIMEOUT_MS: u64 = 50; +const MAX_WG_PACKET: usize = 1500; + +/// Run the WebSocket-to-WireGuard proxy server. +/// +/// Each incoming WebSocket connection gets a dedicated UDP socket bound to an +/// ephemeral port. WireGuard packets are relayed bidirectionally: +/// Client ↔ WebSocket ↔ UDP ↔ WireGuard (127.0.0.1:{wg_port}) +/// +/// This allows TEE nodes to expose only HTTP+WS ports (no raw UDP). +pub fn run_ws_proxy( + bind_addr: &str, + wg_port: u16, + shutdown_flag: Arc, +) { + let listener = match TcpListener::bind(bind_addr) { + Ok(l) => l, + Err(e) => { + tracing::error!("WS proxy: failed to bind on {}: {}", bind_addr, e); + return; + } + }; + + // Set a timeout on accept so we periodically check shutdown + set_tcp_listener_timeout(&listener, Duration::from_secs(2)); + + tracing::info!("WS proxy listening on {}", bind_addr); + + let wg_addr: SocketAddr = format!("127.0.0.1:{}", wg_port).parse().unwrap(); + + while !shutdown_flag.load(Ordering::Relaxed) { + let stream = match listener.accept() { + Ok((stream, peer_addr)) => { + tracing::info!("WS proxy: new connection from {}", peer_addr); + stream + } + Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock + || e.kind() == std::io::ErrorKind::TimedOut => { + continue; + } + Err(e) => { + tracing::warn!("WS proxy: accept error: {}", e); + continue; + } + }; + + let shutdown = Arc::clone(&shutdown_flag); + std::thread::Builder::new() + .name("ws-client".into()) + .spawn(move || { + handle_ws_client(stream, wg_addr, shutdown); + }) + .ok(); + } + + tracing::info!("WS proxy shutting down"); +} + +fn handle_ws_client( + stream: TcpStream, + wg_addr: SocketAddr, + shutdown_flag: Arc, +) { + let peer_addr = stream.peer_addr().ok(); + + // Set TCP timeouts for the WebSocket handshake and reads + stream.set_read_timeout(Some(Duration::from_millis(WS_READ_TIMEOUT_MS))).ok(); + stream.set_write_timeout(Some(Duration::from_secs(5))).ok(); + + let mut ws = match accept(stream) { + Ok(ws) => ws, + Err(e) => { + tracing::warn!("WS proxy: handshake failed from {:?}: {}", peer_addr, e); + return; + } + }; + + // Create an UNCONNECTED UDP socket to talk to the local WireGuard instance. + // We use send_to/recv_from instead of connect/send/recv because boringtun + // may respond from a different port (connected UDP sockets per peer). + let udp = match UdpSocket::bind("127.0.0.1:0") { + Ok(s) => s, + Err(e) => { + tracing::error!("WS proxy: failed to bind UDP socket: {}", e); + let _ = ws.close(None); + return; + } + }; + udp.set_read_timeout(Some(Duration::from_millis(UDP_READ_TIMEOUT_MS))).ok(); + + let local_udp = udp.local_addr().unwrap(); + tracing::info!( + "WS proxy: session started for {:?}, UDP {} → {}", + peer_addr, + local_udp, + wg_addr + ); + + let mut udp_buf = [0u8; MAX_WG_PACKET]; + let mut ws_to_udp_count: u64 = 0; + let mut udp_to_ws_count: u64 = 0; + + loop { + if shutdown_flag.load(Ordering::Relaxed) { + tracing::info!("WS proxy [{:?}]: shutdown flag set, exiting", peer_addr); + break; + } + + // 1. Read from WebSocket → send to WireGuard UDP + match ws.read() { + Ok(Message::Binary(data)) => { + ws_to_udp_count += 1; + tracing::info!( + "WS proxy [{:?}]: WS→UDP #{} | {} bytes | sending to {}", + peer_addr, ws_to_udp_count, data.len(), wg_addr + ); + match udp.send_to(&data, wg_addr) { + Ok(sent) => { + tracing::info!( + "WS proxy [{:?}]: WS→UDP #{} | sent {} bytes to {}", + peer_addr, ws_to_udp_count, sent, wg_addr + ); + } + Err(e) => { + tracing::warn!( + "WS proxy [{:?}]: WS→UDP #{} | send error: {}", + peer_addr, ws_to_udp_count, e + ); + break; + } + } + } + Ok(Message::Close(frame)) => { + tracing::info!("WS proxy [{:?}]: received Close frame: {:?}", peer_addr, frame); + break; + } + Ok(Message::Ping(payload)) => { + tracing::debug!("WS proxy [{:?}]: received Ping ({} bytes)", peer_addr, payload.len()); + let _ = ws.send(Message::Pong(payload)); + } + Ok(Message::Text(text)) => { + tracing::info!("WS proxy [{:?}]: received unexpected Text: {}", peer_addr, text); + } + Ok(msg) => { + tracing::info!("WS proxy [{:?}]: received other message type: {:?}", peer_addr, msg); + } + Err(tungstenite::Error::Io(ref e)) + if e.kind() == std::io::ErrorKind::WouldBlock + || e.kind() == std::io::ErrorKind::TimedOut => + { + // Timeout — fall through to check UDP + } + Err(e) => { + tracing::info!("WS proxy [{:?}]: WS read error: {}", peer_addr, e); + break; + } + } + + // 2. Read from WireGuard UDP → send to WebSocket + // Accept packets from ANY source (boringtun may use connected sockets + // on different ports for the data fast-path). + match udp.recv_from(&mut udp_buf) { + Ok((n, from)) => { + udp_to_ws_count += 1; + tracing::info!( + "WS proxy [{:?}]: UDP→WS #{} | {} bytes from {} | forwarding to WS", + peer_addr, udp_to_ws_count, n, from + ); + match ws.send(Message::Binary(udp_buf[..n].to_vec().into())) { + Ok(_) => { + tracing::info!( + "WS proxy [{:?}]: UDP→WS #{} | sent {} bytes to WS", + peer_addr, udp_to_ws_count, n + ); + } + Err(e) => { + tracing::warn!( + "WS proxy [{:?}]: UDP→WS #{} | WS send error: {}", + peer_addr, udp_to_ws_count, e + ); + break; + } + } + } + Err(ref e) + if e.kind() == std::io::ErrorKind::WouldBlock + || e.kind() == std::io::ErrorKind::TimedOut => + { + // No data from WG yet, loop back + } + Err(e) => { + tracing::warn!("WS proxy [{:?}]: UDP recv error: {}", peer_addr, e); + break; + } + } + } + + tracing::info!( + "WS proxy [{:?}]: session stats | WS→UDP: {} packets | UDP→WS: {} packets", + peer_addr, ws_to_udp_count, udp_to_ws_count + ); + + let _ = ws.close(None); + tracing::info!("WS proxy: session ended for {:?}", peer_addr); +} + +#[cfg(unix)] +fn set_tcp_listener_timeout(listener: &TcpListener, timeout: Duration) { + use std::os::unix::io::AsRawFd; + let fd = listener.as_raw_fd(); + let tv = libc::timeval { + tv_sec: timeout.as_secs() as _, + tv_usec: timeout.subsec_micros() as _, + }; + unsafe { + libc::setsockopt( + fd, + libc::SOL_SOCKET, + libc::SO_RCVTIMEO, + &tv as *const _ as *const libc::c_void, + std::mem::size_of::() as u32, + ); + } +} diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000..5b44084 --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,41 @@ +# Local Test Scripts + +Test boringtun server + client with WebSocket proxy on macOS. + +## Prerequisites + +```bash +brew install socat wireguard-tools +cargo build --features payment +``` + +## Quick Start + +```bash +# Terminal 1: Generate keys +./scripts/gen-keys.sh + +# Terminal 2: Start server (needs sudo for TUN) +sudo ./scripts/test-server.sh + +# Terminal 3: Start client (needs sudo for TUN) +sudo ./scripts/test-client.sh + +# Terminal 3: Test +ping 10.0.0.1 +``` + +## What happens + +``` +┌─────────────┐ UDP ┌────────────┐ WS ┌────────────┐ UDP ┌─────────────┐ +│ boringtun │ ──────────> │ ws-bridge │ ──────────> │ ws_proxy │ ──────────> │ boringtun │ +│ client │ <────────── │ (node) │ <────────── │ (server) │ <────────── │ server │ +│ 10.0.0.x │ :51821 │ │ :8443 │ │ 127.0.0.1 │ 10.0.0.1 │ +└─────────────┘ └────────────┘ └────────────┘ :51820 └─────────────┘ +``` + +- **Server**: boringtun + HTTP API (:8080) + WS proxy (:8443) +- **Client**: boringtun + ws-bridge (local UDP :51821 → server WS :8443) +- Client registers via `POST /v1/register` to get assigned IP + server pubkey +- All WireGuard traffic flows through WebSocket (no direct UDP exposure) diff --git a/scripts/gen-keys.sh b/scripts/gen-keys.sh new file mode 100755 index 0000000..ed02448 --- /dev/null +++ b/scripts/gen-keys.sh @@ -0,0 +1,34 @@ +#!/bin/bash +# Generate WireGuard keypairs for server and client testing. +# Writes to scripts/keys/ directory. + +set -e +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +KEYS_DIR="$SCRIPT_DIR/keys" +mkdir -p "$KEYS_DIR" + +gen_keypair() { + local name=$1 + if command -v wg &>/dev/null; then + wg genkey > "$KEYS_DIR/${name}.key" + wg pubkey < "$KEYS_DIR/${name}.key" > "$KEYS_DIR/${name}.pub" + else + openssl genpkey -algorithm x25519 -outform DER 2>/dev/null | tail -c 32 | base64 > "$KEYS_DIR/${name}.key" + echo "(public key requires 'wg' tool)" > "$KEYS_DIR/${name}.pub" + fi + echo "Generated $name keypair:" + echo " Private: $(cat "$KEYS_DIR/${name}.key")" + echo " Public: $(cat "$KEYS_DIR/${name}.pub")" +} + +gen_keypair "server" +gen_keypair "client" + +echo "" +echo "Keys saved to $KEYS_DIR/" +echo "" +echo "Export for test scripts:" +echo " export SERVER_PRIVKEY=$(cat "$KEYS_DIR/server.key")" +echo " export SERVER_PUBKEY=$(cat "$KEYS_DIR/server.pub")" +echo " export CLIENT_PRIVKEY=$(cat "$KEYS_DIR/client.key")" +echo " export CLIENT_PUBKEY=$(cat "$KEYS_DIR/client.pub")" diff --git a/scripts/keys/client.key b/scripts/keys/client.key new file mode 100644 index 0000000..d5d3836 --- /dev/null +++ b/scripts/keys/client.key @@ -0,0 +1 @@ +sFXVIR9WwYJUkBUj8WlgyyyFOKiiwzWP/aNeqCCUUEw= diff --git a/scripts/keys/client.pub b/scripts/keys/client.pub new file mode 100644 index 0000000..f8d55a8 --- /dev/null +++ b/scripts/keys/client.pub @@ -0,0 +1 @@ +YuTbqQyv+idWANm+roj28a8GFpyOYxsJVVZ+b6ogeVA= diff --git a/scripts/keys/server.key b/scripts/keys/server.key new file mode 100644 index 0000000..f02de53 --- /dev/null +++ b/scripts/keys/server.key @@ -0,0 +1 @@ +oKFmpjmnTiebqXpZNZANqiYCxKfFE2w0BJMYg5uLT34= diff --git a/scripts/keys/server.pub b/scripts/keys/server.pub new file mode 100644 index 0000000..8a7381a --- /dev/null +++ b/scripts/keys/server.pub @@ -0,0 +1 @@ +BSn6bBRNzkG+QzmC2YA9uVytzCIIHa5ws9fvlkzlBis= diff --git a/scripts/package-lock.json b/scripts/package-lock.json new file mode 100644 index 0000000..ead0f70 --- /dev/null +++ b/scripts/package-lock.json @@ -0,0 +1,37 @@ +{ + "name": "scripts", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "scripts", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "ws": "^8.20.0" + } + }, + "node_modules/ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/scripts/package.json b/scripts/package.json new file mode 100644 index 0000000..b7320f3 --- /dev/null +++ b/scripts/package.json @@ -0,0 +1,16 @@ +{ + "name": "scripts", + "version": "1.0.0", + "description": "Test boringtun server + client with WebSocket proxy on macOS.", + "main": "ws-bridge.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "", + "license": "ISC", + "type": "commonjs", + "dependencies": { + "ws": "^8.20.0" + } +} diff --git a/scripts/test-client.sh b/scripts/test-client.sh new file mode 100755 index 0000000..99cb28e --- /dev/null +++ b/scripts/test-client.sh @@ -0,0 +1,115 @@ +#!/bin/bash +# Start boringtun client locally, connecting to server via WebSocket proxy. +# Run with: sudo ./scripts/test-client.sh +# Full tunnel: sudo FULL_TUNNEL=1 ./scripts/test-client.sh + +set -e +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT_DIR="$(dirname "$SCRIPT_DIR")" +KEYS_DIR="$SCRIPT_DIR/keys" + +SERVER_HOST="${SERVER_HOST:-127.0.0.1}" +SERVER_HTTP_PORT="${SERVER_HTTP_PORT:-8089}" +SERVER_WS_PORT="${SERVER_WS_PORT:-8443}" +LOCAL_UDP_PORT=51821 +FULL_TUNNEL="${FULL_TUNNEL:-0}" + +# --- Keys --- +[ -f "$KEYS_DIR/client.key" ] || { echo "ERROR: Run ./scripts/gen-keys.sh first"; exit 1; } +CLIENT_PRIVKEY_HEX=$(cat "$KEYS_DIR/client.key" | base64 -d | xxd -p -c 64) +CLIENT_PUBKEY=$(cat "$KEYS_DIR/client.pub") +SERVER_PUBKEY_HEX=$(cat "$KEYS_DIR/server.pub" | base64 -d | xxd -p -c 64) + +# --- Register with server --- +echo "[Register] Checking server..." +curl -sf --connect-timeout 3 "http://${SERVER_HOST}:${SERVER_HTTP_PORT}/health" >/dev/null || \ + { echo "ERROR: Server not reachable"; exit 1; } + +REGISTER_RESPONSE=$(curl -sf -X POST "http://${SERVER_HOST}:${SERVER_HTTP_PORT}/v1/register" \ + -H "Content-Type: application/json" \ + -d "{\"public_key\":\"${CLIENT_PUBKEY}\"}") || { echo "ERROR: Registration failed"; exit 1; } + +ASSIGNED_IP=$(echo "$REGISTER_RESPONSE" | grep -o '"assigned_ip":"[^"]*"' | cut -d'"' -f4 | cut -d'/' -f1) +echo "[Register] Assigned IP: $ASSIGNED_IP" + +# --- WS Bridge --- +if [ ! -d "$SCRIPT_DIR/node_modules/ws" ]; then + (cd "$SCRIPT_DIR" && npm init -y --silent 2>/dev/null; npm install ws --silent) +fi +node "$SCRIPT_DIR/ws-bridge.js" "$LOCAL_UDP_PORT" "ws://${SERVER_HOST}:${SERVER_WS_PORT}" & +BRIDGE_PID=$! +sleep 1 + +# --- Boringtun client --- +BT_BIN="$ROOT_DIR/target/debug/boringtun-cli" +[ -f "$ROOT_DIR/target/release/boringtun-cli" ] && BT_BIN="$ROOT_DIR/target/release/boringtun-cli" + +BEFORE_SOCKS=$(ls /var/run/wireguard/utun*.sock 2>/dev/null || true) + +WG_LOG_LEVEL=info WG_SUDO=1 "$BT_BIN" utun --foreground --disable-drop-privileges & +BT_PID=$! +sleep 3 +kill -0 $BT_PID 2>/dev/null || { echo "ERROR: boringtun exited"; kill $BRIDGE_PID; exit 1; } + +# Find new socket +UTUN_NAME="" +for sock in /var/run/wireguard/utun*.sock; do + [ -e "$sock" ] || continue + echo "$BEFORE_SOCKS" | grep -q "$sock" && continue + UTUN_NAME=$(basename "$sock" .sock) + break +done +[ -n "$UTUN_NAME" ] || { echo "ERROR: No UAPI socket found"; kill $BRIDGE_PID $BT_PID; exit 1; } + +echo "[Setup] Interface: $UTUN_NAME" + +# --- Configure WireGuard --- +if [ "$FULL_TUNNEL" = "1" ]; then + ALLOWED_IPS="allowed_ip=0.0.0.0/0" +else + ALLOWED_IPS="allowed_ip=10.0.0.0/24\nallowed_ip=8.8.8.8/32\nallowed_ip=8.8.4.4/32" +fi + +printf "set=1\nprivate_key=%s\npublic_key=%s\nendpoint=127.0.0.1:%s\n%b\npersistent_keepalive_interval=25\n\n" \ + "$CLIENT_PRIVKEY_HEX" "$SERVER_PUBKEY_HEX" "$LOCAL_UDP_PORT" "$ALLOWED_IPS" | \ + socat -t5 - UNIX-CONNECT:"/var/run/wireguard/${UTUN_NAME}.sock" >/dev/null 2>&1 + +# --- Routing --- +ifconfig "$UTUN_NAME" "$ASSIGNED_IP" 10.0.0.1 up + +if [ "$FULL_TUNNEL" = "1" ]; then + echo "[Route] Full tunnel — all traffic through VPN" + route add -net 0.0.0.0/1 -interface "$UTUN_NAME" 2>/dev/null || true + route add -net 128.0.0.0/1 -interface "$UTUN_NAME" 2>/dev/null || true +else + echo "[Route] Split tunnel — VPN subnet + DNS (8.8.8.8, 8.8.4.4)" + route add -host 8.8.8.8 -interface "$UTUN_NAME" 2>/dev/null || true + route add -host 8.8.4.4 -interface "$UTUN_NAME" 2>/dev/null || true +fi + +echo "" +echo "=== Client running ===" +echo "Client IP: $ASSIGNED_IP" +echo "Mode: $([ "$FULL_TUNNEL" = "1" ] && echo "full tunnel" || echo "split tunnel")" +echo "" +echo "Test:" +echo " ping 10.0.0.1" +echo " ping 8.8.8.8" +[ "$FULL_TUNNEL" = "1" ] && echo " curl ifconfig.me" +echo "" + +cleanup() { + echo "[Stop] Cleaning up..." + if [ "$FULL_TUNNEL" = "1" ]; then + route delete -net 0.0.0.0/1 2>/dev/null || true + route delete -net 128.0.0.0/1 2>/dev/null || true + else + route delete -host 8.8.8.8 2>/dev/null || true + route delete -host 8.8.4.4 2>/dev/null || true + fi + kill $BRIDGE_PID $BT_PID 2>/dev/null + wait $BRIDGE_PID $BT_PID 2>/dev/null +} +trap cleanup EXIT INT TERM + +wait $BT_PID diff --git a/scripts/test-server.sh b/scripts/test-server.sh new file mode 100755 index 0000000..f94e895 --- /dev/null +++ b/scripts/test-server.sh @@ -0,0 +1,173 @@ +#!/bin/bash +# Start boringtun server locally with HTTP API + WebSocket proxy. +# Run with: sudo ./scripts/test-server.sh +# +# Prereqs: +# 1. Run ./scripts/gen-keys.sh first +# 2. cargo build --features payment + +set -e +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT_DIR="$(dirname "$SCRIPT_DIR")" +KEYS_DIR="$SCRIPT_DIR/keys" + +if [ ! -f "$KEYS_DIR/server.key" ]; then + echo "ERROR: Run ./scripts/gen-keys.sh first" + exit 1 +fi + +SERVER_PRIVKEY=$(cat "$KEYS_DIR/server.key") +SERVER_PRIVKEY_HEX=$(echo -n "$SERVER_PRIVKEY" | base64 -d | xxd -p -c 64) + +# Check for port conflicts +for PORT in 8089 8443; do + if lsof -i :$PORT -P 2>/dev/null | grep -q LISTEN; then + echo "ERROR: Port $PORT is already in use:" + lsof -i :$PORT -P 2>/dev/null | grep LISTEN + exit 1 + fi +done + +# Kill any stale boringtun and clean up sockets +echo "[Cleanup] Killing stale boringtun processes..." +pkill -f "boringtun-cli utun" 2>/dev/null || true +sleep 1 +rm -f /var/run/wireguard/utun*.sock 2>/dev/null || true + +# Record existing sockets before we start +BEFORE_SOCKS=$(ls /var/run/wireguard/*.sock 2>/dev/null || true) + +echo "=== Boringtun Server (local test) ===" +echo "Server public key: $(cat "$KEYS_DIR/server.pub")" +echo "HTTP API: http://127.0.0.1:8089" +echo "WebSocket proxy: ws://127.0.0.1:8443" +echo "WireGuard UDP: 127.0.0.1:51820" +echo "Subnet: 10.0.0.0/24 (server=10.0.0.1)" +echo "" + +# Build if needed +BT_BIN="$ROOT_DIR/target/debug/boringtun-cli" +[ -f "$ROOT_DIR/target/release/boringtun-cli" ] && BT_BIN="$ROOT_DIR/target/release/boringtun-cli" + +if [ ! -f "$BT_BIN" ]; then + echo "[Build] Compiling boringtun..." + cargo build --manifest-path "$ROOT_DIR/Cargo.toml" --features payment +fi + +# Start boringtun in foreground +echo "[Start] Launching boringtun..." +BT_PAYMENT_SERVER=1 \ +BT_HTTP_BIND="0.0.0.0:8089" \ +BT_PUBLIC_IP="127.0.0.1" \ +BT_WS_BIND="0.0.0.0:8443" \ +BT_WG_PORT=51820 \ +WG_LOG_LEVEL=info \ +WG_SUDO=1 \ + "$BT_BIN" utun --foreground --disable-drop-privileges --disable-connected-udp & + +BT_PID=$! +sleep 3 + +# Verify boringtun is still running +if ! kill -0 $BT_PID 2>/dev/null; then + echo "ERROR: boringtun exited immediately. Check if you're running with sudo." + exit 1 +fi + +# Find the NEW socket (not any stale ones) +UTUN_NAME="" +for sock in /var/run/wireguard/utun*.sock; do + [ -e "$sock" ] || continue + # Skip sockets that existed before we started + if echo "$BEFORE_SOCKS" | grep -q "$sock"; then + continue + fi + UTUN_NAME=$(basename "$sock" .sock) + break +done + +# Fallback: just use the newest socket +if [ -z "$UTUN_NAME" ]; then + NEWEST_SOCK=$(ls -t /var/run/wireguard/utun*.sock 2>/dev/null | head -1) + if [ -n "$NEWEST_SOCK" ]; then + UTUN_NAME=$(basename "$NEWEST_SOCK" .sock) + fi +fi + +if [ -z "$UTUN_NAME" ]; then + echo "ERROR: Could not find UAPI socket. Is boringtun running?" + kill $BT_PID 2>/dev/null + exit 1 +fi + +echo "[Setup] Interface: $UTUN_NAME" +echo "[Setup] UAPI socket: /var/run/wireguard/${UTUN_NAME}.sock" + +# Configure private key and listen port via UAPI +echo "[Setup] Configuring WireGuard via UAPI..." +UAPI_RESULT=$(printf "set=1\nprivate_key=%s\nlisten_port=51820\n\n" "$SERVER_PRIVKEY_HEX" | \ + socat -t5 - UNIX-CONNECT:"/var/run/wireguard/${UTUN_NAME}.sock" 2>&1) +echo "[Setup] UAPI response: $UAPI_RESULT" + +if echo "$UAPI_RESULT" | grep -q "errno=0"; then + echo "[Setup] WireGuard configured successfully" +else + echo "WARNING: UAPI config may have failed. Response: $UAPI_RESULT" +fi + +# Assign IP to the interface (point-to-point) +ifconfig "$UTUN_NAME" 10.0.0.1 10.0.0.2 up 2>/dev/null || \ + echo "WARNING: Could not assign IP (need sudo?)" + +# Route the VPN subnet through the tunnel so replies go back +route add -net 10.0.0.0/24 -interface "$UTUN_NAME" 2>/dev/null || true + +# Enable IP forwarding and NAT for full tunnel support +echo "[Setup] Enabling IP forwarding and NAT..." +sysctl -w net.inet.ip.forwarding=1 >/dev/null 2>&1 || true + +# Find the default internet interface (en0, en1, etc.) +INET_IF=$(route -n get default 2>/dev/null | awk '/interface:/{print $2}') +if [ -n "$INET_IF" ]; then + echo "[Setup] Internet interface: $INET_IF" + # Create pf NAT rule: VPN clients → internet + PF_CONF="/tmp/bt-pf-nat.conf" + cat > "$PF_CONF" < ($INET_IF) +pass all +PFEOF + pfctl -ef "$PF_CONF" 2>/dev/null || echo "WARNING: pfctl NAT setup failed" + echo "[Setup] NAT enabled: 10.0.0.0/24 → $INET_IF" +else + echo "WARNING: Could not detect internet interface for NAT" +fi + +# Verify health endpoint +echo "" +echo "[Verify] Testing health endpoint..." +HEALTH=$(curl -s --connect-timeout 3 http://127.0.0.1:8089/health 2>&1) || true +if [ -n "$HEALTH" ]; then + echo "[Verify] Health: $HEALTH" +else + echo "[Verify] WARNING: Health endpoint not responding" +fi + +echo "" +echo "=== Server running ===" +echo "To register a client peer:" +echo " curl -X POST http://127.0.0.1:8089/v1/register -d '{\"public_key\":\"\"}'" +echo "" +echo "Press Ctrl+C to stop" + +# Cleanup on exit +cleanup() { + echo "" + echo "[Stop] Shutting down..." + pfctl -d 2>/dev/null || true + sysctl -w net.inet.ip.forwarding=0 >/dev/null 2>&1 || true + kill $BT_PID 2>/dev/null + wait $BT_PID 2>/dev/null +} +trap cleanup EXIT INT TERM + +wait $BT_PID diff --git a/scripts/ws-bridge.js b/scripts/ws-bridge.js new file mode 100644 index 0000000..82903e1 --- /dev/null +++ b/scripts/ws-bridge.js @@ -0,0 +1,144 @@ +#!/usr/bin/env node +// Client-side WebSocket-to-UDP bridge. +// +// Boringtun client sends UDP packets to a local port. +// This bridge forwards them over WebSocket to the server's WS proxy, +// and relays responses back over UDP. +// +// Usage: node ws-bridge.js [local_udp_port] [ws_server_url] +// Default: node ws-bridge.js 51821 ws://127.0.0.1:8443 +// +// Requires: npm install ws + +const dgram = require("dgram"); +const WebSocket = require("ws"); + +const LOCAL_PORT = parseInt(process.argv[2] || "51821", 10); +const WS_URL = process.argv[3] || "ws://127.0.0.1:8443"; + +console.log(`[WS Bridge] UDP :${LOCAL_PORT} <-> ${WS_URL}`); + +const udpServer = dgram.createSocket("udp4"); +let clientAddr = null; +let clientPort = null; +let ws = null; +let reconnecting = false; +let udpToWsCount = 0; +let wsToUdpCount = 0; + +function ts() { + return new Date().toISOString(); +} + +function connectWs() { + console.log(`[WS Bridge] [${ts()}] Connecting to ${WS_URL}...`); + ws = new WebSocket(WS_URL); + + ws.on("open", () => { + console.log(`[WS Bridge] [${ts()}] WebSocket CONNECTED to ${WS_URL}`); + reconnecting = false; + }); + + ws.on("message", (data) => { + wsToUdpCount++; + const buf = Buffer.from(data); + console.log( + `[WS Bridge] [${ts()}] WS→UDP #${wsToUdpCount} | ${buf.length} bytes | ` + + `first4=[${buf.slice(0, 4).toString("hex")}] | ` + + `forwarding to ${clientAddr}:${clientPort}` + ); + + if (clientAddr && clientPort) { + udpServer.send(buf, clientPort, clientAddr, (err) => { + if (err) { + console.error(`[WS Bridge] [${ts()}] WS→UDP #${wsToUdpCount} | UDP send error: ${err.message}`); + } else { + console.log(`[WS Bridge] [${ts()}] WS→UDP #${wsToUdpCount} | sent ${buf.length} bytes to ${clientAddr}:${clientPort}`); + } + }); + } else { + console.warn(`[WS Bridge] [${ts()}] WS→UDP #${wsToUdpCount} | DROPPED — no client address known yet`); + } + }); + + ws.on("close", (code, reason) => { + console.log(`[WS Bridge] [${ts()}] WebSocket CLOSED | code=${code} reason=${reason || "(none)"}`); + console.log(`[WS Bridge] [${ts()}] Session stats | UDP→WS: ${udpToWsCount} packets | WS→UDP: ${wsToUdpCount} packets`); + scheduleReconnect(); + }); + + ws.on("error", (err) => { + console.error(`[WS Bridge] [${ts()}] WebSocket ERROR: ${err.message}`); + scheduleReconnect(); + }); + + ws.on("ping", (data) => { + console.log(`[WS Bridge] [${ts()}] WebSocket PING received (${data.length} bytes)`); + }); + + ws.on("pong", (data) => { + console.log(`[WS Bridge] [${ts()}] WebSocket PONG received (${data.length} bytes)`); + }); +} + +function scheduleReconnect() { + if (reconnecting) return; + reconnecting = true; + console.log(`[WS Bridge] [${ts()}] Reconnecting in 2s...`); + setTimeout(connectWs, 2000); +} + +// Client -> Server: forward UDP to WebSocket +udpServer.on("message", (msg, rinfo) => { + clientAddr = rinfo.address; + clientPort = rinfo.port; + udpToWsCount++; + + const wsState = ws ? ["CONNECTING", "OPEN", "CLOSING", "CLOSED"][ws.readyState] : "null"; + console.log( + `[WS Bridge] [${ts()}] UDP→WS #${udpToWsCount} | ${msg.length} bytes from ${rinfo.address}:${rinfo.port} | ` + + `first4=[${msg.slice(0, 4).toString("hex")}] | ` + + `ws=${wsState}` + ); + + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(msg, (err) => { + if (err) { + console.error(`[WS Bridge] [${ts()}] UDP→WS #${udpToWsCount} | WS send error: ${err.message}`); + } else { + console.log(`[WS Bridge] [${ts()}] UDP→WS #${udpToWsCount} | sent ${msg.length} bytes to WS`); + } + }); + } else { + console.warn(`[WS Bridge] [${ts()}] UDP→WS #${udpToWsCount} | DROPPED — WebSocket not open (state=${wsState})`); + } +}); + +udpServer.on("listening", () => { + const addr = udpServer.address(); + console.log(`[WS Bridge] [${ts()}] UDP listening on ${addr.address}:${addr.port}`); + connectWs(); +}); + +udpServer.on("error", (err) => { + console.error(`[WS Bridge] [${ts()}] UDP socket error: ${err.message}`); +}); + +udpServer.bind(LOCAL_PORT, "127.0.0.1"); + +// Periodic stats +setInterval(() => { + const wsState = ws ? ["CONNECTING", "OPEN", "CLOSING", "CLOSED"][ws.readyState] : "null"; + console.log( + `[WS Bridge] [${ts()}] STATS | UDP→WS: ${udpToWsCount} | WS→UDP: ${wsToUdpCount} | ` + + `client=${clientAddr}:${clientPort} | ws=${wsState}` + ); +}, 10000); + +process.on("SIGINT", () => { + console.log(`\n[WS Bridge] [${ts()}] Shutting down...`); + console.log(`[WS Bridge] Final stats | UDP→WS: ${udpToWsCount} | WS→UDP: ${wsToUdpCount}`); + if (ws) ws.close(); + udpServer.close(); + process.exit(0); +}); From ab040418789cbdc111e12c3b1c5823d861a8c4fc Mon Sep 17 00:00:00 2001 From: UYUYANMARUL Date: Sat, 4 Apr 2026 20:41:17 +0200 Subject: [PATCH 2/9] fix --- scripts/test-client.sh | 8 +++- scripts/test-server-linux.sh | 74 ++++++++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 1 deletion(-) create mode 100755 scripts/test-server-linux.sh diff --git a/scripts/test-client.sh b/scripts/test-client.sh index 99cb28e..e61b663 100755 --- a/scripts/test-client.sh +++ b/scripts/test-client.sh @@ -8,7 +8,7 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" ROOT_DIR="$(dirname "$SCRIPT_DIR")" KEYS_DIR="$SCRIPT_DIR/keys" -SERVER_HOST="${SERVER_HOST:-127.0.0.1}" +SERVER_HOST="${SERVER_HOST:-37.27.29.160}" SERVER_HTTP_PORT="${SERVER_HTTP_PORT:-8089}" SERVER_WS_PORT="${SERVER_WS_PORT:-8443}" LOCAL_UDP_PORT=51821 @@ -79,6 +79,11 @@ ifconfig "$UTUN_NAME" "$ASSIGNED_IP" 10.0.0.1 up if [ "$FULL_TUNNEL" = "1" ]; then echo "[Route] Full tunnel — all traffic through VPN" + # Preserve route to server via original gateway to avoid routing loop + ORIG_GW=$(route -n get default 2>/dev/null | awk '/gateway:/{print $2}') + if [ -n "$ORIG_GW" ]; then + route add -host "$SERVER_HOST" "$ORIG_GW" 2>/dev/null || true + fi route add -net 0.0.0.0/1 -interface "$UTUN_NAME" 2>/dev/null || true route add -net 128.0.0.0/1 -interface "$UTUN_NAME" 2>/dev/null || true else @@ -101,6 +106,7 @@ echo "" cleanup() { echo "[Stop] Cleaning up..." if [ "$FULL_TUNNEL" = "1" ]; then + route delete -host "$SERVER_HOST" 2>/dev/null || true route delete -net 0.0.0.0/1 2>/dev/null || true route delete -net 128.0.0.0/1 2>/dev/null || true else diff --git a/scripts/test-server-linux.sh b/scripts/test-server-linux.sh new file mode 100755 index 0000000..0f8e304 --- /dev/null +++ b/scripts/test-server-linux.sh @@ -0,0 +1,74 @@ +#!/bin/bash +# Start boringtun server on a Linux VPS. +# Usage: sudo ./scripts/test-server-linux.sh +# +# Prereqs: +# 1. Run ./scripts/gen-keys.sh first +# 2. cargo build --release -p boringtun-cli --features payment +# 3. socat installed (apt install socat) + +set -e +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT_DIR="$(dirname "$SCRIPT_DIR")" +KEYS_DIR="$SCRIPT_DIR/keys" + +[ -f "$KEYS_DIR/server.key" ] || { echo "ERROR: Run ./scripts/gen-keys.sh first"; exit 1; } + +SERVER_PRIVKEY_HEX=$(base64 -d "$KEYS_DIR/server.key" | xxd -p -c 64) +PUBLIC_IP="${PUBLIC_IP:-37.27.29.160}" + +BT_BIN="$ROOT_DIR/target/release/boringtun" +[ -f "$BT_BIN" ] || BT_BIN="$ROOT_DIR/target/debug/boringtun" +[ -f "$BT_BIN" ] || { echo "ERROR: Build first: cargo build --release -p boringtun-cli --features payment"; exit 1; } + +# Kill stale instances +pkill -f "boringtun.*wg0" 2>/dev/null || true +sleep 1 +rm -f /var/run/wireguard/wg0.sock + +echo "=== Veil VPN Server ===" +echo "Public IP: $PUBLIC_IP" +echo "HTTP API: http://$PUBLIC_IP:8089" +echo "WebSocket: ws://$PUBLIC_IP:8443" +echo "WireGuard: $PUBLIC_IP:51820/udp" +echo "Subnet: 10.0.0.0/24 (server=10.0.0.1)" +echo "" + +# Start boringtun +BT_PAYMENT_SERVER=1 \ +BT_HTTP_BIND="0.0.0.0:8089" \ +BT_PUBLIC_IP="$PUBLIC_IP" \ +BT_WS_BIND="0.0.0.0:8443" \ +BT_WG_PORT=51820 \ +WG_LOG_LEVEL=info \ +WG_SUDO=1 \ + "$BT_BIN" wg0 --foreground --disable-drop-privileges & +BT_PID=$! +sleep 3 +kill -0 $BT_PID 2>/dev/null || { echo "ERROR: boringtun exited"; exit 1; } + +# Configure WireGuard via UAPI +printf "set=1\nprivate_key=%s\nlisten_port=51820\n\n" "$SERVER_PRIVKEY_HEX" | \ + socat -t5 - UNIX-CONNECT:/var/run/wireguard/wg0.sock + +# Interface + routing +ip addr add 10.0.0.1/24 dev wg0 2>/dev/null || true +ip link set wg0 up + +# IP forwarding + NAT +sysctl -w net.ipv4.ip_forward=1 >/dev/null +INET_IF=$(ip route show default | awk '{print $5; exit}') +iptables -t nat -C POSTROUTING -s 10.0.0.0/24 -o "$INET_IF" -j MASQUERADE 2>/dev/null || \ + iptables -t nat -A POSTROUTING -s 10.0.0.0/24 -o "$INET_IF" -j MASQUERADE + +echo "" +echo "=== Server running (Ctrl+C to stop) ===" + +cleanup() { + echo "[Stop] Shutting down..." + kill $BT_PID 2>/dev/null; wait $BT_PID 2>/dev/null + iptables -t nat -D POSTROUTING -s 10.0.0.0/24 -o "$INET_IF" -j MASQUERADE 2>/dev/null || true + sysctl -w net.ipv4.ip_forward=0 >/dev/null 2>&1 || true +} +trap cleanup EXIT INT TERM +wait $BT_PID From a130f58f024787323a9fcf1458f69707acb4e979 Mon Sep 17 00:00:00 2001 From: UYUYANMARUL Date: Sat, 4 Apr 2026 20:46:44 +0200 Subject: [PATCH 3/9] fix --- scripts/test-server-linux.sh | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/scripts/test-server-linux.sh b/scripts/test-server-linux.sh index 0f8e304..231455d 100755 --- a/scripts/test-server-linux.sh +++ b/scripts/test-server-linux.sh @@ -61,8 +61,18 @@ INET_IF=$(ip route show default | awk '{print $5; exit}') iptables -t nat -C POSTROUTING -s 10.0.0.0/24 -o "$INET_IF" -j MASQUERADE 2>/dev/null || \ iptables -t nat -A POSTROUTING -s 10.0.0.0/24 -o "$INET_IF" -j MASQUERADE +# Verify echo "" -echo "=== Server running (Ctrl+C to stop) ===" +echo "[Verify] Testing health endpoint..." +HEALTH=$(curl -s --connect-timeout 3 "http://127.0.0.1:8089/health" 2>&1) || true +[ -n "$HEALTH" ] && echo "[Verify] Health: $HEALTH" || echo "[Verify] WARNING: Health endpoint not responding" + +echo "" +echo "=== Server running ===" +echo "To register a client:" +echo " curl -X POST http://$PUBLIC_IP:8089/v1/register -d '{\"public_key\":\"\"}'" +echo "" +echo "Press Ctrl+C to stop" cleanup() { echo "[Stop] Shutting down..." From f94944be9d6986f804804c34b21d9974c1bf469a Mon Sep 17 00:00:00 2001 From: UYUYANMARUL Date: Sat, 4 Apr 2026 20:49:27 +0200 Subject: [PATCH 4/9] fix --- scripts/test-server-linux.sh | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/scripts/test-server-linux.sh b/scripts/test-server-linux.sh index 231455d..6d283b8 100755 --- a/scripts/test-server-linux.sh +++ b/scripts/test-server-linux.sh @@ -48,18 +48,29 @@ sleep 3 kill -0 $BT_PID 2>/dev/null || { echo "ERROR: boringtun exited"; exit 1; } # Configure WireGuard via UAPI -printf "set=1\nprivate_key=%s\nlisten_port=51820\n\n" "$SERVER_PRIVKEY_HEX" | \ - socat -t5 - UNIX-CONNECT:/var/run/wireguard/wg0.sock +echo "[Setup] Configuring WireGuard via UAPI..." +UAPI_RESULT=$(printf "set=1\nprivate_key=%s\nlisten_port=51820\n\n" "$SERVER_PRIVKEY_HEX" | \ + socat -t5 - UNIX-CONNECT:/var/run/wireguard/wg0.sock 2>&1) +echo "[Setup] UAPI response: $UAPI_RESULT" -# Interface + routing -ip addr add 10.0.0.1/24 dev wg0 2>/dev/null || true -ip link set wg0 up +# Interface + routing (Linux) +echo "[Setup] Configuring interface wg0..." +ip link set wg0 up 2>/dev/null || true +ip addr replace 10.0.0.1/24 dev wg0 2>/dev/null || true +ip route replace 10.0.0.0/24 dev wg0 2>/dev/null || true +echo "[Setup] Interface wg0: $(ip -4 addr show wg0 2>/dev/null | grep inet || echo 'no address')" # IP forwarding + NAT +echo "[Setup] Enabling IP forwarding and NAT..." sysctl -w net.ipv4.ip_forward=1 >/dev/null INET_IF=$(ip route show default | awk '{print $5; exit}') -iptables -t nat -C POSTROUTING -s 10.0.0.0/24 -o "$INET_IF" -j MASQUERADE 2>/dev/null || \ - iptables -t nat -A POSTROUTING -s 10.0.0.0/24 -o "$INET_IF" -j MASQUERADE +if [ -n "$INET_IF" ]; then + iptables -t nat -C POSTROUTING -s 10.0.0.0/24 -o "$INET_IF" -j MASQUERADE 2>/dev/null || \ + iptables -t nat -A POSTROUTING -s 10.0.0.0/24 -o "$INET_IF" -j MASQUERADE + echo "[Setup] NAT: 10.0.0.0/24 -> $INET_IF" +else + echo "WARNING: Could not detect default interface for NAT" +fi # Verify echo "" From 42e872a0b9722c5d34ce3a6e61f91d1804813534 Mon Sep 17 00:00:00 2001 From: UYUYANMARUL Date: Sat, 4 Apr 2026 20:51:59 +0200 Subject: [PATCH 5/9] fix --- scripts/test-server-linux.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/test-server-linux.sh b/scripts/test-server-linux.sh index 6d283b8..652144c 100755 --- a/scripts/test-server-linux.sh +++ b/scripts/test-server-linux.sh @@ -17,8 +17,8 @@ KEYS_DIR="$SCRIPT_DIR/keys" SERVER_PRIVKEY_HEX=$(base64 -d "$KEYS_DIR/server.key" | xxd -p -c 64) PUBLIC_IP="${PUBLIC_IP:-37.27.29.160}" -BT_BIN="$ROOT_DIR/target/release/boringtun" -[ -f "$BT_BIN" ] || BT_BIN="$ROOT_DIR/target/debug/boringtun" +BT_BIN="$ROOT_DIR/target/release/boringtun-cli" +[ -f "$BT_BIN" ] || BT_BIN="$ROOT_DIR/target/debug/boringtun-cli" [ -f "$BT_BIN" ] || { echo "ERROR: Build first: cargo build --release -p boringtun-cli --features payment"; exit 1; } # Kill stale instances From 5c5f5721b4ea90ef5ec63e0691bdf62ba02b6acd Mon Sep 17 00:00:00 2001 From: UYUYANMARUL Date: Sat, 4 Apr 2026 20:54:35 +0200 Subject: [PATCH 6/9] fix --- scripts/test-server-linux.sh | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/scripts/test-server-linux.sh b/scripts/test-server-linux.sh index 652144c..380f0cb 100755 --- a/scripts/test-server-linux.sh +++ b/scripts/test-server-linux.sh @@ -14,7 +14,10 @@ KEYS_DIR="$SCRIPT_DIR/keys" [ -f "$KEYS_DIR/server.key" ] || { echo "ERROR: Run ./scripts/gen-keys.sh first"; exit 1; } -SERVER_PRIVKEY_HEX=$(base64 -d "$KEYS_DIR/server.key" | xxd -p -c 64) +SERVER_PRIVKEY_HEX=$(cat "$KEYS_DIR/server.key" | base64 -d | xxd -p -c 64) + +# Stop exiting on non-critical failures after initial checks +set +e PUBLIC_IP="${PUBLIC_IP:-37.27.29.160}" BT_BIN="$ROOT_DIR/target/release/boringtun-cli" From 61f43294bc4647b6d8ac1df8f4cf7fa20bde58c5 Mon Sep 17 00:00:00 2001 From: UYUYANMARUL Date: Sat, 4 Apr 2026 22:09:20 +0200 Subject: [PATCH 7/9] fix --- boringtun-cli/src/main.rs | 43 ++++++++- boringtun/src/device/mod.rs | 2 + boringtun/src/device/ws_bridge.rs | 143 +++++++++++++++++++++++++++++ client/src-tauri/src/vpn.rs | 12 ++- scripts/keys/client.key | 2 +- scripts/keys/client.pub | 2 +- scripts/keys/server.key | 2 +- scripts/keys/server.pub | 2 +- scripts/test-client.sh | 52 ++++++----- scripts/ws-bridge.js | 144 ------------------------------ 10 files changed, 223 insertions(+), 181 deletions(-) create mode 100644 boringtun/src/device/ws_bridge.rs delete mode 100644 scripts/ws-bridge.js diff --git a/boringtun-cli/src/main.rs b/boringtun-cli/src/main.rs index 208c4dc..b516dff 100644 --- a/boringtun-cli/src/main.rs +++ b/boringtun-cli/src/main.rs @@ -98,6 +98,18 @@ fn main() { .env("BT_WS_BIND") .help("WebSocket proxy bind address (e.g. 0.0.0.0:8443)") .default_value(""), + Arg::new("ws-connect") + .long("ws-connect") + .takes_value(true) + .env("BT_WS_CONNECT") + .help("Client WS bridge: connect to server WS URL (e.g. ws://1.2.3.4:8443)") + .default_value(""), + Arg::new("ws-local-port") + .long("ws-local-port") + .takes_value(true) + .env("BT_WS_LOCAL_PORT") + .help("Client WS bridge: local UDP port for boringtun ↔ bridge") + .default_value("51821"), #[cfg(target_os = "linux")] Arg::new("disable-multi-queue") .long("disable-multi-queue") @@ -197,12 +209,14 @@ fn main() { tracing::info!("BoringTun started successfully"); - // HTTP registration API (server mode only) + // Payment features: server API, WS proxy, client WS bridge #[cfg(feature = "payment")] { use std::sync::atomic::AtomicBool; use std::sync::Arc; + let shutdown_flag = Arc::new(AtomicBool::new(false)); + let is_server = std::env::var("BT_PAYMENT_SERVER") .map(|v| v == "1" || v.eq_ignore_ascii_case("true")) .unwrap_or(false); @@ -234,8 +248,6 @@ fn main() { public_ip, )); - let shutdown_flag = Arc::new(AtomicBool::new(false)); - // HTTP server thread let state_http = Arc::clone(&state); let shutdown_http = Arc::clone(&shutdown_flag); @@ -287,6 +299,31 @@ fn main() { tracing::info!("WebSocket proxy on {}", ws_bind_log); } } + + // Client-side WS bridge (connects to remote server's WS proxy) + let ws_connect = std::env::var("BT_WS_CONNECT") + .unwrap_or_else(|_| matches.value_of("ws-connect").unwrap_or("").to_string()); + if !ws_connect.is_empty() { + let ws_local_port: u16 = std::env::var("BT_WS_LOCAL_PORT") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or_else(|| matches.value_of_t("ws-local-port").unwrap_or(51821)); + + let shutdown_bridge = Arc::clone(&shutdown_flag); + let ws_url = ws_connect.clone(); + std::thread::Builder::new() + .name("ws-bridge".into()) + .spawn(move || { + boringtun::device::ws_bridge::run_ws_bridge( + ws_local_port, + &ws_url, + shutdown_bridge, + ); + }) + .expect("Failed to spawn WS bridge thread"); + + tracing::info!("WS bridge: UDP :{} ↔ {}", ws_local_port, ws_connect); + } } device_handle.wait(); diff --git a/boringtun/src/device/mod.rs b/boringtun/src/device/mod.rs index 4b852c7..a5c834b 100644 --- a/boringtun/src/device/mod.rs +++ b/boringtun/src/device/mod.rs @@ -6,6 +6,8 @@ pub mod api; #[cfg(feature = "payment")] pub mod http_api; #[cfg(feature = "payment")] +pub mod ws_bridge; +#[cfg(feature = "payment")] pub mod ws_proxy; mod dev_lock; pub mod drop_privileges; diff --git a/boringtun/src/device/ws_bridge.rs b/boringtun/src/device/ws_bridge.rs new file mode 100644 index 0000000..28defed --- /dev/null +++ b/boringtun/src/device/ws_bridge.rs @@ -0,0 +1,143 @@ +use std::net::{SocketAddr, UdpSocket}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +use tungstenite::protocol::Message; +use tungstenite::stream::MaybeTlsStream; +use tungstenite::WebSocket; + +const UDP_READ_TIMEOUT_MS: u64 = 50; +const WS_READ_TIMEOUT_MS: u64 = 100; +const MAX_WG_PACKET: usize = 1500; +const RECONNECT_DELAY_MS: u64 = 2000; + +/// Client-side WebSocket-to-UDP bridge. +/// +/// Boringtun client sends/receives WireGuard UDP packets on a local port. +/// This bridge forwards them over a WebSocket connection to the server's WS proxy, +/// and relays responses back over UDP. +/// +/// Boringtun client ↔ UDP (local_port) ↔ WS Bridge ↔ WebSocket ↔ Server WS Proxy +pub fn run_ws_bridge( + local_udp_port: u16, + ws_url: &str, + shutdown_flag: Arc, +) { + let udp = match UdpSocket::bind(format!("127.0.0.1:{}", local_udp_port)) { + Ok(s) => s, + Err(e) => { + tracing::error!("WS bridge: failed to bind UDP on port {}: {}", local_udp_port, e); + return; + } + }; + udp.set_read_timeout(Some(Duration::from_millis(UDP_READ_TIMEOUT_MS))).ok(); + + tracing::info!("WS bridge: UDP listening on 127.0.0.1:{}", local_udp_port); + + let mut client_addr: Option = None; + let mut udp_buf = [0u8; MAX_WG_PACKET]; + let mut udp_to_ws: u64 = 0; + let mut ws_to_udp: u64 = 0; + + while !shutdown_flag.load(Ordering::Relaxed) { + // Connect to server WebSocket + tracing::info!("WS bridge: connecting to {}...", ws_url); + let mut ws = match connect_ws(ws_url) { + Some(ws) => ws, + None => { + tracing::warn!("WS bridge: connection failed, retrying in {}ms", RECONNECT_DELAY_MS); + std::thread::sleep(Duration::from_millis(RECONNECT_DELAY_MS)); + continue; + } + }; + tracing::info!("WS bridge: connected to {}", ws_url); + + // Main relay loop + loop { + if shutdown_flag.load(Ordering::Relaxed) { + break; + } + + // 1. Read from local UDP (boringtun client) → send to WebSocket + match udp.recv_from(&mut udp_buf) { + Ok((n, from)) => { + client_addr = Some(from); + udp_to_ws += 1; + if let Err(e) = ws.send(Message::Binary(udp_buf[..n].to_vec().into())) { + tracing::warn!("WS bridge: UDP→WS send error: {}, reconnecting", e); + break; + } + tracing::debug!("WS bridge: UDP→WS #{} | {} bytes", udp_to_ws, n); + } + Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock + || e.kind() == std::io::ErrorKind::TimedOut => {} + Err(e) => { + tracing::warn!("WS bridge: UDP recv error: {}", e); + break; + } + } + + // 2. Read from WebSocket → send to local UDP (boringtun client) + match ws.read() { + Ok(Message::Binary(data)) => { + ws_to_udp += 1; + if let Some(addr) = client_addr { + if let Err(e) = udp.send_to(&data, addr) { + tracing::warn!("WS bridge: WS→UDP send error: {}", e); + } else { + tracing::debug!("WS bridge: WS→UDP #{} | {} bytes → {}", ws_to_udp, data.len(), addr); + } + } else { + tracing::warn!("WS bridge: WS→UDP #{} | dropped, no client yet", ws_to_udp); + } + } + Ok(Message::Ping(payload)) => { + let _ = ws.send(Message::Pong(payload)); + } + Ok(Message::Close(_)) => { + tracing::info!("WS bridge: server closed connection"); + break; + } + Ok(_) => {} + Err(tungstenite::Error::Io(ref e)) + if e.kind() == std::io::ErrorKind::WouldBlock + || e.kind() == std::io::ErrorKind::TimedOut => {} + Err(e) => { + tracing::warn!("WS bridge: WS read error: {}, reconnecting", e); + break; + } + } + } + + let _ = ws.close(None); + tracing::info!( + "WS bridge: session ended | UDP→WS: {} | WS→UDP: {}", + udp_to_ws, ws_to_udp + ); + + if !shutdown_flag.load(Ordering::Relaxed) { + tracing::info!("WS bridge: reconnecting in {}ms...", RECONNECT_DELAY_MS); + std::thread::sleep(Duration::from_millis(RECONNECT_DELAY_MS)); + } + } + + tracing::info!("WS bridge: shutting down"); +} + +fn connect_ws(url: &str) -> Option>> { + match tungstenite::connect(url) { + Ok((ws, _response)) => { + // Set read timeout on the underlying TCP stream + if let MaybeTlsStream::Plain(ref stream) = ws.get_ref() { + stream.set_read_timeout(Some(Duration::from_millis(WS_READ_TIMEOUT_MS))).ok(); + stream.set_write_timeout(Some(Duration::from_secs(5))).ok(); + } + Some(ws) + } + Err(e) => { + tracing::warn!("WS bridge: connect error: {}", e); + None + } + } +} diff --git a/client/src-tauri/src/vpn.rs b/client/src-tauri/src/vpn.rs index a27c51a..9c8df81 100644 --- a/client/src-tauri/src/vpn.rs +++ b/client/src-tauri/src/vpn.rs @@ -29,6 +29,8 @@ use x25519_dalek::{PublicKey, StaticSecret}; const API_BASE: &str = "http://37.27.29.160:8080"; const SERVER_IP: &str = "37.27.29.160"; +const SERVER_WS_PORT: u16 = 8443; +const WS_LOCAL_PORT: u16 = 51821; const GATEWAY_API: &str = "https://gateway-api-testnet.circle.com"; const ARC_DOMAIN: u32 = 26; @@ -174,7 +176,7 @@ impl VpnManager { .unwrap_or(&assigned_ip) .to_string(); - log::info!("[vpn] registered: server_pub={server_pub} ip={assigned_ip} endpoint={endpoint}"); + log::info!("[vpn] registered: server_pub={server_pub} ip={assigned_ip} endpoint={endpoint} ws={ws_url}", ws_url = format!("ws://{}:{}", SERVER_IP, SERVER_WS_PORT)); // ── 3. Launch boringtun-cli ──────────────────────────────────────── kill_stale_boringtun(); @@ -185,11 +187,14 @@ impl VpnManager { .map_err(|e| format!("Failed to create boringtun log: {e}"))?; let bt_err = bt_log.try_clone() .map_err(|e| format!("Failed to clone log handle: {e}"))?; + let ws_url = format!("ws://{}:{}", SERVER_IP, SERVER_WS_PORT); let proc = Command::new("sudo") .arg(&boringtun_path) .arg(&iface) .arg("--disable-drop-privileges") .arg("--foreground") + .env("BT_WS_CONNECT", &ws_url) + .env("BT_WS_LOCAL_PORT", WS_LOCAL_PORT.to_string()) .stdout(std::process::Stdio::from(bt_log)) .stderr(std::process::Stdio::from(bt_err)) .spawn() @@ -211,8 +216,9 @@ impl VpnManager { return Err(e); } - // ── 5. Configure WireGuard peer ──────────────────────────────────── - if let Err(e) = configure_wireguard(&iface, &priv_b64, &server_pub, &endpoint) { + // ── 5. Configure WireGuard peer (via local WS bridge) ───────────── + let ws_endpoint = format!("127.0.0.1:{}", WS_LOCAL_PORT); + if let Err(e) = configure_wireguard(&iface, &priv_b64, &server_pub, &ws_endpoint) { cleanup.run(); return Err(e); } diff --git a/scripts/keys/client.key b/scripts/keys/client.key index d5d3836..87054cd 100644 --- a/scripts/keys/client.key +++ b/scripts/keys/client.key @@ -1 +1 @@ -sFXVIR9WwYJUkBUj8WlgyyyFOKiiwzWP/aNeqCCUUEw= ++EYej/55FgCCsPFl14wGPAZ3Ot8qnTkk85m7Tu5l8WQ= diff --git a/scripts/keys/client.pub b/scripts/keys/client.pub index f8d55a8..e64c816 100644 --- a/scripts/keys/client.pub +++ b/scripts/keys/client.pub @@ -1 +1 @@ -YuTbqQyv+idWANm+roj28a8GFpyOYxsJVVZ+b6ogeVA= +h2nS2fdTI+LOaad4tbnTNvpbg+wNd9fa8kIvlSkfSSY= diff --git a/scripts/keys/server.key b/scripts/keys/server.key index f02de53..2d30beb 100644 --- a/scripts/keys/server.key +++ b/scripts/keys/server.key @@ -1 +1 @@ -oKFmpjmnTiebqXpZNZANqiYCxKfFE2w0BJMYg5uLT34= +6KOG0bpC7b0VSNnZIYDPwzjlhV+OgByiRdFvn4PxM0A= diff --git a/scripts/keys/server.pub b/scripts/keys/server.pub index 8a7381a..37f046a 100644 --- a/scripts/keys/server.pub +++ b/scripts/keys/server.pub @@ -1 +1 @@ -BSn6bBRNzkG+QzmC2YA9uVytzCIIHa5ws9fvlkzlBis= +sNcMmVjWcYIBQjSyg+BqvnQ5J6BoWQ0Dh6Trt12kfBQ= diff --git a/scripts/test-client.sh b/scripts/test-client.sh index e61b663..acc392f 100755 --- a/scripts/test-client.sh +++ b/scripts/test-client.sh @@ -1,14 +1,19 @@ #!/bin/bash # Start boringtun client locally, connecting to server via WebSocket proxy. -# Run with: sudo ./scripts/test-client.sh -# Full tunnel: sudo FULL_TUNNEL=1 ./scripts/test-client.sh +# +# Modes: +# Full tunnel (all traffic via remote host): +# sudo FULL_TUNNEL=1 ./scripts/test-client.sh +# +# Local only (VPN subnet only, default): +# sudo ./scripts/test-client.sh set -e SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" ROOT_DIR="$(dirname "$SCRIPT_DIR")" KEYS_DIR="$SCRIPT_DIR/keys" -SERVER_HOST="${SERVER_HOST:-37.27.29.160}" +SERVER_HOST="${SERVER_HOST:-127.0.0.1}" SERVER_HTTP_PORT="${SERVER_HTTP_PORT:-8089}" SERVER_WS_PORT="${SERVER_WS_PORT:-8443}" LOCAL_UDP_PORT=51821 @@ -32,24 +37,18 @@ REGISTER_RESPONSE=$(curl -sf -X POST "http://${SERVER_HOST}:${SERVER_HTTP_PORT}/ ASSIGNED_IP=$(echo "$REGISTER_RESPONSE" | grep -o '"assigned_ip":"[^"]*"' | cut -d'"' -f4 | cut -d'/' -f1) echo "[Register] Assigned IP: $ASSIGNED_IP" -# --- WS Bridge --- -if [ ! -d "$SCRIPT_DIR/node_modules/ws" ]; then - (cd "$SCRIPT_DIR" && npm init -y --silent 2>/dev/null; npm install ws --silent) -fi -node "$SCRIPT_DIR/ws-bridge.js" "$LOCAL_UDP_PORT" "ws://${SERVER_HOST}:${SERVER_WS_PORT}" & -BRIDGE_PID=$! -sleep 1 - -# --- Boringtun client --- +# --- Boringtun client with built-in WS bridge --- BT_BIN="$ROOT_DIR/target/debug/boringtun-cli" [ -f "$ROOT_DIR/target/release/boringtun-cli" ] && BT_BIN="$ROOT_DIR/target/release/boringtun-cli" BEFORE_SOCKS=$(ls /var/run/wireguard/utun*.sock 2>/dev/null || true) +BT_WS_CONNECT="ws://${SERVER_HOST}:${SERVER_WS_PORT}" \ +BT_WS_LOCAL_PORT="$LOCAL_UDP_PORT" \ WG_LOG_LEVEL=info WG_SUDO=1 "$BT_BIN" utun --foreground --disable-drop-privileges & BT_PID=$! sleep 3 -kill -0 $BT_PID 2>/dev/null || { echo "ERROR: boringtun exited"; kill $BRIDGE_PID; exit 1; } +kill -0 $BT_PID 2>/dev/null || { echo "ERROR: boringtun exited"; exit 1; } # Find new socket UTUN_NAME="" @@ -59,7 +58,7 @@ for sock in /var/run/wireguard/utun*.sock; do UTUN_NAME=$(basename "$sock" .sock) break done -[ -n "$UTUN_NAME" ] || { echo "ERROR: No UAPI socket found"; kill $BRIDGE_PID $BT_PID; exit 1; } +[ -n "$UTUN_NAME" ] || { echo "ERROR: No UAPI socket found"; kill $BT_PID; exit 1; } echo "[Setup] Interface: $UTUN_NAME" @@ -67,7 +66,7 @@ echo "[Setup] Interface: $UTUN_NAME" if [ "$FULL_TUNNEL" = "1" ]; then ALLOWED_IPS="allowed_ip=0.0.0.0/0" else - ALLOWED_IPS="allowed_ip=10.0.0.0/24\nallowed_ip=8.8.8.8/32\nallowed_ip=8.8.4.4/32" + ALLOWED_IPS="allowed_ip=10.0.0.0/24" fi printf "set=1\nprivate_key=%s\npublic_key=%s\nendpoint=127.0.0.1:%s\n%b\npersistent_keepalive_interval=25\n\n" \ @@ -78,8 +77,7 @@ printf "set=1\nprivate_key=%s\npublic_key=%s\nendpoint=127.0.0.1:%s\n%b\npersist ifconfig "$UTUN_NAME" "$ASSIGNED_IP" 10.0.0.1 up if [ "$FULL_TUNNEL" = "1" ]; then - echo "[Route] Full tunnel — all traffic through VPN" - # Preserve route to server via original gateway to avoid routing loop + echo "[Route] Full tunnel — all traffic through remote host" ORIG_GW=$(route -n get default 2>/dev/null | awk '/gateway:/{print $2}') if [ -n "$ORIG_GW" ]; then route add -host "$SERVER_HOST" "$ORIG_GW" 2>/dev/null || true @@ -87,20 +85,21 @@ if [ "$FULL_TUNNEL" = "1" ]; then route add -net 0.0.0.0/1 -interface "$UTUN_NAME" 2>/dev/null || true route add -net 128.0.0.0/1 -interface "$UTUN_NAME" 2>/dev/null || true else - echo "[Route] Split tunnel — VPN subnet + DNS (8.8.8.8, 8.8.4.4)" - route add -host 8.8.8.8 -interface "$UTUN_NAME" 2>/dev/null || true - route add -host 8.8.4.4 -interface "$UTUN_NAME" 2>/dev/null || true + echo "[Route] Local only — VPN subnet (10.0.0.0/24)" + route add -net 10.0.0.0/24 -interface "$UTUN_NAME" 2>/dev/null || true fi echo "" echo "=== Client running ===" echo "Client IP: $ASSIGNED_IP" -echo "Mode: $([ "$FULL_TUNNEL" = "1" ] && echo "full tunnel" || echo "split tunnel")" +echo "Mode: $([ "$FULL_TUNNEL" = "1" ] && echo "full tunnel (remote)" || echo "local only")" echo "" echo "Test:" echo " ping 10.0.0.1" -echo " ping 8.8.8.8" -[ "$FULL_TUNNEL" = "1" ] && echo " curl ifconfig.me" +if [ "$FULL_TUNNEL" = "1" ]; then + echo " ping 8.8.8.8" + echo " curl ifconfig.me" +fi echo "" cleanup() { @@ -110,11 +109,10 @@ cleanup() { route delete -net 0.0.0.0/1 2>/dev/null || true route delete -net 128.0.0.0/1 2>/dev/null || true else - route delete -host 8.8.8.8 2>/dev/null || true - route delete -host 8.8.4.4 2>/dev/null || true + route delete -net 10.0.0.0/24 2>/dev/null || true fi - kill $BRIDGE_PID $BT_PID 2>/dev/null - wait $BRIDGE_PID $BT_PID 2>/dev/null + kill $BT_PID 2>/dev/null + wait $BT_PID 2>/dev/null } trap cleanup EXIT INT TERM diff --git a/scripts/ws-bridge.js b/scripts/ws-bridge.js deleted file mode 100644 index 82903e1..0000000 --- a/scripts/ws-bridge.js +++ /dev/null @@ -1,144 +0,0 @@ -#!/usr/bin/env node -// Client-side WebSocket-to-UDP bridge. -// -// Boringtun client sends UDP packets to a local port. -// This bridge forwards them over WebSocket to the server's WS proxy, -// and relays responses back over UDP. -// -// Usage: node ws-bridge.js [local_udp_port] [ws_server_url] -// Default: node ws-bridge.js 51821 ws://127.0.0.1:8443 -// -// Requires: npm install ws - -const dgram = require("dgram"); -const WebSocket = require("ws"); - -const LOCAL_PORT = parseInt(process.argv[2] || "51821", 10); -const WS_URL = process.argv[3] || "ws://127.0.0.1:8443"; - -console.log(`[WS Bridge] UDP :${LOCAL_PORT} <-> ${WS_URL}`); - -const udpServer = dgram.createSocket("udp4"); -let clientAddr = null; -let clientPort = null; -let ws = null; -let reconnecting = false; -let udpToWsCount = 0; -let wsToUdpCount = 0; - -function ts() { - return new Date().toISOString(); -} - -function connectWs() { - console.log(`[WS Bridge] [${ts()}] Connecting to ${WS_URL}...`); - ws = new WebSocket(WS_URL); - - ws.on("open", () => { - console.log(`[WS Bridge] [${ts()}] WebSocket CONNECTED to ${WS_URL}`); - reconnecting = false; - }); - - ws.on("message", (data) => { - wsToUdpCount++; - const buf = Buffer.from(data); - console.log( - `[WS Bridge] [${ts()}] WS→UDP #${wsToUdpCount} | ${buf.length} bytes | ` + - `first4=[${buf.slice(0, 4).toString("hex")}] | ` + - `forwarding to ${clientAddr}:${clientPort}` - ); - - if (clientAddr && clientPort) { - udpServer.send(buf, clientPort, clientAddr, (err) => { - if (err) { - console.error(`[WS Bridge] [${ts()}] WS→UDP #${wsToUdpCount} | UDP send error: ${err.message}`); - } else { - console.log(`[WS Bridge] [${ts()}] WS→UDP #${wsToUdpCount} | sent ${buf.length} bytes to ${clientAddr}:${clientPort}`); - } - }); - } else { - console.warn(`[WS Bridge] [${ts()}] WS→UDP #${wsToUdpCount} | DROPPED — no client address known yet`); - } - }); - - ws.on("close", (code, reason) => { - console.log(`[WS Bridge] [${ts()}] WebSocket CLOSED | code=${code} reason=${reason || "(none)"}`); - console.log(`[WS Bridge] [${ts()}] Session stats | UDP→WS: ${udpToWsCount} packets | WS→UDP: ${wsToUdpCount} packets`); - scheduleReconnect(); - }); - - ws.on("error", (err) => { - console.error(`[WS Bridge] [${ts()}] WebSocket ERROR: ${err.message}`); - scheduleReconnect(); - }); - - ws.on("ping", (data) => { - console.log(`[WS Bridge] [${ts()}] WebSocket PING received (${data.length} bytes)`); - }); - - ws.on("pong", (data) => { - console.log(`[WS Bridge] [${ts()}] WebSocket PONG received (${data.length} bytes)`); - }); -} - -function scheduleReconnect() { - if (reconnecting) return; - reconnecting = true; - console.log(`[WS Bridge] [${ts()}] Reconnecting in 2s...`); - setTimeout(connectWs, 2000); -} - -// Client -> Server: forward UDP to WebSocket -udpServer.on("message", (msg, rinfo) => { - clientAddr = rinfo.address; - clientPort = rinfo.port; - udpToWsCount++; - - const wsState = ws ? ["CONNECTING", "OPEN", "CLOSING", "CLOSED"][ws.readyState] : "null"; - console.log( - `[WS Bridge] [${ts()}] UDP→WS #${udpToWsCount} | ${msg.length} bytes from ${rinfo.address}:${rinfo.port} | ` + - `first4=[${msg.slice(0, 4).toString("hex")}] | ` + - `ws=${wsState}` - ); - - if (ws && ws.readyState === WebSocket.OPEN) { - ws.send(msg, (err) => { - if (err) { - console.error(`[WS Bridge] [${ts()}] UDP→WS #${udpToWsCount} | WS send error: ${err.message}`); - } else { - console.log(`[WS Bridge] [${ts()}] UDP→WS #${udpToWsCount} | sent ${msg.length} bytes to WS`); - } - }); - } else { - console.warn(`[WS Bridge] [${ts()}] UDP→WS #${udpToWsCount} | DROPPED — WebSocket not open (state=${wsState})`); - } -}); - -udpServer.on("listening", () => { - const addr = udpServer.address(); - console.log(`[WS Bridge] [${ts()}] UDP listening on ${addr.address}:${addr.port}`); - connectWs(); -}); - -udpServer.on("error", (err) => { - console.error(`[WS Bridge] [${ts()}] UDP socket error: ${err.message}`); -}); - -udpServer.bind(LOCAL_PORT, "127.0.0.1"); - -// Periodic stats -setInterval(() => { - const wsState = ws ? ["CONNECTING", "OPEN", "CLOSING", "CLOSED"][ws.readyState] : "null"; - console.log( - `[WS Bridge] [${ts()}] STATS | UDP→WS: ${udpToWsCount} | WS→UDP: ${wsToUdpCount} | ` + - `client=${clientAddr}:${clientPort} | ws=${wsState}` - ); -}, 10000); - -process.on("SIGINT", () => { - console.log(`\n[WS Bridge] [${ts()}] Shutting down...`); - console.log(`[WS Bridge] Final stats | UDP→WS: ${udpToWsCount} | WS→UDP: ${wsToUdpCount}`); - if (ws) ws.close(); - udpServer.close(); - process.exit(0); -}); From cc322b81a38e453a13e4002c0a6ccf5992d9bd8c Mon Sep 17 00:00:00 2001 From: UYUYANMARUL Date: Sat, 4 Apr 2026 23:49:17 +0200 Subject: [PATCH 8/9] fix --- boringtun/src/device/http_api.rs | 76 +++++++++++- boringtun/src/device/ws_bridge.rs | 12 +- scripts/client.sh | 135 ++++++++++++++++++++ scripts/server.sh | 133 ++++++++++++++++++++ scripts/test-client-linux.sh | 200 ++++++++++++++++++++++++++++++ scripts/test-client.sh | 2 +- scripts/test-server.sh | 8 +- 7 files changed, 559 insertions(+), 7 deletions(-) create mode 100755 scripts/client.sh create mode 100755 scripts/server.sh create mode 100755 scripts/test-client-linux.sh diff --git a/boringtun/src/device/http_api.rs b/boringtun/src/device/http_api.rs index 7cd1b3b..940a4b1 100644 --- a/boringtun/src/device/http_api.rs +++ b/boringtun/src/device/http_api.rs @@ -222,6 +222,65 @@ fn uapi_remove_peer(tun_name: &str, pubkey_hex: &str) -> Result { Ok(response.trim() == "errno=0") } +// === Kernel Route Management === + +/// Add a /32 host route for a client IP via the TUN interface. +/// Without this, the kernel has no route to deliver decrypted response packets +/// back into the tunnel (since we use /32 on the interface to prevent loops). +fn add_kernel_route(tun_name: &str, ip: Ipv4Addr) { + let result = if cfg!(target_os = "linux") { + std::process::Command::new("ip") + .args(["route", "add", &format!("{}/32", ip), "dev", tun_name]) + .output() + } else { + std::process::Command::new("route") + .args(["add", "-host", &ip.to_string(), "-interface", tun_name]) + .output() + }; + match result { + Ok(out) if out.status.success() => { + tracing::info!(ip = %ip, tun = %tun_name, "Kernel route added"); + } + Ok(out) => { + let stderr = String::from_utf8_lossy(&out.stderr); + // "File exists" / "RTNETLINK: File exists" means route already present — not an error + if stderr.contains("exist") { + tracing::debug!(ip = %ip, "Kernel route already exists"); + } else { + tracing::warn!(ip = %ip, stderr = %stderr, "Failed to add kernel route"); + } + } + Err(e) => { + tracing::warn!(ip = %ip, error = %e, "Failed to run route command"); + } + } +} + +/// Remove a /32 host route when a peer is reaped. +fn remove_kernel_route(tun_name: &str, ip: Ipv4Addr) { + let result = if cfg!(target_os = "linux") { + std::process::Command::new("ip") + .args(["route", "del", &format!("{}/32", ip), "dev", tun_name]) + .output() + } else { + std::process::Command::new("route") + .args(["delete", "-host", &ip.to_string(), "-interface", tun_name]) + .output() + }; + match result { + Ok(out) if out.status.success() => { + tracing::info!(ip = %ip, tun = %tun_name, "Kernel route removed"); + } + Ok(out) => { + let stderr = String::from_utf8_lossy(&out.stderr); + tracing::debug!(ip = %ip, stderr = %stderr, "Kernel route removal note"); + } + Err(e) => { + tracing::warn!(ip = %ip, error = %e, "Failed to run route delete command"); + } + } +} + // === Registration Handler === fn handle_registration(state: &RegistrationState, pubkey_base64: &str) -> (u16, String) { @@ -269,6 +328,8 @@ fn handle_registration(state: &RegistrationState, pubkey_base64: &str) -> (u16, state.subnet_prefix[2], octet, ); + // Re-ensure kernel route exists (may be lost after server restart) + add_kernel_route(&state.tun_name, ip); return (200, build_success_response( ip, &state.payment_config, &state.public_ip, inner.server_pubkey_cache.as_deref().unwrap_or(""), @@ -300,6 +361,9 @@ fn handle_registration(state: &RegistrationState, pubkey_base64: &str) -> (u16, inner.peer_to_octet.insert(pubkey_hex.clone(), octet); inner.peer_registered_at.insert(pubkey_hex.clone(), Instant::now()); + // Add kernel route so the OS knows how to reach this client via the TUN + add_kernel_route(&state.tun_name, assigned_ip); + tracing::info!( pubkey = %pubkey_hex, ip = %assigned_ip, @@ -527,15 +591,23 @@ pub fn run_reaper(state: Arc, shutdown_flag: Arc) match uapi_remove_peer(&state.tun_name, pubkey_hex) { Ok(true) => { // errno=0 — safe to update state + let ip = Ipv4Addr::new( + state.subnet_prefix[0], + state.subnet_prefix[1], + state.subnet_prefix[2], + *octet, + ); + remove_kernel_route(&state.tun_name, ip); + inner.available_ips.insert(*octet); inner.registered_peers.remove(pubkey_hex); inner.peer_to_octet.remove(pubkey_hex); inner.peer_registered_at.remove(pubkey_hex); tracing::info!( pubkey = %pubkey_hex, - octet = octet, + ip = %ip, pool_remaining = inner.available_ips.len(), - "Reaper: removed stale peer" + "Reaper: removed stale peer + kernel route" ); } Ok(false) => { diff --git a/boringtun/src/device/ws_bridge.rs b/boringtun/src/device/ws_bridge.rs index 28defed..d3847b1 100644 --- a/boringtun/src/device/ws_bridge.rs +++ b/boringtun/src/device/ws_bridge.rs @@ -68,7 +68,11 @@ pub fn run_ws_bridge( tracing::warn!("WS bridge: UDP→WS send error: {}, reconnecting", e); break; } - tracing::debug!("WS bridge: UDP→WS #{} | {} bytes", udp_to_ws, n); + tracing::info!( + "WS bridge: UDP→WS #{} | {} bytes from {} | first4=[{:02x}{:02x}{:02x}{:02x}]", + udp_to_ws, n, from, + udp_buf[0], udp_buf[1.min(n-1)], udp_buf[2.min(n-1)], udp_buf[3.min(n-1)] + ); } Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock || e.kind() == std::io::ErrorKind::TimedOut => {} @@ -86,7 +90,11 @@ pub fn run_ws_bridge( if let Err(e) = udp.send_to(&data, addr) { tracing::warn!("WS bridge: WS→UDP send error: {}", e); } else { - tracing::debug!("WS bridge: WS→UDP #{} | {} bytes → {}", ws_to_udp, data.len(), addr); + tracing::info!( + "WS bridge: WS→UDP #{} | {} bytes → {} | first4=[{:02x}{:02x}{:02x}{:02x}]", + ws_to_udp, data.len(), addr, + data[0], data[1.min(data.len()-1)], data[2.min(data.len()-1)], data[3.min(data.len()-1)] + ); } } else { tracing::warn!("WS bridge: WS→UDP #{} | dropped, no client yet", ws_to_udp); diff --git a/scripts/client.sh b/scripts/client.sh new file mode 100755 index 0000000..b8db2c6 --- /dev/null +++ b/scripts/client.sh @@ -0,0 +1,135 @@ +#!/bin/bash +# ============================================================================= +# Veil VPN — Client (macOS) +# ============================================================================= +# +# Usage: +# sudo SERVER_HOST= ./scripts/client.sh +# sudo SERVER_HOST= FULL_TUNNEL=1 ./scripts/client.sh +# +# Prereqs: +# 1. ./scripts/gen-keys.sh (same keys as registered with server) +# 2. cargo build --release -p boringtun-cli --features payment +# 3. brew install socat + +set -e +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT_DIR="$(dirname "$SCRIPT_DIR")" +KEYS_DIR="$SCRIPT_DIR/keys" + +SERVER_HOST="${SERVER_HOST:?ERROR: Set SERVER_HOST env var (e.g. SERVER_HOST=1.2.3.4 ./scripts/client.sh)}" +SERVER_HTTP_PORT="${SERVER_HTTP_PORT:-8089}" +SERVER_WS_PORT="${SERVER_WS_PORT:-8443}" +LOCAL_UDP_PORT=51821 +FULL_TUNNEL="${FULL_TUNNEL:-0}" + +# --- Keys --- +[ -f "$KEYS_DIR/client.key" ] || { echo "ERROR: Run ./scripts/gen-keys.sh first"; exit 1; } +CLIENT_PRIVKEY_HEX=$(cat "$KEYS_DIR/client.key" | base64 -d | xxd -p -c 64) +CLIENT_PUBKEY=$(cat "$KEYS_DIR/client.pub") +SERVER_PUBKEY_HEX=$(cat "$KEYS_DIR/server.pub" | base64 -d | xxd -p -c 64) + +# --- Binary --- +BT_BIN="$ROOT_DIR/target/release/boringtun-cli" +[ -f "$BT_BIN" ] || BT_BIN="$ROOT_DIR/target/debug/boringtun-cli" +[ -f "$BT_BIN" ] || { echo "ERROR: Build first: cargo build --release -p boringtun-cli --features payment"; exit 1; } + +# --- Cleanup stale --- +pkill -f "boringtun-cli utun" 2>/dev/null || true +sleep 1 + +# --- Register with server --- +echo "[Register] Checking server at ${SERVER_HOST}..." +curl -sf --connect-timeout 5 "http://${SERVER_HOST}:${SERVER_HTTP_PORT}/health" >/dev/null || \ + { echo "ERROR: Server not reachable at http://${SERVER_HOST}:${SERVER_HTTP_PORT}"; exit 1; } + +REGISTER_RESPONSE=$(curl -sf -X POST "http://${SERVER_HOST}:${SERVER_HTTP_PORT}/v1/register" \ + -H "Content-Type: application/json" \ + -d "{\"public_key\":\"${CLIENT_PUBKEY}\"}") || { echo "ERROR: Registration failed"; exit 1; } + +ASSIGNED_IP=$(echo "$REGISTER_RESPONSE" | grep -o '"assigned_ip":"[^"]*"' | cut -d'"' -f4 | cut -d'/' -f1) +echo "[Register] Assigned IP: $ASSIGNED_IP" + +# --- Start boringtun with built-in Rust WS bridge --- +BEFORE_SOCKS=$(ls /var/run/wireguard/utun*.sock 2>/dev/null || true) + +echo "[Start] Launching boringtun (WS bridge → ${SERVER_HOST}:${SERVER_WS_PORT})..." +BT_WS_CONNECT="ws://${SERVER_HOST}:${SERVER_WS_PORT}" \ +BT_WS_LOCAL_PORT="$LOCAL_UDP_PORT" \ +WG_LOG_LEVEL=info WG_SUDO=1 \ + "$BT_BIN" utun --foreground --disable-drop-privileges --disable-connected-udp & +BT_PID=$! +sleep 3 +kill -0 $BT_PID 2>/dev/null || { echo "ERROR: boringtun exited"; exit 1; } + +# --- Find new UAPI socket --- +UTUN_NAME="" +for sock in /var/run/wireguard/utun*.sock; do + [ -e "$sock" ] || continue + echo "$BEFORE_SOCKS" | grep -q "$sock" && continue + UTUN_NAME=$(basename "$sock" .sock) + break +done +[ -n "$UTUN_NAME" ] || { echo "ERROR: No UAPI socket found"; kill $BT_PID; exit 1; } +echo "[Setup] Interface: $UTUN_NAME" + +# --- Configure WireGuard peer --- +if [ "$FULL_TUNNEL" = "1" ]; then + ALLOWED_IPS="allowed_ip=0.0.0.0/0" +else + ALLOWED_IPS="allowed_ip=10.0.0.0/24" +fi + +printf "set=1\nprivate_key=%s\npublic_key=%s\nendpoint=127.0.0.1:%s\n%b\npersistent_keepalive_interval=25\n\n" \ + "$CLIENT_PRIVKEY_HEX" "$SERVER_PUBKEY_HEX" "$LOCAL_UDP_PORT" "$ALLOWED_IPS" | \ + socat -t5 - UNIX-CONNECT:"/var/run/wireguard/${UTUN_NAME}.sock" >/dev/null 2>&1 + +# --- Interface + Routing --- +# Use /32 netmask to prevent macOS class-A default (/8 = 255.0.0.0). +# Without this, macOS routes ALL 10.x.x.x traffic to the TUN, creating +# a forwarding loop for non-existent VPN IPs. +ifconfig "$UTUN_NAME" inet "$ASSIGNED_IP" 10.0.0.1 netmask 255.255.255.255 up + +if [ "$FULL_TUNNEL" = "1" ]; then + echo "[Route] Full tunnel — all traffic through VPN" + # Keep server IP reachable via original gateway + ORIG_GW=$(route -n get default 2>/dev/null | awk '/gateway:/{print $2}') + if [ -n "$ORIG_GW" ]; then + route add -host "$SERVER_HOST" "$ORIG_GW" 2>/dev/null || true + fi + route add -net 0.0.0.0/1 -interface "$UTUN_NAME" 2>/dev/null || true + route add -net 128.0.0.0/1 -interface "$UTUN_NAME" 2>/dev/null || true +else + echo "[Route] Split tunnel — VPN subnet only" + route add -net 10.0.0.0/24 -interface "$UTUN_NAME" 2>/dev/null || true +fi + +echo "" +echo "=== Client running ===" +echo "Client IP: $ASSIGNED_IP" +echo "Server: $SERVER_HOST" +echo "Interface: $UTUN_NAME" +echo "WS Bridge: 127.0.0.1:$LOCAL_UDP_PORT → ws://${SERVER_HOST}:${SERVER_WS_PORT}" +echo "Mode: $([ "$FULL_TUNNEL" = "1" ] && echo "full tunnel" || echo "split tunnel")" +echo "" +echo "Test:" +echo " ping 10.0.0.1" +[ "$FULL_TUNNEL" = "1" ] && echo " ping 8.8.8.8" && echo " curl ifconfig.me" +echo "" + +# --- Cleanup --- +cleanup() { + echo "[Stop] Cleaning up..." + if [ "$FULL_TUNNEL" = "1" ]; then + route delete -host "$SERVER_HOST" 2>/dev/null || true + route delete -net 0.0.0.0/1 2>/dev/null || true + route delete -net 128.0.0.0/1 2>/dev/null || true + else + route delete -net 10.0.0.0/24 2>/dev/null || true + fi + kill $BT_PID 2>/dev/null + wait $BT_PID 2>/dev/null +} +trap cleanup EXIT INT TERM + +wait $BT_PID diff --git a/scripts/server.sh b/scripts/server.sh new file mode 100755 index 0000000..b083014 --- /dev/null +++ b/scripts/server.sh @@ -0,0 +1,133 @@ +#!/bin/bash +# ============================================================================= +# Veil VPN — Production Server (Linux) +# ============================================================================= +# +# Usage: +# sudo PUBLIC_IP= ./scripts/server.sh +# +# Prereqs: +# 1. ./scripts/gen-keys.sh +# 2. cargo build --release -p boringtun-cli --features payment +# 3. apt install socat +# +# Ports: +# 8089/tcp — HTTP API (registration) +# 8443/tcp — WebSocket proxy (WireGuard over WS) +# 51820/udp — WireGuard (direct UDP, optional) + +set -e +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT_DIR="$(dirname "$SCRIPT_DIR")" +KEYS_DIR="$SCRIPT_DIR/keys" +IFACE="wg0" + +[ -f "$KEYS_DIR/server.key" ] || { echo "ERROR: Run ./scripts/gen-keys.sh first"; exit 1; } +SERVER_PRIVKEY_HEX=$(cat "$KEYS_DIR/server.key" | base64 -d | xxd -p -c 64) + +PUBLIC_IP="${PUBLIC_IP:?ERROR: Set PUBLIC_IP env var (e.g. PUBLIC_IP=1.2.3.4 ./scripts/server.sh)}" + +BT_BIN="$ROOT_DIR/target/release/boringtun-cli" +[ -f "$BT_BIN" ] || BT_BIN="$ROOT_DIR/target/debug/boringtun-cli" +[ -f "$BT_BIN" ] || { echo "ERROR: Build first: cargo build --release -p boringtun-cli --features payment"; exit 1; } + +# --- Cleanup stale state --- +pkill -f "boringtun.*${IFACE}" 2>/dev/null || true +sleep 1 +ip link delete "$IFACE" 2>/dev/null || true +rm -f "/var/run/wireguard/${IFACE}.sock" + +echo "=== Veil VPN Server ===" +echo "Public IP: $PUBLIC_IP" +echo "HTTP API: http://$PUBLIC_IP:8089" +echo "WebSocket: ws://$PUBLIC_IP:8443" +echo "WireGuard: $PUBLIC_IP:51820/udp" +echo "Subnet: 10.0.0.0/24 (server=10.0.0.1)" +echo "" + +# --- Start boringtun --- +echo "[Start] Launching boringtun..." +BT_PAYMENT_SERVER=1 \ +BT_HTTP_BIND="0.0.0.0:8089" \ +BT_PUBLIC_IP="$PUBLIC_IP" \ +BT_WS_BIND="0.0.0.0:8443" \ +BT_WG_PORT=51820 \ +WG_LOG_LEVEL=info \ +WG_SUDO=1 \ + "$BT_BIN" "$IFACE" --foreground --disable-drop-privileges --disable-connected-udp & +BT_PID=$! +sleep 3 +kill -0 $BT_PID 2>/dev/null || { echo "ERROR: boringtun exited"; exit 1; } + +# --- Wait for UAPI socket --- +UAPI_SOCK="/var/run/wireguard/${IFACE}.sock" +for i in $(seq 1 10); do + [ -S "$UAPI_SOCK" ] && break + sleep 1 +done +[ -S "$UAPI_SOCK" ] || { echo "ERROR: UAPI socket not found"; kill $BT_PID; exit 1; } + +# --- Configure WireGuard via UAPI --- +echo "[Setup] Configuring WireGuard..." +UAPI_RESULT=$(printf "set=1\nprivate_key=%s\nlisten_port=51820\n\n" "$SERVER_PRIVKEY_HEX" | \ + socat -t5 - UNIX-CONNECT:"$UAPI_SOCK" 2>&1) +echo "[Setup] UAPI: $UAPI_RESULT" + +# --- Interface --- +# Use /32 for the server address. Do NOT use /24 here — it causes a +# kernel-level routing loop: decrypted packets for non-existent VPN IPs +# get routed back into the TUN by the kernel (ip_forward=1), boringtun +# reads them again, can't find a peer, drops, but kernel already wrote +# another copy. With /32, only 10.0.0.1 is on-link. +echo "[Setup] Configuring interface..." +ip link set "$IFACE" up +ip addr replace 10.0.0.1/32 dev "$IFACE" + +# --- IP Forwarding + NAT --- +echo "[Setup] Enabling IP forwarding and NAT..." +sysctl -w net.ipv4.ip_forward=1 >/dev/null + +INET_IF=$(ip route show default | awk '{print $5; exit}') +if [ -n "$INET_IF" ]; then + iptables -t nat -C POSTROUTING -s 10.0.0.0/24 -o "$INET_IF" -j MASQUERADE 2>/dev/null || \ + iptables -t nat -A POSTROUTING -s 10.0.0.0/24 -o "$INET_IF" -j MASQUERADE + # Allow forwarding from/to wg0 + iptables -C FORWARD -i "$IFACE" -j ACCEPT 2>/dev/null || \ + iptables -A FORWARD -i "$IFACE" -j ACCEPT + iptables -C FORWARD -o "$IFACE" -m state --state RELATED,ESTABLISHED -j ACCEPT 2>/dev/null || \ + iptables -A FORWARD -o "$IFACE" -m state --state RELATED,ESTABLISHED -j ACCEPT + echo "[Setup] NAT: 10.0.0.0/24 → $INET_IF" +else + echo "WARNING: Could not detect default interface for NAT" +fi + +# --- Verify --- +echo "" +echo "[Verify] Testing health endpoint..." +HEALTH=$(curl -s --connect-timeout 3 "http://127.0.0.1:8089/health" 2>&1) || true +[ -n "$HEALTH" ] && echo "[Verify] Health: $HEALTH" || echo "[Verify] WARNING: Health not responding" + +echo "" +echo "=== Server running ===" +echo "Register a client:" +echo " curl -X POST http://$PUBLIC_IP:8089/v1/register -d '{\"public_key\":\"\"}'" +echo "" +echo "Press Ctrl+C to stop" + +# --- Cleanup --- +cleanup() { + echo "" + echo "[Stop] Shutting down..." + kill $BT_PID 2>/dev/null + wait $BT_PID 2>/dev/null + ip link delete "$IFACE" 2>/dev/null || true + if [ -n "$INET_IF" ]; then + iptables -t nat -D POSTROUTING -s 10.0.0.0/24 -o "$INET_IF" -j MASQUERADE 2>/dev/null || true + iptables -D FORWARD -i "$IFACE" -j ACCEPT 2>/dev/null || true + iptables -D FORWARD -o "$IFACE" -m state --state RELATED,ESTABLISHED -j ACCEPT 2>/dev/null || true + fi + echo "[Stop] Done." +} +trap cleanup EXIT INT TERM + +wait $BT_PID diff --git a/scripts/test-client-linux.sh b/scripts/test-client-linux.sh new file mode 100755 index 0000000..bdd75b8 --- /dev/null +++ b/scripts/test-client-linux.sh @@ -0,0 +1,200 @@ +#!/bin/bash +# Start boringtun client on Linux, connecting to server via WebSocket proxy. +# +# Modes: +# Full tunnel (all traffic via remote host): +# sudo FULL_TUNNEL=1 ./scripts/test-client-linux.sh +# +# Local only (VPN subnet only, default): +# sudo ./scripts/test-client-linux.sh + +set -e +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT_DIR="$(dirname "$SCRIPT_DIR")" +KEYS_DIR="$SCRIPT_DIR/keys" + +SERVER_HOST="${SERVER_HOST:-37.27.29.160}" +SERVER_HTTP_PORT="${SERVER_HTTP_PORT:-8089}" +SERVER_WS_PORT="${SERVER_WS_PORT:-8443}" +LOCAL_UDP_PORT=51821 +FULL_TUNNEL="${FULL_TUNNEL:-0}" +IFACE="wg0" + +# --- Keys --- +[ -f "$KEYS_DIR/client.key" ] || { echo "ERROR: Run ./scripts/gen-keys.sh first"; exit 1; } +CLIENT_PRIVKEY_HEX=$(cat "$KEYS_DIR/client.key" | base64 -d | xxd -p -c 64) +CLIENT_PUBKEY=$(cat "$KEYS_DIR/client.pub") +SERVER_PUBKEY_HEX=$(cat "$KEYS_DIR/server.pub" | base64 -d | xxd -p -c 64) + +# --- Binary --- +BT_BIN="$ROOT_DIR/target/release/boringtun-cli" +[ -f "$BT_BIN" ] || BT_BIN="$ROOT_DIR/target/debug/boringtun-cli" +[ -f "$BT_BIN" ] || { echo "ERROR: Build first: cargo build -p boringtun-cli --features payment"; exit 1; } + +# --- Cleanup stale state --- +pkill -f "boringtun.*${IFACE}" 2>/dev/null || true +sleep 1 +ip link delete "$IFACE" 2>/dev/null || true +rm -f "/var/run/wireguard/${IFACE}.sock" + +# --- Register with server --- +echo "[Register] Checking server at ${SERVER_HOST}:${SERVER_HTTP_PORT}..." +curl -sf --connect-timeout 5 "http://${SERVER_HOST}:${SERVER_HTTP_PORT}/health" >/dev/null || \ + { echo "ERROR: Server not reachable at http://${SERVER_HOST}:${SERVER_HTTP_PORT}/health"; exit 1; } + +REGISTER_RESPONSE=$(curl -sf -X POST "http://${SERVER_HOST}:${SERVER_HTTP_PORT}/v1/register" \ + -H "Content-Type: application/json" \ + -d "{\"public_key\":\"${CLIENT_PUBKEY}\"}") || { echo "ERROR: Registration failed"; exit 1; } + +ASSIGNED_IP=$(echo "$REGISTER_RESPONSE" | grep -o '"assigned_ip":"[^"]*"' | cut -d'"' -f4 | cut -d'/' -f1) +echo "[Register] Assigned IP: $ASSIGNED_IP" + +# --- Start boringtun with WS bridge --- +echo "[Start] Launching boringtun (WS bridge → ${SERVER_HOST}:${SERVER_WS_PORT})..." +WG_LOG_LEVEL=debug \ +WG_SUDO=1 \ +BT_WS_CONNECT="ws://${SERVER_HOST}:${SERVER_WS_PORT}" \ +BT_WS_LOCAL_PORT="$LOCAL_UDP_PORT" \ + "$BT_BIN" "$IFACE" --foreground --disable-drop-privileges \ + --ws-connect "ws://${SERVER_HOST}:${SERVER_WS_PORT}" \ + --ws-local-port "$LOCAL_UDP_PORT" & +BT_PID=$! +sleep 3 + +if ! kill -0 $BT_PID 2>/dev/null; then + echo "ERROR: boringtun exited. Check /tmp/boringtun.out for details." + exit 1 +fi + +echo "[Setup] Interface: $IFACE (PID: $BT_PID)" + +# --- Wait for UAPI socket --- +UAPI_SOCK="/var/run/wireguard/${IFACE}.sock" +for i in $(seq 1 10); do + [ -S "$UAPI_SOCK" ] && break + sleep 1 +done +[ -S "$UAPI_SOCK" ] || { echo "ERROR: UAPI socket not found at $UAPI_SOCK"; kill $BT_PID; exit 1; } + +# --- Configure WireGuard --- +if [ "$FULL_TUNNEL" = "1" ]; then + ALLOWED_IPS="allowed_ip=0.0.0.0/0" +else + ALLOWED_IPS="allowed_ip=10.0.0.0/24" +fi + +echo "[Setup] Configuring WireGuard peer..." +printf "set=1\nprivate_key=%s\npublic_key=%s\nendpoint=127.0.0.1:%s\n%b\npersistent_keepalive_interval=25\n\n" \ + "$CLIENT_PRIVKEY_HEX" "$SERVER_PUBKEY_HEX" "$LOCAL_UDP_PORT" "$ALLOWED_IPS" | \ + socat -t5 - UNIX-CONNECT:"$UAPI_SOCK" >/dev/null 2>&1 + +# --- Interface + Routing --- +echo "[Setup] Configuring interface..." +ip link set "$IFACE" up +ip addr replace "${ASSIGNED_IP}/24" dev "$IFACE" + +ORIG_GW="" +ORIG_IF="" + +if [ "$FULL_TUNNEL" = "1" ]; then + echo "[Route] Full tunnel — all traffic through remote host" + ORIG_GW=$(ip route show default | awk '{print $3; exit}') + ORIG_IF=$(ip route show default | awk '{print $5; exit}') + + # Keep server reachable via original gateway + if [ -n "$ORIG_GW" ]; then + ip route add "$SERVER_HOST" via "$ORIG_GW" dev "$ORIG_IF" 2>/dev/null || true + fi + + # Override default: 0/1 + 128/1 trick + ip route add 0.0.0.0/1 dev "$IFACE" 2>/dev/null || true + ip route add 128.0.0.0/1 dev "$IFACE" 2>/dev/null || true + + # Set DNS + if command -v resolvconf >/dev/null 2>&1; then + printf "nameserver 1.1.1.1\nnameserver 8.8.8.8\n" | resolvconf -a "$IFACE" 2>/dev/null || true + fi +else + echo "[Route] Local only — VPN subnet (10.0.0.0/24)" + ip route replace 10.0.0.0/24 dev "$IFACE" 2>/dev/null || true +fi + +echo "" +echo "=== Client running ===" +echo "Client IP: $ASSIGNED_IP" +echo "Interface: $IFACE" +echo "WS Bridge: 127.0.0.1:$LOCAL_UDP_PORT → ws://${SERVER_HOST}:${SERVER_WS_PORT}" +echo "Mode: $([ "$FULL_TUNNEL" = "1" ] && echo "full tunnel (remote)" || echo "local only")" +echo "" +echo "Test:" +echo " ping 10.0.0.1" +if [ "$FULL_TUNNEL" = "1" ]; then + echo " ping 8.8.8.8" + echo " curl ifconfig.me" +fi +echo "" +echo "Press Ctrl+C to stop" + +# --- Cleanup --- +cleanup() { + echo "" + echo "[Stop] Cleaning up..." + if [ "$FULL_TUNNEL" = "1" ]; then + ip route delete "$SERVER_HOST" 2>/dev/null || true + ip route delete 0.0.0.0/1 dev "$IFACE" 2>/dev/null || true + ip route delete 128.0.0.0/1 dev "$IFACE" 2>/dev/null || true + if command -v resolvconf >/dev/null 2>&1; then + resolvconf -d "$IFACE" 2>/dev/null || true + fi + else + ip route delete 10.0.0.0/24 dev "$IFACE" 2>/dev/null || true + fi + ip link delete "$IFACE" 2>/dev/null || true + kill $BT_PID 2>/dev/null + wait $BT_PID 2>/dev/null + echo "[Stop] Done." +} +trap cleanup EXIT INT TERM + +# --- Keep alive: monitor boringtun and restart if it dies --- +while true; do + if ! kill -0 $BT_PID 2>/dev/null; then + echo "[Monitor] boringtun died, restarting..." + rm -f "$UAPI_SOCK" + + WG_LOG_LEVEL=debug \ + WG_SUDO=1 \ + BT_WS_CONNECT="ws://${SERVER_HOST}:${SERVER_WS_PORT}" \ + BT_WS_LOCAL_PORT="$LOCAL_UDP_PORT" \ + "$BT_BIN" "$IFACE" --foreground --disable-drop-privileges \ + --ws-connect "ws://${SERVER_HOST}:${SERVER_WS_PORT}" \ + --ws-local-port "$LOCAL_UDP_PORT" & + BT_PID=$! + sleep 3 + + if ! kill -0 $BT_PID 2>/dev/null; then + echo "[Monitor] boringtun failed to restart, exiting." + exit 1 + fi + + # Wait for socket and reconfigure + for i in $(seq 1 10); do + [ -S "$UAPI_SOCK" ] && break + sleep 1 + done + + if [ -S "$UAPI_SOCK" ]; then + printf "set=1\nprivate_key=%s\npublic_key=%s\nendpoint=127.0.0.1:%s\n%b\npersistent_keepalive_interval=25\n\n" \ + "$CLIENT_PRIVKEY_HEX" "$SERVER_PUBKEY_HEX" "$LOCAL_UDP_PORT" "$ALLOWED_IPS" | \ + socat -t5 - UNIX-CONNECT:"$UAPI_SOCK" >/dev/null 2>&1 + + ip link set "$IFACE" up 2>/dev/null || true + ip addr replace "${ASSIGNED_IP}/24" dev "$IFACE" 2>/dev/null || true + echo "[Monitor] boringtun restarted and reconfigured." + else + echo "[Monitor] UAPI socket not found after restart, exiting." + exit 1 + fi + fi + sleep 5 +done diff --git a/scripts/test-client.sh b/scripts/test-client.sh index acc392f..7f5fcca 100755 --- a/scripts/test-client.sh +++ b/scripts/test-client.sh @@ -45,7 +45,7 @@ BEFORE_SOCKS=$(ls /var/run/wireguard/utun*.sock 2>/dev/null || true) BT_WS_CONNECT="ws://${SERVER_HOST}:${SERVER_WS_PORT}" \ BT_WS_LOCAL_PORT="$LOCAL_UDP_PORT" \ -WG_LOG_LEVEL=info WG_SUDO=1 "$BT_BIN" utun --foreground --disable-drop-privileges & +WG_LOG_LEVEL=info WG_SUDO=1 "$BT_BIN" utun --foreground --disable-drop-privileges --disable-connected-udp & BT_PID=$! sleep 3 kill -0 $BT_PID 2>/dev/null || { echo "ERROR: boringtun exited"; exit 1; } diff --git a/scripts/test-server.sh b/scripts/test-server.sh index f94e895..f5e808d 100755 --- a/scripts/test-server.sh +++ b/scripts/test-server.sh @@ -119,8 +119,12 @@ fi ifconfig "$UTUN_NAME" 10.0.0.1 10.0.0.2 up 2>/dev/null || \ echo "WARNING: Could not assign IP (need sudo?)" -# Route the VPN subnet through the tunnel so replies go back -route add -net 10.0.0.0/24 -interface "$UTUN_NAME" 2>/dev/null || true +# Route only assigned client IPs through the tunnel (NOT the whole /24). +# A blanket /24 route causes a kernel-level routing loop for non-existent IPs +# (packet goes to TUN → boringtun → back to TUN → repeat). +# The HTTP API adds /32 routes per peer on registration; for local testing +# we pre-add the first client IP. +route add -host 10.0.0.2 -interface "$UTUN_NAME" 2>/dev/null || true # Enable IP forwarding and NAT for full tunnel support echo "[Setup] Enabling IP forwarding and NAT..." From 18d79044ae68937aee5cb7324cdd8e4c10e5bfed Mon Sep 17 00:00:00 2001 From: UYUYANMARUL Date: Sun, 5 Apr 2026 03:47:06 +0200 Subject: [PATCH 9/9] add ws proxy --- .gitignore | 1 + Cargo.lock | 100 +++++++++++-- boringtun-cli/src/main.rs | 89 ++++++++++-- boringtun/Cargo.toml | 3 +- boringtun/src/device/http_api.rs | 153 +++++++++++++++++--- boringtun/src/device/mod.rs | 105 ++++++++++---- boringtun/src/device/ws_bridge.rs | 159 ++++++++++++++++++++ boringtun/src/device/ws_proxy.rs | 233 ++++++++++++++++++++++++++++++ 8 files changed, 767 insertions(+), 76 deletions(-) create mode 100644 boringtun/src/device/ws_bridge.rs create mode 100644 boringtun/src/device/ws_proxy.rs diff --git a/.gitignore b/.gitignore index b4e53ce..2c6acc6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ /target **/*.rs.bk .vscode/ +*/node_modules diff --git a/Cargo.lock b/Cargo.lock index 49f7ce7..a12a29e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -152,6 +152,7 @@ dependencies = [ "tiny_http", "tracing", "tracing-subscriber", + "tungstenite", "untrusted", "x25519-dalek", ] @@ -180,6 +181,12 @@ version = "3.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d261e256854913907f67ed06efbc3338dfe6179796deefc1ff763fc1aee5535" +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "bytes" version = "1.1.0" @@ -500,6 +507,12 @@ dependencies = [ "libc", ] +[[package]] +name = "data-encoding" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" + [[package]] name = "der" version = "0.7.10" @@ -512,9 +525,9 @@ dependencies = [ [[package]] name = "deranged" -version = "0.5.8" +version = "0.3.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4" dependencies = [ "powerfmt", ] @@ -1044,9 +1057,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.2.1" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" [[package]] name = "num-traits" @@ -1211,6 +1224,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -1229,6 +1251,27 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + [[package]] name = "rand_core" version = "0.6.4" @@ -1500,6 +1543,17 @@ dependencies = [ "serde", ] +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sha2" version = "0.10.9" @@ -1686,30 +1740,30 @@ dependencies = [ [[package]] name = "time" -version = "0.3.47" +version = "0.3.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +checksum = "5dfd88e563464686c916c7e46e623e520ddc6d79fa6641390f2e3fa86e83e885" dependencies = [ "deranged", "itoa", "num-conv", "powerfmt", - "serde_core", + "serde", "time-core", "time-macros", ] [[package]] name = "time-core" -version = "0.1.8" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" +checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" [[package]] name = "time-macros" -version = "0.2.27" +version = "0.2.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +checksum = "3f252a68540fde3a3877aeea552b832b40ab9a69e318efd078774a01ddee1ccf" dependencies = [ "num-conv", "time-core", @@ -1883,6 +1937,24 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand", + "sha1", + "thiserror", + "utf-8", +] + [[package]] name = "typenum" version = "1.15.0" @@ -1943,6 +2015,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + [[package]] name = "valuable" version = "0.1.0" diff --git a/boringtun-cli/src/main.rs b/boringtun-cli/src/main.rs index 37439f2..9c039e8 100644 --- a/boringtun-cli/src/main.rs +++ b/boringtun-cli/src/main.rs @@ -92,6 +92,24 @@ fn main() { Arg::new("disable-connected-udp") .long("disable-connected-udp") .help("Disable connected UDP sockets to each peer"), + Arg::new("ws-bind") + .long("ws-bind") + .takes_value(true) + .env("BT_WS_BIND") + .help("WebSocket proxy bind address (e.g. 0.0.0.0:8443)") + .default_value(""), + Arg::new("ws-connect") + .long("ws-connect") + .takes_value(true) + .env("BT_WS_CONNECT") + .help("Client WS bridge: connect to server WS URL (e.g. ws://1.2.3.4:8443)") + .default_value(""), + Arg::new("ws-local-port") + .long("ws-local-port") + .takes_value(true) + .env("BT_WS_LOCAL_PORT") + .help("Client WS bridge: local UDP port for boringtun ↔ bridge") + .default_value("51821"), #[cfg(target_os = "linux")] Arg::new("disable-multi-queue") .long("disable-multi-queue") @@ -191,21 +209,23 @@ fn main() { tracing::info!("BoringTun started successfully"); - // HTTP registration API (server mode only) + // Payment features: server API, WS proxy, client WS bridge #[cfg(feature = "payment")] { use std::sync::atomic::AtomicBool; use std::sync::Arc; + let shutdown_flag = Arc::new(AtomicBool::new(false)); + let is_server = std::env::var("BT_PAYMENT_SERVER") .map(|v| v == "1" || v.eq_ignore_ascii_case("true")) .unwrap_or(false); if is_server { - let bind_addr = std::env::var("BT_HTTP_BIND") - .unwrap_or_else(|_| "0.0.0.0:8080".to_string()); - let public_ip = std::env::var("BT_PUBLIC_IP") - .unwrap_or_else(|_| "127.0.0.1".to_string()); + let bind_addr = + std::env::var("BT_HTTP_BIND").unwrap_or_else(|_| "0.0.0.0:8080".to_string()); + let public_ip = + std::env::var("BT_PUBLIC_IP").unwrap_or_else(|_| "127.0.0.1".to_string()); let payment_config = boringtun::payment::PaymentConfig::default(); let snapshot = boringtun::device::http_api::PaymentConfigSnapshot { @@ -217,8 +237,8 @@ fn main() { }; // On macOS, "utun" gets assigned as "utun6" etc. Find the actual socket. - let actual_tun_name = find_uapi_interface(tun_name) - .unwrap_or_else(|| tun_name.to_string()); + let actual_tun_name = + find_uapi_interface(tun_name).unwrap_or_else(|| tun_name.to_string()); tracing::info!("Registration API using interface: {}", actual_tun_name); let state = Arc::new(boringtun::device::http_api::RegistrationState::new( @@ -228,8 +248,6 @@ fn main() { public_ip, )); - let shutdown_flag = Arc::new(AtomicBool::new(false)); - // HTTP server thread let state_http = Arc::clone(&state); let shutdown_http = Arc::clone(&shutdown_flag); @@ -252,6 +270,59 @@ fn main() { .expect("Failed to spawn reaper thread"); tracing::info!("Registration API on {}", bind_addr); + + // WebSocket proxy thread + let ws_bind = std::env::var("BT_WS_BIND") + .unwrap_or_else(|_| matches.value_of("ws-bind").unwrap_or("").to_string()); + if !ws_bind.is_empty() { + let wg_port: u16 = std::env::var("BT_WG_PORT") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(51820); + let shutdown_ws = Arc::clone(&shutdown_flag); + let ws_bind_log = ws_bind.clone(); + std::thread::Builder::new() + .name("ws-proxy".into()) + .spawn(move || { + if let Err(e) = std::panic::catch_unwind(|| { + boringtun::device::ws_proxy::run_ws_proxy( + &ws_bind, + wg_port, + shutdown_ws, + ); + }) { + tracing::error!("WS proxy thread panicked: {:?}", e); + } + }) + .expect("Failed to spawn WS proxy thread"); + + tracing::info!("WebSocket proxy on {}", ws_bind_log); + } + } + + // Client-side WS bridge (connects to remote server's WS proxy) + let ws_connect = std::env::var("BT_WS_CONNECT") + .unwrap_or_else(|_| matches.value_of("ws-connect").unwrap_or("").to_string()); + if !ws_connect.is_empty() { + let ws_local_port: u16 = std::env::var("BT_WS_LOCAL_PORT") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or_else(|| matches.value_of_t("ws-local-port").unwrap_or(51821)); + + let shutdown_bridge = Arc::clone(&shutdown_flag); + let ws_url = ws_connect.clone(); + std::thread::Builder::new() + .name("ws-bridge".into()) + .spawn(move || { + boringtun::device::ws_bridge::run_ws_bridge( + ws_local_port, + &ws_url, + shutdown_bridge, + ); + }) + .expect("Failed to spawn WS bridge thread"); + + tracing::info!("WS bridge: UDP :{} ↔ {}", ws_local_port, ws_connect); } } diff --git a/boringtun/Cargo.toml b/boringtun/Cargo.toml index e7792d1..dc48e4e 100644 --- a/boringtun/Cargo.toml +++ b/boringtun/Cargo.toml @@ -16,7 +16,7 @@ edition = "2018" [features] default = [] device = ["socket2", "thiserror"] -payment = ["device", "k256", "hkdf", "sha2", "sha3", "reqwest", "serde", "serde_json", "tiny_http"] +payment = ["device", "k256", "hkdf", "sha2", "sha3", "reqwest", "serde", "serde_json", "tiny_http", "tungstenite"] jni-bindings = ["ffi-bindings", "jni"] ffi-bindings = ["tracing-subscriber"] # mocks std::time::Instant with mock_instant @@ -54,6 +54,7 @@ reqwest = { version = "0.12", default-features = false, features = ["json", "rus serde = { version = "1", features = ["derive"], optional = true } serde_json = { version = "1", optional = true } tiny_http = { version = "0.12", optional = true } +tungstenite = { version = "0.24", optional = true } [target.'cfg(unix)'.dependencies] nix = { version = "0.25", default-features = false, features = [ diff --git a/boringtun/src/device/http_api.rs b/boringtun/src/device/http_api.rs index 7cd1b3b..85b0abf 100644 --- a/boringtun/src/device/http_api.rs +++ b/boringtun/src/device/http_api.rs @@ -106,7 +106,9 @@ impl RegistrationState { // === UAPI Client === /// Query UAPI get=1. Returns map of hex_pubkey → Option. -fn uapi_get_peers(tun_name: &str) -> Result<(Option, Option, HashMap>), String> { +fn uapi_get_peers( + tun_name: &str, +) -> Result<(Option, Option, HashMap>), String> { let path = format!("{}/{}.sock", UAPI_SOCK_DIR, tun_name); let stream = UnixStream::connect(&path).map_err(|e| format!("UAPI connect: {}", e))?; stream.set_read_timeout(Some(UAPI_TIMEOUT)).ok(); @@ -193,7 +195,9 @@ fn uapi_set_peer(tun_name: &str, pubkey_hex: &str, allowed_ip: Ipv4Addr) -> Resu let mut reader = BufReader::new(&stream); let mut response = String::new(); - reader.read_line(&mut response).map_err(|e| format!("UAPI read: {}", e))?; + reader + .read_line(&mut response) + .map_err(|e| format!("UAPI read: {}", e))?; if response.trim() == "errno=0" { Ok(()) @@ -217,11 +221,72 @@ fn uapi_remove_peer(tun_name: &str, pubkey_hex: &str) -> Result { let mut reader = BufReader::new(&stream); let mut response = String::new(); - reader.read_line(&mut response).map_err(|e| format!("UAPI read: {}", e))?; + reader + .read_line(&mut response) + .map_err(|e| format!("UAPI read: {}", e))?; Ok(response.trim() == "errno=0") } +// === Kernel Route Management === + +/// Add a /32 host route for a client IP via the TUN interface. +/// Without this, the kernel has no route to deliver decrypted response packets +/// back into the tunnel (since we use /32 on the interface to prevent loops). +fn add_kernel_route(tun_name: &str, ip: Ipv4Addr) { + let result = if cfg!(target_os = "linux") { + std::process::Command::new("ip") + .args(["route", "add", &format!("{}/32", ip), "dev", tun_name]) + .output() + } else { + std::process::Command::new("route") + .args(["add", "-host", &ip.to_string(), "-interface", tun_name]) + .output() + }; + match result { + Ok(out) if out.status.success() => { + tracing::info!(ip = %ip, tun = %tun_name, "Kernel route added"); + } + Ok(out) => { + let stderr = String::from_utf8_lossy(&out.stderr); + // "File exists" / "RTNETLINK: File exists" means route already present — not an error + if stderr.contains("exist") { + tracing::debug!(ip = %ip, "Kernel route already exists"); + } else { + tracing::warn!(ip = %ip, stderr = %stderr, "Failed to add kernel route"); + } + } + Err(e) => { + tracing::warn!(ip = %ip, error = %e, "Failed to run route command"); + } + } +} + +/// Remove a /32 host route when a peer is reaped. +fn remove_kernel_route(tun_name: &str, ip: Ipv4Addr) { + let result = if cfg!(target_os = "linux") { + std::process::Command::new("ip") + .args(["route", "del", &format!("{}/32", ip), "dev", tun_name]) + .output() + } else { + std::process::Command::new("route") + .args(["delete", "-host", &ip.to_string(), "-interface", tun_name]) + .output() + }; + match result { + Ok(out) if out.status.success() => { + tracing::info!(ip = %ip, tun = %tun_name, "Kernel route removed"); + } + Ok(out) => { + let stderr = String::from_utf8_lossy(&out.stderr); + tracing::debug!(ip = %ip, stderr = %stderr, "Kernel route removal note"); + } + Err(e) => { + tracing::warn!(ip = %ip, error = %e, "Failed to run route delete command"); + } + } +} + // === Registration Handler === fn handle_registration(state: &RegistrationState, pubkey_base64: &str) -> (u16, String) { @@ -229,8 +294,14 @@ fn handle_registration(state: &RegistrationState, pubkey_base64: &str) -> (u16, let pubkey_bytes = match base64::decode(pubkey_base64.trim()) { Ok(bytes) if bytes.len() == 32 => bytes, Ok(bytes) => { - tracing::warn!("Registration rejected: invalid key length {} bytes", bytes.len()); - return (400, r#"{"error":"public key must be exactly 32 bytes"}"#.into()); + tracing::warn!( + "Registration rejected: invalid key length {} bytes", + bytes.len() + ); + return ( + 400, + r#"{"error":"public key must be exactly 32 bytes"}"#.into(), + ); } Err(_) => { tracing::warn!("Registration rejected: invalid base64"); @@ -269,11 +340,18 @@ fn handle_registration(state: &RegistrationState, pubkey_base64: &str) -> (u16, state.subnet_prefix[2], octet, ); - return (200, build_success_response( - ip, &state.payment_config, &state.public_ip, - inner.server_pubkey_cache.as_deref().unwrap_or(""), - inner.listen_port_cache.unwrap_or(51820), - )); + // Re-ensure kernel route exists (may be lost after server restart) + // add_kernel_route(&state.tun_name, ip); + return ( + 200, + build_success_response( + ip, + &state.payment_config, + &state.public_ip, + inner.server_pubkey_cache.as_deref().unwrap_or(""), + inner.listen_port_cache.unwrap_or(51820), + ), + ); } } @@ -298,7 +376,12 @@ fn handle_registration(state: &RegistrationState, pubkey_base64: &str) -> (u16, inner.available_ips.remove(&octet); inner.registered_peers.insert(pubkey_hex.clone()); inner.peer_to_octet.insert(pubkey_hex.clone(), octet); - inner.peer_registered_at.insert(pubkey_hex.clone(), Instant::now()); + inner + .peer_registered_at + .insert(pubkey_hex.clone(), Instant::now()); + + // Add kernel route so the OS knows how to reach this client via the TUN + // add_kernel_route(&state.tun_name, assigned_ip); tracing::info!( pubkey = %pubkey_hex, @@ -307,11 +390,16 @@ fn handle_registration(state: &RegistrationState, pubkey_base64: &str) -> (u16, "Peer registered" ); - (200, build_success_response( - assigned_ip, &state.payment_config, &state.public_ip, - inner.server_pubkey_cache.as_deref().unwrap_or(""), - inner.listen_port_cache.unwrap_or(51820), - )) + ( + 200, + build_success_response( + assigned_ip, + &state.payment_config, + &state.public_ip, + inner.server_pubkey_cache.as_deref().unwrap_or(""), + inner.listen_port_cache.unwrap_or(51820), + ), + ) } Err(e) => { tracing::warn!(pubkey = %pubkey_hex, error = %e, "UAPI set_peer failed"); @@ -418,7 +506,10 @@ pub fn run_http_server( (tiny_http::Method::Post, "/v1/register") => { let mut body_buf = Vec::new(); let reader = request.as_reader(); - match reader.take(MAX_BODY_BYTES as u64 + 1).read_to_end(&mut body_buf) { + match reader + .take(MAX_BODY_BYTES as u64 + 1) + .read_to_end(&mut body_buf) + { Ok(n) if n > MAX_BODY_BYTES => { (413u16, r#"{"error":"request body too large"}"#.into()) } @@ -500,7 +591,11 @@ pub fn run_reaper(state: Arc, shutdown_flag: Arc) Some(Some(handshake_sec)) if *handshake_sec > 0 => { // handshake_sec is a Duration (seconds since last handshake), NOT an epoch timestamp let age = *handshake_sec; - tracing::info!(age_secs = age, threshold = STALE_PEER_SECS, "Reaper: has handshake"); + tracing::info!( + age_secs = age, + threshold = STALE_PEER_SECS, + "Reaper: has handshake" + ); if age > STALE_PEER_SECS { to_remove.push((pubkey_hex.clone(), octet)); } @@ -508,7 +603,11 @@ pub fn run_reaper(state: Arc, shutdown_flag: Arc) Some(Some(_)) | Some(None) => { if let Some(registered_at) = inner.peer_registered_at.get(pubkey_hex) { let age = now_instant.duration_since(*registered_at).as_secs(); - tracing::info!(age_secs = age, threshold = STALE_NEVER_CONNECTED_SECS, "Reaper: never handshaked"); + tracing::info!( + age_secs = age, + threshold = STALE_NEVER_CONNECTED_SECS, + "Reaper: never handshaked" + ); if age > STALE_NEVER_CONNECTED_SECS { to_remove.push((pubkey_hex.clone(), octet)); } @@ -527,15 +626,23 @@ pub fn run_reaper(state: Arc, shutdown_flag: Arc) match uapi_remove_peer(&state.tun_name, pubkey_hex) { Ok(true) => { // errno=0 — safe to update state + let ip = Ipv4Addr::new( + state.subnet_prefix[0], + state.subnet_prefix[1], + state.subnet_prefix[2], + *octet, + ); + // remove_kernel_route(&state.tun_name, ip); + inner.available_ips.insert(*octet); inner.registered_peers.remove(pubkey_hex); inner.peer_to_octet.remove(pubkey_hex); inner.peer_registered_at.remove(pubkey_hex); tracing::info!( pubkey = %pubkey_hex, - octet = octet, + ip = %ip, pool_remaining = inner.available_ips.len(), - "Reaper: removed stale peer" + "Reaper: removed stale peer + kernel route" ); } Ok(false) => { @@ -558,9 +665,7 @@ pub fn run_reaper(state: Arc, shutdown_flag: Arc) // Prune old rate limiter entries (P2-A fix) { let mut rl = state.rate_limiter.lock().unwrap(); - rl.retain(|_, bucket| { - bucket.last_refill.elapsed().as_secs() < RATE_LIMITER_PRUNE_SECS - }); + rl.retain(|_, bucket| bucket.last_refill.elapsed().as_secs() < RATE_LIMITER_PRUNE_SECS); } if !to_remove.is_empty() { diff --git a/boringtun/src/device/mod.rs b/boringtun/src/device/mod.rs index 64c0702..c1754cc 100644 --- a/boringtun/src/device/mod.rs +++ b/boringtun/src/device/mod.rs @@ -3,13 +3,17 @@ pub mod allowed_ips; pub mod api; -#[cfg(feature = "payment")] -pub mod http_api; mod dev_lock; pub mod drop_privileges; +#[cfg(feature = "payment")] +pub mod http_api; #[cfg(test)] mod integration_tests; pub mod peer; +#[cfg(feature = "payment")] +pub mod ws_bridge; +#[cfg(feature = "payment")] +pub mod ws_proxy; #[cfg(any(target_os = "macos", target_os = "ios", target_os = "tvos"))] #[path = "kqueue.rs"] @@ -358,7 +362,10 @@ impl Device { peer.quota = Some(crate::payment::quota::BandwidthQuota::new( crate::payment::quota::DEFAULT_QUOTA_BYTES, )); - tracing::info!("Peer added with {}MB quota", crate::payment::quota::DEFAULT_QUOTA_BYTES / 1024 / 1024); + tracing::info!( + "Peer added with {}MB quota", + crate::payment::quota::DEFAULT_QUOTA_BYTES / 1024 / 1024 + ); } let peer = Arc::new(Mutex::new(peer)); @@ -507,10 +514,16 @@ impl Device { { let key_bytes = private_key.to_bytes(); let wallet = crate::payment::wallet::PaymentWallet::from_wireguard_key(&key_bytes); - let role = if self.payment_config.is_server { "server" } else { "client" }; + let role = if self.payment_config.is_server { + "server" + } else { + "client" + }; tracing::info!( "Payment mode: {} | chain: {} | wallet: {}", - role, self.payment_config.chain_id, wallet.ethereum_address_hex() + role, + self.payment_config.chain_id, + wallet.ethereum_address_hex() ); if !self.payment_config.is_server { // Client: auto-deposit USDC into Gateway Wallet @@ -522,11 +535,9 @@ impl Device { } self.payment_wallet = Some(Arc::new(wallet)); if self.payment_config.is_server { - self.settlement_client = Some( - crate::payment::settlement::SettlementClient::new( - &self.payment_config.gateway_api_url, - ), - ); + self.settlement_client = Some(crate::payment::settlement::SettlementClient::new( + &self.payment_config.gateway_api_url, + )); tracing::info!("Settlement client: {}", self.payment_config.gateway_api_url); } } @@ -718,15 +729,20 @@ impl Device { { use crate::payment::protocol; if protocol::is_payment_signal(packet) { - if let Some(payload) = protocol::extract_signal_payload(packet) { + if let Some(payload) = protocol::extract_signal_payload(packet) + { if let Some(dst) = protocol::dst_ipv4(packet) { if dst == protocol::PAYMENT_GATEWAY_IP { // Client → Server: PaymentSubmit - Self::handle_payment_submit(d, &mut p, payload, &udp, &addr); + Self::handle_payment_submit( + d, &mut p, payload, &udp, &addr, + ); } else if let Some(src) = protocol::src_ipv4(packet) { if src == protocol::PAYMENT_GATEWAY_IP { // Server → Client: handle on client side - Self::handle_payment_signal_client(d, &mut p, payload, &udp, &addr); + Self::handle_payment_signal_client( + d, &mut p, payload, &udp, &addr, + ); } } } @@ -744,7 +760,9 @@ impl Device { continue; } if !quota.consume(packet.len() as u64) { - tracing::info!("Peer quota exhausted (udp inbound v4)"); + tracing::info!( + "Peer quota exhausted (udp inbound v4)" + ); Self::send_payment_required(d, &mut p, &udp, &addr); continue; } @@ -847,19 +865,24 @@ impl Device { { use crate::payment::protocol; if protocol::is_payment_signal(packet) { - if let Some(payload) = protocol::extract_signal_payload(packet) { + if let Some(payload) = protocol::extract_signal_payload(packet) + { if let Some(dst) = protocol::dst_ipv4(packet) { if dst == protocol::PAYMENT_GATEWAY_IP { let sock_addr = socket2::SockAddr::from( - p.endpoint().addr.unwrap() + p.endpoint().addr.unwrap(), + ); + Self::handle_payment_submit( + d, &mut p, payload, &udp, &sock_addr, ); - Self::handle_payment_submit(d, &mut p, payload, &udp, &sock_addr); } else if let Some(src) = protocol::src_ipv4(packet) { if src == protocol::PAYMENT_GATEWAY_IP { let sock_addr = socket2::SockAddr::from( - p.endpoint().addr.unwrap() + p.endpoint().addr.unwrap(), + ); + Self::handle_payment_signal_client( + d, &mut p, payload, &udp, &sock_addr, ); - Self::handle_payment_signal_client(d, &mut p, payload, &udp, &sock_addr); } } } @@ -876,11 +899,15 @@ impl Device { continue; } if !quota.consume(packet.len() as u64) { - tracing::info!("Peer quota exhausted (conn inbound v4)"); + tracing::info!( + "Peer quota exhausted (conn inbound v4)" + ); let sock_addr = socket2::SockAddr::from( - p.endpoint().addr.unwrap() + p.endpoint().addr.unwrap(), + ); + Self::send_payment_required( + d, &mut p, &udp, &sock_addr, ); - Self::send_payment_required(d, &mut p, &udp, &sock_addr); continue; } } @@ -898,7 +925,9 @@ impl Device { continue; } if !quota.consume(packet.len() as u64) { - tracing::info!("Peer quota exhausted (conn inbound v6)"); + tracing::info!( + "Peer quota exhausted (conn inbound v6)" + ); continue; } } @@ -1065,7 +1094,9 @@ impl Device { tracing::info!("PaymentRequired sent to peer {}", peer_ip); } TunnResult::Err(e) => tracing::error!("Failed to encapsulate PaymentRequired: {:?}", e), - _ => tracing::warn!("Unexpected encapsulate result for PaymentRequired (no active session?)"), + _ => tracing::warn!( + "Unexpected encapsulate result for PaymentRequired (no active session?)" + ), } } @@ -1077,8 +1108,8 @@ impl Device { udp: &socket2::Socket, addr: &socket2::SockAddr, ) { - use crate::payment::protocol::*; use crate::payment::eip3009; + use crate::payment::protocol::*; let wallet = match d.payment_wallet.as_ref() { Some(w) => w, @@ -1164,11 +1195,15 @@ impl Device { Some((IpAddr::V4(ip), _)) => ip, _ => return, }; - let accepted = PaymentAccepted { new_quota_bytes: d.payment_config.quota_bytes }; + let accepted = PaymentAccepted { + new_quota_bytes: d.payment_config.quota_bytes, + }; let tlv = accepted.encode(); let ip_packet = build_signal_packet(PAYMENT_GATEWAY_IP, peer_ip, &tlv); let mut enc_buf = [0u8; MAX_PAYMENT_PACKET_SIZE]; - if let TunnResult::WriteToNetwork(enc) = p.tunnel.encapsulate(&ip_packet, &mut enc_buf) { + if let TunnResult::WriteToNetwork(enc) = + p.tunnel.encapsulate(&ip_packet, &mut enc_buf) + { let _: Result<_, _> = udp.send_to(enc, addr); tracing::info!("PaymentAccepted sent to peer {}", peer_ip); } @@ -1200,7 +1235,9 @@ impl Device { Ok(resp) => { tracing::warn!( "Settlement REJECTED: reason={:?} msg={:?} message={:?}", - resp.error_reason, resp.error_message, resp.message + resp.error_reason, + resp.error_message, + resp.message ); return; } @@ -1229,7 +1266,9 @@ impl Device { tracing::info!("PaymentAccepted sent to peer {}", peer_ip); } TunnResult::Err(e) => tracing::error!("Failed to encapsulate PaymentAccepted: {:?}", e), - _ => tracing::warn!("Unexpected encapsulate result for PaymentAccepted (no active session?)"), + _ => tracing::warn!( + "Unexpected encapsulate result for PaymentAccepted (no active session?)" + ), } } @@ -1315,8 +1354,12 @@ impl Device { let _: Result<_, _> = udp.send_to(enc, addr); tracing::info!("PaymentSubmit sent (auto-signed)"); } - TunnResult::Err(e) => tracing::error!("Failed to encapsulate PaymentSubmit: {:?}", e), - _ => tracing::warn!("Unexpected encapsulate result for PaymentSubmit (no active session?)"), + TunnResult::Err(e) => { + tracing::error!("Failed to encapsulate PaymentSubmit: {:?}", e) + } + _ => tracing::warn!( + "Unexpected encapsulate result for PaymentSubmit (no active session?)" + ), } } MSG_PAYMENT_ACCEPTED => { diff --git a/boringtun/src/device/ws_bridge.rs b/boringtun/src/device/ws_bridge.rs new file mode 100644 index 0000000..f710e1d --- /dev/null +++ b/boringtun/src/device/ws_bridge.rs @@ -0,0 +1,159 @@ +use std::net::{SocketAddr, UdpSocket}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +use tungstenite::protocol::Message; +use tungstenite::stream::MaybeTlsStream; +use tungstenite::WebSocket; + +const UDP_READ_TIMEOUT_MS: u64 = 50; +const WS_READ_TIMEOUT_MS: u64 = 100; +const MAX_WG_PACKET: usize = 1500; +const RECONNECT_DELAY_MS: u64 = 2000; + +/// Client-side WebSocket-to-UDP bridge. +/// +/// Boringtun client sends/receives WireGuard UDP packets on a local port. +/// This bridge forwards them over a WebSocket connection to the server's WS proxy, +/// and relays responses back over UDP. +/// +/// Boringtun client ↔ UDP (local_port) ↔ WS Bridge ↔ WebSocket ↔ Server WS Proxy +pub fn run_ws_bridge(local_udp_port: u16, ws_url: &str, shutdown_flag: Arc) { + let udp = match UdpSocket::bind(format!("127.0.0.1:{}", local_udp_port)) { + Ok(s) => s, + Err(e) => { + tracing::error!( + "WS bridge: failed to bind UDP on port {}: {}", + local_udp_port, + e + ); + return; + } + }; + udp.set_read_timeout(Some(Duration::from_millis(UDP_READ_TIMEOUT_MS))) + .ok(); + + tracing::info!("WS bridge: UDP listening on 127.0.0.1:{}", local_udp_port); + + let mut client_addr: Option = None; + let mut udp_buf = [0u8; MAX_WG_PACKET]; + let mut udp_to_ws: u64 = 0; + let mut ws_to_udp: u64 = 0; + + while !shutdown_flag.load(Ordering::Relaxed) { + // Connect to server WebSocket + tracing::info!("WS bridge: connecting to {}...", ws_url); + let mut ws = match connect_ws(ws_url) { + Some(ws) => ws, + None => { + tracing::warn!( + "WS bridge: connection failed, retrying in {}ms", + RECONNECT_DELAY_MS + ); + std::thread::sleep(Duration::from_millis(RECONNECT_DELAY_MS)); + continue; + } + }; + tracing::info!("WS bridge: connected to {}", ws_url); + + // Main relay loop + loop { + if shutdown_flag.load(Ordering::Relaxed) { + break; + } + + // 1. Read from local UDP (boringtun client) → send to WebSocket + match udp.recv_from(&mut udp_buf) { + Ok((n, from)) => { + client_addr = Some(from); + udp_to_ws += 1; + if let Err(e) = ws.send(Message::Binary(udp_buf[..n].to_vec().into())) { + tracing::warn!("WS bridge: UDP→WS send error: {}, reconnecting", e); + break; + } + tracing::trace!( + "WS bridge: UDP→WS #{} | {} bytes from {} | first4=[{:02x}{:02x}{:02x}{:02x}]", + udp_to_ws, n, from, + udp_buf[0], udp_buf[1.min(n-1)], udp_buf[2.min(n-1)], udp_buf[3.min(n-1)] + ); + } + Err(ref e) + if e.kind() == std::io::ErrorKind::WouldBlock + || e.kind() == std::io::ErrorKind::TimedOut => {} + Err(e) => { + tracing::warn!("WS bridge: UDP recv error: {}", e); + break; + } + } + + // 2. Read from WebSocket → send to local UDP (boringtun client) + match ws.read() { + Ok(Message::Binary(data)) => { + ws_to_udp += 1; + if let Some(addr) = client_addr { + if let Err(e) = udp.send_to(&data, addr) { + tracing::warn!("WS bridge: WS→UDP send error: {}", e); + } else { + tracing::trace!( + "WS bridge: WS→UDP #{} | {} bytes → {} | first4=[{:02x}{:02x}{:02x}{:02x}]", + ws_to_udp, data.len(), addr, + data[0], data[1.min(data.len()-1)], data[2.min(data.len()-1)], data[3.min(data.len()-1)] + ); + } + } else { + tracing::warn!("WS bridge: WS→UDP #{} | dropped, no client yet", ws_to_udp); + } + } + Ok(Message::Ping(payload)) => { + let _ = ws.send(Message::Pong(payload)); + } + Ok(Message::Close(_)) => { + tracing::info!("WS bridge: server closed connection"); + break; + } + Ok(_) => {} + Err(tungstenite::Error::Io(ref e)) + if e.kind() == std::io::ErrorKind::WouldBlock + || e.kind() == std::io::ErrorKind::TimedOut => {} + Err(e) => { + tracing::warn!("WS bridge: WS read error: {}, reconnecting", e); + break; + } + } + } + + let _ = ws.close(None); + tracing::info!( + "WS bridge: session ended | UDP→WS: {} | WS→UDP: {}", + udp_to_ws, + ws_to_udp + ); + + if !shutdown_flag.load(Ordering::Relaxed) { + tracing::info!("WS bridge: reconnecting in {}ms...", RECONNECT_DELAY_MS); + std::thread::sleep(Duration::from_millis(RECONNECT_DELAY_MS)); + } + } + + tracing::info!("WS bridge: shutting down"); +} + +fn connect_ws(url: &str) -> Option>> { + match tungstenite::connect(url) { + Ok((ws, _response)) => { + // Set read timeout on the underlying TCP stream + if let MaybeTlsStream::Plain(ref stream) = ws.get_ref() { + stream + .set_read_timeout(Some(Duration::from_millis(WS_READ_TIMEOUT_MS))) + .ok(); + stream.set_write_timeout(Some(Duration::from_secs(5))).ok(); + } + Some(ws) + } + Err(e) => { + tracing::warn!("WS bridge: connect error: {}", e); + None + } + } +} diff --git a/boringtun/src/device/ws_proxy.rs b/boringtun/src/device/ws_proxy.rs new file mode 100644 index 0000000..e07cadf --- /dev/null +++ b/boringtun/src/device/ws_proxy.rs @@ -0,0 +1,233 @@ +use std::net::{TcpListener, TcpStream, UdpSocket, SocketAddr}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +use tungstenite::protocol::Message; +use tungstenite::accept; + +const WS_READ_TIMEOUT_MS: u64 = 100; +const UDP_READ_TIMEOUT_MS: u64 = 50; +const MAX_WG_PACKET: usize = 1500; + +/// Run the WebSocket-to-WireGuard proxy server. +/// +/// Each incoming WebSocket connection gets a dedicated UDP socket bound to an +/// ephemeral port. WireGuard packets are relayed bidirectionally: +/// Client ↔ WebSocket ↔ UDP ↔ WireGuard (127.0.0.1:{wg_port}) +/// +/// This allows TEE nodes to expose only HTTP+WS ports (no raw UDP). +pub fn run_ws_proxy( + bind_addr: &str, + wg_port: u16, + shutdown_flag: Arc, +) { + let listener = match TcpListener::bind(bind_addr) { + Ok(l) => l, + Err(e) => { + tracing::error!("WS proxy: failed to bind on {}: {}", bind_addr, e); + return; + } + }; + + // Set a timeout on accept so we periodically check shutdown + set_tcp_listener_timeout(&listener, Duration::from_secs(2)); + + tracing::info!("WS proxy listening on {}", bind_addr); + + let wg_addr: SocketAddr = format!("127.0.0.1:{}", wg_port).parse().unwrap(); + + while !shutdown_flag.load(Ordering::Relaxed) { + let stream = match listener.accept() { + Ok((stream, peer_addr)) => { + tracing::info!("WS proxy: new connection from {}", peer_addr); + stream + } + Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock + || e.kind() == std::io::ErrorKind::TimedOut => { + continue; + } + Err(e) => { + tracing::warn!("WS proxy: accept error: {}", e); + continue; + } + }; + + let shutdown = Arc::clone(&shutdown_flag); + std::thread::Builder::new() + .name("ws-client".into()) + .spawn(move || { + handle_ws_client(stream, wg_addr, shutdown); + }) + .ok(); + } + + tracing::info!("WS proxy shutting down"); +} + +fn handle_ws_client( + stream: TcpStream, + wg_addr: SocketAddr, + shutdown_flag: Arc, +) { + let peer_addr = stream.peer_addr().ok(); + + // Set TCP timeouts for the WebSocket handshake and reads + stream.set_read_timeout(Some(Duration::from_millis(WS_READ_TIMEOUT_MS))).ok(); + stream.set_write_timeout(Some(Duration::from_secs(5))).ok(); + + let mut ws = match accept(stream) { + Ok(ws) => ws, + Err(e) => { + tracing::warn!("WS proxy: handshake failed from {:?}: {}", peer_addr, e); + return; + } + }; + + // Create an UNCONNECTED UDP socket to talk to the local WireGuard instance. + // We use send_to/recv_from instead of connect/send/recv because boringtun + // may respond from a different port (connected UDP sockets per peer). + let udp = match UdpSocket::bind("127.0.0.1:0") { + Ok(s) => s, + Err(e) => { + tracing::error!("WS proxy: failed to bind UDP socket: {}", e); + let _ = ws.close(None); + return; + } + }; + udp.set_read_timeout(Some(Duration::from_millis(UDP_READ_TIMEOUT_MS))).ok(); + + let local_udp = udp.local_addr().unwrap(); + tracing::info!( + "WS proxy: session started for {:?}, UDP {} → {}", + peer_addr, + local_udp, + wg_addr + ); + + let mut udp_buf = [0u8; MAX_WG_PACKET]; + let mut ws_to_udp_count: u64 = 0; + let mut udp_to_ws_count: u64 = 0; + + loop { + if shutdown_flag.load(Ordering::Relaxed) { + tracing::info!("WS proxy [{:?}]: shutdown flag set, exiting", peer_addr); + break; + } + + // 1. Read from WebSocket → send to WireGuard UDP + match ws.read() { + Ok(Message::Binary(data)) => { + ws_to_udp_count += 1; + tracing::trace!( + "WS proxy [{:?}]: WS→UDP #{} | {} bytes | sending to {}", + peer_addr, ws_to_udp_count, data.len(), wg_addr + ); + match udp.send_to(&data, wg_addr) { + Ok(sent) => { + tracing::trace!( + "WS proxy [{:?}]: WS→UDP #{} | sent {} bytes to {}", + peer_addr, ws_to_udp_count, sent, wg_addr + ); + } + Err(e) => { + tracing::warn!( + "WS proxy [{:?}]: WS→UDP #{} | send error: {}", + peer_addr, ws_to_udp_count, e + ); + break; + } + } + } + Ok(Message::Close(frame)) => { + tracing::info!("WS proxy [{:?}]: received Close frame: {:?}", peer_addr, frame); + break; + } + Ok(Message::Ping(payload)) => { + tracing::debug!("WS proxy [{:?}]: received Ping ({} bytes)", peer_addr, payload.len()); + let _ = ws.send(Message::Pong(payload)); + } + Ok(Message::Text(text)) => { + tracing::info!("WS proxy [{:?}]: received unexpected Text: {}", peer_addr, text); + } + Ok(msg) => { + tracing::info!("WS proxy [{:?}]: received other message type: {:?}", peer_addr, msg); + } + Err(tungstenite::Error::Io(ref e)) + if e.kind() == std::io::ErrorKind::WouldBlock + || e.kind() == std::io::ErrorKind::TimedOut => + { + // Timeout — fall through to check UDP + } + Err(e) => { + tracing::info!("WS proxy [{:?}]: WS read error: {}", peer_addr, e); + break; + } + } + + // 2. Read from WireGuard UDP → send to WebSocket + // Accept packets from ANY source (boringtun may use connected sockets + // on different ports for the data fast-path). + match udp.recv_from(&mut udp_buf) { + Ok((n, from)) => { + udp_to_ws_count += 1; + tracing::trace!( + "WS proxy [{:?}]: UDP→WS #{} | {} bytes from {} | forwarding to WS", + peer_addr, udp_to_ws_count, n, from + ); + match ws.send(Message::Binary(udp_buf[..n].to_vec().into())) { + Ok(_) => { + tracing::trace!( + "WS proxy [{:?}]: UDP→WS #{} | sent {} bytes to WS", + peer_addr, udp_to_ws_count, n + ); + } + Err(e) => { + tracing::warn!( + "WS proxy [{:?}]: UDP→WS #{} | WS send error: {}", + peer_addr, udp_to_ws_count, e + ); + break; + } + } + } + Err(ref e) + if e.kind() == std::io::ErrorKind::WouldBlock + || e.kind() == std::io::ErrorKind::TimedOut => + { + // No data from WG yet, loop back + } + Err(e) => { + tracing::warn!("WS proxy [{:?}]: UDP recv error: {}", peer_addr, e); + break; + } + } + } + + tracing::info!( + "WS proxy [{:?}]: session stats | WS→UDP: {} packets | UDP→WS: {} packets", + peer_addr, ws_to_udp_count, udp_to_ws_count + ); + + let _ = ws.close(None); + tracing::info!("WS proxy: session ended for {:?}", peer_addr); +} + +#[cfg(unix)] +fn set_tcp_listener_timeout(listener: &TcpListener, timeout: Duration) { + use std::os::unix::io::AsRawFd; + let fd = listener.as_raw_fd(); + let tv = libc::timeval { + tv_sec: timeout.as_secs() as _, + tv_usec: timeout.subsec_micros() as _, + }; + unsafe { + libc::setsockopt( + fd, + libc::SOL_SOCKET, + libc::SO_RCVTIMEO, + &tv as *const _ as *const libc::c_void, + std::mem::size_of::() as u32, + ); + } +}