use ferrotunnel_common::{
LimitsConfig, Result, TunnelError, DEFAULT_HTTP_PORT, DEFAULT_LOCAL_ADDR, DEFAULT_TUNNEL_PORT,
};
use ferrotunnel_core::validate_limits;
use std::fmt;
use std::net::SocketAddr;
#[cfg(feature = "http3")]
use std::path::Path;
#[cfg(feature = "http3")]
use std::path::PathBuf;
use std::time::Duration;
const REDACTED: &str = "<redacted>";
#[derive(Clone)]
pub struct ClientConfig {
pub(crate) server_addr: String,
pub(crate) token: String,
pub(crate) local_addr: String,
pub(crate) tunnel_id: Option<String>,
pub(crate) auto_reconnect: bool,
pub(crate) reconnect_delay: Duration,
pub(crate) startup_timeout: Option<Duration>,
pub(crate) limits: LimitsConfig,
}
impl ClientConfig {
pub fn validate(&self) -> Result<()> {
if self.server_addr.is_empty() {
return Err(TunnelError::Config("server_addr is required".into()));
}
if self.token.is_empty() {
return Err(TunnelError::Config("token is required".into()));
}
if self.local_addr.is_empty() {
return Err(TunnelError::Config("local_addr is required".into()));
}
validate_limits(&self.limits)?;
Ok(())
}
pub fn server_addr(&self) -> &str {
&self.server_addr
}
pub fn token(&self) -> &str {
&self.token
}
pub fn local_addr(&self) -> &str {
&self.local_addr
}
pub fn tunnel_id(&self) -> Option<&str> {
self.tunnel_id.as_deref()
}
pub fn auto_reconnect(&self) -> bool {
self.auto_reconnect
}
pub fn reconnect_delay(&self) -> Duration {
self.reconnect_delay
}
pub fn startup_timeout(&self) -> Option<Duration> {
self.startup_timeout
}
pub fn limits(&self) -> &LimitsConfig {
&self.limits
}
}
impl fmt::Debug for ClientConfig {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ClientConfig")
.field("server_addr", &self.server_addr)
.field("token", &REDACTED)
.field("local_addr", &self.local_addr)
.field("tunnel_id", &self.tunnel_id)
.field("auto_reconnect", &self.auto_reconnect)
.field("reconnect_delay", &self.reconnect_delay)
.field("startup_timeout", &self.startup_timeout)
.field("limits", &self.limits)
.finish()
}
}
impl Default for ClientConfig {
fn default() -> Self {
Self {
server_addr: String::new(),
token: String::new(),
local_addr: DEFAULT_LOCAL_ADDR.to_string(),
tunnel_id: None,
auto_reconnect: true,
reconnect_delay: Duration::from_secs(5),
startup_timeout: Some(Duration::from_secs(30)),
limits: LimitsConfig::default(),
}
}
}
#[derive(Clone)]
pub struct ServerConfig {
pub(crate) bind_addr: SocketAddr,
pub(crate) http_bind_addr: SocketAddr,
#[cfg(feature = "http3")]
pub(crate) http3_bind_addr: Option<SocketAddr>,
#[cfg(feature = "http3")]
pub(crate) http3_cert_path: Option<PathBuf>,
#[cfg(feature = "http3")]
pub(crate) http3_key_path: Option<PathBuf>,
pub(crate) token: String,
pub(crate) limits: LimitsConfig,
}
impl ServerConfig {
pub fn validate(&self) -> Result<()> {
if self.token.is_empty() {
return Err(TunnelError::Config("token is required".into()));
}
validate_limits(&self.limits)?;
#[cfg(feature = "http3")]
if self.http3_bind_addr.is_some()
&& (self.http3_cert_path.is_none() || self.http3_key_path.is_none())
{
return Err(TunnelError::Config(
"HTTP/3 ingress requires certificate and key paths".into(),
));
}
Ok(())
}
pub fn bind_addr(&self) -> SocketAddr {
self.bind_addr
}
pub fn http_bind_addr(&self) -> SocketAddr {
self.http_bind_addr
}
#[cfg(feature = "http3")]
pub fn http3_bind_addr(&self) -> Option<SocketAddr> {
self.http3_bind_addr
}
#[cfg(feature = "http3")]
pub fn http3_cert_path(&self) -> Option<&Path> {
self.http3_cert_path.as_deref()
}
#[cfg(feature = "http3")]
pub fn http3_key_path(&self) -> Option<&Path> {
self.http3_key_path.as_deref()
}
pub fn token(&self) -> &str {
&self.token
}
pub fn limits(&self) -> &LimitsConfig {
&self.limits
}
}
impl fmt::Debug for ServerConfig {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let mut debug = f.debug_struct("ServerConfig");
debug
.field("bind_addr", &self.bind_addr)
.field("http_bind_addr", &self.http_bind_addr);
#[cfg(feature = "http3")]
debug
.field("http3_bind_addr", &self.http3_bind_addr)
.field("http3_cert_path", &self.http3_cert_path)
.field("http3_key_path", &self.http3_key_path);
debug
.field("token", &REDACTED)
.field("limits", &self.limits)
.finish()
}
}
impl Default for ServerConfig {
fn default() -> Self {
Self {
bind_addr: ([0, 0, 0, 0], DEFAULT_TUNNEL_PORT).into(),
http_bind_addr: ([0, 0, 0, 0], DEFAULT_HTTP_PORT).into(),
#[cfg(feature = "http3")]
http3_bind_addr: None,
#[cfg(feature = "http3")]
http3_cert_path: None,
#[cfg(feature = "http3")]
http3_key_path: None,
token: String::new(),
limits: LimitsConfig::default(),
}
}
}
#[derive(Debug, Clone)]
pub struct TunnelInfo {
pub(crate) session_id: Option<uuid::Uuid>,
pub(crate) public_url: Option<String>,
}
impl TunnelInfo {
pub fn session_id(&self) -> Option<uuid::Uuid> {
self.session_id
}
pub fn public_url(&self) -> Option<&str> {
self.public_url.as_deref()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_client_config_default() {
let config = ClientConfig::default();
assert!(config.server_addr.is_empty());
assert!(config.token.is_empty());
assert_eq!(config.local_addr, "127.0.0.1:8080");
assert!(config.auto_reconnect);
assert_eq!(config.reconnect_delay, Duration::from_secs(5));
assert_eq!(config.startup_timeout, Some(Duration::from_secs(30)));
assert_eq!(config.limits.max_frame_bytes, 16 * 1024 * 1024);
}
#[test]
fn test_client_config_validate_success() {
let config = ClientConfig {
server_addr: "localhost:7835".to_string(),
token: "secret-token".to_string(),
local_addr: "127.0.0.1:8080".to_string(),
..Default::default()
};
assert!(config.validate().is_ok());
}
#[test]
fn test_client_config_validate_rejects_zero_frame_limit() {
let mut config = ClientConfig {
server_addr: "localhost:7835".to_string(),
token: "secret-token".to_string(),
local_addr: "127.0.0.1:8080".to_string(),
..Default::default()
};
config.limits.max_frame_bytes = 0;
let err = config.validate().unwrap_err();
assert!(err.to_string().contains("max_frame_bytes"));
}
#[test]
fn test_client_config_validate_rejects_oversized_frame_limit() {
let mut config = ClientConfig {
server_addr: "localhost:7835".to_string(),
token: "secret-token".to_string(),
local_addr: "127.0.0.1:8080".to_string(),
..Default::default()
};
config.limits.max_frame_bytes = 17 * 1024 * 1024;
let err = config.validate().unwrap_err();
assert!(err.to_string().contains("max_frame_bytes"));
}
#[test]
fn test_client_config_validate_missing_server_addr() {
let config = ClientConfig {
server_addr: String::new(),
token: "secret".to_string(),
local_addr: "127.0.0.1:8080".to_string(),
..Default::default()
};
let err = config.validate().unwrap_err();
assert!(err.to_string().contains("server_addr"));
}
#[test]
fn test_client_config_validate_missing_token() {
let config = ClientConfig {
server_addr: "localhost:7835".to_string(),
token: String::new(),
local_addr: "127.0.0.1:8080".to_string(),
..Default::default()
};
let err = config.validate().unwrap_err();
assert!(err.to_string().contains("token"));
}
#[test]
fn test_client_config_validate_missing_local_addr() {
let config = ClientConfig {
server_addr: "localhost:7835".to_string(),
token: "secret".to_string(),
local_addr: String::new(),
..Default::default()
};
let err = config.validate().unwrap_err();
assert!(err.to_string().contains("local_addr"));
}
#[test]
fn test_client_config_getters() {
let config = ClientConfig {
server_addr: "localhost:7835".to_string(),
token: "secret".to_string(),
local_addr: "127.0.0.1:8080".to_string(),
tunnel_id: Some("my-tunnel".to_string()),
..Default::default()
};
assert_eq!(config.server_addr(), "localhost:7835");
assert_eq!(config.token(), "secret");
assert_eq!(config.local_addr(), "127.0.0.1:8080");
assert_eq!(config.tunnel_id(), Some("my-tunnel"));
assert!(config.auto_reconnect());
assert_eq!(config.reconnect_delay(), Duration::from_secs(5));
assert_eq!(config.startup_timeout(), Some(Duration::from_secs(30)));
assert_eq!(config.limits().max_frame_bytes, 16 * 1024 * 1024);
}
#[test]
fn test_client_config_debug_redacts_token() {
let config = ClientConfig {
server_addr: "localhost:7835".to_string(),
token: "super-secret-token".to_string(),
..Default::default()
};
let rendered = format!("{config:?}");
assert!(!rendered.contains("super-secret-token"));
assert!(rendered.contains("<redacted>"));
}
#[test]
fn test_server_config_default() {
let config = ServerConfig::default();
assert_eq!(config.bind_addr, SocketAddr::from(([0, 0, 0, 0], 7835)));
assert_eq!(
config.http_bind_addr,
SocketAddr::from(([0, 0, 0, 0], 8080))
);
assert!(config.token.is_empty());
assert_eq!(config.limits.max_frame_bytes, 16 * 1024 * 1024);
}
#[test]
fn test_server_config_validate_success() {
let config = ServerConfig {
token: "secret-token".to_string(),
..Default::default()
};
assert!(config.validate().is_ok());
}
#[test]
fn test_server_config_validate_rejects_zero_frame_limit() {
let mut config = ServerConfig {
token: "secret-token".to_string(),
..Default::default()
};
config.limits.max_frame_bytes = 0;
let err = config.validate().unwrap_err();
assert!(err.to_string().contains("max_frame_bytes"));
}
#[test]
fn test_server_config_validate_rejects_oversized_frame_limit() {
let mut config = ServerConfig {
token: "secret-token".to_string(),
..Default::default()
};
config.limits.max_frame_bytes = 17 * 1024 * 1024;
let err = config.validate().unwrap_err();
assert!(err.to_string().contains("max_frame_bytes"));
}
#[test]
fn test_server_config_validate_missing_token() {
let config = ServerConfig::default();
let err = config.validate().unwrap_err();
assert!(err.to_string().contains("token"));
}
#[test]
fn test_server_config_getters() {
let config = ServerConfig {
token: "secret".to_string(),
..Default::default()
};
assert_eq!(config.bind_addr(), SocketAddr::from(([0, 0, 0, 0], 7835)));
assert_eq!(
config.http_bind_addr(),
SocketAddr::from(([0, 0, 0, 0], 8080))
);
assert_eq!(config.token(), "secret");
assert_eq!(config.limits().max_frame_bytes, 16 * 1024 * 1024);
}
#[test]
fn test_server_config_debug_redacts_token() {
let config = ServerConfig {
token: "super-secret-token".to_string(),
..Default::default()
};
let rendered = format!("{config:?}");
assert!(!rendered.contains("super-secret-token"));
assert!(rendered.contains("<redacted>"));
}
#[test]
fn test_tunnel_info_none_values() {
let info = TunnelInfo {
session_id: None,
public_url: None,
};
assert!(info.session_id().is_none());
assert!(info.public_url().is_none());
}
#[test]
fn test_tunnel_info_with_values() {
let uuid = uuid::Uuid::new_v4();
let info = TunnelInfo {
session_id: Some(uuid),
public_url: Some("https://tunnel.example.com".to_string()),
};
assert_eq!(info.session_id(), Some(uuid));
assert_eq!(info.public_url(), Some("https://tunnel.example.com"));
}
}