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),
}
#[derive(Debug, Clone)]
pub struct Config {
pub listen_addr: SocketAddr,
#[cfg(feature = "grpc")]
pub grpc_addr: SocketAddr,
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_ms: u64,
pub try_servers: Vec<String>,
pub forced_hosts: HashMap<String, Vec<String>>,
pub log_level: String,
pub log_json: bool,
pub shutdown_drain: Duration,
}
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", "0.0.0.0:25577").parse()?;
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 = env_or("DEEPSLATE_READ_TIMEOUT", "30000").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()?;
Self {
listen_addr,
#[cfg(feature = "grpc")]
grpc_addr,
online_mode,
forwarding_secret,
compression_threshold,
compression_level,
motd,
max_players,
read_timeout_ms,
try_servers,
forced_hosts,
log_level,
log_json,
shutdown_drain: Duration::from_millis(shutdown_drain_ms),
}
.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));
}
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::UNSPECIFIED, 25_577)),
online_mode: true,
forwarding_secret: Vec::new(),
compression_threshold: 256,
compression_level: 1,
motd: "A Deepslate Proxy".to_string(),
max_players: 500,
read_timeout_ms: 30_000,
try_servers: vec![],
forced_hosts: HashMap::new(),
log_level: "info".to_string(),
log_json: false,
shutdown_drain: Duration::from_secs(10),
}
}
}
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(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"));
}
}