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