From 38fa00dc7c95bb32da6efc0d919099c801f19b66 Mon Sep 17 00:00:00 2001 From: Christian Date: Mon, 30 Mar 2026 13:34:22 -0500 Subject: [PATCH 1/3] feat: make axum host configurable via manifest and env vars Add host/port configuration to the axum adapter so the bind address can be set to 0.0.0.0 or any other IP instead of the hardcoded 127.0.0.1:8787 default. Configuration precedence (highest wins): 1. EDGEZERO_HOST / EDGEZERO_PORT environment variables 2. Manifest value (edgezero.toml or axum.toml) 3. Default: 127.0.0.1:8787 Invalid values produce warnings instead of silently falling back to defaults. The CLI subprocess now receives EDGEZERO_HOST/EDGEZERO_PORT so resolved values propagate to the running server. --- crates/edgezero-adapter-axum/src/cli.rs | 78 +++++++++++++++++- .../edgezero-adapter-axum/src/dev_server.rs | 80 ++++++++++++++++++- crates/edgezero-adapter-axum/src/lib.rs | 2 +- crates/edgezero-cli/src/dev_server.rs | 27 ++++++- crates/edgezero-core/src/manifest.rs | 37 +++++++++ 5 files changed, 216 insertions(+), 8 deletions(-) diff --git a/crates/edgezero-adapter-axum/src/cli.rs b/crates/edgezero-adapter-axum/src/cli.rs index c070526..9bbacf5 100644 --- a/crates/edgezero-adapter-axum/src/cli.rs +++ b/crates/edgezero-adapter-axum/src/cli.rs @@ -1,4 +1,5 @@ use std::fs; +use std::net::IpAddr; use std::path::{Path, PathBuf}; use std::process::Command; @@ -139,10 +140,12 @@ fn deploy(_extra_args: &[String]) -> Result<(), String> { Err("Axum adapter does not define a deploy command. Extend your workspace manifest with one if needed.".into()) } +#[derive(Debug)] struct AxumProject { crate_dir: PathBuf, cargo_manifest: PathBuf, crate_name: String, + host: IpAddr, port: u16, } @@ -155,8 +158,8 @@ fn locate_project() -> Result { fn run_cargo(project: &AxumProject, subcommand: &str, extra_args: &[String]) -> Result<(), String> { let display = project.crate_dir.display(); println!( - "[edgezero] Axum {subcommand} ({}) in {} (port: {})", - project.crate_name, display, project.port + "[edgezero] Axum {subcommand} ({}) in {} ({}:{})", + project.crate_name, display, project.host, project.port ); let mut command = Command::new("cargo"); command.arg(subcommand); @@ -169,6 +172,8 @@ fn run_cargo(project: &AxumProject, subcommand: &str, extra_args: &[String]) -> ); command.args(extra_args); command.current_dir(&project.crate_dir); + command.env("EDGEZERO_HOST", project.host.to_string()); + command.env("EDGEZERO_PORT", project.port.to_string()); let status = command .status() .map_err(|err| format!("failed to run cargo {subcommand}: {err}"))?; @@ -255,6 +260,16 @@ fn read_axum_project(manifest: &Path) -> Result { }) }); + let host: IpAddr = match adapter.get("host").and_then(Value::as_str) { + Some(value) => value.parse().map_err(|_| { + format!( + "adapter.host in {} must be a valid IP address", + manifest.display() + ) + })?, + None => [127, 0, 0, 1].into(), + }; + let port = match adapter.get("port").and_then(Value::as_integer) { Some(value) => { if !(1..=u16::MAX as i64).contains(&value) { @@ -272,6 +287,7 @@ fn read_axum_project(manifest: &Path) -> Result { crate_dir, cargo_manifest, crate_name, + host, port, }) } @@ -532,6 +548,64 @@ mod tests { assert_eq!(project.port, 1); } + #[test] + fn read_axum_project_defaults_host_to_localhost() { + let dir = tempdir().unwrap(); + let root = dir.path(); + fs::write( + root.join("axum.toml"), + "[adapter]\ncrate = \"demo\"\ncrate_dir = \".\"\n", + ) + .unwrap(); + fs::write( + root.join("Cargo.toml"), + "[package]\nname = \"demo\"\nversion = \"0.1.0\"\n", + ) + .unwrap(); + + let project = read_axum_project(&root.join("axum.toml")).expect("project"); + assert_eq!(project.host, IpAddr::from([127, 0, 0, 1])); + } + + #[test] + fn read_axum_project_uses_custom_host() { + let dir = tempdir().unwrap(); + let root = dir.path(); + fs::write( + root.join("axum.toml"), + "[adapter]\ncrate = \"demo\"\ncrate_dir = \".\"\nhost = \"0.0.0.0\"\n", + ) + .unwrap(); + fs::write( + root.join("Cargo.toml"), + "[package]\nname = \"demo\"\nversion = \"0.1.0\"\n", + ) + .unwrap(); + + let project = read_axum_project(&root.join("axum.toml")).expect("project"); + assert_eq!(project.host, IpAddr::from([0, 0, 0, 0])); + } + + #[test] + fn read_axum_project_rejects_invalid_host() { + let dir = tempdir().unwrap(); + let root = dir.path(); + fs::write( + root.join("axum.toml"), + "[adapter]\ncrate = \"demo\"\ncrate_dir = \".\"\nhost = \"not-an-ip\"\n", + ) + .unwrap(); + fs::write( + root.join("Cargo.toml"), + "[package]\nname = \"demo\"\nversion = \"0.1.0\"\n", + ) + .unwrap(); + + let result = read_axum_project(&root.join("axum.toml")); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("valid IP address")); + } + #[test] fn find_axum_manifest_returns_error_when_not_found() { let dir = tempdir().unwrap(); diff --git a/crates/edgezero-adapter-axum/src/dev_server.rs b/crates/edgezero-adapter-axum/src/dev_server.rs index a984cdb..70e4c03 100644 --- a/crates/edgezero-adapter-axum/src/dev_server.rs +++ b/crates/edgezero-adapter-axum/src/dev_server.rs @@ -253,18 +253,24 @@ pub fn run_app(manifest_src: &str) -> anyhow::Result<()> { SimpleLogger::new().with_level(level).init().ok(); + let addr = resolve_addr(manifest); let app = A::build_app(); let router = app.router().clone(); + println!( + "[edgezero] starting axum server on http://{}:{}", + addr.ip(), + addr.port() + ); + let runtime = RuntimeBuilder::new_multi_thread() .enable_all() .build() .context("failed to build tokio runtime")?; runtime.block_on(async move { - let config = AxumDevServerConfig::default(); - let listener = StdTcpListener::bind(config.addr) - .with_context(|| format!("failed to bind dev server to {}", config.addr))?; + let listener = StdTcpListener::bind(addr) + .with_context(|| format!("failed to bind dev server to {}", addr))?; listener .set_nonblocking(true) .context("failed to set listener to non-blocking")?; @@ -294,10 +300,33 @@ pub fn run_app(manifest_src: &str) -> anyhow::Result<()> { } } }; - serve_with_listener_and_kv_handle(router, listener, config.enable_ctrl_c, kv_handle).await + serve_with_listener_and_kv_handle(router, listener, true, kv_handle).await }) } +/// Resolve the bind address from environment variables and manifest config. +/// +/// Precedence (highest wins): +/// 1. `EDGEZERO_HOST` / `EDGEZERO_PORT` environment variables +/// 2. `[adapters.axum.adapter]` host/port in the manifest +/// 3. Default: `127.0.0.1:8787` +pub(crate) fn resolve_addr(manifest: &edgezero_core::manifest::Manifest) -> SocketAddr { + let env_host = std::env::var("EDGEZERO_HOST").ok(); + let env_port = std::env::var("EDGEZERO_PORT").ok(); + resolve_addr_from_parts(manifest, env_host.as_deref(), env_port.as_deref()) +} + +fn resolve_addr_from_parts( + manifest: &edgezero_core::manifest::Manifest, + env_host: Option<&str>, + env_port: Option<&str>, +) -> SocketAddr { + let adapter = manifest.adapters.get("axum"); + let config_host = adapter.and_then(|a| a.adapter.host.as_deref()); + let config_port = adapter.and_then(|a| a.adapter.port); + edgezero_core::addr::resolve_bind_addr(env_host, env_port, config_host, config_port) +} + #[cfg(test)] mod tests { use super::*; @@ -422,6 +451,49 @@ name = "EDGEZERO_KV" "unexpected file name length: {file_name}" ); } + + #[test] + fn resolve_addr_defaults_without_manifest_config() { + // Note: env var tests use resolve_addr_from_parts to avoid races. + let loader = ManifestLoader::load_from_str(""); + let addr = resolve_addr_from_parts(loader.manifest(), None, None); + assert_eq!(addr, SocketAddr::from(([127, 0, 0, 1], 8787))); + } + + #[test] + fn resolve_addr_reads_manifest_host_and_port() { + let manifest = r#" +[adapters.axum.adapter] +host = "0.0.0.0" +port = 3000 +"#; + let loader = ManifestLoader::load_from_str(manifest); + let addr = resolve_addr_from_parts(loader.manifest(), None, None); + assert_eq!(addr, SocketAddr::from(([0, 0, 0, 0], 3000))); + } + + #[test] + fn resolve_addr_env_overrides_manifest() { + let manifest = r#" +[adapters.axum.adapter] +host = "127.0.0.1" +port = 3000 +"#; + let loader = ManifestLoader::load_from_str(manifest); + let addr = resolve_addr_from_parts(loader.manifest(), Some("0.0.0.0"), Some("4000")); + assert_eq!(addr, SocketAddr::from(([0, 0, 0, 0], 4000))); + } + + #[test] + fn resolve_addr_partial_env_override() { + let manifest = r#" +[adapters.axum.adapter] +port = 5000 +"#; + let loader = ManifestLoader::load_from_str(manifest); + let addr = resolve_addr_from_parts(loader.manifest(), Some("0.0.0.0"), None); + assert_eq!(addr, SocketAddr::from(([0, 0, 0, 0], 5000))); + } } #[cfg(test)] diff --git a/crates/edgezero-adapter-axum/src/lib.rs b/crates/edgezero-adapter-axum/src/lib.rs index ef78ffe..660ff05 100644 --- a/crates/edgezero-adapter-axum/src/lib.rs +++ b/crates/edgezero-adapter-axum/src/lib.rs @@ -21,7 +21,7 @@ pub mod cli; #[cfg(feature = "axum")] pub use context::AxumRequestContext; #[cfg(feature = "axum")] -pub use dev_server::{run_app, AxumDevServer, AxumDevServerConfig}; +pub use dev_server::{resolve_addr, run_app, AxumDevServer, AxumDevServerConfig}; #[cfg(feature = "axum")] pub use key_value_store::PersistentKvStore; #[cfg(feature = "axum")] diff --git a/crates/edgezero-cli/src/dev_server.rs b/crates/edgezero-cli/src/dev_server.rs index 7cb6e05..94e0ad8 100644 --- a/crates/edgezero-cli/src/dev_server.rs +++ b/crates/edgezero-cli/src/dev_server.rs @@ -25,7 +25,7 @@ pub fn run_dev() { Err(err) => eprintln!("[edgezero] dev manifest error: {err}"), } - let addr = SocketAddr::from(([127, 0, 0, 1], 8787)); + let addr = resolve_dev_addr(); println!( "[edgezero] dev: starting local server on http://{}:{}", addr.ip(), @@ -83,6 +83,31 @@ async fn dev_echo(Path(params): Path) -> Text { Text::new(format!("hello {}", params.name)) } +/// Resolve the dev server bind address from `EDGEZERO_HOST` / `EDGEZERO_PORT` +/// environment variables, falling back to `127.0.0.1:8787`. +fn resolve_dev_addr() -> SocketAddr { + let default_host: std::net::IpAddr = [127, 0, 0, 1].into(); + let host = match std::env::var("EDGEZERO_HOST") { + Ok(v) => v.parse().unwrap_or_else(|_| { + eprintln!( + "[edgezero] warning: EDGEZERO_HOST={v:?} is not a valid IP address, using default" + ); + default_host + }), + Err(_) => default_host, + }; + let port = match std::env::var("EDGEZERO_PORT") { + Ok(v) => v.parse().unwrap_or_else(|_| { + eprintln!( + "[edgezero] warning: EDGEZERO_PORT={v:?} is not a valid port number, using default" + ); + 8787 + }), + Err(_) => 8787, + }; + SocketAddr::from((host, port)) +} + fn try_run_manifest_axum() -> Result { let manifest = match load_manifest_optional()? { Some(manifest) => manifest, diff --git a/crates/edgezero-core/src/manifest.rs b/crates/edgezero-core/src/manifest.rs index 0efb690..4c36be2 100644 --- a/crates/edgezero-core/src/manifest.rs +++ b/crates/edgezero-core/src/manifest.rs @@ -306,6 +306,13 @@ pub struct ManifestAdapterDefinition { #[serde(default)] #[validate(length(min = 1))] pub manifest: Option, + /// Bind address for the adapter server (e.g. `"0.0.0.0"` or `"127.0.0.1"`). + #[serde(default)] + #[validate(length(min = 1))] + pub host: Option, + /// Port for the adapter server. + #[serde(default)] + pub port: Option, } #[derive(Debug, Default, Deserialize, Validate)] @@ -1239,4 +1246,34 @@ name = "FASTLY_STORE" assert_eq!(manifest.kv_store_name("fastly"), "FASTLY_STORE"); assert_eq!(manifest.kv_store_name("FASTLY"), "FASTLY_STORE"); } + + // -- Adapter host/port config ------------------------------------------ + + #[test] + fn adapter_definition_with_host_and_port() { + let manifest = r#" +[adapters.axum.adapter] +crate = "crates/axum-adapter" +host = "0.0.0.0" +port = 3000 +"#; + let loader = ManifestLoader::load_from_str(manifest); + let m = loader.manifest(); + let adapter = m.adapters.get("axum").unwrap(); + assert_eq!(adapter.adapter.host.as_deref(), Some("0.0.0.0")); + assert_eq!(adapter.adapter.port, Some(3000)); + } + + #[test] + fn adapter_definition_host_and_port_default_to_none() { + let manifest = r#" +[adapters.axum.adapter] +crate = "crates/axum-adapter" +"#; + let loader = ManifestLoader::load_from_str(manifest); + let m = loader.manifest(); + let adapter = m.adapters.get("axum").unwrap(); + assert!(adapter.adapter.host.is_none()); + assert!(adapter.adapter.port.is_none()); + } } From 956150202715b4099026dbe14f7849f5c6432a65 Mon Sep 17 00:00:00 2001 From: Christian Date: Tue, 31 Mar 2026 10:56:46 -0500 Subject: [PATCH 2/3] fix: reject port 0, extract shared addr resolution, reduce resolve_addr visibility - Extract resolve_bind_addr into edgezero_core::addr so both the axum adapter and the CLI dev server share a single code path for resolving bind addresses from env vars and config. - Reject port 0 (random OS port) with a warning and fallback to 8787, matching the existing cli.rs validation. - Narrow resolve_addr to pub(crate) since it has no external consumers. --- crates/edgezero-adapter-axum/src/lib.rs | 2 +- crates/edgezero-cli/src/dev_server.rs | 23 +--- crates/edgezero-core/src/addr.rs | 146 ++++++++++++++++++++++++ crates/edgezero-core/src/lib.rs | 1 + 4 files changed, 151 insertions(+), 21 deletions(-) create mode 100644 crates/edgezero-core/src/addr.rs diff --git a/crates/edgezero-adapter-axum/src/lib.rs b/crates/edgezero-adapter-axum/src/lib.rs index 660ff05..ef78ffe 100644 --- a/crates/edgezero-adapter-axum/src/lib.rs +++ b/crates/edgezero-adapter-axum/src/lib.rs @@ -21,7 +21,7 @@ pub mod cli; #[cfg(feature = "axum")] pub use context::AxumRequestContext; #[cfg(feature = "axum")] -pub use dev_server::{resolve_addr, run_app, AxumDevServer, AxumDevServerConfig}; +pub use dev_server::{run_app, AxumDevServer, AxumDevServerConfig}; #[cfg(feature = "axum")] pub use key_value_store::PersistentKvStore; #[cfg(feature = "axum")] diff --git a/crates/edgezero-cli/src/dev_server.rs b/crates/edgezero-cli/src/dev_server.rs index 94e0ad8..205c4d2 100644 --- a/crates/edgezero-cli/src/dev_server.rs +++ b/crates/edgezero-cli/src/dev_server.rs @@ -86,26 +86,9 @@ async fn dev_echo(Path(params): Path) -> Text { /// Resolve the dev server bind address from `EDGEZERO_HOST` / `EDGEZERO_PORT` /// environment variables, falling back to `127.0.0.1:8787`. fn resolve_dev_addr() -> SocketAddr { - let default_host: std::net::IpAddr = [127, 0, 0, 1].into(); - let host = match std::env::var("EDGEZERO_HOST") { - Ok(v) => v.parse().unwrap_or_else(|_| { - eprintln!( - "[edgezero] warning: EDGEZERO_HOST={v:?} is not a valid IP address, using default" - ); - default_host - }), - Err(_) => default_host, - }; - let port = match std::env::var("EDGEZERO_PORT") { - Ok(v) => v.parse().unwrap_or_else(|_| { - eprintln!( - "[edgezero] warning: EDGEZERO_PORT={v:?} is not a valid port number, using default" - ); - 8787 - }), - Err(_) => 8787, - }; - SocketAddr::from((host, port)) + let env_host = std::env::var("EDGEZERO_HOST").ok(); + let env_port = std::env::var("EDGEZERO_PORT").ok(); + edgezero_core::addr::resolve_bind_addr(env_host.as_deref(), env_port.as_deref(), None, None) } fn try_run_manifest_axum() -> Result { diff --git a/crates/edgezero-core/src/addr.rs b/crates/edgezero-core/src/addr.rs new file mode 100644 index 0000000..3991b43 --- /dev/null +++ b/crates/edgezero-core/src/addr.rs @@ -0,0 +1,146 @@ +//! Shared bind-address resolution for EdgeZero dev servers. +//! +//! Centralises the precedence logic (env vars > config > defaults) so that +//! both the Axum adapter and the CLI dev server produce consistent results. + +use std::net::{IpAddr, SocketAddr}; + +const DEFAULT_HOST: IpAddr = IpAddr::V4(std::net::Ipv4Addr::new(127, 0, 0, 1)); +const DEFAULT_PORT: u16 = 8787; + +/// Resolve a bind address from optional environment and config values. +/// +/// Precedence (highest wins): +/// 1. `env_host` / `env_port` (typically `EDGEZERO_HOST` / `EDGEZERO_PORT`) +/// 2. `config_host` / `config_port` (from manifest or adapter config) +/// 3. Defaults: `127.0.0.1:8787` +/// +/// Invalid values produce a `log::warn!` and fall back to the default. +/// Port 0 is rejected (random OS port is almost never intended). +pub fn resolve_bind_addr( + env_host: Option<&str>, + env_port: Option<&str>, + config_host: Option<&str>, + config_port: Option, +) -> SocketAddr { + let host = resolve_host(env_host, config_host); + let port = resolve_port(env_port, config_port); + SocketAddr::from((host, port)) +} + +fn resolve_host(env_host: Option<&str>, config_host: Option<&str>) -> IpAddr { + if let Some(v) = env_host { + return v.parse().unwrap_or_else(|_| { + log::warn!("EDGEZERO_HOST={v:?} is not a valid IP address, using default"); + DEFAULT_HOST + }); + } + if let Some(h) = config_host { + return h.parse().unwrap_or_else(|_| { + log::warn!("configured host={h:?} is not a valid IP address, using default"); + DEFAULT_HOST + }); + } + DEFAULT_HOST +} + +fn resolve_port(env_port: Option<&str>, config_port: Option) -> u16 { + let port = if let Some(v) = env_port { + v.parse().unwrap_or_else(|_| { + log::warn!("EDGEZERO_PORT={v:?} is not a valid port number, using default"); + DEFAULT_PORT + }) + } else { + config_port.unwrap_or(DEFAULT_PORT) + }; + + if port == 0 { + log::warn!("port 0 is not supported, using default {DEFAULT_PORT}"); + return DEFAULT_PORT; + } + + port +} + +#[cfg(test)] +mod tests { + use super::*; + use std::net::Ipv4Addr; + + #[test] + fn defaults_when_nothing_provided() { + let addr = resolve_bind_addr(None, None, None, None); + assert_eq!(addr, SocketAddr::from(([127, 0, 0, 1], 8787))); + } + + #[test] + fn config_overrides_defaults() { + let addr = resolve_bind_addr(None, None, Some("0.0.0.0"), Some(3000)); + assert_eq!(addr.ip(), IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0))); + assert_eq!(addr.port(), 3000); + } + + #[test] + fn env_overrides_config() { + let addr = resolve_bind_addr(Some("0.0.0.0"), Some("4000"), Some("127.0.0.1"), Some(3000)); + assert_eq!(addr.ip(), IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0))); + assert_eq!(addr.port(), 4000); + } + + #[test] + fn partial_env_override_host_only() { + let addr = resolve_bind_addr(Some("0.0.0.0"), None, None, Some(5000)); + assert_eq!(addr.ip(), IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0))); + assert_eq!(addr.port(), 5000); + } + + #[test] + fn partial_env_override_port_only() { + let addr = resolve_bind_addr(None, Some("9000"), Some("0.0.0.0"), None); + assert_eq!(addr.ip(), IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0))); + assert_eq!(addr.port(), 9000); + } + + #[test] + fn invalid_env_host_falls_back_to_default() { + let addr = resolve_bind_addr(Some("not-an-ip"), None, Some("0.0.0.0"), None); + assert_eq!(addr.ip(), DEFAULT_HOST); + } + + #[test] + fn invalid_env_port_falls_back_to_default() { + let addr = resolve_bind_addr(None, Some("abc"), None, Some(3000)); + assert_eq!(addr.port(), DEFAULT_PORT); + } + + #[test] + fn invalid_config_host_falls_back_to_default() { + let addr = resolve_bind_addr(None, None, Some("not-an-ip"), None); + assert_eq!(addr.ip(), DEFAULT_HOST); + } + + #[test] + fn port_zero_from_env_falls_back_to_default() { + let addr = resolve_bind_addr(None, Some("0"), None, None); + assert_eq!(addr.port(), DEFAULT_PORT); + } + + #[test] + fn port_zero_from_config_falls_back_to_default() { + let addr = resolve_bind_addr(None, None, None, Some(0)); + assert_eq!(addr.port(), DEFAULT_PORT); + } + + #[test] + fn ipv6_host_from_env() { + let addr = resolve_bind_addr(Some("::1"), None, None, None); + assert_eq!(addr.ip(), "::1".parse::().unwrap()); + } + + #[test] + fn ipv6_host_from_config() { + let addr = resolve_bind_addr(None, None, Some("::"), Some(3000)); + assert_eq!(addr.ip(), "::".parse::().unwrap()); + assert_eq!(addr.port(), 3000); + } +} diff --git a/crates/edgezero-core/src/lib.rs b/crates/edgezero-core/src/lib.rs index bc6fe81..0cb30cb 100644 --- a/crates/edgezero-core/src/lib.rs +++ b/crates/edgezero-core/src/lib.rs @@ -1,5 +1,6 @@ //! Core primitives for building portable edge workloads across edge adapters. +pub mod addr; pub mod app; pub mod body; pub mod compression; From 979c17513360dae4bcc120bfa8116bffd64a9d1f Mon Sep 17 00:00:00 2001 From: Christian Date: Wed, 1 Apr 2026 15:48:33 -0500 Subject: [PATCH 3/3] fix: unify validation paths, return Result from resolve_bind_addr - resolve_bind_addr now returns Result instead of silently falling back on invalid values (addresses silent warning drop) - read_axum_project delegates host/port validation to resolve_bind_addr, eliminating the inconsistency between axum.toml and edgezero.toml paths - DEFAULT_HOST and DEFAULT_PORT are now pub const for cross-crate reuse - CLI fallback path surfaces errors via eprintln instead of dropped log::warn - Added doc comment on manifest host field explaining late IP validation --- crates/edgezero-adapter-axum/src/cli.rs | 54 +++++------ .../edgezero-adapter-axum/src/dev_server.rs | 22 +++-- crates/edgezero-cli/src/dev_server.rs | 10 +- crates/edgezero-core/src/addr.rs | 96 +++++++++---------- crates/edgezero-core/src/manifest.rs | 4 + 5 files changed, 96 insertions(+), 90 deletions(-) diff --git a/crates/edgezero-adapter-axum/src/cli.rs b/crates/edgezero-adapter-axum/src/cli.rs index 9bbacf5..361e96a 100644 --- a/crates/edgezero-adapter-axum/src/cli.rs +++ b/crates/edgezero-adapter-axum/src/cli.rs @@ -1,5 +1,5 @@ use std::fs; -use std::net::IpAddr; +use std::net::SocketAddr; use std::path::{Path, PathBuf}; use std::process::Command; @@ -145,8 +145,7 @@ struct AxumProject { crate_dir: PathBuf, cargo_manifest: PathBuf, crate_name: String, - host: IpAddr, - port: u16, + addr: SocketAddr, } fn locate_project() -> Result { @@ -158,8 +157,8 @@ fn locate_project() -> Result { fn run_cargo(project: &AxumProject, subcommand: &str, extra_args: &[String]) -> Result<(), String> { let display = project.crate_dir.display(); println!( - "[edgezero] Axum {subcommand} ({}) in {} ({}:{})", - project.crate_name, display, project.host, project.port + "[edgezero] Axum {subcommand} ({}) in {} ({})", + project.crate_name, display, project.addr ); let mut command = Command::new("cargo"); command.arg(subcommand); @@ -172,8 +171,8 @@ fn run_cargo(project: &AxumProject, subcommand: &str, extra_args: &[String]) -> ); command.args(extra_args); command.current_dir(&project.crate_dir); - command.env("EDGEZERO_HOST", project.host.to_string()); - command.env("EDGEZERO_PORT", project.port.to_string()); + command.env("EDGEZERO_HOST", project.addr.ip().to_string()); + command.env("EDGEZERO_PORT", project.addr.port().to_string()); let status = command .status() .map_err(|err| format!("failed to run cargo {subcommand}: {err}"))?; @@ -260,35 +259,25 @@ fn read_axum_project(manifest: &Path) -> Result { }) }); - let host: IpAddr = match adapter.get("host").and_then(Value::as_str) { - Some(value) => value.parse().map_err(|_| { + let config_host = adapter.get("host").and_then(Value::as_str); + let config_port = match adapter.get("port").and_then(Value::as_integer) { + Some(value) => Some(u16::try_from(value).map_err(|_| { format!( - "adapter.host in {} must be a valid IP address", + "adapter.port in {} must be between 1 and 65535", manifest.display() ) - })?, - None => [127, 0, 0, 1].into(), + })?), + None => None, }; - let port = match adapter.get("port").and_then(Value::as_integer) { - Some(value) => { - if !(1..=u16::MAX as i64).contains(&value) { - return Err(format!( - "adapter.port in {} must be between 1 and 65535", - manifest.display() - )); - } - value as u16 - } - None => 8787, - }; + let addr = edgezero_core::addr::resolve_bind_addr(None, None, config_host, config_port) + .map_err(|e| format!("{e} (in {})", manifest.display()))?; Ok(AxumProject { crate_dir, cargo_manifest, crate_name, - host, - port, + addr, }) } @@ -317,7 +306,8 @@ mod tests { assert_eq!(project.crate_name, "demo"); assert_eq!(project.crate_dir, root); assert_eq!(project.cargo_manifest, root.join("Cargo.toml")); - assert_eq!(project.port, 8787); + assert_eq!(project.addr.port(), edgezero_core::addr::DEFAULT_PORT); + assert_eq!(project.addr.ip(), edgezero_core::addr::DEFAULT_HOST); } #[test] @@ -353,7 +343,7 @@ mod tests { .unwrap(); let project = read_axum_project(&root.join("axum.toml")).expect("project"); - assert_eq!(project.port, 4001); + assert_eq!(project.addr.port(), 4001); } #[test] @@ -526,7 +516,7 @@ mod tests { .unwrap(); let project = read_axum_project(&root.join("axum.toml")).expect("project"); - assert_eq!(project.port, 65535); + assert_eq!(project.addr.port(), 65535); } #[test] @@ -545,7 +535,7 @@ mod tests { .unwrap(); let project = read_axum_project(&root.join("axum.toml")).expect("project"); - assert_eq!(project.port, 1); + assert_eq!(project.addr.port(), 1); } #[test] @@ -564,7 +554,7 @@ mod tests { .unwrap(); let project = read_axum_project(&root.join("axum.toml")).expect("project"); - assert_eq!(project.host, IpAddr::from([127, 0, 0, 1])); + assert_eq!(project.addr.ip(), edgezero_core::addr::DEFAULT_HOST); } #[test] @@ -583,7 +573,7 @@ mod tests { .unwrap(); let project = read_axum_project(&root.join("axum.toml")).expect("project"); - assert_eq!(project.host, IpAddr::from([0, 0, 0, 0])); + assert_eq!(project.addr.ip(), std::net::IpAddr::from([0, 0, 0, 0])); } #[test] diff --git a/crates/edgezero-adapter-axum/src/dev_server.rs b/crates/edgezero-adapter-axum/src/dev_server.rs index 70e4c03..5daecbc 100644 --- a/crates/edgezero-adapter-axum/src/dev_server.rs +++ b/crates/edgezero-adapter-axum/src/dev_server.rs @@ -31,7 +31,10 @@ pub struct AxumDevServerConfig { impl Default for AxumDevServerConfig { fn default() -> Self { Self { - addr: SocketAddr::from(([127, 0, 0, 1], 8787)), + addr: SocketAddr::from(( + edgezero_core::addr::DEFAULT_HOST, + edgezero_core::addr::DEFAULT_PORT, + )), enable_ctrl_c: true, } } @@ -253,7 +256,7 @@ pub fn run_app(manifest_src: &str) -> anyhow::Result<()> { SimpleLogger::new().with_level(level).init().ok(); - let addr = resolve_addr(manifest); + let addr = resolve_addr(manifest).map_err(|e| anyhow::anyhow!("{e}"))?; let app = A::build_app(); let router = app.router().clone(); @@ -310,7 +313,9 @@ pub fn run_app(manifest_src: &str) -> anyhow::Result<()> { /// 1. `EDGEZERO_HOST` / `EDGEZERO_PORT` environment variables /// 2. `[adapters.axum.adapter]` host/port in the manifest /// 3. Default: `127.0.0.1:8787` -pub(crate) fn resolve_addr(manifest: &edgezero_core::manifest::Manifest) -> SocketAddr { +pub(crate) fn resolve_addr( + manifest: &edgezero_core::manifest::Manifest, +) -> Result { let env_host = std::env::var("EDGEZERO_HOST").ok(); let env_port = std::env::var("EDGEZERO_PORT").ok(); resolve_addr_from_parts(manifest, env_host.as_deref(), env_port.as_deref()) @@ -320,7 +325,7 @@ fn resolve_addr_from_parts( manifest: &edgezero_core::manifest::Manifest, env_host: Option<&str>, env_port: Option<&str>, -) -> SocketAddr { +) -> Result { let adapter = manifest.adapters.get("axum"); let config_host = adapter.and_then(|a| a.adapter.host.as_deref()); let config_port = adapter.and_then(|a| a.adapter.port); @@ -456,7 +461,7 @@ name = "EDGEZERO_KV" fn resolve_addr_defaults_without_manifest_config() { // Note: env var tests use resolve_addr_from_parts to avoid races. let loader = ManifestLoader::load_from_str(""); - let addr = resolve_addr_from_parts(loader.manifest(), None, None); + let addr = resolve_addr_from_parts(loader.manifest(), None, None).unwrap(); assert_eq!(addr, SocketAddr::from(([127, 0, 0, 1], 8787))); } @@ -468,7 +473,7 @@ host = "0.0.0.0" port = 3000 "#; let loader = ManifestLoader::load_from_str(manifest); - let addr = resolve_addr_from_parts(loader.manifest(), None, None); + let addr = resolve_addr_from_parts(loader.manifest(), None, None).unwrap(); assert_eq!(addr, SocketAddr::from(([0, 0, 0, 0], 3000))); } @@ -480,7 +485,8 @@ host = "127.0.0.1" port = 3000 "#; let loader = ManifestLoader::load_from_str(manifest); - let addr = resolve_addr_from_parts(loader.manifest(), Some("0.0.0.0"), Some("4000")); + let addr = + resolve_addr_from_parts(loader.manifest(), Some("0.0.0.0"), Some("4000")).unwrap(); assert_eq!(addr, SocketAddr::from(([0, 0, 0, 0], 4000))); } @@ -491,7 +497,7 @@ port = 3000 port = 5000 "#; let loader = ManifestLoader::load_from_str(manifest); - let addr = resolve_addr_from_parts(loader.manifest(), Some("0.0.0.0"), None); + let addr = resolve_addr_from_parts(loader.manifest(), Some("0.0.0.0"), None).unwrap(); assert_eq!(addr, SocketAddr::from(([0, 0, 0, 0], 5000))); } } diff --git a/crates/edgezero-cli/src/dev_server.rs b/crates/edgezero-cli/src/dev_server.rs index 205c4d2..4c63135 100644 --- a/crates/edgezero-cli/src/dev_server.rs +++ b/crates/edgezero-cli/src/dev_server.rs @@ -25,7 +25,13 @@ pub fn run_dev() { Err(err) => eprintln!("[edgezero] dev manifest error: {err}"), } - let addr = resolve_dev_addr(); + let addr = match resolve_dev_addr() { + Ok(addr) => addr, + Err(err) => { + eprintln!("[edgezero] {err}"); + return; + } + }; println!( "[edgezero] dev: starting local server on http://{}:{}", addr.ip(), @@ -85,7 +91,7 @@ async fn dev_echo(Path(params): Path) -> Text { /// Resolve the dev server bind address from `EDGEZERO_HOST` / `EDGEZERO_PORT` /// environment variables, falling back to `127.0.0.1:8787`. -fn resolve_dev_addr() -> SocketAddr { +fn resolve_dev_addr() -> Result { let env_host = std::env::var("EDGEZERO_HOST").ok(); let env_port = std::env::var("EDGEZERO_PORT").ok(); edgezero_core::addr::resolve_bind_addr(env_host.as_deref(), env_port.as_deref(), None, None) diff --git a/crates/edgezero-core/src/addr.rs b/crates/edgezero-core/src/addr.rs index 3991b43..b158ccc 100644 --- a/crates/edgezero-core/src/addr.rs +++ b/crates/edgezero-core/src/addr.rs @@ -5,8 +5,10 @@ use std::net::{IpAddr, SocketAddr}; -const DEFAULT_HOST: IpAddr = IpAddr::V4(std::net::Ipv4Addr::new(127, 0, 0, 1)); -const DEFAULT_PORT: u16 = 8787; +/// Default bind host: localhost (`127.0.0.1`). +pub const DEFAULT_HOST: IpAddr = IpAddr::V4(std::net::Ipv4Addr::new(127, 0, 0, 1)); +/// Default bind port (`8787`). +pub const DEFAULT_PORT: u16 = 8787; /// Resolve a bind address from optional environment and config values. /// @@ -15,51 +17,46 @@ const DEFAULT_PORT: u16 = 8787; /// 2. `config_host` / `config_port` (from manifest or adapter config) /// 3. Defaults: `127.0.0.1:8787` /// -/// Invalid values produce a `log::warn!` and fall back to the default. -/// Port 0 is rejected (random OS port is almost never intended). +/// Returns an error if any provided value is invalid (unparseable host, +/// unparseable port, or port 0). Missing values fall through to the default. pub fn resolve_bind_addr( env_host: Option<&str>, env_port: Option<&str>, config_host: Option<&str>, config_port: Option, -) -> SocketAddr { - let host = resolve_host(env_host, config_host); - let port = resolve_port(env_port, config_port); - SocketAddr::from((host, port)) +) -> Result { + let host = resolve_host(env_host, config_host)?; + let port = resolve_port(env_port, config_port)?; + Ok(SocketAddr::from((host, port))) } -fn resolve_host(env_host: Option<&str>, config_host: Option<&str>) -> IpAddr { +fn resolve_host(env_host: Option<&str>, config_host: Option<&str>) -> Result { if let Some(v) = env_host { - return v.parse().unwrap_or_else(|_| { - log::warn!("EDGEZERO_HOST={v:?} is not a valid IP address, using default"); - DEFAULT_HOST - }); + return v + .parse() + .map_err(|_| format!("EDGEZERO_HOST={v:?} is not a valid IP address")); } if let Some(h) = config_host { - return h.parse().unwrap_or_else(|_| { - log::warn!("configured host={h:?} is not a valid IP address, using default"); - DEFAULT_HOST - }); + return h + .parse() + .map_err(|_| format!("configured host={h:?} is not a valid IP address")); } - DEFAULT_HOST + Ok(DEFAULT_HOST) } -fn resolve_port(env_port: Option<&str>, config_port: Option) -> u16 { +fn resolve_port(env_port: Option<&str>, config_port: Option) -> Result { let port = if let Some(v) = env_port { - v.parse().unwrap_or_else(|_| { - log::warn!("EDGEZERO_PORT={v:?} is not a valid port number, using default"); - DEFAULT_PORT - }) + v.parse() + .map_err(|_| format!("EDGEZERO_PORT={v:?} is not a valid port number"))? } else { config_port.unwrap_or(DEFAULT_PORT) }; if port == 0 { - log::warn!("port 0 is not supported, using default {DEFAULT_PORT}"); - return DEFAULT_PORT; + return Err("port 0 is not supported (would bind to a random OS port)".to_string()); } - port + Ok(port) } #[cfg(test)] @@ -69,77 +66,80 @@ mod tests { #[test] fn defaults_when_nothing_provided() { - let addr = resolve_bind_addr(None, None, None, None); + let addr = resolve_bind_addr(None, None, None, None).unwrap(); assert_eq!(addr, SocketAddr::from(([127, 0, 0, 1], 8787))); } #[test] fn config_overrides_defaults() { - let addr = resolve_bind_addr(None, None, Some("0.0.0.0"), Some(3000)); + let addr = resolve_bind_addr(None, None, Some("0.0.0.0"), Some(3000)).unwrap(); assert_eq!(addr.ip(), IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0))); assert_eq!(addr.port(), 3000); } #[test] fn env_overrides_config() { - let addr = resolve_bind_addr(Some("0.0.0.0"), Some("4000"), Some("127.0.0.1"), Some(3000)); + let addr = resolve_bind_addr(Some("0.0.0.0"), Some("4000"), Some("127.0.0.1"), Some(3000)) + .unwrap(); assert_eq!(addr.ip(), IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0))); assert_eq!(addr.port(), 4000); } #[test] fn partial_env_override_host_only() { - let addr = resolve_bind_addr(Some("0.0.0.0"), None, None, Some(5000)); + let addr = resolve_bind_addr(Some("0.0.0.0"), None, None, Some(5000)).unwrap(); assert_eq!(addr.ip(), IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0))); assert_eq!(addr.port(), 5000); } #[test] fn partial_env_override_port_only() { - let addr = resolve_bind_addr(None, Some("9000"), Some("0.0.0.0"), None); + let addr = resolve_bind_addr(None, Some("9000"), Some("0.0.0.0"), None).unwrap(); assert_eq!(addr.ip(), IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0))); assert_eq!(addr.port(), 9000); } #[test] - fn invalid_env_host_falls_back_to_default() { - let addr = resolve_bind_addr(Some("not-an-ip"), None, Some("0.0.0.0"), None); - assert_eq!(addr.ip(), DEFAULT_HOST); + fn invalid_env_host_returns_error() { + let err = resolve_bind_addr(Some("not-an-ip"), None, Some("0.0.0.0"), None).unwrap_err(); + assert!(err.contains("EDGEZERO_HOST")); + assert!(err.contains("not a valid IP address")); } #[test] - fn invalid_env_port_falls_back_to_default() { - let addr = resolve_bind_addr(None, Some("abc"), None, Some(3000)); - assert_eq!(addr.port(), DEFAULT_PORT); + fn invalid_env_port_returns_error() { + let err = resolve_bind_addr(None, Some("abc"), None, Some(3000)).unwrap_err(); + assert!(err.contains("EDGEZERO_PORT")); + assert!(err.contains("not a valid port number")); } #[test] - fn invalid_config_host_falls_back_to_default() { - let addr = resolve_bind_addr(None, None, Some("not-an-ip"), None); - assert_eq!(addr.ip(), DEFAULT_HOST); + fn invalid_config_host_returns_error() { + let err = resolve_bind_addr(None, None, Some("not-an-ip"), None).unwrap_err(); + assert!(err.contains("not a valid IP address")); } #[test] - fn port_zero_from_env_falls_back_to_default() { - let addr = resolve_bind_addr(None, Some("0"), None, None); - assert_eq!(addr.port(), DEFAULT_PORT); + fn port_zero_from_env_returns_error() { + let err = resolve_bind_addr(None, Some("0"), None, None).unwrap_err(); + assert!(err.contains("port 0")); } #[test] - fn port_zero_from_config_falls_back_to_default() { - let addr = resolve_bind_addr(None, None, None, Some(0)); - assert_eq!(addr.port(), DEFAULT_PORT); + fn port_zero_from_config_returns_error() { + let err = resolve_bind_addr(None, None, None, Some(0)).unwrap_err(); + assert!(err.contains("port 0")); } #[test] fn ipv6_host_from_env() { - let addr = resolve_bind_addr(Some("::1"), None, None, None); + let addr = resolve_bind_addr(Some("::1"), None, None, None).unwrap(); assert_eq!(addr.ip(), "::1".parse::().unwrap()); } #[test] fn ipv6_host_from_config() { - let addr = resolve_bind_addr(None, None, Some("::"), Some(3000)); + let addr = resolve_bind_addr(None, None, Some("::"), Some(3000)).unwrap(); assert_eq!(addr.ip(), "::".parse::().unwrap()); assert_eq!(addr.port(), 3000); } diff --git a/crates/edgezero-core/src/manifest.rs b/crates/edgezero-core/src/manifest.rs index 4c36be2..de7c497 100644 --- a/crates/edgezero-core/src/manifest.rs +++ b/crates/edgezero-core/src/manifest.rs @@ -307,6 +307,10 @@ pub struct ManifestAdapterDefinition { #[validate(length(min = 1))] pub manifest: Option, /// Bind address for the adapter server (e.g. `"0.0.0.0"` or `"127.0.0.1"`). + /// + /// Stored as a raw string for WASM compatibility — IP-address validation + /// happens at the adapter layer when the server binds + /// (see [`crate::addr::resolve_bind_addr`]). #[serde(default)] #[validate(length(min = 1))] pub host: Option,