493 lines
17 KiB
Rust
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)
|
|
}
|