mirror of
https://github.com/mrvasil/ctf-ad-mirror-tool-rust.git
synced 2026-10-25 03:29:26 +00:00
448 lines
14 KiB
Rust
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());
|
|
}
|
|
}
|