mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 04:55:42 +09:00
feat(desktop): bundled renderer, modules and instance accounts (#3254)
This commit is contained in:
1 parent
c748c8af4e
commit
7c5fa2180a
1275 files changed
+215074
-81246
No files matched your search
@@ -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"] }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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,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(())
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>,
|
||||
|
||||
@@ -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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user