451 lines
15 KiB
Rust
451 lines
15 KiB
Rust
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 rust_proxy::{
|
|
auth,
|
|
config::{self, Config, Envelope, ValidationPolicy},
|
|
db::Store,
|
|
runtime::App,
|
|
};
|
|
use rustls::{
|
|
client::{
|
|
danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier},
|
|
WebPkiServerVerifier,
|
|
},
|
|
pki_types::{CertificateDer, ServerName, UnixTime},
|
|
ClientConfig, DigitallySignedStruct, RootCertStore, SignatureScheme,
|
|
};
|
|
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 tracing_subscriber::prelude::*;
|
|
use zeroize::Zeroizing;
|
|
|
|
#[derive(Parser)]
|
|
#[command(
|
|
name = "rust-proxy",
|
|
version,
|
|
about = "RustProxy - independent Rust reverse proxy and static Web Server 0.26.7"
|
|
)]
|
|
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. Safe while RustProxy is serving.
|
|
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)
|
|
}
|