328ac31844
- 主站:游戏广场、详情、在线游玩、网页发布与作者中心,以及共享契约与客户端服务 - 后端:module-game-distribution 领域层、SpacetimeDB 表/迁移/绑定、spacetime-client facade、api-server 路由与发行网关 - 后台:游戏审核页(待审列表、通过/拒绝、安全下架) - AGC:发布面板、本地导出包读取命令与发布服务,含默认跳过的真实链路测试 - 运维:发行来源 nginx 模板与门禁、game-distribution:publish 灰度发布开关、OSS PutObject 受控重试 - 文档:主规范、里程碑与实施计划、决策日志与踩坑记录
273 lines
8.2 KiB
JavaScript
273 lines
8.2 KiB
JavaScript
#!/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 ~^(?<game_id>[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();
|