use std::collections::HashMap;
use std::sync::atomic::AtomicUsize;
use std::sync::Arc;
use anyhow::{anyhow, bail, Result};
use serde::{Deserialize, Serialize};
use tokio::sync::Mutex;
pub type ServerStateDb = Arc<Mutex<HashMap<String, ServerState>>>;
pub type ServerConfigDb = Arc<Mutex<HashMap<String, Server>>>;
fn default_max_connections() -> usize {
10_000
}
fn default_handshake_timeout_ms() -> u64 {
10_000
}
fn default_idle_timeout_ms() -> u64 {
0 }
fn default_max_message_bytes() -> usize {
256 * 1024 * 1024 }
fn default_max_client_message_bytes() -> usize {
64 * 1024 }
fn default_shutdown_grace_ms() -> u64 {
5_000
}
const DEFAULT_UPSTREAM_CONNECT_TIMEOUT_MS: u64 = 10_000;
#[derive(Debug, Deserialize, Serialize, Hash, Eq, PartialEq, Clone)]
pub struct Server {
pub name: String,
pub endpoint: String,
pub enabled: bool,
}
impl Server {
pub fn ws_url(&self) -> String {
dial_url(&self.endpoint)
}
}
fn is_uri_scheme(scheme: &str) -> bool {
let mut chars = scheme.chars();
match chars.next() {
Some(c) if c.is_ascii_alphabetic() => {}
_ => return false,
}
chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))
}
fn dial_url(endpoint: &str) -> String {
let endpoint = endpoint.trim();
if let Some((scheme, rest)) = endpoint.split_once("://") {
if is_uri_scheme(scheme) {
return format!("{}://{rest}", scheme.to_ascii_lowercase());
}
}
format!("ws://{endpoint}")
}
fn upstream_identity(endpoint: &str) -> Result<String> {
let trimmed = endpoint.trim();
if let Some((scheme, _)) = trimmed.split_once("://") {
if !is_uri_scheme(scheme) {
bail!(
"endpoint '{endpoint}' has a malformed scheme; expected host:port, ws://, or wss://"
);
}
let scheme = scheme.to_ascii_lowercase();
if scheme != "ws" && scheme != "wss" {
bail!(
"endpoint '{endpoint}' uses unsupported scheme '{scheme}'; expected host:port, ws://, or wss://"
);
}
}
let url = dial_url(trimmed);
let uri: http::Uri = url.parse().map_err(|err| {
anyhow!(
"endpoint '{endpoint}' is not a valid WebSocket URL ({err}); expected host:port, ws://, or wss://"
)
})?;
let scheme = uri.scheme_str().unwrap_or("");
if scheme != "ws" && scheme != "wss" {
bail!(
"endpoint '{endpoint}' uses unsupported scheme '{scheme}'; expected host:port, ws://, or wss://"
);
}
let Some(authority) = uri.authority() else {
bail!(
"endpoint '{endpoint}' is missing a host; expected host:port, ws://host:port, or wss://host:port"
);
};
let host = authority.host();
if host.is_empty() {
bail!(
"endpoint '{endpoint}' is missing a host; expected host:port, ws://host:port, or wss://host:port"
);
}
let port = authority
.port_u16()
.unwrap_or(if scheme == "wss" { 443 } else { 80 });
let host = host.to_ascii_lowercase();
let userinfo = match authority.as_str().rsplit_once('@') {
Some((user, _)) => format!("{user}@"),
None => String::new(),
};
let path = uri.path();
let query = uri
.query()
.map(|query| format!("?{query}"))
.unwrap_or_default();
Ok(format!("{scheme}://{userinfo}{host}:{port}{path}{query}"))
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
pub struct ListenerConfig {
pub name: String,
#[serde(default)]
pub listen_address: Option<String>,
pub listen_port: u16,
pub servers: Vec<Server>,
#[serde(default)]
pub max_connections: Option<usize>,
#[serde(default)]
pub chain_id: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedListener {
pub name: Option<String>,
pub listen_address: String,
pub listen_port: u16,
pub max_connections: usize,
pub expected_chain_id: Option<[u8; 32]>,
pub servers: Vec<Server>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ServerConfig {
#[serde(default)]
pub listen_address: String,
#[serde(default)]
pub listen_port: u16,
pub upstream_reconnect_ms: u64,
pub upstream_monitoring_ms: u64,
pub upstream_status_ms: u64,
#[serde(default)]
pub servers: Vec<Server>,
#[serde(default)]
pub chain_id: Option<String>,
#[serde(default)]
pub listeners: Vec<ListenerConfig>,
#[serde(default = "default_max_connections")]
pub max_connections: usize,
#[serde(default = "default_handshake_timeout_ms")]
pub handshake_timeout_ms: u64,
#[serde(default = "default_idle_timeout_ms")]
pub idle_timeout_ms: u64,
#[serde(default = "default_max_message_bytes")]
pub max_message_bytes: usize,
#[serde(default = "default_max_client_message_bytes")]
pub max_client_message_bytes: usize,
#[serde(default = "default_shutdown_grace_ms")]
pub shutdown_grace_ms: u64,
#[serde(default)]
pub metrics_address: Option<String>,
#[serde(default)]
pub metrics_port: Option<u16>,
}
pub fn format_bind_addr(host: &str, port: u16) -> String {
let host = host.trim();
if host.contains(':') && !host.starts_with('[') {
format!("[{host}]:{port}")
} else {
format!("{host}:{port}")
}
}
fn normalize_bind_host(addr: &str) -> &str {
let trimmed = addr.trim();
trimmed
.strip_prefix('[')
.and_then(|s| s.strip_suffix(']'))
.unwrap_or(trimmed)
}
fn is_unspecified_addr(addr: &str) -> bool {
matches!(
normalize_bind_host(addr),
"0.0.0.0" | "::" | "0:0:0:0:0:0:0:0"
)
}
fn addresses_conflict(a: &str, b: &str) -> bool {
let a = normalize_bind_host(a);
let b = normalize_bind_host(b);
a.eq_ignore_ascii_case(b) || is_unspecified_addr(a) || is_unspecified_addr(b)
}
pub fn parse_chain_id(raw: &str, listener_ctx: Option<&str>) -> Result<[u8; 32]> {
let prefix = match listener_ctx {
Some(name) => format!("listener '{name}': "),
None => String::new(),
};
let hex = raw.trim();
if hex.len() != 64 || !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
bail!("{prefix}chain_id '{raw}' must be a 64-character hex string (32 bytes)");
}
let mut out = [0u8; 32];
for (i, chunk) in hex.as_bytes().as_chunks::<2>().0.iter().enumerate() {
let s = std::str::from_utf8(chunk).unwrap();
out[i] = u8::from_str_radix(s, 16).unwrap();
}
Ok(out)
}
fn validate_servers(servers: &[Server], listener_ctx: Option<&str>) -> Result<()> {
let prefix = match listener_ctx {
Some(name) => format!("listener '{name}': "),
None => String::new(),
};
if servers.is_empty() {
bail!("{prefix}config must define at least one server");
}
let enabled: Vec<&Server> = servers.iter().filter(|s| s.enabled).collect();
if enabled.is_empty() {
bail!("{prefix}config must have at least one enabled server");
}
let mut seen: HashMap<String, String> = HashMap::new();
for s in servers {
if s.endpoint.trim().is_empty() {
if s.enabled {
bail!("{prefix}server '{}' has an empty endpoint", s.name);
}
continue;
}
let identity = upstream_identity(&s.endpoint)
.map_err(|err| anyhow!("{prefix}server '{}': {err}", s.name))?;
if !s.enabled {
continue;
}
if let Some(previous) = seen.insert(identity, s.endpoint.clone()) {
if previous == s.endpoint {
bail!(
"{prefix}duplicate upstream endpoint '{}' — endpoints must be unique",
s.endpoint
);
}
bail!(
"{prefix}duplicate upstream endpoint '{}' — same target as '{}'; endpoints must be unique",
s.endpoint,
previous
);
}
}
Ok(())
}
impl ServerConfig {
pub fn validate(&self) -> Result<()> {
if self.upstream_reconnect_ms == 0 {
bail!("upstream_reconnect_ms must be greater than 0");
}
if self.upstream_monitoring_ms == 0 {
bail!("upstream_monitoring_ms must be greater than 0");
}
if self.upstream_status_ms == 0 {
bail!("upstream_status_ms must be greater than 0");
}
if self.max_connections == 0 {
bail!("max_connections must be greater than 0");
}
if self.max_message_bytes == 0 {
bail!("max_message_bytes must be greater than 0");
}
if self.max_client_message_bytes == 0 {
bail!("max_client_message_bytes must be greater than 0");
}
if let Some(0) = self.metrics_port {
bail!("metrics_port must be non-zero when set");
}
if !self.servers.is_empty() && !self.listeners.is_empty() {
bail!(
"config cannot specify both top-level 'servers' and 'listeners'; move all pools into 'listeners'"
);
}
if self.listeners.is_empty() {
if self.listen_address.trim().is_empty() {
bail!("listen_address must not be empty");
}
if self.listen_port == 0 {
bail!("listen_port must be non-zero");
}
if let Some(cid) = &self.chain_id {
parse_chain_id(cid, None)?;
}
validate_servers(&self.servers, None)?;
} else {
if self.listen_port != 0 {
bail!(
"top-level 'listen_port' cannot be combined with 'listeners'; set 'listen_port' on each listener"
);
}
if self.chain_id.is_some() {
bail!(
"top-level 'chain_id' cannot be combined with 'listeners'; set 'chain_id' on each listener"
);
}
let mut seen_names = HashMap::<String, usize>::new();
let mut bound_listeners = Vec::<(String, String, u16)>::new();
for (idx, listener) in self.listeners.iter().enumerate() {
let name = listener.name.trim();
if name.is_empty() {
bail!("listener at index {idx} has an empty name");
}
if name.contains('/') || name.chars().any(char::is_whitespace) {
bail!("listener '{name}': name must not contain '/' or whitespace");
}
if seen_names.insert(name.to_string(), idx).is_some() {
bail!("duplicate listener name '{name}' — listener names must be unique");
}
let addr = match &listener.listen_address {
Some(a) => {
if a.trim().is_empty() {
bail!("listener '{name}': listen_address must not be empty");
}
a.trim().to_string()
}
None => {
let top = self.listen_address.trim();
if top.is_empty() {
"0.0.0.0".to_string()
} else {
top.to_string()
}
}
};
if listener.listen_port == 0 {
bail!("listener '{name}': listen_port must be non-zero");
}
if let Some(0) = listener.max_connections {
bail!("listener '{name}': max_connections must be greater than 0");
}
if let Some(cid) = &listener.chain_id {
parse_chain_id(cid, Some(name))?;
}
for (prev_name, prev_addr, prev_port) in &bound_listeners {
if *prev_port == listener.listen_port && addresses_conflict(prev_addr, &addr) {
bail!(
"listener '{name}' ({}) conflicts with listener '{prev_name}' ({})",
format_bind_addr(&addr, listener.listen_port),
format_bind_addr(prev_addr, *prev_port)
);
}
}
bound_listeners.push((name.to_string(), addr, listener.listen_port));
validate_servers(&listener.servers, Some(name))?;
}
}
if let Some((metrics_host, metrics_port)) = self.resolved_metrics_host_port() {
for listener in self.resolved_listeners() {
if listener.listen_port == metrics_port
&& addresses_conflict(&metrics_host, &listener.listen_address)
{
bail!(
"metrics_port {metrics_port} on {metrics_host} conflicts with listener port {}",
listener.listen_port
);
}
}
}
Ok(())
}
pub fn resolved_listeners(&self) -> Vec<ResolvedListener> {
if self.listeners.is_empty() {
vec![ResolvedListener {
name: None,
listen_address: self.listen_address.trim().to_string(),
listen_port: self.listen_port,
max_connections: self.max_connections,
expected_chain_id: self
.chain_id
.as_deref()
.and_then(|c| parse_chain_id(c, None).ok()),
servers: self.servers.clone(),
}]
} else {
let default_addr = {
let top = self.listen_address.trim();
if top.is_empty() {
"0.0.0.0".to_string()
} else {
top.to_string()
}
};
self.listeners
.iter()
.map(|l| ResolvedListener {
name: Some(l.name.trim().to_string()),
listen_address: l
.listen_address
.as_deref()
.map(str::trim)
.unwrap_or(&default_addr)
.to_string(),
listen_port: l.listen_port,
max_connections: l.max_connections.unwrap_or(self.max_connections),
expected_chain_id: l
.chain_id
.as_deref()
.and_then(|c| parse_chain_id(c, Some(&l.name)).ok()),
servers: l.servers.clone(),
})
.collect()
}
}
pub fn resolved_metrics_host_port(&self) -> Option<(String, u16)> {
let port = self.metrics_port?;
let host = self
.metrics_address
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.map(ToOwned::to_owned)
.unwrap_or_else(|| {
let top = self.listen_address.trim();
if !top.is_empty() {
top.to_string()
} else if let Some(first) = self.listeners.first() {
first
.listen_address
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.unwrap_or("0.0.0.0")
.to_string()
} else {
"0.0.0.0".to_string()
}
});
Some((host, port))
}
pub fn upstream_connect_timeout_ms(&self) -> u64 {
if self.handshake_timeout_ms == 0 {
DEFAULT_UPSTREAM_CONNECT_TIMEOUT_MS
} else {
self.handshake_timeout_ms
}
}
pub fn proxy_limits(&self) -> ProxyLimits {
ProxyLimits {
handshake_timeout_ms: self.handshake_timeout_ms,
idle_timeout_ms: self.idle_timeout_ms,
max_message_bytes: self.max_message_bytes,
max_client_message_bytes: self.max_client_message_bytes,
}
}
}
#[derive(Debug)]
pub struct ServerState {
pub connections: Arc<AtomicUsize>,
pub enabled: bool,
pub online: bool,
pub stale: bool,
pub expected_chain_id: Option<[u8; 32]>,
pub chain_id: Option<[u8; 32]>,
pub trace_begin_block: u32,
pub trace_end_block: u32,
pub chain_state_begin_block: u32,
pub chain_state_end_block: u32,
pub finality_data_begin_block: u32,
pub finality_data_end_block: u32,
}
impl ServerState {
pub fn new() -> ServerState {
Self::with_expected_chain_id(None)
}
pub fn with_expected_chain_id(expected_chain_id: Option<[u8; 32]>) -> ServerState {
ServerState {
connections: Arc::new(AtomicUsize::new(0)),
enabled: true,
online: false,
stale: false,
expected_chain_id,
chain_id: None,
trace_begin_block: 0,
trace_end_block: 0,
chain_state_begin_block: 0,
chain_state_end_block: 0,
finality_data_begin_block: 0,
finality_data_end_block: 0,
}
}
}
impl Default for ServerState {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone)]
pub struct StaticConfig {
pub upstream_reconnect_ms: u64,
pub upstream_monitoring_ms: u64,
pub upstream_status_ms: u64,
pub connect_timeout_ms: u64,
pub max_message_bytes: usize,
}
#[derive(Debug, Clone, Copy)]
pub struct ProxyLimits {
pub handshake_timeout_ms: u64,
pub idle_timeout_ms: u64,
pub max_message_bytes: usize,
pub max_client_message_bytes: usize,
}
#[cfg(test)]
pub fn build_state_db(servers: Vec<Server>) -> ServerStateDb {
build_state_db_with_chain_id(servers, None)
}
pub fn build_state_db_with_chain_id(
servers: Vec<Server>,
expected_chain_id: Option<[u8; 32]>,
) -> ServerStateDb {
Arc::new(Mutex::new(
servers
.iter()
.filter(|s| s.enabled)
.map(|s| {
(
s.endpoint.clone(),
ServerState::with_expected_chain_id(expected_chain_id),
)
})
.collect::<HashMap<String, ServerState>>(),
))
}
pub fn build_config_db(servers: Vec<Server>) -> ServerConfigDb {
Arc::new(Mutex::new(
servers
.into_iter()
.filter(|s| s.enabled)
.map(|s| (s.endpoint.clone(), s))
.collect::<HashMap<String, Server>>(),
))
}
#[cfg(test)]
mod tests {
use super::*;
fn minimal_config() -> serde_json::Value {
serde_json::json!({
"listen_address": "127.0.0.1",
"listen_port": 17000,
"upstream_reconnect_ms": 1000,
"upstream_monitoring_ms": 1000,
"upstream_status_ms": 1000,
"servers": [
{ "name": "a", "endpoint": "127.0.0.1:18080", "enabled": true }
]
})
}
#[test]
fn legacy_config_defaults_the_client_message_cap() {
let cfg: ServerConfig = serde_json::from_value(minimal_config()).unwrap();
assert_eq!(cfg.max_message_bytes, 256 * 1024 * 1024);
assert_eq!(cfg.max_client_message_bytes, 64 * 1024);
cfg.validate().unwrap();
let limits = cfg.proxy_limits();
assert_eq!(limits.max_message_bytes, cfg.max_message_bytes);
assert_eq!(limits.max_client_message_bytes, 64 * 1024);
}
#[test]
fn example_config_without_client_cap_stays_valid() {
let cfg: ServerConfig =
serde_json::from_str(include_str!("../example.config.json")).unwrap();
assert_eq!(cfg.max_client_message_bytes, 64 * 1024);
cfg.validate().unwrap();
}
#[test]
fn custom_client_message_cap_is_honored() {
let mut value = minimal_config();
value["max_client_message_bytes"] = serde_json::json!(4096);
let cfg: ServerConfig = serde_json::from_value(value).unwrap();
assert_eq!(cfg.max_client_message_bytes, 4096);
cfg.validate().unwrap();
assert_eq!(cfg.proxy_limits().max_client_message_bytes, 4096);
}
#[test]
fn zero_client_message_cap_is_rejected() {
let mut value = minimal_config();
value["max_client_message_bytes"] = serde_json::json!(0);
let cfg: ServerConfig = serde_json::from_value(value).unwrap();
let err = cfg.validate().unwrap_err().to_string();
assert!(
err.contains("max_client_message_bytes"),
"unexpected error: {err}"
);
}
fn config_with_servers(servers: serde_json::Value) -> ServerConfig {
let mut value = minimal_config();
value["servers"] = servers;
serde_json::from_value(value).unwrap()
}
#[test]
fn bare_endpoint_dials_plaintext_ws() {
let cfg = config_with_servers(serde_json::json!([
{ "name": "a", "endpoint": " 127.0.0.1:18080 ", "enabled": true }
]));
cfg.validate().unwrap();
assert_eq!(cfg.servers[0].ws_url(), "ws://127.0.0.1:18080");
}
#[test]
fn explicit_ws_and_wss_schemes_are_preserved() {
let cfg = config_with_servers(serde_json::json!([
{ "name": "plain", "endpoint": "WS://ship.example:18080/v1", "enabled": true },
{ "name": "tls", "endpoint": "wss://ship.example:443", "enabled": true }
]));
cfg.validate().unwrap();
assert_eq!(cfg.servers[0].ws_url(), "ws://ship.example:18080/v1");
assert_eq!(cfg.servers[1].ws_url(), "wss://ship.example:443");
}
#[test]
fn ipv6_wss_endpoint_keeps_brackets() {
let cfg = config_with_servers(serde_json::json!([
{ "name": "a", "endpoint": "wss://[::1]:8443", "enabled": true }
]));
cfg.validate().unwrap();
assert_eq!(cfg.servers[0].ws_url(), "wss://[::1]:8443");
}
#[test]
fn unknown_and_malformed_schemes_are_rejected() {
let https = config_with_servers(serde_json::json!([
{ "name": "a", "endpoint": "https://127.0.0.1:443", "enabled": true }
]));
let err = https.validate().unwrap_err().to_string();
assert!(
err.contains("unsupported scheme 'https'"),
"unexpected error: {err}"
);
let disabled = config_with_servers(serde_json::json!([
{ "name": "ok", "endpoint": "127.0.0.1:1", "enabled": true },
{ "name": "bad", "endpoint": "http://127.0.0.1:2", "enabled": false }
]));
let err = disabled.validate().unwrap_err().to_string();
assert!(
err.contains("unsupported scheme 'http'"),
"unexpected error: {err}"
);
let malformed = config_with_servers(serde_json::json!([
{ "name": "a", "endpoint": "://127.0.0.1:1", "enabled": true }
]));
let err = malformed.validate().unwrap_err().to_string();
assert!(err.contains("malformed scheme"), "unexpected error: {err}");
}
#[test]
fn endpoint_uniqueness_ignores_ws_spelling_and_default_port() {
let spelled = config_with_servers(serde_json::json!([
{ "name": "a", "endpoint": "127.0.0.1:18080", "enabled": true },
{ "name": "b", "endpoint": "ws://127.0.0.1:18080", "enabled": true }
]));
let err = spelled.validate().unwrap_err().to_string();
assert!(
err.contains("duplicate upstream endpoint"),
"unexpected error: {err}"
);
assert!(err.contains("127.0.0.1:18080"), "unexpected error: {err}");
let ports = config_with_servers(serde_json::json!([
{ "name": "a", "endpoint": "ws://ship.example", "enabled": true },
{ "name": "b", "endpoint": "ws://SHIP.example:80", "enabled": true }
]));
assert!(ports.validate().is_err());
let tls_is_distinct = config_with_servers(serde_json::json!([
{ "name": "plain", "endpoint": "ws://127.0.0.1:18080", "enabled": true },
{ "name": "tls", "endpoint": "wss://127.0.0.1:18080", "enabled": true }
]));
tls_is_distinct.validate().unwrap();
let wss_default_port = config_with_servers(serde_json::json!([
{ "name": "a", "endpoint": "wss://ship.example", "enabled": true },
{ "name": "b", "endpoint": "wss://ship.example:443", "enabled": true }
]));
assert!(wss_default_port.validate().is_err());
}
#[test]
fn multi_listener_config_resolves_defaults_and_overrides() {
let cfg: ServerConfig = serde_json::from_value(serde_json::json!({
"listen_address": "127.0.0.1",
"upstream_reconnect_ms": 1000,
"upstream_monitoring_ms": 1000,
"upstream_status_ms": 1000,
"max_connections": 2000,
"metrics_port": 19090,
"listeners": [
{
"name": "eos",
"listen_port": 17000,
"servers": [
{ "name": "eos-1", "endpoint": "127.0.0.1:18080", "enabled": true },
{ "name": "eos-2", "endpoint": "127.0.0.1:18081", "enabled": true }
]
},
{
"name": "wax",
"listen_address": "0.0.0.0",
"listen_port": 17001,
"max_connections": 500,
"servers": [
{ "name": "wax-1", "endpoint": "127.0.0.1:28080", "enabled": true }
]
}
]
}))
.unwrap();
cfg.validate().unwrap();
let resolved = cfg.resolved_listeners();
assert_eq!(resolved.len(), 2);
assert_eq!(resolved[0].name.as_deref(), Some("eos"));
assert_eq!(resolved[0].listen_address, "127.0.0.1");
assert_eq!(resolved[0].listen_port, 17000);
assert_eq!(resolved[0].max_connections, 2000);
assert_eq!(resolved[0].servers.len(), 2);
assert_eq!(resolved[1].name.as_deref(), Some("wax"));
assert_eq!(resolved[1].listen_address, "0.0.0.0");
assert_eq!(resolved[1].listen_port, 17001);
assert_eq!(resolved[1].max_connections, 500);
assert_eq!(resolved[1].servers.len(), 1);
assert_eq!(
cfg.resolved_metrics_host_port(),
Some(("127.0.0.1".to_string(), 19090))
);
}
#[test]
fn multi_listener_rejects_mixed_servers_and_listeners() {
let mut value = minimal_config();
value["listeners"] = serde_json::json!([
{
"name": "eos",
"listen_port": 17001,
"servers": [
{ "name": "a", "endpoint": "127.0.0.1:18081", "enabled": true }
]
}
]);
let cfg: ServerConfig = serde_json::from_value(value).unwrap();
let err = cfg.validate().unwrap_err().to_string();
assert!(
err.contains("cannot specify both top-level 'servers' and 'listeners'"),
"unexpected error: {err}"
);
}
#[test]
fn multi_listener_rejects_duplicate_names_and_conflicting_ports() {
let dup_names: ServerConfig = serde_json::from_value(serde_json::json!({
"upstream_reconnect_ms": 1000,
"upstream_monitoring_ms": 1000,
"upstream_status_ms": 1000,
"listeners": [
{
"name": "eos",
"listen_port": 17000,
"servers": [{ "name": "a", "endpoint": "127.0.0.1:18080", "enabled": true }]
},
{
"name": "eos",
"listen_port": 17001,
"servers": [{ "name": "b", "endpoint": "127.0.0.1:18081", "enabled": true }]
}
]
}))
.unwrap();
let err = dup_names.validate().unwrap_err().to_string();
assert!(
err.contains("duplicate listener name 'eos'"),
"unexpected error: {err}"
);
let dup_ports: ServerConfig = serde_json::from_value(serde_json::json!({
"upstream_reconnect_ms": 1000,
"upstream_monitoring_ms": 1000,
"upstream_status_ms": 1000,
"listeners": [
{
"name": "eos",
"listen_address": "127.0.0.1",
"listen_port": 17000,
"servers": [{ "name": "a", "endpoint": "127.0.0.1:18080", "enabled": true }]
},
{
"name": "wax",
"listen_address": "0.0.0.0",
"listen_port": 17000,
"servers": [{ "name": "b", "endpoint": "127.0.0.1:18081", "enabled": true }]
}
]
}))
.unwrap();
let err = dup_ports.validate().unwrap_err().to_string();
assert!(
err.contains("conflicts with listener 'eos'"),
"unexpected error: {err}"
);
}
#[test]
fn multi_listener_rejects_invalid_names_and_metrics_port_collision() {
let slash_name: ServerConfig = serde_json::from_value(serde_json::json!({
"upstream_reconnect_ms": 1000,
"upstream_monitoring_ms": 1000,
"upstream_status_ms": 1000,
"listeners": [
{
"name": "eos/main",
"listen_port": 17000,
"servers": [{ "name": "a", "endpoint": "127.0.0.1:18080", "enabled": true }]
}
]
}))
.unwrap();
let err = slash_name.validate().unwrap_err().to_string();
assert!(
err.contains("name must not contain '/' or whitespace"),
"unexpected error: {err}"
);
let metrics_collision: ServerConfig = serde_json::from_value(serde_json::json!({
"upstream_reconnect_ms": 1000,
"upstream_monitoring_ms": 1000,
"upstream_status_ms": 1000,
"metrics_port": 17000,
"listeners": [
{
"name": "eos",
"listen_port": 17000,
"servers": [{ "name": "a", "endpoint": "127.0.0.1:18080", "enabled": true }]
}
]
}))
.unwrap();
let err = metrics_collision.validate().unwrap_err().to_string();
assert!(
err.contains("metrics_port 17000"),
"unexpected error: {err}"
);
}
#[test]
fn ipv6_bind_formatting_and_conflict_detection() {
assert_eq!(format_bind_addr("::", 17000), "[::]:17000");
assert_eq!(format_bind_addr("::1", 17000), "[::1]:17000");
assert_eq!(format_bind_addr("[::1]", 17000), "[::1]:17000");
assert_eq!(format_bind_addr("127.0.0.1", 17000), "127.0.0.1:17000");
let ipv6_conflict: ServerConfig = serde_json::from_value(serde_json::json!({
"upstream_reconnect_ms": 1000,
"upstream_monitoring_ms": 1000,
"upstream_status_ms": 1000,
"listeners": [
{
"name": "eos",
"listen_address": "::1",
"listen_port": 17000,
"servers": [{ "name": "a", "endpoint": "127.0.0.1:18080", "enabled": true }]
},
{
"name": "wax",
"listen_address": "[::1]",
"listen_port": 17000,
"servers": [{ "name": "b", "endpoint": "127.0.0.1:18081", "enabled": true }]
}
]
}))
.unwrap();
let err = ipv6_conflict.validate().unwrap_err().to_string();
assert!(
err.contains("conflicts with listener 'eos'"),
"unexpected error: {err}"
);
}
#[test]
fn chain_id_validation_accepts_64_hex_and_rejects_invalid() {
let valid_cid = "aca376f206b8fc25a6ed44dbdc66547c36c6c33e3a119ffbeaef943642f0e906";
let cfg: ServerConfig = serde_json::from_value(serde_json::json!({
"upstream_reconnect_ms": 1000,
"upstream_monitoring_ms": 1000,
"upstream_status_ms": 1000,
"listeners": [
{
"name": "eos",
"listen_port": 17000,
"chain_id": valid_cid,
"servers": [{ "name": "a", "endpoint": "127.0.0.1:18080", "enabled": true }]
}
]
}))
.unwrap();
cfg.validate().expect("valid 64-hex chain_id should pass");
assert!(cfg.resolved_listeners()[0].expected_chain_id.is_some());
let bad_cid: ServerConfig = serde_json::from_value(serde_json::json!({
"listen_address": "127.0.0.1",
"upstream_reconnect_ms": 1000,
"upstream_monitoring_ms": 1000,
"upstream_status_ms": 1000,
"chain_id": "1234",
"listen_port": 17000,
"servers": [{ "name": "a", "endpoint": "127.0.0.1:18080", "enabled": true }]
}))
.unwrap();
let err = bad_cid.validate().unwrap_err().to_string();
assert!(
err.contains("must be a 64-character hex string"),
"unexpected error: {err}"
);
}
}