bamboo_server/server/
web_service.rs1use std::path::PathBuf;
2
3use actix_files as fs;
4use actix_web::{web, App, HttpServer};
5use tokio::sync::oneshot;
6use tracing::{error, info};
7
8use super::listeners::DEFAULT_WORKER_COUNT;
9
10pub(crate) const MAX_JSON_BODY_BYTES: usize = 25 * 1024 * 1024;
15pub(crate) const MAX_PAYLOAD_BYTES: usize = 30 * 1024 * 1024;
16use super::tls::build_rustls_config;
17use crate::app_state::AppState;
18use crate::config::{build_cors, build_rate_limiter, build_security_headers, is_loopback_bind};
19use crate::routes::{configure_routes, configure_routes_with_rate_limiting};
20use actix_governor::Governor;
21use bamboo_config::TlsConfig;
22
23pub struct WebService {
28 shutdown_tx: Option<oneshot::Sender<()>>,
29 server_handle: Option<tokio::task::JoinHandle<()>>,
30 app_state: Option<web::Data<AppState>>,
35 bamboo_home_dir: PathBuf,
37 port: u16,
38}
39
40impl WebService {
41 pub fn new(bamboo_home_dir: PathBuf) -> Self {
46 Self {
47 shutdown_tx: None,
48 server_handle: None,
49 app_state: None,
50 bamboo_home_dir,
51 port: 3456, }
53 }
54
55 pub async fn start(&mut self, port: u16) -> Result<(), String> {
57 self.start_with_bind(port, "127.0.0.1").await
58 }
59
60 pub async fn start_with_bind(&mut self, port: u16, bind: &str) -> Result<(), String> {
62 self.start_with_bind_tls(port, bind, None).await
63 }
64
65 pub async fn start_with_bind_tls(
69 &mut self,
70 port: u16,
71 bind: &str,
72 tls: Option<&TlsConfig>,
73 ) -> Result<(), String> {
74 info!("Starting web service...");
75 if self.server_handle.is_some() {
76 return Err("Web service is already running".to_string());
77 }
78
79 let (shutdown_tx, mut shutdown_rx) = oneshot::channel::<()>();
80 self.port = port;
81
82 let app_state = web::Data::new(
83 AppState::new(self.bamboo_home_dir.clone())
84 .await
85 .map_err(|e| format!("Failed to initialize app state: {e}"))?,
86 );
87 self.app_state = Some(app_state.clone());
89 let bind_addr = bind.to_string();
90 let listen_addr = format!("{bind}:{port}");
91 let bind_for_log = bind_addr.clone();
92
93 let server = HttpServer::new(move || {
94 App::new()
95 .app_data(web::JsonConfig::default().limit(MAX_JSON_BODY_BYTES))
96 .app_data(web::PayloadConfig::new(MAX_PAYLOAD_BYTES))
97 .app_data(app_state.clone())
98 .wrap(build_cors(&bind_addr, port))
99 .configure(configure_routes) })
101 .workers(DEFAULT_WORKER_COUNT);
102
103 let server = match tls {
106 Some(tls) => server
107 .bind_rustls_0_23(&listen_addr, build_rustls_config(tls)?)
108 .map_err(|e| format!("Failed to bind TLS server: {e}"))?,
109 None => server
110 .bind(&listen_addr)
111 .map_err(|e| format!("Failed to bind server: {e}"))?,
112 }
113 .run();
114
115 let server_handle = tokio::spawn(async move {
116 tokio::select! {
117 result = server => {
118 if let Err(e) = result {
119 error!("Server error: {}", e);
120 }
121 }
122 _ = &mut shutdown_rx => {
123 info!("Web service shutdown signal received");
124 }
125 }
126 });
127
128 self.shutdown_tx = Some(shutdown_tx);
129 self.server_handle = Some(server_handle);
130
131 let scheme = if tls.is_some() { "https" } else { "http" };
132 info!(
133 "Web service started successfully on {scheme}://{}:{}",
134 bind_for_log, port
135 );
136 Ok(())
137 }
138
139 pub async fn start_with_bind_and_static(
142 &mut self,
143 port: u16,
144 bind: &str,
145 static_dir: PathBuf,
146 ) -> Result<(), String> {
147 self.start_with_bind_and_static_tls(port, bind, static_dir, None)
148 .await
149 }
150
151 pub async fn start_with_bind_and_static_tls(
154 &mut self,
155 port: u16,
156 bind: &str,
157 static_dir: PathBuf,
158 tls: Option<&TlsConfig>,
159 ) -> Result<(), String> {
160 info!("Starting web service with static frontend...");
161 if self.server_handle.is_some() {
162 return Err("Web service is already running".to_string());
163 }
164
165 let (shutdown_tx, mut shutdown_rx) = oneshot::channel::<()>();
166 self.port = port;
167
168 let static_dir = static_dir
169 .canonicalize()
170 .map_err(|e| format!("Static directory not found: {:?}: {}", static_dir, e))?;
171 if !static_dir.is_dir() {
172 return Err(format!(
173 "Static path is not a directory: {}",
174 static_dir.display()
175 ));
176 }
177
178 let app_state = web::Data::new(
179 AppState::new(self.bamboo_home_dir.clone())
180 .await
181 .map_err(|e| format!("Failed to initialize app state: {e}"))?,
182 );
183 self.app_state = Some(app_state.clone());
185 let rate_limiter = build_rate_limiter();
190 let apply_rate_limit = !is_loopback_bind(bind);
191 let bind_addr = bind.to_string();
192 let listen_addr = format!("{bind}:{port}");
193 let bind_for_log = bind_addr.clone();
194
195 let server = HttpServer::new(move || {
196 App::new()
197 .app_data(web::JsonConfig::default().limit(MAX_JSON_BODY_BYTES))
198 .app_data(web::PayloadConfig::new(MAX_PAYLOAD_BYTES))
199 .app_data(app_state.clone())
200 .wrap(actix_web::middleware::Condition::new(
201 apply_rate_limit,
202 Governor::new(&rate_limiter),
203 ))
204 .wrap(build_cors(&bind_addr, port))
205 .wrap(build_security_headers())
206 .wrap(actix_web::middleware::from_fn(
210 crate::config::add_asset_cache_headers,
211 ))
212 .configure(configure_routes_with_rate_limiting)
213 .service(
214 fs::Files::new("/", static_dir.clone())
215 .index_file("index.html")
216 .prefer_utf8(true)
217 .disable_content_disposition()
218 .disable_content_disposition(),
219 )
220 })
221 .workers(DEFAULT_WORKER_COUNT);
222
223 let server = match tls {
226 Some(tls) => server
227 .bind_rustls_0_23(&listen_addr, build_rustls_config(tls)?)
228 .map_err(|e| format!("Failed to bind TLS server: {e}"))?,
229 None => server
230 .bind(&listen_addr)
231 .map_err(|e| format!("Failed to bind server: {e}"))?,
232 }
233 .run();
234
235 let server_handle = tokio::spawn(async move {
236 tokio::select! {
237 result = server => {
238 if let Err(e) = result {
239 error!("Server error: {}", e);
240 }
241 }
242 _ = &mut shutdown_rx => {
243 info!("Web service shutdown signal received");
244 }
245 }
246 });
247
248 self.shutdown_tx = Some(shutdown_tx);
249 self.server_handle = Some(server_handle);
250
251 let scheme = if tls.is_some() { "https" } else { "http" };
252 info!(
253 "Web service with static frontend started successfully on {scheme}://{}:{}",
254 bind_for_log, port
255 );
256 Ok(())
257 }
258
259 pub async fn stop(&mut self) -> Result<(), String> {
261 if let Some(shutdown_tx) = self.shutdown_tx.take() {
262 if shutdown_tx.send(()).is_err() {
263 error!("Failed to send shutdown signal");
264 return Err("Error sending shutdown signal".to_string());
265 }
266
267 if let Some(handle) = self.server_handle.take() {
268 if let Err(e) = handle.await {
269 error!("Error waiting for server shutdown: {}", e);
270 return Err(format!("Error waiting for server shutdown: {}", e));
271 }
272 }
273
274 if let Some(state) = self.app_state.take() {
279 state.shutdown().await;
280 }
281
282 info!("Web service stopped successfully");
283 }
284
285 Ok(())
286 }
287
288 pub fn is_running(&self) -> bool {
290 self.server_handle.is_some()
291 }
292
293 pub fn port(&self) -> u16 {
295 self.port
296 }
297}
298
299impl Drop for WebService {
300 fn drop(&mut self) {
301 if let Some(shutdown_tx) = self.shutdown_tx.take() {
302 let _ = shutdown_tx.send(());
303 }
304 if let Some(state) = self.app_state.take() {
309 state.mcp_proxy_shutdown.cancel();
310 }
311 }
312}
313
314#[cfg(test)]
315mod tests {
316 use super::*;
317
318 #[tokio::test]
322 async fn stop_cancels_mcp_proxy_supervisor_token() {
323 let home = tempfile::TempDir::new().expect("tempdir");
324 let mut service = WebService::new(home.path().to_path_buf());
325 service
327 .start_with_bind(0, "127.0.0.1")
328 .await
329 .expect("web service starts");
330
331 let token = service
333 .app_state
334 .as_ref()
335 .expect("app_state retained after start")
336 .mcp_proxy_shutdown
337 .clone();
338 assert!(
339 !token.is_cancelled(),
340 "supervisor token is live while the service runs"
341 );
342
343 service.stop().await.expect("web service stops");
344
345 assert!(
346 token.is_cancelled(),
347 "stop() must cancel the MCP-proxy supervisor token so it terminates"
348 );
349 }
350}