Guanghu Domestic Migration d1e47f4565
Some checks are pending
自动更新代码和重启 / update-and-restart (push) Waiting to run
CI检查 + 自动部署 / check (push) Waiting to run
CI检查 + 自动部署 / deploy (push) Blocked by required conditions
重启聊天服务 / restart (push) Waiting to run
chore: import sanitized domestic snapshot for REPO-002
Source snapshot: ca48d3ddf926d79aa138306164169baf764bb829
2026-07-17 15:54:41 +08:00

235 lines
8.0 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* ═══════════════════════════════════════════════════════════
* 🔌 Channel Switcher · 母编号 × 子编号 切换器 (Phase 1 最小版本)
* ═══════════════════════════════════════════════════════════
*
* 角色:
* 一台服务器 (母编号 ZY-SVR-XXX) 上常驻的唯一切换 Agent.
* Phase 1 仅支持只读: list / current. 不实施真实切换 (避免误动生产).
* Phase 2 增加 activate (移软链 + reload PM2 + reload Nginx).
*
* 守护: 铸渊 · ICE-GL-ZY001
* 主权: TCS-0002∞ | SYS-GLW-0001
* 版权: 国作登字-2026-A-00037559
*
* 监听:
* 仅 127.0.0.1:39000 (CHANNEL_SWITCHER_BIND / CHANNEL_SWITCHER_PORT 可覆盖)
* 由 Nginx 反向代理到 /__switch (各域名共享同一切换器)
*
* 数据源:
* FUNCTION_MANIFEST_PATH (默认 /opt/guanghu/_manifest/function-manifest.json)
* 该文件由部署 workflow 从仓库 .github/brain/architecture/ 同步而来,
* 不直接读仓库 — 服务器零 git 依赖.
*
* 路由:
* GET /__switch/health 健康
* GET /__switch/current 当前服务器激活的 ZY-FN 列表
* GET /__switch/list 本服务器上所有已登记的 ZY-FN
* GET /__switch/manifest 返回精简后的 manifest (供前端徽章)
* POST /__switch/activate Phase 2 实现, 当前返回 501
*
* SYSLOG 回执: 每次有意义的查询写一行到 stdout, 由 PM2 收集后由
* .github/workflows/bridge-syslog-to-notion.yml 汇聚到 AG-ZY-README,
* 天眼涌现层自然感知.
*/
'use strict';
const http = require('http');
const fs = require('fs');
const url = require('url');
const os = require('os');
const PORT = parseInt(process.env.CHANNEL_SWITCHER_PORT || '39000', 10);
const BIND = process.env.CHANNEL_SWITCHER_BIND || '127.0.0.1';
const SERVER_ID = process.env.CHANNEL_SWITCHER_SERVER_ID || 'ZY-SVR-UNKNOWN';
const MANIFEST_PATH =
process.env.FUNCTION_MANIFEST_PATH ||
'/opt/guanghu/_manifest/function-manifest.json';
let manifestCache = null;
let manifestMtime = 0;
function loadManifest() {
try {
const stat = fs.statSync(MANIFEST_PATH);
if (manifestCache && stat.mtimeMs === manifestMtime) {
return manifestCache;
}
const raw = fs.readFileSync(MANIFEST_PATH, 'utf8');
const parsed = JSON.parse(raw);
manifestCache = parsed;
manifestMtime = stat.mtimeMs;
return parsed;
} catch (err) {
return { _error: err.message };
}
}
function jsonResponse(res, status, body) {
res.writeHead(status, {
'Content-Type': 'application/json; charset=utf-8',
'Cache-Control': 'no-store'
});
res.end(JSON.stringify(body, null, 2));
}
function logSyslog(event, payload) {
const line = JSON.stringify({
ts: new Date().toISOString(),
source: 'channel-switcher',
server_id: SERVER_ID,
host: os.hostname(),
event,
payload: payload || {}
});
process.stdout.write(line + '\n');
}
function serverFunctionsFromManifest(manifest) {
if (!manifest || !Array.isArray(manifest.functions)) return [];
return manifest.functions.filter((fn) => fn.host_server === SERVER_ID);
}
function activeRoutesForServer(manifest) {
if (!manifest || !manifest.active_routes) return {};
const out = {};
for (const [domain, route] of Object.entries(manifest.active_routes)) {
if (!route || typeof route !== 'object') continue;
if (route.host_server === SERVER_ID) out[domain] = route;
}
return out;
}
function handleHealth(_req, res) {
jsonResponse(res, 200, {
ok: true,
service: 'channel-switcher',
server_id: SERVER_ID,
bind: BIND,
port: PORT,
manifest_path: MANIFEST_PATH,
manifest_loaded: !!manifestCache,
phase: 1,
capabilities: ['list', 'current', 'manifest'],
activate_supported: false
});
}
function handleList(_req, res) {
const manifest = loadManifest();
if (manifest._error) return jsonResponse(res, 500, { error: true, message: manifest._error });
const fns = serverFunctionsFromManifest(manifest).map((fn) => ({
id: fn.id,
display_name_zh: fn.display_name_zh,
aliases: fn.aliases || [],
domain: fn.domain,
description: fn.description,
status: fn.status,
keep_decision: fn.keep_decision || 'evaluate'
}));
logSyslog('list', { count: fns.length });
jsonResponse(res, 200, { server_id: SERVER_ID, count: fns.length, functions: fns });
}
function handleCurrent(_req, res) {
const manifest = loadManifest();
if (manifest._error) return jsonResponse(res, 500, { error: true, message: manifest._error });
const routes = activeRoutesForServer(manifest);
const fns = serverFunctionsFromManifest(manifest);
const fnById = Object.fromEntries(fns.map((f) => [f.id, f]));
const enriched = {};
for (const [domain, route] of Object.entries(routes)) {
const fn = fnById[route.active_fn];
enriched[domain] = {
active_fn: route.active_fn,
display_name_zh: fn ? fn.display_name_zh : null,
since: route.since || null,
status: fn ? fn.status : 'unknown'
};
}
logSyslog('current', { domains: Object.keys(enriched) });
jsonResponse(res, 200, { server_id: SERVER_ID, active_routes: enriched });
}
function handleManifest(_req, res) {
const manifest = loadManifest();
if (manifest._error) return jsonResponse(res, 500, { error: true, message: manifest._error });
// 精简版: 仅本服务器 + 必要字段, 不暴露 secrets 别名映射的全貌
const fns = serverFunctionsFromManifest(manifest).map((fn) => ({
id: fn.id,
display_name_zh: fn.display_name_zh,
aliases: fn.aliases || [],
domain: fn.domain,
description: fn.description,
created_for: fn.created_for,
status: fn.status,
keep_decision: fn.keep_decision || 'evaluate'
}));
jsonResponse(res, 200, {
server_id: SERVER_ID,
version: manifest.version,
updated_at: manifest.updated_at,
functions: fns,
active_routes: activeRoutesForServer(manifest)
});
}
function handleActivate(_req, res) {
// Phase 1 不支持真实切换. 返回 501 但带上未来形状, 让前端可以提前对接.
logSyslog('activate.rejected', { reason: 'phase-1-readonly' });
jsonResponse(res, 501, {
error: true,
code: 'not_implemented_phase_1',
message: '切换器当前为只读模式 (Phase 1). Phase 2 将启用 activate.',
future_shape: {
method: 'POST',
path: '/__switch/activate',
body: { fn_id: 'ZY-FN-XXXX', token: '<语言层令牌>' }
}
});
}
const ROUTES = {
'/__switch/health': handleHealth,
'/__switch/list': handleList,
'/__switch/current': handleCurrent,
'/__switch/manifest': handleManifest,
'/__switch/activate': handleActivate
};
const server = http.createServer((req, res) => {
const parsed = url.parse(req.url || '/', true);
const pathname = (parsed.pathname || '/').replace(/\/+$/, '') || '/';
const handler = ROUTES[pathname];
if (!handler) {
return jsonResponse(res, 404, {
error: true,
code: 'not_found',
message: `未知路径 ${pathname}`,
available: Object.keys(ROUTES)
});
}
if (pathname === '/__switch/activate' && req.method !== 'POST') {
return jsonResponse(res, 405, { error: true, code: 'method_not_allowed' });
}
if (pathname !== '/__switch/activate' && req.method !== 'GET') {
return jsonResponse(res, 405, { error: true, code: 'method_not_allowed' });
}
try {
handler(req, res);
} catch (err) {
jsonResponse(res, 500, { error: true, message: err.message });
}
});
if (require.main === module) {
server.listen(PORT, BIND, () => {
logSyslog('bootstrap', { bind: BIND, port: PORT, server_id: SERVER_ID, manifest: MANIFEST_PATH });
// eslint-disable-next-line no-console
console.log(`[channel-switcher] ${SERVER_ID} listening on ${BIND}:${PORT} · manifest: ${MANIFEST_PATH}`);
});
}
module.exports = { server, loadManifest, serverFunctionsFromManifest, activeRoutesForServer };