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; }