diff --git a/server/chat.js b/server/chat.js index 4773e55..642d72d 100644 --- a/server/chat.js +++ b/server/chat.js @@ -1,13 +1,14 @@ const WebSocket = require('ws'); -const { serverConfig } = require('./server_config'); +const { serverConfig, configExists } = require('./server_config'); const { logChat } = require('./console'); const helpers = require('./helpers'); +const storage = require('./storage.js'); function heartbeat() { // WebSocket heartbeat helper this.isAlive = true; } -function createChatServer(storage) { +function createChatServer() { if (!serverConfig.webserver.chatEnabled) return; const chatWss = new WebSocket.Server({ noServer: true }); @@ -119,4 +120,5 @@ function createChatServer(storage) { storage.websocket_delegation.set("/chat", chatWss); } -module.exports = { createChatServer }; \ No newline at end of file +if(!configExists()) return; +createChatServer(); \ No newline at end of file diff --git a/server/console.js b/server/console.js index 5d1c9cf..7646e46 100644 --- a/server/console.js +++ b/server/console.js @@ -3,7 +3,7 @@ const fs = require('fs').promises; const verboseMode = process.argv.includes('--debug'); const verboseModeFfmpeg = process.argv.includes('--ffmpegdebug'); -const LOG_FILE = process.argv.includes('--config') && process.argv[process.argv.indexOf('--config') + 1] +const LOG_FILE = process.argv.includes('--config') && process.argv[process.argv.indexOf('--config') + 1] ? `serverlog_${process.argv[process.argv.indexOf('--config') + 1]}.txt` : 'serverlog.txt'; const ANSI_ESCAPE_CODE_PATTERN = /\x1b\[[0-9;]*m/g; diff --git a/server/datahandler.js b/server/datahandler.js index 53417fa..8dbad07 100644 --- a/server/datahandler.js +++ b/server/datahandler.js @@ -90,7 +90,7 @@ function handleData(wss, receivedData, rdsWss) { let modifiedData, parsedValue; const receivedLines = receivedData.split('\n'); - + for (const receivedLine of receivedLines) { switch (true) { case receivedLine.startsWith('F'): // Bandwidth diff --git a/server/helpers.js b/server/helpers.js index 936ca4b..144062e 100644 --- a/server/helpers.js +++ b/server/helpers.js @@ -230,7 +230,7 @@ function processConnection(clientIp, locationInfo, currentUsers, ws, callback) { .join(', ') : 'Unknown'; const userLocationForLog = locationInfo?.isp ? `${userLocation} (${locationInfo.isp})` : userLocation; - + storage.connectedUsers.push({ ip: clientIp, location: userLocation, diff --git a/server/index.js b/server/index.js index 87febe1..3e3a67f 100644 --- a/server/index.js +++ b/server/index.js @@ -1,76 +1,35 @@ -const express = require('express'); -const endpoints = require('./endpoints'); -const session = require('express-session'); -const bodyParser = require('body-parser'); -const http = require('http'); -const readline = require('readline'); -const app = express(); -const httpServer = http.createServer(app); -const WebSocket = require('ws'); -const path = require('path'); -const net = require('net'); const { SerialPort } = require('serialport'); const tunnel = require('./tunnel'); -const { createChatServer } = require('./chat'); -const figlet = require('figlet'); - const helpers = require('./helpers'); -const { findServerFiles, startPluginsWithDelay } = helpers; - const dataHandler = require('./datahandler'); -const fmdxList = require('./fmdx_list'); const { logError, logInfo, logWarn } = require('./console'); -const storage = require('./storage'); const { serverConfig, configExists } = require('./server_config'); const pluginsApi = require('./plugins_api'); -const pjson = require('../package.json'); -const client = new net.Socket(); -const wss = new WebSocket.Server({ noServer: true }); -const rdsWss = new WebSocket.Server({ noServer: true }); -const pluginsWss = new WebSocket.Server({ noServer: true, perMessageDeflate: true }); - -storage.websocket_delegation.set("/text", wss); -storage.websocket_delegation.set("/rds", rdsWss); -storage.websocket_delegation.set("/rdsspy", rdsWss); -storage.websocket_delegation.set("/data_plugins", pluginsWss); -require('./stream/ws.js'); +const client = new (require('net')).Socket(); // Get all plugins from config and find corresponding server files -const plugins = findServerFiles(serverConfig.plugins); +const plugins = helpers.findServerFiles(serverConfig.plugins); // Start the first plugin after 3 seconds, then the rest with 3 seconds delay if (plugins.length > 0) { setTimeout(() => { - startPluginsWithDelay(plugins, 3000); // Start plugins with 3 seconds interval + helpers.startPluginsWithDelay(plugins, 3000); // Start plugins with 3 seconds interval }, 4000); // Initial delay of 4 seconds for the first plugin } -const terminalWidth = readline.createInterface({input: process.stdin, output: process.stdout}).output.columns; +const terminalWidth = require('readline').createInterface({input: process.stdin, output: process.stdout}).output.columns; -console.log('\x1b[32m' + figlet.textSync("FM-DX Webserver")); +console.log('\x1b[32m' + require('figlet').textSync("FM-DX Webserver")); console.log('\x1b[32m\x1b[2mby Noobish @ \x1b[4mFMDX.org + KubaPro010\x1b[0m'); -console.log("v" + pjson.version) +console.log("v" + require('../package.json').version) console.log('\x1b[90m' + '─'.repeat(terminalWidth - 1) + '\x1b[0m'); require('./plugins'); -let currentUsers = 0; let serialport; -let timeoutAntenna; - -app.use(bodyParser.urlencoded({ extended: true })); -const sessionMiddleware = session({ - secret: 'GTce3tN6U8odMwoI', // Cool - resave: false, - saveUninitialized: true, -}); -app.use(sessionMiddleware); -app.use(bodyParser.json()); -createChatServer(storage); tunnel.download(); -require('./stream/index'); connectToXdrd(); connectToSerial(); @@ -268,272 +227,7 @@ client.on('error', (err) => { } }); -const tunerLockTracker = new WeakMap(); -const ipConnectionCounts = new Map(); // Per-IP limit variables -const ipLogTimestamps = new Map(); -const MAX_CONNECTIONS_PER_IP = 4; -const IP_LOG_INTERVAL_MS = 60000; -// Remove old per-IP limit addresses -setInterval(() => { - const now = Date.now(); - - for (const [ip, count] of ipConnectionCounts.entries()) { - const lastSeen = ipLogTimestamps.get(ip) || 0; - const inactive = now - lastSeen > 60 * 60 * 1000; - - if (count === 0 && inactive) { - ipConnectionCounts.delete(ip); - ipLogTimestamps.delete(ip); - } - } -}, 30 * 60 * 1000); // Run every half hour - -wss.on('connection', (ws, request) => { - const output = serverConfig.xdrd.wirelessConnection ? client : serialport; - let clientIp = helpers.getIpAddress(request); - const userCommandHistory = {}; - - // Per-IP limit connection open - if (clientIp) { - const isLocalIp = ( - clientIp === '127.0.0.1' || clientIp === '::1' || - clientIp.startsWith('192.168.') || clientIp.startsWith('10.') || clientIp.startsWith('172.16.')); - if (!isLocalIp) { - if (!ipConnectionCounts.has(clientIp)) ipConnectionCounts.set(clientIp, 0); - const currentCount = ipConnectionCounts.get(clientIp); - if (currentCount >= MAX_CONNECTIONS_PER_IP) { - ws.close(1008, 'Too many open connections from this IP'); - const lastLogTime = ipLogTimestamps.get(clientIp) || 0; - const now = Date.now(); - if (now - lastLogTime > IP_LOG_INTERVAL_MS) { - logWarn(`Web client \x1b[31mclosed: limit exceeded\x1b[0m (${clientIp}) \x1b[90m[${currentUsers}]`); - ipLogTimestamps.set(clientIp, now); - } - return; - } - ipConnectionCounts.set(clientIp, currentCount + 1); - } - } - - if (clientIp !== '127.0.0.1' || - (request.socket && request.socket.remoteAddress && request.socket.remoteAddress !== '::ffff:127.0.0.1') || - (request.headers && request.headers['origin'] && request.headers['origin'].trim() !== '')) { - currentUsers++; - } - - if (timeoutAntenna) clearTimeout(timeoutAntenna); - - helpers.handleConnect(clientIp, currentUsers, ws, (result) => { - if (result === "User banned") { - ws.close(1008, 'Banned IP'); - return; - } - dataHandler.showOnlineUsers(currentUsers); - - if (currentUsers === 1 && serverConfig.autoShutdown === true && serverConfig.xdrd.wirelessConnection) serverConfig.xdrd.wirelessConnection ? connectToXdrd() : serialport.write('x\n'); - }); - - const userCommands = {}; - let lastWarn = { time: 0 }; - - ws.on('message', (message) => { - const command = helpers.antispamProtection(message, clientIp, ws, userCommands, lastWarn, userCommandHistory, '18', 'text', 16 * 1024); - - if (!clientIp.includes("127.0.0.1")) { - if (((command.startsWith('X') || command.startsWith('Y')) && !request.session.isAdminAuthenticated) || - ((command.startsWith('F') || command.startsWith('W')) && serverConfig.bwSwitch === false)) { - logWarn(`User \x1b[90m${clientIp}\x1b[0m attempted to send a potentially dangerous command: ${command.slice(0, 64)}.`); - return; - } - } - - if (command.includes("\'")) return; - - const { isAdminAuthenticated, isTuneAuthenticated } = request.session || {}; - - if (command.startsWith('w') && (isAdminAuthenticated || isTuneAuthenticated)) { - switch (command) { - case 'wL1': - if (isAdminAuthenticated) serverConfig.lockToAdmin = true; - break; - case 'wL0': - if (isAdminAuthenticated) serverConfig.lockToAdmin = false; - break; - case 'wT0': - serverConfig.publicTuner = true; - if(!isAdminAuthenticated) tunerLockTracker.delete(ws); - break; - case 'wT1': - serverConfig.publicTuner = false; - if(!isAdminAuthenticated) tunerLockTracker.set(ws, true); - break; - default: - break; - } - } - - if (command.startsWith('T')) { - const tuneFreq = Number(command.slice(1)) / 1000; - const { tuningLimit, tuningLowerLimit, tuningUpperLimit } = serverConfig.webserver; - - if (tuningLimit && (tuneFreq < tuningLowerLimit || tuneFreq > tuningUpperLimit) || isNaN(tuneFreq)) return; - } - - if ((serverConfig.publicTuner && !serverConfig.lockToAdmin) || isAdminAuthenticated || (!serverConfig.publicTuner && !serverConfig.lockToAdmin && isTuneAuthenticated)) output.write(`${command}\n`); - }); - - ws.on('close', (code, reason) => { - // Per-IP limit connection closed - if (clientIp) { - const isLocalIp = ( - clientIp === '127.0.0.1' || - clientIp === '::1' || - clientIp.startsWith('192.168.') || - clientIp.startsWith('10.') || - clientIp.startsWith('172.16.') - ); - if (!isLocalIp) { - const current = ipConnectionCounts.get(clientIp) || 1; - ipConnectionCounts.set(clientIp, Math.max(0, current - 1)); - } - } - - if (clientIp !== ':127.0.0.1' || - (request.socket && request.socket.remoteAddress && request.socket.remoteAddress !== '::ffff:127.0.0.1') || - (request.headers && request.headers['origin'] && request.headers['origin'].trim() !== '')) { - currentUsers--; - } - dataHandler.showOnlineUsers(currentUsers); - - const index = storage.connectedUsers.findIndex(user => user.ip === clientIp); - if (index !== -1) storage.connectedUsers.splice(index, 1); - - if (currentUsers === 0) { - storage.connectedUsers = []; - - if (serverConfig.bwAutoNoUsers === "1") output.write("W0\n"); // Auto BW 'Enabled' - - // cEQ and iMS combinations - if (serverConfig.ceqNoUsers === "1" && serverConfig.imsNoUsers === "1") output.write("G00\n"); // Both Disabled - else if (serverConfig.ceqNoUsers === "1" && serverConfig.imsNoUsers === "0") output.write(`G0${dataHandler.dataToSend.ims}\n`); - else if (serverConfig.ceqNoUsers === "0" && serverConfig.imsNoUsers === "1") output.write(`G${dataHandler.dataToSend.eq}0\n`); - else if (serverConfig.ceqNoUsers === "2" && serverConfig.imsNoUsers === "0") output.write(`G1${dataHandler.dataToSend.ims}\n`); - else if (serverConfig.ceqNoUsers === "0" && serverConfig.imsNoUsers === "2") output.write(`G${dataHandler.dataToSend.eq}1\n`); - else if (serverConfig.ceqNoUsers === "2" && serverConfig.imsNoUsers === "1") output.write("G10\n"); // Only cEQ enabled - else if (serverConfig.ceqNoUsers === "1" && serverConfig.imsNoUsers === "2") output.write("G01\n"); // Only iMS enabled - else if (serverConfig.ceqNoUsers === "2" && serverConfig.imsNoUsers === "2") output.write("G11\n"); // Both Enabled - - // Handle stereo mode - if (serverConfig.stereoNoUsers === "1") output.write("B0\n"); - else if (serverConfig.stereoNoUsers === "2") output.write("B1\n"); - - // Handle Antenna selection - if (timeoutAntenna) clearTimeout(timeoutAntenna); - timeoutAntenna = setTimeout(() => { - if (serverConfig.antennaNoUsers === "1") output.write("Z0\n"); - else if (serverConfig.antennaNoUsers === "2") output.write("Z1\n"); - else if (serverConfig.antennaNoUsers === "3") output.write("Z2\n"); - else if (serverConfig.antennaNoUsers === "4") output.write("Z3\n"); - }, serverConfig.antennaNoUsersDelay ? 15000 : 0); - } - - if (tunerLockTracker.has(ws)) { - logInfo(`User who locked the tuner left. Unlocking the tuner.`); - output.write('wT0\n') - tunerLockTracker.delete(ws); - serverConfig.publicTuner = true; - } - - if (currentUsers === 0 && serverConfig.enableDefaultFreq === true && - serverConfig.autoShutdown !== true && serverConfig.xdrd.wirelessConnection === true) { - setTimeout(function() { - if (currentUsers === 0) { - output.write('T' + Math.round(serverConfig.defaultFreq * 1000) + '\n'); - dataHandler.resetToDefault(dataHandler.dataToSend); - dataHandler.dataToSend.freq = Number(serverConfig.defaultFreq).toFixed(3); - dataHandler.initialData.freq = Number(serverConfig.defaultFreq).toFixed(3); - } - }, 10000); - } - - if (currentUsers === 0 && serverConfig.autoShutdown === true && serverConfig.xdrd.wirelessConnection) client.write('X\n'); - - if (code !== 1008) logInfo(`Web client \x1b[31mdisconnected\x1b[0m (${clientIp}) \x1b[90m[${currentUsers}]`); - }); - - ws.on('error', console.error); -}); - -// Additional web socket for using plugins -pluginsWss.on('connection', (ws, request) => { - const clientIp = helpers.getIpAddress(request); - const userCommandHistory = {}; - // Anti-spam tracking for each client - const userCommands = {}; - let lastWarn = { time: 0 }; - - ws.on('message', message => { - // Anti-spam - helpers.antispamProtection(message, clientIp, ws, userCommands, lastWarn, userCommandHistory, '10', 'data_plugins'); - - try { - let messageData = JSON.parse(message); // Attempt to parse the JSON - - if (messageData.type === "GPS" && messageData.value) { - const gpsData = messageData.value; - const { status, time, lat, lon, alt, mode } = gpsData; - - if (status === "active") { - Latitude = parseFloat(lat); - Longitude = parseFloat(lon); - } - } - } catch (error) {} - - // Broadcast the message to all other clients - pluginsWss.clients.forEach(client => { - if (client.readyState === WebSocket.OPEN) client.send(message); // Send the message to all clients - }); - }); - - ws.on('error', error => { - logError('WebSocket Extra error: ' + error); // Use custom logError function - }); -}); - -httpServer.on('upgrade', (request, socket, head) => { - if (serverConfig.webserver.banlist?.includes(helpers.getIpAddress(request))) { - socket.destroy(); - return; - } - - const upgradeWss = storage.websocket_delegation.get(request.url); - if(upgradeWss) { - sessionMiddleware(request, {}, () => { - upgradeWss.handleUpgrade(request, socket, head, (ws) => { - upgradeWss.emit('connection', ws, request); - }); - }); - } else socket.destroy(); -}); - -app.set('view engine', 'ejs'); -app.set('views', path.join(__dirname, '../views')); -app.use('/', endpoints); -app.use(express.static(path.join(__dirname, '../web'))); // Serve the entire web folder to the user -pluginsApi.registerServerContext({ wss, pluginsWss, httpServer, serverConfig }); - -const logServerStart = (address) => { - logInfo(`Web server has started on address \x1b[34mhttp://${address}:${serverConfig.webserver.webserverPort}\x1b[0m.`); -}; - -const startServer = (address) => { - httpServer.listen(serverConfig.webserver.webserverPort, address, () => { - if (!configExists()) logInfo(`Open your browser and proceed to \x1b[34mhttp://${address}:${serverConfig.webserver.webserverPort}\x1b[0m to continue with setup.`); - else logServerStart(address); - }); -}; - -startServer(serverConfig.webserver.webserverIp === '0.0.0.0' ? 'localhost' : serverConfig.webserver.webserverIp); +require('./stream/index'); +require("./web")(serverConfig.webserver.webserverIp === '0.0.0.0' ? 'localhost' : serverConfig.webserver.webserverIp); tunnel.connect(); -fmdxList.update(); \ No newline at end of file +require('./fmdx_list').update(); \ No newline at end of file diff --git a/server/plugins.js b/server/plugins.js index dda88f8..1acbdbc 100644 --- a/server/plugins.js +++ b/server/plugins.js @@ -38,7 +38,7 @@ function parsePluginConfig(filePath) { if (fs.existsSync(destinationFile)) fs.unlinkSync(destinationFile); // Remove existing file/symlink fs.symlinkSync(sourcePath, destinationFile); setTimeout(function() { - consoleCmd.logInfo(`Plugin ${pluginConfig.name} ${pluginConfig.version} initialized successfully.`); + consoleCmd.logInfo(`Plugin ${pluginConfig.name} ${pluginConfig.version} initialized successfully.`); }, 500) } catch (err) { console.error(`Error creating symlink at ${destinationFile}: ${err.message}`); diff --git a/server/rds.js b/server/rds.js index 5d26da7..ef6ef6b 100644 --- a/server/rds.js +++ b/server/rds.js @@ -236,7 +236,7 @@ class RDSDecoder { if(d_error > 2) return; // Don't risk it const idx = blockB & 0x3; - + const last_err = this.ps_errors[idx * 2]; const cur_err = Math.ceil(d_error * (10/3)); // They expect an error score of 0-10 with 10 being the worst. Too bad we don't have that resolution const character_a = decode_charset(blockD >> 8); diff --git a/server/server_config.js b/server/server_config.js index 189b784..69fbdb3 100644 --- a/server/server_config.js +++ b/server/server_config.js @@ -153,7 +153,7 @@ function configUpdate(newConfig) { serverConfig.plugins = newConfig.plugins; delete newConfig.webserver.banlist; } - + deepMerge(serverConfig, newConfig); // Overwrite with newConfig values configSave(); } diff --git a/server/stream/index.js b/server/stream/index.js index dc728b6..d549800 100644 --- a/server/stream/index.js +++ b/server/stream/index.js @@ -1,8 +1,13 @@ +const { PassThrough } = require('stream'); +const audio_pipe = new PassThrough(); +module.exports = audio_pipe; // Important + +const { serverConfig, configExists } = require('../server_config'); +if (!configExists()) return; + const { spawn } = require('child_process'); -const { serverConfig } = require('../server_config'); const { logDebug, logError, logInfo, logWarn, logFfmpeg } = require('../console'); const checkFFmpeg = require('./checkFFmpeg'); -const { PassThrough } = require('stream'); const consoleLogTitle = '[Audio Stream]'; @@ -15,7 +20,6 @@ function connectMessage(message) { } } -const audio_pipe = new PassThrough(); checkFFmpeg().then((ffmpegPath) => { logInfo(`${consoleLogTitle} Using ${ffmpegPath === 'ffmpeg' ? 'system-installed FFmpeg' : 'ffmpeg-static'}`); @@ -137,6 +141,4 @@ checkFFmpeg().then((ffmpegPath) => { }).catch((err) => { logError(`${consoleLogTitle} Error: ${err.message}`); -}); - -module.exports = audio_pipe; \ No newline at end of file +}); \ No newline at end of file diff --git a/server/stream/ws.js b/server/stream/ws.js index 681e371..0966de7 100644 --- a/server/stream/ws.js +++ b/server/stream/ws.js @@ -11,9 +11,7 @@ audio_pipe.on('data', (chunk) => { }); audio_pipe.on('end', () => { - audioWss.clients.forEach((client) => { - client.close(1001, "Audio stream ended"); - }); + audioWss.clients.forEach((client) => client.close(1001, "Audio stream ended")); }); storage.websocket_delegation.set("/audio", audioWss); \ No newline at end of file diff --git a/server/tunnel.js b/server/tunnel.js index 654315c..0688ed0 100644 --- a/server/tunnel.js +++ b/server/tunnel.js @@ -20,7 +20,7 @@ async function download() { if (!await fileExists(frpcPath)) { logInfo('frpc binary, required for tunnel is not available. Downloading now...'); const frpcFileName = `frpc_${os.platform}_${os.arch}` + (os.platform() === 'win32' ? '.exe' : ''); - + try { const res = await fetch('https://fmtuner.org/binaries/' + frpcFileName); if (res.status === 404) throw new Error('404 error'); @@ -40,7 +40,7 @@ async function connect() { if (serverConfig.tunnel?.enabled === true) { const librariesDir = path.resolve(__dirname, '../libraries'); const frpcPath = path.resolve(librariesDir, 'frpc' + (os.platform() === 'win32' ? '.exe' : '')); - + const cfg = ejs.render(frpcConfigTemplate, { cfg: serverConfig.tunnel, host: serverConfig.tunnel.community.enabled ? serverConfig.tunnel.community.host : ((serverConfig.tunnel.region == "pldx") ? "pldx.duckdns.org" : (serverConfig.tunnel.region + ".fmtuner.org")), @@ -67,11 +67,11 @@ async function connect() { else if (line.includes('login to server success')) logInfo('Connection to tunnel server was successful'); else logDebug('Tunnel log:', line); }); - + child.on('error', (err) => { logError('Failed to start tunnel process:', err); }); - + child.on('close', (code) => { logInfo(`Tunnel process exited with code ${code}`); }); diff --git a/server/tx_search.js b/server/tx_search.js index b76094a..bf2a1b3 100644 --- a/server/tx_search.js +++ b/server/tx_search.js @@ -1,5 +1,7 @@ +const { serverConfig, configExists } = require('./server_config'); +if(!configExists()) return; + const fetch = require('node-fetch'); -const { serverConfig } = require('./server_config'); const consoleCmd = require('./console'); let localDb = {}; @@ -154,10 +156,10 @@ function validPsCompare(rdsPs, stationPs) { // Standardize the rdsPs string: replace spaces with underscores and convert to lowercase. const standardizedRdsPs = rdsPs.replace(/ /g, '_').toLowerCase(); - + // Split stationPs into tokens (e.g., "__mdr___ _kultur_" -> ["__mdr___", "_kultur_"]) const psTokens = stationPs.split(/\s+/).filter(token => token.length > 0).map(token => { const lower = token.toLowerCase(); return lower.length < 8 ? lower.padEnd(8, '_') : lower; }); - + // Iterate through all tokens and check if any token yields at least three valid (non "_" ) matches. for (let token of psTokens) { // If total non "_" length of token is less than 3, allow match based on that length instead @@ -165,7 +167,7 @@ function validPsCompare(rdsPs, stationPs) { const minMatchLen = tokenLength > 2 ? 3 : tokenLength; // If the token's length does not match the standardized rdsPs length, skip this token. if (token.length !== standardizedRdsPs.length) continue; - + let matchCount = 0; for (let i = 0; i < standardizedRdsPs.length; i++) { // Skip this position if the character in standardizedRdsPs is an underscore. @@ -234,7 +236,7 @@ async function fetchTx(freq, piCode, rdsPs) { if (filteredLocations.length > 1) { const extraFilteredLocations = filteredLocations.map(locData => ({ ...locData, - stations: locData.stations.filter(station => + stations: locData.stations.filter(station => station.ps?.toLowerCase().includes( rdsPs.replace(/ /g, '_').toLowerCase() ) ?? false @@ -243,7 +245,7 @@ async function fetchTx(freq, piCode, rdsPs) { if (extraFilteredLocations.length > 0) filteredLocations = extraFilteredLocations; } - + for (let loc of filteredLocations) { loc = Object.assign(loc, loc.stations[0]); delete loc.stations; @@ -251,7 +253,7 @@ async function fetchTx(freq, piCode, rdsPs) { loc = Object.assign(loc, dist); loc.detectedByPireg = (loc.pireg === piCode.toUpperCase()); } - + if (filteredLocations.length > 1) { // Check for any 10kW+ stations within 700km, and don't Es weight if any found. const tropoPriority = filteredLocations.some(loc => loc.distanceKm < 700 && loc.erp >= 10); diff --git a/server/web.js b/server/web.js new file mode 100644 index 0000000..8f8d61b --- /dev/null +++ b/server/web.js @@ -0,0 +1,314 @@ +const http = require('http'); +const WebSocket = require('ws'); +const storage = require('./storage.js'); +const { serverConfig, configExists } = require('./server_config'); +const path = require('path'); +const endpoints = require('./endpoints'); +const pluginsApi = require('./plugins_api'); +const { logError, logInfo, logWarn } = require('./console'); +const { send_to_xdr } = require("./xdr_server") + +const express = require('express'); +const session = require('express-session'); +const bodyParser = require('body-parser'); +const app = express(); +const httpServer = http.createServer(app); + +app.use(bodyParser.urlencoded({ extended: true })); +const sessionMiddleware = session({ + secret: 'GTce3tN6U8odMwoI', // Cool + resave: false, + saveUninitialized: true, +}); +app.use(sessionMiddleware); +app.use(bodyParser.json()); + +const wss = new WebSocket.Server({ noServer: true }); +const rdsWss = new WebSocket.Server({ noServer: true }); +const pluginsWss = new WebSocket.Server({ noServer: true, perMessageDeflate: true }); +require('./chat'); + +const tunerLockTracker = new WeakMap(); +const ipConnectionCounts = new Map(); // Per-IP limit variables +const ipLogTimestamps = new Map(); +const MAX_CONNECTIONS_PER_IP = 4; +const IP_LOG_INTERVAL_MS = 60000; +const MAX_USERS = 12; // Server max + +setInterval(() => { + const now = Date.now(); + + for (const [ip, count] of ipConnectionCounts.entries()) { + const lastSeen = ipLogTimestamps.get(ip) || 0; + const inactive = now - lastSeen > 60 * 60 * 1000; + + if (count === 0 && inactive) { + ipConnectionCounts.delete(ip); + ipLogTimestamps.delete(ip); + } + } +}, 30 * 60 * 1000); // Run every half hour (this is a stop sign ahh comment, i wrote it lol) + +let currentUsers = 0; +let timeoutAntenna; + +wss.on('connection', (ws, request) => { + const output = serverConfig.xdrd.wirelessConnection ? client : serialport; + let clientIp = helpers.getIpAddress(request); + const userCommandHistory = {}; + + // Per-IP limit connection open + if (clientIp) { + const isLocalIp = ( + clientIp === '127.0.0.1' || clientIp === '::1' || + clientIp.startsWith('192.168.') || clientIp.startsWith('10.') || clientIp.startsWith('172.16.')); + if (!isLocalIp) { + if (!ipConnectionCounts.has(clientIp)) ipConnectionCounts.set(clientIp, 0); + const currentCount = ipConnectionCounts.get(clientIp); + if (currentCount >= MAX_CONNECTIONS_PER_IP) { + ws.close(1008, 'Too many open connections from this IP'); + const lastLogTime = ipLogTimestamps.get(clientIp) || 0; + const now = Date.now(); + if (now - lastLogTime > IP_LOG_INTERVAL_MS) { + logWarn(`Web client \x1b[31mclosed: limit exceeded\x1b[0m (${clientIp}) \x1b[90m[${currentUsers}]`); + ipLogTimestamps.set(clientIp, now); + } return; + } ipConnectionCounts.set(clientIp, currentCount + 1); + } + } + + if(currentUsers >= MAX_USERS) { + // TODO: give a nice fucking unwelcome message on the ui (we kindly ask you to FUCK OFF?) + ws.send("a0"); // This means not authenticated in XDR + ws.close(1008, 'Too many users using this server at the moment.'); + return; + } + + if (clientIp !== '127.0.0.1' || + (request.socket && request.socket.remoteAddress && request.socket.remoteAddress !== '::ffff:127.0.0.1') || + (request.headers && request.headers['origin'] && request.headers['origin'].trim() !== '')) { + currentUsers++; + } + + if (timeoutAntenna) clearTimeout(timeoutAntenna); + + helpers.handleConnect(clientIp, currentUsers, ws, (result) => { + if (result === "User banned") { + ws.close(1008, 'Banned IP'); + return; + } + dataHandler.showOnlineUsers(currentUsers); + + if (currentUsers === 1 && serverConfig.autoShutdown === true && serverConfig.xdrd.wirelessConnection) serverConfig.xdrd.wirelessConnection ? connectToXdrd() : serialport.write('x\n'); + }); + + const userCommands = {}; + let lastWarn = { time: 0 }; + + ws.on('message', (message) => { + const command = helpers.antispamProtection(message, clientIp, ws, userCommands, lastWarn, userCommandHistory, '18', 'text', 16 * 1024); + + if (!clientIp.includes("127.0.0.1")) { + if (((command.startsWith('X') || command.startsWith('Y')) && !request.session.isAdminAuthenticated) || + ((command.startsWith('F') || command.startsWith('W')) && serverConfig.bwSwitch === false)) { + logWarn(`User \x1b[90m${clientIp}\x1b[0m attempted to send a potentially dangerous command: ${command.slice(0, 64)}.`); + return; + } + } + + if (command.includes("\'")) return; + + const { isAdminAuthenticated, isTuneAuthenticated } = request.session || {}; + + if (command.startsWith('w') && (isAdminAuthenticated || isTuneAuthenticated)) { + switch (command) { + case 'wL1': + if (isAdminAuthenticated) serverConfig.lockToAdmin = true; + send_to_xdr("wL1\n"); + break; + case 'wL0': + if (isAdminAuthenticated) serverConfig.lockToAdmin = false; + send_to_xdr("wL0\n"); + break; + case 'wT0': + serverConfig.publicTuner = true; + if(!isAdminAuthenticated) tunerLockTracker.delete(ws); + send_to_xdr("wT0\n"); + break; + case 'wT1': + serverConfig.publicTuner = false; + if(!isAdminAuthenticated) tunerLockTracker.set(ws, true); + send_to_xdr("wT1\n"); + break; + default: + break; + } + } + + if (command.startsWith('T')) { + const tuneFreq = Number(command.slice(1)) / 1000; + const { tuningLimit, tuningLowerLimit, tuningUpperLimit } = serverConfig.webserver; + + if (tuningLimit && (tuneFreq < tuningLowerLimit || tuneFreq > tuningUpperLimit) || isNaN(tuneFreq)) return; + } + + if ((serverConfig.publicTuner && !serverConfig.lockToAdmin) || isAdminAuthenticated || (!serverConfig.publicTuner && !serverConfig.lockToAdmin && isTuneAuthenticated)) output.write(`${command}\n`); + }); + + ws.on('close', (code) => { + // Per-IP limit connection closed + if (clientIp) { + const isLocalIp = ( + clientIp === '127.0.0.1' || + clientIp === '::1' || + clientIp.startsWith('192.168.') || + clientIp.startsWith('10.') || + clientIp.startsWith('172.16.') + ); + if (!isLocalIp) { + const current = ipConnectionCounts.get(clientIp) || 1; + ipConnectionCounts.set(clientIp, Math.max(0, current - 1)); + } + } + + if (clientIp !== '127.0.0.1' || + (request.socket && request.socket.remoteAddress && request.socket.remoteAddress !== '::ffff:127.0.0.1') || + (request.headers && request.headers['origin'] && request.headers['origin'].trim() !== '')) { + currentUsers--; + } dataHandler.showOnlineUsers(currentUsers); + + const index = storage.connectedUsers.findIndex(user => user.ip === clientIp); + if (index !== -1) storage.connectedUsers.splice(index, 1); + + if (currentUsers === 0) { + storage.connectedUsers = []; + + if (serverConfig.bwAutoNoUsers === "1") output.write("W0\n"); // Auto BW 'Enabled' + + // cEQ and iMS combinations + if (serverConfig.ceqNoUsers === "1" && serverConfig.imsNoUsers === "1") output.write("G00\n"); // Both Disabled + else if (serverConfig.ceqNoUsers === "1" && serverConfig.imsNoUsers === "0") output.write(`G0${dataHandler.dataToSend.ims}\n`); + else if (serverConfig.ceqNoUsers === "0" && serverConfig.imsNoUsers === "1") output.write(`G${dataHandler.dataToSend.eq}0\n`); + else if (serverConfig.ceqNoUsers === "2" && serverConfig.imsNoUsers === "0") output.write(`G1${dataHandler.dataToSend.ims}\n`); + else if (serverConfig.ceqNoUsers === "0" && serverConfig.imsNoUsers === "2") output.write(`G${dataHandler.dataToSend.eq}1\n`); + else if (serverConfig.ceqNoUsers === "2" && serverConfig.imsNoUsers === "1") output.write("G10\n"); // Only cEQ enabled + else if (serverConfig.ceqNoUsers === "1" && serverConfig.imsNoUsers === "2") output.write("G01\n"); // Only iMS enabled + else if (serverConfig.ceqNoUsers === "2" && serverConfig.imsNoUsers === "2") output.write("G11\n"); // Both Enabled + + // Handle stereo mode + if (serverConfig.stereoNoUsers === "1") output.write("B0\n"); + else if (serverConfig.stereoNoUsers === "2") output.write("B1\n"); + + // Handle Antenna selection + if (timeoutAntenna) clearTimeout(timeoutAntenna); + timeoutAntenna = setTimeout(() => { + if (serverConfig.antennaNoUsers === "1") output.write("Z0\n"); + else if (serverConfig.antennaNoUsers === "2") output.write("Z1\n"); + else if (serverConfig.antennaNoUsers === "3") output.write("Z2\n"); + else if (serverConfig.antennaNoUsers === "4") output.write("Z3\n"); + }, serverConfig.antennaNoUsersDelay ? 15000 : 0); + } + + if (tunerLockTracker.has(ws)) { + logInfo(`User who locked the tuner left. Unlocking the tuner.`); + output.write('wT0\n') // Why are we telling the tuner that? + tunerLockTracker.delete(ws); + serverConfig.publicTuner = true; + } + + if (currentUsers === 0 && serverConfig.enableDefaultFreq === true && + serverConfig.autoShutdown !== true && serverConfig.xdrd.wirelessConnection === true) { + setTimeout(function() { + if (currentUsers === 0) { + output.write('T' + Math.round(serverConfig.defaultFreq * 1000) + '\n'); + dataHandler.resetToDefault(dataHandler.dataToSend); + dataHandler.dataToSend.freq = Number(serverConfig.defaultFreq).toFixed(3); + dataHandler.initialData.freq = Number(serverConfig.defaultFreq).toFixed(3); + } + }, 10000); + } + + if (currentUsers === 0 && serverConfig.autoShutdown === true && serverConfig.xdrd.wirelessConnection) client.write('X\n'); + + if (code !== 1008) logInfo(`Web client \x1b[31mdisconnected\x1b[0m (${clientIp}) \x1b[90m[${currentUsers}]`); + }); + + ws.on('error', console.error); +}); + +// Additional web socket for using plugins +pluginsWss.on('connection', (ws, request) => { + const clientIp = helpers.getIpAddress(request); + const userCommandHistory = {}; + // Anti-spam tracking for each client + const userCommands = {}; + let lastWarn = { time: 0 }; + + ws.on('message', message => { + // Anti-spam + helpers.antispamProtection(message, clientIp, ws, userCommands, lastWarn, userCommandHistory, '10', 'data_plugins'); + + try { + let messageData = JSON.parse(message); // Attempt to parse the JSON. This code will attemp to parse json using the JSON.parse which coincidentally actually parses the json + + if (messageData.type === "GPS" && messageData.value) { + const gpsData = messageData.value; + const { status, time, lat, lon, alt, mode } = gpsData; + + if (status === "active") { + Latitude = parseFloat(lat); + Longitude = parseFloat(lon); + } + } + } catch (error) {} + + // Broadcast the message to all other clients + pluginsWss.clients.forEach(client => { + if (client.readyState === WebSocket.OPEN) client.send(message); // Send the message to all clients + }); + }); + + ws.on('error', error => logError('WebSocket Extra error: ' + error)); +}); + +storage.websocket_delegation.set("/text", wss); +storage.websocket_delegation.set("/rds", rdsWss); +storage.websocket_delegation.set("/rdsspy", rdsWss); +storage.websocket_delegation.set("/data_plugins", pluginsWss); +require('./stream/ws.js'); + +httpServer.on('upgrade', (request, socket, head) => { + if (serverConfig.webserver.banlist?.includes(helpers.getIpAddress(request))) { + socket.destroy(); + return; + } + + const upgradeWss = storage.websocket_delegation.get(request.url); + if(upgradeWss) { + sessionMiddleware(request, {}, () => { + // Sure want to, but no + upgradeWss.handleUpgrade(request, socket, head, (ws) => upgradeWss.emit('connection', ws, request)); + }); + } else socket.destroy(); +}); + +app.set('view engine', 'ejs'); +app.set('views', path.join(__dirname, '../views')); +app.use('/', endpoints); +app.use(express.static(path.join(__dirname, '../web'))); +pluginsApi.registerServerContext({ wss, pluginsWss, httpServer, serverConfig }); + +const logServerStart = (address) => { + logInfo(`Web server has started on address \x1b[34mhttp://${address}:${serverConfig.webserver.webserverPort}\x1b[0m.`); +}; + +const startServer = (address) => { + httpServer.listen(serverConfig.webserver.webserverPort, address, () => { + if (!configExists()) { + logInfo(`Open your browser and proceed to \x1b[34mhttp://${address}:${serverConfig.webserver.webserverPort}\x1b[0m to continue with setup.`); + logInfo("Remember to restart afterwards!"); + } else logServerStart(address); + }); +}; + +module.exports = startServer; \ No newline at end of file diff --git a/server/xdr_server.js b/server/xdr_server.js index 58e0ded..a3bab07 100644 --- a/server/xdr_server.js +++ b/server/xdr_server.js @@ -66,12 +66,26 @@ xdr.on('connection', async (ws, request) => { ws.send(`A${initialData.agc}\n`); ws.send(`F${initialData.bw}\n`); ws.send(`W${initialData.bw}\n`); - ws.send(`OK\n`); + ws.send(`wL${serverConfig.lockToAdmin ? "1" : "0"}\n`); // Don't know how XDR-GTK will handle this, but lets hope it will be fine + ws.send(`wT${serverConfig.publicTuner ? "1" : "0"}\n`); // Again + ws.send(`OK\n`); // Make sure dumbass clients don't need to wait long for the OK, does the comms really REQUIRE you to start the receiver for you to know you are in? clients.push(ws); ws.on('message', (message) => { const data = message.toString(); + if(data.startsWith("w")) { + switch(data.trim()) { + case "wL1": + serverConfig.lockToAdmin = true; + break; + case "wL0": + serverConfig.lockToAdmin = false; + break; + // TODO: Do the wT, but not rn because i don't feel like doing the tunerlockTracker + } + } + if (!storage.ctl_output.write(data)) { ws.pause(); storage.ctl_output.once('drain', () => ws.resume());