Skip to main content

bamboo_server/server/
entrypoints.rs

1use std::path::{Path, PathBuf};
2
3use actix_files as fs;
4use actix_web::{
5    dev::{fn_service, ServiceRequest, ServiceResponse},
6    web, App, HttpResponse, HttpServer,
7};
8use tracing::{error, info};
9
10use super::listeners::{build_bind_listeners, build_desktop_listeners, resolve_worker_count};
11use super::tls::build_rustls_config;
12use crate::app_state::AppState;
13use crate::config::{
14    build_cors, build_rate_limiter, build_security_headers, is_loopback_bind,
15    require_limiter_for_nonloopback, wrap_governor_and_cors,
16};
17use crate::routes::{configure_routes, configure_routes_with_rate_limiting};
18use crate::services::frontend_package::{
19    ensure_current_frontend_dir_in, has_embedded_frontend_package, resolve_frontend_package_path,
20};
21use bamboo_config::TlsConfig;
22
23/// Whether `path` belongs to bamboo's API surface (as opposed to a SPA
24/// frontend route the static-file fallback should serve `index.html` for).
25///
26/// Shared by both SPA-fallback closures below (desktop + production/Docker
27/// serve paths) so the allow-list can't drift between them — previously each
28/// closure hand-duplicated this list and neither included `/v2/` (the pairing/
29/// device/WS-multiplex prefix), so an unmatched `/v2/*` path would silently
30/// fall through to `index.html` instead of a real 404. #251 (finding 7).
31fn is_api_path(path: &str) -> bool {
32    path.starts_with("/api/")
33        || path.starts_with("/v1/")
34        || path.starts_with("/v2/")
35        || path.starts_with("/openai/")
36        || path.starts_with("/anthropic/")
37        || path.starts_with("/gemini/")
38}
39
40fn canonicalize_static_dir(path: &Path) -> Result<PathBuf, String> {
41    let canonicalized = path
42        .canonicalize()
43        .map_err(|e| format!("Static directory not found: {:?}: {}", path, e))?;
44    if !canonicalized.is_dir() {
45        return Err(format!(
46            "Static path is not a directory: {}",
47            canonicalized.display()
48        ));
49    }
50    Ok(canonicalized)
51}
52
53fn resolve_runtime_static_dir(
54    bamboo_home_dir: &Path,
55    configured_static_dir: Option<PathBuf>,
56) -> Result<Option<PathBuf>, String> {
57    if let Some(path) = configured_static_dir {
58        let canonicalized = canonicalize_static_dir(&path)?;
59        info!(
60            "Serving static files from configured directory: {:?}",
61            canonicalized
62        );
63        return Ok(Some(canonicalized));
64    }
65
66    if !has_embedded_frontend_package() && resolve_frontend_package_path(None).is_none() {
67        info!("No embedded or sidecar Bamboo frontend package found; starting API-only server");
68        return Ok(None);
69    }
70
71    let status = ensure_current_frontend_dir_in(bamboo_home_dir, None)
72        .map_err(|e| format!("Failed to prepare Bamboo frontend assets: {e}"))?;
73    let frontend_dir = canonicalize_static_dir(&status.frontend_dir)?;
74
75    if status.refreshed {
76        info!(
77            "Refreshed Bamboo frontend assets at {} (version {}, hash {})",
78            frontend_dir.display(),
79            status.bundled_manifest.frontend_version,
80            status.bundled_manifest.bundle_hash
81        );
82    } else {
83        info!(
84            "Using existing Bamboo frontend assets at {} (version {}, hash {})",
85            frontend_dir.display(),
86            status.bundled_manifest.frontend_version,
87            status.bundled_manifest.bundle_hash
88        );
89    }
90
91    Ok(Some(frontend_dir))
92}
93
94/// Run the unified server in desktop mode (localhost only, no rate limiting)
95///
96/// This is the simplest mode for desktop applications:
97/// - Binds to 127.0.0.1 only (safe, localhost-only)
98/// - No rate limiting (assumes single user)
99/// - No security headers (development mode)
100///
101/// # Arguments
102/// * `bamboo_home_dir` - Bamboo home directory containing all app data (config, sessions, skills, etc.)
103///   Equivalent to `${HOME}/.bamboo` in standard installations.
104/// * `port` - Port to listen on
105pub async fn run(bamboo_home_dir: PathBuf, port: u16) -> Result<(), String> {
106    run_with_tls(bamboo_home_dir, port, None).await
107}
108
109/// Like [`run`], but terminates TLS itself when `tls` is `Some` (#181).
110///
111/// Desktop loopback callers pass `None` and get the unchanged plaintext path.
112pub async fn run_with_tls(
113    bamboo_home_dir: PathBuf,
114    port: u16,
115    tls: Option<TlsConfig>,
116) -> Result<(), String> {
117    info!("Starting unified server in desktop mode...");
118
119    let static_dir = resolve_runtime_static_dir(&bamboo_home_dir, None)?;
120
121    let app_state = web::Data::new(
122        AppState::new(bamboo_home_dir.clone())
123            .await
124            .map_err(|e| format!("Failed to initialize app state: {e}"))?,
125    );
126    // Retained for graceful shutdown after the server stops — the `move` factory
127    // below consumes `app_state`. #119.
128    let app_state_for_shutdown = app_state.clone();
129    let workers = resolve_worker_count();
130
131    let app_factory = move || {
132        // Body limits (and any future shared app config) come from the one shared
133        // factory used by every serve path, so a desktop chat request with an
134        // inline image isn't rejected with 413 while production accepts it — the
135        // paths can no longer drift apart (#252).
136        let mut app = super::web_service::with_body_limits(App::new())
137            .app_data(app_state.clone())
138            .wrap(build_cors("127.0.0.1", port))
139            // Immutable long-cache for hashed `/assets/*` (parity with the web
140            // service path; harmless on localhost, useful when this binary is
141            // fronted by a proxy/CDN).
142            .wrap(actix_web::middleware::from_fn(
143                crate::config::add_asset_cache_headers,
144            ))
145            .configure(configure_routes); // No rate limiting for desktop mode
146
147        if let Some(static_path) = &static_dir {
148            let index_file = static_path.join("index.html");
149            info!("Serving static files from: {:?}", static_path);
150            app = app.service(
151                fs::Files::new("/", static_path)
152                    .index_file("index.html")
153                    .prefer_utf8(true)
154                    .disable_content_disposition()
155                    .default_handler(fn_service(move |req: ServiceRequest| {
156                        let index_file = index_file.clone();
157                        async move {
158                            let path = req.path().to_string();
159                            if is_api_path(&path) {
160                                let response = HttpResponse::NotFound().finish();
161                                return Ok(ServiceResponse::new(req.into_parts().0, response));
162                            }
163
164                            let (http_req, _) = req.into_parts();
165                            match actix_files::NamedFile::open_async(index_file).await {
166                                Ok(file) => Ok(ServiceResponse::new(
167                                    http_req.clone(),
168                                    file.into_response(&http_req),
169                                )),
170                                Err(_) => Ok(ServiceResponse::new(
171                                    http_req,
172                                    HttpResponse::NotFound().finish(),
173                                )),
174                            }
175                        }
176                    })),
177            );
178        }
179
180        app
181    };
182
183    // Fail-fast: when TLS is configured, build the rustls config up front so a
184    // bad/missing cert refuses startup instead of silently falling back to
185    // plaintext. `None` → unchanged plaintext path.
186    let rustls_cfg = match &tls {
187        Some(tls) => Some(build_rustls_config(tls)?),
188        None => None,
189    };
190
191    let listeners = build_desktop_listeners(port)?;
192
193    let mut http = HttpServer::new(app_factory).workers(workers);
194    for (idx, listener) in listeners.into_iter().enumerate() {
195        http = match &rustls_cfg {
196            Some(cfg) => http
197                .listen_rustls_0_23(listener, cfg.clone())
198                .map_err(|e| format!("Failed to attach TLS listener #{idx}: {e}"))?,
199            None => http
200                .listen(listener)
201                .map_err(|e| format!("Failed to attach listener #{idx}: {e}"))?,
202        };
203    }
204
205    let server = http.run();
206
207    let scheme = if rustls_cfg.is_some() {
208        "https"
209    } else {
210        "http"
211    };
212    info!("Unified server running on {scheme}://127.0.0.1:{port}");
213
214    let result = server.await;
215
216    // The server has stopped (actix handles SIGINT/SIGTERM, returning here on an
217    // intended stop). Gracefully stop AppState-owned background tasks — the #47
218    // MCP-proxy reconnect supervisor + MCP servers — instead of leaking them until
219    // process exit. Runs on both the clean and error exit paths. #119.
220    app_state_for_shutdown.shutdown().await;
221
222    if let Err(e) = result {
223        error!("Server error: {}", e);
224        return Err(format!("Server error: {e}"));
225    }
226
227    Ok(())
228}
229
230/// Run the unified server with custom bind address (Docker/production mode)
231///
232/// Production mode features:
233/// - Custom bind address (0.0.0.0 for Docker, custom for standalone)
234/// - Rate limiting enabled (10 req/sec, burst 20)
235/// - Security headers enabled
236/// - Request size limits (25MB JSON, 30MB payload)
237///
238/// # Arguments
239/// * `bamboo_home_dir` - Bamboo home directory containing all app data (config, sessions, skills, etc.)
240///   Equivalent to `${HOME}/.bamboo` in standard installations.
241/// * `port` - Port to listen on
242/// * `bind` - Bind address (127.0.0.1, 0.0.0.0, or custom)
243pub async fn run_with_bind(bamboo_home_dir: PathBuf, port: u16, bind: &str) -> Result<(), String> {
244    run_with_bind_and_static(bamboo_home_dir, port, bind, None).await
245}
246
247/// Like [`run_with_bind`], but terminates TLS itself when `tls` is `Some` (#181).
248pub async fn run_with_bind_tls(
249    bamboo_home_dir: PathBuf,
250    port: u16,
251    bind: &str,
252    tls: Option<TlsConfig>,
253) -> Result<(), String> {
254    run_with_bind_and_static_tls(bamboo_home_dir, port, bind, None, tls).await
255}
256
257/// Run the unified server with custom bind address and static file serving
258///
259/// Production mode with frontend serving:
260/// - All features from run_with_bind()
261/// - Static file serving for frontend (index.html, assets, etc.)
262///
263/// # Arguments
264/// * `bamboo_home_dir` - Bamboo home directory containing all app data (config, sessions, skills, etc.)
265///   Equivalent to `${HOME}/.bamboo` in standard installations.
266/// * `port` - Port to listen on
267/// * `bind` - Bind address (127.0.0.1 for localhost, 0.0.0.0 for all interfaces)
268/// * `static_dir` - Optional directory containing built frontend files
269///
270/// # Example
271/// ```bash
272/// # Docker mode (serve frontend)
273/// bamboo serve --port 9562 --bind 0.0.0.0 --static-dir /app/static
274///
275/// # Standalone production mode (serve frontend)
276/// bamboo serve --port 9562 --static-dir ./dist
277/// ```
278pub async fn run_with_bind_and_static(
279    bamboo_home_dir: PathBuf,
280    port: u16,
281    bind: &str,
282    static_dir: Option<PathBuf>,
283) -> Result<(), String> {
284    run_with_bind_and_static_tls(bamboo_home_dir, port, bind, static_dir, None).await
285}
286
287/// Like [`run_with_bind_and_static`], but terminates TLS itself when `tls` is
288/// `Some` (#181). When `None`, the plaintext `.listen()` path is unchanged.
289pub async fn run_with_bind_and_static_tls(
290    bamboo_home_dir: PathBuf,
291    port: u16,
292    bind: &str,
293    static_dir: Option<PathBuf>,
294    tls: Option<TlsConfig>,
295) -> Result<(), String> {
296    info!("Starting unified server on {}:{}...", bind, port);
297
298    // Loopback/desktop binds skip the limiter (see is_loopback_bind): the local
299    // frontend bursts ~45 asset requests on load. Network binds stay throttled.
300    let apply_rate_limit = !is_loopback_bind(bind);
301    // Bind-aware guard: a non-loopback bind must have the limiter applied (it is,
302    // below). Defends against a future edit that disables it for a routable bind.
303    // Checked BEFORE the async app-state/static-dir setup so a bad bind fails fast,
304    // consistent with `start_with_bind_tls`. #169, #428.
305    require_limiter_for_nonloopback(bind, apply_rate_limit)?;
306
307    let static_dir = resolve_runtime_static_dir(&bamboo_home_dir, static_dir)?;
308
309    let app_state = web::Data::new(
310        AppState::new(bamboo_home_dir.clone())
311            .await
312            .map_err(|e| format!("Failed to initialize app state: {e}"))?,
313    );
314    // Retained for graceful shutdown after the server stops — the `move` factory
315    // below consumes `app_state`. #119.
316    let app_state_for_shutdown = app_state.clone();
317    let workers = resolve_worker_count();
318
319    // Per-IP rate limiter for the network-exposed production server (#13). Built
320    // once and shared (Clone) across workers. It is wrapped so that a throttled
321    // request is rejected with 429 before any handler work runs.
322    let rate_limiter = build_rate_limiter();
323    let bind_for_cors = bind.to_string();
324    let app_factory = move || {
325        // Request size limits (base64-image chats) come from the one shared
326        // factory used by every serve path — same limits everywhere, no drift
327        // (#252).
328        // WRAP ORDER (#169 part 2, #428): Governor + CORS are applied together,
329        // in the fixed order enforced by the shared `wrap_governor_and_cors`
330        // helper (Governor inner, CORS outer) — see its doc comment in
331        // config.rs for why the order is load-bearing, and the
332        // `governor_*_cors_*` regression tests there, which exercise this SAME
333        // helper so a swap can no longer regress in only one call site.
334        let mut app = wrap_governor_and_cors(
335            super::web_service::with_body_limits(App::new()).app_data(app_state.clone()),
336            &rate_limiter,
337            apply_rate_limit,
338            &bind_for_cors,
339            port,
340        )
341        .wrap(build_security_headers())
342        // Immutable long-cache for hashed `/assets/*` (Docker / `serve -s`
343        // path, fronted by a proxy/CDN — same fix as the other factories).
344        .wrap(actix_web::middleware::from_fn(
345            crate::config::add_asset_cache_headers,
346        ))
347        .configure(configure_routes_with_rate_limiting);
348
349        if let Some(static_path) = &static_dir {
350            let index_file = static_path.join("index.html");
351            info!("Serving static files from: {:?}", static_path);
352            app = app.service(
353                fs::Files::new("/", static_path)
354                    .index_file("index.html")
355                    .prefer_utf8(true)
356                    .disable_content_disposition()
357                    .default_handler(fn_service(move |req: ServiceRequest| {
358                        let index_file = index_file.clone();
359                        async move {
360                            let path = req.path().to_string();
361                            if is_api_path(&path) {
362                                let response = HttpResponse::NotFound().finish();
363                                return Ok(ServiceResponse::new(req.into_parts().0, response));
364                            }
365
366                            let (http_req, _) = req.into_parts();
367                            match actix_files::NamedFile::open_async(index_file).await {
368                                Ok(file) => Ok(ServiceResponse::new(
369                                    http_req.clone(),
370                                    file.into_response(&http_req),
371                                )),
372                                Err(_) => Ok(ServiceResponse::new(
373                                    http_req,
374                                    HttpResponse::NotFound().finish(),
375                                )),
376                            }
377                        }
378                    })),
379            );
380        }
381
382        app
383    };
384
385    // Fail-fast: build the rustls config before binding so a bad/missing cert
386    // refuses startup rather than silently downgrading to plaintext. `None` →
387    // unchanged plaintext path (desktop/loopback behavior preserved). #181.
388    let rustls_cfg = match &tls {
389        Some(tls) => Some(build_rustls_config(tls)?),
390        None => None,
391    };
392
393    let listeners = build_bind_listeners(bind, port)?;
394
395    let mut http = HttpServer::new(app_factory).workers(workers);
396    for (idx, listener) in listeners.into_iter().enumerate() {
397        http = match &rustls_cfg {
398            Some(cfg) => http
399                .listen_rustls_0_23(listener, cfg.clone())
400                .map_err(|e| format!("Failed to attach TLS listener #{idx}: {e}"))?,
401            None => http
402                .listen(listener)
403                .map_err(|e| format!("Failed to attach listener #{idx}: {e}"))?,
404        };
405    }
406
407    let server = http.run();
408
409    let scheme = if rustls_cfg.is_some() {
410        "https"
411    } else {
412        "http"
413    };
414    info!("Unified server running on {scheme}://{}:{}", bind, port);
415
416    let result = server.await;
417
418    // Gracefully stop AppState-owned background tasks (the #47 MCP-proxy reconnect
419    // supervisor + MCP servers) once the server stops, instead of leaking them
420    // until process exit. Runs on both the clean and error exit paths. #119.
421    app_state_for_shutdown.shutdown().await;
422
423    if let Err(e) = result {
424        error!("Server error: {}", e);
425        return Err(format!("Server error: {e}"));
426    }
427
428    Ok(())
429}
430
431#[cfg(test)]
432mod tests {
433    use super::*;
434    use tempfile::tempdir;
435
436    #[test]
437    fn resolve_runtime_static_dir_uses_configured_dir_when_present() {
438        let bamboo_home = tempdir().unwrap();
439        let static_dir = tempdir().unwrap();
440        std::fs::write(static_dir.path().join("index.html"), "ok").unwrap();
441
442        let resolved =
443            resolve_runtime_static_dir(bamboo_home.path(), Some(static_dir.path().to_path_buf()))
444                .expect("configured static dir should resolve")
445                .expect("configured static dir should be returned");
446
447        assert_eq!(resolved, static_dir.path().canonicalize().unwrap());
448    }
449
450    #[test]
451    fn is_api_path_covers_every_registered_version_prefix() {
452        // #251 (finding 7): every prefix `routes::configure_routes` actually
453        // registers must be recognized here, or an unmatched sub-path under it
454        // would wrongly fall through to the SPA `index.html` instead of a 404.
455        for api_path in [
456            "/api/v1/sessions",
457            "/v1/bamboo/workflows",
458            "/v2/unknown",
459            "/openai/v1/models",
460            "/anthropic/v1/messages",
461            "/gemini/v1beta/models",
462        ] {
463            assert!(is_api_path(api_path), "{api_path} must be an API path");
464        }
465
466        for frontend_path in ["/", "/index.html", "/assets/app.js", "/settings"] {
467            assert!(
468                !is_api_path(frontend_path),
469                "{frontend_path} must NOT be treated as an API path"
470            );
471        }
472    }
473
474    /// #512: every prior route/allow-list test (`routes::tests`,
475    /// `is_api_path_covers_every_registered_version_prefix` above) exercises
476    /// `configure_routes`/`is_api_path` in isolation — never together, and
477    /// never with the `actix_files::Files` SPA-fallback service actually
478    /// mounted the way the real `run`/`run_with_bind_and_static_tls` server
479    /// factories mount it. That gap matters: `Files::new("/", ..)` is
480    /// registered LAST (after `.configure(configure_routes)`), and its
481    /// `default_handler` is the ONLY thing standing between an unmatched path
482    /// and the SPA `index.html`. A route-table assertion can't see that
483    /// interaction; only a real `test::call_service` against the exact same
484    /// composed `App` can. This test builds that composition (routes +
485    /// Files-with-`is_api_path`-gated-fallback, mirroring the closures in
486    /// `run_with_tls`/`run_with_bind_and_static_tls` above) and drives every
487    /// native-API prefix plus the SPA fallback through it end-to-end.
488    #[actix_web::test]
489    async fn full_app_assembly_forwards_every_api_prefix_and_still_serves_spa_fallback() {
490        use actix_web::dev::{fn_service, ServiceRequest, ServiceResponse};
491        use actix_web::http::StatusCode;
492        use actix_web::{test, App};
493
494        let static_dir = tempdir().unwrap();
495        let index_file = static_dir.path().join("index.html");
496        std::fs::write(&index_file, "<html>spa-fallback-marker</html>").unwrap();
497
498        let app = test::init_service(
499            App::new()
500                .configure(crate::routes::configure_routes)
501                .service(
502                    fs::Files::new("/", static_dir.path())
503                        .index_file("index.html")
504                        .default_handler(fn_service(move |req: ServiceRequest| {
505                            let index_file = index_file.clone();
506                            async move {
507                                let path = req.path().to_string();
508                                if is_api_path(&path) {
509                                    let response = HttpResponse::NotFound().finish();
510                                    return Ok(ServiceResponse::new(req.into_parts().0, response));
511                                }
512                                let (http_req, _) = req.into_parts();
513                                match actix_files::NamedFile::open_async(index_file).await {
514                                    Ok(file) => Ok(ServiceResponse::new(
515                                        http_req.clone(),
516                                        file.into_response(&http_req),
517                                    )),
518                                    Err(_) => Ok(ServiceResponse::new(
519                                        http_req,
520                                        HttpResponse::NotFound().finish(),
521                                    )),
522                                }
523                            }
524                        })),
525                ),
526        )
527        .await;
528
529        // Every native-API prefix (legacy /v1 alias, canonical /api/v1, the
530        // /api/v1-nested session sub-resource alias, /v2, and the three
531        // provider-forwarding prefixes) must still reach ITS OWN handler, not
532        // get swallowed by the Files fallback registered after it.
533        for (method, uri) in [
534            ("GET", "/v1/bamboo/workflows"),
535            ("GET", "/api/v1/bamboo/workflows"),
536            ("GET", "/api/v1/sessions"),
537            ("GET", "/api/v1/sessions/does-not-exist/history"),
538            ("GET", "/api/v1/history/does-not-exist"),
539            ("GET", "/v2/stream"),
540            ("GET", "/openai/v1/models"),
541            ("GET", "/anthropic/v1/models"),
542            ("GET", "/gemini/v1beta/models"),
543        ] {
544            let req = test::TestRequest::with_uri(uri)
545                .method(method.parse().unwrap())
546                .to_request();
547            let resp = test::call_service(&app, req).await;
548            assert_ne!(
549                resp.status(),
550                StatusCode::NOT_FOUND,
551                "{method} {uri} must be routed to its real handler, not 404 via the SPA fallback"
552            );
553        }
554
555        // The two flat/nested session-history aliases must resolve to the SAME
556        // handler (both "session not found", not one 404-route/one 404-session).
557        let flat = test::TestRequest::get()
558            .uri("/api/v1/history/does-not-exist")
559            .to_request();
560        let flat_status = test::call_service(&app, flat).await.status();
561        let nested = test::TestRequest::get()
562            .uri("/api/v1/sessions/does-not-exist/history")
563            .to_request();
564        let nested_status = test::call_service(&app, nested).await.status();
565        assert_eq!(
566            flat_status, nested_status,
567            "flat and nested history aliases must behave identically"
568        );
569
570        // An unmatched path UNDER a real API prefix must 404 for real — it must
571        // NOT fall through to index.html just because Files is mounted at "/".
572        let bogus_api_req = test::TestRequest::get()
573            .uri("/api/v1/totally-not-a-real-route")
574            .to_request();
575        let bogus_api_resp = test::call_service(&app, bogus_api_req).await;
576        assert_eq!(
577            bogus_api_resp.status(),
578            StatusCode::NOT_FOUND,
579            "an unmatched /api/v1/* path must 404, not silently serve the SPA"
580        );
581
582        // A genuine frontend deep-link (not under any API prefix) must serve
583        // index.html via the SPA fallback, proving the fallback still works
584        // once every API scope above it has had its shot at matching first.
585        let spa_req = test::TestRequest::get()
586            .uri("/chat/some-deep-route")
587            .to_request();
588        let spa_resp = test::call_service(&app, spa_req).await;
589        assert_eq!(spa_resp.status(), StatusCode::OK);
590        let body = actix_web::body::to_bytes(spa_resp.into_body())
591            .await
592            .unwrap();
593        assert!(
594            String::from_utf8_lossy(&body).contains("spa-fallback-marker"),
595            "non-API deep link must serve the SPA index.html"
596        );
597    }
598}