fn parse_sse_event(event: &str) -> (Option<&str>, Option) { let mut kind = None; let mut data = Vec::new(); for raw in event.lines() { let line = raw.trim_end_matches('\r'); if let Some(value) = line.strip_prefix("event:") { kind = Some(value.trim()); } else if let Some(value) = line.strip_prefix("data:") { data.push(value.trim_start()); } } (kind, (!data.is_empty()).then(|| data.join("\n"))) } type LocalAdminHttpClient = Client, Body>; #[derive(Debug)] struct LocalAdminCertificateVerifier { expected: Vec<[u8; 32]>, signature_verifier: Arc, } impl ServerCertVerifier for LocalAdminCertificateVerifier { fn verify_server_cert( &self, end_entity: &CertificateDer<'_>, _intermediates: &[CertificateDer<'_>], _server_name: &ServerName<'_>, _ocsp_response: &[u8], _now: UnixTime, ) -> std::result::Result { let actual = Sha256::digest(end_entity.as_ref()); if !self .expected .iter() .any(|expected| actual[..] == expected[..]) { return Err(rustls::Error::General( "local administration TLS certificate is not present in data-dir/certs".into(), )); } Ok(ServerCertVerified::assertion()) } fn verify_tls12_signature( &self, message: &[u8], cert: &CertificateDer<'_>, dss: &DigitallySignedStruct, ) -> std::result::Result { self.signature_verifier .verify_tls12_signature(message, cert, dss) } fn verify_tls13_signature( &self, message: &[u8], cert: &CertificateDer<'_>, dss: &DigitallySignedStruct, ) -> std::result::Result { self.signature_verifier .verify_tls13_signature(message, cert, dss) } fn supported_verify_schemes(&self) -> Vec { self.signature_verifier.supported_verify_schemes() } } fn local_admin_public_roots() -> RootCertStore { let mut roots = RootCertStore::empty(); roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned()); roots } fn local_admin_certificate_pins(data_dir: &Path) -> Result> { let directory = data_dir.join("certs"); let mut pins = Vec::new(); for entry in std::fs::read_dir(&directory).with_context(|| { format!( "cannot read local certificate directory {}", directory.display() ) })? { let entry = entry?; let path = entry.path(); if path.extension().and_then(|value| value.to_str()) != Some("pem") { continue; } let metadata = entry.metadata()?; if !metadata.is_file() || metadata.len() > 512 * 1024 { continue; } let bytes = std::fs::read(&path) .with_context(|| format!("cannot read local certificate {}", path.display()))?; let mut reader = bytes.as_slice(); let Some(Ok(certificate)) = rustls_pemfile::certs(&mut reader).next() else { continue; }; let digest = Sha256::digest(certificate.as_ref()); let mut pin = [0_u8; 32]; pin.copy_from_slice(&digest); pins.push(pin); } pins.sort_unstable(); pins.dedup(); ensure!( !pins.is_empty(), "no local TLS certificate was found in {}; start rust-proxy serve first", directory.display() ); Ok(pins) } fn local_admin_scheme_from_probe( status: StatusCode, location: Option<&str>, ) -> Result<&'static str> { if status == StatusCode::PERMANENT_REDIRECT { ensure!( location.is_some_and(|value| value.starts_with("https://")), "administration HTTP probe returned 308 without an HTTPS location" ); return Ok("https"); } ensure!( status.is_success(), "administration health probe returned {status}" ); Ok("http") } async fn detect_local_admin_scheme(addr: SocketAddr) -> Result<&'static str> { let mut connector = HttpConnector::new(); connector.set_connect_timeout(Some(Duration::from_secs(3))); let client: Client<_, Body> = Client::builder(TokioExecutor::new()).build(connector); let request = Request::builder() .method(Method::GET) .uri(format!("http://{addr}/healthz")) .header("accept", "application/json") .body(Body::empty())?; let response = tokio::time::timeout(Duration::from_secs(5), client.request(request)) .await .context("administration health probe timed out")??; let location = response .headers() .get(http::header::LOCATION) .and_then(|value| value.to_str().ok()); local_admin_scheme_from_probe(response.status(), location) } #[derive(Clone)] struct LocalAdminClient { addr: SocketAddr, scheme: &'static str, client: LocalAdminHttpClient, } impl LocalAdminClient { async fn connect(data_dir: &Path, addr: SocketAddr) -> Result { ensure!( addr.ip().is_loopback(), "CLI administration requires a loopback address" ); let scheme = detect_local_admin_scheme(addr).await?; let provider = Arc::new(rustls::crypto::ring::default_provider()); let mut config = ClientConfig::builder_with_provider(provider.clone()) .with_safe_default_protocol_versions()? .with_root_certificates(local_admin_public_roots()) .with_no_client_auth(); if scheme == "https" { let data_dir = data_dir.to_path_buf(); let expected = tokio::task::spawn_blocking(move || local_admin_certificate_pins(&data_dir)) .await??; let signature_verifier = WebPkiServerVerifier::builder_with_provider( Arc::new(local_admin_public_roots()), provider, ) .build()?; config .dangerous() .set_certificate_verifier(Arc::new(LocalAdminCertificateVerifier { expected, signature_verifier, })); } let mut http = HttpConnector::new(); http.enforce_http(false); http.set_connect_timeout(Some(Duration::from_secs(3))); http.set_nodelay(true); let connector = HttpsConnectorBuilder::new() .with_tls_config(config) .https_or_http() .enable_http1() .wrap_connector(http); Ok(Self { addr, scheme, client: Client::builder(TokioExecutor::new()).build(connector), }) } fn uri(&self, path: &str) -> String { format!("{}://{}{}", self.scheme, self.addr, path) } fn origin(&self) -> String { format!("{}://{}", self.scheme, self.addr) } } async fn live_logs_command(dir: PathBuf, addr: SocketAddr) -> Result<()> { ensure!( addr.ip().is_loopback(), "--livelogs requires the local loopback administration listener" ); auth::prepare_directory(&dir)?; let path = dir.join("config.sqlite3"); ensure!( path.is_file(), "database does not exist at {}; start rust-proxy serve first", path.display() ); auth::prepare_database(&path)?; let store = Store::open(&path, &Config::default()).await?; let admin = LocalAdminClient::connect(&dir, addr).await?; let result = async { let record = store.admin_record().await?.context("create the administrator first")?; let mut session = store .create_live_log_session(record.generation) .await? .context("credentials changed; retry --livelogs")?; let (command_tx, mut command_rx) = tokio::sync::mpsc::unbounded_channel::(); std::thread::spawn(move || { let stdin = io::stdin(); for line in stdin.lock().lines() { match line { Ok(line) => { if command_tx.send(line).is_err() { break; } } Err(_) => break, } } }); let interactive = io::stdin().is_terminal() && io::stdout().is_terminal(); println!("RustProxy live logs 0.28.2 - all vhosts. Type 'help' for interactive filters; Ctrl-C or 'quit' exits."); if interactive { print!("> "); io::stdout().flush()?; } let mut filters = LiveLogFilters::default(); let mut history = std::collections::BTreeMap::::new(); let mut last_id = 0_u64; let mut command_input_open = true; 'reconnect: loop { let request = Request::builder() .method(Method::GET) .uri(admin.uri("/api/live-logs")) .header("cookie", format!("{}={}", auth::COOKIE_NAME, session.secret)) .header("accept", "text/event-stream") .body(Body::empty())?; let response = match tokio::time::timeout(Duration::from_secs(10), admin.client.request(request)).await { Ok(Ok(response)) if response.status().is_success() => response, Ok(Ok(response)) => { eprintln!("live-log stream returned {}; reconnecting", response.status()); let replacement = store.create_live_log_session(record.generation).await?.context("administrator credentials changed; --livelogs stopped")?; let old = std::mem::replace(&mut session, replacement); let _ = store.end_session(&old.session).await; tokio::time::sleep(Duration::from_secs(1)).await; continue; } Ok(Err(error)) => { eprintln!("live-log stream unavailable: {error}; reconnecting"); tokio::time::sleep(Duration::from_secs(1)).await; continue; } Err(_) => { eprintln!("live-log stream connect timeout; reconnecting"); tokio::time::sleep(Duration::from_secs(1)).await; continue; } }; // The server subscribes before the SSE response is returned. Read the // retained history only after that point, so requests cannot disappear // between the initial snapshot and the live subscription. Buffered live // duplicates are ignored by request id below. let history_limit = if last_id == 0 { 25 } else { 1024 }; if let Ok(snapshot) = api( &admin, &session, Method::GET, &format!("/api/logs?limit={history_limit}"), None, ) .await { if let Ok(snapshot) = serde_json::from_str::(&snapshot) { if let Some(entries) = snapshot.get("entries").and_then(|value| value.as_array()) { let mut entries = entries.clone(); entries.reverse(); for entry in entries { let id = entry.get("id").and_then(|value| value.as_u64()).unwrap_or(0); if id > 0 && id <= last_id { continue; } if id > 0 { last_id = id; history.insert(id, entry.clone()); } if !filters.paused && live_log_matches(&entry, &filters) { print_live_entry(&entry, filters.detail); } } } } } let mut body = response.into_body(); let mut buffer = Vec::new(); loop { tokio::select! { _ = tokio::signal::ctrl_c() => break 'reconnect, command = command_rx.recv(), if command_input_open => { let Some(command) = command else { command_input_open = false; continue; }; match handle_live_command(&command, &mut filters, &history) { Ok(true) => {} Ok(false) => break 'reconnect, Err(error) => eprintln!("command error: {error:#}"), } if interactive { print!("> "); io::stdout().flush()?; } } frame = body.frame() => { let Some(frame) = frame else { eprintln!("live-log stream closed; reconnecting"); tokio::time::sleep(Duration::from_millis(500)).await; continue 'reconnect; }; let frame = match frame { Ok(frame) => frame, Err(error) => { eprintln!("live-log stream error: {error}; reconnecting"); tokio::time::sleep(Duration::from_millis(500)).await; continue 'reconnect; } }; let Ok(data) = frame.into_data() else { continue; }; buffer.extend_from_slice(&data); ensure!(buffer.len() <= 2 * 1024 * 1024, "live-log stream frame buffer exceeded 2 MiB"); while let Some(raw) = take_sse_event(&mut buffer) { let raw = std::str::from_utf8(&raw).context("live-log SSE event is not UTF-8")?; let (kind, data) = parse_sse_event(raw); match (kind, data) { (Some("log"), Some(data)) => { let entry: serde_json::Value = serde_json::from_str(&data)?; let id = entry.get("id").and_then(|value| value.as_u64()).unwrap_or(0); if id > 0 && id <= last_id { continue; } if id > 0 { last_id = id; history.insert(id, entry.clone()); } while history.len() > 1024 { if let Some(id) = history.keys().next().copied() { history.remove(&id); } else { break; } } if !filters.paused && live_log_matches(&entry, &filters) { print_live_entry(&entry, filters.detail); } } (Some("gap"), Some(data)) => { let dropped = serde_json::from_str::(&data).ok().and_then(|value| value.get("dropped").and_then(|v| v.as_u64())).unwrap_or(0); eprintln!("*** live-log receiver lagged: {dropped} entries were dropped before display ***"); } _ => {} } } if interactive { io::stdout().flush()?; } } } } } let _ = store.end_session(&session.session).await; Ok::<_, anyhow::Error>(()) }.await; store.close().await; result } async fn api( admin: &LocalAdminClient, session: &auth::SessionToken, method: Method, path: &str, body: Option, ) -> Result { let request = Request::builder() .method(method) .uri(admin.uri(path)) .header( "cookie", format!("{}={}", auth::COOKIE_NAME, session.secret), ) .header("origin", admin.origin()) .header("x-rustproxy-request", "1") .header("x-csrf-token", &session.session.csrf) .header("content-type", "application/json") .body(Body::from(body.unwrap_or_default()))?; tokio::time::timeout(Duration::from_secs(15), async { let response = admin .client .request(request) .await .context("admin API unavailable; start rust-proxy serve first")?; let status = response.status(); let bytes = Limited::new(response.into_body(), config::MAX_CONFIG_BYTES + 4096) .collect() .await .map_err(|e| anyhow::anyhow!("admin body error: {e}"))? .to_bytes(); let text = String::from_utf8(bytes.to_vec())?; if !status.is_success() { bail!("admin API returned {status}: {text}"); } Ok(text) }) .await .context("admin API timed out")? } async fn read_config_file(file: PathBuf) -> Result { ensure!( tokio::fs::metadata(&file).await?.len() <= config::MAX_CONFIG_BYTES as u64, "config file exceeds 256 KiB" ); // Bound actual reads as well as metadata; replacement/append races cannot grow memory. let data = tokio::task::spawn_blocking(move || { let file = std::fs::File::open(file)?; let mut bytes = Vec::new(); file.take((config::MAX_CONFIG_BYTES + 1) as u64) .read_to_end(&mut bytes)?; anyhow::ensure!( bytes.len() <= config::MAX_CONFIG_BYTES, "config file exceeds 256 KiB" ); Ok::<_, anyhow::Error>(bytes) }) .await??; Ok(serde_json::from_slice(&data)?) }