use std::collections::HashMap;
use std::net::{Ipv4Addr, SocketAddr};
use std::time::Duration;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum ConfigError {
#[error(transparent)]
AddrParse(#[from] std::net::AddrParseError),
#[error(transparent)]
ParseInt(#[from] std::num::ParseIntError),
#[error("required environment variable {0} is not set")]
MissingEnv(&'static str),
#[error("invalid boolean for {key}: {value}")]
InvalidBool {
key: &'static str,
value: String,
},
#[error("failed to read forwarding secret from {path}")]
SecretFileRead {
path: String,
#[source]
source: std::io::Error,
},
#[error("forwarding secret must not be empty")]
MissingForwardingSecret,
#[error("invalid compression level {0}: must be 0-12")]
InvalidCompressionLevel(i32),
#[error("{0} must be greater than zero")]
InvalidConnectionLimit(&'static str),
}
#[derive(Debug, Clone)]
pub struct Config {
pub listen_addr: SocketAddr,
#[cfg(feature = "grpc")]
pub grpc_addr: SocketAddr,
#[cfg(feature = "grpc")]
pub grpc_auth_token: Option<String>,
pub online_mode: bool,
pub forwarding_secret: Vec<u8>,
pub compression_threshold: i32,
pub compression_level: i32,
pub motd: String,
pub max_players: i32,
pub read_timeout: Duration,
pub connect_timeout: Duration,
pub max_connections: u32,
pub max_connections_per_ip: u32,
pub try_servers: Vec<String>,
pub forced_hosts: HashMap<String, Vec<String>>,
pub log_level: String,
pub log_json: bool,
pub shutdown_drain: Duration,
#[cfg(feature = "metrics")]
pub metrics_addr: SocketAddr,
}
impl Config {
pub fn from_env() -> Result<Self, ConfigError> {
let listen_addr = env_or("DEEPSLATE_ADDR", "0.0.0.0:25565").parse()?;
#[cfg(feature = "grpc")]
let grpc_addr = env_or("DEEPSLATE_GRPC_ADDR", "127.0.0.1:25577").parse()?;
#[cfg(feature = "grpc")]
let grpc_auth_token = grpc_auth_token_from_env()?;
let online_mode = env_bool("DEEPSLATE_ONLINE_MODE", true)?;
let forwarding_secret = forwarding_secret_from_env()?;
let compression_threshold = env_or("DEEPSLATE_COMPRESSION_THRESHOLD", "256").parse()?;
let compression_level: i32 = env_or("DEEPSLATE_COMPRESSION_LEVEL", "1").parse()?;
let motd = env_or("DEEPSLATE_MOTD", "A Deepslate Proxy");
let max_players = env_or("DEEPSLATE_MAX_PLAYERS", "500").parse()?;
let read_timeout_ms: u64 = env_or("DEEPSLATE_READ_TIMEOUT_MS", "30000").parse()?;
let connect_timeout_ms: u64 = env_or("DEEPSLATE_CONNECT_TIMEOUT_MS", "5000").parse()?;
let max_connections = env_or("DEEPSLATE_MAX_CONNECTIONS", "10000").parse()?;
let max_connections_per_ip = env_or("DEEPSLATE_MAX_CONNECTIONS_PER_IP", "3").parse()?;
let try_servers = env_or("DEEPSLATE_TRY_SERVERS", "")
.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(String::from)
.collect();
let forced_hosts = parse_forced_hosts(&env_or("DEEPSLATE_FORCED_HOSTS", ""));
let log_level = env_or("DEEPSLATE_LOG_LEVEL", "info");
let log_json = env_bool("DEEPSLATE_LOG_JSON", false)?;
let shutdown_drain_ms: u64 = env_or("DEEPSLATE_SHUTDOWN_DRAIN_MS", "10000").parse()?;
#[cfg(feature = "metrics")]
let metrics_addr = env_or("DEEPSLATE_METRICS_ADDR", "127.0.0.1:9100").parse()?;
Self {
listen_addr,
#[cfg(feature = "grpc")]
grpc_addr,
#[cfg(feature = "grpc")]
grpc_auth_token,
online_mode,
forwarding_secret,
compression_threshold,
compression_level,
motd,
max_players,
read_timeout: Duration::from_millis(read_timeout_ms),
connect_timeout: Duration::from_millis(connect_timeout_ms),
max_connections,
max_connections_per_ip,
try_servers,
forced_hosts,
log_level,
log_json,
shutdown_drain: Duration::from_millis(shutdown_drain_ms),
#[cfg(feature = "metrics")]
metrics_addr,
}
.validate()
}
pub fn validate(self) -> Result<Self, ConfigError> {
if self.forwarding_secret.is_empty() {
return Err(ConfigError::MissingForwardingSecret);
}
if !(0..=12).contains(&self.compression_level) {
return Err(ConfigError::InvalidCompressionLevel(self.compression_level));
}
if self.max_connections == 0 {
return Err(ConfigError::InvalidConnectionLimit(
"DEEPSLATE_MAX_CONNECTIONS",
));
}
if self.max_connections_per_ip == 0 {
return Err(ConfigError::InvalidConnectionLimit(
"DEEPSLATE_MAX_CONNECTIONS_PER_IP",
));
}
Ok(self)
}
}
impl Default for Config {
fn default() -> Self {
Self {
listen_addr: SocketAddr::from((Ipv4Addr::UNSPECIFIED, 25_565)),
#[cfg(feature = "grpc")]
grpc_addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 25_577)),
#[cfg(feature = "grpc")]
grpc_auth_token: None,
online_mode: true,
forwarding_secret: Vec::new(),
compression_threshold: 256,
compression_level: 1,
motd: "A Deepslate Proxy".to_string(),
max_players: 500,
read_timeout: Duration::from_secs(30),
connect_timeout: Duration::from_secs(5),
max_connections: 10_000,
max_connections_per_ip: 3,
try_servers: vec![],
forced_hosts: HashMap::new(),
log_level: "info".to_string(),
log_json: false,
shutdown_drain: Duration::from_secs(10),
#[cfg(feature = "metrics")]
metrics_addr: SocketAddr::from((Ipv4Addr::LOCALHOST, 9100)),
}
}
}
fn env_or(key: &str, default: &str) -> String {
std::env::var(key).unwrap_or_else(|_| default.to_string())
}
fn env_required(key: &'static str) -> Result<String, ConfigError> {
std::env::var(key).map_err(|_| ConfigError::MissingEnv(key))
}
fn env_bool(key: &'static str, default: bool) -> Result<bool, ConfigError> {
std::env::var(key).map_or_else(
|_| Ok(default),
|val| match val.to_lowercase().as_str() {
"true" | "1" => Ok(true),
"false" | "0" => Ok(false),
_ => Err(ConfigError::InvalidBool { key, value: val }),
},
)
}
fn parse_forced_hosts(raw: &str) -> HashMap<String, Vec<String>> {
let mut map = HashMap::new();
for entry in raw.split(';') {
let entry = entry.trim();
if entry.is_empty() {
continue;
}
let Some((host, servers_raw)) = entry.split_once('=') else {
continue;
};
let host = host.trim().to_lowercase();
if host.is_empty() {
continue;
}
let servers: Vec<String> = servers_raw
.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(String::from)
.collect();
if !servers.is_empty() {
map.insert(host, servers);
}
}
map
}
fn forwarding_secret_from_env() -> Result<Vec<u8>, ConfigError> {
if let Ok(path) = std::env::var("DEEPSLATE_FORWARDING_SECRET_FILE") {
let path = path.trim().to_owned();
let contents =
std::fs::read(&path).map_err(|source| ConfigError::SecretFileRead { path, source })?;
Ok(contents
.strip_suffix(b"\r\n")
.or_else(|| contents.strip_suffix(b"\n"))
.unwrap_or(&contents)
.to_vec())
} else {
env_required("DEEPSLATE_FORWARDING_SECRET").map(String::into_bytes)
}
}
#[cfg(feature = "grpc")]
fn grpc_auth_token_from_env() -> Result<Option<String>, ConfigError> {
if let Ok(path) = std::env::var("DEEPSLATE_GRPC_AUTH_TOKEN_FILE") {
let path = path.trim().to_owned();
let contents =
std::fs::read(&path).map_err(|source| ConfigError::SecretFileRead { path, source })?;
let token = contents
.strip_suffix(b"\r\n")
.or_else(|| contents.strip_suffix(b"\n"))
.unwrap_or(&contents);
let token = String::from_utf8_lossy(token).into_owned();
if token.is_empty() {
Ok(None)
} else {
Ok(Some(token))
}
} else if let Ok(val) = std::env::var("DEEPSLATE_GRPC_AUTH_TOKEN") {
let val = val.trim().to_owned();
if val.is_empty() {
Ok(None)
} else {
Ok(Some(val))
}
} else {
Ok(None)
}
}
#[cfg(test)]
mod tests {
use super::*;
const FILE_VAR: &str = "DEEPSLATE_FORWARDING_SECRET_FILE";
const ENV_VAR: &str = "DEEPSLATE_FORWARDING_SECRET";
#[test]
fn secret_from_env_var() {
temp_env::with_vars([(ENV_VAR, Some("my-secret")), (FILE_VAR, None)], || {
let secret = forwarding_secret_from_env().unwrap();
assert_eq!(secret, b"my-secret");
});
}
#[test]
fn secret_from_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("secret");
std::fs::write(&path, b"file-secret").unwrap();
temp_env::with_vars(
[
(FILE_VAR, Some(path.to_str().unwrap())),
(ENV_VAR, None::<&str>),
],
|| {
let secret = forwarding_secret_from_env().unwrap();
assert_eq!(secret, b"file-secret");
},
);
}
#[test]
fn secret_file_trims_trailing_lf() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("secret");
std::fs::write(&path, b"file-secret\n").unwrap();
temp_env::with_vars(
[
(FILE_VAR, Some(path.to_str().unwrap())),
(ENV_VAR, None::<&str>),
],
|| {
let secret = forwarding_secret_from_env().unwrap();
assert_eq!(secret, b"file-secret");
},
);
}
#[test]
fn secret_file_trims_trailing_crlf() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("secret");
std::fs::write(&path, b"file-secret\r\n").unwrap();
temp_env::with_vars(
[
(FILE_VAR, Some(path.to_str().unwrap())),
(ENV_VAR, None::<&str>),
],
|| {
let secret = forwarding_secret_from_env().unwrap();
assert_eq!(secret, b"file-secret");
},
);
}
#[test]
fn secret_file_takes_precedence() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("secret");
std::fs::write(&path, b"from-file").unwrap();
temp_env::with_vars(
[
(FILE_VAR, Some(path.to_str().unwrap())),
(ENV_VAR, Some("from-env")),
],
|| {
let secret = forwarding_secret_from_env().unwrap();
assert_eq!(secret, b"from-file");
},
);
}
#[test]
fn secret_file_missing_returns_error() {
temp_env::with_vars(
[(FILE_VAR, Some("/no/such/file")), (ENV_VAR, None::<&str>)],
|| {
let err = forwarding_secret_from_env().unwrap_err();
assert!(
matches!(err, ConfigError::SecretFileRead { .. }),
"expected SecretFileRead, got {err:?}"
);
},
);
}
#[test]
fn secret_neither_set_returns_error() {
temp_env::with_vars([(ENV_VAR, None::<&str>), (FILE_VAR, None::<&str>)], || {
let err = forwarding_secret_from_env().unwrap_err();
assert!(
matches!(err, ConfigError::MissingEnv(_)),
"expected MissingEnv, got {err:?}"
);
});
}
#[test]
fn parse_forced_hosts_single_entry() {
let map = parse_forced_hosts("pvp.example.com=pvp,pvp-fallback");
assert_eq!(
map.get("pvp.example.com").unwrap(),
&["pvp", "pvp-fallback"]
);
}
#[test]
fn parse_forced_hosts_multiple_entries() {
let map = parse_forced_hosts("pvp.example.com=pvp;lobby.example.com=lobby1,lobby2");
assert_eq!(map.len(), 2);
assert_eq!(map.get("pvp.example.com").unwrap(), &["pvp"]);
assert_eq!(map.get("lobby.example.com").unwrap(), &["lobby1", "lobby2"]);
}
#[test]
fn parse_forced_hosts_trims_whitespace() {
let map = parse_forced_hosts(" pvp.example.com = pvp , games ; lobby.example.com = lobby ");
assert_eq!(map.get("pvp.example.com").unwrap(), &["pvp", "games"]);
assert_eq!(map.get("lobby.example.com").unwrap(), &["lobby"]);
}
#[test]
fn parse_forced_hosts_lowercases_hostname() {
let map = parse_forced_hosts("PVP.Example.COM=pvp");
assert!(map.contains_key("pvp.example.com"));
assert!(!map.contains_key("PVP.Example.COM"));
}
#[test]
fn parse_forced_hosts_empty_string() {
let map = parse_forced_hosts("");
assert!(map.is_empty());
}
#[test]
fn parse_forced_hosts_skips_malformed() {
let map = parse_forced_hosts("no-equals;also-bad;good.host=srv");
assert_eq!(map.len(), 1);
assert_eq!(map.get("good.host").unwrap(), &["srv"]);
}
#[test]
fn parse_forced_hosts_skips_empty_server_list() {
let map = parse_forced_hosts("empty.host=;good.host=srv");
assert_eq!(map.len(), 1);
assert!(!map.contains_key("empty.host"));
}
#[test]
fn validate_rejects_zero_max_connections() {
let config = Config {
forwarding_secret: b"secret".to_vec(),
max_connections: 0,
..Config::default()
};
let err = config.validate().unwrap_err();
assert!(
matches!(err, ConfigError::InvalidConnectionLimit(_)),
"expected InvalidConnectionLimit, got {err:?}"
);
}
#[test]
fn validate_rejects_zero_max_connections_per_ip() {
let config = Config {
forwarding_secret: b"secret".to_vec(),
max_connections_per_ip: 0,
..Config::default()
};
let err = config.validate().unwrap_err();
assert!(
matches!(err, ConfigError::InvalidConnectionLimit(_)),
"expected InvalidConnectionLimit, got {err:?}"
);
}
#[test]
fn validate_accepts_valid_connection_limits() {
let config = Config {
forwarding_secret: b"secret".to_vec(),
max_connections: 5000,
max_connections_per_ip: 10,
..Config::default()
};
assert!(config.validate().is_ok());
}
}