1use std::path::PathBuf;
2
3use actix_files as fs;
4use actix_web::dev::{ServiceFactory, ServiceRequest, ServiceResponse};
5use actix_web::{web, App, HttpServer};
6use tokio::sync::oneshot;
7use tracing::{error, info};
8
9use super::listeners::DEFAULT_WORKER_COUNT;
10
11pub(crate) const MAX_JSON_BODY_BYTES: usize = 25 * 1024 * 1024;
16pub(crate) const MAX_PAYLOAD_BYTES: usize = 30 * 1024 * 1024;
17
18pub(crate) fn with_body_limits<T>(app: App<T>) -> App<T>
29where
30 T: ServiceFactory<
31 ServiceRequest,
32 Config = (),
33 Response = ServiceResponse,
34 Error = actix_web::Error,
35 InitError = (),
36 >,
37{
38 app.app_data(web::JsonConfig::default().limit(MAX_JSON_BODY_BYTES))
39 .app_data(web::PayloadConfig::new(MAX_PAYLOAD_BYTES))
40}
41use super::tls::build_rustls_config;
42use crate::app_state::AppState;
43use crate::config::{
44 build_cors, build_rate_limiter, build_security_headers, is_loopback_bind,
45 require_limiter_for_nonloopback, wrap_governor_and_cors,
46};
47use crate::routes::{configure_routes, configure_routes_with_rate_limiting};
48use bamboo_config::TlsConfig;
49
50pub struct WebService {
55 shutdown_tx: Option<oneshot::Sender<()>>,
56 server_handle: Option<tokio::task::JoinHandle<()>>,
57 app_state: Option<web::Data<AppState>>,
62 bamboo_home_dir: PathBuf,
64 port: u16,
65}
66
67impl WebService {
68 pub fn new(bamboo_home_dir: PathBuf) -> Self {
73 Self {
74 shutdown_tx: None,
75 server_handle: None,
76 app_state: None,
77 bamboo_home_dir,
78 port: 3456, }
80 }
81
82 pub async fn start(&mut self, port: u16) -> Result<(), String> {
84 self.start_with_bind(port, "127.0.0.1").await
85 }
86
87 pub async fn start_with_bind(&mut self, port: u16, bind: &str) -> Result<(), String> {
89 self.start_with_bind_tls(port, bind, None).await
90 }
91
92 pub async fn start_with_bind_tls(
96 &mut self,
97 port: u16,
98 bind: &str,
99 tls: Option<&TlsConfig>,
100 ) -> Result<(), String> {
101 info!("Starting web service...");
102 if self.server_handle.is_some() {
103 return Err("Web service is already running".to_string());
104 }
105
106 require_limiter_for_nonloopback(bind, false)?;
111
112 let (shutdown_tx, mut shutdown_rx) = oneshot::channel::<()>();
113 self.port = port;
114
115 let app_state = web::Data::new(
116 AppState::new(self.bamboo_home_dir.clone())
117 .await
118 .map_err(|e| format!("Failed to initialize app state: {e}"))?,
119 );
120 self.app_state = Some(app_state.clone());
122 let bind_addr = bind.to_string();
123 let listen_addr = format!("{bind}:{port}");
124 let bind_for_log = bind_addr.clone();
125
126 let server = HttpServer::new(move || {
127 with_body_limits(App::new())
128 .app_data(app_state.clone())
129 .wrap(build_cors(&bind_addr, port))
130 .configure(configure_routes) })
132 .workers(DEFAULT_WORKER_COUNT);
133
134 let server = match tls {
137 Some(tls) => server
138 .bind_rustls_0_23(&listen_addr, build_rustls_config(tls)?)
139 .map_err(|e| format!("Failed to bind TLS server: {e}"))?,
140 None => server
141 .bind(&listen_addr)
142 .map_err(|e| format!("Failed to bind server: {e}"))?,
143 }
144 .run();
145
146 let server_handle = tokio::spawn(async move {
147 tokio::select! {
148 result = server => {
149 if let Err(e) = result {
150 error!("Server error: {}", e);
151 }
152 }
153 _ = &mut shutdown_rx => {
154 info!("Web service shutdown signal received");
155 }
156 }
157 });
158
159 self.shutdown_tx = Some(shutdown_tx);
160 self.server_handle = Some(server_handle);
161
162 let scheme = if tls.is_some() { "https" } else { "http" };
163 info!(
164 "Web service started successfully on {scheme}://{}:{}",
165 bind_for_log, port
166 );
167 Ok(())
168 }
169
170 pub async fn start_with_bind_and_static(
173 &mut self,
174 port: u16,
175 bind: &str,
176 static_dir: PathBuf,
177 ) -> Result<(), String> {
178 self.start_with_bind_and_static_tls(port, bind, static_dir, None)
179 .await
180 }
181
182 pub async fn start_with_bind_and_static_tls(
185 &mut self,
186 port: u16,
187 bind: &str,
188 static_dir: PathBuf,
189 tls: Option<&TlsConfig>,
190 ) -> Result<(), String> {
191 info!("Starting web service with static frontend...");
192 if self.server_handle.is_some() {
193 return Err("Web service is already running".to_string());
194 }
195
196 let apply_rate_limit = !is_loopback_bind(bind);
201 require_limiter_for_nonloopback(bind, apply_rate_limit)?;
207
208 let (shutdown_tx, mut shutdown_rx) = oneshot::channel::<()>();
209 self.port = port;
210
211 let static_dir = static_dir
212 .canonicalize()
213 .map_err(|e| format!("Static directory not found: {:?}: {}", static_dir, e))?;
214 if !static_dir.is_dir() {
215 return Err(format!(
216 "Static path is not a directory: {}",
217 static_dir.display()
218 ));
219 }
220
221 let app_state = web::Data::new(
222 AppState::new(self.bamboo_home_dir.clone())
223 .await
224 .map_err(|e| format!("Failed to initialize app state: {e}"))?,
225 );
226 self.app_state = Some(app_state.clone());
228 let rate_limiter = build_rate_limiter();
230 let bind_addr = bind.to_string();
231 let listen_addr = format!("{bind}:{port}");
232 let bind_for_log = bind_addr.clone();
233
234 let server = HttpServer::new(move || {
235 wrap_governor_and_cors(
243 with_body_limits(App::new()).app_data(app_state.clone()),
244 &rate_limiter,
245 apply_rate_limit,
246 &bind_addr,
247 port,
248 )
249 .wrap(build_security_headers())
250 .wrap(actix_web::middleware::from_fn(
254 crate::config::add_asset_cache_headers,
255 ))
256 .configure(configure_routes_with_rate_limiting)
257 .service(
258 fs::Files::new("/", static_dir.clone())
259 .index_file("index.html")
260 .prefer_utf8(true)
261 .disable_content_disposition()
262 .disable_content_disposition(),
263 )
264 })
265 .workers(DEFAULT_WORKER_COUNT);
266
267 let server = match tls {
270 Some(tls) => server
271 .bind_rustls_0_23(&listen_addr, build_rustls_config(tls)?)
272 .map_err(|e| format!("Failed to bind TLS server: {e}"))?,
273 None => server
274 .bind(&listen_addr)
275 .map_err(|e| format!("Failed to bind server: {e}"))?,
276 }
277 .run();
278
279 let server_handle = tokio::spawn(async move {
280 tokio::select! {
281 result = server => {
282 if let Err(e) = result {
283 error!("Server error: {}", e);
284 }
285 }
286 _ = &mut shutdown_rx => {
287 info!("Web service shutdown signal received");
288 }
289 }
290 });
291
292 self.shutdown_tx = Some(shutdown_tx);
293 self.server_handle = Some(server_handle);
294
295 let scheme = if tls.is_some() { "https" } else { "http" };
296 info!(
297 "Web service with static frontend started successfully on {scheme}://{}:{}",
298 bind_for_log, port
299 );
300 Ok(())
301 }
302
303 pub async fn stop(&mut self) -> Result<(), String> {
305 if let Some(shutdown_tx) = self.shutdown_tx.take() {
306 if shutdown_tx.send(()).is_err() {
307 error!("Failed to send shutdown signal");
308 return Err("Error sending shutdown signal".to_string());
309 }
310
311 if let Some(handle) = self.server_handle.take() {
312 if let Err(e) = handle.await {
313 error!("Error waiting for server shutdown: {}", e);
314 return Err(format!("Error waiting for server shutdown: {}", e));
315 }
316 }
317
318 if let Some(state) = self.app_state.take() {
323 state.shutdown().await;
324 }
325
326 info!("Web service stopped successfully");
327 }
328
329 Ok(())
330 }
331
332 pub fn is_running(&self) -> bool {
334 self.server_handle.is_some()
335 }
336
337 pub fn port(&self) -> u16 {
339 self.port
340 }
341}
342
343impl Drop for WebService {
344 fn drop(&mut self) {
345 if let Some(shutdown_tx) = self.shutdown_tx.take() {
346 let _ = shutdown_tx.send(());
347 }
348 if let Some(state) = self.app_state.take() {
353 state.mcp_proxy_shutdown.cancel();
354 }
355 }
356}
357
358#[cfg(test)]
359mod tests {
360 use super::*;
361
362 #[actix_web::test]
370 async fn shared_factory_raises_json_body_limit() {
371 use actix_web::{http::StatusCode, test, HttpResponse};
372
373 async fn echo(_body: web::Json<serde_json::Value>) -> HttpResponse {
374 HttpResponse::Ok().finish()
375 }
376
377 let big = "x".repeat(3 * 1024 * 1024);
379 let payload = serde_json::json!({ "data": big });
380
381 let app =
383 test::init_service(with_body_limits(App::new()).route("/echo", web::post().to(echo)))
384 .await;
385 let resp = test::call_service(
386 &app,
387 test::TestRequest::post()
388 .uri("/echo")
389 .set_json(&payload)
390 .to_request(),
391 )
392 .await;
393 assert_eq!(
394 resp.status(),
395 StatusCode::OK,
396 "shared factory must accept a >2MB JSON body (#252)"
397 );
398
399 let app_default = test::init_service(App::new().route("/echo", web::post().to(echo))).await;
401 let resp_default = test::call_service(
402 &app_default,
403 test::TestRequest::post()
404 .uri("/echo")
405 .set_json(&payload)
406 .to_request(),
407 )
408 .await;
409 assert_eq!(
410 resp_default.status(),
411 StatusCode::PAYLOAD_TOO_LARGE,
412 "actix's default JSON limit must reject a >2MB body"
413 );
414 }
415
416 #[tokio::test]
422 async fn start_with_bind_rejects_nonloopback_without_limiter() {
423 let home = tempfile::TempDir::new().expect("tempdir");
424 let mut service = WebService::new(home.path().to_path_buf());
425
426 let err = service
428 .start_with_bind(0, "0.0.0.0")
429 .await
430 .expect_err("non-loopback bind without a limiter must be rejected (#169 part 3)");
431 assert!(
432 err.contains("without a rate limiter"),
433 "rejection must explain the missing limiter, got: {err}"
434 );
435 assert!(
436 !service.is_running(),
437 "the guard must reject BEFORE the server starts"
438 );
439
440 service
442 .start_with_bind(0, "127.0.0.1")
443 .await
444 .expect("loopback bind must still start without a limiter");
445 service.stop().await.expect("web service stops");
446 }
447
448 #[tokio::test]
452 async fn stop_cancels_mcp_proxy_supervisor_token() {
453 let home = tempfile::TempDir::new().expect("tempdir");
454 let mut service = WebService::new(home.path().to_path_buf());
455 service
457 .start_with_bind(0, "127.0.0.1")
458 .await
459 .expect("web service starts");
460
461 let token = service
463 .app_state
464 .as_ref()
465 .expect("app_state retained after start")
466 .mcp_proxy_shutdown
467 .clone();
468 assert!(
469 !token.is_cancelled(),
470 "supervisor token is live while the service runs"
471 );
472
473 service.stop().await.expect("web service stops");
474
475 assert!(
476 token.is_cancelled(),
477 "stop() must cancel the MCP-proxy supervisor token so it terminates"
478 );
479 }
480}