Merge pull request #3226 from BPplays/ipv6_auto Automatically detect what IP versions are available and use them
Signed| @@ -7,9 +7,11 @@ cardsCacheCapacity: 100 | ||
| 7 | 7 | # Listen for incoming connections |
| 8 | 8 | listen: false |
| 9 | 9 | # 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 | |
| 10 | 12 | protocol: |
| 11 | 13 | ipv4: trueauto |
| 12 | 14 | ipv6: falseauto |
| 13 | 15 | # Prefers IPv6 for DNS. Enable this on ISPs that don't have issues with IPv6 |
| 14 | 16 | dnsPreferIPv6: false |
| 15 | 17 | # The hostname that autorun opens. |
| @@ -4,6 +4,7 @@ | ||
| 4 | 4 | import fs from 'node:fs'; |
| 5 | 5 | import http from 'node:http'; |
| 6 | 6 | import https from 'node:https'; |
| 7 | +import os from 'os'; | |
| 7 | 8 | import path from 'node:path'; |
| 8 | 9 | import util from 'node:util'; |
| 9 | 10 | import net from 'node:net'; |
| @@ -67,6 +68,8 @@ import { | ||
| 67 | 68 | forwardFetchResponse, |
| 68 | 69 | removeColorFormatting, |
| 69 | 70 | getSeparator, |
| 71 | + stringToBool, | |
| 72 | + urlHostnameToIPv6, | |
| 70 | 73 | } from './src/util.js'; |
| 71 | 74 | import { UPLOADS_DIRECTORY } from './src/constants.js'; |
| 72 | 75 | import { ensureThumbnailCache } from './src/endpoints/thumbnails.js'; |
| @@ -133,8 +136,8 @@ const DEFAULT_CSRF_DISABLED = false; | ||
| 133 | 136 | const DEFAULT_BASIC_AUTH = false; |
| 134 | 137 | const DEFAULT_PER_USER_BASIC_AUTH = false; |
| 135 | 138 | |
| 136 | 139 | const DEFAULT_ENABLE_IPV6 = false'auto'; |
| 137 | 140 | const DEFAULT_ENABLE_IPV4 = true'auto'; |
| 138 | 141 | |
| 139 | 142 | const DEFAULT_PREFER_IPV6 = false; |
| 140 | 143 | |
| @@ -150,11 +153,11 @@ const DEFAULT_PROXY_BYPASS = []; | ||
| 150 | 153 | const cliArguments = yargs(hideBin(process.argv)) |
| 151 | 154 | .usage('Usage: <your-start-script> <command> [options]') |
| 152 | 155 | .option('enableIPv6', { |
| 153 | 156 | type: 'booleanstring', |
| 154 | 157 | default: null, |
| 155 | 158 | describe: `Enables IPv6.\n[config default: ${DEFAULT_ENABLE_IPV6}]`, |
| 156 | 159 | }).option('enableIPv4', { |
| 157 | 160 | type: 'booleanstring', |
| 158 | 161 | default: null, |
| 159 | 162 | describe: `Enables IPv4.\n[config default: ${DEFAULT_ENABLE_IPV4}]`, |
| 160 | 163 | }).option('port', { |
| @@ -243,6 +246,7 @@ app.use(helmet({ | ||
| 243 | 246 | app.use(compression()); |
| 244 | 247 | app.use(responseTime()); |
| 245 | 248 | |
| 249 | + | |
| 246 | 250 | const server_port = cliArguments.port ?? process.env.SILLY_TAVERN_PORT ?? getConfigValue('port', DEFAULT_PORT); |
| 247 | 251 | const autorun = (cliArguments.autorun ?? getConfigValue('autorun', DEFAULT_AUTORUN)) && !cliArguments.ssl; |
| 248 | 252 | const listen = cliArguments.listen ?? getConfigValue('listen', DEFAULT_LISTEN); |
| @@ -256,8 +260,9 @@ const enableAccounts = getConfigValue('enableUserAccounts', DEFAULT_ACCOUNTS); | ||
| 256 | 260 | |
| 257 | 261 | const uploadsPath = path.join(dataRoot, UPLOADS_DIRECTORY); |
| 258 | 262 | |
| 259 | -const enableIPv6 = cliArguments.enableIPv6 ?? getConfigValue('protocol.ipv6', DEFAULT_ENABLE_IPV6); | |
| 263 | + | |
| 260 | 264 | constlet enableIPv4enableIPv6 = stringToBool(cliArguments.enableIPv4enableIPv6) ?? getConfigValue('protocol.ipv4ipv6', DEFAULT_ENABLE_IPV4DEFAULT_ENABLE_IPV6); |
| 265 | +let enableIPv4 = stringToBool(cliArguments.enableIPv4) ?? getConfigValue('protocol.ipv4', DEFAULT_ENABLE_IPV4); | |
| 261 | 266 | |
| 262 | 267 | const autorunHostname = cliArguments.autorunHostname ?? getConfigValue('autorunHostname', DEFAULT_AUTORUN_HOSTNAME); |
| 263 | 268 | const autorunPortOverride = cliArguments.autorunPortOverride ?? getConfigValue('autorunPortOverride', DEFAULT_AUTORUN_PORT); |
| @@ -280,7 +285,19 @@ if (dnsPreferIPv6) { | ||
| 280 | 285 | console.log('Preferring IPv4 for DNS resolution'); |
| 281 | 286 | } |
| 282 | 287 | |
| 283 | -if (!enableIPv6 && !enableIPv4) { | |
| 288 | + | |
| 289 | +const ipOptions = [true, 'auto', false]; | |
| 290 | + | |
| 291 | +if (!ipOptions.includes(enableIPv6)) { | |
| 292 | + console.warn(color.red('`protocol: ipv6` option invalid'), '\n use:', ipOptions, '\n setting to: auto'); | |
| 293 | + enableIPv6 = 'auto'; | |
| 294 | +} | |
| 295 | +if (!ipOptions.includes(enableIPv4)) { | |
| 296 | + console.warn(color.red('`protocol: ipv4` option invalid'), '\n use:', ipOptions, '\n setting to: auto'); | |
| 297 | + enableIPv4 = 'auto'; | |
| 298 | +} | |
| 299 | + | |
| 300 | +if (enableIPv6 === false && enableIPv4 === false) { | |
| 284 | 301 | console.error('error: You can\'t disable all internet protocols: at least IPv6 or IPv4 must be enabled.'); |
| 285 | 302 | process.exit(1); |
| 286 | 303 | } |
| @@ -365,6 +382,40 @@ function getSessionCookieAge() { | ||
| 365 | 382 | return undefined; |
| 366 | 383 | } |
| 367 | 384 | |
| 385 | +async function getHasIP() { | |
| 386 | + let hasIPv6 = false; | |
| 387 | + let hasIPv6Local = false; | |
| 388 | + | |
| 389 | + let hasIPv4 = false; | |
| 390 | + let hasIPv4Local = false; | |
| 391 | + | |
| 392 | + const interfaces = os.networkInterfaces(); | |
| 393 | + | |
| 394 | + for (const iface of Object.values(interfaces)) { | |
| 395 | + if (iface === undefined) { | |
| 396 | + continue; | |
| 397 | + } | |
| 398 | + for (const info of iface) { | |
| 399 | + if (info.family === 'IPv6') { | |
| 400 | + hasIPv6 = true; | |
| 401 | + if (info.address === '::1') { | |
| 402 | + hasIPv6Local = true; | |
| 403 | + } | |
| 404 | + } | |
| 405 | + | |
| 406 | + if (info.family === 'IPv4') { | |
| 407 | + hasIPv4 = true; | |
| 408 | + if (info.address === '127.0.0.1') { | |
| 409 | + hasIPv4Local = true; | |
| 410 | + } | |
| 411 | + } | |
| 412 | + if (hasIPv6 && hasIPv4 && hasIPv6Local && hasIPv4Local) break; | |
| 413 | + } | |
| 414 | + if (hasIPv6 && hasIPv4 && hasIPv6Local && hasIPv4Local) break; | |
| 415 | + } | |
| 416 | + return [hasIPv6, hasIPv4, hasIPv6Local, hasIPv4Local]; | |
| 417 | +} | |
| 418 | + | |
| 368 | 419 | app.use(cookieSession({ |
| 369 | 420 | name: getCookieSessionName(), |
| 370 | 421 | sameSite: 'strict', |
| @@ -685,18 +736,18 @@ const preSetupTasks = async function () { | ||
| 685 | 736 | * Gets the hostname to use for autorun in the browser. |
| 686 | 737 | * @returns {string} The hostname to use for autorun |
| 687 | 738 | */ |
| 688 | 739 | function getAutorunHostname(useIPv6, useIPv4) { |
| 689 | 740 | if (autorunHostname === 'auto') { |
| 690 | 741 | if (enableIPv6useIPv6 && enableIPv4useIPv4) { |
| 691 | 742 | if (avoidLocalhost) return '[::1]'; |
| 692 | 743 | return 'localhost'; |
| 693 | 744 | } |
| 694 | 745 | |
| 695 | 746 | if (enableIPv6useIPv6) { |
| 696 | 747 | return '[::1]'; |
| 697 | 748 | } |
| 698 | 749 | |
| 699 | 750 | if (enableIPv4useIPv4) { |
| 700 | 751 | return '127.0.0.1'; |
| 701 | 752 | } |
| 702 | 753 | } |
| @@ -709,10 +760,10 @@ function getAutorunHostname() { | ||
| 709 | 760 | * @param {boolean} v6Failed If the server failed to start on IPv6 |
| 710 | 761 | * @param {boolean} v4Failed If the server failed to start on IPv4 |
| 711 | 762 | */ |
| 712 | 763 | const postSetupTasks = async function (v6Failed, v4Failed, useIPv6, useIPv4) { |
| 713 | 764 | const autorunUrl = new URL( |
| 714 | 765 | (cliArguments.ssl ? 'https://' : 'http://') + |
| 715 | 766 | (getAutorunHostname(useIPv6, useIPv4)) + |
| 716 | 767 | (':') + |
| 717 | 768 | ((autorunPortOverride >= 0) ? autorunPortOverride : server_port), |
| 718 | 769 | ); |
| @@ -725,11 +776,11 @@ const postSetupTasks = async function (v6Failed, v4Failed) { | ||
| 725 | 776 | |
| 726 | 777 | let logListen = 'SillyTavern is listening on'; |
| 727 | 778 | |
| 728 | 779 | if (enableIPv6useIPv6 && !v6Failed) { |
| 729 | 780 | logListen += color.green(' IPv6: ' + tavernUrlV6.host); |
| 730 | 781 | } |
| 731 | 782 | |
| 732 | 783 | if (enableIPv4useIPv4 && !v4Failed) { |
| 733 | 784 | logListen += color.green(' IPv4: ' + tavernUrl.host); |
| 734 | 785 | } |
| 735 | 786 | |
| @@ -805,13 +856,13 @@ function logSecurityAlert(message) { | ||
| 805 | 856 | * @param {boolean} v6Failed If the server failed to start on IPv6 |
| 806 | 857 | * @param {boolean} v4Failed If the server failed to start on IPv4 |
| 807 | 858 | */ |
| 808 | 859 | function handleServerListenFail(v6Failed, v4Failed, useIPv6, useIPv4) { |
| 809 | 860 | if (v6Failed && !enableIPv4useIPv4) { |
| 810 | 861 | console.error(color.red('fatal error: Failed to start server on IPv6 and IPv4 disabled')); |
| 811 | 862 | process.exit(1); |
| 812 | 863 | } |
| 813 | 864 | |
| 814 | 865 | if (v4Failed && !enableIPv6useIPv6) { |
| 815 | 866 | console.error(color.red('fatal error: Failed to start server on IPv4 and IPv6 disabled')); |
| 816 | 867 | process.exit(1); |
| 817 | 868 | } |
| @@ -828,7 +879,7 @@ function handleServerListenFail(v6Failed, v4Failed) { | ||
| 828 | 879 | * @returns {Promise<void>} A promise that resolves when the server is listening |
| 829 | 880 | * @throws {Error} If the server fails to start |
| 830 | 881 | */ |
| 831 | 882 | function createHttpsServer(url, ipVersion) { |
| 832 | 883 | return new Promise((resolve, reject) => { |
| 833 | 884 | const server = https.createServer( |
| 834 | 885 | { |
| @@ -837,7 +888,15 @@ function createHttpsServer(url) { | ||
| 837 | 888 | }, app); |
| 838 | 889 | server.on('error', reject); |
| 839 | 890 | server.on('listening', resolve); |
| 840 | - server.listen(Number(url.port || 443), url.hostname); | |
| 891 | + | |
| 892 | + let host = url.hostname; | |
| 893 | + if (ipVersion === 6) host = urlHostnameToIPv6(url.hostname); | |
| 894 | + server.listen({ | |
| 895 | + host: host, | |
| 896 | + port: Number(url.port || 443), | |
| 897 | + // see https://nodejs.org/api/net.html#serverlisten for why ipv6Only is used | |
| 898 | + ipv6Only: true, | |
| 899 | + }); | |
| 841 | 900 | }); |
| 842 | 901 | } |
| 843 | 902 | |
| @@ -847,24 +906,32 @@ function createHttpsServer(url) { | ||
| 847 | 906 | * @returns {Promise<void>} A promise that resolves when the server is listening |
| 848 | 907 | * @throws {Error} If the server fails to start |
| 849 | 908 | */ |
| 850 | 909 | function createHttpServer(url, ipVersion) { |
| 851 | 910 | return new Promise((resolve, reject) => { |
| 852 | 911 | const server = http.createServer(app); |
| 853 | 912 | server.on('error', reject); |
| 854 | 913 | server.on('listening', resolve); |
| 855 | - server.listen(Number(url.port || 80), url.hostname); | |
| 914 | + | |
| 915 | + let host = url.hostname; | |
| 916 | + if (ipVersion === 6) host = urlHostnameToIPv6(url.hostname); | |
| 917 | + server.listen({ | |
| 918 | + host: host, | |
| 919 | + port: Number(url.port || 80), | |
| 920 | + // see https://nodejs.org/api/net.html#serverlisten for why ipv6Only is used | |
| 921 | + ipv6Only: true, | |
| 922 | + }); | |
| 856 | 923 | }); |
| 857 | 924 | } |
| 858 | 925 | |
| 859 | 926 | async function startHTTPorHTTPS(useIPv6, useIPv4) { |
| 860 | 927 | let v6Failed = false; |
| 861 | 928 | let v4Failed = false; |
| 862 | 929 | |
| 863 | 930 | const createFunc = cliArguments.ssl ? createHttpsServer : createHttpServer; |
| 864 | 931 | |
| 865 | 932 | if (enableIPv6useIPv6) { |
| 866 | 933 | try { |
| 867 | 934 | await createFunc(tavernUrlV6, 6); |
| 868 | 935 | } catch (error) { |
| 869 | 936 | console.error('non-fatal error: failed to start server on IPv6'); |
| 870 | 937 | console.error(error); |
| @@ -873,9 +940,9 @@ async function startHTTPorHTTPS() { | ||
| 873 | 940 | } |
| 874 | 941 | } |
| 875 | 942 | |
| 876 | 943 | if (enableIPv4useIPv4) { |
| 877 | 944 | try { |
| 878 | 945 | await createFunc(tavernUrl, 4); |
| 879 | 946 | } catch (error) { |
| 880 | 947 | console.error('non-fatal error: failed to start server on IPv4'); |
| 881 | 948 | console.error(error); |
| @@ -888,10 +955,60 @@ async function startHTTPorHTTPS() { | ||
| 888 | 955 | } |
| 889 | 956 | |
| 890 | 957 | async function startServer() { |
| 891 | - const [v6Failed, v4Failed] = await startHTTPorHTTPS(); | |
| 958 | + let useIPv6 = (enableIPv6 === true); | |
| 959 | + let useIPv4 = (enableIPv4 === true); | |
| 960 | + let hasIPv6, hasIPv4, hasIPv6Local, hasIPv4Local, hasIPv6Any, hasIPv4Any; | |
| 961 | + | |
| 962 | + | |
| 963 | + if (enableIPv6 === 'auto' || enableIPv4 === 'auto') { | |
| 964 | + [hasIPv6Any, hasIPv4Any, hasIPv6Local, hasIPv4Local] = await getHasIP(); | |
| 965 | + | |
| 966 | + hasIPv6 = listen ? hasIPv6Any : hasIPv6Local; | |
| 967 | + if (enableIPv6 === 'auto') { | |
| 968 | + useIPv6 = hasIPv6; | |
| 969 | + } | |
| 970 | + if (hasIPv6) { | |
| 971 | + if (useIPv6) { | |
| 972 | + console.log(color.green('IPv6 support detected')); | |
| 973 | + } else { | |
| 974 | + console.log('IPv6 support detected (but disabled)'); | |
| 975 | + } | |
| 976 | + } | |
| 977 | + | |
| 978 | + | |
| 979 | + hasIPv4 = listen ? hasIPv4Any : hasIPv4Local; | |
| 980 | + if (enableIPv4 === 'auto') { | |
| 981 | + useIPv4 = hasIPv4; | |
| 982 | + } | |
| 983 | + if (hasIPv4) { | |
| 984 | + if (useIPv4) { | |
| 985 | + console.log(color.green('IPv4 support detected')); | |
| 986 | + } else { | |
| 987 | + console.log('IPv4 support detected (but disabled)'); | |
| 988 | + } | |
| 989 | + } | |
| 990 | + } else { | |
| 991 | + console.log("Neither protocol: ipv6, nor ipv4 are set to auto, skipping detection") | |
| 992 | + } | |
| 993 | + | |
| 994 | + | |
| 995 | + | |
| 996 | + if (enableIPv6 === 'auto' && enableIPv4 === 'auto') { | |
| 997 | + if (!hasIPv6 && !hasIPv4) { | |
| 998 | + console.error('Both IPv6 and IPv4 are not detected'); | |
| 999 | + process.exit(1); | |
| 1000 | + } | |
| 1001 | + } | |
| 1002 | + | |
| 1003 | + if (!useIPv6 && !useIPv4) { | |
| 1004 | + 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'); | |
| 1005 | + process.exit(1); | |
| 1006 | + } | |
| 1007 | + | |
| 1008 | + const [v6Failed, v4Failed] = await startHTTPorHTTPS(useIPv6, useIPv4); | |
| 892 | 1009 | |
| 893 | 1010 | handleServerListenFail(v6Failed, v4Failed, useIPv6, useIPv4); |
| 894 | 1011 | postSetupTasks(v6Failed, v4Failed, useIPv6, useIPv4); |
| 895 | 1012 | } |
| 896 | 1013 | |
| 897 | 1014 | async function verifySecuritySettings() { |
| @@ -692,6 +692,22 @@ export function isValidUrl(url) { | ||
| 692 | 692 | } |
| 693 | 693 | } |
| 694 | 694 | |
| 695 | +export function urlHostnameToIPv6(hostname) { | |
| 696 | + if (hostname.startsWith('[')) { | |
| 697 | + hostname = hostname.slice(1); | |
| 698 | + } | |
| 699 | + if (hostname.endsWith(']')) { | |
| 700 | + hostname = hostname.slice(0, -1); | |
| 701 | + } | |
| 702 | + return hostname; | |
| 703 | +} | |
| 704 | + | |
| 705 | +export function stringToBool(str) { | |
| 706 | + if (str === 'true') return true; | |
| 707 | + if (str === 'false') return false; | |
| 708 | + return str; | |
| 709 | +} | |
| 710 | + | |
| 695 | 711 | /** |
| 696 | 712 | * MemoryLimitedMap class that limits the memory usage of string values. |
| 697 | 713 | */ |