Skip to main content

praxis/
server.rs

1// SPDX-License-Identifier: MIT
2// Copyright (c) 2024 Praxis Contributors
3
4//! Server bootstrap: protocol registration and startup.
5
6use 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
23// -----------------------------------------------------------------------------
24// Config Path Resolution
25// -----------------------------------------------------------------------------
26
27/// Resolve the config file path without loading the config.
28///
29/// Returns `Some` if an explicit path was given or `praxis.yaml`
30/// exists in the working directory. Returns `None` when using the
31/// built-in default (no file to watch).
32///
33/// ```
34/// let path = praxis::resolve_config_path(None);
35/// // Returns None if ./praxis.yaml doesn't exist.
36/// ```
37pub 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// -----------------------------------------------------------------------------
46// Server
47// -----------------------------------------------------------------------------
48
49/// Build filter pipelines using built-in and auto-discovered external filters, register
50/// protocols and run the server.
51///
52/// # Security: Root Check
53///
54/// On Unix, this function refuses to start if the effective UID is 0 (root). Set
55/// `insecure_options.allow_root: true` in the configuration to override. Prefer
56/// `CAP_NET_BIND_SERVICE` or a reverse proxy for low-port binding.
57///
58/// Config is owned for the server's lifetime (never returns).
59#[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/// Build filter pipelines from the given registry, register protocols and run the server.
66///
67/// Use this variant when you need custom filters beyond the built-ins (e.g. via [`register_filters!`]).
68///
69/// Assumes tracing is already initialized. Blocks until the process is terminated; never returns.
70///
71/// Config is owned for the server's lifetime (never returns).
72///
73/// [`register_filters!`]: praxis_filter::register_filters
74#[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, &registry, &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
96// -----------------------------------------------------------------------------
97// Server State
98// -----------------------------------------------------------------------------
99
100/// State built during server initialization and shared with the
101/// file watcher for hot reload.
102struct ServerState {
103    /// Resolved filter pipelines per listener.
104    pipelines: Arc<ListenerPipelines>,
105    /// KV store registry.
106    kv_stores: praxis_core::kv::KvStoreRegistry,
107    /// Health check cancellation token.
108    health_shutdown: Arc<Mutex<CancellationToken>>,
109}
110
111/// Build filter pipelines, health checks, and registries.
112fn 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
128// -----------------------------------------------------------------------------
129// Protocol Registration
130// -----------------------------------------------------------------------------
131
132/// Register HTTP and TCP protocol handlers with the Pingora server.
133fn 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
157/// Spawn the config file watcher if a config path is available.
158fn 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
177// -----------------------------------------------------------------------------
178// Admin
179// -----------------------------------------------------------------------------
180
181/// Register admin/health endpoints with the Pingora server.
182fn 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
199// -----------------------------------------------------------------------------
200// Runtime Limits
201// -----------------------------------------------------------------------------
202
203/// Initialize global connection and memory limits from runtime config.
204fn 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
218// -----------------------------------------------------------------------------
219// Insecure Options Warnings
220// -----------------------------------------------------------------------------
221
222/// Emit startup warnings for every active insecure option.
223fn 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
252/// Log a warning if an insecure option is active.
253fn insecure_warn(active: bool, msg: &str) {
254    if active {
255        tracing::warn!("insecure_options.{msg}");
256    }
257}
258
259// -----------------------------------------------------------------------------
260// Root Privilege Check
261// -----------------------------------------------------------------------------
262
263/// Refuse to start when running as root (UID 0) unless `allow_root` is set.
264///
265/// # Errors
266///
267/// Returns an error message when the effective UID is 0 and `allow_root` is `false`.
268///
269/// ```
270/// let msg = praxis::check_root_privilege(false, 0);
271/// assert!(msg.is_some());
272///
273/// let msg = praxis::check_root_privilege(true, 0);
274/// assert!(msg.is_none());
275///
276/// let msg = praxis::check_root_privilege(false, 1000);
277/// assert!(msg.is_none());
278/// ```
279pub 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/// Enforce the root privilege check on Unix, using the real effective UID.
300#[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/// No-op on non-Unix platforms.
309#[cfg(not(unix))]
310fn enforce_root_check(_config: &Config) {}
311
312// -----------------------------------------------------------------------------
313// TLS Key Permission Checks
314// -----------------------------------------------------------------------------
315
316/// Warn if any TLS private key file has group or world read/write permissions.
317///
318/// This check is intentionally advisory-only (warning, not error) because
319/// Kubernetes secret volume mounts often use permissions that would fail a
320/// strict check (e.g. `0644`). The warning gives operators visibility without
321/// blocking legitimate deployments.
322#[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/// No-op on non-Unix platforms.
354#[cfg(not(unix))]
355fn warn_insecure_key_permissions(_config: &Config) {}
356
357// -----------------------------------------------------------------------------
358// Health Check Tasks
359// -----------------------------------------------------------------------------
360
361/// Spawn background health check tasks on a dedicated tokio runtime.
362///
363/// The spawned thread listens for `ctrl_c` and cancels the
364/// [`CancellationToken`] so that every health check loop exits
365/// cleanly via `shutdown.cancelled()` before the thread returns.
366///
367/// Pingora's `server.run()` installs its own signal handlers and may
368/// terminate the process before this thread receives `ctrl_c`. This is
369/// acceptable: the OS reaps the thread on process exit, so the graceful
370/// shutdown path here is best-effort.
371///
372/// [`CancellationToken`]: tokio_util::sync::CancellationToken
373#[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, &registry, &shutdown);
393            shutdown.cancelled().await;
394        });
395    });
396}
397
398// -----------------------------------------------------------------------------
399// Utility Functions
400// -----------------------------------------------------------------------------
401
402/// Print a fatal error to stderr and exit the process.
403#[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// -----------------------------------------------------------------------------
414// Tests
415// -----------------------------------------------------------------------------
416
417#[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    // -----------------------------------------------------------------------
490    // insecure_warn
491    // -----------------------------------------------------------------------
492
493    #[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    // -----------------------------------------------------------------------
504    // init_runtime_limits
505    // -----------------------------------------------------------------------
506
507    #[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    // -----------------------------------------------------------------------
523    // warn_insecure_key_permissions (Unix)
524    // -----------------------------------------------------------------------
525
526    #[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    // -----------------------------------------------------------------------
566    // Test Utilities
567    // -----------------------------------------------------------------------
568
569    #[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}