Skip to main content

fakecloud_core/
container_net.rs

1//! Shared container-to-host networking resolution for service runtimes
2//! that spawn sibling containers (Lambda, ECS, RDS, ElastiCache).
3//!
4//! Captures the issue #1539 fix shape in one place so the four runtimes
5//! that shell out to `docker`/`podman` can't drift apart again:
6//!
7//! - **podman** ships `host.containers.internal` as a built-in container
8//!   DNS entry on every platform and must NOT receive
9//!   `--add-host host.docker.internal:host-gateway` — rootless podman's
10//!   gvproxy leaves the magic alias empty and the `create` fails with
11//!   "host containers internal IP address is empty".
12//! - **bare docker on Linux** has no `host-gateway` magic; the bridge
13//!   gateway IP has to be resolved from the daemon and injected explicitly.
14//! - **Docker Desktop on Mac/Windows** resolves the `host-gateway` magic
15//!   value to the host's IP.
16//! - when fakecloud itself runs in a container (`FAKECLOUD_IN_CONTAINER=1`,
17//!   baked into the published image), the sibling containers it spawns
18//!   publish their ports on the *host's* daemon — reachable from inside
19//!   fakecloud's container as `host.docker.internal:<port>`, not
20//!   `127.0.0.1:<port>`.
21
22/// Actionable remediation appended to every error raised when a container
23/// runtime (Docker/Podman) is required for an operation but none is
24/// available. Kept in one place so RDS, Lambda, ECS, and the server startup
25/// banner all surface the same fix steps and can't drift apart.
26pub const CONTAINER_RUNTIME_HINT: &str = "Install and start Docker or Podman, or set FAKECLOUD_CONTAINER_CLI to your container CLI path.";
27
28/// Auto-detect an available container CLI. Honors `FAKECLOUD_CONTAINER_CLI`
29/// as an explicit override (returns `None` if the override doesn't work),
30/// otherwise prefers `docker` then `podman`. Returns `None` when neither
31/// is usable.
32pub fn detect_container_cli() -> Option<String> {
33    if let Ok(cli) = std::env::var("FAKECLOUD_CONTAINER_CLI") {
34        return if cli_available(&cli) { Some(cli) } else { None };
35    }
36    if cli_available("docker") {
37        Some("docker".to_string())
38    } else if cli_available("podman") {
39        Some("podman".to_string())
40    } else {
41        None
42    }
43}
44
45/// True when the CLI responds to `<cli> info` with success — the same
46/// liveness probe every runtime used before this module existed.
47pub fn cli_available(cli: &str) -> bool {
48    std::process::Command::new(cli)
49        .arg("info")
50        .stdout(std::process::Stdio::null())
51        .stderr(std::process::Stdio::null())
52        .status()
53        .map(|s| s.success())
54        .unwrap_or(false)
55}
56
57/// True when `cli` is podman or a podman-compatible binary. Matches on the
58/// filename component so absolute paths (`/opt/homebrew/bin/podman`) and
59/// wrappers (`podman-remote`) both register as podman. Docker Desktop's
60/// compatibility CLI is named `docker`, so this check is safe.
61pub fn is_podman_binary(cli: &str) -> bool {
62    std::path::Path::new(cli)
63        .file_name()
64        .and_then(|n| n.to_str())
65        .map(|n| n.contains("podman"))
66        .unwrap_or(false)
67}
68
69/// Detect the Docker bridge gateway IP on Linux. Returns `None` if
70/// detection fails (caller falls back to the conventional `172.17.0.1`).
71pub fn detect_bridge_gateway(cli: &str) -> Option<String> {
72    let output = std::process::Command::new(cli)
73        .args([
74            "network",
75            "inspect",
76            "bridge",
77            "--format",
78            "{{range .IPAM.Config}}{{.Gateway}}{{end}}",
79        ])
80        .output()
81        .ok()?;
82    if !output.status.success() {
83        return None;
84    }
85    let gateway = String::from_utf8_lossy(&output.stdout).trim().to_string();
86    if gateway.is_empty() || !gateway.contains('.') {
87        return None;
88    }
89    Some(gateway)
90}
91
92/// Resolved container-to-host networking for a given CLI. Built once at
93/// runtime construction and reused for every container spawn.
94#[derive(Debug, Clone)]
95pub struct HostNetworking {
96    /// DNS name a spawned container uses to reach fakecloud on the host.
97    /// `host.containers.internal` for podman, `host.docker.internal` for
98    /// docker.
99    pub host_alias: String,
100    /// `<alias>:<value>` argument for `--add-host`, injected into every
101    /// container `create`/`run`. `None` when the runtime provides the
102    /// alias natively (podman).
103    pub add_host_arg: Option<String>,
104    /// Address fakecloud uses to reach the *sibling* containers it just
105    /// spawned (readiness probes + advertised endpoints). `127.0.0.1`
106    /// when fakecloud runs on the host; `host.docker.internal` when
107    /// fakecloud is itself containerized (`FAKECLOUD_IN_CONTAINER=1`).
108    pub sibling_host: String,
109}
110
111impl HostNetworking {
112    /// Resolve networking for `cli`, reading `FAKECLOUD_IN_CONTAINER` from
113    /// the process environment.
114    pub fn detect(cli: &str) -> Self {
115        let (host_alias, add_host_arg) = resolve_host_alias(cli);
116        let sibling_host =
117            resolve_sibling_host(&host_alias, std::env::var("FAKECLOUD_IN_CONTAINER").ok());
118        Self {
119            host_alias,
120            add_host_arg,
121            sibling_host,
122        }
123    }
124
125    /// Convenience: append the `--add-host <alias>:<value>` flag pair to a
126    /// growing argv vector when this runtime needs an explicit mapping.
127    /// No-op for podman.
128    pub fn push_add_host_args(&self, argv: &mut Vec<String>) {
129        if let Some(arg) = &self.add_host_arg {
130            argv.push("--add-host".to_string());
131            argv.push(arg.clone());
132        }
133    }
134}
135
136/// Compute the `(host_alias, add_host_arg)` pair for a CLI. Pure except
137/// for the bridge-gateway daemon probe on Linux docker, so the macOS /
138/// podman branches are unit-testable without a daemon.
139pub fn resolve_host_alias(cli: &str) -> (String, Option<String>) {
140    if is_podman_binary(cli) {
141        // Podman provides `host.containers.internal` natively on every
142        // supported platform; injecting `host-gateway` on macOS fails
143        // because rootless podman's gvproxy doesn't expose the magic alias.
144        ("host.containers.internal".to_string(), None)
145    } else if cfg!(target_os = "linux") {
146        // Bare docker on Linux: resolve the bridge gateway IP and add an
147        // explicit alias. `host.docker.internal:host-gateway` only works
148        // on Docker Desktop; native Linux docker has no such magic.
149        let ip = detect_bridge_gateway(cli).unwrap_or_else(|| "172.17.0.1".to_string());
150        (
151            "host.docker.internal".to_string(),
152            Some(format!("host.docker.internal:{ip}")),
153        )
154    } else {
155        // Docker Desktop on Mac/Windows: `host-gateway` is the magic alias
156        // that resolves to the host's IP.
157        (
158            "host.docker.internal".to_string(),
159            Some("host.docker.internal:host-gateway".to_string()),
160        )
161    }
162}
163
164/// Decide what address fakecloud uses to reach the sibling containers it
165/// just spawned. Pure helper so the env-var parsing can be tested without
166/// touching the process's real environment.
167///
168/// - `Some("1")` / `Some("true")` (case-insensitive) -> fakecloud is in a
169///   container; the siblings publish their ports on the host's daemon and
170///   are reachable at the same host alias the spawned containers use to
171///   reach fakecloud — `host.docker.internal` under docker,
172///   `host.containers.internal` under podman. Hardcoding
173///   `host.docker.internal` here broke podman, whose gvproxy network only
174///   resolves `host.containers.internal` (issue #1539 follow-up).
175/// - anything else, including `None` -> fakecloud runs on the host,
176///   siblings live on `127.0.0.1:<port>`.
177pub fn resolve_sibling_host(host_alias: &str, env_value: Option<String>) -> String {
178    let in_container = env_value
179        .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
180        .unwrap_or(false);
181    if in_container {
182        host_alias.to_string()
183    } else {
184        "127.0.0.1".to_string()
185    }
186}
187
188/// Hostnames fakecloud's bundled ECR/OCI registry can be addressed by from a
189/// sibling container, each at `server_port`.
190///
191/// A container-spawning service rewrites the image pull URI to the runtime's
192/// sibling host -- `host.docker.internal` under Docker, `host.containers.internal`
193/// under podman -- or leaves it `127.0.0.1` when fakecloud runs on the host. The
194/// registry enforces auth, and the Docker/Podman CLI only attaches the
195/// `Authorization` header for hosts present in `config.json`, so the isolated
196/// pull config must list *every* alias or the pull gets a 401. The map
197/// previously omitted the podman alias, so image-based Lambda/ECS pulls failed
198/// under podman-in-a-container (bug-audit 2026-06-20, 0.B2). Authorize all of
199/// them with the same credential; centralized here so the two builders can't
200/// drift again.
201pub fn registry_auth_hosts(server_port: u16) -> Vec<String> {
202    [
203        "127.0.0.1",
204        "host.docker.internal",
205        "host.containers.internal",
206    ]
207    .iter()
208    .map(|host| format!("{host}:{server_port}"))
209    .collect()
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215
216    #[test]
217    fn is_podman_binary_matches_bare_name() {
218        assert!(is_podman_binary("podman"));
219        assert!(is_podman_binary("podman-remote"));
220    }
221
222    #[test]
223    fn registry_auth_hosts_includes_podman_alias() {
224        // The podman sibling alias (host.containers.internal) must be authorized
225        // or image-based Lambda/ECS pulls 401 under podman-in-a-container (0.B2).
226        let hosts = registry_auth_hosts(4566);
227        assert!(hosts.contains(&"127.0.0.1:4566".to_string()));
228        assert!(hosts.contains(&"host.docker.internal:4566".to_string()));
229        assert!(
230            hosts.contains(&"host.containers.internal:4566".to_string()),
231            "podman sibling alias must be authorized: {hosts:?}"
232        );
233    }
234
235    #[test]
236    fn is_podman_binary_matches_absolute_path() {
237        assert!(is_podman_binary("/opt/homebrew/bin/podman"));
238        assert!(is_podman_binary("/usr/local/bin/podman-remote"));
239    }
240
241    #[test]
242    fn is_podman_binary_rejects_docker() {
243        assert!(!is_podman_binary("docker"));
244        assert!(!is_podman_binary("/usr/local/bin/docker"));
245        assert!(!is_podman_binary("docker-credential-helper"));
246    }
247
248    #[test]
249    fn resolve_host_alias_podman_has_no_add_host() {
250        let (alias, add_host) = resolve_host_alias("podman");
251        assert_eq!(alias, "host.containers.internal");
252        assert_eq!(add_host, None);
253        let (alias, add_host) = resolve_host_alias("/opt/homebrew/bin/podman");
254        assert_eq!(alias, "host.containers.internal");
255        assert_eq!(add_host, None);
256    }
257
258    #[test]
259    fn resolve_host_alias_docker_emits_add_host() {
260        let (alias, add_host) = resolve_host_alias("docker");
261        assert_eq!(alias, "host.docker.internal");
262        // On macOS this is host-gateway; on Linux it's a bridge IP. Either
263        // way docker must get an explicit --add-host.
264        assert!(add_host.is_some());
265        assert!(add_host.unwrap().starts_with("host.docker.internal:"));
266    }
267
268    #[test]
269    fn resolve_sibling_host_defaults_to_loopback() {
270        assert_eq!(
271            resolve_sibling_host("host.docker.internal", None),
272            "127.0.0.1"
273        );
274        assert_eq!(
275            resolve_sibling_host("host.docker.internal", Some(String::new())),
276            "127.0.0.1"
277        );
278        assert_eq!(
279            resolve_sibling_host("host.docker.internal", Some("0".to_string())),
280            "127.0.0.1"
281        );
282        assert_eq!(
283            resolve_sibling_host("host.containers.internal", Some("false".to_string())),
284            "127.0.0.1"
285        );
286    }
287
288    #[test]
289    fn resolve_sibling_host_uses_host_alias_when_in_container() {
290        // Docker: siblings reachable at host.docker.internal.
291        assert_eq!(
292            resolve_sibling_host("host.docker.internal", Some("1".to_string())),
293            "host.docker.internal"
294        );
295        assert_eq!(
296            resolve_sibling_host("host.docker.internal", Some("true".to_string())),
297            "host.docker.internal"
298        );
299        assert_eq!(
300            resolve_sibling_host("host.docker.internal", Some("TRUE".to_string())),
301            "host.docker.internal"
302        );
303        // Podman: must use host.containers.internal, NOT host.docker.internal
304        // (issue #1539 follow-up — gvproxy only resolves the containers alias).
305        assert_eq!(
306            resolve_sibling_host("host.containers.internal", Some("1".to_string())),
307            "host.containers.internal"
308        );
309    }
310
311    #[test]
312    fn detect_wires_sibling_host_to_podman_alias_in_container() {
313        // Full path: a podman binary in a container must advertise siblings
314        // at host.containers.internal. resolve_host_alias drives host_alias,
315        // which resolve_sibling_host then reuses.
316        let (alias, add_host) = resolve_host_alias("podman");
317        assert_eq!(alias, "host.containers.internal");
318        assert_eq!(add_host, None);
319        assert_eq!(
320            resolve_sibling_host(&alias, Some("1".to_string())),
321            "host.containers.internal"
322        );
323    }
324
325    #[test]
326    fn push_add_host_args_noop_for_podman() {
327        let net = HostNetworking {
328            host_alias: "host.containers.internal".to_string(),
329            add_host_arg: None,
330            sibling_host: "127.0.0.1".to_string(),
331        };
332        let mut argv = vec!["create".to_string()];
333        net.push_add_host_args(&mut argv);
334        assert_eq!(argv, vec!["create".to_string()]);
335    }
336
337    #[test]
338    fn push_add_host_args_emits_for_docker() {
339        let net = HostNetworking {
340            host_alias: "host.docker.internal".to_string(),
341            add_host_arg: Some("host.docker.internal:host-gateway".to_string()),
342            sibling_host: "127.0.0.1".to_string(),
343        };
344        let mut argv = vec!["create".to_string()];
345        net.push_add_host_args(&mut argv);
346        assert_eq!(
347            argv,
348            vec![
349                "create".to_string(),
350                "--add-host".to_string(),
351                "host.docker.internal:host-gateway".to_string(),
352            ]
353        );
354    }
355}