diff --git a/src/cli/stacker_client.rs b/src/cli/stacker_client.rs index 238343ba..4eb304d7 100644 --- a/src/cli/stacker_client.rs +++ b/src/cli/stacker_client.rs @@ -3826,6 +3826,26 @@ fn parse_port_mapping(port_str: &str) -> (String, String) { } } +/// The `shared_ports` entry for a port mapping from stacker.yml. +/// +/// A host IP must reach the server: `127.0.0.1:5432:5432` is host-local, and +/// the server reads the IP from `container_port` to keep the port out of the +/// firewall and to publish it on 127.0.0.1 only. Sending just `5432` opened a +/// database bound to loopback to the internet. +fn port_mapping_json(port_str: &str) -> serde_json::Value { + let (host, container) = parse_port_mapping(port_str); + let mapping = port_str.split('/').next().unwrap_or(port_str); + let container_port = if mapping.matches(':').count() >= 2 { + mapping.to_string() + } else { + container + }; + serde_json::json!({ + "host_port": host, + "container_port": container_port, + }) +} + /// Parse a volume mapping string like "./dist:/usr/share/nginx/html" or "data:/var/lib/db" /// into (host_path, container_path, read_only) tuple. /// Handles optional `:ro` / `:rw` suffix (e.g. "/var/run/docker.sock:/var/run/docker.sock:ro"). @@ -3847,17 +3867,8 @@ fn service_to_app_json(svc: &ServiceDefinition, network_ids: &[String]) -> serde let (dockerhub_user, dockerhub_name, dockerhub_tag) = parse_docker_image(&svc.image); let id = generate_app_id(); - let shared_ports: Vec = svc - .ports - .iter() - .map(|p| { - let (host, container) = parse_port_mapping(p); - serde_json::json!({ - "host_port": host, - "container_port": container, - }) - }) - .collect(); + let shared_ports: Vec = + svc.ports.iter().map(|p| port_mapping_json(p)).collect(); let volumes: Vec = svc .volumes @@ -3977,13 +3988,7 @@ fn app_source_to_app_json( .app .ports .iter() - .map(|p| { - let (host, container) = parse_port_mapping(p); - serde_json::json!({ - "host_port": host, - "container_port": container, - }) - }) + .map(|p| port_mapping_json(p)) .collect() }; diff --git a/src/routes/project/deploy.rs b/src/routes/project/deploy.rs index 0665694b..cc7e94dc 100644 --- a/src/routes/project/deploy.rs +++ b/src/routes/project/deploy.rs @@ -3060,3 +3060,92 @@ mod tests { assert!(embedded_marketplace_compose(&metadata).is_none()); } } + +/// Contract across the CLI and the server for host-local ports. +/// +/// The loopback handling broke more than once because each half was tested +/// alone: the CLI parser was pinned to drop the host IP (`127.0.0.1:1025:25` -> +/// `1025`), while the server expects the IP to arrive with the mapping and +/// skips such ports. Together they opened databases bound to 127.0.0.1 to the +/// internet: the firewall rule allowed 5432/6379 from anywhere and the compose +/// file published them on every interface. This test runs a stacker.yml +/// through the CLI's payload builder and then through what the server does +/// with it. +#[cfg(test)] +mod loopback_ports_contract { + use super::derive_public_ports_from_metadata; + use crate::cli::config_parser::StackerConfig; + use crate::cli::stacker_client::build_project_body; + + const STACKER_YML: &str = r#" +name: loopback-contract +app: + type: custom + image: example/web:1.0 + ports: + - "8000:8000" + - "127.0.0.1:8025:8025" +services: + - name: postgres + image: postgres:16 + ports: + - "127.0.0.1:5432:5432" + - name: redis + image: redis:7 + ports: + - "127.0.0.1:6379:6379/tcp" + - name: proxy + image: nginx:alpine + ports: + - "443:443" +"#; + + fn shared_ports(body: &serde_json::Value) -> Vec { + ["web", "service", "feature"] + .iter() + .filter_map(|group| body["custom"][group].as_array()) + .flatten() + .filter_map(|app| app["shared_ports"].as_array()) + .flatten() + .cloned() + .collect() + } + + #[test] + fn ports_bound_to_loopback_in_stacker_yml_are_not_opened_to_the_internet() { + let config = StackerConfig::from_str(STACKER_YML).expect("stacker.yml parses"); + let body = build_project_body(&config); + + let mut public = derive_public_ports_from_metadata(&body); + public.sort(); + assert_eq!( + public, + vec!["443/tcp".to_string(), "8000/tcp".to_string()], + "only the ports not bound to 127.0.0.1 may reach the firewall" + ); + } + + #[test] + fn ports_bound_to_loopback_in_stacker_yml_stay_on_loopback_in_compose() { + let config = StackerConfig::from_str(STACKER_YML).expect("stacker.yml parses"); + let body = build_project_body(&config); + + let mut loopback_published = Vec::new(); + for raw in shared_ports(&body) { + let port: crate::forms::project::Port = + serde_json::from_value(raw.clone()).expect("shared port"); + let dc: docker_compose_types::Port = (&port).try_into().expect("compose port"); + if let Some(docker_compose_types::PublishedPort::Single(p)) = dc.published { + if dc.host_ip.as_deref() == Some("127.0.0.1") { + loopback_published.push(p); + } + } + } + loopback_published.sort(); + assert_eq!( + loopback_published, + vec![5432, 6379, 8025], + "compose must publish these on 127.0.0.1 only" + ); + } +}