Files
LimitlessHPPK 51c53de189 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).
2026-10-08 18:53:24 +08:00

109 lines
4.9 KiB
JavaScript

// server/http/boot.js — running the server as a process (`node server/index.js`, npm start, scripts/launch.mjs, the
// Windows service's scripts/run-server.cmd, NSSM, systemd):
//
// * first, an update package extracted over the install (UPDATE.json) is finished — old files deleted, the install
// verified (server/update.js); one that does not match its MANIFEST.json keeps the server from starting (exit 1);
// * the boot banner: release version, the Local URL, the LAN URLs when listening on every interface, the
// cloudflared command for internet play;
// * a server that cannot start exits 1 (with a hint when the port is in use); unhandled rejections and uncaught
// exceptions are logged, not fatal;
// * graceful shutdown on SIGINT/SIGTERM (rooms get room.closed{reason:'shutdown'}, sockets close 1001); a second
// signal exits at once, and the process exits anyway 5 s after the first.
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 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 v4 = [];
const v6 = [];
const v6Seen = new Set();
for (const addrs of Object.values(os.networkInterfaces())) {
for (const a of addrs || []) {
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 [...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? */
export function isProcessEntry(metaUrl) {
if (!process.argv[1]) return false;
try {
return fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(metaUrl));
} catch {
return false;
}
}
/** Print the boot banner of a started server. @param {{ url: string, host: string, port: number }} srv */
export function printBanner(srv) {
console.log(`\n 卫戍协议:盟约 · Stronghold Protocol: Alliance v${APP_VERSION}`);
if (DEV_BUILD) console.log(' ! 开发版(dev 分支):不稳定,请勿用于公开服务器 · development build — unstable, not for public servers');
console.log(` Local: ${srv.url}`);
if (srv.host === '0.0.0.0' || srv.host === '::') {
for (const u of lanUrls(srv.port)) console.log(` LAN: ${u}`);
}
console.log(' Internet: cloudflared tunnel --url ' + `http://localhost:${srv.port}` + '\n');
}
/**
* The process main: finish a pending update package, start the server, print the banner, stop gracefully on SIGINT /
* SIGTERM.
* @param {() => Promise<{ url: string, host: string, port: number, close: () => Promise<void> }>} start index.js startServer
*/
export async function runMain(start) {
process.on('unhandledRejection', (e) => console.error('[process] unhandled rejection', e));
process.on('uncaughtException', (e) => console.error('[process] uncaught exception', e));
// before the data, the packs or the browser runtime are read: the files must be the new version's (server/update.js).
// Nothing is listening yet, so returning ends the process with exit code 1 once the message is written.
if (applyPendingUpdate(ROOT).state === 'failed') { process.exitCode = 1; return; }
let srv;
try {
srv = await start();
} catch (e) {
if (e && e.code === 'EADDRINUSE') console.error(`端口已被占用 / port in use: ${e.port ?? process.env.PORT ?? 3000}. Try PORT=3001 npm start`);
else console.error('[boot] failed to start', e);
process.exit(1);
}
printBanner(srv);
let stopping = false;
const stop = (signal) => {
if (stopping) { console.log('forced exit'); process.exit(1); }
stopping = true;
console.log(`\n[${signal}] shutting down…`);
setTimeout(() => process.exit(0), 5000).unref();
srv.close().then(() => process.exit(0), () => process.exit(1));
};
process.on('SIGINT', () => stop('SIGINT'));
process.on('SIGTERM', () => stop('SIGTERM'));
}