1pub 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#[derive(Clone)]
56pub struct AdminState {
57 pub database: Arc<Database>,
58 pub config: Arc<Config>,
59 pub profiles: Arc<HashMap<String, Arc<Profile>>>,
61 pub logins: Arc<LoginLimiter>,
62 pub templates: Arc<minijinja::Environment<'static>>,
67 pub audit: Arc<acme_proxy_jobs::auditor::Auditor>,
71 pub notifiers: acme_proxy_jobs::notify::Notifiers,
76 pub jobs: acme_proxy_jobs::jobs::JobQueue,
79}
80
81impl AdminState {
82 #[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 #[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 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 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 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 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 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#[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
312pub(crate) struct WebTrail<'a> {
317 pub(crate) state: &'a AdminState,
318 pub(crate) request_context: &'a acme_proxy_core::audit::RequestContext,
319 pub(crate) actor: &'a str,
321 pub(crate) client: Option<std::net::IpAddr>,
322 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
360pub(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 value
375 .chars()
376 .take(acme_proxy_core::audit::USER_AGENT_MAX)
377 .collect::<String>()
378 })
379 .filter(|value| !value.is_empty())
380}
381
382pub 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#[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 .route(
457 "/session/mfa",
458 get(handlers::get_session_mfa).post(handlers::post_session_mfa),
459 )
460 .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 .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 .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 .route("/upstream-orders", get(handlers::list_upstream_orders))
548 .route("/upstream-orders/{id}", get(handlers::get_upstream_order))
549 .route("/audit", get(handlers::list_audit))
556 .route("/audit/{id}", get(handlers::get_audit))
557 .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 .route("/profiles/{name}/filter", get(handlers::get_profile_filter));
572
573 let router = Router::new()
574 .route(
577 "/health",
578 get(acme_proxy_protocol::handlers::get_health_check),
579 )
580 .route("/", get(|| async { Redirect::to("/ui/") }))
582 .merge(api_with_fallbacks(api))
583 .merge(pages::pages_router())
584 .fallback(|| async { PageError::not_found("no such page") })
589 .with_state(state)
590 .layer(catch_panic_admin_pages())
595 .layer(DefaultBodyLimit::max(config.admin.max_body_bytes))
596 .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 .layer(acme_proxy_protocol::router::security_headers())
612 .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 .layer(middleware::from_fn_with_state(
633 policy,
634 filter::admin_filter_middleware,
635 ))
636 .layer(middleware::from_fn(
641 middlewares::access::add_access_middleware,
642 ));
643
644 (router, logins)
645}
646
647fn 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 .layer(catch_panic_admin_api()),
668 )
669}
670
671fn 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
686fn 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
700pub 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
708pub 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
716pub 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 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 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 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
791fn 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
825fn binds_loopback_only(bind: &str) -> bool {
832 let Ok(addr) = bind.parse::<std::net::SocketAddr>() else {
833 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 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 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 #[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 #[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 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 #[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 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 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 #[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 #[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}