kumo_server_common/http_server/
mod.rs

1use crate::diagnostic_logging::set_diagnostic_log_filter;
2use crate::start::{MACHINE_INFO, ONLINE_SINCE};
3use anyhow::Context;
4use axum::extract::{DefaultBodyLimit, Json, Query, State};
5use axum::handler::Handler;
6use axum::http::StatusCode;
7use axum::response::{IntoResponse, Response};
8use axum::Router;
9use axum_server::tls_rustls::RustlsConfig;
10use axum_streams::{HttpHeaderValue, StreamBodyAsOptions};
11use cidr_map::CidrSet;
12use data_loader::KeySource;
13use kumo_server_memory::{get_usage_and_limit, tracking_stats, JemallocStats};
14use kumo_server_runtime::spawn;
15use serde::Deserialize;
16use std::net::{IpAddr, SocketAddr};
17use tower_http::compression::CompressionLayer;
18use tower_http::decompression::RequestDecompressionLayer;
19use tower_http::trace::TraceLayer;
20use utoipa::openapi::security::{Http, HttpAuthScheme, SecurityScheme};
21use utoipa::openapi::PathItem;
22use utoipa::OpenApi;
23use utoipa_rapidoc::RapiDoc;
24// Avoid referencing api types as crate::name in the utoipa macros,
25// otherwise it generates namespaced names in the openapi.json, which
26// in turn require annotating each and every struct with the namespace
27// in order for the document to be valid.
28use kumo_api_types::*;
29
30pub mod auth;
31
32use auth::*;
33
34struct OptionalAuth;
35
36impl utoipa::Modify for OptionalAuth {
37    fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) {
38        let components = openapi
39            .components
40            .as_mut()
41            .expect("always set because we always have components above");
42        // Define basic_auth as http basic auth
43        components.add_security_scheme(
44            "basic_auth",
45            SecurityScheme::Http(Http::new(HttpAuthScheme::Basic)),
46        );
47    }
48}
49
50#[derive(Deserialize, Clone, Debug)]
51#[serde(deny_unknown_fields)]
52pub struct HttpListenerParams {
53    #[serde(default = "HttpListenerParams::default_hostname")]
54    pub hostname: String,
55
56    #[serde(default = "HttpListenerParams::default_listen")]
57    pub listen: String,
58
59    /// Accept-queue depth requested of the kernel. Defaults to the kernel
60    /// maximum (`net.core.somaxconn` on Linux).
61    #[serde(default)]
62    pub listen_backlog: Option<u32>,
63
64    #[serde(default)]
65    pub use_tls: bool,
66
67    #[serde(default)]
68    pub request_body_limit: Option<usize>,
69
70    #[serde(default)]
71    pub tls_certificate: Option<KeySource>,
72    #[serde(default)]
73    pub tls_private_key: Option<KeySource>,
74
75    #[serde(default = "CidrSet::default_trusted_hosts")]
76    pub trusted_hosts: CidrSet,
77}
78
79/// Encapsulates both an axum router and a set of OpenApi docs.
80/// Use the router_with_docs! macro to create one of these.
81pub struct RouterAndDocs {
82    pub router: Router<AppState>,
83    pub docs: utoipa::openapi::OpenApi,
84}
85
86impl RouterAndDocs {
87    /// Helper to figure out what kind of handler method should be
88    /// used to wrap the provided handler.
89    /// It will examine the provided PathItem to figure out which
90    /// operation was defined and use that.
91    fn add_route<T, H: Handler<T, AppState>>(&mut self, path: &str, item: &PathItem, handler: H)
92    where
93        T: 'static,
94        H: 'static,
95    {
96        let router = std::mem::take(&mut self.router);
97        if item.get.is_some() {
98            self.router = router.route(path, axum::routing::get(handler));
99        } else if item.put.is_some() {
100            self.router = router.route(path, axum::routing::put(handler));
101        } else if item.post.is_some() {
102            self.router = router.route(path, axum::routing::post(handler));
103        } else if item.delete.is_some() {
104            self.router = router.route(path, axum::routing::delete(handler));
105        } else {
106            panic!("unhandled path operation");
107        }
108    }
109
110    /// Register a route based on the definition contained in the provided
111    /// OpenApi docs. This will extract the path and correct operation
112    /// type from the openapi docs and use that to register the handler,
113    /// allowing the path and operation for the handler to be centrally
114    /// defined in the `utoipa::path` macro annotation on the handler
115    /// itself.
116    ///
117    /// You will not call this directly: it will be called via the
118    /// `router_with_docs!` macro invocation.
119    ///
120    /// The OpenApi instance MUST have only a single path with
121    /// a single operation defined within it.  This is upheld
122    /// in the macro implementation.
123    pub fn register<T, H: Handler<T, AppState>>(
124        &mut self,
125        api: utoipa::openapi::OpenApi,
126        handler: H,
127    ) where
128        T: 'static,
129        H: 'static,
130    {
131        if let Some((path, item)) = api.paths.paths.iter().next() {
132            self.add_route(path, item, handler);
133        } else {
134            panic!("register didn't register any paths!");
135        }
136
137        self.docs.merge(api);
138    }
139
140    /// Create a new RouterAndDocs instance.
141    /// You should trigger this via the `router_with_docs!` macro
142    /// rather than using it directly.
143    pub fn new(title: &str) -> Self {
144        #[derive(OpenApi)]
145        #[openapi(
146                info(
147                    license(name="Apache-2.0"),
148                    version=version_info::kumo_version()
149                ),
150                // Indicate that all paths can accept http basic auth.
151                // the "basic_auth" name corresponds with the scheme
152                // defined by the OptionalAuth addon defined below
153                security(
154                    ("basic_auth" = [""])
155                ),
156                modifiers(&OptionalAuth),
157            )]
158        struct ApiDoc;
159        let mut router = Self {
160            docs: ApiDoc::openapi(),
161            router: Router::new(),
162        };
163
164        router.docs.info.title = title.to_string();
165
166        router.register_common_handlers();
167        router
168    }
169
170    /// Register the default/common handlers.
171    fn register_common_handlers(&mut self) {
172        macro_rules! add_handlers {
173            ($($handler:path $(,)?)*) => {
174                $(
175                {
176                    // See the `router_with_docs!` definition below
177                    // for an explanation of this `O` struct.
178                    #[derive(OpenApi)]
179                    #[openapi(paths($handler))]
180                    struct O;
181
182                    self.register(O::openapi(), $handler);
183                }
184                )*
185            }
186        }
187
188        add_handlers!(
189            bump_config_epoch,
190            memory_stats,
191            task_dump,
192            report_metrics,
193            report_metrics_json,
194            set_diagnostic_log_filter_v1,
195            machine_info,
196        );
197    }
198}
199
200/// Create a RouterAndDocs instance and register each
201/// of the handlers into it.
202///
203/// Each handler must be an axum compatible handler
204/// that has a `utoipa::path` annotation to define
205/// its method and path.
206#[macro_export]
207macro_rules! router_with_docs {
208    (title=$title:literal, handlers=[
209     $($handler:path $(,)?  )*
210    ]
211    $(, layers=[
212        $(
213            $layer:expr $(,)?
214        )*
215    ])?
216
217    ) => {
218        {
219            // Allow adding deprecated routes without
220            // triggering a warning; the deprecation
221            // status flows through to the docs
222            #![allow(deprecated)]
223
224            let mut router = RouterAndDocs::new($title);
225
226            $(
227                // Utoipa's path macro defines an auxilliary
228                // path configuration struct at `__path_{ident}`.
229                // We can't "know" that here as it is a funky
230                // implementation detail, and rust provides no
231                // way for us to magic up a reference to that
232                // identifier.
233                // Instead, we leverage the proc macro that
234                // derives an OpenApi impl; its `paths` parameter
235                // knows how to find the path configuration given
236                // the bare/normal handler reference.
237                // Armed with that OpenApi impl, we can then
238                // merge it into our router via our register
239                // method.
240                {
241                    #[derive(OpenApi)]
242                    #[openapi(paths($handler))]
243                    struct O;
244
245                    router.register(O::openapi(), $handler);
246                }
247            )*
248
249            $(
250                $(
251                    router.router = router.router.layer($layer);
252                )*
253            )?
254
255            router
256        }
257    }
258}
259
260#[derive(Clone)]
261pub struct AppState {
262    process_kind: String,
263    params: HttpListenerParams,
264    local_addr: SocketAddr,
265}
266
267impl AppState {
268    pub fn is_trusted_host(&self, addr: IpAddr) -> bool {
269        self.params.trusted_hosts.contains(addr)
270    }
271
272    pub fn params(&self) -> &HttpListenerParams {
273        &self.params
274    }
275
276    pub fn local_addr(&self) -> &SocketAddr {
277        &self.local_addr
278    }
279}
280
281impl HttpListenerParams {
282    fn default_listen() -> String {
283        "127.0.0.1:8000".to_string()
284    }
285
286    fn default_hostname() -> String {
287        gethostname::gethostname()
288            .to_str()
289            .unwrap_or("localhost")
290            .to_string()
291    }
292
293    // Note: it is possible to call
294    // server.with_graceful_shutdown(ShutdownSubscription::get().shutting_down)
295    // to have it listen for a shutdown request, but we're avoiding it:
296    // the request is the start of a shutdown and we need to allow a grace
297    // period for in-flight operations to complete.
298    // Some of those may require call backs to the HTTP endpoint
299    // if we're doing some kind of web hook like thing.
300    // So, for now at least, we'll have to manually verify if
301    // a request should proceed based on the results from the lifecycle
302    // module.
303    pub async fn start(
304        self,
305        router_and_docs: RouterAndDocs,
306        runtime: Option<tokio::runtime::Handle>,
307    ) -> anyhow::Result<()> {
308        let compression_layer: CompressionLayer = CompressionLayer::new()
309            .deflate(true)
310            .gzip(true)
311            .quality(tower_http::CompressionLevel::Fastest);
312        let decompression_layer = RequestDecompressionLayer::new().deflate(true).gzip(true);
313
314        let socket = crate::listener::bind_with_backlog(&self.listen, self.listen_backlog)
315            .with_context(|| format!("listen on {}", self.listen))?;
316        let addr = socket.local_addr()?;
317
318        let app_state = AppState {
319            process_kind: router_and_docs.docs.info.title.clone(),
320            params: self.clone(),
321            local_addr: addr,
322        };
323
324        let app = router_and_docs
325            .router
326            .layer(DefaultBodyLimit::max(
327                self.request_body_limit.unwrap_or(2 * 1024 * 1024),
328            ))
329            .merge(
330                RapiDoc::with_openapi("/api-docs/openapi.json", router_and_docs.docs)
331                    .path("/rapidoc"),
332            )
333            // Require that all requests be authenticated as either coming
334            // from a trusted IP address, or with an authorization header
335            .route_layer(axum::middleware::from_fn_with_state(
336                app_state.clone(),
337                auth_middleware,
338            ))
339            .layer(compression_layer)
340            .layer(decompression_layer)
341            .layer(TraceLayer::new_for_http())
342            .layer(axum_client_ip::ClientIpSource::ConnectInfo.into_extension())
343            .with_state(app_state);
344
345        let make_service = app.into_make_service_with_connect_info::<SocketAddr>();
346
347        // The logic below is a bit repeatey, but it is still fewer
348        // lines of magic than it would be to factor out into a
349        // generic function because of all of the trait bounds
350        // that it would require.
351        if self.use_tls {
352            let config = self.tls_config().await?;
353            tracing::info!("https listener on {addr:?}");
354            let server = axum_server::from_tcp_rustls(socket, config);
355            let serve = async move { server.serve(make_service).await };
356
357            if let Some(runtime) = runtime {
358                runtime.spawn(serve);
359            } else {
360                spawn(format!("https {addr:?}"), serve)?;
361            }
362        } else {
363            tracing::info!("http listener on {addr:?}");
364            let server = axum_server::from_tcp(socket);
365            let serve = async move { server.serve(make_service).await };
366            if let Some(runtime) = runtime {
367                runtime.spawn(serve);
368            } else {
369                spawn(format!("http {addr:?}"), serve)?;
370            }
371        }
372        Ok(())
373    }
374
375    async fn tls_config(&self) -> anyhow::Result<RustlsConfig> {
376        let config = crate::tls_helpers::make_server_config(
377            &self.hostname,
378            &self.tls_private_key,
379            &self.tls_certificate,
380            &None,
381        )
382        .await?;
383        Ok(RustlsConfig::from_config(config))
384    }
385}
386
387#[derive(Debug)]
388pub struct AppError {
389    pub err: anyhow::Error,
390    pub code: StatusCode,
391}
392
393impl AppError {
394    pub fn new(code: StatusCode, err: impl Into<String>) -> Self {
395        let err: String = err.into();
396        Self {
397            err: anyhow::anyhow!(err),
398            code,
399        }
400    }
401}
402
403// Tell axum how to convert `AppError` into a response.
404impl IntoResponse for AppError {
405    fn into_response(self) -> Response {
406        (self.code, format!("Error: {:#}", self.err)).into_response()
407    }
408}
409
410// This enables using `?` on functions that return `Result<_, anyhow::Error>` to turn them into
411// `Result<_, AppError>`. That way you don't need to do that manually.
412impl<E> From<E> for AppError
413where
414    E: Into<anyhow::Error>,
415{
416    fn from(err: E) -> Self {
417        Self {
418            err: err.into(),
419            code: StatusCode::INTERNAL_SERVER_ERROR,
420        }
421    }
422}
423
424/// Returns information identifying this instance
425#[utoipa::path(get, tag = "debugging", path = "/api/machine-info",
426    responses(
427        (status=200, description="Machine information", body=MachineInfoV1)
428    ),
429)]
430async fn machine_info(State(state): State<AppState>) -> Result<Json<MachineInfoV1>, AppError> {
431    let online_since = *ONLINE_SINCE;
432    match MACHINE_INFO.lock().as_ref() {
433        Some(info) => Ok(Json(MachineInfoV1 {
434            hostname: info.hostname.clone(),
435            mac_address: info.mac_address.clone(),
436            node_id: info.node_id.clone().unwrap_or_else(String::new),
437            num_cores: info.num_cores,
438            kernel_version: info.kernel_version.clone(),
439            platform: info.platform.clone(),
440            distribution: info.distribution.clone(),
441            os_version: info.os_version.clone(),
442            total_memory_bytes: info.total_memory_bytes,
443            container_runtime: info.container_runtime.clone(),
444            cpu_brand: info.cpu_brand.clone(),
445            fingerprint: info.fingerprint(),
446            online_since,
447            process_kind: state.process_kind.clone(),
448            version: version_info::kumo_version().to_string(),
449        })),
450        None => {
451            return Err(AppError::new(
452                StatusCode::SERVICE_UNAVAILABLE,
453                "machine info not yet available",
454            ));
455        }
456    }
457}
458
459/// Allows the system operator to trigger a configuration epoch bump,
460/// which causes various configs that are using the Epoch strategy to
461/// be re-evaluated by triggering the appropriate callbacks.
462#[utoipa::path(
463    post,
464    tag="config",
465    path="/api/admin/bump-config-epoch",
466    responses(
467        (status=200, description = "bump successful")
468    ),
469)]
470async fn bump_config_epoch() -> Result<(), AppError> {
471    config::epoch::bump_current_epoch();
472    Ok(())
473}
474
475#[derive(Deserialize)]
476struct TaskDumpParams {
477    #[serde(default)]
478    timeout: Option<u64>,
479}
480
481/// Returns a dump of the runtime task state.
482///
483/// {{since('2026.04.09-ea3b2a9b')}}
484///
485/// The output is not machine parseable and may change without notice
486/// between versions of kumomta.
487///
488/// Capturing the dump is very expensive and can take several seconds.
489/// Capturing a dump is not guaranteed to succeed.
490///
491/// At the time of writing, capturing a dump can cause a subsequent
492/// graceful shutdown to get stuck, unless you repeatedly trigger
493/// this API endpoint a few more times to "clock through" the shutdown
494/// process and enable it to complete successfully.
495#[utoipa::path(
496    get,
497    tag="debugging",
498    path="/api/admin/task-dump",
499    responses(
500        (status=200, description="data was returned")
501    ),
502)]
503async fn task_dump(Query(params): Query<TaskDumpParams>) -> String {
504    kumo_server_runtime::dump_all_runtimes(tokio::time::Duration::from_secs(
505        params.timeout.unwrap_or(5),
506    ))
507    .await
508}
509
510/// Returns information about the system memory usage in an unstructured
511/// human readable format.  The output is not machine parseable and may
512/// change without notice between versions of kumomta.
513#[utoipa::path(
514    get,
515    tag="memory",
516    path="/api/admin/memory/stats",
517    responses(
518        (status=200, description = "stats were returned")
519    ),
520)]
521async fn memory_stats() -> String {
522    use kumo_server_memory::NumBytes;
523    use std::fmt::Write;
524    let mut result = String::new();
525
526    let jstats = JemallocStats::collect();
527    writeln!(result, "{jstats:#?}").ok();
528
529    if let Ok((usage, limit)) = get_usage_and_limit() {
530        writeln!(result, "RSS = {:?}", NumBytes::from(usage.bytes)).ok();
531        writeln!(
532            result,
533            "soft limit = {:?}",
534            limit.soft_limit.map(NumBytes::from)
535        )
536        .ok();
537        writeln!(
538            result,
539            "hard limit = {:?}",
540            limit.hard_limit.map(NumBytes::from)
541        )
542        .ok();
543    }
544
545    let mut stats = tracking_stats();
546    writeln!(result, "live = {:?}", stats.live).ok();
547
548    if stats.top_callstacks.is_empty() {
549        write!(
550            result,
551            "\nuse kumo.enable_memory_callstack_tracking(true) to enable additional stats\n"
552        )
553        .ok();
554    } else {
555        writeln!(result, "small_threshold = {:?}", stats.small_threshold).ok();
556        write!(result, "\ntop call stacks:\n").ok();
557        for stack in &mut stats.top_callstacks {
558            writeln!(
559                result,
560                "sampled every {} allocations, estimated {} allocations of {} total bytes",
561                stack.stochastic_rate,
562                stack.count * stack.stochastic_rate,
563                stack.total_size * stack.stochastic_rate
564            )
565            .ok();
566            write!(result, "{:?}\n\n", stack.bt).ok();
567        }
568    }
569
570    result
571}
572
573#[derive(Deserialize)]
574struct PrometheusMetricsParams {
575    #[serde(default)]
576    prefix: Option<String>,
577}
578
579/// Returns the current set of metrics in
580/// [Prometheus Text Exposition Format](https://prometheus.io/docs/instrumenting/exposition_formats/).
581///
582/// !!! note
583///     Metrics generally represent data at the current point in time,
584///     to be consumed by an external system (such as Prometheus) which
585///     then in turn can build time series data around those metrics.
586///
587///     In addition, in order to avoid unbounded RAM usage for systems
588///     with many queues, a number of queue- or service-specific metrics
589///     will be automatically pruned away when the corresponding queue
590///     idles out for a period of time.
591///
592/// In the default configuration, access to this endpoint requires *Trusted IP*
593/// authentication.  See the [Authorization](../../access_control.md) documentation
594/// for more information on adjusting ACLs.
595///
596/// See also [metrics.json](metrics.json_get.md).
597///
598/// ## Metric Documentation
599///
600/// * [Metrics exported by kumod](../../metrics/kumod/index.md)
601///
602/// ## Example Data
603///
604/// Here's an example of the shape of the data.  The precise set of
605/// counters will vary as we continue to enhance KumoMTA.
606///
607/// You can see the current list by querying the endpoint with no arguments:
608///
609/// ```console
610/// $ curl http://localhost:8000/metrics
611/// ```
612///
613/// ```txt
614/// {% include "reference/http/sample-metrics.txt" %}
615/// ```
616#[utoipa::path(get, path = "/metrics", responses(
617        (status = 200, content_type="text/plain")
618))]
619async fn report_metrics(Query(params): Query<PrometheusMetricsParams>) -> impl IntoResponse {
620    StreamBodyAsOptions::new()
621        .content_type(HttpHeaderValue::from_static("text/plain; charset=utf-8"))
622        .text(kumo_prometheus::registry::Registry::stream_text(
623            params.prefix.clone(),
624        ))
625}
626
627/// Returns the current set of metrics in a json representation.
628/// This is easier to consume than the Prometheus Exposition format, but
629/// is more resource intensive to produce and parse when the number of
630/// metrics is large, such as for a busy server.
631///
632/// !!! note
633///     Metrics generally represent data at the current point in time,
634///     to be consumed by an external system (such as Prometheus) which
635///     then in turn can build time series data around those metrics.
636///
637///     In addition, in order to avoid unbounded RAM usage for systems
638///     with many queues, a number of queue- or service-specific metrics
639///     will be automatically pruned away when the corresponding queue
640///     idles out for a period of time.
641///
642/// In the default configuration, access to this endpoint requires *Trusted IP*
643/// authentication.  See the [Authorization](../../access_control.md) documentation
644/// for more information on adjusting ACLs.
645///
646/// See also [metrics](metrics_get.md).
647///
648/// ## Metric Documentation
649///
650/// * [Metrics exported by kumod](../../metrics/kumod/index.md)
651///
652/// ## Example Data
653///
654/// Here's an example of the shape of the data.  The precise set of
655/// counters will vary as we continue to enhance KumoMTA:
656///
657/// ```json
658/// {% include "reference/http/sample-metrics.json" %}
659/// ```
660#[utoipa::path(get, path = "/metrics.json", responses(
661    (status = 200, content_type="application/json")
662))]
663async fn report_metrics_json() -> impl IntoResponse {
664    StreamBodyAsOptions::new()
665        .content_type(HttpHeaderValue::from_static(
666            "application/json; charset=utf-8",
667        ))
668        .text(kumo_prometheus::registry::Registry::stream_json())
669}
670
671/// Changes the diagnostic log filter dynamically.
672/// See <https://docs.kumomta.com/reference/kumo/set_diagnostic_log_filter/>
673/// for more information on diagnostic log filters.
674#[utoipa::path(
675    post,
676    tags=["logging", "kcli:set-log-filter"],
677    path="/api/admin/set_diagnostic_log_filter/v1",
678    request_body=SetDiagnosticFilterRequest,
679    responses(
680        (status = 200, description = "Diagnostic level set successfully")
681    ),
682)]
683async fn set_diagnostic_log_filter_v1(
684    // Note: Json<> must be last in the param list
685    Json(request): Json<SetDiagnosticFilterRequest>,
686) -> Result<(), AppError> {
687    set_diagnostic_log_filter(&request.filter)?;
688    Ok(())
689}