Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 23 additions & 18 deletions src/cli/stacker_client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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").
Expand All @@ -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<serde_json::Value> = 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<serde_json::Value> =
svc.ports.iter().map(|p| port_mapping_json(p)).collect();

let volumes: Vec<serde_json::Value> = svc
.volumes
Expand Down Expand Up @@ -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()
};

Expand Down
89 changes: 89 additions & 0 deletions src/routes/project/deploy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<serde_json::Value> {
["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"
);
}
}
Loading