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