freenet_test_network/
docker.rs

1//! Docker-based NAT simulation backend for testing Freenet in isolated networks.
2//!
3//! This module provides infrastructure to run Freenet peers in Docker containers
4//! behind simulated NAT routers, allowing detection of bugs that only manifest
5//! when peers are on different networks.
6
7use crate::{logs::LogEntry, process::PeerProcess, Error, Result};
8use bollard::{
9    container::{
10        Config, CreateContainerOptions, LogOutput, LogsOptions, RemoveContainerOptions,
11        StartContainerOptions, StopContainerOptions, UploadToContainerOptions,
12    },
13    exec::{CreateExecOptions, StartExecResults},
14    image::BuildImageOptions,
15    network::CreateNetworkOptions,
16    secret::{ContainerStateStatusEnum, HostConfig, Ipam, IpamConfig, PortBinding},
17    Docker,
18};
19use futures::StreamExt;
20use ipnetwork::Ipv4Network;
21use rand::Rng;
22use std::{
23    collections::HashMap,
24    net::Ipv4Addr,
25    path::{Path, PathBuf},
26    time::Duration,
27};
28
29/// Configuration for Docker NAT simulation
30#[derive(Debug, Clone)]
31pub struct DockerNatConfig {
32    /// NAT topology configuration
33    pub topology: NatTopology,
34    /// Base subnet for public network (gateway network)
35    pub public_subnet: Ipv4Network,
36    /// Base for private network subnets (each NAT gets one)
37    pub private_subnet_base: Ipv4Addr,
38    /// Whether to remove containers on drop
39    pub cleanup_on_drop: bool,
40    /// Prefix for container and network names
41    pub name_prefix: String,
42}
43
44impl Default for DockerNatConfig {
45    fn default() -> Self {
46        // Generate timestamp-based prefix for easier identification of stale resources
47        let timestamp = chrono::Utc::now().format("%Y%m%d-%H%M%S").to_string();
48        let random_id = rand::thread_rng().gen::<u16>();
49        let name_prefix = format!("freenet-nat-{}-{}", timestamp, random_id);
50
51        // Randomize the second octet (16-31) to avoid subnet overlap when running
52        // multiple tests sequentially. Docker cannot create networks with overlapping
53        // subnets, so each test run needs a unique subnet range.
54        // Using 172.16.0.0/12 private range: 172.16-31.x.x
55        // Use /16 subnet to allow peers in different /24s (different ring locations)
56        let second_octet = rand::thread_rng().gen_range(16..=31);
57        let public_subnet = format!("172.{}.0.0/16", second_octet).parse().unwrap();
58
59        // Also randomize the private subnet base to avoid conflicts
60        // Using 10.x.0.0 range with random first octet portion
61        let private_first_octet = rand::thread_rng().gen_range(1..=250);
62
63        Self {
64            topology: NatTopology::OnePerNat,
65            public_subnet,
66            private_subnet_base: Ipv4Addr::new(10, private_first_octet, 0, 0),
67            cleanup_on_drop: true,
68            name_prefix,
69        }
70    }
71}
72
73/// How peers are distributed across NAT networks
74#[derive(Debug, Clone)]
75pub enum NatTopology {
76    /// Each peer (except gateways) gets its own NAT network
77    OnePerNat,
78    /// Specific assignment of peers to NAT networks
79    Custom(Vec<NatNetwork>),
80}
81
82/// A NAT network containing one or more peers
83#[derive(Debug, Clone)]
84pub struct NatNetwork {
85    pub name: String,
86    pub peer_indices: Vec<usize>,
87    pub nat_type: NatType,
88}
89
90/// Type of NAT simulation
91#[derive(Debug, Clone, Default)]
92pub enum NatType {
93    /// Outbound MASQUERADE only - most common residential NAT
94    #[default]
95    RestrictedCone,
96    /// MASQUERADE + port forwarding for specified ports
97    FullCone { forwarded_ports: Option<Vec<u16>> },
98}
99
100/// Manages Docker resources for NAT simulation
101pub struct DockerNatBackend {
102    docker: Docker,
103    config: DockerNatConfig,
104    /// Network IDs created by this backend
105    networks: Vec<String>,
106    /// Container IDs created by this backend (NAT routers + peers)
107    containers: Vec<String>,
108    /// Mapping from peer index to container info
109    peer_containers: HashMap<usize, DockerPeerInfo>,
110    /// ID of the public network
111    public_network_id: Option<String>,
112}
113
114/// Information about a peer running in a Docker container
115#[derive(Debug, Clone)]
116pub struct DockerPeerInfo {
117    pub container_id: String,
118    pub container_name: String,
119    /// IP address on private network (behind NAT)
120    pub private_ip: Ipv4Addr,
121    /// IP address on public network (for gateways) or NAT router's public IP (for peers)
122    pub public_ip: Ipv4Addr,
123    /// Port mapped to host for WebSocket API access
124    pub host_ws_port: u16,
125    /// Network port inside container
126    pub network_port: u16,
127    /// Whether this is a gateway (not behind NAT)
128    pub is_gateway: bool,
129    /// NAT router container ID (None for gateways)
130    pub nat_router_id: Option<String>,
131}
132
133/// A peer process running in a Docker container
134pub struct DockerProcess {
135    docker: Docker,
136    container_id: String,
137    container_name: String,
138    local_log_cache: PathBuf,
139}
140
141impl PeerProcess for DockerProcess {
142    fn is_running(&self) -> bool {
143        // Use blocking runtime to check container status
144        let docker = self.docker.clone();
145        let id = self.container_id.clone();
146
147        tokio::task::block_in_place(|| {
148            tokio::runtime::Handle::current().block_on(async {
149                match docker.inspect_container(&id, None).await {
150                    Ok(info) => info
151                        .state
152                        .and_then(|s| s.status)
153                        .map(|s| s == ContainerStateStatusEnum::RUNNING)
154                        .unwrap_or(false),
155                    Err(_) => false,
156                }
157            })
158        })
159    }
160
161    fn kill(&mut self) -> Result<()> {
162        let docker = self.docker.clone();
163        let id = self.container_id.clone();
164
165        tokio::task::block_in_place(|| {
166            tokio::runtime::Handle::current().block_on(async {
167                // Stop container with timeout
168                let _ = docker
169                    .stop_container(&id, Some(StopContainerOptions { t: 5 }))
170                    .await;
171                Ok(())
172            })
173        })
174    }
175
176    fn log_path(&self) -> PathBuf {
177        self.local_log_cache.clone()
178    }
179
180    fn read_logs(&self) -> Result<Vec<LogEntry>> {
181        let docker = self.docker.clone();
182        let id = self.container_id.clone();
183        let cache_path = self.local_log_cache.clone();
184
185        tokio::task::block_in_place(|| {
186            tokio::runtime::Handle::current().block_on(async {
187                // Fetch logs from container
188                let options = LogsOptions::<String> {
189                    stdout: true,
190                    stderr: true,
191                    timestamps: true,
192                    ..Default::default()
193                };
194
195                let mut logs = docker.logs(&id, Some(options));
196                let mut log_content = String::new();
197
198                while let Some(log_result) = logs.next().await {
199                    match log_result {
200                        Ok(LogOutput::StdOut { message }) | Ok(LogOutput::StdErr { message }) => {
201                            log_content.push_str(&String::from_utf8_lossy(&message));
202                        }
203                        _ => {}
204                    }
205                }
206
207                // Write to cache file
208                if let Some(parent) = cache_path.parent() {
209                    std::fs::create_dir_all(parent)?;
210                }
211                std::fs::write(&cache_path, &log_content)?;
212
213                // Parse logs
214                crate::logs::read_log_file(&cache_path)
215            })
216        })
217    }
218}
219
220impl Drop for DockerProcess {
221    fn drop(&mut self) {
222        let _ = self.kill();
223    }
224}
225
226impl DockerNatBackend {
227    /// Create a new Docker NAT backend
228    pub async fn new(config: DockerNatConfig) -> Result<Self> {
229        let docker = Docker::connect_with_local_defaults()
230            .map_err(|e| Error::Other(anyhow::anyhow!("Failed to connect to Docker: {}", e)))?;
231
232        // Verify Docker is accessible
233        docker
234            .ping()
235            .await
236            .map_err(|e| Error::Other(anyhow::anyhow!("Docker ping failed: {}", e)))?;
237
238        // Clean up stale resources before creating new ones.
239        // Use a short max_age (10 seconds) to remove resources from previous test runs
240        // while preserving any resources created in the current session. This prevents
241        // "Pool overlaps with other one on this address space" errors when tests
242        // run sequentially in the same process.
243        Self::cleanup_stale_resources(&docker, Duration::from_secs(10)).await?;
244
245        Ok(Self {
246            docker,
247            config,
248            networks: Vec::new(),
249            containers: Vec::new(),
250            peer_containers: HashMap::new(),
251            public_network_id: None,
252        })
253    }
254
255    /// Clean up stale Docker resources older than the specified duration
256    ///
257    /// This removes containers and networks matching the "freenet-nat-" prefix
258    /// that are older than `max_age`. Pass `Duration::ZERO` to clean up ALL
259    /// matching resources regardless of age.
260    async fn cleanup_stale_resources(docker: &Docker, max_age: Duration) -> Result<()> {
261        use bollard::container::ListContainersOptions;
262        use bollard::network::ListNetworksOptions;
263
264        let now = std::time::SystemTime::now();
265        let now_secs = now.duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() as i64;
266        // If max_age is zero, set cutoff to future to match everything
267        let cutoff = if max_age.is_zero() {
268            i64::MAX // Match everything
269        } else {
270            now_secs - max_age.as_secs() as i64
271        };
272
273        if max_age.is_zero() {
274            tracing::debug!("Cleaning up ALL freenet-nat resources");
275        } else {
276            tracing::debug!(
277                "Cleaning up freenet-nat resources older than {} seconds",
278                max_age.as_secs()
279            );
280        }
281
282        // Clean up stale containers
283        let mut filters = HashMap::new();
284        filters.insert("name".to_string(), vec!["freenet-nat-".to_string()]);
285
286        let options = ListContainersOptions {
287            all: true,
288            filters,
289            ..Default::default()
290        };
291
292        match docker.list_containers(Some(options)).await {
293            Ok(containers) => {
294                let mut removed_count = 0;
295                for container in containers {
296                    // Parse timestamp from container name
297                    if let Some(name) = container.names.and_then(|n| n.first().cloned()) {
298                        if let Some(created) = container.created {
299                            if created < cutoff {
300                                if let Some(id) = container.id {
301                                    tracing::info!(
302                                        "Removing stale container: {} (age: {}s)",
303                                        name,
304                                        now.duration_since(std::time::UNIX_EPOCH).unwrap().as_secs()
305                                            as i64
306                                            - created
307                                    );
308                                    let _ = docker
309                                        .stop_container(&id, Some(StopContainerOptions { t: 2 }))
310                                        .await;
311                                    let _ = docker
312                                        .remove_container(
313                                            &id,
314                                            Some(RemoveContainerOptions {
315                                                force: true,
316                                                ..Default::default()
317                                            }),
318                                        )
319                                        .await;
320                                    removed_count += 1;
321                                }
322                            }
323                        }
324                    }
325                }
326                if removed_count > 0 {
327                    tracing::info!("Removed {} stale container(s)", removed_count);
328                }
329            }
330            Err(e) => {
331                tracing::warn!("Failed to list containers for cleanup: {}", e);
332            }
333        }
334
335        // Clean up stale networks
336        let mut filters = HashMap::new();
337        filters.insert("name".to_string(), vec!["freenet-nat-".to_string()]);
338
339        let options = ListNetworksOptions { filters };
340
341        match docker.list_networks(Some(options)).await {
342            Ok(networks) => {
343                let mut removed_count = 0;
344                for network in networks {
345                    if let Some(name) = &network.name {
346                        if name.starts_with("freenet-nat-") {
347                            // Parse timestamp from network name (format: freenet-nat-YYYYMMDD-HHMMSS-xxxxx)
348                            if let Some(timestamp_str) = name.strip_prefix("freenet-nat-") {
349                                // Extract YYYYMMDD-HHMMSS part
350                                let parts: Vec<&str> = timestamp_str.split('-').collect();
351                                if parts.len() >= 2 {
352                                    let date_time = format!("{}-{}", parts[0], parts[1]);
353                                    if let Ok(created_time) = chrono::NaiveDateTime::parse_from_str(
354                                        &date_time,
355                                        "%Y%m%d-%H%M%S",
356                                    ) {
357                                        let created_timestamp = created_time.and_utc().timestamp();
358                                        if created_timestamp < cutoff {
359                                            if let Some(id) = &network.id {
360                                                tracing::info!(
361                                                    "Removing stale network: {} (age: {}s)",
362                                                    name,
363                                                    now.duration_since(std::time::UNIX_EPOCH)
364                                                        .unwrap()
365                                                        .as_secs()
366                                                        as i64
367                                                        - created_timestamp
368                                                );
369                                                let _ = docker.remove_network(id).await;
370                                                removed_count += 1;
371                                            }
372                                        }
373                                    }
374                                }
375                            }
376                        }
377                    }
378                }
379                if removed_count > 0 {
380                    tracing::info!("Removed {} stale network(s)", removed_count);
381                }
382            }
383            Err(e) => {
384                tracing::warn!("Failed to list networks for cleanup: {}", e);
385            }
386        }
387
388        Ok(())
389    }
390
391    /// Create the public network where gateways live
392    ///
393    /// If the initially chosen subnet conflicts with an existing Docker network,
394    /// this will retry with a different random subnet up to MAX_SUBNET_RETRIES times.
395    pub async fn create_public_network(&mut self) -> Result<String> {
396        const MAX_SUBNET_RETRIES: usize = 10;
397
398        for attempt in 0..MAX_SUBNET_RETRIES {
399            let network_name = format!("{}-public", self.config.name_prefix);
400
401            let options = CreateNetworkOptions {
402                name: network_name.clone(),
403                driver: "bridge".to_string(),
404                ipam: Ipam {
405                    config: Some(vec![IpamConfig {
406                        subnet: Some(self.config.public_subnet.to_string()),
407                        ..Default::default()
408                    }]),
409                    ..Default::default()
410                },
411                ..Default::default()
412            };
413
414            match self.docker.create_network(options).await {
415                Ok(response) => {
416                    let network_id = response.id;
417                    self.networks.push(network_id.clone());
418                    self.public_network_id = Some(network_id.clone());
419                    tracing::info!(
420                        "Created public network: {} ({}) with subnet {}",
421                        network_name,
422                        network_id,
423                        self.config.public_subnet
424                    );
425                    return Ok(network_id);
426                }
427                Err(e) => {
428                    let error_msg = e.to_string();
429                    if error_msg.contains("Pool overlaps") {
430                        // Subnet conflict - pick a new random subnet and retry
431                        let old_subnet = self.config.public_subnet;
432                        let new_second_octet = rand::thread_rng().gen_range(16..=31);
433                        self.config.public_subnet =
434                            format!("172.{}.0.0/16", new_second_octet).parse().unwrap();
435                        tracing::warn!(
436                            "Subnet {} conflicts with existing network, retrying with {} (attempt {}/{})",
437                            old_subnet,
438                            self.config.public_subnet,
439                            attempt + 1,
440                            MAX_SUBNET_RETRIES
441                        );
442                        continue;
443                    }
444                    return Err(Error::Other(anyhow::anyhow!(
445                        "Failed to create public network: {}",
446                        e
447                    )));
448                }
449            }
450        }
451
452        Err(Error::Other(anyhow::anyhow!(
453            "Failed to create public network after {} attempts due to subnet conflicts. \
454             This may indicate stale Docker networks. Try running: \
455             docker network ls | grep freenet-nat | awk '{{print $1}}' | xargs -r docker network rm",
456            MAX_SUBNET_RETRIES
457        )))
458    }
459
460    /// Create a private network behind NAT for a peer
461    pub async fn create_nat_network(
462        &mut self,
463        peer_index: usize,
464    ) -> Result<(String, String, Ipv4Addr)> {
465        // Create private network using randomized base to avoid subnet conflicts
466        // between concurrent test runs. Each peer gets its own /24 subnet.
467        let network_name = format!("{}-nat-{}", self.config.name_prefix, peer_index);
468        let base = self.config.private_subnet_base.octets();
469        let subnet = Ipv4Network::new(
470            Ipv4Addr::new(base[0], base[1].wrapping_add(peer_index as u8), 0, 0),
471            24,
472        )
473        .map_err(|e| Error::Other(anyhow::anyhow!("Invalid subnet: {}", e)))?;
474
475        let options = CreateNetworkOptions {
476            name: network_name.clone(),
477            driver: "bridge".to_string(),
478            internal: true, // No direct external access
479            ipam: Ipam {
480                config: Some(vec![IpamConfig {
481                    subnet: Some(subnet.to_string()),
482                    ..Default::default()
483                }]),
484                ..Default::default()
485            },
486            ..Default::default()
487        };
488
489        let response =
490            self.docker.create_network(options).await.map_err(|e| {
491                Error::Other(anyhow::anyhow!("Failed to create NAT network: {}", e))
492            })?;
493
494        let network_id = response.id;
495        self.networks.push(network_id.clone());
496
497        // Create NAT router container
498        let router_name = format!("{}-router-{}", self.config.name_prefix, peer_index);
499        let public_network_id = self
500            .public_network_id
501            .as_ref()
502            .ok_or_else(|| Error::Other(anyhow::anyhow!("Public network not created yet")))?;
503
504        // NAT router IP addresses
505        // Each peer gets an IP in a different /24 subnet to ensure different ring locations
506        // E.g., peer 0 -> 172.X.0.100, peer 1 -> 172.X.1.100, peer 2 -> 172.X.2.100
507        // This way, Location::from_address (which masks last byte) gives each peer a different location
508        let router_public_ip = Ipv4Addr::new(
509            self.config.public_subnet.ip().octets()[0],
510            self.config.public_subnet.ip().octets()[1],
511            peer_index as u8, // Different /24 per peer for unique ring locations
512            100,              // Fixed host part within each /24
513        );
514        // Use .254 for router to avoid conflict with Docker's default gateway at .1
515        let router_private_ip =
516            Ipv4Addr::new(base[0], base[1].wrapping_add(peer_index as u8), 0, 254);
517
518        // Create router container with iptables NAT rules
519        // Create without network first, then connect to both networks before starting
520        // Build patterns for matching the public and private networks
521        let public_octets = self.config.public_subnet.ip().octets();
522        let public_pattern = format!("172\\.{}\\.", public_octets[1]);
523        let private_pattern = format!(" {}\\.", base[0]);
524        // Calculate peer's private IP (matches what create_peer will use)
525        let peer_private_ip = Ipv4Addr::new(base[0], base[1].wrapping_add(peer_index as u8), 0, 2);
526
527        // Build iptables rules based on NAT type
528        //
529        // NAT Types (from most permissive to most restrictive):
530        // 1. Full Cone: Any external host can send to mapped port (like port forwarding)
531        // 2. Address-Restricted Cone: Only hosts the peer has contacted can send back
532        // 3. Port-Restricted Cone: Only host:port pairs the peer has contacted can send back
533        // 4. Symmetric: Different mapping for each destination (breaks hole punching)
534        //
535        // Default: Port-Restricted Cone NAT - the most common residential NAT type
536        // This requires proper UDP hole-punching: peer must send packet to remote's public
537        // IP:port first, which creates a NAT mapping that allows return traffic.
538        //
539        // The key insight: Linux conntrack already provides port-restricted cone behavior
540        // by default with MASQUERADE - it allows return traffic from the exact IP:port
541        // that received outbound traffic. We just need to NOT add blanket DNAT rules.
542        let dnat_rules = if std::env::var("FREENET_TEST_FULL_CONE_NAT").is_ok() {
543            // Full Cone NAT: Add DNAT rules to forward all traffic on port 31337 to peer
544            // This simulates port forwarding / UPnP - unrealistic for testing hole punching
545            format!(
546                "iptables -t nat -A PREROUTING -i $PUBLIC_IF -p udp --dport 31337 -j DNAT --to-destination {}:31337 && \
547                 echo 'Full Cone NAT: DNAT rule added for port 31337 -> {}:31337' && ",
548                peer_private_ip, peer_private_ip
549            )
550        } else if std::env::var("FREENET_TEST_SYMMETRIC_NAT").is_ok() {
551            // Symmetric NAT: Use random source ports for each destination
552            // This breaks UDP hole punching entirely
553            format!(
554                "iptables -t nat -A POSTROUTING -o $PUBLIC_IF -p udp -j MASQUERADE --random && \
555                 echo 'Symmetric NAT: Random port mapping enabled (hole punching will fail)' && "
556            )
557        } else {
558            // Port-Restricted Cone NAT (default): Realistic residential NAT
559            //
560            // Residential NATs typically have two properties (RFC 4787):
561            // 1. Endpoint-Independent Mapping (EIM): Same internal IP:port maps to same
562            //    external port regardless of destination
563            // 2. Port-Restricted Cone Filtering: Only allow inbound from IP:port pairs
564            //    that we've previously sent to (handled by conntrack)
565            //
566            // Implementation:
567            // - DNAT: Forward incoming UDP/31337 to internal peer (enables hole punching)
568            // - SNAT: Preserve port 31337 on outbound (EIM behavior)
569            // - Conntrack handles port-restricted filtering automatically
570            //
571            // Note: Without DNAT, incoming packets to the NAT's public IP are delivered
572            // to the router itself (INPUT chain) rather than forwarded to the internal peer.
573            format!(
574                "echo 'Port-Restricted Cone NAT: EIM + port-restricted filtering' && \
575                 iptables -t nat -A PREROUTING -i $PUBLIC_IF -p udp --dport 31337 -j DNAT --to-destination {}:31337 && \
576                 iptables -t nat -A POSTROUTING -o $PUBLIC_IF -p udp --sport 31337 -j SNAT --to-source $PUBLIC_IP:31337 && ",
577                peer_private_ip
578            )
579        };
580
581        let router_config = Config {
582            image: Some("alpine:latest".to_string()),
583            hostname: Some(router_name.clone()),
584            cmd: Some(vec![
585                "sh".to_string(),
586                "-c".to_string(),
587                // Set up NAT (IP forwarding enabled via sysctl in host_config)
588                // Find interfaces dynamically by IP address since Docker doesn't guarantee interface order
589                // PUBLIC_IF: interface with 172.X.x.x (public network, X varies)
590                // PRIVATE_IF: interface with 10.x.x.x (private network)
591                format!(
592                    "apk add --no-cache iptables iproute2 > /dev/null 2>&1 && \
593                     PUBLIC_IF=$(ip -o addr show | grep '{}' | awk '{{print $2}}') && \
594                     PRIVATE_IF=$(ip -o addr show | grep '{}' | awk '{{print $2}}') && \
595                     PUBLIC_IP=$(ip -o addr show dev $PUBLIC_IF | awk '/inet / {{split($4,a,\"/\"); print a[1]}}') && \
596                     echo \"Public interface: $PUBLIC_IF ($PUBLIC_IP), Private interface: $PRIVATE_IF\" && \
597                     {}iptables -t nat -A POSTROUTING -o $PUBLIC_IF -j MASQUERADE && \
598                     iptables -A FORWARD -i $PRIVATE_IF -o $PUBLIC_IF -j ACCEPT && \
599                     iptables -A FORWARD -i $PUBLIC_IF -o $PRIVATE_IF -j ACCEPT && \
600                     echo 'NAT router ready' && \
601                     tail -f /dev/null",
602                    public_pattern, private_pattern, dnat_rules
603                ),
604            ]),
605            host_config: Some(HostConfig {
606                cap_add: Some(vec!["NET_ADMIN".to_string()]),
607                sysctls: Some(HashMap::from([
608                    ("net.ipv4.ip_forward".to_string(), "1".to_string()),
609                ])),
610                ..Default::default()
611            }),
612            ..Default::default()
613        };
614
615        let router_id = self
616            .docker
617            .create_container(
618                Some(CreateContainerOptions {
619                    name: router_name.clone(),
620                    ..Default::default()
621                }),
622                router_config,
623            )
624            .await
625            .map_err(|e| Error::Other(anyhow::anyhow!("Failed to create NAT router: {}", e)))?
626            .id;
627
628        self.containers.push(router_id.clone());
629
630        // Disconnect from default bridge network
631        let _ = self
632            .docker
633            .disconnect_network(
634                "bridge",
635                bollard::network::DisconnectNetworkOptions {
636                    container: router_id.clone(),
637                    force: true,
638                },
639            )
640            .await;
641
642        // Connect router to public network (becomes eth0 after starting)
643        self.docker
644            .connect_network(
645                public_network_id,
646                bollard::network::ConnectNetworkOptions {
647                    container: router_id.clone(),
648                    endpoint_config: bollard::secret::EndpointSettings {
649                        ipam_config: Some(bollard::secret::EndpointIpamConfig {
650                            ipv4_address: Some(router_public_ip.to_string()),
651                            ..Default::default()
652                        }),
653                        ..Default::default()
654                    },
655                },
656            )
657            .await
658            .map_err(|e| {
659                Error::Other(anyhow::anyhow!(
660                    "Failed to connect router to public network: {}",
661                    e
662                ))
663            })?;
664
665        // Connect router to private network (becomes eth1 after starting)
666        self.docker
667            .connect_network(
668                &network_id,
669                bollard::network::ConnectNetworkOptions {
670                    container: router_id.clone(),
671                    endpoint_config: bollard::secret::EndpointSettings {
672                        ipam_config: Some(bollard::secret::EndpointIpamConfig {
673                            ipv4_address: Some(router_private_ip.to_string()),
674                            ..Default::default()
675                        }),
676                        ..Default::default()
677                    },
678                },
679            )
680            .await
681            .map_err(|e| {
682                Error::Other(anyhow::anyhow!(
683                    "Failed to connect router to private network: {}",
684                    e
685                ))
686            })?;
687
688        // Start the router
689        self.docker
690            .start_container(&router_id, None::<StartContainerOptions<String>>)
691            .await
692            .map_err(|e| Error::Other(anyhow::anyhow!("Failed to start NAT router: {}", e)))?;
693
694        // Wait for router to be ready
695        tokio::time::sleep(Duration::from_secs(2)).await;
696
697        tracing::info!(
698            "Created NAT network {} with router {} (public: {}, private: {})",
699            network_name,
700            router_name,
701            router_public_ip,
702            router_private_ip
703        );
704
705        Ok((network_id, router_id, router_public_ip))
706    }
707
708    /// Build the base Freenet peer Docker image
709    pub async fn ensure_base_image(&self) -> Result<String> {
710        let image_name = "freenet-test-peer:latest";
711
712        // Check if image already exists
713        if self.docker.inspect_image(image_name).await.is_ok() {
714            tracing::debug!("Base image {} already exists", image_name);
715            return Ok(image_name.to_string());
716        }
717
718        tracing::info!("Building base image {}...", image_name);
719
720        // Create a minimal Dockerfile - use Ubuntu 24.04 to match host glibc version
721        let dockerfile = r#"
722FROM ubuntu:24.04
723RUN apt-get update && \
724    apt-get install -y --no-install-recommends \
725        libssl3 \
726        ca-certificates \
727        iproute2 \
728        && rm -rf /var/lib/apt/lists/*
729RUN mkdir -p /data /config
730WORKDIR /app
731"#;
732
733        // Create tar archive with Dockerfile
734        let mut tar_builder = tar::Builder::new(Vec::new());
735        let mut header = tar::Header::new_gnu();
736        header.set_path("Dockerfile")?;
737        header.set_size(dockerfile.len() as u64);
738        header.set_mode(0o644);
739        header.set_cksum();
740        tar_builder.append(&header, dockerfile.as_bytes())?;
741        let tar_data = tar_builder.into_inner()?;
742
743        // Build image
744        let options = BuildImageOptions {
745            dockerfile: "Dockerfile",
746            t: image_name,
747            rm: true,
748            ..Default::default()
749        };
750
751        let mut build_stream = self
752            .docker
753            .build_image(options, None, Some(tar_data.into()));
754
755        while let Some(result) = build_stream.next().await {
756            match result {
757                Ok(info) => {
758                    if let Some(stream) = info.stream {
759                        tracing::debug!("Build: {}", stream.trim());
760                    }
761                    if let Some(error) = info.error {
762                        return Err(Error::Other(anyhow::anyhow!(
763                            "Image build error: {}",
764                            error
765                        )));
766                    }
767                }
768                Err(e) => {
769                    return Err(Error::Other(anyhow::anyhow!("Image build failed: {}", e)));
770                }
771            }
772        }
773
774        tracing::info!("Built base image {}", image_name);
775        Ok(image_name.to_string())
776    }
777
778    /// Copy binary into a container
779    pub async fn copy_binary_to_container(
780        &self,
781        container_id: &str,
782        binary_path: &Path,
783    ) -> Result<()> {
784        // Read binary
785        let binary_data = std::fs::read(binary_path)?;
786
787        // Create tar archive with the binary
788        let mut tar_builder = tar::Builder::new(Vec::new());
789        let mut header = tar::Header::new_gnu();
790        header.set_path("freenet")?;
791        header.set_size(binary_data.len() as u64);
792        header.set_mode(0o755);
793        header.set_cksum();
794        tar_builder.append(&header, binary_data.as_slice())?;
795        let tar_data = tar_builder.into_inner()?;
796
797        // Upload to container
798        self.docker
799            .upload_to_container(
800                container_id,
801                Some(UploadToContainerOptions {
802                    path: "/app",
803                    ..Default::default()
804                }),
805                tar_data.into(),
806            )
807            .await
808            .map_err(|e| Error::Other(anyhow::anyhow!("Failed to copy binary: {}", e)))?;
809
810        Ok(())
811    }
812
813    /// Create a gateway container (on public network, no NAT)
814    pub async fn create_gateway(
815        &mut self,
816        index: usize,
817        binary_path: &Path,
818        keypair_path: &Path,
819        public_key_path: &Path,
820        ws_port: u16,
821        network_port: u16,
822        run_root: &Path,
823    ) -> Result<(DockerPeerInfo, DockerProcess)> {
824        let container_name = format!("{}-gw-{}", self.config.name_prefix, index);
825        let image = self.ensure_base_image().await?;
826
827        let public_network_id = self
828            .public_network_id
829            .as_ref()
830            .ok_or_else(|| Error::Other(anyhow::anyhow!("Public network not created yet")))?;
831
832        // Gateway IP on public network
833        let gateway_ip = Ipv4Addr::new(
834            self.config.public_subnet.ip().octets()[0],
835            self.config.public_subnet.ip().octets()[1],
836            0,
837            10 + index as u8,
838        );
839
840        // Create container - let Docker auto-allocate host port to avoid TOCTOU race
841        let config = Config {
842            image: Some(image),
843            hostname: Some(container_name.clone()),
844            exposed_ports: Some(HashMap::from([(
845                format!("{}/tcp", ws_port),
846                HashMap::new(),
847            )])),
848            host_config: Some(HostConfig {
849                port_bindings: Some(HashMap::from([(
850                    format!("{}/tcp", ws_port),
851                    Some(vec![PortBinding {
852                        host_ip: Some("0.0.0.0".to_string()),
853                        host_port: None, // Let Docker auto-allocate to avoid port conflicts
854                    }]),
855                )])),
856                cap_add: Some(vec!["NET_ADMIN".to_string()]),
857                ..Default::default()
858            }),
859            env: Some(vec![
860                format!("RUST_LOG={}", std::env::var("RUST_LOG").unwrap_or_else(|_| "info".to_string())),
861                "RUST_BACKTRACE=1".to_string(),
862            ]),
863            cmd: Some(vec![
864                "/app/freenet".to_string(),
865                "network".to_string(),
866                "--data-dir".to_string(),
867                "/data".to_string(),
868                "--config-dir".to_string(),
869                "/config".to_string(),
870                "--ws-api-address".to_string(),
871                "0.0.0.0".to_string(),
872                "--ws-api-port".to_string(),
873                ws_port.to_string(),
874                "--network-address".to_string(),
875                "0.0.0.0".to_string(),
876                "--network-port".to_string(),
877                network_port.to_string(),
878                "--public-network-address".to_string(),
879                gateway_ip.to_string(),
880                "--public-network-port".to_string(),
881                network_port.to_string(),
882                "--is-gateway".to_string(),
883                "--skip-load-from-network".to_string(),
884                "--transport-keypair".to_string(),
885                "/config/keypair.pem".to_string(),
886            ]),
887            ..Default::default()
888        };
889
890        let container_id = self
891            .docker
892            .create_container(
893                Some(CreateContainerOptions {
894                    name: container_name.clone(),
895                    ..Default::default()
896                }),
897                config,
898            )
899            .await
900            .map_err(|e| {
901                Error::Other(anyhow::anyhow!("Failed to create gateway container: {}", e))
902            })?
903            .id;
904
905        self.containers.push(container_id.clone());
906
907        // Connect to public network with specific IP
908        self.docker
909            .connect_network(
910                public_network_id,
911                bollard::network::ConnectNetworkOptions {
912                    container: container_id.clone(),
913                    endpoint_config: bollard::secret::EndpointSettings {
914                        ipam_config: Some(bollard::secret::EndpointIpamConfig {
915                            ipv4_address: Some(gateway_ip.to_string()),
916                            ..Default::default()
917                        }),
918                        ..Default::default()
919                    },
920                },
921            )
922            .await
923            .map_err(|e| {
924                Error::Other(anyhow::anyhow!(
925                    "Failed to connect gateway to network: {}",
926                    e
927                ))
928            })?;
929
930        // Copy binary and keys into container
931        self.copy_binary_to_container(&container_id, binary_path)
932            .await?;
933        self.copy_file_to_container(&container_id, keypair_path, "/config/keypair.pem")
934            .await?;
935        self.copy_file_to_container(&container_id, public_key_path, "/config/public_key.pem")
936            .await?;
937
938        // Start container
939        self.docker
940            .start_container(&container_id, None::<StartContainerOptions<String>>)
941            .await
942            .map_err(|e| Error::Other(anyhow::anyhow!("Failed to start gateway: {}", e)))?;
943
944        // Get the Docker-allocated host port by inspecting the running container
945        let host_ws_port = self
946            .get_container_host_port(&container_id, ws_port)
947            .await?;
948
949        let info = DockerPeerInfo {
950            container_id: container_id.clone(),
951            container_name: container_name.clone(),
952            private_ip: gateway_ip, // Gateways don't have private IP
953            public_ip: gateway_ip,
954            host_ws_port,
955            network_port,
956            is_gateway: true,
957            nat_router_id: None,
958        };
959
960        self.peer_containers.insert(index, info.clone());
961
962        let local_log_cache = run_root.join(format!("gw{}", index)).join("peer.log");
963
964        tracing::info!(
965            "Created gateway {} at {} (ws: localhost:{})",
966            container_name,
967            gateway_ip,
968            host_ws_port
969        );
970
971        Ok((
972            info,
973            DockerProcess {
974                docker: self.docker.clone(),
975                container_id,
976                container_name,
977                local_log_cache,
978            },
979        ))
980    }
981
982    /// Create a peer container behind NAT
983    pub async fn create_peer(
984        &mut self,
985        index: usize,
986        binary_path: &Path,
987        keypair_path: &Path,
988        public_key_path: &Path,
989        gateways_toml_path: &Path,
990        gateway_public_key_path: Option<&Path>,
991        ws_port: u16,
992        network_port: u16,
993        run_root: &Path,
994    ) -> Result<(DockerPeerInfo, DockerProcess)> {
995        let container_name = format!("{}-peer-{}", self.config.name_prefix, index);
996        let image = self.ensure_base_image().await?;
997
998        // Create NAT network for this peer
999        let (nat_network_id, router_id, router_public_ip) = self.create_nat_network(index).await?;
1000
1001        // Peer's private IP (behind NAT) - use the randomized base from config
1002        let base = self.config.private_subnet_base.octets();
1003        let private_ip = Ipv4Addr::new(base[0], base[1].wrapping_add(index as u8), 0, 2);
1004
1005        // Create container - let Docker auto-allocate host port to avoid TOCTOU race
1006        let config = Config {
1007            image: Some(image),
1008            hostname: Some(container_name.clone()),
1009            exposed_ports: Some(HashMap::from([(
1010                format!("{}/tcp", ws_port),
1011                HashMap::new(),
1012            )])),
1013            host_config: Some(HostConfig {
1014                port_bindings: Some(HashMap::from([(
1015                    format!("{}/tcp", ws_port),
1016                    Some(vec![PortBinding {
1017                        host_ip: Some("0.0.0.0".to_string()),
1018                        host_port: None, // Let Docker auto-allocate to avoid port conflicts
1019                    }]),
1020                )])),
1021                cap_add: Some(vec!["NET_ADMIN".to_string()]),
1022                ..Default::default()
1023            }),
1024            env: Some(vec![
1025                format!("RUST_LOG={}", std::env::var("RUST_LOG").unwrap_or_else(|_| "info".to_string())),
1026                "RUST_BACKTRACE=1".to_string(),
1027            ]),
1028            cmd: Some(vec![
1029                "/app/freenet".to_string(),
1030                "network".to_string(),
1031                "--data-dir".to_string(),
1032                "/data".to_string(),
1033                "--config-dir".to_string(),
1034                "/config".to_string(),
1035                "--ws-api-address".to_string(),
1036                "0.0.0.0".to_string(),
1037                "--ws-api-port".to_string(),
1038                ws_port.to_string(),
1039                "--network-address".to_string(),
1040                "0.0.0.0".to_string(),
1041                "--network-port".to_string(),
1042                network_port.to_string(),
1043                // Don't set public address - let Freenet discover it via gateway
1044                "--skip-load-from-network".to_string(),
1045                "--transport-keypair".to_string(),
1046                "/config/keypair.pem".to_string(),
1047            ]),
1048            ..Default::default()
1049        };
1050
1051        let container_id = self
1052            .docker
1053            .create_container(
1054                Some(CreateContainerOptions {
1055                    name: container_name.clone(),
1056                    ..Default::default()
1057                }),
1058                config,
1059            )
1060            .await
1061            .map_err(|e| Error::Other(anyhow::anyhow!("Failed to create peer container: {}", e)))?
1062            .id;
1063
1064        self.containers.push(container_id.clone());
1065
1066        // Keep bridge network connected for Docker port forwarding to work (WebSocket access from host)
1067        // Connect to NAT private network for Freenet traffic
1068        self.docker
1069            .connect_network(
1070                &nat_network_id,
1071                bollard::network::ConnectNetworkOptions {
1072                    container: container_id.clone(),
1073                    endpoint_config: bollard::secret::EndpointSettings {
1074                        ipam_config: Some(bollard::secret::EndpointIpamConfig {
1075                            ipv4_address: Some(private_ip.to_string()),
1076                            ..Default::default()
1077                        }),
1078                        gateway: Some(
1079                            Ipv4Addr::new(base[0], base[1].wrapping_add(index as u8), 0, 1)
1080                                .to_string(),
1081                        ),
1082                        ..Default::default()
1083                    },
1084                },
1085            )
1086            .await
1087            .map_err(|e| {
1088                Error::Other(anyhow::anyhow!(
1089                    "Failed to connect peer to NAT network: {}",
1090                    e
1091                ))
1092            })?;
1093
1094        // Copy binary and keys into container
1095        self.copy_binary_to_container(&container_id, binary_path)
1096            .await?;
1097        self.copy_file_to_container(&container_id, keypair_path, "/config/keypair.pem")
1098            .await?;
1099        self.copy_file_to_container(&container_id, public_key_path, "/config/public_key.pem")
1100            .await?;
1101        self.copy_file_to_container(&container_id, gateways_toml_path, "/config/gateways.toml")
1102            .await?;
1103
1104        // Copy gateway public key if provided
1105        if let Some(gw_pubkey_path) = gateway_public_key_path {
1106            self.copy_file_to_container(&container_id, gw_pubkey_path, "/config/gw_public_key.pem")
1107                .await?;
1108        }
1109
1110        // Start container
1111        self.docker
1112            .start_container(&container_id, None::<StartContainerOptions<String>>)
1113            .await
1114            .map_err(|e| Error::Other(anyhow::anyhow!("Failed to start peer: {}", e)))?;
1115
1116        // Get the Docker-allocated host port by inspecting the running container
1117        let host_ws_port = self
1118            .get_container_host_port(&container_id, ws_port)
1119            .await?;
1120
1121        // Configure routing: traffic to public network goes through NAT router
1122        // Keep default route via bridge for Docker port forwarding (WebSocket access from host)
1123        let router_gateway = Ipv4Addr::new(base[0], base[1].wrapping_add(index as u8), 0, 254);
1124        let public_subnet = self.config.public_subnet;
1125        self.exec_in_container(
1126            &container_id,
1127            &[
1128                "sh",
1129                "-c",
1130                &format!("ip route add {} via {}", public_subnet, router_gateway),
1131            ],
1132        )
1133        .await?;
1134
1135        let info = DockerPeerInfo {
1136            container_id: container_id.clone(),
1137            container_name: container_name.clone(),
1138            private_ip,
1139            public_ip: router_public_ip,
1140            host_ws_port,
1141            network_port,
1142            is_gateway: false,
1143            nat_router_id: Some(router_id),
1144        };
1145
1146        self.peer_containers.insert(index, info.clone());
1147
1148        let local_log_cache = run_root.join(format!("peer{}", index)).join("peer.log");
1149
1150        tracing::info!(
1151            "Created peer {} at {} behind NAT {} (ws: localhost:{})",
1152            container_name,
1153            private_ip,
1154            router_public_ip,
1155            host_ws_port
1156        );
1157
1158        Ok((
1159            info,
1160            DockerProcess {
1161                docker: self.docker.clone(),
1162                container_id,
1163                container_name,
1164                local_log_cache,
1165            },
1166        ))
1167    }
1168
1169    /// Copy a file into a container (public version)
1170    pub async fn copy_file_to_container_pub(
1171        &self,
1172        container_id: &str,
1173        local_path: &Path,
1174        container_path: &str,
1175    ) -> Result<()> {
1176        self.copy_file_to_container(container_id, local_path, container_path)
1177            .await
1178    }
1179
1180    /// Copy a file into a container
1181    async fn copy_file_to_container(
1182        &self,
1183        container_id: &str,
1184        local_path: &Path,
1185        container_path: &str,
1186    ) -> Result<()> {
1187        let file_data = std::fs::read(local_path)?;
1188        let file_name = Path::new(container_path)
1189            .file_name()
1190            .ok_or_else(|| Error::Other(anyhow::anyhow!("Invalid container path")))?
1191            .to_str()
1192            .ok_or_else(|| Error::Other(anyhow::anyhow!("Invalid file name")))?;
1193
1194        let dir_path = Path::new(container_path)
1195            .parent()
1196            .ok_or_else(|| Error::Other(anyhow::anyhow!("Invalid container path")))?
1197            .to_str()
1198            .ok_or_else(|| Error::Other(anyhow::anyhow!("Invalid directory path")))?;
1199
1200        // Create tar archive
1201        let mut tar_builder = tar::Builder::new(Vec::new());
1202        let mut header = tar::Header::new_gnu();
1203        header.set_path(file_name)?;
1204        header.set_size(file_data.len() as u64);
1205        header.set_mode(0o644);
1206        header.set_cksum();
1207        tar_builder.append(&header, file_data.as_slice())?;
1208        let tar_data = tar_builder.into_inner()?;
1209
1210        self.docker
1211            .upload_to_container(
1212                container_id,
1213                Some(UploadToContainerOptions {
1214                    path: dir_path,
1215                    ..Default::default()
1216                }),
1217                tar_data.into(),
1218            )
1219            .await
1220            .map_err(|e| Error::Other(anyhow::anyhow!("Failed to copy file: {}", e)))?;
1221
1222        Ok(())
1223    }
1224
1225    /// Execute a command in a container
1226    async fn exec_in_container(&self, container_id: &str, cmd: &[&str]) -> Result<String> {
1227        let exec = self
1228            .docker
1229            .create_exec(
1230                container_id,
1231                CreateExecOptions {
1232                    cmd: Some(cmd.iter().map(|s| s.to_string()).collect()),
1233                    attach_stdout: Some(true),
1234                    attach_stderr: Some(true),
1235                    ..Default::default()
1236                },
1237            )
1238            .await
1239            .map_err(|e| Error::Other(anyhow::anyhow!("Failed to create exec: {}", e)))?;
1240
1241        let output = self
1242            .docker
1243            .start_exec(&exec.id, None)
1244            .await
1245            .map_err(|e| Error::Other(anyhow::anyhow!("Failed to start exec: {}", e)))?;
1246
1247        let mut result = String::new();
1248        if let StartExecResults::Attached { mut output, .. } = output {
1249            while let Some(Ok(msg)) = output.next().await {
1250                match msg {
1251                    LogOutput::StdOut { message } | LogOutput::StdErr { message } => {
1252                        result.push_str(&String::from_utf8_lossy(&message));
1253                    }
1254                    _ => {}
1255                }
1256            }
1257        }
1258
1259        Ok(result)
1260    }
1261
1262    /// Clean up all Docker resources created by this backend
1263    pub async fn cleanup(&mut self) -> Result<()> {
1264        tracing::info!("Cleaning up Docker NAT resources...");
1265
1266        // Stop and remove containers
1267        for container_id in self.containers.drain(..) {
1268            let _ = self
1269                .docker
1270                .stop_container(&container_id, Some(StopContainerOptions { t: 2 }))
1271                .await;
1272            let _ = self
1273                .docker
1274                .remove_container(
1275                    &container_id,
1276                    Some(RemoveContainerOptions {
1277                        force: true,
1278                        ..Default::default()
1279                    }),
1280                )
1281                .await;
1282        }
1283
1284        // Remove networks
1285        for network_id in self.networks.drain(..) {
1286            let _ = self.docker.remove_network(&network_id).await;
1287        }
1288
1289        self.peer_containers.clear();
1290        self.public_network_id = None;
1291
1292        Ok(())
1293    }
1294
1295    /// Get peer info by index
1296    pub fn get_peer_info(&self, index: usize) -> Option<&DockerPeerInfo> {
1297        self.peer_containers.get(&index)
1298    }
1299
1300    /// Dump iptables NAT rules and packet counters from all NAT routers
1301    ///
1302    /// Returns a map of peer_index -> iptables output showing:
1303    /// - NAT table rules with packet/byte counters
1304    /// - FORWARD chain counters
1305    pub async fn dump_iptables_counters(&self) -> Result<std::collections::HashMap<usize, String>> {
1306        let mut results = std::collections::HashMap::new();
1307
1308        for (&peer_index, peer_info) in &self.peer_containers {
1309            if let Some(router_id) = &peer_info.nat_router_id {
1310                let mut output = String::new();
1311
1312                // Get NAT table with counters
1313                output.push_str("=== NAT table ===\n");
1314                match self.exec_in_container(router_id, &["iptables", "-t", "nat", "-nvL"]).await {
1315                    Ok(s) => output.push_str(&s),
1316                    Err(e) => output.push_str(&format!("Error: {}\n", e)),
1317                }
1318
1319                // Get FORWARD chain with counters
1320                output.push_str("\n=== FORWARD chain ===\n");
1321                match self.exec_in_container(router_id, &["iptables", "-nvL", "FORWARD"]).await {
1322                    Ok(s) => output.push_str(&s),
1323                    Err(e) => output.push_str(&format!("Error: {}\n", e)),
1324                }
1325
1326                results.insert(peer_index, output);
1327            }
1328        }
1329
1330        Ok(results)
1331    }
1332
1333    /// Dump conntrack table from all NAT routers
1334    ///
1335    /// Shows active NAT connection tracking entries for UDP traffic.
1336    /// Note: Installs conntrack-tools if not present (adds ~2s per router first time).
1337    pub async fn dump_conntrack_table(&self) -> Result<std::collections::HashMap<usize, String>> {
1338        let mut results = std::collections::HashMap::new();
1339
1340        for (&peer_index, peer_info) in &self.peer_containers {
1341            if let Some(router_id) = &peer_info.nat_router_id {
1342                // Install conntrack-tools if needed
1343                let _ = self.exec_in_container(
1344                    router_id,
1345                    &["apk", "add", "--no-cache", "conntrack-tools"]
1346                ).await;
1347
1348                // Get conntrack entries for UDP
1349                match self.exec_in_container(router_id, &["conntrack", "-L", "-p", "udp"]).await {
1350                    Ok(s) if s.trim().is_empty() => {
1351                        results.insert(peer_index, "(no UDP conntrack entries)".to_string());
1352                    }
1353                    Ok(s) => {
1354                        results.insert(peer_index, s);
1355                    }
1356                    Err(e) => {
1357                        results.insert(peer_index, format!("Error: {}", e));
1358                    }
1359                }
1360            }
1361        }
1362
1363        Ok(results)
1364    }
1365
1366    /// Dump routing tables from all peer containers
1367    ///
1368    /// Shows the ip route table for each peer, useful for debugging NAT connectivity.
1369    pub async fn dump_peer_routes(&self) -> Result<std::collections::HashMap<usize, String>> {
1370        let mut results = std::collections::HashMap::new();
1371
1372        for (&peer_index, peer_info) in &self.peer_containers {
1373            if peer_info.nat_router_id.is_some() {
1374                // Get route table from the peer container
1375                match self.exec_in_container(&peer_info.container_id, &["ip", "route"]).await {
1376                    Ok(s) => { results.insert(peer_index, s); }
1377                    Err(e) => { results.insert(peer_index, format!("Error: {}", e)); }
1378                }
1379            }
1380        }
1381
1382        Ok(results)
1383    }
1384
1385    /// Get the host port allocated by Docker for a container's exposed port.
1386    ///
1387    /// This is used after starting a container to discover which host port Docker
1388    /// auto-allocated when we specified `host_port: None` in the port binding.
1389    /// This approach avoids TOCTOU race conditions that can occur when pre-allocating
1390    /// ports with `get_free_port()` and then trying to bind them in Docker.
1391    async fn get_container_host_port(&self, container_id: &str, container_port: u16) -> Result<u16> {
1392        let info = self
1393            .docker
1394            .inspect_container(container_id, None)
1395            .await
1396            .map_err(|e| {
1397                Error::Other(anyhow::anyhow!(
1398                    "Failed to inspect container for port allocation: {}",
1399                    e
1400                ))
1401            })?;
1402
1403        let port_key = format!("{}/tcp", container_port);
1404
1405        let host_port = info
1406            .network_settings
1407            .and_then(|ns| ns.ports)
1408            .and_then(|ports| ports.get(&port_key).cloned())
1409            .flatten()
1410            .and_then(|bindings| bindings.first().cloned())
1411            .and_then(|binding| binding.host_port)
1412            .and_then(|port_str| port_str.parse::<u16>().ok())
1413            .ok_or_else(|| {
1414                Error::Other(anyhow::anyhow!(
1415                    "Failed to get allocated host port for container {} port {}",
1416                    container_id,
1417                    container_port
1418                ))
1419            })?;
1420
1421        Ok(host_port)
1422    }
1423}
1424
1425impl Drop for DockerNatBackend {
1426    fn drop(&mut self) {
1427        if self.config.cleanup_on_drop {
1428            tracing::info!("Cleaning up Docker NAT backend resources...");
1429
1430            // Use blocking approach to ensure cleanup completes before drop finishes
1431            let docker = self.docker.clone();
1432            let containers = std::mem::take(&mut self.containers);
1433            let networks = std::mem::take(&mut self.networks);
1434
1435            // Block until cleanup completes - important for ensuring resources are freed
1436            // even on panic or ctrl-c.
1437            // If we're already in a runtime, use block_in_place; otherwise create a new runtime.
1438            let cleanup = async {
1439                // Stop and remove containers in parallel for faster cleanup
1440                let container_futures = containers.into_iter().map(|container_id| {
1441                    let docker = docker.clone();
1442                    async move {
1443                        if let Err(e) = docker
1444                            .stop_container(&container_id, Some(StopContainerOptions { t: 2 }))
1445                            .await
1446                        {
1447                            tracing::debug!("Failed to stop container {}: {}", container_id, e);
1448                        }
1449                        if let Err(e) = docker
1450                            .remove_container(
1451                                &container_id,
1452                                Some(RemoveContainerOptions {
1453                                    force: true,
1454                                    ..Default::default()
1455                                }),
1456                            )
1457                            .await
1458                        {
1459                            tracing::debug!("Failed to remove container {}: {}", container_id, e);
1460                        }
1461                    }
1462                });
1463
1464                // Wait for all containers to be cleaned up
1465                futures::future::join_all(container_futures).await;
1466
1467                // Then remove networks (must happen after containers are disconnected)
1468                for network_id in networks {
1469                    if let Err(e) = docker.remove_network(&network_id).await {
1470                        tracing::debug!("Failed to remove network {}: {}", network_id, e);
1471                    }
1472                }
1473
1474                tracing::info!("Docker NAT backend cleanup complete");
1475            };
1476
1477            // Try to use existing runtime first (if we're in async context)
1478            // Otherwise fall back to creating a new runtime
1479            if let Ok(handle) = tokio::runtime::Handle::try_current() {
1480                tokio::task::block_in_place(|| {
1481                    handle.block_on(cleanup);
1482                });
1483            } else if let Ok(rt) = tokio::runtime::Runtime::new() {
1484                rt.block_on(cleanup);
1485            } else {
1486                tracing::error!("Failed to create runtime for cleanup");
1487            }
1488        }
1489    }
1490}