use arc_swap::ArcSwap;
use axum::Router;
use std::sync::Arc;
use subtle::ConstantTimeEq;
use tokio_util::sync::CancellationToken;
use tracing_subscriber::EnvFilter;
use vl_convert_rs::anyhow;
use vl_convert_rs::converter::{VlConverter, VlcConfig};
use crate::budget::BudgetTracker;
use crate::listen::ListenAddr;
use crate::reconfig::ReconfigCoordinator;
use crate::{health, json_fmt};
#[derive(Debug, Clone, Copy, clap::ValueEnum, Default, PartialEq, Eq)]
pub enum LogFormat {
#[default]
Text,
Json,
}
pub fn init_tracing(filter: &str, format: LogFormat) {
let filter: EnvFilter = filter.parse().expect("valid tracing filter directives");
match format {
LogFormat::Json => {
tracing_subscriber::fmt()
.event_format(json_fmt::FlatJsonFormatter)
.fmt_fields(tracing_subscriber::fmt::format::JsonFields::new())
.with_env_filter(filter)
.with_writer(std::io::stderr)
.init();
}
LogFormat::Text => {
tracing_subscriber::fmt()
.with_env_filter(filter)
.with_target(true)
.with_writer(std::io::stderr)
.init();
}
}
}
#[derive(Debug, Clone)]
pub struct ServeConfig {
pub main: ListenAddr,
pub admin: Option<ListenAddr>,
pub api_key: Option<String>,
pub admin_api_key: Option<String>,
pub cors_origin: Option<String>,
pub max_concurrent_requests: Option<usize>,
pub request_timeout_secs: u64,
pub max_body_size_mb: usize,
pub opaque_errors: bool,
pub require_user_agent: bool,
pub log_format: LogFormat,
pub per_ip_budget_ms: Option<i64>,
pub global_budget_ms: Option<i64>,
pub budget_hold_ms: i64,
pub google_font_cache_miss_penalty_ms: i64,
pub trust_proxy: bool,
pub socket_mode: u32,
pub reconfig_drain_timeout_secs: u64,
}
impl Default for ServeConfig {
fn default() -> Self {
Self {
main: ListenAddr::Tcp {
host: "127.0.0.1".to_string(),
port: 3000,
},
admin: None,
api_key: None,
admin_api_key: None,
cors_origin: None,
max_concurrent_requests: None,
request_timeout_secs: 30,
max_body_size_mb: 50,
opaque_errors: false,
require_user_agent: false,
log_format: LogFormat::Text,
per_ip_budget_ms: None,
global_budget_ms: None,
budget_hold_ms: 1000,
google_font_cache_miss_penalty_ms: 0,
trust_proxy: false,
socket_mode: 0o600,
reconfig_drain_timeout_secs: 30,
}
}
}
pub(crate) struct RuntimeSnapshot {
pub converter: VlConverter,
pub config: Arc<VlcConfig>,
pub generation: u64,
}
pub(crate) struct AppState {
pub runtime: Arc<ArcSwap<RuntimeSnapshot>>,
pub api_key: Option<ApiKey>,
pub opaque_errors: bool,
pub require_user_agent: bool,
pub readiness: Arc<health::ReadinessState>,
pub local_tz: Option<String>,
pub coordinator: Arc<ReconfigCoordinator>,
}
pub(crate) struct ApiKey(String);
impl ApiKey {
pub fn new(key: String) -> Self {
Self(key)
}
pub fn matches(&self, other: &[u8]) -> bool {
let key_bytes = self.0.as_bytes();
key_bytes.ct_eq(other).into()
}
}
pub struct BuiltApp {
pub(crate) router: Router,
pub(crate) runtime: Arc<ArcSwap<RuntimeSnapshot>>,
pub(crate) shutdown_token: CancellationToken,
pub(crate) tracker: Option<Arc<BudgetTracker>>,
pub(crate) admin: Option<AdminConfig>,
}
impl BuiltApp {
pub fn admin_endpoint(&self) -> Option<&str> {
self.admin.as_ref().map(|a| a.addr.as_str())
}
pub fn admin_endpoint_info(&self) -> Option<crate::EndpointInfo> {
self.admin.as_ref().map(|a| a.listener.endpoint_info())
}
pub fn current_converter(&self) -> VlConverter {
self.runtime.load_full().converter.clone()
}
pub fn current_config(&self) -> Arc<VlcConfig> {
self.runtime.load_full().config.clone()
}
}
pub(crate) struct AdminConfig {
pub listener: crate::listener::BoundListener,
pub addr: String,
pub router: Router,
}
pub(crate) fn validate_serve_config(serve_config: &ServeConfig) -> Result<(), anyhow::Error> {
if serve_config.budget_hold_ms <= 0 {
anyhow::bail!("budget_hold_ms must be positive");
}
if serve_config.google_font_cache_miss_penalty_ms < 0 {
anyhow::bail!("google_font_cache_miss_penalty_ms must be non-negative");
}
if serve_config
.api_key
.as_deref()
.is_some_and(|key| key.trim().is_empty())
{
anyhow::bail!("api_key must not be empty or whitespace-only");
}
if serve_config
.admin_api_key
.as_deref()
.is_some_and(|key| key.trim().is_empty())
{
anyhow::bail!("admin_api_key must not be empty or whitespace-only");
}
if let Some(admin_addr) = &serve_config.admin {
if serve_config.admin_api_key.is_none() && !admin_addr.is_loopback_or_uds() {
anyhow::bail!(
"admin listener bound to non-loopback address {admin_addr} requires \
a non-empty admin_api_key; either set admin_api_key or use a \
loopback / UDS bind"
);
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validate_serve_config_rejects_negative_google_font_penalty() {
let serve_config = ServeConfig {
google_font_cache_miss_penalty_ms: -1,
..ServeConfig::default()
};
let err = validate_serve_config(&serve_config).unwrap_err();
assert!(
err.to_string()
.contains("google_font_cache_miss_penalty_ms must be non-negative"),
"unexpected error: {err}"
);
}
}