/** * ═══════════════════════════════════════════════════════════ * 🔌 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 };