Skip to main content

bamboo_server/server/
web_service.rs

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
11/// Request body size limits, applied on EVERY serve path so the desktop/embedded
12/// server accepts the same payloads (e.g. an inline-image chat request) as the
13/// production server, instead of falling back to actix's ~2MB JSON / 256KB
14/// payload defaults and rejecting them with 413 (#252).
15pub(crate) const MAX_JSON_BODY_BYTES: usize = 25 * 1024 * 1024;
16pub(crate) const MAX_PAYLOAD_BYTES: usize = 30 * 1024 * 1024;
17
18/// Install the shared request body-size limits ([`MAX_JSON_BODY_BYTES`] /
19/// [`MAX_PAYLOAD_BYTES`]) onto an actix `App`.
20///
21/// EVERY serve path — desktop (`run_with_tls`), production
22/// (`run_with_bind_and_static_tls`), and `WebService::start*` — funnels its
23/// `App::new()` through this one helper, so the limits can no longer drift
24/// between paths. That drift was the #252 bug: the desktop/embedded server set
25/// neither limit and rejected an inline-image chat request with 413 while the
26/// production server (which set them) accepted it. Callers layer their own app
27/// data, middleware, routes, and static files on top of the returned `App`.
28pub(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, wrap_governor_and_cors,
46};
47use crate::routes::{configure_routes, configure_routes_with_rate_limiting};
48use bamboo_config::TlsConfig;
49
50/// Manageable web service with start/stop lifecycle
51///
52/// Use this when you need to programmatically control the server lifecycle,
53/// such as in tests or embedded scenarios.
54pub struct WebService {
55    shutdown_tx: Option<oneshot::Sender<()>>,
56    server_handle: Option<tokio::task::JoinHandle<()>>,
57    /// Handle to the running server's [`AppState`], retained so [`WebService::stop`]
58    /// /[`Drop`] can gracefully stop AppState-owned background tasks (the #47
59    /// MCP-proxy reconnect supervisor) instead of leaking them until process exit.
60    /// #119.
61    app_state: Option<web::Data<AppState>>,
62    /// Bamboo home directory containing all application data (config, sessions, skills, etc.)
63    bamboo_home_dir: PathBuf,
64    port: u16,
65}
66
67impl WebService {
68    /// Create a new WebService instance
69    ///
70    /// # Arguments
71    /// * `bamboo_home_dir` - Bamboo home directory (e.g., `${HOME}/.bamboo` or custom path)
72    pub fn new(bamboo_home_dir: PathBuf) -> Self {
73        Self {
74            shutdown_tx: None,
75            server_handle: None,
76            app_state: None,
77            bamboo_home_dir,
78            port: 3456, // Default port
79        }
80    }
81
82    /// Start the web service on the specified port using the default localhost bind.
83    pub async fn start(&mut self, port: u16) -> Result<(), String> {
84        self.start_with_bind(port, "127.0.0.1").await
85    }
86
87    /// Start the web service on the specified port and bind address.
88    pub async fn start_with_bind(&mut self, port: u16, bind: &str) -> Result<(), String> {
89        self.start_with_bind_tls(port, bind, None).await
90    }
91
92    /// Start the web service, terminating TLS itself when `tls` is `Some` (#181).
93    ///
94    /// `None` keeps the plaintext `.bind()` path unchanged (desktop loopback).
95    pub async fn start_with_bind_tls(
96        &mut self,
97        port: u16,
98        bind: &str,
99        tls: Option<&TlsConfig>,
100    ) -> Result<(), String> {
101        info!("Starting web service...");
102        if self.server_handle.is_some() {
103            return Err("Web service is already running".to_string());
104        }
105
106        // This serve path installs NO rate limiter (API-only WebService). Refuse a
107        // non-loopback bind so it can't silently run unthrottled on a routable
108        // interface — that would re-open the #13 DoS surface. Loopback binds stay
109        // allowed (desktop behavior preserved). #169 part 3.
110        require_limiter_for_nonloopback(bind, false)?;
111
112        let (shutdown_tx, mut shutdown_rx) = oneshot::channel::<()>();
113        self.port = port;
114
115        let app_state = web::Data::new(
116            AppState::new(self.bamboo_home_dir.clone())
117                .await
118                .map_err(|e| format!("Failed to initialize app state: {e}"))?,
119        );
120        // Retain a handle so stop()/Drop can stop AppState-owned background tasks. #119
121        self.app_state = Some(app_state.clone());
122        let bind_addr = bind.to_string();
123        let listen_addr = format!("{bind}:{port}");
124        let bind_for_log = bind_addr.clone();
125
126        let server = HttpServer::new(move || {
127            with_body_limits(App::new())
128                .app_data(app_state.clone())
129                .wrap(build_cors(&bind_addr, port))
130                .configure(configure_routes) // No rate limiting for WebService
131        })
132        .workers(DEFAULT_WORKER_COUNT);
133
134        // Fail-fast: build the rustls config before binding; `None` → unchanged
135        // plaintext `.bind()` path. #181.
136        let server = match tls {
137            Some(tls) => server
138                .bind_rustls_0_23(&listen_addr, build_rustls_config(tls)?)
139                .map_err(|e| format!("Failed to bind TLS server: {e}"))?,
140            None => server
141                .bind(&listen_addr)
142                .map_err(|e| format!("Failed to bind server: {e}"))?,
143        }
144        .run();
145
146        let server_handle = tokio::spawn(async move {
147            tokio::select! {
148                result = server => {
149                    if let Err(e) = result {
150                        error!("Server error: {}", e);
151                    }
152                }
153                _ = &mut shutdown_rx => {
154                    info!("Web service shutdown signal received");
155                }
156            }
157        });
158
159        self.shutdown_tx = Some(shutdown_tx);
160        self.server_handle = Some(server_handle);
161
162        let scheme = if tls.is_some() { "https" } else { "http" };
163        info!(
164            "Web service started successfully on {scheme}://{}:{}",
165            bind_for_log, port
166        );
167        Ok(())
168    }
169
170    /// Start the web service on the specified port and bind address, serving static files
171    /// alongside the API routes.
172    pub async fn start_with_bind_and_static(
173        &mut self,
174        port: u16,
175        bind: &str,
176        static_dir: PathBuf,
177    ) -> Result<(), String> {
178        self.start_with_bind_and_static_tls(port, bind, static_dir, None)
179            .await
180    }
181
182    /// Like [`WebService::start_with_bind_and_static`], terminating TLS itself
183    /// when `tls` is `Some` (#181). `None` keeps the plaintext path unchanged.
184    pub async fn start_with_bind_and_static_tls(
185        &mut self,
186        port: u16,
187        bind: &str,
188        static_dir: PathBuf,
189        tls: Option<&TlsConfig>,
190    ) -> Result<(), String> {
191        info!("Starting web service with static frontend...");
192        if self.server_handle.is_some() {
193            return Err("Web service is already running".to_string());
194        }
195
196        // Per-IP rate limiter (#13) will be applied below for non-loopback binds.
197        // SKIPPED for loopback/desktop binds: the local frontend legitimately
198        // bursts ~45 hashed `/assets/*` requests on load and would otherwise be
199        // throttled to a 429 (chunk import fails / "Too many requests").
200        let apply_rate_limit = !is_loopback_bind(bind);
201        // Bind-aware guard: a non-loopback bind must have the limiter applied
202        // (it is here for non-loopback binds). Belt-and-suspenders against a
203        // future edit that flips `apply_rate_limit` off for a routable bind.
204        // Checked BEFORE the async app-state setup so a bad bind fails fast,
205        // consistent with `start_with_bind_tls`. #169, #428.
206        require_limiter_for_nonloopback(bind, apply_rate_limit)?;
207
208        let (shutdown_tx, mut shutdown_rx) = oneshot::channel::<()>();
209        self.port = port;
210
211        let static_dir = static_dir
212            .canonicalize()
213            .map_err(|e| format!("Static directory not found: {:?}: {}", static_dir, e))?;
214        if !static_dir.is_dir() {
215            return Err(format!(
216                "Static path is not a directory: {}",
217                static_dir.display()
218            ));
219        }
220
221        let app_state = web::Data::new(
222            AppState::new(self.bamboo_home_dir.clone())
223                .await
224                .map_err(|e| format!("Failed to initialize app state: {e}"))?,
225        );
226        // Retain a handle so stop()/Drop can stop AppState-owned background tasks. #119
227        self.app_state = Some(app_state.clone());
228        // Per-IP rate limiter for the network-exposed production server (#13).
229        let rate_limiter = build_rate_limiter();
230        let bind_addr = bind.to_string();
231        let listen_addr = format!("{bind}:{port}");
232        let bind_for_log = bind_addr.clone();
233
234        let server = HttpServer::new(move || {
235            // WRAP ORDER (#169 part 2, #428): Governor + CORS are applied
236            // together, in the fixed order enforced by the shared
237            // `wrap_governor_and_cors` helper (Governor inner, CORS outer) —
238            // see its doc comment in config.rs for why the order is
239            // load-bearing, and the `governor_*_cors_*` regression tests
240            // there, which exercise this SAME helper so a swap can no longer
241            // regress in only one call site.
242            wrap_governor_and_cors(
243                with_body_limits(App::new()).app_data(app_state.clone()),
244                &rate_limiter,
245                apply_rate_limit,
246                &bind_addr,
247                port,
248            )
249            .wrap(build_security_headers())
250            // Immutable long-cache for hashed `/assets/*` so a proxy/CDN
251            // (e.g. Cloudflare tunnel) caches chunks at the edge instead of
252            // round-tripping each one to origin (#preload-error fix).
253            .wrap(actix_web::middleware::from_fn(
254                crate::config::add_asset_cache_headers,
255            ))
256            .configure(configure_routes_with_rate_limiting)
257            .service(
258                fs::Files::new("/", static_dir.clone())
259                    .index_file("index.html")
260                    .prefer_utf8(true)
261                    .disable_content_disposition()
262                    .disable_content_disposition(),
263            )
264        })
265        .workers(DEFAULT_WORKER_COUNT);
266
267        // Fail-fast: build the rustls config before binding; `None` → unchanged
268        // plaintext `.bind()` path. #181.
269        let server = match tls {
270            Some(tls) => server
271                .bind_rustls_0_23(&listen_addr, build_rustls_config(tls)?)
272                .map_err(|e| format!("Failed to bind TLS server: {e}"))?,
273            None => server
274                .bind(&listen_addr)
275                .map_err(|e| format!("Failed to bind server: {e}"))?,
276        }
277        .run();
278
279        let server_handle = tokio::spawn(async move {
280            tokio::select! {
281                result = server => {
282                    if let Err(e) = result {
283                        error!("Server error: {}", e);
284                    }
285                }
286                _ = &mut shutdown_rx => {
287                    info!("Web service shutdown signal received");
288                }
289            }
290        });
291
292        self.shutdown_tx = Some(shutdown_tx);
293        self.server_handle = Some(server_handle);
294
295        let scheme = if tls.is_some() { "https" } else { "http" };
296        info!(
297            "Web service with static frontend started successfully on {scheme}://{}:{}",
298            bind_for_log, port
299        );
300        Ok(())
301    }
302
303    /// Stop the web service
304    pub async fn stop(&mut self) -> Result<(), String> {
305        if let Some(shutdown_tx) = self.shutdown_tx.take() {
306            if shutdown_tx.send(()).is_err() {
307                error!("Failed to send shutdown signal");
308                return Err("Error sending shutdown signal".to_string());
309            }
310
311            if let Some(handle) = self.server_handle.take() {
312                if let Err(e) = handle.await {
313                    error!("Error waiting for server shutdown: {}", e);
314                    return Err(format!("Error waiting for server shutdown: {}", e));
315                }
316            }
317
318            // Gracefully stop AppState-owned background tasks — the #47 MCP-proxy
319            // reconnect supervisor (via its cancellation token) and the MCP servers.
320            // Without this the token was wired but never cancelled, so the
321            // supervisor only died at process exit. #119.
322            if let Some(state) = self.app_state.take() {
323                state.shutdown().await;
324            }
325
326            info!("Web service stopped successfully");
327        }
328
329        Ok(())
330    }
331
332    /// Check if the web service is currently running
333    pub fn is_running(&self) -> bool {
334        self.server_handle.is_some()
335    }
336
337    /// Get the port the web service is running on
338    pub fn port(&self) -> u16 {
339        self.port
340    }
341}
342
343impl Drop for WebService {
344    fn drop(&mut self) {
345        if let Some(shutdown_tx) = self.shutdown_tx.take() {
346            let _ = shutdown_tx.send(());
347        }
348        // Drop can't run the async shutdown(), but cancelling the MCP-proxy
349        // supervisor's token is synchronous — so a WebService dropped without an
350        // explicit stop() still tears down the reconnect loop. (The async MCP
351        // server cleanup is left to process exit on this fallback path.) #119.
352        if let Some(state) = self.app_state.take() {
353            state.mcp_proxy_shutdown.cancel();
354        }
355    }
356}
357
358#[cfg(test)]
359mod tests {
360    use super::*;
361
362    /// #252: the shared [`with_body_limits`] factory must raise actix's default
363    /// ~2MB JSON limit to 25MB on every serve path. This is the limit the
364    /// desktop/embedded serve path previously lacked (rejecting inline-image
365    /// chat requests with 413 while production accepted them). The control app
366    /// built without `with_body_limits` rejects the same body, proving the
367    /// factory — not a default — is doing the work, so this test fails without
368    /// the shared-factory change.
369    #[actix_web::test]
370    async fn shared_factory_raises_json_body_limit() {
371        use actix_web::{http::StatusCode, test, HttpResponse};
372
373        async fn echo(_body: web::Json<serde_json::Value>) -> HttpResponse {
374            HttpResponse::Ok().finish()
375        }
376
377        // ~3MB JSON body: over actix's ~2MB default, under the 25MB shared limit.
378        let big = "x".repeat(3 * 1024 * 1024);
379        let payload = serde_json::json!({ "data": big });
380
381        // Via the shared factory (what every serve path now funnels through).
382        let app =
383            test::init_service(with_body_limits(App::new()).route("/echo", web::post().to(echo)))
384                .await;
385        let resp = test::call_service(
386            &app,
387            test::TestRequest::post()
388                .uri("/echo")
389                .set_json(&payload)
390                .to_request(),
391        )
392        .await;
393        assert_eq!(
394            resp.status(),
395            StatusCode::OK,
396            "shared factory must accept a >2MB JSON body (#252)"
397        );
398
399        // Control: a plain `App` (actix's ~2MB default) rejects the same body.
400        let app_default = test::init_service(App::new().route("/echo", web::post().to(echo))).await;
401        let resp_default = test::call_service(
402            &app_default,
403            test::TestRequest::post()
404                .uri("/echo")
405                .set_json(&payload)
406                .to_request(),
407        )
408        .await;
409        assert_eq!(
410            resp_default.status(),
411            StatusCode::PAYLOAD_TOO_LARGE,
412            "actix's default JSON limit must reject a >2MB body"
413        );
414    }
415
416    /// #169 part 3: the no-limiter `start_with_bind` path must REFUSE a
417    /// non-loopback bind (which would run unthrottled on a routable interface),
418    /// while still accepting a loopback bind. Without the guard, this call would
419    /// happily start an unthrottled network server (returning `Ok`), so the
420    /// `is_err()` assertion fails without the fix.
421    #[tokio::test]
422    async fn start_with_bind_rejects_nonloopback_without_limiter() {
423        let home = tempfile::TempDir::new().expect("tempdir");
424        let mut service = WebService::new(home.path().to_path_buf());
425
426        // Port 0 → OS-assigned ephemeral port, so a false "Ok" would actually bind.
427        let err = service
428            .start_with_bind(0, "0.0.0.0")
429            .await
430            .expect_err("non-loopback bind without a limiter must be rejected (#169 part 3)");
431        assert!(
432            err.contains("without a rate limiter"),
433            "rejection must explain the missing limiter, got: {err}"
434        );
435        assert!(
436            !service.is_running(),
437            "the guard must reject BEFORE the server starts"
438        );
439
440        // Sanity: loopback is still accepted (desktop behavior preserved).
441        service
442            .start_with_bind(0, "127.0.0.1")
443            .await
444            .expect("loopback bind must still start without a limiter");
445        service.stop().await.expect("web service stops");
446    }
447
448    /// #119 e2e: WebService::stop() must cancel the AppState-owned MCP-proxy
449    /// reconnect supervisor's token, so it terminates on server stop rather than
450    /// leaking until process exit.
451    #[tokio::test]
452    async fn stop_cancels_mcp_proxy_supervisor_token() {
453        let home = tempfile::TempDir::new().expect("tempdir");
454        let mut service = WebService::new(home.path().to_path_buf());
455        // Port 0 -> OS-assigned ephemeral port (no conflict).
456        service
457            .start_with_bind(0, "127.0.0.1")
458            .await
459            .expect("web service starts");
460
461        // Capture the supervisor's cancellation token while the service runs.
462        let token = service
463            .app_state
464            .as_ref()
465            .expect("app_state retained after start")
466            .mcp_proxy_shutdown
467            .clone();
468        assert!(
469            !token.is_cancelled(),
470            "supervisor token is live while the service runs"
471        );
472
473        service.stop().await.expect("web service stops");
474
475        assert!(
476            token.is_cancelled(),
477            "stop() must cancel the MCP-proxy supervisor token so it terminates"
478        );
479    }
480}