From 108bab6a9027c797d5a461ab3eb54fe4499151a2 Mon Sep 17 00:00:00 2001 From: ospab Date: Fri, 31 Jul 2026 19:02:03 +0300 Subject: [PATCH] fix(client): retry resume reconnects; add a way to hash the panel password MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two unrelated user-reported blockers. Resume on desktop. The suspend/resume detector fired a single reconnect attempt roughly half a second after waking — which is exactly when the NIC has not reassociated yet, so it almost always failed. Failure then fell back to the ordinary 25s stall heuristic, which keys off a monotonic clock that does not advance while the machine is asleep, so it could take another 25s of real uptime to fire, or never fire. The forced reconnect is now sticky: retried every 3s until a session is actually established, verified via last_valid_recv rather than by "an attempt was made". Panel password. `api.password_hash` wants a hash, and nothing in the CLI could produce one: `ostp init server` emits password_hash: "" and the only generator was inline in the Server+Panel wizard branch, which is Unix-only — so on a plain server there was no supported path to working API auth at all. Adds `ostp hash-password [PASSWORD]`, prompting when the argument is omitted so the password stays out of shell history. Output verified to match both handle_login's comparison and a reference SHA-256. --- ostp-client/src/bridge.rs | 32 ++++++++++++++++++++++++++++++++ ostp/src/main.rs | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+) diff --git a/ostp-client/src/bridge.rs b/ostp-client/src/bridge.rs index 23201db..3e4a821 100644 --- a/ostp-client/src/bridge.rs +++ b/ostp-client/src/bridge.rs @@ -137,6 +137,16 @@ pub struct Bridge { last_rtt_ms: f64, last_sample_at: Instant, last_valid_recv: Instant, + /// Set when a suspend/resume is detected, cleared once a reconnect actually + /// succeeds. Waking is precisely when the network is least likely to be + /// ready — Wi-Fi has not reassociated yet — so a single attempt fired + /// milliseconds after resume usually fails, and a one-shot forced reconnect + /// then fell back to the ordinary 25s stall heuristic. That heuristic keys + /// off a monotonic clock which does not advance while the machine is + /// asleep, so it could take a further 25s of real uptime to fire, or not + /// fire at all. Retrying until success removes the dependency on either. + forced_reconnect_pending: bool, + last_forced_reconnect_try: Instant, } impl Bridge { @@ -173,6 +183,8 @@ impl Bridge { last_rtt_ms: 0.0, last_sample_at: Instant::now(), last_valid_recv: Instant::now(), + forced_reconnect_pending: false, + last_forced_reconnect_try: Instant::now(), }) } @@ -255,7 +267,27 @@ impl Bridge { let _ = tx.send(UiEvent::Log(format!( "Resumed after ~{}s suspend — forcing clean reconnect", wall_gap.as_secs() ))).await; + self.forced_reconnect_pending = true; + self.last_forced_reconnect_try = Instant::now() - Duration::from_secs(60); + } + + // Keep retrying a resume-triggered reconnect until one lands. + // The first attempt fires within half a second of waking, when + // the NIC is typically still reassociating, so treating it as + // one-shot left the tunnel dead until some other timer noticed. + if self.running + && self.forced_reconnect_pending + && self.last_forced_reconnect_try.elapsed() >= Duration::from_secs(3) + { + self.last_forced_reconnect_try = Instant::now(); self.handle_keepalive(true, &mut sessions_opt, &mut udp_rx_opt, &mut proxy_guard, &mut stream_map, &tx, &proxy_tx, &mut proxy_rx).await; + // handle_keepalive refreshes last_valid_recv only when a + // session was actually established, so this is a real + // success check rather than "we tried". + if self.last_valid_recv.elapsed() < Duration::from_secs(3) { + self.forced_reconnect_pending = false; + let _ = tx.send(UiEvent::Log("Reconnected after suspend".into())).await; + } } if self.running { self.emit_metrics(&tx).await; diff --git a/ostp/src/main.rs b/ostp/src/main.rs index fe9dac2..0abf405 100644 --- a/ostp/src/main.rs +++ b/ostp/src/main.rs @@ -28,6 +28,12 @@ enum Commands { Init { mode: String, }, + /// Hash a password for the web panel's `api.password_hash` config field + #[command(name = "hash-password", alias = "hp")] + HashPassword { + /// The password to hash. Omit to be prompted (keeps it out of shell history). + password: Option, + }, /// Generate a new secure access key #[command(name = "gk", alias = "generate-key")] GenerateKey { @@ -920,6 +926,38 @@ async fn run_app() -> Result<()> { match cmd { Commands::Setup { init } => { args.setup = true; args.init = init; } Commands::Init { mode } => { args.init = Some(mode); } + Commands::HashPassword { password } => { + // The panel stores only a hash, and until now nothing in the CLI + // could produce one: `ostp init server` writes password_hash: "" + // and the only generator lived inside the Unix-only Server+Panel + // wizard branch, leaving no supported way to set up API auth on a + // plain server. + let password = match password { + Some(p) => p, + None => { + print!("Password: "); + use std::io::Write as _; + std::io::stdout().flush().ok(); + let mut buf = String::new(); + std::io::stdin().read_line(&mut buf)?; + buf.trim_end_matches(['\r', '\n']).to_string() + } + }; + if password.is_empty() { + anyhow::bail!("password must not be empty"); + } + // Must match api.rs's handle_login byte for byte. + let hash = format!( + "{:x}", + ::digest(password.as_bytes()) + ); + println!(); + println!("Add this to the \"api\" section of your config:"); + println!(); + println!(" \"password_hash\": \"{hash}\""); + println!(); + return Ok(()); + } Commands::GenerateKey { format, count } => { args.generate_key = true; args.format = format; args.count = count; } Commands::Links => { args.links = true; } Commands::Check => { args.check = true; }