feat(desktop): bundled renderer, modules and instance accounts (#3254)

This commit is contained in:
Hampus authored and GitHub committed 2026-10-07 16:48:07 +02:00
1 parent c748c8af4e
commit 7c5fa2180a
1275 files changed
+215074 -81246

No files matched your search

+3 -4
View File
@@ -7,17 +7,16 @@ publish = false
[dependencies]
anyhow = "1.0.104"
axum = { version = "0.8.9", features = ["macros"] }
axum = "0.8.9"
base64 = "0.23"
fluxer_common = { path = "../fluxer_common" }
futures-util = { version = "0.3.34", default-features = false, features = ["std"] }
hex = "0.4"
rand = "0.10"
reqwest = { version = "0.13.5", default-features = false, features = ["json", "rustls", "stream", "gzip", "brotli", "deflate"] }
serde = { version = "1.0.229", features = ["derive"] }
reqwest = { version = "0.13.5", default-features = false, features = ["rustls", "stream", "gzip", "brotli", "deflate"] }
serde_json = "1.0.151"
sha2 = "0.11.0"
tokio = { version = "1.53.1", features = ["macros", "net", "rt-multi-thread", "signal", "time", "fs"] }
tokio = { version = "1.53.1", features = ["macros", "net", "rt-multi-thread", "signal", "time", "fs", "io-util", "sync"] }
tokio-util = { version = "0.7.19", features = ["io"] }
tower = { version = "0.5.3", features = ["util"] }
tower-http = { version = "0.7.1", features = ["compression-gzip", "trace"] }
+6 -1
View File
@@ -36,6 +36,7 @@ COPY patches/ ./patches/
COPY fluxer_app/package.json ./fluxer_app/
COPY packages/constants/package.json ./packages/constants/
COPY packages/date_utils/package.json ./packages/date_utils/
COPY packages/desktop_ipc/package.json ./packages/desktop_ipc/
COPY packages/geo_utils/package.json ./packages/geo_utils/
COPY packages/instance_bootstrap/package.json ./packages/instance_bootstrap/
COPY packages/limits/package.json ./packages/limits/
@@ -51,6 +52,7 @@ RUN pnpm install --frozen-lockfile --filter fluxer_app...
COPY tsconfigs ./tsconfigs
COPY packages/constants/ ./packages/constants/
COPY packages/date_utils/ ./packages/date_utils/
COPY packages/desktop_ipc/ ./packages/desktop_ipc/
COPY packages/geo_utils/ ./packages/geo_utils/
COPY packages/instance_bootstrap/ ./packages/instance_bootstrap/
COPY packages/limits/ ./packages/limits/
@@ -116,7 +118,6 @@ WORKDIR /usr/src/app
COPY Cargo.lock ./
COPY fluxer_common/ fluxer_common/
COPY fluxer_app_proxy/ fluxer_app_proxy/
COPY packages/constants/src/AgeGeos.json packages/constants/src/AgeGeos.json
RUN printf '%s\n' \
'[workspace]' \
@@ -143,6 +144,8 @@ RUN cargo build --release -p fluxer_app_proxy \
FROM debian:trixie-slim
ARG BUILD_VERSION=""
ARG PUBLIC_ASSET_BASE_URL=""
ARG FLUXER_SELF_HOSTED="false"
ARG SOURCE_SHA=""
ARG SOURCE_DATE=""
@@ -168,6 +171,8 @@ COPY --from=rust-builder /usr/local/bin/fluxer-app-proxy /usr/local/bin/fluxer-a
COPY --from=app-static /assets ./static/
ENV BUILD_VERSION="${BUILD_VERSION}"
ENV FLUXER_SELF_HOSTED="${FLUXER_SELF_HOSTED}"
ENV FLUXER_STATIC_CDN_ENDPOINT="${PUBLIC_ASSET_BASE_URL}"
ENV FLUXER_APP_PROXY_HOST="0.0.0.0"
ENV FLUXER_APP_PROXY_PORT="8080"
ENV LD_PRELOAD=libjemalloc.so.2
-609
View File
@@ -1,609 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::config::AppProxyConfig;
use crate::discovery_cache::DiscoveryResponse;
use reqwest::Url;
use serde::Serialize;
#[derive(Serialize)]
pub struct BootstrapPayload<'a> {
pub config: BootstrapConfig<'a>,
pub instance: &'a serde_json::Value,
}
#[derive(Serialize)]
pub struct BootstrapConfig<'a> {
#[serde(rename = "releaseChannel")]
pub release_channel: &'a str,
#[serde(rename = "bootstrapApiEndpoint")]
pub bootstrap_api_endpoint: &'a str,
#[serde(
rename = "bootstrapApiPublicEndpoint",
skip_serializing_if = "Option::is_none"
)]
pub bootstrap_api_public_endpoint: Option<&'a str>,
}
#[derive(Serialize)]
struct LegacyConfig<'a> {
#[serde(rename = "PUBLIC_RELEASE_CHANNEL")]
release_channel: &'a str,
#[serde(rename = "PUBLIC_BOOTSTRAP_API_ENDPOINT")]
bootstrap_api_endpoint: &'a str,
#[serde(
rename = "PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT",
skip_serializing_if = "Option::is_none"
)]
bootstrap_api_public_endpoint: Option<&'a str>,
}
pub fn build_bootstrap_script(config: &AppProxyConfig, discovery: &DiscoveryResponse) -> String {
let api_public_endpoint =
api_public_endpoint(config.bootstrap_api_public_endpoint.as_deref(), discovery);
let payload = BootstrapPayload {
config: BootstrapConfig {
release_channel: config.release_channel.as_str(),
bootstrap_api_endpoint: &config.bootstrap_api_endpoint,
bootstrap_api_public_endpoint: api_public_endpoint,
},
instance: &discovery.data,
};
let legacy = LegacyConfig {
release_channel: config.release_channel.as_str(),
bootstrap_api_endpoint: &config.bootstrap_api_endpoint,
bootstrap_api_public_endpoint: api_public_endpoint,
};
let bootstrap_json = escape_json_for_script(&serde_json::to_string(&payload).unwrap());
let legacy_json = escape_json_for_script(&serde_json::to_string(&legacy).unwrap());
format!(
r#"<script>window.__FLUXER_BOOTSTRAP__={bootstrap_json};window.__FLUXER_CONFIG__={legacy_json};</script>"#
)
}
pub fn rewrite_endpoints_for_same_origin_host(instance: &mut serde_json::Value, host: &str) {
let Some(endpoints) = instance
.get_mut("endpoints")
.and_then(serde_json::Value::as_object_mut)
else {
return;
};
let origin = format!("https://{host}");
let api = format!("{origin}/api");
for (key, value) in [
("api_client", &api),
("api", &api),
("webapp", &origin),
("app", &origin),
] {
if let Some(endpoint) = endpoints.get_mut(key) {
*endpoint = serde_json::Value::String(value.clone());
}
}
}
fn api_public_endpoint<'a>(
configured: Option<&'a str>,
discovery: &'a DiscoveryResponse,
) -> Option<&'a str> {
let configured = configured?;
let Some(discovered) = discovered_api_public(discovery) else {
return Some(configured);
};
if has_explicit_port(configured) || !has_explicit_port(discovered) {
return Some(configured);
}
let (Ok(configured_url), Ok(discovered_url)) = (Url::parse(configured), Url::parse(discovered))
else {
return Some(configured);
};
if configured_url.scheme() != discovered_url.scheme()
|| configured_url.host_str() != discovered_url.host_str()
|| configured_url.path() != discovered_url.path()
{
return Some(configured);
}
Some(discovered)
}
fn discovered_api_public(discovery: &DiscoveryResponse) -> Option<&str> {
discovery
.data
.get("endpoints")
.and_then(|endpoints| endpoints.get("api_public"))
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
}
fn has_explicit_port(url: &str) -> bool {
let Some(scheme_end) = url.find("://") else {
return false;
};
let rest = &url[scheme_end + 3..];
let authority_end = rest.find(['/', '\\', '?', '#']).unwrap_or(rest.len());
let host = rest[..authority_end].rsplit('@').next().unwrap_or_default();
host[host.rfind(']').map_or(0, |index| index + 1)..].contains(':')
}
const MEDIA_PRECONNECT_TAG: &str = r#"<link rel="preconnect" href="{{MEDIA_ENDPOINT}}">"#;
const STATIC_PRECONNECT_TAGS: [&str; 2] = [
r#"<link rel="preconnect" href="{{STATIC_CDN_ENDPOINT}}">"#,
r#"<link rel="preconnect" href="{{STATIC_CDN_ENDPOINT}}" crossorigin>"#,
];
pub fn inject_bootstrap(
html: &str,
script_tag: &str,
static_cdn_endpoint: &str,
media_endpoint: &str,
) -> String {
let static_cdn = static_cdn_endpoint.trim_end_matches('/');
let media = media_endpoint.trim_end_matches('/');
let html = apply_static_preconnect(html.to_owned(), static_cdn);
let html = html.replace("{{STATIC_CDN_ENDPOINT}}", static_cdn);
let html = apply_media_preconnect(&html, media, static_cdn);
if html.contains("<!--{{FLUXER_BOOTSTRAP}}-->") {
return html.replace("<!--{{FLUXER_BOOTSTRAP}}-->", script_tag);
}
if html.contains("{{FLUXER_BOOTSTRAP}}") {
return html.replace("{{FLUXER_BOOTSTRAP}}", script_tag);
}
let insert_at = html
.find("<head>")
.map(|pos| pos + "<head>".len())
.or_else(|| {
let pos = html.find("<head ")?;
html[pos..].find('>').map(|close| pos + close + 1)
});
if let Some(insert_at) = insert_at {
let mut result = String::with_capacity(html.len() + script_tag.len() + 3);
result.push_str(&html[..insert_at]);
result.push_str("\n\t\t");
result.push_str(script_tag);
result.push_str(&html[insert_at..]);
return result;
}
html
}
fn apply_static_preconnect(mut html: String, static_cdn: &str) -> String {
if !static_cdn.is_empty() {
return html;
}
for tag in STATIC_PRECONNECT_TAGS {
html = html.replace(&format!("{tag}\n"), "").replace(tag, "");
}
html
}
fn apply_media_preconnect(html: &str, media: &str, static_cdn: &str) -> String {
if media.is_empty() || media == static_cdn {
return html
.replace(&format!("{MEDIA_PRECONNECT_TAG}\n"), "")
.replace(MEDIA_PRECONNECT_TAG, "")
.replace("{{MEDIA_ENDPOINT}}", "");
}
html.replace("{{MEDIA_ENDPOINT}}", media)
}
fn escape_json_for_script(value: &str) -> String {
value
.replace("</", "<\\/")
.replace('\u{2028}', "\\u2028")
.replace('\u{2029}', "\\u2029")
}
#[cfg(test)]
mod tests {
use super::*;
const SHIPPED_APP_SHELL: &str = include_str!("../../fluxer_app/index.html");
fn inject_into_shipped_shell() -> String {
inject_bootstrap(
SHIPPED_APP_SHELL,
"<script>boot</script>",
"https://cdn.example.test/",
"https://media.example.test/",
)
}
#[test]
fn shipped_shell_declares_both_static_cdn_socket_pools() {
assert!(
SHIPPED_APP_SHELL
.contains(r#"<link rel="preconnect" href="{{STATIC_CDN_ENDPOINT}}" crossorigin>"#),
"fluxer_app/index.html has no anonymous preconnect for its module scripts and fonts"
);
assert!(
SHIPPED_APP_SHELL.contains(r#"<link rel="preconnect" href="{{STATIC_CDN_ENDPOINT}}">"#),
"fluxer_app/index.html lost the credentialed preconnect its stylesheet and icons use"
);
}
#[test]
fn shipped_shell_serves_three_distinct_preconnects() {
let result = inject_into_shipped_shell();
assert!(result.contains(r#"<link rel="preconnect" href="https://cdn.example.test">"#));
assert!(
result
.contains(r#"<link rel="preconnect" href="https://cdn.example.test" crossorigin>"#)
);
assert!(result.contains(r#"<link rel="preconnect" href="https://media.example.test">"#));
assert_eq!(result.matches("preconnect").count(), 3);
}
#[test]
fn shipped_shell_ships_no_unsubstituted_placeholder() {
let result = inject_into_shipped_shell();
assert!(!result.contains("{{STATIC_CDN_ENDPOINT}}"));
assert!(!result.contains("{{MEDIA_ENDPOINT}}"));
assert!(!result.contains("{{FLUXER_BOOTSTRAP}}"));
assert!(result.contains("<script>boot</script>"));
}
#[test]
fn shipped_shell_has_no_nonce_attribute_or_placeholder() {
assert!(!SHIPPED_APP_SHELL.contains("nonce"));
assert!(!SHIPPED_APP_SHELL.contains("{{CSP_NONCE_PLACEHOLDER}}"));
}
#[test]
fn inject_bootstrap_before_head_close() {
let html = "<html><head><title>App</title></head><body></body></html>";
let result = inject_bootstrap(html, "<script>boot</script>", "", "");
assert!(result.contains("<script>boot</script>"));
assert!(result.contains("<head>"));
}
#[test]
fn inject_bootstrap_fluxer_placeholder() {
let html = "<html><head>{{FLUXER_BOOTSTRAP}}</head></html>";
let result = inject_bootstrap(html, "<script>x</script>", "", "");
assert!(result.contains("<script>x</script>"));
assert!(!result.contains("{{FLUXER_BOOTSTRAP}}"));
}
#[test]
fn inject_bootstrap_comment_placeholder() {
let html = "<html><head><!--{{FLUXER_BOOTSTRAP}}--></head></html>";
let result = inject_bootstrap(html, "<script>y</script>", "", "");
assert!(result.contains("<script>y</script>"));
assert!(!result.contains("<!--{{FLUXER_BOOTSTRAP}}-->"));
}
#[test]
fn inject_bootstrap_replaces_static_cdn_endpoint_placeholder() {
let html = r#"<html><head><link href="{{STATIC_CDN_ENDPOINT}}/web/favicon-32x32.png">{{FLUXER_BOOTSTRAP}}</head></html>"#;
let result = inject_bootstrap(
html,
"<script>boot</script>",
"https://cdn.example.test/",
"",
);
assert!(result.contains(r#"href="https://cdn.example.test/web/favicon-32x32.png""#));
assert!(!result.contains("{{STATIC_CDN_ENDPOINT}}"));
}
#[test]
fn inject_bootstrap_replaces_media_endpoint_placeholder() {
let html = r#"<html><head><link rel="preconnect" href="{{STATIC_CDN_ENDPOINT}}">
<link rel="preconnect" href="{{MEDIA_ENDPOINT}}">
{{FLUXER_BOOTSTRAP}}</head></html>"#;
let result = inject_bootstrap(
html,
"<script>boot</script>",
"https://cdn.example.test/",
"https://media.example.test/",
);
assert!(result.contains(r#"<link rel="preconnect" href="https://media.example.test">"#));
assert!(result.contains(r#"<link rel="preconnect" href="https://cdn.example.test">"#));
assert!(!result.contains("{{MEDIA_ENDPOINT}}"));
}
#[test]
fn inject_bootstrap_drops_media_preconnect_when_endpoint_is_empty() {
let html = r#"<html><head><link rel="preconnect" href="{{STATIC_CDN_ENDPOINT}}">
<link rel="preconnect" href="{{MEDIA_ENDPOINT}}">
{{FLUXER_BOOTSTRAP}}</head></html>"#;
let result = inject_bootstrap(
html,
"<script>boot</script>",
"https://cdn.example.test",
"",
);
assert_eq!(result.matches("preconnect").count(), 1);
assert!(!result.contains("{{MEDIA_ENDPOINT}}"));
assert!(!result.contains(r#"href="">"#));
}
#[test]
fn inject_bootstrap_drops_media_preconnect_when_it_matches_the_static_cdn() {
let html = r#"<html><head><link rel="preconnect" href="{{STATIC_CDN_ENDPOINT}}">
<link rel="preconnect" href="{{MEDIA_ENDPOINT}}">
{{FLUXER_BOOTSTRAP}}</head></html>"#;
let result = inject_bootstrap(
html,
"<script>boot</script>",
"https://cdn.example.test",
"https://cdn.example.test/",
);
assert_eq!(result.matches("preconnect").count(), 1);
assert!(!result.contains("{{MEDIA_ENDPOINT}}"));
}
const SHELL_PRECONNECT_HEAD: &str = r#"<html><head><link rel="preconnect" href="{{STATIC_CDN_ENDPOINT}}">
<link rel="preconnect" href="{{STATIC_CDN_ENDPOINT}}" crossorigin>
<link rel="preconnect" href="{{MEDIA_ENDPOINT}}">
{{FLUXER_BOOTSTRAP}}</head></html>"#;
#[test]
fn static_cdn_keeps_a_credentialed_and_an_anonymous_preconnect() {
let result = inject_bootstrap(
SHELL_PRECONNECT_HEAD,
"<script>boot</script>",
"https://cdn.example.test/",
"https://media.example.test",
);
assert!(result.contains(r#"<link rel="preconnect" href="https://cdn.example.test">"#));
assert!(
result
.contains(r#"<link rel="preconnect" href="https://cdn.example.test" crossorigin>"#)
);
assert_eq!(result.matches("preconnect").count(), 3);
}
#[test]
fn both_static_preconnects_are_dropped_when_the_endpoint_is_empty() {
let result = inject_bootstrap(
SHELL_PRECONNECT_HEAD,
"<script>boot</script>",
"",
"https://media.example.test",
);
assert_eq!(result.matches("preconnect").count(), 1);
assert!(!result.contains(r#"href="""#));
assert!(!result.contains("{{STATIC_CDN_ENDPOINT}}"));
}
#[test]
fn media_preconnect_has_no_crossorigin_attribute() {
assert!(!MEDIA_PRECONNECT_TAG.contains("crossorigin"));
}
#[test]
fn escape_json_for_script_escapes_closing_script() {
assert_eq!(escape_json_for_script("</script>"), "<\\/script>");
}
#[test]
fn escape_json_for_script_escapes_line_separators() {
let input = "a\u{2028}b\u{2029}c";
let result = escape_json_for_script(input);
assert_eq!(result, "a\\u2028b\\u2029c");
}
#[test]
fn escape_json_for_script_no_change_for_safe_input() {
assert_eq!(
escape_json_for_script(r#"{"key":"value"}"#),
r#"{"key":"value"}"#
);
}
#[test]
fn bootstrap_payload_serialization_field_names() {
let instance = serde_json::json!({"name": "test"});
let payload = BootstrapPayload {
config: BootstrapConfig {
release_channel: "stable",
bootstrap_api_endpoint: "/api",
bootstrap_api_public_endpoint: None,
},
instance: &instance,
};
let json = serde_json::to_string(&payload).unwrap();
assert!(json.contains(r#""releaseChannel""#));
assert!(json.contains(r#""bootstrapApiEndpoint""#));
assert!(json.contains(r#""config""#));
assert!(json.contains(r#""instance""#));
assert!(!json.contains("geoip"));
}
#[test]
fn bootstrap_config_serializes_public_endpoint_when_present() {
let instance = serde_json::json!({});
let payload = BootstrapPayload {
config: BootstrapConfig {
release_channel: "canary",
bootstrap_api_endpoint: "/api",
bootstrap_api_public_endpoint: Some("https://pub.example.com/api"),
},
instance: &instance,
};
let json = serde_json::to_string(&payload).unwrap();
assert!(json.contains(r#""bootstrapApiPublicEndpoint""#));
}
#[test]
fn bootstrap_config_omits_public_endpoint_when_none() {
let instance = serde_json::json!({});
let payload = BootstrapPayload {
config: BootstrapConfig {
release_channel: "stable",
bootstrap_api_endpoint: "/api",
bootstrap_api_public_endpoint: None,
},
instance: &instance,
};
let json = serde_json::to_string(&payload).unwrap();
assert!(!json.contains("bootstrapApiPublicEndpoint"));
}
fn discovery_offering(api_public: &str) -> DiscoveryResponse {
DiscoveryResponse {
data: serde_json::json!({"endpoints": {"api_public": api_public}}),
}
}
#[test]
fn the_discovered_port_repairs_a_portless_configured_endpoint() {
let discovery = discovery_offering("https://chat.example.test:8443/api");
assert_eq!(
api_public_endpoint(Some("https://chat.example.test/api"), &discovery),
Some("https://chat.example.test:8443/api")
);
}
#[test]
fn a_configured_endpoint_that_already_has_a_port_is_left_alone() {
let discovery = discovery_offering("https://chat.example.test:8443/api");
assert_eq!(
api_public_endpoint(Some("https://chat.example.test:9443/api"), &discovery),
Some("https://chat.example.test:9443/api")
);
}
#[test]
fn a_discovered_endpoint_on_another_host_is_ignored() {
let discovery = discovery_offering("https://api.example.test:8443/api");
assert_eq!(
api_public_endpoint(Some("https://chat.example.test/api"), &discovery),
Some("https://chat.example.test/api")
);
}
#[test]
fn a_discovered_endpoint_on_another_path_is_ignored() {
let discovery = discovery_offering("https://chat.example.test:8443/v9/api");
assert_eq!(
api_public_endpoint(Some("https://chat.example.test/api"), &discovery),
Some("https://chat.example.test/api")
);
}
#[test]
fn a_discovered_endpoint_on_another_scheme_is_ignored() {
let discovery = discovery_offering("http://chat.example.test:8443/api");
assert_eq!(
api_public_endpoint(Some("https://chat.example.test/api"), &discovery),
Some("https://chat.example.test/api")
);
}
#[test]
fn a_discovery_document_without_a_snapshot_leaves_the_configured_endpoint_alone() {
let discovery = DiscoveryResponse {
data: serde_json::json!({}),
};
assert_eq!(
api_public_endpoint(Some("https://chat.example.test/api"), &discovery),
Some("https://chat.example.test/api")
);
}
#[test]
fn a_discovery_document_without_a_public_api_endpoint_changes_nothing() {
let discovery = DiscoveryResponse {
data: serde_json::json!({"endpoints": {"api_public": " "}}),
};
assert_eq!(
api_public_endpoint(Some("https://chat.example.test/api"), &discovery),
Some("https://chat.example.test/api")
);
}
#[test]
fn a_portless_discovered_endpoint_leaves_a_default_install_alone() {
let discovery = discovery_offering("https://chat.example.test/api");
assert_eq!(
api_public_endpoint(Some("https://chat.example.test/api"), &discovery),
Some("https://chat.example.test/api")
);
}
#[test]
fn an_unconfigured_public_endpoint_is_never_invented_from_discovery() {
let discovery = discovery_offering("https://chat.example.test:8443/api");
assert_eq!(api_public_endpoint(None, &discovery), None);
}
#[test]
fn a_same_origin_host_takes_over_the_client_and_web_app_endpoints() {
let mut instance = serde_json::json!({
"endpoints": {
"api": "https://web.fluxer.app/api",
"api_client": "https://web.fluxer.app/api",
"api_public": "https://api.fluxer.app",
"gateway": "wss://gateway.fluxer.app",
"webapp": "https://web.fluxer.app",
"app": "https://web.fluxer.app",
"marketing": "https://fluxer.app"
}
});
rewrite_endpoints_for_same_origin_host(&mut instance, "fluxer.com");
assert_eq!(
instance["endpoints"],
serde_json::json!({
"api": "https://fluxer.com/api",
"api_client": "https://fluxer.com/api",
"api_public": "https://api.fluxer.app",
"gateway": "wss://gateway.fluxer.app",
"webapp": "https://fluxer.com",
"app": "https://fluxer.com",
"marketing": "https://fluxer.app"
})
);
}
#[test]
fn a_same_origin_host_never_invents_endpoints_discovery_left_out() {
let mut instance = serde_json::json!({
"endpoints": {"api_client": "https://web.fluxer.app/api"}
});
rewrite_endpoints_for_same_origin_host(&mut instance, "fluxer.com");
assert_eq!(
instance,
serde_json::json!({"endpoints": {"api_client": "https://fluxer.com/api"}})
);
let mut without_endpoints = serde_json::json!({"name": "test"});
rewrite_endpoints_for_same_origin_host(&mut without_endpoints, "fluxer.com");
assert_eq!(without_endpoints, serde_json::json!({"name": "test"}));
}
#[test]
fn the_boot_script_hands_the_repaired_endpoint_to_both_globals() {
let discovery = discovery_offering("https://chat.example.test:8443/api");
let mut config = AppProxyConfig::from_env();
config.bootstrap_api_public_endpoint = Some("https://chat.example.test/api".to_owned());
let script = build_bootstrap_script(&config, &discovery);
assert!(
script.contains(r#""bootstrapApiPublicEndpoint":"https://chat.example.test:8443/api""#)
);
assert!(script.contains(
r#""PUBLIC_BOOTSTRAP_API_PUBLIC_ENDPOINT":"https://chat.example.test:8443/api""#
));
assert!(!script.contains(r#""https://chat.example.test/api""#));
}
#[test]
fn the_boot_script_is_a_bare_inline_script_with_nothing_per_visitor() {
let discovery = discovery_offering("https://chat.example.test/api");
let config = AppProxyConfig::from_env();
let script = build_bootstrap_script(&config, &discovery);
assert!(script.starts_with("<script>window.__FLUXER_BOOTSTRAP__="));
assert!(script.ends_with("</script>"));
assert!(!script.contains("nonce"));
assert!(!script.contains("geoip"));
assert!(!script.contains("countryCode"));
}
}
+7
View File
@@ -343,6 +343,7 @@ pub struct AppProxyConfig {
pub static_dir: String,
pub index_upstream_url: Option<HttpUrl>,
pub static_cdn_endpoint: Option<HttpEndpoint>,
pub media_endpoint: Option<HttpEndpoint>,
pub s3_public_endpoint: Option<HttpEndpoint>,
pub s3_uploads_endpoint: Option<HttpEndpoint>,
pub discovery_upstream_url: String,
@@ -358,6 +359,7 @@ pub struct AppProxyConfig {
pub client_ip_header_name: String,
pub same_origin_hosts: Vec<String>,
pub manifest_scope_extensions: Vec<String>,
pub self_hosted: bool,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -476,6 +478,10 @@ impl AppProxyConfig {
"FLUXER_STATIC_CDN_ENDPOINT",
cfg::env_value("FLUXER_STATIC_CDN_ENDPOINT"),
),
media_endpoint: parse_optional_http_endpoint(
"FLUXER_MEDIA_ENDPOINT",
cfg::env_value("FLUXER_MEDIA_ENDPOINT"),
),
s3_public_endpoint,
s3_uploads_endpoint,
discovery_upstream_url: resolve_discovery_upstream_url_from_env(),
@@ -506,6 +512,7 @@ impl AppProxyConfig {
"FLUXER_APP_PROXY_MANIFEST_SCOPE_EXTENSIONS",
&cfg::read_env("FLUXER_APP_PROXY_MANIFEST_SCOPE_EXTENSIONS", ""),
),
self_hosted: cfg::read_bool_env("FLUXER_SELF_HOSTED", false),
}
}
}
+78 -24
View File
@@ -86,7 +86,6 @@ pub struct RuntimeCspSources {
pub media_endpoint: Option<HttpEndpoint>,
pub s3_public_endpoint: Option<HttpEndpoint>,
pub s3_uploads_endpoint: Option<HttpEndpoint>,
pub branding_image_origins: Vec<HttpEndpoint>,
}
const FRAME_SOURCES: &[&str] = &[
@@ -169,19 +168,25 @@ impl std::error::Error for CspCompileError {
#[derive(Clone, Debug)]
pub struct CompiledCspPolicy {
config: CspConfig,
runtime_sources: RuntimeCspSources,
asset: HeaderValue,
}
#[derive(Clone, Debug)]
pub struct CspHeaderValues {
pub enforced: HeaderValue,
pub report_only: Option<HeaderValue>,
}
impl CompiledCspPolicy {
pub fn from_config(config: &AppProxyConfig) -> Result<Self, CspCompileError> {
Self::compile(
config.csp.clone(),
&RuntimeCspSources {
static_cdn_endpoint: config.static_cdn_endpoint.clone(),
media_endpoint: None,
media_endpoint: config.media_endpoint.clone(),
s3_public_endpoint: config.s3_public_endpoint.clone(),
s3_uploads_endpoint: config.s3_uploads_endpoint.clone(),
branding_image_origins: Vec::new(),
},
)
}
@@ -202,22 +207,31 @@ impl CompiledCspPolicy {
configured_sources,
))
.map_err(CspCompileError::InvalidSpaPolicy)?;
Ok(Self { config, asset })
Ok(Self {
config,
runtime_sources: configured_sources.clone(),
asset,
})
}
pub fn asset_header(&self) -> HeaderValue {
self.asset.clone()
}
pub fn spa_header(
&self,
script_hashes: &[InlineScriptHash],
runtime_sources: &RuntimeCspSources,
) -> HeaderValue {
HeaderValue::from_str(&build_csp(&self.config, script_hashes, runtime_sources)).expect(
pub fn spa_headers(&self, script_hashes: &[InlineScriptHash]) -> CspHeaderValues {
let enforced = HeaderValue::from_str(&build_csp(
&self.config,
script_hashes,
&self.runtime_sources,
))
.expect(
"every CSP source is a validated keyword, scheme, ASCII origin, or base64 hash, so a \
policy built from them is always a valid header value",
)
);
CspHeaderValues {
enforced,
report_only: None,
}
}
}
@@ -264,9 +278,6 @@ fn build_csp_directives(
let mut img = vec!["'self'".to_owned(), "blob:".to_owned(), "data:".to_owned()];
extend_from(&mut img, &config.extra_img_src, IMAGE_SOURCES);
extend_runtime_sources(&mut img, runtime_sources, true, true);
for origin in &runtime_sources.branding_image_origins {
push_endpoint_source(&mut img, Some(origin));
}
directives.push(format!("img-src {}", img.join(" ")));
let mut media = vec!["'self'".to_owned(), "blob:".to_owned()];
@@ -636,20 +647,63 @@ mod tests {
}
#[test]
fn a_compiled_policy_stamps_the_documents_script_hashes_and_discovery_endpoints() {
let policy = CompiledCspPolicy::compile(default_csp_config(), &runtime_sources()).unwrap();
let discovered = RuntimeCspSources {
static_cdn_endpoint: Some(endpoint("https://cdn.discovered.test")),
branding_image_origins: vec![endpoint("https://branding.discovered.test")],
fn a_compiled_policy_stamps_the_documents_script_hashes_and_configured_endpoints() {
let configured = RuntimeCspSources {
static_cdn_endpoint: Some(endpoint("https://cdn.configured.test")),
..Default::default()
};
let policy = CompiledCspPolicy::compile(default_csp_config(), &configured).unwrap();
let header = policy.spa_header(&[hash_of("boot()")], &discovered);
let header = header.to_str().unwrap();
let headers = policy.spa_headers(&[hash_of("boot()")]);
let header = headers.enforced.to_str().unwrap();
assert!(header.contains(hash_of("boot()").as_source()));
assert!(header.contains("https://cdn.discovered.test"));
assert!(header.contains("https://branding.discovered.test"));
assert!(header.contains("https://cdn.configured.test"));
assert!(!header.contains("nonce-"));
assert!(headers.report_only.is_none());
}
#[test]
fn the_configured_media_endpoint_reaches_every_directive_that_loads_media() {
let mut config = AppProxyConfig::from_env();
config.csp = default_csp_config();
config.media_endpoint = Some(endpoint("https://media.configured.test/"));
let policy = CompiledCspPolicy::from_config(&config).unwrap();
let headers = policy.spa_headers(&[hash_of("boot()")]);
let header = headers.enforced.to_str().unwrap();
for directive in [
"img-src",
"media-src",
"font-src",
"style-src",
"connect-src",
] {
let sources = header
.split("; ")
.find(|candidate| candidate.starts_with(directive))
.unwrap_or_else(|| panic!("the policy has no {directive}"));
assert!(
sources
.split(' ')
.any(|source| source == "https://media.configured.test"),
"{directive} does not grant the configured media endpoint"
);
}
for directive in ["script-src", "worker-src", "manifest-src"] {
let sources = header
.split("; ")
.find(|candidate| candidate.starts_with(directive))
.unwrap_or_else(|| panic!("the policy has no {directive}"));
assert!(!sources.contains("https://media.configured.test"));
}
assert!(
!policy
.asset_header()
.to_str()
.unwrap()
.contains("https://media.configured.test")
);
}
#[test]
@@ -667,7 +721,7 @@ mod tests {
);
let hashes = [hash_of("boot()")];
assert_eq!(
policy.spa_header(&hashes, &sources).to_str().unwrap(),
policy.spa_headers(&hashes).enforced.to_str().unwrap(),
build_csp(&config, &hashes, &sources)
);
}
-242
View File
@@ -1,242 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::config::HttpEndpoint;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::{Mutex, RwLock};
use tokio::task::JoinHandle;
const COLD_START_RETRY_COOLDOWN: Duration = Duration::from_secs(10);
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct DiscoveryResponse {
#[serde(flatten)]
pub data: serde_json::Value,
}
pub fn discovery_endpoint(
discovery: &DiscoveryResponse,
key: &'static str,
) -> Option<HttpEndpoint> {
let raw = discovery
.data
.get("endpoints")
.and_then(|endpoints| endpoints.get(key))
.and_then(|value| value.as_str())
.map(str::trim)
.filter(|value| !value.is_empty())?;
match HttpEndpoint::parse(key, raw) {
Ok(endpoint) => Some(endpoint),
Err(error) => {
tracing::warn!(%error, "ignoring invalid discovery endpoint");
None
}
}
}
pub struct DiscoveryCache {
cached: RwLock<Option<DiscoveryResponse>>,
cold_start_attempt: Mutex<Option<Instant>>,
}
impl DiscoveryCache {
pub fn new() -> Self {
Self {
cached: RwLock::new(None),
cold_start_attempt: Mutex::new(None),
}
}
/// Discovery for a request that has no cached value yet.
///
/// Steady-state freshness is owned by `start_background_refresh`, which backs off when the
/// upstream is unhealthy. Request handlers must never block on the network while a cached
/// value exists, and must not retry a failing upstream once per request: the upstream is
/// reached through this proxy's own public hostname, so a stall here feeds back into the
/// front door that serves it.
pub async fn get_or_cold_start(
&self,
client: &reqwest::Client,
upstream_url: &str,
) -> Option<DiscoveryResponse> {
if let Some(cached) = self.get().await {
return Some(cached);
}
{
let mut last_attempt = self.cold_start_attempt.lock().await;
if let Some(at) = *last_attempt
&& at.elapsed() < COLD_START_RETRY_COOLDOWN
{
return None;
}
*last_attempt = Some(Instant::now());
}
if let Err(err) = self.refresh(client, upstream_url).await {
tracing::warn!(%err, url = upstream_url, "cold-start discovery fetch failed");
}
self.get().await
}
pub async fn refresh(
&self,
client: &reqwest::Client,
upstream_url: &str,
) -> anyhow::Result<()> {
let response = client
.get(upstream_url)
.header("Accept", "application/json")
.timeout(Duration::from_secs(5))
.send()
.await?;
if !response.status().is_success() {
anyhow::bail!("upstream discovery returned {}", response.status());
}
let body: DiscoveryResponse = response.json().await?;
let api_code_version = body
.data
.get("api_code_version")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
tracing::info!(url = upstream_url, %api_code_version, "discovery cache updated");
*self.cached.write().await = Some(body);
Ok(())
}
pub async fn get(&self) -> Option<DiscoveryResponse> {
self.cached.read().await.clone()
}
pub async fn has_snapshot(&self) -> bool {
self.cached.read().await.is_some()
}
pub fn start_background_refresh(
self: &Arc<Self>,
client: reqwest::Client,
upstream_url: String,
interval_ms: u64,
) -> JoinHandle<()> {
let cache = Arc::clone(self);
tokio::spawn(async move {
let base = Duration::from_millis(interval_ms);
let max_backoff = Duration::from_secs(300);
let mut consecutive_failures: u32 = 0;
tokio::time::sleep(base).await;
loop {
match cache.refresh(&client, &upstream_url).await {
Ok(()) => {
consecutive_failures = 0;
tokio::time::sleep(base).await;
}
Err(err) => {
consecutive_failures = consecutive_failures.saturating_add(1);
let multiplier = 1u32 << consecutive_failures.min(5);
let backoff = base.saturating_mul(multiplier).min(max_backoff);
tracing::warn!(
%err,
url = %upstream_url,
consecutive_failures,
backoff_ms = backoff.as_millis() as u64,
"discovery refresh failed; backing off"
);
tokio::time::sleep(backoff).await;
}
}
}
})
}
}
impl Default for DiscoveryCache {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn new_cache_starts_with_none() {
let cache = DiscoveryCache::new();
assert!(cache.get().await.is_none());
}
#[tokio::test]
async fn default_cache_starts_with_none() {
let cache = DiscoveryCache::default();
assert!(cache.get().await.is_none());
}
#[tokio::test]
async fn a_seeded_cache_reports_a_snapshot_without_cloning_it() {
let cache = DiscoveryCache::new();
assert!(!cache.has_snapshot().await);
*cache.cached.write().await =
Some(serde_json::from_str(r#"{"api_code_version":"v1"}"#).unwrap());
assert!(cache.has_snapshot().await);
}
#[tokio::test]
async fn cached_discovery_never_touches_the_network() {
let cache = DiscoveryCache::new();
let json = r#"{"api_code_version":"v1"}"#;
*cache.cached.write().await = Some(serde_json::from_str(json).unwrap());
// An unroutable upstream: if this were contacted the call would stall or error.
let client = reqwest::Client::new();
let got = cache
.get_or_cold_start(&client, "http://127.0.0.1:1/discovery")
.await;
assert!(got.is_some());
assert!(cache.cold_start_attempt.lock().await.is_none());
}
#[tokio::test]
async fn cold_start_failure_is_not_retried_on_every_request() {
let cache = DiscoveryCache::new();
let client = reqwest::Client::builder()
.timeout(Duration::from_millis(50))
.build()
.unwrap();
assert!(
cache
.get_or_cold_start(&client, "http://127.0.0.1:1/discovery")
.await
.is_none()
);
let first = *cache.cold_start_attempt.lock().await;
assert!(first.is_some());
assert!(
cache
.get_or_cold_start(&client, "http://127.0.0.1:1/discovery")
.await
.is_none()
);
assert_eq!(*cache.cold_start_attempt.lock().await, first);
}
#[test]
fn discovery_response_deserializes_from_json() {
let json = r#"{"api_code_version":"v1","name":"test"}"#;
let resp: DiscoveryResponse = serde_json::from_str(json).unwrap();
assert_eq!(
resp.data.get("api_code_version").and_then(|v| v.as_str()),
Some("v1")
);
assert_eq!(resp.data.get("name").and_then(|v| v.as_str()), Some("test"));
}
#[test]
fn discovery_response_roundtrips_through_serde() {
let json = r#"{"api_code_version":"v2","features":["a","b"]}"#;
let resp: DiscoveryResponse = serde_json::from_str(json).unwrap();
let serialized = serde_json::to_string(&resp).unwrap();
let resp2: DiscoveryResponse = serde_json::from_str(&serialized).unwrap();
assert_eq!(resp.data, resp2.data);
}
}
-46
View File
@@ -1,46 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::config::AppProxyConfig;
use fluxer_common::geoip::{GeoipConfig, GeoipLookup, GeoipResolver};
use serde::{Deserialize, Serialize};
use std::sync::LazyLock;
#[derive(Deserialize, Serialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct GeoEntry {
country_code: String,
region_code: Option<String>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
struct AgeGeos {
age_restricted_geos: Vec<GeoEntry>,
age_blocked_geos: Vec<GeoEntry>,
}
static AGE_GEOS: LazyLock<AgeGeos> = LazyLock::new(|| {
serde_json::from_str(include_str!("../../packages/constants/src/AgeGeos.json")).expect(
"bundled packages/constants/src/AgeGeos.json must contain a valid age geography policy",
)
});
pub fn resolver_from_app_config(config: &AppProxyConfig) -> GeoipResolver {
GeoipResolver::from_config(&GeoipConfig {
geoip_source: config.geoip_source.clone(),
geoip_s3_config: config.geoip_s3_config.clone(),
trust_client_ip_header: config.trust_client_ip_header,
client_ip_header_name: config.client_ip_header_name.clone(),
})
}
pub fn build_geoip_response(lookup: GeoipLookup) -> serde_json::Value {
serde_json::json!({
"countryCode": lookup.country_code,
"regionCode": lookup.region_code,
"latitude": lookup.latitude.map(|v| v.to_string()),
"longitude": lookup.longitude.map(|v| v.to_string()),
"ageRestrictedGeos": &AGE_GEOS.age_restricted_geos,
"ageBlockedGeos": &AGE_GEOS.age_blocked_geos,
})
}
+2 -3
View File
@@ -1,9 +1,8 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
pub mod bootstrap;
pub mod config;
pub mod csp;
pub mod discovery_cache;
pub mod geoip;
mod local_files;
pub mod routes;
pub mod state;
mod static_asset_policy;
+927
View File
@@ -0,0 +1,927 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::routes::capacity_refused_response;
use crate::routes::file_stream::stream_file;
use crate::state::AppProxyBudgets;
use crate::static_asset_policy::{accepts_content_encoding, set_vary_on_accept_encoding};
use anyhow::Context;
use axum::{
body::{Body, BodyDataStream, Bytes},
http::{HeaderMap, HeaderValue, StatusCode, header},
response::{IntoResponse, Response},
};
use futures_util::Stream;
use sha2::{Digest, Sha256};
use std::fmt;
use std::path::{Component, Path, PathBuf};
use std::pin::Pin;
use std::sync::Arc;
use std::task::{Context as TaskContext, Poll};
use tokio::io::AsyncReadExt;
use tokio::sync::{OwnedSemaphorePermit, Semaphore, TryAcquireError};
const LOCAL_FILE_BUFFER_BUDGET_BYTES: usize = 128 * 1024 * 1024;
const LOCAL_FILE_STREAM_BUFFER_BYTES: usize = 256 * 1024;
const PRECOMPRESSED_SIBLINGS: &[(&str, &str)] = &[("br", "br"), ("gzip", "gz")];
#[derive(Clone)]
pub(crate) struct LocalFileStore {
root: Arc<PathBuf>,
read_slots: Arc<Semaphore>,
buffer_budget: Arc<Semaphore>,
}
impl LocalFileStore {
pub(crate) async fn load(root: &Path, budgets: &AppProxyBudgets) -> anyhow::Result<Self> {
let root = tokio::fs::canonicalize(root)
.await
.with_context(|| format!("failed to resolve static directory {}", root.display()))?;
let metadata = tokio::fs::metadata(&root)
.await
.with_context(|| format!("failed to inspect static directory {}", root.display()))?;
Self::from_resolved_root(root, metadata, budgets)
}
fn from_resolved_root(
root: PathBuf,
metadata: std::fs::Metadata,
budgets: &AppProxyBudgets,
) -> anyhow::Result<Self> {
anyhow::ensure!(
root.is_absolute(),
"static directory must resolve absolutely"
);
anyhow::ensure!(metadata.is_dir(), "{} is not a directory", root.display());
Ok(Self {
root: Arc::new(root),
read_slots: Arc::clone(&budgets.local_read_slots),
buffer_budget: Arc::new(Semaphore::new(LOCAL_FILE_BUFFER_BUDGET_BYTES)),
})
}
#[cfg(test)]
pub(crate) fn load_blocking(root: &Path, budgets: &AppProxyBudgets) -> anyhow::Result<Self> {
let root = std::fs::canonicalize(root)
.with_context(|| format!("failed to resolve static directory {}", root.display()))?;
let metadata = std::fs::metadata(&root)
.with_context(|| format!("failed to inspect static directory {}", root.display()))?;
Self::from_resolved_root(root, metadata, budgets)
}
async fn resolve_checked(
&self,
relative_path: &str,
max_bytes: usize,
) -> Result<ResolvedLocalFile, LocalFileReadError> {
let relative = Path::new(relative_path);
if relative.as_os_str().is_empty()
|| relative
.components()
.any(|component| !matches!(component, Component::Normal(_)))
{
return Err(LocalFileReadError::InvalidPath(relative_path.to_owned()));
}
let read_slot = match self.read_slots.clone().try_acquire_owned() {
Ok(permit) => permit,
Err(TryAcquireError::NoPermits) => {
return Err(LocalFileReadError::Busy(relative_path.to_owned()));
}
Err(TryAcquireError::Closed) => {
panic!("local file read capacity semaphore closed unexpectedly")
}
};
let requested = self.root.join(relative);
let resolved = match tokio::fs::canonicalize(&requested).await {
Ok(path) => path,
Err(source) if source.kind() == std::io::ErrorKind::NotFound => {
return Err(LocalFileReadError::NotFound(relative_path.to_owned()));
}
Err(source) => {
return Err(LocalFileReadError::Io {
path: requested,
source,
});
}
};
if !resolved.starts_with(self.root.as_ref()) {
return Err(LocalFileReadError::InvalidPath(relative_path.to_owned()));
}
let metadata =
tokio::fs::metadata(&resolved)
.await
.map_err(|source| LocalFileReadError::Io {
path: resolved.clone(),
source,
})?;
if !metadata.is_file() {
return Err(LocalFileReadError::NotFound(relative_path.to_owned()));
}
if metadata.len() > max_bytes as u64 {
return Err(LocalFileReadError::TooLarge {
path: relative_path.to_owned(),
actual: metadata.len(),
maximum: max_bytes,
});
}
Ok(ResolvedLocalFile {
read_slot,
metadata,
path: resolved,
})
}
async fn read(
&self,
relative_path: &str,
max_bytes: usize,
) -> Result<LocalFileContents, LocalFileReadError> {
assert!(max_bytes <= LOCAL_FILE_BUFFER_BUDGET_BYTES);
let ResolvedLocalFile {
read_slot: _read_slot,
metadata,
path,
} = self.resolve_checked(relative_path, max_bytes).await?;
let expected_bytes = usize::try_from(metadata.len())
.expect("file length within a usize byte limit must fit usize");
let buffer_budget = self.reserve_buffer(expected_bytes, relative_path)?;
let file = tokio::fs::File::open(&path)
.await
.map_err(|source| LocalFileReadError::Io {
path: path.clone(),
source,
})?;
let mut bytes = Vec::with_capacity(expected_bytes);
file.take(metadata.len() + 1)
.read_to_end(&mut bytes)
.await
.map_err(|source| LocalFileReadError::Io { path, source })?;
if bytes.len() > expected_bytes {
return Err(LocalFileReadError::ChangedDuringRead {
path: relative_path.to_owned(),
expected: expected_bytes,
});
}
Ok(LocalFileContents {
bytes,
buffer_budget,
})
}
async fn negotiate_encoding(
&self,
relative_path: &str,
request_headers: &HeaderMap,
max_bytes: usize,
) -> Result<NegotiatedLocalFile, LocalFileReadError> {
for &(encoding, extension) in PRECOMPRESSED_SIBLINGS {
if !accepts_content_encoding(request_headers, encoding) {
continue;
}
let sibling_path = format!("{relative_path}.{extension}");
match self.resolve_checked(&sibling_path, max_bytes).await {
Ok(resolved) => {
return self.with_stream_budget(resolved, Some(encoding), &sibling_path);
}
Err(
LocalFileReadError::NotFound(_)
| LocalFileReadError::InvalidPath(_)
| LocalFileReadError::TooLarge { .. }
| LocalFileReadError::Io { .. },
) => continue,
Err(error) => return Err(error),
}
}
let resolved = self.resolve_checked(relative_path, max_bytes).await?;
self.with_stream_budget(resolved, None, relative_path)
}
fn with_stream_budget(
&self,
resolved: ResolvedLocalFile,
content_encoding: Option<&'static str>,
relative_path: &str,
) -> Result<NegotiatedLocalFile, LocalFileReadError> {
let buffer_budget = self.reserve_buffer(LOCAL_FILE_STREAM_BUFFER_BYTES, relative_path)?;
Ok(NegotiatedLocalFile {
entity_tag: modification_entity_tag(&resolved.metadata),
path: resolved.path,
content_encoding,
buffer_budget,
})
}
fn reserve_buffer(
&self,
bytes: usize,
relative_path: &str,
) -> Result<OwnedSemaphorePermit, LocalFileReadError> {
let permits = u32::try_from(bytes.max(1))
.expect("local file buffer budget must fit the semaphore permit type");
match self.buffer_budget.clone().try_acquire_many_owned(permits) {
Ok(permit) => Ok(permit),
Err(TryAcquireError::NoPermits) => {
Err(LocalFileReadError::Busy(relative_path.to_owned()))
}
Err(TryAcquireError::Closed) => {
panic!("local file buffer budget semaphore closed unexpectedly")
}
}
}
}
struct ResolvedLocalFile {
read_slot: OwnedSemaphorePermit,
metadata: std::fs::Metadata,
path: PathBuf,
}
struct NegotiatedLocalFile {
entity_tag: Option<HeaderValue>,
path: PathBuf,
content_encoding: Option<&'static str>,
buffer_budget: OwnedSemaphorePermit,
}
struct LocalFileContents {
bytes: Vec<u8>,
buffer_budget: OwnedSemaphorePermit,
}
struct LocalFileBody {
bytes: Vec<u8>,
_buffer_budget: OwnedSemaphorePermit,
}
impl AsRef<[u8]> for LocalFileBody {
fn as_ref(&self) -> &[u8] {
&self.bytes
}
}
struct BudgetedLocalFileBody {
chunks: BodyDataStream,
_buffer_budget: OwnedSemaphorePermit,
}
impl Stream for BudgetedLocalFileBody {
type Item = Result<Bytes, axum::Error>;
fn poll_next(
mut self: Pin<&mut Self>,
context: &mut TaskContext<'_>,
) -> Poll<Option<Self::Item>> {
Pin::new(&mut self.chunks).poll_next(context)
}
}
#[derive(Clone, Copy)]
pub(crate) struct LocalFileResponseOptions {
pub content_type: &'static str,
pub cache_control: &'static str,
pub allow_cross_origin: bool,
pub max_bytes: usize,
}
pub(crate) async fn serve_binary_file(
store: &LocalFileStore,
relative_path: &str,
request_headers: &HeaderMap,
options: LocalFileResponseOptions,
) -> Response {
let negotiated = match store
.negotiate_encoding(relative_path, request_headers, options.max_bytes)
.await
{
Ok(negotiated) => negotiated,
Err(error) => return local_file_error_response(error),
};
local_file_stream_response(negotiated, relative_path, request_headers, options).await
}
pub(crate) async fn serve_text_file(
store: &LocalFileStore,
relative_path: &str,
request_headers: &HeaderMap,
options: LocalFileResponseOptions,
replacements: &[(&str, &str)],
finish: impl FnOnce(String) -> String,
) -> Response {
let LocalFileContents {
bytes,
mut buffer_budget,
} = match store.read(relative_path, options.max_bytes).await {
Ok(contents) => contents,
Err(error) => return local_file_error_response(error),
};
let mut text = match String::from_utf8(bytes) {
Ok(text) => text,
Err(_) => {
return local_file_error_response(LocalFileReadError::InvalidUtf8(
relative_path.to_owned(),
));
}
};
for (pattern, replacement) in replacements {
text = match replace_text_bounded(
store,
&mut buffer_budget,
text,
pattern,
replacement,
options.max_bytes,
relative_path,
) {
Ok(text) => text,
Err(error) => return local_file_error_response(error),
};
}
let text = finish(text);
if text.len() > options.max_bytes {
return local_file_error_response(LocalFileReadError::TooLarge {
path: relative_path.to_owned(),
actual: text.len() as u64,
maximum: options.max_bytes,
});
}
if text.len() > buffer_budget.num_permits() {
buffer_budget = match store.reserve_buffer(text.len(), relative_path) {
Ok(output_budget) => output_budget,
Err(error) => return local_file_error_response(error),
};
}
local_file_response(
LocalFileContents {
bytes: text.into_bytes(),
buffer_budget,
},
request_headers,
options,
)
}
fn replace_text_bounded(
store: &LocalFileStore,
buffer_budget: &mut OwnedSemaphorePermit,
text: String,
pattern: &str,
replacement: &str,
max_bytes: usize,
relative_path: &str,
) -> Result<String, LocalFileReadError> {
assert!(!pattern.is_empty());
let matches = text.match_indices(pattern).count();
if matches == 0 {
return Ok(text);
}
let removed = matches
.checked_mul(pattern.len())
.expect("replacement removal length must fit usize");
let inserted =
matches
.checked_mul(replacement.len())
.ok_or_else(|| LocalFileReadError::TooLarge {
path: relative_path.to_owned(),
actual: u64::MAX,
maximum: max_bytes,
})?;
let output_bytes = text
.len()
.checked_sub(removed)
.and_then(|length| length.checked_add(inserted))
.ok_or_else(|| LocalFileReadError::TooLarge {
path: relative_path.to_owned(),
actual: u64::MAX,
maximum: max_bytes,
})?;
if output_bytes > max_bytes {
return Err(LocalFileReadError::TooLarge {
path: relative_path.to_owned(),
actual: output_bytes as u64,
maximum: max_bytes,
});
}
let output_budget = store.reserve_buffer(output_bytes, relative_path)?;
let output = text.replace(pattern, replacement);
drop(text);
*buffer_budget = output_budget;
Ok(output)
}
fn local_file_response(
contents: LocalFileContents,
request_headers: &HeaderMap,
options: LocalFileResponseOptions,
) -> Response {
let LocalFileContents {
bytes,
buffer_budget,
} = contents;
let digest = Sha256::digest(&bytes);
let entity_tag = HeaderValue::from_str(&format!("W/\"{}\"", hex::encode(digest)))
.expect("SHA-256 entity tag must be a valid HTTP header value");
let mut response = if entity_tag_matches(request_headers, &entity_tag) {
StatusCode::NOT_MODIFIED.into_response()
} else {
let body = Bytes::from_owner(LocalFileBody {
bytes,
_buffer_budget: buffer_budget,
});
Response::new(Body::from(body))
};
apply_local_file_headers(response.headers_mut(), Some(entity_tag), None, options);
response
}
async fn local_file_stream_response(
negotiated: NegotiatedLocalFile,
relative_path: &str,
request_headers: &HeaderMap,
options: LocalFileResponseOptions,
) -> Response {
let NegotiatedLocalFile {
entity_tag,
path,
content_encoding,
buffer_budget,
} = negotiated;
if entity_tag
.as_ref()
.is_some_and(|entity_tag| entity_tag_matches(request_headers, entity_tag))
{
let mut response = StatusCode::NOT_MODIFIED.into_response();
apply_local_file_headers(
response.headers_mut(),
entity_tag,
content_encoding,
options,
);
return response;
}
let validator = entity_tag.as_ref().map(|entity_tag| {
entity_tag
.to_str()
.expect("a local file entity tag must contain only visible ASCII")
});
let streamed = stream_file(&path, request_headers, validator).await;
let response = match streamed {
Ok(response) => response,
Err(source) if source.kind() == std::io::ErrorKind::NotFound => {
return local_file_error_response(LocalFileReadError::NotFound(
relative_path.to_owned(),
));
}
Err(source) => return local_file_error_response(LocalFileReadError::Io { path, source }),
};
let (mut parts, body) = response.into_parts();
apply_local_file_headers(&mut parts.headers, entity_tag, content_encoding, options);
Response::from_parts(
parts,
Body::from_stream(BudgetedLocalFileBody {
chunks: body.into_data_stream(),
_buffer_budget: buffer_budget,
}),
)
}
fn modification_entity_tag(metadata: &std::fs::Metadata) -> Option<HeaderValue> {
let modified = metadata.modified().ok()?;
let since_epoch = modified
.duration_since(std::time::SystemTime::UNIX_EPOCH)
.ok()?;
HeaderValue::from_str(&format!(
"W/\"{:x}-{:x}\"",
metadata.len(),
since_epoch.as_nanos()
))
.ok()
}
fn entity_tag_matches(request_headers: &HeaderMap, entity_tag: &HeaderValue) -> bool {
let entity_tag_text = entity_tag
.to_str()
.expect("a local file entity tag must contain only visible ASCII");
let entity_tag_opaque = entity_tag_text
.strip_prefix("W/")
.unwrap_or(entity_tag_text);
request_headers
.get(header::IF_NONE_MATCH)
.and_then(|value| value.to_str().ok())
.is_some_and(|value| {
value.split(',').any(|candidate| {
let candidate = candidate.trim();
candidate == "*"
|| candidate.strip_prefix("W/").unwrap_or(candidate) == entity_tag_opaque
})
})
}
fn apply_local_file_headers(
headers: &mut HeaderMap,
entity_tag: Option<HeaderValue>,
content_encoding: Option<&'static str>,
options: LocalFileResponseOptions,
) {
headers.insert(
header::CONTENT_TYPE,
HeaderValue::from_static(options.content_type),
);
headers.insert(
header::CACHE_CONTROL,
HeaderValue::from_static(options.cache_control),
);
if let Some(entity_tag) = entity_tag {
headers.insert(header::ETAG, entity_tag);
}
if let Some(content_encoding) = content_encoding {
headers.insert(
header::CONTENT_ENCODING,
HeaderValue::from_static(content_encoding),
);
}
set_vary_on_accept_encoding(headers);
if options.allow_cross_origin {
headers.insert(
header::ACCESS_CONTROL_ALLOW_ORIGIN,
HeaderValue::from_static("*"),
);
}
}
#[derive(Debug)]
pub(crate) enum LocalFileReadError {
NotFound(String),
InvalidPath(String),
Busy(String),
ChangedDuringRead {
path: String,
expected: usize,
},
TooLarge {
path: String,
actual: u64,
maximum: usize,
},
InvalidUtf8(String),
Io {
path: PathBuf,
source: std::io::Error,
},
}
impl fmt::Display for LocalFileReadError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::NotFound(path) => write!(formatter, "local file {path:?} was not found"),
Self::InvalidPath(path) => write!(formatter, "local file path {path:?} is invalid"),
Self::Busy(path) => {
write!(formatter, "local file capacity is unavailable for {path:?}")
}
Self::ChangedDuringRead { path, expected } => write!(
formatter,
"local file {path:?} grew after its {expected} byte size was inspected"
),
Self::TooLarge {
path,
actual,
maximum,
} => write!(
formatter,
"local file {path:?} is {actual} bytes and exceeds the {maximum} byte limit"
),
Self::InvalidUtf8(path) => write!(formatter, "local text file {path:?} is not UTF-8"),
Self::Io { path, source } => {
write!(
formatter,
"failed to read local file {}: {source}",
path.display()
)
}
}
}
}
impl std::error::Error for LocalFileReadError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Io { source, .. } => Some(source),
_ => None,
}
}
}
fn local_file_error_response(error: LocalFileReadError) -> Response {
match error {
LocalFileReadError::NotFound(_) | LocalFileReadError::InvalidPath(_) => {
StatusCode::NOT_FOUND.into_response()
}
LocalFileReadError::Busy(_) => capacity_refused_response(),
error => {
tracing::error!(%error, "failed to serve local file");
(StatusCode::INTERNAL_SERVER_ERROR, "Internal Server Error").into_response()
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::state::LOCAL_FILE_READS_IN_FLIGHT_MAX;
fn temp_root(name: &str) -> PathBuf {
let root = std::env::temp_dir().join(format!(
"fluxer-app-proxy-local-files-{}-{name}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).unwrap();
root
}
fn text_options(max_bytes: usize) -> LocalFileResponseOptions {
LocalFileResponseOptions {
content_type: "text/plain; charset=utf-8",
cache_control: "no-cache",
allow_cross_origin: false,
max_bytes,
}
}
async fn body_of(response: Response) -> Vec<u8> {
axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap()
.to_vec()
}
#[tokio::test]
async fn an_open_response_never_holds_a_read_slot_against_the_next_reader() {
let root = temp_root("in-flight");
std::fs::write(root.join("robots.txt"), "User-agent: *\n").unwrap();
let store = LocalFileStore::load(&root, &AppProxyBudgets::new())
.await
.unwrap();
let mut open = Vec::with_capacity(LOCAL_FILE_READS_IN_FLIGHT_MAX + 1);
for _ in 0..=LOCAL_FILE_READS_IN_FLIGHT_MAX {
open.push(
serve_binary_file(&store, "robots.txt", &HeaderMap::new(), text_options(1024))
.await,
);
}
let refused = open
.iter()
.filter(|response| response.status() != StatusCode::OK)
.count();
assert_eq!(
refused, 0,
"{refused} readers were turned away while earlier responses were still open"
);
assert_eq!(
body_of(open.pop().unwrap()).await,
b"User-agent: *\n",
"a response served past the read slot count carried the wrong bytes"
);
std::fs::remove_dir_all(&root).unwrap();
}
#[tokio::test]
async fn concurrent_bundle_responses_are_not_charged_their_whole_file_size() {
let root = temp_root("bundle-budget");
let bundle_bytes = 8 * 1024 * 1024;
std::fs::write(root.join("bundle.js"), vec![b'x'; bundle_bytes]).unwrap();
let store = LocalFileStore::load(&root, &AppProxyBudgets::new())
.await
.unwrap();
let mut open = Vec::with_capacity(32);
for _ in 0..32 {
open.push(
serve_binary_file(
&store,
"bundle.js",
&HeaderMap::new(),
text_options(100 * 1024 * 1024),
)
.await,
);
}
let refused = open
.iter()
.filter(|response| response.status() != StatusCode::OK)
.count();
assert_eq!(
refused, 0,
"{refused} of 32 concurrent bundle responses were refused for buffer capacity"
);
let last = open.pop().unwrap();
assert_eq!(
last.headers().get(header::CONTENT_LENGTH).unwrap(),
bundle_bytes.to_string().as_str(),
"a streamed bundle response did not declare its length"
);
let body = body_of(last).await;
assert_eq!(body.len(), bundle_bytes);
assert!(
body.iter().all(|byte| *byte == b'x'),
"a streamed bundle response carried bytes the file does not hold"
);
std::fs::remove_dir_all(&root).unwrap();
}
#[tokio::test]
async fn a_path_that_leaves_the_static_root_is_not_served() {
let root = temp_root("escape");
let outside = root.parent().unwrap().join("outside.txt");
std::fs::write(root.join("robots.txt"), "User-agent: *\n").unwrap();
std::fs::write(&outside, "secret").unwrap();
let store = LocalFileStore::load(&root, &AppProxyBudgets::new())
.await
.unwrap();
for path in [
"../outside.txt",
"/etc/hosts",
"nested/../../outside.txt",
"",
] {
let response =
serve_binary_file(&store, path, &HeaderMap::new(), text_options(1024)).await;
assert_eq!(
response.status(),
StatusCode::NOT_FOUND,
"{path} was served from outside the static root"
);
}
std::fs::remove_file(&outside).unwrap();
std::fs::remove_dir_all(&root).unwrap();
}
#[cfg(unix)]
#[tokio::test]
async fn a_symlink_pointing_out_of_the_static_root_is_not_served() {
let root = temp_root("symlink");
let outside = root.parent().unwrap().join("symlink-target.txt");
std::fs::write(&outside, "secret").unwrap();
std::os::unix::fs::symlink(&outside, root.join("leak.txt")).unwrap();
let store = LocalFileStore::load(&root, &AppProxyBudgets::new())
.await
.unwrap();
let response =
serve_binary_file(&store, "leak.txt", &HeaderMap::new(), text_options(1024)).await;
assert_eq!(
response.status(),
StatusCode::NOT_FOUND,
"a symlink resolved outside the static root and was served anyway"
);
std::fs::remove_file(&outside).unwrap();
std::fs::remove_dir_all(&root).unwrap();
}
#[tokio::test]
async fn a_revalidating_client_is_answered_from_its_entity_tag() {
let root = temp_root("revalidate");
std::fs::write(root.join("robots.txt"), "User-agent: *\n").unwrap();
let store = LocalFileStore::load(&root, &AppProxyBudgets::new())
.await
.unwrap();
let first =
serve_binary_file(&store, "robots.txt", &HeaderMap::new(), text_options(1024)).await;
assert_eq!(first.status(), StatusCode::OK);
let entity_tag = first.headers().get(header::ETAG).unwrap().clone();
let mut revalidation = HeaderMap::new();
revalidation.insert(header::IF_NONE_MATCH, entity_tag.clone());
let second =
serve_binary_file(&store, "robots.txt", &revalidation, text_options(1024)).await;
assert_eq!(second.status(), StatusCode::NOT_MODIFIED);
assert_eq!(second.headers().get(header::ETAG), Some(&entity_tag));
assert!(body_of(second).await.is_empty());
let mut stale = HeaderMap::new();
stale.insert(header::IF_NONE_MATCH, HeaderValue::from_static("\"other\""));
let third = serve_binary_file(&store, "robots.txt", &stale, text_options(1024)).await;
assert_eq!(
third.status(),
StatusCode::OK,
"a client holding a different entity tag was told its copy was current"
);
std::fs::remove_dir_all(&root).unwrap();
}
#[tokio::test]
async fn a_file_over_its_response_limit_is_refused_before_it_is_buffered() {
let root = temp_root("too-large");
std::fs::write(root.join("version.json"), "x".repeat(4096)).unwrap();
let store = LocalFileStore::load(&root, &AppProxyBudgets::new())
.await
.unwrap();
let response = serve_binary_file(
&store,
"version.json",
&HeaderMap::new(),
text_options(1024),
)
.await;
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
assert_eq!(
store.buffer_budget.available_permits(),
LOCAL_FILE_BUFFER_BUDGET_BYTES,
"a refused file still holds its buffer budget"
);
std::fs::remove_dir_all(&root).unwrap();
}
#[tokio::test]
async fn a_replacement_that_grows_past_the_response_limit_is_refused() {
let root = temp_root("replacement");
std::fs::write(root.join("sw.js"), "const endpoint = '{{MEDIA_ENDPOINT}}';").unwrap();
let store = LocalFileStore::load(&root, &AppProxyBudgets::new())
.await
.unwrap();
let served = serve_text_file(
&store,
"sw.js",
&HeaderMap::new(),
text_options(1024),
&[("{{MEDIA_ENDPOINT}}", "https://media.example.test")],
std::convert::identity,
)
.await;
assert_eq!(served.status(), StatusCode::OK);
assert_eq!(
String::from_utf8(body_of(served).await).unwrap(),
"const endpoint = 'https://media.example.test';"
);
let overflowed = serve_text_file(
&store,
"sw.js",
&HeaderMap::new(),
text_options(64),
&[("{{MEDIA_ENDPOINT}}", &"x".repeat(64))],
std::convert::identity,
)
.await;
assert_eq!(overflowed.status(), StatusCode::INTERNAL_SERVER_ERROR);
assert_eq!(
store.buffer_budget.available_permits(),
LOCAL_FILE_BUFFER_BUDGET_BYTES,
"a refused replacement still holds its buffer budget"
);
std::fs::remove_dir_all(&root).unwrap();
}
#[tokio::test]
async fn an_exhausted_buffer_budget_asks_the_client_to_retry() {
let root = temp_root("busy");
std::fs::write(root.join("robots.txt"), "User-agent: *\n").unwrap();
let store = LocalFileStore::load(&root, &AppProxyBudgets::new())
.await
.unwrap();
let held = store
.buffer_budget
.clone()
.try_acquire_many_owned(u32::try_from(LOCAL_FILE_BUFFER_BUDGET_BYTES).unwrap())
.unwrap();
let response =
serve_binary_file(&store, "robots.txt", &HeaderMap::new(), text_options(1024)).await;
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
assert_eq!(
response.headers().get(header::RETRY_AFTER).unwrap(),
"1",
"a client refused for capacity was not told when to come back"
);
assert_eq!(
response.headers().get(header::CACHE_CONTROL).unwrap(),
"no-store"
);
drop(held);
std::fs::remove_dir_all(&root).unwrap();
}
}
+4 -51
View File
@@ -4,12 +4,8 @@ use anyhow::Context;
use fluxer_app_proxy::{
config::AppProxyConfig,
csp::CompiledCspPolicy,
discovery_cache::DiscoveryCache,
geoip,
routes::{build_router, present_local_asset_prefixes},
state::{
AppProxyBudgets, AppState, MAX_SPA_INDEX_BYTES, build_http_client, read_bounded_text_file,
},
routes::build_router,
state::{AppState, build_http_client},
};
use std::sync::Arc;
use tokio::{net::TcpListener, runtime::Builder};
@@ -29,8 +25,6 @@ fn main() -> anyhow::Result<()> {
.context("failed to compile the Fluxer app proxy content security policy")?,
);
let geoip = Arc::new(geoip::resolver_from_app_config(&config));
let runtime = Builder::new_multi_thread()
.enable_all()
.build()
@@ -39,49 +33,9 @@ fn main() -> anyhow::Result<()> {
runtime.block_on(async move {
let http_client =
build_http_client().context("failed to build Fluxer app proxy HTTP client")?;
let discovery_cache = Arc::new(DiscoveryCache::new());
if let Err(err) = discovery_cache
.refresh(&http_client, &config.discovery_upstream_url)
let state = AppState::load(config, csp, http_client)
.await
{
tracing::warn!(%err, url = %config.discovery_upstream_url, "initial discovery fetch failed; will retry in background");
}
let cancel = discovery_cache.start_background_refresh(
http_client.clone(),
config.discovery_upstream_url.clone(),
config.discovery_refresh_interval_ms,
);
let index_html = if config.index_upstream_url.is_none() {
let index_path = std::path::Path::new(&config.static_dir).join("index.html");
match read_bounded_text_file(&index_path, MAX_SPA_INDEX_BYTES).await {
Ok(contents) => Some(Arc::<str>::from(contents)),
Err(err) => {
tracing::warn!(path = ?index_path, %err, "failed to preload index.html; will read per request");
None
}
}
} else {
None
};
let local_asset_prefixes = config
.index_upstream_url
.is_none()
.then(|| present_local_asset_prefixes(&config.static_dir));
let state = AppState {
config,
csp,
http_client,
discovery_cache,
geoip,
index_html,
local_asset_prefixes,
budgets: AppProxyBudgets::default(),
};
.context("failed to load the Fluxer app proxy static directory")?;
let router = build_router(state);
let listener = TcpListener::bind(&addr)
@@ -94,7 +48,6 @@ fn main() -> anyhow::Result<()> {
.await
.context("app proxy server exited unexpectedly")?;
cancel.abort();
Ok(())
})
}
+141 -409
View File
@@ -1,57 +1,22 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::state::{AppProxyBudgets, AppState};
use crate::local_files::{LocalFileResponseOptions, LocalFileStore, serve_binary_file};
use crate::state::AppState;
use crate::static_asset_policy::{
apply_asset_request_headers, apply_asset_response_policy, asset_cache_control,
copy_asset_response_headers, guess_mime, is_font_mime,
};
use axum::{
body::Body,
extract::{Path, State},
http::{HeaderMap, HeaderValue, StatusCode, header},
http::{HeaderMap, HeaderValue, StatusCode},
response::{IntoResponse, Response},
};
use std::path::{Path as FsPath, PathBuf};
use std::time::Duration;
use tokio::sync::{OwnedSemaphorePermit, TryAcquireError};
use super::file_stream::stream_file;
use super::spa_static::{CORS_ALLOW_ANY_VALUE, asset_cache_control, guess_mime, is_font_mime};
const ASSET_REQUEST_TIMEOUT: Duration = Duration::from_secs(15);
const PRECOMPRESSED_VARIANTS: &[(&str, &str)] = &[("br", "br"), ("gzip", "gz")];
const MAX_ASSET_SIZE_BYTES: u64 = 100 * 1024 * 1024;
const UPSTREAM_FAILURE_CACHE_CONTROL: &str = "no-store";
const UPSTREAM_FAILURE_STRIPPED_HEADERS: &[&str] = &[
"cdn-cache-control",
"cloudflare-cdn-cache-control",
"surrogate-control",
"expires",
"age",
];
const BLOCKED_REQUEST_HEADERS: &[&str] = &[
"authorization",
"connection",
"cookie",
"host",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailer",
"trailers",
"transfer-encoding",
"upgrade",
];
const BLOCKED_RESPONSE_HEADERS: &[&str] = &[
"connection",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailer",
"trailers",
"transfer-encoding",
"upgrade",
];
pub async fn proxy_assets(
State(state): State<AppState>,
@@ -60,8 +25,7 @@ pub async fn proxy_assets(
) -> Response {
let Some(cdn_endpoint) = &state.config.static_cdn_endpoint else {
return serve_local_asset(
&state.budgets,
&state.config.static_dir,
&state.local_files,
&format!("assets/{path}"),
request.headers(),
state.csp.asset_header(),
@@ -69,16 +33,14 @@ pub async fn proxy_assets(
.await;
};
let target_url = format!("{}/assets/{path}", cdn_endpoint.as_str());
if path
.split('/')
.any(|segment| matches!(segment, "" | "." | ".."))
{
return StatusCode::NOT_FOUND.into_response();
}
let upstream_host = cdn_endpoint
.as_url()
.host_str()
.map(|host| match cdn_endpoint.as_url().port() {
Some(port) => format!("{host}:{port}"),
None => host.to_owned(),
})
.unwrap_or_else(|| "localhost".to_owned());
let target_url = format!("{}/assets/{path}", cdn_endpoint.as_str());
let upstream_slot = match state
.budgets
@@ -93,19 +55,11 @@ pub async fn proxy_assets(
}
};
let mut request_builder = state
let request_builder = state
.http_client
.get(&target_url)
.timeout(ASSET_REQUEST_TIMEOUT);
for (name, value) in request.headers() {
let name_str = name.as_str();
if BLOCKED_REQUEST_HEADERS.contains(&name_str) {
continue;
}
request_builder = request_builder.header(name.clone(), value.clone());
}
request_builder = request_builder.header("host", upstream_host.as_str());
let request_builder = apply_asset_request_headers(request_builder, request.headers());
let upstream_response = match request_builder.send().await {
Ok(resp) => resp,
@@ -129,21 +83,13 @@ pub async fn proxy_assets(
let status = StatusCode::from_u16(upstream_response.status().as_u16())
.unwrap_or(StatusCode::BAD_GATEWAY);
let mut response_headers = axum::http::HeaderMap::new();
for (name, value) in upstream_response.headers() {
let name_str = name.as_str();
if BLOCKED_RESPONSE_HEADERS.contains(&name_str) {
continue;
}
response_headers.insert(name.clone(), value.clone());
}
set_known_asset_content_type(&mut response_headers, &path);
set_font_cors(&mut response_headers);
set_proxied_cache_control(&mut response_headers, &path, status);
set_vary_on_accept_encoding(&mut response_headers);
response_headers.insert(header::CONTENT_SECURITY_POLICY, state.csp.asset_header());
response_headers.remove("content-security-policy-report-only");
copy_asset_response_headers(upstream_response.headers(), &mut response_headers);
apply_asset_response_policy(
&mut response_headers,
&path,
status,
state.csp.asset_header(),
);
let body = upstream_asset_body(upstream_response, upstream_slot);
let mut response = Response::new(body);
@@ -201,259 +147,46 @@ fn upstream_asset_body(
}
pub(super) async fn serve_local_asset(
budgets: &AppProxyBudgets,
static_dir: &str,
files: &LocalFileStore,
relative_path: &str,
request_headers: &HeaderMap,
csp_asset_header: HeaderValue,
) -> Response {
let Ok(_read_slot) = budgets.local_read_slots.try_acquire() else {
return super::capacity_refused_response();
};
let file_path = FsPath::new(static_dir).join(relative_path);
let resolved = match tokio::fs::canonicalize(&file_path).await {
Ok(path) => path,
Err(_) => return StatusCode::NOT_FOUND.into_response(),
};
let base = match tokio::fs::canonicalize(static_dir).await {
Ok(path) => path,
Err(_) => return StatusCode::NOT_FOUND.into_response(),
};
if !resolved.starts_with(&base) {
tracing::warn!(path = relative_path, "directory traversal attempt blocked");
return StatusCode::NOT_FOUND.into_response();
}
let (served_path, content_encoding) =
select_precompressed_variant(&resolved, &base, request_headers).await;
let entity_tag = tokio::fs::metadata(&served_path)
.await
.ok()
.and_then(|metadata| local_asset_entity_tag(&metadata));
if let Some(entity_tag) = entity_tag.as_deref()
&& if_none_match_matches(request_headers, entity_tag)
{
let mut response = StatusCode::NOT_MODIFIED.into_response();
set_local_asset_headers(
response.headers_mut(),
relative_path,
Some(entity_tag),
&csp_asset_header,
);
return response;
}
let mut response = match stream_file(&served_path, request_headers, entity_tag.as_deref()).await
{
Ok(response) => response,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
return StatusCode::NOT_FOUND.into_response();
}
Err(err) => {
tracing::error!(path = relative_path, %err, "failed to read local asset");
return (StatusCode::INTERNAL_SERVER_ERROR, "Internal Server Error").into_response();
}
};
let mime_type = guess_mime(relative_path);
if let Ok(value) = HeaderValue::from_str(mime_type) {
response.headers_mut().insert(header::CONTENT_TYPE, value);
}
if let Some(content_encoding) = content_encoding {
response.headers_mut().insert(
header::CONTENT_ENCODING,
HeaderValue::from_static(content_encoding),
);
}
set_local_asset_headers(
let mut response = serve_binary_file(
files,
relative_path,
request_headers,
LocalFileResponseOptions {
content_type: mime_type,
cache_control: asset_cache_control(relative_path),
allow_cross_origin: is_font_mime(mime_type),
max_bytes: MAX_ASSET_SIZE_BYTES as usize,
},
)
.await;
let status = response.status();
apply_asset_response_policy(
response.headers_mut(),
relative_path,
entity_tag.as_deref(),
&csp_asset_header,
status,
csp_asset_header,
);
response
}
async fn select_precompressed_variant(
resolved: &FsPath,
base: &FsPath,
request_headers: &HeaderMap,
) -> (PathBuf, Option<&'static str>) {
for &(encoding, extension) in PRECOMPRESSED_VARIANTS {
if !accepts_encoding(request_headers, encoding) {
continue;
}
let Some(candidate) = usable_sibling(resolved, base, extension).await else {
continue;
};
return (candidate, Some(encoding));
}
(resolved.to_path_buf(), None)
}
async fn usable_sibling(resolved: &FsPath, base: &FsPath, extension: &str) -> Option<PathBuf> {
let mut name = resolved.as_os_str().to_owned();
name.push(".");
name.push(extension);
let candidate = tokio::fs::canonicalize(PathBuf::from(name)).await.ok()?;
if !candidate.starts_with(base) {
return None;
}
tokio::fs::metadata(&candidate)
.await
.ok()
.filter(std::fs::Metadata::is_file)
.map(|_| candidate)
}
fn accepts_encoding(headers: &HeaderMap, encoding: &str) -> bool {
let Some(header_value) = headers
.get(header::ACCEPT_ENCODING)
.and_then(|value| value.to_str().ok())
else {
return false;
};
header_value.split(',').any(|candidate| {
let mut parts = candidate.split(';').map(str::trim);
let Some(name) = parts.next() else {
return false;
};
name.eq_ignore_ascii_case(encoding) && !parts.any(is_zero_quality)
})
}
fn is_zero_quality(parameter: &str) -> bool {
let Some((key, value)) = parameter.split_once('=') else {
return false;
};
key.trim().eq_ignore_ascii_case("q")
&& value
.trim()
.parse::<f32>()
.is_ok_and(|quality| quality <= 0.0)
}
fn set_local_asset_headers(
headers: &mut HeaderMap,
relative_path: &str,
entity_tag: Option<&str>,
csp_asset_header: &HeaderValue,
) {
headers.insert(
header::CACHE_CONTROL,
HeaderValue::from_static(asset_cache_control(relative_path)),
);
headers.insert(header::CONTENT_SECURITY_POLICY, csp_asset_header.clone());
set_vary_on_accept_encoding(headers);
if is_font_mime(guess_mime(relative_path)) {
headers.insert(
header::ACCESS_CONTROL_ALLOW_ORIGIN,
HeaderValue::from_static(CORS_ALLOW_ANY_VALUE),
);
}
if let Some(entity_tag) = entity_tag
&& let Ok(value) = HeaderValue::from_str(entity_tag)
{
headers.insert(header::ETAG, value);
}
}
fn local_asset_entity_tag(metadata: &std::fs::Metadata) -> Option<String> {
let modified = metadata.modified().ok()?;
let nanos = modified
.duration_since(std::time::UNIX_EPOCH)
.ok()?
.as_nanos();
Some(format!("\"{nanos:x}-{:x}\"", metadata.len()))
}
fn if_none_match_matches(headers: &HeaderMap, entity_tag: &str) -> bool {
let Some(header_value) = headers
.get(header::IF_NONE_MATCH)
.and_then(|value| value.to_str().ok())
else {
return false;
};
header_value.split(',').any(|candidate| {
let candidate = candidate.trim();
candidate == "*" || candidate.trim_start_matches("W/") == entity_tag
})
}
fn set_proxied_cache_control(headers: &mut HeaderMap, path: &str, status: StatusCode) {
if status.is_success() || status == StatusCode::NOT_MODIFIED {
headers.insert(
header::CACHE_CONTROL,
HeaderValue::from_static(asset_cache_control(path)),
);
return;
}
headers.insert(
header::CACHE_CONTROL,
HeaderValue::from_static(UPSTREAM_FAILURE_CACHE_CONTROL),
);
for name in UPSTREAM_FAILURE_STRIPPED_HEADERS {
headers.remove(*name);
}
}
fn set_vary_on_accept_encoding(headers: &mut HeaderMap) {
let already_varies = headers.get_all(header::VARY).iter().any(|value| {
value.to_str().is_ok_and(|value| {
value.split(',').any(|field| {
let field = field.trim();
field == "*" || field.eq_ignore_ascii_case("accept-encoding")
})
})
});
if !already_varies {
headers.append(header::VARY, HeaderValue::from_static("accept-encoding"));
}
}
fn set_font_cors(headers: &mut HeaderMap) {
let is_font = headers
.get(header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.map(|value| value.split(';').next().unwrap_or(value).trim())
.is_some_and(is_font_mime);
if is_font {
headers.insert(
header::ACCESS_CONTROL_ALLOW_ORIGIN,
HeaderValue::from_static(CORS_ALLOW_ANY_VALUE),
);
}
}
fn set_known_asset_content_type(headers: &mut HeaderMap, path: &str) {
let mime_type = guess_mime(path);
if mime_type == "application/octet-stream" {
return;
}
if let Ok(value) = HeaderValue::from_str(mime_type) {
headers.insert(header::CONTENT_TYPE, value);
}
}
#[cfg(test)]
mod tests {
use super::super::spa_static::{
LONG_LIVED_ASSET_CACHE_CONTROL, REVALIDATED_ASSET_CACHE_CONTROL, is_hashed_asset,
};
use super::*;
use crate::config::AppProxyConfig;
use crate::discovery_cache::DiscoveryCache;
use crate::state::build_http_client;
use crate::state::{AppProxyBudgets, SpaIndexSource, build_http_client};
use crate::static_asset_policy::{
CORS_ALLOW_ANY_VALUE, LONG_LIVED_ASSET_CACHE_CONTROL, REVALIDATED_ASSET_CACHE_CONTROL,
UPSTREAM_FAILURE_CACHE_CONTROL, is_hashed_asset,
};
use axum::Router;
use axum::http::Request as HttpRequest;
use axum::http::header::HeaderName;
use fluxer_common::config::GeoipSourceConfig;
use fluxer_common::geoip::{GeoipConfig, GeoipResolver};
use axum::http::header::{self, HeaderName};
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use tower::ServiceExt;
@@ -478,6 +211,7 @@ mod tests {
fn upstream_backed_state(cdn_endpoint: &str) -> AppState {
let mut config = AppProxyConfig::from_env();
config.static_dir = ".".to_owned();
config.static_cdn_endpoint = Some(
crate::config::HttpEndpoint::parse("TEST_STATIC_CDN_ENDPOINT", cdn_endpoint).unwrap(),
);
@@ -496,22 +230,18 @@ mod tests {
crate::csp::CompiledCspPolicy::from_config(&config)
.expect("the test configuration must compile to a valid CSP"),
);
let budgets = AppProxyBudgets::default();
let local_files =
LocalFileStore::load_blocking(std::path::Path::new(&config.static_dir), &budgets)
.expect("the test static directory must exist");
AppState {
config: Arc::new(config),
csp,
http_client: build_http_client().unwrap(),
discovery_cache: Arc::new(DiscoveryCache::new()),
geoip: Arc::new(GeoipResolver::from_config(&GeoipConfig {
geoip_source: GeoipSourceConfig::Filesystem {
maxmind_db_path: None,
},
geoip_s3_config: None,
trust_client_ip_header: false,
client_ip_header_name: "x-forwarded-for".to_owned(),
})),
index_html: None,
spa_index_source: SpaIndexSource::bundled(""),
local_asset_prefixes: None,
budgets: crate::state::AppProxyBudgets::default(),
budgets,
local_files,
}
}
@@ -661,8 +391,17 @@ mod tests {
);
}
fn apply_test_asset_policy(headers: &mut HeaderMap, path: &str, status: StatusCode) {
apply_asset_response_policy(
headers,
path,
status,
HeaderValue::from_static("default-src 'none'"),
);
}
#[test]
fn a_failure_drops_the_cdn_lifetimes_a_success_keeps() {
fn upstream_cdn_lifetimes_never_reach_the_client() {
let long_lived = || {
let mut headers = HeaderMap::new();
headers.insert(
@@ -680,16 +419,16 @@ mod tests {
headers
};
let mut ok = long_lived();
set_proxied_cache_control(&mut ok, "2d715e4730758083.worker.js", StatusCode::OK);
assert!(
ok.contains_key("cdn-cache-control"),
"positive control: a real asset still reaches the cdn with its own long lifetime"
);
assert!(ok.contains_key(header::EXPIRES));
let upstream = long_lived();
let mut ok = HeaderMap::new();
copy_asset_response_headers(&upstream, &mut ok);
apply_test_asset_policy(&mut ok, "2d715e4730758083.worker.js", StatusCode::OK);
assert!(!ok.contains_key("cdn-cache-control"));
assert!(!ok.contains_key(header::EXPIRES));
let mut failed = long_lived();
set_proxied_cache_control(
let mut failed = HeaderMap::new();
copy_asset_response_headers(&upstream, &mut failed);
apply_test_asset_policy(
&mut failed,
"2d715e4730758083.worker.js",
StatusCode::NOT_FOUND,
@@ -731,6 +470,14 @@ mod tests {
fn dir(&self) -> &str {
self.root.to_str().unwrap()
}
fn files(&self) -> LocalFileStore {
self.files_with(&budgets())
}
fn files_with(&self, budgets: &AppProxyBudgets) -> LocalFileStore {
LocalFileStore::load_blocking(&self.root, budgets).unwrap()
}
}
impl Drop for LocalAssetDir {
@@ -771,8 +518,7 @@ mod tests {
let fixture = LocalAssetDir::with_asset("0018072843a46dc4.woff2", b"wOF2stub");
let first = serve_local_asset(
&budgets(),
fixture.dir(),
&fixture.files(),
"assets/0018072843a46dc4.woff2",
&HeaderMap::new(),
test_asset_csp(),
@@ -787,8 +533,7 @@ mod tests {
HeaderValue::from_str(&entity_tag).unwrap(),
);
let second = serve_local_asset(
&budgets(),
fixture.dir(),
&fixture.files(),
"assets/0018072843a46dc4.woff2",
&conditional,
test_asset_csp(),
@@ -808,8 +553,7 @@ mod tests {
let fixture = LocalAssetDir::with_asset("356aaade04a117b1.js", b"console.log(1)");
let response = serve_local_asset(
&budgets(),
fixture.dir(),
&fixture.files(),
"assets/356aaade04a117b1.js",
&HeaderMap::new(),
test_asset_csp(),
@@ -834,8 +578,7 @@ mod tests {
let mut resumed = HeaderMap::new();
resumed.insert(header::RANGE, HeaderValue::from_static("bytes=10-"));
let response = serve_local_asset(
&budgets(),
fixture.dir(),
&fixture.files(),
"assets/fluxer-setup.exe",
&resumed,
test_asset_csp(),
@@ -868,8 +611,7 @@ mod tests {
let fixture = LocalAssetDir::with_asset("f00dcafe12345678.css", b"body{}");
let first = serve_local_asset(
&budgets(),
fixture.dir(),
&fixture.files(),
"assets/f00dcafe12345678.css",
&HeaderMap::new(),
test_asset_csp(),
@@ -883,8 +625,7 @@ mod tests {
HeaderValue::from_str(&entity_tag).unwrap(),
);
let second = serve_local_asset(
&budgets(),
fixture.dir(),
&fixture.files(),
"assets/f00dcafe12345678.css",
&conditional,
test_asset_csp(),
@@ -909,8 +650,7 @@ mod tests {
HeaderValue::from_static("\"stale-from-a-previous-build\""),
);
let response = serve_local_asset(
&budgets(),
fixture.dir(),
&fixture.files(),
"assets/voice_engine_bg.wasm",
&conditional,
test_asset_csp(),
@@ -933,8 +673,7 @@ mod tests {
let fixture = LocalAssetDir::with_asset("2d715e4730758083.worker.js", b"self.onmessage=0");
let response = serve_local_asset(
&budgets(),
fixture.dir(),
&fixture.files(),
"assets/2d715e4730758083.worker.js",
&HeaderMap::new(),
test_asset_csp(),
@@ -987,8 +726,7 @@ mod tests {
.and_sibling("356aaade04a117b1.js.br", b"brotli-bytes");
let response = serve_local_asset(
&budgets(),
fixture.dir(),
&fixture.files(),
"assets/356aaade04a117b1.js",
&accept_encoding("gzip, deflate, br, zstd"),
test_asset_csp(),
@@ -1017,8 +755,7 @@ mod tests {
let fixture = LocalAssetDir::with_asset("469e0b8f10c496a1.css", b"body{color:red}");
let response = serve_local_asset(
&budgets(),
fixture.dir(),
&fixture.files(),
"assets/469e0b8f10c496a1.css",
&accept_encoding("gzip, deflate, br"),
test_asset_csp(),
@@ -1038,8 +775,7 @@ mod tests {
.and_sibling("488b87159423ca35.js.gz", b"gzip-bytes");
let gzip_only = serve_local_asset(
&budgets(),
fixture.dir(),
&fixture.files(),
"assets/488b87159423ca35.js",
&accept_encoding("gzip, deflate"),
test_asset_csp(),
@@ -1049,8 +785,7 @@ mod tests {
assert_eq!(body_bytes(gzip_only).await, b"gzip-bytes");
let identity = serve_local_asset(
&budgets(),
fixture.dir(),
&fixture.files(),
"assets/488b87159423ca35.js",
&HeaderMap::new(),
test_asset_csp(),
@@ -1070,8 +805,7 @@ mod tests {
.and_sibling("2d715e4730758083.worker.js.br", b"brotli-bytes");
let response = serve_local_asset(
&budgets(),
fixture.dir(),
&fixture.files(),
"assets/2d715e4730758083.worker.js",
&accept_encoding("br;q=0, gzip"),
test_asset_csp(),
@@ -1088,8 +822,7 @@ mod tests {
.and_sibling("f00dcafe12345678.css.br", b"brotli-bytes-are-longer");
let brotli = serve_local_asset(
&budgets(),
fixture.dir(),
&fixture.files(),
"assets/f00dcafe12345678.css",
&accept_encoding("br"),
test_asset_csp(),
@@ -1098,8 +831,7 @@ mod tests {
let brotli_tag = entity_tag_of(&brotli).expect("the brotli variant has a validator");
let identity = serve_local_asset(
&budgets(),
fixture.dir(),
&fixture.files(),
"assets/f00dcafe12345678.css",
&HeaderMap::new(),
test_asset_csp(),
@@ -1118,8 +850,7 @@ mod tests {
HeaderValue::from_str(&brotli_tag).unwrap(),
);
let revalidated = serve_local_asset(
&budgets(),
fixture.dir(),
&fixture.files(),
"assets/f00dcafe12345678.css",
&conditional,
test_asset_csp(),
@@ -1137,8 +868,7 @@ mod tests {
let mut ranged = accept_encoding("br");
ranged.insert(header::RANGE, HeaderValue::from_static("bytes=4-6"));
let response = serve_local_asset(
&budgets(),
fixture.dir(),
&fixture.files(),
"assets/356aaade04a117b1.js",
&ranged,
test_asset_csp(),
@@ -1165,36 +895,42 @@ mod tests {
assert_eq!(body_bytes(response).await, b"456");
}
async fn spawn_encoded_upstream(content_encoding: &'static str, body: &'static str) -> String {
async fn spawn_encoded_upstream(
content_encoding: &'static str,
body: &'static str,
) -> (String, Arc<std::sync::Mutex<Option<String>>>) {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let router = Router::new().fallback(move |request: HttpRequest<Body>| async move {
let echoed = request
.headers()
.get(header::ACCEPT_ENCODING)
.and_then(|value| value.to_str().ok())
.unwrap_or("<absent>")
.to_owned();
let mut response = Response::new(Body::from(body));
response.headers_mut().insert(
header::CONTENT_ENCODING,
HeaderValue::from_static(content_encoding),
);
response.headers_mut().insert(
HeaderName::from_static("x-echoed-accept-encoding"),
HeaderValue::from_str(&echoed).unwrap(),
);
response
let observed_accept_encoding: Arc<std::sync::Mutex<Option<String>>> =
Arc::new(std::sync::Mutex::new(None));
let recorder = Arc::clone(&observed_accept_encoding);
let router = Router::new().fallback(move |request: HttpRequest<Body>| {
let recorder = Arc::clone(&recorder);
async move {
let received = request
.headers()
.get(header::ACCEPT_ENCODING)
.and_then(|value| value.to_str().ok())
.map(str::to_owned);
*recorder.lock().unwrap() = received;
let mut response = Response::new(Body::from(body));
response.headers_mut().insert(
header::CONTENT_ENCODING,
HeaderValue::from_static(content_encoding),
);
response
}
});
tokio::spawn(async move {
axum::serve(listener, router).await.unwrap();
});
format!("http://{addr}")
(format!("http://{addr}"), observed_accept_encoding)
}
#[tokio::test]
async fn a_cdn_backed_asset_streams_the_upstream_encoding_untouched() {
let endpoint = spawn_encoded_upstream("br", "already-brotli").await;
let (endpoint, observed_accept_encoding) =
spawn_encoded_upstream("br", "already-brotli").await;
let state = upstream_backed_state(&endpoint);
let request = HttpRequest::builder()
.uri("/assets/356aaade04a117b1.js")
@@ -1210,10 +946,7 @@ mod tests {
.await;
assert_eq!(
response
.headers()
.get("x-echoed-accept-encoding")
.and_then(|value| value.to_str().ok()),
observed_accept_encoding.lock().unwrap().as_deref(),
Some("gzip, deflate, br"),
"blocking accept-encoding forces the origin to hand us bytes it already had compressed"
);
@@ -1228,7 +961,8 @@ mod tests {
#[tokio::test]
async fn a_cdn_backed_asset_keeps_the_upstream_content_length() {
let endpoint = spawn_encoded_upstream("gzip", "0123456789").await;
let (endpoint, _observed_accept_encoding) =
spawn_encoded_upstream("gzip", "0123456789").await;
let state = upstream_backed_state(&endpoint);
let request = HttpRequest::builder()
.uri("/assets/voice_engine_bg.wasm")
@@ -1283,7 +1017,7 @@ mod tests {
#[tokio::test]
async fn a_passed_through_cdn_encoding_is_never_recompressed_by_the_layer() {
let endpoint = spawn_encoded_upstream(
let (endpoint, _observed_accept_encoding) = spawn_encoded_upstream(
"br",
"already brotli, and long enough to clear the thirty-two byte floor",
)
@@ -1342,7 +1076,7 @@ mod tests {
HeaderValue::from_static("application/octet-stream"),
);
set_known_asset_content_type(&mut headers, "356aaade04a117b1.js");
apply_test_asset_policy(&mut headers, "356aaade04a117b1.js", StatusCode::OK);
assert_eq!(
headers
@@ -1360,7 +1094,7 @@ mod tests {
HeaderValue::from_static("application/octet-stream"),
);
set_known_asset_content_type(&mut headers, "voice_engine_bg.wasm");
apply_test_asset_policy(&mut headers, "voice_engine_bg.wasm", StatusCode::OK);
assert_eq!(
headers
@@ -1378,8 +1112,7 @@ mod tests {
HeaderValue::from_static("application/octet-stream"),
);
set_known_asset_content_type(&mut headers, "0018072843a46dc4.woff2");
set_font_cors(&mut headers);
apply_test_asset_policy(&mut headers, "0018072843a46dc4.woff2", StatusCode::OK);
assert_eq!(
headers
@@ -1398,7 +1131,7 @@ mod tests {
HeaderValue::from_static("https://example.invalid"),
);
set_font_cors(&mut headers);
apply_test_asset_policy(&mut headers, "0018072843a46dc4.woff2", StatusCode::OK);
assert_eq!(
headers
@@ -1416,7 +1149,7 @@ mod tests {
HeaderValue::from_static("font/woff2; charset=binary"),
);
set_font_cors(&mut headers);
apply_test_asset_policy(&mut headers, "font-download", StatusCode::OK);
assert_eq!(
headers
@@ -1434,7 +1167,7 @@ mod tests {
HeaderValue::from_static("application/javascript; charset=utf-8"),
);
set_font_cors(&mut headers);
apply_test_asset_policy(&mut headers, "356aaade04a117b1.js", StatusCode::OK);
assert!(
headers.get(header::ACCESS_CONTROL_ALLOW_ORIGIN).is_none(),
@@ -1450,7 +1183,7 @@ mod tests {
HeaderValue::from_static("application/octet-stream"),
);
set_known_asset_content_type(&mut headers, "artifact.unknown-extension");
apply_test_asset_policy(&mut headers, "artifact.unknown-extension", StatusCode::OK);
assert_eq!(
headers
@@ -1464,6 +1197,7 @@ mod tests {
async fn a_local_asset_is_refused_once_the_read_slots_are_gone() {
let fixture = LocalAssetDir::with_asset("356aaade04a117b1.js", b"console.log(1)");
let budgets = AppProxyBudgets::default();
let files = fixture.files_with(&budgets);
let held = budgets
.local_read_slots
.clone()
@@ -1473,8 +1207,7 @@ mod tests {
.expect("a fresh budget holds every local read slot");
let refused = serve_local_asset(
&budgets,
fixture.dir(),
&files,
"assets/356aaade04a117b1.js",
&HeaderMap::new(),
test_asset_csp(),
@@ -1492,8 +1225,7 @@ mod tests {
drop(held);
let served = serve_local_asset(
&budgets,
fixture.dir(),
&files,
"assets/356aaade04a117b1.js",
&HeaderMap::new(),
test_asset_csp(),
@@ -1506,13 +1238,13 @@ mod tests {
async fn an_open_local_asset_response_never_holds_a_read_slot() {
let fixture = LocalAssetDir::with_asset("356aaade04a117b1.js", b"console.log(1)");
let budgets = AppProxyBudgets::default();
let files = fixture.files_with(&budgets);
let mut open = Vec::with_capacity(crate::state::LOCAL_FILE_READS_IN_FLIGHT_MAX + 1);
for _ in 0..=crate::state::LOCAL_FILE_READS_IN_FLIGHT_MAX {
open.push(
serve_local_asset(
&budgets,
fixture.dir(),
&files,
"assets/356aaade04a117b1.js",
&HeaderMap::new(),
test_asset_csp(),
@@ -1,98 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::geoip::build_geoip_response;
use crate::state::AppState;
use axum::{
Json,
extract::State,
http::{HeaderMap, HeaderValue, header},
response::{IntoResponse, Response},
};
pub const CLIENT_GEOIP_PATH: &str = "/_geoip";
const CLIENT_GEOIP_CACHE_CONTROL: &str = "no-store, private";
pub async fn client_geoip(State(state): State<AppState>, headers: HeaderMap) -> Response {
let mut response = Json(build_geoip_response(state.geoip.lookup(&headers))).into_response();
response.headers_mut().insert(
header::CACHE_CONTROL,
HeaderValue::from_static(CLIENT_GEOIP_CACHE_CONTROL),
);
response
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::AppProxyConfig;
use crate::discovery_cache::DiscoveryCache;
use crate::routes::build_router;
use axum::body::Body;
use axum::http::{Request, StatusCode};
use fluxer_common::config::GeoipSourceConfig;
use fluxer_common::geoip::{GeoipConfig, GeoipLookup, GeoipResolver};
use std::sync::Arc;
use tower::ServiceExt;
fn geoip_state() -> AppState {
let config = AppProxyConfig::from_env();
let csp = Arc::new(
crate::csp::CompiledCspPolicy::from_config(&config)
.expect("the test configuration must compile to a valid CSP"),
);
AppState {
config: Arc::new(config),
csp,
http_client: reqwest::Client::new(),
discovery_cache: Arc::new(DiscoveryCache::new()),
geoip: Arc::new(GeoipResolver::from_config(&GeoipConfig {
geoip_source: GeoipSourceConfig::Filesystem {
maxmind_db_path: None,
},
geoip_s3_config: None,
trust_client_ip_header: true,
client_ip_header_name: "x-forwarded-for".to_owned(),
})),
index_html: Some(Arc::from("<html><head></head><body></body></html>")),
local_asset_prefixes: None,
budgets: crate::state::AppProxyBudgets::default(),
}
}
#[tokio::test]
async fn the_geoip_endpoint_answers_for_the_requesting_client_and_is_never_stored() {
let response = build_router(geoip_state())
.oneshot(
Request::get(CLIENT_GEOIP_PATH)
.header("x-forwarded-for", "203.0.113.10")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let headers = response.headers();
assert_eq!(
headers.get(header::CACHE_CONTROL).unwrap(),
CLIENT_GEOIP_CACHE_CONTROL
);
assert!(
headers
.get(header::CONTENT_TYPE)
.unwrap()
.to_str()
.unwrap()
.starts_with("application/json"),
"the SPA fallback answered the geoip endpoint with the app shell"
);
assert!(headers.get("x-fluxer-app-shell").is_none());
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(body, build_geoip_response(GeoipLookup::default()));
assert_eq!(body["countryCode"], serde_json::Value::Null);
}
}
+1 -1
View File
@@ -24,7 +24,7 @@ enum RequestedRange {
Unsatisfiable,
}
pub(super) async fn stream_file(
pub(crate) async fn stream_file(
path: &Path,
request_headers: &HeaderMap,
entity_tag: Option<&str>,
+17 -89
View File
@@ -1,95 +1,43 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::state::AppState;
use axum::{
extract::State,
http::StatusCode,
response::{IntoResponse, Response},
};
pub async fn health() -> &'static str {
"OK"
}
pub async fn ready(State(state): State<AppState>) -> Response {
readiness_report(state.discovery_cache.has_snapshot().await).into_response()
}
fn readiness_report(discovery_cached: bool) -> (StatusCode, &'static str) {
if discovery_cached {
(StatusCode::OK, "OK")
} else {
(StatusCode::SERVICE_UNAVAILABLE, "NOT READY: discovery")
}
pub async fn ready() -> &'static str {
"OK"
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::AppProxyConfig;
use crate::discovery_cache::DiscoveryCache;
use crate::state::build_http_client;
use axum::Router;
use crate::state::{AppState, SpaIndexSource, build_http_client};
use axum::body::Body;
use axum::http::{HeaderValue, Request as HttpRequest, header};
use fluxer_common::config::GeoipSourceConfig;
use fluxer_common::geoip::{GeoipConfig, GeoipResolver};
use axum::http::{Request as HttpRequest, StatusCode};
use std::sync::Arc;
use tower::ServiceExt;
const DISCOVERY_BODY: &str = r#"{"api_code_version":"proxy-test"}"#;
async fn spawn_discovery_origin() -> String {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let router = Router::new().fallback(|| async {
let mut response = Response::new(Body::from(DISCOVERY_BODY));
response.headers_mut().insert(
header::CONTENT_TYPE,
HeaderValue::from_static("application/json"),
);
response
});
tokio::spawn(async move {
axum::serve(listener, router).await.unwrap();
});
format!("http://{addr}/")
}
async fn probe_state() -> AppState {
let mut config = AppProxyConfig::from_env();
config.discovery_upstream_url = spawn_discovery_origin().await;
fn probe_state() -> AppState {
let config = AppProxyConfig::from_env();
let csp = Arc::new(
crate::csp::CompiledCspPolicy::from_config(&config)
.expect("the test configuration must compile to a valid CSP"),
);
let budgets = crate::state::AppProxyBudgets::default();
let local_files =
crate::local_files::LocalFileStore::load_blocking(std::path::Path::new("."), &budgets)
.expect("the test static directory must exist");
AppState {
config: Arc::new(config),
csp,
http_client: build_http_client().unwrap(),
discovery_cache: Arc::new(DiscoveryCache::new()),
geoip: Arc::new(GeoipResolver::from_config(&GeoipConfig {
geoip_source: GeoipSourceConfig::Filesystem {
maxmind_db_path: None,
},
geoip_s3_config: None,
trust_client_ip_header: false,
client_ip_header_name: "x-forwarded-for".to_owned(),
})),
index_html: None,
spa_index_source: SpaIndexSource::bundled(""),
local_asset_prefixes: None,
budgets: crate::state::AppProxyBudgets::default(),
budgets,
local_files,
}
}
async fn warm_discovery(state: &AppState) {
state
.discovery_cache
.refresh(&state.http_client, &state.config.discovery_upstream_url)
.await
.unwrap();
}
async fn probe(state: AppState, path: &str) -> (StatusCode, String) {
let response = crate::routes::build_router(state)
.oneshot(
@@ -108,38 +56,18 @@ mod tests {
}
#[tokio::test]
async fn liveness_stays_constant_while_the_proxy_cannot_serve() {
let state = probe_state().await;
async fn liveness_answers_without_any_upstream() {
assert_eq!(
probe(state, "/_health").await,
probe(probe_state(), "/_health").await,
(StatusCode::OK, "OK".to_owned())
);
}
#[tokio::test]
async fn readiness_fails_while_the_discovery_cache_is_empty() {
let state = probe_state().await;
let (status, body) = probe(state, "/_ready").await;
assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE);
assert!(body.contains("discovery"), "{body}");
}
#[tokio::test]
async fn readiness_passes_once_a_discovery_snapshot_is_cached() {
let state = probe_state().await;
warm_discovery(&state).await;
async fn readiness_needs_no_discovery_upstream() {
assert_eq!(
probe(state, "/_ready").await,
probe(probe_state(), "/_ready").await,
(StatusCode::OK, "OK".to_owned())
);
}
#[test]
fn readiness_tracks_only_the_discovery_snapshot() {
assert_eq!(readiness_report(true), (StatusCode::OK, "OK"));
assert_eq!(
readiness_report(false),
(StatusCode::SERVICE_UNAVAILABLE, "NOT READY: discovery")
);
}
}
+2 -7
View File
@@ -3,8 +3,7 @@
mod android_association;
mod apple_association;
mod assets_proxy;
mod client_geoip;
mod file_stream;
pub(crate) mod file_stream;
mod health;
mod spa_index;
mod spa_static;
@@ -38,10 +37,6 @@ pub fn build_router(state: AppState) -> Router {
Router::new()
.route("/_health", get(health::health))
.route("/_ready", get(health::ready))
.route(
client_geoip::CLIENT_GEOIP_PATH,
get(client_geoip::client_geoip),
)
.route(
"/.well-known/apple-app-site-association",
get(apple_association::apple_app_site_association),
@@ -128,7 +123,7 @@ fn generate_request_id() -> String {
hex::encode(bytes)
}
pub(super) fn capacity_refused_response() -> Response {
pub(crate) fn capacity_refused_response() -> Response {
let mut response = axum::http::StatusCode::SERVICE_UNAVAILABLE.into_response();
let headers = response.headers_mut();
headers.insert(header::RETRY_AFTER, HeaderValue::from_static("1"));
File diff suppressed because it is too large. Load diff
+67 -275
View File
@@ -1,17 +1,20 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::config::HttpEndpoint;
use crate::discovery_cache::discovery_endpoint;
use crate::state::{AppState, MAX_STATIC_TEXT_FILE_BYTES, read_bounded_file};
use crate::local_files::{LocalFileResponseOptions, serve_text_file};
use crate::state::{AppState, MAX_STATIC_TEXT_FILE_BYTES};
use crate::static_asset_policy::apply_static_resource_security_policy;
use axum::{
extract::State,
http::{HeaderValue, StatusCode, header},
http::{HeaderMap, HeaderValue, StatusCode, header},
response::{IntoResponse, Response},
};
use std::path::Path;
pub async fn version_json(State(state): State<AppState>) -> Response {
let mut result = serve_static_text_file(&state, "version.json", "application/json").await;
const STATIC_TEXT_CACHE_CONTROL: &str = "no-cache";
pub async fn version_json(State(state): State<AppState>, headers: HeaderMap) -> Response {
let mut result =
serve_static_text_file(&state, &headers, "version.json", "application/json").await;
if result.status() == StatusCode::NOT_FOUND && !state.config.build_version.is_empty() {
let body = serde_json::json!({ "version": state.config.build_version });
@@ -24,13 +27,13 @@ pub async fn version_json(State(state): State<AppState>) -> Response {
result
}
pub async fn manifest_json(State(state): State<AppState>) -> Response {
let static_cdn_endpoint = runtime_static_cdn_endpoint(&state).await;
pub async fn manifest_json(State(state): State<AppState>, headers: HeaderMap) -> Response {
serve_static_text_file_with_substitutions(
&state,
&headers,
"manifest.json",
"application/manifest+json",
static_cdn_endpoint.as_ref(),
state.config.static_cdn_endpoint.as_ref(),
Some(&state.config.manifest_scope_extensions),
)
.await
@@ -54,59 +57,50 @@ fn with_scope_extensions(text: String, origins: &[String]) -> String {
serde_json::to_string_pretty(&manifest).unwrap_or(text)
}
fn substitute_placeholders(
text: &str,
static_cdn_endpoint: &str,
scope_extensions: Option<&[String]>,
) -> String {
let text = text.replace("{{STATIC_CDN_ENDPOINT}}", static_cdn_endpoint);
match scope_extensions {
Some(origins) => with_scope_extensions(text, origins),
None => text,
}
}
pub async fn browserconfig_xml(State(state): State<AppState>) -> Response {
let static_cdn_endpoint = runtime_static_cdn_endpoint(&state).await;
pub async fn browserconfig_xml(State(state): State<AppState>, headers: HeaderMap) -> Response {
serve_static_text_file_with_cdn(
&state,
&headers,
"browserconfig.xml",
"application/xml; charset=utf-8",
static_cdn_endpoint.as_ref(),
state.config.static_cdn_endpoint.as_ref(),
)
.await
}
pub async fn service_worker(State(state): State<AppState>) -> Response {
serve_static_text_file(&state, "sw.js", "application/javascript; charset=utf-8").await
pub async fn service_worker(State(state): State<AppState>, headers: HeaderMap) -> Response {
serve_static_text_file(
&state,
&headers,
"sw.js",
"application/javascript; charset=utf-8",
)
.await
}
pub async fn service_worker_map(State(state): State<AppState>) -> Response {
serve_static_text_file(&state, "sw.js.map", "application/json").await
pub async fn service_worker_map(State(state): State<AppState>, headers: HeaderMap) -> Response {
serve_static_text_file(&state, &headers, "sw.js.map", "application/json").await
}
async fn runtime_static_cdn_endpoint(state: &AppState) -> Option<HttpEndpoint> {
if let Some(discovery) = state.discovery_cache.get().await
&& let Some(endpoint) = discovery_endpoint(&discovery, "static_cdn")
{
return Some(endpoint);
}
state.config.static_cdn_endpoint.clone()
}
async fn serve_static_text_file(state: &AppState, filename: &str, content_type: &str) -> Response {
serve_static_text_file_with_cdn(state, filename, content_type, None).await
async fn serve_static_text_file(
state: &AppState,
headers: &HeaderMap,
filename: &str,
content_type: &'static str,
) -> Response {
serve_static_text_file_with_cdn(state, headers, filename, content_type, None).await
}
async fn serve_static_text_file_with_cdn(
state: &AppState,
headers: &HeaderMap,
filename: &str,
content_type: &str,
content_type: &'static str,
static_cdn_endpoint: Option<&HttpEndpoint>,
) -> Response {
serve_static_text_file_with_substitutions(
state,
headers,
filename,
content_type,
static_cdn_endpoint,
@@ -117,137 +111,50 @@ async fn serve_static_text_file_with_cdn(
async fn serve_static_text_file_with_substitutions(
state: &AppState,
headers: &HeaderMap,
filename: &str,
content_type: &str,
content_type: &'static str,
static_cdn_endpoint: Option<&HttpEndpoint>,
scope_extensions: Option<&[String]>,
) -> Response {
let static_dir = state.config.static_dir.as_str();
let file_path = Path::new(static_dir).join(filename);
let Ok(_read_slot) = state.budgets.local_read_slots.try_acquire() else {
return super::capacity_refused_response();
};
let resolved = match tokio::fs::canonicalize(&file_path).await {
Ok(p) => p,
Err(_) => return StatusCode::NOT_FOUND.into_response(),
};
let base = match tokio::fs::canonicalize(static_dir).await {
Ok(p) => p,
Err(_) => return StatusCode::NOT_FOUND.into_response(),
};
if !resolved.starts_with(&base) {
return StatusCode::NOT_FOUND.into_response();
}
let content = match read_bounded_file(&resolved, MAX_STATIC_TEXT_FILE_BYTES).await {
Ok(bytes) => bytes,
Err(error) if error.is_not_found() => return StatusCode::NOT_FOUND.into_response(),
Err(error) => {
tracing::error!(file = filename, %error, "refusing to serve static text file");
return StatusCode::NOT_FOUND.into_response();
}
};
let replacement = static_cdn_endpoint.map_or("", HttpEndpoint::as_str);
let body: axum::body::Body = match std::str::from_utf8(&content) {
Ok(text) => substitute_placeholders(text, replacement, scope_extensions)
.into_bytes()
.into(),
Err(_) => content.into(),
};
let mut response = body.into_response();
if let Ok(ct) = HeaderValue::from_str(content_type) {
response.headers_mut().insert(header::CONTENT_TYPE, ct);
}
response
.headers_mut()
.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
response
}
pub fn guess_mime(path: &str) -> &'static str {
let ext = match path.rfind('.') {
Some(i) => &path[i..],
None => return "application/octet-stream",
};
match ext.to_ascii_lowercase().as_str() {
".html" | ".htm" => "text/html; charset=utf-8",
".js" | ".mjs" => "application/javascript; charset=utf-8",
".css" => "text/css; charset=utf-8",
".json" => "application/json; charset=utf-8",
".png" => "image/png",
".jpg" | ".jpeg" => "image/jpeg",
".gif" => "image/gif",
".webp" => "image/webp",
".avif" => "image/avif",
".svg" => "image/svg+xml",
".ico" => "image/x-icon",
".woff" => "font/woff",
".woff2" => "font/woff2",
".ttf" => "font/ttf",
".otf" => "font/otf",
".eot" => "application/vnd.ms-fontobject",
".mp3" => "audio/mpeg",
".mp4" => "video/mp4",
".webm" => "video/webm",
".ogg" => "audio/ogg",
".wav" => "audio/wav",
".pdf" => "application/pdf",
".txt" => "text/plain; charset=utf-8",
".xml" => "application/xml; charset=utf-8",
".webmanifest" => "application/manifest+json",
".map" => "application/json",
".wasm" => "application/wasm",
_ => "application/octet-stream",
}
}
pub const CORS_ALLOW_ANY_VALUE: &str = "*";
pub fn is_font_mime(mime_type: &str) -> bool {
matches!(
mime_type,
"font/woff" | "font/woff2" | "font/ttf" | "font/otf" | "application/vnd.ms-fontobject"
let mut response = serve_text_file(
&state.local_files,
filename,
headers,
LocalFileResponseOptions {
content_type,
cache_control: STATIC_TEXT_CACHE_CONTROL,
allow_cross_origin: false,
max_bytes: MAX_STATIC_TEXT_FILE_BYTES,
},
&[("{{STATIC_CDN_ENDPOINT}}", replacement)],
|text| match scope_extensions {
Some(origins) => with_scope_extensions(text, origins),
None => text,
},
)
}
pub const LONG_LIVED_ASSET_CACHE_CONTROL: &str = "public, max-age=31536000, immutable";
pub const REVALIDATED_ASSET_CACHE_CONTROL: &str = "public, max-age=3600, must-revalidate";
pub fn is_hashed_asset(path: &str) -> bool {
let filename = path.rsplit('/').next().unwrap_or(path);
let Some(last_dot) = filename.rfind('.') else {
return false;
};
let stem = &filename[..last_dot];
if stem.split('.').next().is_some_and(is_content_hash) {
return true;
}
['.', '-'].iter().any(|sep| {
stem.rfind(*sep)
.is_some_and(|sep_pos| is_content_hash(&stem[sep_pos + 1..]))
})
}
pub fn asset_cache_control(path: &str) -> &'static str {
if is_hashed_asset(path) {
LONG_LIVED_ASSET_CACHE_CONTROL
} else {
REVALIDATED_ASSET_CACHE_CONTROL
}
}
fn is_content_hash(value: &str) -> bool {
value.len() >= 8 && value.chars().all(|c| c.is_ascii_hexdigit())
.await;
apply_static_resource_security_policy(response.headers_mut(), state.csp.asset_header());
response
}
#[cfg(test)]
mod tests {
use super::*;
fn substitute_placeholders(
text: &str,
static_cdn_endpoint: &str,
scope_extensions: Option<&[String]>,
) -> String {
let text = text.replace("{{STATIC_CDN_ENDPOINT}}", static_cdn_endpoint);
match scope_extensions {
Some(origins) => with_scope_extensions(text, origins),
None => text,
}
}
const BUILT_MANIFEST: &str = r#"{
"id": "/",
"start_url": "/app",
@@ -321,119 +228,4 @@ mod tests {
BUILT_MANIFEST.replace("{{STATIC_CDN_ENDPOINT}}", "")
);
}
#[test]
fn mime_html() {
assert_eq!(guess_mime("i.html"), "text/html; charset=utf-8");
}
#[test]
fn mime_js() {
assert_eq!(guess_mime("a.js"), "application/javascript; charset=utf-8");
}
#[test]
fn mime_css() {
assert_eq!(guess_mime("s.css"), "text/css; charset=utf-8");
}
#[test]
fn mime_json() {
assert_eq!(guess_mime("d.json"), "application/json; charset=utf-8");
}
#[test]
fn mime_wasm() {
assert_eq!(guess_mime("m.wasm"), "application/wasm");
}
#[test]
fn mime_svg() {
assert_eq!(guess_mime("i.svg"), "image/svg+xml");
}
#[test]
fn mime_png() {
assert_eq!(guess_mime("p.png"), "image/png");
}
#[test]
fn mime_jpg() {
assert_eq!(guess_mime("p.jpg"), "image/jpeg");
}
#[test]
fn mime_webp() {
assert_eq!(guess_mime("p.webp"), "image/webp");
}
#[test]
fn mime_avif() {
assert_eq!(guess_mime("p.avif"), "image/avif");
}
#[test]
fn mime_ico() {
assert_eq!(guess_mime("f.ico"), "image/x-icon");
}
#[test]
fn mime_woff2() {
assert_eq!(guess_mime("f.woff2"), "font/woff2");
}
#[test]
fn mime_mp4() {
assert_eq!(guess_mime("c.mp4"), "video/mp4");
}
#[test]
fn mime_unknown() {
assert_eq!(guess_mime("f.xyz"), "application/octet-stream");
}
#[test]
fn mime_no_ext() {
assert_eq!(guess_mime("LICENSE"), "application/octet-stream");
}
#[test]
fn mime_case_insensitive() {
assert_eq!(guess_mime("F.HTML"), "text/html; charset=utf-8");
assert_eq!(guess_mime("F.JS"), "application/javascript; charset=utf-8");
}
#[test]
fn hashed_asset_positive() {
assert!(is_hashed_asset("app.a1b2c3d4.js"));
assert!(is_hashed_asset("style-abcdef01.css"));
}
#[test]
fn hashed_asset_accepts_bare_contenthash_filenames() {
assert!(is_hashed_asset("assets/469e0b8f10c496a1.css"));
assert!(is_hashed_asset("assets/a79f1c3119cd700d.woff2"));
assert!(is_hashed_asset("/assets/488b87159423ca35.js"));
}
#[test]
fn hashed_asset_accepts_the_contenthash_worker_bundle_name() {
assert!(
is_hashed_asset("assets/2d715e4730758083.worker.js"),
"rspack emits workers as assets/[contenthash:16].worker.js"
);
}
#[test]
fn hashed_asset_negative() {
assert!(!is_hashed_asset("app.js"));
assert!(!is_hashed_asset("style.css"));
assert!(!is_hashed_asset("a79f1c3119cd700d/app.js"));
}
#[test]
fn the_bundled_font_licences_are_not_treated_as_content_hashed() {
assert!(!is_hashed_asset("assets/fonts-NOTICE.txt"));
assert!(!is_hashed_asset("assets/fonts-LICENSE-IBM-PLEX.txt"));
}
#[test]
fn only_a_content_hashed_asset_is_promised_to_never_change() {
assert_eq!(
asset_cache_control("assets/469e0b8f10c496a1.css"),
LONG_LIVED_ASSET_CACHE_CONTROL
);
assert_eq!(
asset_cache_control("assets/fonts-NOTICE.txt"),
REVALIDATED_ASSET_CACHE_CONTROL
);
}
}
+78 -12
View File
@@ -1,9 +1,9 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::config::AppProxyConfig;
use crate::config::{AppProxyConfig, HttpUrl};
use crate::csp::CompiledCspPolicy;
use crate::discovery_cache::DiscoveryCache;
use fluxer_common::geoip::GeoipResolver;
use crate::local_files::LocalFileStore;
use crate::routes::present_local_asset_prefixes;
use std::sync::Arc;
use std::time::Duration;
use tokio::io::AsyncReadExt;
@@ -36,16 +36,81 @@ impl Default for AppProxyBudgets {
}
}
pub const AUTH_ENTRY_SHELL_FILE: &str = "auth-index.html";
#[derive(Clone)]
pub enum SpaIndexSource {
Bundled {
shell: Arc<str>,
auth_entry_shell: Option<Arc<str>>,
},
Upstream(Arc<HttpUrl>),
}
impl SpaIndexSource {
pub fn bundled(shell: &str) -> Self {
Self::Bundled {
shell: Arc::from(shell),
auth_entry_shell: None,
}
}
pub async fn load(config: &AppProxyConfig) -> anyhow::Result<Self> {
if let Some(index_upstream_url) = &config.index_upstream_url {
return Ok(Self::Upstream(Arc::new(index_upstream_url.clone())));
}
let static_dir = std::path::Path::new(&config.static_dir);
let shell = read_shell(&static_dir.join("index.html")).await?;
let auth_entry_path = static_dir.join(AUTH_ENTRY_SHELL_FILE);
let auth_entry_shell = if tokio::fs::try_exists(&auth_entry_path).await? {
Some(read_shell(&auth_entry_path).await?)
} else {
None
};
Ok(Self::Bundled {
shell,
auth_entry_shell,
})
}
pub fn is_upstream(&self) -> bool {
matches!(self, Self::Upstream(_))
}
}
#[derive(Clone)]
pub struct AppState {
pub config: Arc<AppProxyConfig>,
pub csp: Arc<CompiledCspPolicy>,
pub http_client: reqwest::Client,
pub discovery_cache: Arc<DiscoveryCache>,
pub geoip: Arc<GeoipResolver>,
pub index_html: Option<Arc<str>>,
pub spa_index_source: SpaIndexSource,
pub local_asset_prefixes: Option<Arc<[&'static str]>>,
pub budgets: AppProxyBudgets,
pub(crate) local_files: LocalFileStore,
}
impl AppState {
pub async fn load(
config: Arc<AppProxyConfig>,
csp: Arc<CompiledCspPolicy>,
http_client: reqwest::Client,
) -> anyhow::Result<Self> {
let budgets = AppProxyBudgets::new();
let local_files =
LocalFileStore::load(std::path::Path::new(&config.static_dir), &budgets).await?;
let spa_index_source = SpaIndexSource::load(&config).await?;
let local_asset_prefixes = (!spa_index_source.is_upstream())
.then(|| present_local_asset_prefixes(&config.static_dir));
Ok(Self {
config,
csp,
http_client,
spa_index_source,
local_asset_prefixes,
budgets,
local_files,
})
}
}
#[derive(Debug)]
@@ -68,12 +133,6 @@ impl std::fmt::Display for BoundedFileReadError {
impl std::error::Error for BoundedFileReadError {}
impl BoundedFileReadError {
pub fn is_not_found(&self) -> bool {
matches!(self, Self::Io(source) if source.kind() == std::io::ErrorKind::NotFound)
}
}
pub async fn read_bounded_file(
path: &std::path::Path,
max_bytes: usize,
@@ -110,6 +169,13 @@ pub async fn read_bounded_file(
Ok(bytes)
}
async fn read_shell(path: &std::path::Path) -> anyhow::Result<Arc<str>> {
let html = read_bounded_text_file(path, MAX_SPA_INDEX_BYTES)
.await
.map_err(|error| anyhow::anyhow!("failed to read {}: {error}", path.display()))?;
Ok(Arc::from(html))
}
pub async fn read_bounded_text_file(
path: &std::path::Path,
max_bytes: usize,
+381
View File
@@ -0,0 +1,381 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode, header};
pub(crate) const CORS_ALLOW_ANY_VALUE: &str = "*";
pub(crate) const LONG_LIVED_ASSET_CACHE_CONTROL: &str = "public, max-age=31536000, immutable";
pub(crate) const REVALIDATED_ASSET_CACHE_CONTROL: &str = "public, max-age=3600, must-revalidate";
pub(crate) const UPSTREAM_FAILURE_CACHE_CONTROL: &str = "no-store";
const FORWARDED_ASSET_REQUEST_HEADERS: &[&str] = &[
"accept",
"accept-encoding",
"accept-language",
"cache-control",
"if-match",
"if-modified-since",
"if-none-match",
"if-range",
"if-unmodified-since",
"range",
];
const FORWARDED_ASSET_RESPONSE_HEADERS: &[&str] = &[
"accept-ranges",
"access-control-allow-origin",
"access-control-expose-headers",
"content-disposition",
"content-encoding",
"content-language",
"content-length",
"content-range",
"content-type",
"cross-origin-resource-policy",
"etag",
"last-modified",
"timing-allow-origin",
"vary",
];
pub(crate) fn apply_asset_request_headers(
mut target: reqwest::RequestBuilder,
source: &HeaderMap,
) -> reqwest::RequestBuilder {
for name in FORWARDED_ASSET_REQUEST_HEADERS {
let header_name = HeaderName::from_static(name);
for value in source.get_all(&header_name) {
target = target.header(header_name.clone(), value.clone());
}
}
target
}
pub(crate) fn copy_asset_response_headers(source: &HeaderMap, target: &mut HeaderMap) {
for name in FORWARDED_ASSET_RESPONSE_HEADERS {
let header_name = HeaderName::from_static(name);
for value in source.get_all(&header_name) {
target.append(header_name.clone(), value.clone());
}
}
}
pub(crate) fn apply_asset_response_policy(
headers: &mut HeaderMap,
path: &str,
status: StatusCode,
csp: HeaderValue,
) {
set_known_asset_content_type(headers, path);
set_font_cors(headers);
set_vary_on_accept_encoding(headers);
weaken_entity_tag(headers);
let cache_control = if status.is_success() || status == StatusCode::NOT_MODIFIED {
asset_cache_control(path)
} else {
UPSTREAM_FAILURE_CACHE_CONTROL
};
headers.insert(
header::CACHE_CONTROL,
HeaderValue::from_static(cache_control),
);
apply_static_resource_security_policy(headers, csp);
}
fn weaken_entity_tag(headers: &mut HeaderMap) {
let Some(value) = headers.get(header::ETAG) else {
return;
};
let Some(value) = value.to_str().ok().map(str::trim) else {
headers.remove(header::ETAG);
return;
};
let (already_weak, opaque) = match value.strip_prefix("W/") {
Some(opaque) => (true, opaque),
None => (false, value),
};
if opaque.len() < 2
|| !opaque.starts_with('"')
|| !opaque.ends_with('"')
|| opaque[1..opaque.len() - 1].contains('"')
{
headers.remove(header::ETAG);
return;
}
if already_weak {
return;
}
let weak = HeaderValue::from_str(&format!("W/{opaque}"))
.expect("a validated ASCII entity tag must remain a valid header when weakened");
headers.insert(header::ETAG, weak);
}
pub(crate) fn set_vary_on_accept_encoding(headers: &mut HeaderMap) {
let already_varies = headers.get_all(header::VARY).iter().any(|value| {
value.to_str().is_ok_and(|value| {
value.split(',').any(|field| {
let field = field.trim();
field == "*" || field.eq_ignore_ascii_case("accept-encoding")
})
})
});
if !already_varies {
headers.append(header::VARY, HeaderValue::from_static("accept-encoding"));
}
}
pub(crate) fn accepts_content_encoding(headers: &HeaderMap, encoding: &str) -> bool {
let Some(accepted) = headers
.get(header::ACCEPT_ENCODING)
.and_then(|value| value.to_str().ok())
else {
return false;
};
accepted.split(',').any(|candidate| {
let mut parameters = candidate.split(';').map(str::trim);
let Some(name) = parameters.next() else {
return false;
};
name.eq_ignore_ascii_case(encoding) && !parameters.any(is_zero_quality)
})
}
fn is_zero_quality(parameter: &str) -> bool {
let Some((key, value)) = parameter.split_once('=') else {
return false;
};
key.trim().eq_ignore_ascii_case("q")
&& value
.trim()
.parse::<f32>()
.is_ok_and(|quality| quality <= 0.0)
}
pub(crate) fn apply_static_resource_security_policy(headers: &mut HeaderMap, csp: HeaderValue) {
headers.insert(header::CONTENT_SECURITY_POLICY, csp);
headers.remove(header::CONTENT_SECURITY_POLICY_REPORT_ONLY);
}
pub(crate) fn guess_mime(path: &str) -> &'static str {
let extension = match path.rfind('.') {
Some(index) => &path[index..],
None => return "application/octet-stream",
};
match extension.to_ascii_lowercase().as_str() {
".html" | ".htm" => "text/html; charset=utf-8",
".js" | ".mjs" => "application/javascript; charset=utf-8",
".css" => "text/css; charset=utf-8",
".json" => "application/json; charset=utf-8",
".png" => "image/png",
".jpg" | ".jpeg" => "image/jpeg",
".gif" => "image/gif",
".webp" => "image/webp",
".avif" => "image/avif",
".svg" => "image/svg+xml",
".ico" => "image/x-icon",
".woff" => "font/woff",
".woff2" => "font/woff2",
".ttf" => "font/ttf",
".otf" => "font/otf",
".eot" => "application/vnd.ms-fontobject",
".mp3" => "audio/mpeg",
".mp4" => "video/mp4",
".webm" => "video/webm",
".ogg" => "audio/ogg",
".wav" => "audio/wav",
".pdf" => "application/pdf",
".txt" => "text/plain; charset=utf-8",
".xml" => "application/xml; charset=utf-8",
".webmanifest" => "application/manifest+json",
".map" => "application/json",
".wasm" => "application/wasm",
_ => "application/octet-stream",
}
}
pub(crate) fn is_font_mime(mime_type: &str) -> bool {
matches!(
mime_type,
"font/woff" | "font/woff2" | "font/ttf" | "font/otf" | "application/vnd.ms-fontobject"
)
}
pub(crate) fn is_hashed_asset(path: &str) -> bool {
let filename = path.rsplit('/').next().unwrap_or(path);
let Some(last_dot) = filename.rfind('.') else {
return false;
};
let stem = &filename[..last_dot];
if stem.split('.').next().is_some_and(is_content_hash) {
return true;
}
['.', '-'].iter().any(|separator| {
stem.rfind(*separator)
.is_some_and(|position| is_content_hash(&stem[position + 1..]))
})
}
pub(crate) fn asset_cache_control(path: &str) -> &'static str {
if is_hashed_asset(path) {
LONG_LIVED_ASSET_CACHE_CONTROL
} else {
REVALIDATED_ASSET_CACHE_CONTROL
}
}
fn set_font_cors(headers: &mut HeaderMap) {
let is_font = headers
.get(header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.map(|value| value.split(';').next().unwrap_or(value).trim())
.is_some_and(is_font_mime);
if is_font {
headers.insert(
header::ACCESS_CONTROL_ALLOW_ORIGIN,
HeaderValue::from_static(CORS_ALLOW_ANY_VALUE),
);
}
}
fn set_known_asset_content_type(headers: &mut HeaderMap, path: &str) {
let mime_type = guess_mime(path);
if mime_type == "application/octet-stream" {
return;
}
headers.insert(header::CONTENT_TYPE, HeaderValue::from_static(mime_type));
}
pub(crate) fn is_content_hash(value: &str) -> bool {
value.len() >= 8 && value.chars().all(|character| character.is_ascii_hexdigit())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mime_html() {
assert_eq!(guess_mime("i.html"), "text/html; charset=utf-8");
}
#[test]
fn mime_js() {
assert_eq!(guess_mime("a.js"), "application/javascript; charset=utf-8");
}
#[test]
fn mime_css() {
assert_eq!(guess_mime("s.css"), "text/css; charset=utf-8");
}
#[test]
fn mime_json() {
assert_eq!(guess_mime("d.json"), "application/json; charset=utf-8");
}
#[test]
fn mime_wasm() {
assert_eq!(guess_mime("m.wasm"), "application/wasm");
}
#[test]
fn mime_svg() {
assert_eq!(guess_mime("i.svg"), "image/svg+xml");
}
#[test]
fn mime_png() {
assert_eq!(guess_mime("p.png"), "image/png");
}
#[test]
fn mime_jpg() {
assert_eq!(guess_mime("p.jpg"), "image/jpeg");
}
#[test]
fn mime_webp() {
assert_eq!(guess_mime("p.webp"), "image/webp");
}
#[test]
fn mime_avif() {
assert_eq!(guess_mime("p.avif"), "image/avif");
}
#[test]
fn mime_ico() {
assert_eq!(guess_mime("f.ico"), "image/x-icon");
}
#[test]
fn mime_woff2() {
assert_eq!(guess_mime("f.woff2"), "font/woff2");
}
#[test]
fn mime_mp4() {
assert_eq!(guess_mime("c.mp4"), "video/mp4");
}
#[test]
fn mime_unknown() {
assert_eq!(guess_mime("f.xyz"), "application/octet-stream");
}
#[test]
fn mime_no_ext() {
assert_eq!(guess_mime("LICENSE"), "application/octet-stream");
}
#[test]
fn mime_case_insensitive() {
assert_eq!(guess_mime("F.HTML"), "text/html; charset=utf-8");
assert_eq!(guess_mime("F.JS"), "application/javascript; charset=utf-8");
}
#[test]
fn hashed_asset_positive() {
assert!(is_hashed_asset("app.a1b2c3d4.js"));
assert!(is_hashed_asset("style-abcdef01.css"));
}
#[test]
fn hashed_asset_accepts_bare_contenthash_filenames() {
assert!(is_hashed_asset("assets/469e0b8f10c496a1.css"));
assert!(is_hashed_asset("assets/a79f1c3119cd700d.woff2"));
assert!(is_hashed_asset("/assets/488b87159423ca35.js"));
}
#[test]
fn hashed_asset_accepts_the_contenthash_worker_bundle_name() {
assert!(
is_hashed_asset("assets/2d715e4730758083.worker.js"),
"rspack emits workers as assets/[contenthash:16].worker.js"
);
}
#[test]
fn hashed_asset_negative() {
assert!(!is_hashed_asset("app.js"));
assert!(!is_hashed_asset("style.css"));
assert!(!is_hashed_asset("a79f1c3119cd700d/app.js"));
}
#[test]
fn the_bundled_font_licences_are_not_treated_as_content_hashed() {
assert!(!is_hashed_asset("assets/fonts-NOTICE.txt"));
assert!(!is_hashed_asset("assets/fonts-LICENSE-IBM-PLEX.txt"));
}
#[test]
fn only_a_content_hashed_asset_is_promised_to_never_change() {
assert_eq!(
asset_cache_control("assets/469e0b8f10c496a1.css"),
LONG_LIVED_ASSET_CACHE_CONTROL
);
assert_eq!(
asset_cache_control("assets/fonts-NOTICE.txt"),
REVALIDATED_ASSET_CACHE_CONTROL
);
}
}