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;
24use 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 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 #[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
79pub struct RouterAndDocs {
82 pub router: Router<AppState>,
83 pub docs: utoipa::openapi::OpenApi,
84}
85
86impl RouterAndDocs {
87 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 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 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 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 fn register_common_handlers(&mut self) {
172 macro_rules! add_handlers {
173 ($($handler:path $(,)?)*) => {
174 $(
175 {
176 #[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#[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(deprecated)]
223
224 let mut router = RouterAndDocs::new($title);
225
226 $(
227 {
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 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 .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 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
403impl IntoResponse for AppError {
405 fn into_response(self) -> Response {
406 (self.code, format!("Error: {:#}", self.err)).into_response()
407 }
408}
409
410impl<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#[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#[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#[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#[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#[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#[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#[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 Json(request): Json<SetDiagnosticFilterRequest>,
686) -> Result<(), AppError> {
687 set_diagnostic_log_filter(&request.filter)?;
688 Ok(())
689}