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        let router_config = Config {
525            image: Some("alpine:latest".to_string()),
526            hostname: Some(router_name.clone()),
527            cmd: Some(vec![
528                "sh".to_string(),
529                "-c".to_string(),
530                // Set up NAT (IP forwarding enabled via sysctl in host_config)
531                // Find interfaces dynamically by IP address since Docker doesn't guarantee interface order
532                // PUBLIC_IF: interface with 172.X.x.x (public network, X varies)
533                // PRIVATE_IF: interface with 10.x.x.x (private network)
534                format!(
535                    "apk add --no-cache iptables iproute2 > /dev/null 2>&1 && \
536                     PUBLIC_IF=$(ip -o addr show | grep '{}' | awk '{{print $2}}') && \
537                     PRIVATE_IF=$(ip -o addr show | grep '{}' | awk '{{print $2}}') && \
538                     PUBLIC_IP=$(ip -o addr show dev $PUBLIC_IF | awk '/inet / {{split($4,a,\"/\"); print a[1]}}') && \
539                     echo \"Public interface: $PUBLIC_IF ($PUBLIC_IP), Private interface: $PRIVATE_IF\" && \
540                     iptables -t nat -A POSTROUTING -o $PUBLIC_IF -p udp -j SNAT --to-source $PUBLIC_IP:31337 && \
541                     iptables -t nat -A POSTROUTING -o $PUBLIC_IF ! -p udp -j MASQUERADE && \
542                     iptables -A FORWARD -i $PRIVATE_IF -o $PUBLIC_IF -j ACCEPT && \
543                     iptables -A FORWARD -i $PUBLIC_IF -o $PRIVATE_IF -j ACCEPT && \
544                     echo 'Cone NAT router ready' && \
545                     tail -f /dev/null",
546                    public_pattern, private_pattern
547                ),
548            ]),
549            host_config: Some(HostConfig {
550                cap_add: Some(vec!["NET_ADMIN".to_string()]),
551                sysctls: Some(HashMap::from([
552                    ("net.ipv4.ip_forward".to_string(), "1".to_string()),
553                ])),
554                ..Default::default()
555            }),
556            ..Default::default()
557        };
558
559        let router_id = self
560            .docker
561            .create_container(
562                Some(CreateContainerOptions {
563                    name: router_name.clone(),
564                    ..Default::default()
565                }),
566                router_config,
567            )
568            .await
569            .map_err(|e| Error::Other(anyhow::anyhow!("Failed to create NAT router: {}", e)))?
570            .id;
571
572        self.containers.push(router_id.clone());
573
574        // Disconnect from default bridge network
575        let _ = self
576            .docker
577            .disconnect_network(
578                "bridge",
579                bollard::network::DisconnectNetworkOptions {
580                    container: router_id.clone(),
581                    force: true,
582                },
583            )
584            .await;
585
586        // Connect router to public network (becomes eth0 after starting)
587        self.docker
588            .connect_network(
589                public_network_id,
590                bollard::network::ConnectNetworkOptions {
591                    container: router_id.clone(),
592                    endpoint_config: bollard::secret::EndpointSettings {
593                        ipam_config: Some(bollard::secret::EndpointIpamConfig {
594                            ipv4_address: Some(router_public_ip.to_string()),
595                            ..Default::default()
596                        }),
597                        ..Default::default()
598                    },
599                },
600            )
601            .await
602            .map_err(|e| {
603                Error::Other(anyhow::anyhow!(
604                    "Failed to connect router to public network: {}",
605                    e
606                ))
607            })?;
608
609        // Connect router to private network (becomes eth1 after starting)
610        self.docker
611            .connect_network(
612                &network_id,
613                bollard::network::ConnectNetworkOptions {
614                    container: router_id.clone(),
615                    endpoint_config: bollard::secret::EndpointSettings {
616                        ipam_config: Some(bollard::secret::EndpointIpamConfig {
617                            ipv4_address: Some(router_private_ip.to_string()),
618                            ..Default::default()
619                        }),
620                        ..Default::default()
621                    },
622                },
623            )
624            .await
625            .map_err(|e| {
626                Error::Other(anyhow::anyhow!(
627                    "Failed to connect router to private network: {}",
628                    e
629                ))
630            })?;
631
632        // Start the router
633        self.docker
634            .start_container(&router_id, None::<StartContainerOptions<String>>)
635            .await
636            .map_err(|e| Error::Other(anyhow::anyhow!("Failed to start NAT router: {}", e)))?;
637
638        // Wait for router to be ready
639        tokio::time::sleep(Duration::from_secs(2)).await;
640
641        tracing::info!(
642            "Created NAT network {} with router {} (public: {}, private: {})",
643            network_name,
644            router_name,
645            router_public_ip,
646            router_private_ip
647        );
648
649        Ok((network_id, router_id, router_public_ip))
650    }
651
652    /// Build the base Freenet peer Docker image
653    pub async fn ensure_base_image(&self) -> Result<String> {
654        let image_name = "freenet-test-peer:latest";
655
656        // Check if image already exists
657        if self.docker.inspect_image(image_name).await.is_ok() {
658            tracing::debug!("Base image {} already exists", image_name);
659            return Ok(image_name.to_string());
660        }
661
662        tracing::info!("Building base image {}...", image_name);
663
664        // Create a minimal Dockerfile - use Ubuntu 24.04 to match host glibc version
665        let dockerfile = r#"
666FROM ubuntu:24.04
667RUN apt-get update && \
668    apt-get install -y --no-install-recommends \
669        libssl3 \
670        ca-certificates \
671        iproute2 \
672        && rm -rf /var/lib/apt/lists/*
673RUN mkdir -p /data /config
674WORKDIR /app
675"#;
676
677        // Create tar archive with Dockerfile
678        let mut tar_builder = tar::Builder::new(Vec::new());
679        let mut header = tar::Header::new_gnu();
680        header.set_path("Dockerfile")?;
681        header.set_size(dockerfile.len() as u64);
682        header.set_mode(0o644);
683        header.set_cksum();
684        tar_builder.append(&header, dockerfile.as_bytes())?;
685        let tar_data = tar_builder.into_inner()?;
686
687        // Build image
688        let options = BuildImageOptions {
689            dockerfile: "Dockerfile",
690            t: image_name,
691            rm: true,
692            ..Default::default()
693        };
694
695        let mut build_stream = self
696            .docker
697            .build_image(options, None, Some(tar_data.into()));
698
699        while let Some(result) = build_stream.next().await {
700            match result {
701                Ok(info) => {
702                    if let Some(stream) = info.stream {
703                        tracing::debug!("Build: {}", stream.trim());
704                    }
705                    if let Some(error) = info.error {
706                        return Err(Error::Other(anyhow::anyhow!(
707                            "Image build error: {}",
708                            error
709                        )));
710                    }
711                }
712                Err(e) => {
713                    return Err(Error::Other(anyhow::anyhow!("Image build failed: {}", e)));
714                }
715            }
716        }
717
718        tracing::info!("Built base image {}", image_name);
719        Ok(image_name.to_string())
720    }
721
722    /// Copy binary into a container
723    pub async fn copy_binary_to_container(
724        &self,
725        container_id: &str,
726        binary_path: &Path,
727    ) -> Result<()> {
728        // Read binary
729        let binary_data = std::fs::read(binary_path)?;
730
731        // Create tar archive with the binary
732        let mut tar_builder = tar::Builder::new(Vec::new());
733        let mut header = tar::Header::new_gnu();
734        header.set_path("freenet")?;
735        header.set_size(binary_data.len() as u64);
736        header.set_mode(0o755);
737        header.set_cksum();
738        tar_builder.append(&header, binary_data.as_slice())?;
739        let tar_data = tar_builder.into_inner()?;
740
741        // Upload to container
742        self.docker
743            .upload_to_container(
744                container_id,
745                Some(UploadToContainerOptions {
746                    path: "/app",
747                    ..Default::default()
748                }),
749                tar_data.into(),
750            )
751            .await
752            .map_err(|e| Error::Other(anyhow::anyhow!("Failed to copy binary: {}", e)))?;
753
754        Ok(())
755    }
756
757    /// Create a gateway container (on public network, no NAT)
758    pub async fn create_gateway(
759        &mut self,
760        index: usize,
761        binary_path: &Path,
762        keypair_path: &Path,
763        public_key_path: &Path,
764        ws_port: u16,
765        network_port: u16,
766        run_root: &Path,
767    ) -> Result<(DockerPeerInfo, DockerProcess)> {
768        let container_name = format!("{}-gw-{}", self.config.name_prefix, index);
769        let image = self.ensure_base_image().await?;
770
771        let public_network_id = self
772            .public_network_id
773            .as_ref()
774            .ok_or_else(|| Error::Other(anyhow::anyhow!("Public network not created yet")))?;
775
776        // Gateway IP on public network
777        let gateway_ip = Ipv4Addr::new(
778            self.config.public_subnet.ip().octets()[0],
779            self.config.public_subnet.ip().octets()[1],
780            0,
781            10 + index as u8,
782        );
783
784        // Allocate host port for WS API
785        let host_ws_port = crate::peer::get_free_port()?;
786
787        // Create container
788        let config = Config {
789            image: Some(image),
790            hostname: Some(container_name.clone()),
791            exposed_ports: Some(HashMap::from([(
792                format!("{}/tcp", ws_port),
793                HashMap::new(),
794            )])),
795            host_config: Some(HostConfig {
796                port_bindings: Some(HashMap::from([(
797                    format!("{}/tcp", ws_port),
798                    Some(vec![PortBinding {
799                        host_ip: Some("0.0.0.0".to_string()),
800                        host_port: Some(host_ws_port.to_string()),
801                    }]),
802                )])),
803                cap_add: Some(vec!["NET_ADMIN".to_string()]),
804                ..Default::default()
805            }),
806            env: Some(vec![
807                "RUST_LOG=info".to_string(),
808                "RUST_BACKTRACE=1".to_string(),
809            ]),
810            cmd: Some(vec![
811                "/app/freenet".to_string(),
812                "network".to_string(),
813                "--data-dir".to_string(),
814                "/data".to_string(),
815                "--config-dir".to_string(),
816                "/config".to_string(),
817                "--ws-api-address".to_string(),
818                "0.0.0.0".to_string(),
819                "--ws-api-port".to_string(),
820                ws_port.to_string(),
821                "--network-address".to_string(),
822                "0.0.0.0".to_string(),
823                "--network-port".to_string(),
824                network_port.to_string(),
825                "--public-network-address".to_string(),
826                gateway_ip.to_string(),
827                "--public-network-port".to_string(),
828                network_port.to_string(),
829                "--is-gateway".to_string(),
830                "--skip-load-from-network".to_string(),
831                "--transport-keypair".to_string(),
832                "/config/keypair.pem".to_string(),
833            ]),
834            ..Default::default()
835        };
836
837        let container_id = self
838            .docker
839            .create_container(
840                Some(CreateContainerOptions {
841                    name: container_name.clone(),
842                    ..Default::default()
843                }),
844                config,
845            )
846            .await
847            .map_err(|e| {
848                Error::Other(anyhow::anyhow!("Failed to create gateway container: {}", e))
849            })?
850            .id;
851
852        self.containers.push(container_id.clone());
853
854        // Connect to public network with specific IP
855        self.docker
856            .connect_network(
857                public_network_id,
858                bollard::network::ConnectNetworkOptions {
859                    container: container_id.clone(),
860                    endpoint_config: bollard::secret::EndpointSettings {
861                        ipam_config: Some(bollard::secret::EndpointIpamConfig {
862                            ipv4_address: Some(gateway_ip.to_string()),
863                            ..Default::default()
864                        }),
865                        ..Default::default()
866                    },
867                },
868            )
869            .await
870            .map_err(|e| {
871                Error::Other(anyhow::anyhow!(
872                    "Failed to connect gateway to network: {}",
873                    e
874                ))
875            })?;
876
877        // Copy binary and keys into container
878        self.copy_binary_to_container(&container_id, binary_path)
879            .await?;
880        self.copy_file_to_container(&container_id, keypair_path, "/config/keypair.pem")
881            .await?;
882        self.copy_file_to_container(&container_id, public_key_path, "/config/public_key.pem")
883            .await?;
884
885        // Start container
886        self.docker
887            .start_container(&container_id, None::<StartContainerOptions<String>>)
888            .await
889            .map_err(|e| Error::Other(anyhow::anyhow!("Failed to start gateway: {}", e)))?;
890
891        let info = DockerPeerInfo {
892            container_id: container_id.clone(),
893            container_name: container_name.clone(),
894            private_ip: gateway_ip, // Gateways don't have private IP
895            public_ip: gateway_ip,
896            host_ws_port,
897            network_port,
898            is_gateway: true,
899            nat_router_id: None,
900        };
901
902        self.peer_containers.insert(index, info.clone());
903
904        let local_log_cache = run_root.join(format!("gw{}", index)).join("peer.log");
905
906        tracing::info!(
907            "Created gateway {} at {} (ws: localhost:{})",
908            container_name,
909            gateway_ip,
910            host_ws_port
911        );
912
913        Ok((
914            info,
915            DockerProcess {
916                docker: self.docker.clone(),
917                container_id,
918                container_name,
919                local_log_cache,
920            },
921        ))
922    }
923
924    /// Create a peer container behind NAT
925    pub async fn create_peer(
926        &mut self,
927        index: usize,
928        binary_path: &Path,
929        keypair_path: &Path,
930        public_key_path: &Path,
931        gateways_toml_path: &Path,
932        gateway_public_key_path: Option<&Path>,
933        ws_port: u16,
934        network_port: u16,
935        run_root: &Path,
936    ) -> Result<(DockerPeerInfo, DockerProcess)> {
937        let container_name = format!("{}-peer-{}", self.config.name_prefix, index);
938        let image = self.ensure_base_image().await?;
939
940        // Create NAT network for this peer
941        let (nat_network_id, router_id, router_public_ip) = self.create_nat_network(index).await?;
942
943        // Peer's private IP (behind NAT) - use the randomized base from config
944        let base = self.config.private_subnet_base.octets();
945        let private_ip = Ipv4Addr::new(base[0], base[1].wrapping_add(index as u8), 0, 2);
946
947        // Allocate host port for WS API
948        let host_ws_port = crate::peer::get_free_port()?;
949
950        // Create container
951        let config = Config {
952            image: Some(image),
953            hostname: Some(container_name.clone()),
954            exposed_ports: Some(HashMap::from([(
955                format!("{}/tcp", ws_port),
956                HashMap::new(),
957            )])),
958            host_config: Some(HostConfig {
959                port_bindings: Some(HashMap::from([(
960                    format!("{}/tcp", ws_port),
961                    Some(vec![PortBinding {
962                        host_ip: Some("0.0.0.0".to_string()),
963                        host_port: Some(host_ws_port.to_string()),
964                    }]),
965                )])),
966                cap_add: Some(vec!["NET_ADMIN".to_string()]),
967                ..Default::default()
968            }),
969            env: Some(vec![
970                "RUST_LOG=info".to_string(),
971                "RUST_BACKTRACE=1".to_string(),
972            ]),
973            cmd: Some(vec![
974                "/app/freenet".to_string(),
975                "network".to_string(),
976                "--data-dir".to_string(),
977                "/data".to_string(),
978                "--config-dir".to_string(),
979                "/config".to_string(),
980                "--ws-api-address".to_string(),
981                "0.0.0.0".to_string(),
982                "--ws-api-port".to_string(),
983                ws_port.to_string(),
984                "--network-address".to_string(),
985                "0.0.0.0".to_string(),
986                "--network-port".to_string(),
987                network_port.to_string(),
988                // Don't set public address - let Freenet discover it via gateway
989                "--skip-load-from-network".to_string(),
990                "--transport-keypair".to_string(),
991                "/config/keypair.pem".to_string(),
992            ]),
993            ..Default::default()
994        };
995
996        let container_id = self
997            .docker
998            .create_container(
999                Some(CreateContainerOptions {
1000                    name: container_name.clone(),
1001                    ..Default::default()
1002                }),
1003                config,
1004            )
1005            .await
1006            .map_err(|e| Error::Other(anyhow::anyhow!("Failed to create peer container: {}", e)))?
1007            .id;
1008
1009        self.containers.push(container_id.clone());
1010
1011        // Keep bridge network connected for Docker port forwarding to work (WebSocket access from host)
1012        // Connect to NAT private network for Freenet traffic
1013        self.docker
1014            .connect_network(
1015                &nat_network_id,
1016                bollard::network::ConnectNetworkOptions {
1017                    container: container_id.clone(),
1018                    endpoint_config: bollard::secret::EndpointSettings {
1019                        ipam_config: Some(bollard::secret::EndpointIpamConfig {
1020                            ipv4_address: Some(private_ip.to_string()),
1021                            ..Default::default()
1022                        }),
1023                        gateway: Some(
1024                            Ipv4Addr::new(base[0], base[1].wrapping_add(index as u8), 0, 1)
1025                                .to_string(),
1026                        ),
1027                        ..Default::default()
1028                    },
1029                },
1030            )
1031            .await
1032            .map_err(|e| {
1033                Error::Other(anyhow::anyhow!(
1034                    "Failed to connect peer to NAT network: {}",
1035                    e
1036                ))
1037            })?;
1038
1039        // Copy binary and keys into container
1040        self.copy_binary_to_container(&container_id, binary_path)
1041            .await?;
1042        self.copy_file_to_container(&container_id, keypair_path, "/config/keypair.pem")
1043            .await?;
1044        self.copy_file_to_container(&container_id, public_key_path, "/config/public_key.pem")
1045            .await?;
1046        self.copy_file_to_container(&container_id, gateways_toml_path, "/config/gateways.toml")
1047            .await?;
1048
1049        // Copy gateway public key if provided
1050        if let Some(gw_pubkey_path) = gateway_public_key_path {
1051            self.copy_file_to_container(&container_id, gw_pubkey_path, "/config/gw_public_key.pem")
1052                .await?;
1053        }
1054
1055        // Start container
1056        self.docker
1057            .start_container(&container_id, None::<StartContainerOptions<String>>)
1058            .await
1059            .map_err(|e| Error::Other(anyhow::anyhow!("Failed to start peer: {}", e)))?;
1060
1061        // Configure routing: traffic to public network goes through NAT router
1062        // Keep default route via bridge for Docker port forwarding (WebSocket access from host)
1063        let router_gateway = Ipv4Addr::new(base[0], base[1].wrapping_add(index as u8), 0, 254);
1064        let public_subnet = self.config.public_subnet;
1065        self.exec_in_container(
1066            &container_id,
1067            &[
1068                "sh",
1069                "-c",
1070                &format!("ip route add {} via {}", public_subnet, router_gateway),
1071            ],
1072        )
1073        .await?;
1074
1075        let info = DockerPeerInfo {
1076            container_id: container_id.clone(),
1077            container_name: container_name.clone(),
1078            private_ip,
1079            public_ip: router_public_ip,
1080            host_ws_port,
1081            network_port,
1082            is_gateway: false,
1083            nat_router_id: Some(router_id),
1084        };
1085
1086        self.peer_containers.insert(index, info.clone());
1087
1088        let local_log_cache = run_root.join(format!("peer{}", index)).join("peer.log");
1089
1090        tracing::info!(
1091            "Created peer {} at {} behind NAT {} (ws: localhost:{})",
1092            container_name,
1093            private_ip,
1094            router_public_ip,
1095            host_ws_port
1096        );
1097
1098        Ok((
1099            info,
1100            DockerProcess {
1101                docker: self.docker.clone(),
1102                container_id,
1103                container_name,
1104                local_log_cache,
1105            },
1106        ))
1107    }
1108
1109    /// Copy a file into a container (public version)
1110    pub async fn copy_file_to_container_pub(
1111        &self,
1112        container_id: &str,
1113        local_path: &Path,
1114        container_path: &str,
1115    ) -> Result<()> {
1116        self.copy_file_to_container(container_id, local_path, container_path)
1117            .await
1118    }
1119
1120    /// Copy a file into a container
1121    async fn copy_file_to_container(
1122        &self,
1123        container_id: &str,
1124        local_path: &Path,
1125        container_path: &str,
1126    ) -> Result<()> {
1127        let file_data = std::fs::read(local_path)?;
1128        let file_name = Path::new(container_path)
1129            .file_name()
1130            .ok_or_else(|| Error::Other(anyhow::anyhow!("Invalid container path")))?
1131            .to_str()
1132            .ok_or_else(|| Error::Other(anyhow::anyhow!("Invalid file name")))?;
1133
1134        let dir_path = Path::new(container_path)
1135            .parent()
1136            .ok_or_else(|| Error::Other(anyhow::anyhow!("Invalid container path")))?
1137            .to_str()
1138            .ok_or_else(|| Error::Other(anyhow::anyhow!("Invalid directory path")))?;
1139
1140        // Create tar archive
1141        let mut tar_builder = tar::Builder::new(Vec::new());
1142        let mut header = tar::Header::new_gnu();
1143        header.set_path(file_name)?;
1144        header.set_size(file_data.len() as u64);
1145        header.set_mode(0o644);
1146        header.set_cksum();
1147        tar_builder.append(&header, file_data.as_slice())?;
1148        let tar_data = tar_builder.into_inner()?;
1149
1150        self.docker
1151            .upload_to_container(
1152                container_id,
1153                Some(UploadToContainerOptions {
1154                    path: dir_path,
1155                    ..Default::default()
1156                }),
1157                tar_data.into(),
1158            )
1159            .await
1160            .map_err(|e| Error::Other(anyhow::anyhow!("Failed to copy file: {}", e)))?;
1161
1162        Ok(())
1163    }
1164
1165    /// Execute a command in a container
1166    async fn exec_in_container(&self, container_id: &str, cmd: &[&str]) -> Result<String> {
1167        let exec = self
1168            .docker
1169            .create_exec(
1170                container_id,
1171                CreateExecOptions {
1172                    cmd: Some(cmd.iter().map(|s| s.to_string()).collect()),
1173                    attach_stdout: Some(true),
1174                    attach_stderr: Some(true),
1175                    ..Default::default()
1176                },
1177            )
1178            .await
1179            .map_err(|e| Error::Other(anyhow::anyhow!("Failed to create exec: {}", e)))?;
1180
1181        let output = self
1182            .docker
1183            .start_exec(&exec.id, None)
1184            .await
1185            .map_err(|e| Error::Other(anyhow::anyhow!("Failed to start exec: {}", e)))?;
1186
1187        let mut result = String::new();
1188        if let StartExecResults::Attached { mut output, .. } = output {
1189            while let Some(Ok(msg)) = output.next().await {
1190                match msg {
1191                    LogOutput::StdOut { message } | LogOutput::StdErr { message } => {
1192                        result.push_str(&String::from_utf8_lossy(&message));
1193                    }
1194                    _ => {}
1195                }
1196            }
1197        }
1198
1199        Ok(result)
1200    }
1201
1202    /// Clean up all Docker resources created by this backend
1203    pub async fn cleanup(&mut self) -> Result<()> {
1204        tracing::info!("Cleaning up Docker NAT resources...");
1205
1206        // Stop and remove containers
1207        for container_id in self.containers.drain(..) {
1208            let _ = self
1209                .docker
1210                .stop_container(&container_id, Some(StopContainerOptions { t: 2 }))
1211                .await;
1212            let _ = self
1213                .docker
1214                .remove_container(
1215                    &container_id,
1216                    Some(RemoveContainerOptions {
1217                        force: true,
1218                        ..Default::default()
1219                    }),
1220                )
1221                .await;
1222        }
1223
1224        // Remove networks
1225        for network_id in self.networks.drain(..) {
1226            let _ = self.docker.remove_network(&network_id).await;
1227        }
1228
1229        self.peer_containers.clear();
1230        self.public_network_id = None;
1231
1232        Ok(())
1233    }
1234
1235    /// Get peer info by index
1236    pub fn get_peer_info(&self, index: usize) -> Option<&DockerPeerInfo> {
1237        self.peer_containers.get(&index)
1238    }
1239}
1240
1241impl Drop for DockerNatBackend {
1242    fn drop(&mut self) {
1243        if self.config.cleanup_on_drop {
1244            tracing::info!("Cleaning up Docker NAT backend resources...");
1245
1246            // Use blocking approach to ensure cleanup completes before drop finishes
1247            let docker = self.docker.clone();
1248            let containers = std::mem::take(&mut self.containers);
1249            let networks = std::mem::take(&mut self.networks);
1250
1251            // Block until cleanup completes - important for ensuring resources are freed
1252            // even on panic or ctrl-c.
1253            // If we're already in a runtime, use block_in_place; otherwise create a new runtime.
1254            let cleanup = async {
1255                // Stop and remove containers in parallel for faster cleanup
1256                let container_futures = containers.into_iter().map(|container_id| {
1257                    let docker = docker.clone();
1258                    async move {
1259                        if let Err(e) = docker
1260                            .stop_container(&container_id, Some(StopContainerOptions { t: 2 }))
1261                            .await
1262                        {
1263                            tracing::debug!("Failed to stop container {}: {}", container_id, e);
1264                        }
1265                        if let Err(e) = docker
1266                            .remove_container(
1267                                &container_id,
1268                                Some(RemoveContainerOptions {
1269                                    force: true,
1270                                    ..Default::default()
1271                                }),
1272                            )
1273                            .await
1274                        {
1275                            tracing::debug!("Failed to remove container {}: {}", container_id, e);
1276                        }
1277                    }
1278                });
1279
1280                // Wait for all containers to be cleaned up
1281                futures::future::join_all(container_futures).await;
1282
1283                // Then remove networks (must happen after containers are disconnected)
1284                for network_id in networks {
1285                    if let Err(e) = docker.remove_network(&network_id).await {
1286                        tracing::debug!("Failed to remove network {}: {}", network_id, e);
1287                    }
1288                }
1289
1290                tracing::info!("Docker NAT backend cleanup complete");
1291            };
1292
1293            // Try to use existing runtime first (if we're in async context)
1294            // Otherwise fall back to creating a new runtime
1295            if let Ok(handle) = tokio::runtime::Handle::try_current() {
1296                tokio::task::block_in_place(|| {
1297                    handle.block_on(cleanup);
1298                });
1299            } else if let Ok(rt) = tokio::runtime::Runtime::new() {
1300                rt.block_on(cleanup);
1301            } else {
1302                tracing::error!("Failed to create runtime for cleanup");
1303            }
1304        }
1305    }
1306}