diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c69e6a1..2673c83f 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 diff --git a/crates/icp-cli/tests/network_tests.rs b/crates/icp-cli/tests/network_tests.rs index 61fff6f3..713bdbfa 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() + ); +} diff --git a/crates/icp/src/context/tests.rs b/crates/icp/src/context/tests.rs index a314374b..bfd73115 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(); @@ -601,7 +602,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 +672,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 +744,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 +766,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 1e8d44bf..97adf3e8 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 8dbe535b..cacd7782 100644 --- a/crates/icp/src/manifest/network.rs +++ b/crates/icp/src/manifest/network.rs @@ -147,8 +147,8 @@ 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, - /// Domains the gateway should respond to. localhost is always included. + pub bind: Option, + /// 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, @@ -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/access.rs b/crates/icp/src/network/access.rs index a224d958..67b85493 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 84bc3c95..4a69de7d 100644 --- a/crates/icp/src/network/config.rs +++ b/crates/icp/src/network/config.rs @@ -16,6 +16,14 @@ use uuid::Uuid; use crate::prelude::*; +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")] @@ -25,6 +33,12 @@ 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, + /// 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 2146c7c2..41a04681 100644 --- a/crates/icp/src/network/managed/launcher.rs +++ b/crates/icp/src/network/managed/launcher.rs @@ -77,10 +77,11 @@ 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(), ]); + cmd.args(["--bind", &launcher_config.gateway.bind]); if let Port::Fixed(port) = launcher_config.gateway.port { cmd.args(["--gateway-port", &port.to_string()]); } @@ -206,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 } diff --git a/crates/icp/src/network/managed/run.rs b/crates/icp/src/network/managed/run.rs index e5169642..e19cde66 100644 --- a/crates/icp/src/network/managed/run.rs +++ b/crates/icp/src/network/managed/run.rs @@ -159,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 @@ -191,6 +190,8 @@ async fn run_network_launcher( let gateway = NetworkDescriptorGatewayPort { port: instance.gateway_port, fixed, + host: "localhost".to_string(), + ip: "127.0.0.1".to_string(), }; Ok((ShutdownGuard::Container(guard), instance, gateway, locator)) } @@ -212,9 +213,15 @@ async fn run_network_launcher( &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(_)), + ip: launcher_config.gateway.bind.clone(), + host, }; Ok((ShutdownGuard::Process(child), instance, gateway, locator)) } @@ -228,7 +235,7 @@ async fn run_network_launcher( } let (candid_ui_canister_id, proxy_canister_id) = initialize_network( - &format!("http://localhost:{}", instance.gateway_port) + &format!("http://{}:{}", gateway.host, gateway.port) .parse() .unwrap(), &instance.root_key, @@ -316,7 +323,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(config.gateway.bind.to_string()), host_port: Some(port.to_string()), }]), )] @@ -874,9 +881,9 @@ mod tests { fn transform_native_launcher_default_config() { let config = ManagedLauncherConfig { gateway: Gateway { - host: "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, @@ -891,7 +898,7 @@ mod tests { opts.image, "ghcr.io/dfinity/icp-cli-network-launcher:latest" ); - assert!(opts.args.is_empty()); + 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"); @@ -908,7 +915,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![], }, diff --git a/crates/icp/src/network/mod.rs b/crates/icp/src/network/mod.rs index 9394fb40..8cca7fcd 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,20 +206,24 @@ 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("127.0.0.1".to_string()); let port = match port { Some(0) => Port::Random, 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.insert(0, "localhost".to_string()); + } Gateway { - host, + bind, port, - domains: domains.unwrap_or_default(), + domains, } } } @@ -451,7 +455,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, }), diff --git a/crates/icp/src/project.rs b/crates/icp/src/project.rs index fd47905f..60171bae 100644 --- a/crates/icp/src/project.rs +++ b/crates/icp/src/project.rs @@ -22,9 +22,8 @@ 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"; #[derive(Debug, Snafu)] pub enum EnvironmentError { @@ -382,7 +381,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 4061ae06..43f528af 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 20e6165f..7db2f6ff 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 17c06c02..3439b89c 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 9ed56ed2..1b6dcab2 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 2b6756c5..a1e054a4 100644 --- a/docs/schemas/icp-yaml-schema.json +++ b/docs/schemas/icp-yaml-schema.json @@ -330,8 +330,15 @@ }, "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.", + "description": "Domains the gateway should respond to. Automatically includes localhost if applicable.", "items": { "type": "string" }, @@ -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 81d8da26..6182c0f9 100644 --- a/docs/schemas/network-yaml-schema.json +++ b/docs/schemas/network-yaml-schema.json @@ -51,8 +51,15 @@ }, "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.", + "description": "Domains the gateway should respond to. Automatically includes localhost if applicable.", "items": { "type": "string" }, @@ -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 61501828..8c3076a1 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