- Node minimum is 22 in package.json engines, the start scripts, the service installer, setup and doctor, matching the README, the release notes and the CI matrix (22 / 24). - .gitattributes: CSS, HTML, JSON, Markdown and YAML check out with LF, so `node --test` on Windows matches the CSS rules the UI tests read; the Windows test step is no longer continue-on-error. - CI: actions/checkout@v7 and actions/setup-node@v7 (Node 24 runtime).
241 lines
14 KiB
JavaScript
Executable File
241 lines
14 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
||
// tools/doctor.mjs — diagnose an install (docs/DEPLOY.md「排错」). Read-only: changes nothing.
|
||
//
|
||
// node tools/doctor.mjs [--port 3000] [--host 0.0.0.0]
|
||
//
|
||
// Checks: Node/npm versions, dependencies, public/vendor, data/*.json, downloaded art/audio, optional local-client art,
|
||
// Python (only needed for the optional extraction), the port (free / our server running → /healthz / another
|
||
// program), LAN addresses friends can use (virtual adapters and VPNs labelled), firewall hints per OS, tunnel tools
|
||
// (Tailscale, ZeroTier, cloudflared) and the env vars the server reads.
|
||
// Exit code 1 when something essential is missing (the same rule as tools/setup.mjs --check).
|
||
|
||
import http from 'node:http';
|
||
import net from 'node:net';
|
||
import os from 'node:os';
|
||
import path from 'node:path';
|
||
import fs from 'node:fs';
|
||
import { fileURLToPath } from 'node:url';
|
||
import {
|
||
ROOT, MIN_NODE, IS_WIN, IS_MAC, c, mark, capture, padDisplay, displayWidth,
|
||
checkNode, checkDeps, checkVendor, checkData, checkAssets, checkLocal, findClient, findPython,
|
||
} from './setup.mjs';
|
||
|
||
// ---------------------------------------------------------------------------------------------------
|
||
// LAN addresses (also used by scripts/launch.mjs)
|
||
// ---------------------------------------------------------------------------------------------------
|
||
|
||
const VIRTUAL_IF = /(vethernet|virtualbox|vmware|vmnet|docker|^br-|^veth|wsl|hyper-v|vboxnet|bridge\d|utun|awdl|llw|parallels|loopback)/i;
|
||
const VPN_IF = /(tailscale|zerotier|^zt|wireguard|^wg\d|tun\d|tap)/i;
|
||
|
||
function ipv4ToInt(ip) { return ip.split('.').reduce((n, x) => (n << 8) + Number(x), 0) >>> 0; }
|
||
function inCidr(ip, base, bits) { const mask = bits === 0 ? 0 : (~0 << (32 - bits)) >>> 0; return (ipv4ToInt(ip) & mask) === (ipv4ToInt(base) & mask); }
|
||
|
||
/**
|
||
* Classify every non-internal IPv4 address: 'lan' (RFC 1918, what friends at home use), 'vpn' (Tailscale / ZeroTier /
|
||
* CGNAT 100.64/10), 'virtual' (Hyper-V, WSL, Docker, VirtualBox … — usually not reachable from other machines),
|
||
* 'public' (a public address directly on this machine), 'linklocal' (169.254 — no DHCP, useless).
|
||
* @returns {{ name: string, address: string, kind: string }[]} best first
|
||
*/
|
||
export function classifyAddresses(ifaces = os.networkInterfaces()) {
|
||
const out = [];
|
||
for (const [name, addrs] of Object.entries(ifaces)) {
|
||
for (const a of addrs || []) {
|
||
if (!(a.family === 'IPv4' || a.family === 4) || a.internal) continue;
|
||
const ip = a.address;
|
||
let kind;
|
||
if (inCidr(ip, '169.254.0.0', 16)) kind = 'linklocal';
|
||
else if (VPN_IF.test(name) || inCidr(ip, '100.64.0.0', 10)) kind = 'vpn';
|
||
else if (VIRTUAL_IF.test(name)) kind = 'virtual';
|
||
else if (inCidr(ip, '10.0.0.0', 8) || inCidr(ip, '172.16.0.0', 12) || inCidr(ip, '192.168.0.0', 16)) kind = 'lan';
|
||
else kind = 'public';
|
||
out.push({ name, address: ip, kind });
|
||
}
|
||
}
|
||
const rank = { lan: 0, vpn: 1, public: 2, virtual: 3, linklocal: 4 };
|
||
return out.sort((x, y) => rank[x.kind] - rank[y.kind]);
|
||
}
|
||
|
||
export const KIND_LABEL = { lan: '局域网', vpn: 'VPN/Tailscale/ZeroTier', public: '公网 IP', virtual: '虚拟网卡(通常无法从别的电脑访问)', linklocal: '无效地址(未获取到 IP)' };
|
||
|
||
// ---------------------------------------------------------------------------------------------------
|
||
// Port probe
|
||
// ---------------------------------------------------------------------------------------------------
|
||
|
||
function getJson(url, timeoutMs = 1500) {
|
||
return new Promise((resolve) => {
|
||
const req = http.get(url, { timeout: timeoutMs }, (res) => {
|
||
let body = '';
|
||
res.setEncoding('utf8');
|
||
res.on('data', (d) => { body += d; if (body.length > 65536) req.destroy(); });
|
||
res.on('end', () => { let json = null; try { json = JSON.parse(body); } catch { /* not ours */ } resolve({ status: res.statusCode, json }); });
|
||
});
|
||
req.on('timeout', () => { req.destroy(); resolve({ error: 'timeout' }); });
|
||
req.on('error', (e) => resolve({ error: e.code || e.message }));
|
||
});
|
||
}
|
||
|
||
function canListen(port, host) {
|
||
return new Promise((resolve) => {
|
||
const srv = net.createServer();
|
||
srv.once('error', (e) => resolve({ ok: false, code: e.code }));
|
||
srv.listen({ port, host, exclusive: true }, () => srv.close(() => resolve({ ok: true })));
|
||
});
|
||
}
|
||
|
||
/** 'ours' (our server answers /healthz), 'free', 'busy' (another program) or 'denied'. */
|
||
export async function probePort(port, host = '0.0.0.0') {
|
||
const r = await getJson(`http://127.0.0.1:${port}/healthz`);
|
||
if (r.json && r.json.ok === true && 'uptimeSec' in r.json) return { state: 'ours', health: r.json };
|
||
const l = await canListen(port, host);
|
||
if (l.ok) return { state: 'free' };
|
||
if (l.code === 'EACCES') return { state: 'denied', code: l.code };
|
||
return { state: 'busy', code: l.code, http: r.status };
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------------------------------
|
||
// Firewall hints
|
||
// ---------------------------------------------------------------------------------------------------
|
||
|
||
function firewallHints(port) {
|
||
const lines = [];
|
||
if (IS_WIN) {
|
||
const rule = capture('netsh', ['advfirewall', 'firewall', 'show', 'rule', 'name=Stronghold Protocol'], { timeout: 10000 });
|
||
if (rule.ok) lines.push([mark.ok, '已存在防火墙入站规则「Stronghold Protocol」']);
|
||
else {
|
||
lines.push([mark.warn, `未找到规则「Stronghold Protocol」。朋友连不上时,用「管理员」PowerShell 运行:`]);
|
||
lines.push(['', c.cyan(`netsh advfirewall firewall add rule name="Stronghold Protocol" dir=in action=allow protocol=TCP localport=${port} profile=private,domain`)]);
|
||
lines.push(['', c.dim('(或首次启动时在 Windows 弹窗里勾选「专用网络」并允许 Node.js;scripts\\install-service-windows.ps1 也会自动添加)')]);
|
||
}
|
||
const prof = capture('powershell', ['-NoProfile', '-NonInteractive', '-Command',
|
||
"Get-NetConnectionProfile | ForEach-Object { $_.InterfaceAlias + '|' + $_.NetworkCategory }"], { timeout: 15000 });
|
||
if (prof.ok && prof.out) {
|
||
for (const l of prof.out.split(/\r?\n/).filter(Boolean)) {
|
||
const [alias, cat] = l.split('|');
|
||
if (/public/i.test(cat || '')) {
|
||
lines.push([mark.warn, `网络「${alias}」是「公用网络」:Windows 默认拦截公用网络的入站连接。家里的网络建议改为「专用」:`]);
|
||
lines.push(['', c.cyan(`Set-NetConnectionProfile -InterfaceAlias "${alias}" -NetworkCategory Private`) + c.dim('(管理员 PowerShell)')]);
|
||
} else lines.push([mark.ok, `网络「${alias}」类型:${cat}`]);
|
||
}
|
||
}
|
||
} else if (IS_MAC) {
|
||
const fw = capture('/usr/libexec/ApplicationFirewall/socketfilterfw', ['--getglobalstate'], { timeout: 5000 });
|
||
if (/enabled/i.test(fw.out) && !/disabled/i.test(fw.out)) {
|
||
lines.push([mark.warn, 'macOS 防火墙已开启:首次启动时请在弹窗中「允许」node 接受传入连接,或运行:']);
|
||
lines.push(['', c.cyan(`sudo /usr/libexec/ApplicationFirewall/socketfilterfw --add "${process.execPath}" --unblockapp "${process.execPath}"`)]);
|
||
} else if (fw.out) lines.push([mark.ok, 'macOS 防火墙未开启(局域网可直接访问)']);
|
||
} else {
|
||
lines.push([c.dim('i'), `Linux:若启用了 ufw/firewalld,请放行端口:sudo ufw allow ${port}/tcp 或 sudo firewall-cmd --add-port=${port}/tcp --permanent && sudo firewall-cmd --reload`]);
|
||
}
|
||
return lines;
|
||
}
|
||
|
||
function tool(cmd, args) {
|
||
const r = capture(cmd, args, { timeout: 5000, shell: IS_WIN });
|
||
return r.ok ? (r.out.split(/\r?\n/)[0] || 'ok') : null;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------------------------------
|
||
// main
|
||
// ---------------------------------------------------------------------------------------------------
|
||
|
||
function parseArgs(argv) {
|
||
const o = { port: Number(process.env.PORT) || 3000, host: process.env.HOST || '0.0.0.0', help: false };
|
||
for (let i = 0; i < argv.length; i++) {
|
||
const [k, v] = argv[i].split('=');
|
||
const val = () => (v !== undefined ? v : argv[++i]);
|
||
if (k === '--port') o.port = Number(val()) || o.port;
|
||
else if (k === '--host') o.host = val() || o.host;
|
||
else if (k === '-h' || k === '--help') o.help = true;
|
||
else throw new Error(`unknown option ${argv[i]}`);
|
||
}
|
||
return o;
|
||
}
|
||
|
||
async function main() {
|
||
let opts;
|
||
try { opts = parseArgs(process.argv.slice(2)); } catch (e) { console.error(e.message); return 2; }
|
||
if (opts.help) { console.log('node tools/doctor.mjs [--port 3000] [--host 0.0.0.0] — 只读诊断,不修改任何文件'); return 0; }
|
||
const rows = [];
|
||
let bad = false;
|
||
const row = (state, label, detail = '') => { rows.push([state, label, detail]); if (state === 'err') bad = true; };
|
||
const section = (title) => rows.push([null, title]);
|
||
|
||
console.log(c.bold('\n卫戍协议:盟约 · doctor') + c.dim(` ${os.type()} ${os.release()} ${process.arch} · ${ROOT}`));
|
||
|
||
section('运行环境');
|
||
const node = checkNode();
|
||
row(node.ok ? (node.recommended ? 'ok' : 'warn') : 'err', 'Node.js', `v${node.version}` + (node.ok ? (node.recommended ? '' : '(推荐 22 / 24 LTS)') : `(需要 ≥ ${MIN_NODE}:https://nodejs.org/zh-cn/download)`));
|
||
const npmV = tool(IS_WIN ? 'npm.cmd' : 'npm', ['--version']);
|
||
row(npmV ? 'ok' : 'warn', 'npm', npmV ? `v${npmV}` : '未找到(安装 Node.js 时会自带)');
|
||
|
||
section('安装');
|
||
const deps = checkDeps();
|
||
row(deps.ok ? 'ok' : 'err', '依赖 node_modules', deps.ok ? '' : `缺少 ${deps.missing.join(', ')} → npm install`);
|
||
const vendor = checkVendor();
|
||
row(vendor.ok ? 'ok' : 'err', '前端库 public/vendor', vendor.ok ? (vendor.optionalMissing.length ? 'three.js 缺失(3D 棋盘回退 2D)' : '') : `缺少 ${vendor.missing.join(', ')} → node tools/vendor.mjs`);
|
||
const data = checkData();
|
||
row(data.ok ? 'ok' : 'err', '游戏数据 data/*.json', data.ok ? '' : `缺少/损坏:${[...data.missing, ...data.broken].join(', ')}`);
|
||
const assets = checkAssets();
|
||
row(assets.ok ? 'ok' : 'warn', '美术/音频 public/assets', assets.ok ? `${assets.total} 个文件`
|
||
: !assets.present ? '未下载 → node tools/setup.mjs(游戏仍可运行,使用占位图)' : `缺 ${assets.missing}/${assets.total}(例:${assets.sample.join(' ')})→ node tools/setup.mjs`);
|
||
const fonts = fs.existsSync(path.join(ROOT, 'public', 'fonts', 'fonts.css'));
|
||
row(fonts ? 'ok' : 'warn', '字体 public/fonts', fonts ? '' : '未生成(随素材下载一起生成;缺失时用系统字体)');
|
||
const local = checkLocal();
|
||
const client = findClient(null);
|
||
row(local.manifest ? 'ok' : 'skip', '本地客户端美术(可选)', local.manifest
|
||
? `${local.count} 项${local.board3d ? ',3D 棋盘可用' : ',无棋盘贴图(2D 棋盘)'}${local.board3d && !local.tiles ? ';缺 tiles.json → node tools/setup.mjs' : ''}`
|
||
: client ? `检测到 ${client.kind} 客户端 → node tools/setup.mjs --local` : '未提取(不影响游戏)');
|
||
if (client || local.manifest) {
|
||
const py = findPython();
|
||
row(py ? 'ok' : 'skip', 'Python(仅提取用)', py ? `${py.cmd} ${py.version}` : '未找到 Python 3.8+');
|
||
}
|
||
|
||
section('服务器');
|
||
const port = await probePort(opts.port, opts.host);
|
||
if (port.state === 'ours') {
|
||
const h = port.health;
|
||
row('ok', `端口 ${opts.port}`, `服务器正在运行${h.app ? `(v${h.app})` : ''}:运行 ${h.uptimeSec}s · 房间 ${h.rooms ?? '?'} · 对局 ${h.matches ?? '?'} · 连接 ${h.sockets ?? '?'}`);
|
||
} else if (port.state === 'free') row('ok', `端口 ${opts.port}`, '空闲(服务器未运行;npm start 启动)');
|
||
else if (port.state === 'denied') row('err', `端口 ${opts.port}`, '没有权限监听(Linux 上 < 1024 的端口需要 root)→ 换一个 PORT');
|
||
else row('err', `端口 ${opts.port}`, `被其他程序占用(${port.code})→ 关闭它或换端口:${IS_WIN ? '$env:PORT=3001; npm start' : 'PORT=3001 npm start'}`);
|
||
const env = ['PORT', 'HOST', 'SP_COMBAT', 'SP_VERIFY', 'TRUST_PROXY', 'DEBUG'].filter((k) => process.env[k] != null && process.env[k] !== '');
|
||
row('skip', '环境变量', env.length ? env.map((k) => `${k}=${process.env[k]}`).join(' ') : '全部默认(PORT=3000 HOST=0.0.0.0 SP_COMBAT=client SP_VERIFY=off)');
|
||
|
||
section('朋友如何访问');
|
||
const addrs = classifyAddresses();
|
||
if (!addrs.length) row('warn', '网络', '没有可用的 IPv4 地址(未联网?)');
|
||
for (const a of addrs) {
|
||
const usable = a.kind === 'lan' || a.kind === 'vpn' || a.kind === 'public';
|
||
row(usable ? 'ok' : 'skip', `http://${a.address}:${opts.port}`, `${KIND_LABEL[a.kind]} · ${a.name}`);
|
||
}
|
||
if (opts.host !== '0.0.0.0' && opts.host !== '::') row('warn', 'HOST', `HOST=${opts.host}:只监听这个地址,其他电脑可能连不上(默认 0.0.0.0)`);
|
||
|
||
section('防火墙');
|
||
for (const [m, text] of firewallHints(opts.port)) rows.push([m === '' ? 'raw' : 'mark', text, '', m]);
|
||
|
||
section('公网联机工具(可选)');
|
||
const ts = tool('tailscale', ['version']);
|
||
const zt = tool('zerotier-cli', ['-v']);
|
||
const cf = tool('cloudflared', ['--version']);
|
||
row(ts ? 'ok' : 'skip', 'Tailscale', ts || '未安装(推荐:https://tailscale.com/download )');
|
||
row(zt ? 'ok' : 'skip', 'ZeroTier', zt || '未安装');
|
||
row(cf ? 'ok' : 'skip', 'cloudflared', cf || '未安装(临时公网链接:cloudflared tunnel --url http://localhost:' + opts.port + ')');
|
||
|
||
// print
|
||
const width = Math.max(...rows.filter((r) => r[0] && r[0] !== 'raw' && r[0] !== 'mark').map((r) => displayWidth(r[1]))) + 2;
|
||
for (const r of rows) {
|
||
if (r[0] === null) { console.log(c.bold(`\n${r[1]}`)); continue; }
|
||
if (r[0] === 'raw') { console.log(` ${r[1]}`); continue; }
|
||
if (r[0] === 'mark') { console.log(`${r[3]} ${r[1]}`); continue; }
|
||
console.log(`${mark[r[0]]} ${padDisplay(r[1], width)}${r[2] ? c.dim(r[2]) : ''}`);
|
||
}
|
||
console.log(bad ? c.err('\n有必须解决的问题(✘)。') + ' 大多数情况运行 node tools/setup.mjs 即可修复。' : c.ok('\n基本环境正常。'));
|
||
return bad ? 1 : 0;
|
||
}
|
||
|
||
function isMain() {
|
||
try { return !!process.argv[1] && fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url)); } catch { return false; }
|
||
}
|
||
|
||
if (isMain()) main().then((code) => { process.exitCode = code; }, (e) => { console.error(e?.stack || e); process.exitCode = 1; });
|