use std::collections::HashMap; use std::convert::Infallible; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, LazyLock, Mutex, MutexGuard}; use std::time::{Duration, Instant}; use anyhow::{Context as _, anyhow, bail}; use axum::extract::{OriginalUri, Query, Request, State}; use axum::http::{HeaderMap, Method, StatusCode, header}; use axum::response::{IntoResponse, Response}; use axum::routing::{get, post, put}; use axum::{Json, Router, middleware}; use base64::Engine as _; use base64::engine::general_purpose::{STANDARD as BASE64_STANDARD, URL_SAFE_NO_PAD}; use fabro_config::Storage; use fabro_config::bind::{Bind, BindRequest}; use fabro_config::envfile::{EnvFileRemoval, EnvFileUpdate}; use fabro_install::{ GITHUB_APP_VAULT_KEYS, GITHUB_INSTALL_SECRET_KEYS, InstallListenConfig, InstallPersistencePlan, InstallSandboxSelection, OBJECT_STORE_ACCESS_KEY_ID_ENV, OBJECT_STORE_SECRET_ACCESS_KEY_ENV, PendingSettingsWrite, SecretStoreWrite, merge_server_settings, prepare_dev_token_write_for_install, seed_default_environment_in_storage, write_github_app_settings, write_object_store_settings, write_sandbox_settings, write_token_settings, }; use fabro_llm::lithos_catalog::{Catalog, CatalogProvider}; use fabro_llm::probe::{self, ApiKeyProbeError, ModelTestStatus}; use fabro_sandbox::daytona; use fabro_sandbox::driver::DaytonaCredentials; use fabro_static::EnvVars; use fabro_store::ArtifactStore; use fabro_types::settings::server::ObjectStoreSettings; use fabro_types::settings::{is_wildcard_host, validate_public_url_with_label}; use fabro_types::{SandboxProviderKind, ServerSettings}; use fabro_util::version::FABRO_VERSION; use fabro_util::{Home, session_secret}; use fabro_vault::SecretType as VaultSecretType; use lithos_llm::catalog::ProviderId; use object_store::aws::resolve_bucket_region; use object_store::path::Path as ObjectStorePath; use object_store::{ClientOptions, RetryConfig}; use serde::{Deserialize, Serialize}; use tokio::net::{TcpListener, UnixListener}; use tokio::sync::watch; use tokio::time::{sleep, timeout}; use tower::service_fn; use tracing::{error, info, warn}; use zeroize::Zeroizing; use crate::error::ApiError; use crate::serve::{self, DEFAULT_TCP_PORT}; use crate::server_secrets::{ServerSecrets, process_env_snapshot}; use crate::{security_headers, server, static_files}; #[derive(Clone)] pub struct InstallAppState { install_token: Arc, pending_install: Arc>, storage_dir: Arc, config_path: Arc, home: Option, install_listen: Arc>, first_operator: Arc>>, finish_in_progress: Arc, upstreams: InstallUpstreamConfig, static_asset_root: Option>, on_finish: Option>, finish_hook: Option, } pub struct InstallFinishInfo { pub canonical_url: String, pub dev_token: Option, } pub type InstallFinishHook = Arc anyhow::Result<()> + Send + Sync>; #[derive(Clone, Debug, Default)] struct InstallUpstreamConfig { provider_base_urls: HashMap, github_api_base_url: Option, daytona_api_base_url: Option, daytona_organization_id: Option, } #[derive(Clone, Debug, PartialEq, Eq)] struct InstallOperatorFingerprint { user_agent: Option, remote_ip: Option, } pub const DEFAULT_INSTALL_GITHUB_API_BASE_URL: &str = "https://api.github.com"; const DEFAULT_INSTALL_TCP_LISTEN_ADDRESS: &str = "127.0.0.1:32276"; const REDACTED_SECRET_VALUE: &str = "[REDACTED]"; const VALIDATION_TIMEOUT: Duration = Duration::from_secs(20); const VALIDATION_CONNECT_TIMEOUT: Duration = Duration::from_secs(5); static INSTALL_CATALOG: LazyLock> = LazyLock::new(|| Arc::new(fabro_llm::default_catalog())); impl InstallAppState { #[must_use] pub fn new(token: String, storage_dir: &Path, config_path: &Path) -> Self { Self { install_token: Arc::from(token), pending_install: Arc::new(Mutex::new(PendingInstall::default())), storage_dir: Arc::from(storage_dir), config_path: Arc::from(config_path), home: None, install_listen: Arc::new(Mutex::new(InstallListenConfig::Tcp( DEFAULT_INSTALL_TCP_LISTEN_ADDRESS.to_string(), ))), first_operator: Arc::new(Mutex::new(None)), finish_in_progress: Arc::new(AtomicBool::new(false)), upstreams: InstallUpstreamConfig::default(), static_asset_root: None, on_finish: None, finish_hook: None, } } #[must_use] pub fn for_test(token: &str) -> Self { let temp_root = std::env::temp_dir().join("fabro-install-test"); Self::for_test_with_paths(token, &temp_root, &temp_root.join("settings.toml")) } #[must_use] #[expect( unsafe_code, reason = "test-only: set FABRO_TEST_IN_MEMORY_STORE to a constant so install tests \ don't hang on real S3; parallel tests race on the same value" )] #[expect( clippy::disallowed_methods, reason = "test-only: forces the in-memory object store for install tests so they \ don't contact real S3" )] pub fn for_test_with_paths(token: &str, storage_dir: &Path, config_path: &Path) -> Self { // Install-flow tests verify persistence and redaction, not S3 // reachability. Force the in-memory object store shortcut so // /install/finish can't hang on an unreachable bucket. unsafe { std::env::set_var(EnvVars::FABRO_TEST_IN_MEMORY_STORE, "1"); } Self { install_token: Arc::from(token), pending_install: Arc::new(Mutex::new(PendingInstall::default())), storage_dir: Arc::from(storage_dir), config_path: Arc::from(config_path), home: None, install_listen: Arc::new(Mutex::new(InstallListenConfig::Tcp( DEFAULT_INSTALL_TCP_LISTEN_ADDRESS.to_string(), ))), first_operator: Arc::new(Mutex::new(None)), finish_in_progress: Arc::new(AtomicBool::new(false)), upstreams: InstallUpstreamConfig::default(), static_asset_root: None, on_finish: None, finish_hook: None, } } #[must_use] pub fn with_finish_callback(self, on_finish: Arc) -> Self { Self { on_finish: Some(on_finish), ..self } } #[must_use] pub fn with_finish_hook(self, finish_hook: InstallFinishHook) -> Self { Self { finish_hook: Some(finish_hook), ..self } } #[must_use] pub fn with_home(mut self, home: Home) -> Self { self.home = Some(home); self } #[must_use] pub fn with_static_asset_root(mut self, root: impl Into) -> Self { self.static_asset_root = Some(Arc::from(root.into())); self } #[must_use] pub fn with_provider_base_url( mut self, provider: impl Into, base_url: impl Into, ) -> Self { self.upstreams .provider_base_urls .insert(provider.into(), base_url.into()); self } #[must_use] pub fn with_github_api_base_url(mut self, base_url: impl Into) -> Self { self.upstreams.github_api_base_url = Some(base_url.into()); self } #[must_use] pub fn with_daytona_api_base_url(mut self, base_url: impl Into) -> Self { self.upstreams.daytona_api_base_url = Some(base_url.into()); self } #[must_use] pub fn with_daytona_organization_id(mut self, organization_id: impl Into) -> Self { self.upstreams.daytona_organization_id = Some(organization_id.into()); self } fn set_install_bind(&self, bind: &Bind) { *lock_unpoisoned(&self.install_listen, "install listen") = install_listen_config(bind); } fn install_listen_config(&self) -> InstallListenConfig { lock_unpoisoned(&self.install_listen, "install listen").clone() } } #[derive(Deserialize, Default)] struct InstallTokenQuery { token: Option, } #[derive(Clone, Debug, Default)] struct PendingInstall { llm: Option, server: Option, object_store: Option, sandbox: Option, github: Option, pending_github_app: Option, } #[derive(Clone, Debug, Deserialize, Serialize)] struct LlmProvidersInput { providers: Vec, } #[derive(Clone, Debug, Deserialize, Serialize)] struct LlmProviderInput { provider: ProviderId, api_key: String, } #[derive(Clone, Debug, Deserialize, Serialize)] struct ServerConfigInput { canonical_url: String, } #[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq, strum::IntoStaticStr)] #[serde(rename_all = "snake_case")] #[strum(serialize_all = "snake_case")] enum InstallObjectStoreProvider { Local, S3, } #[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq, strum::IntoStaticStr)] #[serde(rename_all = "snake_case")] #[strum(serialize_all = "snake_case")] enum InstallObjectStoreCredentialMode { Runtime, AccessKey, } #[derive(Clone, Debug, Deserialize)] struct InstallObjectStoreInput { provider: InstallObjectStoreProvider, root: Option, bucket: Option, region: Option, credential_mode: Option, access_key_id: Option, secret_access_key: Option, } #[derive(Clone)] struct InstallSecret(Zeroizing); impl InstallSecret { fn new(value: impl Into) -> Self { Self(Zeroizing::new(value.into())) } fn expose_secret(&self) -> &str { &self.0 } } impl std::fmt::Debug for InstallSecret { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(REDACTED_SECRET_VALUE) } } impl std::fmt::Display for InstallSecret { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(REDACTED_SECRET_VALUE) } } impl Serialize for InstallSecret { fn serialize(&self, serializer: S) -> Result where S: serde::Serializer, { serializer.serialize_str(REDACTED_SECRET_VALUE) } } #[derive(Clone)] struct InstallAwsCredentialPair { access_key_id: InstallSecret, secret_access_key: InstallSecret, } impl InstallAwsCredentialPair { fn new(access_key_id: impl Into, secret_access_key: impl Into) -> Self { Self { access_key_id: InstallSecret::new(access_key_id), secret_access_key: InstallSecret::new(secret_access_key), } } } impl std::fmt::Debug for InstallAwsCredentialPair { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("InstallAwsCredentialPair") .field("access_key_id", &self.access_key_id) .field("secret_access_key", &self.secret_access_key) .finish() } } #[derive(Clone, Debug)] enum InstallObjectStoreState { Local { root: String, }, S3 { bucket: String, region: String, credential_mode: InstallObjectStoreCredentialMode, manual_credentials: Option, }, } impl InstallObjectStoreState { fn as_session_value(&self) -> serde_json::Value { match self { Self::Local { root } => serde_json::json!({ "provider": "local", "root": root, }), Self::S3 { bucket, region, credential_mode, manual_credentials, } => serde_json::json!({ "provider": "s3", "bucket": bucket, "region": region, "credential_mode": <&'static str>::from(*credential_mode), "manual_credentials_saved": matches!( credential_mode, InstallObjectStoreCredentialMode::AccessKey ) && manual_credentials.is_some(), }), } } fn to_persistence_selection(&self) -> fabro_install::InstallObjectStoreSelection { match self { Self::Local { root } => { fabro_install::InstallObjectStoreSelection::Local { root: root.clone() } } Self::S3 { bucket, region, credential_mode, manual_credentials, } => fabro_install::InstallObjectStoreSelection::S3 { bucket: bucket.clone(), region: region.clone(), credential_mode: match credential_mode { InstallObjectStoreCredentialMode::Runtime => { fabro_install::InstallObjectStoreCredentialMode::Runtime } InstallObjectStoreCredentialMode::AccessKey => { fabro_install::InstallObjectStoreCredentialMode::AccessKey } }, access_key_id: manual_credentials .as_ref() .map(|credentials| credentials.access_key_id.expose_secret().to_string()), secret_access_key: manual_credentials .as_ref() .map(|credentials| credentials.secret_access_key.expose_secret().to_string()), }, } } } #[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq, strum::IntoStaticStr)] #[serde(rename_all = "snake_case")] #[strum(serialize_all = "snake_case")] enum InstallSandboxProvider { Docker, Daytona, } #[derive(Clone, Debug, Deserialize)] struct InstallSandboxInput { provider: InstallSandboxProvider, api_key: Option, allow_local: Option, } #[derive(Clone, Debug)] enum InstallSandboxProviderState { Docker, Daytona { api_key: InstallSecret }, } #[derive(Clone, Debug)] struct InstallSandboxState { provider: InstallSandboxProviderState, allow_local: bool, } impl InstallSandboxState { fn as_session_value(&self) -> serde_json::Value { match &self.provider { InstallSandboxProviderState::Docker => serde_json::json!({ "provider": "docker", "allow_local": self.allow_local, }), InstallSandboxProviderState::Daytona { .. } => serde_json::json!({ "provider": "daytona", "api_key_saved": true, "allow_local": self.allow_local, }), } } fn to_persistence_selection(&self) -> InstallSandboxSelection { match &self.provider { InstallSandboxProviderState::Docker => InstallSandboxSelection::Docker, InstallSandboxProviderState::Daytona { .. } => InstallSandboxSelection::Daytona, } } fn to_environment_provider(&self) -> SandboxProviderKind { match &self.provider { InstallSandboxProviderState::Docker => SandboxProviderKind::DOCKER, InstallSandboxProviderState::Daytona { .. } => SandboxProviderKind::DAYTONA, } } } #[derive(Clone, Debug, Deserialize, Serialize)] struct GithubTokenInput { token: String, username: String, } #[derive(Clone, Debug)] enum GithubInstallState { Token(GithubTokenInput), App(GithubAppInstall), } #[derive(Clone, Debug)] struct PendingGithubApp { state: String, owner: GitHubAppOwner, app_name: String, allowed_username: String, expires_at: Instant, } #[derive(Clone, Debug)] struct GithubAppInstall { owner: GitHubAppOwner, app_name: String, allowed_username: String, app_id: String, slug: String, client_id: String, client_secret: String, webhook_secret: Option, pem: String, } #[derive(Clone, Debug, Deserialize)] struct InstallLlmTestInput { provider: ProviderId, api_key: String, } #[derive(Clone, Debug, Deserialize)] struct GithubTokenTestInput { token: String, } #[derive(Clone, Debug, Deserialize)] struct GithubAppManifestInput { owner: GithubAppOwnerInput, app_name: String, allowed_username: String, } #[derive(Clone, Debug, Deserialize)] struct GithubAppOwnerInput { kind: GithubAppOwnerKind, slug: Option, } #[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)] #[serde(rename_all = "lowercase")] enum GithubAppOwnerKind { Personal, Org, } #[derive(Clone, Debug, Deserialize)] struct GithubAppRedirectQuery { code: Option, state: Option, } #[derive(Clone, Debug, Deserialize)] struct GithubUserResponse { login: String, } #[derive(Clone, Debug, Deserialize)] struct GitHubAppManifestConversion { id: i64, slug: String, client_id: String, client_secret: String, webhook_secret: Option, pem: String, } #[derive(Clone, Debug)] enum GitHubAppOwner { Personal, Organization(String), } impl GitHubAppOwner { fn manifest_form_action(&self) -> String { match self { Self::Personal => "https://github.com/settings/apps/new".to_string(), Self::Organization(org) => { format!("https://github.com/organizations/{org}/settings/apps/new") } } } fn as_session_value(&self) -> serde_json::Value { match self { Self::Personal => serde_json::json!({ "kind": "personal" }), Self::Organization(org) => serde_json::json!({ "kind": "org", "slug": org }), } } } impl TryFrom for GitHubAppOwner { type Error = String; fn try_from(value: GithubAppOwnerInput) -> Result { match value.kind { GithubAppOwnerKind::Personal => Ok(Self::Personal), GithubAppOwnerKind::Org => { let slug = value.slug.unwrap_or_default(); let trimmed = slug.trim(); if trimmed.is_empty() { return Err("organization owner requires a non-empty slug".to_string()); } Ok(Self::Organization(trimmed.to_string())) } } } } pub fn build_install_router(state: InstallAppState) -> Router { let static_asset_root = state.static_asset_root.clone(); Router::new() .route("/health", get(health)) .route("/install/session", get(get_install_session)) .route("/install/llm/test", post(post_install_llm_test)) .route( "/install/llm", get(render_install_shell).put(put_install_llm), ) .route( "/install/server", get(render_install_shell).put(put_install_server), ) .route( "/install/object-store/test", post(post_install_object_store_test), ) .route( "/install/object-store", get(render_install_shell).put(put_install_object_store), ) .route("/install/sandbox/test", post(post_install_sandbox_test)) .route( "/install/sandbox", get(render_install_shell).put(put_install_sandbox), ) .route( "/install/github/token/test", post(post_install_github_token_test), ) .route("/install/github/token", put(put_install_github_token)) .route( "/install/github/app/manifest", post(post_install_github_app_manifest), ) .route( "/install/github/app/redirect", get(get_install_github_app_redirect), ) .route("/install/finish", post(post_install_finish)) .with_state(state) .fallback_service(service_fn(move |req: Request| { let static_asset_root = static_asset_root.clone(); async move { let path = req.uri().path().to_string(); if path.starts_with("/api/") { Ok::<_, Infallible>(StatusCode::NOT_FOUND.into_response()) } else if matches!(req.method(), &Method::GET | &Method::HEAD) { let headers = req.headers().clone(); Ok::<_, Infallible>( static_files::serve_install_with_asset_root( &path, &headers, static_asset_root.as_deref(), false, ) .await, ) } else { Ok::<_, Infallible>(StatusCode::NOT_FOUND.into_response()) } } })) // Install mode serves the same multi-megabyte SPA bundle as the main // router; a first-run setup over a slow link needs compression just // as much. .layer(server::compression_layer()) .layer(middleware::from_fn(security_headers::layer)) } struct InstallFinishGuard { flag: Arc, release: bool, } impl InstallFinishGuard { fn try_acquire(flag: Arc) -> Option { flag.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) .ok()?; Some(Self { flag, release: true, }) } fn disarm(mut self) { self.release = false; } } impl Drop for InstallFinishGuard { fn drop(&mut self) { if self.release { self.flag.store(false, Ordering::Release); } } } pub async fn serve_install_command( bind_request: BindRequest, state: InstallAppState, on_ready: F, ) -> anyhow::Result<()> where F: FnOnce(&Bind) -> anyhow::Result<()>, { let (shutdown_tx, shutdown_rx) = watch::channel(false); let finish_callback: Arc = Arc::new(move || { let _ = shutdown_tx.send(true); }); let bound_listener = bind_install_listener(&bind_request).await?; state.set_install_bind(&bound_listener.bind); let state = state.with_finish_callback(finish_callback); let router = build_install_router(state); let bind = bound_listener.bind.clone(); on_ready(&bind)?; match bound_listener.listener { BoundInstallListener::Unix(listener) => { axum::serve(listener, router) .with_graceful_shutdown(wait_for_shutdown(shutdown_rx.clone())) .await?; } BoundInstallListener::Tcp(listener) => { axum::serve( listener, router.into_make_service_with_connect_info::(), ) .with_graceful_shutdown(wait_for_shutdown(shutdown_rx.clone())) .await?; } } Ok(()) } fn lock_unpoisoned<'a, T>(mutex: &'a Mutex, label: &'static str) -> MutexGuard<'a, T> { mutex.lock().unwrap_or_else(|poisoned| { error!(lock = label, "recovering from poisoned install lock"); poisoned.into_inner() }) } fn install_listen_config(bind: &Bind) -> InstallListenConfig { match bind { Bind::Tcp(address) => InstallListenConfig::Tcp(address.to_string()), Bind::Unix(path) => InstallListenConfig::Unix(path.clone()), } } async fn health() -> Response { Json(serde_json::json!({ "status": "ok", "mode": "install", })) .into_response() } async fn get_install_session( State(state): State, headers: HeaderMap, Query(query): Query, ) -> Response { if !token_is_valid(&state, &headers, query.token.as_deref()) { return ApiError::new(StatusCode::UNAUTHORIZED, "invalid install token").into_response(); } observe_operator(&state, &headers); let pending_install = lock_unpoisoned(&state.pending_install, "install session").clone(); Json(serde_json::json!({ "completed_steps": completed_steps(&pending_install), "llm": redacted_llm(&pending_install), "server": pending_install.server, "object_store": redacted_object_store(&pending_install), "sandbox": redacted_sandbox(&pending_install), "github": redacted_github(&pending_install), "prefill": { "canonical_url": detect_canonical_url(&headers), "object_store_local_root": default_local_object_store_root(&state), } })) .into_response() } async fn post_install_llm_test( State(state): State, headers: HeaderMap, Query(query): Query, Json(input): Json, ) -> Response { if let Some(response) = require_valid_token(&state, &headers, query.token.as_deref()) { return response; } observe_operator(&state, &headers); if let Err(error) = install_catalog_provider(&input.provider) { return install_error_response(StatusCode::UNPROCESSABLE_ENTITY, error); } if input.api_key.trim().is_empty() { return install_error_response(StatusCode::UNPROCESSABLE_ENTITY, "api_key is required"); } match validate_llm_provider(&state, &input).await { Ok(()) => Json(serde_json::json!({ "ok": true })).into_response(), Err(err) => { warn!(provider = %input.provider, error = ?err, "install LLM validation failed"); install_error_response(StatusCode::UNPROCESSABLE_ENTITY, err.to_string()) } } } async fn put_install_llm( State(state): State, headers: HeaderMap, Query(query): Query, Json(input): Json, ) -> Response { if let Some(response) = require_valid_token(&state, &headers, query.token.as_deref()) { return response; } observe_operator(&state, &headers); // An empty providers list is an explicit skip: the LLM step is recorded as // completed with zero credentials. `/install/finish` still requires the // step to be present, just not populated. for provider in &input.providers { if let Err(error) = install_catalog_provider(&provider.provider) { return install_error_response(StatusCode::UNPROCESSABLE_ENTITY, error); } if provider.api_key.trim().is_empty() { return install_error_response( StatusCode::UNPROCESSABLE_ENTITY, format!("api_key is required for {}", provider.provider), ); } } lock_unpoisoned(&state.pending_install, "install session").llm = Some(input); info!(step = "llm", "install step completed"); StatusCode::NO_CONTENT.into_response() } fn install_catalog_provider(provider: &ProviderId) -> Result<&'static CatalogProvider, String> { let catalog_provider = INSTALL_CATALOG .enabled_provider(provider.as_str()) .ok_or_else(|| format!("provider '{provider}' is not configured in the model catalog"))?; if fabro_auth::accepts_api_key(catalog_provider) { Ok(catalog_provider) } else { Err(format!( "provider '{}' does not define an API-key credential path", catalog_provider.id() )) } } fn provider_secret_name(provider: &ProviderId) -> Result { let catalog_provider = install_catalog_provider(provider)?; fabro_auth::expected_secret_name(catalog_provider) .ok_or_else(|| format!("provider '{provider}' does not define a vault credential path")) } async fn put_install_server( State(state): State, headers: HeaderMap, Query(query): Query, Json(mut input): Json, ) -> Response { if let Some(response) = require_valid_token(&state, &headers, query.token.as_deref()) { return response; } observe_operator(&state, &headers); let canonical_url = input.canonical_url.trim(); if canonical_url.is_empty() { return ( StatusCode::UNPROCESSABLE_ENTITY, Json(serde_json::json!({ "error": "canonical_url is required" })), ) .into_response(); } let canonical_url = match validate_public_url_with_label(canonical_url, "canonical_url") { Ok(value) => value, Err(err) => return install_error_response(StatusCode::UNPROCESSABLE_ENTITY, err), }; input.canonical_url = canonical_url; lock_unpoisoned(&state.pending_install, "install session").server = Some(input); info!(step = "server", "install step completed"); StatusCode::NO_CONTENT.into_response() } async fn post_install_object_store_test( State(state): State, headers: HeaderMap, Query(query): Query, Json(input): Json, ) -> Response { if let Some(response) = require_valid_token(&state, &headers, query.token.as_deref()) { return response; } observe_operator(&state, &headers); let selection = { let pending_install = lock_unpoisoned(&state.pending_install, "install session"); match resolve_install_object_store_state( pending_install.object_store.as_ref(), input, &default_local_object_store_root(&state), ) { Ok(selection) => selection, Err(err) => return install_error_response(StatusCode::UNPROCESSABLE_ENTITY, err), } }; match validate_install_object_store_selection(&state, &selection).await { Ok(()) => Json(serde_json::json!({ "ok": true })).into_response(), Err(err) => { warn!(error = ?err, "install object store validation failed"); install_error_response(StatusCode::UNPROCESSABLE_ENTITY, err.to_string()) } } } async fn put_install_object_store( State(state): State, headers: HeaderMap, Query(query): Query, Json(input): Json, ) -> Response { if let Some(response) = require_valid_token(&state, &headers, query.token.as_deref()) { return response; } observe_operator(&state, &headers); let mut pending_install = lock_unpoisoned(&state.pending_install, "install session"); let selection = match resolve_install_object_store_state( pending_install.object_store.as_ref(), input, &default_local_object_store_root(&state), ) { Ok(selection) => selection, Err(err) => return install_error_response(StatusCode::UNPROCESSABLE_ENTITY, err), }; pending_install.object_store = Some(selection); info!(step = "object_store", "install step completed"); StatusCode::NO_CONTENT.into_response() } async fn post_install_sandbox_test( State(state): State, headers: HeaderMap, Query(query): Query, Json(input): Json, ) -> Response { if let Some(response) = require_valid_token(&state, &headers, query.token.as_deref()) { return response; } observe_operator(&state, &headers); let api_key = { let pending_install = lock_unpoisoned(&state.pending_install, "install session"); match resolve_install_sandbox_state(pending_install.sandbox.as_ref(), input) { Ok(InstallSandboxState { provider: InstallSandboxProviderState::Docker, .. }) => { return Json(serde_json::json!({ "ok": true })).into_response(); } Ok(InstallSandboxState { provider: InstallSandboxProviderState::Daytona { api_key }, .. }) => api_key.expose_secret().to_string(), Err(err) => return install_error_response(StatusCode::UNPROCESSABLE_ENTITY, err), } }; match check_install_daytona_api_key(&state, api_key).await { Ok(check) if check.ok() => Json(serde_json::json!({ "ok": true })).into_response(), Ok(check) => { warn!( missing = %check.missing_display(), "install sandbox scopes insufficient" ); install_error_response(StatusCode::UNPROCESSABLE_ENTITY, check.missing_message()) } Err(err) => { warn!(error = %err, "install sandbox validation failed"); install_error_response( StatusCode::UNPROCESSABLE_ENTITY, format!("daytona credential validation failed: {err}"), ) } } } async fn check_install_daytona_api_key( state: &InstallAppState, api_key: String, ) -> anyhow::Result { let credentials = DaytonaCredentials::new(api_key) .with_api_url(state.upstreams.daytona_api_base_url.clone()) .with_organization_id(state.upstreams.daytona_organization_id.clone()) .with_http_client(Some( fabro_http::http_client().context("failed to build HTTP client")?, )); daytona::check_daytona_api_key(&credentials, daytona::DAYTONA_CREDENTIAL_PROBE_TIMEOUT).await } async fn put_install_sandbox( State(state): State, headers: HeaderMap, Query(query): Query, Json(input): Json, ) -> Response { if let Some(response) = require_valid_token(&state, &headers, query.token.as_deref()) { return response; } observe_operator(&state, &headers); let mut pending_install = lock_unpoisoned(&state.pending_install, "install session"); let selection = match resolve_install_sandbox_state(pending_install.sandbox.as_ref(), input) { Ok(selection) => selection, Err(err) => return install_error_response(StatusCode::UNPROCESSABLE_ENTITY, err), }; pending_install.sandbox = Some(selection); info!(step = "sandbox", "install step completed"); StatusCode::NO_CONTENT.into_response() } fn trim_install_field(value: Option) -> Option { value .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()) } fn default_local_object_store_root(state: &InstallAppState) -> String { state .storage_dir .as_ref() .join("objects") .display() .to_string() } fn resolve_install_sandbox_state( current: Option<&InstallSandboxState>, input: InstallSandboxInput, ) -> Result { // Local sandboxes are allowed by default unless the wizard unchecked the box. let allow_local = input.allow_local.unwrap_or(true); let provider = match input.provider { InstallSandboxProvider::Docker => InstallSandboxProviderState::Docker, InstallSandboxProvider::Daytona => { let api_key = match trim_install_field(input.api_key) { Some(value) => InstallSecret::new(value), None => match current.map(|state| &state.provider) { Some(InstallSandboxProviderState::Daytona { api_key }) => { InstallSecret::new(api_key.expose_secret()) } _ => return Err("api_key is required for daytona".to_string()), }, }; InstallSandboxProviderState::Daytona { api_key } } }; Ok(InstallSandboxState { provider, allow_local, }) } fn resolve_install_object_store_state( current: Option<&InstallObjectStoreState>, input: InstallObjectStoreInput, default_local_root: &str, ) -> Result { let root = trim_install_field(input.root); let bucket = trim_install_field(input.bucket); let region = trim_install_field(input.region); let access_key_id = trim_install_field(input.access_key_id); let secret_access_key = trim_install_field(input.secret_access_key); match input.provider { InstallObjectStoreProvider::Local => { if bucket.is_some() || region.is_some() || input.credential_mode.is_some() || access_key_id.is_some() || secret_access_key.is_some() { return Err( "Local disk does not accept S3 bucket, region, or AWS credential fields." .to_string(), ); } let root = root .or_else(|| match current { Some(InstallObjectStoreState::Local { root }) => Some(root.clone()), _ => None, }) .unwrap_or_else(|| default_local_root.to_string()); Ok(InstallObjectStoreState::Local { root }) } InstallObjectStoreProvider::S3 => { let bucket = bucket.ok_or_else(|| "Bucket is required.".to_string())?; let region = region .ok_or_else(|| "Region is required. Use a value like us-east-1.".to_string())?; let credential_mode = input .credential_mode .ok_or_else(|| "Choose how Fabro should authenticate to AWS.".to_string())?; let manual_credentials = match credential_mode { InstallObjectStoreCredentialMode::Runtime => { if access_key_id.is_some() || secret_access_key.is_some() { return Err( "AWS access key fields are only allowed when using manual AWS access key credentials." .to_string(), ); } None } InstallObjectStoreCredentialMode::AccessKey => Some(resolve_s3_manual_credentials( access_key_id, secret_access_key, current, )?), }; Ok(InstallObjectStoreState::S3 { bucket, region, credential_mode, manual_credentials, }) } } } fn resolve_s3_manual_credentials( access_key_id: Option, secret_access_key: Option, current: Option<&InstallObjectStoreState>, ) -> Result { match (access_key_id, secret_access_key) { (Some(access_key_id), Some(secret_access_key)) => Ok(InstallAwsCredentialPair::new( access_key_id, secret_access_key, )), (None, None) => current .and_then(|state| match state { InstallObjectStoreState::S3 { credential_mode: InstallObjectStoreCredentialMode::AccessKey, manual_credentials, .. } => manual_credentials.clone(), _ => None, }) .ok_or_else(|| { "Enter both AWS access key fields or switch to runtime credentials.".to_string() }), (Some(_), None) | (None, Some(_)) => { Err("Enter both AWS access key fields or switch to runtime credentials.".to_string()) } } } fn object_store_validation_settings( selection: &InstallObjectStoreState, ) -> Option { match selection { InstallObjectStoreState::Local { .. } => None, InstallObjectStoreState::S3 { bucket, region, .. } => Some(ObjectStoreSettings::S3 { bucket: bucket.clone(), region: region.clone(), endpoint: None, path_style: false, }), } } fn install_object_store_lookup<'a>( server_secrets: &'a ServerSecrets, manual_credentials: Option<&'a InstallAwsCredentialPair>, ) -> impl Fn(&str) -> Option + 'a { move |name| match (manual_credentials, name) { (Some(credentials), OBJECT_STORE_ACCESS_KEY_ID_ENV) => { Some(credentials.access_key_id.expose_secret().to_string()) } (Some(credentials), OBJECT_STORE_SECRET_ACCESS_KEY_ENV) => { Some(credentials.secret_access_key.expose_secret().to_string()) } (Some(_), EnvVars::AWS_SESSION_TOKEN) => None, _ => server_secrets.get(name), } } async fn validate_install_object_store_selection( state: &InstallAppState, selection: &InstallObjectStoreState, ) -> anyhow::Result<()> { let Some(settings) = object_store_validation_settings(selection) else { return Ok(()); }; let (bucket, region, manual_credentials) = match selection { InstallObjectStoreState::Local { .. } => return Ok(()), InstallObjectStoreState::S3 { bucket, region, credential_mode: _, manual_credentials, } => ( bucket.as_str(), region.as_str(), manual_credentials.as_ref(), ), }; let client_options = ClientOptions::new() .with_connect_timeout(VALIDATION_CONNECT_TIMEOUT) .with_timeout(VALIDATION_TIMEOUT); match timeout( VALIDATION_TIMEOUT, resolve_bucket_region(bucket, &client_options), ) .await { Ok(Ok(actual_region)) if actual_region != region => { bail!( "Bucket {bucket} is in region {actual_region}, not {region}. Use the bucket's AWS region and try again." ); } Ok(Err(err)) => { let rendered = err.to_string(); if rendered.contains("not found") { bail!("Bucket {bucket} was not found."); } } Err(_) => { bail!(VALIDATION_TIMEOUT_MSG); } Ok(Ok(_)) => {} } let server_env_path = Storage::new(state.storage_dir.as_ref()) .runtime_directory() .env_path(); let server_secrets = ServerSecrets::load(server_env_path, process_env_snapshot()).map_err(anyhow::Error::new)?; let build_options = serve::ObjectStoreBuildOptions { client_options, retry_config: RetryConfig { max_retries: 0, retry_timeout: VALIDATION_TIMEOUT, ..RetryConfig::default() }, }; let env_lookup = install_object_store_lookup(&server_secrets, manual_credentials); let object_store = serve::build_object_store_from_settings_with_lookup( &settings, &env_lookup, Some(&build_options), )?; let probe_prefix = |index: usize, prefix: &'static str| { let object_store = &object_store; async move { let path = ObjectStorePath::from(prefix); object_store .list_with_delimiter(Some(&path)) .await .map(|_| ()) .map_err(|err| (index, err)) } }; let probe = async { tokio::try_join!(probe_prefix(0, "artifacts"), probe_prefix(1, "slatedb")).map(|_| ()) }; match timeout(VALIDATION_TIMEOUT, probe).await { Ok(Ok(())) => Ok(()), Err(_) => bail!(VALIDATION_TIMEOUT_MSG), Ok(Err((index, err))) => bail!( "{}", classify_object_store_validation_error(bucket, region, index, &err) ), } } const PREFIX_ACCESS_ERROR_MSG: &str = "Fabro reached the bucket but could not verify access to slatedb/ and artifacts/. Validation requires bucket list access plus object access under both prefixes."; const VALIDATION_TIMEOUT_MSG: &str = "Timed out while checking S3 access. Verify the bucket, region, and network path, then try again."; fn bucket_credentials_error(bucket: &str, region: &str) -> String { format!("Could not access bucket {bucket} in region {region} with the selected credentials.") } fn classify_object_store_validation_error( bucket: &str, region: &str, prefix_index: usize, err: &object_store::Error, ) -> String { let credentials_or_prefix_error = || { if prefix_index == 0 { bucket_credentials_error(bucket, region) } else { PREFIX_ACCESS_ERROR_MSG.to_string() } }; match err { object_store::Error::PermissionDenied { .. } | object_store::Error::Unauthenticated { .. } => credentials_or_prefix_error(), object_store::Error::NotFound { .. } => format!("Bucket {bucket} was not found."), object_store::Error::Generic { .. } => { let rendered = err.to_string(); if rendered.contains("incorrectly configured region") { format!( "Bucket {bucket} is not reachable in region {region}. Verify the AWS region and try again." ) } else if rendered.contains("not found") { format!("Bucket {bucket} was not found.") } else { credentials_or_prefix_error() } } _ => PREFIX_ACCESS_ERROR_MSG.to_string(), } } async fn post_install_github_token_test( State(state): State, headers: HeaderMap, Query(query): Query, Json(input): Json, ) -> Response { if let Some(response) = require_valid_token(&state, &headers, query.token.as_deref()) { return response; } observe_operator(&state, &headers); if input.token.trim().is_empty() { return install_error_response(StatusCode::UNPROCESSABLE_ENTITY, "token is required"); } match validate_github_token(&state, input.token.trim()).await { Ok(username) => Json(serde_json::json!({ "username": username })).into_response(), Err(err) => { warn!(error = ?err, "install GitHub token validation failed"); install_error_response(StatusCode::UNPROCESSABLE_ENTITY, err.to_string()) } } } async fn put_install_github_token( State(state): State, headers: HeaderMap, Query(query): Query, Json(input): Json, ) -> Response { if let Some(response) = require_valid_token(&state, &headers, query.token.as_deref()) { return response; } observe_operator(&state, &headers); if input.token.trim().is_empty() || input.username.trim().is_empty() { return ( StatusCode::UNPROCESSABLE_ENTITY, Json(serde_json::json!({ "error": "token and username are required" })), ) .into_response(); } lock_unpoisoned(&state.pending_install, "install session").github = Some(GithubInstallState::Token(input)); info!(step = "github_token", "install step completed"); StatusCode::NO_CONTENT.into_response() } async fn post_install_github_app_manifest( State(state): State, headers: HeaderMap, Query(query): Query, Json(input): Json, ) -> Response { if let Some(response) = require_valid_token(&state, &headers, query.token.as_deref()) { return response; } observe_operator(&state, &headers); let owner = match GitHubAppOwner::try_from(input.owner) { Ok(owner) => owner, Err(err) => { return install_error_response(StatusCode::UNPROCESSABLE_ENTITY, err); } }; if input.app_name.trim().is_empty() { return install_error_response(StatusCode::UNPROCESSABLE_ENTITY, "app_name is required"); } if input.allowed_username.trim().is_empty() { return install_error_response( StatusCode::UNPROCESSABLE_ENTITY, "allowed_username is required", ); } let mut pending_install = lock_unpoisoned(&state.pending_install, "install session"); let Some(server) = pending_install.server.clone() else { return missing_step_response("server"); }; let state_token = generate_ephemeral_secret(); let manifest = build_github_app_manifest( input.app_name.trim(), &format!("{}/install/github/app/redirect", server.canonical_url), &format!("{}/auth/callback/github", server.canonical_url), &format!("{}/setup", server.canonical_url), ); pending_install.pending_github_app = Some(PendingGithubApp { state: state_token.clone(), owner: owner.clone(), app_name: input.app_name.trim().to_string(), allowed_username: input.allowed_username.trim().to_string(), expires_at: Instant::now() + Duration::from_mins(10), }); Json(serde_json::json!({ "manifest": manifest, "github_form_action": owner.manifest_form_action(), "state": state_token, })) .into_response() } async fn get_install_github_app_redirect( State(state): State, headers: HeaderMap, Query(query): Query, ) -> Response { observe_operator(&state, &headers); let Some(state_token) = query .state .as_deref() .filter(|value| !value.trim().is_empty()) else { return install_github_redirect_error(&state, "missing-install-github-app-state"); }; let Some(code) = query .code .as_deref() .filter(|value| !value.trim().is_empty()) else { return install_github_redirect_error(&state, "missing-install-github-app-code"); }; let pending = { let pending_install = lock_unpoisoned(&state.pending_install, "install session"); let Some(pending) = pending_install.pending_github_app.clone() else { return install_github_redirect_error(&state, "missing-install-github-app-state"); }; if pending.expires_at <= Instant::now() { return install_github_redirect_error(&state, "expired-install-github-app-state"); } if pending.state != state_token { return install_github_redirect_error(&state, "invalid-install-github-app-state"); } pending }; match exchange_github_app_manifest_code(&state, code).await { Ok(conversion) => { let mut pending_install = lock_unpoisoned(&state.pending_install, "install session"); let Some(still_pending) = pending_install.pending_github_app.as_ref() else { return install_github_redirect_error(&state, "missing-install-github-app-state"); }; if still_pending.state != pending.state { return install_github_redirect_error(&state, "invalid-install-github-app-state"); } pending_install.pending_github_app = None; pending_install.github = Some(GithubInstallState::App(GithubAppInstall { owner: pending.owner, app_name: pending.app_name, allowed_username: pending.allowed_username, app_id: conversion.id.to_string(), slug: conversion.slug, client_id: conversion.client_id, client_secret: conversion.client_secret, webhook_secret: conversion.webhook_secret, pem: conversion.pem, })); info!(step = "github_app", "install step completed"); (StatusCode::FOUND, [( header::LOCATION, format!("/install/github/done?token={}", &*state.install_token), )]) .into_response() } Err(err) => { error!(error = ?err, "install GitHub app exchange failed"); install_github_redirect_error(&state, "github-app-manifest-conversion-failed") } } } async fn post_install_finish( State(state): State, headers: HeaderMap, Query(query): Query, ) -> Response { if let Some(response) = require_valid_token(&state, &headers, query.token.as_deref()) { return response; } observe_operator(&state, &headers); let Some(finish_guard) = InstallFinishGuard::try_acquire(Arc::clone(&state.finish_in_progress)) else { return install_error_response(StatusCode::CONFLICT, "install finish already in progress"); }; let pending_install = lock_unpoisoned(&state.pending_install, "install session").clone(); let Some(server) = pending_install.server else { return missing_step_response("server"); }; let Some(object_store) = pending_install.object_store else { return missing_step_response("object_store"); }; let Some(sandbox) = pending_install.sandbox else { return missing_step_response("sandbox"); }; let Some(llm) = pending_install.llm else { return missing_step_response("llm"); }; let Some(github) = pending_install.github else { return missing_step_response("github"); }; let mut settings_doc = toml::Value::Table(toml::Table::default()); let install_listen = state.install_listen_config(); if let Err(err) = merge_server_settings(&mut settings_doc, &server.canonical_url, &install_listen) { return install_error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()); } let object_store_env_plan = match write_object_store_settings( &mut settings_doc, &object_store.to_persistence_selection(), ) { Ok(plan) => plan, Err(err) => { return install_error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()); } }; if let Err(err) = write_sandbox_settings( &mut settings_doc, sandbox.to_persistence_selection(), sandbox.allow_local, ) { return install_error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()); } let mut vault_secrets = Vec::new(); if let InstallSandboxProviderState::Daytona { api_key } = &sandbox.provider { vault_secrets.push(SecretStoreWrite { name: EnvVars::DAYTONA_API_KEY.to_string(), value: api_key.expose_secret().to_string(), secret_type: VaultSecretType::Token, description: None, }); } for provider in llm.providers { let name = match provider_secret_name(&provider.provider) { Ok(name) => name, Err(err) => return install_error_response(StatusCode::UNPROCESSABLE_ENTITY, err), }; vault_secrets.push(SecretStoreWrite { name, value: provider.api_key, secret_type: VaultSecretType::Token, description: None, }); } let make_env_write = |key: &str, value: String| EnvFileUpdate { key: key.to_string(), value, comment: None, }; let make_env_removal = |key: &str| EnvFileRemoval { key: key.to_string(), comment: None, }; let mut server_env_writes = object_store_env_plan.writes; let mut server_env_removals = object_store_env_plan.removals; let mut vault_removals = Vec::new(); let mut dev_token: Option = None; let mut dev_token_write = None; match github { GithubInstallState::Token(github) => { if let Err(err) = write_token_settings(&mut settings_doc) { return install_error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()); } vault_secrets.push(SecretStoreWrite { name: EnvVars::GITHUB_TOKEN.to_string(), value: github.token, secret_type: VaultSecretType::Token, description: None, }); vault_removals.extend(GITHUB_APP_VAULT_KEYS.iter().map(|k| (*k).to_string())); server_env_removals.extend( GITHUB_INSTALL_SECRET_KEYS .iter() .map(|k| make_env_removal(k)), ); let dev_token_path = Storage::new(state.storage_dir.as_ref()) .runtime_directory() .dev_token_path(); let prepared = match prepare_dev_token_write_for_install(&dev_token_path) { Ok(value) => value, Err(err) => { return install_error_response( StatusCode::INTERNAL_SERVER_ERROR, err.to_string(), ); } }; dev_token_write = prepared.write; dev_token = Some(prepared.token); } GithubInstallState::App(github) => { if let Err(err) = write_github_app_settings( &mut settings_doc, &github.app_id, &github.slug, &github.client_id, &[github.allowed_username], ) { return install_error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()); } vault_secrets.push(SecretStoreWrite { name: EnvVars::GITHUB_APP_PRIVATE_KEY.to_string(), value: BASE64_STANDARD.encode(github.pem.as_bytes()), secret_type: VaultSecretType::File, description: None, }); vault_secrets.push(SecretStoreWrite { name: EnvVars::GITHUB_APP_CLIENT_SECRET.to_string(), value: github.client_secret, secret_type: VaultSecretType::Token, description: None, }); if let Some(secret) = github.webhook_secret { vault_secrets.push(SecretStoreWrite { name: EnvVars::GITHUB_APP_WEBHOOK_SECRET.to_string(), value: secret, secret_type: VaultSecretType::Token, description: None, }); } else { vault_removals.push(EnvVars::GITHUB_APP_WEBHOOK_SECRET.to_string()); } vault_removals.push(EnvVars::GITHUB_TOKEN.to_string()); server_env_removals.extend( GITHUB_INSTALL_SECRET_KEYS .iter() .map(|k| make_env_removal(k)), ); } } let settings_toml = match toml::to_string_pretty(&settings_doc) { Ok(value) => value, Err(err) => { return install_error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()); } }; let session_secret = session_secret::generate_session_secret(); server_env_writes.push(make_env_write(EnvVars::SESSION_SECRET, session_secret)); if let Some(token) = dev_token.as_ref() { server_env_writes.push(make_env_write(EnvVars::FABRO_DEV_TOKEN, token.clone())); } #[expect( clippy::disallowed_methods, reason = "install-finish handler: reads current settings file once to produce a rollback \ snapshot before writing the new settings; one-shot per install-finish request" )] let previous_settings = std::fs::read_to_string(state.config_path.as_ref()).ok(); let persistence_plan = InstallPersistencePlan { storage_dir: state.storage_dir.as_ref(), settings_write: Some(PendingSettingsWrite { path: state.config_path.as_ref(), contents: &settings_toml, previous_contents: previous_settings.as_deref(), }), server_env_writes, server_env_removals, dev_token_write, vault_writes: vault_secrets, vault_removals, }; if let Err(err) = persistence_plan.persist_direct().await { error!(error = %err, "install persistence failed"); let status = StatusCode::INTERNAL_SERVER_ERROR; let detail = err.to_string(); let title = status.canonical_reason().unwrap_or("Unknown").to_string(); let leftover_env_keys: Vec = if err.server_env_applied { persistence_plan .server_env_writes .iter() .map(|write| write.key.clone()) .collect() } else { Vec::new() }; let removed_env_keys: Vec = if err.server_env_applied { err.removed_env_keys.clone() } else { Vec::new() }; return ( status, Json(serde_json::json!({ "errors": [{ "status": status.as_u16().to_string(), "title": title, "detail": detail, }], "leftover_env_keys": leftover_env_keys, "removed_env_keys": removed_env_keys, })), ) .into_response(); } // Seed the default environment in SQLite. The server does not seed on // startup, so install is the only place the default is written; existing // rows are preserved, so re-running install never clobbers edits. if let Err(err) = seed_default_environment_in_storage( state.storage_dir.as_ref(), sandbox.to_environment_provider(), ) .await { warn!(error = %err, "failed to seed default environment after install"); } if let Ok(settings) = fabro_config::ServerSettingsBuilder::from_toml(&settings_toml) { if let Err(err) = write_artifact_store_metadata(&settings, state.storage_dir.as_ref()).await { warn!(error = %err, "failed to write artifact store metadata after install"); } } if let Some(InstallObjectStoreState::S3 { credential_mode: InstallObjectStoreCredentialMode::AccessKey, manual_credentials, .. }) = lock_unpoisoned(&state.pending_install, "install session") .object_store .as_mut() { *manual_credentials = None; } { let mut pending_install = lock_unpoisoned(&state.pending_install, "install session"); if let Some(sandbox) = pending_install.sandbox.as_mut() { if matches!( sandbox.provider, InstallSandboxProviderState::Daytona { .. } ) { sandbox.provider = InstallSandboxProviderState::Docker; } } } if let Some(finish_hook) = state.finish_hook.clone() { let info = InstallFinishInfo { canonical_url: server.canonical_url.clone(), dev_token: dev_token.clone(), }; if let Err(err) = finish_hook(&info) { warn!(error = %err, "install finish hook failed"); } } if let Some(on_finish) = state.on_finish.clone() { info!(restart_url = %server.canonical_url, "install finish succeeded"); info!("install exit scheduled"); tokio::spawn(async move { sleep(Duration::from_millis(500)).await; on_finish(); }); } else { info!(restart_url = %server.canonical_url, "install finish succeeded"); } finish_guard.disarm(); let mut body = serde_json::json!({ "status": "completing", "restart_url": server.canonical_url, }); if let Some(token) = dev_token { body["dev_token"] = serde_json::Value::String(token); } (StatusCode::ACCEPTED, Json(body)).into_response() } async fn render_install_shell( State(state): State, headers: HeaderMap, uri: OriginalUri, ) -> Response { static_files::serve_install_with_asset_root( uri.path(), &headers, state.static_asset_root.as_deref(), false, ) .await } fn token_is_valid(state: &InstallAppState, headers: &HeaderMap, query_token: Option<&str>) -> bool { [ headers .get("authorization") .and_then(|value| value.to_str().ok()) .and_then(|value| value.strip_prefix("Bearer ")), query_token, headers .get("x-install-token") .and_then(|value| value.to_str().ok()), ] .into_iter() .flatten() .any(|token| token == &*state.install_token) } fn require_valid_token( state: &InstallAppState, headers: &HeaderMap, query_token: Option<&str>, ) -> Option { (!token_is_valid(state, headers, query_token)) .then(|| ApiError::new(StatusCode::UNAUTHORIZED, "invalid install token").into_response()) } fn observe_operator(state: &InstallAppState, headers: &HeaderMap) { let current = InstallOperatorFingerprint { user_agent: headers .get(header::USER_AGENT) .and_then(|value| value.to_str().ok()) .map(ToString::to_string), remote_ip: detect_remote_ip(headers), }; if current.user_agent.is_none() && current.remote_ip.is_none() { return; } let mut first = lock_unpoisoned(&state.first_operator, "install operator"); match first.as_ref() { None => *first = Some(current), Some(initial) if initial != ¤t => { warn!( initial_user_agent = ?initial.user_agent, current_user_agent = ?current.user_agent, initial_remote_ip = ?initial.remote_ip, current_remote_ip = ?current.remote_ip, "suspected concurrent install operators" ); } Some(_) => {} } } fn detect_remote_ip(headers: &HeaderMap) -> Option { headers .get("x-forwarded-for") .and_then(|value| value.to_str().ok()) .and_then(|value| value.split(',').next()) .map(str::trim) .filter(|value| !value.is_empty()) .map(ToString::to_string) } fn install_github_redirect_error(state: &InstallAppState, error: &str) -> Response { (StatusCode::FOUND, [( header::LOCATION, format!( "/install/github?token={}&error={error}", state.install_token ), )]) .into_response() } fn detect_canonical_url(headers: &HeaderMap) -> String { let scheme = headers .get("x-forwarded-proto") .and_then(|value| value.to_str().ok()) .and_then(|value| value.split(',').next()) .map(str::trim) .filter(|value| !value.is_empty()) .unwrap_or("http"); let host = headers .get("x-forwarded-host") .or_else(|| headers.get("host")) .and_then(|value| value.to_str().ok()) .and_then(|value| value.split(',').next()) .map(str::trim) .filter(|value| !value.is_empty()) .unwrap_or("127.0.0.1:32276"); format!("{scheme}://{}", sanitize_client_facing_host(host)) } fn sanitize_client_facing_host(host: &str) -> String { let host = host.trim(); if let Some(end) = host .strip_prefix('[') .and_then(|rest| rest.find(']').map(|end| end + 1)) { let address = &host[1..end]; let suffix = &host[end + 1..]; if is_wildcard_host(address) { return format!("localhost{suffix}"); } return host.to_string(); } if let Some((address, port)) = host.rsplit_once(':') { if !address.contains(':') && is_wildcard_host(address) { return format!("localhost:{port}"); } } if is_wildcard_host(host) { return "localhost".to_string(); } host.to_string() } fn completed_steps(pending_install: &PendingInstall) -> Vec<&'static str> { let mut steps = Vec::new(); if pending_install.server.is_some() { steps.push("server"); } if pending_install.object_store.is_some() { steps.push("object_store"); } if pending_install.sandbox.is_some() { steps.push("sandbox"); } if pending_install.llm.is_some() { steps.push("llm"); } if pending_install.github.is_some() { steps.push("github"); } steps } fn redacted_llm(pending_install: &PendingInstall) -> serde_json::Value { pending_install.llm.as_ref().map_or_else( || serde_json::Value::Null, |llm| { serde_json::json!({ "providers": llm.providers.iter().map(|provider| serde_json::json!({ "provider": provider.provider.to_string(), "configured": true, })).collect::>() }) }, ) } fn redacted_github(pending_install: &PendingInstall) -> serde_json::Value { pending_install.github.as_ref().map_or_else( || serde_json::Value::Null, |github| match github { GithubInstallState::Token(github) => serde_json::json!({ "strategy": "token", "username": github.username, }), GithubInstallState::App(github) => serde_json::json!({ "strategy": "app", "owner": github.owner.as_session_value(), "app_name": github.app_name, "slug": github.slug, "allowed_username": github.allowed_username, }), }, ) } fn redacted_object_store(pending_install: &PendingInstall) -> serde_json::Value { pending_install.object_store.as_ref().map_or_else( || serde_json::Value::Null, InstallObjectStoreState::as_session_value, ) } fn redacted_sandbox(pending_install: &PendingInstall) -> serde_json::Value { pending_install.sandbox.as_ref().map_or_else( || serde_json::Value::Null, InstallSandboxState::as_session_value, ) } fn missing_step_response(step: &str) -> Response { ApiError::new( StatusCode::UNPROCESSABLE_ENTITY, format!("install step '{step}' is incomplete"), ) .into_response() } fn install_error_response(status: StatusCode, message: impl Into) -> Response { ApiError::new(status, message).into_response() } fn generate_ephemeral_secret() -> String { URL_SAFE_NO_PAD.encode(rand::random::<[u8; 32]>()) } fn build_github_app_manifest( app_name: &str, redirect_url: &str, callback_url: &str, setup_url: &str, ) -> serde_json::Value { serde_json::json!({ "name": app_name, "url": "https://fabro.sh", "redirect_url": redirect_url, "callback_urls": [callback_url], "setup_url": setup_url, "public": false, "default_permissions": { "contents": "write", "metadata": "read", "pull_requests": "write", "checks": "write", "issues": "write", "emails": "read", "vulnerability_alerts": "write", "organization_projects": "write", "packages": "read" }, "default_events": [] }) } #[expect( clippy::disallowed_types, reason = "Install HTTP client selection parses a public upstream base URL only to decide localhost proxy behavior." )] fn install_http_client_for_url(base_url: &str) -> anyhow::Result { let mut builder = fabro_http::HttpClientBuilder::new(); if fabro_http::Url::parse(base_url) .ok() .and_then(|url| url.host_str().map(ToString::to_string)) .is_some_and(|host| host == "127.0.0.1" || host == "localhost") { builder = builder.no_proxy(); } builder.build().map_err(anyhow::Error::new) } /// Parse and validate an install-time upstream URL. /// /// In production the base URL comes from installer defaults and provider /// catalog base-url settings. /// Only test code can override via /// [`InstallAppState::with_github_api_base_url`] /// or [`InstallAppState::with_provider_base_url`], but CodeQL sees those /// `pub` setters as external entry points and traces taint into the /// `format!` URL construction sites below. Passing every upstream URL /// through this parser turns it into a typed `Url` with a verified scheme /// and host before it is combined with a path segment. #[expect( clippy::disallowed_types, reason = "Install upstream endpoints are raw HTTP request URLs; logging uses separate redacted boundaries." )] fn parse_install_upstream_url(raw: &str) -> anyhow::Result { let url = fabro_http::Url::parse(raw).map_err(anyhow::Error::new)?; match url.scheme() { "http" | "https" => {} other => { bail!("install upstream URL must use http or https, got {other}"); } } if url.host_str().is_none() { bail!("install upstream URL must include a host"); } Ok(url) } /// Append `segments` as new path segments to a validated base URL. /// /// Each segment is percent-encoded by `url`, so caller-controlled values /// (e.g. a GitHub manifest `code`) cannot insert additional path components, /// alter the host, or redirect the request to a different URL scheme. #[expect( clippy::disallowed_types, reason = "Install upstream endpoints are raw HTTP request URLs; logging uses separate redacted boundaries." )] fn install_upstream_endpoint(base_url: &str, segments: &[&str]) -> anyhow::Result { let mut url = parse_install_upstream_url(base_url)?; { let mut path = url .path_segments_mut() .map_err(|()| anyhow!("install upstream URL cannot be a base"))?; for segment in segments { path.push(segment); } } Ok(url) } async fn validate_llm_provider( state: &InstallAppState, input: &InstallLlmTestInput, ) -> anyhow::Result<()> { let provider = install_catalog_provider(&input.provider).map_err(anyhow::Error::msg)?; let catalog = install_catalog_with_base_url(state, provider)?; let outcome = probe::probe_provider_with_api_key( catalog, provider.id(), input.api_key.clone(), Duration::from_secs(30), ) .await .map_err(|err| match err { ApiKeyProbeError::Setup(err) => { anyhow::Error::new(err).context("failed to create LLM client for install validation") } other => anyhow::Error::msg(other.to_string()), })?; match outcome.status { ModelTestStatus::Ok => Ok(()), ModelTestStatus::Error => Err(anyhow::anyhow!( "LLM provider validation request failed: {}", outcome .error_message .unwrap_or_else(|| "unknown error".to_string()) )), } } /// The install catalog with the provider's base URL replaced by the state /// override, when the install flow points a provider at a test upstream. fn install_catalog_with_base_url( state: &InstallAppState, provider: &CatalogProvider, ) -> anyhow::Result { let Some(base_url) = state.upstreams.provider_base_urls.get(provider.id()) else { return Ok(Catalog::clone(&INSTALL_CATALOG)); }; let overlay = fabro_config::LlmLayer( toml::from_str(&format!( "[providers.{}]\nbase_url = {}\n", toml_key(provider.id().as_str()), toml::Value::String(base_url.clone()) )) .context("install provider base URL overlay should parse")?, ); fabro_llm::build_catalog(&overlay, &|_| None) .context("install catalog with provider base URL override should build") } fn toml_key(key: &str) -> String { if key .chars() .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') { key.to_string() } else { format!("{key:?}") } } #[cfg(test)] fn provider_base_url_override(state: &InstallAppState, provider: &CatalogProvider) -> String { state .upstreams .provider_base_urls .get(provider.id()) .cloned() .unwrap_or_else(|| provider.base_url().to_string()) } async fn validate_github_token(state: &InstallAppState, token: &str) -> anyhow::Result { let base_url = state .upstreams .github_api_base_url .clone() .unwrap_or_else(|| DEFAULT_INSTALL_GITHUB_API_BASE_URL.to_string()); let endpoint = install_upstream_endpoint(&base_url, &["user"])?; let client = install_http_client_for_url(&base_url)?; let response = client .get(endpoint) .header("Authorization", format!("Bearer {token}")) .header("Accept", "application/vnd.github+json") .header("User-Agent", "fabro-server") .send() .await .map_err(anyhow::Error::new)?; if !response.status().is_success() { bail!("GitHub returned {}", response.status()); } let body: GithubUserResponse = response .json() .await .context("Failed to parse GitHub user response")?; Ok(body.login) } async fn exchange_github_app_manifest_code( state: &InstallAppState, code: &str, ) -> anyhow::Result { if !is_valid_github_manifest_code(code) { bail!("install GitHub manifest code is not in the expected format"); } let base_url = state .upstreams .github_api_base_url .clone() .unwrap_or_else(|| DEFAULT_INSTALL_GITHUB_API_BASE_URL.to_string()); let endpoint = install_upstream_endpoint(&base_url, &["app-manifests", code, "conversions"])?; let client = install_http_client_for_url(&base_url)?; let response = client .post(endpoint) .header("Accept", "application/vnd.github+json") .header("User-Agent", "fabro-server") .send() .await .map_err(anyhow::Error::new)?; if !response.status().is_success() { let status = response.status(); let _ = response.text().await; bail!("GitHub manifest conversion failed ({status})"); } response .json() .await .context("Failed to parse GitHub manifest conversion response") } /// GitHub's manifest-conversion `code` is short, unpadded-base64url by /// construction. Reject anything outside that alphabet so a malicious /// browser callback cannot smuggle extra path segments, host overrides, or /// query parameters into the request. fn is_valid_github_manifest_code(code: &str) -> bool { !code.is_empty() && code.len() <= 256 && code .chars() .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') } async fn write_artifact_store_metadata( settings: &ServerSettings, storage_dir: &Path, ) -> anyhow::Result<()> { let mut settings = settings.clone(); settings.server.storage.root = storage_dir.display().to_string(); let (object_store, prefix) = serve::build_artifact_object_store(&settings.server)?; let artifact_store = ArtifactStore::new(object_store, prefix); artifact_store.write_metadata(FABRO_VERSION).await?; Ok(()) } struct InstallListener { listener: BoundInstallListener, bind: Bind, } enum BoundInstallListener { Unix(UnixListener), Tcp(TcpListener), } async fn bind_install_listener(requested: &BindRequest) -> anyhow::Result { match requested { BindRequest::Unix(path) => { if path.exists() { std::fs::remove_file(path)?; } let listener = UnixListener::bind(path)?; Ok(InstallListener { listener: BoundInstallListener::Unix(listener), bind: Bind::Unix(path.clone()), }) } BindRequest::Tcp(address) => { let listener = TcpListener::bind(address).await?; Ok(InstallListener { bind: Bind::Tcp(listener.local_addr()?), listener: BoundInstallListener::Tcp(listener), }) } BindRequest::TcpHost(host) => { let listener = TcpListener::bind((*host, DEFAULT_TCP_PORT)).await?; Ok(InstallListener { bind: Bind::Tcp(listener.local_addr()?), listener: BoundInstallListener::Tcp(listener), }) } } } async fn wait_for_shutdown(mut shutdown_rx: watch::Receiver) { if *shutdown_rx.borrow() { return; } let _ = shutdown_rx.changed().await; } #[cfg(test)] mod tests { use std::collections::HashMap; use std::io; use std::sync::atomic::AtomicBool; use std::sync::{Arc, Mutex}; use axum::extract::{Query, State}; use axum::http::{HeaderMap, StatusCode}; use base64::Engine as _; use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; use fabro_config::{Storage, envfile}; use fabro_install::{OBJECT_STORE_ACCESS_KEY_ID_ENV, OBJECT_STORE_SECRET_ACCESS_KEY_ENV}; use fabro_static::EnvVars; use fabro_vault::SecretType as VaultSecretType; use object_store::Error as ObjectStoreError; use serde_json::json; use super::{ DEFAULT_INSTALL_GITHUB_API_BASE_URL, GitHubAppOwner, GithubAppInstall, GithubInstallState, InstallAppState, InstallAwsCredentialPair, InstallFinishGuard, InstallObjectStoreCredentialMode, InstallObjectStoreInput, InstallObjectStoreProvider, InstallObjectStoreState, InstallSandboxProviderState, InstallSandboxState, InstallTokenQuery, LlmProvidersInput, PendingInstall, ServerConfigInput, ServerSecrets, build_github_app_manifest, classify_object_store_validation_error, detect_canonical_url, install_catalog_provider, install_object_store_lookup, lock_unpoisoned, post_install_finish, provider_base_url_override, resolve_install_object_store_state, token_is_valid, write_artifact_store_metadata, }; #[test] fn github_app_manifest_excludes_workflows_permission() { let manifest = build_github_app_manifest( "Fabro Test", "https://fabro.example/setup", "https://fabro.example/auth/callback/github", "https://fabro.example/setup", ); assert!( manifest["default_permissions"].get("workflows").is_none(), "GitHub App must not be able to write workflow files" ); } #[test] fn github_app_manifest_includes_packages_read_permission() { let manifest = build_github_app_manifest( "Fabro Test", "https://fabro.example/setup", "https://fabro.example/auth/callback/github", "https://fabro.example/setup", ); assert_eq!( manifest["default_permissions"]["packages"], serde_json::json!("read"), ); } #[test] fn token_validation_accepts_any_matching_source() { let state = InstallAppState::for_test("expected"); let mut headers = HeaderMap::new(); headers.insert("authorization", "Bearer wrong".parse().unwrap()); headers.insert("x-install-token", "also-wrong".parse().unwrap()); assert!(token_is_valid(&state, &headers, Some("expected"))); } #[test] fn token_validation_falls_back_to_custom_header() { let state = InstallAppState::for_test("expected"); let mut headers = HeaderMap::new(); headers.insert("authorization", "Bearer wrong".parse().unwrap()); headers.insert("x-install-token", "expected".parse().unwrap()); assert!(token_is_valid(&state, &headers, None)); } #[test] fn canonical_url_prefers_forwarded_headers() { let mut headers = HeaderMap::new(); headers.insert("x-forwarded-proto", "https".parse().unwrap()); headers.insert("x-forwarded-host", "fabro.example.com".parse().unwrap()); assert_eq!(detect_canonical_url(&headers), "https://fabro.example.com"); } #[test] fn token_validation_requires_exact_match() { let state = InstallAppState::for_test("expected"); let mut headers = HeaderMap::new(); headers.insert("authorization", "Bearer expected".parse().unwrap()); assert!(token_is_valid(&state, &headers, None)); headers.insert("authorization", "Bearer wrong".parse().unwrap()); assert!(!token_is_valid(&state, &headers, None)); } #[test] fn pending_install_lock_recovers_after_poison() { let pending = Arc::new(Mutex::new(PendingInstall::default())); let poisoned = Arc::clone(&pending); let _ = std::panic::catch_unwind(move || { let _guard = poisoned.lock().unwrap(); panic!("poison install lock"); }); let _guard = lock_unpoisoned(&pending, "install session"); } #[test] fn finish_guard_rejects_concurrent_finish_calls() { let flag = Arc::new(AtomicBool::new(false)); let first = InstallFinishGuard::try_acquire(Arc::clone(&flag)); assert!(first.is_some()); assert!(InstallFinishGuard::try_acquire(Arc::clone(&flag)).is_none()); drop(first); assert!(InstallFinishGuard::try_acquire(flag).is_some()); } #[tokio::test] async fn finish_with_github_app_writes_runtime_secrets_to_vault_not_server_env() { let dir = tempfile::tempdir().unwrap(); let storage = Storage::new(dir.path()); let config_path = dir.path().join("settings.toml"); let server_env_path = storage.runtime_directory().env_path(); envfile::write_env_file( &server_env_path, &HashMap::from([ ( EnvVars::GITHUB_APP_PRIVATE_KEY.to_string(), "stale-private".to_string(), ), ( EnvVars::GITHUB_APP_CLIENT_SECRET.to_string(), "stale-client".to_string(), ), ( EnvVars::GITHUB_APP_WEBHOOK_SECRET.to_string(), "stale-webhook".to_string(), ), ]), ) .unwrap(); let mut stale_vault = fabro_vault::Vault::load(storage.secrets_path()).unwrap(); stale_vault .set( EnvVars::GITHUB_TOKEN, "stale-token", VaultSecretType::Token, None, ) .unwrap(); let state = InstallAppState::for_test_with_paths("install-token", dir.path(), &config_path); { let mut pending = lock_unpoisoned(&state.pending_install, "install session"); pending.server = Some(ServerConfigInput { canonical_url: "https://fabro.example".to_string(), }); pending.object_store = Some(InstallObjectStoreState::Local { root: dir.path().join("runs").display().to_string(), }); pending.sandbox = Some(InstallSandboxState { provider: InstallSandboxProviderState::Docker, allow_local: true, }); pending.llm = Some(LlmProvidersInput { providers: Vec::new(), }); pending.github = Some(GithubInstallState::App(GithubAppInstall { owner: GitHubAppOwner::Personal, app_name: "Fabro Test".to_string(), allowed_username: "octocat".to_string(), app_id: "12345".to_string(), slug: "fabro-test".to_string(), client_id: "Iv1.test".to_string(), client_secret: "vault-client-secret".to_string(), webhook_secret: Some("vault-webhook-secret".to_string()), pem: "-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----\n" .to_string(), })); } let response = post_install_finish( State(state), HeaderMap::new(), Query(InstallTokenQuery { token: Some("install-token".to_string()), }), ) .await; assert_eq!(response.status(), StatusCode::ACCEPTED); let server_env = envfile::read_env_file(&server_env_path).unwrap(); assert!(server_env.contains_key(EnvVars::SESSION_SECRET)); assert!(!server_env.contains_key(EnvVars::GITHUB_APP_PRIVATE_KEY)); assert!(!server_env.contains_key(EnvVars::GITHUB_APP_CLIENT_SECRET)); assert!(!server_env.contains_key(EnvVars::GITHUB_APP_WEBHOOK_SECRET)); let vault = fabro_vault::SecretStore::open_snapshot(storage.sqlite_path(), storage.secrets_path()) .await .unwrap(); assert_eq!(vault.get(EnvVars::GITHUB_TOKEN), None); assert_eq!( vault.get(EnvVars::GITHUB_APP_CLIENT_SECRET), Some("vault-client-secret") ); assert_eq!( vault.get(EnvVars::GITHUB_APP_WEBHOOK_SECRET), Some("vault-webhook-secret") ); let private_key_entry = vault .get_entry(EnvVars::GITHUB_APP_PRIVATE_KEY) .expect("private key should be stored in vault"); assert_eq!(private_key_entry.secret_type, VaultSecretType::File); assert_eq!( private_key_entry.value, BASE64_STANDARD.encode( "-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----\n".as_bytes() ) ); } #[test] fn install_github_requests_default_to_fixed_github_api_base_url() { assert_eq!( DEFAULT_INSTALL_GITHUB_API_BASE_URL, "https://api.github.com" ); } #[test] fn install_provider_base_url_falls_back_to_catalog_base_url() { let state = InstallAppState::for_test("expected"); let provider = install_catalog_provider(&lithos_llm::catalog::builtin::openai()).unwrap(); assert_eq!( provider_base_url_override(&state, provider), "https://api.openai.com" ); } #[test] fn install_provider_base_url_prefers_state_override() { let state = InstallAppState::for_test("expected").with_provider_base_url( lithos_llm::catalog::builtin::openai(), "https://proxy.example.com/v1", ); let provider = install_catalog_provider(&lithos_llm::catalog::builtin::openai()).unwrap(); assert_eq!( provider_base_url_override(&state, provider), "https://proxy.example.com/v1" ); } #[tokio::test] async fn write_artifact_store_metadata_creates_marker_in_overridden_storage_root() { use object_store::path::Path as ObjectPath; let dir = tempfile::tempdir().unwrap(); let settings = fabro_config::ServerSettingsBuilder::from_toml( r#" _version = 1 [server.auth] methods = ["dev-token"] "#, ) .unwrap(); write_artifact_store_metadata(&settings, dir.path()) .await .unwrap(); let mut overridden = settings.clone(); overridden.server.storage.root = dir.path().display().to_string(); let (object_store, prefix) = crate::serve::build_artifact_object_store(&overridden.server).unwrap(); let marker = if prefix.is_empty() { "store-metadata.json".to_string() } else { format!("{prefix}/store-metadata.json") }; let bytes = object_store .get(&ObjectPath::from(marker)) .await .unwrap() .bytes() .await .unwrap(); let value: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); assert_eq!(value["fabro_version"], super::FABRO_VERSION); } #[test] fn resolve_install_object_store_state_rejects_local_with_s3_fields() { let err = resolve_install_object_store_state( None, InstallObjectStoreInput { provider: InstallObjectStoreProvider::Local, root: Some("/srv/fabro/objects".to_string()), bucket: Some("fabro-data".to_string()), region: None, credential_mode: None, access_key_id: None, secret_access_key: None, }, "/srv/fabro/objects", ) .expect_err("local mode should reject S3-only fields"); assert_eq!( err, "Local disk does not accept S3 bucket, region, or AWS credential fields." ); } #[test] fn resolve_install_object_store_state_uses_local_root() { let selection = resolve_install_object_store_state( None, InstallObjectStoreInput { provider: InstallObjectStoreProvider::Local, root: Some(" /srv/fabro/objects ".to_string()), bucket: None, region: None, credential_mode: None, access_key_id: None, secret_access_key: None, }, "/default/fabro/objects", ) .expect("local mode should accept a root"); assert!(matches!( selection, InstallObjectStoreState::Local { ref root } if root == "/srv/fabro/objects" )); } #[test] fn resolve_install_object_store_state_rejects_runtime_with_submitted_access_keys() { let err = resolve_install_object_store_state( None, InstallObjectStoreInput { provider: InstallObjectStoreProvider::S3, root: None, bucket: Some("fabro-data".to_string()), region: Some("us-east-1".to_string()), credential_mode: Some(InstallObjectStoreCredentialMode::Runtime), access_key_id: Some("AKIA_FAKE_VALUE".to_string()), secret_access_key: Some("fake-secret-value".to_string()), }, "/srv/fabro/objects", ) .expect_err("runtime mode should reject submitted access keys"); assert_eq!( err, "AWS access key fields are only allowed when using manual AWS access key credentials." ); } #[test] #[expect( clippy::disallowed_methods, reason = "sync std::fs::write in a unit test fixture; not on a Tokio path" )] fn install_object_store_lookup_overrides_static_keys_and_suppresses_session_token() { let temp_dir = tempfile::tempdir().unwrap(); let env_path = temp_dir.path().join("server.env"); std::fs::write( &env_path, "\ AWS_ACCESS_KEY_ID=ambient-access\n\ AWS_SECRET_ACCESS_KEY=ambient-secret\n\ AWS_SESSION_TOKEN=ambient-session\n\ AWS_WEB_IDENTITY_TOKEN_FILE=/tmp/fabro-web-identity-token\n", ) .unwrap(); let server_secrets = ServerSecrets::load(env_path.clone(), HashMap::new()).unwrap(); let manual_credentials = InstallAwsCredentialPair::new("submitted-access", "submitted-secret"); let lookup = install_object_store_lookup(&server_secrets, Some(&manual_credentials)); assert_eq!( lookup(OBJECT_STORE_ACCESS_KEY_ID_ENV).as_deref(), Some("submitted-access") ); assert_eq!( lookup(OBJECT_STORE_SECRET_ACCESS_KEY_ENV).as_deref(), Some("submitted-secret") ); assert_eq!(lookup(EnvVars::AWS_SESSION_TOKEN), None); assert_eq!( lookup(EnvVars::AWS_WEB_IDENTITY_TOKEN_FILE).as_deref(), Some("/tmp/fabro-web-identity-token") ); } #[test] fn classify_object_store_validation_error_reports_region_mismatch() { let err = ObjectStoreError::Generic { store: "AmazonS3", source: Box::new(io::Error::other( "Received redirect without LOCATION, this normally indicates an incorrectly configured region", )), }; assert_eq!( classify_object_store_validation_error("fabro-data", "us-east-1", 0, &err), "Bucket fabro-data is not reachable in region us-east-1. Verify the AWS region and try again." ); } #[test] fn install_secret_debug_display_and_json_are_redacted() { let manual_credentials = InstallAwsCredentialPair::new("AKIA_STRUCTURALLY_REALISTIC", "secret-value-123"); let debug = format!("{manual_credentials:?}"); let rendered = json!({ "access_key_id": &manual_credentials.access_key_id, "secret_access_key": &manual_credentials.secret_access_key, }) .to_string(); assert!(!debug.contains("AKIA_STRUCTURALLY_REALISTIC")); assert!(!debug.contains("secret-value-123")); assert!(!rendered.contains("AKIA_STRUCTURALLY_REALISTIC")); assert!(!rendered.contains("secret-value-123")); assert!(rendered.contains("[REDACTED]")); } }