1use std::path::PathBuf;
2
3use actix_files as fs;
4use actix_web::dev::{ServiceFactory, ServiceRequest, ServiceResponse};
5use actix_web::{web, App};
6use tokio::sync::oneshot;
7use tracing::{error, info};
8
9use super::h1::build_h1_server;
10use super::listeners::{build_resolved_listeners, DEFAULT_WORKER_COUNT};
11
12pub(crate) const MAX_JSON_BODY_BYTES: usize = 25 * 1024 * 1024;
17pub(crate) const MAX_PAYLOAD_BYTES: usize = 30 * 1024 * 1024;
18
19pub(crate) fn with_body_limits<T>(app: App<T>) -> App<T>
30where
31 T: ServiceFactory<
32 ServiceRequest,
33 Config = (),
34 Response = ServiceResponse,
35 Error = actix_web::Error,
36 InitError = (),
37 >,
38{
39 app.app_data(web::JsonConfig::default().limit(MAX_JSON_BODY_BYTES))
40 .app_data(web::PayloadConfig::new(MAX_PAYLOAD_BYTES))
41}
42use super::tls::build_rustls_config;
43use crate::app_state::AppState;
44use crate::config::{
45 build_cors, build_rate_limiter, build_security_headers, is_loopback_bind,
46 require_limiter_for_nonloopback, wrap_governor_and_cors,
47};
48use crate::routes::{configure_routes, configure_routes_with_rate_limiting};
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 bind_for_log = bind_addr.clone();
125
126 let app_factory = 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
133 let rustls_config = tls.map(build_rustls_config).transpose()?;
136 let listeners = build_resolved_listeners(bind, port)?;
137 let server = build_h1_server(app_factory, listeners, DEFAULT_WORKER_COUNT, rustls_config)
138 .map_err(|e| format!("Failed to build HTTP/1.1 server: {e}"))?;
139
140 let server_handle = tokio::spawn(async move {
141 tokio::select! {
142 result = server => {
143 if let Err(e) = result {
144 error!("Server error: {}", e);
145 }
146 }
147 _ = &mut shutdown_rx => {
148 info!("Web service shutdown signal received");
149 }
150 }
151 });
152
153 self.shutdown_tx = Some(shutdown_tx);
154 self.server_handle = Some(server_handle);
155
156 let scheme = if tls.is_some() { "https" } else { "http" };
157 info!(
158 "Web service started successfully on {scheme}://{}:{}",
159 bind_for_log, port
160 );
161 Ok(())
162 }
163
164 pub async fn start_with_bind_and_static(
167 &mut self,
168 port: u16,
169 bind: &str,
170 static_dir: PathBuf,
171 ) -> Result<(), String> {
172 self.start_with_bind_and_static_tls(port, bind, static_dir, None)
173 .await
174 }
175
176 pub async fn start_with_bind_and_static_tls(
179 &mut self,
180 port: u16,
181 bind: &str,
182 static_dir: PathBuf,
183 tls: Option<&TlsConfig>,
184 ) -> Result<(), String> {
185 info!("Starting web service with static frontend...");
186 if self.server_handle.is_some() {
187 return Err("Web service is already running".to_string());
188 }
189
190 let apply_rate_limit = !is_loopback_bind(bind);
195 require_limiter_for_nonloopback(bind, apply_rate_limit)?;
201
202 let (shutdown_tx, mut shutdown_rx) = oneshot::channel::<()>();
203 self.port = port;
204
205 let static_dir = static_dir
206 .canonicalize()
207 .map_err(|e| format!("Static directory not found: {:?}: {}", static_dir, e))?;
208 if !static_dir.is_dir() {
209 return Err(format!(
210 "Static path is not a directory: {}",
211 static_dir.display()
212 ));
213 }
214
215 let app_state = web::Data::new(
216 AppState::new(self.bamboo_home_dir.clone())
217 .await
218 .map_err(|e| format!("Failed to initialize app state: {e}"))?,
219 );
220 self.app_state = Some(app_state.clone());
222 let rate_limiter = build_rate_limiter();
224 let bind_addr = bind.to_string();
225 let bind_for_log = bind_addr.clone();
226
227 let app_factory = move || {
228 wrap_governor_and_cors(
236 with_body_limits(App::new()).app_data(app_state.clone()),
237 &rate_limiter,
238 apply_rate_limit,
239 &bind_addr,
240 port,
241 )
242 .wrap(build_security_headers())
243 .wrap(actix_web::middleware::from_fn(
247 crate::config::add_asset_cache_headers,
248 ))
249 .configure(configure_routes_with_rate_limiting)
250 .service(
251 fs::Files::new("/", static_dir.clone())
252 .index_file("index.html")
253 .prefer_utf8(true)
254 .disable_content_disposition()
255 .disable_content_disposition(),
256 )
257 };
258
259 let rustls_config = tls.map(build_rustls_config).transpose()?;
262 let listeners = build_resolved_listeners(bind, port)?;
263 let server = build_h1_server(app_factory, listeners, DEFAULT_WORKER_COUNT, rustls_config)
264 .map_err(|e| format!("Failed to build HTTP/1.1 server: {e}"))?;
265
266 let server_handle = tokio::spawn(async move {
267 tokio::select! {
268 result = server => {
269 if let Err(e) = result {
270 error!("Server error: {}", e);
271 }
272 }
273 _ = &mut shutdown_rx => {
274 info!("Web service shutdown signal received");
275 }
276 }
277 });
278
279 self.shutdown_tx = Some(shutdown_tx);
280 self.server_handle = Some(server_handle);
281
282 let scheme = if tls.is_some() { "https" } else { "http" };
283 info!(
284 "Web service with static frontend started successfully on {scheme}://{}:{}",
285 bind_for_log, port
286 );
287 Ok(())
288 }
289
290 pub async fn stop(&mut self) -> Result<(), String> {
292 if let Some(shutdown_tx) = self.shutdown_tx.take() {
293 if shutdown_tx.send(()).is_err() {
294 error!("Failed to send shutdown signal");
295 return Err("Error sending shutdown signal".to_string());
296 }
297
298 if let Some(handle) = self.server_handle.take() {
299 if let Err(e) = handle.await {
300 error!("Error waiting for server shutdown: {}", e);
301 return Err(format!("Error waiting for server shutdown: {}", e));
302 }
303 }
304
305 if let Some(state) = self.app_state.take() {
310 state.shutdown().await;
311 }
312
313 info!("Web service stopped successfully");
314 }
315
316 Ok(())
317 }
318
319 pub fn is_running(&self) -> bool {
321 self.server_handle.is_some()
322 }
323
324 pub fn port(&self) -> u16 {
326 self.port
327 }
328}
329
330impl Drop for WebService {
331 fn drop(&mut self) {
332 if let Some(shutdown_tx) = self.shutdown_tx.take() {
333 let _ = shutdown_tx.send(());
334 }
335 if let Some(state) = self.app_state.take() {
340 state.mcp_proxy_shutdown.cancel();
341 }
342 }
343}
344
345#[cfg(test)]
346mod tests {
347 use super::*;
348
349 #[actix_web::test]
357 async fn shared_factory_raises_json_body_limit() {
358 use actix_web::{http::StatusCode, test, HttpResponse};
359
360 async fn echo(_body: web::Json<serde_json::Value>) -> HttpResponse {
361 HttpResponse::Ok().finish()
362 }
363
364 let big = "x".repeat(3 * 1024 * 1024);
366 let payload = serde_json::json!({ "data": big });
367
368 let app =
370 test::init_service(with_body_limits(App::new()).route("/echo", web::post().to(echo)))
371 .await;
372 let resp = test::call_service(
373 &app,
374 test::TestRequest::post()
375 .uri("/echo")
376 .set_json(&payload)
377 .to_request(),
378 )
379 .await;
380 assert_eq!(
381 resp.status(),
382 StatusCode::OK,
383 "shared factory must accept a >2MB JSON body (#252)"
384 );
385
386 let app_default = test::init_service(App::new().route("/echo", web::post().to(echo))).await;
388 let resp_default = test::call_service(
389 &app_default,
390 test::TestRequest::post()
391 .uri("/echo")
392 .set_json(&payload)
393 .to_request(),
394 )
395 .await;
396 assert_eq!(
397 resp_default.status(),
398 StatusCode::PAYLOAD_TOO_LARGE,
399 "actix's default JSON limit must reject a >2MB body"
400 );
401 }
402
403 #[tokio::test]
409 async fn start_with_bind_rejects_nonloopback_without_limiter() {
410 let home = tempfile::TempDir::new().expect("tempdir");
411 let mut service = WebService::new(home.path().to_path_buf());
412
413 let err = service
415 .start_with_bind(0, "0.0.0.0")
416 .await
417 .expect_err("non-loopback bind without a limiter must be rejected (#169 part 3)");
418 assert!(
419 err.contains("without a rate limiter"),
420 "rejection must explain the missing limiter, got: {err}"
421 );
422 assert!(
423 !service.is_running(),
424 "the guard must reject BEFORE the server starts"
425 );
426
427 service
429 .start_with_bind(0, "127.0.0.1")
430 .await
431 .expect("loopback bind must still start without a limiter");
432 service.stop().await.expect("web service stops");
433 }
434
435 #[tokio::test]
439 async fn stop_cancels_mcp_proxy_supervisor_token() {
440 let home = tempfile::TempDir::new().expect("tempdir");
441 let mut service = WebService::new(home.path().to_path_buf());
442 service
444 .start_with_bind(0, "127.0.0.1")
445 .await
446 .expect("web service starts");
447
448 let token = service
450 .app_state
451 .as_ref()
452 .expect("app_state retained after start")
453 .mcp_proxy_shutdown
454 .clone();
455 assert!(
456 !token.is_cancelled(),
457 "supervisor token is live while the service runs"
458 );
459
460 service.stop().await.expect("web service stops");
461
462 assert!(
463 token.is_cancelled(),
464 "stop() must cancel the MCP-proxy supervisor token so it terminates"
465 );
466 }
467}