use std::collections::HashMap;
use std::fmt;
use std::future::Future;
use std::io;
use std::net::SocketAddr;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use rustls::RootCertStore;
use rustls::pki_types::CertificateDer;
use tokio::net::lookup_host;
use crate::acme::{
AcmeLifecycle, ManagedAcmeRuntime, build_acme_state, build_client_acme_state, run_acme_state,
};
use crate::client_public_cert::{
CLIENT_PUBLIC_CERT_FILENAME, CLIENT_PUBLIC_KEY_FILENAME, client_public_cert_leaf_dir,
};
use crate::runtime_log::{self, AcmeEvent, AcmeRole};
use crate::tls_material::{
SERVER_CERT_FILENAME, SERVER_KEY_FILENAME, TlsMaterialError, load_certificate_chain,
load_private_key,
};
use crate::{
CLIENT_CERT_FILENAME, CLIENT_KEY_FILENAME, Client, ClientConnectError, ClientIdentity,
ClientPublicCertConfig, ClientServiceSettings, ClientSettings, ClientTlsMode, QuicConfigError,
Server, ServerCertificateSettings, ServerConfig, ServerSettings, client::validate_services,
make_client_quic_config_with_client_auth, make_server_quic_config_with_client_auth,
make_server_quic_config_with_client_auth_resolver, shutdown::GracefulShutdown,
};
pub struct PreparedServer {
server: Server,
trusted_client_identities: Vec<ClientIdentity>,
acme_runtime: Option<ManagedAcmeRuntime>,
}
impl PreparedServer {
pub async fn bind(
settings: &ServerSettings,
public_bind_addr: SocketAddr,
tunnel_connection_bind_addr: SocketAddr,
) -> Result<Self, ServerStartupError> {
prepare_default_server_acme_state_dir(settings)?;
let trusted_client_identities = settings
.tunnels
.iter()
.map(|tunnel| tunnel.client_identity.clone())
.collect::<Vec<_>>();
let (quic_server_config, acme_runtime) = match &settings.certificate {
ServerCertificateSettings::Manual { directory } => {
let cert_chain = load_certificate_chain(&directory.join(SERVER_CERT_FILENAME))?;
let private_key = load_private_key(&directory.join(SERVER_KEY_FILENAME))?;
let quic_server_config = make_server_quic_config_with_client_auth(
cert_chain,
private_key,
&trusted_client_identities,
)
.map_err(ServerStartupError::QuicConfig)?;
(quic_server_config, None)
}
ServerCertificateSettings::Acme {
email,
state_directory,
..
} => {
if should_warn_server_acme_non_standard_public_port(settings, public_bind_addr) {
runtime_log::acme(
AcmeRole::Server {
server_hostname: &settings.hostname,
},
AcmeEvent::NonStandardPublicBind {
bind_address: public_bind_addr,
},
);
}
let acme_state = build_acme_state(&settings.hostname, email, state_directory);
let acme_lifecycle =
AcmeLifecycle::server(&settings.hostname, state_directory).await;
let quic_server_config = make_server_quic_config_with_client_auth_resolver(
acme_state.resolver(),
&trusted_client_identities,
)
.map_err(ServerStartupError::QuicConfig)?;
(
quic_server_config,
Some(ManagedAcmeRuntime {
state: acme_state,
lifecycle: acme_lifecycle,
}),
)
}
};
let server = Server::bind(ServerConfig {
public_bind_addr,
tunnel_connection_bind_addr,
server_hostname: settings.hostname.clone(),
configured_tunnels: settings.tunnels.clone(),
public_tls_config: acme_runtime
.as_ref()
.map(|acme| acme.state.challenge_rustls_config()),
quic_server_config,
})
.await
.map_err(ServerStartupError::Bind)?;
Ok(Self {
server,
trusted_client_identities,
acme_runtime,
})
}
pub fn public_addr(&self) -> io::Result<SocketAddr> {
self.server.public_addr()
}
pub fn tunnel_addr(&self) -> io::Result<SocketAddr> {
self.server.tunnel_addr()
}
pub fn trusted_client_identities(&self) -> &[ClientIdentity] {
&self.trusted_client_identities
}
pub async fn run(self) -> io::Result<()> {
let Self {
server,
acme_runtime,
..
} = self;
if let Some(acme_runtime) = acme_runtime {
tokio::select! {
server_result = server.run() => server_result,
acme_result = run_acme_state(acme_runtime.state, acme_runtime.lifecycle) => match acme_result {
Ok(never) => match never {},
Err(error) => Err(error),
},
}
} else {
server.run().await
}
}
pub async fn run_until_shutdown<Shutdown>(self, shutdown_signal: Shutdown) -> io::Result<()>
where
Shutdown: Future<Output = ()> + Send + 'static,
{
let shutdown = GracefulShutdown::new(std::time::Duration::from_millis(100));
let shutdown_trigger = shutdown.clone();
tokio::spawn(async move {
shutdown_signal.await;
shutdown_trigger.begin();
});
self.run_with_shutdown(&shutdown).await
}
async fn run_with_shutdown(self, shutdown: &GracefulShutdown) -> io::Result<()> {
let Self {
server,
acme_runtime,
..
} = self;
if let Some(acme_runtime) = acme_runtime {
tokio::select! {
server_result = server.run_with_shutdown(shutdown) => server_result,
acme_result = run_acme_state(acme_runtime.state, acme_runtime.lifecycle) => match acme_result {
Ok(never) => match never {},
Err(error) => Err(error),
},
}
} else {
server.run_with_shutdown(shutdown).await
}
}
}
pub struct PreparedClient {
client: Client,
native_root_error_count: usize,
acme_runtimes: Vec<ManagedAcmeRuntime>,
}
type TerminationTlsConfigs = HashMap<String, Arc<rustls::ServerConfig>>;
type TerminationAcmeChallengeTlsConfigs = HashMap<String, Arc<rustls::ServerConfig>>;
type LoadedTerminationTls = (
TerminationTlsConfigs,
TerminationAcmeChallengeTlsConfigs,
Vec<ManagedAcmeRuntime>,
);
impl PreparedClient {
pub async fn connect(
settings: &ClientSettings,
local_bind_addr: SocketAddr,
) -> Result<Self, ClientStartupError> {
let mut server_addrs =
lookup_host((settings.server_hostname.as_str(), settings.server_port))
.await
.map_err(ClientStartupError::Resolve)?;
let Some(server_addr) = server_addrs.next() else {
return Err(ClientStartupError::MissingServerAddress {
server_hostname: settings.server_hostname.clone(),
});
};
Self::connect_to(settings, local_bind_addr, server_addr).await
}
pub async fn connect_to(
settings: &ClientSettings,
local_bind_addr: SocketAddr,
server_addr: SocketAddr,
) -> Result<Self, ClientStartupError> {
if settings.services.is_empty() {
return Err(ClientStartupError::InvalidSettings(
"client settings must include at least one Service".to_owned(),
));
}
let services = validate_services(&settings.services)
.map_err(|error| ClientStartupError::InvalidSettings(error.to_string()))?;
prepare_default_client_acme_state_dir(settings)?;
let (hostname_tls_configs, hostname_acme_challenge_tls_configs, acme_runtimes) =
load_termination_tls_configs(settings)
.await
.map_err(ClientStartupError::InvalidSettings)?;
let loaded_roots = load_root_store(settings.server_ca_file.as_deref())?;
let cert_chain =
load_certificate_chain(&settings.identity_directory.join(CLIENT_CERT_FILENAME))
.map_err(|error| ClientStartupError::TlsMaterial(error.into()))?;
let private_key = load_private_key(&settings.identity_directory.join(CLIENT_KEY_FILENAME))
.map_err(|error| ClientStartupError::TlsMaterial(error.into()))?;
let quic_client_config =
make_client_quic_config_with_client_auth(loaded_roots.roots, cert_chain, private_key)
.map_err(ClientStartupError::QuicConfig)?;
let client = Client::connect_with_services(crate::client::RoutedClientConfig {
local_bind_addr,
server_addr,
server_name: settings.server_hostname.clone(),
services,
quic_client_config,
hostname_tls_configs,
hostname_acme_challenge_tls_configs,
})
.await
.map_err(ClientStartupError::Connect)?;
Ok(Self {
client,
native_root_error_count: loaded_roots.native_root_error_count,
acme_runtimes,
})
}
pub fn local_addr(&self) -> io::Result<SocketAddr> {
self.client.local_addr()
}
pub fn native_root_error_count(&self) -> usize {
self.native_root_error_count
}
pub async fn run(self) -> Result<(), quinn::ConnectionError> {
let Self {
client,
acme_runtimes,
..
} = self;
for acme_runtime in acme_runtimes {
tokio::spawn(async move {
let _ = run_acme_state(acme_runtime.state, acme_runtime.lifecycle).await;
});
}
client.run().await
}
pub async fn run_until_shutdown<Shutdown>(
self,
shutdown_signal: Shutdown,
) -> Result<(), quinn::ConnectionError>
where
Shutdown: Future<Output = ()> + Send + 'static,
{
let shutdown = GracefulShutdown::new(std::time::Duration::from_millis(100));
let shutdown_trigger = shutdown.clone();
tokio::spawn(async move {
shutdown_signal.await;
shutdown_trigger.begin();
});
self.run_with_shutdown(&shutdown).await
}
async fn run_with_shutdown(
self,
shutdown: &GracefulShutdown,
) -> Result<(), quinn::ConnectionError> {
let Self {
client,
acme_runtimes,
..
} = self;
for acme_runtime in acme_runtimes {
tokio::spawn(async move {
let _ = run_acme_state(acme_runtime.state, acme_runtime.lifecycle).await;
});
}
client.run_with_shutdown(shutdown).await
}
}
fn should_warn_server_acme_non_standard_public_port(
settings: &ServerSettings,
public_bind_addr: SocketAddr,
) -> bool {
matches!(settings.certificate, ServerCertificateSettings::Acme { .. })
&& public_bind_addr.port() != 443
}
#[derive(Debug)]
pub enum ServerStartupError {
ReadFile {
path: std::path::PathBuf,
source: io::Error,
},
MissingCertificate {
path: std::path::PathBuf,
},
MissingPrivateKey {
path: std::path::PathBuf,
},
ParsePem {
path: std::path::PathBuf,
source: io::Error,
},
InvalidTlsMaterial(String),
QuicConfig(QuicConfigError),
Bind(io::Error),
CreateDirectory {
field_name: &'static str,
path: PathBuf,
source: io::Error,
},
}
impl fmt::Display for ServerStartupError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::ReadFile { path, .. } => {
write!(formatter, "failed to read {}", path.display())
}
Self::MissingCertificate { path } => {
write!(formatter, "no certificates found in {}", path.display())
}
Self::MissingPrivateKey { path } => {
write!(formatter, "no private key found in {}", path.display())
}
Self::ParsePem { path, .. } => {
write!(formatter, "failed to parse PEM in {}", path.display())
}
Self::InvalidTlsMaterial(message) => write!(formatter, "{message}"),
Self::QuicConfig(source) => write!(formatter, "{source}"),
Self::Bind(source) => write!(formatter, "{source}"),
Self::CreateDirectory {
field_name, path, ..
} => write!(
formatter,
"failed to create {field_name} directory {}",
path.display()
),
}
}
}
impl std::error::Error for ServerStartupError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::ReadFile { source, .. } => Some(source),
Self::ParsePem { source, .. } => Some(source),
Self::QuicConfig(source) => Some(source),
Self::Bind(source) => Some(source),
Self::CreateDirectory { source, .. } => Some(source),
Self::MissingCertificate { .. }
| Self::MissingPrivateKey { .. }
| Self::InvalidTlsMaterial(_) => None,
}
}
}
impl From<TlsMaterialError> for ServerStartupError {
fn from(error: TlsMaterialError) -> Self {
match error {
TlsMaterialError::ReadFile { path, source } => Self::ReadFile { path, source },
TlsMaterialError::MissingCertificate { path } => Self::MissingCertificate { path },
TlsMaterialError::MissingPrivateKey { path } => Self::MissingPrivateKey { path },
TlsMaterialError::ParsePem { path, source } => Self::ParsePem { path, source },
TlsMaterialError::ParseX509 { .. }
| TlsMaterialError::AddRootCertificate { .. }
| TlsMaterialError::BuildServerVerifier(_)
| TlsMaterialError::InvalidServerName { .. }
| TlsMaterialError::InvalidCertificateAuthority { .. }
| TlsMaterialError::InvalidServerCertificate { .. } => {
Self::InvalidTlsMaterial(error.to_string())
}
TlsMaterialError::InvalidConfiguration(source) => Self::QuicConfig(source),
}
}
}
#[derive(Debug)]
pub enum ClientStartupError {
TlsMaterial(ServerStartupError),
InvalidSettings(String),
NativeRoots {
errors: usize,
},
AddRootCertificate(rustls::Error),
QuicConfig(QuicConfigError),
Resolve(io::Error),
MissingServerAddress {
server_hostname: String,
},
Connect(ClientConnectError),
CreateDirectory {
field_name: &'static str,
path: PathBuf,
source: io::Error,
},
}
impl fmt::Display for ClientStartupError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::TlsMaterial(source) => write!(formatter, "{source}"),
Self::InvalidSettings(message) => formatter.write_str(message),
Self::NativeRoots { errors } => write!(
formatter,
"failed to load the system trust store: {errors} certificate(s) could not be loaded"
),
Self::AddRootCertificate(_) => {
formatter.write_str("failed to add a trusted CA certificate")
}
Self::QuicConfig(source) => write!(formatter, "{source}"),
Self::Resolve(_) => formatter.write_str("failed to resolve the Server hostname"),
Self::MissingServerAddress { server_hostname } => {
write!(
formatter,
"the Server hostname did not resolve to any addresses: {server_hostname}"
)
}
Self::Connect(source) => write!(formatter, "{source}"),
Self::CreateDirectory {
field_name, path, ..
} => write!(
formatter,
"failed to create {field_name} directory {}",
path.display()
),
}
}
}
impl std::error::Error for ClientStartupError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::TlsMaterial(source) => Some(source),
Self::AddRootCertificate(source) => Some(source),
Self::QuicConfig(source) => Some(source),
Self::Resolve(source) => Some(source),
Self::Connect(source) => Some(source),
Self::CreateDirectory { source, .. } => Some(source),
Self::InvalidSettings(_)
| Self::NativeRoots { .. }
| Self::MissingServerAddress { .. } => None,
}
}
}
fn prepare_default_server_acme_state_dir(
settings: &ServerSettings,
) -> Result<(), ServerStartupError> {
let ServerCertificateSettings::Acme {
state_directory,
state_directory_was_defaulted: true,
..
} = &settings.certificate
else {
return Ok(());
};
std::fs::create_dir_all(state_directory).map_err(|source| ServerStartupError::CreateDirectory {
field_name: "server.acme.state-dir",
path: state_directory.clone(),
source,
})
}
fn prepare_default_client_acme_state_dir(
settings: &ClientSettings,
) -> Result<(), ClientStartupError> {
let Some(ClientPublicCertConfig::Acme {
state_directory,
state_directory_was_defaulted: true,
..
}) = &settings.public_cert_config
else {
return Ok(());
};
std::fs::create_dir_all(state_directory).map_err(|source| ClientStartupError::CreateDirectory {
field_name: "client.acme.state-dir",
path: state_directory.clone(),
source,
})
}
#[derive(Debug)]
struct NativeRootsLoad {
certs: Vec<CertificateDer<'static>>,
error_count: usize,
}
#[derive(Debug)]
struct LoadedRootStore {
roots: RootCertStore,
native_root_error_count: usize,
#[cfg(test)]
loaded_root_count: usize,
}
fn load_root_store(
server_ca_file: Option<&std::path::Path>,
) -> Result<LoadedRootStore, ClientStartupError> {
let native_certs = rustls_native_certs::load_native_certs();
build_root_store(
NativeRootsLoad {
certs: native_certs.certs,
error_count: native_certs.errors.len(),
},
server_ca_file,
)
}
fn build_root_store(
native_roots: NativeRootsLoad,
server_ca_file: Option<&std::path::Path>,
) -> Result<LoadedRootStore, ClientStartupError> {
let mut roots = RootCertStore::empty();
let mut loaded_root_count = 0;
let native_root_error_count = if let Some(server_ca_file) = server_ca_file {
for cert in load_certificate_chain(server_ca_file)
.map_err(|error| ClientStartupError::TlsMaterial(error.into()))?
{
roots
.add(cert)
.map_err(ClientStartupError::AddRootCertificate)?;
loaded_root_count += 1;
}
0
} else {
for cert in native_roots.certs {
roots
.add(cert)
.map_err(ClientStartupError::AddRootCertificate)?;
loaded_root_count += 1;
}
native_roots.error_count
};
if loaded_root_count == 0 {
return Err(ClientStartupError::NativeRoots {
errors: native_root_error_count,
});
}
if maybe_emit_client_trust_store_warning(native_root_error_count) {
crate::runtime_log::client_trust_store_warning(native_root_error_count);
}
Ok(LoadedRootStore {
roots,
native_root_error_count,
#[cfg(test)]
loaded_root_count,
})
}
fn maybe_emit_client_trust_store_warning(errors: usize) -> bool {
static CLIENT_TRUST_STORE_WARNING_EMITTED: AtomicBool = AtomicBool::new(false);
should_emit_client_trust_store_warning(errors, &CLIENT_TRUST_STORE_WARNING_EMITTED)
}
fn should_emit_client_trust_store_warning(errors: usize, emitted: &AtomicBool) -> bool {
errors > 0 && !emitted.swap(true, Ordering::AcqRel)
}
pub(crate) fn acme_terminating_hostnames(services: &[ClientServiceSettings]) -> Vec<String> {
let mut seen = std::collections::HashSet::new();
let mut hostnames = Vec::new();
for service in services {
if service.tls_mode != ClientTlsMode::Terminate {
continue;
}
let Some(hs) = &service.public_hostnames else {
continue;
};
for hostname in hs {
if seen.insert(hostname.clone()) {
hostnames.push(hostname.clone());
}
}
}
hostnames
}
async fn load_termination_tls_configs(
settings: &ClientSettings,
) -> Result<LoadedTerminationTls, String> {
match &settings.public_cert_config {
None => Ok((HashMap::new(), HashMap::new(), Vec::new())),
Some(ClientPublicCertConfig::Manual { directory }) => {
let configs = load_manual_termination_tls_configs(settings, directory)?;
Ok((configs, HashMap::new(), Vec::new()))
}
Some(ClientPublicCertConfig::Acme {
email,
state_directory,
..
}) => build_acme_termination_configs(settings, email, state_directory).await,
}
}
fn load_manual_termination_tls_configs(
settings: &ClientSettings,
directory: &std::path::Path,
) -> Result<TerminationTlsConfigs, String> {
let mut configs = HashMap::new();
for service in &settings.services {
if service.tls_mode != ClientTlsMode::Terminate {
continue;
}
let Some(hostnames) = &service.public_hostnames else {
continue;
};
for hostname in hostnames {
if configs.contains_key(hostname) {
continue;
}
let leaf_dir = client_public_cert_leaf_dir(directory, hostname);
let cert_chain = load_certificate_chain(&leaf_dir.join(CLIENT_PUBLIC_CERT_FILENAME))
.map_err(|_| {
format!(
"missing certificate for terminating hostname {hostname}: \
run `runewarp client public-cert init --hostname {hostname}`"
)
})?;
let private_key = load_private_key(&leaf_dir.join(CLIENT_PUBLIC_KEY_FILENAME))
.map_err(|_| {
format!(
"missing private key for terminating hostname {hostname}: \
run `runewarp client public-cert init --hostname {hostname}`"
)
})?;
let tls_config = rustls::ServerConfig::builder()
.with_no_client_auth()
.with_single_cert(cert_chain, private_key)
.map_err(|e| {
format!("invalid TLS material for terminating hostname {hostname}: {e}")
})?;
configs.insert(hostname.clone(), Arc::new(tls_config));
}
}
Ok(configs)
}
async fn build_acme_termination_configs(
settings: &ClientSettings,
email: &str,
state_directory: &std::path::Path,
) -> Result<LoadedTerminationTls, String> {
let hostnames = acme_terminating_hostnames(&settings.services);
if hostnames.is_empty() {
return Err(
"[client.acme] requires at least one service with tls-mode = \"terminate\" \
and explicit public-hostnames"
.to_owned(),
);
}
let mut configs = HashMap::new();
let mut challenge_configs = HashMap::new();
let mut acme_runtimes = Vec::new();
for hostname in hostnames {
let hostname_set = vec![hostname.clone()];
let acme_state = build_client_acme_state(&hostname_set, email, state_directory);
let tls_config: Arc<rustls::ServerConfig> = acme_state.default_rustls_config();
let challenge_tls_config: Arc<rustls::ServerConfig> = acme_state.challenge_rustls_config();
let acme_lifecycle = AcmeLifecycle::client(&hostname, state_directory).await;
configs.insert(hostname.clone(), tls_config);
challenge_configs.insert(hostname.clone(), challenge_tls_config);
acme_runtimes.push(ManagedAcmeRuntime {
state: acme_state,
lifecycle: acme_lifecycle,
});
}
Ok((configs, challenge_configs, acme_runtimes))
}
#[cfg(test)]
mod tests {
use rcgen::{CertificateParams, KeyPair, generate_simple_self_signed};
use rustls::pki_types::CertificateDer;
use std::fs;
use std::io;
use std::path::Path;
use std::path::PathBuf;
use std::str::FromStr;
use std::sync::atomic::AtomicBool;
use super::{
ClientStartupError, NativeRootsLoad, PreparedServer, ServerStartupError,
acme_terminating_hostnames, build_root_store,
};
use crate::tls_material::{SERVER_CERT_FILENAME, SERVER_KEY_FILENAME};
use crate::{
ClientIdentity, ClientPublicCertConfig, ClientServiceSettings, ClientSettings,
ClientTlsMode, LogLevel, ServerCertificateSettings, ServerSettings, ServerTunnelSettings,
};
#[test]
fn configured_server_ca_file_still_loads_without_native_roots() {
let tempdir = tempfile::tempdir().unwrap();
let extra_ca = generate_simple_self_signed(vec!["tunnel.example.test".to_owned()]).unwrap();
fs::write(tempdir.path().join("server-ca.pem"), extra_ca.cert.pem()).unwrap();
let loaded = build_root_store(
NativeRootsLoad {
certs: Vec::new(),
error_count: 2,
},
Some(tempdir.path().join("server-ca.pem").as_path()),
)
.unwrap();
assert_eq!(loaded.native_root_error_count, 0);
assert_eq!(loaded.loaded_root_count, 1);
}
#[test]
fn configured_server_ca_file_replaces_native_roots() {
let tempdir = tempfile::tempdir().unwrap();
let native_ca =
generate_simple_self_signed(vec!["native.example.test".to_owned()]).unwrap();
let extra_ca = generate_simple_self_signed(vec!["tunnel.example.test".to_owned()]).unwrap();
fs::write(tempdir.path().join("server-ca.pem"), extra_ca.cert.pem()).unwrap();
let loaded = build_root_store(
NativeRootsLoad {
certs: vec![CertificateDer::from(native_ca.cert)],
error_count: 1,
},
Some(tempdir.path().join("server-ca.pem").as_path()),
)
.unwrap();
assert_eq!(loaded.native_root_error_count, 0);
assert_eq!(loaded.loaded_root_count, 1);
}
#[test]
fn missing_all_trust_anchors_still_fails() {
let error = build_root_store(
NativeRootsLoad {
certs: Vec::new(),
error_count: 2,
},
None,
)
.unwrap_err();
assert!(matches!(
error,
ClientStartupError::NativeRoots { errors: 2 }
));
}
#[test]
fn client_trust_store_warning_only_emits_once_per_process_session() {
let warning_emitted = AtomicBool::new(false);
assert!(super::should_emit_client_trust_store_warning(
2,
&warning_emitted
));
assert!(!super::should_emit_client_trust_store_warning(
2,
&warning_emitted
));
assert!(!super::should_emit_client_trust_store_warning(
0,
&warning_emitted
));
}
#[test]
fn startup_error_display_includes_bind_address_and_os_detail() {
assert_eq!(
ClientStartupError::Resolve(io::Error::other("lookup failed")).to_string(),
"failed to resolve the Server hostname"
);
assert_eq!(
ServerStartupError::Bind(io::Error::other(
"failed to bind server.public-bind-address 127.0.0.1:443: address already in use",
))
.to_string(),
"failed to bind server.public-bind-address 127.0.0.1:443: address already in use"
);
assert_eq!(
ClientStartupError::CreateDirectory {
field_name: "client.acme.state-dir",
path: PathBuf::from("/tmp/runewarp/client-acme"),
source: io::Error::other("permission denied"),
}
.to_string(),
"failed to create client.acme.state-dir directory /tmp/runewarp/client-acme"
);
}
#[test]
fn acme_terminating_hostnames_only_includes_terminate_mode_services() {
let services = vec![
ClientServiceSettings {
public_hostnames: Some(vec!["app.example.test".to_owned()]),
backend_address: "localhost:80".to_owned(),
tls_mode: ClientTlsMode::Terminate,
},
ClientServiceSettings {
public_hostnames: Some(vec!["api.example.test".to_owned()]),
backend_address: "localhost:8080".to_owned(),
tls_mode: ClientTlsMode::Passthrough,
},
];
let hostnames = acme_terminating_hostnames(&services);
assert_eq!(hostnames, vec!["app.example.test"]);
}
#[test]
fn acme_terminating_hostnames_skips_catch_all_service() {
let services = vec![ClientServiceSettings {
public_hostnames: None, backend_address: "localhost:80".to_owned(),
tls_mode: ClientTlsMode::Terminate,
}];
let hostnames = acme_terminating_hostnames(&services);
assert!(
hostnames.is_empty(),
"Catch-all Services must not contribute Client ACME hostnames"
);
}
#[test]
fn acme_terminating_hostnames_deduplicates_across_services() {
let services = vec![
ClientServiceSettings {
public_hostnames: Some(vec![
"app.example.test".to_owned(),
"api.example.test".to_owned(),
]),
backend_address: "localhost:80".to_owned(),
tls_mode: ClientTlsMode::Terminate,
},
ClientServiceSettings {
public_hostnames: Some(vec!["app.example.test".to_owned()]),
backend_address: "localhost:8080".to_owned(),
tls_mode: ClientTlsMode::Terminate,
},
];
let hostnames = acme_terminating_hostnames(&services);
let mut sorted = hostnames.clone();
sorted.sort();
assert_eq!(sorted, vec!["api.example.test", "app.example.test"]);
assert_eq!(hostnames.len(), 2, "each hostname must appear only once");
}
#[test]
fn acme_terminating_hostnames_empty_when_no_terminate_services() {
let services = vec![
ClientServiceSettings {
public_hostnames: Some(vec!["app.example.test".to_owned()]),
backend_address: "localhost:80".to_owned(),
tls_mode: ClientTlsMode::Passthrough,
},
ClientServiceSettings {
public_hostnames: Some(vec!["api.example.test".to_owned()]),
backend_address: "localhost:8080".to_owned(),
tls_mode: ClientTlsMode::Passthrough,
},
];
let hostnames = acme_terminating_hostnames(&services);
assert!(hostnames.is_empty());
}
#[test]
fn defaulted_server_acme_state_dir_is_created_during_startup()
-> Result<(), Box<dyn std::error::Error>> {
let tempdir = tempfile::tempdir()?;
let state_directory = tempdir.path().join("server/acme");
let settings = ServerSettings {
hostname: "tunnel.example.test".to_owned(),
log_level: LogLevel::Info,
certificate: ServerCertificateSettings::Acme {
email: "admin@example.test".to_owned(),
state_directory: state_directory.clone(),
state_directory_was_defaulted: true,
},
public_bind_address: "127.0.0.1:443".parse()?,
tunnel_connection_bind_address: "127.0.0.1:443".parse()?,
tunnels: Vec::new(),
};
super::prepare_default_server_acme_state_dir(&settings)?;
assert!(state_directory.is_dir());
Ok(())
}
#[test]
fn non_standard_server_acme_public_port_warning_only_applies_to_server_acme() {
let acme_settings = ServerSettings {
hostname: "tunnel.example.test".to_owned(),
log_level: LogLevel::Info,
certificate: ServerCertificateSettings::Acme {
email: "admin@example.test".to_owned(),
state_directory: PathBuf::from("/tmp/server-acme"),
state_directory_was_defaulted: false,
},
public_bind_address: "127.0.0.1:443".parse().unwrap(),
tunnel_connection_bind_address: "127.0.0.1:443".parse().unwrap(),
tunnels: Vec::new(),
};
let manual_settings = ServerSettings {
certificate: ServerCertificateSettings::Manual {
directory: PathBuf::from("/tmp/server-cert"),
},
..acme_settings.clone()
};
assert!(super::should_warn_server_acme_non_standard_public_port(
&acme_settings,
"127.0.0.1:8443".parse().unwrap()
));
assert!(!super::should_warn_server_acme_non_standard_public_port(
&acme_settings,
"127.0.0.1:443".parse().unwrap()
));
assert!(!super::should_warn_server_acme_non_standard_public_port(
&manual_settings,
"127.0.0.1:8443".parse().unwrap()
));
}
#[tokio::test]
async fn client_acme_builds_one_runtime_per_terminating_hostname() {
let tempdir = tempfile::tempdir().unwrap();
let settings = ClientSettings {
server_hostname: "tunnel.example.test".to_owned(),
server_port: 443,
log_level: LogLevel::Info,
server_ca_file: None,
identity_directory: tempdir.path().join("client-identity"),
services: vec![ClientServiceSettings {
public_hostnames: Some(vec![
"app.example.test".to_owned(),
"api.example.test".to_owned(),
]),
backend_address: "127.0.0.1:8080".to_owned(),
tls_mode: ClientTlsMode::Terminate,
}],
public_cert_config: Some(ClientPublicCertConfig::Acme {
email: "admin@example.test".to_owned(),
state_directory: tempdir.path().join("acme-state"),
state_directory_was_defaulted: false,
}),
};
let (configs, challenge_configs, acme_runtimes) =
super::load_termination_tls_configs(&settings)
.await
.expect("ACME termination configs should build");
assert_eq!(configs.len(), 2);
assert_eq!(challenge_configs.len(), 2);
assert_eq!(acme_runtimes.len(), 2);
}
#[test]
fn defaulted_client_acme_state_dir_is_created_during_startup()
-> Result<(), Box<dyn std::error::Error>> {
let tempdir = tempfile::tempdir()?;
let state_directory = tempdir.path().join("client/acme");
let settings = ClientSettings {
server_hostname: "tunnel.example.test".to_owned(),
server_port: 443,
log_level: LogLevel::Info,
server_ca_file: None,
identity_directory: tempdir.path().join("client-identity"),
services: vec![ClientServiceSettings {
public_hostnames: Some(vec!["app.example.test".to_owned()]),
backend_address: "127.0.0.1:443".to_owned(),
tls_mode: ClientTlsMode::Terminate,
}],
public_cert_config: Some(ClientPublicCertConfig::Acme {
email: "admin@example.test".to_owned(),
state_directory: state_directory.clone(),
state_directory_was_defaulted: true,
}),
};
super::prepare_default_client_acme_state_dir(&settings)?;
assert!(state_directory.is_dir());
Ok(())
}
#[test]
fn startup_surfaces_defaulted_server_acme_state_dir_creation_failures()
-> Result<(), Box<dyn std::error::Error>> {
let tempdir = tempfile::tempdir()?;
let blocked_parent = tempdir.path().join("blocked");
fs::write(&blocked_parent, "not a directory")?;
let state_directory = blocked_parent.join("acme");
let settings = ServerSettings {
hostname: "tunnel.example.test".to_owned(),
log_level: LogLevel::Info,
certificate: ServerCertificateSettings::Acme {
email: "admin@example.test".to_owned(),
state_directory: state_directory.clone(),
state_directory_was_defaulted: true,
},
public_bind_address: "127.0.0.1:443".parse()?,
tunnel_connection_bind_address: "127.0.0.1:443".parse()?,
tunnels: Vec::new(),
};
let error = match super::prepare_default_server_acme_state_dir(&settings) {
Ok(()) => panic!("expected startup preparation to fail"),
Err(error) => error,
};
assert!(matches!(
error,
ServerStartupError::CreateDirectory {
field_name: "server.acme.state-dir",
path,
..
} if path == state_directory
));
Ok(())
}
#[test]
fn startup_surfaces_defaulted_client_acme_state_dir_creation_failures()
-> Result<(), Box<dyn std::error::Error>> {
let tempdir = tempfile::tempdir()?;
let blocked_parent = tempdir.path().join("blocked");
fs::write(&blocked_parent, "not a directory")?;
let state_directory = blocked_parent.join("acme");
let settings = ClientSettings {
server_hostname: "tunnel.example.test".to_owned(),
server_port: 443,
log_level: LogLevel::Info,
server_ca_file: None,
identity_directory: tempdir.path().join("client-identity"),
services: vec![ClientServiceSettings {
public_hostnames: Some(vec!["app.example.test".to_owned()]),
backend_address: "127.0.0.1:443".to_owned(),
tls_mode: ClientTlsMode::Terminate,
}],
public_cert_config: Some(ClientPublicCertConfig::Acme {
email: "admin@example.test".to_owned(),
state_directory: state_directory.clone(),
state_directory_was_defaulted: true,
}),
};
let error = match super::prepare_default_client_acme_state_dir(&settings) {
Ok(()) => panic!("expected startup preparation to fail"),
Err(error) => error,
};
assert!(matches!(
error,
ClientStartupError::CreateDirectory {
field_name: "client.acme.state-dir",
path,
..
} if path == state_directory
));
Ok(())
}
#[tokio::test]
async fn server_startup_reports_public_bind_address_failures()
-> Result<(), Box<dyn std::error::Error>> {
let tempdir = tempfile::tempdir()?;
write_manual_server_certificate(tempdir.path())?;
let occupied_listener = std::net::TcpListener::bind("127.0.0.1:0")?;
let public_bind_address = occupied_listener.local_addr()?;
let error = match PreparedServer::bind(
&server_settings(tempdir.path()),
public_bind_address,
"127.0.0.1:0".parse()?,
)
.await
{
Ok(_) => panic!("expected public listener bind failure"),
Err(error) => error,
};
assert!(matches!(
&error,
ServerStartupError::Bind(source)
if source.to_string().starts_with(&format!(
"failed to bind server.public-bind-address {public_bind_address}:"
))
));
let message = error.to_string();
assert!(
message.starts_with(&format!(
"failed to bind server.public-bind-address {public_bind_address}:"
)),
"{message}"
);
assert!(
message.contains("Address already in use")
|| message.contains("address already in use"),
"{message}"
);
Ok(())
}
#[tokio::test]
async fn server_startup_reports_tunnel_bind_address_failures()
-> Result<(), Box<dyn std::error::Error>> {
let tempdir = tempfile::tempdir()?;
write_manual_server_certificate(tempdir.path())?;
let occupied_socket = std::net::UdpSocket::bind("127.0.0.1:0")?;
let tunnel_bind_address = occupied_socket.local_addr()?;
let error = match PreparedServer::bind(
&server_settings(tempdir.path()),
"127.0.0.1:0".parse()?,
tunnel_bind_address,
)
.await
{
Ok(_) => panic!("expected tunnel listener bind failure"),
Err(error) => error,
};
assert!(matches!(
&error,
ServerStartupError::Bind(source)
if source.to_string().starts_with(&format!(
"failed to bind server.tunnel-bind-address {tunnel_bind_address}:"
))
));
let message = error.to_string();
assert!(
message.starts_with(&format!(
"failed to bind server.tunnel-bind-address {tunnel_bind_address}:"
)),
"{message}"
);
assert!(
message.contains("Address already in use")
|| message.contains("address already in use"),
"{message}"
);
Ok(())
}
fn server_settings(certificate_directory: &Path) -> ServerSettings {
ServerSettings {
hostname: "tunnel.example.test".to_owned(),
log_level: LogLevel::Info,
certificate: ServerCertificateSettings::Manual {
directory: certificate_directory.to_path_buf(),
},
public_bind_address: "127.0.0.1:0".parse().unwrap(),
tunnel_connection_bind_address: "127.0.0.1:0".parse().unwrap(),
tunnels: vec![ServerTunnelSettings {
public_hostnames: vec!["app.example.test".to_owned()],
client_identity: ClientIdentity::from_str(
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
)
.unwrap(),
}],
}
}
fn write_manual_server_certificate(directory: &Path) -> Result<(), Box<dyn std::error::Error>> {
let signing_key = KeyPair::generate()?;
let certificate = CertificateParams::new(vec!["tunnel.example.test".to_owned()])?
.self_signed(&signing_key)?;
fs::write(directory.join(SERVER_CERT_FILENAME), certificate.pem())?;
fs::write(
directory.join(SERVER_KEY_FILENAME),
signing_key.serialize_pem(),
)?;
Ok(())
}
}