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,
46};
47use crate::routes::{configure_routes, configure_routes_with_rate_limiting};
48use actix_governor::Governor;
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 `.bind()` 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 listen_addr = format!("{bind}:{port}");
125        let bind_for_log = bind_addr.clone();
126
127        let server = HttpServer::new(move || {
128            with_body_limits(App::new())
129                .app_data(app_state.clone())
130                .wrap(build_cors(&bind_addr, port))
131                .configure(configure_routes) // No rate limiting for WebService
132        })
133        .workers(DEFAULT_WORKER_COUNT);
134
135        // Fail-fast: build the rustls config before binding; `None` → unchanged
136        // plaintext `.bind()` path. #181.
137        let server = match tls {
138            Some(tls) => server
139                .bind_rustls_0_23(&listen_addr, build_rustls_config(tls)?)
140                .map_err(|e| format!("Failed to bind TLS server: {e}"))?,
141            None => server
142                .bind(&listen_addr)
143                .map_err(|e| format!("Failed to bind server: {e}"))?,
144        }
145        .run();
146
147        let server_handle = tokio::spawn(async move {
148            tokio::select! {
149                result = server => {
150                    if let Err(e) = result {
151                        error!("Server error: {}", e);
152                    }
153                }
154                _ = &mut shutdown_rx => {
155                    info!("Web service shutdown signal received");
156                }
157            }
158        });
159
160        self.shutdown_tx = Some(shutdown_tx);
161        self.server_handle = Some(server_handle);
162
163        let scheme = if tls.is_some() { "https" } else { "http" };
164        info!(
165            "Web service started successfully on {scheme}://{}:{}",
166            bind_for_log, port
167        );
168        Ok(())
169    }
170
171    /// Start the web service on the specified port and bind address, serving static files
172    /// alongside the API routes.
173    pub async fn start_with_bind_and_static(
174        &mut self,
175        port: u16,
176        bind: &str,
177        static_dir: PathBuf,
178    ) -> Result<(), String> {
179        self.start_with_bind_and_static_tls(port, bind, static_dir, None)
180            .await
181    }
182
183    /// Like [`WebService::start_with_bind_and_static`], terminating TLS itself
184    /// when `tls` is `Some` (#181). `None` keeps the plaintext path unchanged.
185    pub async fn start_with_bind_and_static_tls(
186        &mut self,
187        port: u16,
188        bind: &str,
189        static_dir: PathBuf,
190        tls: Option<&TlsConfig>,
191    ) -> Result<(), String> {
192        info!("Starting web service with static frontend...");
193        if self.server_handle.is_some() {
194            return Err("Web service is already running".to_string());
195        }
196
197        let (shutdown_tx, mut shutdown_rx) = oneshot::channel::<()>();
198        self.port = port;
199
200        let static_dir = static_dir
201            .canonicalize()
202            .map_err(|e| format!("Static directory not found: {:?}: {}", static_dir, e))?;
203        if !static_dir.is_dir() {
204            return Err(format!(
205                "Static path is not a directory: {}",
206                static_dir.display()
207            ));
208        }
209
210        let app_state = web::Data::new(
211            AppState::new(self.bamboo_home_dir.clone())
212                .await
213                .map_err(|e| format!("Failed to initialize app state: {e}"))?,
214        );
215        // Retain a handle so stop()/Drop can stop AppState-owned background tasks. #119
216        self.app_state = Some(app_state.clone());
217        // Per-IP rate limiter for the network-exposed production server (#13).
218        // SKIPPED for loopback/desktop binds: the local frontend legitimately
219        // bursts ~45 hashed `/assets/*` requests on load and would otherwise be
220        // throttled to a 429 (chunk import fails / "Too many requests").
221        let rate_limiter = build_rate_limiter();
222        let apply_rate_limit = !is_loopback_bind(bind);
223        // Bind-aware guard: a non-loopback bind must have the limiter applied
224        // (it is here for non-loopback binds). Belt-and-suspenders against a
225        // future edit that flips `apply_rate_limit` off for a routable bind. #169.
226        require_limiter_for_nonloopback(bind, apply_rate_limit)?;
227        let bind_addr = bind.to_string();
228        let listen_addr = format!("{bind}:{port}");
229        let bind_for_log = bind_addr.clone();
230
231        let server = HttpServer::new(move || {
232            with_body_limits(App::new())
233                .app_data(app_state.clone())
234                // WRAP ORDER (#169 part 2): Governor is registered BEFORE `build_cors`,
235                // so CORS is the OUTER layer and Governor the INNER one. This is
236                // load-bearing: (1) a genuine CORS preflight is answered by CORS and
237                // never reaches Governor, so it isn't counted against the bucket; and
238                // (2) a 429 from Governor propagates back OUT through CORS, which adds
239                // `Access-Control-Allow-Origin` so a browser sees a readable 429 rather
240                // than an opaque network error. Reversing these two wraps regresses both
241                // (see the config.rs `governor_inside_cors_*` / `governor_outside_cors_*`
242                // tests).
243                .wrap(actix_web::middleware::Condition::new(
244                    apply_rate_limit,
245                    Governor::new(&rate_limiter),
246                ))
247                .wrap(build_cors(&bind_addr, port))
248                .wrap(build_security_headers())
249                // Immutable long-cache for hashed `/assets/*` so a proxy/CDN
250                // (e.g. Cloudflare tunnel) caches chunks at the edge instead of
251                // round-tripping each one to origin (#preload-error fix).
252                .wrap(actix_web::middleware::from_fn(
253                    crate::config::add_asset_cache_headers,
254                ))
255                .configure(configure_routes_with_rate_limiting)
256                .service(
257                    fs::Files::new("/", static_dir.clone())
258                        .index_file("index.html")
259                        .prefer_utf8(true)
260                        .disable_content_disposition()
261                        .disable_content_disposition(),
262                )
263        })
264        .workers(DEFAULT_WORKER_COUNT);
265
266        // Fail-fast: build the rustls config before binding; `None` → unchanged
267        // plaintext `.bind()` path. #181.
268        let server = match tls {
269            Some(tls) => server
270                .bind_rustls_0_23(&listen_addr, build_rustls_config(tls)?)
271                .map_err(|e| format!("Failed to bind TLS server: {e}"))?,
272            None => server
273                .bind(&listen_addr)
274                .map_err(|e| format!("Failed to bind server: {e}"))?,
275        }
276        .run();
277
278        let server_handle = tokio::spawn(async move {
279            tokio::select! {
280                result = server => {
281                    if let Err(e) = result {
282                        error!("Server error: {}", e);
283                    }
284                }
285                _ = &mut shutdown_rx => {
286                    info!("Web service shutdown signal received");
287                }
288            }
289        });
290
291        self.shutdown_tx = Some(shutdown_tx);
292        self.server_handle = Some(server_handle);
293
294        let scheme = if tls.is_some() { "https" } else { "http" };
295        info!(
296            "Web service with static frontend started successfully on {scheme}://{}:{}",
297            bind_for_log, port
298        );
299        Ok(())
300    }
301
302    /// Stop the web service
303    pub async fn stop(&mut self) -> Result<(), String> {
304        if let Some(shutdown_tx) = self.shutdown_tx.take() {
305            if shutdown_tx.send(()).is_err() {
306                error!("Failed to send shutdown signal");
307                return Err("Error sending shutdown signal".to_string());
308            }
309
310            if let Some(handle) = self.server_handle.take() {
311                if let Err(e) = handle.await {
312                    error!("Error waiting for server shutdown: {}", e);
313                    return Err(format!("Error waiting for server shutdown: {}", e));
314                }
315            }
316
317            // Gracefully stop AppState-owned background tasks — the #47 MCP-proxy
318            // reconnect supervisor (via its cancellation token) and the MCP servers.
319            // Without this the token was wired but never cancelled, so the
320            // supervisor only died at process exit. #119.
321            if let Some(state) = self.app_state.take() {
322                state.shutdown().await;
323            }
324
325            info!("Web service stopped successfully");
326        }
327
328        Ok(())
329    }
330
331    /// Check if the web service is currently running
332    pub fn is_running(&self) -> bool {
333        self.server_handle.is_some()
334    }
335
336    /// Get the port the web service is running on
337    pub fn port(&self) -> u16 {
338        self.port
339    }
340}
341
342impl Drop for WebService {
343    fn drop(&mut self) {
344        if let Some(shutdown_tx) = self.shutdown_tx.take() {
345            let _ = shutdown_tx.send(());
346        }
347        // Drop can't run the async shutdown(), but cancelling the MCP-proxy
348        // supervisor's token is synchronous — so a WebService dropped without an
349        // explicit stop() still tears down the reconnect loop. (The async MCP
350        // server cleanup is left to process exit on this fallback path.) #119.
351        if let Some(state) = self.app_state.take() {
352            state.mcp_proxy_shutdown.cancel();
353        }
354    }
355}
356
357#[cfg(test)]
358mod tests {
359    use super::*;
360
361    /// #252: the shared [`with_body_limits`] factory must raise actix's default
362    /// ~2MB JSON limit to 25MB on every serve path. This is the limit the
363    /// desktop/embedded serve path previously lacked (rejecting inline-image
364    /// chat requests with 413 while production accepted them). The control app
365    /// built without `with_body_limits` rejects the same body, proving the
366    /// factory — not a default — is doing the work, so this test fails without
367    /// the shared-factory change.
368    #[actix_web::test]
369    async fn shared_factory_raises_json_body_limit() {
370        use actix_web::{http::StatusCode, test, HttpResponse};
371
372        async fn echo(_body: web::Json<serde_json::Value>) -> HttpResponse {
373            HttpResponse::Ok().finish()
374        }
375
376        // ~3MB JSON body: over actix's ~2MB default, under the 25MB shared limit.
377        let big = "x".repeat(3 * 1024 * 1024);
378        let payload = serde_json::json!({ "data": big });
379
380        // Via the shared factory (what every serve path now funnels through).
381        let app =
382            test::init_service(with_body_limits(App::new()).route("/echo", web::post().to(echo)))
383                .await;
384        let resp = test::call_service(
385            &app,
386            test::TestRequest::post()
387                .uri("/echo")
388                .set_json(&payload)
389                .to_request(),
390        )
391        .await;
392        assert_eq!(
393            resp.status(),
394            StatusCode::OK,
395            "shared factory must accept a >2MB JSON body (#252)"
396        );
397
398        // Control: a plain `App` (actix's ~2MB default) rejects the same body.
399        let app_default = test::init_service(App::new().route("/echo", web::post().to(echo))).await;
400        let resp_default = test::call_service(
401            &app_default,
402            test::TestRequest::post()
403                .uri("/echo")
404                .set_json(&payload)
405                .to_request(),
406        )
407        .await;
408        assert_eq!(
409            resp_default.status(),
410            StatusCode::PAYLOAD_TOO_LARGE,
411            "actix's default JSON limit must reject a >2MB body"
412        );
413    }
414
415    /// #169 part 3: the no-limiter `start_with_bind` path must REFUSE a
416    /// non-loopback bind (which would run unthrottled on a routable interface),
417    /// while still accepting a loopback bind. Without the guard, this call would
418    /// happily start an unthrottled network server (returning `Ok`), so the
419    /// `is_err()` assertion fails without the fix.
420    #[tokio::test]
421    async fn start_with_bind_rejects_nonloopback_without_limiter() {
422        let home = tempfile::TempDir::new().expect("tempdir");
423        let mut service = WebService::new(home.path().to_path_buf());
424
425        // Port 0 → OS-assigned ephemeral port, so a false "Ok" would actually bind.
426        let err = service
427            .start_with_bind(0, "0.0.0.0")
428            .await
429            .expect_err("non-loopback bind without a limiter must be rejected (#169 part 3)");
430        assert!(
431            err.contains("without a rate limiter"),
432            "rejection must explain the missing limiter, got: {err}"
433        );
434        assert!(
435            !service.is_running(),
436            "the guard must reject BEFORE the server starts"
437        );
438
439        // Sanity: loopback is still accepted (desktop behavior preserved).
440        service
441            .start_with_bind(0, "127.0.0.1")
442            .await
443            .expect("loopback bind must still start without a limiter");
444        service.stop().await.expect("web service stops");
445    }
446
447    /// #119 e2e: WebService::stop() must cancel the AppState-owned MCP-proxy
448    /// reconnect supervisor's token, so it terminates on server stop rather than
449    /// leaking until process exit.
450    #[tokio::test]
451    async fn stop_cancels_mcp_proxy_supervisor_token() {
452        let home = tempfile::TempDir::new().expect("tempdir");
453        let mut service = WebService::new(home.path().to_path_buf());
454        // Port 0 -> OS-assigned ephemeral port (no conflict).
455        service
456            .start_with_bind(0, "127.0.0.1")
457            .await
458            .expect("web service starts");
459
460        // Capture the supervisor's cancellation token while the service runs.
461        let token = service
462            .app_state
463            .as_ref()
464            .expect("app_state retained after start")
465            .mcp_proxy_shutdown
466            .clone();
467        assert!(
468            !token.is_cancelled(),
469            "supervisor token is live while the service runs"
470        );
471
472        service.stop().await.expect("web service stops");
473
474        assert!(
475            token.is_cancelled(),
476            "stop() must cancel the MCP-proxy supervisor token so it terminates"
477        );
478    }
479}