Merge pull request #3431 from SillyTavern/ipv6_auto Better IPv6 support

acf71fd702783c1507ad952a8ed0781125c34391

Cohee <18619528+Cohee1207@users.noreply.github.com>

Signed
3 files changed, +272 -37Ignore whitespace
default/config.yaml+2 -0
@@ -7,6 +7,8 @@ cardsCacheCapacity: 100
77# Listen for incoming connections
88listen: false
99# Enables IPv6 and/or IPv4 protocols. Need to have at least one enabled!
10+# - Use option "auto" to automatically detect support
11+# - Use true or false (no qoutes) to enable or disable each protocol
1012protocol:
1113 ipv4: true
1214 ipv6: false
server.js+205 -37
@@ -4,6 +4,7 @@
44import fs from 'node:fs';
55import http from 'node:http';
66import https from 'node:https';
7+import os from 'os';
78import path from 'node:path';
89import util from 'node:util';
910import net from 'node:net';
@@ -65,6 +66,9 @@ import {
6566 forwardFetchResponse,
6667 removeColorFormatting,
6768 getSeparator,
69+ stringToBool,
70+ urlHostnameToIPv6,
71+ canResolve,
6872 safeReadFileSync,
6973 setupLogLevel,
7074} from './src/util.js';
@@ -150,11 +154,11 @@ const DEFAULT_PROXY_BYPASS = [];
150154const cliArguments = yargs(hideBin(process.argv))
151155 .usage('Usage: <your-start-script> <command> [options]')
152156 .option('enableIPv6', {
153157 type: 'booleanstring',
154158 default: null,
155159 describe: `Enables IPv6.\n[config default: ${DEFAULT_ENABLE_IPV6}]`,
156160 }).option('enableIPv4', {
157161 type: 'booleanstring',
158162 default: null,
159163 describe: `Enables IPv4.\n[config default: ${DEFAULT_ENABLE_IPV4}]`,
160164 }).option('port', {
@@ -243,27 +247,42 @@ app.use(helmet({
243247app.use(compression());
244248app.use(responseTime());
245249
250+
251+/** @type {number} */
246252const server_port = cliArguments.port ?? process.env.SILLY_TAVERN_PORT ?? getConfigValue('port', DEFAULT_PORT);
253+/** @type {boolean} */
247254const autorun = (cliArguments.autorun ?? getConfigValue('autorun', DEFAULT_AUTORUN)) && !cliArguments.ssl;
255+/** @type {boolean} */
248256const listen = cliArguments.listen ?? getConfigValue('listen', DEFAULT_LISTEN);
257+/** @type {boolean} */
249258const enableCorsProxy = cliArguments.corsProxy ?? getConfigValue('enableCorsProxy', DEFAULT_CORS_PROXY);
250259const enableWhitelist = cliArguments.whitelist ?? getConfigValue('whitelistMode', DEFAULT_WHITELIST);
260+/** @type {string} */
251261const dataRoot = cliArguments.dataRoot ?? getConfigValue('dataRoot', './data');
262+/** @type {boolean} */
252263const disableCsrf = cliArguments.disableCsrf ?? getConfigValue('disableCsrfProtection', DEFAULT_CSRF_DISABLED);
253264const basicAuthMode = cliArguments.basicAuthMode ?? getConfigValue('basicAuthMode', DEFAULT_BASIC_AUTH);
254265const perUserBasicAuth = getConfigValue('perUserBasicAuth', DEFAULT_PER_USER_BASIC_AUTH);
266+/** @type {boolean} */
255267const enableAccounts = getConfigValue('enableUserAccounts', DEFAULT_ACCOUNTS);
256268
257269const uploadsPath = path.join(dataRoot, UPLOADS_DIRECTORY);
258270
259-const enableIPv6 = cliArguments.enableIPv6 ?? getConfigValue('protocol.ipv6', DEFAULT_ENABLE_IPV6);
260-const enableIPv4 = cliArguments.enableIPv4 ?? getConfigValue('protocol.ipv4', DEFAULT_ENABLE_IPV4);
261271
272+/** @type {boolean | "auto"} */
273+let enableIPv6 = stringToBool(cliArguments.enableIPv6) ?? getConfigValue('protocol.ipv6', DEFAULT_ENABLE_IPV6);
274+/** @type {boolean | "auto"} */
275+let enableIPv4 = stringToBool(cliArguments.enableIPv4) ?? getConfigValue('protocol.ipv4', DEFAULT_ENABLE_IPV4);
276+
277+/** @type {string} */
262278const autorunHostname = cliArguments.autorunHostname ?? getConfigValue('autorunHostname', DEFAULT_AUTORUN_HOSTNAME);
279+/** @type {number} */
263280const autorunPortOverride = cliArguments.autorunPortOverride ?? getConfigValue('autorunPortOverride', DEFAULT_AUTORUN_PORT);
264281
282+/** @type {boolean} */
265283const dnsPreferIPv6 = cliArguments.dnsPreferIPv6 ?? getConfigValue('dnsPreferIPv6', DEFAULT_PREFER_IPV6);
266284
285+/** @type {boolean} */
267286const avoidLocalhost = cliArguments.avoidLocalhost ?? getConfigValue('avoidLocalhost', DEFAULT_AVOID_LOCALHOST);
268287
269288const proxyEnabled = cliArguments.requestProxyEnabled ?? getConfigValue('requestProxy.enabled', DEFAULT_PROXY_ENABLED);
@@ -280,7 +299,19 @@ if (dnsPreferIPv6) {
280299 console.log('Preferring IPv4 for DNS resolution');
281300}
282301
283-if (!enableIPv6 && !enableIPv4) {
302+
303+const ipOptions = [true, 'auto', false];
304+
305+if (!ipOptions.includes(enableIPv6)) {
306+ console.warn(color.red('`protocol: ipv6` option invalid'), '\n use:', ipOptions, '\n setting to:', DEFAULT_ENABLE_IPV6);
307+ enableIPv6 = DEFAULT_ENABLE_IPV6;
308+}
309+if (!ipOptions.includes(enableIPv4)) {
310+ console.warn(color.red('`protocol: ipv4` option invalid'), '\n use:', ipOptions, '\n setting to:', DEFAULT_ENABLE_IPV4);
311+ enableIPv4 = DEFAULT_ENABLE_IPV4;
312+}
313+
314+if (enableIPv6 === false && enableIPv4 === false) {
284315 console.error('error: You can\'t disable all internet protocols: at least IPv6 or IPv4 must be enabled.');
285316 process.exit(1);
286317}
@@ -365,6 +396,55 @@ function getSessionCookieAge() {
365396 return undefined;
366397}
367398
399+/**
400+ * Checks the network interfaces to determine the presence of IPv6 and IPv4 addresses.
401+ *
402+ * @returns {Promise<[boolean, boolean, boolean, boolean]>} A promise that resolves to an array containing:
403+ * - [0]: `hasIPv6` (boolean) - Whether the computer has any IPv6 address, including (`::1`).
404+ * - [1]: `hasIPv4` (boolean) - Whether the computer has any IPv4 address, including (`127.0.0.1`).
405+ * - [2]: `hasIPv6Local` (boolean) - Whether the computer has local IPv6 address (`::1`).
406+ * - [3]: `hasIPv4Local` (boolean) - Whether the computer has local IPv4 address (`127.0.0.1`).
407+ */
408+async function getHasIP() {
409+ let hasIPv6 = false;
410+ let hasIPv6Local = false;
411+
412+ let hasIPv4 = false;
413+ let hasIPv4Local = false;
414+
415+ const interfaces = os.networkInterfaces();
416+
417+ for (const iface of Object.values(interfaces)) {
418+ if (iface === undefined) {
419+ continue;
420+ }
421+
422+ for (const info of iface) {
423+ if (info.family === 'IPv6') {
424+ hasIPv6 = true;
425+ if (info.address === '::1') {
426+ hasIPv6Local = true;
427+ }
428+ }
429+
430+ if (info.family === 'IPv4') {
431+ hasIPv4 = true;
432+ if (info.address === '127.0.0.1') {
433+ hasIPv4Local = true;
434+ }
435+ }
436+ if (hasIPv6 && hasIPv4 && hasIPv6Local && hasIPv4Local) break;
437+ }
438+ if (hasIPv6 && hasIPv4 && hasIPv6Local && hasIPv4Local) break;
439+ }
440+ return [
441+ hasIPv6,
442+ hasIPv4,
443+ hasIPv6Local,
444+ hasIPv4Local,
445+ ];
446+}
447+
368448app.use(cookieSession({
369449 name: getCookieSessionName(),
370450 sameSite: 'strict',
@@ -694,21 +774,23 @@ const preSetupTasks = async function () {
694774
695775/**
696776 * Gets the hostname to use for autorun in the browser.
697777 * @returnsparam {stringboolean} The hostnameuseIPv6 toIf use for autorunIPv6
778+ * @param {boolean} useIPv4 If use IPv4
779+ * @returns Promise<string> The hostname to use for autorun
698780 */
699781async function getAutorunHostname(useIPv6, useIPv4) {
700782 if (autorunHostname === 'auto') {
701- if (enableIPv6 && enableIPv4) {
783+ let localhostResolve = await canResolve('localhost', useIPv6, useIPv4);
702- return avoidLocalhost
784+
703- ? '[::1]'
785+ if (useIPv6 && useIPv4) {
704- : 'localhost';
786+ return (avoidLocalhost || !localhostResolve) ? '[::1]' : 'localhost';
705787 }
706788
707789 if (enableIPv6useIPv6) {
708790 return '[::1]';
709791 }
710792
711793 if (enableIPv4useIPv4) {
712794 return '127.0.0.1';
713795 }
714796 }
@@ -720,11 +802,13 @@ function getAutorunHostname() {
720802 * Tasks that need to be run after the server starts listening.
721803 * @param {boolean} v6Failed If the server failed to start on IPv6
722804 * @param {boolean} v4Failed If the server failed to start on IPv4
805+ * @param {boolean} useIPv6 If the server is using IPv6
806+ * @param {boolean} useIPv4 If the server is using IPv4
723807 */
724808const postSetupTasks = async function (v6Failed, v4Failed, useIPv6, useIPv4) {
725809 const autorunUrl = new URL(
726810 (cliArguments.ssl ? 'https://' : 'http://') +
727811 (await getAutorunHostname(useIPv6, useIPv4)) +
728812 (':') +
729813 ((autorunPortOverride >= 0) ? autorunPortOverride : server_port),
730814 );
@@ -737,12 +821,16 @@ const postSetupTasks = async function (v6Failed, v4Failed) {
737821
738822 let logListen = 'SillyTavern is listening on';
739823
740824 if (enableIPv6useIPv6 && !v6Failed) {
741825 logListen += color.green(' IPv6: ' + tavernUrlV6.host);
826+ ' IPv6: ' + tavernUrlV6.host,
827+ );
742828 }
743829
744830 if (enableIPv4useIPv4 && !v4Failed) {
745831 logListen += color.green(' IPv4: ' + tavernUrl.host);
832+ ' IPv4: ' + tavernUrl.host,
833+ );
746834 }
747835
748836 const goToLog = 'Go to: ' + color.blue(autorunUrl) + ' to open SillyTavern';
@@ -754,16 +842,22 @@ const postSetupTasks = async function (v6Failed, v4Failed) {
754842 console.log('\n' + getSeparator(plainGoToLog.length) + '\n');
755843
756844 if (listen) {
757- console.log('[::] or 0.0.0.0 means SillyTavern is listening on all network interfaces (Wi-Fi, LAN, localhost). If you want to limit it only to internal localhost ([::1] or 127.0.0.1), change the setting in config.yaml to "listen: false". Check "access.log" file in the SillyTavern directory if you want to inspect incoming connections.\n');
845+ console.log(
846+ '[::] or 0.0.0.0 means SillyTavern is listening on all network interfaces (Wi-Fi, LAN, localhost). If you want to limit it only to internal localhost ([::1] or 127.0.0.1), change the setting in config.yaml to "listen: false". Check "access.log" file in the SillyTavern directory if you want to inspect incoming connections.\n',
847+ );
758848 }
759849
760850 if (basicAuthMode) {
761851 if (perUserBasicAuth && !enableAccounts) {
762- console.error(color.red('Per-user basic authentication is enabled, but user accounts are disabled. This configuration may be insecure.'));
852+ console.error(color.red(
853+ 'Per-user basic authentication is enabled, but user accounts are disabled. This configuration may be insecure.',
854+ ));
763855 } else if (!perUserBasicAuth) {
764856 const basicAuthUser = getConfigValue('basicAuthUser', {});
765857 if (!basicAuthUser?.username || !basicAuthUser?.password) {
766- console.warn(color.yellow('Basic Authentication is enabled, but username or password is not set or empty!'));
858+ console.warn(color.yellow(
859+ 'Basic Authentication is enabled, but username or password is not set or empty!',
860+ ));
767861 }
768862 }
769863 }
@@ -818,14 +912,16 @@ function logSecurityAlert(message) {
818912 * Handles the case where the server failed to start on one or both protocols.
819913 * @param {boolean} v6Failed If the server failed to start on IPv6
820914 * @param {boolean} v4Failed If the server failed to start on IPv4
915+ * @param {boolean} useIPv6 If use IPv6
916+ * @param {boolean} useIPv4 If use IPv4
821917 */
822918function handleServerListenFail(v6Failed, v4Failed, useIPv6, useIPv4) {
823919 if (v6Failed && !enableIPv4useIPv4) {
824920 console.error(color.red('fatal error: Failed to start server on IPv6 and IPv4 disabled'));
825921 process.exit(1);
826922 }
827923
828924 if (v4Failed && !enableIPv6useIPv6) {
829925 console.error(color.red('fatal error: Failed to start server on IPv4 and IPv6 disabled'));
830926 process.exit(1);
831927 }
@@ -839,10 +935,11 @@ function handleServerListenFail(v6Failed, v4Failed) {
839935/**
840936 * Creates an HTTPS server.
841937 * @param {URL} url The URL to listen on
938+ * @param {number} ipVersion the ip version to use
842939 * @returns {Promise<void>} A promise that resolves when the server is listening
843940 * @throws {Error} If the server fails to start
844941 */
845942function createHttpsServer(url, ipVersion) {
846943 return new Promise((resolve, reject) => {
847944 const server = https.createServer(
848945 {
@@ -851,34 +948,56 @@ function createHttpsServer(url) {
851948 }, app);
852949 server.on('error', reject);
853950 server.on('listening', resolve);
854- server.listen(Number(url.port || 443), url.hostname);
951+
952+ let host = url.hostname;
953+ if (ipVersion === 6) host = urlHostnameToIPv6(url.hostname);
954+ server.listen({
955+ host: host,
956+ port: Number(url.port || 443),
957+ // see https://nodejs.org/api/net.html#serverlisten for why ipv6Only is used
958+ ipv6Only: true,
959+ });
855960 });
856961}
857962
858963/**
859964 * Creates an HTTP server.
860965 * @param {URL} url The URL to listen on
966+ * @param {number} ipVersion the ip version to use
861967 * @returns {Promise<void>} A promise that resolves when the server is listening
862968 * @throws {Error} If the server fails to start
863969 */
864970function createHttpServer(url, ipVersion) {
865971 return new Promise((resolve, reject) => {
866972 const server = http.createServer(app);
867973 server.on('error', reject);
868974 server.on('listening', resolve);
869- server.listen(Number(url.port || 80), url.hostname);
975+
976+ let host = url.hostname;
977+ if (ipVersion === 6) host = urlHostnameToIPv6(url.hostname);
978+ server.listen({
979+ host: host,
980+ port: Number(url.port || 80),
981+ // see https://nodejs.org/api/net.html#serverlisten for why ipv6Only is used
982+ ipv6Only: true,
983+ });
870984 });
871985}
872986
873-async function startHTTPorHTTPS() {
987+/**
988+ * Starts the server using http or https depending on config
989+ * @param {boolean} useIPv6 If use IPv6
990+ * @param {boolean} useIPv4 If use IPv4
991+ */
992+async function startHTTPorHTTPS(useIPv6, useIPv4) {
874993 let v6Failed = false;
875994 let v4Failed = false;
876995
877996 const createFunc = cliArguments.ssl ? createHttpsServer : createHttpServer;
878997
879998 if (enableIPv6useIPv6) {
880999 try {
8811000 await createFunc(tavernUrlV6, 6);
8821001 } catch (error) {
8831002 console.error('non-fatal error: failed to start server on IPv6');
8841003 console.error(error);
@@ -887,9 +1006,9 @@ async function startHTTPorHTTPS() {
8871006 }
8881007 }
8891008
8901009 if (enableIPv4useIPv4) {
8911010 try {
8921011 await createFunc(tavernUrl, 4);
8931012 } catch (error) {
8941013 console.error('non-fatal error: failed to start server on IPv4');
8951014 console.error(error);
@@ -902,10 +1021,59 @@ async function startHTTPorHTTPS() {
9021021}
9031022
9041023async function startServer() {
905- const [v6Failed, v4Failed] = await startHTTPorHTTPS();
1024+ let useIPv6 = (enableIPv6 === true);
1025+ let useIPv4 = (enableIPv4 === true);
1026+
1027+ let hasIPv6 = false,
1028+ hasIPv4 = false,
1029+ hasIPv6Local = false,
1030+ hasIPv4Local = false,
1031+ hasIPv6Any = false,
1032+ hasIPv4Any = false;
1033+
1034+ if (enableIPv6 === 'auto' || enableIPv4 === 'auto') {
1035+ [hasIPv6Any, hasIPv4Any, hasIPv6Local, hasIPv4Local] = await getHasIP();
1036+
1037+ hasIPv6 = listen ? hasIPv6Any : hasIPv6Local;
1038+ if (enableIPv6 === 'auto') {
1039+ useIPv6 = hasIPv6;
1040+ }
1041+ if (hasIPv6) {
1042+ if (useIPv6) {
1043+ console.log(color.green('IPv6 support detected'));
1044+ } else {
1045+ console.log('IPv6 support detected (but disabled)');
1046+ }
1047+ }
1048+
1049+ hasIPv4 = listen ? hasIPv4Any : hasIPv4Local;
1050+ if (enableIPv4 === 'auto') {
1051+ useIPv4 = hasIPv4;
1052+ }
1053+ if (hasIPv4) {
1054+ if (useIPv4) {
1055+ console.log(color.green('IPv4 support detected'));
1056+ } else {
1057+ console.log('IPv4 support detected (but disabled)');
1058+ }
1059+ }
1060+
1061+ if (enableIPv6 === 'auto' && enableIPv4 === 'auto') {
1062+ if (!hasIPv6 && !hasIPv4) {
1063+ console.error('Both IPv6 and IPv4 are not detected');
1064+ process.exit(1);
1065+ }
1066+ }
1067+ }
1068+
1069+ if (!useIPv6 && !useIPv4) {
1070+ console.error('Both IPv6 and IPv4 are disabled,\nP.S. you should never see this error, at least at one point it was checked for before this, with the rest of the config options');
1071+ process.exit(1);
1072+ }
9061073
907- handleServerListenFail(v6Failed, v4Failed);
1074+ const [v6Failed, v4Failed] = await startHTTPorHTTPS(useIPv6, useIPv4);
9081075 postSetupTaskshandleServerListenFail(v6Failed, v4Failed, useIPv6, useIPv4);
1076+ postSetupTasks(v6Failed, v4Failed, useIPv6, useIPv4);
9091077}
9101078
9111079async function verifySecuritySettings() {
src/util.js+65 -0
@@ -5,6 +5,7 @@ import process from 'node:process';
55import { Readable } from 'node:stream';
66import { createRequire } from 'node:module';
77import { Buffer } from 'node:buffer';
8+import { promises as dnsPromise } from 'node:dns';
89
910import yaml from 'yaml';
1011import { sync as commandExistsSync } from 'command-exists';
@@ -695,6 +696,70 @@ export function isValidUrl(url) {
695696}
696697
697698/**
699+ * removes starting `[` or ending `]` from hostname.
700+ * @param {string} hostname hostname to use
701+ * @returns {string} hostname plus the modifications
702+ */
703+export function urlHostnameToIPv6(hostname) {
704+ if (hostname.startsWith('[')) {
705+ hostname = hostname.slice(1);
706+ }
707+ if (hostname.endsWith(']')) {
708+ hostname = hostname.slice(0, -1);
709+ }
710+ return hostname;
711+}
712+
713+/**
714+ * Test if can resolve a dns name.
715+ * @param {string} name Domain name to use
716+ * @param {boolean} useIPv6 If use IPv6
717+ * @param {boolean} useIPv4 If use IPv4
718+ * @returns Promise<boolean> If the URL is valid
719+ */
720+export async function canResolve(name, useIPv6 = true, useIPv4 = true) {
721+ try {
722+ let v6Resolved = false;
723+ let v4Resolved = false;
724+
725+ if (useIPv6) {
726+ try {
727+ await dnsPromise.resolve6(name);
728+ v6Resolved = true;
729+ } catch (error) {
730+ v6Resolved = false;
731+ }
732+ }
733+
734+ if (useIPv4) {
735+ try {
736+ await dnsPromise.resolve(name);
737+ v4Resolved = true;
738+ } catch (error) {
739+ v4Resolved = false;
740+ }
741+ }
742+
743+ return v6Resolved || v4Resolved;
744+
745+ } catch (error) {
746+ return false;
747+ }
748+}
749+
750+
751+/**
752+ * converts string to boolean accepts 'true' or 'false' else it returns the string put in
753+ * @param {string|null} str Input string or null
754+ * @returns {boolean|string|null} boolean else original input string or null if input is
755+ */
756+export function stringToBool(str) {
757+ if (str === 'true') return true;
758+ if (str === 'false') return false;
759+ return str;
760+}
761+
762+/**
698763 * Setup the minimum log level
699764 */
700765export function setupLogLevel() {