Files
RustProxy/src/main/proxy_runtime.rs
T
2026-10-05 09:30:21 +02:00

493 lines
17 KiB
Rust

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<String> {
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<String>,
vhost: Option<String>,
backend: Option<String>,
route: Option<String>,
client: Option<String>,
method: Option<String>,
transport: Option<String>,
cache: Option<String>,
status: Option<String>,
path_prefix: Option<String>,
errors_only: bool,
slow_ms: Option<u64>,
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::<u64>()
.ok()
.is_some_and(|class| status / 100 == class);
}
filter.parse::<u64>().ok() == Some(status)
}
fn live_log_matches(entry: &serde_json::Value, filters: &LiveLogFilters) -> bool {
let eq = |value: &str, expected: &Option<String>| {
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 <value|*>");
println!(" path <prefix|*> status <200|2xx|3xx|4xx|5xx|*>");
println!(" errors <on|off> slow <milliseconds|off>");
println!(" detail <on|off> show <request-id>");
println!(" filters reset pause resume clear help quit");
}
fn set_live_filter(target: &mut Option<String>, 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<u64, serde_json::Value>,
) -> Result<bool> {
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::<u16>()
.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<u8>) -> Option<Vec<u8>> {
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)
}