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