feat(server): make web ui optional

Add a server-side web.enabled toggle and CLI overrides so Fabro can run
with API and health only while disabling the embedded SPA, browser auth
routes, and web-only helper endpoints.
This commit is contained in:
Bryan Helmkamp 2026-04-08 16:22:41 -04:00
parent eb4b71cafa
commit b101bfd5d5
No known key found for this signature in database
12 changed files with 296 additions and 23 deletions

View file

@ -44,6 +44,7 @@ ca = "/etc/fabro/tls/ca.pem"
type = "Jwt"
[web]
enabled = true
url = "https://fabro-web.example.com"
[web.auth]
@ -103,6 +104,8 @@ Several `settings.toml` settings can be overridden via `fabro server start` flag
| Flag | Default | Description |
|---|---|---|
| `--bind` | `~/.fabro/fabro.sock` | Address to bind: `IP` or `IP:port` for TCP, or a path for Unix socket |
| `--web` | enabled | Enable the embedded web UI, browser auth routes, and web-only helper endpoints |
| `--no-web` | disabled | Disable the embedded web UI, browser auth routes, and web-only helper endpoints |
| `--foreground` | — | Run in the foreground instead of daemonizing |
| `--model` | — | Override default LLM model |
| `--provider` | — | Override default LLM provider |
@ -113,6 +116,17 @@ Several `settings.toml` settings can be overridden via `fabro server start` flag
CLI flags take precedence over `settings.toml` values. See [Run Configuration — Precedence](/execution/run-configuration#precedence) for the full resolution order.
### `[web]` section
Control the embedded SPA and browser-oriented routes.
| Key | Description | Default |
|---|---|---|
| `enabled` | Serve the embedded SPA, `/auth/*`, and the web-only helper endpoints under `/api/v1` | `true` |
| `url` | External web UI URL used for OAuth redirects | `http://localhost:3000` |
When `enabled = false`, the server still exposes the machine API and `/health`, but `/`, `/auth/*`, SPA client routes, `/api/v1/auth/me`, `/api/v1/setup/*`, and `/api/v1/demo/toggle` all return `404`.
### Run defaults
The `[llm]`, `[setup]`, `[sandbox]`, `[checkpoint]`, and `[vars]` sections in `settings.toml` act as defaults for every run.

View file

@ -4653,6 +4653,9 @@ components:
description: Web UI configuration.
type: object
properties:
enabled:
type: boolean
description: Whether the embedded web UI and browser-oriented routes are enabled.
url:
type: string
description: Web UI URL.

View file

@ -328,6 +328,7 @@ Start the Fabro server daemon. By default, the server launches as a background p
fabro server start # background daemon on Unix socket
fabro server start --bind 127.0.0.1 # TCP on 32276, or random port if 32276 is busy
fabro server start --bind 127.0.0.1:8080 # TCP on a specific port
fabro server start --no-web # API and /health only
fabro server start --foreground # blocking foreground mode
fabro server start --sandbox daytona --max-concurrent-runs 4
```
@ -335,6 +336,8 @@ fabro server start --sandbox daytona --max-concurrent-runs 4
| Flag | Description | Default |
|---|---|---|
| `--bind <ADDR>` | Address to bind: `IP` or `IP:port` for TCP, or a path for Unix socket | `~/.fabro/fabro.sock` |
| `--web` | Enable the embedded web UI, browser auth routes, and web-only helper endpoints | Enabled |
| `--no-web` | Disable the embedded web UI, browser auth routes, and web-only helper endpoints | Disabled |
| `--foreground` | Run in the foreground instead of daemonizing | — |
| `--model <MODEL>` | Override default LLM model | — |
| `--provider <PROVIDER>` | Override default LLM provider | — |
@ -345,6 +348,8 @@ fabro server start --sandbox daytona --max-concurrent-runs 4
Demo mode is per-request: send the `X-Fabro-Demo: 1` header to get static demo data with auth disabled.
When `--no-web` is set, the server still exposes the machine API under `/api/v1` and `/health`, but it returns `404` for `/`, `/auth/*`, SPA client routes, and the web-only helper endpoints under `/api/v1`.
## `fabro server stop`
Stop the running server daemon. Sends SIGTERM and waits for graceful shutdown, escalating to SIGKILL after the timeout.

View file

@ -69,6 +69,8 @@ fn ensure_server_running_with_bind(
let serve_args = ServeArgs {
bind: None,
web: false,
no_web: false,
model: None,
provider: None,
dry_run: false,
@ -207,6 +209,12 @@ fn execute_daemon(
if let Some(ref provider) = serve_args.provider {
cmd.args(["--provider", provider]);
}
if serve_args.web {
cmd.arg("--web");
}
if serve_args.no_web {
cmd.arg("--no-web");
}
if serve_args.dry_run {
cmd.arg("--dry-run");
}

View file

@ -35,14 +35,18 @@ fn help() {
Address to bind to (IP or IP:port for TCP, or path containing / for Unix socket)
--no-upgrade-check
Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true]
--model <MODEL>
Override default LLM model
--quiet
Suppress non-essential output [env: FABRO_QUIET=]
--provider <PROVIDER>
Override default LLM provider
--web
Enable the embedded web UI and browser auth routes
--no-web
Disable the embedded web UI, browser auth routes, and web-only helper endpoints
--verbose
Enable verbose output [env: FABRO_VERBOSE=]
--model <MODEL>
Override default LLM model
--provider <PROVIDER>
Override default LLM provider
--dry-run
Execute with simulated LLM backend
--sandbox <SANDBOX>
@ -188,8 +192,11 @@ fn start_with_tcp_host_only_bind_warns_and_falls_back_when_default_port_is_unava
let context = test_context!();
let storage_root = isolated_storage_dir();
let storage_dir = storage_root.path().join("storage");
let occupied = std::net::TcpListener::bind(("127.0.0.1", 32276))
.expect("test requires default TCP port 32276 to be free before occupying it");
let occupied = match std::net::TcpListener::bind(("127.0.0.1", 32276)) {
Ok(listener) => Some(listener),
Err(error) if error.kind() == std::io::ErrorKind::AddrInUse => None,
Err(error) => panic!("failed to bind default TCP port 32276: {error}"),
};
let mut filters = context.filters();
filters.push((r"pid \d+".to_string(), "pid [PID]".to_string()));

View file

@ -134,6 +134,7 @@ impl TryFrom<GitConfig> for GitSettings {
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Serialize, crate::Combine)]
pub struct WebConfig {
pub enabled: Option<bool>,
pub url: Option<String>,
pub auth: Option<AuthConfig>,
}
@ -145,6 +146,7 @@ fn default_web_url() -> String {
impl From<WebConfig> for WebSettings {
fn from(value: WebConfig) -> Self {
Self {
enabled: value.enabled.unwrap_or(true),
url: value.url.unwrap_or_else(default_web_url),
auth: value.auth.map(Into::into).unwrap_or_default(),
}

View file

@ -1495,6 +1495,7 @@ mod settings {
storage_dir: Some("/home/fabro/.fabro".into()),
max_concurrent_runs: Some(10),
web: Some(WebSettings {
enabled: true,
url: "https://fabro.example.com".into(),
auth: AuthSettings {
provider: AuthProvider::Github,

View file

@ -24,8 +24,8 @@ use crate::github_webhooks::WebhookManager;
use crate::jwt_auth::{AuthMode, AuthStrategy, resolve_auth_mode_with_lookup};
use crate::secret_store::SecretStore;
use crate::server::{
build_app_state_with_path, build_router, reconcile_incomplete_runs_on_startup,
shutdown_active_workers, spawn_scheduler,
build_app_state_with_path, reconcile_incomplete_runs_on_startup, shutdown_active_workers,
spawn_scheduler,
};
use crate::tls::{ClientAuth, build_rustls_config, serve_tls_with_shutdown};
use fabro_llm::client::Client as LlmClient;
@ -47,6 +47,14 @@ pub struct ServeArgs {
#[arg(long)]
pub bind: Option<String>,
/// Enable the embedded web UI and browser auth routes
#[arg(long, conflicts_with = "no_web")]
pub web: bool,
/// Disable the embedded web UI, browser auth routes, and web-only helper endpoints
#[arg(long, conflicts_with = "web")]
pub no_web: bool,
/// Override default LLM model
#[arg(long)]
pub model: Option<String>,
@ -85,6 +93,9 @@ fn apply_serve_overrides(base: &Settings, args: &ServeArgs, dry_run_mode: bool)
if dry_run_mode {
settings.dry_run = Some(true);
}
if args.web || args.no_web {
settings.web.get_or_insert_default().enabled = args.web;
}
if let Some(ref model) = args.model {
settings.llm.get_or_insert_default().model = Some(model.clone());
}
@ -250,6 +261,13 @@ where
.unwrap_or(5);
(auth_mode, client_auth, max_concurrent_runs)
};
let web_enabled = shared_settings
.read()
.expect("config lock poisoned")
.web
.as_ref()
.map(|web| web.enabled)
.unwrap_or(true);
let store_path = storage.store_dir();
let object_store = build_object_store(&store_path)?;
@ -281,7 +299,11 @@ where
);
}
spawn_scheduler(Arc::clone(&state));
let router = build_router(Arc::clone(&state), auth_mode);
let router = crate::server::build_router_with_options(
Arc::clone(&state),
auth_mode,
crate::server::RouterOptions { web_enabled },
);
let bind_request = match args.bind {
Some(ref s) => bind::parse_bind(s)?,
@ -631,6 +653,8 @@ mod tests {
provider: None,
dry_run: false,
sandbox: None,
web: false,
no_web: false,
max_concurrent_runs: None,
config: None,
};
@ -644,6 +668,52 @@ mod tests {
);
}
#[test]
fn apply_runtime_settings_enables_web_from_cli_flag() {
let base: Settings = toml::from_str(
r#"
[web]
enabled = false
"#,
)
.unwrap();
let args = ServeArgs {
bind: None,
model: None,
provider: None,
dry_run: false,
sandbox: None,
web: true,
no_web: false,
max_concurrent_runs: None,
config: None,
};
let resolved = apply_runtime_settings(&base, &args, false, &PathBuf::from("/srv/fabro"));
assert!(resolved.web.expect("web settings should exist").enabled);
}
#[test]
fn apply_runtime_settings_disables_web_from_cli_flag() {
let base = Settings::default();
let args = ServeArgs {
bind: None,
model: None,
provider: None,
dry_run: false,
sandbox: None,
web: false,
no_web: true,
max_concurrent_runs: None,
config: None,
};
let resolved = apply_runtime_settings(&base, &args, false, &PathBuf::from("/srv/fabro"));
assert!(!resolved.web.expect("web settings should exist").enabled);
}
#[test]
fn server_title_formats_boot_listening_and_stopping() {
let bind = Bind::Tcp("127.0.0.1:3000".parse().unwrap());

View file

@ -785,20 +785,46 @@ fn start_optional_slack_service(state: &Arc<AppState>) {
/// Build the axum Router with all run endpoints and embedded static assets.
pub fn build_router(state: Arc<AppState>, auth_mode: AuthMode) -> Router {
build_router_with_options(state, auth_mode, RouterOptions::default())
}
#[derive(Clone, Copy, Debug)]
pub struct RouterOptions {
pub web_enabled: bool,
}
impl Default for RouterOptions {
fn default() -> Self {
Self { web_enabled: true }
}
}
/// Build the axum Router with configurable web surface routing.
pub fn build_router_with_options(
state: Arc<AppState>,
auth_mode: AuthMode,
options: RouterOptions,
) -> Router {
start_optional_slack_service(&state);
let middleware_state = Arc::clone(&state);
let api_common = Router::new()
.route("/openapi.json", get(openapi_spec))
.merge(web_auth::api_routes());
let api_common = if options.web_enabled {
Router::new()
.route("/openapi.json", get(openapi_spec))
.merge(web_auth::api_routes())
} else {
Router::new().route("/openapi.json", get(openapi_spec))
};
let demo_router = Router::new()
.nest("/api/v1", api_common.clone().merge(demo_routes()))
.layer(axum::Extension(AuthMode::Disabled))
.with_state(state.clone());
let real_router = Router::new()
.nest("/api/v1", api_common.merge(real_routes()))
.nest("/auth", web_auth::routes())
let mut real_router = Router::new().nest("/api/v1", api_common.merge(real_routes()));
if options.web_enabled {
real_router = real_router.nest("/auth", web_auth::routes());
}
let real_router = real_router
.layer(axum::Extension(auth_mode))
.with_state(state);
@ -806,7 +832,7 @@ pub fn build_router(state: Arc<AppState>, auth_mode: AuthMode) -> Router {
let demo = demo_router.clone();
let real = real_router.clone();
async move {
if req.headers().get("x-fabro-demo").is_some_and(|v| v == "1") {
if options.web_enabled && req.headers().get("x-fabro-demo").is_some_and(|v| v == "1") {
demo.oneshot(req).await
} else {
real.oneshot(req).await
@ -837,19 +863,27 @@ pub fn build_router(state: Arc<AppState>, auth_mode: AuthMode) -> Router {
},
);
Router::new()
.route("/health", get(health))
.layer(middleware::from_fn_with_state(
let mut router = Router::new().route("/health", get(health));
if options.web_enabled {
router = router.layer(middleware::from_fn_with_state(
middleware_state,
cookie_and_demo_middleware,
))
));
}
router
.fallback_service(service_fn(move |req: axum_extract::Request| {
let dispatch = dispatch.clone();
async move {
let path = req.uri().path().to_string();
if path.starts_with("/api/v1/") || path.starts_with("/auth/") || path == "/health" {
let dispatch_path = path.starts_with("/api/v1/")
|| path == "/health"
|| (options.web_enabled && path.starts_with("/auth/"));
if dispatch_path {
dispatch.oneshot(req).await
} else if matches!(req.method(), &Method::GET | &Method::HEAD) {
} else if options.web_enabled
&& matches!(req.method(), &Method::GET | &Method::HEAD)
{
Ok::<_, std::convert::Infallible>(static_files::serve(&path))
} else {
Ok::<_, std::convert::Infallible>(StatusCode::NOT_FOUND.into_response())
@ -6156,6 +6190,7 @@ mod tests {
async fn auth_login_github_redirects_to_github() {
let mut settings = Settings::default();
settings.web = Some(WebSettings {
enabled: true,
url: "http://localhost:3000".to_string(),
auth: AuthSettings {
provider: AuthProvider::Github,

View file

@ -1,7 +1,11 @@
use axum::body::{Body, to_bytes};
use axum::http::{Method, Request, StatusCode};
use fabro_server::jwt_auth::AuthMode;
use fabro_server::server::{build_router, create_app_state};
use fabro_server::server::{
RouterOptions, build_router, build_router_with_options, create_app_state,
create_app_state_with_options,
};
use fabro_types::Settings;
use std::path::PathBuf;
use tower::ServiceExt;
@ -82,6 +86,122 @@ async fn source_maps_are_not_served() {
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn web_enabled_serves_web_only_routes() {
let app = build_router(create_app_state(), AuthMode::Disabled);
let auth_me_request = Request::builder()
.method("GET")
.uri("/api/v1/auth/me")
.body(Body::empty())
.unwrap();
let auth_me_response = app.clone().oneshot(auth_me_request).await.unwrap();
assert_eq!(auth_me_response.status(), StatusCode::UNAUTHORIZED);
let setup_status_request = Request::builder()
.method("GET")
.uri("/api/v1/setup/status")
.body(Body::empty())
.unwrap();
let setup_status_response = app.clone().oneshot(setup_status_request).await.unwrap();
assert_eq!(setup_status_response.status(), StatusCode::OK);
let demo_toggle_request = Request::builder()
.method("POST")
.uri("/api/v1/demo/toggle")
.header("content-type", "application/json")
.body(Body::from(r#"{"enabled":true}"#))
.unwrap();
let demo_toggle_response = app.oneshot(demo_toggle_request).await.unwrap();
assert_eq!(demo_toggle_response.status(), StatusCode::OK);
assert!(
demo_toggle_response.headers().contains_key("set-cookie"),
"demo toggle should set a cookie"
);
}
#[tokio::test]
async fn web_disabled_returns_404_for_web_routes_and_keeps_machine_api() {
let settings: Settings = toml::from_str(
r#"
[web]
enabled = false
"#,
)
.expect("settings fixture should parse");
let app = build_router_with_options(
create_app_state_with_options(settings, 5),
AuthMode::Disabled,
RouterOptions { web_enabled: false },
);
for (method, path, body) in [
("GET", "/", Body::empty()),
("GET", "/runs/abc", Body::empty()),
("GET", "/auth/login/github", Body::empty()),
("GET", "/api/v1/auth/me", Body::empty()),
("GET", "/api/v1/setup/status", Body::empty()),
(
"POST",
"/api/v1/demo/toggle",
Body::from(r#"{"enabled":true}"#),
),
] {
let request = Request::builder()
.method(method)
.uri(path)
.header("content-type", "application/json")
.body(body)
.unwrap();
let response = app.clone().oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND, "{method} {path}");
}
let settings_request = Request::builder()
.method("GET")
.uri("/api/v1/settings")
.body(Body::empty())
.unwrap();
let settings_response = app.clone().oneshot(settings_request).await.unwrap();
assert_eq!(settings_response.status(), StatusCode::OK);
let health_request = Request::builder()
.method("GET")
.uri("/health")
.body(Body::empty())
.unwrap();
let health_response = app.oneshot(health_request).await.unwrap();
assert_eq!(health_response.status(), StatusCode::OK);
}
#[tokio::test]
async fn web_disabled_ignores_demo_header_dispatch() {
let settings: Settings = toml::from_str(
r#"
[web]
enabled = false
"#,
)
.expect("settings fixture should parse");
let app = build_router_with_options(
create_app_state_with_options(settings, 5),
AuthMode::Disabled,
RouterOptions { web_enabled: false },
);
let run_id = "01ARZ3NDEKTSV4RRFFQ69G5FAV";
let request = Request::builder()
.method("GET")
.uri(format!("/api/v1/runs/{run_id}"))
.header("X-Fabro-Demo", "1")
.body(Body::empty())
.unwrap();
let response = app.oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
fn find_dist_source_map() -> String {
let dist_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../../apps/fabro-web/dist");
let mut entries = std::fs::read_dir(&dist_dir)

View file

@ -262,6 +262,7 @@ fn fully_populated_server_config() -> Settings {
storage_dir: Some("/data".into()),
max_concurrent_runs: Some(10),
web: Some(WebSettings {
enabled: true,
url: "https://example.com".into(),
auth: AuthSettings {
provider: AuthProvider::Github,

View file

@ -97,12 +97,18 @@ pub struct GitSettings {
#[derive(Debug, Clone, Deserialize, PartialEq, Serialize)]
pub struct WebSettings {
#[serde(default = "default_web_enabled")]
pub enabled: bool,
#[serde(default = "default_web_url")]
pub url: String,
#[serde(default)]
pub auth: AuthSettings,
}
fn default_web_enabled() -> bool {
true
}
fn default_web_url() -> String {
"http://localhost:3000".to_string()
}
@ -110,6 +116,7 @@ fn default_web_url() -> String {
impl Default for WebSettings {
fn default() -> Self {
Self {
enabled: default_web_enabled(),
url: default_web_url(),
auth: AuthSettings::default(),
}