Skip to main content

bamboo_server/server/
web_service.rs

1use 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;
9use super::tls::build_rustls_config;
10use crate::app_state::AppState;
11use crate::config::{build_cors, build_rate_limiter, build_security_headers, is_loopback_bind};
12use crate::routes::{configure_routes, configure_routes_with_rate_limiting};
13use actix_governor::Governor;
14use bamboo_config::TlsConfig;
15
16/// Manageable web service with start/stop lifecycle
17///
18/// Use this when you need to programmatically control the server lifecycle,
19/// such as in tests or embedded scenarios.
20pub struct WebService {
21    shutdown_tx: Option<oneshot::Sender<()>>,
22    server_handle: Option<tokio::task::JoinHandle<()>>,
23    /// Handle to the running server's [`AppState`], retained so [`WebService::stop`]
24    /// /[`Drop`] can gracefully stop AppState-owned background tasks (the #47
25    /// MCP-proxy reconnect supervisor) instead of leaking them until process exit.
26    /// #119.
27    app_state: Option<web::Data<AppState>>,
28    /// Bamboo home directory containing all application data (config, sessions, skills, etc.)
29    bamboo_home_dir: PathBuf,
30    port: u16,
31}
32
33impl WebService {
34    /// Create a new WebService instance
35    ///
36    /// # Arguments
37    /// * `bamboo_home_dir` - Bamboo home directory (e.g., `${HOME}/.bamboo` or custom path)
38    pub fn new(bamboo_home_dir: PathBuf) -> Self {
39        Self {
40            shutdown_tx: None,
41            server_handle: None,
42            app_state: None,
43            bamboo_home_dir,
44            port: 3456, // Default port
45        }
46    }
47
48    /// Start the web service on the specified port using the default localhost bind.
49    pub async fn start(&mut self, port: u16) -> Result<(), String> {
50        self.start_with_bind(port, "127.0.0.1").await
51    }
52
53    /// Start the web service on the specified port and bind address.
54    pub async fn start_with_bind(&mut self, port: u16, bind: &str) -> Result<(), String> {
55        self.start_with_bind_tls(port, bind, None).await
56    }
57
58    /// Start the web service, terminating TLS itself when `tls` is `Some` (#181).
59    ///
60    /// `None` keeps the plaintext `.bind()` path unchanged (desktop loopback).
61    pub async fn start_with_bind_tls(
62        &mut self,
63        port: u16,
64        bind: &str,
65        tls: Option<&TlsConfig>,
66    ) -> Result<(), String> {
67        info!("Starting web service...");
68        if self.server_handle.is_some() {
69            return Err("Web service is already running".to_string());
70        }
71
72        let (shutdown_tx, mut shutdown_rx) = oneshot::channel::<()>();
73        self.port = port;
74
75        let app_state = web::Data::new(
76            AppState::new(self.bamboo_home_dir.clone())
77                .await
78                .map_err(|e| format!("Failed to initialize app state: {e}"))?,
79        );
80        // Retain a handle so stop()/Drop can stop AppState-owned background tasks. #119
81        self.app_state = Some(app_state.clone());
82        let bind_addr = bind.to_string();
83        let listen_addr = format!("{bind}:{port}");
84        let bind_for_log = bind_addr.clone();
85
86        let server = HttpServer::new(move || {
87            App::new()
88                .app_data(app_state.clone())
89                .wrap(build_cors(&bind_addr, port))
90                .configure(configure_routes) // No rate limiting for WebService
91        })
92        .workers(DEFAULT_WORKER_COUNT);
93
94        // Fail-fast: build the rustls config before binding; `None` → unchanged
95        // plaintext `.bind()` path. #181.
96        let server = match tls {
97            Some(tls) => server
98                .bind_rustls_0_23(&listen_addr, build_rustls_config(tls)?)
99                .map_err(|e| format!("Failed to bind TLS server: {e}"))?,
100            None => server
101                .bind(&listen_addr)
102                .map_err(|e| format!("Failed to bind server: {e}"))?,
103        }
104        .run();
105
106        let server_handle = tokio::spawn(async move {
107            tokio::select! {
108                result = server => {
109                    if let Err(e) = result {
110                        error!("Server error: {}", e);
111                    }
112                }
113                _ = &mut shutdown_rx => {
114                    info!("Web service shutdown signal received");
115                }
116            }
117        });
118
119        self.shutdown_tx = Some(shutdown_tx);
120        self.server_handle = Some(server_handle);
121
122        let scheme = if tls.is_some() { "https" } else { "http" };
123        info!(
124            "Web service started successfully on {scheme}://{}:{}",
125            bind_for_log, port
126        );
127        Ok(())
128    }
129
130    /// Start the web service on the specified port and bind address, serving static files
131    /// alongside the API routes.
132    pub async fn start_with_bind_and_static(
133        &mut self,
134        port: u16,
135        bind: &str,
136        static_dir: PathBuf,
137    ) -> Result<(), String> {
138        self.start_with_bind_and_static_tls(port, bind, static_dir, None)
139            .await
140    }
141
142    /// Like [`WebService::start_with_bind_and_static`], terminating TLS itself
143    /// when `tls` is `Some` (#181). `None` keeps the plaintext path unchanged.
144    pub async fn start_with_bind_and_static_tls(
145        &mut self,
146        port: u16,
147        bind: &str,
148        static_dir: PathBuf,
149        tls: Option<&TlsConfig>,
150    ) -> Result<(), String> {
151        info!("Starting web service with static frontend...");
152        if self.server_handle.is_some() {
153            return Err("Web service is already running".to_string());
154        }
155
156        let (shutdown_tx, mut shutdown_rx) = oneshot::channel::<()>();
157        self.port = port;
158
159        let static_dir = static_dir
160            .canonicalize()
161            .map_err(|e| format!("Static directory not found: {:?}: {}", static_dir, e))?;
162        if !static_dir.is_dir() {
163            return Err(format!(
164                "Static path is not a directory: {}",
165                static_dir.display()
166            ));
167        }
168
169        let app_state = web::Data::new(
170            AppState::new(self.bamboo_home_dir.clone())
171                .await
172                .map_err(|e| format!("Failed to initialize app state: {e}"))?,
173        );
174        // Retain a handle so stop()/Drop can stop AppState-owned background tasks. #119
175        self.app_state = Some(app_state.clone());
176        // Per-IP rate limiter for the network-exposed production server (#13).
177        // SKIPPED for loopback/desktop binds: the local frontend legitimately
178        // bursts ~45 hashed `/assets/*` requests on load and would otherwise be
179        // throttled to a 429 (chunk import fails / "Too many requests").
180        let rate_limiter = build_rate_limiter();
181        let apply_rate_limit = !is_loopback_bind(bind);
182        let bind_addr = bind.to_string();
183        let listen_addr = format!("{bind}:{port}");
184        let bind_for_log = bind_addr.clone();
185
186        let server = HttpServer::new(move || {
187            App::new()
188                .app_data(web::JsonConfig::default().limit(25 * 1024 * 1024))
189                .app_data(web::PayloadConfig::new(30 * 1024 * 1024))
190                .app_data(app_state.clone())
191                .wrap(actix_web::middleware::Condition::new(
192                    apply_rate_limit,
193                    Governor::new(&rate_limiter),
194                ))
195                .wrap(build_cors(&bind_addr, port))
196                .wrap(build_security_headers())
197                // Immutable long-cache for hashed `/assets/*` so a proxy/CDN
198                // (e.g. Cloudflare tunnel) caches chunks at the edge instead of
199                // round-tripping each one to origin (#preload-error fix).
200                .wrap(actix_web::middleware::from_fn(
201                    crate::config::add_asset_cache_headers,
202                ))
203                .configure(configure_routes_with_rate_limiting)
204                .service(
205                    fs::Files::new("/", static_dir.clone())
206                        .index_file("index.html")
207                        .prefer_utf8(true)
208                        .disable_content_disposition()
209                        .disable_content_disposition(),
210                )
211        })
212        .workers(DEFAULT_WORKER_COUNT);
213
214        // Fail-fast: build the rustls config before binding; `None` → unchanged
215        // plaintext `.bind()` path. #181.
216        let server = match tls {
217            Some(tls) => server
218                .bind_rustls_0_23(&listen_addr, build_rustls_config(tls)?)
219                .map_err(|e| format!("Failed to bind TLS server: {e}"))?,
220            None => server
221                .bind(&listen_addr)
222                .map_err(|e| format!("Failed to bind server: {e}"))?,
223        }
224        .run();
225
226        let server_handle = tokio::spawn(async move {
227            tokio::select! {
228                result = server => {
229                    if let Err(e) = result {
230                        error!("Server error: {}", e);
231                    }
232                }
233                _ = &mut shutdown_rx => {
234                    info!("Web service shutdown signal received");
235                }
236            }
237        });
238
239        self.shutdown_tx = Some(shutdown_tx);
240        self.server_handle = Some(server_handle);
241
242        let scheme = if tls.is_some() { "https" } else { "http" };
243        info!(
244            "Web service with static frontend started successfully on {scheme}://{}:{}",
245            bind_for_log, port
246        );
247        Ok(())
248    }
249
250    /// Stop the web service
251    pub async fn stop(&mut self) -> Result<(), String> {
252        if let Some(shutdown_tx) = self.shutdown_tx.take() {
253            if shutdown_tx.send(()).is_err() {
254                error!("Failed to send shutdown signal");
255                return Err("Error sending shutdown signal".to_string());
256            }
257
258            if let Some(handle) = self.server_handle.take() {
259                if let Err(e) = handle.await {
260                    error!("Error waiting for server shutdown: {}", e);
261                    return Err(format!("Error waiting for server shutdown: {}", e));
262                }
263            }
264
265            // Gracefully stop AppState-owned background tasks — the #47 MCP-proxy
266            // reconnect supervisor (via its cancellation token) and the MCP servers.
267            // Without this the token was wired but never cancelled, so the
268            // supervisor only died at process exit. #119.
269            if let Some(state) = self.app_state.take() {
270                state.shutdown().await;
271            }
272
273            info!("Web service stopped successfully");
274        }
275
276        Ok(())
277    }
278
279    /// Check if the web service is currently running
280    pub fn is_running(&self) -> bool {
281        self.server_handle.is_some()
282    }
283
284    /// Get the port the web service is running on
285    pub fn port(&self) -> u16 {
286        self.port
287    }
288}
289
290impl Drop for WebService {
291    fn drop(&mut self) {
292        if let Some(shutdown_tx) = self.shutdown_tx.take() {
293            let _ = shutdown_tx.send(());
294        }
295        // Drop can't run the async shutdown(), but cancelling the MCP-proxy
296        // supervisor's token is synchronous — so a WebService dropped without an
297        // explicit stop() still tears down the reconnect loop. (The async MCP
298        // server cleanup is left to process exit on this fallback path.) #119.
299        if let Some(state) = self.app_state.take() {
300            state.mcp_proxy_shutdown.cancel();
301        }
302    }
303}
304
305#[cfg(test)]
306mod tests {
307    use super::*;
308
309    /// #119 e2e: WebService::stop() must cancel the AppState-owned MCP-proxy
310    /// reconnect supervisor's token, so it terminates on server stop rather than
311    /// leaking until process exit.
312    #[tokio::test]
313    async fn stop_cancels_mcp_proxy_supervisor_token() {
314        let home = tempfile::TempDir::new().expect("tempdir");
315        let mut service = WebService::new(home.path().to_path_buf());
316        // Port 0 -> OS-assigned ephemeral port (no conflict).
317        service
318            .start_with_bind(0, "127.0.0.1")
319            .await
320            .expect("web service starts");
321
322        // Capture the supervisor's cancellation token while the service runs.
323        let token = service
324            .app_state
325            .as_ref()
326            .expect("app_state retained after start")
327            .mcp_proxy_shutdown
328            .clone();
329        assert!(
330            !token.is_cancelled(),
331            "supervisor token is live while the service runs"
332        );
333
334        service.stop().await.expect("web service stops");
335
336        assert!(
337            token.is_cancelled(),
338            "stop() must cancel the MCP-proxy supervisor token so it terminates"
339        );
340    }
341}