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