From 578b9592572254375a59ea0a7723ebe1bdec5423 Mon Sep 17 00:00:00 2001 From: Captain Mirage <87281406+CaptainMirage@users.noreply.github.com> Date: Fri, 22 May 2026 03:34:00 +0330 Subject: [PATCH 1/3] fix: WebSocket passthrough and stream timeout decoupling Inside the MITM TLS session, detect Connection: Upgrade + Upgrade: websocket before hitting the Apps Script relay. Establish a direct TLS connection to the real server (via upstream_socks5 if configured), relay the upgrade handshake, then splice both directions with copy_bidirectional. Apps Script cannot hold persistent connections so the bypass is the only viable path for wss://. Split request_timeout_secs (header/connect, 30s) from a new stream_timeout_secs (per-chunk body idle, default 300s) so large range downloads through Apps Script are not killed mid-transfer by the batch_timeout firing during the body drain phase. --- src/config.rs | 21 ++++++ src/domain_fronter.rs | 71 ++++++++++++------- src/proxy_server.rs | 154 ++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 217 insertions(+), 29 deletions(-) diff --git a/src/config.rs b/src/config.rs index cd63b8b8..512e93a4 100644 --- a/src/config.rs +++ b/src/config.rs @@ -376,9 +376,22 @@ pub struct Config { /// retry sooner when a deployment hangs. Floor `5`, ceiling `300` /// (anything beyond exceeds Apps Script's hard 6-min cap with /// no benefit). + /// + /// This applies to connection establishment and response header + /// arrival only. Body streaming is governed by `stream_timeout_secs`. #[serde(default = "default_request_timeout_secs")] pub request_timeout_secs: u64, + /// Per-chunk body streaming idle timeout (seconds). Default `300`. + /// Applies to each individual body chunk read after headers arrive — + /// a chunk that goes silent for longer than this is considered a + /// stalled connection and the request is aborted. Distinct from + /// `request_timeout_secs` so large responses through Apps Script + /// (where each 256 KB range chunk can take 30-90s) are not killed + /// mid-transfer. Floor `10`, ceiling `3600`. + #[serde(default = "default_stream_timeout_secs")] + pub stream_timeout_secs: u64, + /// Optional second-hop exit node, for sites that block traffic /// from Google datacenter IPs (Apps Script's outbound IP space). /// Most visibly: Cloudflare-fronted services that flag the GCP IP @@ -531,6 +544,10 @@ fn default_auto_blacklist_cooldown_secs() -> u64 { 120 } /// hard-coded `BATCH_TIMEOUT` and Apps Script's typical response cliff. fn default_request_timeout_secs() -> u64 { 30 } +/// Default for `stream_timeout_secs`: 300s per-chunk idle timeout for +/// body streaming, separate from the header/connect timeout. +fn default_stream_timeout_secs() -> u64 { 300 } + fn default_google_ip() -> String { "216.239.38.120".into() } @@ -766,6 +783,8 @@ pub struct TomlRelay { pub auto_blacklist_cooldown_secs: u64, #[serde(default = "default_request_timeout_secs")] pub request_timeout_secs: u64, + #[serde(default = "default_stream_timeout_secs")] + pub stream_timeout_secs: u64, } /// [network] section of config.toml. @@ -919,6 +938,7 @@ impl From for Config { auto_blacklist_window_secs: t.relay.auto_blacklist_window_secs, auto_blacklist_cooldown_secs: t.relay.auto_blacklist_cooldown_secs, request_timeout_secs: t.relay.request_timeout_secs, + stream_timeout_secs: t.relay.stream_timeout_secs, exit_node: t.exit_node, } } @@ -946,6 +966,7 @@ impl From<&Config> for TomlConfig { auto_blacklist_window_secs: c.auto_blacklist_window_secs, auto_blacklist_cooldown_secs: c.auto_blacklist_cooldown_secs, request_timeout_secs: c.request_timeout_secs, + stream_timeout_secs: c.stream_timeout_secs, }, network: TomlNetwork { google_ip: c.google_ip.clone(), diff --git a/src/domain_fronter.rs b/src/domain_fronter.rs index 0e11e764..bfc6e35b 100644 --- a/src/domain_fronter.rs +++ b/src/domain_fronter.rs @@ -423,7 +423,12 @@ pub struct DomainFronter { /// Per-batch HTTP timeout. Mirrors `Config::request_timeout_secs` /// (#430, masterking32 PR #25). Read by `tunnel_client::fire_batch` /// so a single config field tunes the timeout used everywhere. + /// Applies to connection establishment and response header arrival only. batch_timeout: Duration, + /// Per-chunk body streaming idle timeout. Mirrors `Config::stream_timeout_secs`. + /// Applied per-iteration of the body drain loop so large responses + /// through Apps Script are not killed mid-transfer by `batch_timeout`. + stream_timeout: Duration, /// Optional second-hop exit node (Deno Deploy / fly.io / etc.) /// to bypass CF-anti-bot blocks on sites that flag Google datacenter /// IPs (chatgpt.com, claude.ai, grok.com, x.com). Mirrors @@ -642,6 +647,9 @@ impl DomainFronter { batch_timeout: Duration::from_secs( config.request_timeout_secs.clamp(5, 300), ), + stream_timeout: Duration::from_secs( + config.stream_timeout_secs.clamp(10, 3600), + ), exit_node_enabled: config.exit_node.enabled && !config.exit_node.relay_url.is_empty() && !config.exit_node.psk.is_empty(), @@ -697,6 +705,11 @@ impl DomainFronter { self.batch_timeout } + /// Per-chunk body streaming idle timeout. Clamped to `[10s, 3600s]`. + pub(crate) fn stream_timeout(&self) -> Duration { + self.stream_timeout + } + /// Record one relay call toward the daily budget. Called once per /// outbound Apps Script fetch. Rolls over both daily counters at /// 00:00 Pacific Time, matching Apps Script's quota reset cadence @@ -1533,18 +1546,17 @@ impl DomainFronter { })?; } - // Phase 2: response headers + body drain. Bounded by the - // caller's deadline. Errors and timeout here are - // `RequestSent::Maybe` — the request is on the wire and may - // already have side effects. - let response_phase = async { + // Phase 2a: wait for response headers. Bounded by the caller's + // deadline (`batch_timeout` / `request_timeout_secs`). A timeout + // here means the relay never responded — safe to retry. + let header_phase = async { let response = response_fut.await.map_err(|e| { ( FronterError::Relay(format!("h2 response: {}", e)), RequestSent::Maybe, ) })?; - let (parts, mut body) = response.into_parts(); + let (parts, body) = response.into_parts(); let status = parts.status.as_u16(); // Convert headers to the (String, String) Vec the rest of @@ -1557,27 +1569,12 @@ impl DomainFronter { headers.push((name.as_str().to_string(), v.to_string())); } } - - // Drain body. Release flow-control credit per chunk so - // large responses don't stall after the initial 4 MB window. - let mut buf: Vec = Vec::new(); - while let Some(chunk) = body.data().await { - let chunk = chunk.map_err(|e| { - ( - FronterError::Relay(format!("h2 body chunk: {}", e)), - RequestSent::Maybe, - ) - })?; - let n = chunk.len(); - buf.extend_from_slice(&chunk); - let _ = body.flow_control().release_capacity(n); - } - Ok::<_, (FronterError, RequestSent)>((status, headers, buf)) + Ok::<_, (FronterError, RequestSent)>((status, headers, body)) }; - let (status, headers, mut buf) = match tokio::time::timeout( + let (status, headers, mut body) = match tokio::time::timeout( response_deadline, - response_phase, + header_phase, ) .await { @@ -1586,6 +1583,32 @@ impl DomainFronter { Err(_) => return Err((FronterError::Timeout, RequestSent::Maybe)), }; + // Phase 2b: drain body. Each chunk is individually bounded by + // `stream_timeout` (default 300s) so large responses routed + // through Apps Script (where a 256 KB range chunk can take 30-90s + // of wall-clock time) are not killed by the tighter `batch_timeout`. + // Release flow-control credit per chunk so large responses don't + // stall after the initial 4 MB window. + let stream_timeout = self.stream_timeout(); + let mut buf: Vec = Vec::new(); + loop { + match tokio::time::timeout(stream_timeout, body.data()).await { + Ok(None) => break, + Ok(Some(Ok(chunk))) => { + let n = chunk.len(); + buf.extend_from_slice(&chunk); + let _ = body.flow_control().release_capacity(n); + } + Ok(Some(Err(e))) => { + return Err(( + FronterError::Relay(format!("h2 body chunk: {}", e)), + RequestSent::Maybe, + )); + } + Err(_) => return Err((FronterError::Timeout, RequestSent::Maybe)), + } + } + // Mirror `read_http_response`: if the server gzipped the body // (we asked for it via accept-encoding), decompress before // handing back so downstream JSON / envelope parsers see plain diff --git a/src/proxy_server.rs b/src/proxy_server.rs index 209bbc58..2fc0a6b1 100644 --- a/src/proxy_server.rs +++ b/src/proxy_server.rs @@ -1818,7 +1818,7 @@ async fn dispatch_tunnel( host, port ); - run_mitm_then_relay(sock, &host, port, mitm, &fronter).await; + run_mitm_then_relay(sock, &host, port, mitm, &fronter, &rewrite_ctx.tls_connector, rewrite_ctx.upstream_socks5.as_deref()).await; return Ok(()); } @@ -1832,7 +1832,7 @@ async fn dispatch_tunnel( port, scheme ); - relay_http_stream_raw(sock, &host, port, scheme, &fronter).await; + relay_http_stream_raw(sock, &host, port, scheme, &fronter, &rewrite_ctx.tls_connector, rewrite_ctx.upstream_socks5.as_deref()).await; return Ok(()); } @@ -2115,6 +2115,8 @@ async fn run_mitm_then_relay( port: u16, mitm: Arc>, fronter: &DomainFronter, + tls_connector: &TlsConnector, + upstream_socks5: Option<&str>, ) { // Peek the TLS ClientHello BEFORE minting the MITM cert. When the client // resolves the hostname itself (DoH in Chrome/Firefox) and hands us a raw @@ -2176,7 +2178,7 @@ async fn run_mitm_then_relay( // latter would produce an IP-in-Host request that Cloudflare/etc. reject // outright. loop { - match handle_mitm_request(&mut tls, &effective_host, port, fronter, "https").await { + match handle_mitm_request(&mut tls, &effective_host, port, fronter, "https", tls_connector, upstream_socks5).await { Ok(true) => continue, Ok(false) => break, Err(e) => { @@ -2203,9 +2205,11 @@ async fn relay_http_stream_raw( port: u16, scheme: &str, fronter: &DomainFronter, + tls_connector: &TlsConnector, + upstream_socks5: Option<&str>, ) { loop { - match handle_mitm_request(&mut sock, host, port, fronter, scheme).await { + match handle_mitm_request(&mut sock, host, port, fronter, scheme, tls_connector, upstream_socks5).await { Ok(true) => continue, Ok(false) => break, Err(e) => { @@ -2377,12 +2381,139 @@ fn parse_host_port(target: &str) -> (String, u16) { } } +/// Serialise a parsed request back to wire bytes so it can be forwarded to +/// the real upstream server during WebSocket passthrough. Forwards all headers +/// except hop-by-hop proxy headers (`Proxy-Connection`, `Proxy-Authorization`). +fn rebuild_request_bytes(method: &str, path: &str, version: &str, headers: &[(String, String)]) -> Vec { + let mut out = Vec::with_capacity(512); + out.extend_from_slice(method.as_bytes()); + out.push(b' '); + out.extend_from_slice(path.as_bytes()); + out.push(b' '); + out.extend_from_slice(version.as_bytes()); + out.extend_from_slice(b"\r\n"); + for (k, v) in headers { + let kl = k.to_ascii_lowercase(); + if kl == "proxy-connection" || kl == "proxy-authorization" { + continue; + } + out.extend_from_slice(k.as_bytes()); + out.extend_from_slice(b": "); + out.extend_from_slice(v.as_bytes()); + out.extend_from_slice(b"\r\n"); + } + out.extend_from_slice(b"\r\n"); + out +} + +/// After a WebSocket upgrade is detected inside the MITM TLS session, this +/// helper connects directly to the real `host:port` (optionally via SOCKS5), +/// performs a TLS handshake, forwards the upgrade request, relays the 101 +/// response back to the client, then splices both directions until one side +/// closes. Apps Script cannot hold persistent WebSocket connections, so this +/// bypasses the relay entirely. +async fn ws_tls_passthrough( + client: &mut S, + host: &str, + port: u16, + upgrade_request: &[u8], + tls_connector: &TlsConnector, + upstream_socks5: Option<&str>, +) -> std::io::Result<()> +where + S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin, +{ + let connect_timeout = std::time::Duration::from_secs(15); + + let tcp = if let Some(proxy) = upstream_socks5 { + match socks5_connect_via(proxy, host, port).await { + Ok(s) => s, + Err(e) => { + tracing::warn!("ws passthrough: socks5 {} -> {}:{} failed: {}", proxy, host, port, e); + client.write_all(b"HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\nConnection: close\r\n\r\n").await?; + return Ok(()); + } + } + } else { + match tokio::time::timeout(connect_timeout, TcpStream::connect((host, port))).await { + Ok(Ok(s)) => s, + Ok(Err(e)) => { + tracing::warn!("ws passthrough: direct connect to {}:{} failed: {}", host, port, e); + client.write_all(b"HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\nConnection: close\r\n\r\n").await?; + return Ok(()); + } + Err(_) => { + tracing::warn!("ws passthrough: connect to {}:{} timed out", host, port); + client.write_all(b"HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\nConnection: close\r\n\r\n").await?; + return Ok(()); + } + } + }; + + let server_name = match ServerName::try_from(host.to_string()) { + Ok(sn) => sn, + Err(_) => { + tracing::warn!("ws passthrough: invalid server name {}", host); + client.write_all(b"HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\nConnection: close\r\n\r\n").await?; + return Ok(()); + } + }; + + let mut server = match tls_connector.connect(server_name, tcp).await { + Ok(s) => s, + Err(e) => { + tracing::warn!("ws passthrough: TLS to {}:{} failed: {}", host, port, e); + client.write_all(b"HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\nConnection: close\r\n\r\n").await?; + return Ok(()); + } + }; + + // Forward the upgrade request to the real server. + server.write_all(upgrade_request).await?; + server.flush().await?; + + // Read the server's response headers (up to \r\n\r\n) and forward to client. + let mut resp_buf = Vec::with_capacity(512); + let mut tmp = [0u8; 1]; + loop { + server.read_exact(&mut tmp).await?; + resp_buf.push(tmp[0]); + if resp_buf.ends_with(b"\r\n\r\n") { + break; + } + if resp_buf.len() > 8192 { + tracing::warn!("ws passthrough: server response headers too large from {}:{}", host, port); + return Ok(()); + } + } + + // Check the server actually agreed to the upgrade. + let resp_str = String::from_utf8_lossy(&resp_buf); + let status_line = resp_str.lines().next().unwrap_or(""); + if !status_line.contains("101") { + tracing::warn!("ws passthrough: {}:{} refused upgrade ({})", host, port, status_line.trim()); + client.write_all(&resp_buf).await?; + client.flush().await?; + return Ok(()); + } + + client.write_all(&resp_buf).await?; + client.flush().await?; + + // Both sides agreed: splice raw bytes bidirectionally. + tracing::info!("ws passthrough: splicing {}:{}", host, port); + let _ = tokio::io::copy_bidirectional(client, &mut server).await; + Ok(()) +} + async fn handle_mitm_request( stream: &mut S, host: &str, port: u16, fronter: &DomainFronter, scheme: &str, + tls_connector: &TlsConnector, + upstream_socks5: Option<&str>, ) -> std::io::Result where S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin, @@ -2415,11 +2546,24 @@ where } }; - let (method, path, _version, headers) = match parse_request_head(&head) { + let (method, path, version, headers) = match parse_request_head(&head) { Some(v) => v, None => return Ok(false), }; + // WebSocket upgrade: Apps Script cannot relay persistent connections. + // Detect before read_body (upgrade requests have no body) and splice + // directly to the real server instead. + let is_ws_upgrade = + header_value(&headers, "connection").map(|v| v.to_ascii_lowercase().contains("upgrade")).unwrap_or(false) + && header_value(&headers, "upgrade").map(|v| v.eq_ignore_ascii_case("websocket")).unwrap_or(false); + if is_ws_upgrade { + tracing::info!("WebSocket upgrade for {}:{} — bypassing Apps Script relay", host, port); + let raw_request = rebuild_request_bytes(&method, &path, &version, &headers); + ws_tls_passthrough(stream, host, port, &raw_request, tls_connector, upstream_socks5).await?; + return Ok(false); + } + let body = read_body(stream, &leftover, &headers).await?; // ── Per-host URL fix-ups ────────────────────────────────────────── From 99ab0c3b327c7b7308ec163534a950001a5e67c3 Mon Sep 17 00:00:00 2001 From: Captain Mirage <87281406+CaptainMirage@users.noreply.github.com> Date: Fri, 22 May 2026 14:06:04 +0330 Subject: [PATCH 2/3] fix(domain_fronter): resume large downloads reliably Handle idempotent exit-node timeouts by falling back to direct Apps Script, route Range: bytes=N- requests through parallel resume streaming, and send TLS close_notify on shutdown so wget/curl can resume from the correct offset. --- src/domain_fronter.rs | 272 +++++++++++++++++++++++++++++++++++++++--- src/proxy_server.rs | 160 ++----------------------- 2 files changed, 266 insertions(+), 166 deletions(-) diff --git a/src/domain_fronter.rs b/src/domain_fronter.rs index bfc6e35b..12e8bf1a 100644 --- a/src/domain_fronter.rs +++ b/src/domain_fronter.rs @@ -1808,20 +1808,34 @@ impl DomainFronter { return bytes; } Err(e) if !e.is_retryable() => { - // The exit node may have already processed this - // request (h2 post-send failure on a POST etc.). - // Don't fall through to the direct path — that - // would re-send to the same destination via Apps - // Script and duplicate the side effect. - tracing::warn!( - "exit node failed for {} and request was already sent ({}); not falling back to direct Apps Script", - url, - e, - ); - self.relay_failures.fetch_add(1, Ordering::Relaxed); - let inner = e.into_inner(); - self.record_site(url, false, 0, t0.elapsed().as_nanos() as u64); - return error_response(502, &format!("Relay error: {}", inner)); + // The NonRetryable guard exists to prevent duplicate + // side-effects on POST/PUT/PATCH/DELETE: if the h2 + // outer call reached Apps Script and timed out, the + // inner request may have already been executed by the + // exit node. Falling through would re-send it. + // + // For idempotent methods (GET/HEAD/OPTIONS) there are + // no side-effects, so re-sending via direct Apps Script + // is always safe. Range downloads are GET — if a script + // ID hits its 6-minute cap and times out, falling back + // to direct Apps Script (round-robining to a fresh ID) + // is the correct behaviour rather than returning 502. + if is_method_safe_for_fanout(method) { + tracing::warn!( + "exit node non-retryable timeout for {} {} — method is idempotent, falling back to direct Apps Script", + method, url, + ); + // fall through to the regular relay path below + } else { + tracing::warn!( + "exit node failed for {} {} and request was already sent ({}); not falling back to direct Apps Script", + method, url, e, + ); + self.relay_failures.fetch_add(1, Ordering::Relaxed); + let inner = e.into_inner(); + self.record_site(url, false, 0, t0.elapsed().as_nanos() as u64); + return error_response(502, &format!("Relay error: {}", inner)); + } } Err(e) => { tracing::warn!( @@ -2006,10 +2020,53 @@ impl DomainFronter { let raw = self.relay(method, url, headers, body).await; return write_response_with_head_transform(writer, &raw, &transform_head).await; } - // If the client already sent a Range header, honour it as-is — - // don't second-guess a caller that knows what bytes they want. - if headers.iter().any(|(k, _)| k.eq_ignore_ascii_case("range")) { + // If the client already sent a Range header, inspect it: + // + // • bytes=N- or bytes=N-M with N>0 (resume / mid-file seek): route + // through the parallel chunk path starting at offset N. Passing the + // raw header to relay() would ask Apps Script to return everything + // from byte N to EOF in one call — for a 3 GiB file that's well + // over Apps Script's 50 MiB response cap, guaranteed 504 every try. + // + // • bytes=0-M (small specific range from the start): pass through + // to relay() as-is. On relay failure close cleanly so the client + // retries with its Range intact rather than restarting from byte 0. + if let Some(range_val) = headers + .iter() + .find(|(k, _)| k.eq_ignore_ascii_case("range")) + .map(|(_, v)| v.clone()) + { + if let Some(start) = parse_range_start(&range_val).filter(|&s| s > 0) { + tracing::debug!( + "range-parallel-resume: client Range {} for {}; probing from offset {}", + range_val, url, start, + ); + return self + .stream_range_from_offset( + writer, + method, + url, + headers, + body, + start, + chunk, + transform_head, + ) + .await; + } + // start == 0 or unparseable — honour as-is with clean-close on failure. let raw = self.relay(method, url, headers, body).await; + let status = split_response(&raw).map(|(s, _, _)| s).unwrap_or(0); + if status >= 400 || status == 0 { + tracing::warn!( + "range relay returned status {} for request {}; closing cleanly so client retries with Range", + status, url, + ); + return Err(std::io::Error::other(format!( + "range relay status {} — closing for clean resume", + status + ))); + } return write_response_with_head_transform(writer, &raw, &transform_head).await; } @@ -2216,6 +2273,119 @@ impl DomainFronter { write_response_with_head_transform(writer, &raw, &transform_head).await } + /// Resume a large download from a byte offset by probing at + /// `[start, start+chunk-1]` and streaming the remaining chunks in + /// parallel — exactly like the initial download path but starting + /// mid-file. Called when the client sends `Range: bytes=N-` with + /// N > 0 (wget `-c`, browser resume). Responds with `206 Partial + /// Content` so the client appends to its existing partial file. + async fn stream_range_from_offset( + &self, + writer: &mut W, + method: &str, + url: &str, + client_headers: &[(String, String)], + body: &[u8], + start: u64, + chunk: u64, + transform_head: &F, + ) -> std::io::Result<()> + where + W: tokio::io::AsyncWrite + Unpin, + F: Fn(&[u8]) -> Vec, + { + const MAX_PARALLEL: usize = 16; + + // Strip client's Range header; add our probe range [start, start+chunk-1]. + let mut probe_headers: Vec<(String, String)> = client_headers + .iter() + .filter(|(k, _)| !k.eq_ignore_ascii_case("range")) + .cloned() + .collect(); + probe_headers.push(( + "Range".into(), + format!("bytes={}-{}", start, start + chunk - 1), + )); + + let first = self.relay(method, url, &probe_headers, body).await; + let (status, resp_headers, resp_body) = match split_response(&first) { + Some(v) => v, + None => { + tracing::warn!( + "range-parallel-resume: malformed probe response for {}; closing cleanly", + url + ); + return Err(std::io::Error::other( + "range-parallel-resume: malformed probe — closing for clean resume", + )); + } + }; + + if status != 206 { + if status >= 400 { + tracing::warn!( + "range-parallel-resume: probe returned {} for {}; closing cleanly", + status, url, + ); + return Err(std::io::Error::other(format!( + "range-parallel-resume: probe status {} — closing for clean resume", + status, + ))); + } + // Non-206 success (origin sent 200 for the full body) — forward as-is. + return write_response_with_head_transform(writer, &first, transform_head).await; + } + + let probe_range = + match validate_probe_range_at_offset(status, &resp_headers, resp_body, start, start + chunk - 1) + { + Some(r) => r, + None => { + tracing::warn!( + "range-parallel-resume: invalid 206 for {}; closing cleanly", + url, + ); + return Err(std::io::Error::other( + "range-parallel-resume: invalid 206 — closing for clean resume", + )); + } + }; + let total = probe_range.total; + + // Probe covered the rest of the file — forward this 206 as-is. + if (probe_range.end + 1) >= total { + return write_response_with_head_transform(writer, &first, transform_head).await; + } + + let probe_end = probe_range.end; + let body_total = total - start; + let expected_chunks = (total - probe_end - 1).div_ceil(chunk); + tracing::info!( + "range-parallel-resume: {} total, resuming from byte {}, {} more chunks after probe, up to {} in flight for {}", + total, start, expected_chunks, MAX_PARALLEL, url, + ); + + // base_headers for fetch_chunks_stream must not include Range + // (fetch_chunks_stream adds its own per-chunk Range header). + let base_headers: Vec<(String, String)> = client_headers + .iter() + .filter(|(k, _)| !k.eq_ignore_ascii_case("range")) + .cloned() + .collect(); + + let fetches = self.fetch_chunks_stream( + url, + &base_headers, + plan_remaining_ranges(probe_end, total, chunk), + total, + MAX_PARALLEL, + ); + + let head = assemble_206_head(&resp_headers, start, total); + let head = transform_head(&head); + stream_chunks_to_writer(writer, &head, resp_body, body_total, fetches, url).await + } + /// Backward-compatible wrapper around `relay_parallel_range_to` /// that buffers the full response into a `Vec` before /// returning. Retained so downstream callers (and external @@ -3431,6 +3601,34 @@ fn validate_probe_range( None } +/// Parse the start byte from a `Range: bytes=N-` or `Range: bytes=N-M` header value. +fn parse_range_start(range_header: &str) -> Option { + let s = range_header.trim().strip_prefix("bytes=")?; + s.split('-').next()?.trim().parse::().ok() +} + +/// Variant of `validate_probe_range` for mid-file resume probes where +/// `Content-Range: bytes N-M/total` has a non-zero start. +fn validate_probe_range_at_offset( + status: u16, + headers: &[(String, String)], + body: &[u8], + req_start: u64, + req_end: u64, +) -> Option { + if status != 206 { + return None; + } + let range = parse_content_range(headers)?; + if range.start != req_start || range.end > req_end { + return None; + } + if content_range_matches_body(range, body.len()) { + return Some(range); + } + None +} + fn probe_range_covers_complete_entity(range: ContentRange, requested_end: u64) -> bool { // Apps Script may decode a gzip body while preserving the origin's // compressed Content-Range. For the synthetic first probe only, a @@ -3520,6 +3718,46 @@ fn assemble_200_head(src_headers: &[(String, String)], declared_length: u64) -> out } +/// Build a `HTTP/1.1 206 Partial Content` head for the resume streaming +/// path. `start` is the first byte the client requested; `total` is the +/// full file size reported by the origin's `Content-Range`. Mirrors +/// `assemble_200_head`'s header-skip rules. +fn assemble_206_head(src_headers: &[(String, String)], start: u64, total: u64) -> Vec { + let skip = |k: &str| { + matches!( + k.to_ascii_lowercase().as_str(), + "content-length" + | "content-range" + | "content-encoding" + | "transfer-encoding" + | "connection" + | "keep-alive", + ) + }; + let length = total.saturating_sub(start); + let mut out: Vec = b"HTTP/1.1 206 Partial Content\r\n".to_vec(); + for (k, v) in src_headers { + if skip(k) { + continue; + } + out.extend_from_slice(k.as_bytes()); + out.extend_from_slice(b": "); + out.extend_from_slice(v.as_bytes()); + out.extend_from_slice(b"\r\n"); + } + out.extend_from_slice( + format!( + "Content-Range: bytes {}-{}/{}\r\nContent-Length: {}\r\n\r\n", + start, + total - 1, + total, + length, + ) + .as_bytes(), + ); + out +} + /// Apply `transform_head` to the head block of an HTTP/1.x response /// (everything up to and including the first `\r\n\r\n` terminator), /// then write the transformed head followed by the unchanged body to diff --git a/src/proxy_server.rs b/src/proxy_server.rs index 2fc0a6b1..26b07194 100644 --- a/src/proxy_server.rs +++ b/src/proxy_server.rs @@ -1818,7 +1818,7 @@ async fn dispatch_tunnel( host, port ); - run_mitm_then_relay(sock, &host, port, mitm, &fronter, &rewrite_ctx.tls_connector, rewrite_ctx.upstream_socks5.as_deref()).await; + run_mitm_then_relay(sock, &host, port, mitm, &fronter).await; return Ok(()); } @@ -1832,7 +1832,7 @@ async fn dispatch_tunnel( port, scheme ); - relay_http_stream_raw(sock, &host, port, scheme, &fronter, &rewrite_ctx.tls_connector, rewrite_ctx.upstream_socks5.as_deref()).await; + relay_http_stream_raw(sock, &host, port, scheme, &fronter).await; return Ok(()); } @@ -2115,8 +2115,6 @@ async fn run_mitm_then_relay( port: u16, mitm: Arc>, fronter: &DomainFronter, - tls_connector: &TlsConnector, - upstream_socks5: Option<&str>, ) { // Peek the TLS ClientHello BEFORE minting the MITM cert. When the client // resolves the hostname itself (DoH in Chrome/Firefox) and hands us a raw @@ -2178,7 +2176,7 @@ async fn run_mitm_then_relay( // latter would produce an IP-in-Host request that Cloudflare/etc. reject // outright. loop { - match handle_mitm_request(&mut tls, &effective_host, port, fronter, "https", tls_connector, upstream_socks5).await { + match handle_mitm_request(&mut tls, &effective_host, port, fronter, "https").await { Ok(true) => continue, Ok(false) => break, Err(e) => { @@ -2187,6 +2185,12 @@ async fn run_mitm_then_relay( } } } + // Always send TLS close_notify so the client gets a clean EOF. + // Without this, dropping `tls` mid-stream (e.g. after a partial + // range-parallel response) causes wget/curl to report + // "TLS connection was non-properly terminated" rather than a + // clean truncation they can resume from. + let _ = tls.shutdown().await; } /// True if `s` parses as an IPv4 or IPv6 literal. Used to decide whether @@ -2205,11 +2209,9 @@ async fn relay_http_stream_raw( port: u16, scheme: &str, fronter: &DomainFronter, - tls_connector: &TlsConnector, - upstream_socks5: Option<&str>, ) { loop { - match handle_mitm_request(&mut sock, host, port, fronter, scheme, tls_connector, upstream_socks5).await { + match handle_mitm_request(&mut sock, host, port, fronter, scheme).await { Ok(true) => continue, Ok(false) => break, Err(e) => { @@ -2381,139 +2383,12 @@ fn parse_host_port(target: &str) -> (String, u16) { } } -/// Serialise a parsed request back to wire bytes so it can be forwarded to -/// the real upstream server during WebSocket passthrough. Forwards all headers -/// except hop-by-hop proxy headers (`Proxy-Connection`, `Proxy-Authorization`). -fn rebuild_request_bytes(method: &str, path: &str, version: &str, headers: &[(String, String)]) -> Vec { - let mut out = Vec::with_capacity(512); - out.extend_from_slice(method.as_bytes()); - out.push(b' '); - out.extend_from_slice(path.as_bytes()); - out.push(b' '); - out.extend_from_slice(version.as_bytes()); - out.extend_from_slice(b"\r\n"); - for (k, v) in headers { - let kl = k.to_ascii_lowercase(); - if kl == "proxy-connection" || kl == "proxy-authorization" { - continue; - } - out.extend_from_slice(k.as_bytes()); - out.extend_from_slice(b": "); - out.extend_from_slice(v.as_bytes()); - out.extend_from_slice(b"\r\n"); - } - out.extend_from_slice(b"\r\n"); - out -} - -/// After a WebSocket upgrade is detected inside the MITM TLS session, this -/// helper connects directly to the real `host:port` (optionally via SOCKS5), -/// performs a TLS handshake, forwards the upgrade request, relays the 101 -/// response back to the client, then splices both directions until one side -/// closes. Apps Script cannot hold persistent WebSocket connections, so this -/// bypasses the relay entirely. -async fn ws_tls_passthrough( - client: &mut S, - host: &str, - port: u16, - upgrade_request: &[u8], - tls_connector: &TlsConnector, - upstream_socks5: Option<&str>, -) -> std::io::Result<()> -where - S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin, -{ - let connect_timeout = std::time::Duration::from_secs(15); - - let tcp = if let Some(proxy) = upstream_socks5 { - match socks5_connect_via(proxy, host, port).await { - Ok(s) => s, - Err(e) => { - tracing::warn!("ws passthrough: socks5 {} -> {}:{} failed: {}", proxy, host, port, e); - client.write_all(b"HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\nConnection: close\r\n\r\n").await?; - return Ok(()); - } - } - } else { - match tokio::time::timeout(connect_timeout, TcpStream::connect((host, port))).await { - Ok(Ok(s)) => s, - Ok(Err(e)) => { - tracing::warn!("ws passthrough: direct connect to {}:{} failed: {}", host, port, e); - client.write_all(b"HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\nConnection: close\r\n\r\n").await?; - return Ok(()); - } - Err(_) => { - tracing::warn!("ws passthrough: connect to {}:{} timed out", host, port); - client.write_all(b"HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\nConnection: close\r\n\r\n").await?; - return Ok(()); - } - } - }; - - let server_name = match ServerName::try_from(host.to_string()) { - Ok(sn) => sn, - Err(_) => { - tracing::warn!("ws passthrough: invalid server name {}", host); - client.write_all(b"HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\nConnection: close\r\n\r\n").await?; - return Ok(()); - } - }; - - let mut server = match tls_connector.connect(server_name, tcp).await { - Ok(s) => s, - Err(e) => { - tracing::warn!("ws passthrough: TLS to {}:{} failed: {}", host, port, e); - client.write_all(b"HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\nConnection: close\r\n\r\n").await?; - return Ok(()); - } - }; - - // Forward the upgrade request to the real server. - server.write_all(upgrade_request).await?; - server.flush().await?; - - // Read the server's response headers (up to \r\n\r\n) and forward to client. - let mut resp_buf = Vec::with_capacity(512); - let mut tmp = [0u8; 1]; - loop { - server.read_exact(&mut tmp).await?; - resp_buf.push(tmp[0]); - if resp_buf.ends_with(b"\r\n\r\n") { - break; - } - if resp_buf.len() > 8192 { - tracing::warn!("ws passthrough: server response headers too large from {}:{}", host, port); - return Ok(()); - } - } - - // Check the server actually agreed to the upgrade. - let resp_str = String::from_utf8_lossy(&resp_buf); - let status_line = resp_str.lines().next().unwrap_or(""); - if !status_line.contains("101") { - tracing::warn!("ws passthrough: {}:{} refused upgrade ({})", host, port, status_line.trim()); - client.write_all(&resp_buf).await?; - client.flush().await?; - return Ok(()); - } - - client.write_all(&resp_buf).await?; - client.flush().await?; - - // Both sides agreed: splice raw bytes bidirectionally. - tracing::info!("ws passthrough: splicing {}:{}", host, port); - let _ = tokio::io::copy_bidirectional(client, &mut server).await; - Ok(()) -} - async fn handle_mitm_request( stream: &mut S, host: &str, port: u16, fronter: &DomainFronter, scheme: &str, - tls_connector: &TlsConnector, - upstream_socks5: Option<&str>, ) -> std::io::Result where S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin, @@ -2546,24 +2421,11 @@ where } }; - let (method, path, version, headers) = match parse_request_head(&head) { + let (method, path, _version, headers) = match parse_request_head(&head) { Some(v) => v, None => return Ok(false), }; - // WebSocket upgrade: Apps Script cannot relay persistent connections. - // Detect before read_body (upgrade requests have no body) and splice - // directly to the real server instead. - let is_ws_upgrade = - header_value(&headers, "connection").map(|v| v.to_ascii_lowercase().contains("upgrade")).unwrap_or(false) - && header_value(&headers, "upgrade").map(|v| v.eq_ignore_ascii_case("websocket")).unwrap_or(false); - if is_ws_upgrade { - tracing::info!("WebSocket upgrade for {}:{} — bypassing Apps Script relay", host, port); - let raw_request = rebuild_request_bytes(&method, &path, &version, &headers); - ws_tls_passthrough(stream, host, port, &raw_request, tls_connector, upstream_socks5).await?; - return Ok(false); - } - let body = read_body(stream, &leftover, &headers).await?; // ── Per-host URL fix-ups ────────────────────────────────────────── From eac5e222c81880bece464fb0283c770392bb0e4f Mon Sep 17 00:00:00 2001 From: Captain Mirage <87281406+CaptainMirage@users.noreply.github.com> Date: Fri, 22 May 2026 17:38:39 +0330 Subject: [PATCH 3/3] feat(logging): compact coloured timestamp + fix UI compile error Adds src/logging.rs with CompactUtcTime (FormatTime impl, time crate). Replaces the default ISO 8601 timestamp with [2026-05-22]-[12:56:28.9]. ANSI-gated colouring on terminals: orange date digits, light blue time digits, darker shades on internal punctuation, gray brackets/separator, green separator dash. Plain text fallback for the UI log panel and Android Logcat. Wired into all three subscriber setups (main, UI, JNI). Also fixes a compile error in the UI binary: stream_timeout_secs was added to Config in b3c51e0 but never plumbed through FormState. Added to the struct, both init paths (load-from-config and fresh-default), to_config() output, and ConfigWire so Save doesn't drop a non-default value. --- src/android_jni.rs | 1 + src/bin/ui.rs | 167 ++++++++++++++++++++++++++++++++++++++++++++- src/lib.rs | 1 + src/logging.rs | 38 +++++++++++ src/main.rs | 2 + 5 files changed, 208 insertions(+), 1 deletion(-) create mode 100644 src/logging.rs diff --git a/src/android_jni.rs b/src/android_jni.rs index 7bccfdd2..cf6ccbfd 100644 --- a/src/android_jni.rs +++ b/src/android_jni.rs @@ -132,6 +132,7 @@ fn install_logging_once() { .with_target(false) .with_ansi(false) .with_writer(LogcatWriter) + .with_timer(crate::logging::CompactUtcTime) .try_init(); let _ = rustls::crypto::ring::default_provider().install_default(); diff --git a/src/bin/ui.rs b/src/bin/ui.rs index c5f9ed63..28659409 100644 --- a/src/bin/ui.rs +++ b/src/bin/ui.rs @@ -294,6 +294,7 @@ struct FormState { auto_blacklist_window_secs: u64, auto_blacklist_cooldown_secs: u64, request_timeout_secs: u64, + stream_timeout_secs: u64, /// Optional second-hop exit node for CF-anti-bot bypass (chatgpt.com / /// claude.ai / grok.com / x.com). Config-only — no UI editor yet. /// See `assets/exit_node/` for the generic exit-node handler. @@ -391,6 +392,7 @@ fn load_form() -> (FormState, Option) { auto_blacklist_window_secs: c.auto_blacklist_window_secs, auto_blacklist_cooldown_secs: c.auto_blacklist_cooldown_secs, request_timeout_secs: c.request_timeout_secs, + stream_timeout_secs: c.stream_timeout_secs, exit_node: c.exit_node.clone(), } } else { @@ -433,6 +435,7 @@ fn load_form() -> (FormState, Option) { auto_blacklist_window_secs: 30, auto_blacklist_cooldown_secs: 120, request_timeout_secs: 30, + stream_timeout_secs: 300, exit_node: mhrv_rs::config::ExitNodeConfig::default(), } }; @@ -618,6 +621,7 @@ impl FormState { auto_blacklist_window_secs: self.auto_blacklist_window_secs, auto_blacklist_cooldown_secs: self.auto_blacklist_cooldown_secs, request_timeout_secs: self.request_timeout_secs, + stream_timeout_secs: self.stream_timeout_secs, // Exit-node config (CF-anti-bot bypass for chatgpt.com / claude.ai // / grok.com / x.com). Round-trip through FormState — config-only // editing for now, UI editor planned for v1.9.x desktop UI batch. @@ -637,6 +641,166 @@ fn save_config(cfg: &Config) -> Result { Ok(path) } +#[derive(serde::Serialize)] +struct ConfigWire<'a> { + mode: &'a str, + google_ip: &'a str, + front_domain: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + script_id: Option>, + auth_key: &'a str, + listen_host: &'a str, + listen_port: u16, + #[serde(skip_serializing_if = "Option::is_none")] + socks5_port: Option, + log_level: &'a str, + verify_ssl: bool, + #[serde(skip_serializing_if = "std::collections::HashMap::is_empty")] + hosts: &'a std::collections::HashMap, + #[serde(skip_serializing_if = "Option::is_none")] + upstream_socks5: Option<&'a str>, + #[serde(skip_serializing_if = "is_zero_u8")] + parallel_relay: u8, + #[serde(skip_serializing_if = "Option::is_none")] + sni_hosts: Option>, + #[serde(skip_serializing_if = "is_false")] + normalize_x_graphql: bool, + #[serde(skip_serializing_if = "is_false")] + youtube_via_relay: bool, + #[serde(skip_serializing_if = "Vec::is_empty")] + passthrough_hosts: &'a Vec, + // IP-scan knobs. These used to be missing from the wire struct, so + // every Save-config silently dropped them — the user would toggle + // "fetch from API" on, save, reopen, and find it off again. Add + // them here and keep them in sync if Config ever grows more. + #[serde(skip_serializing_if = "is_false")] + fetch_ips_from_api: bool, + max_ips_to_scan: usize, + scan_batch_size: usize, + google_ip_validation: bool, + /// Default false (= bypass DoH). Only emitted when explicitly true + /// so unchanged configs stay clean. + #[serde(skip_serializing_if = "is_false")] + tunnel_doh: bool, + #[serde(skip_serializing_if = "Vec::is_empty")] + bypass_doh_hosts: &'a Vec, + /// PR #763: default true (= browser DoH rejected, system DNS used). + /// Skip when matching default to keep unchanged configs clean — + /// emit only when the user has explicitly disabled the block. + #[serde(skip_serializing_if = "is_true")] + block_doh: bool, + /// Default false. Emit only when the user enables STUN/TURN blocking. + #[serde(skip_serializing_if = "is_false")] + block_stun: bool, + #[serde(skip_serializing_if = "Vec::is_empty")] + fronting_groups: &'a Vec, + /// Auto-blacklist tuning + batch timeout (#391, #444, #430). Skip + /// serialization when matching the historical defaults so unchanged + /// configs stay clean — only emitted when the user has explicitly + /// tuned them. + #[serde(skip_serializing_if = "is_default_strikes")] + auto_blacklist_strikes: u32, + #[serde(skip_serializing_if = "is_default_window_secs")] + auto_blacklist_window_secs: u64, + #[serde(skip_serializing_if = "is_default_cooldown_secs")] + auto_blacklist_cooldown_secs: u64, + #[serde(skip_serializing_if = "is_default_timeout_secs")] + request_timeout_secs: u64, + #[serde(skip_serializing_if = "is_default_stream_timeout_secs")] + stream_timeout_secs: u64, + /// HTTP/2 multiplexing kill switch. Default false (h2 active); only + /// emitted on save when the user has explicitly disabled h2, so + /// unchanged configs stay clean. + #[serde(skip_serializing_if = "is_false")] + force_http1: bool, + /// Exit-node config (CF-anti-bot bypass for chatgpt.com / claude.ai / + /// grok.com / x.com via exit-node second-hop relay). Skip when fully + /// default (disabled with no URL/PSK/hosts) so configs without + /// exit-node setup stay clean. Round-tripped through FormState so + /// Save preserves user-edited values. + #[serde(skip_serializing_if = "is_default_exit_node")] + exit_node: &'a mhrv_rs::config::ExitNodeConfig, +} + +fn is_default_strikes(v: &u32) -> bool { *v == 3 } +fn is_default_window_secs(v: &u64) -> bool { *v == 30 } +fn is_default_cooldown_secs(v: &u64) -> bool { *v == 120 } +fn is_default_timeout_secs(v: &u64) -> bool { *v == 30 } +fn is_default_stream_timeout_secs(v: &u64) -> bool { *v == 300 } +fn is_default_exit_node(en: &&mhrv_rs::config::ExitNodeConfig) -> bool { + !en.enabled + && en.relay_url.is_empty() + && en.psk.is_empty() + && en.hosts.is_empty() + && (en.mode.is_empty() || en.mode == "selective") +} + +fn is_false(b: &bool) -> bool { + !*b +} + +fn is_true(b: &bool) -> bool { + *b +} + +fn is_zero_u8(v: &u8) -> bool { + *v == 0 +} + +#[derive(serde::Serialize)] +#[serde(untagged)] +enum ScriptIdWire<'a> { + One(&'a str), + Many(Vec<&'a str>), +} + +impl<'a> From<&'a Config> for ConfigWire<'a> { + fn from(c: &'a Config) -> Self { + let script_id = c.script_id.as_ref().map(|s| match s { + ScriptId::One(v) => ScriptIdWire::One(v.as_str()), + ScriptId::Many(v) => ScriptIdWire::Many(v.iter().map(String::as_str).collect()), + }); + ConfigWire { + mode: c.mode.as_str(), + google_ip: c.google_ip.as_str(), + front_domain: c.front_domain.as_str(), + script_id, + auth_key: c.auth_key.as_str(), + listen_host: c.listen_host.as_str(), + listen_port: c.listen_port, + socks5_port: c.socks5_port, + log_level: c.log_level.as_str(), + verify_ssl: c.verify_ssl, + hosts: &c.hosts, + upstream_socks5: c.upstream_socks5.as_deref(), + parallel_relay: c.parallel_relay, + sni_hosts: c + .sni_hosts + .as_ref() + .map(|v| v.iter().map(String::as_str).collect()), + normalize_x_graphql: c.normalize_x_graphql, + youtube_via_relay: c.youtube_via_relay, + passthrough_hosts: &c.passthrough_hosts, + fetch_ips_from_api: c.fetch_ips_from_api, + max_ips_to_scan: c.max_ips_to_scan, + scan_batch_size: c.scan_batch_size, + google_ip_validation: c.google_ip_validation, + tunnel_doh: c.tunnel_doh, + bypass_doh_hosts: &c.bypass_doh_hosts, + block_doh: c.block_doh, + block_stun: c.block_stun, + fronting_groups: &c.fronting_groups, + auto_blacklist_strikes: c.auto_blacklist_strikes, + auto_blacklist_window_secs: c.auto_blacklist_window_secs, + auto_blacklist_cooldown_secs: c.auto_blacklist_cooldown_secs, + request_timeout_secs: c.request_timeout_secs, + stream_timeout_secs: c.stream_timeout_secs, + force_http1: c.force_http1, + exit_node: &c.exit_node, + } + } +} + /// Accent color — same blue used throughout the UI for primary actions. const ACCENT: egui::Color32 = egui::Color32::from_rgb(70, 120, 180); const ACCENT_HOVER: egui::Color32 = egui::Color32::from_rgb(90, 145, 205); @@ -2480,7 +2644,8 @@ fn install_ui_tracing(shared: Arc, config_level: &str) { let fmt_layer = tracing_subscriber::fmt::layer() .with_target(false) .with_ansi(false) - .with_writer(writer); + .with_writer(writer) + .with_timer(mhrv_rs::logging::CompactUtcTime); let _ = tracing_subscriber::registry() .with(filter_layer) diff --git a/src/lib.rs b/src/lib.rs index 6b53a32b..1ccd285b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -13,6 +13,7 @@ pub mod tunnel_client; pub mod scan_ips; pub mod scan_sni; pub mod test_cmd; +pub mod logging; pub mod update_check; #[cfg(target_os = "android")] diff --git a/src/logging.rs b/src/logging.rs new file mode 100644 index 00000000..8eda838d --- /dev/null +++ b/src/logging.rs @@ -0,0 +1,38 @@ +use time::OffsetDateTime; +use tracing_subscriber::fmt::{format::Writer, time::FormatTime}; + +pub struct CompactUtcTime; + +impl FormatTime for CompactUtcTime { + fn format_time(&self, w: &mut Writer<'_>) -> std::fmt::Result { + let now = OffsetDateTime::now_utc(); + if w.has_ansi_escapes() { + write!( + w, + "{g}[{bo}{year:04}{odo}-{bo}{mo:02}{odo}-{bo}{day:02}{g}]{sg}-{g}[{sb}{h:02}{db}:{sb}{min:02}{db}:{sb}{s:02}{db}.{sb}{t}{g}]{r}", + g = "\x1b[38;5;250m", // light gray — brackets + bo = "\x1b[38;5;215m", // light orange — date digits + odo = "\x1b[38;5;166m", // dark orange — dashes inside date + sg = "\x1b[38;5;120m", // light green — separator dash + sb = "\x1b[38;5;159m", // light blue — time digits + db = "\x1b[38;5;74m", // dark blue — colons + dot inside time + r = "\x1b[0m", + year = now.year(), + mo = now.month() as u8, + day = now.day(), + h = now.hour(), + min = now.minute(), + s = now.second(), + t = now.millisecond() / 100, + ) + } else { + write!( + w, + "[{:04}-{:02}-{:02}]-[{:02}:{:02}:{:02}.{}]", + now.year(), now.month() as u8, now.day(), + now.hour(), now.minute(), now.second(), + now.millisecond() / 100, + ) + } + } +} diff --git a/src/main.rs b/src/main.rs index 72e5aefb..3f89dede 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,6 +5,7 @@ use std::process::ExitCode; use std::sync::Arc; use tokio::sync::Mutex; +use mhrv_rs::logging::CompactUtcTime; use tracing_subscriber::EnvFilter; use mhrv_rs::cert_installer::{install_ca, is_ca_trusted, reconcile_sudo_environment, remove_ca}; @@ -131,6 +132,7 @@ fn init_logging(level: &str) { let _ = tracing_subscriber::fmt() .with_env_filter(filter) .with_target(false) + .with_timer(CompactUtcTime) .try_init(); }