ctf-ad-mirror-tool-rust/src/config.rs

448 lines
14 KiB
Rust

use anyhow::{Context, Result, bail, ensure};
use serde::{Deserialize, Serialize};
use std::{
collections::HashSet,
net::{IpAddr, SocketAddr},
path::{Path, PathBuf},
};
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Config {
pub main_team_ip: Option<IpAddr>,
pub flags: Option<crate::flags::FlagConfig>,
pub web: WebConfig,
pub network: NetworkConfig,
pub monitoring: MonitoringConfig,
pub scoreboard: Option<ScoreboardConfig>,
pub teams: Vec<Team>,
pub services: Vec<ServiceConfig>,
}
#[derive(Clone, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct WebConfig {
pub listen: SocketAddr,
#[serde(skip_serializing)]
pub password: Option<String>,
pub password_env: String,
pub state_file: PathBuf,
pub session_ttl_seconds: u64,
pub secure_cookie: bool,
}
impl std::fmt::Debug for WebConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("WebConfig")
.field("listen", &self.listen)
.field("password", &self.password.as_ref().map(|_| "[REDACTED]"))
.field("password_env", &self.password_env)
.field("state_file", &self.state_file)
.field("session_ttl_seconds", &self.session_ttl_seconds)
.field("secure_cookie", &self.secure_cookie)
.finish()
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct NetworkConfig {
pub connect_timeout_ms: u64,
pub buffer_size: usize,
pub replica_queue_chunks: usize,
pub max_buffered_bytes: usize,
pub max_connections: usize,
pub drain_timeout_ms: u64,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct MonitoringConfig {
pub interval_ms: u64,
pub timeout_ms: u64,
pub failure_threshold: u32,
pub recovery_threshold: u32,
pub cooldown_ms: u64,
pub concurrency: usize,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ScoreboardConfig {
pub kind: ScoreboardKind,
pub url: String,
#[serde(default = "default_poll")]
pub poll_interval_ms: u64,
#[serde(default = "default_stale")]
pub stale_after_ms: u64,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ScoreboardKind {
Forcad,
Adplatf,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct Team {
#[serde(default)]
pub hidden: bool,
pub id: String,
pub name: String,
pub address: IpAddr,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct ServiceConfig {
#[serde(default)]
pub custom: bool,
#[serde(default)]
pub custom_ip: Option<IpAddr>,
pub id: String,
pub name: String,
pub listen: SocketAddr,
pub upstream_port: u16,
#[serde(default)]
pub primary: Option<String>,
#[serde(default)]
pub replicas: Vec<String>,
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub auto_switch: bool,
#[serde(default)]
pub replica_policy: ReplicaPolicy,
#[serde(default)]
pub flag_protection: bool,
}
#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ReplicaPolicy {
/// Bound memory, await every replica, never intentionally discard a byte.
Strict,
/// Protect primary latency; disconnect a lagging replica explicitly.
#[default]
Isolate,
}
impl Default for WebConfig {
fn default() -> Self {
Self {
listen: "0.0.0.0:6700".parse().unwrap(),
password: None,
password_env: "MIRROR_PASSWORD".into(),
state_file: "data/state.json".into(),
session_ttl_seconds: 43200,
secure_cookie: false,
}
}
}
impl Default for NetworkConfig {
fn default() -> Self {
Self {
connect_timeout_ms: 2000,
buffer_size: 65536,
replica_queue_chunks: 16,
max_buffered_bytes: 268435456,
max_connections: 2048,
drain_timeout_ms: 15000,
}
}
}
impl Default for MonitoringConfig {
fn default() -> Self {
Self {
interval_ms: 3000,
timeout_ms: 1000,
failure_threshold: 2,
recovery_threshold: 2,
cooldown_ms: 30000,
concurrency: 32,
}
}
}
fn default_poll() -> u64 {
3000
}
fn default_stale() -> u64 {
15000
}
pub fn valid_id(id: &str) -> bool {
!id.is_empty()
&& id.len() <= 64
&& id
.bytes()
.all(|c| c.is_ascii_alphanumeric() || c == b'_' || c == b'-')
}
impl Config {
pub fn load(path: &Path) -> Result<Self> {
let content = std::fs::read_to_string(path)
.with_context(|| format!("read config {}", path.display()))?;
let mut config: Self = toml::from_str(&content).context("parse TOML config")?;
if config.web.state_file.is_relative() {
config.web.state_file = path
.parent()
.unwrap_or(Path::new("."))
.join(&config.web.state_file);
}
if let Some(flags) = &mut config.flags {
if flags.database.is_relative() {
flags.database = path
.parent()
.unwrap_or(Path::new("."))
.join(&flags.database);
}
ensure!(
flags.database != path,
"flag database cannot be the TOML config"
);
}
config.validate()?;
Ok(config)
}
pub fn validate(&self) -> Result<()> {
ensure!(
self.web.password.as_ref().is_none_or(|p| p.len() >= 12),
"web.password must contain at least 12 characters"
);
ensure!(
!self.web.password_env.is_empty(),
"password_env must name an environment variable"
);
ensure!(
(60..=604800).contains(&self.web.session_ttl_seconds),
"session TTL must be 60..604800 seconds"
);
ensure!(
(1024..=1048576).contains(&self.network.buffer_size),
"buffer_size must be 1024..1048576"
);
ensure!(
(1..=1024).contains(&self.network.replica_queue_chunks),
"replica_queue_chunks must be 1..1024"
);
ensure!(
(self.network.buffer_size..=2147483647).contains(&self.network.max_buffered_bytes),
"max_buffered_bytes must cover one buffer and be <= 2 GiB - 1"
);
ensure!(
(1..=1000000).contains(&self.network.max_connections),
"max_connections must be positive and <= 1000000"
);
ensure!(
(1..=60000).contains(&self.network.connect_timeout_ms),
"connect_timeout_ms must be 1..60000"
);
ensure!(
(1..=300000).contains(&self.network.drain_timeout_ms),
"drain_timeout_ms must be 1..300000"
);
let m = &self.monitoring;
ensure!(
m.interval_ms >= 100
&& m.timeout_ms > 0
&& m.timeout_ms <= 60000
&& m.concurrency > 0
&& m.concurrency <= 1024
&& m.failure_threshold > 0
&& m.recovery_threshold > 0,
"invalid monitoring limits"
);
if let Some(board) = &self.scoreboard {
let url = url::Url::parse(&board.url).context("invalid scoreboard URL")?;
ensure!(
matches!(url.scheme(), "http" | "https")
&& url.host_str().is_some()
&& url.username().is_empty()
&& url.password().is_none()
&& url.query().is_none()
&& url.fragment().is_none(),
"scoreboard URL must be HTTP(S) without credentials, query or fragment"
);
ensure!(
board.poll_interval_ms >= 100 && board.stale_after_ms >= board.poll_interval_ms,
"invalid scoreboard intervals"
);
}
if let Some(flags) = &self.flags {
flags.validate()?;
ensure!(
flags.database != self.web.state_file,
"flag database and topology state must be separate files"
);
}
ensure!(
self.flags.is_some() || !self.services.iter().any(|s| s.flag_protection),
"configure [flags] and its key before enabling flag protection"
);
validate_topology(&self.teams, &self.services)?;
for service in &self.services {
ensure!(
!bind_conflicts(service.listen, self.web.listen),
"service {} conflicts with web listener",
service.id
);
}
Ok(())
}
}
pub fn normalized_ip(ip: IpAddr) -> IpAddr {
match ip {
IpAddr::V6(v6) => v6.to_ipv4_mapped().map(IpAddr::V4).unwrap_or(ip),
_ => ip,
}
}
pub fn bind_conflicts(a: SocketAddr, b: SocketAddr) -> bool {
a.port() == b.port()
&& (normalized_ip(a.ip()) == normalized_ip(b.ip())
|| (a.ip().is_unspecified() && (a.is_ipv6() || normalized_ip(b.ip()).is_ipv4()))
|| (b.ip().is_unspecified() && (b.is_ipv6() || normalized_ip(a.ip()).is_ipv4())))
}
pub fn validate_topology(teams: &[Team], services: &[ServiceConfig]) -> Result<()> {
ensure!(
teams.len() <= 1024 && services.len() <= 256,
"topology limit: 1024 teams, 256 services"
);
let mut ids = HashSet::new();
let mut names = HashSet::new();
let mut addresses = HashSet::new();
let mut local_addresses: HashSet<IpAddr> = if_addrs::get_if_addrs()
.context("enumerate local interface addresses")?
.into_iter()
.map(|i| i.ip())
.collect();
local_addresses.insert("127.0.0.1".parse().unwrap());
local_addresses.insert("::1".parse().unwrap());
for team in teams {
ensure!(
valid_id(&team.id) && !team.name.trim().is_empty() && team.name.len() <= 128,
"invalid team id/name"
);
ensure!(ids.insert(&team.id), "duplicate team id {}", team.id);
ensure!(
names.insert(&team.name),
"duplicate team name {}",
team.name
);
ensure!(
addresses.insert(normalized_ip(team.address)),
"duplicate team address"
);
ensure!(
!normalized_ip(team.address).is_unspecified()
&& !normalized_ip(team.address).is_multicast(),
"team must be a unicast address"
);
}
local_addresses = local_addresses.into_iter().map(normalized_ip).collect();
let mut service_ids = HashSet::new();
for (index, service) in services.iter().enumerate() {
ensure!(
valid_id(&service.id) && !service.name.trim().is_empty() && service.name.len() <= 128,
"invalid service id/name"
);
ensure!(
service_ids.insert(&service.id),
"duplicate service id {}",
service.id
);
ensure!(
service.listen.port() != 0 && service.upstream_port != 0,
"service ports must be nonzero"
);
ensure!(
!service.listen.ip().is_multicast(),
"listener must be a local unicast or wildcard address"
);
ensure!(
!service.enabled || service.primary.is_some(),
"enabled service {} needs a primary",
service.id
);
ensure!(
service.custom || service.custom_ip.is_none(),
"only custom services may set custom_ip"
);
if let Some(ip) = service.custom_ip {
ensure!(
teams.iter().any(|t| Some(&t.id) == service.primary.as_ref()
&& normalized_ip(t.address) == normalized_ip(ip)),
"custom_ip must match primary"
);
}
let mut targets = HashSet::new();
for id in service.primary.iter().chain(service.replicas.iter()) {
ensure!(
targets.insert(id),
"duplicate primary/replica on {}",
service.id
);
let Some(team) = teams.iter().find(|t| &t.id == id) else {
bail!("unknown team {} in {}", id, service.id)
};
let target = SocketAddr::new(normalized_ip(team.address), service.upstream_port);
for listener in services {
let reaches_listener = target.port() == listener.listen.port()
&& (target.ip() == normalized_ip(listener.listen.ip())
|| (listener.listen.ip().is_unspecified()
&& (listener.listen.is_ipv6() || target.is_ipv4())
&& (target.ip().is_loopback()
|| local_addresses.contains(&target.ip()))));
ensure!(
!reaches_listener,
"{} routes to managed listener {} (proxy loop)",
service.id,
listener.id
);
}
}
ensure!(
service.replicas.len() <= 32,
"at most 32 replicas per service"
);
for other in &services[..index] {
ensure!(
!bind_conflicts(service.listen, other.listen),
"service {} listener conflicts with {}",
service.id,
other.id
);
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rejects_invalid_resource_bounds() {
let mut c = Config::default();
c.network.replica_queue_chunks = 0;
assert!(c.validate().is_err());
}
#[test]
fn rejects_credential_urls() {
let c = Config {
scoreboard: Some(ScoreboardConfig {
kind: ScoreboardKind::Forcad,
url: "http://user:secret@localhost/".into(),
poll_interval_ms: 3000,
stale_after_ms: 15000,
}),
..Config::default()
};
assert!(c.validate().is_err());
}
}