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