use std::future::Future;
use std::io::{BufRead, IsTerminal};
use std::net::SocketAddr;
use std::sync::Arc;
use std::time::Duration;
use clap::{Parser, Subcommand};
use clap_complete::aot::Shell;
use tokio::net::TcpListener;
use tracing::{error, info, warn};
pub mod account;
pub mod audit;
pub mod eab;
pub mod filter;
pub mod generate;
mod logging;
pub use logging::init_logging;
pub mod nonce;
pub mod order;
pub mod render;
pub mod style;
pub mod upstream;
pub mod webadmin;
pub mod window;
pub use account::AccountCommand;
pub use audit::AuditCommand;
pub use eab::EabCommand;
pub use nonce::NonceCommand;
pub use order::OrderCommand;
pub use upstream::UpstreamCommand;
pub use webadmin::AdminCommand;
use crate::cli::filter::FilterCommand;
pub use crate::cli::style::{ColorChoice, Palette};
use crate::config::Config;
use crate::sqlite::db::Database;
use crate::{Profile, build_app, tls};
#[derive(Parser)]
#[command(
name = "acme-proxy",
version = env!("CARGO_PKG_VERSION"),
about = "ACME server, plus admin commands for its database"
)]
pub struct Cli {
#[arg(short = 'y', long, global = true)]
pub yes: bool,
#[arg(long, value_enum, default_value_t = ColorChoice::Auto, global = true)]
pub color: ColorChoice,
#[command(subcommand)]
pub command: Option<Command>,
}
#[derive(Subcommand)]
pub enum Command {
Serve,
Account {
#[command(subcommand)]
command: AccountCommand,
},
Order {
#[command(subcommand)]
command: OrderCommand,
},
Audit {
#[command(subcommand)]
command: AuditCommand,
},
Nonce {
#[command(subcommand)]
command: NonceCommand,
},
Eab {
#[command(subcommand)]
command: EabCommand,
},
Filter {
#[command(subcommand)]
command: FilterCommand,
},
Upstream {
#[command(subcommand)]
command: UpstreamCommand,
},
Admin {
#[command(subcommand)]
command: AdminCommand,
},
Completions {
#[arg(value_enum)]
shell: Shell,
},
Man,
}
pub(crate) fn resolve_profile(
config: &Config,
wanted: Option<&str>,
) -> Result<crate::config::ProfileConfig, CliError> {
let profiles = config
.resolve_profiles()
.map_err(|error| CliError(format!("configuration error: {error}")))?;
match wanted {
Some(name) => profiles
.into_iter()
.find(|profile| profile.name == name)
.ok_or_else(|| CliError(format!("no profile named `{name}` in this configuration"))),
None if profiles.len() == 1 => Ok(profiles.into_iter().next().expect("length checked")),
None => {
let names: Vec<&str> = profiles.iter().map(|p| p.name.as_str()).collect();
Err(CliError(format!(
"this configuration defines several profiles ({}); say which one with --profile",
names.join(", ")
)))
}
}
}
#[derive(Debug, PartialEq, Eq, thiserror::Error)]
#[error("{0}")]
pub struct CliError(pub String);
impl From<sqlx::Error> for CliError {
fn from(error: sqlx::Error) -> Self {
Self(format!("database error: {error}"))
}
}
pub async fn dispatch(
command: Option<Command>,
yes: bool,
color: ColorChoice,
reader: &mut impl BufRead,
config: &Arc<Config>,
database: Arc<Database>,
) -> Result<(), CliError> {
let palette = Palette::resolve(
color,
std::io::stdout().is_terminal(),
std::env::var("NO_COLOR").ok().as_deref(),
);
match command.unwrap_or(Command::Serve) {
Command::Serve => serve(config.clone(), database).await,
Command::Account { command } => {
account::run_account_command(command, yes, palette, reader, config, database).await
}
Command::Order { command } => {
order::run_order_command(command, yes, palette, reader, config, database).await
}
Command::Audit { command } => {
audit::run_audit_command(command, yes, palette, reader, database).await
}
Command::Nonce { command } => {
nonce::run_nonce_command(command, yes, reader, config, database).await
}
Command::Eab { command } => eab::run_eab_command(command, palette, database).await,
Command::Filter { command } => filter::run_filter_command(command, palette, config).await,
Command::Upstream { command } => {
upstream::run_upstream_command(command, reader, config).await
}
Command::Admin { command } => {
webadmin::run_admin_command(command, yes, palette, reader, config, database).await
}
command @ (Command::Completions { .. } | Command::Man) => {
generate::write(&command, &mut std::io::stdout().lock())
}
}
}
pub async fn serve(config: Arc<Config>, database: Arc<Database>) -> Result<(), CliError> {
let listener = TcpListener::bind(&config.server.bind_address)
.await
.map_err(|error| {
error!(event = "server_socket_bind_failed", outcome = "failure", bind_address = %config.server.bind_address, error = %error);
CliError(format!(
"cannot bind {}: {error}",
config.server.bind_address
))
})?;
let (reload_handle, reloads) = crate::reload::channel();
let _hangups = AbortOnDrop(tokio::spawn(watch_for_hangup(reload_handle)));
let admin_listener = bind_admin(&config).await.map_err(|error| {
error!(event = "server_fatal_error", outcome = "failure", error = %error);
CliError(error.to_string())
})?;
let metrics_listener = bind_metrics(&config).await.map_err(|error| {
error!(event = "server_fatal_error", outcome = "failure", error = %error);
CliError(error.to_string())
})?;
serve_on_with_reloads(
config,
database,
listener,
admin_listener,
metrics_listener,
shutdown_signal(),
reloads,
)
.await
.map_err(|error| {
error!(event = "server_fatal_error", outcome = "failure", error = %error);
CliError(error.to_string())
})
}
#[cfg(unix)]
async fn watch_for_hangup(handle: crate::reload::ReloadHandle) {
let mut hangups = match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::hangup()) {
Ok(stream) => stream,
Err(error) => {
error!(event = "server_signal_handler_failed", outcome = "failure", signal = "SIGHUP", error = %error);
return;
}
};
while hangups.recv().await.is_some() {
handle.trigger();
}
}
#[cfg(not(unix))]
async fn watch_for_hangup(_handle: crate::reload::ReloadHandle) {
std::future::pending::<()>().await;
}
async fn bind_admin(config: &Arc<Config>) -> anyhow::Result<Option<TcpListener>> {
crate::webadmin::check_config(config).inspect_err(|error| {
error!(event = "admin_config_invalid", outcome = "failure", error = %error);
})?;
match config.admin.enabled {
false => Ok(None),
true => Ok(Some(
TcpListener::bind(&config.admin.bind_address)
.await
.inspect_err(|error| {
error!(event = "admin_socket_bind_failed",
outcome = "failure",
bind_address = %config.admin.bind_address,
error = %error);
})?,
)),
}
}
pub fn check_metrics_config(config: &Config) -> anyhow::Result<()> {
if !config.metrics.enabled {
return Ok(());
}
let bind = &config.metrics.bind_address;
for (name, other) in [
("server.bind_address", &config.server.bind_address),
("admin.bind_address", &config.admin.bind_address),
] {
if bind == other && (name != "admin.bind_address" || config.admin.enabled) {
error!(event = "metrics_config_invalid",
outcome = "failure",
bind_address = %bind);
anyhow::bail!(
"metrics.bind_address and {name} are both `{bind}`: the metrics endpoint is a \
separate listener and cannot share a socket (give it its own port)"
);
}
}
Ok(())
}
async fn bind_metrics(config: &Arc<Config>) -> anyhow::Result<Option<TcpListener>> {
check_metrics_config(config)?;
if !config.metrics.enabled {
return Ok(None);
}
let bind = &config.metrics.bind_address;
let listener = TcpListener::bind(bind).await.inspect_err(|error| {
error!(event = "metrics_socket_bind_failed",
outcome = "failure",
bind_address = %bind,
error = %error);
})?;
Ok(Some(listener))
}
pub async fn serve_on(
config: Arc<Config>,
database: Arc<Database>,
listener: TcpListener,
shutdown: impl Future<Output = ()> + Send + 'static,
) -> anyhow::Result<()> {
let admin_listener = bind_admin(&config).await?;
let metrics_listener = bind_metrics(&config).await?;
serve_on_with(
config,
database,
listener,
admin_listener,
metrics_listener,
shutdown,
)
.await
}
pub async fn serve_on_with(
config: Arc<Config>,
database: Arc<Database>,
listener: TcpListener,
admin_listener: Option<TcpListener>,
metrics_listener: Option<TcpListener>,
shutdown: impl Future<Output = ()> + Send + 'static,
) -> anyhow::Result<()> {
serve_on_with_reloads(
config,
database,
listener,
admin_listener,
metrics_listener,
shutdown,
crate::reload::Reloads::none(),
)
.await
}
pub async fn serve_on_with_reloads(
config: Arc<Config>,
database: Arc<Database>,
listener: TcpListener,
admin_listener: Option<TcpListener>,
metrics_listener: Option<TcpListener>,
shutdown: impl Future<Output = ()> + Send + 'static,
reloads: crate::reload::Reloads,
) -> anyhow::Result<()> {
info!(
event = "server_startup",
outcome = "success",
bind_address = %config.server.bind_address,
base_url = %config.server.base_url,
tls = config.server.tls.enabled,
database_database_url = %config.database.url
);
let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false);
let _shutdown_relay = AbortOnDrop(tokio::spawn(async move {
shutdown.await;
let _ = shutdown_tx.send(true);
}));
let job_queue = crate::jobs::JobQueue::new(database.clone(), &config.jobs);
let resolved = config.resolve_profiles().inspect_err(|error| {
error!(event = "profile_init_failed", outcome = "failure", error = %error);
})?;
let (assembly, parts) =
crate::Assembly::new(&resolved, database.clone(), job_queue.clone(), &config).inspect_err(
|error| {
error!(event = "profile_init_failed", outcome = "failure", error = %error);
},
)?;
let assembly = Arc::new(assembly);
let generation =
build_generation(&config, &resolved, &assembly, &parts, None).inspect_err(|error| {
error!(event = "profile_init_failed", outcome = "failure", error = %error);
})?;
for profile in &generation.profiles {
announce_profile(profile).await;
}
let Generation {
profiles: _,
acme_app,
admin_app,
job_registry,
tls,
admin_tls,
logins,
} = generation;
let (registry_tx, registry_rx) = tokio::sync::watch::channel(Arc::new(job_registry));
let (jobs_tx, jobs_rx) = tokio::sync::watch::channel(Arc::new(config.jobs.clone()));
let _job_runner = AbortOnDrop(crate::jobs::spawn_runner_watching(
job_queue,
registry_rx,
jobs_rx,
shutdown_rx.clone(),
));
info!(
event = "server_listening",
outcome = "success",
bind_address = %config.server.bind_address,
protocol = if tls.is_some() { "https" } else { "http" }
);
let admin_bound = bound_address(admin_listener.as_ref(), &config.admin.bind_address);
let metrics_bound = bound_address(metrics_listener.as_ref(), &config.metrics.bind_address);
let (acme_socket, acme_handle) = crate::listener::spawn("acme", Some(listener), tls);
let (admin_socket, admin_handle) = crate::listener::spawn("admin", admin_listener, admin_tls);
let (metrics_socket, metrics_handle) =
crate::listener::spawn("metrics", metrics_listener, None);
let (acme_router_tx, acme_router_rx) = crate::reload::router_channel(acme_app);
let acme = serve_role(
crate::reload::swappable(acme_router_rx),
acme_socket,
shutdown_rx.clone(),
);
let (admin_router_tx, admin_router_rx) =
crate::reload::router_channel(admin_app.unwrap_or_default());
let admin = serve_role(
crate::reload::swappable(admin_router_rx),
admin_socket,
shutdown_rx.clone(),
);
if config.admin.enabled {
announce_admin_listener(&config, &database, &admin_bound).await;
}
if config.metrics.enabled {
announce_metrics_listener(&metrics_bound);
}
let metrics = serve_role(
crate::metrics_app(assembly.metrics.clone()),
metrics_socket,
shutdown_rx,
);
let _reload_supervisor = AbortOnDrop(tokio::spawn(supervise_reloads(
reloads,
config.clone(),
resolved,
assembly,
Cells {
acme_router: acme_router_tx,
admin_router: admin_router_tx,
job_registry: registry_tx,
jobs: jobs_tx,
acme: acme_handle,
admin: admin_handle,
metrics: metrics_handle,
},
logins,
)));
tokio::try_join!(acme, admin, metrics)?;
Ok(())
}
fn serve_role(
app: axum::Router,
socket: crate::listener::RoleSocket,
shutdown: tokio::sync::watch::Receiver<bool>,
) -> impl Future<Output = std::io::Result<()>> + Send {
axum::serve(
socket,
app.into_make_service_with_connect_info::<SocketAddr>(),
)
.with_graceful_shutdown(on_shutdown(shutdown))
.into_future()
}
pub(crate) struct Generation {
profiles: Vec<Arc<Profile>>,
acme_app: axum::Router,
admin_app: Option<axum::Router>,
job_registry: crate::jobs::JobRegistry,
tls: Option<tls::TlsSettings>,
admin_tls: Option<tls::TlsSettings>,
logins: Option<Arc<crate::webadmin::LoginLimiter>>,
}
pub(crate) fn build_generation(
config: &Arc<Config>,
resolved: &[crate::config::ProfileConfig],
assembly: &crate::Assembly,
parts: &crate::GenerationParts,
previous_logins: Option<&crate::webadmin::LoginLimiter>,
) -> anyhow::Result<Generation> {
let admin_enabled = config.admin.enabled;
let database = assembly.database.clone();
let profiles = Profile::build_all_with(config, resolved, parts)?;
let tls = tls::from_config(&config.server)
.inspect_err(|error| {
error!(event = "tls_init_failed", outcome = "failure", error = %error);
})?
.map(|acceptor| {
tls::TlsSettings::new(
acceptor,
Duration::from_millis(config.server.tls.handshake_timeout_ms),
)
});
let admin_tls = match admin_enabled {
false => None,
true => tls::admin_from_config(&config.admin)
.inspect_err(|error| {
error!(event = "admin_tls_init_failed", outcome = "failure", error = %error);
})?
.map(|acceptor| {
tls::TlsSettings::new(
acceptor,
Duration::from_millis(config.admin.tls.handshake_timeout_ms),
)
}),
};
let mut job_registry = crate::jobs::JobRegistry::new();
let mut registered: Vec<usize> = Vec::new();
let mut pruners: Vec<Arc<dyn crate::signer::CrlPruner>> = Vec::new();
for profile in &profiles {
let identity = Arc::as_ptr(&profile.signer).cast::<()>() as usize;
if registered.contains(&identity) {
continue;
}
registered.push(identity);
for handler in profile.signer.jobs() {
job_registry.register(handler).inspect_err(|error| {
error!(event = "job_registry_init_failed", outcome = "failure", error = %error);
})?;
}
pruners.extend(profile.signer.crl_pruner());
}
if !pruners.is_empty() {
job_registry
.register(Arc::new(crate::signer::local_ca::sweep::CrlSweepJob::new(
pruners,
)))
.inspect_err(|error| {
error!(event = "job_registry_init_failed", outcome = "failure", error = %error);
})?;
}
job_registry
.register(Arc::new(crate::notify::NotifyJob::new(
assembly.notifiers.clone(),
)))
.inspect_err(|error| {
error!(event = "job_registry_init_failed", outcome = "failure", error = %error);
})?;
if let Some(digest) = crate::notify::expiry::ExpiryDigestJob::from_profiles(
resolved,
assembly.notifiers.clone(),
database.clone(),
assembly.jobs.clone(),
) {
job_registry
.register(Arc::new(digest))
.inspect_err(|error| {
error!(event = "job_registry_init_failed", outcome = "failure", error = %error);
})?;
}
let ttl = Duration::from_secs(config.nonce.ttl_seconds);
let mut sweeps = vec![crate::jobs::SweepJob::nonces(database.clone(), ttl)];
if config.audit.retention_days > 0 {
sweeps.push(crate::jobs::SweepJob::audit(
database.clone(),
config.audit.retention_days,
));
}
if config.jobs.retention_days > 0 {
sweeps.push(crate::jobs::SweepJob::jobs(
database.clone(),
config.jobs.retention_days,
));
}
let order_retention: Vec<(String, u64)> = resolved
.iter()
.filter(|profile| profile.sections.order.retention_days > 0)
.map(|profile| (profile.name.clone(), profile.sections.order.retention_days))
.collect();
if !order_retention.is_empty() {
sweeps.push(crate::jobs::SweepJob::orders(
database.clone(),
order_retention,
));
}
if admin_enabled {
sweeps.push(crate::jobs::SweepJob::admin_sessions(
database.clone(),
Duration::from_secs(config.admin.session_idle_timeout_seconds),
config.admin.session_ttl_seconds,
));
}
for sweep in sweeps {
job_registry
.register(Arc::new(sweep))
.inspect_err(|error| {
error!(event = "job_registry_init_failed", outcome = "failure", error = %error);
})?;
}
let auditor = Arc::new(
crate::audit::Auditor::from_config(
&config.audit,
&config.dns,
database.clone(),
assembly.metrics.clone(),
)
.inspect_err(|error| {
error!(event = "audit_init_failed", outcome = "failure", error = %error);
})?,
);
let (admin_app, logins) = match admin_enabled {
false => (None, None),
true => {
let (router, logins) = crate::webadmin::build_admin_app_with_logins(
database.clone(),
config.clone(),
&profiles,
auditor.clone(),
previous_logins,
);
(Some(router), Some(logins))
}
};
let acme_app = build_app(
database,
config.clone(),
profiles.clone(),
auditor,
assembly.metrics.clone(),
);
Ok(Generation {
profiles,
acme_app,
admin_app,
job_registry,
tls,
admin_tls,
logins,
})
}
struct Cells {
acme_router: tokio::sync::watch::Sender<axum::routing::RouterIntoService<axum::body::Body>>,
admin_router: tokio::sync::watch::Sender<axum::routing::RouterIntoService<axum::body::Body>>,
job_registry: tokio::sync::watch::Sender<Arc<crate::jobs::JobRegistry>>,
jobs: tokio::sync::watch::Sender<Arc<crate::config::JobsConfig>>,
acme: crate::listener::ListenerHandle,
admin: crate::listener::ListenerHandle,
metrics: crate::listener::ListenerHandle,
}
async fn supervise_reloads(
mut reloads: crate::reload::Reloads,
mut config: Arc<Config>,
mut resolved: Vec<crate::config::ProfileConfig>,
assembly: Arc<crate::Assembly>,
cells: Cells,
mut logins: Option<Arc<crate::webadmin::LoginLimiter>>,
) {
let mut generation: u64 = 1;
while let Some(request) = reloads.recv().await {
let started = std::time::Instant::now();
info!(
event = "server_config_reload_requested",
outcome = "progress",
generation = generation,
);
let outcome = {
let config = config.clone();
let resolved = resolved.clone();
let assembly = assembly.clone();
let logins = logins.clone();
tokio::task::spawn_blocking(move || {
prepare_reload(&config, &resolved, &assembly, logins.as_deref())
})
.await
.unwrap_or_else(|error| {
Err(crate::reload::ReloadError::Build(format!(
"the reload build task did not finish: {error}"
)))
})
}
.map(|prepared| {
publish_reload(
prepared,
&config,
&assembly,
&cells,
generation + 1,
started,
)
});
match outcome {
Ok(reloaded) => {
let report = reloaded.report;
config = reloaded.config;
resolved = reloaded.resolved;
logins = reloaded.logins;
generation = report.generation;
info!(
event = "server_config_reloaded",
outcome = "success",
generation = report.generation,
profiles = ?report.profiles,
job_kinds = ?report.job_kinds,
tls_reloaded = report.tls_reloaded,
admin_tls_reloaded = report.admin_tls_reloaded,
listeners_rebound = ?report.listeners_rebound,
logging_reloaded = report.logging_reloaded,
duration_ms = crate::millis(report.duration),
);
for (role, address) in reloaded.opened {
match role {
Role::Acme => info!(
event = "server_listening",
outcome = "success",
bind_address = %address,
protocol = if config.server.tls.enabled { "https" } else { "http" }
),
Role::Admin => {
announce_admin_listener(&config, &assembly.database, &address).await;
}
Role::Metrics => announce_metrics_listener(&address),
}
}
for profile in reloaded.mounted {
announce_profile(&profile).await;
}
if let Some(respond) = request.respond {
let _ = respond.send(Ok(report));
}
}
Err(error) => {
match &error {
crate::reload::ReloadError::Frozen { .. } => warn!(
event = "server_config_reload_refused",
outcome = "failure",
generation = generation,
reason = error.kind(),
error = %error,
),
_ => error!(
event = "server_config_reload_failed",
outcome = "failure",
generation = generation,
reason = error.kind(),
error = %error,
),
}
if let Some(respond) = request.respond {
let _ = respond.send(Err(error));
}
}
}
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Role {
Acme,
Admin,
Metrics,
}
impl Role {
fn label(self) -> &'static str {
match self {
Self::Acme => "acme",
Self::Admin => "admin",
Self::Metrics => "metrics",
}
}
fn bind_key(self) -> &'static str {
match self {
Self::Acme => "server.bind_address",
Self::Admin => "admin.bind_address",
Self::Metrics => "metrics.bind_address",
}
}
}
enum SocketPlan {
Keep,
Serve(TcpListener),
Close,
}
struct SocketPlans {
acme: SocketPlan,
admin: SocketPlan,
metrics: SocketPlan,
bound: Vec<(Role, String)>,
}
impl SocketPlans {
fn rebound(&self) -> Vec<&'static str> {
self.bound.iter().map(|(role, _)| role.label()).collect()
}
fn publish(self, cells: &Cells) {
for (role, plan, handle) in [
(Role::Acme, self.acme, &cells.acme),
(Role::Admin, self.admin, &cells.admin),
(Role::Metrics, self.metrics, &cells.metrics),
] {
match plan {
SocketPlan::Keep => {}
SocketPlan::Serve(listener) => handle.serve(listener),
SocketPlan::Close => {
handle.close();
info!(
event = "server_listener_stopped",
outcome = "success",
listener = role.label(),
"switched off by a configuration reload: the socket is released \
and nothing new is accepted on it"
);
}
}
}
}
}
fn plan_sockets(
applied: &Config,
proposed: &Config,
) -> Result<SocketPlans, crate::reload::ReloadError> {
let mut bound = Vec::new();
let mut plan = |role: Role,
was: Option<&str>,
now: Option<&str>|
-> Result<SocketPlan, crate::reload::ReloadError> {
match (was, now) {
(None, None) => Ok(SocketPlan::Keep),
(Some(_), None) => Ok(SocketPlan::Close),
(Some(was), Some(now)) if was == now => Ok(SocketPlan::Keep),
(_, Some(now)) => {
let listener = crate::listener::bind_blocking(now).map_err(|error| {
error!(event = "server_socket_bind_failed",
outcome = "failure",
listener = role.label(),
bind_address = %now,
error = %error);
crate::reload::ReloadError::Build(format!(
"`{}` is `{now}`, which cannot be bound: {error}",
role.bind_key()
))
})?;
bound.push((role, bound_address(Some(&listener), now)));
Ok(SocketPlan::Serve(listener))
}
}
};
let acme = plan(
Role::Acme,
Some(&applied.server.bind_address),
Some(&proposed.server.bind_address),
)?;
let admin = plan(
Role::Admin,
applied
.admin
.enabled
.then_some(applied.admin.bind_address.as_str()),
proposed
.admin
.enabled
.then_some(proposed.admin.bind_address.as_str()),
)?;
let metrics = plan(
Role::Metrics,
applied
.metrics
.enabled
.then_some(applied.metrics.bind_address.as_str()),
proposed
.metrics
.enabled
.then_some(proposed.metrics.bind_address.as_str()),
)?;
Ok(SocketPlans {
acme,
admin,
metrics,
bound,
})
}
struct Reloaded {
report: crate::reload::ReloadReport,
config: Arc<Config>,
resolved: Vec<crate::config::ProfileConfig>,
logins: Option<Arc<crate::webadmin::LoginLimiter>>,
opened: Vec<(Role, String)>,
mounted: Vec<Arc<Profile>>,
}
struct Prepared {
config: Arc<Config>,
resolved: Vec<crate::config::ProfileConfig>,
parts: crate::GenerationParts,
generation: Generation,
sockets: SocketPlans,
logging: logging::PreparedLogging,
logging_filter_from_env: bool,
mounted: Vec<Arc<Profile>>,
unmounted: Vec<String>,
}
fn prepare_reload(
config: &Arc<Config>,
resolved: &[crate::config::ProfileConfig],
assembly: &crate::Assembly,
logins: Option<&crate::webadmin::LoginLimiter>,
) -> Result<Prepared, crate::reload::ReloadError> {
use crate::reload::{Applied, ReloadError, check_frozen};
let next = Arc::new(Config::load().map_err(|error| ReloadError::Load(error.to_string()))?);
let next_resolved = next
.resolve_profiles()
.map_err(|error| ReloadError::Load(error.to_string()))?;
check_frozen(
&Applied {
config,
profiles: resolved,
},
&Applied {
config: &next,
profiles: &next_resolved,
},
)?;
let logging = logging::prepare_logging(&next.logging).map_err(ReloadError::Build)?;
let logging_filter_from_env = logging.filter_from_env;
crate::webadmin::check_config(&next).map_err(|error| ReloadError::Build(error.to_string()))?;
check_metrics_config(&next).map_err(|error| ReloadError::Build(error.to_string()))?;
let sockets = plan_sockets(config, &next)?;
let parts = assembly
.build_parts(&next_resolved, &next)
.map_err(|error| ReloadError::Build(error.to_string()))?;
let generation = build_generation(&next, &next_resolved, assembly, &parts, logins)
.map_err(|error| ReloadError::Build(error.to_string()))?;
let running: std::collections::HashSet<&str> = resolved
.iter()
.map(|profile| profile.name.as_str())
.collect();
let mounted = generation
.profiles
.iter()
.filter(|profile| !running.contains(profile.name.as_str()))
.cloned()
.collect();
let next_names: std::collections::HashSet<&str> = next_resolved
.iter()
.map(|profile| profile.name.as_str())
.collect();
let unmounted = resolved
.iter()
.map(|profile| profile.name.clone())
.filter(|name| !next_names.contains(name.as_str()))
.collect();
Ok(Prepared {
config: next,
resolved: next_resolved,
parts,
generation,
sockets,
logging,
logging_filter_from_env,
mounted,
unmounted,
})
}
fn publish_reload(
prepared: Prepared,
applied: &Arc<Config>,
assembly: &crate::Assembly,
cells: &Cells,
generation: u64,
started: std::time::Instant,
) -> Reloaded {
use crate::reload::ReloadReport;
let Prepared {
config: next,
resolved: next_resolved,
parts,
generation: built,
sockets,
logging,
logging_filter_from_env,
mounted,
unmounted,
} = prepared;
let logging_reloaded = logging::publish_logging(logging);
let report = ReloadReport {
generation,
profiles: built
.profiles
.iter()
.map(|profile| profile.name.clone())
.collect(),
job_kinds: built.job_registry.kinds(),
tls_reloaded: built.tls.is_some(),
admin_tls_reloaded: built.admin_tls.is_some(),
listeners_rebound: sockets.rebound(),
logging_reloaded,
duration: started.elapsed(),
};
let next_logins = built.logins.clone();
assembly.publish_notifiers(parts.dispatchers);
assembly.publish_signers(parts.signers);
cells
.job_registry
.send_replace(Arc::new(built.job_registry));
cells.jobs.send_replace(Arc::new(next.jobs.clone()));
assembly.jobs.set_max_attempts(next.jobs.max_attempts);
cells.acme.set_tls(built.tls);
cells.admin.set_tls(built.admin_tls);
let opened = sockets.bound.clone();
sockets.publish(cells);
cells
.acme_router
.send_replace(built.acme_app.into_service::<axum::body::Body>());
cells.admin_router.send_replace(
built
.admin_app
.unwrap_or_default()
.into_service::<axum::body::Body>(),
);
for profile in unmounted {
warn!(
event = "profile_unmounted",
outcome = "advisory",
profile = %profile,
"the endpoint is no longer served: its accounts and orders stay in the \
database and come back if it is mounted again, but any issuance still in \
flight for it has no handler left to finish it"
);
}
if logging_filter_from_env && applied.logging.filter != next.logging.filter {
warn!(
event = "server_logging_filter_overridden",
outcome = "advisory",
configured = %next.logging.filter,
);
}
Reloaded {
report,
config: next,
resolved: next_resolved,
logins: next_logins,
opened,
mounted,
}
}
async fn announce_profile(profile: &Arc<Profile>) {
info!(
event = "profile_mounted",
outcome = "success",
profile = %profile.name,
directory = %profile.directory_url(),
challenge_bypass = profile.challenges.is_bypassed(),
eab_enabled = profile.eab.enabled
);
profile
.notify
.dispatch(crate::notify::NotifyEvent::ProfileMounted(
crate::notify::ProfileMountedData {
profile: profile.name.clone(),
},
))
.await;
}
async fn on_shutdown(mut receiver: tokio::sync::watch::Receiver<bool>) {
let _ = receiver.wait_for(|ready| *ready).await;
}
async fn announce_admin_listener(config: &Arc<Config>, database: &Arc<Database>, bound: &str) {
if crate::sqlite::admin_user::AdminUser::list_all(database)
.await
.is_ok_and(|users| users.is_empty())
{
warn!(
event = "admin_no_users",
outcome = "advisory",
"the web admin is enabled but has no operators: create one with \
`acme-proxy admin user create <username>`"
);
}
if config.admin.require_mfa
&& let Ok(count) = crate::admin::mfa::operators_without_a_factor(database.clone()).await
&& count > 0
{
warn!(
event = "admin_mfa_enrolment_pending",
outcome = "advisory",
count = count,
"admin.require_mfa is on and some operators have no second factor: \
their next sign-in will require enrolment before the session is usable"
);
}
let idle = Duration::from_secs(config.admin.session_idle_timeout_seconds);
if let Err(error) = crate::sqlite::admin_session::AdminSession::cleanup(idle, database).await {
error!(event = "admin_session_cleanup_failed", outcome = "failure", error = %error);
}
info!(
event = "admin_listening",
outcome = "success",
bind_address = %bound,
protocol = if config.admin.tls.enabled { "https" } else { "http" },
base_url = %config.admin.base_url
);
}
fn announce_metrics_listener(bound: &str) {
info!(
event = "metrics_listening",
outcome = "success",
bind_address = %bound,
"unauthenticated by design: the port is the boundary, so firewall it"
);
}
fn bound_address(listener: Option<&TcpListener>, configured: &str) -> String {
listener
.and_then(|listener| listener.local_addr().ok())
.map_or_else(|| configured.to_string(), |address| address.to_string())
}
async fn shutdown_signal() {
let ctrl_c = async {
tokio::signal::ctrl_c()
.await
.expect("failed to install Ctrl+C handler");
};
#[cfg(unix)]
let terminate = async {
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
.expect("failed to install signal handler")
.recv()
.await;
};
#[cfg(not(unix))]
let terminate = std::future::pending::<()>();
tokio::select! {
_ = ctrl_c => {},
_ = terminate => {},
}
}
struct AbortOnDrop(tokio::task::JoinHandle<()>);
impl Drop for AbortOnDrop {
fn drop(&mut self) {
self.0.abort();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn version_flag_reports_the_crate_version() {
let Err(error) = Cli::try_parse_from(["acme-proxy", "--version"]) else {
panic!("--version parsed as a command rather than printing a version");
};
assert_eq!(error.kind(), clap::error::ErrorKind::DisplayVersion);
assert!(error.to_string().contains(env!("CARGO_PKG_VERSION")));
}
#[test]
fn parse_cli_subcommands() {
let cli = Cli::try_parse_from(["acme-proxy"]).unwrap();
assert!(cli.command.is_none());
let cli = Cli::try_parse_from(["acme-proxy", "serve"]).unwrap();
assert!(matches!(cli.command, Some(Command::Serve)));
let cli = Cli::try_parse_from(["acme-proxy", "account", "list", "--json"]).unwrap();
assert!(matches!(
cli.command,
Some(Command::Account {
command: AccountCommand::List {
json: true,
profile: None,
limit: window::DEFAULT_LIMIT,
offset: 0
}
})
));
let cli = Cli::try_parse_from(["acme-proxy", "account", "show", "acct-1"]).unwrap();
assert!(matches!(
cli.command,
Some(Command::Account {
command: AccountCommand::Show { id, json: false }
}) if id == "acct-1"
));
let cli = Cli::try_parse_from([
"acme-proxy",
"account",
"update-contact",
"acct-1",
"--contact",
"mailto:test@example.com",
])
.unwrap();
assert!(matches!(
cli.command,
Some(Command::Account {
command: AccountCommand::UpdateContact { id, contact }
}) if id == "acct-1" && contact == vec!["mailto:test@example.com"]
));
let cli = Cli::try_parse_from(["acme-proxy", "account", "deactivate", "acct-1"]).unwrap();
assert!(matches!(
cli.command,
Some(Command::Account {
command: AccountCommand::Deactivate { id }
}) if id == "acct-1"
));
let cli = Cli::try_parse_from(["acme-proxy", "-y", "account", "delete", "acct-1"]).unwrap();
assert!(cli.yes);
assert!(matches!(
cli.command,
Some(Command::Account {
command: AccountCommand::Delete { id }
}) if id == "acct-1"
));
let cli = Cli::try_parse_from([
"acme-proxy",
"order",
"list",
"--account-id",
"acct-1",
"--status",
"pending",
"--json",
])
.unwrap();
assert!(matches!(
cli.command,
Some(Command::Order {
command: OrderCommand::List {
profile: None,
account_id: Some(a),
status: Some(s),
expiring_in: None,
hide_superseded: false,
limit: window::DEFAULT_LIMIT,
offset: 0,
json: true
}
}) if a == "acct-1" && s == "pending"
));
let cli = Cli::try_parse_from([
"acme-proxy",
"order",
"list",
"--expiring-in",
"30",
"--hide-superseded",
])
.unwrap();
assert!(matches!(
cli.command,
Some(Command::Order {
command: OrderCommand::List {
expiring_in: Some(30),
hide_superseded: true,
status: None,
account_id: None,
profile: None,
limit: window::DEFAULT_LIMIT,
offset: 0,
json: false
}
})
));
let cli = Cli::try_parse_from(["acme-proxy", "order", "show", "ord-1"]).unwrap();
assert!(matches!(
cli.command,
Some(Command::Order {
command: OrderCommand::Show { id, json: false }
}) if id == "ord-1"
));
let cli = Cli::try_parse_from(["acme-proxy", "order", "delete", "ord-1"]).unwrap();
assert!(matches!(
cli.command,
Some(Command::Order {
command: OrderCommand::Delete { id }
}) if id == "ord-1"
));
let cli = Cli::try_parse_from(["acme-proxy", "order", "revoke", "ord-1", "--reason", "1"])
.unwrap();
assert!(matches!(
cli.command,
Some(Command::Order {
command: OrderCommand::Revoke { id, reason: Some(1) }
}) if id == "ord-1"
));
let cli =
Cli::try_parse_from(["acme-proxy", "nonce", "cleanup", "--ttl-seconds", "60"]).unwrap();
assert!(matches!(
cli.command,
Some(Command::Nonce {
command: NonceCommand::Cleanup {
ttl_seconds: Some(60)
}
})
));
let cli = Cli::try_parse_from([
"acme-proxy",
"eab",
"create",
"--label",
"test-key",
"--json",
])
.unwrap();
assert!(matches!(
cli.command,
Some(Command::Eab {
command: EabCommand::Create { label: Some(l), profile: None, json: true }
}) if l == "test-key"
));
let cli = Cli::try_parse_from(["acme-proxy", "eab", "list"]).unwrap();
assert!(matches!(
cli.command,
Some(Command::Eab {
command: EabCommand::List { json: false }
})
));
let cli = Cli::try_parse_from(["acme-proxy", "eab", "show", "kid-1", "--json"]).unwrap();
assert!(matches!(
cli.command,
Some(Command::Eab {
command: EabCommand::Show { kid, json: true }
}) if kid == "kid-1"
));
let cli = Cli::try_parse_from(["acme-proxy", "upstream", "register", "--eab-kid", "kid-1"])
.unwrap();
assert!(matches!(
cli.command,
Some(Command::Upstream {
command: UpstreamCommand::Register { eab_kid: Some(kid), eab_hmac_key_file: None, profile: None }
}) if kid == "kid-1"
));
let cli = Cli::try_parse_from(["acme-proxy", "upstream", "register"]).unwrap();
assert!(matches!(
cli.command,
Some(Command::Upstream {
command: UpstreamCommand::Register {
eab_kid: None,
eab_hmac_key_file: None,
profile: None,
}
})
));
assert!(
Cli::try_parse_from(["acme-proxy", "upstream", "register", "--eab-hmac-key", "s"])
.is_err(),
"an EAB secret must not be accepted on the command line"
);
let cli = Cli::try_parse_from(["acme-proxy", "upstream", "show", "--json"]).unwrap();
assert!(matches!(
cli.command,
Some(Command::Upstream {
command: UpstreamCommand::Show {
json: true,
profile: None
}
})
));
let cli = Cli::try_parse_from(["acme-proxy", "eab", "revoke", "kid-1"]).unwrap();
assert!(matches!(
cli.command,
Some(Command::Eab {
command: EabCommand::Revoke { kid }
}) if kid == "kid-1"
));
let cli = Cli::try_parse_from(["acme-proxy", "admin", "user", "create", "alice"]).unwrap();
assert!(matches!(
cli.command,
Some(Command::Admin {
command: AdminCommand::User {
command: crate::cli::webadmin::AdminUserCommand::Create {
username,
password_file: None
}
}
}) if username == "alice"
));
let cli = Cli::try_parse_from([
"acme-proxy",
"admin",
"user",
"passwd",
"alice",
"--password-file",
"/run/secrets/pw",
])
.unwrap();
assert!(matches!(
cli.command,
Some(Command::Admin {
command: AdminCommand::User {
command: crate::cli::webadmin::AdminUserCommand::Passwd {
username,
password_file: Some(path)
}
}
}) if username == "alice" && path == std::path::Path::new("/run/secrets/pw")
));
for command in ["create", "passwd"] {
assert!(
Cli::try_parse_from([
"acme-proxy",
"admin",
"user",
command,
"alice",
"--password",
"hunter2",
])
.is_err(),
"`admin user {command}` must not accept a password on the command line"
);
}
let cli = Cli::try_parse_from(["acme-proxy", "account", "list", "--color", "never"])
.expect("--color is global and accepts `never`");
assert_eq!(cli.color, ColorChoice::Never);
let cli = Cli::try_parse_from(["acme-proxy", "--color", "always", "account", "list"])
.expect("--color is global, so it may precede the subcommand");
assert_eq!(cli.color, ColorChoice::Always);
assert_eq!(
Cli::try_parse_from(["acme-proxy", "account", "list"])
.unwrap()
.color,
ColorChoice::Auto,
"unset means auto"
);
assert!(
Cli::try_parse_from(["acme-proxy", "account", "list", "--color", "sometimes"]).is_err(),
"an unknown --color value must be refused, not ignored"
);
let cli = Cli::try_parse_from(["acme-proxy", "admin", "user", "totp", "status", "alice"])
.unwrap();
assert!(matches!(
cli.command,
Some(Command::Admin {
command: AdminCommand::User {
command: crate::cli::webadmin::AdminUserCommand::Totp {
command: crate::cli::webadmin::AdminUserTotpCommand::Status {
username,
json: false
}
}
}
}) if username == "alice"
));
let cli = Cli::try_parse_from([
"acme-proxy",
"admin",
"user",
"totp",
"recovery-codes",
"alice",
])
.unwrap();
assert!(matches!(
cli.command,
Some(Command::Admin {
command: AdminCommand::User {
command: crate::cli::webadmin::AdminUserCommand::Totp {
command: crate::cli::webadmin::AdminUserTotpCommand::RecoveryCodes {
username
}
}
}
}) if username == "alice"
));
assert!(
Cli::try_parse_from(["acme-proxy", "admin", "user", "totp", "enrol", "alice"]).is_err()
);
let cli =
Cli::try_parse_from(["acme-proxy", "-y", "admin", "user", "delete", "alice"]).unwrap();
assert!(cli.yes);
assert!(matches!(
cli.command,
Some(Command::Admin {
command: AdminCommand::User {
command: crate::cli::webadmin::AdminUserCommand::Delete { username }
}
}) if username == "alice"
));
let cli =
Cli::try_parse_from(["acme-proxy", "admin", "session", "list", "--json"]).unwrap();
assert!(matches!(
cli.command,
Some(Command::Admin {
command: AdminCommand::Session {
command: crate::cli::webadmin::AdminSessionCommand::List {
username: None,
json: true
}
}
})
));
assert!(
Cli::try_parse_from([
"acme-proxy",
"admin",
"session",
"revoke",
"--user",
"alice",
"--all",
])
.is_err(),
"--user and --all are mutually exclusive"
);
}
#[test]
fn a_database_error_renders_as_a_cli_error() {
let error = CliError::from(sqlx::Error::PoolClosed);
assert!(error.to_string().starts_with("database error: "), "{error}");
}
#[tokio::test]
async fn dispatch_routes_each_command() {
let database = Arc::new(Database::connect_in_memory().await.unwrap());
let config = Arc::new(Config::default());
let mut reader: &[u8] = &[];
let commands = vec![
Command::Account {
command: AccountCommand::List {
profile: None,
limit: window::DEFAULT_LIMIT,
offset: 0,
json: false,
},
},
Command::Order {
command: OrderCommand::List {
profile: None,
account_id: None,
status: None,
expiring_in: None,
hide_superseded: false,
limit: window::DEFAULT_LIMIT,
offset: 0,
json: false,
},
},
Command::Nonce {
command: NonceCommand::Cleanup {
ttl_seconds: Some(1),
},
},
Command::Eab {
command: EabCommand::List { json: false },
},
Command::Man,
Command::Completions {
shell: clap_complete::aot::Shell::Bash,
},
];
for command in commands {
dispatch(
Some(command),
true,
ColorChoice::Never,
&mut reader,
&config,
database.clone(),
)
.await
.expect("every command must succeed against an empty database");
}
}
#[tokio::test]
async fn dispatch_propagates_a_command_failure() {
let database = Arc::new(Database::connect_in_memory().await.unwrap());
let config = Arc::new(Config::default());
let mut reader: &[u8] = &[];
let error = dispatch(
Some(Command::Account {
command: AccountCommand::Show {
id: "acct-nope".to_string(),
json: false,
},
}),
true,
ColorChoice::Never,
&mut reader,
&config,
database,
)
.await
.expect_err("an unknown account must fail");
assert_eq!(error, CliError("no such account: acct-nope".to_string()));
}
mod serving {
use super::*;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
fn config_in(dir: impl AsRef<std::path::Path>, tls: bool) -> Config {
let dir = dir.as_ref();
let _lock = crate::config::ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let ca = dir.join("ca");
let body = format!(
r#"
[server]
bind_address = "127.0.0.1:0"
base_url = "http://localhost:3000"
[server.tls]
enabled = {tls}
cert_path = "{dir}/server.pem"
key_path = "{dir}/server.key"
[profiles.default]
signer.local_ca.cert_path = "{ca}.pem"
signer.local_ca.key_path = "{ca}.key"
signer.local_ca.crl_path = "{ca}.crl"
"#,
dir = dir.display(),
ca = ca.display(),
);
std::fs::write(dir.join("config.toml"), body).unwrap();
unsafe {
std::env::set_var("ACME_PROXY_CONFIG", dir.join("config").to_str().unwrap());
}
let config = Config::load().expect("the configuration must load");
unsafe {
std::env::remove_var("ACME_PROXY_CONFIG");
}
config
}
fn temp_dir() -> crate::testutil::TempDir {
crate::testutil::TempDir::new("serve")
}
async fn boot(
config: Config,
) -> (
SocketAddr,
tokio::sync::oneshot::Sender<()>,
tokio::task::JoinHandle<anyhow::Result<()>>,
) {
let database = Arc::new(Database::connect_in_memory().await.unwrap());
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let (tx, rx) = tokio::sync::oneshot::channel::<()>();
let handle = tokio::spawn(serve_on(Arc::new(config), database, listener, async {
let _ = rx.await;
}));
(addr, tx, handle)
}
#[tokio::test]
async fn a_cleartext_server_answers_then_shuts_down() {
let dir = temp_dir();
let (addr, shutdown, handle) = boot(config_in(&dir, false)).await;
let mut stream = TcpStream::connect(addr).await.unwrap();
stream
.write_all(b"GET /health HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")
.await
.unwrap();
let mut response = String::new();
stream.read_to_string(&mut response).await.unwrap();
assert!(response.starts_with("HTTP/1.1 200 OK"), "{response}");
shutdown.send(()).unwrap();
handle
.await
.unwrap()
.expect("a clean shutdown is not an error");
}
#[tokio::test]
async fn a_tls_server_answers_over_a_real_handshake() {
use tokio_rustls::TlsConnector;
use tokio_rustls::rustls::pki_types::ServerName;
let dir = temp_dir();
let (addr, shutdown, handle) = boot(config_in(&dir, true)).await;
let client =
crate::challenge::tls_alpn_01::accept_any_client_config(&[b"http/1.1"]).unwrap();
let stream = TcpStream::connect(addr).await.unwrap();
let mut tls = TlsConnector::from(client)
.connect(ServerName::try_from("localhost").unwrap(), stream)
.await
.unwrap();
tls.write_all(b"GET /health HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")
.await
.unwrap();
let mut response = String::new();
tls.read_to_string(&mut response).await.unwrap();
assert!(response.starts_with("HTTP/1.1 200 OK"), "{response}");
shutdown.send(()).unwrap();
handle
.await
.unwrap()
.expect("a clean shutdown is not an error");
}
#[tokio::test]
async fn all_three_listeners_serve_and_one_signal_stops_them() {
let dir = temp_dir();
let mut config = config_in(&dir, false);
config.admin.enabled = true;
config.metrics.enabled = true;
let database = Arc::new(Database::connect_in_memory().await.unwrap());
let acme_listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let admin_listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let metrics_listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let acme_addr = acme_listener.local_addr().unwrap();
let admin_addr = admin_listener.local_addr().unwrap();
let metrics_addr = metrics_listener.local_addr().unwrap();
assert_ne!(acme_addr, admin_addr);
assert_ne!(acme_addr, metrics_addr);
assert_ne!(admin_addr, metrics_addr);
let (tx, rx) = tokio::sync::oneshot::channel::<()>();
let handle = tokio::spawn(serve_on_with(
Arc::new(config),
database,
acme_listener,
Some(admin_listener),
Some(metrics_listener),
async {
let _ = rx.await;
},
));
for addr in [acme_addr, admin_addr] {
let response = get(addr, "/health").await;
assert!(
response.starts_with("HTTP/1.1 200 OK"),
"{addr}: {response}"
);
}
let admin = get(admin_addr, "/api/accounts").await;
assert!(admin.starts_with("HTTP/1.1 401"), "{admin}");
let acme = get(acme_addr, "/api/accounts").await;
assert!(acme.starts_with("HTTP/1.1 404"), "{acme}");
let directory = get(acme_addr, "/profile/default/directory").await;
assert!(directory.starts_with("HTTP/1.1 200 OK"), "{directory}");
let no_directory = get(admin_addr, "/profile/default/directory").await;
assert!(no_directory.starts_with("HTTP/1.1 404"), "{no_directory}");
let metrics = get(metrics_addr, "/metrics").await;
assert!(metrics.starts_with("HTTP/1.1 200 OK"), "{metrics}");
assert!(metrics.contains("acme_proxy_requests_total"), "{metrics}");
for addr in [acme_addr, admin_addr] {
let leaked = get(addr, "/metrics").await;
assert!(leaked.starts_with("HTTP/1.1 404"), "{addr}: {leaked}");
}
tx.send(()).unwrap();
tokio::time::timeout(Duration::from_secs(10), handle)
.await
.expect("every listener must stop on one signal")
.unwrap()
.expect("a clean shutdown is not an error");
}
#[tokio::test]
async fn a_metrics_bind_colliding_with_another_listener_is_refused() {
let dir = temp_dir();
for (other, set) in [
(
"server.bind_address",
Box::new(|c: &mut Config| {
c.metrics.bind_address = c.server.bind_address.clone()
}) as Box<dyn Fn(&mut Config)>,
),
(
"admin.bind_address",
Box::new(|c: &mut Config| {
c.admin.enabled = true;
c.metrics.bind_address = c.admin.bind_address.clone();
}),
),
] {
let mut config = config_in(&dir, false);
config.metrics.enabled = true;
set(&mut config);
let error = bind_metrics(&Arc::new(config))
.await
.expect_err("a shared socket must not start");
let message = error.to_string();
assert!(message.contains(other), "{message}");
}
}
#[tokio::test]
async fn a_metrics_bind_matching_a_disabled_admin_is_allowed() {
let dir = temp_dir();
let mut config = config_in(&dir, false);
config.metrics.enabled = true;
config.admin.enabled = false;
config.metrics.bind_address = config.admin.bind_address.clone();
let listener = bind_metrics(&Arc::new(config))
.await
.expect("a disabled panel holds no socket");
assert!(listener.is_some());
}
#[tokio::test]
async fn metrics_disabled_binds_nothing() {
let dir = temp_dir();
let config = config_in(&dir, false);
assert!(!config.metrics.enabled);
assert!(bind_metrics(&Arc::new(config)).await.unwrap().is_none());
}
#[tokio::test]
async fn the_admin_listener_answers_over_its_own_tls() {
use tokio_rustls::TlsConnector;
use tokio_rustls::rustls::pki_types::ServerName;
let dir = temp_dir();
let mut config = config_in(&dir, false);
config.admin.enabled = true;
config.admin.tls.enabled = true;
config.admin.tls.cert_path = dir.as_ref().join("admin.pem").display().to_string();
config.admin.tls.key_path = dir.as_ref().join("admin.key").display().to_string();
config.admin.base_url = "https://localhost:3001".to_string();
let database = Arc::new(Database::connect_in_memory().await.unwrap());
let acme_listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let admin_listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let acme_addr = acme_listener.local_addr().unwrap();
let admin_addr = admin_listener.local_addr().unwrap();
let (tx, rx) = tokio::sync::oneshot::channel::<()>();
let handle = tokio::spawn(serve_on_with(
Arc::new(config),
database,
acme_listener,
Some(admin_listener),
None,
async {
let _ = rx.await;
},
));
let acme = get(acme_addr, "/health").await;
assert!(acme.starts_with("HTTP/1.1 200 OK"), "{acme}");
let client =
crate::challenge::tls_alpn_01::accept_any_client_config(&[b"http/1.1"]).unwrap();
let stream = TcpStream::connect(admin_addr).await.unwrap();
let mut tls = TlsConnector::from(client)
.connect(ServerName::try_from("localhost").unwrap(), stream)
.await
.expect("the admin listener must complete a handshake");
tls.write_all(
b"GET /api/accounts HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n",
)
.await
.unwrap();
let mut response = String::new();
tls.read_to_string(&mut response).await.unwrap();
assert!(
response.starts_with("HTTP/1.1 401"),
"the admin API answers over TLS, unauthenticated: {response}"
);
assert!(dir.as_ref().join("admin.pem").exists());
assert!(dir.as_ref().join("admin.key").exists());
assert!(!dir.as_ref().join("server.pem").exists());
tx.send(()).unwrap();
tokio::time::timeout(Duration::from_secs(10), handle)
.await
.expect("both listeners must stop")
.unwrap()
.expect("a clean shutdown is not an error");
}
#[tokio::test]
async fn the_admin_listener_is_absent_by_default() {
let dir = temp_dir();
let config = config_in(&dir, false);
assert!(!config.admin.enabled, "the default must stay off");
let (addr, shutdown, handle) = boot(config).await;
let response = get(addr, "/api/accounts").await;
assert!(response.starts_with("HTTP/1.1 404"), "{response}");
shutdown.send(()).unwrap();
handle.await.unwrap().unwrap();
}
#[tokio::test]
async fn an_invalid_admin_section_refuses_to_serve() {
let dir = temp_dir();
let mut config = config_in(&dir, false);
config.admin.enabled = true;
config.admin.bind_address = "0.0.0.0:0".to_string();
let database = Arc::new(Database::connect_in_memory().await.unwrap());
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let error = serve_on(Arc::new(config), database, listener, std::future::ready(()))
.await
.expect_err("a panel that cannot work must not start");
assert!(error.to_string().contains("is not loopback"), "{error}");
}
async fn get(addr: SocketAddr, path: &str) -> String {
let mut stream = TcpStream::connect(addr).await.unwrap();
stream
.write_all(
format!("GET {path} HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")
.as_bytes(),
)
.await
.unwrap();
let mut response = String::new();
stream.read_to_string(&mut response).await.unwrap();
response
}
#[tokio::test]
async fn a_configuration_with_no_profile_refuses_to_serve() {
let database = Arc::new(Database::connect_in_memory().await.unwrap());
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let error = serve_on(
Arc::new(Config::default()),
database,
listener,
std::future::ready(()),
)
.await
.expect_err("a server with no endpoint must not start");
assert!(error.to_string().contains("profile"), "{error}");
}
#[tokio::test]
async fn dispatch_serve_reports_a_startup_failure() {
let database = Arc::new(Database::connect_in_memory().await.unwrap());
let mut config = Config::default();
config.server.bind_address = "127.0.0.1:0".to_string();
let mut reader: &[u8] = &[];
let error = dispatch(
Some(Command::Serve),
true,
ColorChoice::Never,
&mut reader,
&Arc::new(config),
database,
)
.await
.expect_err("a server with no endpoint must not start");
assert!(error.to_string().contains("profile"), "{error}");
}
#[tokio::test]
async fn unusable_tls_material_stops_startup() {
let dir = temp_dir();
let mut config = config_in(&dir, true);
std::fs::write(dir.join("server.pem"), "not a certificate").unwrap();
std::fs::write(dir.join("server.key"), "not a key").unwrap();
config.server.tls.cert_path = dir.join("server.pem").display().to_string();
config.server.tls.key_path = dir.join("server.key").display().to_string();
let database = Arc::new(Database::connect_in_memory().await.unwrap());
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let error = serve_on(Arc::new(config), database, listener, std::future::ready(()))
.await
.expect_err("unreadable TLS material must not start a server");
assert!(!error.to_string().is_empty());
}
#[tokio::test]
async fn an_unbindable_address_is_reported() {
let database = Arc::new(Database::connect_in_memory().await.unwrap());
let mut config = Config::default();
config.server.bind_address = "192.0.2.1:1".to_string();
let error = serve(Arc::new(config), database)
.await
.expect_err("binding an unroutable address must fail");
assert!(error.to_string().contains("192.0.2.1:1"), "{error}");
}
}
}