#!/usr/bin/env node /** * 游戏发行来源配置门禁。 * * 逐条校验 `deploy/nginx/genarrative-release-origin.conf`: * 1) 每游戏独立 origin 的按主机映射(命名捕获 `game_id` + 发行网关前缀); * 2) 只暴露发行网关,不代理平台 API / 后台 / SPA; * 3) 发行来源不使用 Cookie(边缘 403 + 转发前清空); * 4) 响应头策略仍由 api-server 发行网关负责(源码级交叉检查)。 * 只要本机存在 nginx 与 openssl,还会用自签通配证书渲染一份临时配置执行 * `nginx -t`,把语法与指令上下文一起验证掉。 */ import { execFileSync } from 'node:child_process'; import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync, } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; const scriptDir = dirname(fileURLToPath(import.meta.url)); const repoRoot = join(scriptDir, '..'); const templatePath = join( repoRoot, 'deploy/nginx/genarrative-release-origin.conf', ); const gatewayPath = join( repoRoot, 'server-rs/crates/api-server/src/modules/game_distribution.rs', ); const failures = []; const notes = []; function fail(message) { failures.push(message); } function normalize(source) { return source.replace(/\s+/gu, ' '); } function requireSnippet(source, snippet, message) { if (!normalize(source).includes(normalize(snippet))) { fail(message); } } function main() { if (!existsSync(templatePath)) { fail(`缺少发行来源模板:${templatePath}`); return; } const template = readFileSync(templatePath, 'utf8'); requireSnippet( template, 'server_name ~^(?[a-z0-9_]+)\\.games\\.example\\.com$;', '发行来源必须用命名捕获 game_id 的子域匹配(每游戏独立 origin)', ); requireSnippet( template, 'ssl_certificate /etc/letsencrypt/live/games.example.com/fullchain.pem;', '发行来源必须使用通配 TLS 证书', ); requireSnippet( template, 'if ($http_cookie) { return 403; }', '发行来源必须拒绝携带平台 Cookie 的请求', ); requireSnippet( template, 'proxy_set_header Cookie "";', '发行来源转发前必须清空 Cookie', ); requireSnippet( template, 'proxy_pass http://genarrative_release_api/api/game-distribution/releases/$game_id$request_uri;', '发行来源必须按 game_id 映射到发行网关前缀', ); requireSnippet( template, 'location /.well-known/acme-challenge/', '发行来源必须保留 ACME challenge 路径', ); requireSnippet( template, 'location = / {', '发行来源必须显式把子域根路径映射为该游戏的 index.html', ); requireSnippet( template, 'proxy_pass http://genarrative_release_api/api/game-distribution/releases/$game_id/index.html;', '子域根路径必须映射到该游戏的 index.html', ); const proxyPassCount = (template.match(/proxy_pass\s/gu) ?? []).length; if (proxyPassCount !== 2) { fail( `发行来源只应存在两条 proxy_pass(子域根路径与发行网关前缀),实际 ${proxyPassCount} 条`, ); } const cookieStripCount = ( template.match(/proxy_set_header Cookie "";/gu) ?? [] ).length; if (cookieStripCount !== 2) { fail(`每条发行来源代理都必须清空 Cookie,实际 ${cookieStripCount} 处`); } const gatewayPrefixCount = ( template.match(/api\/game-distribution\/releases\/\$game_id/gu) ?? [] ).length; if (gatewayPrefixCount !== 2) { fail(`发行来源代理必须都映射到发行网关前缀,实际 ${gatewayPrefixCount} 处`); } for (const forbidden of [ '/api/auth', '/api/profile', '/admin/api', '/api/game-distribution/games', '/api/game-distribution/versions', ]) { if (template.includes(forbidden)) { fail(`发行来源不得代理平台命名空间:${forbidden}`); } } if (!existsSync(gatewayPath)) { fail(`缺少发行网关源码:${gatewayPath}`); } else { const gateway = readFileSync(gatewayPath, 'utf8'); for (const [snippet, message] of [ [ 'header::X_CONTENT_TYPE_OPTIONS', '发行网关必须继续设置 X-Content-Type-Options', ], [ 'HeaderName::from_static("cross-origin-resource-policy")', '发行网关必须继续设置 CORP', ], [ 'HeaderValue::from_static("cross-origin")', 'CORP 必须是 cross-origin(opaque sandbox 才能加载自有脚本)', ], [ 'header::ACCESS_CONTROL_ALLOW_ORIGIN', '发行网关必须继续设置无凭据 CORS', ], ['header::CONTENT_SECURITY_POLICY', '发行网关必须继续为 HTML 设置 CSP'], ['StatusCode::FORBIDDEN', '发行网关必须继续拒绝携带 Cookie 的请求'], ]) { if (!gateway.includes(snippet)) { fail(message); } } } validateWithNginx(template); if (failures.length > 0) { console.error('[check:release-origin-config] FAILED'); for (const message of failures) { console.error(`- ${message}`); } process.exit(1); } for (const note of notes) { console.log(`[check:release-origin-config] ${note}`); } console.log( '[check:release-origin-config] OK(发行来源模板、网关响应头策略与 nginx 语法一致)', ); } function binaryExists(binary) { try { execFileSync('sh', ['-c', `command -v ${binary}`], { stdio: 'ignore' }); return true; } catch { return false; } } function validateWithNginx(template) { if (!binaryExists('nginx')) { notes.push('未找到 nginx,跳过渲染后的 nginx -t'); return; } const workDir = mkdtempSync(join(tmpdir(), 'genarrative-release-origin-')); try { const certPath = join(workDir, 'wildcard.crt'); const keyPath = join(workDir, 'wildcard.key'); if (binaryExists('openssl')) { execFileSync( 'openssl', [ 'req', '-x509', '-newkey', 'rsa:2048', '-nodes', '-days', '1', '-subj', '/CN=games.example.com', '-addext', 'subjectAltName=DNS:*.games.example.com,DNS:games.example.com', '-keyout', keyPath, '-out', certPath, ], { stdio: 'ignore' }, ); } else { notes.push('未找到 openssl,跳过渲染后的 nginx -t'); return; } const rendered = template .replace( '/etc/letsencrypt/live/games.example.com/fullchain.pem', certPath, ) .replace('/etc/letsencrypt/live/games.example.com/privkey.pem', keyPath) .replace( /\/var\/log\/nginx\/(genarrative-release\.[a-z]+\.log)/gu, join(workDir, '$1'), ) // 非 root 环境无法绑定 80/443;语法检查用高位端口,不改生产模板本身。 .replace('listen 80;', 'listen 18080;') .replace('listen 443 ssl http2;', 'listen 18443 ssl http2;'); const renderedPath = join(workDir, 'release-origin.conf'); writeFileSync(renderedPath, rendered); const wrapperPath = join(workDir, 'nginx.conf'); writeFileSync( wrapperPath, [ `pid ${join(workDir, 'nginx.pid')};`, `error_log ${join(workDir, 'error.log')} warn;`, 'events { worker_connections 64; }', 'http {', ' access_log off;', ' client_body_temp_path ' + join(workDir, 'client-body') + ';', ' proxy_temp_path ' + join(workDir, 'proxy') + ';', ' fastcgi_temp_path ' + join(workDir, 'fastcgi') + ';', ' uwsgi_temp_path ' + join(workDir, 'uwsgi') + ';', ' scgi_temp_path ' + join(workDir, 'scgi') + ';', ` include ${renderedPath};`, '}', '', ].join('\n'), ); try { execFileSync('nginx', ['-t', '-c', wrapperPath], { stdio: ['ignore', 'pipe', 'pipe'], }); notes.push('渲染后的发行来源配置通过 nginx -t'); } catch (error) { const stderr = error.stderr ? String(error.stderr) : ''; fail( `渲染后的发行来源配置未通过 nginx -t:${stderr.trim() || error.message}`, ); } } finally { rmSync(workDir, { recursive: true, force: true }); } } main();