const mqtt = require('mqtt'); const { exec, spawn } = require('child_process'); const pty = require('node-pty'); const express = require('express'); const WebSocket = require('ws'); const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); const app = express(); const port = 3001; const brokerUrl = 'mqtt://localhost'; // Replace with your broker's URL const client = mqtt.connect(brokerUrl); const mainTopic = 'apu_tb'; const commandTopic = 'apu_tb/cmd/all'; const ackTopicPrefix = 'apu_tb/ack/'; const nodeData = Array.from({ length: 5 }, () => Array(5).fill(null)); const nodeLastActive = {}; // Track the last active timestamp for each node let testbedServerData = null; let failureTimeoutMs = 30000; const nodePositionsById = {}; const nodeNeighborsById = {}; const nodePathsById = {}; // Define the path to the script const scriptPath = path.join(__dirname, 'node_addresses.sh'); // Read the bash script const scriptContent = fs.readFileSync(scriptPath, 'utf8'); const AUTH_FILE = path.join(__dirname, 'data', 'auth.json'); const DEFAULT_AUTH = { user: process.env.DASHBOARD_USER || 'admin', pass: process.env.DASHBOARD_PASS || 'admin', enabled: false }; const AUTH_COOKIE = 'dashboard_auth'; const authTokens = new Set(); function loadAuthFile() { try { const raw = fs.readFileSync(AUTH_FILE, 'utf8'); const parsed = JSON.parse(raw); if (parsed && typeof parsed.user === 'string' && typeof parsed.pass === 'string') { if (typeof parsed.enabled !== 'boolean') { parsed.enabled = false; } return parsed; } } catch (err) { return { ...DEFAULT_AUTH }; } return { ...DEFAULT_AUTH }; } function saveAuthFile(auth) { try { fs.mkdirSync(path.dirname(AUTH_FILE), { recursive: true }); fs.writeFileSync(AUTH_FILE, JSON.stringify(auth)); } catch (err) { console.error('Failed to save auth file:', err); } } let authConfig = loadAuthFile(); authConfig.enabled = false; const PXE_CONFIG_PATH = path.join(__dirname, '..', 'apu-tb-configuration', 'pxelinux.cfg', 'default'); function loadPxeConfig() { return fs.readFileSync(PXE_CONFIG_PATH, 'utf8'); } function parsePxeConfig(content) { const lines = content.split(/\r?\n/); let defaultLabel = null; const blocks = []; let current = null; lines.forEach((line) => { const defMatch = line.match(/^\s*#?\s*DEFAULT\s+(\S+)/); if (defMatch) { const trimmed = line.trim(); if (!trimmed.startsWith('#')) { defaultLabel = defMatch[1]; } } const labelMatch = line.match(/^\s*LABEL\s+(\S+)/); if (labelMatch) { if (current) { blocks.push(current); } current = { name: labelMatch[1], lines: [line] }; return; } if (current) { const normalized = /^\s+/.test(line) ? line.replace(/^\s+/, ' ') : line; current.lines.push(normalized); } }); if (current) { blocks.push(current); } let selected = null; if (defaultLabel) { selected = blocks.find((block) => block.name === defaultLabel) || null; } if (!selected && blocks.length) { selected = blocks[0]; } return { defaultLabel, labelsText: selected ? selected.lines.join('\n') : '' }; } function updatePxeDefault(content, newDefault) { const lines = content.split(/\r?\n/); let found = false; const updated = lines.map((line) => { const match = line.match(/^(\s*)#?\s*DEFAULT\s+(\S+)/); if (!match) { return line; } const indent = match[1] || ''; const label = match[2]; if (label === newDefault) { found = true; return `${indent}DEFAULT ${label}`; } return `${indent}# DEFAULT ${label}`; }); if (!found) { updated.unshift(`DEFAULT ${newDefault}`); } return updated.join('\n'); } const INIT_NODE_PATH = path.join(__dirname, '..', 'apu-tb-opt', 'init_node.sh'); const NETCONF_DEFAULTS_PATH = path.join(__dirname, 'config', 'netconf-defaults.json'); function updateMeshInitTopology(content, updates) { const lines = content.split(/\r?\n/); const updated = lines.map((line) => { if (/^\s*#/.test(line)) { return line; } const match = line.match(/^(\s*\/opt\/scripts\/mesh\/meshinit\.sh\s+"[^"]+"\s+"(mesh[01])"\s+)"([^"]+)"\s+"([^"]+)"\s+"([^"]+)"\s+"([^"]+)"(.*)$/); if (!match) { return line; } const dev = match[2]; const newTopo = updates[dev] && updates[dev].topology; const newChannel = updates[dev] && updates[dev].channel; const newTx = updates[dev] && updates[dev].txpower; const newNoise = updates[dev] && updates[dev].noisefloor; const channelValue = match[3]; const txValue = newTx || match[4]; const noiseValue = newNoise || match[5]; const topoValue = newTopo || match[6]; const channelUpdated = newChannel || channelValue; return `${match[1]}"${channelUpdated}" "${txValue}" "${noiseValue}" "${topoValue}"${match[7]}`; }); return updated.join('\n'); } function parseMeshInit(content) { const result = { mesh0: { topology: null, channel: null, txpower: null, noisefloor: null }, mesh1: { topology: null, channel: null, txpower: null, noisefloor: null } }; const lines = content.split(/\r?\n/); lines.forEach((line) => { if (/^\s*#/.test(line)) { return; } const match = line.match(/^\s*\/opt\/scripts\/mesh\/meshinit\.sh\s+"[^"]+"\s+"(mesh[01])"\s+"([^"]+)"\s+"([^"]+)"\s+"([^"]+)"\s+"([^"]+)"/); if (!match) { return; } const dev = match[1]; const channel = match[2]; const txpower = match[3]; const noisefloor = match[4]; const topo = match[5]; if (result[dev]) { result[dev].channel = channel; result[dev].topology = topo; result[dev].txpower = txpower; result[dev].noisefloor = noisefloor; } }); return result; } function parseMeshChannels(content) { const lines = content.split(/\r?\n/); const entries = []; lines.forEach((line) => { const match = line.match(/^\s*\["(\d+)"\]\s*=\s*"(\d+)"/); if (!match) { return; } entries.push({ channel: match[1], frequency: match[2] }); }); return entries.sort((a, b) => Number(a.channel) - Number(b.channel)); } const BACKUP_PLAN_PATH = path.join(__dirname, '..', 'apu-tb-scripts', 'apu_backup_plan.sh'); const BACKUP_ACTIONS = [ 'A_UROM', 'A_SWOS', 'A_INIT', 'A_PULL', 'A_GRUB', 'A_PART', 'A_SWKL', 'A_PUSH' ]; function parseBackupVersions(content) { const getValue = (key) => { const match = content.match(new RegExp(`^\\s*${key}\\s*=\\s*"?([^"\\n]+)"?`, 'm')); return match ? match[1] : null; }; return { V_BACKUP: getValue('V_BACKUP'), V_KERNEL: getValue('V_KERNEL'), V_ROM: getValue('V_ROM') }; } function extractBackupVersionOptions(content) { const values = { V_BACKUP: [], V_KERNEL: [], V_ROM: [] }; const patterns = [ /^\s*#?\s*(V_BACKUP|V_KERNEL|V_ROM)\s*=\s*"([^"]+)"/gm, /^\s*#?\s*(V_BACKUP|V_KERNEL|V_ROM)\s*=\s*'([^']+)'/gm ]; patterns.forEach((pattern) => { let match = null; while ((match = pattern.exec(content)) !== null) { const key = match[1]; const value = match[2]; if (values[key]) { values[key].push(value); } } }); return values; } function mergeUniqueOptions(...lists) { const seen = new Set(); const result = []; lists.forEach((list) => { if (!Array.isArray(list)) { return; } list.forEach((item) => { if (!item || seen.has(item)) { return; } seen.add(item); result.push(item); }); }); return result; } function updateBackupVersion(content, key, value) { const regex = new RegExp(`^(\\s*${key}\\s*=).*$`, 'm'); if (regex.test(content)) { return content.replace(regex, `$1"${value}"`); } return `${key}="${value}"\n${content}`; } function listDirectories(root) { try { return fs.readdirSync(root, { withFileTypes: true }) .filter((entry) => entry.isDirectory()) .map((entry) => entry.name) .sort(); } catch (err) { return []; } } function listRomFiles(root) { const results = []; const walk = (dir, prefix = '') => { let entries = []; try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch (err) { return; } entries.forEach((entry) => { if (entry.isDirectory()) { walk(path.join(dir, entry.name), path.join(prefix, entry.name)); } else if (entry.isFile() && entry.name.endsWith('.rom')) { results.push(path.join(prefix, entry.name)); } }); }; walk(root); return results.sort(); } function loadBackupPlan() { return fs.readFileSync(BACKUP_PLAN_PATH, 'utf8'); } function parseBackupPlan(content) { const status = {}; BACKUP_ACTIONS.forEach((action) => { const match = content.match(new RegExp(`^\\s*${action}\\s*=\\s*(\\d+)`, 'm')); status[action] = match ? Number(match[1]) : 0; }); return status; } function parseBackupReference(content) { const match = content.match(/^\s*D_REFND\s*=\s*"?([^"\n]+)"?/m); return match ? match[1] : null; } function updateBackupReference(content, referenceNode) { const normalized = normalizeNodeId(referenceNode); if (!normalized || !expectedNodeIds.has(normalized)) { return content; } const regex = /^(\s*D_REFND\s*=).*/m; if (regex.test(content)) { return content.replace(regex, `$1"${normalized}"`); } return `D_REFND="${normalized}"\n${content}`; } function updateBackupPlan(content, selectedAction) { let updated = content; BACKUP_ACTIONS.forEach((action) => { const value = action === selectedAction ? 1 : 0; const regex = new RegExp(`^(\\s*${action}\\s*=)\\s*\\d+`, 'm'); if (regex.test(updated)) { updated = updated.replace(regex, `$1${value}`); } }); return updated; } function parseCookies(header = '') { return header.split(';').reduce((acc, part) => { const [key, ...rest] = part.trim().split('='); if (!key) { return acc; } acc[key] = decodeURIComponent(rest.join('=')); return acc; }, {}); } function isAuthenticated(req) { if (authConfig.enabled === false) { return true; } const cookies = parseCookies(req.headers.cookie || ''); const token = cookies[AUTH_COOKIE]; return token && authTokens.has(token); } function requireAuth(req, res, next) { if (authConfig.enabled === false) { return next(); } if (req.path === '/login' || req.path === '/logout') { return next(); } if (!isAuthenticated(req)) { if (req.path.startsWith('/api/')) { return res.status(401).json({ error: 'unauthorized' }); } return res.redirect('/login'); } return next(); } app.use(express.urlencoded({ extended: false })); app.use(express.json()); app.use(requireAuth); app.get('/login', (req, res) => { if (authConfig.enabled === false) { return res.redirect('/'); } res.send(` Login

