Skip to main content

fips_core/gateway/
pool.rs

1//! Virtual IP pool manager.
2//!
3//! Manages allocation, TTL, and reclamation of virtual IPv6 addresses
4//! from a configured CIDR range. Tracks mapping state and integrates
5//! with conntrack to determine active sessions.
6
7use crate::NodeAddr;
8use std::collections::{HashMap, VecDeque};
9use std::net::Ipv6Addr;
10use std::time::Instant;
11use tracing::{debug, info};
12
13/// Errors from pool operations.
14#[derive(Debug, thiserror::Error)]
15pub enum PoolError {
16    #[error("invalid CIDR: {0}")]
17    InvalidCidr(String),
18    #[error("pool exhausted ({0} addresses in use)")]
19    Exhausted(usize),
20    #[error("prefix length must be between 1 and 128")]
21    InvalidPrefix,
22}
23
24/// State of a virtual IP mapping.
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum MappingState {
27    /// Allocated via DNS query, no NAT sessions yet.
28    Allocated,
29    /// Active NAT sessions exist.
30    Active,
31    /// TTL expired but sessions remain.
32    Draining,
33}
34
35/// A single virtual IP ↔ FIPS mesh address mapping.
36#[derive(Debug, Clone)]
37pub struct VirtualIpMapping {
38    /// The FIPS node address this mapping is for.
39    pub node_addr: NodeAddr,
40    /// The virtual IP allocated from the pool.
41    pub virtual_ip: Ipv6Addr,
42    /// The FIPS mesh address (fd00::/8).
43    pub mesh_addr: Ipv6Addr,
44    /// The DNS name that was queried (e.g. "npub1abc...xyz.fips").
45    pub dns_name: String,
46    /// Current state.
47    pub state: MappingState,
48    /// When this mapping was created.
49    pub created: Instant,
50    /// When this mapping was last referenced (DNS query or session).
51    pub last_referenced: Instant,
52    /// When draining started (for grace period tracking).
53    pub drain_start: Option<Instant>,
54    /// Number of active conntrack sessions.
55    pub session_count: u32,
56}
57
58/// Events emitted by the pool on state transitions.
59#[derive(Debug)]
60pub enum PoolEvent {
61    /// A new mapping was allocated — NAT rules should be created.
62    MappingCreated {
63        virtual_ip: Ipv6Addr,
64        mesh_addr: Ipv6Addr,
65    },
66    /// A mapping was reclaimed — NAT rules should be removed.
67    MappingRemoved {
68        virtual_ip: Ipv6Addr,
69        mesh_addr: Ipv6Addr,
70    },
71}
72
73/// Pool utilization summary.
74#[derive(Debug, Clone)]
75pub struct PoolStatus {
76    pub total: usize,
77    pub allocated: usize,
78    pub active: usize,
79    pub draining: usize,
80    pub free: usize,
81}
82
83/// Summary of a single mapping for display.
84#[derive(Debug, Clone)]
85pub struct MappingInfo {
86    pub virtual_ip: Ipv6Addr,
87    pub mesh_addr: Ipv6Addr,
88    pub node_addr: NodeAddr,
89    pub dns_name: String,
90    pub state: MappingState,
91    pub session_count: u32,
92    pub age_secs: u64,
93    pub last_ref_secs: u64,
94}
95
96/// Trait for querying conntrack session counts.
97pub trait ConntrackQuerier: Send + Sync {
98    /// Returns the number of active conntrack entries whose original
99    /// destination matches the given virtual IP.
100    fn active_sessions(&self, virtual_ip: Ipv6Addr) -> Result<u32, std::io::Error>;
101}
102
103/// Conntrack querier that parses /proc/net/nf_conntrack.
104pub struct ProcConntrack;
105
106impl ConntrackQuerier for ProcConntrack {
107    fn active_sessions(&self, virtual_ip: Ipv6Addr) -> Result<u32, std::io::Error> {
108        let content = std::fs::read_to_string("/proc/net/nf_conntrack")?;
109        let target = virtual_ip.to_string();
110        let count = content
111            .lines()
112            .filter(|line| line.contains(&format!("dst={target}")))
113            .count();
114        Ok(count as u32)
115    }
116}
117
118/// Virtual IP pool manager.
119pub struct VirtualIpPool {
120    /// Available addresses (free pool).
121    available: VecDeque<Ipv6Addr>,
122    /// Active mappings keyed by NodeAddr.
123    mappings: HashMap<NodeAddr, VirtualIpMapping>,
124    /// Reverse map: virtual IP → NodeAddr.
125    reverse: HashMap<Ipv6Addr, NodeAddr>,
126    /// DNS TTL / mapping TTL in seconds.
127    ttl_secs: u64,
128    /// Grace period after last session before reclamation.
129    grace_secs: u64,
130    /// Total pool size.
131    total: usize,
132}
133
134impl VirtualIpPool {
135    /// Create a new pool from a CIDR string (e.g., `fd01::/112`).
136    pub fn new(cidr: &str, ttl_secs: u64, grace_secs: u64) -> Result<Self, PoolError> {
137        let (base, prefix_len) = parse_ipv6_cidr(cidr)?;
138        if prefix_len == 0 || prefix_len > 128 {
139            return Err(PoolError::InvalidPrefix);
140        }
141
142        let mut available = VecDeque::new();
143        let host_bits = 128 - prefix_len;
144
145        // Cap at 2^16 addresses to avoid massive allocations
146        let max_addrs: u128 = if host_bits > 16 {
147            1u128 << 16
148        } else {
149            1u128 << host_bits
150        };
151
152        let base_int = u128::from(base);
153        // Skip address 0 (network equivalent)
154        for i in 1..max_addrs {
155            available.push_back(Ipv6Addr::from(base_int + i));
156        }
157
158        let total = available.len();
159        info!(cidr = %cidr, addresses = total, "Virtual IP pool initialized");
160
161        Ok(Self {
162            available,
163            mappings: HashMap::new(),
164            reverse: HashMap::new(),
165            ttl_secs,
166            grace_secs,
167            total,
168        })
169    }
170
171    /// Allocate a virtual IP for the given node. Idempotent: returns
172    /// existing mapping if one exists.
173    pub fn allocate(
174        &mut self,
175        node_addr: NodeAddr,
176        mesh_addr: Ipv6Addr,
177        dns_name: &str,
178    ) -> Result<(Ipv6Addr, bool), PoolError> {
179        // Idempotent: return existing mapping
180        if let Some(mapping) = self.mappings.get_mut(&node_addr) {
181            mapping.last_referenced = Instant::now();
182            return Ok((mapping.virtual_ip, false));
183        }
184
185        let virtual_ip = self
186            .available
187            .pop_front()
188            .ok_or(PoolError::Exhausted(self.mappings.len()))?;
189
190        let now = Instant::now();
191        let mapping = VirtualIpMapping {
192            node_addr,
193            virtual_ip,
194            mesh_addr,
195            dns_name: dns_name.to_string(),
196            state: MappingState::Allocated,
197            created: now,
198            last_referenced: now,
199            drain_start: None,
200            session_count: 0,
201        };
202
203        self.mappings.insert(node_addr, mapping);
204        self.reverse.insert(virtual_ip, node_addr);
205
206        info!(
207            virtual_ip = %virtual_ip,
208            mesh_addr = %mesh_addr,
209            dns_name = %dns_name,
210            "Allocated virtual IP"
211        );
212
213        Ok((virtual_ip, true))
214    }
215
216    /// Periodic tick — drives state transitions. Returns events for
217    /// the NAT and network modules.
218    pub fn tick(&mut self, now: Instant, conntrack: &dyn ConntrackQuerier) -> Vec<PoolEvent> {
219        let mut events = Vec::new();
220        let mut to_free = Vec::new();
221        let ttl = std::time::Duration::from_secs(self.ttl_secs);
222        let grace = std::time::Duration::from_secs(self.grace_secs);
223
224        for (node_addr, mapping) in &mut self.mappings {
225            // Query conntrack for active sessions
226            let sessions = conntrack.active_sessions(mapping.virtual_ip).unwrap_or(0);
227            mapping.session_count = sessions;
228
229            if sessions > 0 {
230                mapping.last_referenced = now;
231            }
232
233            match mapping.state {
234                MappingState::Allocated => {
235                    if sessions > 0 {
236                        mapping.state = MappingState::Active;
237                        debug!(
238                            virtual_ip = %mapping.virtual_ip,
239                            sessions,
240                            "Mapping activated"
241                        );
242                    } else if now.duration_since(mapping.last_referenced) > ttl {
243                        // TTL expired — enter draining with grace period so
244                        // the mapping survives browser DNS cache, even if no
245                        // conntrack sessions were observed (short HTTP requests
246                        // may complete between ticks).
247                        mapping.state = MappingState::Draining;
248                        mapping.drain_start = Some(now);
249                        debug!(
250                            virtual_ip = %mapping.virtual_ip,
251                            "Allocated mapping TTL expired, draining"
252                        );
253                    }
254                }
255                MappingState::Active => {
256                    if now.duration_since(mapping.last_referenced) > ttl {
257                        mapping.state = MappingState::Draining;
258                        mapping.drain_start = Some(now);
259                    }
260                }
261                MappingState::Draining => {
262                    if sessions > 0 {
263                        mapping.state = MappingState::Active;
264                        mapping.drain_start = None;
265                        debug!(
266                            virtual_ip = %mapping.virtual_ip,
267                            sessions,
268                            "Draining mapping recovered to active (traffic resumed)"
269                        );
270                    } else if let Some(drain_start) = mapping.drain_start
271                        && now.duration_since(drain_start) > grace
272                    {
273                        to_free.push(*node_addr);
274                    }
275                }
276            }
277        }
278
279        // Free expired mappings
280        for node_addr in to_free {
281            if let Some(mapping) = self.mappings.remove(&node_addr) {
282                self.reverse.remove(&mapping.virtual_ip);
283                self.available.push_back(mapping.virtual_ip);
284                info!(
285                    virtual_ip = %mapping.virtual_ip,
286                    mesh_addr = %mapping.mesh_addr,
287                    "Reclaimed virtual IP"
288                );
289                events.push(PoolEvent::MappingRemoved {
290                    virtual_ip: mapping.virtual_ip,
291                    mesh_addr: mapping.mesh_addr,
292                });
293            }
294        }
295
296        events
297    }
298
299    /// Pool utilization summary.
300    pub fn status(&self) -> PoolStatus {
301        let mut allocated = 0;
302        let mut active = 0;
303        let mut draining = 0;
304        for mapping in self.mappings.values() {
305            match mapping.state {
306                MappingState::Allocated => allocated += 1,
307                MappingState::Active => active += 1,
308                MappingState::Draining => draining += 1,
309            }
310        }
311        PoolStatus {
312            total: self.total,
313            allocated,
314            active,
315            draining,
316            free: self.available.len(),
317        }
318    }
319
320    /// Summary of all active mappings.
321    pub fn mapping_info(&self, now: Instant) -> Vec<MappingInfo> {
322        self.mappings
323            .values()
324            .map(|m| MappingInfo {
325                virtual_ip: m.virtual_ip,
326                mesh_addr: m.mesh_addr,
327                node_addr: m.node_addr,
328                dns_name: m.dns_name.clone(),
329                state: m.state,
330                session_count: m.session_count,
331                age_secs: now.duration_since(m.created).as_secs(),
332                last_ref_secs: now.duration_since(m.last_referenced).as_secs(),
333            })
334            .collect()
335    }
336
337    /// Look up which node a virtual IP maps to.
338    pub fn lookup_virtual_ip(&self, virtual_ip: &Ipv6Addr) -> Option<&VirtualIpMapping> {
339        self.reverse
340            .get(virtual_ip)
341            .and_then(|addr| self.mappings.get(addr))
342    }
343}
344
345/// Parse an IPv6 CIDR string into base address and prefix length.
346fn parse_ipv6_cidr(cidr: &str) -> Result<(Ipv6Addr, u32), PoolError> {
347    let parts: Vec<&str> = cidr.split('/').collect();
348    if parts.len() != 2 {
349        return Err(PoolError::InvalidCidr(cidr.to_string()));
350    }
351    let addr: Ipv6Addr = parts[0]
352        .parse()
353        .map_err(|_| PoolError::InvalidCidr(cidr.to_string()))?;
354    let prefix: u32 = parts[1]
355        .parse()
356        .map_err(|_| PoolError::InvalidCidr(cidr.to_string()))?;
357    Ok((addr, prefix))
358}
359
360#[cfg(test)]
361mod tests {
362    use super::*;
363
364    /// Mock conntrack that returns a configurable session count.
365    struct MockConntrack {
366        counts: HashMap<Ipv6Addr, u32>,
367    }
368
369    impl MockConntrack {
370        fn new() -> Self {
371            Self {
372                counts: HashMap::new(),
373            }
374        }
375
376        fn set(&mut self, addr: Ipv6Addr, count: u32) {
377            self.counts.insert(addr, count);
378        }
379    }
380
381    impl ConntrackQuerier for MockConntrack {
382        fn active_sessions(&self, virtual_ip: Ipv6Addr) -> Result<u32, std::io::Error> {
383            Ok(*self.counts.get(&virtual_ip).unwrap_or(&0))
384        }
385    }
386
387    fn make_node_addr(byte: u8) -> NodeAddr {
388        let mut bytes = [0u8; 16];
389        bytes[0] = byte;
390        NodeAddr::from_bytes(bytes)
391    }
392
393    fn make_mesh_addr(byte: u8) -> Ipv6Addr {
394        let mut bytes = [0u8; 16];
395        bytes[0] = 0xfd;
396        bytes[15] = byte;
397        Ipv6Addr::from(bytes)
398    }
399
400    #[test]
401    fn test_parse_cidr() {
402        let (addr, prefix) = parse_ipv6_cidr("fd01::/112").unwrap();
403        assert_eq!(addr, "fd01::".parse::<Ipv6Addr>().unwrap());
404        assert_eq!(prefix, 112);
405    }
406
407    #[test]
408    fn test_parse_cidr_invalid() {
409        assert!(parse_ipv6_cidr("not-a-cidr").is_err());
410        assert!(parse_ipv6_cidr("fd01::").is_err());
411        assert!(parse_ipv6_cidr("fd01::/abc").is_err());
412    }
413
414    #[test]
415    fn test_pool_creation() {
416        let pool = VirtualIpPool::new("fd01::/120", 60, 60).unwrap();
417        // /120 = 8 host bits = 256 addresses, minus 1 (network) = 255
418        assert_eq!(pool.total, 255);
419        assert_eq!(pool.available.len(), 255);
420    }
421
422    #[test]
423    fn test_pool_allocation() {
424        let mut pool = VirtualIpPool::new("fd01::/120", 60, 60).unwrap();
425        let node = make_node_addr(1);
426        let mesh = make_mesh_addr(1);
427
428        let (vip, is_new) = pool.allocate(node, mesh, "test.fips").unwrap();
429        assert!(is_new);
430        assert_eq!(vip, "fd01::1".parse::<Ipv6Addr>().unwrap());
431        assert_eq!(pool.available.len(), 254);
432    }
433
434    #[test]
435    fn test_pool_idempotent() {
436        let mut pool = VirtualIpPool::new("fd01::/120", 60, 60).unwrap();
437        let node = make_node_addr(1);
438        let mesh = make_mesh_addr(1);
439
440        let (vip1, new1) = pool.allocate(node, mesh, "test.fips").unwrap();
441        let (vip2, new2) = pool.allocate(node, mesh, "test.fips").unwrap();
442        assert!(new1);
443        assert!(!new2);
444        assert_eq!(vip1, vip2);
445        assert_eq!(pool.available.len(), 254);
446    }
447
448    #[test]
449    fn test_pool_exhaustion() {
450        // /126 = 2 host bits = 4 addresses, minus 1 = 3
451        let mut pool = VirtualIpPool::new("fd01::/126", 60, 60).unwrap();
452        assert_eq!(pool.total, 3);
453
454        for i in 1..=3u8 {
455            pool.allocate(make_node_addr(i), make_mesh_addr(i), "test.fips")
456                .unwrap();
457        }
458        assert!(
459            pool.allocate(make_node_addr(4), make_mesh_addr(4), "test.fips")
460                .is_err()
461        );
462    }
463
464    #[test]
465    fn test_mapping_lifecycle_allocated_to_free() {
466        let mut pool = VirtualIpPool::new("fd01::/120", 1, 1).unwrap();
467        let ct = MockConntrack::new();
468        let node = make_node_addr(1);
469        let mesh = make_mesh_addr(1);
470
471        pool.allocate(node, mesh, "test.fips").unwrap();
472
473        // Tick before TTL — no change
474        let now = Instant::now();
475        let events = pool.tick(now, &ct);
476        assert!(events.is_empty());
477        assert_eq!(pool.mappings.len(), 1);
478
479        // Tick after TTL with no sessions — enters draining
480        let later = now + std::time::Duration::from_secs(2);
481        let events = pool.tick(later, &ct);
482        assert!(events.is_empty());
483        assert_eq!(pool.mappings.len(), 1);
484        assert_eq!(
485            pool.mappings.values().next().unwrap().state,
486            MappingState::Draining
487        );
488
489        // Tick after grace period — freed
490        let after_grace = later + std::time::Duration::from_secs(2);
491        let events = pool.tick(after_grace, &ct);
492        assert_eq!(events.len(), 1);
493        assert!(matches!(events[0], PoolEvent::MappingRemoved { .. }));
494        assert_eq!(pool.mappings.len(), 0);
495        assert_eq!(pool.available.len(), 255); // returned to pool
496    }
497
498    #[test]
499    fn test_mapping_lifecycle_active_draining_free() {
500        let mut pool = VirtualIpPool::new("fd01::/120", 1, 1).unwrap();
501        let mut ct = MockConntrack::new();
502        let node = make_node_addr(1);
503        let mesh = make_mesh_addr(1);
504
505        let (vip, _) = pool.allocate(node, mesh, "test.fips").unwrap();
506
507        // Simulate active sessions
508        ct.set(vip, 3);
509        let now = Instant::now();
510        let events = pool.tick(now, &ct);
511        assert!(events.is_empty());
512        assert_eq!(pool.mappings[&node].state, MappingState::Active);
513
514        // TTL expires after sessions drop to 0 → Draining
515        let later = now + std::time::Duration::from_secs(2);
516        ct.set(vip, 0);
517        let events = pool.tick(later, &ct);
518        assert!(events.is_empty());
519        assert_eq!(pool.mappings[&node].state, MappingState::Draining);
520
521        // Still draining, grace period not elapsed
522        let events = pool.tick(later, &ct);
523        assert!(events.is_empty());
524        assert_eq!(pool.mappings[&node].state, MappingState::Draining);
525
526        // Grace period elapsed → Free
527        let much_later = later + std::time::Duration::from_secs(2);
528        let events = pool.tick(much_later, &ct);
529        assert_eq!(events.len(), 1);
530        assert!(matches!(events[0], PoolEvent::MappingRemoved { .. }));
531        assert_eq!(pool.mappings.len(), 0);
532    }
533
534    #[test]
535    fn test_active_traffic_never_reclaimed() {
536        let mut pool = VirtualIpPool::new("fd01::/120", 1, 1).unwrap();
537        let mut ct = MockConntrack::new();
538        let node = make_node_addr(1);
539        let mesh = make_mesh_addr(1);
540
541        let (vip, _) = pool.allocate(node, mesh, "test.fips").unwrap();
542        ct.set(vip, 2);
543
544        let mut now = Instant::now();
545        let events = pool.tick(now, &ct);
546        assert!(events.is_empty());
547        assert_eq!(pool.mappings[&node].state, MappingState::Active);
548
549        for _ in 0..10 {
550            now += std::time::Duration::from_secs(5);
551            let events = pool.tick(now, &ct);
552            assert!(events.is_empty(), "mapping must not be reclaimed");
553            assert_eq!(
554                pool.mappings[&node].state,
555                MappingState::Active,
556                "mapping must stay active while traffic flows"
557            );
558        }
559
560        assert_eq!(pool.mappings.len(), 1);
561    }
562
563    #[test]
564    fn test_bursty_draining_recovers_to_active() {
565        let mut pool = VirtualIpPool::new("fd01::/120", 1, 5).unwrap();
566        let mut ct = MockConntrack::new();
567        let node = make_node_addr(1);
568        let mesh = make_mesh_addr(1);
569
570        let (vip, _) = pool.allocate(node, mesh, "test.fips").unwrap();
571        ct.set(vip, 1);
572        let now = Instant::now();
573        let events = pool.tick(now, &ct);
574        assert!(events.is_empty());
575        assert_eq!(pool.mappings[&node].state, MappingState::Active);
576
577        let drained = now + std::time::Duration::from_secs(2);
578        ct.set(vip, 0);
579        let events = pool.tick(drained, &ct);
580        assert!(events.is_empty());
581        assert_eq!(pool.mappings[&node].state, MappingState::Draining);
582
583        let resumed = drained + std::time::Duration::from_secs(2);
584        ct.set(vip, 3);
585        let events = pool.tick(resumed, &ct);
586        assert!(events.is_empty());
587        assert_eq!(pool.mappings[&node].state, MappingState::Active);
588        assert!(pool.mappings[&node].drain_start.is_none());
589        assert_eq!(pool.mappings.len(), 1);
590    }
591
592    #[test]
593    fn test_redrain_honors_fresh_grace_window() {
594        let mut pool = VirtualIpPool::new("fd01::/120", 1, 5).unwrap();
595        let mut ct = MockConntrack::new();
596        let node = make_node_addr(1);
597        let mesh = make_mesh_addr(1);
598
599        let (vip, _) = pool.allocate(node, mesh, "test.fips").unwrap();
600
601        ct.set(vip, 1);
602        let now = Instant::now();
603        pool.tick(now, &ct);
604        assert_eq!(pool.mappings[&node].state, MappingState::Active);
605
606        let first_drain = now + std::time::Duration::from_secs(2);
607        ct.set(vip, 0);
608        pool.tick(first_drain, &ct);
609        assert_eq!(pool.mappings[&node].state, MappingState::Draining);
610
611        let recover = first_drain + std::time::Duration::from_secs(2);
612        ct.set(vip, 2);
613        pool.tick(recover, &ct);
614        assert_eq!(pool.mappings[&node].state, MappingState::Active);
615
616        let second_drain = recover + std::time::Duration::from_secs(2);
617        ct.set(vip, 0);
618        pool.tick(second_drain, &ct);
619        assert_eq!(pool.mappings[&node].state, MappingState::Draining);
620
621        let before_grace = second_drain + std::time::Duration::from_secs(4);
622        let events = pool.tick(before_grace, &ct);
623        assert!(events.is_empty(), "fresh grace window must be honored");
624        assert_eq!(pool.mappings.len(), 1);
625
626        let after_grace = second_drain + std::time::Duration::from_secs(6);
627        let events = pool.tick(after_grace, &ct);
628        assert_eq!(events.len(), 1);
629        assert!(matches!(events[0], PoolEvent::MappingRemoved { .. }));
630        assert_eq!(pool.mappings.len(), 0);
631    }
632
633    #[test]
634    fn test_pool_status() {
635        let mut pool = VirtualIpPool::new("fd01::/120", 60, 60).unwrap();
636        let status = pool.status();
637        assert_eq!(status.total, 255);
638        assert_eq!(status.free, 255);
639        assert_eq!(status.allocated, 0);
640
641        pool.allocate(make_node_addr(1), make_mesh_addr(1), "test.fips")
642            .unwrap();
643        let status = pool.status();
644        assert_eq!(status.allocated, 1);
645        assert_eq!(status.free, 254);
646    }
647
648    #[test]
649    fn test_lookup_virtual_ip() {
650        let mut pool = VirtualIpPool::new("fd01::/120", 60, 60).unwrap();
651        let node = make_node_addr(1);
652        let mesh = make_mesh_addr(1);
653
654        let (vip, _) = pool.allocate(node, mesh, "test.fips").unwrap();
655        let mapping = pool.lookup_virtual_ip(&vip).unwrap();
656        assert_eq!(mapping.node_addr, node);
657        assert_eq!(mapping.mesh_addr, mesh);
658
659        let unknown: Ipv6Addr = "fd01::ff".parse().unwrap();
660        assert!(pool.lookup_virtual_ip(&unknown).is_none());
661    }
662
663    #[test]
664    fn test_large_prefix_capped() {
665        // /96 = 32 host bits, but pool caps at 2^16
666        let pool = VirtualIpPool::new("fd01::/96", 60, 60).unwrap();
667        assert_eq!(pool.total, 65535); // 2^16 - 1 (skip addr 0)
668    }
669}