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("/nonces", get(handlers::get_nonces))
218 .route("/nonces/cleanup", post(handlers::cleanup_nonces))
219 .route("/profiles", get(handlers::list_profiles));
220
221 let router = Router::new()
222 .route("/health", get(crate::handlers::get_health_check))
225 .route("/", get(|| async { Redirect::to("/ui/") }))
227 .merge(api_with_fallbacks(api))
228 .merge(pages::pages_router())
229 .fallback(|| async { PageError::not_found("no such page") })
234 .with_state(state)
235 .layer(DefaultBodyLimit::max(config.admin.max_body_bytes))
236 .layer(SetResponseHeaderLayer::overriding(
240 header::CACHE_CONTROL,
241 HeaderValue::from_static("no-store"),
242 ))
243 .layer(SetResponseHeaderLayer::overriding(
244 header::REFERRER_POLICY,
245 HeaderValue::from_static("same-origin"),
246 ))
247 .layer(crate::security_headers())
252 .layer(SetResponseHeaderLayer::overriding(
261 header::CONTENT_SECURITY_POLICY,
262 HeaderValue::from_static(
263 "default-src 'none'; script-src 'self'; style-src 'self'; \
264 img-src 'self' data:; connect-src 'self'; form-action 'self'; \
265 frame-ancestors 'none'; base-uri 'none'",
266 ),
267 ))
268 .layer(middleware::from_fn(
273 middlewares::access::add_access_middleware,
274 ));
275
276 (router, logins)
277}
278
279fn api_with_fallbacks(api: Router<AdminState>) -> Router<AdminState> {
286 Router::new().nest(
287 "/api",
288 api.method_not_allowed_fallback(|| async {
289 AdminError::with_code(
290 StatusCode::METHOD_NOT_ALLOWED,
291 "method_not_allowed",
292 "that method is not allowed on this resource",
293 )
294 })
295 .fallback(|| async { AdminError::not_found("no such admin API resource") }),
296 )
297}
298
299pub fn check_config(config: &Config) -> anyhow::Result<()> {
304 let admin = &config.admin;
305 if !admin.enabled {
306 return Ok(());
307 }
308
309 if admin.bind_address == config.server.bind_address {
310 bail!(
311 "admin.bind_address and server.bind_address are both `{}`: the web admin is a \
312 second listener on its own socket, not a path on the ACME one",
313 admin.bind_address
314 );
315 }
316
317 let url = url::Url::parse(&admin.base_url).map_err(|error| {
318 anyhow::anyhow!("admin.base_url `{}` is not a URL: {error}", admin.base_url)
319 })?;
320 if url.host_str().is_none_or(str::is_empty) {
321 bail!(
322 "admin.base_url `{}` has no host: it names the origin the panel is reached at, \
323 and a generated certificate takes its name from it",
324 admin.base_url
325 );
326 }
327
328 if !admin.tls.enabled && !binds_loopback_only(&admin.bind_address) {
337 bail!(
338 "admin.bind_address `{}` is not loopback while admin.tls.enabled is false: the \
339 session cookie is sent `Secure`, which a browser will not store over plain HTTP \
340 on anything but localhost, so signing in would appear to succeed and then fail \
341 silently. Set admin.tls.enabled = true, or bind 127.0.0.1 and reach it through \
342 an SSH tunnel",
343 admin.bind_address
344 );
345 }
346
347 if admin.tls.enabled && url.scheme() == "http" {
350 warn!(event = "admin_base_url_mismatch",
351 outcome = "advisory",
352 base_url = %admin.base_url,
353 "admin.base_url names http:// while admin.tls.enabled is true: the CSRF origin \
354 check compares against it, so browser requests will be refused until it names \
355 https://");
356 }
357
358 info!(event = "admin_origin_resolved",
364 outcome = "success",
365 origin = %url.origin().ascii_serialization(),
366 bind_address = %admin.bind_address);
367
368 check_templates(&admin.template_dir)?;
369
370 Ok(())
371}
372
373fn check_templates(template_dir: &str) -> anyhow::Result<()> {
379 if !template_dir.is_empty() {
380 let path = std::path::Path::new(template_dir);
381 if !path.is_dir() {
382 bail!(
383 "admin.template_dir `{template_dir}` is not a directory: it holds per-file \
384 overrides of the compiled-in page templates, checked by name before the \
385 default. Leave it empty to use the defaults"
386 );
387 }
388 }
389
390 let env = pages::templates::build_environment(template_dir);
391 for name in pages::templates::template_names() {
392 env.get_template(name).map_err(|error| {
393 anyhow::anyhow!(
394 "admin page template `{name}` does not compile: {error}. \
395 It was loaded from admin.template_dir `{template_dir}`"
396 )
397 })?;
398 }
399
400 if !template_dir.is_empty() {
401 info!(event = "admin_templates_overridden", outcome = "success", template_dir = %template_dir);
402 }
403
404 Ok(())
405}
406
407fn binds_loopback_only(bind: &str) -> bool {
414 let Ok(addr) = bind.parse::<std::net::SocketAddr>() else {
415 return matches!(
419 bind.rsplit_once(':').map(|(host, _)| host),
420 Some("localhost" | "ip6-localhost")
421 );
422 };
423 addr.ip().is_loopback()
424}
425
426#[cfg(test)]
427mod tests {
428 use super::*;
429 use crate::config::{AdminConfig, Config};
430
431 fn enabled() -> Config {
437 let mut config = Config::default();
438 config.admin = AdminConfig {
439 enabled: true,
440 ..AdminConfig::default()
441 };
442 config
443 }
444
445 #[test]
446 fn a_disabled_panel_is_never_checked() {
447 let mut config = Config::default();
449 config.admin = AdminConfig {
450 enabled: false,
451 bind_address: "0.0.0.0:3001".to_string(),
452 base_url: "not a url".to_string(),
453 ..AdminConfig::default()
454 };
455 assert!(check_config(&config).is_ok());
456 }
457
458 #[test]
459 fn the_defaults_are_a_working_configuration() {
460 check_config(&enabled()).expect("loopback + the default base_url must start");
461 }
462
463 #[test]
467 fn the_embedded_templates_all_compile_at_startup() {
468 check_templates("").expect("the shipped templates must compile");
469 }
470
471 #[test]
472 fn a_template_dir_that_is_not_a_directory_is_refused() {
473 let dir = crate::testutil::TempDir::new("admin-template-dir");
474 let file = dir.write("not-a-directory", "");
475
476 let error = check_templates(file.to_str().unwrap()).unwrap_err();
477 assert!(error.to_string().contains("is not a directory"));
478 }
479
480 #[test]
483 fn an_override_that_does_not_compile_is_refused_at_startup() {
484 let dir = crate::testutil::TempDir::new("admin-bad-template");
485 dir.write("index.html", "{% for x in %}");
486
487 let error = check_templates(dir.path().to_str().unwrap()).unwrap_err();
488 let message = error.to_string();
489 assert!(message.contains("index.html"));
490 assert!(message.contains("does not compile"));
491 }
492
493 #[test]
494 fn a_valid_override_directory_starts() {
495 let dir = crate::testutil::TempDir::new("admin-good-template");
496 dir.write("login.html", "<p>{{ flash }}</p>");
497
498 check_templates(dir.path().to_str().unwrap())
499 .expect("one overridden template must not stop the other twenty");
500 }
501
502 #[test]
503 fn a_bind_shared_with_the_acme_listener_is_refused() {
504 let mut config = enabled();
505 config.admin.bind_address = config.server.bind_address.clone();
506 let error = check_config(&config).unwrap_err().to_string();
507 assert!(error.contains("second listener"), "got: {error}");
508 assert!(error.contains(&config.server.bind_address));
509 }
510
511 #[test]
512 fn a_base_url_that_is_not_a_url_or_has_no_host_is_refused() {
513 let mut config = enabled();
514 config.admin.base_url = "not a url".to_string();
515 assert!(
516 check_config(&config)
517 .unwrap_err()
518 .to_string()
519 .contains("is not a URL")
520 );
521
522 config.admin.base_url = "unix:/run/admin.sock".to_string();
524 let error = check_config(&config).unwrap_err().to_string();
525 assert!(error.contains("has no host"), "got: {error}");
526 }
527
528 #[test]
529 fn a_non_loopback_bind_without_tls_is_a_startup_error_not_a_warning() {
530 for bind in [
531 "0.0.0.0:3001",
532 "192.0.2.10:3001",
533 "[::]:3001",
534 "[2001:db8::1]:3001",
535 ] {
536 let mut config = enabled();
537 config.admin.bind_address = bind.to_string();
538 let error = check_config(&config).unwrap_err().to_string();
539 assert!(
540 error.contains("is not loopback"),
541 "`{bind}` must be refused without TLS, got: {error}"
542 );
543 }
544 }
545
546 #[test]
547 fn a_non_loopback_bind_is_allowed_once_tls_is_on() {
548 let mut config = enabled();
549 config.admin.bind_address = "0.0.0.0:3001".to_string();
550 config.admin.tls.enabled = true;
551 config.admin.base_url = "https://admin.example.com".to_string();
552 check_config(&config).expect("TLS is what the loopback rule was standing in for");
553 }
554
555 #[test]
556 fn every_loopback_spelling_is_accepted_without_tls() {
557 for bind in [
558 "127.0.0.1:3001",
559 "127.0.0.53:3001",
560 "[::1]:3001",
561 "localhost:3001",
562 ] {
563 let mut config = enabled();
564 config.admin.bind_address = bind.to_string();
565 check_config(&config).unwrap_or_else(|error| panic!("`{bind}` must start: {error}"));
566 }
567 }
568
569 #[test]
570 fn an_unparseable_bind_is_treated_as_non_loopback() {
571 let mut config = enabled();
574 config.admin.bind_address = "not-a-socket-address".to_string();
575 assert!(
576 check_config(&config)
577 .unwrap_err()
578 .to_string()
579 .contains("is not loopback")
580 );
581 }
582
583 #[test]
584 fn tls_with_an_http_base_url_warns_but_starts() {
585 let mut config = enabled();
586 config.admin.tls.enabled = true;
587 check_config(&config).expect("a scheme mismatch is a warning, not a refusal");
589 }
590}