1use hyper::body::Incoming;
8use hyper::service::service_fn;
9use hyper::Request;
10use hyper_util::rt::TokioIo;
11use rust_dix::{ServiceCollection, ServiceProvider};
12use webx_core::app::IHost;
13use webx_core::config::{self, AppOptions};
14use webx_core::error::{Error, Result};
15use webx_core::handler::IHostedService;
16use webx_core::http::IHttpContext;
17use webx_core::localization::{IStringLocalizer, LocalizationOptions, StringLocalizer};
18use webx_core::middleware::IMiddleware;
19use webx_core::mode::AppMode;
20use webx_core::route::catalog::{EndpointCatalog, IEndpointCatalog};
21use webx_core::routing::{HttpMethod, IEndpoint, IRouter};
22
23use crate::auth_jwt::{init_jwt_secret, jwt_middleware, JwtAuth};
24use crate::authz::collect_authorizers;
25use crate::context::{BodyLimits, HttpContext, RespBody};
26use crate::cors::{CorsConfig, CorsMiddleware};
27use crate::endpoint::{StaticHtmlEndpoint, StaticJsonEndpoint, StubEndpoint};
28use crate::health::{HealthCheckEndpoint, HealthCheckRegistry, HealthStatus};
29use crate::memory_cache::MemoryCache;
30use crate::metrics::{HttpMetrics, MetricsEndpoint, MetricsMiddleware};
31use crate::pipeline::{HandlerFn, MiddlewarePipeline};
32use crate::problem_response::{build_problem, write_problem_response, write_unauthorized};
33use crate::rate_limit::RateLimitMiddleware;
34use crate::router::Router;
35use jsonwebtoken::{DecodingKey, Validation};
36use webx_core::route::scan::RouteEntry;
37use webx_core::DispatchRuntime;
38use webx_openapi::{generate_openapi_spec, APIUI_HTML};
39use webx_spa::{EmbeddedAssets, SpaMiddleware, SpaSource};
40
41use rustls::pki_types::{CertificateDer, PrivateKeyDer};
42use rustls::ServerConfig;
43use rustls_pemfile::{certs, pkcs8_private_keys, rsa_private_keys};
44use std::sync::Arc;
45use std::time::Instant;
46use tokio::task::JoinSet;
47use tokio_rustls::TlsAcceptor;
48
49fn is_weak_jwt_secret(secret: &str) -> bool {
51 secret.is_empty()
52 || secret.len() < 32
53 || secret.contains("change-in-production")
54 || secret.contains("please-change")
55 || secret.contains("demo-secret")
56 || secret.contains("dev-secret")
57}
58
59fn log_spa_source(spa: &SpaMiddleware) {
64 tracing::info!("[Host] SPA root: {}", spa.resolved_root().display());
65 let Some(assets) = spa.embedded() else {
66 return;
67 };
68 tracing::info!(
69 "[Host] Embedded assets: {} files, {:.1} MiB embedded ({:.1} MiB uncompressed), built from `{}`",
70 assets.len(),
71 assets.total_bytes() as f64 / (1024.0 * 1024.0),
72 assets.total_raw_bytes() as f64 / (1024.0 * 1024.0),
73 assets.root(),
74 );
75 let shadowed = assets.shadowed_by(spa.resolved_root());
76 if !shadowed.is_empty() {
77 tracing::warn!(
78 "[Host] {} embedded file(s) are overridden on disk: {}. \
79 An override older than the current build stays in effect until it is \
80 updated or deleted.",
81 shadowed.len(),
82 shadowed.join(", "),
83 );
84 }
85}
86
87fn with_embedded_assets(
93 source: Option<SpaSource>,
94 assets: Option<EmbeddedAssets>,
95) -> Option<SpaSource> {
96 let Some(assets) = assets else {
97 return source;
98 };
99 let overlay_root = match source.as_ref() {
100 Some(existing) => existing.disk_root().to_string(),
101 None => assets.root().to_string(),
102 };
103 Some(SpaSource::Embedded {
104 assets,
105 overlay_root,
106 })
107}
108
109type ShutdownSignal = Arc<tokio::sync::watch::Sender<bool>>;
116
117pub struct Host {
118 #[allow(dead_code)]
119 provider: Arc<ServiceProvider>,
120 dispatch_runtime: Arc<DispatchRuntime>,
121 pub options: AppOptions,
122 endpoints: Arc<dyn IEndpointCatalog>,
124 localizer: Arc<dyn IStringLocalizer>,
126 body_limits: BodyLimits,
128 pipeline: Arc<MiddlewarePipeline>,
129 #[allow(dead_code)]
131 router: Arc<Router>,
132 router_handler: HandlerFn,
134 mode: AppMode,
135 #[allow(dead_code)]
137 spa_source: Option<SpaSource>,
138 shutdown: ShutdownSignal,
139 hosted_services: Vec<Arc<dyn IHostedService>>,
142}
143
144type MiddlewareFactory = Box<dyn Fn() -> Arc<dyn IMiddleware> + Send + Sync>;
146
147struct MiddlewareSpec {
149 slot: webx_core::middleware::MiddlewareSlot,
150 factory: MiddlewareFactory,
151}
152
153#[allow(clippy::type_complexity)]
154pub struct HostBuilder {
155 service_configs: Vec<Box<dyn FnOnce(ServiceCollection) -> ServiceCollection + Send>>,
156 mode: AppMode,
157 spa_source: Option<SpaSource>,
158 spa_disabled: bool,
159 options_modifiers: Vec<Box<dyn FnOnce(&mut AppOptions) + Send>>,
160 cors_config: Option<CorsConfig>,
161 auth_enabled: bool,
162 localization: Option<LocalizationOptions>,
163 health_registry: Arc<HealthCheckRegistry>,
164 middleware_specs: Vec<MiddlewareSpec>,
167}
168
169#[allow(clippy::type_complexity)]
170pub struct HostAppBuilder {
171 options_modifiers: Vec<Box<dyn FnOnce(&mut AppOptions) + Send>>,
172}
173
174impl HostAppBuilder {
175 fn new() -> Self {
176 Self {
177 options_modifiers: Vec::new(),
178 }
179 }
180
181 #[allow(non_snake_case)]
182 pub fn useOptions<F>(&mut self, f: F)
183 where
184 F: FnOnce(&mut AppOptions) + Send + 'static,
185 {
186 self.options_modifiers.push(Box::new(f));
187 }
188}
189
190impl HostBuilder {
191 pub fn new() -> Self {
192 Self {
193 service_configs: Vec::new(),
194 mode: AppMode::from_env(),
197 spa_source: None,
198 spa_disabled: false,
199 options_modifiers: Vec::new(),
200 cors_config: None,
201 auth_enabled: false,
202 localization: None,
203 health_registry: HealthCheckRegistry::new(),
204 middleware_specs: Vec::new(),
205 }
206 }
207
208 pub fn register<F>(mut self, f: F) -> Self
209 where
210 F: FnOnce(ServiceCollection) -> ServiceCollection + Send + 'static,
211 {
212 self.service_configs.push(Box::new(f));
213 self
214 }
215
216 pub fn configure<F>(mut self, f: F) -> Self
217 where
218 F: FnOnce(&mut HostAppBuilder) + Send + 'static,
219 {
220 let mut builder = HostAppBuilder::new();
221 f(&mut builder);
222 self.options_modifiers
223 .append(&mut builder.options_modifiers);
224 self
225 }
226
227 pub fn mode(mut self, mode: AppMode) -> Self {
228 self.mode = mode;
229 self
230 }
231
232 pub fn add_options<T>(self, section: &str) -> Self
244 where
245 T: for<'de> serde::Deserialize<'de> + Default + Send + Sync + 'static,
246 {
247 let mode = self.mode;
248 let section = section.to_string();
249 self.register(move |svc| {
250 let appsettings = config::load_appsettings(mode).unwrap_or_default();
251 let bound: T = config::bind_config(&appsettings, §ion);
252 tracing::info!(
253 "[Host] add_options<{}>(\"{}\") registered",
254 std::any::type_name::<T>(),
255 section
256 );
257 svc.instance(Arc::new(bound))
258 })
259 }
260
261 pub fn use_spa(mut self, source: impl Into<SpaSource>) -> Self {
276 self.spa_source = Some(match (self.spa_source.take(), source.into()) {
277 (Some(SpaSource::Embedded { assets, .. }), SpaSource::Disk(root)) => {
280 SpaSource::Embedded {
281 assets,
282 overlay_root: root,
283 }
284 }
285 (_, incoming) => incoming,
286 });
287 self
288 }
289
290 pub fn no_spa(mut self) -> Self {
296 self.spa_disabled = true;
297 self
298 }
299
300 pub fn use_cors(mut self, config: CorsConfig) -> Self {
301 self.cors_config = Some(config);
302 self
303 }
304
305 pub fn add_authentication(mut self) -> Self {
319 self.auth_enabled = true;
320 self
321 }
322
323 pub fn add_localization(mut self) -> Self {
339 self.localization = Some(LocalizationOptions::new());
340 self
341 }
342
343 pub fn add_localization_with(mut self, options: LocalizationOptions) -> Self {
359 self.localization = Some(options);
360 self
361 }
362
363 pub fn add_memory_cache(mut self) -> Self {
375 let cache = Arc::new(MemoryCache::new());
376 self.service_configs
377 .push(Box::new(move |svc| svc.instance(cache)));
378 self
379 }
380
381 pub fn add_memory_cache_with(mut self, max_entries: usize) -> Self {
383 let cache = Arc::new(MemoryCache::new().with_max_entries(max_entries));
384 self.service_configs
385 .push(Box::new(move |svc| svc.instance(Arc::clone(&cache))));
386 self
387 }
388
389 pub fn use_middleware<T: IMiddleware + Default + 'static>(self) -> Self {
402 self.use_middleware_at::<T>(webx_core::middleware::MiddlewareSlot::AfterAuth)
403 }
404
405 pub fn use_middleware_at<T: IMiddleware + Default + 'static>(
407 mut self,
408 slot: webx_core::middleware::MiddlewareSlot,
409 ) -> Self {
410 self.middleware_specs.push(MiddlewareSpec {
411 slot,
412 factory: Box::new(|| Arc::new(T::default()) as Arc<dyn IMiddleware>),
413 });
414 self
415 }
416
417 pub fn use_middleware_with<F>(self, factory: F) -> Self
430 where
431 F: Fn() -> Arc<dyn IMiddleware> + Send + Sync + 'static,
432 {
433 self.use_middleware_with_at(webx_core::middleware::MiddlewareSlot::AfterAuth, factory)
434 }
435
436 pub fn use_middleware_with_at<F>(
438 mut self,
439 slot: webx_core::middleware::MiddlewareSlot,
440 factory: F,
441 ) -> Self
442 where
443 F: Fn() -> Arc<dyn IMiddleware> + Send + Sync + 'static,
444 {
445 self.middleware_specs.push(MiddlewareSpec {
446 slot,
447 factory: Box::new(factory),
448 });
449 self
450 }
451
452 pub fn add_health_check<F>(self, name: impl Into<String>, check: F) -> Self
465 where
466 F: Fn() -> HealthStatus + Send + Sync + 'static,
467 {
468 self.health_registry.register(name, Arc::new(check));
469 self
470 }
471
472 pub fn try_build(self) -> Result<Host> {
488 let env_filter = std::env::var("RUST_LOG").unwrap_or_else(|_| "info".into());
491 if self.mode == AppMode::Development {
492 let _ = tracing_subscriber::fmt()
493 .with_env_filter(env_filter)
494 .without_time()
495 .with_target(false)
496 .try_init();
497 } else {
498 let _ = tracing_subscriber::fmt()
499 .json()
500 .with_env_filter(env_filter)
501 .try_init();
502 }
503
504 let appsettings =
507 config::load_appsettings(self.mode).unwrap_or_else(|| serde_json::json!({}));
508 let mut options: AppOptions = config::bind_root(&appsettings);
509 for modifier in self.options_modifiers {
510 modifier(&mut options);
511 }
512
513 let default_culture = if options.culture.default.trim().is_empty() {
514 "en".to_string()
515 } else {
516 options.culture.default.clone()
517 };
518 let localization = self.localization.unwrap_or_else(LocalizationOptions::none);
519 let localizer: Arc<dyn IStringLocalizer> = Arc::new(
520 StringLocalizer::new(default_culture, &localization)
521 .map_err(|error| Error::Di(format!("Invalid localization resources: {error}")))?,
522 );
523 let endpoints: Arc<dyn IEndpointCatalog> = Arc::new(EndpointCatalog::from_inventory());
524
525 let mut svc = ServiceCollection::from_injected();
526 for cfg in self.service_configs {
527 svc = cfg(svc);
528 }
529
530 let health_registry = Arc::clone(&self.health_registry);
532 svc = svc.instance(health_registry);
533
534 let endpoints_for_di = Arc::clone(&endpoints);
536 svc = svc.singleton::<dyn IEndpointCatalog>(move |_| Arc::clone(&endpoints_for_di));
537 let localizer_for_di = Arc::clone(&localizer);
538 svc = svc.singleton::<dyn IStringLocalizer>(move |_| Arc::clone(&localizer_for_di));
539
540 let provider = svc.build().map_err(|e| {
541 Error::Di(format!(
542 "Failed to build ServiceProvider: {}. Check your DI registrations.",
543 e
544 ))
545 })?;
546
547 let handler_cache = Arc::new(webx_core::route::scan::HandlerCache::build());
549 let dispatch_runtime = Arc::new(DispatchRuntime::new(Arc::clone(&provider), handler_cache));
550
551 let body_limits = BodyLimits::from_options(&options);
553 body_limits.apply_spool_root();
554 tracing::info!(
555 "[Host] Uploads: max request {} bytes, max file {} bytes, spool >{} bytes to {}",
556 body_limits.multipart_body,
557 body_limits.file,
558 body_limits.memory_threshold,
559 body_limits.spool_dir.display()
560 );
561
562 let http_metrics = HttpMetrics::new();
563
564 let mut pipeline = MiddlewarePipeline::new();
565
566 pipeline.add_middleware(Arc::new(
576 crate::security_headers::SecurityHeadersMiddleware::new(),
577 ));
578 pipeline.add_middleware(Arc::new(crate::request_id::RequestIdMiddleware::new()));
579
580 for spec in self
581 .middleware_specs
582 .iter()
583 .filter(|spec| spec.slot == webx_core::middleware::MiddlewareSlot::BeforeCors)
584 {
585 pipeline.add_middleware((spec.factory)());
586 }
587
588 let cors = self.cors_config.unwrap_or_else(|| {
589 let cs = &options.cors;
590 CorsConfig {
591 origins: cs.origins.clone(),
592 methods: cs.methods.clone(),
593 headers: cs.headers.clone(),
594 expose_headers: cs.expose_headers.clone(),
595 allow_credentials: cs.allow_credentials,
596 max_age: cs.max_age,
597 }
598 });
599 pipeline.add_middleware(Arc::new(CorsMiddleware::new(cors)));
600
601 if options.metrics.enabled {
602 tracing::info!("[Host] Metrics enabled at GET /metrics");
603 pipeline.add_middleware(Arc::new(MetricsMiddleware::new(Arc::clone(&http_metrics))));
604 }
605
606 if options.rate_limit.enabled {
607 tracing::info!(
608 "[Host] RateLimit enabled: {} req/s, burst {}, max_ips {}",
609 options.rate_limit.requests_per_second,
610 options.rate_limit.burst_size,
611 options.rate_limit.max_tracked_ips
612 );
613 pipeline.add_middleware(Arc::new(RateLimitMiddleware::from_config(
614 &options.rate_limit,
615 )));
616 }
617
618 if self.auth_enabled {
619 let secret = options.jwt.secret.clone();
620 if !secret.is_empty() {
621 let jwt_auth = Arc::new(JwtAuth::new(
622 DecodingKey::from_secret(secret.as_bytes()),
623 Validation::default(),
624 ));
625 pipeline.add_middleware(Arc::new(jwt_middleware(jwt_auth)));
626 init_jwt_secret(&secret);
627 } else {
628 tracing::warn!(
629 "add_authentication() enabled but no JWT secret configured. Set Jwt.Secret in appsettings.json, JWT_SECRET, or APP__Jwt__Secret env var."
630 );
631 }
632 }
633
634 for spec in self
638 .middleware_specs
639 .iter()
640 .filter(|spec| spec.slot == webx_core::middleware::MiddlewareSlot::AfterAuth)
641 {
642 pipeline.add_middleware((spec.factory)());
643 }
644 for mw in provider.get_all::<dyn IMiddleware>() {
645 pipeline.add_middleware(mw);
646 }
647
648 let spa_source = if self.spa_disabled {
656 None
657 } else {
658 let base = self.spa_source.clone().or_else(|| {
659 let candidate = webx_core::paths::app_base().join("wwwroot");
660 if candidate.is_dir() {
661 tracing::info!("[Host] Auto-detected SPA root: {}", candidate.display());
662 Some(SpaSource::Disk(candidate.to_string_lossy().into_owned()))
663 } else {
664 None
665 }
666 });
667 with_embedded_assets(base, EmbeddedAssets::registered().copied())
668 };
669 let mut spa_fallback: Option<Arc<SpaMiddleware>> = None;
670 if let Some(ref source) = spa_source {
671 let spa = Arc::new(SpaMiddleware::from_source(source.clone()));
672 log_spa_source(&spa);
673 pipeline.add_middleware(Arc::clone(&spa) as Arc<dyn IMiddleware>);
674 spa_fallback = Some(spa);
678 }
679
680 for spec in self
682 .middleware_specs
683 .iter()
684 .filter(|spec| spec.slot == webx_core::middleware::MiddlewareSlot::AfterSpa)
685 {
686 pipeline.add_middleware((spec.factory)());
687 }
688
689 let pipeline = Arc::new(pipeline);
690
691 if self.mode == AppMode::Production
693 && self.auth_enabled
694 && is_weak_jwt_secret(&options.jwt.secret)
695 {
696 return Err(Error::Di(
697 "Production requires a strong JWT secret. Set APP__Jwt__Secret or JWT_SECRET env var (min 32 chars, not a placeholder)."
698 .to_string(),
699 ));
700 }
701
702 if self.mode == AppMode::Production && options.cors.origins.iter().any(|o| o == "*") {
704 return Err(Error::Di(
705 "Production forbids CORS origin '*'. Set explicit origins via Cors.Origins or APP__Cors__Origins."
706 .to_string(),
707 ));
708 }
709
710 let mut router = Router::new();
711 let mut route_count = 0usize;
712
713 crate::diagnostics::validate_route_configuration()?;
714
715 let mut dispatch_map: std::collections::HashMap<
717 &'static str,
718 webx_core::route::scan::RouteDispatchFn,
719 > = std::collections::HashMap::new();
720
721 for dispatch in inventory::iter::<webx_core::route::scan::RouteDispatch> {
722 dispatch_map.insert(dispatch.handler_type, dispatch.dispatch);
723 }
724
725 for entry in inventory::iter::<RouteEntry> {
726 route_count += 1;
727 let stub = Arc::new(StubEndpoint {
728 method: entry.method.as_str(),
729 path: entry.path,
730 handler_type: entry.handler_type,
731 dispatch_fn: dispatch_map.get(entry.handler_type).copied(),
732 authorization: entry.authorization,
733 authorizers: collect_authorizers(provider.as_ref()),
734 });
735 router.register(entry.method, entry.path, stub);
736 }
737
738 let openapi_spec = generate_openapi_spec(
739 "Rust WebX API",
740 env!("CARGO_PKG_VERSION"),
741 localizer.as_ref(),
742 );
743 if self.mode == AppMode::Development {
744 let openapi_bytes = serde_json::to_vec(&openapi_spec).unwrap_or_default();
745 router.register(
746 HttpMethod::Get,
747 "/api/openapi.json",
748 Arc::new(StaticJsonEndpoint::new(openapi_bytes)),
749 );
750 router.register(
751 HttpMethod::Get,
752 "/api/openapi.html",
753 Arc::new(StaticHtmlEndpoint { body: APIUI_HTML }),
754 );
755 }
756
757 let health_registry = Arc::clone(&self.health_registry);
760 let health_endpoint: Arc<dyn IEndpoint> =
761 Arc::new(HealthCheckEndpoint::new(Arc::clone(&health_registry)));
762 router.register(HttpMethod::Get, "/health", Arc::clone(&health_endpoint));
763 router.register(HttpMethod::Get, "/healthz", health_endpoint);
764
765 let live_body =
767 serde_json::to_vec(&serde_json::json!({"status":"pass"})).unwrap_or_default();
768 router.register(
769 HttpMethod::Get,
770 "/health/live",
771 Arc::new(StaticJsonEndpoint {
772 body: live_body,
773 content_type: "application/health+json",
774 }),
775 );
776
777 router.register(
779 HttpMethod::Get,
780 "/health/ready",
781 Arc::new(HealthCheckEndpoint::new(health_registry)),
782 );
783
784 if options.metrics.enabled {
785 router.register(
786 HttpMethod::Get,
787 "/metrics",
788 Arc::new(MetricsEndpoint::new(http_metrics)),
789 );
790 }
791
792 if self.mode == AppMode::Development {
793 let version = env!("CARGO_PKG_VERSION");
794 tracing::info!("");
795 tracing::info!(" ----------------------------------------------------------------");
796 tracing::info!(" Rust WebX Framework v{}", version);
797 tracing::info!(" ----------------------------------------------------------------");
798 tracing::info!(" App: {}", options.app.name);
799 tracing::info!(" CORS: enabled");
800 if let Some(source) = spa_source.as_ref() {
801 tracing::info!(" SPA Root: {}", source.disk_root());
802 if let Some(assets) = source.embedded() {
803 tracing::info!(
804 " Embedded: {} files, {:.1} MiB from {:.1} MiB (built from {})",
805 assets.len(),
806 assets.total_bytes() as f64 / (1024.0 * 1024.0),
807 assets.total_raw_bytes() as f64 / (1024.0 * 1024.0),
808 assets.root(),
809 );
810 }
811 }
812 if route_count > 0 {
813 tracing::info!(" Routes: {} registered", route_count);
814 }
815 crate::diagnostics::log_startup_diagnostics();
816 let banner_urls = if options.app.urls.is_empty() {
817 vec!["http://localhost:5000".to_string()]
818 } else {
819 options
820 .app
821 .urls
822 .iter()
823 .map(|u| u.replace("0.0.0.0", "localhost"))
824 .collect::<Vec<_>>()
825 };
826 for url in &banner_urls {
827 tracing::info!(" OpenAPI: {}/api/openapi.html", url);
828 tracing::info!(" OpenAPI: {}/api/openapi.json", url);
829 }
830 tracing::info!(" ----------------------------------------------------------------");
831 tracing::info!("");
832 } else if route_count > 0 {
833 tracing::info!("{} route(s) registered", route_count);
834 }
835
836 let router = Arc::new(router);
837 let router_handler = make_router_handler(Arc::clone(&router), spa_fallback);
838
839 let hosted_services: Vec<Arc<dyn IHostedService>> =
842 provider.get_all::<dyn IHostedService>();
843
844 Ok(Host {
845 provider,
846 dispatch_runtime,
847 options,
848 endpoints,
849 localizer,
850 body_limits,
851 pipeline,
852 router,
853 router_handler,
854 mode: self.mode,
855 spa_source,
856 shutdown: Arc::new(tokio::sync::watch::channel(false).0),
857 hosted_services,
858 })
859 }
860
861 pub fn build(self) -> Host {
868 match self.try_build() {
869 Ok(host) => host,
870 Err(error) => panic!("{}", build_failure_message(&error)),
873 }
874 }
875}
876
877fn build_failure_message(error: &Error) -> String {
880 match error {
881 Error::Di(message) => message.clone(),
882 other => other.to_string(),
883 }
884}
885
886pub struct ServerHandle {
888 shutdown: ShutdownSignal,
889}
890
891impl ServerHandle {
892 pub fn shutdown(self) {
898 trigger_shutdown(&self.shutdown);
899 }
900}
901
902pub struct Server {
914 host: Host,
915}
916
917impl Server {
918 pub async fn run(self) -> Result<()> {
920 self.host.run().await
921 }
922
923 pub async fn run_at(self, addr: &str) -> Result<()> {
925 self.host.run_at(addr).await
926 }
927
928 pub fn handle(&self) -> ServerHandle {
930 self.host.server_handle()
931 }
932}
933
934impl Default for HostBuilder {
935 fn default() -> Self {
936 Self::new()
937 }
938}
939
940impl Host {
941 pub fn builder() -> HostBuilder {
942 HostBuilder::new()
943 }
944
945 pub fn options(&self) -> &AppOptions {
946 &self.options
947 }
948
949 pub fn provider(&self) -> &Arc<ServiceProvider> {
951 &self.provider
952 }
953
954 pub fn dispatch_runtime(&self) -> &Arc<DispatchRuntime> {
956 &self.dispatch_runtime
957 }
958
959 pub fn endpoints(&self) -> &dyn IEndpointCatalog {
965 &*self.endpoints
966 }
967
968 pub fn localizer(&self) -> &dyn IStringLocalizer {
970 &*self.localizer
971 }
972
973 pub async fn run(&self) -> Result<()> {
987 let urls = if self.options.app.urls.is_empty() {
988 vec!["http://0.0.0.0:5000".to_string()]
989 } else {
990 self.options.app.urls.clone()
991 };
992
993 let mut http_addrs: Vec<String> = Vec::new();
994 let mut https_addrs: Vec<String> = Vec::new();
995
996 for url in &urls {
997 let (scheme, addr) = parse_url(url)?;
998 match scheme {
999 "http" => http_addrs.push(addr),
1000 "https" => https_addrs.push(addr),
1001 other => {
1002 return Err(webx_core::error::Error::Http(format!(
1003 "Unsupported URL scheme '{}' in '{}'",
1004 other, url
1005 )))
1006 }
1007 }
1008 }
1009
1010 let acceptor = if !https_addrs.is_empty() {
1011 let tls = &self.options.tls;
1012 if tls.cert_path.is_empty() || tls.key_path.is_empty() {
1013 return Err(webx_core::error::Error::Http(
1014 "HTTPS URLs require Tls.CertPath and Tls.KeyPath".into(),
1015 ));
1016 }
1017 Some(build_tls_acceptor(&tls.cert_path, &tls.key_path)?)
1018 } else {
1019 None
1020 };
1021
1022 let mut http_listeners = Vec::with_capacity(http_addrs.len());
1026 for addr in &http_addrs {
1027 http_listeners.push(bind_listener(addr, "HTTP").await?);
1028 }
1029 let mut https_listeners = Vec::with_capacity(https_addrs.len());
1030 for addr in &https_addrs {
1031 https_listeners.push(bind_listener(addr, "HTTPS").await?);
1032 }
1033
1034 start_hosted_services(&self.hosted_services, &self.dispatch_runtime).await?;
1035
1036 if self.mode == AppMode::Development {
1038 tracing::info!("");
1039 for url in &urls {
1040 let display_url = url.replace("0.0.0.0", "localhost");
1041 tracing::info!(" Listening on {}", display_url);
1042 }
1043 } else {
1044 tracing::info!("Listening on {} url(s)", urls.len());
1045 }
1046
1047 install_shutdown_handler(Arc::clone(&self.shutdown));
1048 spawn_async_shutdown_listener(Arc::clone(&self.shutdown));
1049
1050 let mut handles = Vec::new();
1051 let pipeline = Arc::clone(&self.pipeline);
1052 let router_handler = self.router_handler.clone();
1053 let dispatch_runtime = Arc::clone(&self.dispatch_runtime);
1054 let mode = self.mode;
1055 let body_limits = self.body_limits.clone();
1056 let max_connections = self.options.app.max_connections;
1057
1058 for (addr, listener) in http_addrs.into_iter().zip(http_listeners) {
1059 handles.push(tokio::spawn(serve_http(
1060 addr,
1061 listener,
1062 self.shutdown.subscribe(),
1063 Arc::clone(&pipeline),
1064 router_handler.clone(),
1065 Arc::clone(&dispatch_runtime),
1066 mode,
1067 body_limits.clone(),
1068 max_connections,
1069 )));
1070 }
1071
1072 if let Some(ref tls_acceptor) = acceptor {
1073 for (addr, listener) in https_addrs.into_iter().zip(https_listeners) {
1074 handles.push(tokio::spawn(serve_https(
1075 addr,
1076 tls_acceptor.clone(),
1077 listener,
1078 self.shutdown.subscribe(),
1079 Arc::clone(&pipeline),
1080 router_handler.clone(),
1081 Arc::clone(&dispatch_runtime),
1082 mode,
1083 body_limits.clone(),
1084 max_connections,
1085 )));
1086 }
1087 }
1088
1089 let mut run_error: Option<webx_core::error::Error> = None;
1092 for h in handles {
1093 if let Err(join_err) = h.await {
1094 if run_error.is_none() {
1095 run_error = Some(webx_core::error::Error::Internal(format!(
1096 "a listener task failed: {join_err}"
1097 )));
1098 }
1099 }
1100 }
1101
1102 stop_hosted_services(&self.hosted_services, &self.dispatch_runtime).await;
1103
1104 match run_error {
1105 Some(err) => Err(err),
1106 None => Ok(()),
1107 }
1108 }
1109
1110 pub async fn run_at(&self, addr: &str) -> Result<()> {
1112 let listener = bind_listener(addr, "HTTP").await?;
1113 start_hosted_services(&self.hosted_services, &self.dispatch_runtime).await?;
1114
1115 install_shutdown_handler(Arc::clone(&self.shutdown));
1116 spawn_async_shutdown_listener(Arc::clone(&self.shutdown));
1117 serve_http(
1118 addr.to_string(),
1119 listener,
1120 self.shutdown.subscribe(),
1121 Arc::clone(&self.pipeline),
1122 self.router_handler.clone(),
1123 Arc::clone(&self.dispatch_runtime),
1124 self.mode,
1125 self.body_limits.clone(),
1126 self.options.app.max_connections,
1127 )
1128 .await;
1129
1130 stop_hosted_services(&self.hosted_services, &self.dispatch_runtime).await;
1131 Ok(())
1132 }
1133
1134 pub fn server_handle(&self) -> ServerHandle {
1145 ServerHandle {
1146 shutdown: Arc::clone(&self.shutdown),
1147 }
1148 }
1149
1150 pub fn into_server(self) -> Server {
1155 Server { host: self }
1156 }
1157}
1158
1159#[async_trait::async_trait]
1160impl IHost for Host {
1161 async fn run(&self, addr: &str) -> Result<()> {
1162 self.run_at(addr).await
1163 }
1164 async fn stop(&self) -> Result<()> {
1165 tracing::info!("Stop requested.");
1166 trigger_shutdown(&self.shutdown);
1167 Ok(())
1168 }
1169}
1170
1171fn make_router_handler(router: Arc<Router>, spa_fallback: Option<Arc<SpaMiddleware>>) -> HandlerFn {
1172 Arc::new(move |ctx: &mut dyn IHttpContext| {
1173 let router = Arc::clone(&router);
1174 let spa_fallback = spa_fallback.clone();
1175 Box::pin(async move {
1176 if let Some(err) = ctx.take_early_error() {
1180 return Err(err);
1181 }
1182 match router.match_route(ctx).await? {
1183 Some((endpoint, params, pattern)) => {
1184 drop(router);
1185 for (key, value) in params {
1186 ctx.request_mut().route_params_mut().insert(key, value);
1187 }
1188 *ctx.request_mut().route_pattern_mut() = Some(pattern);
1189 endpoint.handle(ctx).await
1190 }
1191 None => {
1192 let path_exists = router.path_exists(ctx.request().path());
1193 let allowed = if path_exists {
1194 router.allowed_methods(ctx.request().path())
1195 } else {
1196 Vec::new()
1197 };
1198 drop(router);
1199 if !ctx.response().has_body() {
1202 if let Some(spa) = spa_fallback {
1205 if spa.try_index_fallback(ctx).await? {
1206 return Ok(());
1207 }
1208 }
1209 if path_exists {
1210 if ctx.request().method().eq_ignore_ascii_case("OPTIONS") {
1211 ctx.response_mut().set_status(204);
1216 ctx.response_mut().set_header("allow", &allowed.join(", "));
1217 } else {
1218 write_method_not_allowed(ctx, &allowed).await;
1220 }
1221 } else {
1222 write_plain_error(ctx, 404, "Not Found").await;
1223 }
1224 }
1225 Ok(())
1226 }
1227 }
1228 })
1229 })
1230}
1231
1232async fn handle_request(
1233 req: Request<Incoming>,
1234 pipeline: Arc<MiddlewarePipeline>,
1235 router_handler: HandlerFn,
1236 dispatch_runtime: Arc<DispatchRuntime>,
1237 body_limits: BodyLimits,
1238 remote_addr: Option<std::net::SocketAddr>,
1239) -> std::result::Result<hyper::Response<RespBody>, std::convert::Infallible> {
1240 let mut ctx = HttpContext::new(req, body_limits, remote_addr);
1241
1242 let result = dispatch_runtime
1243 .run(async { pipeline.execute(&mut ctx, router_handler).await })
1244 .await;
1245 if let Err(e) = result {
1246 match e {
1247 webx_core::error::Error::Problem(problem) => {
1251 write_problem_response(&mut ctx, *problem).await
1252 }
1253 other => {
1254 write_framework_error(&mut ctx, &other).await;
1255 }
1256 }
1257 }
1258
1259 Ok(ctx.into_response().await)
1260}
1261
1262async fn write_framework_error(ctx: &mut dyn IHttpContext, error: &webx_core::error::Error) {
1274 let status = error.status_code();
1275 if status == 401 {
1276 write_unauthorized(
1277 ctx,
1278 error.public_detail().unwrap_or("authentication required"),
1279 )
1280 .await;
1281 } else if status >= 500 {
1282 tracing::error!(status, error = %error, "unhandled server error");
1283 write_problem_response(ctx, build_problem(status, "an unexpected error occurred")).await;
1284 } else {
1285 write_problem_response(
1286 ctx,
1287 build_problem(status, error.public_detail().unwrap_or("request failed")),
1288 )
1289 .await;
1290 }
1291}
1292
1293async fn write_plain_error(ctx: &mut dyn IHttpContext, status: u16, message: &str) {
1296 write_problem_response(ctx, build_problem(status, message)).await;
1297}
1298
1299async fn write_method_not_allowed(ctx: &mut dyn IHttpContext, allowed: &[&str]) {
1301 let allow = allowed.join(", ");
1302 ctx.response_mut().set_header("allow", &allow);
1303 write_plain_error(ctx, 405, "Method Not Allowed").await;
1304}
1305
1306fn trigger_shutdown(shutdown: &ShutdownSignal) {
1316 shutdown.send_replace(true);
1321}
1322
1323async fn wait_for_shutdown(shutdown: &mut tokio::sync::watch::Receiver<bool>) {
1328 if *shutdown.borrow() {
1329 return;
1330 }
1331 loop {
1332 if shutdown.changed().await.is_err() {
1333 return;
1334 }
1335 if *shutdown.borrow() {
1336 return;
1337 }
1338 }
1339}
1340
1341fn install_shutdown_handler(shutdown: ShutdownSignal) {
1352 use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
1353 use std::sync::{Mutex, OnceLock, Weak};
1354
1355 static HANDLERS: OnceLock<Mutex<Vec<Weak<tokio::sync::watch::Sender<bool>>>>> = OnceLock::new();
1356 static CTRL_COUNT: AtomicUsize = AtomicUsize::new(0);
1357 static INSTALLED: AtomicBool = AtomicBool::new(false);
1358
1359 let handlers = HANDLERS.get_or_init(|| Mutex::new(Vec::new()));
1360 {
1361 let mut registered = handlers
1362 .lock()
1363 .unwrap_or_else(|poisoned| poisoned.into_inner());
1364 registered.retain(|weak| weak.strong_count() > 0);
1366 registered.push(Arc::downgrade(&shutdown));
1367 }
1368
1369 if INSTALLED.swap(true, Ordering::SeqCst) {
1373 return;
1374 }
1375
1376 if let Err(e) = ctrlc::set_handler(move || {
1377 let n = CTRL_COUNT.fetch_add(1, Ordering::SeqCst);
1378 if n == 0 {
1379 tracing::info!("Shutdown signal received, draining connections...");
1380 let registered = handlers
1381 .lock()
1382 .unwrap_or_else(|poisoned| poisoned.into_inner());
1383 for weak in registered.iter() {
1384 if let Some(shutdown) = weak.upgrade() {
1385 trigger_shutdown(&shutdown);
1386 }
1387 }
1388 } else {
1389 tracing::warn!("Second interrupt — force exiting.");
1390 std::process::exit(130);
1391 }
1392 }) {
1393 tracing::warn!("Could not install Ctrl+C handler: {}", e);
1394 }
1395}
1396
1397fn spawn_async_shutdown_listener(shutdown: ShutdownSignal) {
1399 tokio::spawn(async move {
1400 wait_for_shutdown_signal().await;
1401 tracing::info!("Async shutdown signal received, draining connections...");
1402 trigger_shutdown(&shutdown);
1403 });
1404}
1405
1406async fn wait_for_shutdown_signal() {
1407 #[cfg(unix)]
1408 {
1409 use tokio::signal::unix::{signal, SignalKind};
1410 let mut sigterm = signal(SignalKind::terminate()).expect("install SIGTERM handler");
1411 tokio::select! {
1412 res = tokio::signal::ctrl_c() => {
1413 if let Err(e) = res {
1414 tracing::warn!("Ctrl+C listener error: {}", e);
1415 }
1416 }
1417 _ = sigterm.recv() => {}
1418 }
1419 }
1420 #[cfg(not(unix))]
1421 {
1422 if let Err(e) = tokio::signal::ctrl_c().await {
1423 tracing::warn!("Ctrl+C listener error: {}", e);
1424 }
1425 }
1426}
1427
1428const HOSTED_START_TIMEOUT_SECS: u64 = 30;
1430const HOSTED_STOP_TIMEOUT_SECS: u64 = 30;
1432
1433async fn start_hosted_services(
1434 services: &[Arc<dyn IHostedService>],
1435 dispatch_runtime: &Arc<DispatchRuntime>,
1436) -> Result<()> {
1437 if services.is_empty() {
1438 return Ok(());
1439 }
1440 tracing::info!("Starting {} hosted service(s)...", services.len());
1441 let mut started = 0usize;
1442 let mut failure: Option<webx_core::error::Error> = None;
1443
1444 for svc in services {
1445 let attempt = tokio::time::timeout(
1450 std::time::Duration::from_secs(HOSTED_START_TIMEOUT_SECS),
1451 dispatch_runtime.run_in_scope(async { svc.start().await }),
1452 )
1453 .await;
1454 match attempt {
1455 Ok(Ok(())) => started += 1,
1456 Ok(Err(err)) => {
1457 failure = Some(err);
1458 break;
1459 }
1460 Err(_) => {
1461 failure = Some(webx_core::error::Error::Internal(format!(
1462 "hosted service start timed out after {HOSTED_START_TIMEOUT_SECS}s"
1463 )));
1464 break;
1465 }
1466 }
1467 }
1468
1469 match failure {
1470 None => {
1471 tracing::info!("All {started} hosted service(s) started.");
1472 Ok(())
1473 }
1474 Some(err) => {
1475 for (index, svc) in services[..started].iter().enumerate().rev() {
1478 stop_hosted_service(dispatch_runtime, index, svc).await;
1479 }
1480 Err(err)
1481 }
1482 }
1483}
1484
1485async fn stop_hosted_services(
1486 services: &[Arc<dyn IHostedService>],
1487 dispatch_runtime: &Arc<DispatchRuntime>,
1488) {
1489 if services.is_empty() {
1490 return;
1491 }
1492 tracing::info!("Stopping {} hosted service(s)...", services.len());
1493 for (index, svc) in services.iter().enumerate().rev() {
1494 stop_hosted_service(dispatch_runtime, index, svc).await;
1495 }
1496 tracing::info!("All hosted services stopped.");
1497}
1498
1499async fn stop_hosted_service(
1501 dispatch_runtime: &Arc<DispatchRuntime>,
1502 index: usize,
1503 svc: &Arc<dyn IHostedService>,
1504) {
1505 match tokio::time::timeout(
1506 std::time::Duration::from_secs(HOSTED_STOP_TIMEOUT_SECS),
1507 dispatch_runtime.run_in_scope(async { svc.stop().await }),
1508 )
1509 .await
1510 {
1511 Ok(Ok(())) => {}
1512 Ok(Err(err)) => {
1513 tracing::warn!(service_index = index, error = %err, "hosted service stop error")
1514 }
1515 Err(_) => tracing::error!(
1516 service_index = index,
1517 timeout_secs = HOSTED_STOP_TIMEOUT_SECS,
1518 "hosted service stop timed out"
1519 ),
1520 }
1521}
1522
1523async fn drain_connections(join_set: &mut JoinSet<()>, label: &str, mode: AppMode) {
1524 let timeout_secs = if mode == AppMode::Development { 5 } else { 30 };
1525 let drain_future = async {
1526 while let Some(result) = join_set.join_next().await {
1527 if let Err(e) = result {
1528 tracing::error!("Connection task panicked: {:?}", e);
1529 }
1530 }
1531 };
1532
1533 match tokio::time::timeout(std::time::Duration::from_secs(timeout_secs), drain_future).await {
1534 Ok(_) => tracing::info!("{}: drained.", label),
1535 Err(_) => {
1536 tracing::warn!("{}: drain timeout, aborting connections.", label);
1537 join_set.abort_all();
1538 while join_set.join_next().await.is_some() {}
1539 }
1540 }
1541}
1542
1543async fn bind_listener(addr: &str, label: &str) -> Result<tokio::net::TcpListener> {
1553 tokio::net::TcpListener::bind(addr).await.map_err(|err| {
1554 webx_core::error::Error::Http(format!("failed to bind {label} on {addr}: {err}"))
1555 })
1556}
1557
1558fn parse_url(url: &str) -> Result<(&str, String)> {
1561 if let Some(rest) = url.strip_prefix("https://") {
1562 Ok(("https", rest.to_string()))
1563 } else if let Some(rest) = url.strip_prefix("http://") {
1564 Ok(("http", rest.to_string()))
1565 } else {
1566 Err(webx_core::error::Error::Http(format!(
1567 "Invalid URL '{}'. Use http://host:port or https://host:port",
1568 url
1569 )))
1570 }
1571}
1572
1573#[allow(clippy::too_many_arguments)]
1575async fn serve_http(
1576 addr: String,
1577 listener: tokio::net::TcpListener,
1578 mut shutdown: tokio::sync::watch::Receiver<bool>,
1579 pipeline: Arc<MiddlewarePipeline>,
1580 router_handler: HandlerFn,
1581 dispatch_runtime: Arc<DispatchRuntime>,
1582 mode: AppMode,
1583 body_limits: BodyLimits,
1584 max_connections: usize,
1585) {
1586 let sem = Arc::new(tokio::sync::Semaphore::new(max_connections));
1587 let mut join_set = JoinSet::new();
1588
1589 loop {
1590 if *shutdown.borrow() {
1594 tracing::info!("HTTP {}: stop accepting, draining...", addr);
1595 break;
1596 }
1597 tokio::select! {
1598 _ = shutdown.changed() => {
1599 tracing::info!("HTTP {}: stop accepting, draining...", addr);
1600 break;
1601 }
1602 accept_result = listener.accept() => {
1603 let (stream, peer_addr) = match accept_result {
1604 Ok(v) => v,
1605 Err(e) => {
1606 tracing::error!("Accept error (will retry): {}", e);
1607 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1608 continue;
1609 }
1610 };
1611 let remote_addr = Some(peer_addr);
1612
1613 while join_set.try_join_next().is_some() {}
1614
1615 let permit = match sem.clone().acquire_owned().await {
1616 Ok(p) => p,
1617 Err(_) => continue,
1618 };
1619
1620 let io = TokioIo::new(stream);
1621 let pipeline = Arc::clone(&pipeline);
1622 let router_handler = router_handler.clone();
1623 let dispatch_runtime = Arc::clone(&dispatch_runtime);
1624 let body_limits = body_limits.clone();
1625 let conn_shutdown = shutdown.clone();
1626
1627 join_set.spawn(async move {
1628 let _permit = permit;
1629 let svc_fn = service_fn(move |req: Request<Incoming>| {
1630 let pipeline = Arc::clone(&pipeline);
1631 let router_handler = router_handler.clone();
1632 let dispatch_runtime = Arc::clone(&dispatch_runtime);
1633 let body_limits = body_limits.clone();
1634 let mode = mode;
1635 let remote_addr = remote_addr;
1638 async move {
1639 let start = Instant::now();
1640 let method = req.method().to_string();
1641 let path = req.uri().path().to_string();
1642 let result = handle_request(
1643 req,
1644 pipeline,
1645 router_handler,
1646 dispatch_runtime,
1647 body_limits,
1648 remote_addr,
1649 )
1650 .await;
1651 let elapsed = start.elapsed();
1652 if mode == AppMode::Development {
1653 let status =
1654 result.as_ref().map(|r| r.status().as_u16()).unwrap_or(500);
1655 tracing::info!(
1656 "[{}] {} -> {} ({:.0}ms)",
1657 method,
1658 path,
1659 status,
1660 elapsed.as_secs_f64() * 1000.0
1661 );
1662 }
1663 result
1664 }
1665 });
1666
1667 let conn = hyper::server::conn::http1::Builder::new()
1668 .serve_connection(io, svc_fn);
1669 let mut conn = std::pin::pin!(conn);
1670 let mut conn_shutdown = conn_shutdown;
1671 tokio::select! {
1672 result = conn.as_mut() => {
1673 if let Err(err) = result {
1674 tracing::error!("Connection error: {}", err);
1675 }
1676 }
1677 _ = wait_for_shutdown(&mut conn_shutdown) => {
1678 conn.as_mut().graceful_shutdown();
1682 if let Err(err) = conn.await {
1683 tracing::debug!(error = %err, "connection closed during shutdown");
1684 }
1685 }
1686 }
1687 });
1688 }
1689 }
1690 }
1691
1692 drain_connections(&mut join_set, &format!("HTTP {}", addr), mode).await;
1693}
1694
1695#[allow(clippy::too_many_arguments)]
1697async fn serve_https(
1698 addr: String,
1699 acceptor: TlsAcceptor,
1700 listener: tokio::net::TcpListener,
1701 mut shutdown: tokio::sync::watch::Receiver<bool>,
1702 pipeline: Arc<MiddlewarePipeline>,
1703 router_handler: HandlerFn,
1704 dispatch_runtime: Arc<DispatchRuntime>,
1705 mode: AppMode,
1706 body_limits: BodyLimits,
1707 max_connections: usize,
1708) {
1709 let sem = Arc::new(tokio::sync::Semaphore::new(max_connections));
1710 let mut join_set = JoinSet::new();
1711
1712 loop {
1713 if *shutdown.borrow() {
1714 tracing::info!("HTTPS {}: stop accepting, draining...", addr);
1715 break;
1716 }
1717 tokio::select! {
1718 _ = shutdown.changed() => {
1719 tracing::info!("HTTPS {}: stop accepting, draining...", addr);
1720 break;
1721 }
1722 accept_result = listener.accept() => {
1723 let (stream, peer_addr) = match accept_result {
1724 Ok(v) => v,
1725 Err(e) => {
1726 tracing::error!("Accept error (will retry): {}", e);
1727 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1728 continue;
1729 }
1730 };
1731 let remote_addr = Some(peer_addr);
1732
1733 while join_set.try_join_next().is_some() {}
1734
1735 let permit = match sem.clone().acquire_owned().await {
1736 Ok(p) => p,
1737 Err(_) => continue,
1738 };
1739
1740 let acceptor = acceptor.clone();
1741 let pipeline = Arc::clone(&pipeline);
1742 let router_handler = router_handler.clone();
1743 let dispatch_runtime = Arc::clone(&dispatch_runtime);
1744 let body_limits = body_limits.clone();
1745 let conn_shutdown = shutdown.clone();
1746
1747 join_set.spawn(async move {
1748 let _permit = permit;
1749 match acceptor.accept(stream).await {
1750 Ok(tls_stream) => {
1751 let io = TokioIo::new(tls_stream);
1752 let svc_fn = service_fn(move |req: Request<Incoming>| {
1753 let pipeline = Arc::clone(&pipeline);
1754 let router_handler = router_handler.clone();
1755 let dispatch_runtime = Arc::clone(&dispatch_runtime);
1756 let body_limits = body_limits.clone();
1757 let mode = mode;
1758 let remote_addr = remote_addr;
1760 async move {
1761 let start = Instant::now();
1762 let method = req.method().to_string();
1763 let path = req.uri().path().to_string();
1764 let result = handle_request(
1765 req,
1766 pipeline,
1767 router_handler,
1768 dispatch_runtime,
1769 body_limits,
1770 remote_addr,
1771 )
1772 .await;
1773 let elapsed = start.elapsed();
1774 if mode == AppMode::Development {
1775 let status =
1776 result.as_ref().map(|r| r.status().as_u16()).unwrap_or(500);
1777 tracing::info!(
1778 "[{}] {} -> {} ({:.0}ms)",
1779 method,
1780 path,
1781 status,
1782 elapsed.as_secs_f64() * 1000.0
1783 );
1784 }
1785 result
1786 }
1787 });
1788
1789 let conn = hyper::server::conn::http1::Builder::new()
1790 .serve_connection(io, svc_fn);
1791 let mut conn = std::pin::pin!(conn);
1792 let mut conn_shutdown = conn_shutdown;
1793 tokio::select! {
1794 result = conn.as_mut() => {
1795 if let Err(err) = result {
1796 tracing::error!("TLS connection error: {}", err);
1797 }
1798 }
1799 _ = wait_for_shutdown(&mut conn_shutdown) => {
1800 conn.as_mut().graceful_shutdown();
1801 if let Err(err) = conn.await {
1802 tracing::debug!(error = %err, "TLS connection closed during shutdown");
1803 }
1804 }
1805 }
1806 }
1807 Err(e) => {
1808 tracing::error!("TLS handshake error: {}", e);
1809 }
1810 }
1811 });
1812 }
1813 }
1814 }
1815
1816 drain_connections(&mut join_set, &format!("HTTPS {}", addr), mode).await;
1817}
1818
1819fn build_tls_acceptor(cert_path: &str, key_path: &str) -> Result<TlsAcceptor> {
1825 use std::fs::File;
1826 use std::io::BufReader;
1827
1828 if cert_path.is_empty() || key_path.is_empty() {
1829 return Err(webx_core::error::Error::Http(
1830 "TLS certificate or key path not configured.".into(),
1831 ));
1832 }
1833
1834 let cert_file = File::open(cert_path).map_err(|e| {
1835 webx_core::error::Error::Http(format!("Cannot open cert '{}': {}", cert_path, e))
1836 })?;
1837 let mut cert_reader = BufReader::new(cert_file);
1838 let certs: Vec<CertificateDer> = certs(&mut cert_reader).filter_map(|r| r.ok()).collect();
1839 if certs.is_empty() {
1840 return Err(webx_core::error::Error::Http(format!(
1841 "No valid certs in '{}'",
1842 cert_path
1843 )));
1844 }
1845
1846 let key_file = File::open(key_path).map_err(|e| {
1847 webx_core::error::Error::Http(format!("Cannot open key '{}': {}", key_path, e))
1848 })?;
1849 let mut key_reader = BufReader::new(key_file);
1850 let key = pkcs8_private_keys(&mut key_reader)
1851 .filter_map(|r| r.ok())
1852 .map(PrivateKeyDer::from)
1853 .next()
1854 .or_else(|| {
1855 let key_file2 = File::open(key_path).map(BufReader::new).ok()?;
1856 let mut kr2 = key_file2;
1857 let rsa_keys: Vec<PrivateKeyDer> = rsa_private_keys(&mut kr2)
1858 .filter_map(|r| r.ok())
1859 .map(PrivateKeyDer::from)
1860 .collect();
1861 rsa_keys.into_iter().next()
1862 })
1863 .ok_or_else(|| {
1864 webx_core::error::Error::Http(format!("No valid private key in '{}'", key_path))
1865 })?;
1866
1867 let config = ServerConfig::builder()
1868 .with_no_client_auth()
1869 .with_single_cert(certs, key)
1870 .map_err(|e| webx_core::error::Error::Http(format!("TLS config error: {}", e)))?;
1871
1872 Ok(TlsAcceptor::from(std::sync::Arc::new(config)))
1873}
1874
1875#[cfg(test)]
1876mod embedded_attach_tests {
1877 use super::with_embedded_assets;
1878 use webx_spa::{ContentEncoding, EmbeddedAsset, EmbeddedAssets, SpaSource};
1879
1880 const FILES: &[EmbeddedAsset] = &[EmbeddedAsset {
1881 path: "index.html",
1882 bytes: b"hi",
1883 raw_len: 2,
1884 encoding: ContentEncoding::Identity,
1885 content_type: "text/html",
1886 etag: "\"x\"",
1887 }];
1888
1889 #[test]
1890 fn no_table_leaves_disk_source() {
1891 let source = Some(SpaSource::Disk("wwwroot".into()));
1892 let merged = with_embedded_assets(source, None);
1893 assert!(matches!(merged, Some(SpaSource::Disk(ref r)) if r == "wwwroot"));
1894 }
1895
1896 #[test]
1897 fn table_layers_under_use_spa_overlay() {
1898 let assets = EmbeddedAssets::new("../wwwroot", FILES);
1899 let merged = with_embedded_assets(Some(SpaSource::Disk("wwwroot".into())), Some(assets));
1900 match merged {
1901 Some(SpaSource::Embedded {
1902 overlay_root,
1903 assets,
1904 }) => {
1905 assert_eq!(overlay_root, "wwwroot");
1906 assert_eq!(assets.root(), "../wwwroot");
1907 }
1908 other => panic!("expected embedded, got {other:?}"),
1909 }
1910 }
1911
1912 #[test]
1913 fn table_alone_uses_build_root_as_overlay() {
1914 let assets = EmbeddedAssets::new("../wwwroot", FILES);
1915 let merged = with_embedded_assets(None, Some(assets));
1916 match merged {
1917 Some(SpaSource::Embedded { overlay_root, .. }) => {
1918 assert_eq!(overlay_root, "../wwwroot");
1919 }
1920 other => panic!("expected embedded, got {other:?}"),
1921 }
1922 }
1923
1924 #[test]
1925 fn host_crate_registers_no_asset_table() {
1926 assert!(EmbeddedAssets::registered().is_none());
1927 }
1928}