async fn shutdown_signal() -> Result<()> { #[cfg(unix)] { let mut term = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?; tokio::select! { result = tokio::signal::ctrl_c() => { result?; }, _ = term.recv() => {} } } #[cfg(not(unix))] { tokio::signal::ctrl_c().await?; } Ok(()) } fn read_password(from_stdin: bool) -> Result { let password = if from_stdin { let mut input = String::new(); io::stdin() .take(131) .read_to_string(&mut input) .context("cannot read password from stdin")?; if input.ends_with('\n') { input.pop(); if input.ends_with('\r') { input.pop(); } } input } else { ensure!( io::stdin().is_terminal(), "use --password-stdin for non-interactive password input" ); let password = Zeroizing::new(rpassword::prompt_password( "New password (12-128 UTF-8 bytes): ", )?); let confirmation = Zeroizing::new(rpassword::prompt_password("Confirm new password: ")?); ensure!( auth::equal_secret(&password, &confirmation), "passwords do not match" ); password.to_string() }; auth::validate_password(&password)?; Ok(password) } async fn admin_command(dir: PathBuf, action: AdminCommand) -> Result<()> { auth::prepare_directory(&dir)?; let db = dir.join("config.sqlite3"); if matches!(&action, AdminCommand::SetupToken) { if db.is_file() { auth::prepare_database(&db)?; let store = Store::open(&db, &Config::default()).await?; let has_admin = store.admin_record().await?.is_some(); store.close().await; ensure!( !has_admin, "setup is complete; setup tokens no longer authorize anything" ); } println!("{}", auth::load_or_create_setup_token(&dir)?); return Ok(()); } if !matches!(&action, AdminCommand::Init { .. }) { ensure!( db.is_file(), "database does not exist at {}; start rust-proxy serve or run rust-proxy admin init first", db.display() ); } auth::prepare_database(&db)?; let store = Store::open(&db, &Config::default()).await?; let result = async { match action { AdminCommand::Init { username, password_stdin, } => { let username = match username { Some(value) => value, None => { ensure!( io::stdin().is_terminal(), "--username is required for non-interactive setup" ); print!("Administrator username: "); io::stdout().flush()?; let mut value = String::new(); io::stdin().read_line(&mut value)?; value.trim().to_owned() } }; let username = auth::username(&username)?; ensure!( store.admin_record().await?.is_none(), "administrator already exists; use admin reset-password" ); let hash = auth::hash_password_offline(read_password(password_stdin)?).await?; ensure!( store .initialize_admin(&username, &hash, "local CLI") .await?, "another setup already created the administrator" ); for name in ["setup-token", "admin-token"] { let _ = tokio::fs::remove_file(dir.join(name)).await; } println!( "Administrator '{}' created. No default credentials exist.", username ); } AdminCommand::ResetPassword { password_stdin } => { let record = store .admin_record() .await? .context("administrator is not initialized; use admin init")?; let hash = auth::hash_password_offline(read_password(password_stdin)?).await?; ensure!( store .reset_admin_password(&hash, record.generation, "local CLI recovery") .await?, "credentials changed concurrently; repeat the reset" ); println!( "Password reset for '{}'. All sessions have been revoked.", record.username ); } AdminCommand::SetupToken => unreachable!("handled before opening SQLite"), AdminCommand::Status => match store.admin_record().await? { Some(record) => println!( "Administrator: {}\nSetup: complete\nSchema: {}", record.username, rust_proxy::db::SCHEMA_VERSION ), None => println!("Setup: required\nUse rust-proxy admin init --username YOUR_NAME"), }, AdminCommand::LogoutAll => { let record = store .admin_record() .await? .context("administrator is not initialized")?; store.revoke_all_sessions(&record.username).await?; println!("All administrator sessions revoked."); } } Ok(()) } .await; store.close().await; result } #[derive(Default)] struct LiveLogFilters { host: Option, vhost: Option, backend: Option, route: Option, client: Option, method: Option, transport: Option, cache: Option, status: Option, path_prefix: Option, errors_only: bool, slow_ms: Option, detail: bool, paused: bool, } fn log_string<'a>(entry: &'a serde_json::Value, key: &str) -> &'a str { entry .get(key) .and_then(|value| value.as_str()) .unwrap_or("-") } fn log_optional_string<'a>(entry: &'a serde_json::Value, key: &str) -> Option<&'a str> { entry.get(key).and_then(|value| value.as_str()) } fn live_status_matches(filter: &str, status: u64) -> bool { if let Some(prefix) = filter.strip_suffix("xx") { return prefix .parse::() .ok() .is_some_and(|class| status / 100 == class); } filter.parse::().ok() == Some(status) } fn live_log_matches(entry: &serde_json::Value, filters: &LiveLogFilters) -> bool { let eq = |value: &str, expected: &Option| { expected .as_ref() .is_none_or(|expected| value.eq_ignore_ascii_case(expected)) }; if !eq(log_string(entry, "host"), &filters.host) || !eq(log_string(entry, "vhost"), &filters.vhost) || !eq(log_string(entry, "backend"), &filters.backend) || !eq(log_string(entry, "route"), &filters.route) || !eq(log_string(entry, "client_ip"), &filters.client) || !eq(log_string(entry, "method"), &filters.method) || !eq(log_string(entry, "transport"), &filters.transport) || !eq(log_string(entry, "cache"), &filters.cache) { return false; } if let Some(prefix) = &filters.path_prefix { if !log_string(entry, "path").starts_with(prefix) { return false; } } let status = entry .get("status") .and_then(|value| value.as_u64()) .unwrap_or(0); if filters .status .as_ref() .is_some_and(|filter| !live_status_matches(filter, status)) { return false; } if filters.slow_ms.is_some_and(|minimum| { entry .get("duration_ms") .and_then(|value| value.as_u64()) .unwrap_or(0) < minimum }) { return false; } if filters.errors_only && status < 500 && log_optional_string(entry, "upstream_error").is_none() && log_optional_string(entry, "websocket_error").is_none() && log_optional_string(entry, "h3_reset_code").is_none() { return false; } true } fn live_time(entry: &serde_json::Value) -> String { let ms = entry .get("time_ms") .and_then(|value| value.as_u64()) .unwrap_or(0); let seconds = (ms / 1000) % 86_400; format!( "{:02}:{:02}:{:02}.{:03}Z", seconds / 3600, (seconds % 3600) / 60, seconds % 60, ms % 1000 ) } fn print_live_entry(entry: &serde_json::Value, detail: bool) { let id = entry .get("id") .and_then(|value| value.as_u64()) .unwrap_or(0); let status = entry .get("status") .and_then(|value| value.as_u64()) .unwrap_or(0); let duration = entry .get("duration_ms") .and_then(|value| value.as_u64()) .unwrap_or(0); let request_bytes = entry .get("request_bytes") .and_then(|value| value.as_u64()) .unwrap_or(0); let response_bytes = entry .get("response_bytes") .and_then(|value| value.as_u64()) .unwrap_or(0); println!( "{} #{:<7} {:<4} {:<5} {}{} -> {} {:>5}ms vhost={} backend={} {} cache={} outcome={} client={}", live_time(entry), id, log_string(entry, "transport"), log_string(entry, "method"), log_string(entry, "host"), log_string(entry, "path"), status, duration, log_string(entry, "vhost"), log_string(entry, "backend"), log_string(entry, "http_version"), log_string(entry, "cache"), log_string(entry, "outcome"), log_string(entry, "client_ip"), ); if let Some(error) = log_optional_string(entry, "upstream_error") { println!(" upstream_error={error}"); } if let Some(error) = log_optional_string(entry, "websocket_error") { println!(" websocket_error={error}"); } if detail { println!( " route={} upstream_status={} cache_reason={} cache_enabled={} transform_enabled={} websocket_bootstrap={} websocket_phase={} h3_reset={} bytes={}/{}", log_string(entry, "route"), entry.get("upstream_status").and_then(|value| value.as_u64()).map(|value| value.to_string()).unwrap_or_else(|| "-".into()), log_string(entry, "cache_reason"), entry.get("cache_effective_enabled").map(|value| value.to_string()).unwrap_or_else(|| "-".into()), entry.get("transform_effective_enabled").map(|value| value.to_string()).unwrap_or_else(|| "-".into()), log_string(entry, "websocket_bootstrap"), log_string(entry, "websocket_phase"), log_string(entry, "h3_reset_code"), request_bytes, response_bytes, ); } } fn live_filters_summary(filters: &LiveLogFilters) -> String { let mut values = Vec::new(); macro_rules! add { ($name:literal, $value:expr) => { if let Some(value) = $value.as_ref() { values.push(format!("{}={}", $name, value)); } }; } add!("host", filters.host); add!("vhost", filters.vhost); add!("backend", filters.backend); add!("route", filters.route); add!("client", filters.client); add!("method", filters.method); add!("transport", filters.transport); add!("cache", filters.cache); add!("status", filters.status); add!("path", filters.path_prefix); if filters.errors_only { values.push("errors=on".into()); } if let Some(ms) = filters.slow_ms { values.push(format!("slow>={ms}ms")); } if filters.detail { values.push("detail=on".into()); } if filters.paused { values.push("paused".into()); } if values.is_empty() { "none".into() } else { values.join(" ") } } fn live_help() { println!("Commands (plain values, no regex):"); println!(" host|vhost|backend|route|client|method|transport|cache "); println!(" path status <200|2xx|3xx|4xx|5xx|*>"); println!(" errors slow "); println!(" detail show "); println!(" filters reset pause resume clear help quit"); } fn set_live_filter(target: &mut Option, value: &str) { *target = if matches!(value, "" | "*" | "off" | "-") { None } else { Some(value.to_owned()) }; } fn handle_live_command( line: &str, filters: &mut LiveLogFilters, history: &std::collections::BTreeMap, ) -> Result { let line = line.trim(); if line.is_empty() { return Ok(true); } let mut parts = line.split_whitespace(); let command = parts.next().unwrap_or_default().to_ascii_lowercase(); let value = parts.next().unwrap_or(""); match command.as_str() { "help" | "?" => live_help(), "filters" => println!("filters: {}", live_filters_summary(filters)), "reset" => { *filters = LiveLogFilters::default(); println!("filters cleared"); } "pause" => { filters.paused = true; println!("paused"); } "resume" => { filters.paused = false; println!("resumed"); } "clear" => { print!("\x1b[2J\x1b[H"); io::stdout().flush()?; } "quit" | "exit" | "q" => return Ok(false), "detail" => filters.detail = matches!(value, "on" | "1" | "true"), "errors" => filters.errors_only = matches!(value, "on" | "1" | "true"), "slow" => { filters.slow_ms = if matches!(value, "" | "off" | "*") { None } else { Some(value.parse().context("slow expects milliseconds or off")?) }; } "status" => { if matches!(value, "" | "*" | "off") { filters.status = None; } else { ensure!( matches!(value, "1xx" | "2xx" | "3xx" | "4xx" | "5xx") || value .parse::() .ok() .is_some_and(|v| (100..=599).contains(&v)), "status expects 100..599 or 1xx..5xx" ); filters.status = Some(value.to_ascii_lowercase()); } } "host" => set_live_filter(&mut filters.host, value), "vhost" => set_live_filter(&mut filters.vhost, value), "backend" => set_live_filter(&mut filters.backend, value), "route" => set_live_filter(&mut filters.route, value), "client" => set_live_filter(&mut filters.client, value), "method" => { set_live_filter(&mut filters.method, value); if let Some(v) = filters.method.as_mut() { *v = v.to_ascii_uppercase(); } } "transport" => set_live_filter(&mut filters.transport, value), "cache" => { set_live_filter(&mut filters.cache, value); if let Some(v) = filters.cache.as_mut() { *v = v.to_ascii_uppercase(); } } "path" => set_live_filter(&mut filters.path_prefix, value), "show" => { let id: u64 = value.parse().context("show expects a request id")?; match history.get(&id) { Some(entry) => println!("{}", serde_json::to_string_pretty(entry)?), None => println!("request #{id} is no longer in the local live-log window"), } } _ => bail!("unknown live-log command '{command}' (type help)"), } if !matches!( command.as_str(), "help" | "?" | "filters" | "show" | "clear" ) { println!("filters: {}", live_filters_summary(filters)); } Ok(true) } fn take_sse_event(buffer: &mut Vec) -> Option> { let find = |needle: &[u8]| { buffer .windows(needle.len()) .position(|window| window == needle) }; let lf = find(b"\n\n").map(|index| (index, 2)); let crlf = find(b"\r\n\r\n").map(|index| (index, 4)); let (index, delimiter) = match (lf, crlf) { (Some(a), Some(b)) => { if a.0 <= b.0 { a } else { b } } (Some(a), None) => a, (None, Some(b)) => b, (None, None) => return None, }; let event = buffer[..index].to_vec(); buffer.drain(..index + delimiter); Some(event) }