/** * LIBRARIES AND IMPORTS */ // Web handling const express = require('express'); const session = require('express-session'); const bodyParser = require('body-parser'); const http = require('http'); const httpProxy = require('http-proxy'); const https = require('https'); const app = express(); const httpServer = http.createServer(app); const process = require("process"); // Websocket handling const WebSocket = require('ws'); const wss = new WebSocket.Server({ noServer: true }); const chatWss = new WebSocket.Server({ noServer: true }); const path = require('path'); const net = require('net'); const client = new net.Socket(); // Other files and libraries const crypto = require('crypto'); const fs = require('fs'); const { SerialPort } = require('serialport') const commandExists = require('command-exists-promise'); const dataHandler = require('./datahandler'); const fmdxList = require('./fmdx_list'); const consoleCmd = require('./console'); const audioStream = require('./stream/index.js'); const { parseAudioDevice } = require('./stream/parser.js'); const { configName, serverConfig, configUpdate, configSave } = require('./server_config'); const { logDebug, logError, logInfo, logWarn } = consoleCmd; var pjson = require('./package.json'); console.log(`\x1b[32m _____ __ __ ______ __ __ __ _ | ___| \\/ | | _ \\ \\/ / \\ \\ / /__| |__ ___ ___ _ ____ _____ _ __ | |_ | |\\/| |_____| | | \\ / \\ \\ /\\ / / _ \\ '_ \\/ __|/ _ \\ '__\\ \\ / / _ \\ '__| | _| | | | |_____| |_| / \\ \\ V V / __/ |_) \\__ \\ __/ | \\ V / __/ | |_| |_| |_| |____/_/\\_\\ \\_/\\_/ \\___|_.__/|___/\\___|_| \\_/ \\___|_| `); console.log('\x1b[0mFM-DX-Webserver', pjson.version); console.log('\x1b[90m======================================================'); // 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 connectedUsers = []; let streamEnabled = false; let incompleteDataBuffer = ''; let serialport; app.use(bodyParser.urlencoded({ extended: true })); const sessionMiddleware = session({ secret: 'GTce3tN6U8odMwoI', resave: false, saveUninitialized: true, }); app.use(sessionMiddleware); app.use(bodyParser.json()); /* Audio Stream */ commandExists('ffmpeg') .then(exists => { if (exists) { logInfo("An existing installation of ffmpeg found, enabling audio stream."); audioStream.enableAudioStream(); streamEnabled = true; } else { logError("No ffmpeg installation found. Audio stream won't be available."); } }) .catch(err => { // Should never happen but better handle it just in case }) // Function to authenticate with the xdrd server function authenticateWithXdrd(client, salt, password) { const sha1 = crypto.createHash('sha1'); const saltBuffer = Buffer.from(salt, 'utf-8'); const passwordBuffer = Buffer.from(password, 'utf-8'); sha1.update(saltBuffer); sha1.update(passwordBuffer); const hashedPassword = sha1.digest('hex'); client.write(hashedPassword + '\n'); client.write('x\n'); } connectToXdrd(); connectToSerial(); // Serial Connection function connectToSerial() { if (serverConfig.xdrd.wirelessConnection === false) { serialport = new SerialPort({path: serverConfig.xdrd.comPort, baudRate: 115200 }); serialport.on('open', () => { logInfo('Using COM device: ' + serverConfig.xdrd.comPort); serialport.write('x\n'); serialport.write('M0\n'); serialport.write('Y100\n'); serialport.write('D0\n'); serialport.write('A0\n'); serialport.write('F-1\n'); serialport.write('Z0\n'); serialport.write('G11\n'); serialport.write('V0\n'); serialport.write('Q0\n'); serialport.write('C0\n'); serialport.write('I0,0\n'); if(serverConfig.defaultFreq) { 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 { serialport.write('T87500\n'); } serialport.on('data', (data) => { resolveDataBuffer(data); }); }); serialport.on('error', (error) => { logError(error.message); }); return serialport; } } // xdrd connection function connectToXdrd() { if (serverConfig.xdrd.wirelessConnection === true) { client.connect(serverConfig.xdrd.xdrdPort, serverConfig.xdrd.xdrdIp, () => { logInfo('Connection to xdrd established successfully.'); const authFlags = { authMsg: false, firstClient: false, receivedPassword: false }; const authDataHandler = (data) => { const receivedData = data.toString(); const lines = receivedData.split('\n'); for (const line of lines) { if (!authFlags.receivedPassword) { authFlags.receivedSalt = line.trim(); authenticateWithXdrd(client, authFlags.receivedSalt, serverConfig.xdrd.xdrdPassword); authFlags.receivedPassword = true; } 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')) { switch (line) { case 'G11': dataHandler.initialData.eq = 1; dataHandler.dataToSend.eq = 1; dataHandler.initialData.ims = 1; dataHandler.dataToSend.ims = 1; break; case 'G01': dataHandler.initialData.eq = 0; dataHandler.dataToSend.eq = 0; dataHandler.initialData.ims = 1; dataHandler.dataToSend.ims = 1; break; case 'G10': dataHandler.initialData.eq = 1; dataHandler.dataToSend.eq = 1; dataHandler.initialData.ims = 0; dataHandler.dataToSend.ims = 0; break; case 'G00': dataHandler.initialData.eq = 0; dataHandler.initialData.ims = 0; dataHandler.dataToSend.eq = 0; dataHandler.dataToSend.ims = 0; break; } } else if (line.startsWith('Z')) { let modifiedLine = line.slice(1); dataHandler.initialData.ant = modifiedLine; dataHandler.dataToSend.ant = modifiedLine; } if (authFlags.authMsg && authFlags.firstClient) { client.write('x\n'); if(serverConfig.defaultFreq) { client.write('T' + Math.round(serverConfig.defaultFreq * 1000) +'\n'); } else { client.write('T87500\n'); } client.write('A0\n'); client.write('G00\n'); client.off('data', authDataHandler); return; } } } }; client.on('data', (data) => { resolveDataBuffer(data); }); client.on('data', authDataHandler); }); } } 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; } }); /* Static data are being sent through here on connection - these don't change when the server is running */ app.get('/static_data', (req, res) => { res.json({ qthLatitude: serverConfig.identification.lat, qthLongitude: serverConfig.identification.lon, streamEnabled: streamEnabled, presets: serverConfig.webserver.presets || [], defaultTheme: serverConfig.webserver.defaultTheme || 'theme1' }); }); app.get('/server_time', (req, res) => { /*const serverTime = new Date().toISOString(); // Get server time in ISO format const serverTimezoneOffset = new Date().getTimezoneOffset(); // Get server timezone offset in minutes*/ const serverTime = new Date(); // Get current server time const serverTimeUTC = new Date(serverTime.getTime() - (serverTime.getTimezoneOffset() * 60000)); // Adjust server time to UTC res.json({ serverTime: serverTimeUTC, }); }); app.get('/ping', (req, res) => { res.send('pong'); }); /** * AUTHENTICATION BLOCK */ const authenticate = (req, res, next) => { const { password } = req.body; // Check if the entered password matches the admin password if (password === serverConfig.password.adminPass) { req.session.isAdminAuthenticated = true; req.session.isTuneAuthenticated = true; logInfo('User from ' + req.connection.remoteAddress + ' logged in as an administrator.'); next(); } else if (password === serverConfig.password.tunePass) { req.session.isAdminAuthenticated = false; req.session.isTuneAuthenticated = true; logInfo('User from ' + req.connection.remoteAddress + ' logged in with tune permissions.'); next(); } else { res.status(403).json({ message: 'Login failed. Wrong password?' }); } }; app.set('view engine', 'ejs'); // Set EJS as the template engine app.set('views', path.join(__dirname, '/web')) function resolveDataBuffer(data) { var receivedData = incompleteDataBuffer + data.toString(); const isIncomplete = (receivedData.slice(-1) != '\n'); if (isIncomplete) { const position = receivedData.lastIndexOf('\n'); if (position < 0) { incompleteDataBuffer = receivedData; receivedData = ''; } else { incompleteDataBuffer = receivedData.slice(position + 1); receivedData = receivedData.slice(0, position + 1); } } else { incompleteDataBuffer = ''; } if (receivedData.length) { wss.clients.forEach((client) => { if (client.readyState === WebSocket.OPEN) { dataHandler.handleData(client, receivedData); } }); } } function parseMarkdown(parsed) { parsed = parsed.replace(/<\/?[^>]+(>|$)/g, ''); var grayTextRegex = /--(.*?)--/g; parsed = parsed.replace(grayTextRegex, '$1'); var boldRegex = /\*\*(.*?)\*\*/g; parsed = parsed.replace(boldRegex, '$1'); var italicRegex = /\*(.*?)\*/g; parsed = parsed.replace(italicRegex, '$1'); var linkRegex = /\[([^\]]+)]\(([^)]+)\)/g; parsed = parsed.replace(linkRegex, '$1'); parsed = parsed.replace(/\n/g, '
'); return parsed; } function removeMarkdown(parsed) { parsed = parsed.replace(/<\/?[^>]+(>|$)/g, ''); var grayTextRegex = /--(.*?)--/g; parsed = parsed.replace(grayTextRegex, '$1'); var boldRegex = /\*\*(.*?)\*\*/g; parsed = parsed.replace(boldRegex, '$1'); var italicRegex = /\*(.*?)\*/g; parsed = parsed.replace(italicRegex, '$1'); var linkRegex = /\[([^\]]+)]\(([^)]+)\)/g; parsed = parsed.replace(linkRegex, '$1'); return parsed; } app.get('/', (req, res) => { if (!fs.existsSync(configName + '.json')) { let serialPorts; SerialPort.list() .then((deviceList) => { serialPorts = deviceList .map(port => ({ path: port.path, friendlyName: port.friendlyName, })); parseAudioDevice((result) => { res.render('wizard', { isAdminAuthenticated: true, videoDevices: result.audioDevices, audioDevices: result.videoDevices, serialPorts: serialPorts }); }); }) } else { res.render('index', { isAdminAuthenticated: req.session.isAdminAuthenticated, isTuneAuthenticated: req.session.isTuneAuthenticated, tunerName: serverConfig.identification.tunerName, tunerDesc: parseMarkdown(serverConfig.identification.tunerDesc), tunerDescMeta: removeMarkdown(serverConfig.identification.tunerDesc), tunerLock: serverConfig.lockToAdmin, publicTuner: serverConfig.publicTuner, ownerContact: serverConfig.identification.contact, antennaSwitch: serverConfig.antennaSwitch, tuningLimit: serverConfig.webserver.tuningLimit, tuningLowerLimit: serverConfig.webserver.tuningLowerLimit, tuningUpperLimit: serverConfig.webserver.tuningUpperLimit, chatEnabled: serverConfig.webserver.chatEnabled, device: serverConfig.tuner }) } }); app.get('/wizard', (req, res) => { let serialPorts; SerialPort.list() .then((deviceList) => { serialPorts = deviceList.map(port => ({ path: port.path, friendlyName: port.friendlyName, })); parseAudioDevice((result) => { res.render('wizard', { isAdminAuthenticated: req.session.isAdminAuthenticated, videoDevices: result.audioDevices, audioDevices: result.videoDevices, serialPorts: serialPorts }); }); }) }) app.get('/setup', (req, res) => { let serialPorts; SerialPort.list() .then((deviceList) => { serialPorts = deviceList.map(port => ({ path: port.path, friendlyName: port.friendlyName, })); parseAudioDevice((result) => { const processUptimeInSeconds = Math.floor(process.uptime()); const formattedProcessUptime = formatUptime(processUptimeInSeconds); res.render('setup', { isAdminAuthenticated: req.session.isAdminAuthenticated, videoDevices: result.audioDevices, audioDevices: result.videoDevices, serialPorts: serialPorts, memoryUsage: (process.memoryUsage.rss() / 1024 / 1024).toFixed(1) + ' MB', processUptime: formattedProcessUptime, consoleOutput: consoleCmd.logs, onlineUsers: dataHandler.dataToSend.users, connectedUsers: connectedUsers }); }); }) }); app.get('/api', (req, res) => { let data = { ...dataHandler.dataToSend }; delete data.ps_errors; delete data.rt0_errors; delete data.rt1_errors; delete data.ims; delete data.eq; delete data.ant; delete data.st_forced; delete data.previousFreq; delete data.txInfo; res.json(data); }); function formatUptime(uptimeInSeconds) { const secondsInMinute = 60; const secondsInHour = secondsInMinute * 60; const secondsInDay = secondsInHour * 24; const days = Math.floor(uptimeInSeconds / secondsInDay); const hours = Math.floor((uptimeInSeconds % secondsInDay) / secondsInHour); const minutes = Math.floor((uptimeInSeconds % secondsInHour) / secondsInMinute); return `${days}d ${hours}h ${minutes}m`; } // Route for login app.post('/login', authenticate, (req, res) => { // Redirect to the main page after successful login res.status(200).json({ message: 'Logged in successfully, refreshing the page...' }); }); app.get('/logout', (req, res) => { // Clear the session and redirect to the main page req.session.destroy(() => { res.status(200).json({ message: 'Logged out successfully, refreshing the page...' }); }); }); app.post('/saveData', (req, res) => { const data = req.body; let firstSetup; if(req.session.isAdminAuthenticated || !fs.existsSync(configName + '.json')) { configUpdate(data); fmdxList.update(); if(!fs.existsSync(configName + '.json')) { firstSetup = true; } /* TODO: Refactor to server_config.js */ // Save data to a JSON file fs.writeFile(configName + '.json', JSON.stringify(serverConfig, null, 2), (err) => { if (err) { logError(err); res.status(500).send('Internal Server Error'); } else { logInfo('Server config changed successfully.'); if(firstSetup === true) { res.status(200).send('Data saved successfully!\nPlease, restart the server to load your configuration.'); } else { res.status(200).send('Data saved successfully!\nSome settings may need a server restart to apply.'); } } }); } }); // Serve the data.json file when the /getData endpoint is accessed app.get('/getData', (req, res) => { if(req.session.isAdminAuthenticated) { // Check if the file exists fs.access(configName + '.json', fs.constants.F_OK, (err) => { if (err) { // File does not exist res.status(404).send('Data not found'); } else { // File exists, send it as the response res.sendFile(path.join(__dirname) + '/' + configName + '.json'); } }); } }); app.get('/getDevices', (req, res) => { if (req.session.isAdminAuthenticated || !fs.existsSync(configName + '.json')) { parseAudioDevice((result) => { res.json(result); }); } else { res.status(403).json({ error: 'Unauthorized' }); } }); /** * WEBSOCKET BLOCK */ let lastDisconnectTime = null; wss.on('connection', (ws, request) => { const clientIp = request.headers['x-forwarded-for'] || request.connection.remoteAddress; currentUsers++; dataHandler.showOnlineUsers(currentUsers); if(currentUsers === 1 && serverConfig.autoShutdown === true && serverConfig.xdrd.wirelessConnection) { serverConfig.xdrd.wirelessConnection === true ? connectToXdrd() : serialport.write('x\n'); } // Use ipinfo.io API to get geolocation information https.get(`https://ipinfo.io/${clientIp}/json`, (response) => { let data = ''; response.on('data', (chunk) => { data += chunk; }); response.on('end', () => { try { const locationInfo = JSON.parse(data); const options = { year: 'numeric', month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' }; const connectionTime = new Date().toLocaleString([], options); if(locationInfo.country === undefined) { const userData = { ip: clientIp, location: 'Unknown', time: connectionTime }; connectedUsers.push(userData); logInfo(`Web client \x1b[32mconnected\x1b[0m (${clientIp}) \x1b[90m[${currentUsers}]\x1b[0m`); } else { const userLocation = `${locationInfo.city}, ${locationInfo.region}, ${locationInfo.country}`; const userData = { ip: clientIp, location: userLocation, time: connectionTime }; connectedUsers.push(userData); logInfo(`Web client \x1b[32mconnected\x1b[0m (${clientIp}) \x1b[90m[${currentUsers}]\x1b[0m Location: ${locationInfo.city}, ${locationInfo.region}, ${locationInfo.country}`); } } catch (error) { logInfo(`Web client \x1b[32mconnected\x1b[0m (${clientIp}) \x1b[90m[${currentUsers}]\x1b[0m`); } }); }); ws.on('message', (message) => { logDebug('Command received from \x1b[90m' + clientIp + '\x1b[0m:', message.toString()); command = message.toString(); if(command.startsWith('X')) { logWarn('Remote tuner shutdown attempted by \x1b[90m' + clientIp + '\x1b[0m. You may consider blocking this user.'); return; } if(command.includes('\'')) { return; } if(command.startsWith('T')) { let tuneFreq = Number(command.slice(1)) / 1000; if(serverConfig.webserver.tuningLimit === true && (tuneFreq < serverConfig.webserver.tuningLowerLimit || tuneFreq > serverConfig.webserver.tuningUpperLimit) || isNaN(tuneFreq)) { return; } } if((serverConfig.publicTuner === true) || (request.session && request.session.isTuneAuthenticated === true && serverConfig.xdrd.wirelessConnection)) { if(serverConfig.lockToAdmin === true) { if(request.session && request.session.isAdminAuthenticated === true) { serverConfig.xdrd.wirelessConnection === true ? client.write(command + "\n") : serialport.write(command + "\n"); } else { return; } } else { serverConfig.xdrd.wirelessConnection === true ? client.write(command + "\n") : serialport.write(command + "\n"); } } }); ws.on('close', (code, reason) => { currentUsers--; dataHandler.showOnlineUsers(currentUsers); // Find the index of the user's data in connectedUsers array const index = connectedUsers.findIndex(user => user.ip === clientIp); if (index !== -1) { connectedUsers.splice(index, 1); // Remove the user's data from connectedUsers array } if (currentUsers === 0 && serverConfig.enableDefaultFreq === true && serverConfig.autoShutdown !== true && serverConfig.xdrd.wirelessConnection === true) { setTimeout(function() { if(currentUsers === 0) { client.write('T' + Math.round(serverConfig.defaultFreq * 1000) +'\n'); dataHandler.resetToDefault(dataHandler.dataToSend); dataHandler.dataToSend.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 // Assuming chatWss is your WebSocket server instance // Initialize an array to store chat messages let chatHistory = []; chatWss.on('connection', (ws, request) => { const clientIp = request.headers['x-forwarded-for'] || request.connection.remoteAddress; // Send chat history to the newly connected client chatHistory.forEach(function(message) { message.history = true; // Adding the history parameter ws.send(JSON.stringify(message)); }); const ipMessage = { type: 'clientIp', ip: clientIp, admin: request.session.isAdminAuthenticated }; ws.send(JSON.stringify(ipMessage)); ws.on('message', function incoming(message) { const messageData = JSON.parse(message); messageData.ip = clientIp; // Adding IP address to the message object 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; // Do not proceed further if banned } if(request.session.isAdminAuthenticated === true) { messageData.admin = true; } // Limit message length to 255 characters if (messageData.message.length > 255) { messageData.message = messageData.message.substring(0, 255); } // Add the new message to chat history and keep only the latest 50 messages chatHistory.push(messageData); if (chatHistory.length > 50) { chatHistory.shift(); // Remove the oldest message if the history exceeds 50 messages } const modifiedMessage = JSON.stringify(messageData); chatWss.clients.forEach(function each(client) { if (client.readyState === WebSocket.OPEN) { client.send(modifiedMessage); } }); }); ws.on('close', function close() { }); }); // Handle upgrade requests to /text and proxy /audio WebSocket connections 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 { socket.destroy(); } }); /* Serving of HTML files */ app.use(express.static(path.join(__dirname, 'web'))); httpServer.listen(serverConfig.webserver.webserverPort, serverConfig.webserver.webserverIp, () => { let currentAddress = serverConfig.webserver.webserverIp; currentAddress == '0.0.0.0' ? currentAddress = 'localhost' : currentAddress = serverConfig.webserver.webserverIp; logInfo(`Web server is running at \x1b[34mhttp://${currentAddress}:${serverConfig.webserver.webserverPort}\x1b[0m.`); }); fmdxList.update();