refactor(server): lock down server secrets

This commit is contained in:
Bryan Helmkamp 2026-04-23 07:15:27 -04:00
parent 732e6f96f3
commit e6e091fe8e
No known key found for this signature in database
32 changed files with 653 additions and 384 deletions

View file

@ -100,6 +100,7 @@ When working on Rust crates, read the relevant strategy doc **before** making ch
- **`docs-internal/logging-strategy.md`** — read when adding `tracing` calls (`info!`, `debug!`, `warn!`, `error!`), working on error handling paths, or adding new operations that should be observable
- **`docs-internal/events-strategy.md`** — read when adding or modifying `Event` variants, touching `Emitter`/`emit()`, changing `progress.jsonl` output, or adding new workflow stage types
- **`files-internal/testing-strategy.md`** — read when adding or reorganizing tests, choosing between unit vs `tests/it`, deciding whether a test belongs in `cmd` vs `workflow` vs `scenario`, or deciding how to structure snapshots and fixtures
- **`docs-internal/server-secrets-strategy.md`** — read when adding or changing server-level secrets, startup validation, install-time secret persistence, or subprocess env inheritance/scrubbing
## Shell quoting in sandbox code

View file

@ -20,6 +20,8 @@ disallowed-methods = [
{ path = "std::fs::File::create", reason = "Blocking open; prefer tokio::fs::File::create on Tokio paths. Document intentional sync I/O with #[expect(clippy::disallowed_methods, reason = \"...\")]" },
{ path = "std::fs::File::create_new", reason = "Blocking open; prefer tokio::fs::File::create_new on Tokio paths. Document intentional sync I/O with #[expect(clippy::disallowed_methods, reason = \"...\")]" },
{ path = "std::fs::OpenOptions::open", reason = "Blocking open; prefer tokio::fs::OpenOptions::open on Tokio paths. OS file-lock semantics may require spawn_blocking instead. Document intentional sync I/O with #[expect(clippy::disallowed_methods, reason = \"...\")]" },
{ path = "std::env::set_var", reason = "Server/process env must be injected at construction or child-process spawn time, not mutated globally. See docs-internal/server-secrets-strategy.md" },
{ path = "std::env::remove_var", reason = "Server/process env must be injected at construction or child-process spawn time, not mutated globally. See docs-internal/server-secrets-strategy.md" },
{ path = "reqwest::Client::new", reason = "Use fabro_http::http_client() or fabro_http::test_http_client()", allow-invalid = true },
{ path = "reqwest::Client::builder", reason = "Use fabro_http::HttpClientBuilder::new()", allow-invalid = true },
{ path = "reqwest::blocking::Client::new", reason = "Use fabro_http::blocking_http_client() or fabro_http::blocking_test_http_client()", allow-invalid = true },

View file

@ -0,0 +1,68 @@
# Server Secrets Strategy
This document defines how Fabro handles server-level secrets.
## Core Rules
- `ServerSecrets` is the canonical server-secret reader.
- It reads from `process env` and `<storage>/server.env`.
- Resolution is snapshot-based: env and file are read once at construction, then treated as immutable for the life of the process.
- `process env` wins over `server.env` on conflicts.
- `fabro server start` never generates secrets. Missing required secrets are a startup error.
- `std::env::set_var` and `std::env::remove_var` are banned workspace-wide. Tests are not exempt.
## Active Server Secrets
These values belong to the server runtime and are read via `state.server_secret(...)`:
| Secret | Used by |
|---|---|
| `SESSION_SECRET` | Cookie encryption and JWT signing derivation |
| `FABRO_DEV_TOKEN` | Dev-token auth for worker/server interactions |
| `GITHUB_APP_PRIVATE_KEY` | GitHub App credentials |
| `GITHUB_APP_WEBHOOK_SECRET` | GitHub webhook verification |
| `GITHUB_APP_CLIENT_SECRET` | GitHub OAuth login |
`FABRO_JWT_PRIVATE_KEY` and `FABRO_JWT_PUBLIC_KEY` are removed. `SESSION_SECRET` is the single auth root.
## Startup
- Foreground and daemon startup use the same validation path.
- Required-at-startup secrets are:
- `SESSION_SECRET`
- `FABRO_DEV_TOKEN` when dev-token auth is enabled
- `GITHUB_APP_CLIENT_SECRET` when GitHub auth is enabled
- Other server secrets remain lazy/feature-specific rather than universal boot blockers.
## Provisioning
Server secrets come from one of two sources:
- Platform env for 12-factor deployments
- `server.env` written by install flows
There is no compatibility layer for removed secrets and no startup-time secret generation.
## Subprocess Boundaries
- Worker and render-graph subprocesses start from `env_clear()` and re-add only explicit allowlisted variables.
- Authority-bearing values are re-injected intentionally.
- The daemon child inherits the parent env unchanged except for output-format hygiene (`FABRO_JSON` removal).
## Tests
- In-process tests must inject server secrets with construction-time stubs (`EnvSource`, `StubEnv`) or by writing `server.env`.
- Subprocess tests must set child env with `Command::env`.
- Tests must not mutate the process-wide environment.
## Rotation
- Secret rotation requires restart.
- Live rotation is intentionally unsupported.
## Adding A New Server Secret
1. Provision it through platform env or install-written `server.env`.
2. Read it through `state.server_secret(...)`.
3. Decide explicitly whether startup should fail when it is absent.
4. If a worker or render subprocess needs it, re-inject it explicitly rather than broadening inheritance casually.

View file

@ -30,7 +30,7 @@ Fabro is single-tenant software designed for small, trusted teams. The following
- **Enable authentication.** Fabro supports `dev-token` and GitHub OAuth. Do not disable auth outside of local development or controlled demos.
- **Configure a username allowlist for GitHub OAuth.** `[server.auth.github].allowed_usernames` should contain the exact GitHub users allowed to log in. An empty list rejects everyone.
- **Configure the session secret used by the web flow.** `SESSION_SECRET` should be provisioned with a strong value on long-lived deployments. If you also provision `FABRO_JWT_PRIVATE_KEY` and `FABRO_JWT_PUBLIC_KEY`, treat them as server runtime secrets, but they are not what currently gates browser auth.
- **Configure the session secret used by the web flow.** `SESSION_SECRET` should be provisioned with a strong value on long-lived deployments.
- **Terminate HTTPS or mTLS upstream when needed.** Fabro's listener is plain HTTP/Unix only. If CI, scripts, or a browser must connect over HTTPS, terminate TLS at a reverse proxy or load balancer and keep the Fabro listener on a private network.
### Secrets

View file

@ -299,7 +299,6 @@ For the auth model above, the main server runtime secrets are:
- `SESSION_SECRET` when the web UI is enabled
- `FABRO_DEV_TOKEN` when `"dev-token"` auth is enabled
- `GITHUB_APP_CLIENT_SECRET` when `"github"` auth is enabled
- `FABRO_JWT_PRIVATE_KEY` / `FABRO_JWT_PUBLIC_KEY`, provisioned during install for future CLI login flows
- `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` when the install wizard or a manual config uses
static S3 object-store credentials
@ -332,8 +331,6 @@ Fabro resolves these from `process env -> server.env`.
| Variable | Description |
|---|---|
| `FABRO_JWT_PRIVATE_KEY` | Ed25519 private key (base64-encoded PEM) for JWT signing |
| `FABRO_JWT_PUBLIC_KEY` | Ed25519 public key (base64-encoded PEM) for JWT verification |
| `SESSION_SECRET` | Session encryption secret (64-character hex string) |
### Object store runtime secrets (optional)

View file

@ -260,7 +260,7 @@ The web wizard produces **the same on-disk state** as the CLI install. The TOML-
Files written:
- `~/.fabro/settings.toml` — server config, auth methods, GitHub integration strategy.
- `<storage_dir>/server.env``FABRO_JWT_PRIVATE_KEY`, `FABRO_JWT_PUBLIC_KEY`, `SESSION_SECRET`, `FABRO_DEV_TOKEN`, plus GitHub App env pairs (`GITHUB_APP_PRIVATE_KEY`, `GITHUB_APP_CLIENT_SECRET`, `GITHUB_APP_WEBHOOK_SECRET`) if the App strategy was chosen.
- `<storage_dir>/server.env``SESSION_SECRET`, `FABRO_DEV_TOKEN`, plus GitHub App env pairs (`GITHUB_APP_PRIVATE_KEY`, `GITHUB_APP_CLIENT_SECRET`, `GITHUB_APP_WEBHOOK_SECRET`) if the App strategy was chosen.
- `<storage_dir>/vaults/default/secrets.json` — vault entries for LLM API key credentials and (if Token strategy) `GITHUB_TOKEN`. Path matches `Storage::secrets_path()` at `lib/crates/fabro-config/src/storage.rs:38`.
- `<storage_dir>/server.dev-token` — the per-storage dev token, written via `Storage::server_state().dev_token_path()` at `storage.rs:103`. The CLI install also writes a home-level mirror at `Home::from_env().dev_token_path()` (`install.rs:1994-1999`); the web flow does the same to keep parity, since the home-level file is what tooling outside the storage dir expects to find.
- Artifact store metadata stamped with `FABRO_VERSION` via `write_artifact_store_metadata` (`install.rs:1458`).

View file

@ -26,8 +26,8 @@ use fabro_config::daemon::ServerDaemon;
use fabro_config::user::{SETTINGS_CONFIG_FILENAME, default_storage_dir};
use fabro_config::{ResolveError, Storage, envfile};
use fabro_install::{
InstallListenConfig, generate_jwt_keypair, merge_server_settings as merge_server_settings_impl,
write_github_app_settings, write_token_settings,
InstallListenConfig, PendingSettingsWrite, merge_server_settings as merge_server_settings_impl,
persist_install_outputs_direct, write_github_app_settings, write_token_settings,
};
use fabro_model::Provider;
use fabro_server::serve;
@ -883,13 +883,6 @@ enum PendingGitHubSettings {
},
}
#[derive(Clone, Copy)]
struct PendingSettingsWrite<'a> {
path: &'a Path,
contents: &'a str,
previous_contents: Option<&'a str>,
}
async fn setup_github_app(
s: &Styles,
web_url: &str,
@ -1148,15 +1141,24 @@ fn credential_secret_request(credential: &AuthCredential) -> Result<CreateSecret
})
}
fn persist_server_env_secrets(storage_dir: &Path, secrets: &[(String, String)]) -> Result<()> {
if secrets.is_empty() {
return Ok(());
}
fn server_env_updates(secrets: &[(String, String)]) -> Vec<envfile::EnvFileUpdate> {
secrets
.iter()
.map(|(key, value)| envfile::EnvFileUpdate {
key: key.clone(),
value: value.clone(),
comment: None,
})
.collect()
}
let env_path = Storage::new(storage_dir).runtime_directory().env_path();
envfile::merge_env_file(&env_path, secrets.iter().cloned())
.with_context(|| format!("merging server env secrets into {}", env_path.display()))?;
Ok(())
fn server_env_removals(keys: &[&'static str]) -> Vec<envfile::EnvFileRemoval> {
keys.iter()
.map(|key| envfile::EnvFileRemoval {
key: (*key).to_string(),
comment: None,
})
.collect()
}
async fn persist_install_outputs(
@ -1221,20 +1223,15 @@ fn persist_github_install_changes(
let previous_vault = std::fs::read_to_string(&vault_path).ok();
let result = (|| -> Result<()> {
let mut server_env = envfile::read_env_file(&server_env_path)
.with_context(|| format!("reading env file {}", server_env_path.display()))?;
for key in &writes.server_env_remove {
server_env.remove(*key);
}
for (key, value) in &writes.server_env_set {
server_env.insert(key.clone(), value.clone());
}
if server_env.is_empty() {
restore_optional_file(&server_env_path, None)?;
} else {
envfile::write_env_file(&server_env_path, &server_env)
.with_context(|| format!("writing env file {}", server_env_path.display()))?;
}
let server_env_writes = server_env_updates(&writes.server_env_set);
let server_env_removals = server_env_removals(&writes.server_env_remove);
persist_install_outputs_direct(
storage_dir,
&server_env_writes,
&server_env_removals,
&[],
Some(&writes.settings_write),
)?;
let mut vault = Vault::load(vault_path.clone()).map_err(anyhow::Error::from)?;
for key in &writes.vault_remove {
@ -1249,14 +1246,6 @@ fn persist_github_install_changes(
.map_err(anyhow::Error::from)?;
}
std::fs::write(writes.settings_write.path, writes.settings_write.contents).with_context(
|| {
format!(
"writing settings file {}",
writes.settings_write.path.display()
)
},
)?;
Ok(())
})();
@ -1305,12 +1294,16 @@ async fn persist_install_outputs_with_settings(
connect_server: impl for<'a> Fn(&'a Path) -> BoxFuture<'a, Result<server_client::Client>>,
stop_server: impl for<'a> Fn(&'a Path, Duration) -> BoxFuture<'a, bool>,
) -> Result<()> {
persist_server_env_secrets(storage_dir, server_env_secrets)?;
if let Some(write) = settings_write {
std::fs::write(write.path, write.contents)
.with_context(|| format!("writing settings file {}", write.path.display()))?;
}
let server_env_path = Storage::new(storage_dir).runtime_directory().env_path();
let previous_server_env = std::fs::read_to_string(&server_env_path).ok();
let settings_write_ref = settings_write.as_ref();
persist_install_outputs_direct(
storage_dir,
&server_env_updates(server_env_secrets),
&[],
&[],
settings_write_ref,
)?;
let persist_result = persist_vault_secrets_with(
storage_dir,
@ -1322,6 +1315,7 @@ async fn persist_install_outputs_with_settings(
.await;
if let Err(err) = persist_result {
restore_optional_file(&server_env_path, previous_server_env.as_deref())?;
if let Some(write) = settings_write {
match write.previous_contents {
Some(previous) => std::fs::write(write.path, previous)
@ -1820,13 +1814,6 @@ async fn run_install_inner(
s.green.apply_to("")
);
let (jwt_private_pem, jwt_public_pem) = generate_jwt_keypair()?;
fabro_util::printerr!(
printer,
" {} Ed25519 JWT keypair generated",
s.green.apply_to("")
);
let dev_token = if fabro_config::dev_token_auth_enabled(&install_settings) {
let token = dev_token::read_or_mint_dev_token_for_install(
&fabro_util::Home::from_env().dev_token_path(),
@ -1847,14 +1834,7 @@ async fn run_install_inner(
None
};
let jwt_private_b64 = BASE64_STANDARD.encode(jwt_private_pem.as_bytes());
let jwt_public_b64 = BASE64_STANDARD.encode(jwt_public_pem.as_bytes());
let mut generated_server_env_pairs = vec![
("FABRO_JWT_PRIVATE_KEY".to_string(), jwt_private_b64),
("FABRO_JWT_PUBLIC_KEY".to_string(), jwt_public_b64),
("SESSION_SECRET".to_string(), session_secret),
];
let mut generated_server_env_pairs = vec![("SESSION_SECRET".to_string(), session_secret)];
if let Some(token) = dev_token {
generated_server_env_pairs.push(("FABRO_DEV_TOKEN".to_string(), token));
}
@ -2039,39 +2019,6 @@ mod tests {
assert!(secret.chars().all(|c| !c.is_ascii_uppercase()));
}
// -- JWT keypair --
#[tokio::test]
async fn jwt_keypair_private_pem_header() {
let (private, _) = generate_jwt_keypair().unwrap();
assert!(
private.starts_with("-----BEGIN PRIVATE KEY-----"),
"private PEM: {private}"
);
}
#[tokio::test]
async fn jwt_keypair_public_pem_header() {
let (_, public) = generate_jwt_keypair().unwrap();
assert!(
public.starts_with("-----BEGIN PUBLIC KEY-----"),
"public PEM: {public}"
);
}
#[tokio::test]
async fn jwt_keypair_public_parses() {
let (_, public) = generate_jwt_keypair().unwrap();
jsonwebtoken::DecodingKey::from_ed_pem(public.as_bytes()).expect("public key should parse");
}
#[tokio::test]
async fn jwt_keypair_private_parses() {
let (private, _) = generate_jwt_keypair().unwrap();
jsonwebtoken::EncodingKey::from_ed_pem(private.as_bytes())
.expect("private key should parse");
}
// -- Config TOML generation --
#[test]
@ -2524,11 +2471,8 @@ client_id = "client-id"
#[tokio::test]
async fn persist_install_outputs_persists_vault_secrets_via_server_when_autostarting() {
let dir = tempfile::tempdir().unwrap();
let server_env_pairs = vec![
("SESSION_SECRET".to_string(), "session".to_string()),
("FABRO_JWT_PUBLIC_KEY".to_string(), "public-key".to_string()),
];
let vault_secrets = vec![
let server_env_pairs = [("SESSION_SECRET".to_string(), "session".to_string())];
let vault_secrets = [
CreateSecretRequest {
name: "GITHUB_TOKEN".to_string(),
value: "gh-token".to_string(),
@ -2562,7 +2506,8 @@ client_id = "client-id"
.await;
let stop_called = Arc::new(AtomicBool::new(false));
persist_server_env_secrets(dir.path(), &server_env_pairs).unwrap();
let env_path = Storage::new(dir.path()).runtime_directory().env_path();
envfile::merge_env_file(&env_path, server_env_pairs.iter().cloned()).unwrap();
persist_vault_secrets_with(
dir.path(),
&vault_secrets,
@ -2589,7 +2534,6 @@ client_id = "client-id"
std::fs::read_to_string(Storage::new(dir.path()).runtime_directory().env_path())
.unwrap();
assert!(server_env.contains("SESSION_SECRET=session"));
assert!(server_env.contains("FABRO_JWT_PUBLIC_KEY=public-key"));
assert_eq!(created.calls_async().await, 2);
assert!(stop_called.load(Ordering::SeqCst));
assert!(!Storage::new(dir.path()).secrets_path().exists());
@ -2598,7 +2542,7 @@ client_id = "client-id"
#[tokio::test]
async fn persist_vault_secrets_with_leaves_running_server_up() {
let dir = tempfile::tempdir().unwrap();
let vault_secrets = vec![CreateSecretRequest {
let vault_secrets = [CreateSecretRequest {
name: "GITHUB_TOKEN".to_string(),
value: "gh-token".to_string(),
type_: ApiSecretType::Environment,
@ -2806,10 +2750,10 @@ client_id = "client-id"
}
#[tokio::test]
async fn persist_install_outputs_with_settings_does_not_write_settings_on_secret_failure() {
async fn persist_install_outputs_with_settings_rolls_back_new_files_on_secret_failure() {
let dir = tempfile::tempdir().unwrap();
let server_env_pairs = vec![("SESSION_SECRET".to_string(), "session".to_string())];
let vault_secrets = vec![CreateSecretRequest {
let server_env_pairs = [("SESSION_SECRET".to_string(), "session".to_string())];
let vault_secrets = [CreateSecretRequest {
name: "GITHUB_CLI_TOKEN".to_string(),
value: "gh-token".to_string(),
type_: ApiSecretType::Environment,
@ -2844,7 +2788,7 @@ client_id = "client-id"
assert!(result.is_err());
assert!(
Storage::new(dir.path())
!Storage::new(dir.path())
.runtime_directory()
.env_path()
.exists()
@ -2856,8 +2800,8 @@ client_id = "client-id"
#[tokio::test]
async fn persist_install_outputs_with_settings_restores_previous_contents_on_secret_failure() {
let dir = tempfile::tempdir().unwrap();
let server_env_pairs = vec![("SESSION_SECRET".to_string(), "session".to_string())];
let vault_secrets = vec![CreateSecretRequest {
let server_env_pairs = [("SESSION_SECRET".to_string(), "session".to_string())];
let vault_secrets = [CreateSecretRequest {
name: "GITHUB_CLI_TOKEN".to_string(),
value: "gh-token".to_string(),
type_: ApiSecretType::Environment,

View file

@ -7,15 +7,15 @@ use std::path::{Path, PathBuf};
use std::time::Duration;
use anyhow::{Context, Result, anyhow, bail};
use fabro_config::RuntimeDirectory;
use fabro_config::bind::{Bind, BindRequest};
use fabro_config::daemon::ServerDaemon;
use fabro_config::user::{FABRO_CONFIG_ENV, default_settings_path, load_settings_config};
use fabro_config::{RuntimeDirectory, envfile};
use fabro_server::jwt_auth::auth_method_name;
use fabro_server::serve::{DEFAULT_TCP_PORT, ServeArgs};
use fabro_server::serve::{DEFAULT_TCP_PORT, ServeArgs, resolve_runtime_server_settings_for_start};
use fabro_server::{ProcessEnv, validate_startup};
use fabro_types::settings::ServerAuthMethod;
use fabro_util::printer::Printer;
use fabro_util::session_secret;
use fabro_util::terminal::Styles;
use tokio::net::{TcpStream, UnixStream};
use tokio::process::Command as TokioCommand;
@ -222,33 +222,6 @@ fn configured_auth_methods(config_path: Option<&Path>) -> Vec<ServerAuthMethod>
.unwrap_or_default()
}
fn valid_session_secret(secret: &str) -> bool {
session_secret::validate_session_secret(secret).is_ok()
}
fn load_or_create_local_session_secret(runtime_directory: &RuntimeDirectory) -> Result<String> {
if let Some(secret) = std::env::var("SESSION_SECRET")
.ok()
.filter(|secret| valid_session_secret(secret))
{
return Ok(secret);
}
let server_env_path = runtime_directory.env_path();
if let Some(secret) = envfile::read_env_file(&server_env_path)
.ok()
.and_then(|entries| entries.get("SESSION_SECRET").cloned())
.filter(|secret| valid_session_secret(secret))
{
return Ok(secret);
}
let secret = session_secret::generate_session_secret();
envfile::merge_env_file(&server_env_path, [("SESSION_SECRET", secret.as_str())])
.with_context(|| format!("merging session secret into {}", server_env_path.display()))?;
Ok(secret)
}
// ---------------------------------------------------------------------------
// Foreground mode
// ---------------------------------------------------------------------------
@ -261,18 +234,6 @@ async fn execute_foreground(
styles: &'static Styles,
_printer: Printer,
) -> Result<()> {
let session_secret = load_or_create_local_session_secret(&RuntimeDirectory::new(&storage_dir))?;
let prior_session_secret = std::env::var_os("SESSION_SECRET");
std::env::set_var("SESSION_SECRET", &session_secret);
let _env_guard =
scopeguard::guard(
prior_session_secret,
|prior_session_secret| match prior_session_secret {
Some(value) => std::env::set_var("SESSION_SECRET", value),
None => std::env::remove_var("SESSION_SECRET"),
},
);
super::foreground::serve_with_daemon_record(serve_args, bind, storage_dir, styles).await
}
@ -303,6 +264,14 @@ async fn execute_daemon(
return Ok(());
}
let resolved_settings = resolve_runtime_server_settings_for_start(serve_args, storage_dir)?;
validate_startup(
runtime_directory.env_path().as_path(),
&ProcessEnv,
&resolved_settings,
)
.map_err(anyhow::Error::from)?;
let log_path = runtime_directory.log_path();
if let Some(parent) = log_path.parent() {
std::fs::create_dir_all(parent)
@ -347,9 +316,7 @@ async fn execute_daemon(
cmd.arg("--watch-web");
}
let session_secret = load_or_create_local_session_secret(&runtime_directory)?;
cmd.arg("--storage-dir").arg(storage_dir);
cmd.env("SESSION_SECRET", &session_secret);
cmd.env_remove("FABRO_JSON");
cmd.stdout(stdout_log)

View file

@ -12,9 +12,16 @@ use fabro_server::serve::resolve_bind_request_from_settings;
use fabro_types::settings::{ServerAuthMethod, SettingsLayer};
pub(crate) fn storage_dir(settings: &SettingsLayer) -> Result<PathBuf> {
storage_dir_with_lookup(settings, &|name| std::env::var(name).ok())
}
pub(crate) fn storage_dir_with_lookup(
settings: &SettingsLayer,
lookup: &dyn Fn(&str) -> Option<String>,
) -> Result<PathBuf> {
let storage_root = fabro_config::resolve_storage_root(settings);
let resolved_root = storage_root
.resolve(|name| std::env::var(name).ok())
.resolve(lookup)
.map_err(|err| anyhow::anyhow!("failed to resolve {}: {err}", storage_root.as_source()))?;
Ok(PathBuf::from(resolved_root.value))
}

View file

@ -500,21 +500,20 @@ mod tests {
#[test]
fn resolve_local_tcp_credential_does_not_fallback_to_home_dev_token() {
let temp_home = tempfile::tempdir().unwrap();
std::fs::write(
temp_home.path().join("dev-token"),
"fabro_dev_abababababababababababababababababababababababababababababababab",
)
.unwrap();
let original_home = std::env::var_os("FABRO_HOME");
std::env::set_var("FABRO_HOME", temp_home.path());
let _guard = scopeguard::guard(original_home, |original_home| match original_home {
Some(value) => std::env::set_var("FABRO_HOME", value),
None => std::env::remove_var("FABRO_HOME"),
});
let token = "fabro_dev_abababababababababababababababababababababababababababababababab";
std::fs::write(temp_home.path().join("dev-token"), token).unwrap();
let target = ServerTarget::http_url("http://127.0.0.1:32276").unwrap();
let store = AuthStore::new(temp_home.path().join("auth.json"));
assert_eq!(
load_cli_dev_token_from_sources(None, &Home::new(temp_home.path())).as_deref(),
Some(token)
);
assert!(resolve_local_tcp_credential(&target).unwrap().is_none());
assert!(
resolve_local_tcp_credential_with_store(&target, None, &store, Utc::now())
.unwrap()
.is_none()
);
}
#[test]

View file

@ -281,13 +281,13 @@ root = "{{ env.FABRO_STORAGE_ROOT }}"
"#,
);
let temp = tempfile::tempdir().unwrap();
let original = std::env::var_os("FABRO_STORAGE_ROOT");
std::env::set_var("FABRO_STORAGE_ROOT", temp.path());
let _guard = scopeguard::guard(original, |original| match original {
Some(value) => std::env::set_var("FABRO_STORAGE_ROOT", value),
None => std::env::remove_var("FABRO_STORAGE_ROOT"),
});
assert_eq!(storage_dir(&settings).unwrap(), temp.path());
assert_eq!(
local_server::storage_dir_with_lookup(&settings, &|name| {
(name == "FABRO_STORAGE_ROOT").then(|| temp.path().display().to_string())
})
.unwrap(),
temp.path()
);
}
}

View file

@ -9,12 +9,17 @@ use crate::support::{fatal_error_line, unique_run_id};
const TEST_DEV_TOKEN: &str =
"fabro_dev_abababababababababababababababababababababababababababababababab";
const TEST_SESSION_SECRET: &str =
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
fn provision_local_server_auth(context: &fabro_test::TestContext, storage_dir: &std::path::Path) {
context.ensure_home_server_auth_methods();
let server_env_path = Storage::new(storage_dir).runtime_directory().env_path();
envfile::merge_env_file(&server_env_path, [("FABRO_DEV_TOKEN", TEST_DEV_TOKEN)])
.expect("merging FABRO_DEV_TOKEN into server.env");
envfile::merge_env_file(&server_env_path, [
("FABRO_DEV_TOKEN", TEST_DEV_TOKEN),
("SESSION_SECRET", TEST_SESSION_SECRET),
])
.expect("merging server auth into server.env");
dev_token::write_dev_token(
&context.home_dir.join(".fabro").join("dev-token"),
TEST_DEV_TOKEN,

View file

@ -21,6 +21,8 @@ use fabro_util::dev_token;
const TEST_DEV_TOKEN: &str =
"fabro_dev_abababababababababababababababababababababababababababababababab";
const TEST_SESSION_SECRET: &str =
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
fn write_dev_token_server_settings(config_path: &std::path::Path, rest: &str) {
std::fs::write(
@ -32,8 +34,11 @@ fn write_dev_token_server_settings(config_path: &std::path::Path, rest: &str) {
fn provision_dev_token_auth(home_dir: &std::path::Path, storage_dir: &std::path::Path) {
let server_env_path = Storage::new(storage_dir).runtime_directory().env_path();
envfile::merge_env_file(&server_env_path, [("FABRO_DEV_TOKEN", TEST_DEV_TOKEN)])
.expect("merging FABRO_DEV_TOKEN into server.env");
envfile::merge_env_file(&server_env_path, [
("FABRO_DEV_TOKEN", TEST_DEV_TOKEN),
("SESSION_SECRET", TEST_SESSION_SECRET),
])
.expect("merging server auth into server.env");
dev_token::write_dev_token(&home_dir.join(".fabro").join("dev-token"), TEST_DEV_TOKEN)
.expect("writing home dev-token");
}

View file

@ -13,10 +13,16 @@ fn start_status_stop_lifecycle() {
let server_env_path = fabro_config::Storage::new(&storage_dir)
.runtime_directory()
.env_path();
fabro_config::envfile::merge_env_file(&server_env_path, [(
"FABRO_DEV_TOKEN",
"fabro_dev_abababababababababababababababababababababababababababababababab",
)])
fabro_config::envfile::merge_env_file(&server_env_path, [
(
"FABRO_DEV_TOKEN",
"fabro_dev_abababababababababababababababababababababababababababababababab",
),
(
"SESSION_SECRET",
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
),
])
.unwrap();
fabro_util::dev_token::write_dev_token(
&context.home_dir.join(".fabro").join("dev-token"),

View file

@ -22,7 +22,8 @@ use fabro_server::auth::GithubEndpoints;
use fabro_server::ip_allowlist::IpAllowlistConfig;
use fabro_server::jwt_auth::resolve_auth_mode_with_lookup;
use fabro_server::server::{
RouterOptions, build_router_with_options, create_app_state_with_env_lookup,
RouterOptions, build_router_with_options,
create_app_state_with_env_lookup_and_server_secret_env,
};
use fabro_test::{GitHubAppState, TestContext, apply_test_isolation};
use fabro_types::RunAuthMethod;
@ -79,12 +80,23 @@ impl RealAuthHarness {
_ => None,
})
.expect("auth mode should resolve");
let state = create_app_state_with_env_lookup(settings, 5, move |name| match name {
"SESSION_SECRET" => Some(TEST_SESSION_SECRET.to_string()),
"GITHUB_APP_CLIENT_SECRET" => Some(github_client_secret.clone()),
"FABRO_DEV_TOKEN" => dev_token.clone(),
_ => None,
});
let state =
create_app_state_with_env_lookup_and_server_secret_env(settings, 5, |_| None, {
let mut secrets = std::collections::HashMap::from([
(
"SESSION_SECRET".to_string(),
TEST_SESSION_SECRET.to_string(),
),
(
"GITHUB_APP_CLIENT_SECRET".to_string(),
github_client_secret.clone(),
),
]);
if let Some(token) = dev_token.clone() {
secrets.insert("FABRO_DEV_TOKEN".to_string(), token);
}
secrets
});
let github_base = github_base_url(&twin.base_url);
let router = build_router_with_options(
state,

View file

@ -358,7 +358,7 @@ mod tests {
let entries = merge_env_file(&path, [
("SESSION_SECRET", "secret"),
("FABRO_JWT_PUBLIC_KEY", "jwt"),
("FABRO_DEV_TOKEN", "token"),
])
.unwrap();
@ -368,8 +368,8 @@ mod tests {
Some("secret")
);
assert_eq!(
entries.get("FABRO_JWT_PUBLIC_KEY").map(String::as_str),
Some("jwt")
entries.get("FABRO_DEV_TOKEN").map(String::as_str),
Some("token")
);
}

View file

@ -6,17 +6,8 @@
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use base64::Engine as _;
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
use fabro_config::{Storage, envfile};
use fabro_vault::{SecretType as VaultSecretType, Vault};
use ring::rand::SystemRandom;
use ring::signature::{Ed25519KeyPair, KeyPair as _};
const ED25519_SPKI_PREFIX: [u8; 12] = [
0x30, 0x2A, 0x30, 0x05, 0x06, 0x03, 0x2B, 0x65, 0x70, 0x03, 0x21, 0x00,
];
const ED25519_PUBLIC_KEY_LEN: usize = 32;
pub struct PendingSettingsWrite<'a> {
pub path: &'a Path,
@ -95,47 +86,6 @@ impl std::error::Error for PersistInstallOutputsError {
}
}
fn pem_encode(label: &str, bytes: &[u8]) -> String {
let body = BASE64_STANDARD.encode(bytes);
let mut pem = String::new();
pem.push_str("-----BEGIN ");
pem.push_str(label);
pem.push_str("-----\n");
for chunk in body.as_bytes().chunks(64) {
pem.push_str(std::str::from_utf8(chunk).expect("base64 output should be valid UTF-8"));
pem.push('\n');
}
pem.push_str("-----END ");
pem.push_str(label);
pem.push_str("-----\n");
pem
}
fn ed25519_public_key_spki(public_key: &[u8]) -> Result<Vec<u8>> {
anyhow::ensure!(
public_key.len() == ED25519_PUBLIC_KEY_LEN,
"generated Ed25519 public key had unexpected length"
);
let mut spki = Vec::with_capacity(ED25519_SPKI_PREFIX.len() + public_key.len());
spki.extend_from_slice(&ED25519_SPKI_PREFIX);
spki.extend_from_slice(public_key);
Ok(spki)
}
pub fn generate_jwt_keypair() -> Result<(String, String)> {
let pkcs8 = Ed25519KeyPair::generate_pkcs8(&SystemRandom::new())
.map_err(|_| anyhow::anyhow!("failed to generate Ed25519 keypair"))?;
let keypair = Ed25519KeyPair::from_pkcs8(pkcs8.as_ref())
.map_err(|_| anyhow::anyhow!("failed to parse generated Ed25519 keypair"))?;
let public_der = ed25519_public_key_spki(keypair.public_key().as_ref())?;
Ok((
pem_encode("PRIVATE KEY", pkcs8.as_ref()),
pem_encode("PUBLIC KEY", &public_der),
))
}
pub fn default_web_url() -> String {
"http://127.0.0.1:32276".to_string()
}

View file

@ -537,30 +537,6 @@ fn check_crypto(state: &AppState) -> CheckResult {
}
}
if let Some(raw) = state.server_secret("FABRO_JWT_PUBLIC_KEY") {
if let Err(err) = decode_pem_value("FABRO_JWT_PUBLIC_KEY", &raw).and_then(|pem| {
jsonwebtoken::DecodingKey::from_ed_pem(pem.as_bytes())
.map(|_| ())
.map_err(|e| format!("invalid JWT public key: {e}"))
}) {
errors.push(err);
} else {
details.push(CheckDetail::new("FABRO_JWT_PUBLIC_KEY valid".to_string()));
}
}
if let Some(raw) = state.server_secret("FABRO_JWT_PRIVATE_KEY") {
if let Err(err) = decode_pem_value("FABRO_JWT_PRIVATE_KEY", &raw).and_then(|pem| {
jsonwebtoken::EncodingKey::from_ed_pem(pem.as_bytes())
.map(|_| ())
.map_err(|e| format!("invalid JWT private key: {e}"))
}) {
errors.push(err);
} else {
details.push(CheckDetail::new("FABRO_JWT_PRIVATE_KEY valid".to_string()));
}
}
if errors.is_empty() {
CheckResult {
name: "Crypto".to_string(),

View file

@ -18,9 +18,8 @@ use fabro_config::envfile::EnvFileUpdate;
use fabro_config::{Storage, resolve_server_from_file};
use fabro_install::{
InstallListenConfig, OBJECT_STORE_ACCESS_KEY_ID_ENV, OBJECT_STORE_SECRET_ACCESS_KEY_ENV,
PendingSettingsWrite, VaultSecretWrite, generate_jwt_keypair, merge_server_settings,
persist_install_outputs_direct, write_github_app_settings, write_object_store_settings,
write_token_settings,
PendingSettingsWrite, VaultSecretWrite, merge_server_settings, persist_install_outputs_direct,
write_github_app_settings, write_object_store_settings, write_token_settings,
};
use fabro_model::Provider;
use fabro_store::ArtifactStore;
@ -43,7 +42,7 @@ use zeroize::Zeroizing;
use crate::error::ApiError;
use crate::serve::{self, DEFAULT_TCP_PORT};
use crate::server_secrets::ServerSecrets;
use crate::server_secrets::{ProcessEnv, ServerSecrets};
use crate::{security_headers, static_files};
#[derive(Clone)]
@ -962,7 +961,8 @@ async fn validate_install_object_store_selection(
let server_env_path = Storage::new(state.storage_dir.as_ref())
.runtime_directory()
.env_path();
let server_secrets = ServerSecrets::load(server_env_path).map_err(|err| err.to_string())?;
let server_secrets =
ServerSecrets::load(server_env_path, &ProcessEnv).map_err(|err| err.to_string())?;
let build_options = serve::ObjectStoreBuildOptions {
client_options,
retry_config: RetryConfig {
@ -1362,23 +1362,7 @@ async fn post_install_finish(
};
let session_secret = session_secret::generate_session_secret();
let (jwt_private_pem, jwt_public_pem) = match generate_jwt_keypair() {
Ok(value) => value,
Err(err) => {
return install_error_response(StatusCode::INTERNAL_SERVER_ERROR, err.to_string());
}
};
server_env_writes.extend([
make_env_write(
"FABRO_JWT_PRIVATE_KEY",
BASE64_STANDARD.encode(jwt_private_pem.as_bytes()),
),
make_env_write(
"FABRO_JWT_PUBLIC_KEY",
BASE64_STANDARD.encode(jwt_public_pem.as_bytes()),
),
make_env_write("SESSION_SECRET", session_secret),
]);
server_env_writes.push(make_env_write("SESSION_SECRET", session_secret));
if let Some(token) = dev_token.as_ref() {
server_env_writes.push(make_env_write("FABRO_DEV_TOKEN", token.clone()));
}
@ -1981,6 +1965,7 @@ mod tests {
classify_object_store_validation_error, detect_canonical_url, install_object_store_lookup,
lock_unpoisoned, resolve_install_object_store_state, token_is_valid,
};
use crate::server_secrets::StubEnv;
#[test]
fn token_validation_accepts_any_matching_source() {
@ -2105,7 +2090,7 @@ AWS_SESSION_TOKEN=ambient-session\n\
AWS_WEB_IDENTITY_TOKEN_FILE=/tmp/fabro-web-identity-token\n",
)
.unwrap();
let server_secrets = ServerSecrets::with_env_lookup(env_path.clone(), |_| None).unwrap();
let server_secrets = ServerSecrets::load(env_path.clone(), &StubEnv::default()).unwrap();
let manual_credentials =
InstallAwsCredentialPair::new("submitted-access", "submitted-secret");

View file

@ -32,7 +32,10 @@ pub mod serve;
pub mod server;
mod server_secrets;
mod settings_view;
mod spawn_env;
mod startup;
pub mod static_files;
pub mod web_auth;
pub use error::{ApiError, Error, Result};
pub use startup::{EnvSource, ProcessEnv, StartupValidationError, validate_startup};

View file

@ -31,12 +31,12 @@ use tracing::{error, info, warn};
use crate::canonical_origin::resolve_canonical_origin;
use crate::github_webhooks::{TailscaleFunnelManager, WEBHOOK_ROUTE, WEBHOOK_SECRET_ENV};
use crate::ip_allowlist::{GitHubMetaResolver, IpAllowlistConfig, resolve_ip_allowlist_config};
use crate::jwt_auth::resolve_auth_mode_with_lookup;
use crate::server::{
AppState, AppStateConfig, RouterOptions, build_app_state, build_router_with_options,
reconcile_incomplete_runs_on_startup, shutdown_active_workers, spawn_scheduler,
};
use crate::server_secrets::ServerSecrets;
use crate::server_secrets::{ProcessEnv, ServerSecrets};
use crate::startup::resolve_startup;
const TEST_IN_MEMORY_STORE_ENV: &str = "FABRO_TEST_IN_MEMORY_STORE";
const AWS_SESSION_TOKEN_ENV: &str = "AWS_SESSION_TOKEN";
@ -449,6 +449,15 @@ fn resolve_server_settings(file: &SettingsLayer) -> anyhow::Result<ResolvedServe
})
}
pub fn resolve_runtime_server_settings_for_start(
args: &ServeArgs,
data_dir: &Path,
) -> anyhow::Result<ResolvedServerSettings> {
let disk_settings = load_settings(args.config.as_deref())?;
let effective_settings = apply_runtime_settings(&disk_settings, args, data_dir);
resolve_server_settings(&effective_settings)
}
pub fn resolve_bind_request_from_settings(
settings: &SettingsLayer,
explicit_bind: Option<&str>,
@ -509,7 +518,7 @@ fn load_server_secrets_for_settings(
) -> anyhow::Result<ServerSecrets> {
let storage_root = resolve_interp_path(&settings.storage.root)?;
let server_env_path = Storage::new(&storage_root).runtime_directory().env_path();
ServerSecrets::load(server_env_path).map_err(anyhow::Error::from)
ServerSecrets::load(server_env_path, &ProcessEnv).map_err(anyhow::Error::from)
}
pub(crate) fn build_artifact_object_store_with_server_secrets(
@ -591,24 +600,19 @@ where
let storage = Storage::new(&data_dir);
let vault_path = storage.secrets_path();
let server_env_path = storage.runtime_directory().env_path();
let server_secrets = ServerSecrets::load(server_env_path.clone())?;
let webhook_secret_present = server_secrets.get(WEBHOOK_SECRET_ENV).is_some();
// Shared config for live reloading
let effective_settings = apply_runtime_settings(&disk_settings, &args, &data_dir);
let resolved_server_settings = resolve_server_settings(&effective_settings)?;
let startup = resolve_startup(&server_env_path, &ProcessEnv, &resolved_server_settings)?;
let auth_mode = startup.auth_mode;
let server_secrets = startup.server_secrets;
let webhook_secret_present = server_secrets.get(WEBHOOK_SECRET_ENV).is_some();
let bind_request =
resolve_bind_request_from_settings(&effective_settings, args.bind.as_deref())?;
let shared_settings = Arc::new(RwLock::new(effective_settings));
std::fs::create_dir_all(&data_dir)
.with_context(|| format!("creating data directory {}", data_dir.display()))?;
let (auth_mode, max_concurrent_runs) = {
let auth_mode = resolve_auth_mode_with_lookup(&resolved_server_settings, |name| {
server_secrets.get(name)
})?;
let max_concurrent_runs = resolved_server_settings.scheduler.max_concurrent_runs;
(auth_mode, max_concurrent_runs)
};
let max_concurrent_runs = resolved_server_settings.scheduler.max_concurrent_runs;
let web_enabled = router_web_enabled(&resolved_server_settings);
let github_meta_resolver = GitHubMetaResolver::from_cache_dir(&storage.cache_dir())?;
@ -641,7 +645,7 @@ where
store,
artifact_store,
vault_path,
server_env_path,
server_secrets,
local_daemon_mode: true,
env_lookup,
http_client: None,

View file

@ -125,8 +125,9 @@ use crate::jwt_auth::{
use crate::run_files::{FilesInFlight, list_run_files, new_files_in_flight};
use crate::run_selector::{ResolveRunError, resolve_run_by_selector};
use crate::server_secrets::{
LlmClientResult, ProviderCredentials, ServerSecrets, auth_issue_message,
LlmClientResult, ProviderCredentials, ServerSecrets, StubEnv, auth_issue_message,
};
use crate::spawn_env::{apply_render_graph_env, apply_worker_env};
use crate::{
demo, diagnostics, run_manifest, security_headers, settings_view, static_files, web_auth,
};
@ -576,7 +577,7 @@ pub struct AppState {
pub(crate) files_in_flight: FilesInFlight,
pub(crate) vault: Arc<AsyncRwLock<Vault>>,
pub(crate) server_secrets: ServerSecrets,
pub(super) server_secrets: ServerSecrets,
pub(crate) provider_credentials: ProviderCredentials,
pub(crate) settings: Arc<RwLock<SettingsLayer>>,
pub(crate) server_settings: RwLock<Arc<ResolvedServerSettings>>,
@ -596,7 +597,7 @@ pub(crate) struct AppStateConfig {
pub(crate) store: Arc<Database>,
pub(crate) artifact_store: ArtifactStore,
pub(crate) vault_path: PathBuf,
pub(crate) server_env_path: PathBuf,
pub(crate) server_secrets: ServerSecrets,
pub(crate) local_daemon_mode: bool,
pub(crate) env_lookup: EnvLookup,
pub(crate) http_client: Option<fabro_http::HttpClient>,
@ -2489,6 +2490,21 @@ pub fn create_app_state_with_env_lookup(
settings: SettingsLayer,
max_concurrent_runs: usize,
env_lookup: impl Fn(&str) -> Option<String> + Send + Sync + 'static,
) -> Arc<AppState> {
create_app_state_with_env_lookup_and_server_secret_env(
settings,
max_concurrent_runs,
env_lookup,
HashMap::new(),
)
}
#[doc(hidden)]
pub fn create_app_state_with_env_lookup_and_server_secret_env(
settings: SettingsLayer,
max_concurrent_runs: usize,
env_lookup: impl Fn(&str) -> Option<String> + Send + Sync + 'static,
server_secret_env: HashMap<String, String>,
) -> Arc<AppState> {
let (store, artifact_store) = test_store_bundle();
let env_lookup: EnvLookup = Arc::new(env_lookup);
@ -2499,6 +2515,8 @@ pub fn create_app_state_with_env_lookup(
);
config.store = store;
config.artifact_store = artifact_store;
let server_env_path = config.vault_path.with_file_name("server.env");
config.server_secrets = load_test_server_secrets(server_env_path, server_secret_env);
build_app_state(config).expect("test app state should build")
}
@ -2535,7 +2553,7 @@ pub(crate) fn create_test_app_state_with_session_key(
store,
artifact_store,
vault_path,
server_env_path,
server_secrets: load_test_server_secrets(server_env_path, HashMap::new()),
local_daemon_mode,
env_lookup,
http_client: Some(fabro_http::test_http_client().expect("test HTTP client should build")),
@ -2571,7 +2589,7 @@ fn default_test_app_state_config(
store,
artifact_store,
vault_path,
server_env_path,
server_secrets: load_test_server_secrets(server_env_path, HashMap::new()),
local_daemon_mode: false,
env_lookup,
http_client: Some(fabro_http::test_http_client().expect("test HTTP client should build")),
@ -2628,6 +2646,10 @@ fn default_env_lookup() -> EnvLookup {
Arc::new(|name| std::env::var(name).ok())
}
fn load_test_server_secrets(path: PathBuf, env: HashMap<String, String>) -> ServerSecrets {
ServerSecrets::load(path, &StubEnv(env)).expect("test server secrets should load")
}
pub(crate) fn build_app_state(config: AppStateConfig) -> anyhow::Result<Arc<AppState>> {
let AppStateConfig {
settings,
@ -2636,17 +2658,13 @@ pub(crate) fn build_app_state(config: AppStateConfig) -> anyhow::Result<Arc<AppS
store,
artifact_store,
vault_path,
server_env_path,
server_secrets,
local_daemon_mode,
env_lookup,
http_client,
} = config;
let vault = Arc::new(AsyncRwLock::new(Vault::load(vault_path)?));
let server_secrets = ServerSecrets::with_env_lookup(server_env_path, {
let env_lookup = Arc::clone(&env_lookup);
move |name| env_lookup(name)
})?;
let provider_credentials = ProviderCredentials::with_env_lookup(Arc::clone(&vault), {
let env_lookup = Arc::clone(&env_lookup);
move |name| env_lookup(name)
@ -3814,8 +3832,7 @@ fn worker_command(
.stdout(Stdio::null())
.stderr(Stdio::piped());
cmd.env_remove("FABRO_JSON");
cmd.env_remove("FABRO_DEV_TOKEN");
apply_worker_env(&mut cmd);
if state
.server_settings()
.auth
@ -7230,9 +7247,9 @@ async fn render_dot_subprocess(
.map_err(|err| RenderSubprocessError::SpawnFailed(err.to_string()))?;
let exe = render_graph_subprocess_exe(exe_override)?;
let mut cmd = Command::new(exe);
apply_render_graph_env(&mut cmd);
cmd.arg("__render-graph")
.env("FABRO_TELEMETRY", "off")
.env_remove("FABRO_JSON")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
@ -7459,9 +7476,12 @@ mod tests {
)]
fn webhook_test_app(auth_mode: AuthMode) -> Router {
let secret = TEST_WEBHOOK_SECRET.to_string();
let state = create_app_state_with_env_lookup(SettingsLayer::default(), 5, move |name| {
(name == WEBHOOK_SECRET_ENV).then(|| secret.clone())
});
let state = create_app_state_with_env_lookup_and_server_secret_env(
SettingsLayer::default(),
5,
|_| None,
HashMap::from([(WEBHOOK_SECRET_ENV.to_string(), secret)]),
);
build_router_with_options(
state,
&auth_mode,
@ -7870,12 +7890,14 @@ type = "http"
)
.unwrap();
let secrets =
ServerSecrets::with_env_lookup(dir.path().join("server.env"), |name| match name {
"SESSION_SECRET" => Some("env-value".to_string()),
_ => None,
})
.unwrap();
let secrets = ServerSecrets::load(
dir.path().join("server.env"),
&StubEnv(HashMap::from([(
"SESSION_SECRET".to_string(),
"env-value".to_string(),
)])),
)
.unwrap();
assert_eq!(secrets.get("SESSION_SECRET").as_deref(), Some("env-value"));
assert_eq!(
@ -7899,7 +7921,7 @@ type = "http"
.unwrap();
assert_eq!(
command_env_value(&github_cmd, "FABRO_DEV_TOKEN"),
EnvOverride::Removed
EnvOverride::Unchanged
);
let dev_token = tempfile::tempdir().unwrap();
@ -7955,10 +7977,14 @@ allowed_usernames = ["octocat"]
.write(&runtime_directory)
.unwrap();
create_app_state_with_env_lookup(settings, 5, move |name| match name {
"FABRO_DEV_TOKEN" => dev_token.clone(),
_ => None,
})
create_app_state_with_env_lookup_and_server_secret_env(
settings,
5,
|_| None,
dev_token
.map(|token| HashMap::from([("FABRO_DEV_TOKEN".to_string(), token)]))
.unwrap_or_default(),
)
}
#[cfg(unix)]

View file

@ -11,6 +11,27 @@ use tokio::sync::RwLock as AsyncRwLock;
type EnvLookup = Arc<dyn Fn(&str) -> Option<String> + Send + Sync>;
pub trait EnvSource {
fn snapshot(&self) -> HashMap<String, String>;
}
pub struct ProcessEnv;
impl EnvSource for ProcessEnv {
fn snapshot(&self) -> HashMap<String, String> {
std::env::vars().collect()
}
}
#[derive(Clone, Debug, Default)]
pub(crate) struct StubEnv(pub(crate) HashMap<String, String>);
impl EnvSource for StubEnv {
fn snapshot(&self) -> HashMap<String, String> {
self.0.clone()
}
}
#[derive(Debug, thiserror::Error)]
pub(crate) enum Error {
#[error(transparent)]
@ -19,28 +40,24 @@ pub(crate) enum Error {
pub(crate) struct ServerSecrets {
path: PathBuf,
env_entries: HashMap<String, String>,
file_entries: HashMap<String, String>,
env_lookup: EnvLookup,
}
impl ServerSecrets {
pub(crate) fn load(path: PathBuf) -> Result<Self, Error> {
Self::with_env_lookup(path, |name| std::env::var(name).ok())
}
pub(crate) fn with_env_lookup<F>(path: PathBuf, env_lookup: F) -> Result<Self, Error>
where
F: Fn(&str) -> Option<String> + Send + Sync + 'static,
{
pub(crate) fn load(path: PathBuf, env: &dyn EnvSource) -> Result<Self, Error> {
Ok(Self {
env_entries: env.snapshot(),
file_entries: envfile::read_env_file(&path)?,
path,
env_lookup: Arc::new(env_lookup),
})
}
pub(crate) fn get(&self, name: &str) -> Option<String> {
(self.env_lookup)(name).or_else(|| self.file_entries.get(name).cloned())
self.env_entries
.get(name)
.cloned()
.or_else(|| self.file_entries.get(name).cloned())
}
}
@ -48,6 +65,7 @@ impl std::fmt::Debug for ServerSecrets {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ServerSecrets")
.field("path", &self.path)
.field("env_entries", &self.env_entries.keys().collect::<Vec<_>>())
.field(
"file_entries",
&self.file_entries.keys().collect::<Vec<_>>(),
@ -149,13 +167,15 @@ impl std::fmt::Debug for ProviderCredentials {
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use std::sync::Arc;
use fabro_auth::{AuthCredential, AuthDetails};
use fabro_config::envfile;
use fabro_vault::{SecretType, Vault};
use tokio::sync::RwLock as AsyncRwLock;
use super::ProviderCredentials;
use super::{ProviderCredentials, ServerSecrets, StubEnv};
use crate::server_secrets::Provider;
#[tokio::test]
@ -198,4 +218,48 @@ mod tests {
Provider::Anthropic
]);
}
#[test]
fn server_secrets_snapshot_prefers_env_over_file() {
let dir = tempfile::tempdir().unwrap();
let env_path = dir.path().join("server.env");
envfile::write_env_file(
&env_path,
&HashMap::from([
("SESSION_SECRET".to_string(), "file-value".to_string()),
(
"GITHUB_APP_CLIENT_SECRET".to_string(),
"file-client".to_string(),
),
]),
)
.unwrap();
let secrets = ServerSecrets::load(
env_path,
&StubEnv(HashMap::from([(
"SESSION_SECRET".to_string(),
"env-value".to_string(),
)])),
)
.unwrap();
assert_eq!(secrets.get("SESSION_SECRET").as_deref(), Some("env-value"));
assert_eq!(
secrets.get("GITHUB_APP_CLIENT_SECRET").as_deref(),
Some("file-client")
);
}
#[test]
fn server_secrets_snapshot_is_owned_after_load() {
let dir = tempfile::tempdir().unwrap();
let env_path = dir.path().join("server.env");
let mut env = HashMap::from([("SESSION_SECRET".to_string(), "before".to_string())]);
let secrets = ServerSecrets::load(env_path, &StubEnv(env.clone())).unwrap();
env.insert("SESSION_SECRET".to_string(), "after".to_string());
assert_eq!(secrets.get("SESSION_SECRET").as_deref(), Some("before"));
}
}

View file

@ -0,0 +1,137 @@
use std::ffi::OsString;
use tokio::process::Command;
const WORKER_ENV_ALLOWLIST: &[&str] = &[
"PATH", // process essentials
"HOME", // process essentials
"TMPDIR", // temp file staging
"USER", // process identity
"RUST_LOG", // diagnostics
"RUST_BACKTRACE", // diagnostics
"FABRO_HOME", // worker state lookup
"FABRO_STORAGE_ROOT", // worker state lookup
];
const RENDER_GRAPH_ENV_ALLOWLIST: &[&str] = &[
"PATH", // executable lookup
"HOME", // graphviz/font resolution
"TMPDIR", // temp file staging
];
pub(crate) fn apply_worker_env(cmd: &mut Command) {
apply_worker_env_with_lookup(cmd, &|name| std::env::var_os(name));
}
pub(crate) fn apply_render_graph_env(cmd: &mut Command) {
apply_render_graph_env_with_lookup(cmd, &|name| std::env::var_os(name));
}
fn apply_worker_env_with_lookup(cmd: &mut Command, lookup: &dyn Fn(&str) -> Option<OsString>) {
apply_allowlist(cmd, WORKER_ENV_ALLOWLIST, lookup);
}
fn apply_render_graph_env_with_lookup(
cmd: &mut Command,
lookup: &dyn Fn(&str) -> Option<OsString>,
) {
apply_allowlist(cmd, RENDER_GRAPH_ENV_ALLOWLIST, lookup);
}
fn apply_allowlist(cmd: &mut Command, keys: &[&str], lookup: &dyn Fn(&str) -> Option<OsString>) {
cmd.env_clear();
for key in keys {
if let Some(value) = lookup(key) {
cmd.env(key, value);
}
}
}
#[cfg(all(test, unix))]
mod tests {
use std::collections::HashMap;
use std::ffi::OsString;
use std::path::Path;
use super::{apply_render_graph_env_with_lookup, apply_worker_env_with_lookup};
fn env_command() -> tokio::process::Command {
assert!(Path::new("/usr/bin/env").exists());
tokio::process::Command::new("/usr/bin/env")
}
fn env_output(mut cmd: tokio::process::Command) -> HashMap<String, String> {
let runtime = tokio::runtime::Runtime::new().expect("creating test Tokio runtime");
runtime.block_on(async move {
let output = cmd.output().await.expect("running env subprocess");
assert!(output.status.success());
String::from_utf8(output.stdout)
.expect("parsing env subprocess output as UTF-8")
.lines()
.filter_map(|line| {
let (key, value) = line.split_once('=')?;
Some((key.to_string(), value.to_string()))
})
.collect()
})
}
#[test]
fn worker_allowlist_is_fail_closed() {
let env = HashMap::from([
("PATH".to_string(), "/bin".to_string()),
("HOME".to_string(), "/tmp/home".to_string()),
("TMPDIR".to_string(), "/tmp".to_string()),
("USER".to_string(), "alice".to_string()),
("RUST_LOG".to_string(), "debug".to_string()),
("FABRO_HOME".to_string(), "/tmp/fabro-home".to_string()),
(
"FABRO_STORAGE_ROOT".to_string(),
"/tmp/fabro-storage".to_string(),
),
("SESSION_SECRET".to_string(), "leak".to_string()),
("FABRO_DEV_TOKEN".to_string(), "garbage".to_string()),
("MY_API_KEY".to_string(), "blocked".to_string()),
]);
let mut cmd = env_command();
apply_worker_env_with_lookup(&mut cmd, &|name| env.get(name).map(OsString::from));
cmd.env(
"FABRO_DEV_TOKEN",
"fabro_dev_abababababababababababababababababababababababababababababababab",
);
let actual = env_output(cmd);
assert_eq!(actual.get("PATH").map(String::as_str), Some("/bin"));
assert_eq!(actual.get("HOME").map(String::as_str), Some("/tmp/home"));
assert_eq!(
actual.get("FABRO_DEV_TOKEN").map(String::as_str),
Some("fabro_dev_abababababababababababababababababababababababababababababababab")
);
assert!(!actual.contains_key("SESSION_SECRET"));
assert!(!actual.contains_key("MY_API_KEY"));
}
#[test]
fn render_graph_allowlist_is_fail_closed() {
let env = HashMap::from([
("PATH".to_string(), "/bin".to_string()),
("HOME".to_string(), "/tmp/home".to_string()),
("TMPDIR".to_string(), "/tmp".to_string()),
("FABRO_TELEMETRY".to_string(), "on".to_string()),
("SESSION_SECRET".to_string(), "leak".to_string()),
]);
let mut cmd = env_command();
apply_render_graph_env_with_lookup(&mut cmd, &|name| env.get(name).map(OsString::from));
cmd.env("FABRO_TELEMETRY", "off");
let actual = env_output(cmd);
assert_eq!(actual.get("PATH").map(String::as_str), Some("/bin"));
assert_eq!(
actual.get("FABRO_TELEMETRY").map(String::as_str),
Some("off")
);
assert!(!actual.contains_key("SESSION_SECRET"));
}
}

View file

@ -0,0 +1,118 @@
use std::path::Path;
use fabro_types::settings::ServerSettings as ResolvedServerSettings;
use crate::jwt_auth::{AuthMode, resolve_auth_mode_with_lookup};
pub use crate::server_secrets::{EnvSource, ProcessEnv};
use crate::server_secrets::{Error as ServerSecretsError, ServerSecrets};
#[derive(Debug)]
pub(crate) struct StartupResolution {
pub(crate) auth_mode: AuthMode,
pub(crate) server_secrets: ServerSecrets,
}
#[derive(Debug, thiserror::Error)]
pub enum StartupValidationError {
#[error("{0}")]
Message(String),
}
impl From<ServerSecretsError> for StartupValidationError {
fn from(err: ServerSecretsError) -> Self {
Self::Message(err.to_string())
}
}
impl From<anyhow::Error> for StartupValidationError {
fn from(err: anyhow::Error) -> Self {
Self::Message(err.to_string())
}
}
pub(crate) fn resolve_startup(
env_path: &Path,
env: &dyn EnvSource,
settings: &ResolvedServerSettings,
) -> std::result::Result<StartupResolution, StartupValidationError> {
let server_secrets = ServerSecrets::load(env_path.to_path_buf(), env)?;
let auth_mode = resolve_auth_mode_with_lookup(settings, |name| server_secrets.get(name))?;
Ok(StartupResolution {
auth_mode,
server_secrets,
})
}
pub fn validate_startup(
env_path: &Path,
env: &dyn EnvSource,
settings: &ResolvedServerSettings,
) -> std::result::Result<(), StartupValidationError> {
resolve_startup(env_path, env, settings).map(|_| ())
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use fabro_config::parse_settings_layer;
use fabro_types::settings::ServerSettings as ResolvedServerSettings;
use super::{resolve_startup, validate_startup};
use crate::server_secrets::StubEnv;
fn resolved_settings(auth_methods: &[&str]) -> ResolvedServerSettings {
let settings = parse_settings_layer(&format!(
r"
_version = 1
[server.auth]
methods = [{}]
",
auth_methods
.iter()
.map(|method| format!("\"{method}\""))
.collect::<Vec<_>>()
.join(", ")
))
.unwrap();
fabro_config::resolve_server_from_file(&settings).unwrap()
}
#[test]
fn validate_startup_matches_resolve_startup() {
let dir = tempfile::tempdir().unwrap();
let env = StubEnv(HashMap::from([
(
"SESSION_SECRET".to_string(),
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef".to_string(),
),
(
"FABRO_DEV_TOKEN".to_string(),
"fabro_dev_abababababababababababababababababababababababababababababababab"
.to_string(),
),
]));
let settings = resolved_settings(&["dev-token"]);
assert!(validate_startup(dir.path().join("server.env").as_path(), &env, &settings).is_ok());
assert!(resolve_startup(dir.path().join("server.env").as_path(), &env, &settings).is_ok());
}
#[test]
fn validate_startup_and_resolve_startup_share_errors() {
let dir = tempfile::tempdir().unwrap();
let env = StubEnv::default();
let settings = resolved_settings(&["dev-token"]);
let validate_err =
validate_startup(dir.path().join("server.env").as_path(), &env, &settings)
.unwrap_err()
.to_string();
let resolve_err = resolve_startup(dir.path().join("server.env").as_path(), &env, &settings)
.unwrap_err()
.to_string();
assert_eq!(validate_err, resolve_err);
}
}

View file

@ -23,8 +23,12 @@ fn security_doc_does_not_require_jwt_keys_for_the_current_web_flow() {
"security doc should still mention the session secret"
);
assert!(
!security.contains("`FABRO_JWT_PRIVATE_KEY`, `FABRO_JWT_PUBLIC_KEY`, and `SESSION_SECRET`"),
"security doc should not describe JWT keys as required for the current web flow"
!security.contains("FABRO_JWT_PRIVATE_KEY"),
"security doc should not mention removed JWT key settings"
);
assert!(
!security.contains("FABRO_JWT_PUBLIC_KEY"),
"security doc should not mention removed JWT key settings"
);
}

View file

@ -764,8 +764,6 @@ async fn token_install_finish_persists_settings_env_and_vault() {
.env_path(),
)
.unwrap();
assert!(server_env.contains("FABRO_JWT_PRIVATE_KEY="));
assert!(server_env.contains("FABRO_JWT_PUBLIC_KEY="));
assert!(server_env.contains("SESSION_SECRET="));
assert!(server_env.contains("FABRO_DEV_TOKEN="));
assert!(!server_env.contains("AWS_ACCESS_KEY_ID="));

View file

@ -12,7 +12,7 @@ use axum::body::Body;
use axum::http::{Method, Request, StatusCode};
use fabro_server::install::{InstallAppState, build_install_router};
use fabro_server::jwt_auth::AuthMode;
use fabro_server::server::{build_router, create_app_state_with_env_lookup};
use fabro_server::server::{build_router, create_app_state_with_env_lookup_and_server_secret_env};
use serde_yaml::Value;
use tower::ServiceExt;
@ -146,9 +146,12 @@ fn github_webhook_spec_and_sdk_describe_a_json_body() {
async fn github_webhook_spec_route_is_routable_when_webhook_secret_is_present() {
let secret = "test-webhook-secret".to_string();
let app = build_router(
create_app_state_with_env_lookup(test_settings(), 5, move |name| {
(name == "GITHUB_APP_WEBHOOK_SECRET").then(|| secret.clone())
}),
create_app_state_with_env_lookup_and_server_secret_env(
test_settings(),
5,
|_| None,
std::collections::HashMap::from([("GITHUB_APP_WEBHOOK_SECRET".to_string(), secret)]),
),
AuthMode::Disabled,
);

View file

@ -37,7 +37,7 @@ pub fn spawn_detached(args: &[&str], env: &[(&str, &str)], env_remove: &[&str])
#[expect(
clippy::disallowed_types,
clippy::disallowed_methods,
reason = "Detaching must flush stdio synchronously before the double-fork."
reason = "Detaching must flush stdio synchronously before the double-fork; post-fork pre-exec env mutation is the one allowed exception to the workspace env-mutation ban."
)]
fn spawn_detached_unix(args: &[&str], env: &[(&str, &str)], env_remove: &[&str]) {
// Flush stdout/stderr before forking so the child process doesn't inherit

View file

@ -631,8 +631,11 @@ fn write_settings_file(path: &Path, storage_dir: &Path, rest: &str) {
fn write_test_server_dev_token(storage_dir: &Path) {
let server_env_path = Storage::new(storage_dir).runtime_directory().env_path();
envfile::merge_env_file(&server_env_path, [("FABRO_DEV_TOKEN", TEST_DEV_TOKEN)])
.unwrap_or_else(|err| panic!("failed to write {}: {err}", server_env_path.display()));
envfile::merge_env_file(&server_env_path, [
("FABRO_DEV_TOKEN", TEST_DEV_TOKEN),
("SESSION_SECRET", TEST_SESSION_SECRET),
])
.unwrap_or_else(|err| panic!("failed to write {}: {err}", server_env_path.display()));
}
fn write_test_home_dev_token(settings_path: &Path) {

View file

@ -11,20 +11,21 @@ pub struct Config {
impl Config {
pub fn from_env() -> Result<Self> {
let bind_addr = std::env::var("TWIN_OPENAI_BIND_ADDR")
.ok()
Self::from_lookup(&|name| std::env::var(name).ok())
}
pub fn from_lookup(lookup: &dyn Fn(&str) -> Option<String>) -> Result<Self> {
let bind_addr = lookup("TWIN_OPENAI_BIND_ADDR")
.map(|value| value.parse().context("invalid TWIN_OPENAI_BIND_ADDR"))
.transpose()?
.unwrap_or_else(|| SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 3000));
let require_auth = std::env::var("TWIN_OPENAI_REQUIRE_AUTH")
.ok()
let require_auth = lookup("TWIN_OPENAI_REQUIRE_AUTH")
.map(|value| parse_bool_env(&value, "TWIN_OPENAI_REQUIRE_AUTH"))
.transpose()?
.unwrap_or(true);
let enable_admin = std::env::var("TWIN_OPENAI_ENABLE_ADMIN")
.ok()
let enable_admin = lookup("TWIN_OPENAI_ENABLE_ADMIN")
.map(|value| parse_bool_env(&value, "TWIN_OPENAI_ENABLE_ADMIN"))
.transpose()?
.unwrap_or(true);

View file

@ -2,30 +2,14 @@ use twin_openai::config::Config;
#[test]
fn config_loads_from_environment() {
let prior_bind = std::env::var("TWIN_OPENAI_BIND_ADDR").ok();
let prior_auth = std::env::var("TWIN_OPENAI_REQUIRE_AUTH").ok();
let prior_admin = std::env::var("TWIN_OPENAI_ENABLE_ADMIN").ok();
std::env::set_var("TWIN_OPENAI_BIND_ADDR", "127.0.0.1:4100");
std::env::set_var("TWIN_OPENAI_REQUIRE_AUTH", "false");
std::env::set_var("TWIN_OPENAI_ENABLE_ADMIN", "false");
let config = Config::from_env().expect("config should load");
let config = Config::from_lookup(&|name| match name {
"TWIN_OPENAI_BIND_ADDR" => Some("127.0.0.1:4100".to_string()),
"TWIN_OPENAI_REQUIRE_AUTH" | "TWIN_OPENAI_ENABLE_ADMIN" => Some("false".to_string()),
_ => None,
})
.expect("config should load");
assert_eq!(config.bind_addr.to_string(), "127.0.0.1:4100");
assert!(!config.require_auth);
assert!(!config.enable_admin);
match prior_bind {
Some(value) => std::env::set_var("TWIN_OPENAI_BIND_ADDR", value),
None => std::env::remove_var("TWIN_OPENAI_BIND_ADDR"),
}
match prior_auth {
Some(value) => std::env::set_var("TWIN_OPENAI_REQUIRE_AUTH", value),
None => std::env::remove_var("TWIN_OPENAI_REQUIRE_AUTH"),
}
match prior_admin {
Some(value) => std::env::set_var("TWIN_OPENAI_ENABLE_ADMIN", value),
None => std::env::remove_var("TWIN_OPENAI_ENABLE_ADMIN"),
}
}