Skip to main content

acme_proxy/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 `src/webadmin/` are the two **front ends**; `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//! and carries no admission control or filter chain because neither fits it
16//! (see `build_admin_app`). 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 handlers;
21pub mod pages;
22pub mod session;
23
24pub use error::AdminError;
25pub use pages::PageError;
26pub use session::LoginLimiter;
27
28use std::collections::HashMap;
29use std::sync::Arc;
30
31use anyhow::bail;
32use axum::extract::DefaultBodyLimit;
33use axum::http::{HeaderValue, StatusCode, header};
34use axum::response::Redirect;
35use axum::routing::{get, post};
36use axum::{Router, middleware};
37use tower_http::set_header::SetResponseHeaderLayer;
38use tracing::{info, warn};
39
40use crate::Profile;
41use crate::config::Config;
42use crate::middlewares;
43use crate::sqlite::db::Database;
44
45/// Shared state for every admin route.
46///
47/// Not [`crate::AppState`]: that one holds exactly one `Profile`, and this
48/// listener is cross-profile by nature — an operator lists accounts from every
49/// endpoint at once, and revoking an order needs *that order's own* profile's
50/// signer, which may be a different CA from the one the request arrived
51/// through.
52#[derive(Clone)]
53pub struct AdminState {
54    pub database: Arc<Database>,
55    pub config: Arc<Config>,
56    /// Keyed by profile name — the lookup `orders.profile` needs.
57    pub profiles: Arc<HashMap<String, Arc<Profile>>>,
58    pub logins: Arc<LoginLimiter>,
59    /// The `/ui` templates, embedded defaults overlaid by
60    /// `admin.template_dir`. Built once: the loader reads a file per template
61    /// on first use, and rebuilding it per request would mean a disk read per
62    /// page.
63    pub templates: Arc<minijinja::Environment<'static>>,
64    /// The same process-wide auditor the ACME listener holds. Shared rather
65    /// than a second instance: an operator revoking through the panel writes
66    /// into the one trail, and the reverse-lookup cache is worth sharing.
67    pub audit: Arc<crate::audit::Auditor>,
68}
69
70impl AdminState {
71    /// Builds the state from what `serve_on` already has in hand.
72    ///
73    /// Takes a **slice**, not the `Vec`: `build_app` consumes that vec, so the
74    /// admin side has to be built first, and expressing it in the signature is
75    /// what stops the ordering being rediscovered as a borrow error.
76    #[must_use]
77    pub fn new(
78        database: Arc<Database>,
79        config: Arc<Config>,
80        profiles: &[Arc<Profile>],
81        audit: Arc<crate::audit::Auditor>,
82    ) -> Self {
83        Self::with_logins(database, config, profiles, audit, None)
84    }
85
86    /// [`new`](Self::new), carrying the previous generation's login counters.
87    ///
88    /// Only a configuration reload passes `Some`: every other caller is building
89    /// the first generation, where there is nothing to carry. See
90    /// [`LoginLimiter::rebuilt`] for why the counters move but the limits do not.
91    #[must_use]
92    pub fn with_logins(
93        database: Arc<Database>,
94        config: Arc<Config>,
95        profiles: &[Arc<Profile>],
96        audit: Arc<crate::audit::Auditor>,
97        previous_logins: Option<&LoginLimiter>,
98    ) -> Self {
99        let by_name = profiles
100            .iter()
101            .map(|profile| (profile.name.clone(), profile.clone()))
102            .collect();
103        let max_attempts = config.admin.login_max_attempts;
104        let window = config.admin.login_window_seconds;
105        let logins = match previous_logins {
106            Some(previous) => previous.rebuilt(max_attempts, window),
107            None => LoginLimiter::new(max_attempts, window),
108        };
109        let templates = pages::templates::build_environment(&config.admin.template_dir);
110        Self {
111            database,
112            config,
113            profiles: Arc::new(by_name),
114            logins: Arc::new(logins),
115            templates: Arc::new(templates),
116            audit,
117        }
118    }
119}
120
121/// Builds the whole admin service: `/health`, then the JSON API under `/api`.
122///
123/// Takes `profiles` as a **slice** so it can be called before `build_app`
124/// consumes the `Vec` in `cli::serve_on` — the ordering is a real constraint
125/// and the signature is where it is stated.
126///
127/// ## What this router deliberately does *not* have
128///
129/// - **No admission control.** `Admission` exists because the ACME surface is
130///   public and unauthenticated. This one defaults to loopback and needs a
131///   session on every route but login; the real availability concern is
132///   credential brute force, which admission control would not touch and the
133///   login limiter does.
134/// - **No filter chain.** Filters are a per-profile ACME concern, and
135///   `filter.exempt_paths` matches profile-stripped paths. Wiring them here
136///   would be a category error. Access control on this listener is the bind
137///   address, TLS, and the session.
138pub fn build_admin_app(
139    database: Arc<Database>,
140    config: Arc<Config>,
141    profiles: &[Arc<Profile>],
142    audit: Arc<crate::audit::Auditor>,
143) -> Router {
144    build_admin_app_with_logins(database, config, profiles, audit, None).0
145}
146
147/// [`build_admin_app`], carrying login counters across a configuration reload.
148///
149/// Returns the limiter it ended up with as well as the router, because the
150/// generation after this one has to carry it in turn — an `Arc<LoginLimiter>`
151/// that only ever moved forward is the whole point.
152pub fn build_admin_app_with_logins(
153    database: Arc<Database>,
154    config: Arc<Config>,
155    profiles: &[Arc<Profile>],
156    audit: Arc<crate::audit::Auditor>,
157    previous_logins: Option<&LoginLimiter>,
158) -> (Router, Arc<LoginLimiter>) {
159    let state = AdminState::with_logins(database, config.clone(), profiles, audit, previous_logins);
160    let logins = state.logins.clone();
161
162    let api = Router::new()
163        .route(
164            "/session",
165            post(handlers::post_session)
166                .get(handlers::get_session)
167                .delete(handlers::delete_session),
168        )
169        // The second half of signing in: reachable only with a `pending_mfa`
170        // cookie, which every other route here refuses.
171        .route(
172            "/session/mfa",
173            get(handlers::get_session_mfa).post(handlers::post_session_mfa),
174        )
175        // The operator's own second factor. Not a server resource like the ones
176        // below — it is about whoever is holding this cookie, which is why
177        // there is no id in any of these paths.
178        .route("/mfa", get(handlers::get_mfa))
179        .route(
180            "/mfa/totp",
181            post(handlers::begin_totp).delete(handlers::disable_totp),
182        )
183        .route("/mfa/totp/confirm", post(handlers::confirm_totp))
184        .route(
185            "/mfa/recovery-codes",
186            post(handlers::regenerate_recovery_codes),
187        )
188        .route("/accounts", get(handlers::list_accounts))
189        .route(
190            "/accounts/{id}",
191            get(handlers::get_account)
192                .patch(handlers::patch_account)
193                .delete(handlers::delete_account),
194        )
195        .route("/accounts/{id}/orders", get(handlers::list_account_orders))
196        .route(
197            "/accounts/{id}/deactivate",
198            post(handlers::deactivate_account),
199        )
200        .route("/orders", get(handlers::list_orders))
201        .route(
202            "/orders/{id}",
203            get(handlers::get_order).delete(handlers::delete_order),
204        )
205        .route("/orders/{id}/revoke", post(handlers::revoke_order))
206        .route("/eab", get(handlers::list_eab).post(handlers::create_eab))
207        .route("/eab/{kid}", get(handlers::get_eab))
208        .route("/eab/{kid}/revoke", post(handlers::revoke_eab))
209        // Read-only, and therefore absent from `mutating_endpoints()`: there
210        // is no route here that writes an audit row, by design. The trail is
211        // pruned from the host (`acme-proxy audit cleanup`) or by
212        // `audit.retention_days`, never through a browser session — a panel
213        // that can delete its own audit history is a panel whose audit history
214        // proves nothing.
215        .route("/audit", get(handlers::list_audit))
216        .route("/audit/{id}", get(handlers::get_audit))
217        // Read-only for its own reason rather than the audit trail's: renewing
218        // is the *client's* action, driven by its own ACME flow, so there is
219        // nothing here for a route to write. Absent from `mutating_endpoints()`
220        // accordingly.
221        .route("/expiring", get(handlers::list_expiring))
222        .route("/nonces", get(handlers::get_nonces))
223        .route("/nonces/cleanup", post(handlers::cleanup_nonces))
224        .route("/profiles", get(handlers::list_profiles))
225        // Read-only for a third reason again: a policy is *configuration*,
226        // edited in `config.toml` and reloaded, so there is nothing here for a
227        // route to write — hence its absence from `mutating_endpoints()`. Note
228        // this is `filter show` and never `filter explain`: the latter runs the
229        // operator's scripts and queries the inventory against caller-chosen
230        // inputs, and has no web surface at all.
231        .route("/profiles/{name}/filter", get(handlers::get_profile_filter));
232
233    let router = Router::new()
234        // Unauthenticated and touching no database: an orchestrator probing
235        // this port should not need a session to learn the process is alive.
236        .route("/health", get(crate::handlers::get_health_check))
237        // The panel is what somebody opening this port in a browser wants.
238        .route("/", get(|| async { Redirect::to("/ui/") }))
239        .merge(api_with_fallbacks(api))
240        .merge(pages::pages_router())
241        // HTML, because this listener is browser-facing and every path that
242        // reaches here is one a person typed. `/api`'s own JSON fallback is
243        // scoped inside its nest and is unaffected, so a script still gets the
244        // admin error shape on the paths a script uses.
245        .fallback(|| async { PageError::not_found("no such page") })
246        .with_state(state)
247        .layer(DefaultBodyLimit::max(config.admin.max_body_bytes))
248        // `no-store` is not decoration: account contacts and a freshly minted
249        // EAB secret must not sit in a disk cache after the operator closes
250        // the tab.
251        .layer(SetResponseHeaderLayer::overriding(
252            header::CACHE_CONTROL,
253            HeaderValue::from_static("no-store"),
254        ))
255        .layer(SetResponseHeaderLayer::overriding(
256            header::REFERRER_POLICY,
257            HeaderValue::from_static("same-origin"),
258        ))
259        // The same three the ACME app applies. This router is not nested
260        // inside `build_app` and inherits none of its layers, so they have to
261        // be applied again here — but from the one constructor, since two
262        // hand-written copies of a security control are a control that drifts.
263        .layer(crate::security_headers())
264        // Strict, and affordable only because of how the pages are built:
265        // htmx is served from this origin (`script-src 'self'`) and drives
266        // everything through `hx-*` attributes rather than inline handlers, so
267        // no `'unsafe-inline'` and no `'unsafe-eval'` are needed. `style-src
268        // 'self'` is why `layout.html` sets htmx's `includeIndicatorStyles` to
269        // false -- htmx would otherwise inject a <style> element this refuses.
270        // `default-src 'none'` means anything added later has to be allowed
271        // deliberately rather than inherited by accident.
272        .layer(SetResponseHeaderLayer::overriding(
273            header::CONTENT_SECURITY_POLICY,
274            HeaderValue::from_static(
275                "default-src 'none'; script-src 'self'; style-src 'self'; \
276                 img-src 'self' data:; connect-src 'self'; form-action 'self'; \
277                 frame-ancestors 'none'; base-uri 'none'",
278            ),
279        ))
280        // Outermost, and the same middleware the ACME listener uses: an admin
281        // request gets an `x-request-id` and an access line on identical
282        // terms. Its `profile` field stays `field::Empty` and renders as
283        // absent, which is correct — an admin request belongs to no profile.
284        .layer(middleware::from_fn(
285            middlewares::access::add_access_middleware,
286        ));
287
288    (router, logins)
289}
290
291/// Mounts the API at `/api` with fallbacks that answer in the admin error
292/// shape.
293///
294/// Without these, a typo'd path would fall through to an empty body — the same
295/// reasoning the profile routers already encode, except that there the fallback
296/// has to be an ACME problem document and here it must not be.
297fn api_with_fallbacks(api: Router<AdminState>) -> Router<AdminState> {
298    Router::new().nest(
299        "/api",
300        api.method_not_allowed_fallback(|| async {
301            AdminError::with_code(
302                StatusCode::METHOD_NOT_ALLOWED,
303                "method_not_allowed",
304                "that method is not allowed on this resource",
305            )
306        })
307        .fallback(|| async { AdminError::not_found("no such admin API resource") }),
308    )
309}
310
311/// Rejects an `[admin]` section that cannot work, before anything binds.
312///
313/// Runs only when the panel is enabled: an operator who has not turned it on
314/// must never be stopped from starting by a section they never edited.
315pub fn check_config(config: &Config) -> anyhow::Result<()> {
316    let admin = &config.admin;
317    if !admin.enabled {
318        return Ok(());
319    }
320
321    if admin.bind_address == config.server.bind_address {
322        bail!(
323            "admin.bind_address and server.bind_address are both `{}`: the web admin is a \
324             second listener on its own socket, not a path on the ACME one",
325            admin.bind_address
326        );
327    }
328
329    let url = url::Url::parse(&admin.base_url).map_err(|error| {
330        anyhow::anyhow!("admin.base_url `{}` is not a URL: {error}", admin.base_url)
331    })?;
332    if url.host_str().is_none_or(str::is_empty) {
333        bail!(
334            "admin.base_url `{}` has no host: it names the origin the panel is reached at, \
335             and a generated certificate takes its name from it",
336            admin.base_url
337        );
338    }
339
340    // A hard error, not a warning, and the reasoning is worth keeping: the
341    // session cookie is always sent `Secure` (never conditionally -- that is
342    // how a session cookie leaks). Browsers accept a `Secure` cookie on
343    // `http://localhost` and silently refuse it on `http://192.0.2.10:3001`.
344    // The operator would see "login succeeds, then I am immediately logged
345    // out" with nothing in any log to explain it. Refuse, and name both keys.
346    // Same reasoning as `filter.allowed_ip` with two empty lists being a
347    // startup error rather than an accept-everything default.
348    if !admin.tls.enabled && !binds_loopback_only(&admin.bind_address) {
349        bail!(
350            "admin.bind_address `{}` is not loopback while admin.tls.enabled is false: the \
351             session cookie is sent `Secure`, which a browser will not store over plain HTTP \
352             on anything but localhost, so signing in would appear to succeed and then fail \
353             silently. Set admin.tls.enabled = true, or bind 127.0.0.1 and reach it through \
354             an SSH tunnel",
355            admin.bind_address
356        );
357    }
358
359    // The `tls_base_url_mismatch` treatment: a warning, because the reverse
360    // proxy case (https:// in the URL, TLS terminated in front) is legitimate.
361    if admin.tls.enabled && url.scheme() == "http" {
362        warn!(event = "admin_base_url_mismatch",
363              outcome = "advisory",
364              base_url = %admin.base_url,
365              "admin.base_url names http:// while admin.tls.enabled is true: the CSRF origin \
366               check compares against it, so browser requests will be refused until it names \
367               https://");
368    }
369
370    // `admin.base_url` is load-bearing in three unrelated ways -- the CSRF
371    // origin check, the generated certificate's name, and later the WebAuthn
372    // relying-party id -- and is easy to leave at its default while binding
373    // somewhere else. Logging the resolved origin makes the mismatch visible
374    // at startup rather than at the first refused request.
375    info!(event = "admin_origin_resolved",
376          outcome = "success",
377          origin = %url.origin().ascii_serialization(),
378          bind_address = %admin.bind_address);
379
380    check_templates(&admin.template_dir)?;
381
382    Ok(())
383}
384
385/// Compiles every page template, so a broken override stops the process here
386/// rather than serving a `500` at three in the morning.
387///
388/// The same posture as the rest of startup: a path that can fail fast should.
389/// The cost is one compile of ~20 small templates, once.
390fn check_templates(template_dir: &str) -> anyhow::Result<()> {
391    if !template_dir.is_empty() {
392        let path = std::path::Path::new(template_dir);
393        if !path.is_dir() {
394            bail!(
395                "admin.template_dir `{template_dir}` is not a directory: it holds per-file \
396                 overrides of the compiled-in page templates, checked by name before the \
397                 default. Leave it empty to use the defaults"
398            );
399        }
400    }
401
402    let env = pages::templates::build_environment(template_dir);
403    for name in pages::templates::template_names() {
404        env.get_template(name).map_err(|error| {
405            anyhow::anyhow!(
406                "admin page template `{name}` does not compile: {error}. \
407                 It was loaded from admin.template_dir `{template_dir}`"
408            )
409        })?;
410    }
411
412    if !template_dir.is_empty() {
413        info!(event = "admin_templates_overridden", outcome = "success", template_dir = %template_dir);
414    }
415
416    Ok(())
417}
418
419/// Whether every address `bind` can accept on is a loopback address.
420///
421/// Deliberately conservative: an address that does not parse, or a hostname
422/// this does not resolve, counts as *not* loopback. Being wrong in that
423/// direction costs an operator one explicit `admin.tls.enabled = true`; being
424/// wrong in the other silently ships a panel whose login does not work.
425fn binds_loopback_only(bind: &str) -> bool {
426    let Ok(addr) = bind.parse::<std::net::SocketAddr>() else {
427        // Not a bare socket address -- e.g. `localhost:3001`, which the
428        // listener resolves later. Accept the two spellings that can only
429        // ever be loopback, and refuse everything else.
430        return matches!(
431            bind.rsplit_once(':').map(|(host, _)| host),
432            Some("localhost" | "ip6-localhost")
433        );
434    };
435    addr.ip().is_loopback()
436}
437
438#[cfg(test)]
439mod tests {
440    use super::*;
441    use crate::config::{AdminConfig, Config};
442
443    /// `[admin]` enabled, with everything else at its default.
444    ///
445    /// Built by mutation rather than struct-update syntax: `Config` keeps a
446    /// private `raw` field, so `..Config::default()` is not available outside
447    /// `crate::config`.
448    fn enabled() -> Config {
449        let mut config = Config::default();
450        config.admin = AdminConfig {
451            enabled: true,
452            ..AdminConfig::default()
453        };
454        config
455    }
456
457    #[test]
458    fn a_disabled_panel_is_never_checked() {
459        // Every one of these would be refused if the panel were on.
460        let mut config = Config::default();
461        config.admin = AdminConfig {
462            enabled: false,
463            bind_address: "0.0.0.0:3001".to_string(),
464            base_url: "not a url".to_string(),
465            ..AdminConfig::default()
466        };
467        assert!(check_config(&config).is_ok());
468    }
469
470    #[test]
471    fn the_defaults_are_a_working_configuration() {
472        check_config(&enabled()).expect("loopback + the default base_url must start");
473    }
474
475    /// The compiled-in templates are checked on every start, so a page that
476    /// stopped compiling fails the build's own test suite rather than the first
477    /// operator to open it.
478    #[test]
479    fn the_embedded_templates_all_compile_at_startup() {
480        check_templates("").expect("the shipped templates must compile");
481    }
482
483    #[test]
484    fn a_template_dir_that_is_not_a_directory_is_refused() {
485        let dir = crate::testutil::TempDir::new("admin-template-dir");
486        let file = dir.write("not-a-directory", "");
487
488        let error = check_templates(file.to_str().unwrap()).unwrap_err();
489        assert!(error.to_string().contains("is not a directory"));
490    }
491
492    /// A broken override should stop the process, not serve a `500` at three in
493    /// the morning — the same fail-fast posture as the rest of startup.
494    #[test]
495    fn an_override_that_does_not_compile_is_refused_at_startup() {
496        let dir = crate::testutil::TempDir::new("admin-bad-template");
497        dir.write("index.html", "{% for x in %}");
498
499        let error = check_templates(dir.path().to_str().unwrap()).unwrap_err();
500        let message = error.to_string();
501        assert!(message.contains("index.html"));
502        assert!(message.contains("does not compile"));
503    }
504
505    #[test]
506    fn a_valid_override_directory_starts() {
507        let dir = crate::testutil::TempDir::new("admin-good-template");
508        dir.write("login.html", "<p>{{ flash }}</p>");
509
510        check_templates(dir.path().to_str().unwrap())
511            .expect("one overridden template must not stop the other twenty");
512    }
513
514    #[test]
515    fn a_bind_shared_with_the_acme_listener_is_refused() {
516        let mut config = enabled();
517        config.admin.bind_address = config.server.bind_address.clone();
518        let error = check_config(&config).unwrap_err().to_string();
519        assert!(error.contains("second listener"), "got: {error}");
520        assert!(error.contains(&config.server.bind_address));
521    }
522
523    #[test]
524    fn a_base_url_that_is_not_a_url_or_has_no_host_is_refused() {
525        let mut config = enabled();
526        config.admin.base_url = "not a url".to_string();
527        assert!(
528            check_config(&config)
529                .unwrap_err()
530                .to_string()
531                .contains("is not a URL")
532        );
533
534        // Parses, but names no host.
535        config.admin.base_url = "unix:/run/admin.sock".to_string();
536        let error = check_config(&config).unwrap_err().to_string();
537        assert!(error.contains("has no host"), "got: {error}");
538    }
539
540    #[test]
541    fn a_non_loopback_bind_without_tls_is_a_startup_error_not_a_warning() {
542        for bind in [
543            "0.0.0.0:3001",
544            "192.0.2.10:3001",
545            "[::]:3001",
546            "[2001:db8::1]:3001",
547        ] {
548            let mut config = enabled();
549            config.admin.bind_address = bind.to_string();
550            let error = check_config(&config).unwrap_err().to_string();
551            assert!(
552                error.contains("is not loopback"),
553                "`{bind}` must be refused without TLS, got: {error}"
554            );
555        }
556    }
557
558    #[test]
559    fn a_non_loopback_bind_is_allowed_once_tls_is_on() {
560        let mut config = enabled();
561        config.admin.bind_address = "0.0.0.0:3001".to_string();
562        config.admin.tls.enabled = true;
563        config.admin.base_url = "https://admin.example.com".to_string();
564        check_config(&config).expect("TLS is what the loopback rule was standing in for");
565    }
566
567    #[test]
568    fn every_loopback_spelling_is_accepted_without_tls() {
569        for bind in [
570            "127.0.0.1:3001",
571            "127.0.0.53:3001",
572            "[::1]:3001",
573            "localhost:3001",
574        ] {
575            let mut config = enabled();
576            config.admin.bind_address = bind.to_string();
577            check_config(&config).unwrap_or_else(|error| panic!("`{bind}` must start: {error}"));
578        }
579    }
580
581    #[test]
582    fn an_unparseable_bind_is_treated_as_non_loopback() {
583        // Conservative on purpose: the cost of being wrong this way is one
584        // explicit config key, the other way is a panel nobody can log in to.
585        let mut config = enabled();
586        config.admin.bind_address = "not-a-socket-address".to_string();
587        assert!(
588            check_config(&config)
589                .unwrap_err()
590                .to_string()
591                .contains("is not loopback")
592        );
593    }
594
595    #[test]
596    fn tls_with_an_http_base_url_warns_but_starts() {
597        let mut config = enabled();
598        config.admin.tls.enabled = true;
599        // Still http://, which the CSRF origin check will compare against.
600        check_config(&config).expect("a scheme mismatch is a warning, not a refusal");
601    }
602}