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}