APU Dashboard Login

Configure credentials via DASHBOARD_USER / DASHBOARD_PASS.
`); }); app.post('/login', (req, res) => { if (authConfig.enabled === false) { return res.redirect('/'); } const { username, password } = req.body || {}; if (username === authConfig.user && password === authConfig.pass) { const token = crypto.randomBytes(16).toString('hex'); authTokens.clear(); authTokens.add(token); res.setHeader('Set-Cookie', `${AUTH_COOKIE}=${token}; HttpOnly; Path=/; SameSite=Lax`); return res.redirect('/'); } return res.status(401).send('Invalid credentials'); }); app.get('/logout', (req, res) => { if (authConfig.enabled === false) { return res.redirect('/'); } const cookies = parseCookies(req.headers.cookie || ''); const token = cookies[AUTH_COOKIE]; if (token) { authTokens.delete(token); } res.setHeader('Set-Cookie', `${AUTH_COOKIE}=; HttpOnly; Path=/; Max-Age=0; SameSite=Lax`); res.redirect('/login'); }); app.post('/api/change-password', (req, res) => { if (!isAuthenticated(req)) { return res.status(401).json({ error: 'unauthorized' }); } const { currentPassword, newPassword, newUsername } = req.body || {}; if (currentPassword !== authConfig.pass) { return res.status(400).json({ error: 'invalid_password' }); } if (newUsername && typeof newUsername === 'string') { authConfig.user = newUsername.trim(); } if (!newPassword || typeof newPassword !== 'string' || newPassword.length < 4) { return res.status(400).json({ error: 'weak_password' }); } authConfig.pass = newPassword; saveAuthFile(authConfig); authTokens.clear(); return res.json({ ok: true }); }); app.get('/api/auth-status', (req, res) => { res.json({ enabled: authConfig.enabled !== false }); }); app.post('/api/auth-toggle', (req, res) => { if (authConfig.enabled !== false && !isAuthenticated(req)) { return res.status(401).json({ error: 'unauthorized' }); } const { enabled } = req.body || {}; authConfig.enabled = enabled !== false; saveAuthFile(authConfig); if (authConfig.enabled === false) { authTokens.clear(); } return res.json({ ok: true, enabled: authConfig.enabled }); }); app.get('/netconf', (req, res) => { res.sendFile(path.join(__dirname, 'public/netconf.html')); }); app.get('/br', (req, res) => { res.sendFile(path.join(__dirname, 'public/br.html')); }); app.get('/api/backup-plan', (req, res) => { try { const content = loadBackupPlan(); const status = parseBackupPlan(content); status.referenceNode = parseBackupReference(content); return res.json(status); } catch (err) { console.error('Failed to read backup plan:', err); return res.status(500).json({ error: 'read_failed' }); } }); app.post('/api/backup-plan', (req, res) => { const { action, referenceNode } = req.body || {}; if (!action && !referenceNode) { return res.status(400).json({ error: 'invalid_action' }); } if (action && !BACKUP_ACTIONS.includes(action)) { return res.status(400).json({ error: 'invalid_action' }); } try { const content = loadBackupPlan(); let updated = content; if (action) { updated = updateBackupPlan(updated, action); } if (referenceNode) { updated = updateBackupReference(updated, referenceNode); } fs.writeFileSync(BACKUP_PLAN_PATH, updated); const status = parseBackupPlan(updated); status.referenceNode = parseBackupReference(updated); return res.json(status); } catch (err) { console.error('Failed to update backup plan:', err); return res.status(500).json({ error: 'write_failed' }); } }); app.get('/api/pxe-config', (req, res) => { try { const content = loadPxeConfig(); return res.json(parsePxeConfig(content)); } catch (err) { console.error('Failed to read PXE config:', err); return res.status(500).json({ error: 'read_failed' }); } }); app.post('/api/pxe-config', (req, res) => { const { defaultLabel } = req.body || {}; if (!defaultLabel || typeof defaultLabel !== 'string') { return res.status(400).json({ error: 'invalid_label' }); } try { const content = loadPxeConfig(); const updated = updatePxeDefault(content, defaultLabel.trim()); fs.writeFileSync(PXE_CONFIG_PATH, updated); return res.json(parsePxeConfig(updated)); } catch (err) { console.error('Failed to update PXE config:', err); return res.status(500).json({ error: 'write_failed' }); } }); app.post('/api/ssh-reboot', (req, res) => { const { user, targets } = req.body || {}; const allowedUsers = new Set(['root', 'tc']); if (!allowedUsers.has(user)) { return res.status(400).json({ error: 'invalid_user' }); } if (!Array.isArray(targets) || targets.length === 0) { return res.status(400).json({ error: 'invalid_targets' }); } const normalized = targets .map((target) => normalizeNodeId(target)) .filter((target) => target && expectedNodeIds.has(target)); normalized.forEach((target) => { const host = `${user}@${target}`; const proc = spawn('ssh', ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=5', '-o', 'StrictHostKeyChecking=no', '-o', 'UserKnownHostsFile=/dev/null', host, 'sudo', 'reboot'], { stdio: 'ignore' }); proc.on('error', (err) => { console.error(`SSH reboot failed for ${host}:`, err); }); }); return res.json({ ok: true, targets: normalized }); }); app.post('/api/ssh-command', (req, res) => { const { command, targets } = req.body || {}; if (typeof command !== 'string' || !command.trim()) { return res.status(400).json({ error: 'invalid_command' }); } if (!Array.isArray(targets) || targets.length === 0) { return res.status(400).json({ error: 'invalid_targets' }); } const normalized = targets .map((target) => normalizeNodeId(target)) .filter((target) => target && expectedNodeIds.has(target)); normalized.forEach((target) => { runSshCommand(target, command.trim()); }); return res.json({ ok: true, targets: normalized }); }); app.post('/api/ssh-console', (req, res) => { const { node, command } = req.body || {}; if (typeof command !== 'string' || !command.trim()) { return res.status(400).json({ error: 'invalid_command' }); } const normalized = normalizeNodeId(node); if (!normalized || !expectedNodeIds.has(normalized)) { return res.status(400).json({ error: 'invalid_target' }); } const host = resolveNodeIp(normalized) || normalized; const detectUser = () => new Promise((resolve) => { const tryUser = (user, next) => { const sshHost = `${user}@${host}`; const proc = spawn('ssh', [ '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=3', '-o', 'StrictHostKeyChecking=no', '-o', 'UserKnownHostsFile=/dev/null', sshHost, 'true' ], { stdio: 'ignore' }); proc.on('close', (code) => { if (code === 0) { resolve(user); } else if (next) { next(); } else { resolve(null); } }); proc.on('error', () => { if (next) { next(); } else { resolve(null); } }); }; tryUser('root', () => tryUser('tc')); }); detectUser().then((user) => { if (!user) { return res.status(500).json({ error: 'ssh_failed', message: 'No reachable SSH user.' }); } const sshHost = `${user}@${host}`; const args = [ '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=5', '-o', 'StrictHostKeyChecking=no', '-o', 'UserKnownHostsFile=/dev/null', sshHost, command.trim() ]; let stdout = ''; let stderr = ''; const proc = spawn('ssh', args, { stdio: ['ignore', 'pipe', 'pipe'] }); const timeout = setTimeout(() => { proc.kill('SIGKILL'); }, 8000); proc.stdout.on('data', (data) => { stdout += data.toString(); }); proc.stderr.on('data', (data) => { stderr += data.toString(); }); proc.on('close', (code) => { clearTimeout(timeout); res.json({ ok: code === 0, code, user, stdout, stderr }); }); proc.on('error', (err) => { clearTimeout(timeout); res.status(500).json({ error: 'ssh_failed', message: err.message }); }); }); }); app.post('/api/backup-status', (req, res) => { const { targets } = req.body || {}; if (!Array.isArray(targets) || targets.length === 0) { return res.status(400).json({ error: 'invalid_targets' }); } const normalized = targets .map((target) => normalizeNodeId(target)) .filter((target) => target && expectedNodeIds.has(target)); const checkNode = (nodeId) => new Promise((resolve) => { const host = `tc@${nodeId}`; const cmd = 'LOG=$(ls -t /home/tc/*_backup.log 2>/dev/null | head -n1); ' + 'if [ -z "$LOG" ]; then echo "missing"; exit 0; fi; ' + 'if grep -q "Backup plan done" "$LOG"; then echo "done"; else echo "running"; fi'; let output = ''; const proc = spawn('ssh', ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=5', '-o', 'StrictHostKeyChecking=no', '-o', 'UserKnownHostsFile=/dev/null', host, cmd], { stdio: ['ignore', 'pipe', 'ignore'] }); proc.stdout.on('data', (data) => { output += data.toString(); }); proc.on('close', () => { const status = output.trim() || 'missing'; resolve({ nodeId, status }); }); proc.on('error', () => { resolve({ nodeId, status: 'missing' }); }); }); Promise.all(normalized.map(checkNode)).then((results) => { const status = {}; results.forEach((result) => { status[result.nodeId.replace('apu', '')] = result.status; }); res.json({ status }); }); }); app.post('/api/os-status', (req, res) => { const { targets } = req.body || {}; if (!Array.isArray(targets) || targets.length === 0) { return res.status(400).json({ error: 'invalid_targets' }); } const normalized = targets .map((target) => normalizeNodeId(target)) .filter((target) => target && expectedNodeIds.has(target)); const checkNode = (nodeId) => new Promise((resolve) => { const runCheck = (user, label, next) => { const host = `${user}@${nodeId}`; const proc = spawn('ssh', ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=3', '-o', 'StrictHostKeyChecking=no', '-o', 'UserKnownHostsFile=/dev/null', host, 'true'], { stdio: 'ignore' }); proc.on('close', (code) => { if (code === 0) { resolve({ nodeId, status: label }); } else if (next) { next(); } else { resolve({ nodeId, status: '' }); } }); proc.on('error', () => { if (next) { next(); } else { resolve({ nodeId, status: '' }); } }); }; runCheck('root', 'Kali', () => runCheck('tc', 'TC')); }); Promise.all(normalized.map(checkNode)).then((results) => { const status = {}; results.forEach((result) => { status[result.nodeId.replace('apu', '')] = result.status; }); res.json({ status }); }); }); app.get('/api/backup-versions', (req, res) => { try { const content = loadBackupPlan(); const current = parseBackupVersions(content); const scriptOptions = extractBackupVersionOptions(content); const options = { backup: mergeUniqueOptions( listDirectories('/home/apu/backup/full_backup'), scriptOptions.V_BACKUP, current.V_BACKUP ? [current.V_BACKUP] : [] ), kernel: mergeUniqueOptions( listDirectories('/home/apu/backup/apu_kernel'), scriptOptions.V_KERNEL, current.V_KERNEL ? [current.V_KERNEL] : [] ), rom: mergeUniqueOptions( listRomFiles('/home/apu/roms'), scriptOptions.V_ROM, current.V_ROM ? [current.V_ROM] : [] ) }; return res.json({ current, options }); } catch (err) { console.error('Failed to read backup versions:', err); return res.status(500).json({ error: 'read_failed' }); } }); app.post('/api/backup-versions', (req, res) => { const { key, value } = req.body || {}; const allowed = new Set(['V_BACKUP', 'V_KERNEL', 'V_ROM']); if (!allowed.has(key) || typeof value !== 'string') { return res.status(400).json({ error: 'invalid_value' }); } try { const content = loadBackupPlan(); const updated = updateBackupVersion(content, key, value.trim()); fs.writeFileSync(BACKUP_PLAN_PATH, updated); return res.json(parseBackupVersions(updated)); } catch (err) { console.error('Failed to update backup versions:', err); return res.status(500).json({ error: 'write_failed' }); } }); app.get('/api/manage-targets/state', (req, res) => { const totalNodes = expectedNodeIds.size || 0; const selected = Array.isArray(manageTargetsState.selected) ? manageTargetsState.selected : []; return res.json({ selected, all: totalNodes > 0 ? selected.length >= totalNodes : false, checkStatus: Boolean(manageTargetsState.checkStatus), checkOs: Boolean(manageTargetsState.checkOs) }); }); app.post('/api/manage-targets/state', (req, res) => { const { selected, checkStatus, checkOs } = req.body || {}; if (!Array.isArray(selected)) { return res.status(400).json({ error: 'invalid_value' }); } const normalized = Array.from(new Set(selected.map(normalizeManageTargetId).filter(Boolean))); manageTargetsState = { selected: normalized, checkStatus: Boolean(checkStatus), checkOs: Boolean(checkOs) }; saveManageTargetsState(); const totalNodes = expectedNodeIds.size || 0; return res.json({ selected: manageTargetsState.selected, all: totalNodes > 0 ? manageTargetsState.selected.length >= totalNodes : false, checkStatus: Boolean(manageTargetsState.checkStatus), checkOs: Boolean(manageTargetsState.checkOs) }); }); app.get('/api/netconf/mesh', (req, res) => { try { const content = fs.readFileSync(INIT_NODE_PATH, 'utf8'); return res.json(parseMeshInit(content)); } catch (err) { console.error('Failed to read init_node.sh:', err); return res.status(500).json({ error: 'read_failed' }); } }); app.get('/api/netconf/defaults', (req, res) => { try { const raw = fs.readFileSync(NETCONF_DEFAULTS_PATH, 'utf8'); const defaults = JSON.parse(raw); return res.json(defaults); } catch (err) { console.error('Failed to read netconf defaults:', err); return res.status(500).json({ error: 'defaults_read_failed' }); } }); app.get('/api/netconf/channels', (req, res) => { try { const content = fs.readFileSync(path.join(__dirname, '..', 'apu-tb-opt', 'scripts', 'mesh', 'freq.sh'), 'utf8'); return res.json(parseMeshChannels(content)); } catch (err) { console.error('Failed to read mesh channels:', err); return res.status(500).json({ error: 'channels_read_failed' }); } }); app.post('/api/netconf/mesh', (req, res) => { const { mesh0, mesh1 } = req.body || {}; const updates = {}; if (mesh0 && typeof mesh0 === 'string') { updates.mesh0 = { topology: mesh0.trim() }; } else if (mesh0 && typeof mesh0 === 'object') { updates.mesh0 = { topology: typeof mesh0.topology === 'string' ? mesh0.topology.trim() : null, channel: typeof mesh0.channel === 'string' ? mesh0.channel.trim() : null, txpower: typeof mesh0.txpower === 'string' ? mesh0.txpower.trim() : null, noisefloor: typeof mesh0.noisefloor === 'string' ? mesh0.noisefloor.trim() : null }; } if (mesh1 && typeof mesh1 === 'string') { updates.mesh1 = { topology: mesh1.trim() }; } else if (mesh1 && typeof mesh1 === 'object') { updates.mesh1 = { topology: typeof mesh1.topology === 'string' ? mesh1.topology.trim() : null, channel: typeof mesh1.channel === 'string' ? mesh1.channel.trim() : null, txpower: typeof mesh1.txpower === 'string' ? mesh1.txpower.trim() : null, noisefloor: typeof mesh1.noisefloor === 'string' ? mesh1.noisefloor.trim() : null }; } if (!updates.mesh0 && !updates.mesh1) { return res.status(400).json({ error: 'invalid_value' }); } try { const content = fs.readFileSync(INIT_NODE_PATH, 'utf8'); const updated = updateMeshInitTopology(content, updates); fs.writeFileSync(INIT_NODE_PATH, updated); return res.json(parseMeshInit(updated)); } catch (err) { console.error('Failed to update init_node.sh:', err); return res.status(500).json({ error: 'write_failed' }); } }); app.post('public/index.html') // Serve static files from the template's public folder app.use(express.static(path.join(__dirname, 'public'))); // app.use(express.static(__dirname)); // Route to serve the main dashboard HTML file app.get('/', (req, res) => { res.sendFile(path.join(__dirname, 'public/index.html')); }); // Example API endpoint for dashboard data app.get('/api/data', (req, res) => { res.json({ users: 150, sales: 2500, revenue: 50000, active_sessions: 45 }); }); app.get('/api/testbed-history', (req, res) => { res.json(testbedHistory); }); const server = require('http').createServer(app); const ws = new WebSocket.Server({ noServer: true }); const consoleWs = new WebSocket.Server({ noServer: true }); server.on('upgrade', (request, socket, head) => { const url = request.url || ''; if (url.startsWith('/ws/console')) { consoleWs.handleUpgrade(request, socket, head, (client) => { consoleWs.emit('connection', client, request); }); return; } ws.handleUpgrade(request, socket, head, (client) => { ws.emit('connection', client, request); }); }); ws.on('connection', (socket, request) => { if (authConfig.enabled !== false) { const cookies = parseCookies(request.headers.cookie || ''); const token = cookies[AUTH_COOKIE]; if (!token || !authTokens.has(token)) { socket.close(); return; } } socket.on('message', (message) => { try { const payload = JSON.parse(message.toString()); if (payload.type === 'failureTimeout') { const allowed = new Set([10, 20, 30, 40, 50, 60]); if (allowed.has(payload.value)) { failureTimeoutMs = payload.value * 1000; } } if (payload.type === 'hwmpProactive') { const enabled = Boolean(payload.enabled); const command = JSON.stringify({ action: 'hwmp_proactive', enabled }); client.publish(commandTopic, command, { qos: 1, retain: false }); } if (payload.type === 'hwmpPing') { const enabled = payload.enabled !== false; const sourceId = payload.sourceId; const targetId = payload.targetId; const pingTargets = targetId ? { mesh0: mesh0IP[targetId], mesh1: mesh1IP[targetId] } : {}; const command = JSON.stringify({ action: 'hwmp_ping', enabled, ping: { source: sourceId, targets: pingTargets } }); client.publish(commandTopic, command, { qos: 1, retain: false }); } if (payload.type === 'rebootAll') { publishCommand('reboot', { scope: 'all' }, { maxRetries: 0 }); } if (payload.type === 'reinitAll') { const command = '/usr/bin/pkill -9 -f mqtt_publisher.py || true; sleep 1; /opt/start_script.sh'; expectedNodeIds.forEach((nodeId) => runSshRootCommand(nodeId, command)); } if (payload.type === 'restartDashboard') { exec('systemctl restart apu-dashboard.service', (err, stdout, stderr) => { if (err) { console.error('Failed to restart dashboard service:', err); return; } if (stderr) { console.warn('Dashboard restart stderr:', stderr); } if (stdout) { console.log('Dashboard restart stdout:', stdout); } }); } if (payload.type === 'sshCommand') { const command = typeof payload.command === 'string' ? payload.command.trim() : ''; const targets = Array.isArray(payload.targets) ? payload.targets : []; if (!command || !targets.length) { return; } const normalizedTargets = targets .map((target) => normalizeNodeId(target)) .filter((target) => target && expectedNodeIds.has(target)); normalizedTargets.forEach((target) => { runSshCommand(target, command); }); } if (payload.type === 'nodeCommand') { const action = payload.action; const targetId = payload.targetId; if (!action || !targetId) { return; } if (action === 're-init') { const normalizedTarget = normalizeNodeId(targetId); if (!normalizedTarget || !expectedNodeIds.has(normalizedTarget)) { return; } const command = '/usr/bin/pkill -9 -f mqtt_publisher.py || true; sleep 1; /opt/start_script.sh'; runSshRootCommand(normalizedTarget, command); return; } const id = `${Date.now()}-${Math.random().toString(16).slice(2, 10)}`; const command = JSON.stringify({ action, target: targetId, scope: 'node', id, ts: Date.now() }); client.publish(commandTopic, command, { qos: 1, retain: false }); } } catch (err) { console.error('WebSocket message error:', err); } }); }); consoleWs.on('connection', (socket, request) => { if (authConfig.enabled !== false) { const cookies = parseCookies(request.headers.cookie || ''); const token = cookies[AUTH_COOKIE]; if (!token || !authTokens.has(token)) { socket.close(); return; } } let url = null; try { url = new URL(request.url, `http://${request.headers.host}`); } catch (err) { socket.close(); return; } const nodeParam = url.searchParams.get('node'); const normalized = normalizeNodeId(nodeParam); if (!normalized || !expectedNodeIds.has(normalized)) { socket.close(); return; } const host = resolveNodeIp(normalized) || normalized; const detectUser = () => new Promise((resolve) => { const tryUser = (user, next) => { const sshHost = `${user}@${host}`; const proc = spawn('ssh', [ '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=3', '-o', 'StrictHostKeyChecking=no', '-o', 'UserKnownHostsFile=/dev/null', sshHost, 'true' ], { stdio: 'ignore' }); proc.on('close', (code) => { if (code === 0) { resolve(user); } else if (next) { next(); } else { resolve(null); } }); proc.on('error', () => { if (next) { next(); } else { resolve(null); } }); }; tryUser('root', () => tryUser('tc')); }); detectUser().then((user) => { if (!user) { socket.close(); return; } const sshHost = `${user}@${host}`; const term = pty.spawn('ssh', [ '-o', 'BatchMode=yes', '-o', 'StrictHostKeyChecking=no', '-o', 'UserKnownHostsFile=/dev/null', sshHost ], { name: 'xterm-256color', cols: 80, rows: 24, cwd: process.env.HOME, env: { ...process.env, TERM: 'xterm-256color' } }); term.onData((data) => { if (socket.readyState === WebSocket.OPEN) { socket.send(data); } }); socket.on('message', (message) => { let text = ''; if (Buffer.isBuffer(message)) { text = message.toString(); } else if (typeof message === 'string') { text = message; } else { text = String(message); } if (text.startsWith('{')) { try { const payload = JSON.parse(text); if (payload && payload.type === 'resize') { const cols = Number(payload.cols); const rows = Number(payload.rows); if (Number.isFinite(cols) && Number.isFinite(rows)) { term.resize(cols, rows); } return; } } catch (err) { // fall through to write } } term.write(text); }); socket.on('close', () => { try { term.kill(); } catch (err) { // ignore } }); }); }); // Start the server server.listen(port, () => { console.log(`Server running at http://localhost:${port}`); }); // Function to parse associative arrays from the bash script function parseAssociativeArray(script, arrayName) { const regex = new RegExp(`declare -A ${arrayName}\\s*=\\s*\\(([^)]+)\\)`, 's'); const match = script.match(regex); if (!match) return {}; const entries = match[1].trim().split('\n').map(line => line.trim()).filter(line => line.startsWith('["')); const obj = {}; entries.forEach(entry => { const parts = entry.match(/\["(.+?)"\]="(.+?)"/); if (parts) { obj[parts[1]] = parts[2]; } }); return obj; } const mesh0MAC = parseAssociativeArray(scriptContent, 'phy0MAC'); const mesh1MAC = parseAssociativeArray(scriptContent, 'phy1MAC'); const eth0IP = parseAssociativeArray(scriptContent, 'eth0IP'); const mesh0IP = parseAssociativeArray(scriptContent, 'phy0IP'); const mesh1IP = parseAssociativeArray(scriptContent, 'phy1IP'); const expectedNodeIds = new Set([ ...Object.keys(mesh0MAC), ...Object.keys(mesh1MAC) ]); function normalizeManageTargetId(value) { if (value === null || value === undefined) { return null; } const raw = String(value).trim(); if (/^\d{2}$/.test(raw)) { return raw; } if (/^apu\d{2}$/i.test(raw)) { return raw.slice(-2); } if (/^\d{1,2}$/.test(raw)) { return raw.padStart(2, '0'); } return null; } function ensureManageTargetsDir() { try { fs.mkdirSync(path.dirname(MANAGE_TARGETS_STATE_PATH), { recursive: true }); } catch (err) { console.error('Failed to create manage targets directory:', err); } } function loadManageTargetsState() { try { const raw = fs.readFileSync(MANAGE_TARGETS_STATE_PATH, 'utf8'); const parsed = JSON.parse(raw); const selected = Array.isArray(parsed.selected) ? parsed.selected : []; const normalized = Array.from(new Set(selected.map(normalizeManageTargetId).filter(Boolean))); manageTargetsState = { selected: normalized, checkStatus: Boolean(parsed.checkStatus), checkOs: Boolean(parsed.checkOs) }; } catch (err) { manageTargetsState = { selected: [], checkStatus: false, checkOs: false }; } } function saveManageTargetsState() { try { fs.writeFileSync(MANAGE_TARGETS_STATE_PATH, JSON.stringify(manageTargetsState, null, 2)); } catch (err) { console.error('Failed to save manage targets state:', err); } } function normalizeNodeId(value) { if (value === null || value === undefined) { return null; } let node = String(value).trim(); if (/^\d{1,2}$/.test(node)) { node = node.padStart(2, '0'); } if (/^\d{2}$/.test(node)) { node = `apu${node}`; } if (!/^apu\d{2}$/.test(node)) { return null; } return node; } function resolveNodeIp(id) { return eth0IP[id] || mesh0IP[id] || mesh1IP[id] || null; } function runSshCommand(nodeId, command) { const host = resolveNodeIp(nodeId); if (!host) { console.warn(`No IP found for ${nodeId}`); return; } const remoteCommand = `sudo -- ${command}`; const proc = spawn('ssh', ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=5', '-o', 'StrictHostKeyChecking=no', '-o', 'UserKnownHostsFile=/dev/null', host, remoteCommand], { stdio: 'ignore' }); proc.on('error', (err) => { console.error(`SSH failed for ${nodeId}:`, err); }); function runSshRootCommand(nodeId, command) { const host = normalizeNodeId(nodeId); if (!host) { console.warn(`Invalid node id: ${nodeId}`); return; } const ip = resolveNodeIp(host) || host; const sshHost = `root@${ip}`; const proc = spawn('ssh', ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=5', '-o', 'StrictHostKeyChecking=no', '-o', 'UserKnownHostsFile=/dev/null', sshHost, 'bash', '-lc', command], { stdio: 'ignore' }); proc.on('error', (err) => { console.error(`SSH failed for ${host}:`, err); }); } } const MANAGE_TARGETS_STATE_PATH = path.join(__dirname, 'data', 'manage_targets_state.json'); let manageTargetsState = { selected: [], checkStatus: false, checkOs: false }; const HISTORY_PATH = path.join(__dirname, 'data', 'testbed_history.json'); const HISTORY_RETENTION_MS = 7 * 24 * 60 * 60 * 1000; const HISTORY_WINDOWS = [ { windowMs: 6 * 60 * 60 * 1000, bucketMs: 1000 }, { windowMs: 24 * 60 * 60 * 1000, bucketMs: 10 * 1000 }, { windowMs: 6 * 24 * 60 * 60 * 1000, bucketMs: 60 * 1000 } ]; let testbedHistory = []; let historyWriteTimer = null; function loadHistoryFromDisk() { try { const raw = fs.readFileSync(HISTORY_PATH, 'utf8'); const parsed = JSON.parse(raw); if (Array.isArray(parsed)) { testbedHistory = parsed; } } catch (err) { testbedHistory = []; } } function ensureHistoryDir() { try { fs.mkdirSync(path.dirname(HISTORY_PATH), { recursive: true }); } catch (err) { console.error('Failed to create history directory:', err); } } function scheduleHistoryWrite() { if (historyWriteTimer) { return; } historyWriteTimer = setTimeout(() => { historyWriteTimer = null; try { fs.writeFileSync(HISTORY_PATH, JSON.stringify(testbedHistory)); } catch (err) { console.error('Failed to write history:', err); } }, 5000); } function normalizeCpuUtil(value) { if (Array.isArray(value)) { return value.map((val) => Number(val)); } return Number(value); } function mergeCpuUtil(existing, incoming) { if (Array.isArray(existing) || Array.isArray(incoming)) { const existingArr = Array.isArray(existing) ? existing : [existing]; const incomingArr = Array.isArray(incoming) ? incoming : [incoming]; const length = Math.max(existingArr.length, incomingArr.length); const merged = []; for (let i = 0; i < length; i += 1) { const a = Number(existingArr[i] ?? existingArr[existingArr.length - 1] ?? 0); const b = Number(incomingArr[i] ?? incomingArr[incomingArr.length - 1] ?? 0); merged.push((a + b) / 2); } return merged; } return (Number(existing) + Number(incoming)) / 2; } function compactHistory(history) { if (!history.length) { return []; } const sorted = history .filter((entry) => entry && typeof entry.ts === 'number') .sort((a, b) => a.ts - b.ts); const now = sorted[sorted.length - 1].ts; const cutoff = now - HISTORY_RETENTION_MS; const buckets = new Map(); sorted.forEach((entry) => { if (entry.ts < cutoff) { return; } const age = now - entry.ts; let bucketMs = HISTORY_WINDOWS[HISTORY_WINDOWS.length - 1].bucketMs; for (const window of HISTORY_WINDOWS) { if (age <= window.windowMs) { bucketMs = window.bucketMs; break; } } const bucketKey = `${bucketMs}:${Math.floor(entry.ts / bucketMs)}`; const existing = buckets.get(bucketKey); const normalizedUtil = normalizeCpuUtil(entry.cpu_util); if (!existing) { buckets.set(bucketKey, { ts: Math.floor(entry.ts / bucketMs) * bucketMs, cpu_util: normalizedUtil }); return; } existing.ts = Math.max(existing.ts, entry.ts); existing.cpu_util = mergeCpuUtil(existing.cpu_util, normalizedUtil); }); return Array.from(buckets.values()).sort((a, b) => a.ts - b.ts); } function recordTestbedHistory(entry) { testbedHistory.push(entry); testbedHistory = compactHistory(testbedHistory); scheduleHistoryWrite(); } ensureHistoryDir(); loadHistoryFromDisk(); ensureManageTargetsDir(); loadManageTargetsState(); testbedHistory = compactHistory(testbedHistory); const COMMAND_RETRY_INTERVAL_MS = 10000; const COMMAND_TIMEOUT_MS = 120000; let pendingCommand = null; function clearPendingCommand() { if (!pendingCommand) { return; } if (pendingCommand.retryTimer) { clearInterval(pendingCommand.retryTimer); } if (pendingCommand.timeoutTimer) { clearTimeout(pendingCommand.timeoutTimer); } pendingCommand = null; } function publishCommand(action, extraPayload = {}, options = {}) { const id = `${Date.now()}-${Math.random().toString(16).slice(2, 10)}`; const payload = JSON.stringify({ action, id, ts: Date.now(), ...extraPayload }); const expected = new Set(expectedNodeIds); const maxRetries = Number.isFinite(options.maxRetries) ? Math.max(0, options.maxRetries) : Math.floor(COMMAND_TIMEOUT_MS / COMMAND_RETRY_INTERVAL_MS); let retryCount = 0; pendingCommand = { id, action, expected, acked: new Set(), retryTimer: maxRetries > 0 ? setInterval(() => { retryCount += 1; if (retryCount > maxRetries) { clearPendingCommand(); return; } client.publish(commandTopic, payload, { retain: false, qos: 1 }); }, COMMAND_RETRY_INTERVAL_MS) : null, timeoutTimer: setTimeout(() => { clearPendingCommand(); }, COMMAND_TIMEOUT_MS) }; client.publish(commandTopic, payload, { retain: false, qos: 1 }); if (expected.size === 0) { clearPendingCommand(); } return id; } function handleAck(payload) { if (!pendingCommand || !payload || payload.id !== pendingCommand.id) { return; } if (payload.node) { pendingCommand.acked.add(payload.node); } if (pendingCommand.acked.size >= pendingCommand.expected.size) { clearPendingCommand(); } } function idToPos(id) { const match = /^apu(\d+)$/.exec(id); if (!match) return null; const num = Number(match[1]); if (num >= 0 && num < 25) { return { x: num % 5, y: Math.floor(num / 5) }; } return null; } const mesh0MacPositionMap = {}; const mesh1MacPositionMap = {}; const mesh0MacIdMap = {}; const mesh1MacIdMap = {}; Object.keys(mesh0MAC).forEach((id) => { const pos = idToPos(id); if (pos) { mesh0MacPositionMap[mesh0MAC[id]] = pos; } mesh0MacIdMap[mesh0MAC[id]] = id; }); Object.keys(mesh1MAC).forEach((id) => { const pos = idToPos(id); if (pos) { mesh1MacPositionMap[mesh1MAC[id]] = pos; } mesh1MacIdMap[mesh1MAC[id]] = id; }); // console.log(mesh0MAC); // console.log(mesh1MAC); function broadcastNodeData() { const nodeStatus = generateNodeStatus(); const message = JSON.stringify({ nodeData, nodeStatus, testbedServerData, mesh0MacPositionMap, mesh1MacPositionMap, mesh0MacIdMap, mesh1MacIdMap }); // Send to WebSocket clients ws.clients.forEach(client => { if (client.readyState === WebSocket.OPEN) { client.send(message); } }); } // Function to generate status colors based on activity function generateNodeStatus() { const now = Date.now(); return nodeData.map((row, y) => row.map((cell, x) => nodeLastActive[`${y},${x}`] && (now - nodeLastActive[`${y},${x}`] < failureTimeoutMs) ? "lightgreen" : "lightcoral" ) ); } client.on('connect', () => { console.log('Connected to MQTT broker'); client.subscribe(`${mainTopic}/#`, (err) => { if (!err) { console.log(`Subscribed to all subtopics of: ${mainTopic}`); } else { console.error('Subscription error:', err); } }); }); client.on('message', (topic, message) => { try { const payload = JSON.parse(message.toString()); if (topic.startsWith(ackTopicPrefix)) { handleAck(payload); return; } if (topic.startsWith('apu_tb/cmd/')) { return; } if (topic.endsWith('/neighbors')) { const id = topic.split('/')[1]; const neighbors = Array.isArray(payload.neighbors) ? payload.neighbors : []; nodeNeighborsById[id] = neighbors; const pos = nodePositionsById[id]; if (pos && nodeData[pos.y] && nodeData[pos.y][pos.x]) { nodeData[pos.y][pos.x].neighbors = neighbors; } return; } if (topic.endsWith('/paths')) { const id = topic.split('/')[1]; nodePathsById[id] = payload.paths || {}; const pos = nodePositionsById[id]; if (pos && nodeData[pos.y] && nodeData[pos.y][pos.x]) { nodeData[pos.y][pos.x].paths = nodePathsById[id]; } return; } if (Array.isArray(payload) && payload.length === 2) { const data = payload[0]; const metadata = payload[1]; const { uptime, cpu_temp, mem_util, cpu_util, ptp4l_count, phc2sys_count, ping_stats, XPosition, YPosition } = data; const { id } = metadata; if (id === 'apuctrl') { testbedServerData = { id, uptime, cpu_temp, cpu_util }; recordTestbedHistory({ ts: Date.now(), cpu_util }); return; } if (XPosition >= 0 && XPosition < 5 && YPosition >= 0 && YPosition < 5) { // nodeData[YPosition][XPosition] = `ID: ${id}
Temp: ${cpu_temp}°C
CUtil: ${cpu_util}
MUtil: ${mem_util}
UpTime: ${uptime}`; nodeData[YPosition][XPosition] = { id, cpu_temp, mem_util, cpu_util, ptp4l_count, phc2sys_count, ping_stats, uptime, mesh0_mac: mesh0MAC[id], mesh1_mac: mesh1MAC[id], neighbors: nodeNeighborsById[id] || [], paths: nodePathsById[id] || {} }; nodePositionsById[id] = { x: XPosition, y: YPosition }; // console.log("MAC Links:", JSON.stringify(mesh0_neighbors, null, 2)); // Update the last active timestamp for the node nodeLastActive[`${YPosition},${XPosition}`] = Date.now(); // console.log(`Node data updated for ID ${id} at position (${XPosition}, ${YPosition})`); // Broadcast updated data and statuses // broadcastNodeData(); // console.log("Checkboxes known:", checkedNodeInfo); // Use this array in your app } } else { console.error('Unexpected payload format:', payload); } } catch (err) { console.error('Failed to process message:', err); } }); // Periodically broadcast the node status setInterval(() => { broadcastNodeData(); }, 1000);