diff --git a/AGENTS.md b/AGENTS.md index 860ab9a..725700a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,6 +22,16 @@ self-hosting — is documented once in https://github.com/flexaccessdev/iroh-common-architecture. Do not duplicate it in this repo; update it there and link to it. +That shared layer's code — `RelayConfig` and the relay probe, endpoint +building and rebuild, the home-relay watchdog, the endpoint-bound auth +transcript — lives in the `flexaccess-iroh` crate (`../flexaccess-iroh`, +consumed by git tag). Fix it there, tag a release, and bump the tag here; never +re-implement or fork a copy of it in this repo. Only ezvpn-specific pieces (the +VPN ALPN, QUIC transport tuning, the auth context, the bounded connect, key +files) belong in `src/transport/` and `src/auth.rs`. ezvpn depends on a fork of +iroh, so the fork is applied through `[patch.crates-io]` in `Cargo.toml` rather +than as a git dependency, so that the shared crate's `iroh` resolves to it too. + The mobile apps live in sibling repos: `../ezvpn-apple` (Swift, see `docs/Apple-App.md`) and `../ezvpn-android` (Kotlin, see `docs/Android-App.md`). Both drive the fd-based `MobileSession` in `src/tunnel/mobile.rs` through diff --git a/Cargo.lock b/Cargo.lock index 6f7cf88..6d3ab40 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -922,7 +922,7 @@ dependencies = [ [[package]] name = "ezvpn" -version = "0.0.45" +version = "0.0.46" dependencies = [ "android_logger", "anyhow", @@ -934,6 +934,7 @@ dependencies = [ "dirs", "env_logger", "etherparse", + "flexaccess-iroh", "flexaccess-keys", "futures", "if-addrs", @@ -994,6 +995,21 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "flexaccess-iroh" +version = "0.0.3" +source = "git+https://github.com/flexaccessdev/flexaccess-iroh?tag=v0.0.3#422dacee5b38a667bd6da2b709d28d86c8d7661f" +dependencies = [ + "anyhow", + "base64", + "flexaccess-keys", + "futures", + "iroh", + "log", + "rustls", + "tokio", +] + [[package]] name = "flexaccess-keys" version = "0.0.2" diff --git a/Cargo.toml b/Cargo.toml index 67802e4..f45baa7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ezvpn" -version = "0.0.45" +version = "0.0.46" edition = "2024" description = "IP-over-QUIC VPN tunnel via iroh P2P" readme = "README.md" @@ -31,10 +31,13 @@ etherparse = "0.20" flexaccess-keys = { git = "https://github.com/flexaccessdev/flexaccess-keys", tag = "v0.0.2", default-features = false } futures = "0.3" ipnet = { version = "2", features = ["serde"] } -# Forked from iroh v1.1.0. This branch preserves established-path transport -# backpressure so noq retains and retries the transmit instead of treating a -# local discard as successful. Cargo.lock pins the tested branch commit. -iroh = { version = "=1.1.0", git = "https://github.com/andrewtheguy/iroh.git", branch = "ezvpn-send-backpressure-1.1.0" } +# Shared iroh transport layer (RelayConfig + per-relay probe, endpoint +# build/rebuild, home-relay watchdog, endpoint-bound auth transcript). No +# `mdns` feature: ezvpn runs no local-network discovery. +flexaccess-iroh = { git = "https://github.com/flexaccessdev/flexaccess-iroh", tag = "v0.0.3" } +# Redirected to the fork below via `[patch.crates-io]`, which also covers the +# shared crate's own `iroh` dependency so the graph holds a single `iroh`. +iroh = "1.1.0" noq-proto = "1.2.0" n0-watcher = "1.0.0" log = "0.4" @@ -105,3 +108,11 @@ strip = true lto = "thin" codegen-units = 1 panic = "abort" + +# Forked from iroh v1.1.0. This branch preserves established-path transport +# backpressure so noq retains and retries the transmit instead of treating a +# local discard as successful. Cargo.lock pins the tested branch commit. A +# patch rather than a git dependency so `flexaccess-iroh`'s `iroh` resolves to +# the same package (see its README, "Consumers on a fork of iroh"). +[patch.crates-io] +iroh = { git = "https://github.com/andrewtheguy/iroh.git", branch = "ezvpn-send-backpressure-1.1.0" } diff --git a/docs/Architecture.md b/docs/Architecture.md index acac8d4..daa5b9e 100644 --- a/docs/Architecture.md +++ b/docs/Architecture.md @@ -704,7 +704,9 @@ sequenceDiagram ### Relay Watchdog (Server, Custom Relays) -Implemented in `src/transport/relay_watchdog.rs`, driven by the serve loop in +Implemented in the shared +[flexaccess-iroh](https://github.com/flexaccessdev/flexaccess-iroh) crate +(`flexaccess_iroh::relay_watchdog`), driven by the serve loop in `VpnServer::run`. A custom-relay server is dialable from off the LAN only while it is **registered on its home relay** (n0 address lookup is off; clients dial by relay hint, and a relay forwards QUIC Initials only to endpoints connected to @@ -746,8 +748,8 @@ on demand and dropped after a minute idle, which is normal and never counts as an outage. With the default relays the watchdog is not armed: reachability there rests on n0 publishing/resolution, not on one relay registration. -The same watchdog lives in flextunnel (`transport::relay_watchdog`); keep the -two in sync. +The watchdog is shared with flextunnel through that crate: fix it there, tag a +release, and bump the tag here. ### Client Network Consistency Check (Reconnect) diff --git a/src/auth.rs b/src/auth.rs index fb85884..d2ed79a 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -1,11 +1,13 @@ -//! Public-key authentication for iroh VPN tunnel connections. +//! Public-key authentication for ezvpn client connections. //! -//! Key management is delegated to the -//! [flexaccess-keys](https://github.com/flexaccessdev/flexaccess-keys) -//! repository: the shared `ed25519-sec:` / `ed25519-pub:` token format, key -//! files, authorized-keys parsing, and the `generate-auth-key` / -//! `show-auth-key` CLI all live there. This module owns only ezvpn's -//! domain-separated authentication transcript and its authorization decision. +//! The transcript — sign the client's own ephemeral endpoint id, verify it +//! against the connection's TLS-authenticated `remote_id()` and the +//! authorized-keys file — is the shared [`flexaccess_iroh::auth`] one, and the +//! key format and files are +//! [flexaccess-keys](https://github.com/flexaccessdev/flexaccess-keys). This +//! module owns only what makes it ezvpn's: the domain-separation context, the +//! key-file loaders, and the authorization decision in the server's handshake +//! (`VpnServer::verify_client_auth`). //! //! ## Handshake //! The client's iroh endpoint id stays ephemeral. In its [`VpnHandshake`] the @@ -20,104 +22,39 @@ //! //! [`VpnHandshake`]: crate::tunnel::signaling::VpnHandshake -use anyhow::{Context, Result}; -use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; -use flexaccess_keys::{PrivateKey, PublicKey}; +use anyhow::Result; +use flexaccess_keys::PublicKey; use iroh::EndpointId; use std::path::Path; -pub use flexaccess_keys::AuthorizedKeys; +pub use flexaccess_iroh::auth::{AuthorizedKeys, ClientKey}; -/// Domain-separation context prepended to the signed message, so a client-auth -/// signature can never be confused with any other ed25519 signature made by -/// the same key — including one made for another FlexAccess application -/// sharing the key format. +/// Domain-separation context prepended to the signed message, so an ezvpn +/// client-auth signature can never be confused with any other ed25519 +/// signature made by the same key — including one made for another FlexAccess +/// application sharing the key format and transcript. const AUTH_CONTEXT: &[u8] = b"ezvpn-client-auth-v1"; -/// A client authentication keypair: a shared-format [`PrivateKey`] bound to -/// ezvpn's signing transcript. -#[derive(Clone)] -pub struct ClientKey { - private: PrivateKey, +/// Sign the client-auth message binding `endpoint_id` (this client's own +/// ephemeral iroh id) under ezvpn's context, returning the base64url +/// signature. +pub fn sign_endpoint_id(key: &ClientKey, endpoint_id: &EndpointId) -> String { + key.sign_endpoint_id(AUTH_CONTEXT, endpoint_id) } -/// `Debug` shows only the public half — the secret must never leak into -/// logs or error context. -impl std::fmt::Debug for ClientKey { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("ClientKey") - .field("public", &self.public_str()) - .finish_non_exhaustive() - } -} - -impl From for ClientKey { - fn from(private: PrivateKey) -> Self { - Self { private } - } -} - -impl ClientKey { - /// Generate a fresh random keypair. Fails only when the system RNG is - /// unavailable — fallible rather than panicking because the FFI surfaces - /// call this, and a panic there aborts the host app process. - pub fn generate() -> Result { - let private = PrivateKey::generate() - .map_err(anyhow::Error::from) - .context("Failed to generate an authentication keypair")?; - Ok(private.into()) - } - - /// Parse an encoded secret key (`ed25519-sec:...`). - pub fn from_secret_str(s: &str) -> Result { - let private = s - .parse::() - .map_err(anyhow::Error::from) - .context("Invalid authentication private key")?; - Ok(private.into()) - } - - /// The encoded secret key (`ed25519-sec:...`). - pub fn secret_str(&self) -> String { - self.private.to_token() - } - - /// The encoded public key (`ed25519-pub:...`). - pub fn public_str(&self) -> String { - self.private.public_key().to_token() - } - - /// The verifying half of this keypair. - pub fn public_key(&self) -> PublicKey { - self.private.public_key() - } - - /// Sign the client-auth message binding `endpoint_id` (this client's own - /// ephemeral iroh id), returning the base64url signature. - pub fn sign_endpoint_id(&self, endpoint_id: &EndpointId) -> String { - let sig = self.private.sign(&auth_message(endpoint_id)); - URL_SAFE_NO_PAD.encode(sig) - } -} - -/// The signed message: domain-separation context + the raw endpoint-id bytes. -fn auth_message(endpoint_id: &EndpointId) -> Vec { - let mut msg = Vec::with_capacity(AUTH_CONTEXT.len() + 32); - msg.extend_from_slice(AUTH_CONTEXT); - msg.extend_from_slice(endpoint_id.as_bytes()); - msg -} - -/// Verify a base64url client-auth signature over `endpoint_id` under `public`. +/// Verify a base64url client-auth signature over `endpoint_id` under `public` +/// and ezvpn's context. pub fn verify_endpoint_id_signature( public: &PublicKey, endpoint_id: &EndpointId, signature_b64: &str, ) -> bool { - let Ok(bytes) = URL_SAFE_NO_PAD.decode(signature_b64) else { - return false; - }; - public.verify(&auth_message(endpoint_id), &bytes) + flexaccess_iroh::auth::verify_endpoint_id_signature( + public, + AUTH_CONTEXT, + endpoint_id, + signature_b64, + ) } /// Load a client secret key from a shared-format key file (a bare @@ -137,40 +74,27 @@ pub fn load_authorized_keys(path: &Path) -> Result { #[cfg(test)] mod tests { use super::*; - use flexaccess_keys::{PRIVATE_KEY_PREFIX, PUBLIC_KEY_PREFIX}; use iroh::SecretKey; use std::io::Write; use tempfile::NamedTempFile; - fn ephemeral_endpoint_id() -> EndpointId { - let bytes: [u8; 32] = rand::random(); - SecretKey::from_bytes(&bytes).public() - } - #[test] - fn keypair_roundtrip() { + fn signature_is_bound_to_ezvpn_context() { let key = ClientKey::generate().unwrap(); - let secret = key.secret_str(); - assert!(secret.starts_with(PRIVATE_KEY_PREFIX)); - let public = key.public_str(); - assert!(public.starts_with(PUBLIC_KEY_PREFIX)); + let id = SecretKey::generate().public(); + let sig = sign_endpoint_id(&key, &id); + assert!(verify_endpoint_id_signature(&key.public_key(), &id, &sig)); - let reparsed = ClientKey::from_secret_str(&secret).unwrap(); - assert_eq!(reparsed.public_str(), public); - assert_eq!(public.parse::().unwrap(), key.public_key()); + // The same key and id signed under another application's context + // (flextunnel shares the key format and transcript) is not an ezvpn + // credential. + let foreign = key.sign_endpoint_id(b"flextunnel-client-auth-v1", &id); + assert!(!verify_endpoint_id_signature(&key.public_key(), &id, &foreign)); } #[test] - fn secret_str_rejects_bad_inputs() { - // Wrong prefix (a public key is not a secret key). - let key = ClientKey::generate().unwrap(); - assert!(ClientKey::from_secret_str(&key.public_str()).is_err()); - // Bad base64. - assert!(ClientKey::from_secret_str("ed25519-sec:!!!").is_err()); - // Wrong length. - let short = format!("{}{}", PRIVATE_KEY_PREFIX, URL_SAFE_NO_PAD.encode([0u8; 16])); - assert!(ClientKey::from_secret_str(&short).is_err()); - // The retired ezvpn auth-token format is rejected, not migrated. + fn retired_auth_token_format_is_rejected() { + // The pre-keypair ezvpn auth token is rejected, not migrated. assert!( ClientKey::from_secret_str("vmfNFxTPDKB3jsM1Q8kzAvZnQHbmJ1W49Rk8i1S2Jzrze9Q").is_err() ); @@ -188,79 +112,27 @@ mod tests { file.write_all(contents.as_bytes()).unwrap(); let loaded = load_client_key_from_file(file.path()).unwrap(); assert_eq!(loaded.public_str(), key.public_str()); - } - - #[test] - fn key_file_without_secret_is_rejected() { - let mut file = NamedTempFile::new().unwrap(); - writeln!(file, "# only comments here").unwrap(); - assert!(load_client_key_from_file(file.path()).is_err()); let mut bad = NamedTempFile::new().unwrap(); - writeln!(bad, "not-a-key").unwrap(); + writeln!(bad, "# only comments here").unwrap(); assert!(load_client_key_from_file(bad.path()).is_err()); } #[test] - fn signature_binds_endpoint_id() { - let key = ClientKey::generate().unwrap(); - let id = ephemeral_endpoint_id(); - let sig = key.sign_endpoint_id(&id); - assert!(verify_endpoint_id_signature(&key.public_key(), &id, &sig)); - - // A different endpoint id (replay from another endpoint) fails. - let other_id = ephemeral_endpoint_id(); - assert!(!verify_endpoint_id_signature( - &key.public_key(), - &other_id, - &sig - )); - - // A different key fails. - let other_key = ClientKey::generate().unwrap(); - assert!(!verify_endpoint_id_signature( - &other_key.public_key(), - &id, - &sig - )); - - // Garbage signatures fail instead of erroring. - assert!(!verify_endpoint_id_signature(&key.public_key(), &id, "!!!")); - assert!(!verify_endpoint_id_signature(&key.public_key(), &id, "")); - } - - #[test] - fn authorized_keys_parsing() { + fn authorized_keys_file_parses_and_rejects_secrets() { let a = ClientKey::generate().unwrap(); let b = ClientKey::generate().unwrap(); - let c = ClientKey::generate().unwrap(); - let mut file = NamedTempFile::new().unwrap(); - writeln!(file, "# Authorized client keys").unwrap(); - writeln!(file).unwrap(); - writeln!(file, "{}", a.public_str()).unwrap(); + writeln!(file, "# Authorized client keys\n\n{}", a.public_str()).unwrap(); writeln!(file, "{} alice laptop", b.public_str()).unwrap(); - writeln!(file, " {} build server ", c.public_str()).unwrap(); - let keys = load_authorized_keys(file.path()).unwrap(); - assert_eq!(keys.len(), 3); + assert_eq!(keys.len(), 2); assert!(keys.contains(&a.public_key())); - assert!(keys.contains(&b.public_key())); - assert!(keys.contains(&c.public_key())); assert_eq!(keys.comment(&b.public_key()), Some("alice laptop")); - } - - #[test] - fn authorized_keys_invalid_key_is_rejected() { - let mut file = NamedTempFile::new().unwrap(); - writeln!(file, "# header").unwrap(); - writeln!(file, "ed25519-pub:short").unwrap(); - let err = load_authorized_keys(file.path()).unwrap_err(); - assert!(err.to_string().contains(":2"), "{err}"); - // A secret key pasted into the authorized-keys file is rejected too. + // A secret key pasted into the authorized-keys file is rejected. let mut wrong = NamedTempFile::new().unwrap(); - writeln!(wrong, "{}", ClientKey::generate().unwrap().secret_str()).unwrap(); + writeln!(wrong, "{}", a.secret_str()).unwrap(); assert!(load_authorized_keys(wrong.path()).is_err()); } } diff --git a/src/ffi_windows.rs b/src/ffi_windows.rs index c78ba84..a258563 100644 --- a/src/ffi_windows.rs +++ b/src/ffi_windows.rs @@ -317,7 +317,7 @@ fn start_inner(json: &str) -> Result { }; runtime.block_on(async move { - let endpoint = match create_client_endpoint(&relay_config, None).await { + let endpoint = match create_client_endpoint(&relay_config).await { Ok(e) => e, Err(e) => { let _ = setup_tx.send(Err(format!("failed to create iroh endpoint: {e}"))); diff --git a/src/main.rs b/src/main.rs index 9e900af..c98222a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1046,7 +1046,7 @@ async fn run_vpn_client( // Create iroh endpoint for signaling (ephemeral identity - no persistent // secret key). - let endpoint = create_client_endpoint(&resolved.relay_config, None) + let endpoint = create_client_endpoint(&resolved.relay_config) .await .context("Failed to create iroh endpoint")?; diff --git a/src/transport/endpoint.rs b/src/transport/endpoint.rs index 9533e31..933879d 100644 --- a/src/transport/endpoint.rs +++ b/src/transport/endpoint.rs @@ -1,22 +1,27 @@ -//! Common endpoint helpers for iroh tunnel connections. +//! ezvpn's endpoints: what this program layers onto the shared +//! [`flexaccess_iroh::endpoint`] builder — the VPN ALPN, its QUIC transport +//! tuning, the client/server identity rules, the bounded connect, and the +//! server's secret-key file. Relay configuration, the per-relay startup probe, +//! and the creation-vs-rebuild policy come from the shared crate. use crate::error::{VpnError, VpnResult}; use crate::transport::build_quic_transport_config; use crate::tunnel::signaling::VPN_ALPN; use anyhow::{Context, Result}; use base64::{Engine, engine::general_purpose::STANDARD as BASE64}; -use futures::future::{BoxFuture, join_all}; +use flexaccess_iroh::endpoint::{ + EndpointOptions, create_endpoint, endpoint_builder, rebuild_endpoint, +}; use iroh::{ - Endpoint, EndpointAddr, EndpointId, RelayMap, RelayMode, RelayUrl, SecretKey, - address_lookup::{DnsAddressLookup, PkarrPublisher}, - endpoint::{Builder as EndpointBuilder, Connection, presets}, + Endpoint, EndpointAddr, EndpointId, SecretKey, + endpoint::{Builder as EndpointBuilder, Connection}, }; -use log::info; use std::path::Path; use std::sync::Arc; use std::time::Duration; -pub const RELAY_CONNECT_TIMEOUT: Duration = Duration::from_secs(10); +pub use flexaccess_iroh::endpoint::EndpointFactory; +pub use flexaccess_iroh::relay::RelayConfig; /// Deadline for establishing the QUIC connection to the VPN server. /// @@ -49,122 +54,7 @@ pub async fn connect_with_timeout(endpoint: &Endpoint, addr: EndpointAddr) -> Vp .map_err(|e| VpnError::Signaling(format!("Failed to connect to server: {e}"))) } -/// Relay configuration, resolved once from the raw config strings. -/// -/// This is the single source of the default-vs-custom distinction. It selects -/// both which relay map iroh uses **and** whether iroh internet discovery is -/// enabled: [`Default`](Self::Default) uses the n0 relays with the full lookup -/// stack (pkarr publishing + DNS resolution of the peer's home relay — see -/// ), while -/// [`Custom`](Self::Custom) uses the configured relays with internet discovery -/// disabled (clients reach the server through relay hints instead). The full -/// design — shared with tunnel-rs and flextunnel — is documented in -/// (see -/// `relays-and-address-lookup.md`). -#[derive(Debug, Clone, PartialEq, Eq, Default)] -pub enum RelayConfig { - /// iroh's default relay map, with n0 address lookup. - #[default] - Default, - /// Custom relay set (parsed, sorted, deduped). Never empty. - /// - /// `auth_token`, when set, is sent to every custom relay as an - /// `Authorization: Bearer ` header on the WebSocket upgrade (see - /// [`Self::relay_mode`]). It is only ever carried by custom relays — the - /// default relays never receive a token (see [`Self::from_urls_with_token`]). - Custom { - urls: Vec, - auth_token: Option, - }, -} - -impl RelayConfig { - /// Parse raw config strings with no relay auth token. - /// - /// Thin wrapper over [`Self::from_urls_with_token`]; see there for behavior. - pub fn from_urls(urls: &[String]) -> Result { - Self::from_urls_with_token(urls, None) - } - - /// Parse raw config strings and attach an optional shared relay auth token. - /// - /// Empty input selects the default relays. Parsing fails on the first - /// malformed URL, so config typos surface at resolve time instead of at each - /// use site. - /// - /// The token is normalized (blank/whitespace-only becomes `None`) and is - /// **strictly gated to custom relays**: a non-empty token with no custom - /// relay URLs is a hard error, since the default iroh relays never take a - /// token. This surfaces the misconfiguration before the endpoint starts. - pub fn from_urls_with_token(urls: &[String], auth_token: Option) -> Result { - let auth_token = auth_token.and_then(|token| { - let token = token.trim(); - (!token.is_empty()).then(|| token.to_string()) - }); - if urls.is_empty() { - if auth_token.is_some() { - anyhow::bail!( - "relay_auth_token requires custom relay_urls; it is not used with the default iroh relays" - ); - } - return Ok(Self::Default); - } - let mut parsed = urls - .iter() - .map(|url| { - url.parse::() - .with_context(|| format!("Invalid relay URL: {url}")) - }) - .collect::>>()?; - parsed.sort(); - parsed.dedup(); - Ok(Self::Custom { - urls: parsed, - auth_token, - }) - } - - /// The custom relay URLs; empty for [`RelayConfig::Default`]. - pub fn custom_urls(&self) -> &[RelayUrl] { - match self { - Self::Default => &[], - Self::Custom { urls, .. } => urls, - } - } - - /// The shared relay auth token, if configured (custom relays only). - pub fn relay_auth_token(&self) -> Option<&str> { - match self { - Self::Default => None, - Self::Custom { auth_token, .. } => auth_token.as_deref(), - } - } - - pub fn is_custom(&self) -> bool { - matches!(self, Self::Custom { .. }) - } - - /// The corresponding iroh [`RelayMode`]. - /// - /// For custom relays, an `auth_token` (when set) is applied to every relay in - /// the map via [`RelayMap::with_auth_token`], which iroh sends as an - /// `Authorization: Bearer ` header on the relay WebSocket upgrade. - pub fn relay_mode(&self) -> RelayMode { - match self { - Self::Default => RelayMode::Default, - Self::Custom { urls, auth_token } => { - let map = RelayMap::from_iter(urls.iter().cloned()); - let map = match auth_token { - Some(token) => map.with_auth_token(token.clone()), - None => map, - }; - RelayMode::Custom(map) - } - } - } -} - -/// Load secret key from file (base64 encoded). +/// Load the server's secret key from its file (base64 encoded). pub fn load_secret(path: &Path) -> Result { if !path.exists() { anyhow::bail!( @@ -178,7 +68,7 @@ pub fn load_secret(path: &Path) -> Result { load_secret_from_string(content.trim()) } -/// Load secret key from a base64-encoded string. +/// Load a secret key from a base64-encoded string. pub fn load_secret_from_string(base64_key: &str) -> Result { let bytes = BASE64 .decode(base64_key) @@ -192,328 +82,60 @@ pub fn secret_to_endpoint_id(secret: &SecretKey) -> EndpointId { secret.public() } -/// Print relay configuration status messages. -pub fn print_relay_status(relay_config: &RelayConfig) { - match relay_config.custom_urls().len() { - 0 => {} - 1 => info!("Using custom relay server"), - n => info!("Using {} custom relay servers", n), - } -} - -/// Create a base endpoint builder with common configuration. -/// -/// iroh internet discovery (pkarr publishing + DNS-based lookup of -/// `_iroh..dns.iroh.link`, see -/// ) is **not** configurable; -/// it follows the relay mode: -/// -/// - [`RelayConfig::Default`]: the full n0 lookup stack is enabled — the server -/// publishes its current home relay and a client resolves it by endpoint ID. -/// - [`RelayConfig::Custom`]: internet discovery is always disabled — nothing is -/// published to or resolved from n0's public infrastructure. The client reaches -/// the server through the configured relay hints it attaches to the server's -/// `EndpointAddr` (see `VpnClient::resolve_server_addr`): iroh sends QUIC -/// Initials to every configured relay, so the handshake succeeds via whichever -/// relay the server is homed on. -pub fn create_endpoint_builder(relay_config: &RelayConfig) -> Result { - let transport_config = build_quic_transport_config()?; - // iroh 1.0 requires the crypto provider to be set explicitly on the - // builder when starting from the `Empty` preset — the `tls-ring` feature - // only makes the ring backend available, it does not wire it in. - let mut builder = Endpoint::builder(presets::Empty) - .relay_mode(relay_config.relay_mode()) - .transport_config(transport_config) - .crypto_provider(Arc::new(rustls::crypto::ring::default_provider())); - - if relay_config.is_custom() { - info!("Internet discovery disabled (custom relays configured)"); - } else { - // Default n0 relays: enable the full lookup stack (pkarr publishing + - // DNS-based lookup). - builder = builder - .address_lookup(PkarrPublisher::n0_dns()) - .address_lookup(DnsAddressLookup::n0_dns()); - } - - Ok(builder) -} - -/// Build a minimal, relay-only endpoint for probing a single relay. -/// -/// It uses an ephemeral identity (no persistent secret, no address publishing) -/// and clears IP transports so [`Endpoint::online`] reflects *pure relay* -/// connectivity — a holepunched direct path can never mask a dead or -/// auth-rejecting relay. This is the "relay only" builder reintroduced for -/// internal probe use. The auth token, when set, rides the WebSocket upgrade -/// exactly as it does for the real endpoint, so the probe validates the token too. -fn probe_endpoint_builder( - relay_url: &RelayUrl, - auth_token: Option<&str>, -) -> Result { - let transport_config = build_quic_transport_config()?; - let map = RelayMap::from_iter([relay_url.clone()]); - let map = match auth_token { - Some(token) => map.with_auth_token(token.to_string()), - None => map, - }; - let builder = Endpoint::builder(presets::Empty) - .relay_mode(RelayMode::Custom(map)) - .transport_config(transport_config) - .crypto_provider(Arc::new(rustls::crypto::ring::default_provider())) - // Relay-only: drop direct IP transports so `online()` is a pure relay - // reachability signal, independent of holepunching. - .clear_ip_transports(); - Ok(builder) -} - -/// Probe a single custom relay by binding a relay-only endpoint and waiting for -/// it to come online, bounded by [`RELAY_CONNECT_TIMEOUT`]. `Ok(())` means the -/// relay connected (and accepted the auth token, if any); otherwise the error -/// describes the failure. The probe endpoint is always closed before returning. -async fn probe_relay(relay_url: &RelayUrl, auth_token: Option<&str>) -> Result<()> { - let endpoint = probe_endpoint_builder(relay_url, auth_token)? - .bind() - .await - .with_context(|| format!("Failed to bind probe endpoint for relay {relay_url}"))?; - let outcome = tokio::time::timeout(RELAY_CONNECT_TIMEOUT, endpoint.online()).await; - endpoint.close().await; - outcome.map_err(|_| { - anyhow::anyhow!( - "did not come online within {}s (unreachable or rejected the auth token)", - RELAY_CONNECT_TIMEOUT.as_secs() - ) - }) -} - -/// Probe every configured custom relay individually (in parallel) and fail if -/// **any** relay is unreachable. -/// -/// This is stricter than a single endpoint-wide `online()` wait, which only -/// proved that *one* relay in the set (the home relay) connected and so reported -/// a misleading all-clear when a backup relay was down. Default relays are not -/// probed (returns `Ok(())` immediately). -async fn probe_custom_relays(relay_config: &RelayConfig) -> Result<()> { - let RelayConfig::Custom { urls, auth_token } = relay_config else { - return Ok(()); - }; - let token = auth_token.as_deref(); - info!("Probing {} custom relay(s) for reachability...", urls.len()); - let results = join_all( - urls.iter() - .map(|url| async move { (url, probe_relay(url, token).await) }), - ) - .await; - let failures: Vec = results - .into_iter() - .filter_map(|(url, res)| res.err().map(|e| format!("{url}: {e}"))) - .collect(); - if !failures.is_empty() { - anyhow::bail!( - "{} of {} custom relay(s) failed to come online:\n {}", - failures.len(), - urls.len(), - failures.join("\n ") - ); - } - Ok(()) +/// The shared base builder with ezvpn's QUIC transport tuning. ezvpn runs no +/// mDNS (the shared crate's `mdns` feature is off), and never relay-only. +fn base_builder(relay_config: &RelayConfig, publish_address: bool) -> Result { + Ok(endpoint_builder( + relay_config, + EndpointOptions { + transport_config: build_quic_transport_config()?, + publish_address, + relay_only: false, + }, + )) +} + +/// A server endpoint builder: persistent identity (published on the default +/// relays) and the VPN ALPN. Binding policy is the caller's — +/// [`create_server_endpoint`] and [`server_rebuild_factory`] each layer their +/// own. +fn server_builder(relay_config: &RelayConfig, secret: SecretKey) -> Result { + Ok(base_builder(relay_config, true)? + .alpns(vec![VPN_ALPN.to_vec()]) + .secret_key(secret)) } -/// Create the VPN server's iroh endpoint with its persistent identity, waiting -/// for it to come online (bounded by [`RELAY_CONNECT_TIMEOUT`]). +/// Create the VPN server's iroh endpoint with its persistent identity. /// /// A single endpoint serves both relay modes. With the default relays internet /// discovery is on, so the server publishes its current home relay and clients /// resolve it by endpoint ID (iroh's relay failover re-homes and republishes on /// its own). With custom relays discovery is off, so clients reach the server /// through the relay hints they attach to its `EndpointAddr` (see -/// [`create_endpoint_builder`]). +/// `VpnClient::resolve_server_addr`). Strict first-creation policy: every +/// custom relay is probed and the endpoint must come online. pub async fn create_server_endpoint(relay_config: &RelayConfig, secret: SecretKey) -> Result { - print_relay_status(relay_config); - - // Validate each custom relay individually (fail if any is unreachable); a - // no-op for the default relays. - probe_custom_relays(relay_config).await?; - - let endpoint = bind_server_endpoint(relay_config, secret).await?; - - if let Err(e) = wait_online(&endpoint).await { - // Close before propagating: dropping a bound endpoint without - // `close()` is fatal under the release profile's panic=abort. - endpoint.close().await; - return Err(e); - } - Ok(endpoint) -} - -/// Bind a server endpoint: persistent identity and the VPN ALPN. No relay -/// probe, no online wait — [`create_server_endpoint`] and -/// [`server_rebuild_factory`] layer their own policy over this. -async fn bind_server_endpoint(relay_config: &RelayConfig, secret: SecretKey) -> Result { - create_endpoint_builder(relay_config)? - .alpns(vec![VPN_ALPN.to_vec()]) - .secret_key(secret) - .bind() - .await - .context("Failed to create iroh endpoint") + create_endpoint(relay_config, server_builder(relay_config, secret)?).await } -/// Recipe producing a fresh, fully bound endpoint — how the server replaces a -/// wedged one mid-run (see `transport::relay_watchdog`). -pub type EndpointFactory = Arc BoxFuture<'static, Result> + Send + Sync>; - /// The rebuild recipe for the server endpoint, used when the relay watchdog -/// (`transport::relay_watchdog`) gives up on the current one. Same identity as -/// the original, so the server's node id — what clients dial — never changes. -/// Differs from first creation in two ways: -/// -/// - **No per-relay probe.** Creation fails fast if *any* relay is down -/// (configuration validation); mid-outage that strictness would block -/// recovery through the one relay that still answers. -/// - **The online wait is tolerated failing.** A fresh endpoint is no worse -/// than the wedged one it replaces — LAN clients can still find it over -/// mDNS — and the watchdog trips again if the relays stay unreachable. +/// (`flexaccess_iroh::relay_watchdog`) gives up on the current one. Same +/// identity as the original, so the server's node id — what clients dial — +/// never changes. Tolerant rebuild policy (see [`rebuild_endpoint`]): no +/// relay probe, and the online wait may fail — the watchdog trips again if the +/// relays stay unreachable, with a lengthening deadline so a dead relay does +/// not churn the endpoint every few minutes (see `VpnServer::run`). pub fn server_rebuild_factory(relay_config: RelayConfig, secret: SecretKey) -> EndpointFactory { Arc::new(move || { let relay_config = relay_config.clone(); let secret = secret.clone(); - Box::pin(async move { - let endpoint = bind_server_endpoint(&relay_config, secret).await?; - if let Err(e) = wait_online(&endpoint).await { - log::warn!("Rebuilt endpoint: {e:#}; continuing (LAN discovery still works)"); - } - Ok(endpoint) - }) + Box::pin(async move { rebuild_endpoint(server_builder(&relay_config, secret)?).await }) }) } -/// Wait for a freshly bound endpoint to come online, bounded by -/// [`RELAY_CONNECT_TIMEOUT`]. Does not close the endpoint on failure; the -/// caller decides (creation closes and fails, a rebuild carries on). -async fn wait_online(endpoint: &Endpoint) -> Result<()> { - info!( - "Waiting for endpoint to come online (timeout: {}s)...", - RELAY_CONNECT_TIMEOUT.as_secs() - ); - match tokio::time::timeout(RELAY_CONNECT_TIMEOUT, endpoint.online()).await { - Ok(()) => Ok(()), - Err(_) => anyhow::bail!( - "Endpoint failed to come online after {}s - check relay server connectivity", - RELAY_CONNECT_TIMEOUT.as_secs() - ), - } -} - -/// Create a client endpoint. -/// If a secret key is provided, the client will use a persistent identity for authentication. -pub async fn create_client_endpoint( - relay_config: &RelayConfig, - secret_key: Option<&SecretKey>, -) -> Result { - print_relay_status(relay_config); - - // Validate each custom relay individually (fail if any is unreachable); a - // no-op for the default relays. - probe_custom_relays(relay_config).await?; - - let mut builder = create_endpoint_builder(relay_config)?; - - // Set the secret key for persistent identity (used for authentication) - if let Some(secret) = secret_key { - builder = builder.secret_key(secret.clone()); - } - - let endpoint = builder - .bind() - .await - .context("Failed to create iroh endpoint")?; - - if let Err(e) = wait_online(&endpoint).await { - // Close before propagating: dropping a bound endpoint without - // `close()` is fatal under the release profile's panic=abort. - endpoint.close().await; - return Err(e); - } - - Ok(endpoint) -} - -#[cfg(test)] -mod tests { - use super::*; - - const RELAY: &str = "https://relay.example.com./"; - - #[test] - fn empty_urls_no_token_is_default() { - let cfg = RelayConfig::from_urls_with_token(&[], None).unwrap(); - assert_eq!(cfg, RelayConfig::Default); - assert!(!cfg.is_custom()); - assert_eq!(cfg.relay_auth_token(), None); - } - - #[test] - fn blank_token_without_urls_is_default() { - // A whitespace-only token normalizes to None, so it is not an error. - let cfg = RelayConfig::from_urls_with_token(&[], Some(" ".to_string())).unwrap(); - assert_eq!(cfg, RelayConfig::Default); - } - - #[test] - fn token_without_custom_urls_is_error() { - let err = RelayConfig::from_urls_with_token(&[], Some("secret".to_string())) - .expect_err("token without custom relays must be rejected"); - assert!( - err.to_string() - .contains("relay_auth_token requires custom relay_urls"), - "unexpected error: {err}" - ); - } - - #[test] - fn malformed_custom_url_is_rejected_without_token() { - // Custom relays are always parse-validated, independent of any token. - let err = RelayConfig::from_urls_with_token(&["not a url".to_string()], None) - .expect_err("malformed relay URL must be rejected"); - assert!( - err.to_string().contains("Invalid relay URL"), - "unexpected error: {err}" - ); - } - - #[test] - fn custom_urls_without_token() { - let cfg = RelayConfig::from_urls_with_token(&[RELAY.to_string()], None).unwrap(); - assert!(cfg.is_custom()); - assert_eq!(cfg.custom_urls().len(), 1); - assert_eq!(cfg.relay_auth_token(), None); - assert!(matches!(cfg.relay_mode(), RelayMode::Custom(_))); - } - - #[test] - fn custom_urls_with_token_trimmed() { - let cfg = RelayConfig::from_urls_with_token( - &[RELAY.to_string()], - Some(" secret\n".to_string()), - ) - .unwrap(); - assert!(cfg.is_custom()); - assert_eq!(cfg.relay_auth_token(), Some("secret")); - assert!(matches!(cfg.relay_mode(), RelayMode::Custom(_))); - } - - #[test] - fn token_is_trimmed_to_none_with_custom_urls() { - // A blank token alongside custom relays is simply no token, not an error. - let cfg = - RelayConfig::from_urls_with_token(&[RELAY.to_string()], Some(" ".to_string())).unwrap(); - assert!(cfg.is_custom()); - assert_eq!(cfg.relay_auth_token(), None); - } - - #[test] - fn from_urls_carries_no_token() { - let cfg = RelayConfig::from_urls(&[RELAY.to_string()]).unwrap(); - assert_eq!(cfg.relay_auth_token(), None); - } +/// Create a client endpoint: ephemeral identity, never published (the client +/// only dials out; its credential is the auth keypair, not the endpoint id). +/// Strict first-creation policy. +pub async fn create_client_endpoint(relay_config: &RelayConfig) -> Result { + create_endpoint(relay_config, base_builder(relay_config, false)?).await } diff --git a/src/transport/mod.rs b/src/transport/mod.rs index 2b6c40b..f331855 100644 --- a/src/transport/mod.rs +++ b/src/transport/mod.rs @@ -14,7 +14,6 @@ pub mod endpoint; pub mod paths; -pub mod relay_watchdog; use anyhow::{Context, Result}; use iroh::endpoint::{AckFrequencyConfig, QuicTransportConfig, VarInt}; diff --git a/src/transport/relay_watchdog.rs b/src/transport/relay_watchdog.rs deleted file mode 100644 index 8d89a39..0000000 --- a/src/transport/relay_watchdog.rs +++ /dev/null @@ -1,367 +0,0 @@ -//! Server-side home-relay watchdog. -//! -//! A server configured with custom relays is reachable to off-LAN clients -//! *only* through its home relay: with n0 discovery off, clients dial with -//! relay hints, and a relay forwards QUIC Initials only to endpoints currently -//! registered on it. iroh keeps that registration alive on its own, but it has -//! been observed (v1.0.3, relays behind Cloudflare tunnels that reset idle -//! WebSockets roughly hourly) to silently lose its home relay for good after -//! one such reset: no dial retries, no warnings, no registration on any relay — -//! the server just stops being dialable until the process is restarted, while -//! LAN clients that find it over mDNS keep working and mask the outage. -//! -//! [`watch_home_relay`] observes [`Endpoint::home_relay_status`] and reacts in -//! two steps, mirroring the client's reconnect escalation: -//! -//! 1. after [`RELAY_OUTAGE_NUDGE`] without a connected home relay it calls -//! [`Endpoint::network_change`], which forces a fresh net report and relay -//! re-selection (enough when only the bookkeeping went stale); -//! 2. after the caller's rebuild deadline ([`RELAY_OUTAGE_REBUILD`] by -//! default) it resolves, telling the caller to replace the endpoint — the -//! in-process equivalent of the restart that is known to fix it. The caller -//! (`VpnServer::run`) closes the wedged endpoint, binds a fresh one with the -//! same identity, and serves on again. -//! -//! The resolution also says whether a home relay was connected at *any* point -//! of the watch ([`RelayOutage::relay_seen`]). A rebuilt endpoint that never -//! registers is a sign the relay itself is unreachable, not that iroh's -//! bookkeeping went stale; rebuilding it again drops every LAN client for -//! nothing, so the caller backs off between such rebuilds by passing a longer -//! deadline. -//! -//! Only the *home* relay matters: non-home relays are connected on demand and -//! dropped after a minute idle, which is normal and not an outage. -//! -//! Ported from flextunnel's `transport::relay_watchdog`; keep the two in sync. - -use iroh::endpoint::RelayStatus; -use iroh::{Endpoint, Watcher}; -use std::future::Future; -use std::time::Duration; -use tokio::time::Instant; - -/// How long the endpoint may go without a connected home relay before the -/// watchdog nudges it with `network_change()`. Long enough to ride out a -/// routine relay reconnect (iroh's own reconnect backoff caps at 16s) plus the -/// ~25s cadence of its periodic net report. -pub const RELAY_OUTAGE_NUDGE: Duration = Duration::from_secs(60); - -/// Default for how long from the start of the outage before the watchdog -/// gives up on the endpoint and asks for a rebuild. Leaves the nudge two -/// minutes to take effect (a net report through slow relays can take tens of -/// seconds). -pub const RELAY_OUTAGE_REBUILD: Duration = Duration::from_secs(180); - -/// A tripped watchdog: the endpoint should be replaced. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct RelayOutage { - /// How long the endpoint has had no connected home relay. - pub duration: Duration, - /// Whether a home relay was connected at any point during the watch. - /// `false` means this endpoint never registered at all. - pub relay_seen: bool, -} - -/// Watch `endpoint`'s home-relay status and resolve once it has had no -/// connected home relay for `rebuild_after` (at least -/// [`RELAY_OUTAGE_NUDGE`]; [`RELAY_OUTAGE_REBUILD`] is the usual value), -/// having nudged it with `network_change()` at [`RELAY_OUTAGE_NUDGE`]. Never -/// resolves while the home relay stays connected; a reconnect at any point -/// resets the clock. Pending forever once the endpoint is gone. -pub async fn watch_home_relay(endpoint: &Endpoint, rebuild_after: Duration) -> RelayOutage { - watch_outage( - endpoint.home_relay_status(), - |statuses| describe_statuses(statuses), - || endpoint.network_change(), - rebuild_after, - ) - .await -} - -/// Describe a home-relay status vector for the watchdog: `Ok(())` when some -/// home relay is connected, otherwise `Err(reason)` naming what is wrong. -fn describe_statuses(statuses: &[RelayStatus]) -> Result<(), String> { - if statuses.iter().any(RelayStatus::is_connected) { - return Ok(()); - } - if statuses.is_empty() { - return Err("no home relay selected".into()); - } - let parts: Vec = statuses - .iter() - .map(|s| match s.last_error() { - Some(e) => format!("{} disconnected ({e:#})", s.url()), - None => format!("{} not connected", s.url()), - }) - .collect(); - Err(parts.join("; ")) -} - -/// The watchdog proper, generic over the status source so it can be driven by -/// a plain watchable in tests. `describe` classifies a status value -/// (`Ok` = connected); `nudge` is the first-stage remedy; `rebuild_after` is -/// the outage duration at which the watchdog trips. -async fn watch_outage( - mut watcher: W, - describe: D, - mut nudge: N, - rebuild_after: Duration, -) -> RelayOutage -where - W: Watcher, - D: Fn(&W::Value) -> Result<(), String>, - N: FnMut() -> Fut, - Fut: Future, -{ - let rebuild_after = rebuild_after.max(RELAY_OUTAGE_NUDGE); - let mut outage_since: Option = None; - let mut nudged = false; - let mut relay_seen = false; - let mut value = watcher.get(); - loop { - match describe(&value) { - Ok(()) => { - relay_seen = true; - if let Some(since) = outage_since.take() { - log::info!( - "Home relay connection restored after {:.0}s", - since.elapsed().as_secs_f64() - ); - } - nudged = false; - } - Err(reason) => { - if outage_since.is_none() { - outage_since = Some(Instant::now()); - log::warn!( - "No connected home relay ({reason}); off-LAN clients cannot reach this \ - server until it reconnects" - ); - } - } - } - - let Some(since) = outage_since else { - // Healthy: nothing to time, just wait for the next status change. - value = match watcher.updated().await { - Ok(value) => value, - Err(_disconnected) => std::future::pending().await, - }; - continue; - }; - - let deadline = since - + if nudged { - rebuild_after - } else { - RELAY_OUTAGE_NUDGE - }; - tokio::select! { - _ = tokio::time::sleep_until(deadline) => { - if nudged { - return RelayOutage { - duration: since.elapsed(), - relay_seen, - }; - } - nudged = true; - log::warn!( - "Still no connected home relay after {:.0}s; nudging the endpoint to \ - re-check its network and relays", - since.elapsed().as_secs_f64() - ); - nudge().await; - // The nudge may have already reconnected the relay; re-read - // rather than wait for a change notification we may have - // missed while it ran. - value = watcher.get(); - } - updated = watcher.updated() => { - value = match updated { - Ok(value) => value, - Err(_disconnected) => std::future::pending().await, - }; - } - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use n0_watcher::Watchable; - use std::sync::Arc; - use std::sync::atomic::{AtomicUsize, Ordering}; - - /// Test double for the home-relay status: `true` = a home relay is - /// connected. - fn describe(connected: &bool) -> Result<(), String> { - if *connected { - Ok(()) - } else { - Err("down".into()) - } - } - - /// Run the watchdog on `status` with the default rebuild deadline, - /// counting nudges. Returns the watchdog future's resolution wrapped in a - /// bounded wait so a test never hangs. - async fn run_for( - status: &Watchable, - nudges: Arc, - bound: Duration, - ) -> Option { - run_with_deadline(status, nudges, bound, RELAY_OUTAGE_REBUILD).await - } - - async fn run_with_deadline( - status: &Watchable, - nudges: Arc, - bound: Duration, - rebuild_after: Duration, - ) -> Option { - let watchdog = watch_outage( - status.watch(), - describe, - || { - let nudges = nudges.clone(); - async move { - nudges.fetch_add(1, Ordering::SeqCst); - } - }, - rebuild_after, - ); - tokio::time::timeout(bound, watchdog).await.ok() - } - - #[tokio::test(start_paused = true)] - async fn healthy_relay_never_trips() { - let status = Watchable::new(true); - let nudges = Arc::new(AtomicUsize::new(0)); - let tripped = run_for(&status, nudges.clone(), RELAY_OUTAGE_REBUILD * 3).await; - assert!( - tripped.is_none(), - "healthy relay must never request a rebuild" - ); - assert_eq!(nudges.load(Ordering::SeqCst), 0); - } - - #[tokio::test(start_paused = true)] - async fn sustained_outage_nudges_then_requests_rebuild() { - let status = Watchable::new(false); - let nudges = Arc::new(AtomicUsize::new(0)); - let outage = run_for(&status, nudges.clone(), RELAY_OUTAGE_REBUILD * 2) - .await - .expect("a sustained outage must request a rebuild"); - assert_eq!( - nudges.load(Ordering::SeqCst), - 1, - "exactly one nudge before the rebuild" - ); - assert!(outage.duration >= RELAY_OUTAGE_REBUILD); - assert!(outage.duration < RELAY_OUTAGE_REBUILD + Duration::from_secs(1)); - assert!( - !outage.relay_seen, - "a relay that was never connected must be reported as never seen" - ); - } - - #[tokio::test(start_paused = true)] - async fn a_longer_rebuild_deadline_delays_the_trip_but_not_the_nudge() { - let status = Watchable::new(false); - let nudges = Arc::new(AtomicUsize::new(0)); - let rebuild_after = RELAY_OUTAGE_REBUILD * 4; - let nudge_count = nudges.clone(); - let (outage, ()) = tokio::join!( - run_with_deadline(&status, nudges.clone(), rebuild_after * 2, rebuild_after), - async move { - // The nudge still comes at the fixed first-stage deadline. - tokio::time::sleep(RELAY_OUTAGE_NUDGE + Duration::from_secs(1)).await; - assert_eq!(nudge_count.load(Ordering::SeqCst), 1); - } - ); - let outage = outage.expect("a sustained outage must request a rebuild"); - assert!(outage.duration >= rebuild_after); - assert!(outage.duration < rebuild_after + Duration::from_secs(1)); - assert_eq!(nudges.load(Ordering::SeqCst), 1); - } - - #[tokio::test(start_paused = true)] - async fn recovery_before_the_nudge_resets_the_clock() { - let status = Watchable::new(true); - let nudges = Arc::new(AtomicUsize::new(0)); - let flipper = { - let status = status.clone(); - async move { - // Drop out for half the nudge window, then recover; the - // watchdog must neither nudge nor trip. - tokio::time::sleep(Duration::from_secs(5)).await; - status.set(false).ok(); - tokio::time::sleep(RELAY_OUTAGE_NUDGE / 2).await; - status.set(true).ok(); - } - }; - let (tripped, ()) = tokio::join!( - run_for(&status, nudges.clone(), RELAY_OUTAGE_REBUILD * 2), - flipper - ); - assert!(tripped.is_none()); - assert_eq!(nudges.load(Ordering::SeqCst), 0); - } - - #[tokio::test(start_paused = true)] - async fn recovery_after_the_nudge_avoids_the_rebuild() { - let status = Watchable::new(false); - let nudges = Arc::new(AtomicUsize::new(0)); - let flipper = { - let status = status.clone(); - async move { - // Recover between the nudge and the rebuild deadline. - tokio::time::sleep(RELAY_OUTAGE_NUDGE + Duration::from_secs(10)).await; - status.set(true).ok(); - } - }; - let (tripped, ()) = tokio::join!( - run_for(&status, nudges.clone(), RELAY_OUTAGE_REBUILD * 2), - flipper - ); - assert!( - tripped.is_none(), - "a relay that came back must not be rebuilt" - ); - assert_eq!(nudges.load(Ordering::SeqCst), 1); - } - - #[tokio::test(start_paused = true)] - async fn a_second_outage_starts_a_fresh_clock() { - let status = Watchable::new(false); - let nudges = Arc::new(AtomicUsize::new(0)); - let flipper = { - let status = status.clone(); - async move { - // First outage: nudged, then recovers. Second outage: must - // get its own nudge and only trip a full window later. - tokio::time::sleep(RELAY_OUTAGE_NUDGE + Duration::from_secs(10)).await; - status.set(true).ok(); - tokio::time::sleep(Duration::from_secs(10)).await; - status.set(false).ok(); - } - }; - let start = Instant::now(); - let (tripped, ()) = tokio::join!( - run_for(&status, nudges.clone(), RELAY_OUTAGE_REBUILD * 3), - flipper - ); - let outage = tripped.expect("second outage must eventually trip"); - assert_eq!(nudges.load(Ordering::SeqCst), 2); - assert!(outage.duration >= RELAY_OUTAGE_REBUILD); - assert!(outage.duration < RELAY_OUTAGE_REBUILD + Duration::from_secs(1)); - assert!( - outage.relay_seen, - "the relay was connected between the outages, so it was seen" - ); - // Second outage began at nudge + 20s; the trip comes a full window after that. - let total = start.elapsed(); - assert!(total >= RELAY_OUTAGE_NUDGE + Duration::from_secs(20) + RELAY_OUTAGE_REBUILD); - } -} diff --git a/src/tunnel/client.rs b/src/tunnel/client.rs index c809859..6f07d3f 100644 --- a/src/tunnel/client.rs +++ b/src/tunnel/client.rs @@ -812,7 +812,7 @@ pub(crate) async fn perform_handshake( ClientAuthPayload { public_key: client_key.public_str(), endpoint_id: own_id.to_string(), - signature: client_key.sign_endpoint_id(&own_id), + signature: crate::auth::sign_endpoint_id(client_key, &own_id), }, ); diff --git a/src/tunnel/mobile.rs b/src/tunnel/mobile.rs index 12976df..5d4ccb5 100644 --- a/src/tunnel/mobile.rs +++ b/src/tunnel/mobile.rs @@ -138,7 +138,7 @@ impl MobileSession { /// so the server may assign a different IP on each connect — acceptable for /// the MVP. pub async fn connect(cfg: MobileConfig) -> VpnResult { - let endpoint = create_client_endpoint(&cfg.relay_config, None) + let endpoint = create_client_endpoint(&cfg.relay_config) .await .map_err(|e| VpnError::Signaling(format!("Failed to create iroh endpoint: {e}")))?; diff --git a/src/tunnel/server.rs b/src/tunnel/server.rs index ad54601..0a950b2 100644 --- a/src/tunnel/server.rs +++ b/src/tunnel/server.rs @@ -18,7 +18,7 @@ use crate::tunnel::offload::VirtioNetHdr; use crate::transport::paths::{format_connection_paths, watch_connection_paths}; use crate::transport::SERVER_ADDR_PUBLISH_INTERVAL; use crate::transport::endpoint::EndpointFactory; -use crate::transport::relay_watchdog::{self, RelayOutage}; +use flexaccess_iroh::relay_watchdog::{self, RelayOutage}; use crate::tunnel::signaling::{ ClientAuthPayload, MAX_HANDSHAKE_SIZE, ServerAddrsMsg, VpnHandshake, VpnHandshakeResponse, read_message, write_message, @@ -757,7 +757,7 @@ impl VpnServer { /// Run the VPN server, accepting connections via iroh. /// /// `rebuild`, when given, arms the home-relay watchdog - /// (`transport::relay_watchdog`): if the endpoint loses its home relay for + /// (`flexaccess_iroh::relay_watchdog`): if the endpoint loses its home relay for /// good, the wedged endpoint is closed and `rebuild` binds its replacement /// (same identity), which the server then serves on. Pass `None` for the /// default relays, where reachability does not hang on one relay