v0.25.1
This commit is contained in:
1 parent
87e571c170
commit
fa7abc9e53
308 files changed
+49746
-50237
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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(¤t.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");
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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)?)
|
||||
}
|
||||
Reference in new issue
Block a user