Skip to main content

fips_core/config/
gateway.rs

1//! Gateway configuration types.
2//!
3//! Configuration for the outbound LAN gateway (`gateway.*`).
4
5use std::collections::HashSet;
6use std::net::SocketAddrV6;
7
8use serde::{Deserialize, Serialize};
9
10/// Default gateway DNS listen address.
11///
12/// The canonical gateway deployment already has a LAN resolver on port 53
13/// forwarding `.fips` queries to the gateway over loopback. Use an
14/// unprivileged loopback port by default to avoid colliding with dnsmasq,
15/// systemd-resolved, or BIND. Hosts without another resolver can set
16/// `gateway.dns.listen: "[::]:53"` explicitly.
17const DEFAULT_DNS_LISTEN: &str = "[::1]:5353";
18
19/// Default upstream DNS resolver (FIPS daemon).
20///
21/// Must match the daemon's `dns.bind_addr` default (`::1`). Linux
22/// IPv6 sockets bound to explicit `::1` do not accept v4-mapped
23/// traffic — so a v4 upstream like `127.0.0.1:5354` cannot reach a
24/// daemon bound on `[::1]:5354`. Operators who set a non-default
25/// `dns.bind_addr` on the daemon must also set this field
26/// accordingly.
27const DEFAULT_DNS_UPSTREAM: &str = "[::1]:5354";
28
29/// Default DNS TTL in seconds.
30const DEFAULT_DNS_TTL: u32 = 60;
31
32/// Default pool grace period in seconds.
33const DEFAULT_GRACE_PERIOD: u64 = 60;
34
35/// Default conntrack TCP established timeout (5 days).
36const DEFAULT_CT_TCP_ESTABLISHED: u64 = 432_000;
37
38/// Default conntrack UDP timeout (unreplied).
39const DEFAULT_CT_UDP_TIMEOUT: u64 = 30;
40
41/// Default conntrack UDP assured timeout (bidirectional).
42const DEFAULT_CT_UDP_ASSURED: u64 = 180;
43
44/// Default conntrack ICMP timeout.
45const DEFAULT_CT_ICMP_TIMEOUT: u64 = 30;
46
47/// Gateway configuration (`gateway.*`).
48#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct GatewayConfig {
50    /// Enable the gateway (`gateway.enabled`, default: false).
51    #[serde(default)]
52    pub enabled: bool,
53
54    /// Virtual IP pool CIDR (e.g., `fd01::/112`).
55    pub pool: String,
56
57    /// LAN-facing interface for proxy ARP/NDP.
58    pub lan_interface: String,
59
60    /// Gateway DNS configuration.
61    #[serde(default)]
62    pub dns: GatewayDnsConfig,
63
64    /// Pool grace period in seconds after last session before reclamation.
65    #[serde(default, skip_serializing_if = "Option::is_none")]
66    pub pool_grace_period: Option<u64>,
67
68    /// Conntrack timeout overrides.
69    #[serde(default)]
70    pub conntrack: ConntrackConfig,
71
72    /// Inbound mesh port forwarding rules. See TASK-2026-0061.
73    #[serde(default, skip_serializing_if = "Vec::is_empty")]
74    pub port_forwards: Vec<PortForward>,
75}
76
77impl GatewayConfig {
78    /// Get pool grace period (default: 60 seconds).
79    pub fn grace_period(&self) -> u64 {
80        self.pool_grace_period.unwrap_or(DEFAULT_GRACE_PERIOD)
81    }
82
83    /// Validate inbound port-forward rules: non-zero listen ports and
84    /// uniqueness of `(listen_port, proto)` pairs across the list.
85    /// IPv6-only targets are enforced by `SocketAddrV6` at deserialize
86    /// time.
87    pub fn validate_port_forwards(&self) -> Result<(), String> {
88        let mut seen = HashSet::new();
89        for pf in &self.port_forwards {
90            if pf.listen_port == 0 {
91                return Err("port_forward listen_port must be non-zero".to_string());
92            }
93            if !seen.insert((pf.listen_port, pf.proto)) {
94                return Err(format!(
95                    "duplicate port_forward ({:?} {}) — each (listen_port, proto) must be unique",
96                    pf.proto, pf.listen_port
97                ));
98            }
99        }
100        Ok(())
101    }
102}
103
104/// Transport protocol for an inbound port forward.
105#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
106#[serde(rename_all = "lowercase")]
107pub enum Proto {
108    Tcp,
109    Udp,
110}
111
112/// An inbound port-forward rule: `fips0:listen_port/proto` → `target`.
113#[derive(Debug, Clone, Serialize, Deserialize)]
114pub struct PortForward {
115    /// Port on `fips0` that mesh peers connect to.
116    pub listen_port: u16,
117    /// Transport protocol to match.
118    pub proto: Proto,
119    /// IPv6 LAN destination (`[addr]:port`). IPv4 targets are rejected
120    /// at parse time by `SocketAddrV6`.
121    pub target: SocketAddrV6,
122}
123
124/// Gateway DNS resolver configuration (`gateway.dns.*`).
125#[derive(Debug, Clone, Default, Serialize, Deserialize)]
126pub struct GatewayDnsConfig {
127    /// Listen address and port (default: `[::1]:5353`).
128    #[serde(default, skip_serializing_if = "Option::is_none")]
129    pub listen: Option<String>,
130
131    /// Upstream FIPS daemon DNS resolver (default: `[::1]:5354`,
132    /// matching the daemon's `dns.bind_addr` default).
133    #[serde(default, skip_serializing_if = "Option::is_none")]
134    pub upstream: Option<String>,
135
136    /// DNS record TTL in seconds (default: 60).
137    #[serde(default, skip_serializing_if = "Option::is_none")]
138    pub ttl: Option<u32>,
139}
140
141impl GatewayDnsConfig {
142    /// Get the listen address (default: `[::1]:5353`).
143    pub fn listen(&self) -> &str {
144        self.listen.as_deref().unwrap_or(DEFAULT_DNS_LISTEN)
145    }
146
147    /// Get the upstream resolver address (default: `[::1]:5354`).
148    pub fn upstream(&self) -> &str {
149        self.upstream.as_deref().unwrap_or(DEFAULT_DNS_UPSTREAM)
150    }
151
152    /// Get the TTL in seconds (default: 60).
153    pub fn ttl(&self) -> u32 {
154        self.ttl.unwrap_or(DEFAULT_DNS_TTL)
155    }
156}
157
158/// Conntrack timeout overrides (`gateway.conntrack.*`).
159#[derive(Debug, Clone, Default, Serialize, Deserialize)]
160pub struct ConntrackConfig {
161    /// TCP established timeout in seconds.
162    #[serde(default, skip_serializing_if = "Option::is_none")]
163    pub tcp_established: Option<u64>,
164
165    /// UDP unreplied timeout in seconds.
166    #[serde(default, skip_serializing_if = "Option::is_none")]
167    pub udp_timeout: Option<u64>,
168
169    /// UDP assured (bidirectional) timeout in seconds.
170    #[serde(default, skip_serializing_if = "Option::is_none")]
171    pub udp_assured: Option<u64>,
172
173    /// ICMP timeout in seconds.
174    #[serde(default, skip_serializing_if = "Option::is_none")]
175    pub icmp_timeout: Option<u64>,
176}
177
178impl ConntrackConfig {
179    /// TCP established timeout (default: 432000s / 5 days).
180    pub fn tcp_established(&self) -> u64 {
181        self.tcp_established.unwrap_or(DEFAULT_CT_TCP_ESTABLISHED)
182    }
183
184    /// UDP unreplied timeout (default: 30s).
185    pub fn udp_timeout(&self) -> u64 {
186        self.udp_timeout.unwrap_or(DEFAULT_CT_UDP_TIMEOUT)
187    }
188
189    /// UDP assured timeout (default: 180s).
190    pub fn udp_assured(&self) -> u64 {
191        self.udp_assured.unwrap_or(DEFAULT_CT_UDP_ASSURED)
192    }
193
194    /// ICMP timeout (default: 30s).
195    pub fn icmp_timeout(&self) -> u64 {
196        self.icmp_timeout.unwrap_or(DEFAULT_CT_ICMP_TIMEOUT)
197    }
198}
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203
204    #[test]
205    fn test_gateway_config_defaults() {
206        let yaml = r#"
207pool: "fd01::/112"
208lan_interface: "eth0"
209"#;
210        let config: GatewayConfig = serde_yaml::from_str(yaml).unwrap();
211        assert!(!config.enabled);
212        assert_eq!(config.pool, "fd01::/112");
213        assert_eq!(config.lan_interface, "eth0");
214        assert_eq!(config.dns.listen(), "[::1]:5353");
215        assert_eq!(config.dns.upstream(), "[::1]:5354");
216        assert_eq!(config.dns.ttl(), 60);
217        assert_eq!(config.grace_period(), 60);
218        assert_eq!(config.conntrack.tcp_established(), 432_000);
219        assert_eq!(config.conntrack.udp_timeout(), 30);
220    }
221
222    #[test]
223    fn test_gateway_config_custom() {
224        let yaml = r#"
225enabled: true
226pool: "fd01::/112"
227lan_interface: "enp3s0"
228dns:
229  listen: "192.168.1.1:53"
230  upstream: "127.0.0.1:5354"
231  ttl: 120
232pool_grace_period: 30
233conntrack:
234  tcp_established: 3600
235  udp_timeout: 60
236"#;
237        let config: GatewayConfig = serde_yaml::from_str(yaml).unwrap();
238        assert!(config.enabled);
239        assert_eq!(config.dns.listen(), "192.168.1.1:53");
240        assert_eq!(config.dns.ttl(), 120);
241        assert_eq!(config.grace_period(), 30);
242        assert_eq!(config.conntrack.tcp_established(), 3600);
243        assert_eq!(config.conntrack.udp_timeout(), 60);
244        // Unset fields use defaults
245        assert_eq!(config.conntrack.udp_assured(), 180);
246        assert_eq!(config.conntrack.icmp_timeout(), 30);
247    }
248
249    #[test]
250    fn test_root_config_with_gateway() {
251        let yaml = r#"
252gateway:
253  enabled: true
254  pool: "fd01::/112"
255  lan_interface: "eth0"
256"#;
257        let config: crate::Config = serde_yaml::from_str(yaml).unwrap();
258        assert!(config.gateway.is_some());
259        let gw = config.gateway.unwrap();
260        assert!(gw.enabled);
261        assert_eq!(gw.pool, "fd01::/112");
262    }
263
264    #[test]
265    fn test_root_config_without_gateway() {
266        let yaml = "node: {}";
267        let config: crate::Config = serde_yaml::from_str(yaml).unwrap();
268        assert!(config.gateway.is_none());
269    }
270
271    #[test]
272    fn test_port_forwards_default_empty() {
273        let yaml = r#"
274pool: "fd01::/112"
275lan_interface: "eth0"
276"#;
277        let config: GatewayConfig = serde_yaml::from_str(yaml).unwrap();
278        assert!(config.port_forwards.is_empty());
279        config.validate_port_forwards().unwrap();
280    }
281
282    #[test]
283    fn test_port_forwards_parse() {
284        let yaml = r#"
285pool: "fd01::/112"
286lan_interface: "eth0"
287port_forwards:
288  - listen_port: 8080
289    proto: tcp
290    target: "[fd12:3456::10]:80"
291  - listen_port: 2222
292    proto: tcp
293    target: "[fd12:3456::20]:22"
294  - listen_port: 5353
295    proto: udp
296    target: "[fd12:3456::10]:53"
297"#;
298        let config: GatewayConfig = serde_yaml::from_str(yaml).unwrap();
299        assert_eq!(config.port_forwards.len(), 3);
300        assert_eq!(config.port_forwards[0].listen_port, 8080);
301        assert_eq!(config.port_forwards[0].proto, Proto::Tcp);
302        assert_eq!(
303            config.port_forwards[0].target,
304            "[fd12:3456::10]:80".parse::<SocketAddrV6>().unwrap()
305        );
306        assert_eq!(config.port_forwards[2].proto, Proto::Udp);
307        config.validate_port_forwards().unwrap();
308    }
309
310    #[test]
311    fn test_port_forwards_reject_ipv4_target() {
312        let yaml = r#"
313pool: "fd01::/112"
314lan_interface: "eth0"
315port_forwards:
316  - listen_port: 8080
317    proto: tcp
318    target: "192.168.1.10:80"
319"#;
320        let result: Result<GatewayConfig, _> = serde_yaml::from_str(yaml);
321        assert!(
322            result.is_err(),
323            "IPv4 target must fail to deserialize as SocketAddrV6"
324        );
325    }
326
327    #[test]
328    fn test_port_forwards_reject_zero_listen_port() {
329        let yaml = r#"
330pool: "fd01::/112"
331lan_interface: "eth0"
332port_forwards:
333  - listen_port: 0
334    proto: tcp
335    target: "[fd12:3456::10]:80"
336"#;
337        let config: GatewayConfig = serde_yaml::from_str(yaml).unwrap();
338        assert!(config.validate_port_forwards().is_err());
339    }
340
341    #[test]
342    fn test_port_forwards_reject_duplicate() {
343        let yaml = r#"
344pool: "fd01::/112"
345lan_interface: "eth0"
346port_forwards:
347  - listen_port: 8080
348    proto: tcp
349    target: "[fd12:3456::10]:80"
350  - listen_port: 8080
351    proto: tcp
352    target: "[fd12:3456::20]:80"
353"#;
354        let config: GatewayConfig = serde_yaml::from_str(yaml).unwrap();
355        let err = config.validate_port_forwards().unwrap_err();
356        assert!(err.contains("duplicate"), "got: {err}");
357    }
358
359    #[test]
360    fn test_port_forwards_same_port_different_proto_ok() {
361        let yaml = r#"
362pool: "fd01::/112"
363lan_interface: "eth0"
364port_forwards:
365  - listen_port: 53
366    proto: tcp
367    target: "[fd12:3456::10]:53"
368  - listen_port: 53
369    proto: udp
370    target: "[fd12:3456::10]:53"
371"#;
372        let config: GatewayConfig = serde_yaml::from_str(yaml).unwrap();
373        config.validate_port_forwards().unwrap();
374    }
375}