use std::collections::HashSet;
use std::fmt;
use std::fs;
use std::net::SocketAddr;
use std::path::{Path, PathBuf};
use serde::Deserialize;
use serde::de::DeserializeOwned;
use crate::config_preparation::PreparedDirectory;
use crate::config_preparation::client::{
PreparedClientAcmeConfig, PreparedClientConfig, PreparedClientServiceConfig,
PreparedClientTlsMode, PreparedClientTrust,
};
use crate::config_preparation::server::{
PreparedServerAcmeConfig, PreparedServerConfig, PreparedServerTunnelConfig,
};
use crate::server_address::ServerAddress;
use crate::tls_material::{
SERVER_CERT_FILENAME, SERVER_KEY_FILENAME, validate_server_tls_material,
};
use crate::{
CLIENT_CERT_FILENAME, CLIENT_IDENTITY_FILENAME, CLIENT_KEY_FILENAME, ClientIdentity,
SERVER_CA_FILENAME, XdgPathError, hostname::validate_public_hostname,
};
#[derive(Clone, Copy, Debug, Default, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub enum LogLevel {
Off,
Error,
Warn,
#[default]
Info,
Debug,
Trace,
}
impl LogLevel {
pub const fn is_enabled(self) -> bool {
!matches!(self, Self::Off)
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ServerSettings {
pub hostname: String,
pub log_level: LogLevel,
pub certificate: ServerCertificateSettings,
pub public_bind_address: SocketAddr,
pub tunnel_connection_bind_address: SocketAddr,
pub tunnels: Vec<ServerTunnelSettings>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ServerTunnelSettings {
pub public_hostnames: Vec<String>,
pub client_identity: ClientIdentity,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ServerCertificateSettings {
Manual {
directory: PathBuf,
},
Acme {
email: String,
state_directory: PathBuf,
state_directory_was_defaulted: bool,
},
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ClientSettings {
pub server_hostname: String,
pub server_port: u16,
pub log_level: LogLevel,
pub server_ca_file: Option<PathBuf>,
pub identity_directory: PathBuf,
pub services: Vec<ClientServiceSettings>,
pub public_cert_config: Option<ClientPublicCertConfig>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ClientPublicCertConfig {
Manual {
directory: PathBuf,
},
Acme {
email: String,
state_directory: PathBuf,
state_directory_was_defaulted: bool,
},
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ClientServiceSettings {
pub public_hostnames: Option<Vec<String>>,
pub backend_address: String,
pub tls_mode: ClientTlsMode,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub enum ClientTlsMode {
#[default]
Passthrough,
Terminate,
}
struct ValidatedRequiredPublicHostnames {
values: Vec<String>,
is_valid: bool,
}
struct ValidatedOptionalPublicHostnames {
values: Option<Vec<String>>,
valid_hostnames: Vec<String>,
is_valid: bool,
}
struct ValidatedServerTunnel {
settings: Option<ServerTunnelSettings>,
public_hostnames: Vec<String>,
client_identity: Option<ClientIdentity>,
}
struct ValidatedClientService {
settings: Option<ClientServiceSettings>,
public_hostnames: Vec<String>,
parsed_tls_mode: Option<ClientTlsMode>,
}
struct ValidatedAcmeStateDirectory {
path: PathBuf,
was_defaulted: bool,
}
#[derive(Debug)]
pub enum SettingsError {
Read {
path: PathBuf,
source: std::io::Error,
},
Parse {
path: PathBuf,
section: &'static str,
source: Box<toml::de::Error>,
},
Validation {
path: PathBuf,
section: &'static str,
messages: Vec<String>,
},
}
impl fmt::Display for SettingsError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Read { path, .. } => {
write!(formatter, "failed to read {}", path.display())
}
Self::Parse { path, section, .. } => write!(
formatter,
"failed to parse [{section}] in {}",
path.display()
),
Self::Validation {
path,
section,
messages,
} => {
write!(formatter, "invalid {section} config in {}:", path.display())?;
for message in messages {
write!(formatter, "\n- {message}")?;
}
Ok(())
}
}
}
}
impl std::error::Error for SettingsError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Read { source, .. } => Some(source),
Self::Parse { source, .. } => Some(source.as_ref()),
Self::Validation { .. } => None,
}
}
}
#[derive(Debug)]
pub enum ServerSettingsResolutionError {
XdgPath(XdgPathError),
Settings(SettingsError),
}
impl ServerSettingsResolutionError {
pub fn selected_config_path(&self) -> Option<&Path> {
match self {
Self::Settings(SettingsError::Read { path, .. })
| Self::Settings(SettingsError::Parse { path, .. })
| Self::Settings(SettingsError::Validation { path, .. }) => Some(path.as_path()),
Self::XdgPath(_) => None,
}
}
pub fn settings_error(&self) -> Option<&SettingsError> {
match self {
Self::Settings(error) => Some(error),
Self::XdgPath(_) => None,
}
}
}
impl fmt::Display for ServerSettingsResolutionError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::XdgPath(error) => write!(formatter, "{error}"),
Self::Settings(error) => write!(formatter, "{error}"),
}
}
}
impl std::error::Error for ServerSettingsResolutionError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::XdgPath(error) => Some(error),
Self::Settings(error) => Some(error),
}
}
}
pub fn load_server_settings(path: &Path) -> Result<ServerSettings, SettingsError> {
let prepared = crate::config_preparation::server::prepare_server_config_from_path(path)?;
validate_prepared_server_settings(path, prepared)
}
pub fn resolve_server_settings_from_cli(
config: Option<PathBuf>,
) -> Result<ServerSettings, ServerSettingsResolutionError> {
let config_path = crate::config_preparation::server::select_server_config_path(config)
.map_err(ServerSettingsResolutionError::XdgPath)?;
load_server_settings(&config_path).map_err(ServerSettingsResolutionError::Settings)
}
pub fn load_client_settings(path: &Path) -> Result<ClientSettings, SettingsError> {
let prepared = crate::config_preparation::client::prepare_client_config_from_path(path)?;
validate_prepared_client_settings(path, prepared)
}
pub fn resolve_server_cert_material_dir_from_config(
path: &Path,
) -> Result<Option<PathBuf>, SettingsError> {
let base_dir = config_dir(path);
let Some(section_value) = load_optional_selected_section_value(path, "server")? else {
return Ok(None);
};
let unknown_field_messages = collect_server_unknown_field_messages(§ion_value);
if !unknown_field_messages.is_empty() {
return Err(SettingsError::Validation {
path: path.to_path_buf(),
section: "server",
messages: unknown_field_messages,
});
}
let raw = deserialize_selected_section::<RawServerConfig>(path, "server", §ion_value)?;
Ok(raw.cert_dir.map(|path| resolve_path(base_dir, &path)))
}
pub fn resolve_server_hostname_from_config(path: &Path) -> Result<Option<String>, SettingsError> {
let Some(section_value) = load_optional_selected_section_value(path, "server")? else {
return Ok(None);
};
let unknown_field_messages = collect_server_unknown_field_messages(§ion_value);
if !unknown_field_messages.is_empty() {
return Err(SettingsError::Validation {
path: path.to_path_buf(),
section: "server",
messages: unknown_field_messages,
});
}
let raw = deserialize_selected_section::<RawServerConfig>(path, "server", §ion_value)?;
let mut messages = Vec::new();
let hostname = raw
.hostname
.and_then(|hostname| validate_hostname_field("server.hostname", hostname, &mut messages));
if messages.is_empty() {
Ok(hostname)
} else {
Err(SettingsError::Validation {
path: path.to_path_buf(),
section: "server",
messages,
})
}
}
pub fn resolve_client_public_cert_material_dir_from_config(
path: &Path,
) -> Result<Option<PathBuf>, SettingsError> {
let base_dir = config_dir(path);
let Some(section_value) = load_optional_selected_section_value(path, "client")? else {
return Ok(None);
};
let unknown_field_messages = collect_client_unknown_field_messages(§ion_value);
if !unknown_field_messages.is_empty() {
return Err(SettingsError::Validation {
path: path.to_path_buf(),
section: "client",
messages: unknown_field_messages,
});
}
let raw = deserialize_selected_section::<RawClientConfig>(path, "client", §ion_value)?;
Ok(raw.public_cert_dir.map(|p| resolve_path(base_dir, &p)))
}
pub fn resolve_terminating_hostnames_from_config(
path: &Path,
) -> Result<Option<Vec<String>>, SettingsError> {
let Some(section_value) = load_optional_selected_section_value(path, "client")? else {
return Ok(None);
};
let unknown_field_messages = collect_client_unknown_field_messages(§ion_value);
if !unknown_field_messages.is_empty() {
return Err(SettingsError::Validation {
path: path.to_path_buf(),
section: "client",
messages: unknown_field_messages,
});
}
let raw = deserialize_selected_section::<RawClientConfig>(path, "client", §ion_value)?;
let mut hostnames: Vec<String> = raw
.services
.into_iter()
.filter(|s| s.tls_mode.as_deref() == Some("terminate"))
.flat_map(|s| s.public_hostnames.unwrap_or_default())
.collect();
hostnames.sort();
hostnames.dedup();
Ok(Some(hostnames))
}
pub fn resolve_client_identity_material_dir_from_config(
path: &Path,
) -> Result<Option<PathBuf>, SettingsError> {
let base_dir = config_dir(path);
let Some(section_value) = load_optional_selected_section_value(path, "client")? else {
return Ok(None);
};
let unknown_field_messages = collect_client_unknown_field_messages(§ion_value);
if !unknown_field_messages.is_empty() {
return Err(SettingsError::Validation {
path: path.to_path_buf(),
section: "client",
messages: unknown_field_messages,
});
}
let raw = deserialize_selected_section::<RawClientConfig>(path, "client", §ion_value)?;
Ok(raw.identity_dir.map(|path| resolve_path(base_dir, &path)))
}
pub(crate) fn load_optional_selected_section_value(
path: &Path,
section: &'static str,
) -> Result<Option<toml::Value>, SettingsError> {
let document = load_config_document(path, section)?;
Ok(document.get(section).cloned())
}
pub(crate) fn load_log_level_from_path(path: &Path) -> Result<LogLevel, SettingsError> {
let document = load_config_document(path, "config")?;
document
.try_into::<RawGlobalConfig>()
.map(|raw| raw.log_level.unwrap_or_default())
.map_err(|source| SettingsError::Parse {
path: path.to_path_buf(),
section: "config",
source: Box::new(source),
})
}
fn load_config_document(path: &Path, section: &'static str) -> Result<toml::Value, SettingsError> {
let contents = fs::read_to_string(path).map_err(|source| SettingsError::Read {
path: path.to_path_buf(),
source,
})?;
let document =
toml::from_str::<toml::Value>(&contents).map_err(|source| SettingsError::Parse {
path: path.to_path_buf(),
section,
source: Box::new(source),
})?;
Ok(document)
}
fn config_dir(path: &Path) -> &Path {
path.parent().unwrap_or_else(|| Path::new("."))
}
pub(crate) fn deserialize_selected_section<T>(
path: &Path,
section: &'static str,
section_value: &toml::Value,
) -> Result<T, SettingsError>
where
T: DeserializeOwned,
{
section_value
.clone()
.try_into::<T>()
.map_err(|source| SettingsError::Parse {
path: path.to_path_buf(),
section,
source: Box::new(source),
})
}
fn validate_prepared_server_settings(
path: &Path,
prepared: PreparedServerConfig,
) -> Result<ServerSettings, SettingsError> {
let PreparedServerConfig {
hostname,
log_level,
public_bind_address,
tunnel_bind_address,
manual_cert_present,
acme_present,
manual_certificate_directory,
acme,
tunnels,
unknown_field_messages,
} = prepared;
let mut messages = unknown_field_messages;
let hostname = match hostname {
Some(hostname) => {
validate_hostname_field("server.hostname", hostname, &mut messages).unwrap_or_default()
}
None => {
messages.push("server.hostname is required".to_owned());
String::new()
}
};
if manual_cert_present && acme_present {
messages.push("[server.acme] and server.cert-dir are mutually exclusive".to_owned());
}
let manual = if !acme_present {
manual_certificate_directory
.and_then(|directory| directory.into_option(&mut messages))
.and_then(|directory| {
validate_prepared_server_manual_cert_settings(
directory,
hostname.as_str(),
&mut messages,
)
})
} else {
None
};
let acme = if acme_present && !manual_cert_present {
acme.and_then(|acme| validate_prepared_server_acme_settings(acme, &mut messages))
} else {
None
};
let certificate = match acme_present {
false => manual.map(|directory| ServerCertificateSettings::Manual { directory }),
true => acme.map(|(email, state_directory)| ServerCertificateSettings::Acme {
email,
state_directory: state_directory.path,
state_directory_was_defaulted: state_directory.was_defaulted,
}),
};
let public_bind_address = validate_socket_address_field(
"server.public-bind-address",
public_bind_address,
&mut messages,
);
let tunnel_connection_bind_address = validate_socket_address_field(
"server.tunnel-bind-address",
tunnel_bind_address,
&mut messages,
);
if tunnels.is_empty() {
messages.push("at least one [[server.tunnels]] entry is required".to_owned());
}
let validated_tunnels = tunnels
.into_iter()
.map(|tunnel| validate_prepared_server_tunnel(tunnel, &mut messages))
.collect::<Vec<_>>();
validate_unique_client_identities(&validated_tunnels, &mut messages);
validate_unique_server_hostnames(&hostname, &validated_tunnels, &mut messages);
let tunnels = validated_tunnels
.into_iter()
.filter_map(|tunnel| tunnel.settings)
.collect::<Vec<_>>();
if messages.is_empty() {
Ok(ServerSettings {
hostname,
log_level,
certificate: certificate.expect("validated server certificate settings"),
public_bind_address: public_bind_address.expect("validated server.public-bind-address"),
tunnel_connection_bind_address: tunnel_connection_bind_address
.expect("validated server.tunnel-bind-address"),
tunnels,
})
} else {
Err(SettingsError::Validation {
path: path.to_path_buf(),
section: "server",
messages,
})
}
}
pub(crate) fn validate_prepared_client_settings(
path: &Path,
prepared: PreparedClientConfig,
) -> Result<ClientSettings, SettingsError> {
let PreparedClientConfig {
server_address,
log_level,
trust,
identity_directory,
services,
manual_public_cert_present,
manual_public_cert_directory,
acme_present,
acme,
unknown_field_messages,
..
} = prepared;
let mut messages = unknown_field_messages;
let server_address = match server_address {
Some(server_address) => {
validate_server_address_field("client.server-address", server_address, &mut messages)
}
None => {
messages.push("client.server-address is required".to_owned());
None
}
};
let server_ca_file = match trust {
PreparedClientTrust::System => None,
PreparedClientTrust::CaFile(server_ca_file) => server_ca_file
.into_option(&mut messages)
.and_then(|server_ca_file| {
validate_existing_file("client.server-ca-file", server_ca_file, &mut messages)
}),
PreparedClientTrust::InvalidMode(value) => {
messages.push(format!(
"client.server-trust must be one of `system` or `ca-file`, got `{value}`"
));
None
}
PreparedClientTrust::UnexpectedServerCaFile => {
messages.push(
"client.server-ca-file may be set only when client.server-trust = \"ca-file\""
.to_owned(),
);
None
}
};
let identity_directory = identity_directory
.into_option(&mut messages)
.and_then(|directory| {
validate_existing_directory_path("client.identity-dir", directory, &mut messages)
});
if let Some(identity_directory) = identity_directory.as_deref() {
let _ = validate_directory_file(
"client.identity-dir",
identity_directory,
CLIENT_CERT_FILENAME,
&mut messages,
);
let _ = validate_directory_file(
"client.identity-dir",
identity_directory,
CLIENT_KEY_FILENAME,
&mut messages,
);
let _ = validate_directory_file(
"client.identity-dir",
identity_directory,
CLIENT_IDENTITY_FILENAME,
&mut messages,
);
}
if manual_public_cert_present && acme_present {
messages.push("[client.acme] and client.public-cert-dir are mutually exclusive".to_owned());
}
let manual_cert_selected = manual_public_cert_directory.is_some() && !acme_present;
let manual_cert = if !acme_present {
manual_public_cert_directory.and_then(|directory| {
directory.into_option(&mut messages).and_then(|directory| {
validate_existing_directory_path("client.public-cert-dir", directory, &mut messages)
})
})
} else {
None
};
let acme = if acme_present && !manual_public_cert_present {
acme.and_then(|acme| validate_prepared_client_acme_settings(acme, &mut messages))
} else {
None
};
let public_cert_config = match acme_present {
false => manual_cert.map(|directory| ClientPublicCertConfig::Manual { directory }),
true => acme.map(|(email, state_directory)| ClientPublicCertConfig::Acme {
email,
state_directory: state_directory.path,
state_directory_was_defaulted: state_directory.was_defaulted,
}),
};
let service_count = services.len();
let omitted_service_public_hostnames = services
.iter()
.filter(|service| service.public_hostnames.is_none())
.count();
if service_count == 0 {
messages.push("at least one [[client.services]] entry is required".to_owned());
}
let validated_services = services
.into_iter()
.map(|service| validate_prepared_client_service(service, &mut messages))
.collect::<Vec<_>>();
validate_client_service_shapes(
service_count,
omitted_service_public_hostnames,
&mut messages,
);
validate_unique_client_service_hostnames(&validated_services, &mut messages);
let has_terminating_service = validated_services
.iter()
.any(|s| s.parsed_tls_mode == Some(ClientTlsMode::Terminate));
if has_terminating_service && public_cert_config.is_none() && !manual_cert_selected {
messages.push(
"client.public-cert-dir or [client.acme] is required when any service uses tls-mode = \"terminate\""
.to_owned(),
);
}
if (manual_public_cert_present || acme_present) && !has_terminating_service {
messages.push(
"client.public-cert-dir and [client.acme] require at least one service with tls-mode = \"terminate\""
.to_owned(),
);
}
let services = validated_services
.into_iter()
.filter_map(|service| service.settings)
.collect::<Vec<_>>();
if messages.is_empty() {
Ok(ClientSettings {
server_hostname: server_address
.as_ref()
.expect("validated client.server-address")
.hostname()
.to_owned(),
server_port: server_address
.as_ref()
.expect("validated client.server-address")
.port(),
log_level,
server_ca_file,
identity_directory: identity_directory.expect("validated client.identity-dir"),
services,
public_cert_config,
})
} else {
Err(SettingsError::Validation {
path: path.to_path_buf(),
section: "client",
messages,
})
}
}
fn validate_prepared_client_acme_settings(
raw: PreparedClientAcmeConfig,
messages: &mut Vec<String>,
) -> Option<(String, ValidatedAcmeStateDirectory)> {
let email = raw.email.unwrap_or_else(|| {
messages.push("client.acme.email is required".to_owned());
String::new()
});
let state_directory = validate_prepared_acme_state_directory(
"client.acme.state-dir",
raw.state_directory,
messages,
);
if email.is_empty() || state_directory.is_none() {
return None;
}
Some((email, state_directory.expect("validated state directory")))
}
fn validate_prepared_server_manual_cert_settings(
directory: PathBuf,
server_hostname: &str,
messages: &mut Vec<String>,
) -> Option<PathBuf> {
let directory = validate_existing_directory_path("server.cert-dir", directory, messages)?;
let cert_path = validate_directory_file(
"server.cert-dir",
&directory,
SERVER_CERT_FILENAME,
messages,
);
let key_path =
validate_directory_file("server.cert-dir", &directory, SERVER_KEY_FILENAME, messages);
let ca_path =
validate_directory_file("server.cert-dir", &directory, SERVER_CA_FILENAME, messages);
if let (Some(cert_path), Some(key_path), Some(ca_path)) = (
cert_path.as_deref(),
key_path.as_deref(),
ca_path.as_deref(),
) && let Err(error) =
validate_server_tls_material(cert_path, key_path, ca_path, server_hostname)
{
messages.push(format!("server TLS material is invalid: {error}"));
return None;
}
Some(directory)
}
fn validate_prepared_server_acme_settings(
raw: PreparedServerAcmeConfig,
messages: &mut Vec<String>,
) -> Option<(String, ValidatedAcmeStateDirectory)> {
let email = raw.email.unwrap_or_else(|| {
messages.push("server.acme.email is required".to_owned());
String::new()
});
let state_directory = validate_prepared_acme_state_directory(
"server.acme.state-dir",
raw.state_directory,
messages,
);
if email.is_empty() || state_directory.is_none() {
return None;
}
Some((email, state_directory.expect("validated state directory")))
}
fn validate_prepared_acme_state_directory(
field_name: &str,
directory: PreparedDirectory,
messages: &mut Vec<String>,
) -> Option<ValidatedAcmeStateDirectory> {
match directory {
PreparedDirectory::Explicit(path) => {
validate_existing_directory_path(field_name, path, messages).map(|path| {
ValidatedAcmeStateDirectory {
path,
was_defaulted: false,
}
})
}
PreparedDirectory::Defaulted(path) => {
path.into_option(messages)
.map(|path| ValidatedAcmeStateDirectory {
path,
was_defaulted: true,
})
}
}
}
fn validate_existing_file(
field_name: &str,
path: PathBuf,
messages: &mut Vec<String>,
) -> Option<PathBuf> {
if !path.is_file() {
messages.push(format!("{field_name} file not found: {}", path.display()));
return None;
}
Some(path)
}
fn validate_existing_directory_path(
field_name: &str,
path: PathBuf,
messages: &mut Vec<String>,
) -> Option<PathBuf> {
if !path.is_dir() {
messages.push(format!(
"{field_name} directory not found: {}",
path.display()
));
return None;
}
Some(path)
}
fn validate_directory_file(
field_name: &str,
directory: &Path,
filename: &str,
messages: &mut Vec<String>,
) -> Option<PathBuf> {
let path = directory.join(filename);
if !path.is_file() {
messages.push(format!("{field_name} file not found: {}", path.display()));
return None;
}
Some(path)
}
fn validate_prepared_server_tunnel(
raw: PreparedServerTunnelConfig,
messages: &mut Vec<String>,
) -> ValidatedServerTunnel {
let public_hostnames = validate_required_public_hostnames(
"server.tunnels[].public-hostnames",
raw.public_hostnames,
messages,
);
let client_identity = match raw.client_identity {
Some(client_identity) => match client_identity.parse::<ClientIdentity>() {
Ok(client_identity) => Some(client_identity),
Err(error) => {
messages.push(format!(
"server.tunnels[].client-identity is invalid: {error}"
));
None
}
},
None => {
messages.push("server.tunnels[].client-identity is required".to_owned());
None
}
};
let settings = if public_hostnames.is_valid {
client_identity
.clone()
.map(|client_identity| ServerTunnelSettings {
public_hostnames: public_hostnames.values.clone(),
client_identity,
})
} else {
None
};
ValidatedServerTunnel {
settings,
public_hostnames: public_hostnames.values,
client_identity,
}
}
fn validate_prepared_client_service(
raw: PreparedClientServiceConfig,
messages: &mut Vec<String>,
) -> ValidatedClientService {
let public_hostnames = validate_optional_public_hostnames(
"client.services[].public-hostnames",
raw.public_hostnames,
messages,
);
let backend_address = match raw.backend_address {
Some(backend_address) => {
if !is_valid_backend_address(&backend_address) {
messages.push(
"client.services[].backend-address must be a TCP address or host:port pair"
.to_owned(),
);
None
} else {
Some(backend_address)
}
}
None => {
messages.push("client.services[].backend-address is required".to_owned());
None
}
};
let parsed_tls_mode = match raw.tls_mode {
PreparedClientTlsMode::Passthrough => Some(ClientTlsMode::Passthrough),
PreparedClientTlsMode::Terminate => Some(ClientTlsMode::Terminate),
PreparedClientTlsMode::Invalid(_) => {
messages.push(
"client.services[].tls-mode must be \"passthrough\" or \"terminate\"".to_owned(),
);
None
}
};
if parsed_tls_mode == Some(ClientTlsMode::Terminate) && public_hostnames.values.is_none() {
messages.push(
"client.services[].tls-mode = \"terminate\" requires explicit public-hostnames"
.to_owned(),
);
}
let settings = if public_hostnames.is_valid && parsed_tls_mode.is_some() {
backend_address.map(|backend_address| ClientServiceSettings {
public_hostnames: public_hostnames.values.clone(),
backend_address,
tls_mode: parsed_tls_mode.clone().expect("validated tls mode"),
})
} else {
None
};
ValidatedClientService {
settings,
public_hostnames: public_hostnames.valid_hostnames,
parsed_tls_mode,
}
}
fn validate_hostname_field(
field_name: &str,
hostname: String,
messages: &mut Vec<String>,
) -> Option<String> {
match validate_public_hostname(&hostname) {
Ok(hostname) => Some(hostname),
Err(error) => {
messages.push(format!("{field_name} is invalid: {error}"));
None
}
}
}
fn validate_server_address_field(
field_name: &str,
server_address: String,
messages: &mut Vec<String>,
) -> Option<ServerAddress> {
match ServerAddress::parse(&server_address) {
Ok(server_address) => Some(server_address),
Err(error) => {
messages.push(format!("{field_name} is invalid: {error}"));
None
}
}
}
fn validate_socket_address_field(
field_name: &str,
socket_address: String,
messages: &mut Vec<String>,
) -> Option<SocketAddr> {
match socket_address.parse::<SocketAddr>() {
Ok(socket_address) => Some(socket_address),
Err(_) => {
messages.push(format!(
"{field_name} is invalid: must be a literal socket address"
));
None
}
}
}
fn validate_required_public_hostnames(
field_name: &str,
raw_hostnames: Option<Vec<String>>,
messages: &mut Vec<String>,
) -> ValidatedRequiredPublicHostnames {
match raw_hostnames {
Some(hostnames) => validate_public_hostnames(field_name, hostnames, messages),
None => {
messages.push(format!("{field_name} is required"));
ValidatedRequiredPublicHostnames {
values: Vec::new(),
is_valid: false,
}
}
}
}
fn validate_optional_public_hostnames(
field_name: &str,
raw_hostnames: Option<Vec<String>>,
messages: &mut Vec<String>,
) -> ValidatedOptionalPublicHostnames {
match raw_hostnames {
Some(hostnames) => {
let validated = validate_public_hostnames(field_name, hostnames, messages);
ValidatedOptionalPublicHostnames {
values: validated.is_valid.then(|| validated.values.clone()),
valid_hostnames: validated.values,
is_valid: validated.is_valid,
}
}
None => ValidatedOptionalPublicHostnames {
values: None,
valid_hostnames: Vec::new(),
is_valid: true,
},
}
}
fn validate_public_hostnames(
field_name: &str,
hostnames: Vec<String>,
messages: &mut Vec<String>,
) -> ValidatedRequiredPublicHostnames {
if hostnames.is_empty() {
messages.push(format!("{field_name} must not be empty"));
return ValidatedRequiredPublicHostnames {
values: Vec::new(),
is_valid: false,
};
}
let mut validated = Vec::with_capacity(hostnames.len());
let hostnames_len = hostnames.len();
for hostname in hostnames {
match validate_public_hostname(&hostname) {
Ok(hostname) => validated.push(hostname),
Err(error) => messages.push(format!(
"{field_name} contains invalid hostname `{hostname}`: {error}"
)),
}
}
ValidatedRequiredPublicHostnames {
is_valid: validated.len() == hostnames_len,
values: validated,
}
}
fn validate_unique_client_identities(
tunnels: &[ValidatedServerTunnel],
messages: &mut Vec<String>,
) {
let mut seen = HashSet::new();
for tunnel in tunnels {
if let Some(identity) = &tunnel.client_identity {
let identity = identity.to_string();
if !seen.insert(identity.clone()) {
messages.push(format!(
"server.tunnels[].client-identity must be unique: {identity}"
));
}
}
}
}
fn validate_unique_server_hostnames(
server_hostname: &str,
tunnels: &[ValidatedServerTunnel],
messages: &mut Vec<String>,
) {
let mut seen = HashSet::new();
for tunnel in tunnels {
for hostname in &tunnel.public_hostnames {
if !server_hostname.is_empty() && hostname == server_hostname {
messages.push(format!(
"server.tunnels[].public-hostnames must not include server.hostname `{server_hostname}`"
));
}
if !seen.insert(hostname.clone()) {
messages.push(format!(
"server.tunnels[].public-hostnames must be unique after normalization: {hostname}"
));
}
}
}
}
fn validate_client_service_shapes(
service_count: usize,
omitted_service_public_hostnames: usize,
messages: &mut Vec<String>,
) {
if service_count > 1 && omitted_service_public_hostnames > 0 {
messages.push(
"client.services[].public-hostnames may be omitted only when there is exactly one service"
.to_owned(),
);
}
}
fn validate_unique_client_service_hostnames(
services: &[ValidatedClientService],
messages: &mut Vec<String>,
) {
let mut seen = HashSet::new();
for service in services {
for hostname in &service.public_hostnames {
if !seen.insert(hostname.clone()) {
messages.push(format!(
"client.services[].public-hostnames must be unique after normalization: {hostname}"
));
}
}
}
}
pub(crate) fn is_valid_backend_address(backend_address: &str) -> bool {
backend_address.parse::<std::net::SocketAddr>().is_ok()
|| backend_address
.rsplit_once(':')
.is_some_and(|(host, port)| !host.is_empty() && port.parse::<u16>().is_ok())
}
fn resolve_path(config_dir: &Path, path: &Path) -> PathBuf {
if path.is_absolute() {
path.to_path_buf()
} else {
config_dir.join(path)
}
}
pub(crate) fn collect_server_unknown_field_messages(section_value: &toml::Value) -> Vec<String> {
let mut messages = Vec::new();
let Some(server) = section_value.as_table() else {
return messages;
};
push_unknown_table_fields(
server,
&[
"hostname",
"cert-dir",
"acme",
"public-bind-address",
"tunnel-bind-address",
"tunnels",
],
&mut messages,
);
if let Some(acme) = server.get("acme").and_then(toml::Value::as_table) {
push_unknown_table_fields(acme, &["email", "state-dir"], &mut messages);
}
if let Some(tunnels) = server.get("tunnels").and_then(toml::Value::as_array) {
for tunnel in tunnels {
if let Some(tunnel) = tunnel.as_table() {
push_unknown_table_fields(
tunnel,
&["public-hostnames", "client-identity"],
&mut messages,
);
}
}
}
messages
}
pub(crate) fn collect_client_unknown_field_messages(section_value: &toml::Value) -> Vec<String> {
let mut messages = Vec::new();
let Some(client) = section_value.as_table() else {
return messages;
};
push_unknown_table_fields(
client,
&[
"server-address",
"server-trust",
"server-ca-file",
"identity-dir",
"public-cert-dir",
"acme",
"services",
],
&mut messages,
);
if let Some(acme) = client.get("acme").and_then(toml::Value::as_table) {
push_unknown_table_fields(acme, &["email", "state-dir"], &mut messages);
}
if let Some(services) = client.get("services").and_then(toml::Value::as_array) {
for service in services {
if let Some(service) = service.as_table() {
push_unknown_table_fields(
service,
&["public-hostnames", "backend-address", "tls-mode"],
&mut messages,
);
}
}
}
messages
}
fn push_unknown_table_fields(
table: &toml::Table,
known_fields: &[&str],
messages: &mut Vec<String>,
) {
for field in table.keys() {
if !known_fields.contains(&field.as_str()) {
messages.push(format!("unknown field `{field}`"));
}
}
}
#[derive(Deserialize)]
#[serde(rename_all = "kebab-case")]
pub(crate) struct RawServerConfig {
pub(crate) hostname: Option<String>,
pub(crate) cert_dir: Option<PathBuf>,
pub(crate) acme: Option<RawServerAcmeConfig>,
pub(crate) public_bind_address: Option<String>,
pub(crate) tunnel_bind_address: Option<String>,
#[serde(default)]
pub(crate) tunnels: Vec<RawServerTunnelConfig>,
}
#[derive(Deserialize)]
#[serde(rename_all = "kebab-case")]
pub(crate) struct RawServerAcmeConfig {
pub(crate) email: Option<String>,
pub(crate) state_dir: Option<PathBuf>,
}
#[derive(Deserialize)]
#[serde(rename_all = "kebab-case")]
pub(crate) struct RawServerTunnelConfig {
pub(crate) public_hostnames: Option<Vec<String>>,
pub(crate) client_identity: Option<String>,
}
#[derive(Clone, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub(crate) struct RawClientConfig {
pub(crate) server_address: Option<String>,
pub(crate) server_trust: Option<String>,
pub(crate) server_ca_file: Option<PathBuf>,
pub(crate) identity_dir: Option<PathBuf>,
pub(crate) public_cert_dir: Option<PathBuf>,
pub(crate) acme: Option<RawClientAcmeConfig>,
#[serde(default)]
pub(crate) services: Vec<RawClientServiceConfig>,
}
#[derive(Clone, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub(crate) struct RawClientAcmeConfig {
pub(crate) email: Option<String>,
pub(crate) state_dir: Option<PathBuf>,
}
#[derive(Clone, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub(crate) struct RawClientServiceConfig {
pub(crate) public_hostnames: Option<Vec<String>>,
pub(crate) backend_address: Option<String>,
pub(crate) tls_mode: Option<String>,
}
#[derive(Deserialize)]
#[serde(rename_all = "kebab-case")]
struct RawGlobalConfig {
log_level: Option<LogLevel>,
}
#[cfg(test)]
mod tests {
use std::io;
use std::path::PathBuf;
use super::{SettingsError, is_valid_backend_address, resolve_path};
#[test]
fn resolves_relative_paths_against_the_config_directory() {
assert_eq!(
resolve_path(
PathBuf::from("/tmp/runewarp").as_path(),
PathBuf::from("server.crt").as_path()
),
PathBuf::from("/tmp/runewarp/server.crt")
);
}
#[test]
fn accepts_host_port_local_backend_pairs() {
assert!(is_valid_backend_address("localhost:8443"));
assert!(is_valid_backend_address("127.0.0.1:443"));
assert!(!is_valid_backend_address("caddy.local"));
}
#[test]
fn settings_error_display_omits_nested_io_detail() {
assert_eq!(
SettingsError::Read {
path: PathBuf::from("/tmp/runewarp/config.toml"),
source: io::Error::other("no such file or directory"),
}
.to_string(),
"failed to read /tmp/runewarp/config.toml"
);
}
}