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::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 #[deprecated(
369 since = "0.6.0",
370 note = "Route authorization is enforced from endpoint metadata; remove this call"
371 )]
372 pub fn use_resource_authorization(self) -> Self {
373 self
374 }
375
376 pub fn add_memory_cache(mut self) -> Self {
388 let cache = Arc::new(MemoryCache::new());
389 self.service_configs
390 .push(Box::new(move |svc| svc.instance(cache)));
391 self
392 }
393
394 pub fn add_memory_cache_with(mut self, max_entries: usize) -> Self {
396 let cache = Arc::new(MemoryCache::new().with_max_entries(max_entries));
397 self.service_configs
398 .push(Box::new(move |svc| svc.instance(Arc::clone(&cache))));
399 self
400 }
401
402 pub fn use_middleware<T: IMiddleware + Default + 'static>(self) -> Self {
415 self.use_middleware_at::<T>(webx_core::middleware::MiddlewareSlot::AfterAuth)
416 }
417
418 pub fn use_middleware_at<T: IMiddleware + Default + 'static>(
420 mut self,
421 slot: webx_core::middleware::MiddlewareSlot,
422 ) -> Self {
423 self.middleware_specs.push(MiddlewareSpec {
424 slot,
425 factory: Box::new(|| Arc::new(T::default()) as Arc<dyn IMiddleware>),
426 });
427 self
428 }
429
430 pub fn use_middleware_with<F>(self, factory: F) -> Self
443 where
444 F: Fn() -> Arc<dyn IMiddleware> + Send + Sync + 'static,
445 {
446 self.use_middleware_with_at(webx_core::middleware::MiddlewareSlot::AfterAuth, factory)
447 }
448
449 pub fn use_middleware_with_at<F>(
451 mut self,
452 slot: webx_core::middleware::MiddlewareSlot,
453 factory: F,
454 ) -> Self
455 where
456 F: Fn() -> Arc<dyn IMiddleware> + Send + Sync + 'static,
457 {
458 self.middleware_specs.push(MiddlewareSpec {
459 slot,
460 factory: Box::new(factory),
461 });
462 self
463 }
464
465 pub fn add_health_check<F>(self, name: impl Into<String>, check: F) -> Self
478 where
479 F: Fn() -> HealthStatus + Send + Sync + 'static,
480 {
481 self.health_registry.register(name, Arc::new(check));
482 self
483 }
484
485 pub fn build(self) -> Host {
486 let env_filter = std::env::var("RUST_LOG").unwrap_or_else(|_| "info".into());
489 if self.mode == AppMode::Development {
490 let _ = tracing_subscriber::fmt()
491 .with_env_filter(env_filter)
492 .without_time()
493 .with_target(false)
494 .try_init();
495 } else {
496 let _ = tracing_subscriber::fmt()
497 .json()
498 .with_env_filter(env_filter)
499 .try_init();
500 }
501
502 let appsettings =
505 config::load_appsettings(self.mode).unwrap_or_else(|| serde_json::json!({}));
506 let mut options: AppOptions = config::bind_root(&appsettings);
507 for modifier in self.options_modifiers {
508 modifier(&mut options);
509 }
510
511 let default_culture = if options.culture.default.trim().is_empty() {
512 "en".to_string()
513 } else {
514 options.culture.default.clone()
515 };
516 let localization = self.localization.unwrap_or_else(LocalizationOptions::none);
517 let localizer: Arc<dyn IStringLocalizer> = Arc::new(
518 StringLocalizer::new(default_culture, &localization)
519 .unwrap_or_else(|error| panic!("Invalid localization resources: {error}")),
520 );
521 let endpoints: Arc<dyn IEndpointCatalog> = Arc::new(EndpointCatalog::from_inventory());
522
523 let mut svc = ServiceCollection::from_injected();
524 for cfg in self.service_configs {
525 svc = cfg(svc);
526 }
527
528 let health_registry = Arc::clone(&self.health_registry);
530 svc = svc.instance(health_registry);
531
532 let endpoints_for_di = Arc::clone(&endpoints);
534 svc = svc.singleton::<dyn IEndpointCatalog>(move |_| Arc::clone(&endpoints_for_di));
535 let localizer_for_di = Arc::clone(&localizer);
536 svc = svc.singleton::<dyn IStringLocalizer>(move |_| Arc::clone(&localizer_for_di));
537
538 let provider = svc.build().unwrap_or_else(|e| {
539 panic!(
540 "Failed to build ServiceProvider: {}. Check your DI registrations.",
541 e
542 )
543 });
544
545 let handler_cache = Arc::new(webx_core::route::scan::HandlerCache::build());
547 let dispatch_runtime = Arc::new(DispatchRuntime::new(Arc::clone(&provider), handler_cache));
548
549 let body_limits = BodyLimits::from_options(&options);
551 body_limits.apply_spool_root();
552 tracing::info!(
553 "[Host] Uploads: max request {} bytes, max file {} bytes, spool >{} bytes to {}",
554 body_limits.multipart_body,
555 body_limits.file,
556 body_limits.memory_threshold,
557 body_limits.spool_dir.display()
558 );
559
560 let http_metrics = HttpMetrics::new();
561
562 let mut pipeline = MiddlewarePipeline::new();
563
564 pipeline.add_middleware(Arc::new(
574 crate::security_headers::SecurityHeadersMiddleware::new(),
575 ));
576 pipeline.add_middleware(Arc::new(crate::request_id::RequestIdMiddleware::new()));
577
578 for spec in self
579 .middleware_specs
580 .iter()
581 .filter(|spec| spec.slot == webx_core::middleware::MiddlewareSlot::BeforeCors)
582 {
583 pipeline.add_middleware((spec.factory)());
584 }
585
586 let cors = self.cors_config.unwrap_or_else(|| {
587 let cs = &options.cors;
588 CorsConfig {
589 origins: cs.origins.clone(),
590 methods: cs.methods.clone(),
591 headers: cs.headers.clone(),
592 expose_headers: cs.expose_headers.clone(),
593 allow_credentials: cs.allow_credentials,
594 max_age: cs.max_age,
595 }
596 });
597 pipeline.add_middleware(Arc::new(CorsMiddleware::new(cors)));
598
599 if options.metrics.enabled {
600 tracing::info!("[Host] Metrics enabled at GET /metrics");
601 pipeline.add_middleware(Arc::new(MetricsMiddleware::new(Arc::clone(&http_metrics))));
602 }
603
604 if options.rate_limit.enabled {
605 tracing::info!(
606 "[Host] RateLimit enabled: {} req/s, burst {}, max_ips {}",
607 options.rate_limit.requests_per_second,
608 options.rate_limit.burst_size,
609 options.rate_limit.max_tracked_ips
610 );
611 pipeline.add_middleware(Arc::new(RateLimitMiddleware::from_config(
612 &options.rate_limit,
613 )));
614 }
615
616 if self.auth_enabled {
617 let secret = options.jwt.secret.clone();
618 if !secret.is_empty() {
619 let jwt_auth = Arc::new(JwtAuth::new(
620 DecodingKey::from_secret(secret.as_bytes()),
621 Validation::default(),
622 ));
623 pipeline.add_middleware(Arc::new(jwt_middleware(jwt_auth)));
624 init_jwt_secret(&secret);
625 } else {
626 tracing::warn!(
627 "add_authentication() enabled but no JWT secret configured. Set Jwt.Secret in appsettings.json, JWT_SECRET, or APP__Jwt__Secret env var."
628 );
629 }
630 }
631
632 for spec in self
636 .middleware_specs
637 .iter()
638 .filter(|spec| spec.slot == webx_core::middleware::MiddlewareSlot::AfterAuth)
639 {
640 pipeline.add_middleware((spec.factory)());
641 }
642 for mw in provider.get_all::<dyn IMiddleware>() {
643 pipeline.add_middleware(mw);
644 }
645
646 let spa_source = if self.spa_disabled {
654 None
655 } else {
656 let base = self.spa_source.clone().or_else(|| {
657 let candidate = webx_core::paths::app_base().join("wwwroot");
658 if candidate.is_dir() {
659 tracing::info!("[Host] Auto-detected SPA root: {}", candidate.display());
660 Some(SpaSource::Disk(candidate.to_string_lossy().into_owned()))
661 } else {
662 None
663 }
664 });
665 with_embedded_assets(base, EmbeddedAssets::registered().copied())
666 };
667 let mut spa_fallback: Option<Arc<SpaMiddleware>> = None;
668 if let Some(ref source) = spa_source {
669 let spa = Arc::new(SpaMiddleware::from_source(source.clone()));
670 log_spa_source(&spa);
671 pipeline.add_middleware(Arc::clone(&spa) as Arc<dyn IMiddleware>);
672 spa_fallback = Some(spa);
676 }
677
678 for spec in self
680 .middleware_specs
681 .iter()
682 .filter(|spec| spec.slot == webx_core::middleware::MiddlewareSlot::AfterSpa)
683 {
684 pipeline.add_middleware((spec.factory)());
685 }
686
687 let pipeline = Arc::new(pipeline);
688
689 if self.mode == AppMode::Production
691 && self.auth_enabled
692 && is_weak_jwt_secret(&options.jwt.secret)
693 {
694 panic!(
695 "Production requires a strong JWT secret. Set APP__Jwt__Secret or JWT_SECRET env var (min 32 chars, not a placeholder)."
696 );
697 }
698
699 if self.mode == AppMode::Production && options.cors.origins.iter().any(|o| o == "*") {
701 panic!(
702 "Production forbids CORS origin '*'. Set explicit origins via Cors.Origins or APP__Cors__Origins."
703 );
704 }
705
706 let mut router = Router::new();
707 let mut route_count = 0usize;
708
709 crate::diagnostics::assert_route_configuration_valid();
710
711 let mut dispatch_map: std::collections::HashMap<
713 &'static str,
714 webx_core::route::scan::RouteDispatchFn,
715 > = std::collections::HashMap::new();
716
717 for dispatch in inventory::iter::<webx_core::route::scan::RouteDispatch> {
718 dispatch_map.insert(dispatch.handler_type, dispatch.dispatch);
719 }
720
721 for entry in inventory::iter::<RouteEntry> {
722 route_count += 1;
723 let stub = Arc::new(StubEndpoint {
724 method: entry.method.as_str(),
725 path: entry.path,
726 handler_type: entry.handler_type,
727 dispatch_fn: dispatch_map.get(entry.handler_type).copied(),
728 authorization: entry.authorization,
729 authorizers: collect_authorizers(provider.as_ref()),
730 });
731 router.register(entry.method, entry.path, stub);
732 }
733
734 let openapi_spec = generate_openapi_spec(
735 "Rust WebX API",
736 env!("CARGO_PKG_VERSION"),
737 localizer.as_ref(),
738 );
739 if self.mode == AppMode::Development {
740 let openapi_bytes = serde_json::to_vec(&openapi_spec).unwrap_or_default();
741 router.register(
742 HttpMethod::Get,
743 "/api/openapi.json",
744 Arc::new(StaticJsonEndpoint::new(openapi_bytes)),
745 );
746 router.register(
747 HttpMethod::Get,
748 "/api/openapi.html",
749 Arc::new(StaticHtmlEndpoint { body: APIUI_HTML }),
750 );
751 }
752
753 let health_registry = Arc::clone(&self.health_registry);
756 let health_endpoint: Arc<dyn IEndpoint> =
757 Arc::new(HealthCheckEndpoint::new(Arc::clone(&health_registry)));
758 router.register(HttpMethod::Get, "/health", Arc::clone(&health_endpoint));
759 router.register(HttpMethod::Get, "/healthz", health_endpoint);
760
761 let live_body =
763 serde_json::to_vec(&serde_json::json!({"status":"pass"})).unwrap_or_default();
764 router.register(
765 HttpMethod::Get,
766 "/health/live",
767 Arc::new(StaticJsonEndpoint {
768 body: live_body,
769 content_type: "application/health+json",
770 }),
771 );
772
773 router.register(
775 HttpMethod::Get,
776 "/health/ready",
777 Arc::new(HealthCheckEndpoint::new(health_registry)),
778 );
779
780 if options.metrics.enabled {
781 router.register(
782 HttpMethod::Get,
783 "/metrics",
784 Arc::new(MetricsEndpoint::new(http_metrics)),
785 );
786 }
787
788 if self.mode == AppMode::Development {
789 let version = env!("CARGO_PKG_VERSION");
790 tracing::info!("");
791 tracing::info!(" ----------------------------------------------------------------");
792 tracing::info!(" Rust WebX Framework v{}", version);
793 tracing::info!(" ----------------------------------------------------------------");
794 tracing::info!(" App: {}", options.app.name);
795 tracing::info!(" CORS: enabled");
796 if let Some(source) = spa_source.as_ref() {
797 tracing::info!(" SPA Root: {}", source.disk_root());
798 if let Some(assets) = source.embedded() {
799 tracing::info!(
800 " Embedded: {} files, {:.1} MiB from {:.1} MiB (built from {})",
801 assets.len(),
802 assets.total_bytes() as f64 / (1024.0 * 1024.0),
803 assets.total_raw_bytes() as f64 / (1024.0 * 1024.0),
804 assets.root(),
805 );
806 }
807 }
808 if route_count > 0 {
809 tracing::info!(" Routes: {} registered", route_count);
810 }
811 crate::diagnostics::log_startup_diagnostics();
812 let banner_urls = if options.app.urls.is_empty() {
813 vec!["http://localhost:5000".to_string()]
814 } else {
815 options
816 .app
817 .urls
818 .iter()
819 .map(|u| u.replace("0.0.0.0", "localhost"))
820 .collect::<Vec<_>>()
821 };
822 for url in &banner_urls {
823 tracing::info!(" OpenAPI: {}/api/openapi.html", url);
824 tracing::info!(" OpenAPI: {}/api/openapi.json", url);
825 }
826 tracing::info!(" ----------------------------------------------------------------");
827 tracing::info!("");
828 } else if route_count > 0 {
829 tracing::info!("{} route(s) registered", route_count);
830 }
831
832 let router = Arc::new(router);
833 let router_handler = make_router_handler(Arc::clone(&router), spa_fallback);
834
835 let hosted_services: Vec<Arc<dyn IHostedService>> =
838 provider.get_all::<dyn IHostedService>();
839
840 Host {
841 provider,
842 dispatch_runtime,
843 options,
844 endpoints,
845 localizer,
846 body_limits,
847 pipeline,
848 router,
849 router_handler,
850 mode: self.mode,
851 spa_source,
852 shutdown: Arc::new(tokio::sync::watch::channel(false).0),
853 hosted_services,
854 }
855 }
856}
857
858pub struct ServerHandle {
860 shutdown: ShutdownSignal,
861}
862
863impl ServerHandle {
864 pub fn shutdown(self) {
870 trigger_shutdown(&self.shutdown);
871 }
872}
873
874pub struct Server {
886 host: Host,
887}
888
889impl Server {
890 pub async fn run(self) -> Result<()> {
892 self.host.run().await
893 }
894
895 pub async fn run_at(self, addr: &str) -> Result<()> {
897 self.host.run_at(addr).await
898 }
899
900 pub fn handle(&self) -> ServerHandle {
902 self.host.server_handle()
903 }
904}
905
906impl Default for HostBuilder {
907 fn default() -> Self {
908 Self::new()
909 }
910}
911
912impl Host {
913 pub fn builder() -> HostBuilder {
914 HostBuilder::new()
915 }
916
917 pub fn options(&self) -> &AppOptions {
918 &self.options
919 }
920
921 pub fn provider(&self) -> &Arc<ServiceProvider> {
923 &self.provider
924 }
925
926 pub fn dispatch_runtime(&self) -> &Arc<DispatchRuntime> {
928 &self.dispatch_runtime
929 }
930
931 pub fn endpoints(&self) -> &dyn IEndpointCatalog {
937 &*self.endpoints
938 }
939
940 pub fn localizer(&self) -> &dyn IStringLocalizer {
942 &*self.localizer
943 }
944
945 pub async fn run(&self) -> Result<()> {
959 let urls = if self.options.app.urls.is_empty() {
960 vec!["http://0.0.0.0:5000".to_string()]
961 } else {
962 self.options.app.urls.clone()
963 };
964
965 let mut http_addrs: Vec<String> = Vec::new();
966 let mut https_addrs: Vec<String> = Vec::new();
967
968 for url in &urls {
969 let (scheme, addr) = parse_url(url)?;
970 match scheme {
971 "http" => http_addrs.push(addr),
972 "https" => https_addrs.push(addr),
973 other => {
974 return Err(webx_core::error::Error::Http(format!(
975 "Unsupported URL scheme '{}' in '{}'",
976 other, url
977 )))
978 }
979 }
980 }
981
982 let acceptor = if !https_addrs.is_empty() {
983 let tls = &self.options.tls;
984 if tls.cert_path.is_empty() || tls.key_path.is_empty() {
985 return Err(webx_core::error::Error::Http(
986 "HTTPS URLs require Tls.CertPath and Tls.KeyPath".into(),
987 ));
988 }
989 Some(build_tls_acceptor(&tls.cert_path, &tls.key_path)?)
990 } else {
991 None
992 };
993
994 let mut http_listeners = Vec::with_capacity(http_addrs.len());
998 for addr in &http_addrs {
999 http_listeners.push(bind_listener(addr, "HTTP").await?);
1000 }
1001 let mut https_listeners = Vec::with_capacity(https_addrs.len());
1002 for addr in &https_addrs {
1003 https_listeners.push(bind_listener(addr, "HTTPS").await?);
1004 }
1005
1006 start_hosted_services(&self.hosted_services, &self.dispatch_runtime).await?;
1007
1008 if self.mode == AppMode::Development {
1010 tracing::info!("");
1011 for url in &urls {
1012 let display_url = url.replace("0.0.0.0", "localhost");
1013 tracing::info!(" Listening on {}", display_url);
1014 }
1015 } else {
1016 tracing::info!("Listening on {} url(s)", urls.len());
1017 }
1018
1019 install_shutdown_handler(Arc::clone(&self.shutdown));
1020 spawn_async_shutdown_listener(Arc::clone(&self.shutdown));
1021
1022 let mut handles = Vec::new();
1023 let pipeline = Arc::clone(&self.pipeline);
1024 let router_handler = self.router_handler.clone();
1025 let dispatch_runtime = Arc::clone(&self.dispatch_runtime);
1026 let mode = self.mode;
1027 let body_limits = self.body_limits.clone();
1028 let max_connections = self.options.app.max_connections;
1029
1030 for (addr, listener) in http_addrs.into_iter().zip(http_listeners) {
1031 handles.push(tokio::spawn(serve_http(
1032 addr,
1033 listener,
1034 self.shutdown.subscribe(),
1035 Arc::clone(&pipeline),
1036 router_handler.clone(),
1037 Arc::clone(&dispatch_runtime),
1038 mode,
1039 body_limits.clone(),
1040 max_connections,
1041 )));
1042 }
1043
1044 if let Some(ref tls_acceptor) = acceptor {
1045 for (addr, listener) in https_addrs.into_iter().zip(https_listeners) {
1046 handles.push(tokio::spawn(serve_https(
1047 addr,
1048 tls_acceptor.clone(),
1049 listener,
1050 self.shutdown.subscribe(),
1051 Arc::clone(&pipeline),
1052 router_handler.clone(),
1053 Arc::clone(&dispatch_runtime),
1054 mode,
1055 body_limits.clone(),
1056 max_connections,
1057 )));
1058 }
1059 }
1060
1061 let mut run_error: Option<webx_core::error::Error> = None;
1064 for h in handles {
1065 if let Err(join_err) = h.await {
1066 if run_error.is_none() {
1067 run_error = Some(webx_core::error::Error::Internal(format!(
1068 "a listener task failed: {join_err}"
1069 )));
1070 }
1071 }
1072 }
1073
1074 stop_hosted_services(&self.hosted_services, &self.dispatch_runtime).await;
1075
1076 match run_error {
1077 Some(err) => Err(err),
1078 None => Ok(()),
1079 }
1080 }
1081
1082 pub async fn run_at(&self, addr: &str) -> Result<()> {
1084 let listener = bind_listener(addr, "HTTP").await?;
1085 start_hosted_services(&self.hosted_services, &self.dispatch_runtime).await?;
1086
1087 install_shutdown_handler(Arc::clone(&self.shutdown));
1088 spawn_async_shutdown_listener(Arc::clone(&self.shutdown));
1089 serve_http(
1090 addr.to_string(),
1091 listener,
1092 self.shutdown.subscribe(),
1093 Arc::clone(&self.pipeline),
1094 self.router_handler.clone(),
1095 Arc::clone(&self.dispatch_runtime),
1096 self.mode,
1097 self.body_limits.clone(),
1098 self.options.app.max_connections,
1099 )
1100 .await;
1101
1102 stop_hosted_services(&self.hosted_services, &self.dispatch_runtime).await;
1103 Ok(())
1104 }
1105
1106 pub fn server_handle(&self) -> ServerHandle {
1117 ServerHandle {
1118 shutdown: Arc::clone(&self.shutdown),
1119 }
1120 }
1121
1122 pub fn into_server(self) -> Server {
1127 Server { host: self }
1128 }
1129}
1130
1131#[async_trait::async_trait]
1132impl IHost for Host {
1133 async fn run(&self, addr: &str) -> Result<()> {
1134 self.run_at(addr).await
1135 }
1136 async fn stop(&self) -> Result<()> {
1137 tracing::info!("Stop requested.");
1138 trigger_shutdown(&self.shutdown);
1139 Ok(())
1140 }
1141}
1142
1143fn make_router_handler(router: Arc<Router>, spa_fallback: Option<Arc<SpaMiddleware>>) -> HandlerFn {
1144 Arc::new(move |ctx: &mut dyn IHttpContext| {
1145 let router = Arc::clone(&router);
1146 let spa_fallback = spa_fallback.clone();
1147 Box::pin(async move {
1148 if let Some(err) = ctx.take_early_error() {
1152 return Err(err);
1153 }
1154 match router.match_route(ctx).await? {
1155 Some((endpoint, params, pattern)) => {
1156 drop(router);
1157 for (key, value) in params {
1158 ctx.request_mut().route_params_mut().insert(key, value);
1159 }
1160 *ctx.request_mut().route_pattern_mut() = Some(pattern);
1161 endpoint.handle(ctx).await
1162 }
1163 None => {
1164 let path_exists = router.path_exists(ctx.request().path());
1165 let allowed = if path_exists {
1166 router.allowed_methods(ctx.request().path())
1167 } else {
1168 Vec::new()
1169 };
1170 drop(router);
1171 if !ctx.response().has_body() {
1174 if let Some(spa) = spa_fallback {
1177 if spa.try_index_fallback(ctx).await? {
1178 return Ok(());
1179 }
1180 }
1181 if path_exists {
1182 if ctx.request().method().eq_ignore_ascii_case("OPTIONS") {
1183 ctx.response_mut().set_status(204);
1188 ctx.response_mut().set_header("allow", &allowed.join(", "));
1189 } else {
1190 write_method_not_allowed(ctx, &allowed).await;
1192 }
1193 } else {
1194 write_plain_error(ctx, 404, "Not Found").await;
1195 }
1196 }
1197 Ok(())
1198 }
1199 }
1200 })
1201 })
1202}
1203
1204async fn handle_request(
1205 req: Request<Incoming>,
1206 pipeline: Arc<MiddlewarePipeline>,
1207 router_handler: HandlerFn,
1208 dispatch_runtime: Arc<DispatchRuntime>,
1209 body_limits: BodyLimits,
1210 remote_addr: Option<std::net::SocketAddr>,
1211) -> std::result::Result<hyper::Response<RespBody>, std::convert::Infallible> {
1212 let mut ctx = HttpContext::new(req, body_limits, remote_addr);
1213
1214 let result = dispatch_runtime
1215 .run(async { pipeline.execute(&mut ctx, router_handler).await })
1216 .await;
1217 if let Err(e) = result {
1218 match e {
1219 webx_core::error::Error::Problem(problem) => {
1223 write_problem_response(&mut ctx, *problem).await
1224 }
1225 other => {
1226 write_framework_error(&mut ctx, &other).await;
1227 }
1228 }
1229 }
1230
1231 Ok(ctx.into_response().await)
1232}
1233
1234async fn write_framework_error(ctx: &mut dyn IHttpContext, error: &webx_core::error::Error) {
1246 let status = error.status_code();
1247 if status == 401 {
1248 write_unauthorized(
1249 ctx,
1250 error.public_detail().unwrap_or("authentication required"),
1251 )
1252 .await;
1253 } else if status >= 500 {
1254 tracing::error!(status, error = %error, "unhandled server error");
1255 write_problem_response(ctx, build_problem(status, "an unexpected error occurred")).await;
1256 } else {
1257 write_problem_response(
1258 ctx,
1259 build_problem(status, error.public_detail().unwrap_or("request failed")),
1260 )
1261 .await;
1262 }
1263}
1264
1265async fn write_plain_error(ctx: &mut dyn IHttpContext, status: u16, message: &str) {
1268 write_problem_response(ctx, build_problem(status, message)).await;
1269}
1270
1271async fn write_method_not_allowed(ctx: &mut dyn IHttpContext, allowed: &[&str]) {
1273 let allow = allowed.join(", ");
1274 ctx.response_mut().set_header("allow", &allow);
1275 write_plain_error(ctx, 405, "Method Not Allowed").await;
1276}
1277
1278fn trigger_shutdown(shutdown: &ShutdownSignal) {
1288 shutdown.send_replace(true);
1293}
1294
1295async fn wait_for_shutdown(shutdown: &mut tokio::sync::watch::Receiver<bool>) {
1300 if *shutdown.borrow() {
1301 return;
1302 }
1303 loop {
1304 if shutdown.changed().await.is_err() {
1305 return;
1306 }
1307 if *shutdown.borrow() {
1308 return;
1309 }
1310 }
1311}
1312
1313fn install_shutdown_handler(shutdown: ShutdownSignal) {
1324 use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
1325 use std::sync::{Mutex, OnceLock, Weak};
1326
1327 static HANDLERS: OnceLock<Mutex<Vec<Weak<tokio::sync::watch::Sender<bool>>>>> = OnceLock::new();
1328 static CTRL_COUNT: AtomicUsize = AtomicUsize::new(0);
1329 static INSTALLED: AtomicBool = AtomicBool::new(false);
1330
1331 let handlers = HANDLERS.get_or_init(|| Mutex::new(Vec::new()));
1332 {
1333 let mut registered = handlers
1334 .lock()
1335 .unwrap_or_else(|poisoned| poisoned.into_inner());
1336 registered.retain(|weak| weak.strong_count() > 0);
1338 registered.push(Arc::downgrade(&shutdown));
1339 }
1340
1341 if INSTALLED.swap(true, Ordering::SeqCst) {
1345 return;
1346 }
1347
1348 if let Err(e) = ctrlc::set_handler(move || {
1349 let n = CTRL_COUNT.fetch_add(1, Ordering::SeqCst);
1350 if n == 0 {
1351 tracing::info!("Shutdown signal received, draining connections...");
1352 let registered = handlers
1353 .lock()
1354 .unwrap_or_else(|poisoned| poisoned.into_inner());
1355 for weak in registered.iter() {
1356 if let Some(shutdown) = weak.upgrade() {
1357 trigger_shutdown(&shutdown);
1358 }
1359 }
1360 } else {
1361 tracing::warn!("Second interrupt — force exiting.");
1362 std::process::exit(130);
1363 }
1364 }) {
1365 tracing::warn!("Could not install Ctrl+C handler: {}", e);
1366 }
1367}
1368
1369fn spawn_async_shutdown_listener(shutdown: ShutdownSignal) {
1371 tokio::spawn(async move {
1372 wait_for_shutdown_signal().await;
1373 tracing::info!("Async shutdown signal received, draining connections...");
1374 trigger_shutdown(&shutdown);
1375 });
1376}
1377
1378async fn wait_for_shutdown_signal() {
1379 #[cfg(unix)]
1380 {
1381 use tokio::signal::unix::{signal, SignalKind};
1382 let mut sigterm = signal(SignalKind::terminate()).expect("install SIGTERM handler");
1383 tokio::select! {
1384 res = tokio::signal::ctrl_c() => {
1385 if let Err(e) = res {
1386 tracing::warn!("Ctrl+C listener error: {}", e);
1387 }
1388 }
1389 _ = sigterm.recv() => {}
1390 }
1391 }
1392 #[cfg(not(unix))]
1393 {
1394 if let Err(e) = tokio::signal::ctrl_c().await {
1395 tracing::warn!("Ctrl+C listener error: {}", e);
1396 }
1397 }
1398}
1399
1400const HOSTED_START_TIMEOUT_SECS: u64 = 30;
1402const HOSTED_STOP_TIMEOUT_SECS: u64 = 30;
1404
1405async fn start_hosted_services(
1406 services: &[Arc<dyn IHostedService>],
1407 dispatch_runtime: &Arc<DispatchRuntime>,
1408) -> Result<()> {
1409 if services.is_empty() {
1410 return Ok(());
1411 }
1412 tracing::info!("Starting {} hosted service(s)...", services.len());
1413 let mut started = 0usize;
1414 let mut failure: Option<webx_core::error::Error> = None;
1415
1416 for svc in services {
1417 let attempt = tokio::time::timeout(
1422 std::time::Duration::from_secs(HOSTED_START_TIMEOUT_SECS),
1423 dispatch_runtime.run_in_scope(async { svc.start().await }),
1424 )
1425 .await;
1426 match attempt {
1427 Ok(Ok(())) => started += 1,
1428 Ok(Err(err)) => {
1429 failure = Some(err);
1430 break;
1431 }
1432 Err(_) => {
1433 failure = Some(webx_core::error::Error::Internal(format!(
1434 "hosted service start timed out after {HOSTED_START_TIMEOUT_SECS}s"
1435 )));
1436 break;
1437 }
1438 }
1439 }
1440
1441 match failure {
1442 None => {
1443 tracing::info!("All {started} hosted service(s) started.");
1444 Ok(())
1445 }
1446 Some(err) => {
1447 for (index, svc) in services[..started].iter().enumerate().rev() {
1450 stop_hosted_service(dispatch_runtime, index, svc).await;
1451 }
1452 Err(err)
1453 }
1454 }
1455}
1456
1457async fn stop_hosted_services(
1458 services: &[Arc<dyn IHostedService>],
1459 dispatch_runtime: &Arc<DispatchRuntime>,
1460) {
1461 if services.is_empty() {
1462 return;
1463 }
1464 tracing::info!("Stopping {} hosted service(s)...", services.len());
1465 for (index, svc) in services.iter().enumerate().rev() {
1466 stop_hosted_service(dispatch_runtime, index, svc).await;
1467 }
1468 tracing::info!("All hosted services stopped.");
1469}
1470
1471async fn stop_hosted_service(
1473 dispatch_runtime: &Arc<DispatchRuntime>,
1474 index: usize,
1475 svc: &Arc<dyn IHostedService>,
1476) {
1477 match tokio::time::timeout(
1478 std::time::Duration::from_secs(HOSTED_STOP_TIMEOUT_SECS),
1479 dispatch_runtime.run_in_scope(async { svc.stop().await }),
1480 )
1481 .await
1482 {
1483 Ok(Ok(())) => {}
1484 Ok(Err(err)) => {
1485 tracing::warn!(service_index = index, error = %err, "hosted service stop error")
1486 }
1487 Err(_) => tracing::error!(
1488 service_index = index,
1489 timeout_secs = HOSTED_STOP_TIMEOUT_SECS,
1490 "hosted service stop timed out"
1491 ),
1492 }
1493}
1494
1495async fn drain_connections(join_set: &mut JoinSet<()>, label: &str, mode: AppMode) {
1496 let timeout_secs = if mode == AppMode::Development { 5 } else { 30 };
1497 let drain_future = async {
1498 while let Some(result) = join_set.join_next().await {
1499 if let Err(e) = result {
1500 tracing::error!("Connection task panicked: {:?}", e);
1501 }
1502 }
1503 };
1504
1505 match tokio::time::timeout(std::time::Duration::from_secs(timeout_secs), drain_future).await {
1506 Ok(_) => tracing::info!("{}: drained.", label),
1507 Err(_) => {
1508 tracing::warn!("{}: drain timeout, aborting connections.", label);
1509 join_set.abort_all();
1510 while join_set.join_next().await.is_some() {}
1511 }
1512 }
1513}
1514
1515async fn bind_listener(addr: &str, label: &str) -> Result<tokio::net::TcpListener> {
1525 tokio::net::TcpListener::bind(addr).await.map_err(|err| {
1526 webx_core::error::Error::Http(format!("failed to bind {label} on {addr}: {err}"))
1527 })
1528}
1529
1530fn parse_url(url: &str) -> Result<(&str, String)> {
1533 if let Some(rest) = url.strip_prefix("https://") {
1534 Ok(("https", rest.to_string()))
1535 } else if let Some(rest) = url.strip_prefix("http://") {
1536 Ok(("http", rest.to_string()))
1537 } else {
1538 Err(webx_core::error::Error::Http(format!(
1539 "Invalid URL '{}'. Use http://host:port or https://host:port",
1540 url
1541 )))
1542 }
1543}
1544
1545#[allow(clippy::too_many_arguments)]
1547async fn serve_http(
1548 addr: String,
1549 listener: tokio::net::TcpListener,
1550 mut shutdown: tokio::sync::watch::Receiver<bool>,
1551 pipeline: Arc<MiddlewarePipeline>,
1552 router_handler: HandlerFn,
1553 dispatch_runtime: Arc<DispatchRuntime>,
1554 mode: AppMode,
1555 body_limits: BodyLimits,
1556 max_connections: usize,
1557) {
1558 let sem = Arc::new(tokio::sync::Semaphore::new(max_connections));
1559 let mut join_set = JoinSet::new();
1560
1561 loop {
1562 if *shutdown.borrow() {
1566 tracing::info!("HTTP {}: stop accepting, draining...", addr);
1567 break;
1568 }
1569 tokio::select! {
1570 _ = shutdown.changed() => {
1571 tracing::info!("HTTP {}: stop accepting, draining...", addr);
1572 break;
1573 }
1574 accept_result = listener.accept() => {
1575 let (stream, peer_addr) = match accept_result {
1576 Ok(v) => v,
1577 Err(e) => {
1578 tracing::error!("Accept error (will retry): {}", e);
1579 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1580 continue;
1581 }
1582 };
1583 let remote_addr = Some(peer_addr);
1584
1585 while join_set.try_join_next().is_some() {}
1586
1587 let permit = match sem.clone().acquire_owned().await {
1588 Ok(p) => p,
1589 Err(_) => continue,
1590 };
1591
1592 let io = TokioIo::new(stream);
1593 let pipeline = Arc::clone(&pipeline);
1594 let router_handler = router_handler.clone();
1595 let dispatch_runtime = Arc::clone(&dispatch_runtime);
1596 let body_limits = body_limits.clone();
1597 let conn_shutdown = shutdown.clone();
1598
1599 join_set.spawn(async move {
1600 let _permit = permit;
1601 let svc_fn = service_fn(move |req: Request<Incoming>| {
1602 let pipeline = Arc::clone(&pipeline);
1603 let router_handler = router_handler.clone();
1604 let dispatch_runtime = Arc::clone(&dispatch_runtime);
1605 let body_limits = body_limits.clone();
1606 let mode = mode;
1607 let remote_addr = remote_addr;
1610 async move {
1611 let start = Instant::now();
1612 let method = req.method().to_string();
1613 let path = req.uri().path().to_string();
1614 let result = handle_request(
1615 req,
1616 pipeline,
1617 router_handler,
1618 dispatch_runtime,
1619 body_limits,
1620 remote_addr,
1621 )
1622 .await;
1623 let elapsed = start.elapsed();
1624 if mode == AppMode::Development {
1625 let status =
1626 result.as_ref().map(|r| r.status().as_u16()).unwrap_or(500);
1627 tracing::info!(
1628 "[{}] {} -> {} ({:.0}ms)",
1629 method,
1630 path,
1631 status,
1632 elapsed.as_secs_f64() * 1000.0
1633 );
1634 }
1635 result
1636 }
1637 });
1638
1639 let conn = hyper::server::conn::http1::Builder::new()
1640 .serve_connection(io, svc_fn);
1641 let mut conn = std::pin::pin!(conn);
1642 let mut conn_shutdown = conn_shutdown;
1643 tokio::select! {
1644 result = conn.as_mut() => {
1645 if let Err(err) = result {
1646 tracing::error!("Connection error: {}", err);
1647 }
1648 }
1649 _ = wait_for_shutdown(&mut conn_shutdown) => {
1650 conn.as_mut().graceful_shutdown();
1654 if let Err(err) = conn.await {
1655 tracing::debug!(error = %err, "connection closed during shutdown");
1656 }
1657 }
1658 }
1659 });
1660 }
1661 }
1662 }
1663
1664 drain_connections(&mut join_set, &format!("HTTP {}", addr), mode).await;
1665}
1666
1667#[allow(clippy::too_many_arguments)]
1669async fn serve_https(
1670 addr: String,
1671 acceptor: TlsAcceptor,
1672 listener: tokio::net::TcpListener,
1673 mut shutdown: tokio::sync::watch::Receiver<bool>,
1674 pipeline: Arc<MiddlewarePipeline>,
1675 router_handler: HandlerFn,
1676 dispatch_runtime: Arc<DispatchRuntime>,
1677 mode: AppMode,
1678 body_limits: BodyLimits,
1679 max_connections: usize,
1680) {
1681 let sem = Arc::new(tokio::sync::Semaphore::new(max_connections));
1682 let mut join_set = JoinSet::new();
1683
1684 loop {
1685 if *shutdown.borrow() {
1686 tracing::info!("HTTPS {}: stop accepting, draining...", addr);
1687 break;
1688 }
1689 tokio::select! {
1690 _ = shutdown.changed() => {
1691 tracing::info!("HTTPS {}: stop accepting, draining...", addr);
1692 break;
1693 }
1694 accept_result = listener.accept() => {
1695 let (stream, peer_addr) = match accept_result {
1696 Ok(v) => v,
1697 Err(e) => {
1698 tracing::error!("Accept error (will retry): {}", e);
1699 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1700 continue;
1701 }
1702 };
1703 let remote_addr = Some(peer_addr);
1704
1705 while join_set.try_join_next().is_some() {}
1706
1707 let permit = match sem.clone().acquire_owned().await {
1708 Ok(p) => p,
1709 Err(_) => continue,
1710 };
1711
1712 let acceptor = acceptor.clone();
1713 let pipeline = Arc::clone(&pipeline);
1714 let router_handler = router_handler.clone();
1715 let dispatch_runtime = Arc::clone(&dispatch_runtime);
1716 let body_limits = body_limits.clone();
1717 let conn_shutdown = shutdown.clone();
1718
1719 join_set.spawn(async move {
1720 let _permit = permit;
1721 match acceptor.accept(stream).await {
1722 Ok(tls_stream) => {
1723 let io = TokioIo::new(tls_stream);
1724 let svc_fn = service_fn(move |req: Request<Incoming>| {
1725 let pipeline = Arc::clone(&pipeline);
1726 let router_handler = router_handler.clone();
1727 let dispatch_runtime = Arc::clone(&dispatch_runtime);
1728 let body_limits = body_limits.clone();
1729 let mode = mode;
1730 let remote_addr = remote_addr;
1732 async move {
1733 let start = Instant::now();
1734 let method = req.method().to_string();
1735 let path = req.uri().path().to_string();
1736 let result = handle_request(
1737 req,
1738 pipeline,
1739 router_handler,
1740 dispatch_runtime,
1741 body_limits,
1742 remote_addr,
1743 )
1744 .await;
1745 let elapsed = start.elapsed();
1746 if mode == AppMode::Development {
1747 let status =
1748 result.as_ref().map(|r| r.status().as_u16()).unwrap_or(500);
1749 tracing::info!(
1750 "[{}] {} -> {} ({:.0}ms)",
1751 method,
1752 path,
1753 status,
1754 elapsed.as_secs_f64() * 1000.0
1755 );
1756 }
1757 result
1758 }
1759 });
1760
1761 let conn = hyper::server::conn::http1::Builder::new()
1762 .serve_connection(io, svc_fn);
1763 let mut conn = std::pin::pin!(conn);
1764 let mut conn_shutdown = conn_shutdown;
1765 tokio::select! {
1766 result = conn.as_mut() => {
1767 if let Err(err) = result {
1768 tracing::error!("TLS connection error: {}", err);
1769 }
1770 }
1771 _ = wait_for_shutdown(&mut conn_shutdown) => {
1772 conn.as_mut().graceful_shutdown();
1773 if let Err(err) = conn.await {
1774 tracing::debug!(error = %err, "TLS connection closed during shutdown");
1775 }
1776 }
1777 }
1778 }
1779 Err(e) => {
1780 tracing::error!("TLS handshake error: {}", e);
1781 }
1782 }
1783 });
1784 }
1785 }
1786 }
1787
1788 drain_connections(&mut join_set, &format!("HTTPS {}", addr), mode).await;
1789}
1790
1791fn build_tls_acceptor(cert_path: &str, key_path: &str) -> Result<TlsAcceptor> {
1797 use std::fs::File;
1798 use std::io::BufReader;
1799
1800 if cert_path.is_empty() || key_path.is_empty() {
1801 return Err(webx_core::error::Error::Http(
1802 "TLS certificate or key path not configured.".into(),
1803 ));
1804 }
1805
1806 let cert_file = File::open(cert_path).map_err(|e| {
1807 webx_core::error::Error::Http(format!("Cannot open cert '{}': {}", cert_path, e))
1808 })?;
1809 let mut cert_reader = BufReader::new(cert_file);
1810 let certs: Vec<CertificateDer> = certs(&mut cert_reader).filter_map(|r| r.ok()).collect();
1811 if certs.is_empty() {
1812 return Err(webx_core::error::Error::Http(format!(
1813 "No valid certs in '{}'",
1814 cert_path
1815 )));
1816 }
1817
1818 let key_file = File::open(key_path).map_err(|e| {
1819 webx_core::error::Error::Http(format!("Cannot open key '{}': {}", key_path, e))
1820 })?;
1821 let mut key_reader = BufReader::new(key_file);
1822 let key = pkcs8_private_keys(&mut key_reader)
1823 .filter_map(|r| r.ok())
1824 .map(PrivateKeyDer::from)
1825 .next()
1826 .or_else(|| {
1827 let key_file2 = File::open(key_path).map(BufReader::new).ok()?;
1828 let mut kr2 = key_file2;
1829 let rsa_keys: Vec<PrivateKeyDer> = rsa_private_keys(&mut kr2)
1830 .filter_map(|r| r.ok())
1831 .map(PrivateKeyDer::from)
1832 .collect();
1833 rsa_keys.into_iter().next()
1834 })
1835 .ok_or_else(|| {
1836 webx_core::error::Error::Http(format!("No valid private key in '{}'", key_path))
1837 })?;
1838
1839 let config = ServerConfig::builder()
1840 .with_no_client_auth()
1841 .with_single_cert(certs, key)
1842 .map_err(|e| webx_core::error::Error::Http(format!("TLS config error: {}", e)))?;
1843
1844 Ok(TlsAcceptor::from(std::sync::Arc::new(config)))
1845}
1846
1847#[cfg(test)]
1848mod embedded_attach_tests {
1849 use super::with_embedded_assets;
1850 use webx_spa::{ContentEncoding, EmbeddedAsset, EmbeddedAssets, SpaSource};
1851
1852 const FILES: &[EmbeddedAsset] = &[EmbeddedAsset {
1853 path: "index.html",
1854 bytes: b"hi",
1855 raw_len: 2,
1856 encoding: ContentEncoding::Identity,
1857 content_type: "text/html",
1858 etag: "\"x\"",
1859 }];
1860
1861 #[test]
1862 fn no_table_leaves_disk_source() {
1863 let source = Some(SpaSource::Disk("wwwroot".into()));
1864 let merged = with_embedded_assets(source, None);
1865 assert!(matches!(merged, Some(SpaSource::Disk(ref r)) if r == "wwwroot"));
1866 }
1867
1868 #[test]
1869 fn table_layers_under_use_spa_overlay() {
1870 let assets = EmbeddedAssets::new("../wwwroot", FILES);
1871 let merged = with_embedded_assets(Some(SpaSource::Disk("wwwroot".into())), Some(assets));
1872 match merged {
1873 Some(SpaSource::Embedded {
1874 overlay_root,
1875 assets,
1876 }) => {
1877 assert_eq!(overlay_root, "wwwroot");
1878 assert_eq!(assets.root(), "../wwwroot");
1879 }
1880 other => panic!("expected embedded, got {other:?}"),
1881 }
1882 }
1883
1884 #[test]
1885 fn table_alone_uses_build_root_as_overlay() {
1886 let assets = EmbeddedAssets::new("../wwwroot", FILES);
1887 let merged = with_embedded_assets(None, Some(assets));
1888 match merged {
1889 Some(SpaSource::Embedded { overlay_root, .. }) => {
1890 assert_eq!(overlay_root, "../wwwroot");
1891 }
1892 other => panic!("expected embedded, got {other:?}"),
1893 }
1894 }
1895
1896 #[test]
1897 fn host_crate_registers_no_asset_table() {
1898 assert!(EmbeddedAssets::registered().is_none());
1899 }
1900}