feat(server): 通过响应头主动暴露版本与 git 描述信息

新增 version_headers_middleware,为所有响应附加三个头:
- Server: yggdrasil/<version>(遵循 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 日志便于确认生效值。
This commit is contained in:
xfy 2026-07-15 17:12:55 +08:00
parent a3ca08f3f6
commit 71f3351c99
2 changed files with 55 additions and 0 deletions

View File

@ -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

View File

@ -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<axum::body::Body>,
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-VersionCargo.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-Gitgit 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)
});
}