1pub 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#[derive(Clone)]
53pub struct AdminState {
54 pub database: Arc<Database>,
55 pub config: Arc<Config>,
56 pub profiles: Arc<HashMap<String, Arc<Profile>>>,
58 pub logins: Arc<LoginLimiter>,
59 pub templates: Arc<minijinja::Environment<'static>>,
64 pub audit: Arc<crate::audit::Auditor>,
68}
69
70impl AdminState {
71 #[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 #[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
121pub 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
147pub 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 .route(
172 "/session/mfa",
173 get(handlers::get_session_mfa).post(handlers::post_session_mfa),
174 )
175 .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 .route("/audit", get(handlers::list_audit))
216 .route("/audit/{id}", get(handlers::get_audit))
217 .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 .route("/profiles/{name}/filter", get(handlers::get_profile_filter));
232
233 let router = Router::new()
234 .route("/health", get(crate::handlers::get_health_check))
237 .route("/", get(|| async { Redirect::to("/ui/") }))
239 .merge(api_with_fallbacks(api))
240 .merge(pages::pages_router())
241 .fallback(|| async { PageError::not_found("no such page") })
246 .with_state(state)
247 .layer(DefaultBodyLimit::max(config.admin.max_body_bytes))
248 .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 .layer(crate::security_headers())
264 .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 .layer(middleware::from_fn(
285 middlewares::access::add_access_middleware,
286 ));
287
288 (router, logins)
289}
290
291fn 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
311pub 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 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 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 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
385fn 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
419fn binds_loopback_only(bind: &str) -> bool {
426 let Ok(addr) = bind.parse::<std::net::SocketAddr>() else {
427 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 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 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 #[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 #[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 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 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 check_config(&config).expect("a scheme mismatch is a warning, not a refusal");
601 }
602}