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..abc72af 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,19 @@ 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 +377,15 @@ 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); + + // 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 +394,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 +510,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 +595,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 +607,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 +630,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 +669,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..a4f5c32 100644 --- a/boringtun/src/device/mod.rs +++ b/boringtun/src/device/mod.rs @@ -5,11 +5,21 @@ pub mod allowed_ips; 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; +#[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 +368,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 +520,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 +541,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 +735,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 +766,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 +871,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 +905,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 +931,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 +1100,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 +1114,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 +1201,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 +1241,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 +1272,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 +1360,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..d3847b1 --- /dev/null +++ b/boringtun/src/device/ws_bridge.rs @@ -0,0 +1,151 @@ +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::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 => {} + 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::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); + } + } + 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..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/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/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/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/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..87054cd --- /dev/null +++ b/scripts/keys/client.key @@ -0,0 +1 @@ ++EYej/55FgCCsPFl14wGPAZ3Ot8qnTkk85m7Tu5l8WQ= diff --git a/scripts/keys/client.pub b/scripts/keys/client.pub new file mode 100644 index 0000000..e64c816 --- /dev/null +++ b/scripts/keys/client.pub @@ -0,0 +1 @@ +h2nS2fdTI+LOaad4tbnTNvpbg+wNd9fa8kIvlSkfSSY= diff --git a/scripts/keys/server.key b/scripts/keys/server.key new file mode 100644 index 0000000..2d30beb --- /dev/null +++ b/scripts/keys/server.key @@ -0,0 +1 @@ +6KOG0bpC7b0VSNnZIYDPwzjlhV+OgByiRdFvn4PxM0A= diff --git a/scripts/keys/server.pub b/scripts/keys/server.pub new file mode 100644 index 0000000..37f046a --- /dev/null +++ b/scripts/keys/server.pub @@ -0,0 +1 @@ +sNcMmVjWcYIBQjSyg+BqvnQ5J6BoWQ0Dh6Trt12kfBQ= 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/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 new file mode 100755 index 0000000..7f5fcca --- /dev/null +++ b/scripts/test-client.sh @@ -0,0 +1,119 @@ +#!/bin/bash +# Start boringtun client locally, connecting to server via WebSocket proxy. +# +# 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:-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" + +# --- 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 --disable-connected-udp & +BT_PID=$! +sleep 3 +kill -0 $BT_PID 2>/dev/null || { echo "ERROR: boringtun exited"; 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 $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" +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 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 + 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] 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 (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 "" + +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/test-server-linux.sh b/scripts/test-server-linux.sh new file mode 100755 index 0000000..380f0cb --- /dev/null +++ b/scripts/test-server-linux.sh @@ -0,0 +1,98 @@ +#!/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=$(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" +[ -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 +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 +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 (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}') +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 "" +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..." + 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 diff --git a/scripts/test-server.sh b/scripts/test-server.sh new file mode 100755 index 0000000..f5e808d --- /dev/null +++ b/scripts/test-server.sh @@ -0,0 +1,177 @@ +#!/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 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..." +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