Skip to main content

acme_proxy_admin/webadmin/
mod.rs

1//! The web admin interface: a second HTTP listener, serving no ACME.
2//!
3//! ## Where this sits
4//!
5//! `src/cli/` and `crates/admin/src/webadmin/` are the two **front ends**; `crates/admin/src/admin/` is
6//! the operation layer both dispatch to and neither owns. A handler here is a
7//! few lines over an `admin::ops` call and an `admin::render_*_json`, the same
8//! way a `src/cli/` command body is a few lines over the same call and a
9//! `render_*_line`.
10//!
11//! ## Why a second listener
12//!
13//! The ACME listener is public, unauthenticated and often internet-facing.
14//! This one defaults to loopback, requires a session on every route but login,
15//! carries no admission control, and runs its own address policy,
16//! `[admin.filter]` (see [`filter`]), rather than the ACME profiles' one. Keeping them on one socket would have meant one
17//! set of defaults for two very different threat models.
18
19pub mod error;
20pub mod filter;
21pub mod handlers;
22pub mod pages;
23pub mod session;
24
25pub use error::AdminError;
26pub use pages::PageError;
27pub use session::LoginLimiter;
28
29use std::any::Any;
30use std::collections::HashMap;
31use std::sync::Arc;
32
33use anyhow::bail;
34use axum::extract::DefaultBodyLimit;
35use axum::http::{HeaderValue, StatusCode, header};
36use axum::response::{IntoResponse, Redirect, Response};
37use axum::routing::{get, post};
38use axum::{Router, middleware};
39use tower_http::catch_panic::CatchPanicLayer;
40use tower_http::set_header::SetResponseHeaderLayer;
41use tracing::{info, warn};
42
43use acme_proxy_core::config::Config;
44use acme_proxy_protocol::middlewares;
45use acme_proxy_protocol::profile::Profile;
46use acme_proxy_store::db::Database;
47
48/// Shared state for every admin route.
49///
50/// Not [`acme_proxy_protocol::router::AppState`]: that one holds exactly one `Profile`, and
51/// this listener is cross-profile by nature — an operator lists accounts from
52/// every endpoint at once, and revoking an order needs *that order's own*
53/// profile's revocation route, which may name a different CA from the one the
54/// request arrived through.
55#[derive(Clone)]
56pub struct AdminState {
57    pub database: Arc<Database>,
58    pub config: Arc<Config>,
59    /// Keyed by profile name — the lookup `orders.profile` needs.
60    pub profiles: Arc<HashMap<String, Arc<Profile>>>,
61    pub logins: Arc<LoginLimiter>,
62    /// The `/ui` templates, embedded defaults overlaid by
63    /// `admin.template_dir`. Built once: the loader reads a file per template
64    /// on first use, and rebuilding it per request would mean a disk read per
65    /// page.
66    pub templates: Arc<minijinja::Environment<'static>>,
67    /// The same process-wide auditor the ACME listener holds. Shared rather
68    /// than a second instance: an operator revoking through the panel writes
69    /// into the one trail, and the reverse-lookup cache is worth sharing.
70    pub audit: Arc<acme_proxy_jobs::auditor::Auditor>,
71    /// The `profile name -> dispatcher` map, as a reload-stable handle — the
72    /// same type `NotifyJob` and the signer backends hold. Used only to reach
73    /// the process-wide security dispatcher under
74    /// [`acme_proxy_jobs::notify::ADMIN_DISPATCHER_KEY`] via [`AdminState::notify_security`].
75    pub notifiers: acme_proxy_jobs::notify::Notifiers,
76    /// The durable queue: a revocation for a backend only the `worker` role
77    /// holds is queued here, and a local CA's CRL regeneration after one.
78    pub jobs: acme_proxy_jobs::jobs::JobQueue,
79}
80
81impl AdminState {
82    /// Builds the state from what `serve_on` already has in hand.
83    ///
84    /// Takes a **slice**, not the `Vec`: `build_app` consumes that vec, so the
85    /// admin side has to be built first, and expressing it in the signature is
86    /// what stops the ordering being rediscovered as a borrow error.
87    #[must_use]
88    pub fn new(
89        database: Arc<Database>,
90        config: Arc<Config>,
91        profiles: &[Arc<Profile>],
92        audit: Arc<acme_proxy_jobs::auditor::Auditor>,
93        notifiers: acme_proxy_jobs::notify::Notifiers,
94        jobs: acme_proxy_jobs::jobs::JobQueue,
95    ) -> Self {
96        Self::with_logins(database, config, profiles, audit, notifiers, jobs, None)
97    }
98
99    /// [`new`](Self::new), carrying the previous generation's login counters.
100    ///
101    /// Only a configuration reload passes `Some`: every other caller is building
102    /// the first generation, where there is nothing to carry. See
103    /// [`LoginLimiter::rebuilt`] for why the counters move but the limits do not.
104    #[must_use]
105    pub fn with_logins(
106        database: Arc<Database>,
107        config: Arc<Config>,
108        profiles: &[Arc<Profile>],
109        audit: Arc<acme_proxy_jobs::auditor::Auditor>,
110        notifiers: acme_proxy_jobs::notify::Notifiers,
111        jobs: acme_proxy_jobs::jobs::JobQueue,
112        previous_logins: Option<&LoginLimiter>,
113    ) -> Self {
114        let by_name = profiles
115            .iter()
116            .map(|profile| (profile.name.clone(), profile.clone()))
117            .collect();
118        let max_attempts = config.admin.login_max_attempts;
119        let window = config.admin.login_window_seconds;
120        let logins = match previous_logins {
121            Some(previous) => previous.rebuilt(max_attempts, window),
122            None => LoginLimiter::new(max_attempts, window),
123        };
124        let templates = pages::templates::build_environment(&config.admin.template_dir);
125        Self {
126            database,
127            config,
128            profiles: Arc::new(by_name),
129            logins: Arc::new(logins),
130            templates: Arc::new(templates),
131            audit,
132            notifiers,
133            jobs,
134        }
135    }
136
137    /// Writes one administrative audit row (`crates/jobs/src/auditor/admin.rs`), attributed to
138    /// the signed-in operator and the address the shared
139    /// [`Auditor`](acme_proxy_jobs::auditor::Auditor) resolves
140    /// from `request_context`. Call it **after** the operation has landed; a
141    /// failed write is swallowed, exactly as on the certificate paths, so it
142    /// cannot fail the request.
143    pub(crate) async fn record_admin_action(
144        &self,
145        request_context: &acme_proxy_core::audit::RequestContext,
146        username: &str,
147        build: impl FnOnce(
148            acme_proxy_core::audit::Actor,
149            acme_proxy_core::audit::ClientContext,
150        ) -> acme_proxy_core::audit::AuditRecord,
151    ) {
152        let actor = acme_proxy_core::audit::Actor::admin(username);
153        let client = self.audit.client(request_context).await;
154        self.audit.record(build(actor, client)).await;
155    }
156
157    /// [`AdminState::record_admin_action`] for an action that writes several
158    /// rows, resolving the client address once for all of them.
159    pub(crate) async fn record_admin_actions(
160        &self,
161        request_context: &acme_proxy_core::audit::RequestContext,
162        username: &str,
163        build: impl FnOnce(
164            acme_proxy_core::audit::Actor,
165            acme_proxy_core::audit::ClientContext,
166        ) -> Vec<acme_proxy_core::audit::AuditRecord>,
167    ) {
168        let actor = acme_proxy_core::audit::Actor::admin(username);
169        let client = self.audit.client(request_context).await;
170        for record in build(actor, client) {
171            self.audit.record(record).await;
172        }
173    }
174
175    /// Queues one web-admin security notification through the process-wide
176    /// dispatcher, if one is configured. A no-op otherwise, and — like every
177    /// [`NotifyDispatcher::dispatch`](acme_proxy_jobs::notify::NotifyDispatcher::dispatch)
178    /// — it cannot fail the request that triggered it.
179    pub(crate) async fn notify_security(&self, event: acme_proxy_jobs::notify::NotifyEvent) {
180        if let Some(dispatcher) = self
181            .notifiers
182            .get(acme_proxy_jobs::notify::ADMIN_DISPATCHER_KEY)
183        {
184            dispatcher.dispatch(event).await;
185        }
186    }
187
188    /// Queues an `admin_credential_changed` notification for the operator whose
189    /// authentication details changed (ASVS V6.3.7). `by_self` is `false` when
190    /// another operator made the change (an admin resetting a colleague's
191    /// second factor). Call it only after the change has actually landed.
192    ///
193    /// `previous_recipient` is the address a `ContactAddress` change replaced,
194    /// and `None` for every other change.
195    pub(crate) async fn notify_credential_change(
196        &self,
197        user: &acme_proxy_store::admin_user::AdminUser,
198        change: acme_proxy_jobs::notify::AdminCredentialChange,
199        by_self: bool,
200        client: Option<std::net::IpAddr>,
201        user_agent: Option<String>,
202        previous_recipient: Option<String>,
203    ) {
204        self.notify_security(
205            acme_proxy_jobs::notify::NotifyEvent::AdminCredentialChanged(
206                acme_proxy_jobs::notify::AdminCredentialChangeData::new(
207                    user,
208                    change,
209                    by_self,
210                    client.map(|ip| ip.to_string()),
211                    user_agent,
212                    previous_recipient,
213                ),
214            ),
215        )
216        .await;
217    }
218
219    /// Records a credential change **both ways at once**: the audit row and the
220    /// notification to the operator it happened to.
221    ///
222    /// The two are one event with two audiences — the trail says what the CA
223    /// did, the message tells the person it was done to — and writing them as
224    /// two adjacent calls meant one could be forgotten. One was: the `/ui`
225    /// enrolment path notified and left no row, where its `/api` twin wrote
226    /// both, which is exactly the drift `finish_enrolment` was extracted to
227    /// prevent. Taking a single [`AdminCredentialChange`] and deriving the
228    /// event from it makes that unrepresentable.
229    ///
230    /// `by_self` is `false` when another operator made the change; `actor` is
231    /// whoever made it, which is not always `user`. The notification names the
232    /// `User-Agent` `request_context` carries.
233    ///
234    /// A contact-address change is not one of these: it goes through
235    /// [`crate::admin::changes::change_contact`], which the CLI shares and
236    /// which knows the address the change replaced.
237    pub(crate) async fn record_credential_change(
238        &self,
239        request_context: &acme_proxy_core::audit::RequestContext,
240        actor: &str,
241        user: &acme_proxy_store::admin_user::AdminUser,
242        change: CredentialChange,
243        by_self: bool,
244        client: Option<std::net::IpAddr>,
245    ) {
246        use CredentialChange as Change;
247
248        self.record_admin_action(request_context, actor, |audit_actor, ctx| match change {
249            Change::Password => acme_proxy_jobs::auditor::admin::operator_password_changed(
250                audit_actor,
251                ctx,
252                &user.username,
253                by_self,
254            ),
255            Change::SecondFactorEnabled => acme_proxy_jobs::auditor::admin::operator_totp_enrolled(
256                audit_actor,
257                ctx,
258                &user.username,
259            ),
260            Change::SecondFactorDisabled => {
261                acme_proxy_jobs::auditor::admin::operator_totp_disabled(
262                    audit_actor,
263                    ctx,
264                    &user.username,
265                    !by_self,
266                )
267            }
268            Change::RecoveryCodesRegenerated => {
269                acme_proxy_jobs::auditor::admin::operator_recovery_codes_regenerated(
270                    audit_actor,
271                    ctx,
272                    &user.username,
273                )
274            }
275        })
276        .await;
277
278        self.notify_credential_change(
279            user,
280            change.into(),
281            by_self,
282            client,
283            request_context.user_agent.clone(),
284            None,
285        )
286        .await;
287    }
288}
289
290/// The credential changes [`AdminState::record_credential_change`] records:
291/// [`AdminCredentialChange`](acme_proxy_jobs::notify::AdminCredentialChange)
292/// less the contact address, which [`crate::admin::changes`] owns.
293#[derive(Debug, Clone, Copy, PartialEq, Eq)]
294pub(crate) enum CredentialChange {
295    Password,
296    SecondFactorEnabled,
297    SecondFactorDisabled,
298    RecoveryCodesRegenerated,
299}
300
301impl From<CredentialChange> for acme_proxy_jobs::notify::AdminCredentialChange {
302    fn from(change: CredentialChange) -> Self {
303        match change {
304            CredentialChange::Password => Self::Password,
305            CredentialChange::SecondFactorEnabled => Self::SecondFactorEnabled,
306            CredentialChange::SecondFactorDisabled => Self::SecondFactorDisabled,
307            CredentialChange::RecoveryCodesRegenerated => Self::RecoveryCodesRegenerated,
308        }
309    }
310}
311
312/// The web admin's [`OperatorTrail`](crate::admin::changes::OperatorTrail):
313/// rows attributed to the signed-in operator through the process's auditor,
314/// and messages through its admin dispatcher, naming the request's address
315/// and browser.
316pub(crate) struct WebTrail<'a> {
317    pub(crate) state: &'a AdminState,
318    pub(crate) request_context: &'a acme_proxy_core::audit::RequestContext,
319    /// The signed-in operator making the change.
320    pub(crate) actor: &'a str,
321    pub(crate) client: Option<std::net::IpAddr>,
322    /// Whether the operator changed is the one making the change — what the
323    /// message tells them.
324    pub(crate) by_self: bool,
325}
326
327impl crate::admin::changes::OperatorTrail for WebTrail<'_> {
328    async fn record(
329        &self,
330        build: impl FnOnce(
331            acme_proxy_core::audit::Actor,
332            acme_proxy_core::audit::ClientContext,
333        ) -> acme_proxy_core::audit::AuditRecord
334        + Send,
335    ) {
336        self.state
337            .record_admin_action(self.request_context, self.actor, build)
338            .await;
339    }
340
341    async fn notify(
342        &self,
343        user: &acme_proxy_store::admin_user::AdminUser,
344        change: acme_proxy_jobs::notify::AdminCredentialChange,
345        previous_recipient: Option<String>,
346    ) {
347        self.state
348            .notify_credential_change(
349                user,
350                change,
351                self.by_self,
352                self.client,
353                self.request_context.user_agent.clone(),
354                previous_recipient,
355            )
356            .await;
357    }
358}
359
360/// The `User-Agent` header as an owned string, capped at
361/// [`USER_AGENT_MAX`](acme_proxy_core::audit::USER_AGENT_MAX), for the sign-in
362/// notification. Every other credential change reads it from the request's
363/// `RequestContext`, which caps it the same way.
364pub(crate) fn user_agent_of(headers: &axum::http::HeaderMap) -> Option<String> {
365    headers
366        .get(axum::http::header::USER_AGENT)
367        .and_then(|value| value.to_str().ok())
368        .map(|value| {
369            // Capped exactly as `audit::RequestContext` caps it, and for the
370            // same reason plus one: the value is attacker-controlled, and here
371            // it is written by an *unauthenticated* caller (the login route)
372            // into a durable `notify_deliver` payload and rendered into an
373            // email body. Uncapped, the sender decides how large those get.
374            value
375                .chars()
376                .take(acme_proxy_core::audit::USER_AGENT_MAX)
377                .collect::<String>()
378        })
379        .filter(|value| !value.is_empty())
380}
381
382/// Builds the whole admin service: `/health`, then the JSON API under `/api`.
383///
384/// Takes `profiles` as a **slice** so it can be called before `build_app`
385/// consumes the `Vec` in `server::generation::build_generation` — the ordering
386/// is a real constraint and the signature is where it is stated.
387///
388/// ## What this router deliberately does *not* have
389///
390/// - **No admission control.** `Admission` exists because the ACME surface is
391///   public and unauthenticated. This one defaults to loopback and needs a
392///   session on every route but login; the real availability concern is
393///   credential brute force, which admission control would not touch and the
394///   login limiter does.
395/// - **No profile filter.** The profiles' `[filter]` is an ACME concern, and
396///   inheriting it would let an edit made for the ACME listener open or shut
397///   this one. The listener's own policy is `[admin.filter]` ([`filter`]),
398///   beside the bind address, TLS and the session.
399///
400/// # Panics
401///
402/// On an `admin.filter` that [`check_config`] would have refused. The server
403/// builds the policy itself and calls [`build_admin_app_with_logins`]; this is
404/// the convenience form tests and fixtures use.
405pub fn build_admin_app(
406    database: Arc<Database>,
407    config: Arc<Config>,
408    profiles: &[Arc<Profile>],
409    audit: Arc<acme_proxy_jobs::auditor::Auditor>,
410    notifiers: acme_proxy_jobs::notify::Notifiers,
411    jobs: acme_proxy_jobs::jobs::JobQueue,
412) -> Router {
413    let policy = filter::build(&config).expect("admin.filter must be valid");
414    build_admin_app_with_logins(
415        database, config, profiles, audit, notifiers, jobs, policy, None,
416    )
417    .0
418}
419
420/// [`build_admin_app`], carrying login counters across a configuration reload.
421///
422/// Returns the limiter it ended up with as well as the router, because the
423/// generation after this one has to carry it in turn — an `Arc<LoginLimiter>`
424/// that only ever moved forward is the whole point.
425#[allow(clippy::too_many_arguments)]
426pub fn build_admin_app_with_logins(
427    database: Arc<Database>,
428    config: Arc<Config>,
429    profiles: &[Arc<Profile>],
430    audit: Arc<acme_proxy_jobs::auditor::Auditor>,
431    notifiers: acme_proxy_jobs::notify::Notifiers,
432    jobs: acme_proxy_jobs::jobs::JobQueue,
433    policy: Arc<acme_proxy_policy::filter::FilterPolicy>,
434    previous_logins: Option<&LoginLimiter>,
435) -> (Router, Arc<LoginLimiter>) {
436    let state = AdminState::with_logins(
437        database,
438        config.clone(),
439        profiles,
440        audit,
441        notifiers,
442        jobs,
443        previous_logins,
444    );
445    let logins = state.logins.clone();
446
447    let api = Router::new()
448        .route(
449            "/session",
450            post(handlers::post_session)
451                .get(handlers::get_session)
452                .delete(handlers::delete_session),
453        )
454        // The second half of signing in: reachable only with a `pending_mfa`
455        // cookie, which every other route here refuses.
456        .route(
457            "/session/mfa",
458            get(handlers::get_session_mfa).post(handlers::post_session_mfa),
459        )
460        // The operator's own second factor. Not a server resource like the ones
461        // below — it is about whoever is holding this cookie, which is why
462        // there is no id in any of these paths.
463        .route("/mfa", get(handlers::get_mfa))
464        .route(
465            "/mfa/totp",
466            post(handlers::begin_totp).delete(handlers::disable_totp),
467        )
468        .route("/mfa/totp/confirm", post(handlers::confirm_totp))
469        .route(
470            "/mfa/recovery-codes",
471            post(handlers::regenerate_recovery_codes),
472        )
473        .route("/account/password", post(handlers::change_password))
474        .route("/account/contact", post(handlers::change_contact))
475        .route("/account/sessions", get(handlers::list_own_sessions))
476        .route(
477            "/account/sessions/{id}/revoke",
478            post(handlers::revoke_own_session),
479        )
480        // The operators surface: every operator this process has, and acting
481        // on one *other* than the caller — see `handlers::operators`. Every
482        // mutating route here sits behind `verify_current_password`, unlike the
483        // `/account/*` routes just above.
484        .route("/operators", get(handlers::list_operators))
485        .route("/operators/{username}", get(handlers::get_operator))
486        .route(
487            "/operators/{username}/sessions",
488            get(handlers::list_operator_sessions),
489        )
490        .route(
491            "/operators/{username}/disable",
492            post(handlers::disable_operator),
493        )
494        .route(
495            "/operators/{username}/enable",
496            post(handlers::enable_operator),
497        )
498        .route(
499            "/operators/{username}/totp/reset",
500            post(handlers::reset_operator_totp),
501        )
502        .route(
503            "/operators/{username}/contact",
504            post(handlers::set_operator_contact),
505        )
506        .route(
507            "/operators/{username}/role",
508            post(handlers::set_operator_role),
509        )
510        .route(
511            "/operators/{username}/sessions/{id}/revoke",
512            post(handlers::revoke_operator_session),
513        )
514        .route("/accounts", get(handlers::list_accounts))
515        .route(
516            "/accounts/{id}",
517            get(handlers::get_account)
518                .patch(handlers::patch_account)
519                .delete(handlers::delete_account),
520        )
521        .route("/accounts/{id}/orders", get(handlers::list_account_orders))
522        .route(
523            "/accounts/{id}/deactivate",
524            post(handlers::deactivate_account),
525        )
526        .route("/orders", get(handlers::list_orders))
527        .route(
528            "/orders/{id}",
529            get(handlers::get_order).delete(handlers::delete_order),
530        )
531        .route("/orders/{id}/revoke", post(handlers::revoke_order))
532        .route("/eab", get(handlers::list_eab).post(handlers::create_eab))
533        .route(
534            "/eab/{kid}",
535            get(handlers::get_eab).delete(handlers::delete_eab),
536        )
537        .route("/eab/{kid}/revoke", post(handlers::revoke_eab))
538        // The background job queue. `list`/`get` read; `cancel`/`run` are in
539        // `mutating_endpoints()` and demand `operator`+.
540        .route("/jobs", get(handlers::list_jobs))
541        .route("/jobs/{id}", get(handlers::get_job))
542        .route("/jobs/{id}/cancel", post(handlers::cancel_job))
543        .route("/jobs/{id}/run", post(handlers::run_job))
544        // Read-only, and therefore absent from `mutating_endpoints()`:
545        // abandoning an in-flight relay is `POST /api/jobs/{id}/cancel` on the
546        // `signer_relay_issue` job, which owns the state machine.
547        .route("/upstream-orders", get(handlers::list_upstream_orders))
548        .route("/upstream-orders/{id}", get(handlers::get_upstream_order))
549        // Read-only, and therefore absent from `mutating_endpoints()`: there
550        // is no route here that writes an audit row, by design. The trail is
551        // pruned from the host (`acme-proxy audit cleanup`) or by
552        // `audit.retention_days`, never through a browser session — a panel
553        // that can delete its own audit history is a panel whose audit history
554        // proves nothing.
555        .route("/audit", get(handlers::list_audit))
556        .route("/audit/{id}", get(handlers::get_audit))
557        // Read-only for its own reason rather than the audit trail's: renewing
558        // is the *client's* action, driven by its own ACME flow, so there is
559        // nothing here for a route to write. Absent from `mutating_endpoints()`
560        // accordingly.
561        .route("/expiring", get(handlers::list_expiring))
562        .route("/nonces", get(handlers::get_nonces))
563        .route("/nonces/cleanup", post(handlers::cleanup_nonces))
564        .route("/profiles", get(handlers::list_profiles))
565        // Read-only for a third reason again: a policy is *configuration*,
566        // edited in `config.toml` and reloaded, so there is nothing here for a
567        // route to write — hence its absence from `mutating_endpoints()`. Note
568        // this is `filter show` and never `filter explain`: the latter runs the
569        // operator's scripts and queries the inventory against caller-chosen
570        // inputs, and has no web surface at all.
571        .route("/profiles/{name}/filter", get(handlers::get_profile_filter));
572
573    let router = Router::new()
574        // Unauthenticated and touching no database: an orchestrator probing
575        // this port should not need a session to learn the process is alive.
576        .route(
577            "/health",
578            get(acme_proxy_protocol::handlers::get_health_check),
579        )
580        // The panel is what somebody opening this port in a browser wants.
581        .route("/", get(|| async { Redirect::to("/ui/") }))
582        .merge(api_with_fallbacks(api))
583        .merge(pages::pages_router())
584        // HTML, because this listener is browser-facing and every path that
585        // reaches here is one a person typed. `/api`'s own JSON fallback is
586        // scoped inside its nest and is unaffected, so a script still gets the
587        // admin error shape on the paths a script uses.
588        .fallback(|| async { PageError::not_found("no such page") })
589        .with_state(state)
590        // Innermost layer: a panic in any page handler (or the `/api` nest, if
591        // its own catch layer somehow did not fire) becomes an HTML 500 rather
592        // than an aborted connection, and the header layers and the access line
593        // above still see a real response. ASVS V16.5.4.
594        .layer(catch_panic_admin_pages())
595        .layer(DefaultBodyLimit::max(config.admin.max_body_bytes))
596        // `no-store` is not decoration: account contacts and a freshly minted
597        // EAB secret must not sit in a disk cache after the operator closes
598        // the tab.
599        .layer(SetResponseHeaderLayer::overriding(
600            header::CACHE_CONTROL,
601            HeaderValue::from_static("no-store"),
602        ))
603        .layer(SetResponseHeaderLayer::overriding(
604            header::REFERRER_POLICY,
605            HeaderValue::from_static("same-origin"),
606        ))
607        // The same three the ACME app applies. This router is not nested
608        // inside `build_app` and inherits none of its layers, so they have to
609        // be applied again here — but from the one constructor, since two
610        // hand-written copies of a security control are a control that drifts.
611        .layer(acme_proxy_protocol::router::security_headers())
612        // Strict, and affordable only because of how the pages are built:
613        // htmx is served from this origin (`script-src 'self'`) and drives
614        // everything through `hx-*` attributes rather than inline handlers, so
615        // no `'unsafe-inline'` and no `'unsafe-eval'` are needed. `style-src
616        // 'self'` is why `layout.html` sets htmx's `includeIndicatorStyles` to
617        // false -- htmx would otherwise inject a <style> element this refuses.
618        // `default-src 'none'` means anything added later has to be allowed
619        // deliberately rather than inherited by accident.
620        .layer(SetResponseHeaderLayer::overriding(
621            header::CONTENT_SECURITY_POLICY,
622            HeaderValue::from_static(
623                "default-src 'none'; script-src 'self'; style-src 'self'; \
624                 img-src 'self' data:; connect-src 'self'; form-action 'self'; \
625                 frame-ancestors 'none'; base-uri 'none'",
626            ),
627        ))
628        // `[admin.filter]`, just inside the access line: a refused caller still
629        // gets an `x-request-id` and is logged, and every header layer above
630        // still applies to the refusal. Resolves the client address the login
631        // limiter reads, too, so it runs whether or not a rule is configured.
632        .layer(middleware::from_fn_with_state(
633            policy,
634            filter::admin_filter_middleware,
635        ))
636        // Outermost, and the same middleware the ACME listener uses: an admin
637        // request gets an `x-request-id` and an access line on identical
638        // terms. Its `profile` field stays `field::Empty` and renders as
639        // absent, which is correct — an admin request belongs to no profile.
640        .layer(middleware::from_fn(
641            middlewares::access::add_access_middleware,
642        ));
643
644    (router, logins)
645}
646
647/// Mounts the API at `/api` with fallbacks that answer in the admin error
648/// shape.
649///
650/// Without these, a typo'd path would fall through to an empty body — the same
651/// reasoning the profile routers already encode, except that there the fallback
652/// has to be an ACME problem document and here it must not be.
653fn api_with_fallbacks(api: Router<AdminState>) -> Router<AdminState> {
654    Router::new().nest(
655        "/api",
656        api.method_not_allowed_fallback(|| async {
657            AdminError::with_code(
658                StatusCode::METHOD_NOT_ALLOWED,
659                "method_not_allowed",
660                "that method is not allowed on this resource",
661            )
662        })
663        .fallback(|| async { AdminError::not_found("no such admin API resource") })
664        // A panic below here answers a script in the JSON admin error shape,
665        // not the HTML page the outer router's own catch layer produces —
666        // the same split `AdminError` and `PageError` exist for. ASVS V16.5.4.
667        .layer(catch_panic_admin_api()),
668    )
669}
670
671/// The response a caught panic produces on the admin **API** surface: the JSON
672/// `{"error":"internal", ...}` shape a script gets everywhere else on `/api`,
673/// never the HTML document a browser gets. The panic message goes to the log
674/// only (ASVS V16.5.1). Relies on `panic = "unwind"`.
675fn admin_api_panic_response(err: Box<dyn Any + Send + 'static>) -> Response {
676    tracing::error!(
677        event = "request_handler_panicked",
678        outcome = "failure",
679        listener = "admin",
680        surface = "api",
681        error = %acme_proxy_protocol::router::panic_message(err.as_ref()),
682    );
683    AdminError::internal().into_response()
684}
685
686/// The response a caught panic produces on every other admin surface (`/ui`,
687/// `/`, the HTML fallback): a standalone HTML error document, matching the outer
688/// router's own `.fallback()`. The panic message goes to the log only.
689fn admin_page_panic_response(err: Box<dyn Any + Send + 'static>) -> Response {
690    tracing::error!(
691        event = "request_handler_panicked",
692        outcome = "failure",
693        listener = "admin",
694        surface = "ui",
695        error = %acme_proxy_protocol::router::panic_message(err.as_ref()),
696    );
697    PageError::internal().into_response()
698}
699
700/// The last-resort panic layer for the admin `/api` nest — see
701/// [`admin_api_panic_response`].
702pub fn catch_panic_admin_api() -> CatchPanicLayer<fn(Box<dyn Any + Send + 'static>) -> Response> {
703    CatchPanicLayer::custom(
704        admin_api_panic_response as fn(Box<dyn Any + Send + 'static>) -> Response,
705    )
706}
707
708/// The last-resort panic layer for the admin pages and the HTML fallback — see
709/// [`admin_page_panic_response`]. `pub` on the same terms as [`build_admin_app`].
710pub fn catch_panic_admin_pages() -> CatchPanicLayer<fn(Box<dyn Any + Send + 'static>) -> Response> {
711    CatchPanicLayer::custom(
712        admin_page_panic_response as fn(Box<dyn Any + Send + 'static>) -> Response,
713    )
714}
715
716/// Rejects an `[admin]` section that cannot work, before anything binds.
717///
718/// Runs only when the panel is enabled: an operator who has not turned it on
719/// must never be stopped from starting by a section they never edited.
720pub fn check_config(config: &Config) -> anyhow::Result<()> {
721    let admin = &config.admin;
722    if !admin.enabled {
723        return Ok(());
724    }
725
726    if admin.bind_address == config.server.bind_address {
727        bail!(
728            "admin.bind_address and server.bind_address are both `{}`: the web admin is a \
729             second listener on its own socket, not a path on the ACME one",
730            admin.bind_address
731        );
732    }
733
734    let url = url::Url::parse(&admin.base_url).map_err(|error| {
735        anyhow::anyhow!("admin.base_url `{}` is not a URL: {error}", admin.base_url)
736    })?;
737    if url.host_str().is_none_or(str::is_empty) {
738        bail!(
739            "admin.base_url `{}` has no host: it names the origin the panel is reached at, \
740             and a generated certificate takes its name from it",
741            admin.base_url
742        );
743    }
744
745    // A hard error, not a warning, and the reasoning is worth keeping: the
746    // session cookie is always sent `Secure` (never conditionally -- that is
747    // how a session cookie leaks). Browsers accept a `Secure` cookie on
748    // `http://localhost` and silently refuse it on `http://192.0.2.10:3001`.
749    // The operator would see "login succeeds, then I am immediately logged
750    // out" with nothing in any log to explain it. Refuse, and name both keys.
751    // Same reasoning as `filter.allowed_ip` with two empty lists being a
752    // startup error rather than an accept-everything default.
753    if !admin.tls.enabled && !binds_loopback_only(&admin.bind_address) {
754        bail!(
755            "admin.bind_address `{}` is not loopback while admin.tls.enabled is false: the \
756             session cookie is sent `Secure`, which a browser will not store over plain HTTP \
757             on anything but localhost, so signing in would appear to succeed and then fail \
758             silently. Set admin.tls.enabled = true, or bind 127.0.0.1 and reach it through \
759             an SSH tunnel",
760            admin.bind_address
761        );
762    }
763
764    // The `tls_base_url_mismatch` treatment: a warning, because the reverse
765    // proxy case (https:// in the URL, TLS terminated in front) is legitimate.
766    if admin.tls.enabled && url.scheme() == "http" {
767        warn!(event = "admin_base_url_mismatch",
768              outcome = "advisory",
769              base_url = %admin.base_url,
770              "admin.base_url names http:// while admin.tls.enabled is true: the CSRF origin \
771               check compares against it, so browser requests will be refused until it names \
772               https://");
773    }
774
775    // `admin.base_url` is load-bearing in three unrelated ways -- the CSRF
776    // origin check, the generated certificate's name, and later the WebAuthn
777    // relying-party id -- and is easy to leave at its default while binding
778    // somewhere else. Logging the resolved origin makes the mismatch visible
779    // at startup rather than at the first refused request.
780    info!(event = "admin_origin_resolved",
781          outcome = "success",
782          origin = %url.origin().ascii_serialization(),
783          bind_address = %admin.bind_address);
784
785    check_templates(&admin.template_dir)?;
786    filter::build(config)?;
787
788    Ok(())
789}
790
791/// Compiles every page template, so a broken override stops the process here
792/// rather than serving a `500` at three in the morning.
793///
794/// The same posture as the rest of startup: a path that can fail fast should.
795/// The cost is one compile of ~20 small templates, once.
796fn check_templates(template_dir: &str) -> anyhow::Result<()> {
797    if !template_dir.is_empty() {
798        let path = std::path::Path::new(template_dir);
799        if !path.is_dir() {
800            bail!(
801                "admin.template_dir `{template_dir}` is not a directory: it holds per-file \
802                 overrides of the compiled-in page templates, checked by name before the \
803                 default. Leave it empty to use the defaults"
804            );
805        }
806    }
807
808    let env = pages::templates::build_environment(template_dir);
809    for name in pages::templates::template_names() {
810        env.get_template(name).map_err(|error| {
811            anyhow::anyhow!(
812                "admin page template `{name}` does not compile: {error}. \
813                 It was loaded from admin.template_dir `{template_dir}`"
814            )
815        })?;
816    }
817
818    if !template_dir.is_empty() {
819        info!(event = "admin_templates_overridden", outcome = "success", template_dir = %template_dir);
820    }
821
822    Ok(())
823}
824
825/// Whether every address `bind` can accept on is a loopback address.
826///
827/// Deliberately conservative: an address that does not parse, or a hostname
828/// this does not resolve, counts as *not* loopback. Being wrong in that
829/// direction costs an operator one explicit `admin.tls.enabled = true`; being
830/// wrong in the other silently ships a panel whose login does not work.
831fn binds_loopback_only(bind: &str) -> bool {
832    let Ok(addr) = bind.parse::<std::net::SocketAddr>() else {
833        // Not a bare socket address -- e.g. `localhost:3001`, which the
834        // listener resolves later. Accept the two spellings that can only
835        // ever be loopback, and refuse everything else.
836        return matches!(
837            bind.rsplit_once(':').map(|(host, _)| host),
838            Some("localhost" | "ip6-localhost")
839        );
840    };
841    addr.ip().is_loopback()
842}
843
844#[cfg(test)]
845mod tests {
846    use super::*;
847    use acme_proxy_core::config::AdminConfig;
848    use acme_proxy_core::config::Config;
849
850    /// `[admin]` enabled, with everything else at its default.
851    ///
852    /// Built by mutation rather than struct-update syntax: `Config` keeps a
853    /// private `raw` field, so `..Config::default()` is not available outside
854    /// `acme_proxy_core::config`.
855    fn enabled() -> Config {
856        let mut config = Config::default();
857        config.admin = AdminConfig {
858            enabled: true,
859            ..AdminConfig::default()
860        };
861        config
862    }
863
864    #[test]
865    fn a_disabled_panel_is_never_checked() {
866        // Every one of these would be refused if the panel were on.
867        let mut config = Config::default();
868        config.admin = AdminConfig {
869            enabled: false,
870            bind_address: "0.0.0.0:3001".to_string(),
871            base_url: "not a url".to_string(),
872            ..AdminConfig::default()
873        };
874        assert!(check_config(&config).is_ok());
875    }
876
877    #[test]
878    fn the_defaults_are_a_working_configuration() {
879        check_config(&enabled()).expect("loopback + the default base_url must start");
880    }
881
882    /// The compiled-in templates are checked on every start, so a page that
883    /// stopped compiling fails the build's own test suite rather than the first
884    /// operator to open it.
885    #[test]
886    fn the_embedded_templates_all_compile_at_startup() {
887        check_templates("").expect("the shipped templates must compile");
888    }
889
890    #[test]
891    fn a_template_dir_that_is_not_a_directory_is_refused() {
892        let dir = acme_proxy_core::testutil::TempDir::new("admin-template-dir");
893        let file = dir.write("not-a-directory", "");
894
895        let error = check_templates(file.to_str().unwrap()).unwrap_err();
896        assert!(error.to_string().contains("is not a directory"));
897    }
898
899    /// A broken override should stop the process, not serve a `500` at three in
900    /// the morning — the same fail-fast posture as the rest of startup.
901    #[test]
902    fn an_override_that_does_not_compile_is_refused_at_startup() {
903        let dir = acme_proxy_core::testutil::TempDir::new("admin-bad-template");
904        dir.write("index.html", "{% for x in %}");
905
906        let error = check_templates(dir.path().to_str().unwrap()).unwrap_err();
907        let message = error.to_string();
908        assert!(message.contains("index.html"));
909        assert!(message.contains("does not compile"));
910    }
911
912    #[test]
913    fn a_valid_override_directory_starts() {
914        let dir = acme_proxy_core::testutil::TempDir::new("admin-good-template");
915        dir.write("login.html", "<p>{{ flash }}</p>");
916
917        check_templates(dir.path().to_str().unwrap())
918            .expect("one overridden template must not stop the other twenty");
919    }
920
921    #[test]
922    fn a_bind_shared_with_the_acme_listener_is_refused() {
923        let mut config = enabled();
924        config.admin.bind_address = config.server.bind_address.clone();
925        let error = check_config(&config).unwrap_err().to_string();
926        assert!(error.contains("second listener"), "got: {error}");
927        assert!(error.contains(&config.server.bind_address));
928    }
929
930    #[test]
931    fn a_base_url_that_is_not_a_url_or_has_no_host_is_refused() {
932        let mut config = enabled();
933        config.admin.base_url = "not a url".to_string();
934        assert!(
935            check_config(&config)
936                .unwrap_err()
937                .to_string()
938                .contains("is not a URL")
939        );
940
941        // Parses, but names no host.
942        config.admin.base_url = "unix:/run/admin.sock".to_string();
943        let error = check_config(&config).unwrap_err().to_string();
944        assert!(error.contains("has no host"), "got: {error}");
945    }
946
947    #[test]
948    fn a_non_loopback_bind_without_tls_is_a_startup_error_not_a_warning() {
949        for bind in [
950            "0.0.0.0:3001",
951            "192.0.2.10:3001",
952            "[::]:3001",
953            "[2001:db8::1]:3001",
954        ] {
955            let mut config = enabled();
956            config.admin.bind_address = bind.to_string();
957            let error = check_config(&config).unwrap_err().to_string();
958            assert!(
959                error.contains("is not loopback"),
960                "`{bind}` must be refused without TLS, got: {error}"
961            );
962        }
963    }
964
965    /// A policy that would refuse to build refuses the process, at startup and
966    /// on `SIGHUP` alike, since both run `check_config`.
967    #[test]
968    fn a_broken_admin_filter_is_a_startup_error() {
969        let mut config = enabled();
970        config.admin.filter.trusted_proxies = vec!["not-a-network".to_string()];
971        let error = check_config(&config).unwrap_err().to_string();
972        assert!(error.starts_with("admin.filter: "), "{error}");
973    }
974
975    #[test]
976    fn a_non_loopback_bind_is_allowed_once_tls_is_on() {
977        let mut config = enabled();
978        config.admin.bind_address = "0.0.0.0:3001".to_string();
979        config.admin.tls.enabled = true;
980        config.admin.base_url = "https://admin.example.com".to_string();
981        check_config(&config).expect("TLS is what the loopback rule was standing in for");
982    }
983
984    #[test]
985    fn every_loopback_spelling_is_accepted_without_tls() {
986        for bind in [
987            "127.0.0.1:3001",
988            "127.0.0.53:3001",
989            "[::1]:3001",
990            "localhost:3001",
991        ] {
992            let mut config = enabled();
993            config.admin.bind_address = bind.to_string();
994            check_config(&config).unwrap_or_else(|error| panic!("`{bind}` must start: {error}"));
995        }
996    }
997
998    #[test]
999    fn an_unparseable_bind_is_treated_as_non_loopback() {
1000        // Conservative on purpose: the cost of being wrong this way is one
1001        // explicit config key, the other way is a panel nobody can log in to.
1002        let mut config = enabled();
1003        config.admin.bind_address = "not-a-socket-address".to_string();
1004        assert!(
1005            check_config(&config)
1006                .unwrap_err()
1007                .to_string()
1008                .contains("is not loopback")
1009        );
1010    }
1011
1012    #[test]
1013    fn tls_with_an_http_base_url_warns_but_starts() {
1014        let mut config = enabled();
1015        config.admin.tls.enabled = true;
1016        // Still http://, which the CSRF origin check will compare against.
1017        check_config(&config).expect("a scheme mismatch is a warning, not a refusal");
1018    }
1019
1020    mod catch_panic {
1021        use super::*;
1022        use axum::body::{Body, to_bytes};
1023        use axum::http::{Request, StatusCode};
1024        use tower::ServiceExt;
1025
1026        async fn boom() -> &'static str {
1027            panic!("this handler panics on purpose")
1028        }
1029
1030        /// An `/api` panic keeps the JSON admin error shape a script gets
1031        /// everywhere else — never the HTML document, never an ACME URN.
1032        #[tokio::test]
1033        async fn an_api_panic_is_the_json_admin_error() {
1034            let response = admin_api_panic_response(Box::new("secret internal detail"));
1035            assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
1036            assert_eq!(
1037                response
1038                    .headers()
1039                    .get(header::CONTENT_TYPE)
1040                    .and_then(|v| v.to_str().ok()),
1041                Some("application/json"),
1042            );
1043            let bytes = to_bytes(response.into_body(), 64 * 1024).await.unwrap();
1044            let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
1045            assert_eq!(body["error"], "internal");
1046            let text = String::from_utf8(bytes.to_vec()).unwrap();
1047            assert!(!text.contains("urn:ietf:params:acme"));
1048            assert!(
1049                !text.contains("secret internal detail"),
1050                "the panic message must not reach the client",
1051            );
1052        }
1053
1054        /// A page panic is a standalone HTML document, matching the outer
1055        /// router's own `.fallback()`.
1056        #[tokio::test]
1057        async fn a_page_panic_is_an_html_document() {
1058            let response = admin_page_panic_response(Box::new(String::from("boom")));
1059            assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
1060            let bytes = to_bytes(response.into_body(), 64 * 1024).await.unwrap();
1061            let text = String::from_utf8(bytes.to_vec()).unwrap();
1062            assert!(text.starts_with("<!doctype html>"), "got: {text}");
1063            assert!(text.contains("internal"));
1064        }
1065
1066        #[tokio::test]
1067        async fn each_layer_catches_a_panicking_route() {
1068            for layer_name in ["api", "pages"] {
1069                let router: Router = if layer_name == "api" {
1070                    Router::new()
1071                        .route("/boom", get(boom))
1072                        .layer(catch_panic_admin_api())
1073                } else {
1074                    Router::new()
1075                        .route("/boom", get(boom))
1076                        .layer(catch_panic_admin_pages())
1077                };
1078                let response = router
1079                    .oneshot(Request::get("/boom").body(Body::empty()).unwrap())
1080                    .await
1081                    .unwrap();
1082                assert_eq!(
1083                    response.status(),
1084                    StatusCode::INTERNAL_SERVER_ERROR,
1085                    "the {layer_name} layer must catch the panic",
1086                );
1087            }
1088        }
1089    }
1090}