pub mod error;
pub mod filter;
pub mod handlers;
pub mod pages;
pub mod session;
pub use error::AdminError;
pub use pages::PageError;
pub use session::LoginLimiter;
use std::any::Any;
use std::collections::HashMap;
use std::sync::Arc;
use anyhow::bail;
use axum::extract::DefaultBodyLimit;
use axum::http::{HeaderValue, StatusCode, header};
use axum::response::{IntoResponse, Redirect, Response};
use axum::routing::{get, post};
use axum::{Router, middleware};
use tower_http::catch_panic::CatchPanicLayer;
use tower_http::set_header::SetResponseHeaderLayer;
use tracing::{info, warn};
use acme_proxy_core::config::Config;
use acme_proxy_protocol::middlewares;
use acme_proxy_protocol::profile::Profile;
use acme_proxy_store::db::Database;
#[derive(Clone)]
pub struct AdminState {
pub database: Arc<Database>,
pub config: Arc<Config>,
pub profiles: Arc<HashMap<String, Arc<Profile>>>,
pub logins: Arc<LoginLimiter>,
pub templates: Arc<minijinja::Environment<'static>>,
pub audit: Arc<acme_proxy_jobs::auditor::Auditor>,
pub notifiers: acme_proxy_jobs::notify::Notifiers,
pub jobs: acme_proxy_jobs::jobs::JobQueue,
}
impl AdminState {
#[must_use]
pub fn new(
database: Arc<Database>,
config: Arc<Config>,
profiles: &[Arc<Profile>],
audit: Arc<acme_proxy_jobs::auditor::Auditor>,
notifiers: acme_proxy_jobs::notify::Notifiers,
jobs: acme_proxy_jobs::jobs::JobQueue,
) -> Self {
Self::with_logins(database, config, profiles, audit, notifiers, jobs, None)
}
#[must_use]
pub fn with_logins(
database: Arc<Database>,
config: Arc<Config>,
profiles: &[Arc<Profile>],
audit: Arc<acme_proxy_jobs::auditor::Auditor>,
notifiers: acme_proxy_jobs::notify::Notifiers,
jobs: acme_proxy_jobs::jobs::JobQueue,
previous_logins: Option<&LoginLimiter>,
) -> Self {
let by_name = profiles
.iter()
.map(|profile| (profile.name.clone(), profile.clone()))
.collect();
let max_attempts = config.admin.login_max_attempts;
let window = config.admin.login_window_seconds;
let logins = match previous_logins {
Some(previous) => previous.rebuilt(max_attempts, window),
None => LoginLimiter::new(max_attempts, window),
};
let templates = pages::templates::build_environment(&config.admin.template_dir);
Self {
database,
config,
profiles: Arc::new(by_name),
logins: Arc::new(logins),
templates: Arc::new(templates),
audit,
notifiers,
jobs,
}
}
pub(crate) async fn record_admin_action(
&self,
request_context: &acme_proxy_core::audit::RequestContext,
username: &str,
build: impl FnOnce(
acme_proxy_core::audit::Actor,
acme_proxy_core::audit::ClientContext,
) -> acme_proxy_core::audit::AuditRecord,
) {
let actor = acme_proxy_core::audit::Actor::admin(username);
let client = self.audit.client(request_context).await;
self.audit.record(build(actor, client)).await;
}
pub(crate) async fn record_admin_actions(
&self,
request_context: &acme_proxy_core::audit::RequestContext,
username: &str,
build: impl FnOnce(
acme_proxy_core::audit::Actor,
acme_proxy_core::audit::ClientContext,
) -> Vec<acme_proxy_core::audit::AuditRecord>,
) {
let actor = acme_proxy_core::audit::Actor::admin(username);
let client = self.audit.client(request_context).await;
for record in build(actor, client) {
self.audit.record(record).await;
}
}
pub(crate) async fn notify_security(&self, event: acme_proxy_jobs::notify::NotifyEvent) {
if let Some(dispatcher) = self
.notifiers
.get(acme_proxy_jobs::notify::ADMIN_DISPATCHER_KEY)
{
dispatcher.dispatch(event).await;
}
}
pub(crate) async fn notify_credential_change(
&self,
user: &acme_proxy_store::admin_user::AdminUser,
change: acme_proxy_jobs::notify::AdminCredentialChange,
by_self: bool,
client: Option<std::net::IpAddr>,
user_agent: Option<String>,
previous_recipient: Option<String>,
) {
self.notify_security(
acme_proxy_jobs::notify::NotifyEvent::AdminCredentialChanged(
acme_proxy_jobs::notify::AdminCredentialChangeData::new(
user,
change,
by_self,
client.map(|ip| ip.to_string()),
user_agent,
previous_recipient,
),
),
)
.await;
}
pub(crate) async fn record_credential_change(
&self,
request_context: &acme_proxy_core::audit::RequestContext,
actor: &str,
user: &acme_proxy_store::admin_user::AdminUser,
change: CredentialChange,
by_self: bool,
client: Option<std::net::IpAddr>,
) {
use CredentialChange as Change;
self.record_admin_action(request_context, actor, |audit_actor, ctx| match change {
Change::Password => acme_proxy_jobs::auditor::admin::operator_password_changed(
audit_actor,
ctx,
&user.username,
by_self,
),
Change::SecondFactorEnabled => acme_proxy_jobs::auditor::admin::operator_totp_enrolled(
audit_actor,
ctx,
&user.username,
),
Change::SecondFactorDisabled => {
acme_proxy_jobs::auditor::admin::operator_totp_disabled(
audit_actor,
ctx,
&user.username,
!by_self,
)
}
Change::RecoveryCodesRegenerated => {
acme_proxy_jobs::auditor::admin::operator_recovery_codes_regenerated(
audit_actor,
ctx,
&user.username,
)
}
})
.await;
self.notify_credential_change(
user,
change.into(),
by_self,
client,
request_context.user_agent.clone(),
None,
)
.await;
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum CredentialChange {
Password,
SecondFactorEnabled,
SecondFactorDisabled,
RecoveryCodesRegenerated,
}
impl From<CredentialChange> for acme_proxy_jobs::notify::AdminCredentialChange {
fn from(change: CredentialChange) -> Self {
match change {
CredentialChange::Password => Self::Password,
CredentialChange::SecondFactorEnabled => Self::SecondFactorEnabled,
CredentialChange::SecondFactorDisabled => Self::SecondFactorDisabled,
CredentialChange::RecoveryCodesRegenerated => Self::RecoveryCodesRegenerated,
}
}
}
pub(crate) struct WebTrail<'a> {
pub(crate) state: &'a AdminState,
pub(crate) request_context: &'a acme_proxy_core::audit::RequestContext,
pub(crate) actor: &'a str,
pub(crate) client: Option<std::net::IpAddr>,
pub(crate) by_self: bool,
}
impl crate::admin::changes::OperatorTrail for WebTrail<'_> {
async fn record(
&self,
build: impl FnOnce(
acme_proxy_core::audit::Actor,
acme_proxy_core::audit::ClientContext,
) -> acme_proxy_core::audit::AuditRecord
+ Send,
) {
self.state
.record_admin_action(self.request_context, self.actor, build)
.await;
}
async fn notify(
&self,
user: &acme_proxy_store::admin_user::AdminUser,
change: acme_proxy_jobs::notify::AdminCredentialChange,
previous_recipient: Option<String>,
) {
self.state
.notify_credential_change(
user,
change,
self.by_self,
self.client,
self.request_context.user_agent.clone(),
previous_recipient,
)
.await;
}
}
pub(crate) fn user_agent_of(headers: &axum::http::HeaderMap) -> Option<String> {
headers
.get(axum::http::header::USER_AGENT)
.and_then(|value| value.to_str().ok())
.map(|value| {
value
.chars()
.take(acme_proxy_core::audit::USER_AGENT_MAX)
.collect::<String>()
})
.filter(|value| !value.is_empty())
}
pub fn build_admin_app(
database: Arc<Database>,
config: Arc<Config>,
profiles: &[Arc<Profile>],
audit: Arc<acme_proxy_jobs::auditor::Auditor>,
notifiers: acme_proxy_jobs::notify::Notifiers,
jobs: acme_proxy_jobs::jobs::JobQueue,
) -> Router {
let policy = filter::build(&config).expect("admin.filter must be valid");
build_admin_app_with_logins(
database, config, profiles, audit, notifiers, jobs, policy, None,
)
.0
}
#[allow(clippy::too_many_arguments)]
pub fn build_admin_app_with_logins(
database: Arc<Database>,
config: Arc<Config>,
profiles: &[Arc<Profile>],
audit: Arc<acme_proxy_jobs::auditor::Auditor>,
notifiers: acme_proxy_jobs::notify::Notifiers,
jobs: acme_proxy_jobs::jobs::JobQueue,
policy: Arc<acme_proxy_policy::filter::FilterPolicy>,
previous_logins: Option<&LoginLimiter>,
) -> (Router, Arc<LoginLimiter>) {
let state = AdminState::with_logins(
database,
config.clone(),
profiles,
audit,
notifiers,
jobs,
previous_logins,
);
let logins = state.logins.clone();
let api = Router::new()
.route(
"/session",
post(handlers::post_session)
.get(handlers::get_session)
.delete(handlers::delete_session),
)
.route(
"/session/mfa",
get(handlers::get_session_mfa).post(handlers::post_session_mfa),
)
.route("/mfa", get(handlers::get_mfa))
.route(
"/mfa/totp",
post(handlers::begin_totp).delete(handlers::disable_totp),
)
.route("/mfa/totp/confirm", post(handlers::confirm_totp))
.route(
"/mfa/recovery-codes",
post(handlers::regenerate_recovery_codes),
)
.route("/account/password", post(handlers::change_password))
.route("/account/contact", post(handlers::change_contact))
.route("/account/sessions", get(handlers::list_own_sessions))
.route(
"/account/sessions/{id}/revoke",
post(handlers::revoke_own_session),
)
.route("/operators", get(handlers::list_operators))
.route("/operators/{username}", get(handlers::get_operator))
.route(
"/operators/{username}/sessions",
get(handlers::list_operator_sessions),
)
.route(
"/operators/{username}/disable",
post(handlers::disable_operator),
)
.route(
"/operators/{username}/enable",
post(handlers::enable_operator),
)
.route(
"/operators/{username}/totp/reset",
post(handlers::reset_operator_totp),
)
.route(
"/operators/{username}/contact",
post(handlers::set_operator_contact),
)
.route(
"/operators/{username}/role",
post(handlers::set_operator_role),
)
.route(
"/operators/{username}/sessions/{id}/revoke",
post(handlers::revoke_operator_session),
)
.route("/accounts", get(handlers::list_accounts))
.route(
"/accounts/{id}",
get(handlers::get_account)
.patch(handlers::patch_account)
.delete(handlers::delete_account),
)
.route("/accounts/{id}/orders", get(handlers::list_account_orders))
.route(
"/accounts/{id}/deactivate",
post(handlers::deactivate_account),
)
.route("/orders", get(handlers::list_orders))
.route(
"/orders/{id}",
get(handlers::get_order).delete(handlers::delete_order),
)
.route("/orders/{id}/revoke", post(handlers::revoke_order))
.route("/eab", get(handlers::list_eab).post(handlers::create_eab))
.route(
"/eab/{kid}",
get(handlers::get_eab).delete(handlers::delete_eab),
)
.route("/eab/{kid}/revoke", post(handlers::revoke_eab))
.route("/jobs", get(handlers::list_jobs))
.route("/jobs/{id}", get(handlers::get_job))
.route("/jobs/{id}/cancel", post(handlers::cancel_job))
.route("/jobs/{id}/run", post(handlers::run_job))
.route("/upstream-orders", get(handlers::list_upstream_orders))
.route("/upstream-orders/{id}", get(handlers::get_upstream_order))
.route("/audit", get(handlers::list_audit))
.route("/audit/{id}", get(handlers::get_audit))
.route("/expiring", get(handlers::list_expiring))
.route("/nonces", get(handlers::get_nonces))
.route("/nonces/cleanup", post(handlers::cleanup_nonces))
.route("/profiles", get(handlers::list_profiles))
.route("/profiles/{name}/filter", get(handlers::get_profile_filter));
let router = Router::new()
.route(
"/health",
get(acme_proxy_protocol::handlers::get_health_check),
)
.route("/", get(|| async { Redirect::to("/ui/") }))
.merge(api_with_fallbacks(api))
.merge(pages::pages_router())
.fallback(|| async { PageError::not_found("no such page") })
.with_state(state)
.layer(catch_panic_admin_pages())
.layer(DefaultBodyLimit::max(config.admin.max_body_bytes))
.layer(SetResponseHeaderLayer::overriding(
header::CACHE_CONTROL,
HeaderValue::from_static("no-store"),
))
.layer(SetResponseHeaderLayer::overriding(
header::REFERRER_POLICY,
HeaderValue::from_static("same-origin"),
))
.layer(acme_proxy_protocol::router::security_headers())
.layer(SetResponseHeaderLayer::overriding(
header::CONTENT_SECURITY_POLICY,
HeaderValue::from_static(
"default-src 'none'; script-src 'self'; style-src 'self'; \
img-src 'self' data:; connect-src 'self'; form-action 'self'; \
frame-ancestors 'none'; base-uri 'none'",
),
))
.layer(middleware::from_fn_with_state(
policy,
filter::admin_filter_middleware,
))
.layer(middleware::from_fn(
middlewares::access::add_access_middleware,
));
(router, logins)
}
fn api_with_fallbacks(api: Router<AdminState>) -> Router<AdminState> {
Router::new().nest(
"/api",
api.method_not_allowed_fallback(|| async {
AdminError::with_code(
StatusCode::METHOD_NOT_ALLOWED,
"method_not_allowed",
"that method is not allowed on this resource",
)
})
.fallback(|| async { AdminError::not_found("no such admin API resource") })
.layer(catch_panic_admin_api()),
)
}
fn admin_api_panic_response(err: Box<dyn Any + Send + 'static>) -> Response {
tracing::error!(
event = "request_handler_panicked",
outcome = "failure",
listener = "admin",
surface = "api",
error = %acme_proxy_protocol::router::panic_message(err.as_ref()),
);
AdminError::internal().into_response()
}
fn admin_page_panic_response(err: Box<dyn Any + Send + 'static>) -> Response {
tracing::error!(
event = "request_handler_panicked",
outcome = "failure",
listener = "admin",
surface = "ui",
error = %acme_proxy_protocol::router::panic_message(err.as_ref()),
);
PageError::internal().into_response()
}
pub fn catch_panic_admin_api() -> CatchPanicLayer<fn(Box<dyn Any + Send + 'static>) -> Response> {
CatchPanicLayer::custom(
admin_api_panic_response as fn(Box<dyn Any + Send + 'static>) -> Response,
)
}
pub fn catch_panic_admin_pages() -> CatchPanicLayer<fn(Box<dyn Any + Send + 'static>) -> Response> {
CatchPanicLayer::custom(
admin_page_panic_response as fn(Box<dyn Any + Send + 'static>) -> Response,
)
}
pub fn check_config(config: &Config) -> anyhow::Result<()> {
let admin = &config.admin;
if !admin.enabled {
return Ok(());
}
if admin.bind_address == config.server.bind_address {
bail!(
"admin.bind_address and server.bind_address are both `{}`: the web admin is a \
second listener on its own socket, not a path on the ACME one",
admin.bind_address
);
}
let url = url::Url::parse(&admin.base_url).map_err(|error| {
anyhow::anyhow!("admin.base_url `{}` is not a URL: {error}", admin.base_url)
})?;
if url.host_str().is_none_or(str::is_empty) {
bail!(
"admin.base_url `{}` has no host: it names the origin the panel is reached at, \
and a generated certificate takes its name from it",
admin.base_url
);
}
if !admin.tls.enabled && !binds_loopback_only(&admin.bind_address) {
bail!(
"admin.bind_address `{}` is not loopback while admin.tls.enabled is false: the \
session cookie is sent `Secure`, which a browser will not store over plain HTTP \
on anything but localhost, so signing in would appear to succeed and then fail \
silently. Set admin.tls.enabled = true, or bind 127.0.0.1 and reach it through \
an SSH tunnel",
admin.bind_address
);
}
if admin.tls.enabled && url.scheme() == "http" {
warn!(event = "admin_base_url_mismatch",
outcome = "advisory",
base_url = %admin.base_url,
"admin.base_url names http:// while admin.tls.enabled is true: the CSRF origin \
check compares against it, so browser requests will be refused until it names \
https://");
}
info!(event = "admin_origin_resolved",
outcome = "success",
origin = %url.origin().ascii_serialization(),
bind_address = %admin.bind_address);
check_templates(&admin.template_dir)?;
filter::build(config)?;
Ok(())
}
fn check_templates(template_dir: &str) -> anyhow::Result<()> {
if !template_dir.is_empty() {
let path = std::path::Path::new(template_dir);
if !path.is_dir() {
bail!(
"admin.template_dir `{template_dir}` is not a directory: it holds per-file \
overrides of the compiled-in page templates, checked by name before the \
default. Leave it empty to use the defaults"
);
}
}
let env = pages::templates::build_environment(template_dir);
for name in pages::templates::template_names() {
env.get_template(name).map_err(|error| {
anyhow::anyhow!(
"admin page template `{name}` does not compile: {error}. \
It was loaded from admin.template_dir `{template_dir}`"
)
})?;
}
if !template_dir.is_empty() {
info!(event = "admin_templates_overridden", outcome = "success", template_dir = %template_dir);
}
Ok(())
}
fn binds_loopback_only(bind: &str) -> bool {
let Ok(addr) = bind.parse::<std::net::SocketAddr>() else {
return matches!(
bind.rsplit_once(':').map(|(host, _)| host),
Some("localhost" | "ip6-localhost")
);
};
addr.ip().is_loopback()
}
#[cfg(test)]
mod tests {
use super::*;
use acme_proxy_core::config::AdminConfig;
use acme_proxy_core::config::Config;
fn enabled() -> Config {
let mut config = Config::default();
config.admin = AdminConfig {
enabled: true,
..AdminConfig::default()
};
config
}
#[test]
fn a_disabled_panel_is_never_checked() {
let mut config = Config::default();
config.admin = AdminConfig {
enabled: false,
bind_address: "0.0.0.0:3001".to_string(),
base_url: "not a url".to_string(),
..AdminConfig::default()
};
assert!(check_config(&config).is_ok());
}
#[test]
fn the_defaults_are_a_working_configuration() {
check_config(&enabled()).expect("loopback + the default base_url must start");
}
#[test]
fn the_embedded_templates_all_compile_at_startup() {
check_templates("").expect("the shipped templates must compile");
}
#[test]
fn a_template_dir_that_is_not_a_directory_is_refused() {
let dir = acme_proxy_core::testutil::TempDir::new("admin-template-dir");
let file = dir.write("not-a-directory", "");
let error = check_templates(file.to_str().unwrap()).unwrap_err();
assert!(error.to_string().contains("is not a directory"));
}
#[test]
fn an_override_that_does_not_compile_is_refused_at_startup() {
let dir = acme_proxy_core::testutil::TempDir::new("admin-bad-template");
dir.write("index.html", "{% for x in %}");
let error = check_templates(dir.path().to_str().unwrap()).unwrap_err();
let message = error.to_string();
assert!(message.contains("index.html"));
assert!(message.contains("does not compile"));
}
#[test]
fn a_valid_override_directory_starts() {
let dir = acme_proxy_core::testutil::TempDir::new("admin-good-template");
dir.write("login.html", "<p>{{ flash }}</p>");
check_templates(dir.path().to_str().unwrap())
.expect("one overridden template must not stop the other twenty");
}
#[test]
fn a_bind_shared_with_the_acme_listener_is_refused() {
let mut config = enabled();
config.admin.bind_address = config.server.bind_address.clone();
let error = check_config(&config).unwrap_err().to_string();
assert!(error.contains("second listener"), "got: {error}");
assert!(error.contains(&config.server.bind_address));
}
#[test]
fn a_base_url_that_is_not_a_url_or_has_no_host_is_refused() {
let mut config = enabled();
config.admin.base_url = "not a url".to_string();
assert!(
check_config(&config)
.unwrap_err()
.to_string()
.contains("is not a URL")
);
config.admin.base_url = "unix:/run/admin.sock".to_string();
let error = check_config(&config).unwrap_err().to_string();
assert!(error.contains("has no host"), "got: {error}");
}
#[test]
fn a_non_loopback_bind_without_tls_is_a_startup_error_not_a_warning() {
for bind in [
"0.0.0.0:3001",
"192.0.2.10:3001",
"[::]:3001",
"[2001:db8::1]:3001",
] {
let mut config = enabled();
config.admin.bind_address = bind.to_string();
let error = check_config(&config).unwrap_err().to_string();
assert!(
error.contains("is not loopback"),
"`{bind}` must be refused without TLS, got: {error}"
);
}
}
#[test]
fn a_broken_admin_filter_is_a_startup_error() {
let mut config = enabled();
config.admin.filter.trusted_proxies = vec!["not-a-network".to_string()];
let error = check_config(&config).unwrap_err().to_string();
assert!(error.starts_with("admin.filter: "), "{error}");
}
#[test]
fn a_non_loopback_bind_is_allowed_once_tls_is_on() {
let mut config = enabled();
config.admin.bind_address = "0.0.0.0:3001".to_string();
config.admin.tls.enabled = true;
config.admin.base_url = "https://admin.example.com".to_string();
check_config(&config).expect("TLS is what the loopback rule was standing in for");
}
#[test]
fn every_loopback_spelling_is_accepted_without_tls() {
for bind in [
"127.0.0.1:3001",
"127.0.0.53:3001",
"[::1]:3001",
"localhost:3001",
] {
let mut config = enabled();
config.admin.bind_address = bind.to_string();
check_config(&config).unwrap_or_else(|error| panic!("`{bind}` must start: {error}"));
}
}
#[test]
fn an_unparseable_bind_is_treated_as_non_loopback() {
let mut config = enabled();
config.admin.bind_address = "not-a-socket-address".to_string();
assert!(
check_config(&config)
.unwrap_err()
.to_string()
.contains("is not loopback")
);
}
#[test]
fn tls_with_an_http_base_url_warns_but_starts() {
let mut config = enabled();
config.admin.tls.enabled = true;
check_config(&config).expect("a scheme mismatch is a warning, not a refusal");
}
mod catch_panic {
use super::*;
use axum::body::{Body, to_bytes};
use axum::http::{Request, StatusCode};
use tower::ServiceExt;
async fn boom() -> &'static str {
panic!("this handler panics on purpose")
}
#[tokio::test]
async fn an_api_panic_is_the_json_admin_error() {
let response = admin_api_panic_response(Box::new("secret internal detail"));
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
assert_eq!(
response
.headers()
.get(header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok()),
Some("application/json"),
);
let bytes = to_bytes(response.into_body(), 64 * 1024).await.unwrap();
let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(body["error"], "internal");
let text = String::from_utf8(bytes.to_vec()).unwrap();
assert!(!text.contains("urn:ietf:params:acme"));
assert!(
!text.contains("secret internal detail"),
"the panic message must not reach the client",
);
}
#[tokio::test]
async fn a_page_panic_is_an_html_document() {
let response = admin_page_panic_response(Box::new(String::from("boom")));
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
let bytes = to_bytes(response.into_body(), 64 * 1024).await.unwrap();
let text = String::from_utf8(bytes.to_vec()).unwrap();
assert!(text.starts_with("<!doctype html>"), "got: {text}");
assert!(text.contains("internal"));
}
#[tokio::test]
async fn each_layer_catches_a_panicking_route() {
for layer_name in ["api", "pages"] {
let router: Router = if layer_name == "api" {
Router::new()
.route("/boom", get(boom))
.layer(catch_panic_admin_api())
} else {
Router::new()
.route("/boom", get(boom))
.layer(catch_panic_admin_pages())
};
let response = router
.oneshot(Request::get("/boom").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(
response.status(),
StatusCode::INTERNAL_SERVER_ERROR,
"the {layer_name} layer must catch the panic",
);
}
}
}
}