From 71f3351c9934500bd37c921a9f8fe4b436798a33 Mon Sep 17 00:00:00 2001 From: xfy Date: Wed, 15 Jul 2026 17:12:55 +0800 Subject: [PATCH] =?UTF-8?q?feat(server):=20=E9=80=9A=E8=BF=87=E5=93=8D?= =?UTF-8?q?=E5=BA=94=E5=A4=B4=E4=B8=BB=E5=8A=A8=E6=9A=B4=E9=9C=B2=E7=89=88?= =?UTF-8?q?=E6=9C=AC=E4=B8=8E=20git=20=E6=8F=8F=E8=BF=B0=E4=BF=A1=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 version_headers_middleware,为所有响应附加三个头: - Server: yggdrasil/(遵循 product/version 习惯) - X-Yggdrasil-Version: Cargo 版本号 - X-Yggdrasil-Git: git describe(版本+提交数+短hash+脏标记) 数据源复用 build_info::BUILD_INFO,与启动日志 log_build_info() 同源, 零新依赖。 关键设计:中间件挂在最终合并 router 的最外层(Ok(router) 前),因此 /healthz、/uploads/*、被 CSRF 拒(403)/超时/admin_guard 重定向的响应 都会带头,探测价值最大。此前 app_routes 的中间件栈只覆盖 Dioxus 路由, 无法覆盖这些端点。 受 EXPOSE_VERSION_HEADERS 控制,默认 true;设 0/false/no 关闭。 bool 解析沿用 COOKIE_SECURE 的 matches!("1"/"true"/"yes") 约定, 此处取反为"非 false 值即开"使默认行为对应「暴露」。启动时记录一条 info 日志便于确认生效值。 --- AGENTS.md | 1 + src/main.rs | 54 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index c1c67fb..f7c2b33 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -107,6 +107,7 @@ Session / security tuning: COOKIE_SECURE=false # set true/1/yes to add Secure flag to session cookie TRUSTED_PROXY_COUNT=0 # number of reverse proxies in front of the app; used to extract real client IP from X-Forwarded-For APP_BASE_URL= # e.g. https://your-domain.example — trusted origin for CSRF checks on write requests; unset falls back to Host header + X-Forwarded-Proto +EXPOSE_VERSION_HEADERS=true # 附加 Server / X-Yggdrasil-Version / X-Yggdrasil-Git 响应头;设 0/false/no 关闭(不向外部暴露版本/commit 时) ``` ## Architecture: Conditional Compilation diff --git a/src/main.rs b/src/main.rs index 7022713..05ea3b3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -417,6 +417,18 @@ fn main() { .invalidate_after(std::time::Duration::from_secs(ssr_cache_secs)), ); + // 版本响应头开关:默认开启。设 0/false/no 关闭(注重安全、不想对外暴露版本/commit 时)。 + // bool 解析约定与 COOKIE_SECURE 一致(matches "1"/"true"/"yes");这里取反为 + // "非 false 值即开",使默认行为(unwrap_or(true))对应「暴露」。 + let expose_version_headers = std::env::var("EXPOSE_VERSION_HEADERS") + .ok() + .map(|v| !matches!(v.as_str(), "0" | "false" | "no")) + .unwrap_or(true); + tracing::info!( + expose_version_headers, + "版本响应头开关(Server / X-Yggdrasil-Version / X-Yggdrasil-Git)" + ); + // SSR 世代号中间件:把当前全局世代号注入请求扩展,并对 GET 请求的 // 响应附加 `X-SSR-Generation` 头。这是为未来 Dioxus 支持自定义 SSR 缓存键 // 预留的钩子;目前主要提供可观测性,不会实际失效 SSR 缓存。 @@ -441,6 +453,40 @@ fn main() { response } + // 版本头中间件:为所有响应附加 Server / X-Yggdrasil-Version / X-Yggdrasil-Git, + // 数据源与启动日志 log_build_info() 同源(crate::build_info::BUILD_INFO)。 + // 挂在最终合并 router 的最外层(见下方 Ok(router) 前),因此连 /healthz、 + // /uploads/*、被 CSRF 拒(403)/超时/admin_guard 重定向的响应都会带头, + // 探测价值最大。受 EXPOSE_VERSION_HEADERS 控制(默认 true)。 + async fn version_headers_middleware( + req: axum::http::Request, + next: axum::middleware::Next, + ) -> axum::response::Response { + let mut response = next.run(req).await; + let h = response.headers_mut(); + // Server 头:产品名/版本,遵循 "Server: product/version" 习惯。 + h.insert( + axum::http::header::SERVER, + axum::http::HeaderValue::from_str(&format!( + "yggdrasil/{}", + crate::build_info::BUILD_INFO.version + )) + .unwrap_or_else(|_| axum::http::HeaderValue::from_static("yggdrasil")), + ); + // X-Yggdrasil-Version:Cargo.toml 版本号。 + h.insert( + axum::http::header::HeaderName::from_static("x-yggdrasil-version"), + axum::http::HeaderValue::from_static(crate::build_info::BUILD_INFO.version), + ); + // X-Yggdrasil-Git:git describe(版本+提交数+短hash+脏标记)。 + h.insert( + axum::http::header::HeaderName::from_static("x-yggdrasil-git"), + axum::http::HeaderValue::from_str(crate::build_info::BUILD_INFO.git_describe) + .unwrap_or_else(|_| axum::http::HeaderValue::from_static("unknown")), + ); + response + } + // 自定义 API 路由:图片上传(大文件,需要更长超时) // CSRF 校验置于最外层,先拦截非法来源再做超时/限体。 let upload_route = axum::Router::new() @@ -533,6 +579,14 @@ fn main() { .merge(app_routes) .merge(static_routes); + // 版本头中间件置于最终合并 router 的最外层:所有端点(含 /healthz、/uploads/*、 + // 被 CSRF 拒/超时/admin_guard 重定向的响应)都会带上版本头。受 EXPOSE_VERSION_HEADERS 控制。 + let router = if expose_version_headers { + router.layer(axum::middleware::from_fn(version_headers_middleware)) + } else { + router + }; + Ok(router) }); }