This commit is contained in:
Mateusz Gruszczyński committed 2026-10-04 11:15:58 +02:00
1 parent 87e571c170
commit fa7abc9e53
308 files changed
+49746 -50237

No files matched your search

+449
View File
@@ -0,0 +1,449 @@
async fn run(
data_dir: PathBuf,
startup_admin_addr: SocketAddr,
options: ServeOptions,
startup_locks: rust_proxy::settings::StartupLocks,
update_check: rust_proxy::update_check::Config,
) -> Result<()> {
ensure!(
startup_admin_addr.port() > 0 && options.proxy_listen.port() > 0,
"listener ports must be nonzero"
);
ensure!(
(1..=4096).contains(&options.max_connections) && (1..=4096).contains(&options.max_inflight),
"limits must be 1..4096"
);
ensure!(
options.upstream_timeout_secs > 0
&& options.body_idle_timeout_secs > 0
&& options.shutdown_timeout_secs > 0
&& options.websocket_idle_timeout_secs > 0,
"timeouts must be positive"
);
ensure!(
startup_admin_addr.port() != options.proxy_listen.port(),
"admin and proxy ports must differ"
);
if let Some(tls) = options.tls_listen {
ensure!(
tls.port() > 0
&& tls.port() != options.proxy_listen.port()
&& tls.port() != startup_admin_addr.port(),
"TLS listener port must be nonzero and differ from HTTP/admin"
);
}
if options.demo {
ensure!(
options.demo_listen.ip().is_loopback() && options.demo_listen.port() > 0,
"demo must bind to a nonzero loopback port"
);
ensure!(
options.demo_listen.port() != startup_admin_addr.port()
&& options.demo_listen.port() != options.proxy_listen.port()
&& options
.tls_listen
.is_none_or(|tls| tls.port() != options.demo_listen.port()),
"demo port must differ from proxy/admin"
);
}
auth::prepare_directory(&data_dir)?;
let _process_lock = auth::lock_directory(&data_dir)?;
auth::prepare_database(&data_dir.join("config.sqlite3"))?;
let startup_listeners = (options.proxy_listen, options.tls_listen);
let initial_listeners =
rust_proxy::settings::Listeners::from_startup(options.proxy_listen, options.tls_listen);
let initial = if options.demo {
let mut config = config::demo_config(options.demo_listen);
config.listeners = initial_listeners;
config.vhosts[0].frontend.listener_ids = config
.listeners
.endpoints
.iter()
.map(|listener| listener.id.clone())
.collect();
config
} else {
let mut config = Config {
listeners: initial_listeners,
..Config::default()
};
// Only a NEW database receives this neutral landing page.
config.pages.direct_ip = Some(Default::default());
config.pages.unknown_host = Some(Default::default());
config
};
let store = Store::open(&data_dir.join("config.sqlite3"), &initial).await?;
let mut saved = store.load().await?;
startup_locks.ensure_config(&mut saved.config);
startup_locks
.validate(&saved.config, startup_admin_addr)
.context("stored configuration conflicts with a listener fixed by systemd/startup")?;
let admin_addr = saved
.config
.administration
.resolved_listen(startup_admin_addr);
let admin_tls_enabled = saved.config.administration.tls;
let remote_admin =
validate_admin_binding(admin_addr, admin_tls_enabled, options.allow_remote_admin)?;
ensure!(
admin_addr.port() != options.proxy_listen.port()
&& options
.tls_listen
.is_none_or(|tls| tls.port() != admin_addr.port())
&& (!options.demo || options.demo_listen.port() != admin_addr.port()),
"active administration port conflicts with another startup listener"
);
let resolved_policy = ValidationPolicy {
proxy_addr: options.proxy_listen,
admin_addr,
tls_addr: options.tls_listen,
cert_dir: None,
data_dir: Some(data_dir.clone()),
};
saved.config.listeners.validate(&resolved_policy)?;
// The certificate library is available even on an HTTP-only installation.
// Initializing private files does not bind or expose a TLS listener.
let directory = data_dir.join("certs");
let initialize = directory.clone();
tokio::task::spawn_blocking(move || rust_proxy::tls::initialize_default(&initialize)).await??;
rust_proxy::secret_store::initialize(&data_dir)?;
let migrated = rust_proxy::secret_store::migrate_acme_credentials(&store, &data_dir, &directory).await?;
if migrated > 0 {
tracing::info!(migrated, "legacy ACME credentials migrated to encrypted Secret Store");
}
let cert_dir = Some(directory.clone());
let policy = ValidationPolicy {
proxy_addr: options.proxy_listen,
admin_addr,
tls_addr: options.tls_listen,
cert_dir,
data_dir: Some(data_dir.clone()),
};
let validation_policy = policy.clone();
let snapshot = tokio::task::spawn_blocking(move || {
config::compile(saved.config, saved.revision, &validation_policy)
})
.await?
.context("stored configuration is invalid; no automatic reset was performed")?;
rust_proxy::logging::configure_error_log(snapshot.envelope.config.error_log.clone());
let admin_tls_config = {
let tls = snapshot.envelope.config.tls.clone();
let administration = snapshot.envelope.config.administration.clone();
let cert_dir = policy.cert_dir.clone();
tokio::task::spawn_blocking(move || {
rust_proxy::tls::compile_admin(&tls, &administration, cert_dir.as_deref())
})
.await??
};
let setup_required = store.admin_record().await?.is_none();
let authentication = auth::Auth::new(store.clone(), data_dir.clone()).await?;
if !setup_required {
authentication.remove_bootstrap_files().await;
}
let renewal_observations = store.load_certificate_renewals().await?;
let geoip = match rust_proxy::geoip::Database::load(&data_dir.join("geoip")) {
Ok(database) => std::sync::Arc::new(database),
Err(error) => {
tracing::warn!(%error, "GeoIP country database unavailable; country allow lists fail closed");
std::sync::Arc::new(rust_proxy::geoip::Database::empty())
}
};
let state = App::new_with_startup_locks_geoip(
store.clone(),
snapshot,
policy,
authentication,
options.max_inflight,
Duration::from_secs(options.upstream_timeout_secs),
Duration::from_secs(options.body_idle_timeout_secs),
Duration::from_secs(options.websocket_idle_timeout_secs),
startup_listeners,
startup_locks,
geoip,
);
state.acme.restore_renewals(renewal_observations);
state.start_update_check(update_check);
// Bind EVERY listener before spawning servers: a missing private IP or
// occupied port fails startup atomically, never falling back to 0.0.0.0.
let mut public_listeners = Vec::new();
for binding in &state.active_listeners {
if options.demo {
ensure!(
!rust_proxy::vhost::socket_overlap(binding.address, options.demo_listen),
"vhost listener conflicts with demo backend"
);
}
let listener = TcpListener::bind(binding.address).await.with_context(|| {
format!(
"cannot bind frontend {} (TLS={}); check local IP, port permissions and conflicts",
binding.address, binding.tls
)
})?;
public_listeners.push((*binding, listener));
}
let mut quic_listeners = Vec::new();
for binding in &state.active_listeners {
if binding.http3.is_some() {
quic_listeners.push(rust_proxy::http3::bind(*binding, &state)?);
}
}
let mut tcp_stream_listeners = Vec::new();
let mut udp_stream_listeners = Vec::new();
for stream in state.active_streams.iter().filter(|stream| stream.enabled) {
let stream_metrics = state
.stream_metrics
.get(&stream.id)
.cloned()
.context("missing stream metrics registry entry")?;
if options.demo && stream.protocol == rust_proxy::stream::Protocol::Tcp {
ensure!(
!rust_proxy::vhost::socket_overlap(stream.listen, options.demo_listen),
"TCP stream listener conflicts with demo backend"
);
}
match stream.protocol {
rust_proxy::stream::Protocol::Tcp => {
let listener = TcpListener::bind(stream.listen).await.with_context(|| {
format!("cannot bind TCP stream {} on {}", stream.id, stream.listen)
})?;
tcp_stream_listeners.push((stream.clone(), listener, stream_metrics));
}
rust_proxy::stream::Protocol::Udp => {
let socket = UdpSocket::bind(stream.listen).await.with_context(|| {
format!("cannot bind UDP stream {} on {}", stream.id, stream.listen)
})?;
udp_stream_listeners.push((stream.clone(), socket, stream_metrics));
}
}
}
let metrics_token = if state.active_prometheus.enabled {
state.active_prometheus.token()?
} else {
None
};
let metrics_listener = if state.active_prometheus.enabled {
ensure!(
!options.demo
|| !rust_proxy::vhost::socket_overlap(
state.active_prometheus.listen,
options.demo_listen
),
"metrics exporter overlaps demo origin"
);
Some(
TcpListener::bind(state.active_prometheus.listen)
.await
.context("cannot bind Prometheus exporter")?,
)
} else {
None
};
let admin_listener = TcpListener::bind(admin_addr)
.await
.context("cannot bind admin listener")?;
let demo_listener = if options.demo {
Some(
TcpListener::bind(options.demo_listen)
.await
.context("cannot bind demo backend")?,
)
} else {
None
};
let shutdown = state.shutdown.clone();
let grace = Duration::from_secs(options.shutdown_timeout_secs);
let mut tasks = JoinSet::new();
let connection_slots =
std::sync::Arc::new(tokio::sync::Semaphore::new(options.max_connections));
for (binding, listener) in public_listeners {
tasks.spawn(rust_proxy::server::serve_frontend_with_http2(
listener,
rust_proxy::proxy::router(state.clone()),
shutdown.clone(),
connection_slots.clone(),
grace,
state.metrics.clone(),
binding.tls.then(|| (state.clone(), binding.http2)),
binding.h2c,
Some(state.clone()),
binding.http2_settings.unwrap_or_default(),
));
}
for listener in quic_listeners {
tasks.spawn(rust_proxy::http3::serve(
listener,
rust_proxy::proxy::router(state.clone()),
state.clone(),
grace,
));
}
for (stream, listener, metrics) in tcp_stream_listeners {
tasks.spawn(rust_proxy::stream::serve_tcp(
stream,
listener,
shutdown.clone(),
grace,
metrics,
));
}
for (stream, socket, metrics) in udp_stream_listeners {
tasks.spawn(rust_proxy::stream::serve_udp(
stream,
socket,
shutdown.clone(),
metrics,
));
}
if let Some(tls) = admin_tls_config {
tasks.spawn(rust_proxy::server::serve_tls_with_plain_redirect(
admin_listener,
rust_proxy::admin::router(state.clone()),
rust_proxy::admin::https_redirect_router(),
shutdown.clone(),
32,
grace,
tls,
));
} else {
tasks.spawn(rust_proxy::server::serve(
admin_listener,
rust_proxy::admin::router(state.clone()),
shutdown.clone(),
32,
grace,
None,
));
}
if let Some(listener) = metrics_listener {
tasks.spawn(rust_proxy::server::serve(
listener,
rust_proxy::prometheus::router(state.clone(), metrics_token),
shutdown.clone(),
16,
grace,
None,
));
}
if let Some(listener) = demo_listener {
tasks.spawn(rust_proxy::server::serve(
listener,
rust_proxy::demo::router(),
shutdown.clone(),
256,
grace,
None,
));
}
println!("\n RustProxy {}\n", env!("CARGO_PKG_VERSION"));
for binding in &state.active_listeners {
println!(
" Frontend: {}://{} ({})",
if binding.tls { "https" } else { "http" },
binding.address,
if binding.http3.is_some() && binding.http2 {
"HTTP/3 (UDP) + HTTP/2 + HTTP/1.1"
} else if binding.http3.is_some() {
"HTTP/3 (UDP) + HTTP/1.1"
} else if binding.tls && binding.http2 {
"HTTP/2 + HTTP/1.1"
} else if binding.h2c {
"h2c prior-knowledge + HTTP/1.1"
} else {
"HTTP/1.1"
}
);
}
for stream in state.active_streams.iter().filter(|stream| stream.enabled) {
println!(
" Stream: {}://{} -> {} ({})",
match stream.protocol {
rust_proxy::stream::Protocol::Tcp => "tcp",
rust_proxy::stream::Protocol::Udp => "udp",
},
stream.listen,
stream.upstream,
stream.id
);
}
println!(
" Panel: {}://{admin_addr}",
if admin_tls_enabled { "https" } else { "http" }
);
if remote_admin && !admin_tls_enabled {
tracing::warn!(
"REMOTE ADMIN ENABLED OVER PLAIN HTTP: credentials and session cookies can be exposed; restrict access with a firewall/private network and prefer a protected tunnel"
);
}
if state.active_prometheus.enabled {
println!(
" Metrics: http://{}{}",
state.active_prometheus.listen, state.active_prometheus.path
);
}
if setup_required {
println!(
" Setup: rust-proxy --data-dir {} admin setup-token",
data_dir.display()
);
println!(
" Or: rust-proxy --data-dir {} admin init --username YOUR_NAME",
data_dir.display()
);
} else {
println!(" Sign in with your administrator account.");
}
println!(" Data: {}\n Stop: Ctrl+C\n", data_dir.display());
if state
.active_listeners
.iter()
.any(|l| !l.address.ip().is_loopback())
{
tracing::warn!("public binding enabled: development release; configure host/route ACLs and trusted certificates before exposure");
}
let result = tokio::select! {
result = shutdown_signal() => result,
result = tasks.join_next() => match result {
Some(Ok(Ok(()))) => Err(anyhow::anyhow!("listener stopped unexpectedly")),
Some(Ok(Err(error))) => Err(error),
Some(Err(error)) => Err(error.into()),
None => Err(anyhow::anyhow!("all listeners stopped")),
},
};
shutdown.cancel();
while tasks.join_next().await.is_some() {}
state.tunnels.close();
let _ = tokio::time::timeout(grace, state.tunnels.wait()).await;
state.drain_updates().await;
let managed_update = data_dir.join(".managed-update").is_file();
let cache_flush_budget = if managed_update {
std::cmp::max(grace, Duration::from_secs(90))
} else {
grace
};
let flush_result = if managed_update {
tokio::time::timeout(
cache_flush_budget,
state.cache.flush_hot_to_cold_for_update(),
)
.await
} else {
tokio::time::timeout(cache_flush_budget, state.cache.flush_hot_to_cold()).await
};
match flush_result {
Ok(moved) if moved > 0 && managed_update => tracing::info!(
moved,
"managed update moved hot cache objects to cold storage"
),
Ok(moved) if moved > 0 => tracing::info!(
moved,
"flushed hot cache objects to cold storage before shutdown"
),
Err(_) if managed_update => {
tracing::warn!("managed-update hot-to-cold cache handoff exceeded its shutdown budget")
}
Err(_) => tracing::warn!("cold-cache shutdown flush exceeded graceful shutdown timeout"),
_ => {}
}
store.close().await;
result
}
+421
View File
@@ -0,0 +1,421 @@
use anyhow::{bail, ensure, Context, Result};
use axum::body::Body;
use clap::{parser::ValueSource, Args, CommandFactory, FromArgMatches, Parser, Subcommand};
use http::{Method, Request, StatusCode};
use http_body_util::{BodyExt, Limited};
use hyper_rustls::{HttpsConnector, HttpsConnectorBuilder};
use hyper_util::{
client::legacy::{connect::HttpConnector, Client},
rt::TokioExecutor,
};
use rustls::{
client::{
danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier},
WebPkiServerVerifier,
},
pki_types::{CertificateDer, ServerName, UnixTime},
ClientConfig, DigitallySignedStruct, RootCertStore, SignatureScheme,
};
use rust_proxy::{
auth,
config::{self, Config, Envelope, ValidationPolicy},
db::Store,
runtime::App,
};
use sha2::{Digest, Sha256};
use std::io::{self, BufRead, IsTerminal, Read, Write};
use std::{
net::SocketAddr,
path::{Path, PathBuf},
sync::Arc,
time::Duration,
};
use tokio::{
net::{TcpListener, UdpSocket},
task::JoinSet,
};
use zeroize::Zeroizing;
use tracing_subscriber::prelude::*;
#[derive(Parser)]
#[command(
name = "rust-proxy",
version,
about = "RustProxy - independent Rust reverse proxy and static Web Server 0.25.1"
)]
struct Cli {
#[arg(
long,
global = true,
env = "RUST_PROXY_DATA_DIR",
default_value = "./data"
)]
data_dir: PathBuf,
#[arg(
long,
global = true,
env = "RUST_PROXY_ADMIN_LISTEN",
default_value = "127.0.0.1:9090"
)]
admin_listen: SocketAddr,
#[arg(long, global = true, env = "RUST_PROXY_UPDATE_REPOSITORY_URL")]
update_repository_url: Option<String>,
#[arg(long, global = true, env = "RUST_PROXY_UPDATE_VERSION_URL")]
update_version_url: Option<String>,
/// Seconds between release checks. Set to 0 to disable automatic checks.
#[arg(long, global = true, env = "RUST_PROXY_UPDATE_CHECK_INTERVAL_SECS")]
update_check_interval_secs: Option<u64>,
/// Deprecated compatibility no-op kept so older systemd units continue to start.
#[arg(long = "accept-remote-admin-risk", global = true, hide = true)]
_accept_remote_admin_risk: bool,
/// Interactive varnishlog-style live request view from all vhosts.
#[arg(long, global = true)]
livelogs: bool,
#[command(subcommand)]
command: Option<Command>,
}
#[derive(Subcommand)]
enum Command {
/// Start proxy and local administration listener.
Serve(ServeOptions),
/// Compatibility alias for `admin setup-token`; never an API access token.
Token,
/// Local administrator setup, password recovery and session management.
Admin {
#[command(subcommand)]
action: AdminCommand,
},
/// Manage local certificate files and hot-reload certificate bindings.
Tls {
#[command(subcommand)]
action: TlsCommand,
},
/// Read or replace configuration through the running admin API.
Config {
#[command(subcommand)]
action: ConfigCommand,
},
/// Create or restore a password-encrypted full application backup.
Backup {
#[command(subcommand)]
action: BackupCommand,
},
}
#[derive(Subcommand)]
enum AdminCommand {
/// Create the sole administrator. Requires ownership of the private data directory.
Init {
#[arg(long)]
username: Option<String>,
#[arg(long)]
password_stdin: bool,
},
/// Replace the administrator password and revoke all sessions, online or offline.
ResetPassword {
#[arg(long)]
password_stdin: bool,
},
/// Generate or print the one-time setup capability, only before an administrator exists.
SetupToken,
/// Report whether administrator setup is complete (no credentials printed).
Status,
/// Revoke all browser/CLI sessions without changing the password.
LogoutAll,
}
#[derive(Subcommand)]
enum ConfigCommand {
/// Print desired configuration as JSON (suitable for `config apply`).
Get,
/// Revalidate and hot-publish the current configuration and certificate files.
Reload,
/// Atomically validate, save and apply a full configuration document.
Apply { file: PathBuf },
/// Validate JSON, ACLs, headers and backend policy without changing the database.
Validate { file: PathBuf },
}
#[derive(Subcommand)]
enum BackupCommand {
/// Create an encrypted full backup. Stop the RustProxy service first.
Create {
/// Destination .rpb file. Existing files are never overwritten.
#[arg(long)]
output: Option<PathBuf>,
},
/// Restore an encrypted full backup atomically. Stop the RustProxy service first.
Restore {
archive: PathBuf,
},
}
#[derive(Subcommand)]
enum TlsCommand {
/// Generate a self-signed development certificate. Existing files are never overwritten.
Generate {
#[arg(long)]
name: String,
#[arg(long, value_delimiter = ',', required = true)]
hosts: Vec<String>,
},
/// Import a matching PEM certificate/key pair under a new name.
Import {
#[arg(long)]
name: String,
#[arg(long)]
cert: PathBuf,
#[arg(long)]
key: PathBuf,
},
/// Stage an externally issued pair (for example Certbot) and atomically
/// switch an existing manual certificate id to it through the running API.
Deploy {
#[arg(long)]
id: String,
#[arg(long)]
cert: PathBuf,
#[arg(long)]
key: PathBuf,
},
/// Reload configured certificate files without restarting listeners.
Reload,
}
#[derive(Args)]
struct ServeOptions {
/// Optional HTTPS listener. dev.sh enables 127.0.0.1:8443 unless RUST_PROXY_TLS=0.
#[arg(long, env = "RUST_PROXY_TLS_LISTEN")]
tls_listen: Option<SocketAddr>,
#[arg(
long,
env = "RUST_PROXY_PROXY_LISTEN",
default_value = "127.0.0.1:8080"
)]
proxy_listen: SocketAddr,
#[arg(long)]
demo: bool,
#[arg(long, env = "RUST_PROXY_DEMO_LISTEN", default_value = "127.0.0.1:9000")]
demo_listen: SocketAddr,
#[arg(long, default_value_t = 512)]
max_connections: usize,
#[arg(long, default_value_t = 256)]
max_inflight: usize,
/// Includes upload time until upstream response headers arrive.
#[arg(long, default_value_t = 120)]
upstream_timeout_secs: u64,
#[arg(long, default_value_t = 60)]
body_idle_timeout_secs: u64,
/// Inactivity in either direction; independent of ordinary HTTP bodies.
#[arg(long, default_value_t = 300)]
websocket_idle_timeout_secs: u64,
#[arg(long, default_value_t = 10)]
shutdown_timeout_secs: u64,
/// Permit an administration listener on an unspecified address such as 0.0.0.0.
#[arg(long)]
allow_remote_admin: bool,
}
#[cfg(target_os = "linux")]
fn disable_transparent_huge_pages() {
// Keep THP policy local to RustProxy. Large 2 MiB anonymous pages amplify
// allocator fragmentation/retention for the long-running proxy workload, so
// opt this process out before Tokio creates worker threads and their heaps.
let result = unsafe { libc::prctl(libc::PR_SET_THP_DISABLE, 1, 0, 0, 0) };
if result != 0 {
eprintln!(
"RustProxy warning: could not disable Transparent Huge Pages for this process: {}",
io::Error::last_os_error()
);
}
}
#[cfg(not(target_os = "linux"))]
fn disable_transparent_huge_pages() {}
fn main() -> Result<()> {
disable_transparent_huge_pages();
let runtime = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()?;
let result = runtime.block_on(application());
// OS getaddrinfo workers cannot be killed by cancelling their future.
// Bound runtime shutdown after listeners and application tasks have drained.
runtime.shutdown_timeout(Duration::from_secs(2));
result
}
fn installed_data_dir_candidates() -> &'static [&'static str] {
#[cfg(any(target_os = "freebsd", target_os = "openbsd", target_os = "netbsd", target_os = "dragonfly"))]
{
return &["/var/db/rustproxy", "/var/lib/rustproxy"];
}
#[cfg(not(any(target_os = "freebsd", target_os = "openbsd", target_os = "netbsd", target_os = "dragonfly")))]
{
&["/var/lib/rustproxy", "/var/db/rustproxy"]
}
}
fn resolve_installed_data_dir(configured: PathBuf, explicitly_configured: bool) -> PathBuf {
if explicitly_configured || configured.join("config.sqlite3").is_file() {
return configured;
}
installed_data_dir_candidates()
.iter()
.map(PathBuf::from)
.find(|candidate| candidate.join("config.sqlite3").is_file())
.unwrap_or(configured)
}
async fn application() -> Result<()> {
let error_log = rust_proxy::logging::ErrorLogController::start();
rust_proxy::logging::install_error_log_controller(error_log.clone())?;
let filter = tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "rust_proxy=info,warn".into());
tracing_subscriber::registry()
.with(filter)
.with(
tracing_subscriber::fmt::layer()
.with_target(false)
.with_writer(rust_proxy::logging::RedactingStderr),
)
.with(rust_proxy::logging::ErrorLogLayer::new(error_log))
.init();
let matches = Cli::command().get_matches();
let explicit = |matches: &clap::ArgMatches, id: &str| {
matches.value_source(id).is_some_and(|source| {
matches!(source, ValueSource::CommandLine | ValueSource::EnvVariable)
})
};
let serve_matches = matches.subcommand_matches("serve");
let startup_locks = rust_proxy::settings::StartupLocks {
administration: explicit(&matches, "admin_listen").then(|| {
*matches
.get_one::<SocketAddr>("admin_listen")
.expect("parsed admin listener")
}),
http: serve_matches
.filter(|sub| explicit(sub, "proxy_listen"))
.map(|sub| {
*sub.get_one::<SocketAddr>("proxy_listen")
.expect("parsed proxy listener")
}),
https: serve_matches
.filter(|sub| explicit(sub, "tls_listen"))
.and_then(|sub| sub.get_one::<SocketAddr>("tls_listen").copied()),
};
let cli = Cli::from_arg_matches(&matches)?;
let data_dir_explicit = explicit(&matches, "data_dir");
if cli.livelogs {
ensure!(cli.command.is_none(), "--livelogs cannot be combined with a subcommand");
let data_dir = resolve_installed_data_dir(cli.data_dir, data_dir_explicit);
return live_logs_command(data_dir, cli.admin_listen).await;
}
let command = cli.command.context("a subcommand is required (or use --livelogs)")?;
match command {
Command::Token => {
admin_command(
resolve_installed_data_dir(cli.data_dir, data_dir_explicit),
AdminCommand::SetupToken,
)
.await
}
Command::Admin { action } => {
admin_command(resolve_installed_data_dir(cli.data_dir, data_dir_explicit), action).await
}
Command::Tls { action } => {
tls_command(
resolve_installed_data_dir(cli.data_dir, data_dir_explicit),
cli.admin_listen,
action,
)
.await
}
Command::Config { action } => {
config_command(
resolve_installed_data_dir(cli.data_dir, data_dir_explicit),
cli.admin_listen,
action,
)
.await
}
Command::Backup { action } => {
backup_command(
resolve_installed_data_dir(cli.data_dir, data_dir_explicit),
action,
)
.await
}
Command::Serve(options) => {
let update_check = rust_proxy::update_check::Config::embedded()?.with_overrides(
cli.update_repository_url,
cli.update_version_url,
cli.update_check_interval_secs,
)?;
// Server startup never guesses a data directory. Package/service/plugin
// installation must pass its configured path explicitly.
run(
cli.data_dir,
cli.admin_listen,
options,
startup_locks,
update_check,
)
.await
}
}
}
async fn backup_command(data_dir: PathBuf, action: BackupCommand) -> Result<()> {
match action {
BackupCommand::Create { output } => {
let output = output.unwrap_or_else(|| {
PathBuf::from(format!("rustproxy-backup-{}.rpb", rust_proxy::auth::now()))
});
let mut generated = false;
let password = if io::stdin().is_terminal() {
let value = Zeroizing::new(
rpassword::prompt_password("Backup password (leave empty to generate): ")?
);
if value.is_empty() {
generated = true;
Zeroizing::new(rust_proxy::backup::generated_password())
} else {
let confirmation = Zeroizing::new(rpassword::prompt_password("Confirm backup password: ")?);
ensure!(value.as_str() == confirmation.as_str(), "backup passwords do not match");
value
}
} else {
generated = true;
Zeroizing::new(rust_proxy::backup::generated_password())
};
let summary = rust_proxy::backup::create(&data_dir, &output, password.as_str()).await?;
println!(
"Encrypted backup created: {} ({} files, configuration revision {})",
summary.path.display(), summary.files, summary.configuration_revision
);
if generated {
println!("Generated backup password (shown once): {}", password.as_str());
println!("Store this password securely; restore is impossible without it.");
}
Ok(())
}
BackupCommand::Restore { archive } => {
ensure!(
io::stdin().is_terminal(),
"backup restore requires an interactive TTY for the password"
);
let password = Zeroizing::new(rpassword::prompt_password("Backup password: ")?);
ensure!(!password.is_empty(), "backup password cannot be empty");
let summary = rust_proxy::backup::restore(&archive, &data_dir, password.as_str()).await?;
println!(
"Backup restored to {} ({} files, configuration revision {})",
data_dir.display(), summary.files, summary.configuration_revision
);
Ok(())
}
}
}
fn validate_admin_binding(admin_addr: SocketAddr, tls: bool, allow_remote: bool) -> Result<bool> {
let remote_admin = !admin_addr.ip().is_loopback();
ensure!(
!remote_admin || tls || allow_remote,
"remote plain-HTTP administration requires --allow-remote-admin"
);
Ok(remote_admin)
}
+417
View File
@@ -0,0 +1,417 @@
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)
}
+230
View File
@@ -0,0 +1,230 @@
async fn read_external_pem(file: PathBuf, private: bool) -> Result<Zeroizing<Vec<u8>>> {
tokio::task::spawn_blocking(move || {
// Certbot's live/*.pem files are symlinks by design. Resolve them once,
// then open the final regular file without following another symlink.
let canonical = std::fs::canonicalize(&file)
.with_context(|| format!("cannot resolve external PEM {}", file.display()))?;
let mut options = std::fs::OpenOptions::new();
options.read(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.custom_flags(libc::O_NOFOLLOW);
}
let opened = options
.open(&canonical)
.with_context(|| format!("cannot open external PEM {}", canonical.display()))?;
let metadata = opened.metadata()?;
ensure!(
metadata.is_file() && metadata.len() <= 131_072,
"external PEM must be a regular file up to 128 KiB"
);
#[cfg(unix)]
if private {
use std::os::unix::fs::PermissionsExt;
ensure!(
metadata.permissions().mode() & 0o077 == 0,
"external private key permissions must not allow group/world access: {}",
canonical.display()
);
}
#[cfg(not(unix))]
let _ = private;
let mut bytes = Zeroizing::new(Vec::new());
opened
.take(131_073)
.read_to_end(&mut bytes)
.context("cannot read external PEM")?;
ensure!(bytes.len() <= 131_072, "external PEM grew beyond 128 KiB");
Ok::<_, anyhow::Error>(bytes)
})
.await?
}
async fn config_command(dir: PathBuf, addr: SocketAddr, action: ConfigCommand) -> Result<()> {
let action = match action {
ConfigCommand::Validate { file } => {
let config = read_config_file(file).await?;
let proxy_addr = std::env::var("RUST_PROXY_PROXY_LISTEN")
.unwrap_or_else(|_| "127.0.0.1:8080".into())
.parse()?;
let tls_addr = std::env::var("RUST_PROXY_TLS_LISTEN")
.ok()
.filter(|s| !s.is_empty())
.map(|s| s.parse())
.transpose()?;
let cert_path = dir.join("certs");
let cert_dir = cert_path.is_dir().then_some(cert_path);
let policy = ValidationPolicy {
proxy_addr,
admin_addr: addr,
tls_addr,
cert_dir,
data_dir: Some(dir.clone()),
};
tokio::task::spawn_blocking(move || config::compile(config, 0, &policy)).await??;
println!("Configuration valid. No database or runtime changes made.");
return Ok(());
}
action => action,
};
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")?;
// OS file ownership is the local recovery/management authority, not a permanent API token.
let session = store.create_session(record.generation, "cli", None).await?.context("credentials changed; retry CLI operation")?;
let result = async {
let current: Envelope = serde_json::from_str(&api(&admin, &session, Method::GET, "/api/config", None).await?)?;
match action {
ConfigCommand::Get => println!("{}", serde_json::to_string_pretty(&current.config)?),
ConfigCommand::Reload => {
let body = serde_json::json!({"expected_revision":current.revision,"config":current.config}).to_string();
let updated: Envelope = serde_json::from_str(&api(&admin, &session, Method::PUT, "/api/config", Some(body)).await?)?;
println!("Reloaded configuration and certificates at revision {}.", updated.revision);
},
ConfigCommand::Apply { file } => {
let config = read_config_file(file).await?;
let body = serde_json::json!({"expected_revision":current.revision,"config":config}).to_string();
let updated: Envelope = serde_json::from_str(&api(&admin, &session, Method::PUT, "/api/config", Some(body)).await?)?;
println!("Saved revision {}. Routing/pool policies are live; listener or ALPN changes require a restart (see /api/status).", updated.revision);
},
ConfigCommand::Validate { .. } => unreachable!("handled before opening SQLite"),
}
Ok::<_, anyhow::Error>(())
}.await;
if let Err(error) = store.end_session(&session.session).await {
tracing::warn!(%error, "CLI session cleanup failed; the capability expires after 60 seconds");
}
result
}.await;
store.close().await;
result
}
async fn tls_command(dir: PathBuf, addr: SocketAddr, action: TlsCommand) -> Result<()> {
if matches!(&action, TlsCommand::Reload) {
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 session = store.create_session(record.generation, "cli", None).await?.context("credentials changed; retry CLI operation")?;
let response = api(&admin, &session, Method::POST, "/api/certificates/reload", Some("{}".into())).await;
if let Err(error) = store.end_session(&session.session).await {
tracing::warn!(%error, "CLI session cleanup failed; the capability expires after 60 seconds");
}
let payload: serde_json::Value = serde_json::from_str(&response?)?;
println!(
"Reloaded configured certificate files at revision {}. New public TLS/H2/H3 handshakes use the refreshed keys.",
payload["revision"]
);
if payload["administration_restart_required"].as_bool() == Some(true) {
println!("Administration TLS uses a startup-fixed listener and requires a service restart for its selected certificate.");
}
Ok::<_, anyhow::Error>(())
}
.await;
store.close().await;
return result;
}
if let TlsCommand::Deploy { id, cert, key } = &action {
let id = id.clone();
let certificate = read_external_pem(cert.clone(), false).await?;
let private_key = read_external_pem(key.clone(), true).await?;
let cert_pem = std::str::from_utf8(certificate.as_slice())
.context("external certificate PEM is not UTF-8")?;
let key_pem = std::str::from_utf8(private_key.as_slice())
.context("external private key PEM is not UTF-8")?;
auth::prepare_directory(&dir)?;
let database = dir.join("config.sqlite3");
ensure!(database.is_file(), "database does not exist at {}; start rust-proxy serve first", database.display());
auth::prepare_database(&database)?;
let store = Store::open(&database, &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 session = store.create_session(record.generation, "cli", None).await?.context("credentials changed; retry CLI operation")?;
let body = serde_json::json!({
"certificate_id": id,
"cert_pem": cert_pem,
"key_pem": key_pem,
})
.to_string();
let response = api(
&admin,
&session,
Method::POST,
"/api/certificates/deploy",
Some(body),
)
.await;
if let Err(error) = store.end_session(&session.session).await {
tracing::warn!(%error, "CLI session cleanup failed; the capability expires after 60 seconds");
}
let payload: serde_json::Value = serde_json::from_str(&response?)?;
let operation = payload["operation"].as_str().unwrap_or("updated");
println!(
"External certificate {} reconciliation: {} at revision {} using {} / {}.",
payload["certificate_id"],
operation,
payload["revision"],
payload["cert_file"],
payload["key_file"]
);
if payload["changed"].as_bool() == Some(true) {
println!("New public TLS/H2/H3 handshakes use the reconciled certificate without a listener restart.");
} else {
println!("Certificate material already matched the configured RustProxy generation; no revision was published.");
}
if payload["administration_restart_required"].as_bool() == Some(true) {
println!("Administration TLS selects this certificate; restart RustProxy to refresh that startup-fixed listener.");
}
Ok::<_, anyhow::Error>(())
}
.await;
store.close().await;
return result;
}
auth::prepare_directory(&dir)?;
let directory = dir.join("certs");
tokio::task::spawn_blocking(move || {
match action {
TlsCommand::Generate { name, hosts } => {
rust_proxy::tls::generate(&directory, &name, hosts)?;
println!(
"Created {name}.pem and {name}.key in {}.",
directory.display()
);
println!(
"Self-signed development certificate. Add its binding in the panel TLS page."
);
}
TlsCommand::Import { name, cert, key } => {
rust_proxy::tls::import(&directory, &name, cert, key)?;
println!(
"Imported {name}.pem and {name}.key. Add the binding in the panel TLS page."
);
}
TlsCommand::Deploy { .. } => unreachable!("handled above"),
TlsCommand::Reload => unreachable!("handled above"),
}
Ok::<_, anyhow::Error>(())
})
.await?
}
#[cfg(test)]
include!("shutdown/tests.rs");
+124
View File
@@ -0,0 +1,124 @@
mod cli_regressions {
use super::*;
#[test]
fn local_admin_probe_switches_cli_to_https_on_plaintext_redirect() {
assert_eq!(
local_admin_scheme_from_probe(StatusCode::OK, None).unwrap(),
"http"
);
assert_eq!(
local_admin_scheme_from_probe(
StatusCode::PERMANENT_REDIRECT,
Some("https://127.0.0.1:9090/healthz"),
)
.unwrap(),
"https"
);
assert!(local_admin_scheme_from_probe(
StatusCode::PERMANENT_REDIRECT,
Some("http://127.0.0.1:9090/healthz"),
)
.is_err());
}
#[test]
fn live_log_filters_are_structured_not_regex_based() {
let entry = serde_json::json!({
"id": 7,
"host": "api.example.test",
"vhost": "api",
"backend": "origin-a",
"route": "users",
"client_ip": "192.0.2.5",
"method": "GET",
"transport": "h2",
"cache": "MISS",
"status": 503,
"duration_ms": 245,
"path": "/v1/users",
"upstream_error": "connect timeout"
});
let mut filters = LiveLogFilters::default();
filters.host = Some("api.example.test".into());
filters.status = Some("5xx".into());
filters.errors_only = true;
filters.slow_ms = Some(200);
assert!(live_log_matches(&entry, &filters));
filters.backend = Some("origin-b".into());
assert!(!live_log_matches(&entry, &filters));
filters.backend = None;
filters.path_prefix = Some("/admin".into());
assert!(!live_log_matches(&entry, &filters));
}
#[test]
fn sse_parser_accepts_lf_and_crlf_frames() {
let mut lf = b"event: log\ndata: {\"id\":1}\n\nrest".to_vec();
assert_eq!(take_sse_event(&mut lf).unwrap(), b"event: log\ndata: {\"id\":1}".to_vec());
assert_eq!(lf, b"rest".to_vec());
let mut crlf = b"event: gap\r\ndata: {\"dropped\":2}\r\n\r\n".to_vec();
let event = take_sse_event(&mut crlf).unwrap();
let event = std::str::from_utf8(&event).unwrap();
let (kind, data) = parse_sse_event(event);
assert_eq!(kind, Some("gap"));
assert_eq!(data.as_deref(), Some("{\"dropped\":2}"));
}
#[test]
fn sse_parser_tolerates_utf8_split_across_transport_frames() {
let mut buffer = b"event: log\ndata: {\"path\":\"/za".to_vec();
buffer.extend_from_slice(&[0xc5]);
assert!(take_sse_event(&mut buffer).is_none());
buffer.extend_from_slice(&[0xbc]);
buffer.extend_from_slice(b"olc\"}\n\n");
let event = take_sse_event(&mut buffer).unwrap();
let event = std::str::from_utf8(&event).unwrap();
let (kind, data) = parse_sse_event(event);
assert_eq!(kind, Some("log"));
assert_eq!(data.as_deref(), Some("{\"path\":\"/zażolc\"}"));
}
#[test]
fn installed_data_dir_candidates_follow_platform_conventions() {
let candidates = installed_data_dir_candidates();
#[cfg(any(
target_os = "freebsd",
target_os = "openbsd",
target_os = "netbsd",
target_os = "dragonfly"
))]
assert_eq!(candidates, &["/var/db/rustproxy", "/var/lib/rustproxy"]);
#[cfg(not(any(
target_os = "freebsd",
target_os = "openbsd",
target_os = "netbsd",
target_os = "dragonfly"
)))]
assert_eq!(candidates, &["/var/lib/rustproxy", "/var/db/rustproxy"]);
}
#[test]
fn explicit_data_dir_is_never_replaced_by_install_discovery() {
let configured = PathBuf::from("/custom/rustproxy-private-state");
assert_eq!(
resolve_installed_data_dir(configured.clone(), true),
configured
);
}
#[test]
fn remote_admin_plain_http_requires_only_allow_flag() {
let local: SocketAddr = "127.0.0.1:9090".parse().unwrap();
assert!(!validate_admin_binding(local, false, false).unwrap());
let wildcard: SocketAddr = "0.0.0.0:9090".parse().unwrap();
assert!(validate_admin_binding(wildcard, false, true).unwrap());
assert!(validate_admin_binding(wildcard, false, false).is_err());
assert!(validate_admin_binding(wildcard, true, false).unwrap());
let concrete: SocketAddr = "192.0.2.10:9090".parse().unwrap();
assert!(validate_admin_binding(concrete, false, true).unwrap());
assert!(validate_admin_binding(concrete, true, false).unwrap());
}
}
+408
View File
@@ -0,0 +1,408 @@
fn parse_sse_event(event: &str) -> (Option<&str>, Option<String>) {
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<HttpsConnector<HttpConnector>, Body>;
#[derive(Debug)]
struct LocalAdminCertificateVerifier {
expected: Vec<[u8; 32]>,
signature_verifier: Arc<WebPkiServerVerifier>,
}
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<ServerCertVerified, rustls::Error> {
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<HandshakeSignatureValid, rustls::Error> {
self.signature_verifier
.verify_tls12_signature(message, cert, dss)
}
fn verify_tls13_signature(
&self,
message: &[u8],
cert: &CertificateDer<'_>,
dss: &DigitallySignedStruct,
) -> std::result::Result<HandshakeSignatureValid, rustls::Error> {
self.signature_verifier
.verify_tls13_signature(message, cert, dss)
}
fn supported_verify_schemes(&self) -> Vec<SignatureScheme> {
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<Vec<[u8; 32]>> {
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<Self> {
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::<String>();
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.25.1 - 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::<u64, serde_json::Value>::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::<serde_json::Value>(&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::<serde_json::Value>(&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<String>,
) -> Result<String> {
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<Config> {
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)?)
}