Files
ark-stronghold-protocol/tools/doctor.mjs
T

241 lines
14 KiB
JavaScript
Executable File
Raw 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 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 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; });