diff --git a/default/config.yaml b/default/config.yaml index 04c9a528f..94673ca11 100644 --- a/default/config.yaml +++ b/default/config.yaml @@ -7,6 +7,8 @@ cardsCacheCapacity: 100 # Listen for incoming connections listen: false # Enables IPv6 and/or IPv4 protocols. Need to have at least one enabled! +# - Use option "auto" to automatically detect support +# - Use true or false (no qoutes) to enable or disable each protocol protocol: ipv4: true ipv6: false diff --git a/server.js b/server.js index 3fd56bfd3..e62562e56 100644 --- a/server.js +++ b/server.js @@ -4,6 +4,7 @@ import fs from 'node:fs'; import http from 'node:http'; import https from 'node:https'; +import os from 'os'; import path from 'node:path'; import util from 'node:util'; import net from 'node:net'; @@ -65,6 +66,9 @@ import { forwardFetchResponse, removeColorFormatting, getSeparator, + stringToBool, + urlHostnameToIPv6, + canResolve, safeReadFileSync, setupLogLevel, } from './src/util.js'; @@ -150,11 +154,11 @@ const DEFAULT_PROXY_BYPASS = []; const cliArguments = yargs(hideBin(process.argv)) .usage('Usage: [options]') .option('enableIPv6', { - type: 'boolean', + type: 'string', default: null, describe: `Enables IPv6.\n[config default: ${DEFAULT_ENABLE_IPV6}]`, }).option('enableIPv4', { - type: 'boolean', + type: 'string', default: null, describe: `Enables IPv4.\n[config default: ${DEFAULT_ENABLE_IPV4}]`, }).option('port', { @@ -243,27 +247,42 @@ app.use(helmet({ app.use(compression()); app.use(responseTime()); + +/** @type {number} */ const server_port = cliArguments.port ?? process.env.SILLY_TAVERN_PORT ?? getConfigValue('port', DEFAULT_PORT); +/** @type {boolean} */ const autorun = (cliArguments.autorun ?? getConfigValue('autorun', DEFAULT_AUTORUN)) && !cliArguments.ssl; +/** @type {boolean} */ const listen = cliArguments.listen ?? getConfigValue('listen', DEFAULT_LISTEN); +/** @type {boolean} */ const enableCorsProxy = cliArguments.corsProxy ?? getConfigValue('enableCorsProxy', DEFAULT_CORS_PROXY); const enableWhitelist = cliArguments.whitelist ?? getConfigValue('whitelistMode', DEFAULT_WHITELIST); +/** @type {string} */ const dataRoot = cliArguments.dataRoot ?? getConfigValue('dataRoot', './data'); +/** @type {boolean} */ const disableCsrf = cliArguments.disableCsrf ?? getConfigValue('disableCsrfProtection', DEFAULT_CSRF_DISABLED); const basicAuthMode = cliArguments.basicAuthMode ?? getConfigValue('basicAuthMode', DEFAULT_BASIC_AUTH); const perUserBasicAuth = getConfigValue('perUserBasicAuth', DEFAULT_PER_USER_BASIC_AUTH); +/** @type {boolean} */ const enableAccounts = getConfigValue('enableUserAccounts', DEFAULT_ACCOUNTS); const uploadsPath = path.join(dataRoot, UPLOADS_DIRECTORY); -const enableIPv6 = cliArguments.enableIPv6 ?? getConfigValue('protocol.ipv6', DEFAULT_ENABLE_IPV6); -const enableIPv4 = cliArguments.enableIPv4 ?? getConfigValue('protocol.ipv4', DEFAULT_ENABLE_IPV4); +/** @type {boolean | "auto"} */ +let enableIPv6 = stringToBool(cliArguments.enableIPv6) ?? getConfigValue('protocol.ipv6', DEFAULT_ENABLE_IPV6); +/** @type {boolean | "auto"} */ +let enableIPv4 = stringToBool(cliArguments.enableIPv4) ?? getConfigValue('protocol.ipv4', DEFAULT_ENABLE_IPV4); + +/** @type {string} */ const autorunHostname = cliArguments.autorunHostname ?? getConfigValue('autorunHostname', DEFAULT_AUTORUN_HOSTNAME); +/** @type {number} */ const autorunPortOverride = cliArguments.autorunPortOverride ?? getConfigValue('autorunPortOverride', DEFAULT_AUTORUN_PORT); +/** @type {boolean} */ const dnsPreferIPv6 = cliArguments.dnsPreferIPv6 ?? getConfigValue('dnsPreferIPv6', DEFAULT_PREFER_IPV6); +/** @type {boolean} */ const avoidLocalhost = cliArguments.avoidLocalhost ?? getConfigValue('avoidLocalhost', DEFAULT_AVOID_LOCALHOST); const proxyEnabled = cliArguments.requestProxyEnabled ?? getConfigValue('requestProxy.enabled', DEFAULT_PROXY_ENABLED); @@ -280,7 +299,19 @@ if (dnsPreferIPv6) { console.log('Preferring IPv4 for DNS resolution'); } -if (!enableIPv6 && !enableIPv4) { + +const ipOptions = [true, 'auto', false]; + +if (!ipOptions.includes(enableIPv6)) { + console.warn(color.red('`protocol: ipv6` option invalid'), '\n use:', ipOptions, '\n setting to:', DEFAULT_ENABLE_IPV6); + enableIPv6 = DEFAULT_ENABLE_IPV6; +} +if (!ipOptions.includes(enableIPv4)) { + console.warn(color.red('`protocol: ipv4` option invalid'), '\n use:', ipOptions, '\n setting to:', DEFAULT_ENABLE_IPV4); + enableIPv4 = DEFAULT_ENABLE_IPV4; +} + +if (enableIPv6 === false && enableIPv4 === false) { console.error('error: You can\'t disable all internet protocols: at least IPv6 or IPv4 must be enabled.'); process.exit(1); } @@ -365,6 +396,55 @@ function getSessionCookieAge() { return undefined; } +/** + * Checks the network interfaces to determine the presence of IPv6 and IPv4 addresses. + * + * @returns {Promise<[boolean, boolean, boolean, boolean]>} A promise that resolves to an array containing: + * - [0]: `hasIPv6` (boolean) - Whether the computer has any IPv6 address, including (`::1`). + * - [1]: `hasIPv4` (boolean) - Whether the computer has any IPv4 address, including (`127.0.0.1`). + * - [2]: `hasIPv6Local` (boolean) - Whether the computer has local IPv6 address (`::1`). + * - [3]: `hasIPv4Local` (boolean) - Whether the computer has local IPv4 address (`127.0.0.1`). + */ +async function getHasIP() { + let hasIPv6 = false; + let hasIPv6Local = false; + + let hasIPv4 = false; + let hasIPv4Local = false; + + const interfaces = os.networkInterfaces(); + + for (const iface of Object.values(interfaces)) { + if (iface === undefined) { + continue; + } + + for (const info of iface) { + if (info.family === 'IPv6') { + hasIPv6 = true; + if (info.address === '::1') { + hasIPv6Local = true; + } + } + + if (info.family === 'IPv4') { + hasIPv4 = true; + if (info.address === '127.0.0.1') { + hasIPv4Local = true; + } + } + if (hasIPv6 && hasIPv4 && hasIPv6Local && hasIPv4Local) break; + } + if (hasIPv6 && hasIPv4 && hasIPv6Local && hasIPv4Local) break; + } + return [ + hasIPv6, + hasIPv4, + hasIPv6Local, + hasIPv4Local, + ]; +} + app.use(cookieSession({ name: getCookieSessionName(), sameSite: 'strict', @@ -694,21 +774,23 @@ const preSetupTasks = async function () { /** * Gets the hostname to use for autorun in the browser. - * @returns {string} The hostname to use for autorun + * @param {boolean} useIPv6 If use IPv6 + * @param {boolean} useIPv4 If use IPv4 + * @returns Promise The hostname to use for autorun */ -function getAutorunHostname() { +async function getAutorunHostname(useIPv6, useIPv4) { if (autorunHostname === 'auto') { - if (enableIPv6 && enableIPv4) { - return avoidLocalhost - ? '[::1]' - : 'localhost'; + let localhostResolve = await canResolve('localhost', useIPv6, useIPv4); + + if (useIPv6 && useIPv4) { + return (avoidLocalhost || !localhostResolve) ? '[::1]' : 'localhost'; } - if (enableIPv6) { + if (useIPv6) { return '[::1]'; } - if (enableIPv4) { + if (useIPv4) { return '127.0.0.1'; } } @@ -720,11 +802,13 @@ function getAutorunHostname() { * Tasks that need to be run after the server starts listening. * @param {boolean} v6Failed If the server failed to start on IPv6 * @param {boolean} v4Failed If the server failed to start on IPv4 + * @param {boolean} useIPv6 If the server is using IPv6 + * @param {boolean} useIPv4 If the server is using IPv4 */ -const postSetupTasks = async function (v6Failed, v4Failed) { +const postSetupTasks = async function (v6Failed, v4Failed, useIPv6, useIPv4) { const autorunUrl = new URL( (cliArguments.ssl ? 'https://' : 'http://') + - (getAutorunHostname()) + + (await getAutorunHostname(useIPv6, useIPv4)) + (':') + ((autorunPortOverride >= 0) ? autorunPortOverride : server_port), ); @@ -737,12 +821,16 @@ const postSetupTasks = async function (v6Failed, v4Failed) { let logListen = 'SillyTavern is listening on'; - if (enableIPv6 && !v6Failed) { - logListen += color.green(' IPv6: ' + tavernUrlV6.host); + if (useIPv6 && !v6Failed) { + logListen += color.green( + ' IPv6: ' + tavernUrlV6.host, + ); } - if (enableIPv4 && !v4Failed) { - logListen += color.green(' IPv4: ' + tavernUrl.host); + if (useIPv4 && !v4Failed) { + logListen += color.green( + ' IPv4: ' + tavernUrl.host, + ); } const goToLog = 'Go to: ' + color.blue(autorunUrl) + ' to open SillyTavern'; @@ -754,16 +842,22 @@ const postSetupTasks = async function (v6Failed, v4Failed) { console.log('\n' + getSeparator(plainGoToLog.length) + '\n'); if (listen) { - 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'); + 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', + ); } if (basicAuthMode) { if (perUserBasicAuth && !enableAccounts) { - console.error(color.red('Per-user basic authentication is enabled, but user accounts are disabled. This configuration may be insecure.')); + console.error(color.red( + 'Per-user basic authentication is enabled, but user accounts are disabled. This configuration may be insecure.', + )); } else if (!perUserBasicAuth) { const basicAuthUser = getConfigValue('basicAuthUser', {}); if (!basicAuthUser?.username || !basicAuthUser?.password) { - console.warn(color.yellow('Basic Authentication is enabled, but username or password is not set or empty!')); + console.warn(color.yellow( + 'Basic Authentication is enabled, but username or password is not set or empty!', + )); } } } @@ -818,14 +912,16 @@ function logSecurityAlert(message) { * Handles the case where the server failed to start on one or both protocols. * @param {boolean} v6Failed If the server failed to start on IPv6 * @param {boolean} v4Failed If the server failed to start on IPv4 + * @param {boolean} useIPv6 If use IPv6 + * @param {boolean} useIPv4 If use IPv4 */ -function handleServerListenFail(v6Failed, v4Failed) { - if (v6Failed && !enableIPv4) { +function handleServerListenFail(v6Failed, v4Failed, useIPv6, useIPv4) { + if (v6Failed && !useIPv4) { console.error(color.red('fatal error: Failed to start server on IPv6 and IPv4 disabled')); process.exit(1); } - if (v4Failed && !enableIPv6) { + if (v4Failed && !useIPv6) { console.error(color.red('fatal error: Failed to start server on IPv4 and IPv6 disabled')); process.exit(1); } @@ -839,10 +935,11 @@ function handleServerListenFail(v6Failed, v4Failed) { /** * Creates an HTTPS server. * @param {URL} url The URL to listen on + * @param {number} ipVersion the ip version to use * @returns {Promise} A promise that resolves when the server is listening * @throws {Error} If the server fails to start */ -function createHttpsServer(url) { +function createHttpsServer(url, ipVersion) { return new Promise((resolve, reject) => { const server = https.createServer( { @@ -851,34 +948,56 @@ function createHttpsServer(url) { }, app); server.on('error', reject); server.on('listening', resolve); - server.listen(Number(url.port || 443), url.hostname); + + let host = url.hostname; + if (ipVersion === 6) host = urlHostnameToIPv6(url.hostname); + server.listen({ + host: host, + port: Number(url.port || 443), + // see https://nodejs.org/api/net.html#serverlisten for why ipv6Only is used + ipv6Only: true, + }); }); } /** * Creates an HTTP server. * @param {URL} url The URL to listen on + * @param {number} ipVersion the ip version to use * @returns {Promise} A promise that resolves when the server is listening * @throws {Error} If the server fails to start */ -function createHttpServer(url) { +function createHttpServer(url, ipVersion) { return new Promise((resolve, reject) => { const server = http.createServer(app); server.on('error', reject); server.on('listening', resolve); - server.listen(Number(url.port || 80), url.hostname); + + let host = url.hostname; + if (ipVersion === 6) host = urlHostnameToIPv6(url.hostname); + server.listen({ + host: host, + port: Number(url.port || 80), + // see https://nodejs.org/api/net.html#serverlisten for why ipv6Only is used + ipv6Only: true, + }); }); } -async function startHTTPorHTTPS() { +/** + * Starts the server using http or https depending on config + * @param {boolean} useIPv6 If use IPv6 + * @param {boolean} useIPv4 If use IPv4 + */ +async function startHTTPorHTTPS(useIPv6, useIPv4) { let v6Failed = false; let v4Failed = false; const createFunc = cliArguments.ssl ? createHttpsServer : createHttpServer; - if (enableIPv6) { + if (useIPv6) { try { - await createFunc(tavernUrlV6); + await createFunc(tavernUrlV6, 6); } catch (error) { console.error('non-fatal error: failed to start server on IPv6'); console.error(error); @@ -887,9 +1006,9 @@ async function startHTTPorHTTPS() { } } - if (enableIPv4) { + if (useIPv4) { try { - await createFunc(tavernUrl); + await createFunc(tavernUrl, 4); } catch (error) { console.error('non-fatal error: failed to start server on IPv4'); console.error(error); @@ -902,10 +1021,59 @@ async function startHTTPorHTTPS() { } async function startServer() { - const [v6Failed, v4Failed] = await startHTTPorHTTPS(); + let useIPv6 = (enableIPv6 === true); + let useIPv4 = (enableIPv4 === true); - handleServerListenFail(v6Failed, v4Failed); - postSetupTasks(v6Failed, v4Failed); + let hasIPv6 = false, + hasIPv4 = false, + hasIPv6Local = false, + hasIPv4Local = false, + hasIPv6Any = false, + hasIPv4Any = false; + + if (enableIPv6 === 'auto' || enableIPv4 === 'auto') { + [hasIPv6Any, hasIPv4Any, hasIPv6Local, hasIPv4Local] = await getHasIP(); + + hasIPv6 = listen ? hasIPv6Any : hasIPv6Local; + if (enableIPv6 === 'auto') { + useIPv6 = hasIPv6; + } + if (hasIPv6) { + if (useIPv6) { + console.log(color.green('IPv6 support detected')); + } else { + console.log('IPv6 support detected (but disabled)'); + } + } + + hasIPv4 = listen ? hasIPv4Any : hasIPv4Local; + if (enableIPv4 === 'auto') { + useIPv4 = hasIPv4; + } + if (hasIPv4) { + if (useIPv4) { + console.log(color.green('IPv4 support detected')); + } else { + console.log('IPv4 support detected (but disabled)'); + } + } + + if (enableIPv6 === 'auto' && enableIPv4 === 'auto') { + if (!hasIPv6 && !hasIPv4) { + console.error('Both IPv6 and IPv4 are not detected'); + process.exit(1); + } + } + } + + if (!useIPv6 && !useIPv4) { + 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'); + process.exit(1); + } + + const [v6Failed, v4Failed] = await startHTTPorHTTPS(useIPv6, useIPv4); + handleServerListenFail(v6Failed, v4Failed, useIPv6, useIPv4); + postSetupTasks(v6Failed, v4Failed, useIPv6, useIPv4); } async function verifySecuritySettings() { diff --git a/src/util.js b/src/util.js index 7ca3a5683..426b238ce 100644 --- a/src/util.js +++ b/src/util.js @@ -5,6 +5,7 @@ import process from 'node:process'; import { Readable } from 'node:stream'; import { createRequire } from 'node:module'; import { Buffer } from 'node:buffer'; +import { promises as dnsPromise } from 'node:dns'; import yaml from 'yaml'; import { sync as commandExistsSync } from 'command-exists'; @@ -694,6 +695,70 @@ export function isValidUrl(url) { } } +/** + * removes starting `[` or ending `]` from hostname. + * @param {string} hostname hostname to use + * @returns {string} hostname plus the modifications + */ +export function urlHostnameToIPv6(hostname) { + if (hostname.startsWith('[')) { + hostname = hostname.slice(1); + } + if (hostname.endsWith(']')) { + hostname = hostname.slice(0, -1); + } + return hostname; +} + +/** + * Test if can resolve a dns name. + * @param {string} name Domain name to use + * @param {boolean} useIPv6 If use IPv6 + * @param {boolean} useIPv4 If use IPv4 + * @returns Promise If the URL is valid + */ +export async function canResolve(name, useIPv6 = true, useIPv4 = true) { + try { + let v6Resolved = false; + let v4Resolved = false; + + if (useIPv6) { + try { + await dnsPromise.resolve6(name); + v6Resolved = true; + } catch (error) { + v6Resolved = false; + } + } + + if (useIPv4) { + try { + await dnsPromise.resolve(name); + v4Resolved = true; + } catch (error) { + v4Resolved = false; + } + } + + return v6Resolved || v4Resolved; + + } catch (error) { + return false; + } +} + + +/** + * converts string to boolean accepts 'true' or 'false' else it returns the string put in + * @param {string|null} str Input string or null + * @returns {boolean|string|null} boolean else original input string or null if input is + */ +export function stringToBool(str) { + if (str === 'true') return true; + if (str === 'false') return false; + return str; +} + /** * Setup the minimum log level */