use std::collections::HashSet;
use std::net::SocketAddrV6;
use serde::{Deserialize, Serialize};
const DEFAULT_DNS_LISTEN: &str = "[::]:53";
const DEFAULT_DNS_UPSTREAM: &str = "[::1]:5354";
const DEFAULT_DNS_TTL: u32 = 60;
const DEFAULT_GRACE_PERIOD: u64 = 60;
const DEFAULT_CT_TCP_ESTABLISHED: u64 = 432_000;
const DEFAULT_CT_UDP_TIMEOUT: u64 = 30;
const DEFAULT_CT_UDP_ASSURED: u64 = 180;
const DEFAULT_CT_ICMP_TIMEOUT: u64 = 30;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GatewayConfig {
#[serde(default)]
pub enabled: bool,
pub pool: String,
pub lan_interface: String,
#[serde(default)]
pub dns: GatewayDnsConfig,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pool_grace_period: Option<u64>,
#[serde(default)]
pub conntrack: ConntrackConfig,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub port_forwards: Vec<PortForward>,
}
impl GatewayConfig {
pub fn grace_period(&self) -> u64 {
self.pool_grace_period.unwrap_or(DEFAULT_GRACE_PERIOD)
}
pub fn validate_port_forwards(&self) -> Result<(), String> {
let mut seen = HashSet::new();
for pf in &self.port_forwards {
if pf.listen_port == 0 {
return Err("port_forward listen_port must be non-zero".to_string());
}
if !seen.insert((pf.listen_port, pf.proto)) {
return Err(format!(
"duplicate port_forward ({:?} {}) — each (listen_port, proto) must be unique",
pf.proto, pf.listen_port
));
}
}
Ok(())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Proto {
Tcp,
Udp,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PortForward {
pub listen_port: u16,
pub proto: Proto,
pub target: SocketAddrV6,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct GatewayDnsConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub listen: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub upstream: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ttl: Option<u32>,
}
impl GatewayDnsConfig {
pub fn listen(&self) -> &str {
self.listen.as_deref().unwrap_or(DEFAULT_DNS_LISTEN)
}
pub fn upstream(&self) -> &str {
self.upstream.as_deref().unwrap_or(DEFAULT_DNS_UPSTREAM)
}
pub fn ttl(&self) -> u32 {
self.ttl.unwrap_or(DEFAULT_DNS_TTL)
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ConntrackConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tcp_established: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub udp_timeout: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub udp_assured: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub icmp_timeout: Option<u64>,
}
impl ConntrackConfig {
pub fn tcp_established(&self) -> u64 {
self.tcp_established.unwrap_or(DEFAULT_CT_TCP_ESTABLISHED)
}
pub fn udp_timeout(&self) -> u64 {
self.udp_timeout.unwrap_or(DEFAULT_CT_UDP_TIMEOUT)
}
pub fn udp_assured(&self) -> u64 {
self.udp_assured.unwrap_or(DEFAULT_CT_UDP_ASSURED)
}
pub fn icmp_timeout(&self) -> u64 {
self.icmp_timeout.unwrap_or(DEFAULT_CT_ICMP_TIMEOUT)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_gateway_config_defaults() {
let yaml = r#"
pool: "fd01::/112"
lan_interface: "eth0"
"#;
let config: GatewayConfig = serde_yaml::from_str(yaml).unwrap();
assert!(!config.enabled);
assert_eq!(config.pool, "fd01::/112");
assert_eq!(config.lan_interface, "eth0");
assert_eq!(config.dns.listen(), "[::]:53");
assert_eq!(config.dns.upstream(), "[::1]:5354");
assert_eq!(config.dns.ttl(), 60);
assert_eq!(config.grace_period(), 60);
assert_eq!(config.conntrack.tcp_established(), 432_000);
assert_eq!(config.conntrack.udp_timeout(), 30);
}
#[test]
fn test_gateway_config_custom() {
let yaml = r#"
enabled: true
pool: "fd01::/112"
lan_interface: "enp3s0"
dns:
listen: "192.168.1.1:53"
upstream: "127.0.0.1:5354"
ttl: 120
pool_grace_period: 30
conntrack:
tcp_established: 3600
udp_timeout: 60
"#;
let config: GatewayConfig = serde_yaml::from_str(yaml).unwrap();
assert!(config.enabled);
assert_eq!(config.dns.listen(), "192.168.1.1:53");
assert_eq!(config.dns.ttl(), 120);
assert_eq!(config.grace_period(), 30);
assert_eq!(config.conntrack.tcp_established(), 3600);
assert_eq!(config.conntrack.udp_timeout(), 60);
assert_eq!(config.conntrack.udp_assured(), 180);
assert_eq!(config.conntrack.icmp_timeout(), 30);
}
#[test]
fn test_root_config_with_gateway() {
let yaml = r#"
gateway:
enabled: true
pool: "fd01::/112"
lan_interface: "eth0"
"#;
let config: crate::Config = serde_yaml::from_str(yaml).unwrap();
assert!(config.gateway.is_some());
let gw = config.gateway.unwrap();
assert!(gw.enabled);
assert_eq!(gw.pool, "fd01::/112");
}
#[test]
fn test_root_config_without_gateway() {
let yaml = "node: {}";
let config: crate::Config = serde_yaml::from_str(yaml).unwrap();
assert!(config.gateway.is_none());
}
#[test]
fn test_port_forwards_default_empty() {
let yaml = r#"
pool: "fd01::/112"
lan_interface: "eth0"
"#;
let config: GatewayConfig = serde_yaml::from_str(yaml).unwrap();
assert!(config.port_forwards.is_empty());
config.validate_port_forwards().unwrap();
}
#[test]
fn test_port_forwards_parse() {
let yaml = r#"
pool: "fd01::/112"
lan_interface: "eth0"
port_forwards:
- listen_port: 8080
proto: tcp
target: "[fd12:3456::10]:80"
- listen_port: 2222
proto: tcp
target: "[fd12:3456::20]:22"
- listen_port: 5353
proto: udp
target: "[fd12:3456::10]:53"
"#;
let config: GatewayConfig = serde_yaml::from_str(yaml).unwrap();
assert_eq!(config.port_forwards.len(), 3);
assert_eq!(config.port_forwards[0].listen_port, 8080);
assert_eq!(config.port_forwards[0].proto, Proto::Tcp);
assert_eq!(
config.port_forwards[0].target,
"[fd12:3456::10]:80".parse::<SocketAddrV6>().unwrap()
);
assert_eq!(config.port_forwards[2].proto, Proto::Udp);
config.validate_port_forwards().unwrap();
}
#[test]
fn test_port_forwards_reject_ipv4_target() {
let yaml = r#"
pool: "fd01::/112"
lan_interface: "eth0"
port_forwards:
- listen_port: 8080
proto: tcp
target: "192.168.1.10:80"
"#;
let result: Result<GatewayConfig, _> = serde_yaml::from_str(yaml);
assert!(
result.is_err(),
"IPv4 target must fail to deserialize as SocketAddrV6"
);
}
#[test]
fn test_port_forwards_reject_zero_listen_port() {
let yaml = r#"
pool: "fd01::/112"
lan_interface: "eth0"
port_forwards:
- listen_port: 0
proto: tcp
target: "[fd12:3456::10]:80"
"#;
let config: GatewayConfig = serde_yaml::from_str(yaml).unwrap();
assert!(config.validate_port_forwards().is_err());
}
#[test]
fn test_port_forwards_reject_duplicate() {
let yaml = r#"
pool: "fd01::/112"
lan_interface: "eth0"
port_forwards:
- listen_port: 8080
proto: tcp
target: "[fd12:3456::10]:80"
- listen_port: 8080
proto: tcp
target: "[fd12:3456::20]:80"
"#;
let config: GatewayConfig = serde_yaml::from_str(yaml).unwrap();
let err = config.validate_port_forwards().unwrap_err();
assert!(err.contains("duplicate"), "got: {err}");
}
#[test]
fn test_port_forwards_same_port_different_proto_ok() {
let yaml = r#"
pool: "fd01::/112"
lan_interface: "eth0"
port_forwards:
- listen_port: 53
proto: tcp
target: "[fd12:3456::10]:53"
- listen_port: 53
proto: udp
target: "[fd12:3456::10]:53"
"#;
let config: GatewayConfig = serde_yaml::from_str(yaml).unwrap();
config.validate_port_forwards().unwrap();
}
}