// Library imports const express = require('express'); const endpoints = require('./endpoints'); const session = require('express-session'); const bodyParser = require('body-parser'); const http = require('http'); const httpProxy = require('http-proxy'); const readline = require('readline'); const app = express(); const httpServer = http.createServer(app); const WebSocket = require('ws'); const wss = new WebSocket.Server({ noServer: true }); const chatWss = new WebSocket.Server({ noServer: true }); const rdsWss = new WebSocket.Server({ noServer: true }); const pluginsWss = new WebSocket.Server({ noServer: true }); const fs = require('fs'); const path = require('path'); const net = require('net'); const client = new net.Socket(); const { SerialPort } = require('serialport'); const tunnel = require('./tunnel') // File imports const helpers = require('./helpers'); const dataHandler = require('./datahandler'); const fmdxList = require('./fmdx_list'); const { logDebug, logError, logInfo, logWarn, logChat } = require('./console'); const storage = require('./storage'); const { serverConfig, configExists, configSave } = require('./server_config'); const pjson = require('../package.json'); // Function to find server files based on the plugins listed in config function findServerFiles(plugins) { let results = []; plugins.forEach(plugin => { // Remove .js extension if present if (plugin.endsWith('.js')) { plugin = plugin.slice(0, -3); } const pluginPath = path.join(__dirname, '..', 'plugins', `${plugin}_server.js`); if (fs.existsSync(pluginPath) && fs.statSync(pluginPath).isFile()) { results.push(pluginPath); } }); return results; } // Start plugins with delay function startPluginsWithDelay(plugins, delay) { plugins.forEach((pluginPath, index) => { setTimeout(() => { const pluginName = path.basename(pluginPath, '.js'); // Extract plugin name from path logInfo(`-----------------------------------------------------------------`); logInfo(`Plugin ${pluginName} loaded successfully!`); require(pluginPath); }, delay * index); }); // Add final log line after all plugins are loaded setTimeout(() => { logInfo(`-----------------------------------------------------------------`); }, delay * plugins.length); } // Get all plugins from config and find corresponding server files const plugins = 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 }, 3000); // Initial delay of 3 seconds for the first plugin } const terminalWidth = readline.createInterface({ input: process.stdin, output: process.stdout }).output.columns; console.log(`\x1b[32m _____ __ __ ______ __ __ __ _ | ___| \\/ | | _ \\ \\/ / \\ \\ / /__| |__ ___ ___ _ ____ _____ _ __ | |_ | |\\/| |_____| | | \\ / \\ \\ /\\ / / _ \\ '_ \\/ __|/ _ \\ '__\\ \\ / / _ \\ '__| | _| | | | |_____| |_| / \\ \\ V V / __/ |_) \\__ \\ __/ | \\ V / __/ | |_| |_| |_| |____/_/\\_\\ \\_/\\_/ \\___|_.__/|___/\\___|_| \\_/ \\___|_| `); console.log('\x1b[32m\x1b[2mby Noobish @ \x1b[4mFMDX.org\x1b[0m'); console.log("v" + pjson.version) console.log('\x1b[90m' + '─'.repeat(terminalWidth - 1) + '\x1b[0m'); // Start ffmpeg require('./stream/index'); require('./plugins'); // Create a WebSocket proxy instance const proxy = httpProxy.createProxyServer({ target: 'ws://localhost:' + (Number(serverConfig.webserver.webserverPort) + 10), // WebSocket httpServer's address ws: true, // Enable WebSocket proxying changeOrigin: true // Change the origin of the host header to the target URL }); let currentUsers = 0; let serialport; app.use(bodyParser.urlencoded({ extended: true })); const sessionMiddleware = session({ secret: 'GTce3tN6U8odMwoI', resave: false, saveUninitialized: true, }); app.use(sessionMiddleware); app.use(bodyParser.json()); connectToXdrd(); connectToSerial(); tunnel.connect(); // Serialport retry code when port is open but communication is lost (additional code in datahandler.js) isSerialportRetrying = false; setInterval(() => { if (!isSerialportAlive && serverConfig.xdrd.wirelessConnection === false) { isSerialportAlive = true; isSerialportRetrying = true; if (serialport && serialport.isOpen) { logWarn('Communication lost from ' + serverConfig.xdrd.comPort + ', force closing serialport.'); setTimeout(() => { serialport.close((err) => { if (err) { logError('Error closing serialport: ', err.message); } }); }, 1000); } else { logWarn('Communication lost from ' + serverConfig.xdrd.comPort + '.'); } } }, 2000); // Serial Connection function connectToSerial() { if (serverConfig.xdrd.wirelessConnection === false) { // Configure the SerialPort with DTR and RTS options serialport = new SerialPort({ path: serverConfig.xdrd.comPort, baudRate: 115200, autoOpen: false, // Prevents automatic opening dtr: false, // Disable DTR rts: false // Disable RTS }); // Open the port manually after configuring DTR and RTS serialport.open((err) => { if (err) { logError('Error opening port: ' + err.message); setTimeout(() => { connectToSerial(); }, 5000); return; } logInfo('Using COM device: ' + serverConfig.xdrd.comPort); isSerialportAlive = true; setTimeout(() => { serialport.write('x\n'); }, 3000); setTimeout(() => { serialport.write('Q0\n'); serialport.write('M0\n'); serialport.write('Z0\n'); if (serverConfig.defaultFreq && serverConfig.enableDefaultFreq === true) { serialport.write('T' + Math.round(serverConfig.defaultFreq * 1000) + '\n'); dataHandler.initialData.freq = Number(serverConfig.defaultFreq).toFixed(3); dataHandler.dataToSend.freq = Number(serverConfig.defaultFreq).toFixed(3); } else if (lastFrequencyAlive && isSerialportRetrying) { // Serialport retry code when port is open but communication is lost serialport.write('T' + (lastFrequencyAlive * 1000) + '\n'); } else { serialport.write('T87500\n'); } isSerialportRetrying = false; serialport.write('A0\n'); serialport.write('F-1\n'); serialport.write('W0\n'); serialport.write('D0\n'); serialport.write('G00\n'); serverConfig.audio.startupVolume ? serialport.write('Y' + (serverConfig.audio.startupVolume * 100).toFixed(0) + '\n') : serialport.write('Y100\n'); }, 6000); serialport.on('data', (data) => { helpers.resolveDataBuffer(data, wss, rdsWss); }); serialport.on('error', (error) => { logError(error.message); }); }); // Handle port closure serialport.on('close', () => { logWarn('Disconnected from ' + serverConfig.xdrd.comPort + '. Attempting to reconnect.'); setTimeout(() => { isSerialportRetrying = true; connectToSerial(); }, 5000); }); return serialport; } } // xdrd connection function connectToXdrd() { const { xdrd } = serverConfig; if (xdrd.wirelessConnection && configExists()) { client.connect(xdrd.xdrdPort, xdrd.xdrdIp, () => { logInfo('Connection to xdrd established successfully.'); let authFlags = { authMsg: false, firstClient: false, receivedSalt: '', receivedPassword: false, messageCount: 0, }; const authDataHandler = (data) => { authFlags.messageCount++ const receivedData = data.toString(); const lines = receivedData.split('\n'); for (const line of lines) { if (authFlags.receivedPassword === false) { authFlags.receivedSalt = line.trim(); authFlags.receivedPassword = true; helpers.authenticateWithXdrd(client, authFlags.receivedSalt, xdrd.xdrdPassword); } else { if (line.startsWith('a')) { authFlags.authMsg = true; logWarn('Authentication with xdrd failed. Is your password set correctly?'); } else if (line.startsWith('o1,')) { authFlags.firstClient = true; } else if (line.startsWith('T') && line.length <= 7) { const freq = line.slice(1) / 1000; dataHandler.dataToSend.freq = freq.toFixed(3); } else if (line.startsWith('OK')) { authFlags.authMsg = true; logInfo('Authentication with xdrd successful.'); } else if (line.startsWith('G')) { const value = line.substring(1); dataHandler.initialData.eq = value.charAt(0); dataHandler.dataToSend.eq = value.charAt(0); dataHandler.initialData.ims = value.charAt(1); dataHandler.dataToSend.ims = value.charAt(1); } else if (line.startsWith('Z')) { let modifiedLine = line.slice(1); dataHandler.initialData.ant = modifiedLine; dataHandler.dataToSend.ant = modifiedLine; } if (authFlags.authMsg === true && authFlags.firstClient === true) { client.write('x\n'); client.write(serverConfig.defaultFreq && serverConfig.enableDefaultFreq === true ? 'T' + Math.round(serverConfig.defaultFreq * 1000) + '\n' : 'T87500\n'); dataHandler.initialData.freq = serverConfig.defaultFreq && serverConfig.enableDefaultFreq === true ? Number(serverConfig.defaultFreq).toFixed(3) : (87.5).toFixed(3); dataHandler.dataToSend.freq = serverConfig.defaultFreq && serverConfig.enableDefaultFreq === true ? Number(serverConfig.defaultFreq).toFixed(3) : (87.5).toFixed(3); client.write('A0\n'); client.write(serverConfig.audio.startupVolume ? 'Y' + (serverConfig.audio.startupVolume * 100).toFixed(0) + '\n' : 'Y100\n'); client.off('data', authDataHandler); return; } } } }; client.on('data', (data) => { helpers.resolveDataBuffer(data, wss, rdsWss); if (authFlags.authMsg == true && authFlags.messageCount > 1) { // If the limit is reached, remove the 'data' event listener client.off('data', authDataHandler); return; } authDataHandler(data); }); }); } } client.on('close', () => { if(serverConfig.autoShutdown === false) { logWarn('Disconnected from xdrd. Attempting to reconnect.'); setTimeout(function () { connectToXdrd(); }, 2000) } else { logWarn('Disconnected from xdrd.'); } }); client.on('error', (err) => { switch (true) { case err.message.includes("ECONNRESET"): logError("Connection to xdrd lost. Reconnecting..."); break; case err.message.includes("ETIMEDOUT"): logError("Connection to xdrd @ " + serverConfig.xdrd.xdrdIp + ":" + serverConfig.xdrd.xdrdPort + " timed out."); break; case err.message.includes("ECONNREFUSED"): logError("Connection to xdrd @ " + serverConfig.xdrd.xdrdIp + ":" + serverConfig.xdrd.xdrdPort + " failed. Is xdrd running?"); break; case err.message.includes("EINVAL"): logError("Attempts to reconnect are failing repeatedly. Consider checking your settings or restarting xdrd."); break; default: logError("Unhandled error: ", err.message); break; } }); app.set('view engine', 'ejs'); app.set('views', path.join(__dirname, '../web')); app.use('/', endpoints); // Anti-spam function function antispamProtection(message, clientIp, ws, userCommands, lastWarn, userCommandHistory, lengthCommands, endpointName) { const command = message.toString(); const now = Date.now(); if (endpointName === 'text') logDebug(`Command received from \x1b[90m${clientIp}\x1b[0m: ${command}`); // Initialize user command history if not present if (!userCommandHistory[clientIp]) { userCommandHistory[clientIp] = []; } // Record the current timestamp for the user userCommandHistory[clientIp].push(now); // Remove timestamps older than 20 ms from the history userCommandHistory[clientIp] = userCommandHistory[clientIp].filter(timestamp => now - timestamp <= 20); // Check if there are 8 or more commands in the last 20 ms if (userCommandHistory[clientIp].length >= 8) { logWarn(`User \x1b[90m${clientIp}\x1b[0m is spamming with rapid commands. Connection will be terminated and user will be banned.`); // Add to banlist if not already banned if (!serverConfig.webserver.banlist.includes(clientIp)) { serverConfig.webserver.banlist.push(clientIp); logInfo(`User \x1b[90m${clientIp}\x1b[0m has been added to the banlist due to extreme spam.`); console.log(serverConfig.webserver.banlist); configSave(); } ws.close(1008, 'Bot-like behavior detected'); return command; // Return command value before closing connection } // Update the last message time for general spam detection lastMessageTime = now; // Initialize command history for rate-limiting checks if (!userCommands[command]) { userCommands[command] = []; } // Record the current timestamp for this command userCommands[command].push(now); // Remove timestamps older than 1 second userCommands[command] = userCommands[command].filter(timestamp => now - timestamp <= 1000); // If command count exceeds limit, close connection if (userCommands[command].length > lengthCommands) { if (now - lastWarn.time > 1000) { // Check if 1 second has passed logWarn(`User \x1b[90m${clientIp}\x1b[0m is spamming command "${command}" in /${endpointName}. Connection will be terminated.`); lastWarn.time = now; // Update the last warning time } ws.close(1008, 'Spamming detected'); return command; // Return command value before closing connection } return command; // Return command value for normal execution } /** * WEBSOCKET BLOCK */ wss.on('connection', (ws, request) => { const output = serverConfig.xdrd.wirelessConnection ? client : serialport; let clientIp = request.headers['x-forwarded-for'] || request.connection.remoteAddress; const userCommandHistory = {}; if (serverConfig.webserver.banlist?.includes(clientIp)) { ws.close(1008, 'Banned IP'); return; } if (clientIp.includes(',')) { clientIp = clientIp.split(',')[0].trim(); } if (clientIp !== '::ffff:127.0.0.1' || (request.connection && request.connection.remoteAddress && request.connection.remoteAddress !== '::ffff:127.0.0.1') || (request.headers && request.headers['origin'] && request.headers['origin'].trim() !== '')) { currentUsers++; } dataHandler.showOnlineUsers(currentUsers); if(currentUsers === 1 && serverConfig.autoShutdown === true && serverConfig.xdrd.wirelessConnection) { serverConfig.xdrd.wirelessConnection === true ? connectToXdrd() : serialport.write('x\n'); } helpers.handleConnect(clientIp, currentUsers, ws); // Anti-spam tracking for each client const userCommands = {}; let lastWarn = { time: 0 }; ws.on('message', (message) => { const command = helpers.antispamProtection(message, clientIp, ws, userCommands, lastWarn, userCommandHistory, '18', 'text'); 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. You may consider blocking this user.`); return; } if (command.includes("\'")) { return; } if (command.startsWith('w') && request.session.isAdminAuthenticated) { switch (command) { case 'wL1': serverConfig.lockToAdmin = true; break; case 'wL0': serverConfig.lockToAdmin = false; break; case 'wT0': serverConfig.publicTuner = true; break; case 'wT1': serverConfig.publicTuner = false; 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; } } const { isAdminAuthenticated, isTuneAuthenticated } = request.session || {}; if ((serverConfig.publicTuner && !serverConfig.lockToAdmin) || isAdminAuthenticated || (!serverConfig.publicTuner && !serverConfig.lockToAdmin && isTuneAuthenticated)) { output.write(`${command}\n`); } }); ws.on('close', (code, reason) => { if (clientIp !== '::ffff:127.0.0.1' || (request.connection && request.connection.remoteAddress && request.connection.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 (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 === true) { client.write('X\n'); } logInfo(`Web client \x1b[31mdisconnected\x1b[0m (${clientIp}) \x1b[90m[${currentUsers}]`); }); ws.on('error', console.error); }); // CHAT WEBSOCKET BLOCK chatWss.on('connection', (ws, request) => { const clientIp = request.headers['x-forwarded-for'] || request.connection.remoteAddress; const userCommandHistory = {}; if (serverConfig.webserver.banlist?.includes(clientIp)) { ws.close(1008, 'Banned IP'); return; } // Send chat history to the newly connected client storage.chatHistory.forEach(function(message) { message.history = true; !request.session.isAdminAuthenticated ? delete message.ip : null; ws.send(JSON.stringify(message)); }); const ipMessage = { type: 'clientIp', ip: clientIp, admin: request.session.isAdminAuthenticated }; ws.send(JSON.stringify(ipMessage)); // Anti-spam tracking for each client const userCommands = {}; let lastWarn = { time: 0 }; ws.on('message', function incoming(message) { // Anti-spam const command = helpers.antispamProtection(message, clientIp, ws, userCommands, lastWarn, userCommandHistory, '5', 'chat'); let messageData; try { messageData = JSON.parse(message); } catch (error) { ws.send(JSON.stringify({ error: "Invalid message format" })); return; } // Escape nickname and other potentially unsafe fields if (messageData.nickname) { messageData.nickname = helpers.escapeHtml(messageData.nickname); } messageData.ip = clientIp; const currentTime = new Date(); const hours = String(currentTime.getHours()).padStart(2, '0'); const minutes = String(currentTime.getMinutes()).padStart(2, '0'); messageData.time = `${hours}:${minutes}`; // Adding current time to the message object in hours:minutes format if (serverConfig.webserver.banlist?.includes(clientIp)) { return; } if (request.session.isAdminAuthenticated === true) { messageData.admin = true; } if (messageData.message.length > 255) { messageData.message = messageData.message.substring(0, 255); } storage.chatHistory.push(messageData); if (storage.chatHistory.length > 50) { storage.chatHistory.shift(); } logChat(messageData); chatWss.clients.forEach(function each(client) { if (client.readyState === WebSocket.OPEN) { // Only include IP for admin clients let responseMessage = { ...messageData }; if (request.session.isAdminAuthenticated !== true) { delete responseMessage.ip; } const modifiedMessage = JSON.stringify(responseMessage); client.send(modifiedMessage); } }); }); ws.on('close', function close() {}); }); // Additional web socket for using plugins pluginsWss.on('connection', (ws, request) => { const clientIp = request.headers['x-forwarded-for'] || request.connection.remoteAddress; const userCommandHistory = {}; if (serverConfig.webserver.banlist?.includes(clientIp)) { ws.close(1008, 'Banned IP'); return; } // Anti-spam tracking for each client const userCommands = {}; let lastWarn = { time: 0 }; ws.on('message', message => { // Anti-spam const command = helpers.antispamProtection(message, clientIp, ws, userCommands, lastWarn, userCommandHistory, '10', 'data_plugins'); let messageData; try { messageData = JSON.parse(message); // Attempt to parse the JSON } catch (error) { // console.error("Failed to parse message:", error); // Log the error return; // Exit if parsing fails } const modifiedMessage = JSON.stringify(messageData); // Broadcast the message to all other clients pluginsWss.clients.forEach(client => { if (client.readyState === WebSocket.OPEN) { client.send(modifiedMessage); // Send the message to all clients } }); }); ws.on('close', () => { // logInfo('WebSocket Extra connection closed'); // Use custom logInfo function }); ws.on('error', error => { logError('WebSocket Extra error: ' + error); // Use custom logError function }); }); // Additional web socket for using plugins pluginsWss.on('connection', (ws, request) => { const clientIp = request.headers['x-forwarded-for'] || request.connection.remoteAddress; const userCommandHistory = {}; if (serverConfig.webserver.banlist?.includes(clientIp)) { ws.close(1008, 'Banned IP'); return; } // Anti-spam tracking for each client const userCommands = {}; let lastWarn = { time: 0 }; ws.on('message', message => { // Anti-spam const command = antispamProtection(message, clientIp, ws, userCommands, lastWarn, userCommandHistory, '10', 'data_plugins'); let messageData; try { messageData = JSON.parse(message); // Attempt to parse the JSON } catch (error) { // console.error("Failed to parse message:", error); // Log the error return; // Exit if parsing fails } const modifiedMessage = JSON.stringify(messageData); // Broadcast the message to all other clients pluginsWss.clients.forEach(client => { if (client.readyState === WebSocket.OPEN) { client.send(modifiedMessage); // Send the message to all clients } }); }); ws.on('close', () => { // logInfo('WebSocket Extra connection closed'); // Use custom logInfo function }); ws.on('error', error => { logError('WebSocket Extra error: ' + error); // Use custom logError function }); }); // Websocket register for /text, /audio and /chat paths httpServer.on('upgrade', (request, socket, head) => { if (request.url === '/text') { sessionMiddleware(request, {}, () => { wss.handleUpgrade(request, socket, head, (ws) => { wss.emit('connection', ws, request); }); }); } else if (request.url === '/audio') { proxy.ws(request, socket, head); } else if (request.url === '/chat') { sessionMiddleware(request, {}, () => { chatWss.handleUpgrade(request, socket, head, (ws) => { chatWss.emit('connection', ws, request); }); }); } else if (request.url === '/rds' || request.url === '/rdsspy') { sessionMiddleware(request, {}, () => { rdsWss.handleUpgrade(request, socket, head, (ws) => { rdsWss.emit('connection', ws, request); const clientIp = request.headers['x-forwarded-for'] || request.connection.remoteAddress; const userCommandHistory = {}; if (serverConfig.webserver.banlist?.includes(clientIp)) { ws.close(1008, 'Banned IP'); return; } // Anti-spam tracking for each client const userCommands = {}; let lastWarn = { time: 0 }; ws.on('message', function incoming(message) { // Anti-spam const command = helpers.antispamProtection(message, clientIp, ws, userCommands, lastWarn, userCommandHistory, '5', 'rds'); }); }); }); } else if (request.url === '/data_plugins') { sessionMiddleware(request, {}, () => { pluginsWss.handleUpgrade(request, socket, head, (ws) => { pluginsWss.emit('connection', ws, request); }); }); } else { socket.destroy(); } }); app.use(express.static(path.join(__dirname, '../web'))); // Serve the entire web folder to the user fmdxList.update(); helpers.checkIPv6Support((isIPv6Supported) => { const ipv4Address = serverConfig.webserver.webserverIp === '0.0.0.0' ? 'localhost' : serverConfig.webserver.webserverIp; const ipv6Address = '::'; // This will bind to all available IPv6 interfaces const port = serverConfig.webserver.webserverPort; const logServerStart = (address, isIPv6) => { const formattedAddress = isIPv6 ? `[${address}]` : address; logInfo(`Web server has started on address \x1b[34mhttp://${formattedAddress}:${port}\x1b[0m.`); }; const startServer = (address, isIPv6) => { httpServer.listen(port, address, () => { if (!isIPv6 && !configExists()) { logInfo(`Open your browser and proceed to \x1b[34mhttp://${address}:${port}\x1b[0m to continue with setup.`); } else { logServerStart(address, isIPv6); } }); }; if (isIPv6Supported) { startServer(ipv4Address, false); // Start on IPv4 startServer(ipv6Address, true); // Start on IPv6 } else { startServer(ipv4Address, false); // Start only on IPv4 } });