use std::env; use std::net::SocketAddr; use std::time::Duration; use anyhow::{Result, anyhow}; use chrono_tz::Tz; use serde::{Deserialize, Serialize}; use tracing::{info, warn}; use crate::rtr::payload::Timing; use crate::rtr::server::RtrServiceConfig; use crate::rtr::server::ssh::SshAuthMode; #[derive(Debug, Clone)] pub struct AppConfig { pub enable_tls: bool, pub enable_ssh: bool, pub tcp_addr: SocketAddr, pub tls_addr: SocketAddr, pub ssh_addr: SocketAddr, pub db_path: String, pub ccr_dir: String, pub slurm_dir: Option, pub tls_cert_path: String, pub tls_key_path: String, pub tls_client_ca_path: String, pub ssh_host_key_path: String, pub ssh_authorized_keys_path: String, pub ssh_username: String, pub ssh_subsystem_name: String, pub ssh_auth_mode: SshAuthMode, pub ssh_password: Option, pub max_delta: u8, pub prune_delta_by_snapshot_size: bool, pub source_refresh_interval: Duration, pub report_dir: String, pub runtime_report_interval: Duration, pub report_history_limit: usize, pub timezone: Tz, pub timing: Timing, pub admin_addr: Option, pub admin_token: Option, pub service_config: RtrServiceConfig, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct RuntimeConfig { pub max_delta: u8, pub prune_delta_by_snapshot_size: bool, pub source_refresh_interval_seconds: u64, pub runtime_report_interval_seconds: u64, pub report_history_limit: usize, pub timezone: String, pub timing: RuntimeTimingConfig, } #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] pub struct RuntimeTimingConfig { pub refresh: u32, pub retry: u32, pub expire: u32, } #[derive(Debug, Clone, Default, Deserialize)] pub struct RuntimeConfigPatch { pub max_delta: Option, pub prune_delta_by_snapshot_size: Option, pub source_refresh_interval_seconds: Option, pub runtime_report_interval_seconds: Option, pub report_history_limit: Option, pub timezone: Option, pub timing: Option, } #[derive(Debug, Clone, Copy, Default, Deserialize)] pub struct RuntimeTimingConfigPatch { pub refresh: Option, pub retry: Option, pub expire: Option, } impl Default for AppConfig { fn default() -> Self { Self { enable_tls: false, enable_ssh: false, tcp_addr: "0.0.0.0:323".parse().expect("invalid default tcp_addr"), tls_addr: "0.0.0.0:324".parse().expect("invalid default tls_addr"), ssh_addr: "0.0.0.0:22".parse().expect("invalid default ssh_addr"), db_path: "./rtr-db".to_string(), ccr_dir: "./data".to_string(), slurm_dir: None, tls_cert_path: "./certs/tls/server-dns.crt".to_string(), tls_key_path: "./certs/tls/server-dns.key".to_string(), tls_client_ca_path: "./certs/tls/client-ca.crt".to_string(), ssh_host_key_path: "./certs/ssh/ssh_host_rsa_key".to_string(), ssh_authorized_keys_path: "./certs/ssh/rtr-authorized_keys".to_string(), ssh_username: "rpki-rtr".to_string(), ssh_subsystem_name: "rpki-rtr".to_string(), ssh_auth_mode: SshAuthMode::Key, ssh_password: None, max_delta: 100, prune_delta_by_snapshot_size: false, source_refresh_interval: Duration::from_secs(300), report_dir: "./report".to_string(), runtime_report_interval: Duration::from_secs(300), report_history_limit: 10, timezone: default_timezone(), timing: Timing::default(), admin_addr: None, admin_token: None, service_config: RtrServiceConfig { max_connections: 512, max_concurrent_handshakes: 128, notify_queue_size: 1024, tcp_keepalive: Some(Duration::from_secs(60)), warn_insecure_tcp: true, require_tls_server_dns_name_san: false, enforce_tls_client_san_ip_match: true, }, } } } impl AppConfig { pub fn from_env() -> Result { let mut config = Self::default(); if let Some(value) = env_var("RPKI_RTR_ENABLE_TLS")? { config.enable_tls = parse_bool(&value, "RPKI_RTR_ENABLE_TLS")?; } if let Some(value) = env_var("RPKI_RTR_ENABLE_SSH")? { config.enable_ssh = parse_bool(&value, "RPKI_RTR_ENABLE_SSH")?; } if let Some(value) = env_var("RPKI_RTR_TCP_ADDR")? { config.tcp_addr = value .parse() .map_err(|err| anyhow!("invalid RPKI_RTR_TCP_ADDR '{}': {}", value, err))?; } if let Some(value) = env_var("RPKI_RTR_TLS_ADDR")? { config.tls_addr = value .parse() .map_err(|err| anyhow!("invalid RPKI_RTR_TLS_ADDR '{}': {}", value, err))?; } if let Some(value) = env_var("RPKI_RTR_SSH_ADDR")? { config.ssh_addr = value .parse() .map_err(|err| anyhow!("invalid RPKI_RTR_SSH_ADDR '{}': {}", value, err))?; } if let Some(value) = env_var("RPKI_RTR_SSH_PORT")? { let port: u16 = value .parse() .map_err(|err| anyhow!("invalid RPKI_RTR_SSH_PORT '{}': {}", value, err))?; config.ssh_addr.set_port(port); } if let Some(value) = env_var("RPKI_RTR_DB_PATH")? { config.db_path = value; } if let Some(value) = env_var("RPKI_RTR_CCR_DIR")? { config.ccr_dir = value; } if let Some(value) = env_var("RPKI_RTR_SLURM_DIR")? { let value = value.trim(); config.slurm_dir = if value.is_empty() { None } else { Some(value.to_string()) }; } if let Some(value) = env_var("RPKI_RTR_TLS_CERT_PATH")? { config.tls_cert_path = value; } if let Some(value) = env_var("RPKI_RTR_TLS_KEY_PATH")? { config.tls_key_path = value; } if let Some(value) = env_var("RPKI_RTR_TLS_CLIENT_CA_PATH")? { config.tls_client_ca_path = value; } if let Some(value) = env_var("RPKI_RTR_SSH_HOST_KEY_PATH")? { config.ssh_host_key_path = value; } if let Some(value) = env_var("RPKI_RTR_SSH_AUTHORIZED_KEYS_PATH")? { config.ssh_authorized_keys_path = value; } if let Some(value) = env_var("RPKI_RTR_SSH_USERNAME")? { config.ssh_username = value; } if let Some(value) = env_var("RPKI_RTR_SSH_SUBSYSTEM_NAME")? { config.ssh_subsystem_name = value; } if let Some(value) = env_var("RPKI_RTR_SSH_AUTH_MODE")? { config.ssh_auth_mode = SshAuthMode::parse(&value).ok_or_else(|| { anyhow!( "invalid RPKI_RTR_SSH_AUTH_MODE '{}': expected key|password|both", value ) })?; } if let Some(value) = env_var("RPKI_RTR_SSH_PASSWORD")? { let value = value.trim().to_string(); config.ssh_password = if value.is_empty() { None } else { Some(value) }; } if let Some(value) = env_var("RPKI_RTR_MAX_DELTA")? { let parsed: u8 = value .parse() .map_err(|err| anyhow!("invalid RPKI_RTR_MAX_DELTA '{}': {}", value, err))?; if parsed == 0 { return Err(anyhow!( "invalid RPKI_RTR_MAX_DELTA '{}': must be >= 1", value )); } config.max_delta = parsed; } if let Some(value) = env_var("RPKI_RTR_PRUNE_DELTA_BY_SNAPSHOT_SIZE")? { config.prune_delta_by_snapshot_size = parse_bool(&value, "RPKI_RTR_PRUNE_DELTA_BY_SNAPSHOT_SIZE")?; } if let Some(value) = env_var("RPKI_RTR_REPORT_DIR")? { config.report_dir = value; } if let Some(value) = env_var("RPKI_RTR_RUNTIME_REPORT_INTERVAL_SECS")? { let secs = parse_positive_u64(&value, "RPKI_RTR_RUNTIME_REPORT_INTERVAL_SECS")?; config.runtime_report_interval = Duration::from_secs(secs); } if let Some(value) = env_var("RPKI_RTR_REPORT_HISTORY_LIMIT")? { config.report_history_limit = parse_positive_usize(&value, "RPKI_RTR_REPORT_HISTORY_LIMIT")?; } if let Some(value) = env_var("RPKI_RTR_TIMEZONE")? { config.timezone = parse_timezone(&value, "RPKI_RTR_TIMEZONE")?; } if let Some(value) = env_var("RPKI_RTR_ADMIN_ADDR")? { let value = value.trim(); if !value.is_empty() { config.admin_addr = Some(value.parse().map_err(|err| { anyhow!("invalid RPKI_RTR_ADMIN_ADDR '{}': {}", value, err) })?); } } if let Some(value) = env_var("RPKI_RTR_ADMIN_TOKEN")? { let value = value.trim().to_string(); config.admin_token = if value.is_empty() { None } else { Some(value) }; } let source_refresh_interval_new = env_var("RPKI_RTR_SOURCE_REFRESH_INTERVAL_SECS")?; let source_refresh_interval_legacy = env_var("RPKI_RTR_REFRESH_INTERVAL_SECS")?; match ( source_refresh_interval_new.as_deref(), source_refresh_interval_legacy.as_deref(), ) { (Some(new_value), Some(_)) => { let secs = parse_positive_u64(new_value, "RPKI_RTR_SOURCE_REFRESH_INTERVAL_SECS")?; config.source_refresh_interval = Duration::from_secs(secs); warn!( "both RPKI_RTR_SOURCE_REFRESH_INTERVAL_SECS and legacy RPKI_RTR_REFRESH_INTERVAL_SECS are set; using RPKI_RTR_SOURCE_REFRESH_INTERVAL_SECS" ); } (Some(new_value), None) => { let secs = parse_positive_u64(new_value, "RPKI_RTR_SOURCE_REFRESH_INTERVAL_SECS")?; config.source_refresh_interval = Duration::from_secs(secs); } (None, Some(legacy_value)) => { let secs = parse_positive_u64(legacy_value, "RPKI_RTR_REFRESH_INTERVAL_SECS")?; config.source_refresh_interval = Duration::from_secs(secs); warn!( "RPKI_RTR_REFRESH_INTERVAL_SECS is deprecated; use RPKI_RTR_SOURCE_REFRESH_INTERVAL_SECS" ); } (None, None) => {} } if let Some(value) = env_var("RPKI_RTR_TIMING_REFRESH_SECS")? { config.timing.refresh = parse_positive_u32(&value, "RPKI_RTR_TIMING_REFRESH_SECS")?; } if let Some(value) = env_var("RPKI_RTR_TIMING_RETRY_SECS")? { config.timing.retry = parse_positive_u32(&value, "RPKI_RTR_TIMING_RETRY_SECS")?; } if let Some(value) = env_var("RPKI_RTR_TIMING_EXPIRE_SECS")? { config.timing.expire = parse_positive_u32(&value, "RPKI_RTR_TIMING_EXPIRE_SECS")?; } config .timing .validate() .map_err(|err| anyhow!("invalid RTR timing configuration: {}", err))?; if let Some(value) = env_var("RPKI_RTR_MAX_CONNECTIONS")? { config.service_config.max_connections = value .parse() .map_err(|err| anyhow!("invalid RPKI_RTR_MAX_CONNECTIONS '{}': {}", value, err))?; } if let Some(value) = env_var("RPKI_RTR_MAX_CONCURRENT_HANDSHAKES")? { config.service_config.max_concurrent_handshakes = value.parse().map_err(|err| { anyhow!( "invalid RPKI_RTR_MAX_CONCURRENT_HANDSHAKES '{}': {}", value, err ) })?; } if let Some(value) = env_var("RPKI_RTR_NOTIFY_QUEUE_SIZE")? { config.service_config.notify_queue_size = value.parse().map_err(|err| { anyhow!("invalid RPKI_RTR_NOTIFY_QUEUE_SIZE '{}': {}", value, err) })?; } if let Some(value) = env_var("RPKI_RTR_TCP_KEEPALIVE_SECS")? { let secs: u64 = value.parse().map_err(|err| { anyhow!("invalid RPKI_RTR_TCP_KEEPALIVE_SECS '{}': {}", value, err) })?; config.service_config.tcp_keepalive = if secs == 0 { None } else { Some(Duration::from_secs(secs)) }; } if let Some(value) = env_var("RPKI_RTR_WARN_INSECURE_TCP")? { config.service_config.warn_insecure_tcp = parse_bool(&value, "RPKI_RTR_WARN_INSECURE_TCP")?; } if let Some(value) = env_var("RPKI_RTR_REQUIRE_TLS_SERVER_DNS_NAME_SAN")? { config.service_config.require_tls_server_dns_name_san = parse_bool(&value, "RPKI_RTR_REQUIRE_TLS_SERVER_DNS_NAME_SAN")?; } if let Some(value) = env_var("RPKI_RTR_ENFORCE_TLS_CLIENT_SAN_IP_MATCH")? { config.service_config.enforce_tls_client_san_ip_match = parse_bool(&value, "RPKI_RTR_ENFORCE_TLS_CLIENT_SAN_IP_MATCH")?; } if config.service_config.max_connections == 0 { return Err(anyhow!( "invalid RPKI_RTR_MAX_CONNECTIONS '{}': must be >= 1", config.service_config.max_connections )); } if config.service_config.max_concurrent_handshakes == 0 { return Err(anyhow!( "invalid RPKI_RTR_MAX_CONCURRENT_HANDSHAKES '{}': must be >= 1", config.service_config.max_concurrent_handshakes )); } if config.service_config.max_concurrent_handshakes > config.service_config.max_connections { return Err(anyhow!( "invalid handshake/connection limits: RPKI_RTR_MAX_CONCURRENT_HANDSHAKES ({}) must be <= RPKI_RTR_MAX_CONNECTIONS ({})", config.service_config.max_concurrent_handshakes, config.service_config.max_connections )); } Ok(config) } pub fn runtime_config(&self) -> RuntimeConfig { RuntimeConfig { max_delta: self.max_delta, prune_delta_by_snapshot_size: self.prune_delta_by_snapshot_size, source_refresh_interval_seconds: self.source_refresh_interval.as_secs(), runtime_report_interval_seconds: self.runtime_report_interval.as_secs(), report_history_limit: self.report_history_limit, timezone: format_timezone(self.timezone), timing: RuntimeTimingConfig::from(self.timing), } } } impl RuntimeConfig { pub fn apply_patch(&self, patch: RuntimeConfigPatch) -> Result { let mut next = self.clone(); if let Some(max_delta) = patch.max_delta { if max_delta == 0 { return Err(anyhow!("invalid max_delta '{}': must be >= 1", max_delta)); } next.max_delta = max_delta; } if let Some(value) = patch.prune_delta_by_snapshot_size { next.prune_delta_by_snapshot_size = value; } if let Some(value) = patch.source_refresh_interval_seconds { if value == 0 { return Err(anyhow!( "invalid source_refresh_interval_seconds '{}': must be >= 1", value )); } next.source_refresh_interval_seconds = value; } if let Some(value) = patch.runtime_report_interval_seconds { if value == 0 { return Err(anyhow!( "invalid runtime_report_interval_seconds '{}': must be >= 1", value )); } next.runtime_report_interval_seconds = value; } if let Some(value) = patch.report_history_limit { if value == 0 { return Err(anyhow!( "invalid report_history_limit '{}': must be >= 1", value )); } next.report_history_limit = value; } if let Some(value) = patch.timezone { next.timezone = format_timezone(parse_timezone(&value, "timezone")?); } if let Some(timing) = patch.timing { let mut next_timing = Timing::from(next.timing); if let Some(value) = timing.refresh { next_timing.refresh = value; } if let Some(value) = timing.retry { next_timing.retry = value; } if let Some(value) = timing.expire { next_timing.expire = value; } next_timing .validate() .map_err(|err| anyhow!("invalid timing: {}", err))?; next.timing = RuntimeTimingConfig::from(next_timing); } Ok(next) } pub fn timezone(&self) -> Result { parse_timezone(&self.timezone, "timezone") } pub fn timing(&self) -> Timing { Timing::from(self.timing) } } impl From for RuntimeTimingConfig { fn from(timing: Timing) -> Self { Self { refresh: timing.refresh, retry: timing.retry, expire: timing.expire, } } } impl From for Timing { fn from(timing: RuntimeTimingConfig) -> Self { Self::new(timing.refresh, timing.retry, timing.expire) } } pub fn log_startup_config(config: &AppConfig) { info!("starting RTR service"); info!("db_path={}", config.db_path); info!("tcp_addr={}", config.tcp_addr); info!("tls_enabled={}", config.enable_tls); info!("ssh_enabled={}", config.enable_ssh); if config.enable_tls { info!("tls_addr={}", config.tls_addr); info!("tls_cert_path={}", config.tls_cert_path); info!("tls_key_path={}", config.tls_key_path); info!("tls_client_ca_path={}", config.tls_client_ca_path); } if config.enable_ssh { info!("ssh_addr={}", config.ssh_addr); info!("ssh_host_key_path={}", config.ssh_host_key_path); info!( "ssh_authorized_keys_path={}", config.ssh_authorized_keys_path ); info!("ssh_username={}", config.ssh_username); info!("ssh_subsystem_name={}", config.ssh_subsystem_name); info!("ssh_auth_mode={}", config.ssh_auth_mode.as_str()); info!("ssh_password_enabled={}", config.ssh_password.is_some()); } info!("ccr_dir={}", config.ccr_dir); info!( "slurm_dir={}", config.slurm_dir.as_deref().unwrap_or("disabled") ); info!("max_delta={}", config.max_delta); info!("strict_ccr_validation=true"); info!( "source_refresh_interval_secs={}", config.source_refresh_interval.as_secs() ); info!("report_dir={}", config.report_dir); info!( "runtime_report_interval_secs={}", config.runtime_report_interval.as_secs() ); info!("report_history_limit={}", config.report_history_limit); info!("timezone={}", format_timezone(config.timezone)); info!("rtr_timing_refresh_secs={}", config.timing.refresh); info!("rtr_timing_retry_secs={}", config.timing.retry); info!("rtr_timing_expire_secs={}", config.timing.expire); info!( "admin_addr={}", config .admin_addr .map(|addr| addr.to_string()) .unwrap_or_else(|| "disabled".to_string()) ); info!("admin_token_enabled={}", config.admin_token.is_some()); info!("max_connections={}", config.service_config.max_connections); info!( "max_concurrent_handshakes={}", config.service_config.max_concurrent_handshakes ); info!( "notify_queue_size={}", config.service_config.notify_queue_size ); info!( "tcp_keepalive_secs={}", config .service_config .tcp_keepalive .map(|duration| duration.as_secs().to_string()) .unwrap_or_else(|| "disabled".to_string()) ); info!( "warn_insecure_tcp={}", config.service_config.warn_insecure_tcp ); info!( "require_tls_server_dns_name_san={}", config.service_config.require_tls_server_dns_name_san ); info!( "enforce_tls_client_san_ip_match={}", config.service_config.enforce_tls_client_san_ip_match ); } pub fn default_timezone() -> Tz { chrono_tz::Asia::Shanghai } pub fn format_timezone(timezone: Tz) -> String { timezone.name().to_string() } fn env_var(name: &str) -> Result> { match env::var(name) { Ok(value) => Ok(Some(value)), Err(env::VarError::NotPresent) => Ok(None), Err(err) => Err(anyhow!("failed to read {}: {}", name, err)), } } fn parse_bool(value: &str, name: &str) -> Result { match value.trim().to_ascii_lowercase().as_str() { "1" | "true" | "yes" | "on" => Ok(true), "0" | "false" | "no" | "off" => Ok(false), _ => Err(anyhow!("invalid {} '{}': expected boolean", name, value)), } } fn parse_positive_u64(value: &str, name: &str) -> Result { let parsed = value .parse::() .map_err(|err| anyhow!("invalid {} '{}': {}", name, value, err))?; if parsed == 0 { return Err(anyhow!("invalid {} '{}': must be >= 1", name, value)); } Ok(parsed) } fn parse_positive_usize(value: &str, name: &str) -> Result { let parsed = value .parse::() .map_err(|err| anyhow!("invalid {} '{}': {}", name, value, err))?; if parsed == 0 { return Err(anyhow!("invalid {} '{}': must be >= 1", name, value)); } Ok(parsed) } fn parse_positive_u32(value: &str, name: &str) -> Result { let parsed = value .parse::() .map_err(|err| anyhow!("invalid {} '{}': {}", name, value, err))?; if parsed == 0 { return Err(anyhow!("invalid {} '{}': must be >= 1", name, value)); } Ok(parsed) } pub fn parse_timezone(value: &str, name: &str) -> Result { let value = value.trim(); let normalized = match value.to_ascii_lowercase().as_str() { "shanghai" | "beijing" | "peking" => "Asia/Shanghai", "utc" | "z" => "UTC", _ => value, }; normalized.parse::().map_err(|err| { anyhow!( "invalid {} '{}': expected IANA timezone like Asia/Shanghai, Europe/London, America/New_York, or UTC: {}", name, value, err ) }) }