1use std::{
7 path::PathBuf,
8 sync::{Arc, Mutex},
9};
10
11use praxis_core::{
12 PingoraServerRuntime,
13 config::{Config, ProtocolKind},
14 health::{HealthRegistry, build_health_registry},
15};
16use praxis_filter::FilterRegistry;
17use praxis_protocol::{CertWatcherShutdowns, ListenerPipelines, Protocol as _, http::PingoraHttp, tcp::PingoraTcp};
18use tokio_util::sync::CancellationToken;
19use tracing::info;
20
21use crate::pipelines::resolve_pipelines;
22
23pub fn resolve_config_path(explicit: Option<&str>) -> Option<PathBuf> {
38 if let Some(path) = explicit {
39 return Some(PathBuf::from(path));
40 }
41 let default_path = PathBuf::from("praxis.yaml");
42 default_path.exists().then_some(default_path)
43}
44
45#[expect(clippy::allow_attributes, reason = "lint is platform/config-dependent")]
60#[allow(clippy::needless_pass_by_value, reason = "server owns config")]
61pub fn run_server(config: Config, config_path: Option<PathBuf>) -> ! {
62 run_server_with_registry(config, crate::build_full_registry(), config_path)
63}
64
65#[expect(clippy::allow_attributes, reason = "lint is platform/config-dependent")]
75#[allow(clippy::needless_pass_by_value, reason = "server owns config")]
76pub fn run_server_with_registry(config: Config, registry: FilterRegistry, config_path: Option<PathBuf>) -> ! {
77 enforce_root_check(&config);
78 warn_insecure_options(&config);
79 init_runtime_limits(&config.runtime);
80 warn_insecure_key_permissions(&config);
81
82 let health_registry = build_health_registry(&config.clusters);
83 let state = build_server_state(&config, ®istry, &health_registry);
84
85 info!("initializing server");
86 let mut server = PingoraServerRuntime::new(&config);
87 let _cert_shutdowns = register_protocols(&mut server, &config, &state.pipelines);
88 register_admin_endpoints(&mut server, &config, health_registry, &state.kv_stores);
89
90 let _watcher = spawn_watcher(config_path, config, registry, state);
91
92 info!("starting server");
93 server.run()
94}
95
96struct ServerState {
103 pipelines: Arc<ListenerPipelines>,
105 kv_stores: praxis_core::kv::KvStoreRegistry,
107 health_shutdown: Arc<Mutex<CancellationToken>>,
109}
110
111fn build_server_state(config: &Config, registry: &FilterRegistry, health_registry: &HealthRegistry) -> ServerState {
113 info!("building filter pipelines");
114 let kv_stores = praxis_core::kv::KvStoreRegistry::new();
115
116 let pipelines = resolve_pipelines(config, registry, health_registry, &kv_stores).unwrap_or_else(|e| fatal(&e));
117
118 let health_shutdown = Arc::new(Mutex::new(CancellationToken::new()));
119 spawn_health_check_tasks(config, Arc::clone(health_registry), &health_shutdown);
120
121 ServerState {
122 pipelines: Arc::new(pipelines),
123 kv_stores,
124 health_shutdown,
125 }
126}
127
128fn register_protocols(
134 server: &mut PingoraServerRuntime,
135 config: &Config,
136 pipelines: &ListenerPipelines,
137) -> CertWatcherShutdowns {
138 let mut all_shutdowns = Vec::new();
139
140 if config.listeners.iter().any(|l| l.protocol == ProtocolKind::Http) {
141 let shutdowns = Box::new(PingoraHttp)
142 .register(server, config, pipelines)
143 .unwrap_or_else(|e| fatal(&e));
144 all_shutdowns.extend(shutdowns);
145 }
146
147 if config.listeners.iter().any(|l| l.protocol == ProtocolKind::Tcp) {
148 let shutdowns = Box::new(PingoraTcp)
149 .register(server, config, pipelines)
150 .unwrap_or_else(|e| fatal(&e));
151 all_shutdowns.extend(shutdowns);
152 }
153
154 CertWatcherShutdowns::new(all_shutdowns)
155}
156
157fn spawn_watcher(
159 config_path: Option<PathBuf>,
160 config: Config,
161 registry: FilterRegistry,
162 state: ServerState,
163) -> Option<std::thread::JoinHandle<()>> {
164 let path = config_path?;
165 let handle = crate::watcher::spawn_config_watcher(crate::watcher::WatcherParams {
166 config_path: path,
167 health_shutdown: state.health_shutdown,
168 initial_config: config,
169 kv_stores: state.kv_stores,
170 pipelines: state.pipelines,
171 registry: Arc::new(registry),
172 shutdown: CancellationToken::new(),
173 });
174 Some(handle)
175}
176
177fn register_admin_endpoints(
183 server: &mut PingoraServerRuntime,
184 config: &Config,
185 health_registry: HealthRegistry,
186 kv_stores: &praxis_core::kv::KvStoreRegistry,
187) {
188 if let Some(admin_addr) = &config.admin.address {
189 praxis_protocol::http::pingora::health::add_admin_endpoints_to_pingora_server(
190 server.server_mut(),
191 admin_addr,
192 Some(health_registry),
193 Some(kv_stores.clone()),
194 config.admin.verbose,
195 );
196 }
197}
198
199fn init_runtime_limits(runtime: &praxis_core::config::RuntimeConfig) {
205 if let Some(max) = runtime.max_connections {
206 praxis_protocol::connections::init_global_limit(max as usize);
207 info!(max_connections = max, "global connection limit enabled");
208 }
209 if let Some(threshold) = runtime.max_memory_bytes {
210 praxis_core::memory::init(threshold);
211 info!(
212 threshold_mib = threshold / 1_048_576,
213 "memory pressure monitoring enabled"
214 );
215 }
216}
217
218fn warn_insecure_options(config: &Config) {
224 let o = &config.insecure_options;
225 insecure_warn(
226 o.allow_unbounded_body,
227 "allow_unbounded_body: body size ceiling relaxed",
228 );
229 insecure_warn(
230 o.allow_open_security_filters,
231 "allow_open_security_filters: open failure_mode allowed",
232 );
233 insecure_warn(
234 o.allow_public_admin,
235 "allow_public_admin: admin may bind all interfaces",
236 );
237 insecure_warn(
238 o.allow_tls_without_sni,
239 "allow_tls_without_sni: TLS hostname verification weakened",
240 );
241 insecure_warn(
242 o.allow_private_health_checks,
243 "allow_private_health_checks: loopback health checks allowed",
244 );
245 insecure_warn(o.csrf_log_only, "csrf_log_only: CSRF violations logged, not rejected");
246 insecure_warn(
247 o.skip_pipeline_validation,
248 "skip_pipeline_validation: pipeline errors demoted to warnings",
249 );
250}
251
252fn insecure_warn(active: bool, msg: &str) {
254 if active {
255 tracing::warn!("insecure_options.{msg}");
256 }
257}
258
259pub fn check_root_privilege(allow_root: bool, euid: u32) -> Option<String> {
280 if euid != 0 {
281 return None;
282 }
283
284 if allow_root {
285 tracing::warn!("running as root (UID 0) with insecure_options.allow_root override; this is not recommended");
286 return None;
287 }
288
289 Some(
290 "Praxis refuses to run as root (UID 0). Running a proxy as root is a security risk.\n\
291 Use one of these alternatives:\n \
292 - Run as a non-root user with CAP_NET_BIND_SERVICE for low ports\n \
293 - Use a reverse proxy or socket activation\n \
294 - Set insecure_options.allow_root: true in config to override (not recommended)"
295 .to_owned(),
296 )
297}
298
299#[cfg(unix)]
301fn enforce_root_check(config: &Config) {
302 let euid = nix::unistd::geteuid().as_raw();
303 if let Some(msg) = check_root_privilege(config.insecure_options.allow_root, euid) {
304 fatal(&msg);
305 }
306}
307
308#[cfg(not(unix))]
310fn enforce_root_check(_config: &Config) {}
311
312#[cfg(unix)]
323fn warn_insecure_key_permissions(config: &Config) {
324 use std::os::unix::fs::PermissionsExt as _;
325
326 for listener in &config.listeners {
327 if let Some(tls) = &listener.tls {
328 for cert in &tls.certificates {
329 let key_path = &cert.key_path;
330 if let Ok(meta) = std::fs::metadata(key_path) {
331 let mode = meta.permissions().mode();
332 if mode & 0o077 != 0 {
333 tracing::warn!(
334 listener = %listener.name,
335 path = %key_path,
336 mode = format!("{:04o}", mode & 0o7777),
337 "TLS private key file has overly permissive \
338 permissions; recommend chmod 0600"
339 );
340 }
341 } else {
342 tracing::trace!(
343 listener = %listener.name,
344 path = %key_path,
345 "skipped permission check: could not read file metadata"
346 );
347 }
348 }
349 }
350 }
351}
352
353#[cfg(not(unix))]
355fn warn_insecure_key_permissions(_config: &Config) {}
356
357#[expect(clippy::expect_used, reason = "fatal")]
374fn spawn_health_check_tasks(
375 config: &Config,
376 registry: HealthRegistry,
377 health_shutdown: &Arc<Mutex<CancellationToken>>,
378) {
379 if registry.is_empty() {
380 return;
381 }
382
383 let shutdown = health_shutdown.lock().expect("health shutdown lock").clone();
384 let clusters = config.clusters.clone();
385
386 std::thread::spawn(move || {
387 let rt = tokio::runtime::Builder::new_current_thread()
388 .enable_all()
389 .build()
390 .expect("health check runtime");
391 rt.block_on(async {
392 praxis_protocol::http::pingora::health::runner::spawn_health_checks(&clusters, ®istry, &shutdown);
393 shutdown.cancelled().await;
394 });
395 });
396}
397
398#[expect(
404 clippy::print_stderr,
405 clippy::exit,
406 reason = "fatal error output before runtime is available"
407)]
408pub fn fatal(err: &dyn std::fmt::Display) -> ! {
409 eprintln!("fatal: {err}");
410 std::process::exit(1)
411}
412
413#[cfg(test)]
418#[expect(clippy::allow_attributes, reason = "blanket test suppressions")]
419#[allow(
420 clippy::unwrap_used,
421 clippy::expect_used,
422 clippy::indexing_slicing,
423 clippy::too_many_lines,
424 reason = "tests"
425)]
426mod tests {
427 use super::*;
428
429 #[test]
430 fn root_uid_without_override_returns_error() {
431 let result = check_root_privilege(false, 0);
432 assert!(result.is_some(), "UID 0 without allow_root should return an error");
433 let msg = result.unwrap();
434 assert!(
435 msg.contains("refuses to run as root"),
436 "error message should explain the refusal"
437 );
438 }
439
440 #[test]
441 fn root_uid_with_override_returns_none() {
442 let result = check_root_privilege(true, 0);
443 assert!(result.is_none(), "UID 0 with allow_root should be allowed");
444 }
445
446 #[test]
447 fn non_root_uid_returns_none() {
448 let result = check_root_privilege(false, 1000);
449 assert!(result.is_none(), "non-root UID should always be allowed");
450 }
451
452 #[test]
453 fn non_root_uid_with_override_returns_none() {
454 let result = check_root_privilege(true, 1000);
455 assert!(result.is_none(), "non-root UID with allow_root should be allowed");
456 }
457
458 #[test]
459 fn error_message_suggests_alternatives() {
460 let msg = check_root_privilege(false, 0).unwrap();
461 assert!(
462 msg.contains("CAP_NET_BIND_SERVICE"),
463 "should suggest CAP_NET_BIND_SERVICE"
464 );
465 assert!(
466 msg.contains("insecure_options.allow_root: true"),
467 "should mention the config override"
468 );
469 }
470
471 #[test]
472 fn resolve_config_path_explicit() {
473 let path = resolve_config_path(Some("/tmp/test.yaml"));
474 assert_eq!(
475 path,
476 Some(PathBuf::from("/tmp/test.yaml")),
477 "explicit path should be returned as-is"
478 );
479 }
480
481 #[test]
482 fn resolve_config_path_none_no_file() {
483 let path = resolve_config_path(None);
484 if !std::path::Path::new("praxis.yaml").exists() {
485 assert!(path.is_none(), "should return None when praxis.yaml does not exist");
486 }
487 }
488
489 #[test]
494 fn insecure_warn_inactive_does_not_panic() {
495 insecure_warn(false, "test_option: this should not panic");
496 }
497
498 #[test]
499 fn insecure_warn_active_does_not_panic() {
500 insecure_warn(true, "test_option: active warning");
501 }
502
503 #[test]
508 fn init_runtime_limits_no_limits_does_not_panic() {
509 let runtime = praxis_core::config::RuntimeConfig::default();
510 init_runtime_limits(&runtime);
511 }
512
513 #[test]
514 fn init_runtime_limits_with_memory_does_not_panic() {
515 let runtime = praxis_core::config::RuntimeConfig {
516 max_memory_bytes: Some(1_073_741_824),
517 ..Default::default()
518 };
519 init_runtime_limits(&runtime);
520 }
521
522 #[cfg(unix)]
527 #[test]
528 fn key_permissions_restrictive_no_warning() {
529 use std::os::unix::fs::PermissionsExt as _;
530
531 let dir = tempfile::TempDir::new().expect("tempdir");
532 let key_path = dir.path().join("key.pem");
533 let cert_path = dir.path().join("cert.pem");
534 std::fs::write(&key_path, "fake-key").expect("write key");
535 std::fs::write(&cert_path, "fake-cert").expect("write cert");
536 std::fs::set_permissions(&key_path, std::fs::Permissions::from_mode(0o600)).expect("chmod");
537
538 let config = config_with_tls(cert_path.to_str().expect("cert"), key_path.to_str().expect("key"));
539 warn_insecure_key_permissions(&config);
540 }
541
542 #[cfg(unix)]
543 #[test]
544 fn key_permissions_permissive_does_not_panic() {
545 use std::os::unix::fs::PermissionsExt as _;
546
547 let dir = tempfile::TempDir::new().expect("tempdir");
548 let key_path = dir.path().join("key.pem");
549 let cert_path = dir.path().join("cert.pem");
550 std::fs::write(&key_path, "fake-key").expect("write key");
551 std::fs::write(&cert_path, "fake-cert").expect("write cert");
552 std::fs::set_permissions(&key_path, std::fs::Permissions::from_mode(0o644)).expect("chmod");
553
554 let config = config_with_tls(cert_path.to_str().expect("cert"), key_path.to_str().expect("key"));
555 warn_insecure_key_permissions(&config);
556 }
557
558 #[cfg(unix)]
559 #[test]
560 fn key_permissions_missing_file_does_not_panic() {
561 let config = config_with_tls("/nonexistent/cert.pem", "/nonexistent/key.pem");
562 warn_insecure_key_permissions(&config);
563 }
564
565 #[cfg(unix)]
570 fn config_with_tls(cert_path: &str, key_path: &str) -> Config {
571 let yaml = format!(
572 r#"
573listeners:
574 - name: tls
575 address: "127.0.0.1:8443"
576 filter_chains: [main]
577 tls:
578 certificates:
579 - cert_path: "{cert_path}"
580 key_path: "{key_path}"
581 server_names: ["localhost"]
582filter_chains:
583 - name: main
584 filters:
585 - filter: router
586 routes:
587 - path_prefix: "/"
588 cluster: backend
589 - filter: load_balancer
590 clusters:
591 - name: backend
592 endpoints:
593 - "127.0.0.1:3000"
594"#
595 );
596 Config::from_yaml(&yaml).expect("test config should parse")
597 }
598}