use std::path::PathBuf;
use actix_files as fs;
use actix_web::dev::{ServiceFactory, ServiceRequest, ServiceResponse};
use actix_web::{web, App, HttpServer};
use tokio::sync::oneshot;
use tracing::{error, info};
use super::listeners::DEFAULT_WORKER_COUNT;
pub(crate) const MAX_JSON_BODY_BYTES: usize = 25 * 1024 * 1024;
pub(crate) const MAX_PAYLOAD_BYTES: usize = 30 * 1024 * 1024;
pub(crate) fn with_body_limits<T>(app: App<T>) -> App<T>
where
T: ServiceFactory<
ServiceRequest,
Config = (),
Response = ServiceResponse,
Error = actix_web::Error,
InitError = (),
>,
{
app.app_data(web::JsonConfig::default().limit(MAX_JSON_BODY_BYTES))
.app_data(web::PayloadConfig::new(MAX_PAYLOAD_BYTES))
}
use super::tls::build_rustls_config;
use crate::app_state::AppState;
use crate::config::{
build_cors, build_rate_limiter, build_security_headers, is_loopback_bind,
require_limiter_for_nonloopback,
};
use crate::routes::{configure_routes, configure_routes_with_rate_limiting};
use actix_governor::Governor;
use bamboo_config::TlsConfig;
pub struct WebService {
shutdown_tx: Option<oneshot::Sender<()>>,
server_handle: Option<tokio::task::JoinHandle<()>>,
app_state: Option<web::Data<AppState>>,
bamboo_home_dir: PathBuf,
port: u16,
}
impl WebService {
pub fn new(bamboo_home_dir: PathBuf) -> Self {
Self {
shutdown_tx: None,
server_handle: None,
app_state: None,
bamboo_home_dir,
port: 3456, }
}
pub async fn start(&mut self, port: u16) -> Result<(), String> {
self.start_with_bind(port, "127.0.0.1").await
}
pub async fn start_with_bind(&mut self, port: u16, bind: &str) -> Result<(), String> {
self.start_with_bind_tls(port, bind, None).await
}
pub async fn start_with_bind_tls(
&mut self,
port: u16,
bind: &str,
tls: Option<&TlsConfig>,
) -> Result<(), String> {
info!("Starting web service...");
if self.server_handle.is_some() {
return Err("Web service is already running".to_string());
}
require_limiter_for_nonloopback(bind, false)?;
let (shutdown_tx, mut shutdown_rx) = oneshot::channel::<()>();
self.port = port;
let app_state = web::Data::new(
AppState::new(self.bamboo_home_dir.clone())
.await
.map_err(|e| format!("Failed to initialize app state: {e}"))?,
);
self.app_state = Some(app_state.clone());
let bind_addr = bind.to_string();
let listen_addr = format!("{bind}:{port}");
let bind_for_log = bind_addr.clone();
let server = HttpServer::new(move || {
with_body_limits(App::new())
.app_data(app_state.clone())
.wrap(build_cors(&bind_addr, port))
.configure(configure_routes) })
.workers(DEFAULT_WORKER_COUNT);
let server = match tls {
Some(tls) => server
.bind_rustls_0_23(&listen_addr, build_rustls_config(tls)?)
.map_err(|e| format!("Failed to bind TLS server: {e}"))?,
None => server
.bind(&listen_addr)
.map_err(|e| format!("Failed to bind server: {e}"))?,
}
.run();
let server_handle = tokio::spawn(async move {
tokio::select! {
result = server => {
if let Err(e) = result {
error!("Server error: {}", e);
}
}
_ = &mut shutdown_rx => {
info!("Web service shutdown signal received");
}
}
});
self.shutdown_tx = Some(shutdown_tx);
self.server_handle = Some(server_handle);
let scheme = if tls.is_some() { "https" } else { "http" };
info!(
"Web service started successfully on {scheme}://{}:{}",
bind_for_log, port
);
Ok(())
}
pub async fn start_with_bind_and_static(
&mut self,
port: u16,
bind: &str,
static_dir: PathBuf,
) -> Result<(), String> {
self.start_with_bind_and_static_tls(port, bind, static_dir, None)
.await
}
pub async fn start_with_bind_and_static_tls(
&mut self,
port: u16,
bind: &str,
static_dir: PathBuf,
tls: Option<&TlsConfig>,
) -> Result<(), String> {
info!("Starting web service with static frontend...");
if self.server_handle.is_some() {
return Err("Web service is already running".to_string());
}
let (shutdown_tx, mut shutdown_rx) = oneshot::channel::<()>();
self.port = port;
let static_dir = static_dir
.canonicalize()
.map_err(|e| format!("Static directory not found: {:?}: {}", static_dir, e))?;
if !static_dir.is_dir() {
return Err(format!(
"Static path is not a directory: {}",
static_dir.display()
));
}
let app_state = web::Data::new(
AppState::new(self.bamboo_home_dir.clone())
.await
.map_err(|e| format!("Failed to initialize app state: {e}"))?,
);
self.app_state = Some(app_state.clone());
let rate_limiter = build_rate_limiter();
let apply_rate_limit = !is_loopback_bind(bind);
require_limiter_for_nonloopback(bind, apply_rate_limit)?;
let bind_addr = bind.to_string();
let listen_addr = format!("{bind}:{port}");
let bind_for_log = bind_addr.clone();
let server = HttpServer::new(move || {
with_body_limits(App::new())
.app_data(app_state.clone())
.wrap(actix_web::middleware::Condition::new(
apply_rate_limit,
Governor::new(&rate_limiter),
))
.wrap(build_cors(&bind_addr, port))
.wrap(build_security_headers())
.wrap(actix_web::middleware::from_fn(
crate::config::add_asset_cache_headers,
))
.configure(configure_routes_with_rate_limiting)
.service(
fs::Files::new("/", static_dir.clone())
.index_file("index.html")
.prefer_utf8(true)
.disable_content_disposition()
.disable_content_disposition(),
)
})
.workers(DEFAULT_WORKER_COUNT);
let server = match tls {
Some(tls) => server
.bind_rustls_0_23(&listen_addr, build_rustls_config(tls)?)
.map_err(|e| format!("Failed to bind TLS server: {e}"))?,
None => server
.bind(&listen_addr)
.map_err(|e| format!("Failed to bind server: {e}"))?,
}
.run();
let server_handle = tokio::spawn(async move {
tokio::select! {
result = server => {
if let Err(e) = result {
error!("Server error: {}", e);
}
}
_ = &mut shutdown_rx => {
info!("Web service shutdown signal received");
}
}
});
self.shutdown_tx = Some(shutdown_tx);
self.server_handle = Some(server_handle);
let scheme = if tls.is_some() { "https" } else { "http" };
info!(
"Web service with static frontend started successfully on {scheme}://{}:{}",
bind_for_log, port
);
Ok(())
}
pub async fn stop(&mut self) -> Result<(), String> {
if let Some(shutdown_tx) = self.shutdown_tx.take() {
if shutdown_tx.send(()).is_err() {
error!("Failed to send shutdown signal");
return Err("Error sending shutdown signal".to_string());
}
if let Some(handle) = self.server_handle.take() {
if let Err(e) = handle.await {
error!("Error waiting for server shutdown: {}", e);
return Err(format!("Error waiting for server shutdown: {}", e));
}
}
if let Some(state) = self.app_state.take() {
state.shutdown().await;
}
info!("Web service stopped successfully");
}
Ok(())
}
pub fn is_running(&self) -> bool {
self.server_handle.is_some()
}
pub fn port(&self) -> u16 {
self.port
}
}
impl Drop for WebService {
fn drop(&mut self) {
if let Some(shutdown_tx) = self.shutdown_tx.take() {
let _ = shutdown_tx.send(());
}
if let Some(state) = self.app_state.take() {
state.mcp_proxy_shutdown.cancel();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[actix_web::test]
async fn shared_factory_raises_json_body_limit() {
use actix_web::{http::StatusCode, test, HttpResponse};
async fn echo(_body: web::Json<serde_json::Value>) -> HttpResponse {
HttpResponse::Ok().finish()
}
let big = "x".repeat(3 * 1024 * 1024);
let payload = serde_json::json!({ "data": big });
let app =
test::init_service(with_body_limits(App::new()).route("/echo", web::post().to(echo)))
.await;
let resp = test::call_service(
&app,
test::TestRequest::post()
.uri("/echo")
.set_json(&payload)
.to_request(),
)
.await;
assert_eq!(
resp.status(),
StatusCode::OK,
"shared factory must accept a >2MB JSON body (#252)"
);
let app_default = test::init_service(App::new().route("/echo", web::post().to(echo))).await;
let resp_default = test::call_service(
&app_default,
test::TestRequest::post()
.uri("/echo")
.set_json(&payload)
.to_request(),
)
.await;
assert_eq!(
resp_default.status(),
StatusCode::PAYLOAD_TOO_LARGE,
"actix's default JSON limit must reject a >2MB body"
);
}
#[tokio::test]
async fn start_with_bind_rejects_nonloopback_without_limiter() {
let home = tempfile::TempDir::new().expect("tempdir");
let mut service = WebService::new(home.path().to_path_buf());
let err = service
.start_with_bind(0, "0.0.0.0")
.await
.expect_err("non-loopback bind without a limiter must be rejected (#169 part 3)");
assert!(
err.contains("without a rate limiter"),
"rejection must explain the missing limiter, got: {err}"
);
assert!(
!service.is_running(),
"the guard must reject BEFORE the server starts"
);
service
.start_with_bind(0, "127.0.0.1")
.await
.expect("loopback bind must still start without a limiter");
service.stop().await.expect("web service stops");
}
#[tokio::test]
async fn stop_cancels_mcp_proxy_supervisor_token() {
let home = tempfile::TempDir::new().expect("tempdir");
let mut service = WebService::new(home.path().to_path_buf());
service
.start_with_bind(0, "127.0.0.1")
.await
.expect("web service starts");
let token = service
.app_state
.as_ref()
.expect("app_state retained after start")
.mcp_proxy_shutdown
.clone();
assert!(
!token.is_cancelled(),
"supervisor token is live while the service runs"
);
service.stop().await.expect("web service stops");
assert!(
token.is_cancelled(),
"stop() must cancel the MCP-proxy supervisor token so it terminates"
);
}
}