Files

310 lines
19 KiB
JavaScript
Executable File
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.
#!/usr/bin/env node
// tools/doctor.mjs — diagnose an install (docs/DEPLOY.md「排错」). Read-only: changes nothing.
//
// node tools/doctor.mjs [--port 3000] [--host ::]
//
// Checks: Node/npm versions, dependencies, public/vendor, data/*.json, the shipped files against the release's
// MANIFEST.json (server/update.js checkInstall; a source checkout has none) and an update package not applied yet,
// 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,
LOCAL_ART_FALLBACK, LOCAL_ART_COPY_HINT,
} from './setup.mjs';
import { checkInstall, MANIFEST_FILE, UPDATE_FILE } from '../server/update.js';
// ---------------------------------------------------------------------------------------------------
// LAN addresses (also used by scripts/launch.mjs)
// ---------------------------------------------------------------------------------------------------
// 名字里带这些的网卡不对局域网开放:虚拟机 / 容器 / WSL / 代理软件的 TUN 适配器(Mihomo、Clash)。
// tun0 / tap0 不归这一类:classifyAddresses 先判下面的 VPN_IF,它的 `tun\d` / `tap` 是子串匹配,
// tun0 / tap0 先被它命中,归为 vpn(那正是同组好友互连用的地址)。
const VIRTUAL_IF = /(vethernet|virtualbox|vmware|vmnet|docker|^br-|^veth|wsl|hyper-v|vboxnet|bridge\d|utun|awdl|llw|parallels|loopback|mihomo|clash|sing-?box)/i;
// 点对点 VPN:这些地址就是同组好友互相访问用的(Tailscale / ZeroTier / WireGuard / Radmin VPN / Hamachi)。
const VPN_IF = /(tailscale|zerotier|^zt|wireguard|^wg\d|tun\d|tap|radmin|hamachi)/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); }
/** The first two hextets of an IPv6 address (a zone id is dropped); null when it cannot be parsed. @param {string} ip */
function ipv6Head(ip) {
const [a = '', b = ''] = String(ip).split('%')[0].split(':');
const h1 = parseInt(a, 16);
if (!Number.isInteger(h1)) return null;
const h2 = parseInt(b, 16);
return [h1, Number.isInteger(h2) ? h2 : 0];
}
/** An http URL for one address. An IPv6 literal needs brackets (`http://[240e:…]:3000`). Also used by scripts/launch.mjs. */
export function hostUrl(address, port) {
return `http://${String(address).includes(':') ? `[${address}]` : address}:${port}`;
}
/** IPv6 counterpart of the IPv4 chain (same order, same kinds). @param {string} name @param {string} ip */
function classifyV6(name, ip) {
const head = ipv6Head(ip);
if (!head) return 'virtual';
const [h1, h2] = head;
// fe80::/10 needs a zone id (%12 / %eth0) that a URL cannot carry, so it is no use to a friend.
if ((h1 & 0xffc0) === 0xfe80) return 'linklocal';
// 2002::/16 (6to4) and 2001:db8::/32 (documentation) are never an address to hand out.
if (h1 === 0x2002 || (h1 === 0x2001 && h2 === 0x0db8)) return 'virtual';
if (VPN_IF.test(name)) return 'vpn';
if (VIRTUAL_IF.test(name)) return 'virtual';
if ((h1 & 0xfe00) === 0xfc00) return 'lan'; // fc00::/7 ULA
if ((h1 & 0xe000) === 0x2000) return 'public'; // 2000::/3 global unicast
return 'virtual';
}
/**
* Classify every non-internal address — IPv4 and IPv6 — as 'lan' (RFC 1918 / ULA fc00::/7), 'vpn' (Tailscale /
* ZeroTier / Radmin / Hamachi / CGNAT 100.64/10), 'virtual' (Hyper-V, WSL, Docker, a Clash/Mihomo TUN adapter),
* 'public' (an address on this machine that is not one of those; an IPv6 global unicast is the usual home-server
* way in) or 'linklocal' (169.254 / fe80::).
* @returns {{ name: string, address: string, kind: string }[]} best first
*/
export function classifyAddresses(ifaces = os.networkInterfaces()) {
const out = [];
const v6Seen = new Set(); // privacy extensions put several addresses of one /64 on a machine — one entry is enough
for (const [name, addrs] of Object.entries(ifaces)) {
for (const a of addrs || []) {
if (a.internal) continue;
const ip = a.address;
if (a.family === 'IPv4' || a.family === 4) {
let kind;
if (inCidr(ip, '169.254.0.0', 16)) kind = 'linklocal';
// 198.18.0.0/15 是 RFC 2544 的基准测试段:代理软件(Clash / Mihomo 的 fake-ip 池)拿它做本地 TUN 地址,
// 绝对不是能发给朋友的「公网 IP」。
else if (inCidr(ip, '198.18.0.0', 15)) kind = 'virtual';
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 });
continue;
}
if (a.family !== 'IPv6' && a.family !== 6) continue;
const kind = classifyV6(name, ip);
if (kind === 'linklocal' || kind === 'virtual') { out.push({ name, address: ip, kind }); continue; }
const prefix = ip.split('%')[0].split(':').slice(0, 4).join(':');
if (v6Seen.has(`${name}|${prefix}`)) continue;
v6Seen.add(`${name}|${prefix}`);
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 = '::') {
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 };
let l = await canListen(port, host);
// The same three errors startServer treats as "this host has no IPv6": don't report the port busy for that.
if (!l.ok && host === '::' && ['EAFNOSUPPORT', 'EADDRNOTAVAIL', 'EINVAL'].includes(l.code)) l = await canListen(port, '0.0.0.0');
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 || '::', 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 ::] — 只读诊断,不修改任何文件'); 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(', ')}`);
// the release's file list (every package has one; art is setup's and is checked below)
const inst = checkInstall(ROOT);
const instLabel = `文件校验 ${MANIFEST_FILE}`;
if (inst.state === 'none') row('skip', instLabel, '没有:源码目录,不校验');
else if (inst.state === 'broken') row('warn', instLabel, `无法读取(${inst.error})→ 重新解压同一版本的整合包`);
else if (inst.state === 'ok') row('ok', instLabel, `${inst.checked} 个文件与 v${inst.app} 一致`);
else {
const bad = [...inst.runtime, ...inst.other];
const eg = bad.slice(0, 3).join(' ') + (bad.length > 3 ? ' …' : '');
row(inst.runtime.length ? 'err' : 'warn', instLabel, `${bad.length} 个文件与 v${inst.app} 不一致或缺失(例:${eg})`
+ (inst.runtime.length ? ` → 重新解压 v${inst.app} 的完整包(或它的更新包)` : ':只是说明 / 脚本文件,不影响运行'));
}
if (inst.pending) row('warn', `更新包 ${UPDATE_FILE}`, '已解压、还没有应用:下次启动服务器时自动应用');
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.scenes ? ',已提取各战场官方场景' : ',无各战场官方场景(3D 棋盘用金属地块;node tools/setup.mjs --scenes)'}${local.board3d && !local.tiles ? ';缺 tiles.json → node tools/setup.mjs' : ''}`
: client ? `检测到 ${client.kind} 客户端 → node tools/setup.mjs --local` : `未提取:${LOCAL_ART_FALLBACK}(${LOCAL_ART_COPY_HINT})`);
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=:: SP_COMBAT=client SP_VERIFY=off)');
section('朋友如何访问');
const addrs = classifyAddresses();
if (!addrs.length) row('warn', '网络', '没有可用的地址(未联网?)');
for (const a of addrs) {
const usable = a.kind === 'lan' || a.kind === 'vpn' || a.kind === 'public';
// An IPv6 literal needs the brackets of hostUrl(): `http://240e:…:3000` is not a URL anyone can open.
row(usable ? 'ok' : 'skip', hostUrl(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}:只监听这个地址,其他电脑可能连不上(默认 :: 双栈)`);
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; });