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,
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 actix_governor::Governor;
22use bamboo_config::TlsConfig;
23
24fn canonicalize_static_dir(path: &Path) -> Result<PathBuf, String> {
25    let canonicalized = path
26        .canonicalize()
27        .map_err(|e| format!("Static directory not found: {:?}: {}", path, e))?;
28    if !canonicalized.is_dir() {
29        return Err(format!(
30            "Static path is not a directory: {}",
31            canonicalized.display()
32        ));
33    }
34    Ok(canonicalized)
35}
36
37fn resolve_runtime_static_dir(
38    bamboo_home_dir: &Path,
39    configured_static_dir: Option<PathBuf>,
40) -> Result<Option<PathBuf>, String> {
41    if let Some(path) = configured_static_dir {
42        let canonicalized = canonicalize_static_dir(&path)?;
43        info!(
44            "Serving static files from configured directory: {:?}",
45            canonicalized
46        );
47        return Ok(Some(canonicalized));
48    }
49
50    if !has_embedded_frontend_package() && resolve_frontend_package_path(None).is_none() {
51        info!("No embedded or sidecar Bamboo frontend package found; starting API-only server");
52        return Ok(None);
53    }
54
55    let status = ensure_current_frontend_dir_in(bamboo_home_dir, None)
56        .map_err(|e| format!("Failed to prepare Bamboo frontend assets: {e}"))?;
57    let frontend_dir = canonicalize_static_dir(&status.frontend_dir)?;
58
59    if status.refreshed {
60        info!(
61            "Refreshed Bamboo frontend assets at {} (version {}, hash {})",
62            frontend_dir.display(),
63            status.bundled_manifest.frontend_version,
64            status.bundled_manifest.bundle_hash
65        );
66    } else {
67        info!(
68            "Using existing Bamboo frontend assets at {} (version {}, hash {})",
69            frontend_dir.display(),
70            status.bundled_manifest.frontend_version,
71            status.bundled_manifest.bundle_hash
72        );
73    }
74
75    Ok(Some(frontend_dir))
76}
77
78/// Run the unified server in desktop mode (localhost only, no rate limiting)
79///
80/// This is the simplest mode for desktop applications:
81/// - Binds to 127.0.0.1 only (safe, localhost-only)
82/// - No rate limiting (assumes single user)
83/// - No security headers (development mode)
84///
85/// # Arguments
86/// * `bamboo_home_dir` - Bamboo home directory containing all app data (config, sessions, skills, etc.)
87///   Equivalent to `${HOME}/.bamboo` in standard installations.
88/// * `port` - Port to listen on
89pub async fn run(bamboo_home_dir: PathBuf, port: u16) -> Result<(), String> {
90    run_with_tls(bamboo_home_dir, port, None).await
91}
92
93/// Like [`run`], but terminates TLS itself when `tls` is `Some` (#181).
94///
95/// Desktop loopback callers pass `None` and get the unchanged plaintext path.
96pub async fn run_with_tls(
97    bamboo_home_dir: PathBuf,
98    port: u16,
99    tls: Option<TlsConfig>,
100) -> Result<(), String> {
101    info!("Starting unified server in desktop mode...");
102
103    let static_dir = resolve_runtime_static_dir(&bamboo_home_dir, None)?;
104
105    let app_state = web::Data::new(
106        AppState::new(bamboo_home_dir.clone())
107            .await
108            .map_err(|e| format!("Failed to initialize app state: {e}"))?,
109    );
110    // Retained for graceful shutdown after the server stops — the `move` factory
111    // below consumes `app_state`. #119.
112    let app_state_for_shutdown = app_state.clone();
113    let workers = resolve_worker_count();
114
115    let app_factory = move || {
116        // Body limits (and any future shared app config) come from the one shared
117        // factory used by every serve path, so a desktop chat request with an
118        // inline image isn't rejected with 413 while production accepts it — the
119        // paths can no longer drift apart (#252).
120        let mut app = super::web_service::with_body_limits(App::new())
121            .app_data(app_state.clone())
122            .wrap(build_cors("127.0.0.1", port))
123            // Immutable long-cache for hashed `/assets/*` (parity with the web
124            // service path; harmless on localhost, useful when this binary is
125            // fronted by a proxy/CDN).
126            .wrap(actix_web::middleware::from_fn(
127                crate::config::add_asset_cache_headers,
128            ))
129            .configure(configure_routes); // No rate limiting for desktop mode
130
131        if let Some(static_path) = &static_dir {
132            let index_file = static_path.join("index.html");
133            info!("Serving static files from: {:?}", static_path);
134            app = app.service(
135                fs::Files::new("/", static_path)
136                    .index_file("index.html")
137                    .prefer_utf8(true)
138                    .disable_content_disposition()
139                    .default_handler(fn_service(move |req: ServiceRequest| {
140                        let index_file = index_file.clone();
141                        async move {
142                            let path = req.path().to_string();
143                            if path.starts_with("/api/")
144                                || path.starts_with("/v1/")
145                                || path.starts_with("/openai/")
146                                || path.starts_with("/anthropic/")
147                                || path.starts_with("/gemini/")
148                            {
149                                let response = HttpResponse::NotFound().finish();
150                                return Ok(ServiceResponse::new(req.into_parts().0, response));
151                            }
152
153                            let (http_req, _) = req.into_parts();
154                            match actix_files::NamedFile::open_async(index_file).await {
155                                Ok(file) => Ok(ServiceResponse::new(
156                                    http_req.clone(),
157                                    file.into_response(&http_req),
158                                )),
159                                Err(_) => Ok(ServiceResponse::new(
160                                    http_req,
161                                    HttpResponse::NotFound().finish(),
162                                )),
163                            }
164                        }
165                    })),
166            );
167        }
168
169        app
170    };
171
172    // Fail-fast: when TLS is configured, build the rustls config up front so a
173    // bad/missing cert refuses startup instead of silently falling back to
174    // plaintext. `None` → unchanged plaintext path.
175    let rustls_cfg = match &tls {
176        Some(tls) => Some(build_rustls_config(tls)?),
177        None => None,
178    };
179
180    let listeners = build_desktop_listeners(port)?;
181
182    let mut http = HttpServer::new(app_factory).workers(workers);
183    for (idx, listener) in listeners.into_iter().enumerate() {
184        http = match &rustls_cfg {
185            Some(cfg) => http
186                .listen_rustls_0_23(listener, cfg.clone())
187                .map_err(|e| format!("Failed to attach TLS listener #{idx}: {e}"))?,
188            None => http
189                .listen(listener)
190                .map_err(|e| format!("Failed to attach listener #{idx}: {e}"))?,
191        };
192    }
193
194    let server = http.run();
195
196    let scheme = if rustls_cfg.is_some() {
197        "https"
198    } else {
199        "http"
200    };
201    info!("Unified server running on {scheme}://127.0.0.1:{port}");
202
203    let result = server.await;
204
205    // The server has stopped (actix handles SIGINT/SIGTERM, returning here on an
206    // intended stop). Gracefully stop AppState-owned background tasks — the #47
207    // MCP-proxy reconnect supervisor + MCP servers — instead of leaking them until
208    // process exit. Runs on both the clean and error exit paths. #119.
209    app_state_for_shutdown.shutdown().await;
210
211    if let Err(e) = result {
212        error!("Server error: {}", e);
213        return Err(format!("Server error: {e}"));
214    }
215
216    Ok(())
217}
218
219/// Run the unified server with custom bind address (Docker/production mode)
220///
221/// Production mode features:
222/// - Custom bind address (0.0.0.0 for Docker, custom for standalone)
223/// - Rate limiting enabled (10 req/sec, burst 20)
224/// - Security headers enabled
225/// - Request size limits (25MB JSON, 30MB payload)
226///
227/// # Arguments
228/// * `bamboo_home_dir` - Bamboo home directory containing all app data (config, sessions, skills, etc.)
229///   Equivalent to `${HOME}/.bamboo` in standard installations.
230/// * `port` - Port to listen on
231/// * `bind` - Bind address (127.0.0.1, 0.0.0.0, or custom)
232pub async fn run_with_bind(bamboo_home_dir: PathBuf, port: u16, bind: &str) -> Result<(), String> {
233    run_with_bind_and_static(bamboo_home_dir, port, bind, None).await
234}
235
236/// Like [`run_with_bind`], but terminates TLS itself when `tls` is `Some` (#181).
237pub async fn run_with_bind_tls(
238    bamboo_home_dir: PathBuf,
239    port: u16,
240    bind: &str,
241    tls: Option<TlsConfig>,
242) -> Result<(), String> {
243    run_with_bind_and_static_tls(bamboo_home_dir, port, bind, None, tls).await
244}
245
246/// Run the unified server with custom bind address and static file serving
247///
248/// Production mode with frontend serving:
249/// - All features from run_with_bind()
250/// - Static file serving for frontend (index.html, assets, etc.)
251///
252/// # Arguments
253/// * `bamboo_home_dir` - Bamboo home directory containing all app data (config, sessions, skills, etc.)
254///   Equivalent to `${HOME}/.bamboo` in standard installations.
255/// * `port` - Port to listen on
256/// * `bind` - Bind address (127.0.0.1 for localhost, 0.0.0.0 for all interfaces)
257/// * `static_dir` - Optional directory containing built frontend files
258///
259/// # Example
260/// ```bash
261/// # Docker mode (serve frontend)
262/// bamboo serve --port 9562 --bind 0.0.0.0 --static-dir /app/static
263///
264/// # Standalone production mode (serve frontend)
265/// bamboo serve --port 9562 --static-dir ./dist
266/// ```
267pub async fn run_with_bind_and_static(
268    bamboo_home_dir: PathBuf,
269    port: u16,
270    bind: &str,
271    static_dir: Option<PathBuf>,
272) -> Result<(), String> {
273    run_with_bind_and_static_tls(bamboo_home_dir, port, bind, static_dir, None).await
274}
275
276/// Like [`run_with_bind_and_static`], but terminates TLS itself when `tls` is
277/// `Some` (#181). When `None`, the plaintext `.listen()` path is unchanged.
278pub async fn run_with_bind_and_static_tls(
279    bamboo_home_dir: PathBuf,
280    port: u16,
281    bind: &str,
282    static_dir: Option<PathBuf>,
283    tls: Option<TlsConfig>,
284) -> Result<(), String> {
285    info!("Starting unified server on {}:{}...", bind, port);
286
287    let static_dir = resolve_runtime_static_dir(&bamboo_home_dir, static_dir)?;
288
289    let app_state = web::Data::new(
290        AppState::new(bamboo_home_dir.clone())
291            .await
292            .map_err(|e| format!("Failed to initialize app state: {e}"))?,
293    );
294    // Retained for graceful shutdown after the server stops — the `move` factory
295    // below consumes `app_state`. #119.
296    let app_state_for_shutdown = app_state.clone();
297    let workers = resolve_worker_count();
298
299    // Per-IP rate limiter for the network-exposed production server (#13). Built
300    // once and shared (Clone) across workers. It is wrapped so that a throttled
301    // request is rejected with 429 before any handler work runs.
302    let rate_limiter = build_rate_limiter();
303    // Loopback/desktop binds skip the limiter (see is_loopback_bind): the local
304    // frontend bursts ~45 asset requests on load. Network binds stay throttled.
305    let apply_rate_limit = !is_loopback_bind(bind);
306    // Bind-aware guard: a non-loopback bind must have the limiter applied (it is
307    // here). Defends against a future edit that disables it for a routable bind. #169.
308    require_limiter_for_nonloopback(bind, apply_rate_limit)?;
309    let bind_for_cors = bind.to_string();
310    let app_factory = move || {
311        // Request size limits (base64-image chats) come from the one shared
312        // factory used by every serve path — same limits everywhere, no drift
313        // (#252).
314        let mut app = super::web_service::with_body_limits(App::new())
315            .app_data(app_state.clone())
316            // WRAP ORDER (#169 part 2): Governor is registered BEFORE `build_cors`,
317            // making CORS the OUTER layer and Governor the INNER one. This ordering
318            // is load-bearing: (1) a genuine CORS preflight is short-circuited by
319            // CORS and never reaches Governor, so it is not counted against the
320            // bucket; and (2) a 429 from Governor propagates back OUT through CORS,
321            // which adds `Access-Control-Allow-Origin` so a browser receives a
322            // readable 429 instead of an opaque network error. Reversing the two
323            // wraps regresses both (see config.rs `governor_*_cors_*` tests).
324            .wrap(actix_web::middleware::Condition::new(
325                apply_rate_limit,
326                Governor::new(&rate_limiter),
327            ))
328            .wrap(build_cors(&bind_for_cors, port))
329            .wrap(build_security_headers())
330            // Immutable long-cache for hashed `/assets/*` (Docker / `serve -s`
331            // path, fronted by a proxy/CDN — same fix as the other factories).
332            .wrap(actix_web::middleware::from_fn(
333                crate::config::add_asset_cache_headers,
334            ))
335            .configure(configure_routes_with_rate_limiting);
336
337        if let Some(static_path) = &static_dir {
338            let index_file = static_path.join("index.html");
339            info!("Serving static files from: {:?}", static_path);
340            app = app.service(
341                fs::Files::new("/", static_path)
342                    .index_file("index.html")
343                    .prefer_utf8(true)
344                    .disable_content_disposition()
345                    .default_handler(fn_service(move |req: ServiceRequest| {
346                        let index_file = index_file.clone();
347                        async move {
348                            let path = req.path().to_string();
349                            if path.starts_with("/api/")
350                                || path.starts_with("/v1/")
351                                || path.starts_with("/openai/")
352                                || path.starts_with("/anthropic/")
353                                || path.starts_with("/gemini/")
354                            {
355                                let response = HttpResponse::NotFound().finish();
356                                return Ok(ServiceResponse::new(req.into_parts().0, response));
357                            }
358
359                            let (http_req, _) = req.into_parts();
360                            match actix_files::NamedFile::open_async(index_file).await {
361                                Ok(file) => Ok(ServiceResponse::new(
362                                    http_req.clone(),
363                                    file.into_response(&http_req),
364                                )),
365                                Err(_) => Ok(ServiceResponse::new(
366                                    http_req,
367                                    HttpResponse::NotFound().finish(),
368                                )),
369                            }
370                        }
371                    })),
372            );
373        }
374
375        app
376    };
377
378    // Fail-fast: build the rustls config before binding so a bad/missing cert
379    // refuses startup rather than silently downgrading to plaintext. `None` →
380    // unchanged plaintext path (desktop/loopback behavior preserved). #181.
381    let rustls_cfg = match &tls {
382        Some(tls) => Some(build_rustls_config(tls)?),
383        None => None,
384    };
385
386    let listeners = build_bind_listeners(bind, port)?;
387
388    let mut http = HttpServer::new(app_factory).workers(workers);
389    for (idx, listener) in listeners.into_iter().enumerate() {
390        http = match &rustls_cfg {
391            Some(cfg) => http
392                .listen_rustls_0_23(listener, cfg.clone())
393                .map_err(|e| format!("Failed to attach TLS listener #{idx}: {e}"))?,
394            None => http
395                .listen(listener)
396                .map_err(|e| format!("Failed to attach listener #{idx}: {e}"))?,
397        };
398    }
399
400    let server = http.run();
401
402    let scheme = if rustls_cfg.is_some() {
403        "https"
404    } else {
405        "http"
406    };
407    info!("Unified server running on {scheme}://{}:{}", bind, port);
408
409    let result = server.await;
410
411    // Gracefully stop AppState-owned background tasks (the #47 MCP-proxy reconnect
412    // supervisor + MCP servers) once the server stops, instead of leaking them
413    // until process exit. Runs on both the clean and error exit paths. #119.
414    app_state_for_shutdown.shutdown().await;
415
416    if let Err(e) = result {
417        error!("Server error: {}", e);
418        return Err(format!("Server error: {e}"));
419    }
420
421    Ok(())
422}
423
424#[cfg(test)]
425mod tests {
426    use super::*;
427    use tempfile::tempdir;
428
429    #[test]
430    fn resolve_runtime_static_dir_uses_configured_dir_when_present() {
431        let bamboo_home = tempdir().unwrap();
432        let static_dir = tempdir().unwrap();
433        std::fs::write(static_dir.path().join("index.html"), "ok").unwrap();
434
435        let resolved =
436            resolve_runtime_static_dir(bamboo_home.path(), Some(static_dir.path().to_path_buf()))
437                .expect("configured static dir should resolve")
438                .expect("configured static dir should be returned");
439
440        assert_eq!(resolved, static_dir.path().canonicalize().unwrap());
441    }
442}