use std::sync::Arc;
use std::time::Duration;
use tokio::net::TcpListener;
use tracing::{error, info, warn};
use acme_proxy_core::config::Config;
use acme_proxy_store::db::Database;
use super::supervisor::Cells;
pub(super) async fn bind_admin(config: &Arc<Config>) -> anyhow::Result<Option<TcpListener>> {
acme_proxy_admin::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(())
}
pub(super) 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))
}
#[derive(Default)]
pub struct Sockets {
pub acme: Option<TcpListener>,
pub admin: Option<TcpListener>,
pub metrics: Option<TcpListener>,
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub(super) enum Role {
Acme,
Admin,
Metrics,
}
impl Role {
pub(super) 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",
}
}
}
pub(super) enum SocketPlan {
Keep,
Serve(TcpListener),
Close,
}
pub(super) struct SocketPlans {
acme: SocketPlan,
admin: SocketPlan,
metrics: SocketPlan,
pub(super) bound: Vec<(Role, String)>,
}
impl SocketPlans {
pub(super) fn rebound(&self) -> Vec<&'static str> {
self.bound.iter().map(|(role, _)| role.label()).collect()
}
pub(super) 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"
);
}
}
}
}
}
pub(super) fn plan_sockets(
roles: super::RoleSet,
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 = acme_proxy_net::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_enabled = roles.has(super::ProcessRole::Acme);
let admin_enabled =
|config: &Config| roles.has(super::ProcessRole::Admin) && config.admin.enabled;
let acme = plan(
Role::Acme,
acme_enabled.then_some(applied.server.bind_address.as_str()),
acme_enabled.then_some(proposed.server.bind_address.as_str()),
)?;
let admin = plan(
Role::Admin,
admin_enabled(applied).then_some(applied.admin.bind_address.as_str()),
admin_enabled(proposed).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,
})
}
pub(super) async fn announce_admin_listener(
config: &Arc<Config>,
database: &Arc<Database>,
bound: &str,
) {
if acme_proxy_store::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) =
acme_proxy_admin::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"
);
}
if config.admin.notify.enabled.is_empty() {
} else if let Ok(count) =
acme_proxy_admin::admin::users::operators_without_a_contact(database.clone()).await
&& count > 0
{
warn!(
event = "admin_notify_contact_missing",
outcome = "advisory",
count = count,
"[admin.notify] is configured but some operators have no contact address: their \
security notifications fall back to notify.email.to, or are dropped if that is \
empty too — set one with `acme-proxy admin user contact <username> --contact <address>`"
);
}
let idle = Duration::from_secs(config.admin.session_idle_timeout_seconds);
if let Err(error) = acme_proxy_store::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
);
}
pub(super) 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"
);
}
pub(super) 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())
}