From 3eb4d3d743ba6a698db3e15c472695fb9354c825 Mon Sep 17 00:00:00 2001 From: Adam Spofford Date: Fri, 20 Feb 2026 13:05:59 -0800 Subject: [PATCH 01/21] Rename host to bind and use IP --- crates/icp/src/context/tests.rs | 8 ++++---- crates/icp/src/lib.rs | 4 ++-- crates/icp/src/manifest/network.rs | 12 ++++++------ crates/icp/src/network/mod.rs | 18 +++++++++--------- crates/icp/src/project.rs | 6 +++--- docs/concepts/environments.md | 2 +- docs/concepts/project-model.md | 2 +- docs/migration/from-dfx.md | 4 ++-- docs/reference/configuration.md | 4 ++-- docs/schemas/icp-yaml-schema.json | 14 +++++++------- docs/schemas/network-yaml-schema.json | 14 +++++++------- examples/icp-environments/icp.yaml | 2 +- 12 files changed, 45 insertions(+), 45 deletions(-) diff --git a/crates/icp/src/context/tests.rs b/crates/icp/src/context/tests.rs index a314374b4..d4b7df745 100644 --- a/crates/icp/src/context/tests.rs +++ b/crates/icp/src/context/tests.rs @@ -601,7 +601,7 @@ async fn test_get_agent_defaults_inside_project_with_default_local() { managed: Managed { mode: ManagedMode::Launcher(Box::new(ManagedLauncherConfig { gateway: Gateway { - host: "localhost".to_string(), + bind: "127.0.0.1".to_string(), port: Port::Fixed(8000), domains: vec![], }, @@ -671,7 +671,7 @@ async fn test_get_agent_defaults_with_overridden_local_network() { managed: Managed { mode: ManagedMode::Launcher(Box::new(ManagedLauncherConfig { gateway: Gateway { - host: "localhost".to_string(), + bind: "127.0.0.1".to_string(), port: Port::Fixed(9000), domains: vec![], }, @@ -743,7 +743,7 @@ async fn test_get_agent_defaults_with_overridden_local_environment() { managed: Managed { mode: ManagedMode::Launcher(Box::new(ManagedLauncherConfig { gateway: Gateway { - host: "localhost".to_string(), + bind: "127.0.0.1".to_string(), port: Port::Fixed(8000), domains: vec![], }, @@ -765,7 +765,7 @@ async fn test_get_agent_defaults_with_overridden_local_environment() { managed: Managed { mode: ManagedMode::Launcher(Box::new(ManagedLauncherConfig { gateway: Gateway { - host: "localhost".to_string(), + bind: "127.0.0.1".to_string(), port: Port::Fixed(7000), domains: vec![], }, diff --git a/crates/icp/src/lib.rs b/crates/icp/src/lib.rs index 1e8d44bf5..97adf3e8d 100644 --- a/crates/icp/src/lib.rs +++ b/crates/icp/src/lib.rs @@ -418,7 +418,7 @@ impl MockProjectLoader { managed: Managed { mode: ManagedMode::Launcher(Box::new(ManagedLauncherConfig { gateway: Gateway { - host: "localhost".to_string(), + bind: "127.0.0.1".to_string(), port: Port::Fixed(8000), domains: vec![], }, @@ -440,7 +440,7 @@ impl MockProjectLoader { managed: Managed { mode: ManagedMode::Launcher(Box::new(ManagedLauncherConfig { gateway: Gateway { - host: "localhost".to_string(), + bind: "127.0.0.1".to_string(), port: Port::Fixed(8001), domains: vec![], }, diff --git a/crates/icp/src/manifest/network.rs b/crates/icp/src/manifest/network.rs index 8dbe535b3..bddf92f19 100644 --- a/crates/icp/src/manifest/network.rs +++ b/crates/icp/src/manifest/network.rs @@ -147,7 +147,7 @@ impl From for String { #[derive(Clone, Debug, Deserialize, PartialEq, JsonSchema)] pub struct Gateway { /// Network interface for the gateway. Defaults to 127.0.0.1 - pub host: Option, + pub bind: Option, /// Domains the gateway should respond to. localhost is always included. pub domains: Option>, /// Port for the gateway to listen on. Defaults to 8000 @@ -282,20 +282,20 @@ mod tests { } #[test] - fn managed_network_with_host() { + fn managed_network_with_bind() { assert_eq!( validate_network_yaml(indoc! {r#" name: my-network mode: managed gateway: - host: localhost + bind: 127.0.0.1 "#}), NetworkManifest { name: "my-network".to_string(), configuration: Mode::Managed(Managed { mode: Box::new(ManagedMode::Launcher { gateway: Some(Gateway { - host: Some("localhost".to_string()), + bind: Some("127.0.0.1".to_string()), domains: None, port: None, }), @@ -319,7 +319,7 @@ mod tests { name: my-network mode: managed gateway: - host: localhost + bind: 127.0.0.1 port: 8000 "#}), NetworkManifest { @@ -327,7 +327,7 @@ mod tests { configuration: Mode::Managed(Managed { mode: Box::new(ManagedMode::Launcher { gateway: Some(Gateway { - host: Some("localhost".to_string()), + bind: Some("127.0.0.1".to_string()), domains: None, port: Some(8000) }), diff --git a/crates/icp/src/network/mod.rs b/crates/icp/src/network/mod.rs index 9394fb402..7cd090224 100644 --- a/crates/icp/src/network/mod.rs +++ b/crates/icp/src/network/mod.rs @@ -49,14 +49,14 @@ impl<'de> Deserialize<'de> for Port { } } -fn default_host() -> String { - "localhost".to_string() +fn default_bind() -> String { + "127.0.0.1".to_string() } #[derive(Clone, Debug, Deserialize, PartialEq, JsonSchema, Serialize)] pub struct Gateway { - #[serde(default = "default_host")] - pub host: String, + #[serde(default = "default_bind")] + pub bind: String, #[serde(default)] pub port: Port, @@ -68,7 +68,7 @@ pub struct Gateway { impl Default for Gateway { fn default() -> Self { Self { - host: default_host(), + bind: default_bind(), port: Default::default(), domains: Default::default(), } @@ -125,7 +125,7 @@ impl ManagedMode { pub fn default_for_port(port: u16) -> Self { ManagedMode::Launcher(Box::new(ManagedLauncherConfig { gateway: Gateway { - host: default_host(), + bind: default_bind(), port: if port == 0 { Port::Random } else { @@ -206,18 +206,18 @@ impl Default for Configuration { impl From for Gateway { fn from(value: ManifestGateway) -> Self { let ManifestGateway { - host, + bind, domains, port, } = value; - let host = host.unwrap_or("localhost".to_string()); + let bind = bind.unwrap_or("localhost".to_string()); let port = match port { Some(0) => Port::Random, Some(p) => Port::Fixed(p), None => Port::Random, }; Gateway { - host, + bind, port, domains: domains.unwrap_or_default(), } diff --git a/crates/icp/src/project.rs b/crates/icp/src/project.rs index fd47905f0..e88a0bdbb 100644 --- a/crates/icp/src/project.rs +++ b/crates/icp/src/project.rs @@ -22,9 +22,9 @@ use crate::{ prelude::*, }; -pub const DEFAULT_LOCAL_NETWORK_HOST: &str = "localhost"; +pub const DEFAULT_LOCAL_NETWORK_BIND: &str = "127.0.0.1"; pub const DEFAULT_LOCAL_NETWORK_PORT: u16 = 8000; -pub const DEFAULT_LOCAL_NETWORK_URL: &str = "http://localhost:8000"; +pub const DEFAULT_LOCAL_NETWORK_URL: &str = "http://127.0.0.1:8000"; #[derive(Debug, Snafu)] pub enum EnvironmentError { @@ -382,7 +382,7 @@ pub async fn consolidate_manifest( managed: Managed { mode: ManagedMode::Launcher(Box::new(ManagedLauncherConfig { gateway: Gateway { - host: DEFAULT_LOCAL_NETWORK_HOST.to_string(), + bind: DEFAULT_LOCAL_NETWORK_BIND.to_string(), port: Port::Fixed(DEFAULT_LOCAL_NETWORK_PORT), domains: vec![], }, diff --git a/docs/concepts/environments.md b/docs/concepts/environments.md index 4061ae065..43f528af4 100644 --- a/docs/concepts/environments.md +++ b/docs/concepts/environments.md @@ -18,7 +18,7 @@ networks: mode: managed ii: true gateway: - host: 127.0.0.1 + bind: 127.0.0.1 port: 8000 ``` diff --git a/docs/concepts/project-model.md b/docs/concepts/project-model.md index 20e6165fc..7db2f6ff8 100644 --- a/docs/concepts/project-model.md +++ b/docs/concepts/project-model.md @@ -71,7 +71,7 @@ networks: configuration: mode: managed gateway: - host: localhost + bind: 127.0.0.1 port: 8000 ``` diff --git a/docs/migration/from-dfx.md b/docs/migration/from-dfx.md index 17c06c027..3439b89c9 100644 --- a/docs/migration/from-dfx.md +++ b/docs/migration/from-dfx.md @@ -310,7 +310,7 @@ networks: - name: local mode: managed gateway: - host: 127.0.0.1 + bind: 127.0.0.1 port: 4943 ``` @@ -318,7 +318,7 @@ networks: - dfx's `"type": "persistent"` maps to icp-cli's `mode: connected` (external networks) - dfx's `"type": "ephemeral"` maps to icp-cli's `mode: managed` (local networks that icp-cli controls) - dfx's `"providers"` array (which can list multiple URLs for redundancy) becomes a single `url` field in icp-cli -- dfx's `"bind"` address for local networks maps to icp-cli's `gateway.host` and `gateway.port` +- dfx's `"bind"` address for local networks maps to icp-cli's `gateway.bind` and `gateway.port` - **Root key handling**: dfx automatically fetches the root key from non-mainnet networks at runtime. icp-cli requires you to specify the `root-key` explicitly in the configuration for testnets (connected networks). For local managed networks, icp-cli retrieves the root key from the network launcher. The root key is the public key used to verify responses from the network. Explicit configuration ensures the root key comes from a trusted source rather than the network itself. **Note:** icp-cli uses `https://icp-api.io` as the default IC mainnet URL, while dfx currently uses `https://icp0.io`. Both URLs point to the same IC mainnet, but `https://icp-api.io` is the recommended API gateway. The implicit `ic` network in icp-cli is configured with `https://icp-api.io`. diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 9ed56ed29..1b6dcab2d 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -199,7 +199,7 @@ networks: - name: local-dev mode: managed gateway: - host: 127.0.0.1 + bind: 127.0.0.1 port: 4943 ``` @@ -207,7 +207,7 @@ networks: |----------|------|----------|-------------| | `name` | string | Yes | Network identifier | | `mode` | string | Yes | `managed` | -| `gateway.host` | string | No | Host address (default: localhost) | +| `gateway.bind` | string | No | Bind address (default: 127.0.0.1) | | `gateway.port` | integer | No | Port number (default: 8000, use 0 for random) | | `artificial-delay-ms` | integer | No | Artificial delay for update calls (ms) | | `ii` | boolean | No | Install Internet Identity canister (default: false). Also implicitly enabled by `nns`, `bitcoind-addr`, and `dogecoind-addr`. | diff --git a/docs/schemas/icp-yaml-schema.json b/docs/schemas/icp-yaml-schema.json index 2b6756c5e..72a8dc302 100644 --- a/docs/schemas/icp-yaml-schema.json +++ b/docs/schemas/icp-yaml-schema.json @@ -330,6 +330,13 @@ }, "Gateway": { "properties": { + "bind": { + "description": "Network interface for the gateway. Defaults to 127.0.0.1", + "type": [ + "string", + "null" + ] + }, "domains": { "description": "Domains the gateway should respond to. localhost is always included.", "items": { @@ -340,13 +347,6 @@ "null" ] }, - "host": { - "description": "Network interface for the gateway. Defaults to 127.0.0.1", - "type": [ - "string", - "null" - ] - }, "port": { "description": "Port for the gateway to listen on. Defaults to 8000", "format": "uint16", diff --git a/docs/schemas/network-yaml-schema.json b/docs/schemas/network-yaml-schema.json index 81d8da262..8e304ac24 100644 --- a/docs/schemas/network-yaml-schema.json +++ b/docs/schemas/network-yaml-schema.json @@ -51,6 +51,13 @@ }, "Gateway": { "properties": { + "bind": { + "description": "Network interface for the gateway. Defaults to 127.0.0.1", + "type": [ + "string", + "null" + ] + }, "domains": { "description": "Domains the gateway should respond to. localhost is always included.", "items": { @@ -61,13 +68,6 @@ "null" ] }, - "host": { - "description": "Network interface for the gateway. Defaults to 127.0.0.1", - "type": [ - "string", - "null" - ] - }, "port": { "description": "Port for the gateway to listen on. Defaults to 8000", "format": "uint16", diff --git a/examples/icp-environments/icp.yaml b/examples/icp-environments/icp.yaml index 61501828c..8c3076a1a 100644 --- a/examples/icp-environments/icp.yaml +++ b/examples/icp-environments/icp.yaml @@ -13,7 +13,7 @@ networks: - name: my-network mode: managed gateway: - host: 127.0.0.1 + bind: 127.0.0.1 environments: - name: my-environment From 89c19c7276be3333d090df5eb3db2910c4cdf2b5 Mon Sep 17 00:00:00 2001 From: Adam Spofford Date: Fri, 20 Feb 2026 13:49:21 -0800 Subject: [PATCH 02/21] Use the bind correctly --- crates/icp/src/network/access.rs | 2 +- crates/icp/src/network/config.rs | 7 ++++ crates/icp/src/network/managed/launcher.rs | 4 ++- crates/icp/src/network/managed/run.rs | 33 +++++++++++++++--- crates/icp/src/network/mod.rs | 40 ++++++++++++++++++++++ 5 files changed, 80 insertions(+), 6 deletions(-) diff --git a/crates/icp/src/network/access.rs b/crates/icp/src/network/access.rs index a224d9588..67b854936 100644 --- a/crates/icp/src/network/access.rs +++ b/crates/icp/src/network/access.rs @@ -76,7 +76,7 @@ pub async fn get_managed_network_access( .fail(); } } - let http_gateway_url = Url::parse(&format!("http://localhost:{port}")).unwrap(); + let http_gateway_url = Url::parse(&format!("http://{}:{port}", desc.gateway.host)).unwrap(); Ok(NetworkAccess { root_key: desc.root_key, api_url: http_gateway_url.clone(), diff --git a/crates/icp/src/network/config.rs b/crates/icp/src/network/config.rs index 84bc3c95f..bcb8e2b06 100644 --- a/crates/icp/src/network/config.rs +++ b/crates/icp/src/network/config.rs @@ -16,6 +16,10 @@ use uuid::Uuid; use crate::prelude::*; +fn default_gateway_host() -> String { + "localhost".to_string() +} + /// Gateway port configuration within a network descriptor. #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] @@ -25,6 +29,9 @@ pub struct NetworkDescriptorGatewayPort { pub fixed: bool, /// The TCP port the gateway is listening on. pub port: u16, + /// The host to use when constructing URLs to reach the gateway. + #[serde(default = "default_gateway_host")] + pub host: String, } /// Runtime state of a running managed network, persisted as `descriptor.json`. diff --git a/crates/icp/src/network/managed/launcher.rs b/crates/icp/src/network/managed/launcher.rs index 2146c7c22..fd4caa4d7 100644 --- a/crates/icp/src/network/managed/launcher.rs +++ b/crates/icp/src/network/managed/launcher.rs @@ -10,7 +10,7 @@ use sysinfo::{Pid, ProcessesToUpdate, Signal, System}; use tokio::{process::Child, select, sync::mpsc::Sender, time::Instant}; use crate::{ - network::{ManagedLauncherConfig, Port, config::ChildLocator}, + network::{ManagedLauncherConfig, Port, ResolvedBind, config::ChildLocator}, prelude::*, }; @@ -65,6 +65,7 @@ pub async fn spawn_network_launcher( background: bool, verbose: bool, launcher_config: &ManagedLauncherConfig, + resolved_bind: &ResolvedBind, state_dir: &Path, ) -> Result< ( @@ -81,6 +82,7 @@ pub async fn spawn_network_launcher( "--state-dir", state_dir.as_str(), ]); + cmd.args(["--bind", &resolved_bind.ip.to_string()]); if let Port::Fixed(port) = launcher_config.gateway.port { cmd.args(["--gateway-port", &port.to_string()]); } diff --git a/crates/icp/src/network/managed/run.rs b/crates/icp/src/network/managed/run.rs index e51696421..2fb70543e 100644 --- a/crates/icp/src/network/managed/run.rs +++ b/crates/icp/src/network/managed/run.rs @@ -45,6 +45,7 @@ use crate::{ docker::{DockerDropGuard, ManagedImageOptions, spawn_docker_launcher}, launcher::{ChildSignalOnDrop, launcher_settings_flags, spawn_network_launcher}, }, + resolve_bind, }, prelude::*, signal::stop_signal, @@ -134,6 +135,18 @@ async fn run_network_launcher( ) -> Result<(), RunNetworkLauncherError> { let network_root = nd.root()?; + // Resolve the bind address to an IP and a URL host + let resolved = match &config.mode { + ManagedMode::Launcher(launcher_config) => resolve_bind(&launcher_config.gateway.bind) + .context(ResolveBindSnafu { + bind: &launcher_config.gateway.bind, + })?, + ManagedMode::Image(_) => crate::network::ResolvedBind { + ip: std::net::Ipv4Addr::LOCALHOST.into(), + host: "localhost".to_string(), + }, + }; + // Determine the image options and fixed ports to check before spawning #[allow(clippy::large_enum_variant)] // Only used inline, not moved enum LaunchMode<'a> { @@ -147,7 +160,7 @@ async fn run_network_launcher( (LaunchMode::Image(options), fixed_ports) } ManagedMode::Launcher(launcher_config) if autocontainerize => { - let options = transform_native_launcher_to_container(launcher_config); + let options = transform_native_launcher_to_container(launcher_config, &resolved); let fixed_ports = options.fixed_host_ports(); (LaunchMode::Image(options), fixed_ports) } @@ -191,6 +204,7 @@ async fn run_network_launcher( let gateway = NetworkDescriptorGatewayPort { port: instance.gateway_port, fixed, + host: resolved.host.clone(), }; Ok((ShutdownGuard::Container(guard), instance, gateway, locator)) } @@ -209,12 +223,14 @@ async fn run_network_launcher( background, verbose, launcher_config, + &resolved, &root.state_dir(), ) .await?; let gateway = NetworkDescriptorGatewayPort { port: instance.gateway_port, fixed: matches!(launcher_config.gateway.port, Port::Fixed(_)), + host: resolved.host.clone(), }; Ok((ShutdownGuard::Process(child), instance, gateway, locator)) } @@ -228,7 +244,7 @@ async fn run_network_launcher( } let (candid_ui_canister_id, proxy_canister_id) = initialize_network( - &format!("http://localhost:{}", instance.gateway_port) + &format!("http://{}:{}", resolved.host, instance.gateway_port) .parse() .unwrap(), &instance.root_key, @@ -285,7 +301,10 @@ async fn run_network_launcher( Ok(()) } -fn transform_native_launcher_to_container(config: &ManagedLauncherConfig) -> ManagedImageOptions { +fn transform_native_launcher_to_container( + config: &ManagedLauncherConfig, + resolved: &crate::network::ResolvedBind, +) -> ManagedImageOptions { use bollard::secret::PortBinding; use std::collections::HashMap; @@ -316,7 +335,7 @@ fn transform_native_launcher_to_container(config: &ManagedLauncherConfig) -> Man let port_bindings: HashMap>> = [( "4943/tcp".to_string(), Some(vec![PortBinding { - host_ip: Some("127.0.0.1".to_string()), + host_ip: Some(resolved.ip.to_string()), host_port: Some(port.to_string()), }]), )] @@ -365,6 +384,12 @@ pub enum RunNetworkLauncherError { #[snafu(display("ICP_CLI_NETWORK_LAUNCHER_PATH environment variable is not set"))] NoNetworkLauncherPath, + #[snafu(display("failed to resolve bind address '{bind}'"))] + ResolveBind { + source: std::io::Error, + bind: String, + }, + #[snafu(display("failed to create dir"))] CreateDirAll { source: crate::fs::IoError }, diff --git a/crates/icp/src/network/mod.rs b/crates/icp/src/network/mod.rs index 7cd090224..bb2cb92dc 100644 --- a/crates/icp/src/network/mod.rs +++ b/crates/icp/src/network/mod.rs @@ -1,3 +1,5 @@ +use std::collections::HashSet; +use std::net::{IpAddr, ToSocketAddrs}; use std::sync::Arc; use async_trait::async_trait; @@ -28,6 +30,44 @@ pub mod config; pub mod directory; pub mod managed; +/// A bind address resolved to an IP and a URL host. +pub struct ResolvedBind { + /// The resolved IP address to pass as the bind address. + pub ip: IpAddr, + /// The host to use when constructing URLs to reach this address. + /// `"localhost"` when the IP is one that `localhost` resolves to, + /// otherwise the original bind string (preserving domain names). + pub host: String, +} + +/// Resolve a bind address string to an IP and a URL host. +/// +/// Uses DNS resolution to turn the bind string into an IP. The URL host is +/// `"localhost"` if the resolved IP matches one of `localhost`'s addresses, +/// otherwise the original bind string is preserved (keeping domain names intact). +pub fn resolve_bind(bind: &str) -> std::io::Result { + let ip = (bind, 0u16) + .to_socket_addrs()? + .next() + .expect("to_socket_addrs returned Ok but no addresses") + .ip(); + + let localhost_ips: HashSet = ("localhost", 0u16) + .to_socket_addrs() + .into_iter() + .flatten() + .map(|a| a.ip()) + .collect(); + + let host = if localhost_ips.contains(&ip) { + "localhost".to_string() + } else { + bind.to_string() + }; + + Ok(ResolvedBind { ip, host }) +} + #[derive(Clone, Debug, PartialEq, JsonSchema, Serialize)] pub enum Port { Fixed(u16), From 256660cdcb40bd1c1e997c78ffd4b92b998953d3 Mon Sep 17 00:00:00 2001 From: Adam Spofford Date: Fri, 20 Feb 2026 14:28:47 -0800 Subject: [PATCH 03/21] Use first configured domain or embed IP as a domain if none --- crates/icp/src/network/managed/launcher.rs | 9 +++-- crates/icp/src/network/managed/run.rs | 32 +++++++++------- crates/icp/src/network/mod.rs | 43 ++++++++++++++++++---- 3 files changed, 59 insertions(+), 25 deletions(-) diff --git a/crates/icp/src/network/managed/launcher.rs b/crates/icp/src/network/managed/launcher.rs index fd4caa4d7..5f54ad2e2 100644 --- a/crates/icp/src/network/managed/launcher.rs +++ b/crates/icp/src/network/managed/launcher.rs @@ -88,7 +88,7 @@ pub async fn spawn_network_launcher( } let status_dir = Utf8TempDir::new().context(CreateStatusDirSnafu)?; cmd.args(["--status-dir", status_dir.path().as_str()]); - cmd.args(launcher_settings_flags(launcher_config)); + cmd.args(launcher_settings_flags(launcher_config, resolved_bind)); if background { eprintln!("For background mode, network output will be redirected:"); eprintln!(" stdout: {}", stdout_file); @@ -169,7 +169,10 @@ pub async fn stop_launcher(pid: Pid) { } } -pub fn launcher_settings_flags(config: &ManagedLauncherConfig) -> Vec { +pub fn launcher_settings_flags( + config: &ManagedLauncherConfig, + resolved_bind: &ResolvedBind, +) -> Vec { let ManagedLauncherConfig { gateway, version: _, @@ -205,7 +208,7 @@ pub fn launcher_settings_flags(config: &ManagedLauncherConfig) -> Vec { flags.push(format!("--dogecoind-addr={addr}")); } } - for domain in &gateway.domains { + for domain in gateway.domains.iter().chain(&resolved_bind.extra_domains) { flags.push(format!("--domain={domain}")); } flags diff --git a/crates/icp/src/network/managed/run.rs b/crates/icp/src/network/managed/run.rs index 2fb70543e..d0717a69a 100644 --- a/crates/icp/src/network/managed/run.rs +++ b/crates/icp/src/network/managed/run.rs @@ -137,13 +137,17 @@ async fn run_network_launcher( // Resolve the bind address to an IP and a URL host let resolved = match &config.mode { - ManagedMode::Launcher(launcher_config) => resolve_bind(&launcher_config.gateway.bind) - .context(ResolveBindSnafu { - bind: &launcher_config.gateway.bind, - })?, + ManagedMode::Launcher(launcher_config) => resolve_bind( + &launcher_config.gateway.bind, + &launcher_config.gateway.domains, + ) + .context(ResolveBindSnafu { + bind: &launcher_config.gateway.bind, + })?, ManagedMode::Image(_) => crate::network::ResolvedBind { ip: std::net::Ipv4Addr::LOCALHOST.into(), host: "localhost".to_string(), + extra_domains: vec![], }, }; @@ -314,7 +318,7 @@ fn transform_native_launcher_to_container( Port::Fixed(port) => port, Port::Random => 0, }; - let args = launcher_settings_flags(config); + let args = launcher_settings_flags(config, resolved); let args = translate_launcher_args_for_docker(args); let all_addrs: Vec = config @@ -893,13 +897,13 @@ async fn install_proxy( #[cfg(test)] mod tests { use super::*; - use crate::network::{Gateway, ManagedLauncherConfig, Port}; + use crate::network::{Gateway, ManagedLauncherConfig, Port, ResolvedBind}; #[test] fn transform_native_launcher_default_config() { let config = ManagedLauncherConfig { gateway: Gateway { - host: "localhost".to_string(), + bind: "localhost".to_string(), port: Port::Fixed(8000), domains: vec![], }, @@ -911,7 +915,7 @@ mod tests { dogecoind_addr: None, version: None, }; - let opts = transform_native_launcher_to_container(&config); + let opts = transform_native_launcher_to_container(&config, &ResolvedBind::default()); assert_eq!( opts.image, "ghcr.io/dfinity/icp-cli-network-launcher:latest" @@ -933,7 +937,7 @@ mod tests { fn transform_native_launcher_random_port() { let config = ManagedLauncherConfig { gateway: Gateway { - host: "localhost".to_string(), + bind: "127.0.0.1".to_string(), port: Port::Random, domains: vec![], }, @@ -945,7 +949,7 @@ mod tests { dogecoind_addr: None, version: None, }; - let opts = transform_native_launcher_to_container(&config); + let opts = transform_native_launcher_to_container(&config, &ResolvedBind::default()); let binding = opts .port_bindings .get("4943/tcp") @@ -967,7 +971,7 @@ mod tests { dogecoind_addr: None, version: None, }; - let opts = transform_native_launcher_to_container(&config); + let opts = transform_native_launcher_to_container(&config, &ResolvedBind::default()); assert!(opts.args.contains(&"--ii".to_string())); assert!( opts.args @@ -991,7 +995,7 @@ mod tests { dogecoind_addr: Some(vec!["localhost:22556".to_string()]), version: None, }; - let opts = transform_native_launcher_to_container(&config); + let opts = transform_native_launcher_to_container(&config, &ResolvedBind::default()); assert!(opts.args.contains(&"--nns".to_string())); assert!(opts.args.contains(&"--artificial-delay-ms=50".to_string())); assert!( @@ -1016,7 +1020,7 @@ mod tests { dogecoind_addr: None, version: None, }; - let opts = transform_native_launcher_to_container(&config); + let opts = transform_native_launcher_to_container(&config, &ResolvedBind::default()); assert!( opts.args .contains(&"--bitcoind-addr=192.168.1.5:18444".to_string()) @@ -1036,7 +1040,7 @@ mod tests { dogecoind_addr: None, version: None, }; - let opts = transform_native_launcher_to_container(&config); + let opts = transform_native_launcher_to_container(&config, &ResolvedBind::default()); assert!( opts.args .contains(&"--bitcoind-addr=host.docker.internal:18444".to_string()) diff --git a/crates/icp/src/network/mod.rs b/crates/icp/src/network/mod.rs index bb2cb92dc..05f1619f7 100644 --- a/crates/icp/src/network/mod.rs +++ b/crates/icp/src/network/mod.rs @@ -38,14 +38,32 @@ pub struct ResolvedBind { /// `"localhost"` when the IP is one that `localhost` resolves to, /// otherwise the original bind string (preserving domain names). pub host: String, + /// Additional domains to pass to the launcher beyond those already + /// configured in `gateway.domains`. Populated when the bind is a bare + /// non-loopback IP with no configured domains, so the gateway knows + /// to respond on that IP. + pub extra_domains: Vec, +} + +impl Default for ResolvedBind { + fn default() -> Self { + ResolvedBind { + ip: IpAddr::V4([127, 0, 0, 1].into()), + host: "localhost".to_string(), + extra_domains: vec![], + } + } } /// Resolve a bind address string to an IP and a URL host. /// /// Uses DNS resolution to turn the bind string into an IP. The URL host is -/// `"localhost"` if the resolved IP matches one of `localhost`'s addresses, -/// otherwise the original bind string is preserved (keeping domain names intact). -pub fn resolve_bind(bind: &str) -> std::io::Result { +/// determined as follows: +/// 1. `"localhost"` if the resolved IP matches one of `localhost`'s addresses +/// 2. The original bind string if it was a domain name (preserving it for URLs) +/// 3. The first entry from `domains` if the bind was a bare IP and domains are configured +/// 4. The bare IP as a last resort +pub fn resolve_bind(bind: &str, domains: &[String]) -> std::io::Result { let ip = (bind, 0u16) .to_socket_addrs()? .next() @@ -59,13 +77,22 @@ pub fn resolve_bind(bind: &str) -> std::io::Result { .map(|a| a.ip()) .collect(); - let host = if localhost_ips.contains(&ip) { - "localhost".to_string() + let (host, extra_domains) = if localhost_ips.contains(&ip) { + ("localhost".to_string(), vec![]) + } else if bind.parse::().is_err() { + // bind was a domain name — preserve it + (bind.to_string(), vec![]) + } else if let Some(domain) = domains.first() { + (domain.clone(), vec![]) } else { - bind.to_string() + (bind.to_string(), vec![bind.to_string()]) }; - Ok(ResolvedBind { ip, host }) + Ok(ResolvedBind { + ip, + host, + extra_domains, + }) } #[derive(Clone, Debug, PartialEq, JsonSchema, Serialize)] @@ -491,7 +518,7 @@ mod tests { let mode = Mode::Managed(ManifestManaged { mode: Box::new(ManifestManagedMode::Launcher { gateway: Some(ManifestGateway { - host: None, + bind: None, port: Some(8000), domains: None, }), From 54a5c14686ed2e6627fcaea2996c5aecd2eff800 Mon Sep 17 00:00:00 2001 From: Adam Spofford Date: Fri, 20 Feb 2026 15:22:44 -0800 Subject: [PATCH 04/21] Fix descriptor and add test --- crates/icp-cli/tests/network_tests.rs | 113 ++++++++++++++++++++++++++ crates/icp/src/network/mod.rs | 9 +- 2 files changed, 120 insertions(+), 2 deletions(-) diff --git a/crates/icp-cli/tests/network_tests.rs b/crates/icp-cli/tests/network_tests.rs index 61fff6f35..c0bdde491 100644 --- a/crates/icp-cli/tests/network_tests.rs +++ b/crates/icp-cli/tests/network_tests.rs @@ -829,3 +829,116 @@ async fn network_gateway_responds_to_custom_domain() { resp.status() ); } + +/// Test that binding to a non-localhost loopback IP (127.0.0.2) works end-to-end: +/// the gateway binds on the right address, deploy prints `?canisterId=` URLs +/// instead of broken subdomains of a bare IP, canister status works, and +/// assets are fetchable via the query-parameter URL form. +/// +/// Only runs on Linux — macOS doesn't route 127.0.0.2 by default. +#[cfg(target_os = "linux")] +#[tokio::test] +async fn network_non_localhost_loopback_bind() { + use icp::{fs::create_dir_all, store_id::IdMapping}; + + let ctx = TestContext::new(); + let project_dir = ctx.create_project_dir("loopback-bind"); + + let assets_dir = project_dir.join("www"); + create_dir_all(&assets_dir).expect("failed to create assets directory"); + write_string(&assets_dir.join("index.html"), "hello").expect("failed to create index page"); + + write_string( + &project_dir.join("icp.yaml"), + &formatdoc! {r#" + canisters: + - name: my-assets + build: + steps: + - type: pre-built + url: https://github.com/dfinity/sdk/raw/refs/tags/0.27.0/src/distributed/assetstorage.wasm.gz + sha256: 865eb25df5a6d857147e078bb33c727797957247f7af2635846d65c5397b36a6 + sync: + steps: + - type: assets + dirs: + - {assets_dir} + + networks: + - name: loopback-network + mode: managed + gateway: + bind: 127.0.0.2 + port: 0 + + environments: + - name: loopback-env + network: loopback-network + "#}, + ) + .expect("failed to write project manifest"); + + let _guard = ctx.start_network_in(&project_dir, "loopback-network").await; + ctx.ping_until_healthy(&project_dir, "loopback-network"); + + // Read the raw descriptor to verify the host field + let descriptor: Value = + serde_json::from_slice(&ctx.read_network_descriptor(&project_dir, "loopback-network")) + .expect("failed to parse descriptor"); + let host = descriptor["gateway"]["host"] + .as_str() + .expect("descriptor missing gateway.host"); + assert_eq!(host, "127.0.0.2", "descriptor host should be the bare IP"); + let port = descriptor["gateway"]["port"].as_u64().unwrap() as u16; + + // Deploy: URLs should use ?canisterId= form, not a broken subdomain of an IP + clients::icp(&ctx, &project_dir, Some("loopback-env".to_string())).mint_cycles(10 * TRILLION); + + ctx.icp() + .current_dir(&project_dir) + .args([ + "deploy", + "--subnet", + common::SUBNET_ID, + "--environment", + "loopback-env", + ]) + .assert() + .success() + .stdout(contains("?canisterId=")) + .stdout(contains("Deployed canisters:")); + + // Read the canister ID + let id_mapping: IdMapping = icp::fs::json::load( + &project_dir + .join(".icp") + .join("cache") + .join("mappings") + .join("loopback-env.ids.json"), + ) + .expect("failed to read ID mapping"); + let cid = id_mapping + .get("my-assets") + .expect("canister ID not found for my-assets"); + + // canister status should work via the descriptor's host + ctx.icp() + .current_dir(&project_dir) + .args([ + "canister", + "status", + "--environment", + "loopback-env", + "my-assets", + ]) + .assert() + .success() + .stdout(contains("Status: Running")); + + // Fetch the asset over the non-localhost loopback IP + let resp = reqwest::get(format!("http://127.0.0.2:{port}/?canisterId={cid}")) + .await + .expect("request to 127.0.0.2 failed"); + let body = resp.text().await.expect("failed to read response body"); + assert_eq!(body, "hello"); +} diff --git a/crates/icp/src/network/mod.rs b/crates/icp/src/network/mod.rs index 05f1619f7..9a966b27f 100644 --- a/crates/icp/src/network/mod.rs +++ b/crates/icp/src/network/mod.rs @@ -80,8 +80,13 @@ pub fn resolve_bind(bind: &str, domains: &[String]) -> std::io::Result().is_err() { - // bind was a domain name — preserve it - (bind.to_string(), vec![]) + // bind was a domain name — ensure the gateway responds to it + let extra = if domains.iter().any(|d| d == bind) { + vec![] + } else { + vec![bind.to_string()] + }; + (bind.to_string(), extra) } else if let Some(domain) = domains.first() { (domain.clone(), vec![]) } else { From 642c3101f7e32cda4a76de689aa5e66642e7376f Mon Sep 17 00:00:00 2001 From: Adam Spofford Date: Fri, 20 Feb 2026 16:08:27 -0800 Subject: [PATCH 05/21] Disallow ipv6 binds --- crates/icp/src/network/managed/run.rs | 10 ++---- crates/icp/src/network/mod.rs | 45 +++++++++++++++++++++++---- 2 files changed, 42 insertions(+), 13 deletions(-) diff --git a/crates/icp/src/network/managed/run.rs b/crates/icp/src/network/managed/run.rs index d0717a69a..15238d3ea 100644 --- a/crates/icp/src/network/managed/run.rs +++ b/crates/icp/src/network/managed/run.rs @@ -140,10 +140,7 @@ async fn run_network_launcher( ManagedMode::Launcher(launcher_config) => resolve_bind( &launcher_config.gateway.bind, &launcher_config.gateway.domains, - ) - .context(ResolveBindSnafu { - bind: &launcher_config.gateway.bind, - })?, + )?, ManagedMode::Image(_) => crate::network::ResolvedBind { ip: std::net::Ipv4Addr::LOCALHOST.into(), host: "localhost".to_string(), @@ -388,10 +385,9 @@ pub enum RunNetworkLauncherError { #[snafu(display("ICP_CLI_NETWORK_LAUNCHER_PATH environment variable is not set"))] NoNetworkLauncherPath, - #[snafu(display("failed to resolve bind address '{bind}'"))] + #[snafu(transparent)] ResolveBind { - source: std::io::Error, - bind: String, + source: crate::network::ResolveBindError, }, #[snafu(display("failed to create dir"))] diff --git a/crates/icp/src/network/mod.rs b/crates/icp/src/network/mod.rs index 9a966b27f..6fd4da257 100644 --- a/crates/icp/src/network/mod.rs +++ b/crates/icp/src/network/mod.rs @@ -55,6 +55,22 @@ impl Default for ResolvedBind { } } +#[derive(Debug, Snafu)] +pub enum ResolveBindError { + #[snafu(display("failed to resolve '{bind}'"))] + Resolve { + source: std::io::Error, + bind: String, + }, + + #[snafu(display( + "'{bind}' only resolves to IPv6 address {ip}; the network launcher \ + requires an IPv4 bind address. Use an IPv4 address or a hostname \ + that resolves to one" + ))] + Ipv6Only { bind: String, ip: IpAddr }, +} + /// Resolve a bind address string to an IP and a URL host. /// /// Uses DNS resolution to turn the bind string into an IP. The URL host is @@ -62,18 +78,35 @@ impl Default for ResolvedBind { /// 1. `"localhost"` if the resolved IP matches one of `localhost`'s addresses /// 2. The original bind string if it was a domain name (preserving it for URLs) /// 3. The first entry from `domains` if the bind was a bare IP and domains are configured -/// 4. The bare IP as a last resort -pub fn resolve_bind(bind: &str, domains: &[String]) -> std::io::Result { - let ip = (bind, 0u16) - .to_socket_addrs()? - .next() - .expect("to_socket_addrs returned Ok but no addresses") +/// 4. Error if the result is a bare IPv6 address (cannot be used as an HTTP host +/// without brackets, and PocketIC does not support IPv6 binding) +/// 5. The bare IPv4 as a last resort +pub fn resolve_bind(bind: &str, domains: &[String]) -> Result { + let addrs: Vec<_> = (bind, 0u16) + .to_socket_addrs() + .context(ResolveSnafu { bind })? + .collect(); + // PocketIC does not support IPv6 binding. + let ip = addrs + .iter() + .find(|a| a.is_ipv4()) + .ok_or_else(|| { + let ip = addrs + .first() + .expect("to_socket_addrs returned Ok but no addresses") + .ip(); + ResolveBindError::Ipv6Only { + bind: bind.to_string(), + ip, + } + })? .ip(); let localhost_ips: HashSet = ("localhost", 0u16) .to_socket_addrs() .into_iter() .flatten() + .filter(|a| a.is_ipv4()) .map(|a| a.ip()) .collect(); From c4c42a4d0d8ef8b9c9ddfaee0b4788f69bd15268 Mon Sep 17 00:00:00 2001 From: Adam Spofford Date: Fri, 20 Feb 2026 16:38:40 -0800 Subject: [PATCH 06/21] Remove all flexible handling --- crates/icp-cli/tests/network_tests.rs | 113 -------------------------- crates/icp/src/network/mod.rs | 45 +++++----- 2 files changed, 22 insertions(+), 136 deletions(-) diff --git a/crates/icp-cli/tests/network_tests.rs b/crates/icp-cli/tests/network_tests.rs index c0bdde491..61fff6f35 100644 --- a/crates/icp-cli/tests/network_tests.rs +++ b/crates/icp-cli/tests/network_tests.rs @@ -829,116 +829,3 @@ async fn network_gateway_responds_to_custom_domain() { resp.status() ); } - -/// Test that binding to a non-localhost loopback IP (127.0.0.2) works end-to-end: -/// the gateway binds on the right address, deploy prints `?canisterId=` URLs -/// instead of broken subdomains of a bare IP, canister status works, and -/// assets are fetchable via the query-parameter URL form. -/// -/// Only runs on Linux — macOS doesn't route 127.0.0.2 by default. -#[cfg(target_os = "linux")] -#[tokio::test] -async fn network_non_localhost_loopback_bind() { - use icp::{fs::create_dir_all, store_id::IdMapping}; - - let ctx = TestContext::new(); - let project_dir = ctx.create_project_dir("loopback-bind"); - - let assets_dir = project_dir.join("www"); - create_dir_all(&assets_dir).expect("failed to create assets directory"); - write_string(&assets_dir.join("index.html"), "hello").expect("failed to create index page"); - - write_string( - &project_dir.join("icp.yaml"), - &formatdoc! {r#" - canisters: - - name: my-assets - build: - steps: - - type: pre-built - url: https://github.com/dfinity/sdk/raw/refs/tags/0.27.0/src/distributed/assetstorage.wasm.gz - sha256: 865eb25df5a6d857147e078bb33c727797957247f7af2635846d65c5397b36a6 - sync: - steps: - - type: assets - dirs: - - {assets_dir} - - networks: - - name: loopback-network - mode: managed - gateway: - bind: 127.0.0.2 - port: 0 - - environments: - - name: loopback-env - network: loopback-network - "#}, - ) - .expect("failed to write project manifest"); - - let _guard = ctx.start_network_in(&project_dir, "loopback-network").await; - ctx.ping_until_healthy(&project_dir, "loopback-network"); - - // Read the raw descriptor to verify the host field - let descriptor: Value = - serde_json::from_slice(&ctx.read_network_descriptor(&project_dir, "loopback-network")) - .expect("failed to parse descriptor"); - let host = descriptor["gateway"]["host"] - .as_str() - .expect("descriptor missing gateway.host"); - assert_eq!(host, "127.0.0.2", "descriptor host should be the bare IP"); - let port = descriptor["gateway"]["port"].as_u64().unwrap() as u16; - - // Deploy: URLs should use ?canisterId= form, not a broken subdomain of an IP - clients::icp(&ctx, &project_dir, Some("loopback-env".to_string())).mint_cycles(10 * TRILLION); - - ctx.icp() - .current_dir(&project_dir) - .args([ - "deploy", - "--subnet", - common::SUBNET_ID, - "--environment", - "loopback-env", - ]) - .assert() - .success() - .stdout(contains("?canisterId=")) - .stdout(contains("Deployed canisters:")); - - // Read the canister ID - let id_mapping: IdMapping = icp::fs::json::load( - &project_dir - .join(".icp") - .join("cache") - .join("mappings") - .join("loopback-env.ids.json"), - ) - .expect("failed to read ID mapping"); - let cid = id_mapping - .get("my-assets") - .expect("canister ID not found for my-assets"); - - // canister status should work via the descriptor's host - ctx.icp() - .current_dir(&project_dir) - .args([ - "canister", - "status", - "--environment", - "loopback-env", - "my-assets", - ]) - .assert() - .success() - .stdout(contains("Status: Running")); - - // Fetch the asset over the non-localhost loopback IP - let resp = reqwest::get(format!("http://127.0.0.2:{port}/?canisterId={cid}")) - .await - .expect("request to 127.0.0.2 failed"); - let body = resp.text().await.expect("failed to read response body"); - assert_eq!(body, "hello"); -} diff --git a/crates/icp/src/network/mod.rs b/crates/icp/src/network/mod.rs index 6fd4da257..2e4e55d99 100644 --- a/crates/icp/src/network/mod.rs +++ b/crates/icp/src/network/mod.rs @@ -1,5 +1,4 @@ -use std::collections::HashSet; -use std::net::{IpAddr, ToSocketAddrs}; +use std::net::{IpAddr, Ipv4Addr, ToSocketAddrs}; use std::sync::Arc; use async_trait::async_trait; @@ -69,24 +68,28 @@ pub enum ResolveBindError { that resolves to one" ))] Ipv6Only { bind: String, ip: IpAddr }, + + #[snafu(display( + "'{bind}' resolves to {ip}, which is not a supported bind address; \ + the network launcher only supports 127.0.0.1 and 0.0.0.0" + ))] + UnsupportedBindAddress { bind: String, ip: IpAddr }, } /// Resolve a bind address string to an IP and a URL host. /// -/// Uses DNS resolution to turn the bind string into an IP. The URL host is -/// determined as follows: -/// 1. `"localhost"` if the resolved IP matches one of `localhost`'s addresses -/// 2. The original bind string if it was a domain name (preserving it for URLs) -/// 3. The first entry from `domains` if the bind was a bare IP and domains are configured -/// 4. Error if the result is a bare IPv6 address (cannot be used as an HTTP host -/// without brackets, and PocketIC does not support IPv6 binding) -/// 5. The bare IPv4 as a last resort +/// PocketIC makes hardcoded self-calls to 127.0.0.1, so only `127.0.0.1` +/// (loopback) and `0.0.0.0` (all interfaces) are valid bind addresses. +/// +/// The URL host is determined as follows: +/// 1. The original bind string if it was a domain name (preserving it for URLs) +/// 2. The first entry from `domains` if configured +/// 3. `"localhost"` as a fallback (reachable for both valid bind addresses) pub fn resolve_bind(bind: &str, domains: &[String]) -> Result { let addrs: Vec<_> = (bind, 0u16) .to_socket_addrs() .context(ResolveSnafu { bind })? .collect(); - // PocketIC does not support IPv6 binding. let ip = addrs .iter() .find(|a| a.is_ipv4()) @@ -102,18 +105,14 @@ pub fn resolve_bind(bind: &str, domains: &[String]) -> Result = ("localhost", 0u16) - .to_socket_addrs() - .into_iter() - .flatten() - .filter(|a| a.is_ipv4()) - .map(|a| a.ip()) - .collect(); + let loopback = IpAddr::V4(Ipv4Addr::LOCALHOST); + let unspecified = IpAddr::V4(Ipv4Addr::UNSPECIFIED); + if ip != loopback && ip != unspecified { + return UnsupportedBindAddressSnafu { bind, ip }.fail(); + } - let (host, extra_domains) = if localhost_ips.contains(&ip) { - ("localhost".to_string(), vec![]) - } else if bind.parse::().is_err() { - // bind was a domain name — ensure the gateway responds to it + let is_domain = bind.parse::().is_err(); + let (host, extra_domains) = if is_domain { let extra = if domains.iter().any(|d| d == bind) { vec![] } else { @@ -123,7 +122,7 @@ pub fn resolve_bind(bind: &str, domains: &[String]) -> Result Date: Mon, 23 Feb 2026 13:04:19 -0800 Subject: [PATCH 07/21] consistency --- crates/icp/src/network/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/icp/src/network/mod.rs b/crates/icp/src/network/mod.rs index 2e4e55d99..c65d5e3fb 100644 --- a/crates/icp/src/network/mod.rs +++ b/crates/icp/src/network/mod.rs @@ -314,7 +314,7 @@ impl From for Gateway { domains, port, } = value; - let bind = bind.unwrap_or("localhost".to_string()); + let bind = bind.unwrap_or("127.0.0.1".to_string()); let port = match port { Some(0) => Port::Random, Some(p) => Port::Fixed(p), From 02050fe36375d4b9b32a5e8951109a3e2e77b399 Mon Sep 17 00:00:00 2001 From: Adam Spofford Date: Mon, 23 Feb 2026 13:09:01 -0800 Subject: [PATCH 08/21] use v1.1 --- crates/icp/src/network/managed/launcher.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/icp/src/network/managed/launcher.rs b/crates/icp/src/network/managed/launcher.rs index 5f54ad2e2..17ab44b81 100644 --- a/crates/icp/src/network/managed/launcher.rs +++ b/crates/icp/src/network/managed/launcher.rs @@ -78,7 +78,7 @@ pub async fn spawn_network_launcher( let mut cmd = tokio::process::Command::new(network_launcher_path); cmd.args([ "--interface-version", - "1.0.0", + "1.1.0", "--state-dir", state_dir.as_str(), ]); From 7d3de35f764f16304c0f35edb763909a2f5dd13d Mon Sep 17 00:00:00 2001 From: Adam Spofford Date: Mon, 23 Feb 2026 15:23:40 -0800 Subject: [PATCH 09/21] fix test? --- crates/icp-cli/tests/network_tests.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/icp-cli/tests/network_tests.rs b/crates/icp-cli/tests/network_tests.rs index 61fff6f35..74c9bb2b5 100644 --- a/crates/icp-cli/tests/network_tests.rs +++ b/crates/icp-cli/tests/network_tests.rs @@ -807,7 +807,6 @@ async fn network_gateway_responds_to_custom_domain() { .expect("failed to write project manifest"); let _guard = ctx.start_network_in(&project_dir, "domain-network").await; - ctx.ping_until_healthy(&project_dir, "domain-network"); let network = ctx.wait_for_network_descriptor(&project_dir, "domain-network"); let port = network.gateway_port; From 284cfe5f9ff617d98b75ff1595696aeb2522d459 Mon Sep 17 00:00:00 2001 From: Adam Spofford Date: Thu, 26 Feb 2026 16:47:52 -0800 Subject: [PATCH 10/21] fix domain test for xplat --- crates/icp-cli/tests/network_tests.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/icp-cli/tests/network_tests.rs b/crates/icp-cli/tests/network_tests.rs index 74c9bb2b5..5ff051dac 100644 --- a/crates/icp-cli/tests/network_tests.rs +++ b/crates/icp-cli/tests/network_tests.rs @@ -798,6 +798,7 @@ async fn network_gateway_responds_to_custom_domain() { gateway: port: 0 domains: + - localhost - {domain} environments: - name: domain-env From 374d9334b3b15fdde936067bd10e4c3d6b14e73e Mon Sep 17 00:00:00 2001 From: Adam Spofford Date: Thu, 26 Feb 2026 17:07:30 -0800 Subject: [PATCH 11/21] clean up --- crates/icp/src/network/managed/run.rs | 11 +++++++---- crates/icp/src/network/mod.rs | 12 ++++++++---- crates/icp/src/project.rs | 2 +- 3 files changed, 16 insertions(+), 9 deletions(-) diff --git a/crates/icp/src/network/managed/run.rs b/crates/icp/src/network/managed/run.rs index 15238d3ea..e4523f4cf 100644 --- a/crates/icp/src/network/managed/run.rs +++ b/crates/icp/src/network/managed/run.rs @@ -137,10 +137,13 @@ async fn run_network_launcher( // Resolve the bind address to an IP and a URL host let resolved = match &config.mode { - ManagedMode::Launcher(launcher_config) => resolve_bind( - &launcher_config.gateway.bind, - &launcher_config.gateway.domains, - )?, + ManagedMode::Launcher(launcher_config) => { + resolve_bind( + &launcher_config.gateway.bind, + &launcher_config.gateway.domains, + ) + .await? + } ManagedMode::Image(_) => crate::network::ResolvedBind { ip: std::net::Ipv4Addr::LOCALHOST.into(), host: "localhost".to_string(), diff --git a/crates/icp/src/network/mod.rs b/crates/icp/src/network/mod.rs index c65d5e3fb..e5a34b8d2 100644 --- a/crates/icp/src/network/mod.rs +++ b/crates/icp/src/network/mod.rs @@ -1,4 +1,4 @@ -use std::net::{IpAddr, Ipv4Addr, ToSocketAddrs}; +use std::net::{IpAddr, Ipv4Addr}; use std::sync::Arc; use async_trait::async_trait; @@ -9,6 +9,7 @@ use snafu::prelude::*; pub use directory::{LoadPidError, NetworkDirectory, SavePidError}; pub use managed::run::{RunNetworkError, run_network}; use strum::EnumString; +use tokio::net::lookup_host; use url::Url; use crate::{ @@ -85,9 +86,12 @@ pub enum ResolveBindError { /// 1. The original bind string if it was a domain name (preserving it for URLs) /// 2. The first entry from `domains` if configured /// 3. `"localhost"` as a fallback (reachable for both valid bind addresses) -pub fn resolve_bind(bind: &str, domains: &[String]) -> Result { - let addrs: Vec<_> = (bind, 0u16) - .to_socket_addrs() +pub async fn resolve_bind( + bind: &str, + domains: &[String], +) -> Result { + let addrs: Vec<_> = lookup_host((bind, 0u16)) + .await .context(ResolveSnafu { bind })? .collect(); let ip = addrs diff --git a/crates/icp/src/project.rs b/crates/icp/src/project.rs index e88a0bdbb..50b358071 100644 --- a/crates/icp/src/project.rs +++ b/crates/icp/src/project.rs @@ -24,7 +24,7 @@ use crate::{ pub const DEFAULT_LOCAL_NETWORK_BIND: &str = "127.0.0.1"; pub const DEFAULT_LOCAL_NETWORK_PORT: u16 = 8000; -pub const DEFAULT_LOCAL_NETWORK_URL: &str = "http://127.0.0.1:8000"; +pub const DEFAULT_LOCAL_NETWORK_URL: &str = "http://localhost:8000"; #[derive(Debug, Snafu)] pub enum EnvironmentError { From cfcbcd5b1b7293bb1c05948fbc984c90b1427e2d Mon Sep 17 00:00:00 2001 From: Adam Spofford Date: Fri, 27 Feb 2026 19:05:40 -0800 Subject: [PATCH 12/21] remove bind domain resolution --- crates/icp/src/network/config.rs | 7 ++ crates/icp/src/network/managed/launcher.rs | 15 ++- crates/icp/src/network/managed/run.rs | 61 ++++-------- crates/icp/src/network/mod.rs | 108 --------------------- 4 files changed, 33 insertions(+), 158 deletions(-) diff --git a/crates/icp/src/network/config.rs b/crates/icp/src/network/config.rs index bcb8e2b06..4a69de7d7 100644 --- a/crates/icp/src/network/config.rs +++ b/crates/icp/src/network/config.rs @@ -20,6 +20,10 @@ fn default_gateway_host() -> String { "localhost".to_string() } +fn default_gateway_ip() -> String { + "127.0.0.1".to_string() +} + /// Gateway port configuration within a network descriptor. #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] @@ -32,6 +36,9 @@ pub struct NetworkDescriptorGatewayPort { /// The host to use when constructing URLs to reach the gateway. #[serde(default = "default_gateway_host")] pub host: String, + /// The IP address to use when constructing URLs to reach the API. + #[serde(default = "default_gateway_ip")] + pub ip: String, } /// Runtime state of a running managed network, persisted as `descriptor.json`. diff --git a/crates/icp/src/network/managed/launcher.rs b/crates/icp/src/network/managed/launcher.rs index 17ab44b81..ea4e36e14 100644 --- a/crates/icp/src/network/managed/launcher.rs +++ b/crates/icp/src/network/managed/launcher.rs @@ -10,7 +10,7 @@ use sysinfo::{Pid, ProcessesToUpdate, Signal, System}; use tokio::{process::Child, select, sync::mpsc::Sender, time::Instant}; use crate::{ - network::{ManagedLauncherConfig, Port, ResolvedBind, config::ChildLocator}, + network::{ManagedLauncherConfig, Port, config::ChildLocator}, prelude::*, }; @@ -65,7 +65,6 @@ pub async fn spawn_network_launcher( background: bool, verbose: bool, launcher_config: &ManagedLauncherConfig, - resolved_bind: &ResolvedBind, state_dir: &Path, ) -> Result< ( @@ -82,13 +81,13 @@ pub async fn spawn_network_launcher( "--state-dir", state_dir.as_str(), ]); - cmd.args(["--bind", &resolved_bind.ip.to_string()]); + cmd.args(["--bind", &launcher_config.gateway.bind]); if let Port::Fixed(port) = launcher_config.gateway.port { cmd.args(["--gateway-port", &port.to_string()]); } let status_dir = Utf8TempDir::new().context(CreateStatusDirSnafu)?; cmd.args(["--status-dir", status_dir.path().as_str()]); - cmd.args(launcher_settings_flags(launcher_config, resolved_bind)); + cmd.args(launcher_settings_flags(launcher_config)); if background { eprintln!("For background mode, network output will be redirected:"); eprintln!(" stdout: {}", stdout_file); @@ -169,10 +168,7 @@ pub async fn stop_launcher(pid: Pid) { } } -pub fn launcher_settings_flags( - config: &ManagedLauncherConfig, - resolved_bind: &ResolvedBind, -) -> Vec { +pub fn launcher_settings_flags(config: &ManagedLauncherConfig) -> Vec { let ManagedLauncherConfig { gateway, version: _, @@ -208,9 +204,10 @@ pub fn launcher_settings_flags( flags.push(format!("--dogecoind-addr={addr}")); } } - for domain in gateway.domains.iter().chain(&resolved_bind.extra_domains) { + for domain in &gateway.domains { flags.push(format!("--domain={domain}")); } + flags.push(format!("--domain={}", gateway.bind)); flags } diff --git a/crates/icp/src/network/managed/run.rs b/crates/icp/src/network/managed/run.rs index e4523f4cf..5336bd1ae 100644 --- a/crates/icp/src/network/managed/run.rs +++ b/crates/icp/src/network/managed/run.rs @@ -45,7 +45,6 @@ use crate::{ docker::{DockerDropGuard, ManagedImageOptions, spawn_docker_launcher}, launcher::{ChildSignalOnDrop, launcher_settings_flags, spawn_network_launcher}, }, - resolve_bind, }, prelude::*, signal::stop_signal, @@ -135,22 +134,6 @@ async fn run_network_launcher( ) -> Result<(), RunNetworkLauncherError> { let network_root = nd.root()?; - // Resolve the bind address to an IP and a URL host - let resolved = match &config.mode { - ManagedMode::Launcher(launcher_config) => { - resolve_bind( - &launcher_config.gateway.bind, - &launcher_config.gateway.domains, - ) - .await? - } - ManagedMode::Image(_) => crate::network::ResolvedBind { - ip: std::net::Ipv4Addr::LOCALHOST.into(), - host: "localhost".to_string(), - extra_domains: vec![], - }, - }; - // Determine the image options and fixed ports to check before spawning #[allow(clippy::large_enum_variant)] // Only used inline, not moved enum LaunchMode<'a> { @@ -164,7 +147,7 @@ async fn run_network_launcher( (LaunchMode::Image(options), fixed_ports) } ManagedMode::Launcher(launcher_config) if autocontainerize => { - let options = transform_native_launcher_to_container(launcher_config, &resolved); + let options = transform_native_launcher_to_container(launcher_config); let fixed_ports = options.fixed_host_ports(); (LaunchMode::Image(options), fixed_ports) } @@ -176,7 +159,6 @@ async fn run_network_launcher( (LaunchMode::NativeLauncher(launcher_config), fixed_ports) } }; - let (mut guard, instance, gateway, locator) = network_root .with_write(async |root| -> Result<_, RunNetworkLauncherError> { // Acquire locks for all fixed ports and check they're not in use @@ -208,7 +190,8 @@ async fn run_network_launcher( let gateway = NetworkDescriptorGatewayPort { port: instance.gateway_port, fixed, - host: resolved.host.clone(), + host: "localhost".to_string(), + ip: "127.0.0.1".to_string(), }; Ok((ShutdownGuard::Container(guard), instance, gateway, locator)) } @@ -227,14 +210,18 @@ async fn run_network_launcher( background, verbose, launcher_config, - &resolved, &root.state_dir(), ) .await?; + let host = match launcher_config.gateway.domains.first() { + Some(domain) => domain.clone(), + None => launcher_config.gateway.bind.to_string(), + }; let gateway = NetworkDescriptorGatewayPort { port: instance.gateway_port, fixed: matches!(launcher_config.gateway.port, Port::Fixed(_)), - host: resolved.host.clone(), + ip: launcher_config.gateway.bind.clone(), + host, }; Ok((ShutdownGuard::Process(child), instance, gateway, locator)) } @@ -248,7 +235,7 @@ async fn run_network_launcher( } let (candid_ui_canister_id, proxy_canister_id) = initialize_network( - &format!("http://{}:{}", resolved.host, instance.gateway_port) + &format!("http://{}:{}", gateway.host, gateway.port) .parse() .unwrap(), &instance.root_key, @@ -305,10 +292,7 @@ async fn run_network_launcher( Ok(()) } -fn transform_native_launcher_to_container( - config: &ManagedLauncherConfig, - resolved: &crate::network::ResolvedBind, -) -> ManagedImageOptions { +fn transform_native_launcher_to_container(config: &ManagedLauncherConfig) -> ManagedImageOptions { use bollard::secret::PortBinding; use std::collections::HashMap; @@ -318,7 +302,7 @@ fn transform_native_launcher_to_container( Port::Fixed(port) => port, Port::Random => 0, }; - let args = launcher_settings_flags(config, resolved); + let args = launcher_settings_flags(config); let args = translate_launcher_args_for_docker(args); let all_addrs: Vec = config @@ -339,7 +323,7 @@ fn transform_native_launcher_to_container( let port_bindings: HashMap>> = [( "4943/tcp".to_string(), Some(vec![PortBinding { - host_ip: Some(resolved.ip.to_string()), + host_ip: Some(config.gateway.bind.to_string()), host_port: Some(port.to_string()), }]), )] @@ -388,11 +372,6 @@ pub enum RunNetworkLauncherError { #[snafu(display("ICP_CLI_NETWORK_LAUNCHER_PATH environment variable is not set"))] NoNetworkLauncherPath, - #[snafu(transparent)] - ResolveBind { - source: crate::network::ResolveBindError, - }, - #[snafu(display("failed to create dir"))] CreateDirAll { source: crate::fs::IoError }, @@ -896,7 +875,7 @@ async fn install_proxy( #[cfg(test)] mod tests { use super::*; - use crate::network::{Gateway, ManagedLauncherConfig, Port, ResolvedBind}; + use crate::network::{Gateway, ManagedLauncherConfig, Port}; #[test] fn transform_native_launcher_default_config() { @@ -914,7 +893,7 @@ mod tests { dogecoind_addr: None, version: None, }; - let opts = transform_native_launcher_to_container(&config, &ResolvedBind::default()); + let opts = transform_native_launcher_to_container(&config); assert_eq!( opts.image, "ghcr.io/dfinity/icp-cli-network-launcher:latest" @@ -948,7 +927,7 @@ mod tests { dogecoind_addr: None, version: None, }; - let opts = transform_native_launcher_to_container(&config, &ResolvedBind::default()); + let opts = transform_native_launcher_to_container(&config); let binding = opts .port_bindings .get("4943/tcp") @@ -970,7 +949,7 @@ mod tests { dogecoind_addr: None, version: None, }; - let opts = transform_native_launcher_to_container(&config, &ResolvedBind::default()); + let opts = transform_native_launcher_to_container(&config); assert!(opts.args.contains(&"--ii".to_string())); assert!( opts.args @@ -994,7 +973,7 @@ mod tests { dogecoind_addr: Some(vec!["localhost:22556".to_string()]), version: None, }; - let opts = transform_native_launcher_to_container(&config, &ResolvedBind::default()); + let opts = transform_native_launcher_to_container(&config); assert!(opts.args.contains(&"--nns".to_string())); assert!(opts.args.contains(&"--artificial-delay-ms=50".to_string())); assert!( @@ -1019,7 +998,7 @@ mod tests { dogecoind_addr: None, version: None, }; - let opts = transform_native_launcher_to_container(&config, &ResolvedBind::default()); + let opts = transform_native_launcher_to_container(&config); assert!( opts.args .contains(&"--bitcoind-addr=192.168.1.5:18444".to_string()) @@ -1039,7 +1018,7 @@ mod tests { dogecoind_addr: None, version: None, }; - let opts = transform_native_launcher_to_container(&config, &ResolvedBind::default()); + let opts = transform_native_launcher_to_container(&config); assert!( opts.args .contains(&"--bitcoind-addr=host.docker.internal:18444".to_string()) diff --git a/crates/icp/src/network/mod.rs b/crates/icp/src/network/mod.rs index e5a34b8d2..4bbbad36e 100644 --- a/crates/icp/src/network/mod.rs +++ b/crates/icp/src/network/mod.rs @@ -1,4 +1,3 @@ -use std::net::{IpAddr, Ipv4Addr}; use std::sync::Arc; use async_trait::async_trait; @@ -9,7 +8,6 @@ use snafu::prelude::*; pub use directory::{LoadPidError, NetworkDirectory, SavePidError}; pub use managed::run::{RunNetworkError, run_network}; use strum::EnumString; -use tokio::net::lookup_host; use url::Url; use crate::{ @@ -30,112 +28,6 @@ pub mod config; pub mod directory; pub mod managed; -/// A bind address resolved to an IP and a URL host. -pub struct ResolvedBind { - /// The resolved IP address to pass as the bind address. - pub ip: IpAddr, - /// The host to use when constructing URLs to reach this address. - /// `"localhost"` when the IP is one that `localhost` resolves to, - /// otherwise the original bind string (preserving domain names). - pub host: String, - /// Additional domains to pass to the launcher beyond those already - /// configured in `gateway.domains`. Populated when the bind is a bare - /// non-loopback IP with no configured domains, so the gateway knows - /// to respond on that IP. - pub extra_domains: Vec, -} - -impl Default for ResolvedBind { - fn default() -> Self { - ResolvedBind { - ip: IpAddr::V4([127, 0, 0, 1].into()), - host: "localhost".to_string(), - extra_domains: vec![], - } - } -} - -#[derive(Debug, Snafu)] -pub enum ResolveBindError { - #[snafu(display("failed to resolve '{bind}'"))] - Resolve { - source: std::io::Error, - bind: String, - }, - - #[snafu(display( - "'{bind}' only resolves to IPv6 address {ip}; the network launcher \ - requires an IPv4 bind address. Use an IPv4 address or a hostname \ - that resolves to one" - ))] - Ipv6Only { bind: String, ip: IpAddr }, - - #[snafu(display( - "'{bind}' resolves to {ip}, which is not a supported bind address; \ - the network launcher only supports 127.0.0.1 and 0.0.0.0" - ))] - UnsupportedBindAddress { bind: String, ip: IpAddr }, -} - -/// Resolve a bind address string to an IP and a URL host. -/// -/// PocketIC makes hardcoded self-calls to 127.0.0.1, so only `127.0.0.1` -/// (loopback) and `0.0.0.0` (all interfaces) are valid bind addresses. -/// -/// The URL host is determined as follows: -/// 1. The original bind string if it was a domain name (preserving it for URLs) -/// 2. The first entry from `domains` if configured -/// 3. `"localhost"` as a fallback (reachable for both valid bind addresses) -pub async fn resolve_bind( - bind: &str, - domains: &[String], -) -> Result { - let addrs: Vec<_> = lookup_host((bind, 0u16)) - .await - .context(ResolveSnafu { bind })? - .collect(); - let ip = addrs - .iter() - .find(|a| a.is_ipv4()) - .ok_or_else(|| { - let ip = addrs - .first() - .expect("to_socket_addrs returned Ok but no addresses") - .ip(); - ResolveBindError::Ipv6Only { - bind: bind.to_string(), - ip, - } - })? - .ip(); - - let loopback = IpAddr::V4(Ipv4Addr::LOCALHOST); - let unspecified = IpAddr::V4(Ipv4Addr::UNSPECIFIED); - if ip != loopback && ip != unspecified { - return UnsupportedBindAddressSnafu { bind, ip }.fail(); - } - - let is_domain = bind.parse::().is_err(); - let (host, extra_domains) = if is_domain { - let extra = if domains.iter().any(|d| d == bind) { - vec![] - } else { - vec![bind.to_string()] - }; - (bind.to_string(), extra) - } else if let Some(domain) = domains.first() { - (domain.clone(), vec![]) - } else { - ("localhost".to_string(), vec![]) - }; - - Ok(ResolvedBind { - ip, - host, - extra_domains, - }) -} - #[derive(Clone, Debug, PartialEq, JsonSchema, Serialize)] pub enum Port { Fixed(u16), From 6e831bbfd6e72861f22dac19842966f3eed1f811 Mon Sep 17 00:00:00 2001 From: Adam Spofford Date: Fri, 27 Feb 2026 21:06:11 -0800 Subject: [PATCH 13/21] clean up --- crates/icp-cli/tests/network_tests.rs | 2 +- crates/icp/src/context/tests.rs | 3 ++- crates/icp/src/manifest/network.rs | 2 +- crates/icp/src/network/access.rs | 3 ++- crates/icp/src/network/managed/run.rs | 4 ++-- crates/icp/src/network/mod.rs | 6 +++++- crates/icp/src/project.rs | 1 - docs/schemas/icp-yaml-schema.json | 2 +- docs/schemas/network-yaml-schema.json | 2 +- 9 files changed, 15 insertions(+), 10 deletions(-) diff --git a/crates/icp-cli/tests/network_tests.rs b/crates/icp-cli/tests/network_tests.rs index 5ff051dac..61fff6f35 100644 --- a/crates/icp-cli/tests/network_tests.rs +++ b/crates/icp-cli/tests/network_tests.rs @@ -798,7 +798,6 @@ async fn network_gateway_responds_to_custom_domain() { gateway: port: 0 domains: - - localhost - {domain} environments: - name: domain-env @@ -808,6 +807,7 @@ async fn network_gateway_responds_to_custom_domain() { .expect("failed to write project manifest"); let _guard = ctx.start_network_in(&project_dir, "domain-network").await; + ctx.ping_until_healthy(&project_dir, "domain-network"); let network = ctx.wait_for_network_descriptor(&project_dir, "domain-network"); let port = network.gateway_port; diff --git a/crates/icp/src/context/tests.rs b/crates/icp/src/context/tests.rs index d4b7df745..bfd731155 100644 --- a/crates/icp/src/context/tests.rs +++ b/crates/icp/src/context/tests.rs @@ -6,12 +6,13 @@ use crate::{ Configuration, Gateway, Managed, ManagedLauncherConfig, ManagedMode, MockNetworkAccessor, Port, access::NetworkAccess, }, - project::DEFAULT_LOCAL_NETWORK_URL, store_id::{Access as IdAccess, mock::MockInMemoryIdStore}, }; use candid::Principal; use std::collections::HashMap; +const DEFAULT_LOCAL_NETWORK_URL: &str = "http://localhost:8000"; + #[tokio::test] async fn test_get_identity_default() { let ctx = Context::mocked(); diff --git a/crates/icp/src/manifest/network.rs b/crates/icp/src/manifest/network.rs index bddf92f19..cacd7782c 100644 --- a/crates/icp/src/manifest/network.rs +++ b/crates/icp/src/manifest/network.rs @@ -148,7 +148,7 @@ impl From for String { pub struct Gateway { /// Network interface for the gateway. Defaults to 127.0.0.1 pub bind: Option, - /// Domains the gateway should respond to. localhost is always included. + /// Domains the gateway should respond to. Automatically includes localhost if applicable. pub domains: Option>, /// Port for the gateway to listen on. Defaults to 8000 pub port: Option, diff --git a/crates/icp/src/network/access.rs b/crates/icp/src/network/access.rs index 67b854936..3ad3771c3 100644 --- a/crates/icp/src/network/access.rs +++ b/crates/icp/src/network/access.rs @@ -77,9 +77,10 @@ pub async fn get_managed_network_access( } } let http_gateway_url = Url::parse(&format!("http://{}:{port}", desc.gateway.host)).unwrap(); + let api_url = Url::parse(&format!("http://{}:{port}", desc.gateway.ip)).unwrap(); Ok(NetworkAccess { root_key: desc.root_key, - api_url: http_gateway_url.clone(), + api_url, http_gateway_url: Some(http_gateway_url), }) } diff --git a/crates/icp/src/network/managed/run.rs b/crates/icp/src/network/managed/run.rs index 5336bd1ae..ad794cc8d 100644 --- a/crates/icp/src/network/managed/run.rs +++ b/crates/icp/src/network/managed/run.rs @@ -881,9 +881,9 @@ mod tests { fn transform_native_launcher_default_config() { let config = ManagedLauncherConfig { gateway: Gateway { - bind: "localhost".to_string(), + bind: "127.0.0.1".to_string(), port: Port::Fixed(8000), - domains: vec![], + domains: vec!["localhost".to_string()], }, artificial_delay_ms: None, ii: false, diff --git a/crates/icp/src/network/mod.rs b/crates/icp/src/network/mod.rs index 4bbbad36e..5f73e3c41 100644 --- a/crates/icp/src/network/mod.rs +++ b/crates/icp/src/network/mod.rs @@ -216,10 +216,14 @@ impl From for Gateway { Some(p) => Port::Fixed(p), None => Port::Random, }; + let mut domains = domains.unwrap_or_default(); + if bind == "127.0.0.1" || bind == "0.0.0.0" || bind == "::1" || bind == "::" { + domains.push("localhost".to_string()); + } Gateway { bind, port, - domains: domains.unwrap_or_default(), + domains, } } } diff --git a/crates/icp/src/project.rs b/crates/icp/src/project.rs index 50b358071..60171baec 100644 --- a/crates/icp/src/project.rs +++ b/crates/icp/src/project.rs @@ -24,7 +24,6 @@ use crate::{ pub const DEFAULT_LOCAL_NETWORK_BIND: &str = "127.0.0.1"; pub const DEFAULT_LOCAL_NETWORK_PORT: u16 = 8000; -pub const DEFAULT_LOCAL_NETWORK_URL: &str = "http://localhost:8000"; #[derive(Debug, Snafu)] pub enum EnvironmentError { diff --git a/docs/schemas/icp-yaml-schema.json b/docs/schemas/icp-yaml-schema.json index 72a8dc302..a1e054a43 100644 --- a/docs/schemas/icp-yaml-schema.json +++ b/docs/schemas/icp-yaml-schema.json @@ -338,7 +338,7 @@ ] }, "domains": { - "description": "Domains the gateway should respond to. localhost is always included.", + "description": "Domains the gateway should respond to. Automatically includes localhost if applicable.", "items": { "type": "string" }, diff --git a/docs/schemas/network-yaml-schema.json b/docs/schemas/network-yaml-schema.json index 8e304ac24..6182c0f96 100644 --- a/docs/schemas/network-yaml-schema.json +++ b/docs/schemas/network-yaml-schema.json @@ -59,7 +59,7 @@ ] }, "domains": { - "description": "Domains the gateway should respond to. localhost is always included.", + "description": "Domains the gateway should respond to. Automatically includes localhost if applicable.", "items": { "type": "string" }, From 0536944cec40e938e4b2b6bd3a8400065b97ef5d Mon Sep 17 00:00:00 2001 From: Adam Spofford Date: Fri, 27 Feb 2026 21:21:24 -0800 Subject: [PATCH 14/21] Add test --- crates/icp-cli/tests/network_tests.rs | 45 +++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/crates/icp-cli/tests/network_tests.rs b/crates/icp-cli/tests/network_tests.rs index 61fff6f35..713bdbfa7 100644 --- a/crates/icp-cli/tests/network_tests.rs +++ b/crates/icp-cli/tests/network_tests.rs @@ -829,3 +829,48 @@ async fn network_gateway_responds_to_custom_domain() { resp.status() ); } + +#[cfg(target_os = "linux")] // alternate loopback +#[tokio::test] +async fn network_gateway_binds_to_configured_interface() { + let ctx = TestContext::new(); + let project_dir = ctx.create_project_dir("custom-bind"); + + write_string( + &project_dir.join("icp.yaml"), + indoc! {r#" + networks: + - name: bind-network + mode: managed + gateway: + port: 0 + bind: 127.0.0.2 + environments: + - name: bind-env + network: bind-network + "#}, + ) + .expect("failed to write project manifest"); + + let _guard = ctx.start_network_in(&project_dir, "bind-network").await; + ctx.ping_until_healthy(&project_dir, "bind-network"); + + let network = ctx.wait_for_network_descriptor(&project_dir, "bind-network"); + let port = network.gateway_port; + + let client = reqwest::Client::builder() + .build() + .expect("failed to build reqwest client"); + + let resp = client + .get(format!("http://127.0.0.2:{port}/api/v2/status")) + .send() + .await + .expect("request to custom interface failed"); + + assert!( + resp.status().is_success(), + "gateway should respond successfully on custom interface, got {}", + resp.status() + ); +} From 855732c669f7dc578bacd91bfccf9db9f5aec213 Mon Sep 17 00:00:00 2001 From: Adam Spofford Date: Fri, 27 Feb 2026 21:32:19 -0800 Subject: [PATCH 15/21] . --- crates/icp/src/network/managed/run.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/icp/src/network/managed/run.rs b/crates/icp/src/network/managed/run.rs index ad794cc8d..8f57476bb 100644 --- a/crates/icp/src/network/managed/run.rs +++ b/crates/icp/src/network/managed/run.rs @@ -898,7 +898,11 @@ mod tests { opts.image, "ghcr.io/dfinity/icp-cli-network-launcher:latest" ); - assert!(opts.args.is_empty()); + assert!( + opts.args + .iter() + .eq(["--domain=localhost", "--domain=127.0.0.1"]) + ); assert!(opts.extra_hosts.is_empty()); assert!(opts.rm_on_exit); assert_eq!(opts.status_dir, "/app/status"); From a152915aa5c36cd9559f7e8717511f6fc30690b6 Mon Sep 17 00:00:00 2001 From: Adam Spofford Date: Mon, 2 Mar 2026 11:55:51 -0800 Subject: [PATCH 16/21] Revert IP change --- crates/icp/src/network/access.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/icp/src/network/access.rs b/crates/icp/src/network/access.rs index 3ad3771c3..67b854936 100644 --- a/crates/icp/src/network/access.rs +++ b/crates/icp/src/network/access.rs @@ -77,10 +77,9 @@ pub async fn get_managed_network_access( } } let http_gateway_url = Url::parse(&format!("http://{}:{port}", desc.gateway.host)).unwrap(); - let api_url = Url::parse(&format!("http://{}:{port}", desc.gateway.ip)).unwrap(); Ok(NetworkAccess { root_key: desc.root_key, - api_url, + api_url: http_gateway_url.clone(), http_gateway_url: Some(http_gateway_url), }) } From 72894d326a4157717ac0df4dddbe8641e42dd119 Mon Sep 17 00:00:00 2001 From: Adam Spofford Date: Mon, 2 Mar 2026 12:38:04 -0800 Subject: [PATCH 17/21] no more need for ip host --- crates/icp/src/network/managed/launcher.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/icp/src/network/managed/launcher.rs b/crates/icp/src/network/managed/launcher.rs index ea4e36e14..881496a17 100644 --- a/crates/icp/src/network/managed/launcher.rs +++ b/crates/icp/src/network/managed/launcher.rs @@ -207,7 +207,6 @@ pub fn launcher_settings_flags(config: &ManagedLauncherConfig) -> Vec { for domain in &gateway.domains { flags.push(format!("--domain={domain}")); } - flags.push(format!("--domain={}", gateway.bind)); flags } From 315194b53f606eb922a83a937e51284a47ebf756 Mon Sep 17 00:00:00 2001 From: Adam Spofford Date: Mon, 2 Mar 2026 12:49:09 -0800 Subject: [PATCH 18/21] . --- crates/icp/src/network/managed/run.rs | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/crates/icp/src/network/managed/run.rs b/crates/icp/src/network/managed/run.rs index 8f57476bb..e19cde668 100644 --- a/crates/icp/src/network/managed/run.rs +++ b/crates/icp/src/network/managed/run.rs @@ -898,11 +898,7 @@ mod tests { opts.image, "ghcr.io/dfinity/icp-cli-network-launcher:latest" ); - assert!( - opts.args - .iter() - .eq(["--domain=localhost", "--domain=127.0.0.1"]) - ); + assert!(opts.args.iter().eq(["--domain=localhost"])); assert!(opts.extra_hosts.is_empty()); assert!(opts.rm_on_exit); assert_eq!(opts.status_dir, "/app/status"); From 69abb18a8cb34c4d14a4b02610a9b23ffac7759b Mon Sep 17 00:00:00 2001 From: Adam Spofford Date: Mon, 2 Mar 2026 13:31:09 -0800 Subject: [PATCH 19/21] re-add fallback bind --- crates/icp/src/network/managed/launcher.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/icp/src/network/managed/launcher.rs b/crates/icp/src/network/managed/launcher.rs index 881496a17..41a04681b 100644 --- a/crates/icp/src/network/managed/launcher.rs +++ b/crates/icp/src/network/managed/launcher.rs @@ -207,6 +207,9 @@ pub fn launcher_settings_flags(config: &ManagedLauncherConfig) -> Vec { for domain in &gateway.domains { flags.push(format!("--domain={domain}")); } + if gateway.domains.is_empty() { + flags.push(format!("--domain={}", gateway.bind)); + } flags } From 49138838df4c168cf4c82a5fc3c51f8e3e6d15c9 Mon Sep 17 00:00:00 2001 From: Adam Spofford Date: Mon, 2 Mar 2026 13:56:34 -0800 Subject: [PATCH 20/21] fix domains check --- crates/icp/src/network/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/icp/src/network/mod.rs b/crates/icp/src/network/mod.rs index 5f73e3c41..8cca7fcd2 100644 --- a/crates/icp/src/network/mod.rs +++ b/crates/icp/src/network/mod.rs @@ -218,7 +218,7 @@ impl From for Gateway { }; let mut domains = domains.unwrap_or_default(); if bind == "127.0.0.1" || bind == "0.0.0.0" || bind == "::1" || bind == "::" { - domains.push("localhost".to_string()); + domains.insert(0, "localhost".to_string()); } Gateway { bind, From ba9495f5ab75f3a3e50e060abb44a681ab1a6d6c Mon Sep 17 00:00:00 2001 From: Adam Spofford Date: Mon, 2 Mar 2026 14:05:03 -0800 Subject: [PATCH 21/21] Changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c69e6a18..2673c83f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ # Unreleased +* feat: Added `bind` key to network gateway config to pick your network interface (previous documentation mentioned a `host` key, but it did not do anything) * feat: check for Candid incompatibility when upgrading a canister * feat: Add `bitcoind-addr` and `dogecoind-addr` options for managed networks to connect to Bitcoin and Dogecoin nodes * feat: Init/call arg files now support raw binary without conversion to hex