mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-08-28 05:27:41 +00:00
Third reducing PR of the interpolation unification (**D11 resolution
(c)**): the control plane never interpolates. `InterpString` is now
strictly the user-facing workflow config language; server identity,
storage, listen, object-store, and GitHub App identifiers are plain
`String`, consumed where needed with no resolution point.
## Demoted to `String` (was `InterpString`)
Both the layer and resolved types:
- `server.listen.unix.path`, `server.api.url`, `server.web.url`
- `server.storage.root`, `server.artifacts.prefix`,
`server.slatedb.prefix`
- object store: `Local.root`, S3 `bucket` / `region` / `endpoint`
(shared by artifacts + slatedb)
- `github.app_id` / `client_id` / `slug`
**Kept `InterpString`:** `slack.default_channel` (run-time consumption —
the one server-defined survivor). `server.listen.tcp.address` stays the
`SocketAddr` `parsed_value` special case.
## Native `FABRO_WEB_URL` read
Deployment-time late binding now goes through a native env read instead
of a `{{ env.* }}` token: `FABRO_WEB_URL` overrides `server.web.url`
(**env override > settings literal > default**), applied in
`canonical_origin` and reused by the JWT issuer, cookie-secure check,
and system-info. `docker/split-web` no longer ferries the value through
a settings token (compose still sets the env var). `canonical_origin`'s
error message now advertises a knob that is actually true for everyone.
## Behavior change (release notes)
- `{{ env.* }}` / `{{ vars.* }}` tokens in the demoted server fields are
now **literal text**, not interpolated. The resolve layer emits
`warn_if_demoted_template` for every demoted field, so operators with
tokens still in server config **fail loud** rather than silently
treating the token as a literal.
- Operators who relied on env-based storage location should use the
existing native `FABRO_STORAGE_DIR` (`--storage-dir`) override.
`FABRO_STORAGE_ROOT` promotion is intentionally deferred (not a proven
need).
## Cleanup
`fabro-server`'s `crate::interp` shrinks to just the process-env lookup
facade; `resolve_interp` / `_path` / `_with` and the
`AppState::resolve_interp` seam are deleted (nothing resolves
server-scope `InterpString` anymore).
## Verification
- `cargo build --workspace` ✅
- `cargo +nightly clippy --workspace --all-targets -- -D warnings` ✅
(incl. the `as_source` gate)
- `cargo +nightly fmt --check --all` ✅
- `cargo nextest run --workspace`: 6305 passed; added two tests covering
the `FABRO_WEB_URL` override precedence (env-wins and settings-literal
fallback).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
438 lines
13 KiB
Rust
438 lines
13 KiB
Rust
#![expect(
|
|
clippy::disallowed_types,
|
|
reason = "Canonical host middleware parses validated public server.web.url for authority comparison and raw redirect transit; logs use DisplaySafeUrl."
|
|
)]
|
|
|
|
use std::sync::Arc;
|
|
|
|
use axum::extract::{Request, State};
|
|
use axum::http::uri::PathAndQuery;
|
|
use axum::http::{HeaderValue, Method, header};
|
|
use axum::middleware::Next;
|
|
use axum::response::{IntoResponse, Redirect, Response};
|
|
use fabro_redact::DisplaySafeUrl;
|
|
use tracing::debug;
|
|
use url::Url;
|
|
|
|
use crate::github_webhooks::WEBHOOK_ROUTE;
|
|
use crate::server::AppState;
|
|
|
|
#[derive(Clone)]
|
|
pub(crate) struct Config {
|
|
pub(crate) state: Arc<AppState>,
|
|
pub(crate) web_enabled: bool,
|
|
}
|
|
|
|
pub(crate) async fn redirect_middleware(
|
|
State(config): State<Config>,
|
|
request: Request,
|
|
next: Next,
|
|
) -> Response {
|
|
let path = request.uri().path();
|
|
if !redirect_applies_to_request(config.web_enabled, request.method(), path) {
|
|
return next.run(request).await;
|
|
}
|
|
|
|
let Ok(canonical_origin) = config.state.canonical_origin() else {
|
|
return next.run(request).await;
|
|
};
|
|
let Ok(canonical_url) = Url::parse(&canonical_origin) else {
|
|
return next.run(request).await;
|
|
};
|
|
let Some(request_host) = request.headers().get(header::HOST) else {
|
|
return next.run(request).await;
|
|
};
|
|
if authority_matches(&canonical_url, request_host) {
|
|
return next.run(request).await;
|
|
}
|
|
|
|
let path_and_query = request
|
|
.uri()
|
|
.path_and_query()
|
|
.map_or(path, PathAndQuery::as_str);
|
|
let target = format!(
|
|
"{}{}",
|
|
canonical_origin.trim_end_matches('/'),
|
|
path_and_query
|
|
);
|
|
debug!(
|
|
target = %redacted_url_for_log(&target),
|
|
"redirecting to canonical host"
|
|
);
|
|
Redirect::permanent(&target).into_response()
|
|
}
|
|
|
|
fn redirect_applies_to_request(web_enabled: bool, method: &Method, path: &str) -> bool {
|
|
if !web_enabled || redirect_bypass_path(path) {
|
|
return false;
|
|
}
|
|
|
|
browser_auth_path(path) || matches!(method, &Method::GET | &Method::HEAD)
|
|
}
|
|
|
|
fn redirect_bypass_path(path: &str) -> bool {
|
|
path == "/health"
|
|
|| path == WEBHOOK_ROUTE
|
|
|| path == "/api"
|
|
|| path.starts_with("/api/")
|
|
|| matches!(
|
|
path,
|
|
"/auth/cli/token" | "/auth/cli/refresh" | "/auth/cli/logout"
|
|
)
|
|
}
|
|
|
|
fn browser_auth_path(path: &str) -> bool {
|
|
matches!(
|
|
path,
|
|
"/auth/login/github"
|
|
| "/auth/callback/github"
|
|
| "/auth/login/dev-token"
|
|
| "/auth/logout"
|
|
| "/auth/cli/start"
|
|
| "/auth/cli/resume"
|
|
)
|
|
}
|
|
|
|
fn authority_matches(canonical_url: &Url, request_host: &HeaderValue) -> bool {
|
|
let Some(canonical_authority) = canonical_authority(canonical_url) else {
|
|
return false;
|
|
};
|
|
let Ok(request_host) = request_host.to_str() else {
|
|
return false;
|
|
};
|
|
let request_authority = request_authority(request_host, canonical_url.scheme());
|
|
|
|
canonical_authority == request_authority
|
|
}
|
|
|
|
fn canonical_authority(canonical_url: &Url) -> Option<String> {
|
|
let mut authority = normalize_host(canonical_url.host_str()?);
|
|
if let Some(port) = canonical_url.port_or_known_default() {
|
|
if Some(port) != default_port(canonical_url.scheme()) {
|
|
authority.push(':');
|
|
authority.push_str(&port.to_string());
|
|
}
|
|
}
|
|
Some(authority)
|
|
}
|
|
|
|
fn request_authority(request_host: &str, canonical_scheme: &str) -> String {
|
|
let request_host = request_host.trim().to_ascii_lowercase();
|
|
let Some(default_port) = default_port(canonical_scheme) else {
|
|
return request_host;
|
|
};
|
|
let default_port_suffix = format!(":{default_port}");
|
|
request_host
|
|
.strip_suffix(&default_port_suffix)
|
|
.filter(|authority| !authority.is_empty())
|
|
.unwrap_or(&request_host)
|
|
.to_string()
|
|
}
|
|
|
|
fn normalize_host(host: &str) -> String {
|
|
let host = host.to_ascii_lowercase();
|
|
if host.contains(':') && !(host.starts_with('[') && host.ends_with(']')) {
|
|
format!("[{host}]")
|
|
} else {
|
|
host
|
|
}
|
|
}
|
|
|
|
fn default_port(scheme: &str) -> Option<u16> {
|
|
match scheme {
|
|
"http" => Some(80),
|
|
"https" => Some(443),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
fn redacted_url_for_log(url: &str) -> String {
|
|
DisplaySafeUrl::parse(url)
|
|
.map_or_else(|_| "<invalid url>".to_string(), |url| url.redacted_string())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use std::sync::Arc;
|
|
|
|
use axum::body::Body;
|
|
use axum::http::{Method, Request, StatusCode, header};
|
|
use axum::response::IntoResponse;
|
|
use axum::routing::{get, post};
|
|
use axum::{Router, middleware};
|
|
use fabro_config::{RunLayer, ServerSettingsBuilder};
|
|
use tower::ServiceExt;
|
|
use url::Url;
|
|
|
|
use super::*;
|
|
use crate::github_webhooks::WEBHOOK_ROUTE;
|
|
use crate::server::AppState;
|
|
|
|
macro_rules! assert_status {
|
|
($response:expr, $expected:expr) => {
|
|
fabro_test::assert_axum_status($response, $expected, concat!(file!(), ":", line!()))
|
|
};
|
|
}
|
|
|
|
macro_rules! checked_response {
|
|
($response:expr, $expected:expr) => {
|
|
fabro_test::expect_axum_status($response, $expected, concat!(file!(), ":", line!()))
|
|
};
|
|
}
|
|
|
|
fn canonical_url(raw: &str) -> Url {
|
|
Url::parse(raw).expect("canonical URL should parse")
|
|
}
|
|
|
|
fn host(value: &'static str) -> axum::http::HeaderValue {
|
|
axum::http::HeaderValue::from_static(value)
|
|
}
|
|
|
|
#[test]
|
|
fn authority_comparison_detects_different_loopback_hosts() {
|
|
assert!(!authority_matches(
|
|
&canonical_url("http://127.0.0.1:32276"),
|
|
&host("localhost:32276")
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn authority_comparison_matches_default_https_port() {
|
|
assert!(authority_matches(
|
|
&canonical_url("https://example.com"),
|
|
&host("example.com")
|
|
));
|
|
assert!(authority_matches(
|
|
&canonical_url("https://example.com"),
|
|
&host("example.com:443")
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn authority_comparison_rejects_non_default_port() {
|
|
assert!(!authority_matches(
|
|
&canonical_url("https://example.com"),
|
|
&host("example.com:8443")
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn authority_comparison_matches_ipv6_authority() {
|
|
assert!(authority_matches(
|
|
&canonical_url("http://[::1]:32276"),
|
|
&host("[::1]:32276")
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn authority_comparison_is_case_insensitive() {
|
|
assert!(authority_matches(
|
|
&canonical_url("https://EXAMPLE.com"),
|
|
&host("example.com")
|
|
));
|
|
}
|
|
|
|
fn settings_with_web_url(web_url: &str) -> fabro_types::ServerSettings {
|
|
ServerSettingsBuilder::from_toml(&format!(
|
|
r#"
|
|
_version = 1
|
|
|
|
[server.auth]
|
|
methods = ["dev-token"]
|
|
|
|
[server.web]
|
|
url = "{web_url}"
|
|
"#
|
|
))
|
|
.expect("test server settings should resolve")
|
|
}
|
|
|
|
fn state_with_web_url(web_url: &str) -> Arc<AppState> {
|
|
crate::test_support::test_app_state_with_options(
|
|
settings_with_web_url(web_url),
|
|
RunLayer::default(),
|
|
5,
|
|
)
|
|
}
|
|
|
|
fn invalid_canonical_origin_state() -> Arc<AppState> {
|
|
crate::test_support::test_app_state_with_env_lookup(
|
|
settings_with_web_url("http://valid.example.com"),
|
|
RunLayer::default(),
|
|
5,
|
|
|_| Some("/relative".to_string()),
|
|
)
|
|
}
|
|
|
|
fn test_app(state: Arc<AppState>, web_enabled: bool) -> Router {
|
|
Router::new()
|
|
.route("/login", get(|| async { StatusCode::OK }))
|
|
.route("/auth/login/github", get(|| async { StatusCode::OK }))
|
|
.route("/auth/login/dev-token", post(|| async { StatusCode::OK }))
|
|
.route("/auth/logout", post(|| async { StatusCode::OK }))
|
|
.route("/auth/cli/start", get(|| async { StatusCode::OK }))
|
|
.route(
|
|
"/auth/cli/resume",
|
|
get(|| async { StatusCode::OK }).post(|| async { StatusCode::OK }),
|
|
)
|
|
.route("/auth/cli/token", post(|| async { StatusCode::OK }))
|
|
.route("/auth/cli/refresh", post(|| async { StatusCode::OK }))
|
|
.route("/auth/cli/logout", post(|| async { StatusCode::OK }))
|
|
.route("/api/v1/runs", get(|| async { StatusCode::OK }))
|
|
.route(WEBHOOK_ROUTE, post(|| async { StatusCode::OK }))
|
|
.route("/health", get(|| async { StatusCode::OK }))
|
|
.fallback(|| async { StatusCode::OK.into_response() })
|
|
.layer(middleware::from_fn_with_state(
|
|
Config { state, web_enabled },
|
|
redirect_middleware,
|
|
))
|
|
}
|
|
|
|
async fn request(
|
|
app: Router,
|
|
method: Method,
|
|
uri: &str,
|
|
host: Option<&str>,
|
|
) -> axum::response::Response {
|
|
let mut builder = Request::builder().method(method).uri(uri);
|
|
if let Some(host) = host {
|
|
builder = builder.header(header::HOST, host);
|
|
}
|
|
app.oneshot(builder.body(Body::empty()).unwrap())
|
|
.await
|
|
.unwrap()
|
|
}
|
|
|
|
async fn assert_redirect(
|
|
app: Router,
|
|
method: Method,
|
|
uri: &str,
|
|
host: &str,
|
|
expected_location: &str,
|
|
) {
|
|
let response = request(app, method, uri, Some(host)).await;
|
|
let response = checked_response!(response, StatusCode::PERMANENT_REDIRECT).await;
|
|
assert_eq!(
|
|
response.headers().get(header::LOCATION).unwrap(),
|
|
expected_location
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn redirects_browser_page_route_to_canonical_host() {
|
|
let app = test_app(state_with_web_url("http://127.0.0.1:32276"), true);
|
|
|
|
assert_redirect(
|
|
app,
|
|
Method::GET,
|
|
"/login",
|
|
"localhost:32276",
|
|
"http://127.0.0.1:32276/login",
|
|
)
|
|
.await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn redirects_browser_auth_route_and_preserves_query() {
|
|
let app = test_app(state_with_web_url("http://127.0.0.1:32276"), true);
|
|
|
|
assert_redirect(
|
|
app,
|
|
Method::GET,
|
|
"/auth/login/github?return_to=/runs",
|
|
"localhost:32276",
|
|
"http://127.0.0.1:32276/auth/login/github?return_to=/runs",
|
|
)
|
|
.await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn redirects_browser_auth_post_route_to_canonical_host() {
|
|
let app = test_app(state_with_web_url("http://127.0.0.1:32276"), true);
|
|
|
|
assert_redirect(
|
|
app,
|
|
Method::POST,
|
|
"/auth/login/dev-token",
|
|
"localhost:32276",
|
|
"http://127.0.0.1:32276/auth/login/dev-token",
|
|
)
|
|
.await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn matching_host_runs_inner_handler() {
|
|
let app = test_app(state_with_web_url("http://127.0.0.1:32276"), true);
|
|
let response = request(app, Method::GET, "/login", Some("127.0.0.1:32276")).await;
|
|
|
|
assert_status!(response, StatusCode::OK).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn bypasses_health_and_api_routes() {
|
|
let app = test_app(state_with_web_url("http://127.0.0.1:32276"), true);
|
|
|
|
let health_response =
|
|
request(app.clone(), Method::GET, "/health", Some("localhost:32276")).await;
|
|
assert_status!(health_response, StatusCode::OK).await;
|
|
|
|
let webhook_response = request(
|
|
app.clone(),
|
|
Method::POST,
|
|
WEBHOOK_ROUTE,
|
|
Some("localhost:32276"),
|
|
)
|
|
.await;
|
|
assert_status!(webhook_response, StatusCode::OK).await;
|
|
|
|
let runs_response =
|
|
request(app, Method::GET, "/api/v1/runs", Some("localhost:32276")).await;
|
|
assert_status!(runs_response, StatusCode::OK).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn bypasses_cli_token_api_routes_with_authorization() {
|
|
let app = test_app(state_with_web_url("http://127.0.0.1:32276"), true);
|
|
for path in ["/auth/cli/token", "/auth/cli/refresh", "/auth/cli/logout"] {
|
|
let response = app
|
|
.clone()
|
|
.oneshot(
|
|
Request::builder()
|
|
.method(Method::POST)
|
|
.uri(path)
|
|
.header(header::HOST, "localhost:32276")
|
|
.header(header::AUTHORIZATION, "Bearer test")
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
assert_status!(response, StatusCode::OK).await;
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn bypasses_when_web_is_disabled() {
|
|
let app = test_app(state_with_web_url("http://127.0.0.1:32276"), false);
|
|
let response = request(app, Method::GET, "/login", Some("localhost:32276")).await;
|
|
|
|
assert_status!(response, StatusCode::OK).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn bypasses_when_canonical_origin_is_invalid() {
|
|
let app = test_app(invalid_canonical_origin_state(), true);
|
|
let response = request(app, Method::GET, "/login", Some("localhost:32276")).await;
|
|
|
|
assert_status!(response, StatusCode::OK).await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn bypasses_when_host_header_is_missing() {
|
|
let app = test_app(state_with_web_url("http://127.0.0.1:32276"), true);
|
|
let response = request(app, Method::GET, "/login", None).await;
|
|
|
|
assert_status!(response, StatusCode::OK).await;
|
|
}
|
|
}
|