diff --git a/docs/technical/【后端架构】BgFilter受限资源调度方案-2026-07-21.md b/docs/technical/【后端架构】BgFilter受限资源调度方案-2026-07-21.md index 4a5ee87e3..674f88b51 100644 --- a/docs/technical/【后端架构】BgFilter受限资源调度方案-2026-07-21.md +++ b/docs/technical/【后端架构】BgFilter受限资源调度方案-2026-07-21.md @@ -382,7 +382,7 @@ BgFilter 成功二进制不是一份新的业务资产: - 首版限定父 worker 与子 worker 同机部署,内部 listener 只绑定 loopback 固定端口,不挂公共 Axum router、Nginx、BFF 或 OpenAPI。 - 使用独立内部 Token;缺失时生产 fail-closed。Token 不复用 BgFilter provider Token。 -- 父、子进程只从同一个 `GENARRATIVE_BGFILTER_INTERNAL_TOKEN_FILE` 读取内部 Token。发布脚本必须在切换 `current` 链接前确认该路径是非符号链接的普通非空文件,owner / group / mode 符合 `root:genarrative 0440`;不能先切换版本、再等 readiness 暴露首次未 provision 或权限错误。 +- 父、子进程只从同一个 `GENARRATIVE_BGFILTER_INTERNAL_TOKEN_FILE` 读取内部 Token。首尾空白按配置读取规则规范化,规范化后的正文必须是非空且不含任何空白字符的单段值;相关进程启动和发布 preflight 都必须拒绝含内部空格、Tab 或多个非空行的 Token。发布脚本还必须在切换 `current` 链接前确认该路径是非符号链接的普通非空文件,owner / group / mode 符合 `root:genarrative 0440`;不能先切换版本、再等 readiness 暴露首次未 provision、格式或权限错误。 - 只接受配置 bucket 下的规范化 object key;禁止 `http://`、`https://`、`data:`、`blob:` 和路径逃逸。 - 不在日志、trace、metrics、错误 JSON 或 SpacetimeDB 审计中写签名 URL、Token、图片字节或 Base64。 @@ -438,7 +438,7 @@ BgFilter 成功二进制不是一份新的业务资产: ### 10.2 部署与回滚 -1. 在切换发布目录前完成 preflight:共享 API env 与两类 worker env 均存在;`external-generation-worker.env` 和 `bgfilter-worker.env` 不得把共享 BgFilter 配置覆盖为不同有效值;父 base URL、子 `HOST / PORT` 与 readiness URL 指向同一 loopback endpoint;内部 Token 文件存在、非空、非符号链接且权限正确;`N / Q` 为正整数且 `Q >= N`。 +1. 在切换发布目录前完成 preflight:共享 API env 与两类 worker env 均存在;`external-generation-worker.env` 和 `bgfilter-worker.env` 不得把共享 BgFilter 配置覆盖为不同有效值;父 base URL、子 `HOST / PORT` 与 readiness URL 指向同一 loopback endpoint;内部 Token 文件存在、非空、非符号链接,规范化后是无空白的单段值且权限正确;`N / Q` 为正整数且 `Q >= N`。 2. 安装非模板单实例 unit;它先加载 `/etc/genarrative/api-server.env`,再加载 `/etc/genarrative/bgfilter-worker.env`。 3. 执行 `stop old -> 等待排空/退出 -> start new`,确认唯一 `bgfilter-worker` 的 loopback readiness、鉴权和 provider smoke,不得滚动重叠。 4. 再重启使用内部 client 的 API / external-generation-worker / controller。 @@ -484,7 +484,7 @@ BgFilter 成功二进制不是一份新的业务资产: - `external-generation-worker.env` 后加载时不得把内部 base URL、Token / Token 文件、connect timeout、`N`、`est`、OSS bucket 或 endpoint 覆盖为与共享 API env 不同的有效值;父侧必须把源对象写到子 worker 将要签名读取的同一 OSS 位置。外部生成 worker 可使用同 bucket 下权限等价或更小的独立 AK,不要求凭据文本相同。 - 父进程 `GENARRATIVE_BGFILTER_WORKER_BASE_URL`、子 worker `HOST / PORT` 和部署 readiness URL 必须指向同一个 `127.0.0.1:` endpoint;旧非空配置不能因为“无需补默认值”而绕过一致性检查。 - `genarrative-bgfilter-worker.service` 必须保持 `TimeoutStopSec=900`,覆盖 `callBudgetMs` 排空上界(约 `321s`)与停止收口余量;停止时排队请求立即类型化失败,不参与排空。 -- 发布目录切换前拒绝缺失、空、符号链接或权限错误的内部 Token 文件;父、子有效 Token 文件路径必须相同。 +- 发布目录切换前拒绝缺失、空、含内部空白、包含多个非空行、符号链接或权限错误的内部 Token 文件;允许文件末尾正常换行,父、子有效 Token 文件路径必须相同。 - 生产运行期巡检同时检查 `genarrative-bgfilter-worker.service` 为 active 且 worker `readyz` 成功(默认 `127.0.0.1:8083`),不能只依赖 systemd 自动重启。provision / deploy 的发布验活 URL 必须从已验证的 env `HOST/PORT` 派生,不得硬编码默认端口;若运维自定义 `GENARRATIVE_BGFILTER_WORKER_PORT`,必须同步覆盖巡检的 `GENARRATIVE_HEALTH_PATROL_BGFILTER_BASE_URL`(巡检是独立进程,不读取 worker env,默认值不会自动跟随)。 - `npm run dev` 启动独立 BgFilter 子进程并使用解析后的第五个端口;`all` 角色不内嵌 listener,单模块入口、watch、状态文件和退出清理没有遗留进程或硬编码端口。 diff --git a/scripts/check-production-api-deploy.mjs b/scripts/check-production-api-deploy.mjs index dbe964d27..9aa7777a5 100644 --- a/scripts/check-production-api-deploy.mjs +++ b/scripts/check-production-api-deploy.mjs @@ -61,6 +61,8 @@ function main() { assertDeployRejectsInlineBgFilterInternalToken(); assertDeployRejectsEmptyBgFilterInternalToken(); assertDeployRejectsWhitespaceBgFilterInternalToken(); + assertDeployRejectsInternalWhitespaceBgFilterInternalToken(); + assertDeployRejectsMultilineBgFilterInternalToken(); assertReadinessFailureKeepsMaintenanceAfterCurrentSwitch(); assertMissingReleaseManifestFails(); assertReleaseManifestMissingApiArtifactFails(); @@ -933,7 +935,7 @@ function assertDeployRejectsWhitespaceBgFilterInternalToken() { } assertIncludes( result.stderr, - 'BgFilter 内部 Token 文件必须至少包含一个非空白字符', + 'BgFilter 内部 Token 文件必须为不含空白字符的单段值', '纯空白 Token 文件预检必须给出明确错误。', ); assertBgFilterPreflightFailedBeforeSwitch( @@ -942,6 +944,44 @@ function assertDeployRejectsWhitespaceBgFilterInternalToken() { ); } +function assertDeployRejectsInternalWhitespaceBgFilterInternalToken() { + const fixture = prepareFixture('internal-whitespace-bgfilter-internal-token'); + writeFileSync(fixture.bgfilterTokenFile, 'fixture bgfilter-token\n', 'utf8'); + const result = runDeploy(fixture); + + if (result.status === 0) { + failures.push('BgFilter 内部 Token 含内部空格时部署必须失败。'); + } + assertIncludes( + result.stderr, + 'BgFilter 内部 Token 文件必须为不含空白字符的单段值', + '含内部空格的 Token 文件预检必须给出明确错误。', + ); + assertBgFilterPreflightFailedBeforeSwitch( + fixture, + 'BgFilter 含内部空格 Token 文件预检失败', + ); +} + +function assertDeployRejectsMultilineBgFilterInternalToken() { + const fixture = prepareFixture('multiline-bgfilter-internal-token'); + writeFileSync(fixture.bgfilterTokenFile, 'fixture-token\nsecond-token\n', 'utf8'); + const result = runDeploy(fixture); + + if (result.status === 0) { + failures.push('BgFilter 内部 Token 包含多个非空行时部署必须失败。'); + } + assertIncludes( + result.stderr, + 'BgFilter 内部 Token 文件必须为不含空白字符的单段值', + '多行 Token 文件预检必须给出明确错误。', + ); + assertBgFilterPreflightFailedBeforeSwitch( + fixture, + 'BgFilter 多行 Token 文件预检失败', + ); +} + function assertDeployRejectsInlineBgFilterInternalToken() { const cases = [ ['api-env', 'apiEnvFile'], diff --git a/scripts/check-production-ops-guardrails.mjs b/scripts/check-production-ops-guardrails.mjs index 53d5492c6..9ed781480 100644 --- a/scripts/check-production-ops-guardrails.mjs +++ b/scripts/check-production-ops-guardrails.mjs @@ -1681,9 +1681,9 @@ const checks = [ }, { file: 'scripts/jenkins-server-provision.sh', - includes: 'BgFilter 内部 Token 文件不得为空或只包含空白字符', + includes: 'BgFilter 内部 Token 文件必须为不含空白字符的单段值', reason: - 'Server-Provision 必须拒绝仅含空白字符的 BgFilter 内部 Token 文件。', + 'Server-Provision 必须拒绝纯空白、含内部空白或包含多个非空行的 BgFilter 内部 Token 文件。', }, { file: 'scripts/jenkins-server-provision.sh', diff --git a/scripts/deploy/production-api-deploy.sh b/scripts/deploy/production-api-deploy.sh index 03dc8343b..ee20dfefa 100644 --- a/scripts/deploy/production-api-deploy.sh +++ b/scripts/deploy/production-api-deploy.sh @@ -654,6 +654,22 @@ validate_no_bgfilter_internal_token_plaintext() { done } +bgfilter_internal_token_file_is_single_segment() { + local token_file="$1" + + run_privileged awk ' + /[^[:space:]]/ { + non_empty_lines += 1 + if ($0 !~ /^[[:space:]]*[^[:space:]]+[[:space:]]*$/) { + invalid = 1 + } + } + END { + exit !(non_empty_lines == 1 && invalid == 0) + } + ' "${token_file}" +} + validate_bgfilter_internal_token_file() { local api_env_file="$1" local token_file token_metadata @@ -667,8 +683,8 @@ validate_bgfilter_internal_token_file() { echo "[production-api-deploy] BgFilter 内部 Token 必须是非空普通文件且不能是符号链接: ${token_file}" >&2 return 1 fi - if ! run_privileged grep -q '[^[:space:]]' -- "${token_file}"; then - echo "[production-api-deploy] BgFilter 内部 Token 文件必须至少包含一个非空白字符: ${token_file}" >&2 + if ! bgfilter_internal_token_file_is_single_segment "${token_file}"; then + echo "[production-api-deploy] BgFilter 内部 Token 文件必须为不含空白字符的单段值: ${token_file}" >&2 return 1 fi diff --git a/scripts/jenkins-server-provision.sh b/scripts/jenkins-server-provision.sh index 880f18257..789cbaa79 100755 --- a/scripts/jenkins-server-provision.sh +++ b/scripts/jenkins-server-provision.sh @@ -809,6 +809,22 @@ ensure_bgfilter_worker_runtime_env_defaults() { ensure_env_value "${BGFILTER_WORKER_ENV_FILE}" "GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS" "300" } +bgfilter_internal_token_file_is_single_segment() { + local token_file="$1" + + awk ' + /[^[:space:]]/ { + non_empty_lines += 1 + if ($0 !~ /^[[:space:]]*[^[:space:]]+[[:space:]]*$/) { + invalid = 1 + } + } + END { + exit !(non_empty_lines == 1 && invalid == 0) + } + ' "${token_file}" +} + ensure_bgfilter_internal_token_file() { local token_file="/etc/genarrative/secrets/bgfilter-worker.token" local token_dir="/etc/genarrative/secrets" @@ -840,9 +856,9 @@ ensure_bgfilter_internal_token_file() { echo "[server-provision] 生成 BgFilter 内部 Token 失败。" >&2 exit 1 fi - if ! grep -q '[^[:space:]]' -- "${temporary_file}"; then + if ! bgfilter_internal_token_file_is_single_segment "${temporary_file}"; then rm -f "${temporary_file}" - echo "[server-provision] 生成的 BgFilter 内部 Token 不得为空或只包含空白字符。" >&2 + echo "[server-provision] 生成的 BgFilter 内部 Token 必须为不含空白字符的单段值。" >&2 exit 1 fi chown root:genarrative "${temporary_file}" @@ -850,8 +866,8 @@ ensure_bgfilter_internal_token_file() { mv -T "${temporary_file}" "${token_file}" echo "[server-provision] 已生成 BgFilter 内部 Token 文件: ${token_file}" else - if ! grep -q '[^[:space:]]' -- "${token_file}"; then - echo "[server-provision] BgFilter 内部 Token 文件不得为空或只包含空白字符: ${token_file}" >&2 + if ! bgfilter_internal_token_file_is_single_segment "${token_file}"; then + echo "[server-provision] BgFilter 内部 Token 文件必须为不含空白字符的单段值: ${token_file}" >&2 exit 1 fi chown root:genarrative "${token_file}" diff --git a/server-rs/crates/api-server/src/bgfilter_worker.rs b/server-rs/crates/api-server/src/bgfilter_worker.rs index ef96144ad..503da4bbf 100644 --- a/server-rs/crates/api-server/src/bgfilter_worker.rs +++ b/server-rs/crates/api-server/src/bgfilter_worker.rs @@ -223,14 +223,10 @@ struct BgfilterWorkerRuntime { impl BgfilterWorkerRuntime { fn new(app_state: AppState, task_tracker: BgfilterTaskTracker) -> Result { - let token = app_state - .config - .bgfilter_internal_token - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(ToOwned::to_owned) - .ok_or_else(|| "GENARRATIVE_BGFILTER_INTERNAL_TOKEN(_FILE) 未配置".to_string())?; + let token = + validate_bgfilter_internal_token(app_state.config.bgfilter_internal_token.as_deref())? + .map(ToOwned::to_owned) + .ok_or_else(|| "GENARRATIVE_BGFILTER_INTERNAL_TOKEN(_FILE) 未配置".to_string())?; if app_state.oss_client().is_none() { return Err("bgfilter-worker 需要完整的 ALIYUN_OSS_* 读取配置".to_string()); } @@ -264,6 +260,20 @@ impl BgfilterWorkerRuntime { } } +pub(crate) fn validate_bgfilter_internal_token( + configured: Option<&str>, +) -> Result, String> { + let Some(token) = configured.map(str::trim).filter(|value| !value.is_empty()) else { + return Ok(None); + }; + if token.chars().any(char::is_whitespace) { + return Err( + "GENARRATIVE_BGFILTER_INTERNAL_TOKEN(_FILE) 必须为不含空白字符的单段 Token".to_string(), + ); + } + Ok(Some(token)) +} + #[derive(Clone)] pub(crate) struct BgfilterTaskTracker { inner: Arc, @@ -2048,12 +2058,8 @@ pub(crate) async fn request_bgfilter_worker( )); } let call_budget_ms = state.config.bgfilter_call_budget_ms(); - let token = state - .config - .bgfilter_internal_token - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) + let token = validate_bgfilter_internal_token(state.config.bgfilter_internal_token.as_deref()) + .map_err(|message| BgfilterClientError::local("unauthorized", message))? .ok_or_else(|| { BgfilterClientError::local("unauthorized", "BgFilter 内部调用 Token 未配置") })?; @@ -2391,6 +2397,35 @@ mod tests { .expect("request should build") } + #[test] + fn internal_token_allows_outer_whitespace_but_rejects_multiple_segments() { + assert_eq!( + validate_bgfilter_internal_token(Some(" shared-token\r\n")) + .expect("outer whitespace should normalize"), + Some("shared-token") + ); + assert_eq!( + validate_bgfilter_internal_token(None).expect("missing token remains optional"), + None + ); + assert_eq!( + validate_bgfilter_internal_token(Some(" \r\n\t ")) + .expect("blank token normalizes to missing"), + None + ); + for value in [ + "shared token", + "shared\ttoken", + "shared\ntoken", + "shared\r\ntoken", + ] { + assert!( + validate_bgfilter_internal_token(Some(value)).is_err(), + "internal whitespace must be rejected: {value:?}" + ); + } + } + #[test] fn constant_time_token_comparison_checks_content_and_length() { assert!(constant_time_eq(b"shared-token", b"shared-token")); diff --git a/server-rs/crates/api-server/src/main.rs b/server-rs/crates/api-server/src/main.rs index 4763f45f3..0fe68f7b6 100644 --- a/server-rs/crates/api-server/src/main.rs +++ b/server-rs/crates/api-server/src/main.rs @@ -93,7 +93,7 @@ use tracing::{error, info, warn}; use crate::{ app::{build_router, build_spacetime_unavailable_router}, - bgfilter_worker::build_bgfilter_worker_router, + bgfilter_worker::{build_bgfilter_worker_router, validate_bgfilter_internal_token}, config::{AppConfig, ProcessRole}, external_generation_worker::run_external_generation_worker, external_generation_worker_controller::run_external_generation_worker_controller, @@ -143,6 +143,7 @@ fn main() -> Result<(), io::Error> { } async fn run_server(config: AppConfig) -> Result<(), io::Error> { + validate_bgfilter_internal_token_for_startup(&config).map_err(io::Error::other)?; init_tracing( &config.log_filter, OtelConfig { @@ -163,6 +164,23 @@ async fn run_server(config: AppConfig) -> Result<(), io::Error> { run_http_role(config).await } +fn validate_bgfilter_internal_token_for_startup(config: &AppConfig) -> Result<(), String> { + if should_validate_bgfilter_internal_token_for_startup(config.process_role) { + validate_bgfilter_internal_token(config.bgfilter_internal_token.as_deref())?; + } + Ok(()) +} + +fn should_validate_bgfilter_internal_token_for_startup(process_role: ProcessRole) -> bool { + matches!( + process_role, + ProcessRole::Api + | ProcessRole::BgfilterWorker + | ProcessRole::ExternalGenerationWorker + | ProcessRole::All + ) +} + async fn run_bgfilter_worker_role(mut config: AppConfig) -> Result<(), io::Error> { let (concurrency, single_image_estimate_ms, max_requests) = required_bgfilter_worker_capacity_from_env()?; @@ -683,9 +701,10 @@ mod tests { parse_required_bgfilter_worker_capacity, protected_env_keys_from, should_initialize_editor_generation_pricing_for_startup, should_restore_auth_store_for_startup, should_start_profile_recharge_expiration_listener, - strip_env_value, + should_validate_bgfilter_internal_token_for_startup, strip_env_value, + validate_bgfilter_internal_token_for_startup, }; - use crate::config::ProcessRole; + use crate::config::{AppConfig, ProcessRole}; #[test] fn strip_env_value_removes_wrapping_quotes() { @@ -763,6 +782,32 @@ mod tests { assert_eq!(AUTH_STORE_STARTUP_RETRY_INTERVAL.as_secs(), 5); } + #[test] + fn bgfilter_internal_token_startup_validation_is_limited_to_consumers() { + for role in [ + ProcessRole::Api, + ProcessRole::BgfilterWorker, + ProcessRole::ExternalGenerationWorker, + ProcessRole::All, + ] { + assert!(should_validate_bgfilter_internal_token_for_startup(role)); + let mut config = AppConfig::default(); + config.process_role = role; + config.bgfilter_internal_token = Some("invalid token".to_string()); + assert!(validate_bgfilter_internal_token_for_startup(&config).is_err()); + } + assert!(!should_validate_bgfilter_internal_token_for_startup( + ProcessRole::ExternalGenerationController + )); + let mut controller_config = AppConfig::default(); + controller_config.process_role = ProcessRole::ExternalGenerationController; + controller_config.bgfilter_internal_token = Some("unused invalid token".to_string()); + assert!(validate_bgfilter_internal_token_for_startup(&controller_config).is_ok()); + + let missing_config = AppConfig::default(); + assert!(validate_bgfilter_internal_token_for_startup(&missing_config).is_ok()); + } + #[test] fn auth_store_startup_restore_is_limited_to_http_roles() { assert!(should_restore_auth_store_for_startup(ProcessRole::Api));