diff --git a/README.md b/README.md index 70170017..c4390d3c 100644 --- a/README.md +++ b/README.md @@ -118,7 +118,7 @@ npm start # 启动服务器:http://localhost:3000 | 环境变量 | 默认 | 说明 | |---|---|---| | `PORT` | `3000` | 监听端口 | -| `HOST` | `0.0.0.0` | 监听地址(`127.0.0.1` = 只允许本机,放在反向代理后面时使用) | +| `HOST` | `::` | 监听地址。默认 `::` 是双栈:同一个端口同时接受 IPv6 和 IPv4;`0.0.0.0` = 只 IPv4;`127.0.0.1` = 只允许本机,放在反向代理后面时使用 | | `SP_COMBAT` | `client` | `client`:各玩家浏览器模拟自己的战斗(服务器负载极低);`server`:由服务器模拟并推流 | | `SP_VERIFY` | `off` | 服务器复算客户端上报的战斗结果:`off` / `sample`(约 1/8 抽查)/ `all`(全部复算,更耗 CPU) | | `TRUST_PROXY` | `auto` | 是否信任 `X-Forwarded-For` 等转发头:`auto` 只信任来自本机 / 内网的代理;`1` 总是;`0` 从不 | diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md index ea375c9c..bc7e5ce5 100644 --- a/docs/DEPLOY.md +++ b/docs/DEPLOY.md @@ -106,7 +106,7 @@ powershell -ExecutionPolicy Bypass -File scripts\install-service-windows.ps1 winget install NSSM.NSSM # 或从 https://nssm.cc 下载 nssm install StrongholdProtocol "C:\Program Files\nodejs\node.exe" server\index.js nssm set StrongholdProtocol AppDirectory C:\Stronghold-Protocol -nssm set StrongholdProtocol AppEnvironmentExtra PORT=3000 HOST=0.0.0.0 +nssm set StrongholdProtocol AppEnvironmentExtra PORT=3000 HOST=:: nssm set StrongholdProtocol AppStdout C:\Stronghold-Protocol\logs\server.log nssm set StrongholdProtocol AppStderr C:\Stronghold-Protocol\logs\server.log nssm start StrongholdProtocol @@ -266,7 +266,7 @@ services: [Service] WorkingDirectory=/opt/Stronghold-Protocol ExecStart=/usr/bin/node server/index.js - Environment=PORT=3000 HOST=0.0.0.0 + Environment=PORT=3000 HOST=:: Restart=always RestartSec=5 User=stronghold diff --git a/scripts/install-service-windows.ps1 b/scripts/install-service-windows.ps1 index 6b11443e..6696a4bd 100644 --- a/scripts/install-service-windows.ps1 +++ b/scripts/install-service-windows.ps1 @@ -13,7 +13,7 @@ #> param( [int]$Port = 3000, - [string]$BindHost = '0.0.0.0', + [string]$BindHost = '::', [ValidateSet('client', 'server')][string]$Combat = 'client', [ValidateSet('off', 'sample', 'all')][string]$Verify = 'off', [string]$TaskName = 'StrongholdProtocol', @@ -162,8 +162,10 @@ try { Start-ScheduledTask -TaskName $TaskName -ErrorAction Stop Start-Sleep -Seconds 4 Show-Status - Write-Host "`n朋友访问地址(局域网):" - & $nodeExe tools\doctor.mjs --port $Port | Select-String -Pattern 'http://\d' | ForEach-Object { Write-Host " $($_.Line.Trim())" } + Write-Host "`n朋友访问地址(局域网 / IPv6,方括号不能少):" + # doctor prints a full URL. An IPv6 literal is bracketed, so a pattern that requires a digit right after http:// + # would drop it. Keep every http:// line; the rest of the report is classification text. + & $nodeExe tools\doctor.mjs --port $Port | Select-String -Pattern 'http://' | ForEach-Object { Write-Host " $($_.Line.Trim())" } Write-Host "`n停止:-Stop 重启:-Restart 状态:-Status 卸载:-Uninstall 日志:$Log" } catch { Write-Host "`n出错:$($_.Exception.Message)" -ForegroundColor Red diff --git a/scripts/launch.mjs b/scripts/launch.mjs index b4eaf1c4..0ceab47c 100755 --- a/scripts/launch.mjs +++ b/scripts/launch.mjs @@ -2,7 +2,7 @@ // scripts/launch.mjs — cross-platform "prepare + start + open the browser", used by scripts/start-windows.bat, // scripts/start-windows.ps1 and scripts/start.sh (docs/DEPLOY.md). // -// node scripts/launch.mjs [--port 3000] [--host 0.0.0.0] [--no-open] [--no-setup] [setup options…] +// node scripts/launch.mjs [--port 3000] [--host ::] [--no-open] [--no-setup] [setup options…] // // 1. If our server already answers on the port, just open the browser (double-clicking twice is harmless). // 2. An update package extracted over the folder (UPDATE.json) is finished (server/update.js; the server does it too, @@ -30,11 +30,11 @@ if (Number(process.versions.node.split('.')[0]) < 22) { } const { c, mark } = await import('../tools/setup.mjs'); -const { probePort, classifyAddresses, KIND_LABEL } = await import('../tools/doctor.mjs'); +const { probePort, classifyAddresses, hostUrl, KIND_LABEL } = await import('../tools/doctor.mjs'); const { applyPendingUpdate, UPDATE_FILE } = await import('../server/update.js'); function parseArgs(argv) { - const o = { port: Number(process.env.PORT) || 3000, host: process.env.HOST || '0.0.0.0', open: !/^(1|true|yes)$/i.test(process.env.SP_NO_BROWSER || ''), setup: true, setupArgs: [], help: false }; + const o = { port: Number(process.env.PORT) || 3000, host: process.env.HOST || '::', open: !/^(1|true|yes)$/i.test(process.env.SP_NO_BROWSER || ''), setup: true, setupArgs: [], help: false }; for (let i = 0; i < argv.length; i++) { const a = argv[i]; const [k, v] = a.split('='); @@ -61,9 +61,9 @@ function printShare(port) { console.log(`${mark.ok} ${c.bold('服务器已启动')} 本机打开:${c.cyan(`http://localhost:${port}`)}`); if (addrs.length) { console.log(' 发给朋友(需要能访问这台电脑的网络):'); - for (const a of addrs.slice(0, 4)) console.log(` ${c.cyan(`http://${a.address}:${port}`)} ${c.dim(KIND_LABEL[a.kind])}`); + for (const a of addrs.slice(0, 4)) console.log(` ${c.cyan(hostUrl(a.address, port))} ${c.dim(KIND_LABEL[a.kind])}`); } else { - console.log(c.warn(' 没有检测到局域网地址:朋友暂时无法连接(检查网线/Wi-Fi)。')); + console.log(c.warn(' 没有检测到可以分享的地址:朋友暂时无法连接(检查网线/Wi-Fi)。')); } console.log(c.dim(' 建房后把 4 位「同盟密钥」或「复制链接」(…/?room=密钥)发给朋友。')); console.log(c.dim(' 朋友打不开?运行 node tools/doctor.mjs 检查防火墙。按 Ctrl+C 停止服务器。')); diff --git a/scripts/run-server.cmd b/scripts/run-server.cmd index 7ae24ba7..ccdc8f77 100644 --- a/scripts/run-server.cmd +++ b/scripts/run-server.cmd @@ -5,6 +5,8 @@ rem Output goes to logs\server.log (rotated at 10 MB to logs\server.old.log); th setlocal EnableExtensions cd /d "%~dp0.." set "NODE_EXE=node" +rem Dual-stack default (IPv6 and IPv4 on one socket). scripts\service.env.cmd may override HOST. +set "HOST=::" if exist "%~dp0service.env.cmd" call "%~dp0service.env.cmd" if not exist "logs" mkdir "logs" diff --git a/server/http/boot.js b/server/http/boot.js index f1d3eb0a..ede2d96f 100644 --- a/server/http/boot.js +++ b/server/http/boot.js @@ -14,18 +14,43 @@ import fs from 'node:fs'; import os from 'node:os'; import { fileURLToPath } from 'node:url'; import { APP_VERSION, DEV_BUILD } from '../../shared/constants.js'; +import { limitKeyOf } from '../net.js'; import { applyPendingUpdate } from '../update.js'; import { ROOT } from './config.js'; -/** Non-internal IPv4 addresses as http URLs. @param {number} port */ +/** + * Non-internal addresses as http URLs — IPv4 first, then IPv6 (an IPv6 literal needs brackets: `http://[240e:…]:3000`). + * Link-local (`fe80::`) is left out: a URL cannot carry the zone id. Several privacy addresses in one /64 collapse + * to one URL (`limitKeyOf`). + * @param {number} port + */ export function lanUrls(port) { - const out = []; + const v4 = []; + const v6 = []; + const v6Seen = new Set(); for (const addrs of Object.values(os.networkInterfaces())) { for (const a of addrs || []) { - if ((a.family === 'IPv4' || a.family === 4) && !a.internal) out.push(`http://${a.address}:${port}`); + if (a.internal) continue; + if (a.family === 'IPv4' || a.family === 4) { v4.push(`http://${a.address}:${port}`); continue; } + if (a.family !== 'IPv6' && a.family !== 6) continue; + if (/^fe80:/i.test(a.address)) continue; + const prefix = limitKeyOf(a.address); + if (v6Seen.has(prefix)) continue; + v6Seen.add(prefix); + v6.push(`http://[${a.address}]:${port}`); } } - return out; + return [...v4, ...v6]; +} + +/** + * The host as it appears in the local URL. A wildcard bind (`0.0.0.0` or `::`) reads `localhost`. An IPv6 literal + * is bracketed (`http://[2001:db8::1]:3000`); an IPv4 address or a name is kept. + * @param {string} host + */ +export function displayHost(host) { + if (host === '0.0.0.0' || host === '::') return 'localhost'; + return host.includes(':') ? `[${host}]` : host; } /** Is the module whose `import.meta.url` is `metaUrl` the file node was started with? */ diff --git a/server/http/config.js b/server/http/config.js index d512a142..3d1bd123 100644 --- a/server/http/config.js +++ b/server/http/config.js @@ -1,6 +1,6 @@ // server/http/config.js — where the server's settings come from. startServer() options win over the environment: // -// * PORT (default 3000), HOST (default 0.0.0.0); +// * PORT (default 3000), HOST (default '::', one dual-stack socket for IPv6 and IPv4); // * TRUST_PROXY ('auto' default: honour CF-Connecting-IP / X-Real-IP / X-Forwarded-For only from loopback/private // peers such as a local cloudflared; '1' always; '0' never) → net.js trustProxy; // * DEBUG → the console logger's debug level; @@ -21,18 +21,36 @@ const NET_OPTION_KEYS = ['reconnectWindowMs', 'heartbeatMs', 'helloTimeoutMs', ' const LOBBY_OPTION_KEYS = ['lobbyGraceMs', 'maxRooms', 'maxRoomsPerAddr', 'maxMatchesPerAddr', 'resyncMinGapMs', 'soloReconnectWindowMs']; /** - * Where to listen: the `port` / `host` options, else PORT / HOST, else port 3000 on 0.0.0.0. + * Bind address used when neither the `host` option nor `HOST` says otherwise: one dual-stack socket, so the server + * answers IPv6 and IPv4 alike without a second listener (Node keeps `ipv6Only` off for `::`). + * `HOST=0.0.0.0` still means IPv4 only, `HOST=127.0.0.1` still means loopback only (a reverse proxy in front). + */ +export const DEFAULT_BIND_HOST = '::'; + +/** + * Where to listen: the `port` / `host` options, else PORT / HOST, else port 3000 on DEFAULT_BIND_HOST. + * An empty host is treated as unset. * @param {{ port?: number, host?: string }} opts * @returns {{ port: number, host: string }} * @throws {RangeError} when the port is not an integer in 0…65535 */ export function listenAddress(opts) { const port = opts.port ?? (process.env.PORT != null && process.env.PORT !== '' ? Number(process.env.PORT) : 3000); - const host = opts.host ?? process.env.HOST ?? '0.0.0.0'; + const host = (opts.host || process.env.HOST) || DEFAULT_BIND_HOST; if (!Number.isInteger(port) || port < 0 || port > 65535) throw new RangeError(`invalid PORT ${port}`); return { port, host }; } +/** + * The hosts to try, in order, for a bind. Only the default is retried: a host with IPv6 switched off refuses `::` + * with EAFNOSUPPORT / EADDRNOTAVAIL / EINVAL, and falling back to IPv4 beats not booting. An explicit HOST is literal. + * @param {string} host a host out of listenAddress() + * @returns {string[]} + */ +export function bindCandidates(host) { + return host === DEFAULT_BIND_HOST ? [DEFAULT_BIND_HOST, '0.0.0.0'] : [host]; +} + /** * The directories the static server reads (packsDir: the pack folders, server/packs.js). * @param {{ publicDir?: string, dataDir?: string, sharedDir?: string, packsDir?: string }} opts diff --git a/server/index.js b/server/index.js index 440d2664..2543c3de 100644 --- a/server/index.js +++ b/server/index.js @@ -1,7 +1,7 @@ // server/index.js — process entry & boot (DESIGN §1, §2). Plain node:http + ws, no framework: startServer() below // wires the modules under server/http/, in this order — // -// http/config.js ROOT, the served directories, the environment (PORT 3000, HOST 0.0.0.0, TRUST_PROXY auto, DEBUG), +// http/config.js ROOT, the served directories, the environment (PORT 3000, HOST '::' dual-stack, TRUST_PROXY auto, DEBUG), // which startServer() options go to net.js / lobby.js, the console logger // http/websocket.js session wiring (SessionRegistry → Lobby → Network) and the WebSocket at /ws (maxPayload 64 KB; // refused at upgrade with 404 / 429 per network / 503) @@ -24,7 +24,7 @@ import http from 'node:http'; import { getData, loadData } from './data.js'; -import { ROOT, listenAddress, serveDirs, makeLogger, parseTrustProxy } from './http/config.js'; +import { ROOT, listenAddress, bindCandidates, serveDirs, makeLogger, parseTrustProxy } from './http/config.js'; import { WS_MAX_PAYLOAD, createSessionStack, attachWebSocket } from './http/websocket.js'; import { DATA_SHIM_JS, createStaticHandler } from './http/static.js'; import { createPackRegistry } from './packs.js'; @@ -32,7 +32,7 @@ import { MIME, COMPRESSIBLE, acceptsGzip, parseRange } from './http/files.js'; import { BUILD_INPUTS, computeBuildTag, buildTag, resetBuildTag } from './http/buildTag.js'; import { createRequestHandler } from './http/routes.js'; import { answerClientError } from './http/common.js'; -import { lanUrls, isProcessEntry, runMain } from './http/boot.js'; +import { lanUrls, displayHost, isProcessEntry, runMain } from './http/boot.js'; // The public API of this module (tests and tools import it from here); the code lives in ./http/. export { @@ -77,14 +77,35 @@ export async function startServer(opts = {}) { server.on('clientError', answerClientError); const wss = attachWebSocket(server, { network, log }); + // The address actually bound. The default may fall back to IPv4; the returned host and url follow that. + let boundHost; try { - await new Promise((resolve, reject) => { - const onError = (e) => { server.off('listening', onListening); reject(e); }; - const onListening = () => { server.off('error', onError); resolve(); }; - server.once('error', onError); - server.once('listening', onListening); - server.listen(port, host); - }); + // A host with IPv6 switched off refuses '::'. Fall back to IPv4 rather than not booting. Only the default is + // retried: an explicit HOST is literal (server/http/config.js bindCandidates). + let bound = null; + let lastError = null; + const candidates = bindCandidates(host); + for (let i = 0; i < candidates.length; i++) { + const candidate = candidates[i]; + try { + await new Promise((resolve, reject) => { + const onError = (e) => { server.off('listening', onListening); reject(e); }; + const onListening = () => { server.off('error', onError); resolve(); }; + server.once('error', onError); + server.once('listening', onListening); + server.listen(port, candidate); + }); + bound = candidate; + break; + } catch (e) { + lastError = e; + const retry = ['EAFNOSUPPORT', 'EADDRNOTAVAIL', 'EINVAL'].includes(e.code) && i < candidates.length - 1; + if (!retry) break; + log.warn(`[boot] cannot bind ${candidate} (${e.code}) — falling back to IPv4 only`); + } + } + if (bound === null) throw lastError; + boundHost = bound; } catch (e) { network.close(); // stop heartbeat/sweep timers of the half-built server throw e; @@ -93,7 +114,7 @@ export async function startServer(opts = {}) { const addr = server.address(); const actualPort = typeof addr === 'object' && addr ? addr.port : port; - const url = `http://${host === '0.0.0.0' || host === '::' ? 'localhost' : host}:${actualPort}`; + const url = `http://${displayHost(boundHost)}:${actualPort}`; let closing = null; async function close() { @@ -111,7 +132,7 @@ export async function startServer(opts = {}) { return closing; } - return { port: actualPort, host, url, server, wss, lobby, network, registry, packs, close }; + return { port: actualPort, host: boundHost, url, server, wss, lobby, network, registry, packs, close }; } // `node server/index.js` / npm start: listen, print the banner, stop on SIGINT / SIGTERM (http/boot.js). diff --git a/test/ipv6-bind.test.js b/test/ipv6-bind.test.js new file mode 100644 index 00000000..a3b0c434 --- /dev/null +++ b/test/ipv6-bind.test.js @@ -0,0 +1,208 @@ +// test/ipv6-bind.test.js — dual-stack listen (accepted as #188, ported from PR #291 without /dev/grant, +// the settings migration, the firewall scripts, or a Dockerfile HOST change). +// +// With HOST unset the server binds '::' (Node leaves ipv6Only off, so one socket answers IPv6 and IPv4). +// That default is the only host retried, and only on EAFNOSUPPORT / EADDRNOTAVAIL / EINVAL. An explicit HOST, +// including 0.0.0.0 and 127.0.0.1, is tried once. The local URL brackets an IPv6 literal. The image stays on +// 0.0.0.0 until an alpine boot of the fallback has actually been run. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import http from 'node:http'; +import net from 'node:net'; +import { readFileSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { classifyAddresses, hostUrl } from '../tools/doctor.mjs'; +import { DEFAULT_BIND_HOST, listenAddress, bindCandidates } from '../server/http/config.js'; +import { displayHost } from '../server/http/boot.js'; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); +const doc = (p) => readFileSync(join(ROOT, p), 'utf8'); + +function restoreHost(saved) { + if (saved === undefined) delete process.env.HOST; + else process.env.HOST = saved; +} + +test('the listen entry points default to ::, and the image stays on IPv4', () => { + assert.match(doc('server/http/config.js'), /export const DEFAULT_BIND_HOST = '::';/); + assert.match(doc('server/http/config.js'), /opts\.host \|\| process\.env\.HOST\) \|\| DEFAULT_BIND_HOST/); + assert.match(doc('server/http/boot.js'), /for \(const u of lanUrls\(srv\.port\)\)/); + assert.match(doc('scripts/run-server.cmd'), /^\s*set "HOST=::"$/m); + assert.match(doc('scripts/install-service-windows.ps1'), /\[string\]\$BindHost = '::',/); + assert.match(doc('scripts/install-service-windows.ps1'), /Select-String -Pattern 'http:\/\/'/); + assert.doesNotMatch(doc('scripts/install-service-windows.ps1'), /http:\/\/\\d/); + assert.match(doc('scripts/launch.mjs'), /process\.env\.HOST \|\| '::'/); + assert.match(doc('tools/doctor.mjs'), /process\.env\.HOST \|\| '::'/); + assert.match(doc('README.md'), /\| `HOST` \| `::` \|/); + assert.match(doc('Dockerfile'), /HOST=0\.0\.0\.0/); + assert.doesNotMatch(doc('Dockerfile'), /HOST=::/); +}); + +test('listenAddress: the option wins over the environment, the environment over the default', () => { + const saved = process.env.HOST; + try { + delete process.env.HOST; + assert.deepEqual(listenAddress({}), { port: 3000, host: DEFAULT_BIND_HOST }); + assert.equal(listenAddress({ host: '' }).host, DEFAULT_BIND_HOST, 'an empty host is no host'); + process.env.HOST = '127.0.0.1'; + assert.equal(listenAddress({}).host, '127.0.0.1', 'HOST is kept when it is set'); + assert.equal(listenAddress({ host: '0.0.0.0' }).host, '0.0.0.0', 'the option wins over HOST'); + } finally { + restoreHost(saved); + } + assert.equal(listenAddress({ port: 0 }).port, 0, 'port 0 is a real port (an ephemeral one)'); + assert.equal(listenAddress({ host: '127.0.0.1' }).host, '127.0.0.1'); + assert.throws(() => listenAddress({ port: 70000 }), RangeError); +}); + +test('bindCandidates: only the default is retried, an explicit host is literal', () => { + assert.deepEqual(bindCandidates(DEFAULT_BIND_HOST), ['::', '0.0.0.0']); + assert.deepEqual(bindCandidates('0.0.0.0'), ['0.0.0.0']); + assert.deepEqual(bindCandidates('127.0.0.1'), ['127.0.0.1']); + assert.deepEqual(bindCandidates('192.168.1.7'), ['192.168.1.7']); + assert.deepEqual(bindCandidates('2001:db8::1'), ['2001:db8::1']); +}); + +test('an IPv6 literal is bracketed in a URL, a wildcard bind reads localhost', () => { + assert.equal(hostUrl('192.168.1.7', 3000), 'http://192.168.1.7:3000'); + assert.equal(hostUrl('100.64.0.9', 3000), 'http://100.64.0.9:3000'); + assert.equal(hostUrl('240e:3b7:8c4:40f0::1000', 3000), 'http://[240e:3b7:8c4:40f0::1000]:3000'); + assert.equal(hostUrl('fe80::320d:9eff:fe07:e79a', 8080), 'http://[fe80::320d:9eff:fe07:e79a]:8080'); + assert.equal(displayHost('0.0.0.0'), 'localhost'); + assert.equal(displayHost('::'), 'localhost'); + assert.equal(displayHost('::1'), '[::1]'); + assert.equal(displayHost('2001:db8::1'), '[2001:db8::1]'); + assert.equal(displayHost('127.0.0.1'), '127.0.0.1'); +}); + +test('classifyAddresses: IPv6 kinds, and one entry per /64', () => { + const v6 = (name, ...addresses) => [name, addresses.map((address) => ({ family: 'IPv6', address, internal: false }))]; + const list = classifyAddresses(Object.fromEntries([ + v6('以太网', 'fe80::1', '240e:3b7:8c4:40f0:bb2b:c23f:a265:a95d', '240e:3b7:8c4:40f0:7c4a:8d15:f95:fc16', 'fd00::5'), + v6('vEthernet (Default Switch)', '2001:db8::1', '2002:c0a8:101::1'), + v6('Tailscale', 'fd7a:115c:a1e0::1'), + ['Loopback Pseudo-Interface 1', [{ family: 'IPv6', address: '::1', internal: true }]], + ])); + const kindOf = (address) => list.find((a) => a.address === address)?.kind; + assert.equal(kindOf('240e:3b7:8c4:40f0:bb2b:c23f:a265:a95d'), 'public'); + assert.equal(kindOf('fe80::1'), 'linklocal'); + assert.equal(kindOf('fd00::5'), 'lan'); + assert.equal(kindOf('fd7a:115c:a1e0::1'), 'vpn'); + assert.equal(kindOf('2001:db8::1'), 'virtual'); + assert.equal(kindOf('2002:c0a8:101::1'), 'virtual'); + assert.equal(list.filter((a) => a.kind === 'public').length, 1, 'the two addresses of one /64 collapse into one'); + assert.ok(!list.some((a) => a.address === '::1'), 'loopback is internal'); +}); + +test('lanUrls lists IPv4 first, brackets IPv6, and drops link-local', async () => { + const { lanUrls } = await import('../server/index.js'); + const urls = lanUrls(3000); + for (const u of urls) assert.match(u, /^http:\/\/(\[[0-9a-fA-F:]+\]|\d{1,3}(?:\.\d{1,3}){3}):3000$/, u); + assert.ok(!urls.some((u) => u.includes('[fe80:')), 'no link-local URLs'); + const firstV6 = urls.findIndex((u) => u.includes('[')); + if (firstV6 > 0) assert.ok(urls.slice(0, firstV6).every((u) => !u.includes('[')), 'IPv4 comes before IPv6'); +}); + +test('the default bind is one dual-stack socket, and an explicit host is not rewritten', async (t) => { + const hasV6 = await new Promise((resolve) => { + const probe = net.createServer(); + probe.once('error', () => resolve(false)); + probe.listen(0, '::', () => probe.close(() => resolve(true))); + }); + if (!hasV6) { t.skip('this host has no IPv6'); return; } + + const saved = process.env.HOST; + delete process.env.HOST; + const { startServer } = await import('../server/index.js'); + const get = (srv, host) => new Promise((resolve) => { + const req = http.get({ host, port: srv.port, path: '/healthz' }, (res) => { res.resume(); resolve(res.statusCode); }); + req.on('error', (e) => resolve(e.code || 0)); + }); + const dual = await startServer({ port: 0, quiet: true }); + try { + assert.equal(dual.host, '::'); + assert.equal(dual.server.address().family, 'IPv6'); + assert.match(dual.url, /^http:\/\/localhost:\d+$/); + assert.equal(await get(dual, '::1'), 200); + assert.equal(await get(dual, '127.0.0.1'), 200); + } finally { + await dual.close(); + } + + const loop = await startServer({ port: 0, host: '::1', quiet: true }); + try { + assert.equal(loop.host, '::1'); + assert.match(loop.url, /^http:\/\/\[::1\]:\d+$/); + assert.equal(await get(loop, '::1'), 200); + } finally { + await loop.close(); + } + + const v4 = await startServer({ port: 0, host: '0.0.0.0', quiet: true }); + try { + assert.equal(v4.host, '0.0.0.0'); + assert.match(v4.url, /^http:\/\/localhost:\d+$/); + assert.equal(await get(v4, '127.0.0.1'), 200); + assert.notEqual(await get(v4, '::1'), 200, 'HOST=0.0.0.0 stays IPv4 only'); + } finally { + await v4.close(); + restoreHost(saved); + } +}); + +test('when :: cannot be bound, the default falls back to 0.0.0.0; EADDRINUSE does not', async () => { + const proto = http.Server.prototype; + const orig = proto.listen; + const saved = process.env.HOST; + delete process.env.HOST; + const { startServer } = await import('../server/index.js'); + + const stub = (failCode) => { + const tried = []; + proto.listen = function (port, host, ...rest) { + tried.push(host); + if (host === '::') { + const err = Object.assign(new Error(failCode), { code: failCode }); + process.nextTick(() => this.emit('error', err)); + return this; + } + return orig.call(this, port, host, ...rest); + }; + return tried; + }; + + try { + const tried = stub('EAFNOSUPPORT'); + const srv = await startServer({ port: 0, quiet: true }); + try { + assert.deepEqual(tried, ['::', '0.0.0.0']); + assert.equal(srv.host, '0.0.0.0'); + assert.match(srv.url, /^http:\/\/localhost:\d+$/); + assert.equal(srv.server.address().family, 'IPv4'); + } finally { + await srv.close(); + } + + const refused = stub('EADDRINUSE'); + await assert.rejects(() => startServer({ port: 0, quiet: true }), (e) => e && e.code === 'EADDRINUSE'); + assert.deepEqual(refused, ['::'], 'a port conflict is not an IPv6-missing error'); + + const explicit = []; + proto.listen = function (port, host, ...rest) { + explicit.push(host); + const err = Object.assign(new Error('EAFNOSUPPORT'), { code: 'EAFNOSUPPORT' }); + process.nextTick(() => this.emit('error', err)); + return this; + }; + await assert.rejects( + () => startServer({ port: 0, host: '::1', quiet: true }), + (e) => e && e.code === 'EAFNOSUPPORT', + ); + assert.deepEqual(explicit, ['::1'], 'an explicit host is not retried'); + } finally { + proto.listen = orig; + restoreHost(saved); + } +}); diff --git a/tools/ci.mjs b/tools/ci.mjs index c4f3d733..a5dc8068 100644 --- a/tools/ci.mjs +++ b/tools/ci.mjs @@ -273,7 +273,7 @@ function stopServer(child) { /** The smoke step of ci.yml's job `test`, on a free port instead of 3000 (a running game server must not clash). */ async function smoke(env) { const port = await freePort(); - // [ASSUMED] HOST=127.0.0.1 instead of ci.yml's default 0.0.0.0: the requests go to 127.0.0.1 either way, and + // [ASSUMED] HOST=127.0.0.1 instead of the unset-HOST default (::): the requests go to 127.0.0.1 either way, and // listening only on loopback spares a firewall prompt on Windows / macOS. const child = spawn(process.execPath, ['server/index.js'], { cwd: ROOT, env: { ...env, PORT: String(port), HOST: '127.0.0.1' }, stdio: ['ignore', 'pipe', 'pipe'], diff --git a/tools/doctor.mjs b/tools/doctor.mjs index 7c8aa45f..e7f59e7e 100755 --- a/tools/doctor.mjs +++ b/tools/doctor.mjs @@ -1,7 +1,7 @@ #!/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] +// 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, @@ -38,28 +38,69 @@ const VPN_IF = /(tailscale|zerotier|^zt|wireguard|^wg\d|tun\d|tap|radmin|hamachi 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 IPv4 address: 'lan' (RFC 1918, what friends at home use), 'vpn' (Tailscale / ZeroTier / - * Radmin / Hamachi / CGNAT 100.64/10), 'virtual' (Hyper-V, WSL, Docker, VirtualBox, a Clash/Mihomo TUN adapter … — - * not reachable from other machines, sharing them only confuses people), 'public' (a public address directly on this - * machine), 'linklocal' (169.254 — no DHCP, useless). + * 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.family === 'IPv4' || a.family === 4) || a.internal) continue; + if (a.internal) continue; const ip = a.address; - 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'; + 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 }); } } @@ -95,10 +136,12 @@ function canListen(port, host) { } /** 'ours' (our server answers /healthz), 'free', 'busy' (another program) or 'denied'. */ -export async function probePort(port, host = '0.0.0.0') { +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 }; - const l = await canListen(port, host); + 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 }; @@ -151,7 +194,7 @@ function tool(cmd, args) { // --------------------------------------------------------------------------------------------------- function parseArgs(argv) { - const o = { port: Number(process.env.PORT) || 3000, host: process.env.HOST || '0.0.0.0', help: false }; + 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]); @@ -166,7 +209,7 @@ function parseArgs(argv) { 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; } + 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; }; @@ -224,16 +267,17 @@ async function main() { 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)'); + 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', '网络', '没有可用的 IPv4 地址(未联网?)'); + if (!addrs.length) row('warn', '网络', '没有可用的地址(未联网?)'); 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}`); + // 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}:只监听这个地址,其他电脑可能连不上(默认 0.0.0.0)`); + 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]);