bestool 2.5.5

BES Deployment tooling
//! HTTP server for alertd daemon control and metrics.

use std::{collections::HashMap, sync::Arc, time::Duration};

use axum::{
	Router,
	routing::{get, post},
};
use jiff::Timestamp;
use tower_http::trace::{DefaultMakeSpan, DefaultOnResponse, TraceLayer};
use tracing::{Level, error, info, warn};

use crate::alertd::{
	context::InternalContext,
	daemon::DaemonControl,
	tasks::{BackgroundTask, TaskEndpoint},
};

mod endpoints;
mod metrics_render;
mod state;
#[cfg(test)]
pub(crate) mod test_utils;
mod types;

pub use endpoints::*;
pub use state::ServerState;
pub use types::*;

#[expect(
	clippy::too_many_arguments,
	reason = "daemon wiring; each shared resource is threaded in explicitly"
)]
pub async fn start_server(
	internal_context: Arc<InternalContext>,
	addrs: Vec<std::net::SocketAddr>,
	watchdog_timeout: Option<Duration>,
	background_tasks: &[Arc<dyn BackgroundTask>],
	control: DaemonControl,
	backups: Option<Arc<crate::alertd::BackupRegistry>>,
	metrics: Option<crate::alertd::doctor::DoctorMetricsHandle>,
	certificates: Option<Arc<crate::alertd::certificates::CertificateState>>,
	binary_version: String,
) {
	let started_at = Timestamp::now();
	let pid = std::process::id();

	let task_endpoints = collect_task_endpoints(background_tasks);

	let state = ServerState {
		started_at,
		pid,
		binary_version,
		internal_context,
		watchdog_timeout,
		task_endpoints: Arc::new(task_endpoints),
		control,
		backups,
		metrics,
		certificates,
	};

	let app = Router::new()
		.route("/", get(handle_index))
		.route("/metrics", get(handle_metrics))
		.route("/status", get(handle_status))
		.route("/health", get(handle_health))
		.route("/seedling", get(handle_seedling))
		.route("/reload", post(handle_reload))
		.route("/restart", post(handle_restart))
		// Both methods reach the same dispatch, which refuses a GET to a guarded
		// endpoint: an endpoint that changes state must not be triggerable by a
		// cross-origin `<img src>`, which can only issue a GET.
		.route(
			"/tasks/{task}/{endpoint}",
			get(handle_task_endpoint).post(handle_task_endpoint),
		)
		// Caddy asks this during a TLS handshake, so it sits at the root rather
		// than under /tasks: the path goes in a Caddyfile an operator writes.
		.route(
			"/certificate",
			get(crate::alertd::certificates::delivery::handle_certificate),
		)
		.layer(
			TraceLayer::new_for_http()
				.make_span_with(
					DefaultMakeSpan::new()
						.level(Level::INFO)
						.include_headers(false),
				)
				.on_request(|request: &axum::http::Request<_>, _span: &tracing::Span| {
					info!(
						method = %request.method(),
						uri = %request.uri(),
						"HTTP request"
					);
				})
				.on_response(
					DefaultOnResponse::new()
						.level(Level::INFO)
						.include_headers(false),
				),
		)
		.with_state(Arc::new(state));

	// Use default if no addresses provided
	let addrs_to_try = if addrs.is_empty() {
		vec![
			"[::1]:8271".parse().unwrap(),
			"127.0.0.1:8271".parse().unwrap(),
		]
	} else {
		addrs
	};

	let mut listeners = Vec::new();
	let mut last_error = None;

	// Bind every address that will accept it, not just the first: on a host where
	// both loopback families are available the daemon must answer on both, so a
	// client fixed to one family can always reach it.
	for addr in &addrs_to_try {
		match tokio::net::TcpListener::bind(addr).await {
			Ok(l) => {
				info!("HTTP server listening on http://{}", addr);
				listeners.push(l);
			}
			Err(e) => {
				warn!("failed to bind HTTP server to {}: {}", addr, e);
				last_error = Some(e);
			}
		}
	}

	if listeners.is_empty() {
		if let Some(e) = last_error {
			warn!("failed to bind HTTP server to any address: {}", e);
		} else {
			warn!("no addresses provided for HTTP server");
		}
		warn!("waiting 10 seconds before continuing without");
		warn!("use --no-server to disable the HTTP server and this warning");

		tokio::time::sleep(tokio::time::Duration::from_secs(10)).await;

		info!("continuing without HTTP server");
		return;
	}

	// Serve every bound listener concurrently; each runs until the process ends.
	let mut servers = tokio::task::JoinSet::new();
	for listener in listeners {
		// With connect info, because the certificate endpoint hands out a
		// private key and has to identify its caller: the two ends of the
		// accepted connection are what name the caller's socket to the kernel.
		let app = app
			.clone()
			.into_make_service_with_connect_info::<crate::alertd::certificates::delivery::Endpoints>(
			);
		servers.spawn(async move {
			if let Err(e) = axum::serve(listener, app).await {
				error!("HTTP server error: {}", e);
			}
		});
	}
	while servers.join_next().await.is_some() {}
}

fn collect_task_endpoints(
	tasks: &[Arc<dyn BackgroundTask>],
) -> HashMap<(String, String), TaskEndpoint> {
	let mut map = HashMap::new();
	for task in tasks {
		let task_name = task.name();
		for endpoint in task.http_endpoints() {
			let key = (task_name.to_string(), endpoint.name.to_string());
			if map.contains_key(&key) {
				warn!(
					task = task_name,
					endpoint = endpoint.name,
					"duplicate task endpoint name; later registration wins"
				);
			}
			info!(
				task = task_name,
				endpoint = endpoint.name,
				path = %format!("/tasks/{task_name}/{}", endpoint.name),
				guarded = endpoint.guarded,
				"mounting task endpoint"
			);
			map.insert(key, endpoint);
		}
	}
	map
}