diff --git a/Cargo.lock b/Cargo.lock index 9fbd646..0a25836 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1945,7 +1945,7 @@ dependencies = [ [[package]] name = "flextunnel-cli" -version = "0.0.79" +version = "0.0.80" dependencies = [ "anyhow", "clap", @@ -1961,7 +1961,7 @@ dependencies = [ [[package]] name = "flextunnel-core" -version = "0.0.79" +version = "0.0.80" dependencies = [ "anyhow", "askama", @@ -1992,7 +1992,7 @@ dependencies = [ [[package]] name = "flextunnel-desktop" -version = "0.0.79" +version = "0.0.80" dependencies = [ "aes-gcm", "anyhow", @@ -2021,7 +2021,7 @@ dependencies = [ [[package]] name = "flextunnel-ffi" -version = "0.0.79" +version = "0.0.80" dependencies = [ "flextunnel-core", "iroh", diff --git a/Cargo.toml b/Cargo.toml index ef6bf0d..169f1c6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,9 +10,9 @@ default-members = [ ] [workspace.package] -version = "0.0.79" +version = "0.0.80" edition = "2024" -description = "SOCKS5/HTTP-proxy-over-QUIC split tunnel via iroh P2P" +description = "SOCKS5/HTTP proxy and port forwards over QUIC — split tunnel via iroh P2P" [workspace.dependencies] anyhow = "1" diff --git a/README.md b/README.md index 502a835..0f31a44 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,15 @@ # flextunnel -A SOCKS5/HTTP-proxy-over-QUIC split tunnel. The **client** runs optional local -SOCKS5 and HTTP proxy listeners. Each request is matched +A SOCKS5/HTTP-proxy- and port-forward-over-QUIC split tunnel. The **client** +runs optional local SOCKS5 and HTTP proxy listeners, plus optional port +forwards that send a local port straight to one server-side address. Each +proxy request is matched against the server-pushed tunnel set: routed targets are tunneled as reliable QUIC bi-streams to the **server**, which performs **DNS resolution and the outbound TCP connection from its own network**, then pipes bytes back; off-list -targets are connected directly from the client device. +targets are connected directly from the client device. A forwarded port does no +matching on the client: everything it accepts goes to the server, which +enforces its routed set there. This lets you reach hosts that are only reachable from the server side — a private network, the server's own `localhost`, or names that only resolve via @@ -618,12 +622,12 @@ Auto-reconnect is **enabled by default** (`auto_reconnect = true`); pass - A failed connection attempt — the **first one included** — or a lost connection is retried with **exponential backoff + jitter** (1s doubling to - 5 min), indefinitely, unless `--max-reconnect-attempts` caps it or + 60s), indefinitely, unless `--max-reconnect-attempts` caps it or auto-reconnect is disabled. A server that is down, or not up yet, is the ordinary case, not a reason to exit: the client waits it out and connects when the server appears. - A long outage is cheap to sit through: once the backoff reaches its cap the - client makes one bounded connect attempt every five minutes. Repeated + client makes one bounded connect attempt a minute. Repeated failures escalate to rebuilding the iroh endpoint from scratch after the third one, and then at most every 30 minutes for as long as the outage lasts (see [`docs/architecture.md`](docs/architecture.md#reconnect-policy-client)). diff --git a/crates/flextunnel-cli/src/main.rs b/crates/flextunnel-cli/src/main.rs index c8b9f0f..9ee9d96 100644 --- a/crates/flextunnel-cli/src/main.rs +++ b/crates/flextunnel-cli/src/main.rs @@ -1,6 +1,7 @@ //! flextunnel //! -//! A SOCKS5/HTTP-proxy-over-QUIC split tunnel via iroh P2P connections. The +//! A SOCKS5/HTTP-proxy- and port-forward-over-QUIC split tunnel via iroh P2P +//! connections. The //! client runs optional local SOCKS5/HTTP proxy listeners and server-direct //! port forwards (declared in its config; `flextunnel client control` shows //! their state); routed @@ -40,7 +41,7 @@ use flextunnel_core::{auth, config, secret}; #[derive(Parser)] #[command(name = "flextunnel")] #[command(version)] -#[command(about = "SOCKS5/HTTP-proxy-over-QUIC split tunnel via iroh P2P")] +#[command(about = "SOCKS5/HTTP proxy and port forwards over QUIC — split tunnel via iroh P2P")] struct Args { #[command(subcommand)] command: Command, diff --git a/crates/flextunnel-cli/src/tui/view.rs b/crates/flextunnel-cli/src/tui/view.rs index cb50f97..8591c20 100644 --- a/crates/flextunnel-cli/src/tui/view.rs +++ b/crates/flextunnel-cli/src/tui/view.rs @@ -106,7 +106,7 @@ fn header_lines(s: &StatusSnapshot) -> Vec> { first.push(Span::styled(format!(" for {}", format_uptime(secs)), DIM)); } // While down, say how far the retry loop has got and when it tries again, - // so a backoff step of minutes reads as waiting, not stuck. + // so a backoff step reads as waiting, not stuck. if s.failed_attempts > 0 { let next = match s.next_attempt_secs { Some(secs) if secs > 0 => format!("next in {}", format_uptime(secs)), diff --git a/crates/flextunnel-core/src/lib.rs b/crates/flextunnel-core/src/lib.rs index 1f4786e..d7ee28d 100644 --- a/crates/flextunnel-core/src/lib.rs +++ b/crates/flextunnel-core/src/lib.rs @@ -1,6 +1,7 @@ //! flextunnel //! -//! A SOCKS5/HTTP-proxy-over-QUIC split tunnel via iroh P2P connections. The +//! A SOCKS5/HTTP-proxy- and port-forward-over-QUIC split tunnel via iroh P2P +//! connections. The //! clients may run local SOCKS5/HTTP proxy listeners or server-direct loopback //! forwards; routed targets are reliable QUIC bi-streams to the server, which //! resolves DNS and connects from its own network. Uses a fixed ALPN for diff --git a/crates/flextunnel-core/src/proxy/client.rs b/crates/flextunnel-core/src/proxy/client.rs index 10e3cf9..a94e7f8 100644 --- a/crates/flextunnel-core/src/proxy/client.rs +++ b/crates/flextunnel-core/src/proxy/client.rs @@ -27,14 +27,15 @@ use tokio::sync::{Semaphore, watch}; /// Reconnect backoff: base 1s, doubling per consecutive failed attempt, capped /// here. The early steps (1s, 2s, 4s, …) catch a server restart within a -/// minute or two; past them the doubling runs on up to the cap, so a server -/// that stays down for hours or days is probed once every five minutes for as -/// long as it takes. Each probe is one bounded connect ([`CONNECT_TIMEOUT`]) -/// on the endpoint the client already holds, so an outage of any length is -/// cheap to sit through — at the price of noticing the server's return up to -/// five minutes late. Events that make an earlier attempt worthwhile cut the -/// wait short (see [`ProxyClient::wait_backoff`]). -const RECONNECT_BACKOFF_MAX: Duration = Duration::from_secs(300); +/// minute or two; past them the doubling settles at the cap, so a server that +/// stays down for hours or days is probed once a minute for as long as it +/// takes. Each probe is one bounded connect ([`CONNECT_TIMEOUT`]) on the +/// endpoint the client already holds, so an outage of any length is cheap to +/// sit through while still noticing the server's return within a minute — a +/// wait of several minutes saves little and reads as a hang to anyone +/// watching. Events that make an earlier attempt worthwhile cut the wait +/// short (see [`ProxyClient::wait_backoff`]). +const RECONNECT_BACKOFF_MAX: Duration = Duration::from_secs(60); /// Escalate to a full endpoint rebuild once this many consecutive attempts of /// an outage have failed. The attempts before it get the cheap /// `network_change()` nudge, which repairs dead UDP sockets; a wedge that @@ -862,7 +863,7 @@ impl ProxyClient { /// reconnect. While the path is up this is a plain backoff sleep, except /// that a mid-sleep loss switches to parking, and that the embedding app /// coming to the foreground ends the sleep the same way a restored path - /// does — a backoff step sized for an unattended outage (up to + /// does — a backoff step sized for a long outage (up to /// [`RECONNECT_BACKOFF_MAX`]) must not keep a user who is looking waiting. /// /// Returns whether the wait was cut short by one of those events (the @@ -2180,12 +2181,12 @@ mod tests { #[test] fn backoff_doubles_to_the_cap() { let jitter = Duration::from_millis(RECONNECT_JITTER_MAX_MS); - for (attempt, secs) in [(0, 1), (1, 1), (2, 2), (3, 4), (7, 64), (9, 256)] { + for (attempt, secs) in [(0, 1), (1, 1), (2, 2), (3, 4), (6, 32)] { let b = calculate_backoff(attempt); let base = Duration::from_secs(secs); assert!(b >= base && b <= base + jitter, "attempt {attempt}: {b:?}"); } - for attempt in [10, 11, 20, u32::MAX] { + for attempt in [7, 9, 10, 20, u32::MAX] { let b = calculate_backoff(attempt); assert!( b >= RECONNECT_BACKOFF_MAX && b <= RECONNECT_BACKOFF_MAX + jitter, diff --git a/crates/flextunnel-ffi/src/lib.rs b/crates/flextunnel-ffi/src/lib.rs index c3bd2ec..6c3f866 100644 --- a/crates/flextunnel-ffi/src/lib.rs +++ b/crates/flextunnel-ffi/src/lib.rs @@ -552,7 +552,7 @@ pub unsafe extern "C" fn flextunnel_close_listeners(handle: *const FlextunnelHan /// heartbeat — the connection's only periodic traffic — slows from 10s to 60s, /// keeping the cellular radio in its low-power state almost the whole time; the /// foreground flip snaps it back and sends any overdue beat immediately, and if -/// the core is sitting out a reconnect backoff (up to 5 min once a long outage +/// the core is sitting out a reconnect backoff (up to 60s once a long outage /// has pushed it to the cap) it ends that wait and attempts at once with a /// fresh backoff series. Idempotent; safe to call with the same value /// repeatedly. diff --git a/docs/architecture.md b/docs/architecture.md index 9a51d09..2ab3029 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -224,7 +224,7 @@ Implemented in `ProxyClient::run` / `handle_failure`: - Every recoverable failure (`ConnectionLost` / `Network` / `Signaling` — see `ProxyError::is_recoverable`) is retried with **exponential backoff + - jitter** (1s doubling to `RECONNECT_BACKOFF_MAX`, 5 min), indefinitely, + jitter** (1s doubling to `RECONNECT_BACKOFF_MAX`, 60s), indefinitely, unless `--max-reconnect-attempts` caps it or `--no-auto-reconnect` disables it. The first attempt is no different from any later one: a server that is down or not up yet is the ordinary case (boot order, maintenance), not an @@ -233,11 +233,11 @@ Implemented in `ProxyClient::run` / `handle_failure`: - Permanent errors (`AuthenticationFailed` / `Config`) never retry — the same credential and config would fail the same way every time. - A long outage costs one bounded connect (`CONNECT_TIMEOUT`) per attempt on - the endpoint the client already holds, once every five minutes at the cap. + the endpoint the client already holds, once a minute at the cap. Two events cut a backoff step short with a fresh series: the device reporting its network path back (`set_network_available`) and the embedding app coming to the foreground (`set_background(false)`), so a user who is - looking never waits out a step sized for an unattended outage. + looking never waits out a step sized for a long outage. - An outage's **third** consecutive failure escalates to a **full endpoint rebuild** (`ClientEndpoint::rebuild`), repeated at most every `REBUILD_ENDPOINT_MIN_INTERVAL` (30 min) for as long as the outage lasts; the @@ -369,7 +369,7 @@ defenses. | `TUNNEL_OPEN_TIMEOUT` | 30s | `proxy/client.rs` | | `CONNECT_TIMEOUT` (server dial) | 10s | `proxy/dial.rs` | | `MAX_CONCURRENT_CONNECTIONS` | 1024 | `proxy/server.rs` | -| reconnect backoff | 1s → 5 min + ≤500ms jitter | `proxy/client.rs` | +| reconnect backoff | 1s → 60s + ≤500ms jitter | `proxy/client.rs` | | `REBUILD_ENDPOINT_ATTEMPTS` / `REBUILD_ENDPOINT_MIN_INTERVAL` (client endpoint rebuild) | 3rd failure, then ≥30 min apart | `proxy/client.rs` | | `MAX_HANDSHAKE_SIZE` | 64 KiB | `proxy/signaling.rs` | | `MAX_CONTROL_MSG_SIZE` | 16 KiB | `proxy/signaling.rs` | diff --git a/docs/systemd.md b/docs/systemd.md index 05293b1..2136837 100644 --- a/docs/systemd.md +++ b/docs/systemd.md @@ -57,12 +57,12 @@ The client already supervises itself where it matters: - Auto-reconnect (on by default) retries every failed connection attempt and every lost connection internally with exponential backoff (1s doubling to - 5 min), indefinitely — the first attempt included. A server that is down + 60s), indefinitely — the first attempt included. A server that is down when the unit starts, or a network that isn't up yet at boot, is waited out, not exited on: the client connects when the server appears, and there is no user-manager `network-online.target` to order against nor any need - for one. A long outage costs one bounded connect attempt every five - minutes. Reconnects that keep failing escalate to rebuilding the iroh + for one. A long outage costs one bounded connect attempt a minute. + Reconnects that keep failing escalate to rebuilding the iroh endpoint from scratch (after the third failure, then at most every 30 minutes) — the in-process equivalent of a unit restart, covering wedges (a dead relay link, stale path state) that only a fresh endpoint repairs. The diff --git a/ios/flextunnel.h b/ios/flextunnel.h index f1f8208..7164be0 100644 --- a/ios/flextunnel.h +++ b/ios/flextunnel.h @@ -148,7 +148,7 @@ int flextunnel_close_listeners(const FlextunnelHandle *handle); * traffic — slows from 10s to 60s so an idle session wakes the cellular radio * once a minute instead of six times; the foreground flip snaps it back and * sends any overdue beat immediately, and ends any reconnect backoff in - * progress (up to 5 min once a long outage has pushed it to the cap) so the + * progress (up to 60s once a long outage has pushed it to the cap) so the * next attempt runs at once with a fresh backoff series. Idempotent. * * Returns 1 on success and -1 for a NULL handle.