feedback7 G-TOOLS: listen on :: by default so one socket serves IPv6 and IPv4 (PR #291 by @LimitlessHPPK)
Ported only the dual-stack listen from PR #291 (the change accepted as #188). With HOST unset the server binds :: and, if that fails with EAFNOSUPPORT, EADDRNOTAVAIL or EINVAL, retries 0.0.0.0. An explicit HOST, including 0.0.0.0 and 127.0.0.1, is tried once. The boot banner and doctor list shareable IPv6 addresses in brackets, skip fe80::, and keep one URL per /64. An explicit IPv6 literal in the local URL is bracketed. The Windows service listing no longer drops those bracketed lines. Not ported: /dev/grant, the settings-default migration, and the firewall scripts. The Docker image stays on 0.0.0.0; the alpine fallback was not booted. Tests: node --test test/ipv6-bind.test.js test/doctor.test.js test/ci.test.js — 20 pass, 0 fail (8 bind, 4 doctor, 8 ci).
This commit is contained in:
1 parent
c36320fe4a
commit
51c53de189
11 files changed
+374
-54
No files matched your search
@@ -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` 从不 |
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+5
-5
@@ -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 停止服务器。'));
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
+29
-4
@@ -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? */
|
||||
|
||||
+21
-3
@@ -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
|
||||
|
||||
+33
-12
@@ -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).
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
+1
-1
@@ -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'],
|
||||
|
||||
+67
-23
@@ -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]);
|
||||
|
||||
Reference in new issue
Block a user