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};
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
12/// Request body size limits, applied on EVERY serve path so the desktop/embedded
13/// server accepts the same payloads (e.g. an inline-image chat request) as the
14/// production server, instead of falling back to actix's ~2MB JSON / 256KB
15/// payload defaults and rejecting them with 413 (#252).
16pub(crate) const MAX_JSON_BODY_BYTES: usize = 25 * 1024 * 1024;
17pub(crate) const MAX_PAYLOAD_BYTES: usize = 30 * 1024 * 1024;
18
19/// Install the shared request body-size limits ([`MAX_JSON_BODY_BYTES`] /
20/// [`MAX_PAYLOAD_BYTES`]) onto an actix `App`.
21///
22/// EVERY serve path — desktop (`run_with_tls`), production
23/// (`run_with_bind_and_static_tls`), and `WebService::start*` — funnels its
24/// `App::new()` through this one helper, so the limits can no longer drift
25/// between paths. That drift was the #252 bug: the desktop/embedded server set
26/// neither limit and rejected an inline-image chat request with 413 while the
27/// production server (which set them) accepted it. Callers layer their own app
28/// data, middleware, routes, and static files on top of the returned `App`.
29pub(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
51/// Manageable web service with start/stop lifecycle
52///
53/// Use this when you need to programmatically control the server lifecycle,
54/// such as in tests or embedded scenarios.
55pub struct WebService {
56    shutdown_tx: Option<oneshot::Sender<()>>,
57    server_handle: Option<tokio::task::JoinHandle<()>>,
58    /// Handle to the running server's [`AppState`], retained so [`WebService::stop`]
59    /// /[`Drop`] can gracefully stop AppState-owned background tasks (the #47
60    /// MCP-proxy reconnect supervisor) instead of leaking them until process exit.
61    /// #119.
62    app_state: Option<web::Data<AppState>>,
63    /// Bamboo home directory containing all application data (config, sessions, skills, etc.)
64    bamboo_home_dir: PathBuf,
65    port: u16,
66}
67
68impl WebService {
69    /// Create a new WebService instance
70    ///
71    /// # Arguments
72    /// * `bamboo_home_dir` - Bamboo home directory (e.g., `${HOME}/.bamboo` or custom path)
73    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, // Default port
80        }
81    }
82
83    /// Start the web service on the specified port using the default localhost bind.
84    pub async fn start(&mut self, port: u16) -> Result<(), String> {
85        self.start_with_bind(port, "127.0.0.1").await
86    }
87
88    /// Start the web service on the specified port and bind address.
89    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    /// Start the web service, terminating TLS itself when `tls` is `Some` (#181).
94    ///
95    /// `None` keeps the plaintext HTTP/1.1 path unchanged (desktop loopback).
96    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        // This serve path installs NO rate limiter (API-only WebService). Refuse a
108        // non-loopback bind so it can't silently run unthrottled on a routable
109        // interface — that would re-open the #13 DoS surface. Loopback binds stay
110        // allowed (desktop behavior preserved). #169 part 3.
111        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        // Retain a handle so stop()/Drop can stop AppState-owned background tasks. #119
122        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) // No rate limiting for WebService
131        };
132
133        // Fail-fast: build the rustls config before binding; `None` → unchanged
134        // plaintext HTTP/1.1 path. #181.
135        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    /// Start the web service on the specified port and bind address, serving static files
165    /// alongside the API routes.
166    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    /// Like [`WebService::start_with_bind_and_static`], terminating TLS itself
177    /// when `tls` is `Some` (#181). `None` keeps the plaintext HTTP/1.1 path unchanged.
178    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        // Per-IP rate limiter (#13) will be applied below for non-loopback binds.
191        // SKIPPED for loopback/desktop binds: the local frontend legitimately
192        // bursts ~45 hashed `/assets/*` requests on load and would otherwise be
193        // throttled to a 429 (chunk import fails / "Too many requests").
194        let apply_rate_limit = !is_loopback_bind(bind);
195        // Bind-aware guard: a non-loopback bind must have the limiter applied
196        // (it is here for non-loopback binds). Belt-and-suspenders against a
197        // future edit that flips `apply_rate_limit` off for a routable bind.
198        // Checked BEFORE the async app-state setup so a bad bind fails fast,
199        // consistent with `start_with_bind_tls`. #169, #428.
200        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        // Retain a handle so stop()/Drop can stop AppState-owned background tasks. #119
221        self.app_state = Some(app_state.clone());
222        // Per-IP rate limiter for the network-exposed production server (#13).
223        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 ORDER (#169 part 2, #428): Governor + CORS are applied
229            // together, in the fixed order enforced by the shared
230            // `wrap_governor_and_cors` helper (Governor inner, CORS outer) —
231            // see its doc comment in config.rs for why the order is
232            // load-bearing, and the `governor_*_cors_*` regression tests
233            // there, which exercise this SAME helper so a swap can no longer
234            // regress in only one call site.
235            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            // Immutable long-cache for hashed `/assets/*` so a proxy/CDN
244            // (e.g. Cloudflare tunnel) caches chunks at the edge instead of
245            // round-tripping each one to origin (#preload-error fix).
246            .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        // Fail-fast: build the rustls config before binding; `None` → unchanged
260        // plaintext HTTP/1.1 path. #181.
261        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    /// Stop the web service
291    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            // Gracefully stop AppState-owned background tasks — the #47 MCP-proxy
306            // reconnect supervisor (via its cancellation token) and the MCP servers.
307            // Without this the token was wired but never cancelled, so the
308            // supervisor only died at process exit. #119.
309            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    /// Check if the web service is currently running
320    pub fn is_running(&self) -> bool {
321        self.server_handle.is_some()
322    }
323
324    /// Get the port the web service is running on
325    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        // Drop can't run the async shutdown(), but cancelling the MCP-proxy
336        // supervisor's token is synchronous — so a WebService dropped without an
337        // explicit stop() still tears down the reconnect loop. (The async MCP
338        // server cleanup is left to process exit on this fallback path.) #119.
339        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    /// #252: the shared [`with_body_limits`] factory must raise actix's default
350    /// ~2MB JSON limit to 25MB on every serve path. This is the limit the
351    /// desktop/embedded serve path previously lacked (rejecting inline-image
352    /// chat requests with 413 while production accepted them). The control app
353    /// built without `with_body_limits` rejects the same body, proving the
354    /// factory — not a default — is doing the work, so this test fails without
355    /// the shared-factory change.
356    #[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        // ~3MB JSON body: over actix's ~2MB default, under the 25MB shared limit.
365        let big = "x".repeat(3 * 1024 * 1024);
366        let payload = serde_json::json!({ "data": big });
367
368        // Via the shared factory (what every serve path now funnels through).
369        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        // Control: a plain `App` (actix's ~2MB default) rejects the same body.
387        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    /// #169 part 3: the no-limiter `start_with_bind` path must REFUSE a
404    /// non-loopback bind (which would run unthrottled on a routable interface),
405    /// while still accepting a loopback bind. Without the guard, this call would
406    /// happily start an unthrottled network server (returning `Ok`), so the
407    /// `is_err()` assertion fails without the fix.
408    #[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        // Port 0 → OS-assigned ephemeral port, so a false "Ok" would actually bind.
414        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        // Sanity: loopback is still accepted (desktop behavior preserved).
428        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    /// #119 e2e: WebService::stop() must cancel the AppState-owned MCP-proxy
436    /// reconnect supervisor's token, so it terminates on server stop rather than
437    /// leaking until process exit.
438    #[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        // Port 0 -> OS-assigned ephemeral port (no conflict).
443        service
444            .start_with_bind(0, "127.0.0.1")
445            .await
446            .expect("web service starts");
447
448        // Capture the supervisor's cancellation token while the service runs.
449        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}