const express = require('express'); const cors = require('cors'); const fs = require('fs'); const path = require('path'); const { execFile, spawn } = require('child_process'); const crypto = require('crypto'); const VERSION = '0.4.3'; const ENV_FILE = '/etc/gracepress/meshpress-bridge.env'; function loadEnv(file) { if (!fs.existsSync(file)) return; for (const line of fs.readFileSync(file, 'utf8').split(/\r?\n/)) { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith('#')) continue; const match = trimmed.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/); if (!match) continue; const key = match[1]; let value = match[2].trim(); if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { value = value.slice(1, -1); } if (!(key in process.env)) process.env[key] = value; } } loadEnv(ENV_FILE); loadEnv(path.join(__dirname, '.env')); const config = { port: Number(process.env.MESHPRESS_BRIDGE_PORT || 3099), bind: process.env.MESHPRESS_BRIDGE_BIND || '127.0.0.1', token: process.env.MESHPRESS_BRIDGE_TOKEN || '', meshUrl: process.env.MESHCTRL_URL || process.env.MESH_URL || 'wss://127.0.0.1', publicMeshUrl: (process.env.PUBLIC_MESH_URL || process.env.MESH_PUBLIC_URL || '').replace(/\/+$/, ''), user: process.env.MESHCTRL_USER || process.env.MESH_USER || 'admin', pass: process.env.MESHCTRL_PASS || process.env.MESH_PASS || '', domain: process.env.MESHCTRL_DOMAIN || process.env.MESH_DOMAIN || '', loginKeyFile: process.env.MESHCTRL_LOGIN_KEY_FILE || process.env.MESH_LOGIN_KEY_FILE || '/etc/gracepress/meshcentral-login.key', meshctrlPath: process.env.MESHCTRL_PATH || '', ignoreCert: process.env.MESHCTRL_IGNORE_CERT === '1', cacheTtl: Number(process.env.MESHPRESS_CACHE_TTL_SECONDS || 60), timeoutMs: Number(process.env.MESHPRESS_MESHCTRL_TIMEOUT_MS || 20000), linkHide: process.env.MESHPRESS_LINK_HIDE || '', linkMode: process.env.MESHPRESS_LINK_MODE || 'gotonode', allowedIps: String(process.env.MESHPRESS_ALLOWED_IPS || '').split(',').map(v => v.trim()).filter(Boolean), authWindowMs: Number(process.env.MESHPRESS_AUTH_WINDOW_SECONDS || 900) * 1000, authMaxFailures: Number(process.env.MESHPRESS_AUTH_MAX_FAILURES || 30), corsOrigin: process.env.MESHPRESS_CORS_ORIGIN || '', allowCommands: process.env.MESHPRESS_ALLOW_COMMANDS === '1', allowMaintenance: process.env.MESHPRESS_ALLOW_MAINTENANCE === '1', meshCentralDir: process.env.MESHCENTRAL_DIR || process.env.MESH_CENTRAL_DIR || '', meshCentralDataDir: process.env.MESHCENTRAL_DATA_DIR || process.env.MESH_CENTRAL_DATA_DIR || '', meshCentralConfigPath: process.env.MESHCENTRAL_CONFIG_PATH || process.env.MESH_CENTRAL_CONFIG_PATH || '', meshCentralServiceName: process.env.MESHCENTRAL_SERVICE_NAME || process.env.MESH_CENTRAL_SERVICE_NAME || 'meshcentral', meshCentralBackupDir: process.env.MESHPRESS_BACKUP_DIR || process.env.MESHCENTRAL_BACKUP_DIR || '/var/backups/gracepress-meshcentral', maintenanceLogFile: process.env.MESHPRESS_MAINTENANCE_LOG || '/var/log/gracepress-meshcentral-maintenance.log', mysqlDumpPath: process.env.MESHPRESS_MYSQLDUMP_PATH || process.env.MYSQLDUMP_PATH || 'mysqldump', mysqlPath: process.env.MESHPRESS_MYSQL_PATH || process.env.MYSQL_PATH || 'mysql', dbDumpTimeoutMs: Number(process.env.MESHPRESS_DB_DUMP_TIMEOUT_SECONDS || 600) * 1000, backupStopService: process.env.MESHPRESS_BACKUP_STOP_SERVICE === '1', wpDomain: (process.env.WP_DOMAIN || process.env.MESHPRESS_WP_DOMAIN || '').replace(/\/+$/, ''), wpApiKey: process.env.WP_API_KEY || process.env.MESHPRESS_WP_API_KEY || process.env.MESHPRESS_BRIDGE_TOKEN || '', pushIntervalSeconds: Number(process.env.MESHPRESS_PUSH_INTERVAL_SECONDS || 3600), pushEvents: String(process.env.MESHPRESS_PUSH_EVENTS || '1') !== '0', catalogUrl: process.env.GRACEPRESS_CATALOG_URL || 'https://christit.com/gracepress/api.php?endpoint=manifest&slug=meshpress-bridge' }; const app = express(); app.disable('x-powered-by'); app.use(express.json({ limit: '1mb' })); if (config.corsOrigin) { app.use(cors({ origin: config.corsOrigin })); } const cache = new Map(); const authFailures = new Map(); function bridgeLog(event, data = {}) { const payload = Object.assign({ event, ts: new Date().toISOString() }, data); console.log(`[meshpress-bridge] ${JSON.stringify(payload)}`); pushBridgeEvent(event, data).catch(error => console.error(`[meshpress-bridge] event push failed: ${error.message}`)); } const WORDPRESS_EVENT_ALLOWLIST = new Set([ 'bridge_update_check', 'bridge_update_check_failed', 'meshcentral_maintenance_status', 'meshcentral_config_audit', 'meshcentral_maintenance_toggle', 'meshcentral_maintenance_toggle_failed', 'meshcentral_backup_started', 'meshcentral_backup_db_dumped', 'meshcentral_backup_finished', 'meshcentral_backup_failed', 'meshcentral_update_started', 'meshcentral_update_failed', 'maintenance_toggle', 'group_resolve', 'group_resolve_failed', 'group_edit_verified_success', 'group_edit_failed', 'device_uninstall_success', 'device_uninstall_failed', 'agent_links', 'fleet_push_success', 'fleet_push_failed', 'enrollment_wait', 'enrollment_seen' ]); function eventSeverity(event) { if (/failed|failure|error/i.test(event)) return 'ERROR'; if (/success|finished|seen/i.test(event)) return 'SUCCESS'; return 'INFO'; } function redactEventData(value) { if (Array.isArray(value)) return value.map(redactEventData).slice(0, 50); if (!value || typeof value !== 'object') return value; const out = {}; for (const [key, item] of Object.entries(value)) { if (/pass|password|token|secret|key|authorization|cookie/i.test(key)) { out[key] = item ? '[redacted]' : item; } else { out[key] = redactEventData(item); } } return out; } async function pushBridgeEvent(event, data = {}) { if (!config.pushEvents || !config.wpDomain || !config.wpApiKey || !WORDPRESS_EVENT_ALLOWLIST.has(event)) return; const response = await fetch(`${config.wpDomain}/wp-json/gracepress/v1/meshpress/event`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${config.wpApiKey}` }, body: JSON.stringify({ event, severity: eventSeverity(event), bridge_version: VERSION, data: redactEventData(data), created_at: new Date().toISOString() }) }); if (!response.ok) throw new Error(`WordPress event endpoint returned HTTP ${response.status}`); } function publicErrorPayload(error, data = {}) { return Object.assign({ success: false, error: error && error.message ? error.message : String(error || 'Unknown bridge error.') }, data); } function candidateMeshctrlPaths() { const candidates = []; if (config.meshctrlPath) candidates.push(config.meshctrlPath); candidates.push( '/opt/meshcentral/node_modules/meshcentral/meshctrl.js', '/opt/meshcentral/node_modules/meshcentral/meshctrl', '/usr/local/lib/node_modules/meshcentral/meshctrl.js', '/usr/local/lib/node_modules/meshcentral/meshctrl' ); for (const home of ['/root', '/home/jbird']) { candidates.push( `${home}/meshcentral/node_modules/meshcentral/meshctrl.js`, `${home}/meshcentral/node_modules/meshcentral/meshctrl` ); } return candidates; } function findMeshctrl() { for (const candidate of candidateMeshctrlPaths()) { if (candidate && fs.existsSync(candidate)) return candidate; } return ''; } function remoteIp(req) { return String(req.socket.remoteAddress || '').replace(/^::ffff:/, ''); } function isAllowedIp(req) { if (!config.allowedIps.length) return true; const ip = remoteIp(req); return config.allowedIps.includes(ip) || config.allowedIps.includes(req.socket.remoteAddress || ''); } function constantTimeEqual(a, b) { const left = Buffer.from(String(a || '')); const right = Buffer.from(String(b || '')); if (left.length !== right.length) return false; return crypto.timingSafeEqual(left, right); } function authLimited(ip) { const now = Date.now(); const windowMs = Math.max(config.authWindowMs, 60000); const hit = authFailures.get(ip) || { count: 0, start: now }; if ((now - hit.start) > windowMs) { authFailures.set(ip, { count: 0, start: now }); return false; } return hit.count >= config.authMaxFailures; } function recordAuthFailure(ip) { const now = Date.now(); const windowMs = Math.max(config.authWindowMs, 60000); const hit = authFailures.get(ip) || { count: 0, start: now }; if ((now - hit.start) > windowMs) { authFailures.set(ip, { count: 1, start: now }); return; } hit.count += 1; authFailures.set(ip, hit); } function clearAuthFailures(ip) { authFailures.delete(ip); } function auth(req, res, next) { const ip = remoteIp(req); if (!isAllowedIp(req)) { recordAuthFailure(ip); return res.status(403).json({ success: false, error: 'Forbidden' }); } if (authLimited(ip)) { return res.status(429).json({ success: false, error: 'Too many failed authorization attempts.' }); } if (!config.token) { recordAuthFailure(ip); return res.status(503).json({ success: false, error: 'Bridge token is not configured. Set MESHPRESS_BRIDGE_TOKEN before exposing this service.' }); } const header = req.get('authorization') || ''; const bearer = header.startsWith('Bearer ') ? header.slice(7) : ''; const supplied = bearer || req.get('x-meshpress-token') || ''; if (supplied && constantTimeEqual(supplied, config.token)) { clearAuthFailures(ip); return next(); } recordAuthFailure(ip); return res.status(401).json({ success: false, error: 'Unauthorized' }); } function cacheGet(key) { const hit = cache.get(key); if (!hit) return null; if ((Date.now() - hit.time) > (config.cacheTtl * 1000)) { cache.delete(key); return null; } return hit.value; } function cacheSet(key, value) { cache.set(key, { time: Date.now(), value }); return value; } function meshctrlArgs(command, extra = []) { const args = [command, '--json', '--url', config.meshUrl, '--loginuser', config.user]; if (config.loginKeyFile && fs.existsSync(config.loginKeyFile)) { args.push('--loginkeyfile', config.loginKeyFile); } else if (config.pass) { args.push('--loginpass', config.pass); } if (config.domain) args.push('--domain', config.domain); if (config.ignoreCert) args.push('--ignorecert'); return args.concat(extra); } function parseMeshctrlJson(raw) { const text = String(raw || '').trim(); if (!text) return []; try { return JSON.parse(text); } catch (_) {} const firstArray = text.indexOf('['); const lastArray = text.lastIndexOf(']'); if (firstArray >= 0 && lastArray > firstArray) { try { return JSON.parse(text.slice(firstArray, lastArray + 1)); } catch (_) {} } const firstObject = text.indexOf('{'); const lastObject = text.lastIndexOf('}'); if (firstObject >= 0 && lastObject > firstObject) { const block = text.slice(firstObject, lastObject + 1); try { return JSON.parse(block); } catch (_) {} } const parsed = []; for (const line of text.split(/\r?\n/)) { const trimmed = line.trim().replace(/,$/, ''); if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) continue; try { parsed.push(JSON.parse(trimmed)); } catch (_) {} } if (parsed.length) return parsed.flat(); throw new Error('MeshCtrl did not return parseable JSON.'); } function runMeshctrl(command, extra = []) { return new Promise((resolve, reject) => { const meshctrl = findMeshctrl(); if (!meshctrl) { reject(new Error('meshctrl was not found. Set MESHCTRL_PATH in /etc/gracepress/meshpress-bridge.env.')); return; } if ((!config.loginKeyFile || !fs.existsSync(config.loginKeyFile)) && !config.pass) { reject(new Error('No MeshCentral authentication method is configured. Set MESHCTRL_PASS or provide MESHCTRL_LOGIN_KEY_FILE.')); return; } const args = [meshctrl].concat(meshctrlArgs(command, extra)); execFile('node', args, { timeout: config.timeoutMs, maxBuffer: 10 * 1024 * 1024 }, (error, stdout, stderr) => { if (error) { const message = stderr || stdout || error.message; reject(new Error(message.trim())); return; } try { resolve(parseMeshctrlJson(stdout)); } catch (parseError) { reject(parseError); } }); }); } function runMeshctrlText(command, extra = []) { return new Promise((resolve, reject) => { const meshctrl = findMeshctrl(); if (!meshctrl) { reject(new Error('meshctrl was not found. Set MESHCTRL_PATH in /etc/gracepress/meshpress-bridge.env.')); return; } if ((!config.loginKeyFile || !fs.existsSync(config.loginKeyFile)) && !config.pass) { reject(new Error('No MeshCentral authentication method is configured. Set MESHCTRL_PASS or provide MESHCTRL_LOGIN_KEY_FILE.')); return; } const args = [meshctrl].concat(meshctrlArgs(command, extra).filter(arg => arg !== '--json')); execFile('node', args, { timeout: config.timeoutMs, maxBuffer: 10 * 1024 * 1024 }, (error, stdout, stderr) => { const output = `${stdout || ''}${stderr ? `\n${stderr}` : ''}`.trim(); if (error) { reject(new Error(output || error.message)); return; } resolve(output || 'ok'); }); }); } function runCommand(command, args = [], options = {}) { return new Promise((resolve, reject) => { const child = execFile(command, args, { timeout: options.timeout || 120000, maxBuffer: options.maxBuffer || 10 * 1024 * 1024, cwd: options.cwd || undefined, env: Object.assign({}, process.env, options.env || {}) }, (error, stdout, stderr) => { const output = `${stdout || ''}${stderr ? `\n${stderr}` : ''}`.trim(); if (error) { reject(new Error(output || error.message)); return; } resolve(output || 'ok'); }); if (options.stdin) { child.stdin.end(options.stdin); } }); } function appendMaintenanceLog(event, data = {}) { const line = JSON.stringify(Object.assign({ ts: new Date().toISOString(), event }, data)) + '\n'; fs.mkdirSync(path.dirname(config.maintenanceLogFile), { recursive: true }); fs.appendFileSync(config.maintenanceLogFile, line, { mode: 0o600 }); bridgeLog(event, data); } function isMeshctrlFailureText(output) { const text = String(output || '').toLowerCase(); if (!text) return false; return /invalid\s+(group|mesh|node|device)\s+identifier|not\s+found|unknown\s+command|failed|failure|error\s+\d+|unable\s+to|access\s+denied|permission\s+denied/.test(text); } function asArray(value) { if (Array.isArray(value)) return value; if (!value) return []; if (typeof value === 'object') return Object.values(value); return []; } function normalizeGroup(group) { const id = group._id || group.id || group.meshid || ''; return { id, short_id: shortNodeId(id), name: group.name || group.meshname || group.desc || 'Unnamed Group', description: group.desc || group.description || '', raw: group }; } function normalizeDevice(device, groupMap = new Map()) { const meshId = device.meshid || device.mesh || device.groupid || ''; return { id: device._id || device.id || device.nodeid || '', name: device.name || device.rname || device.host || device.hostname || 'Unnamed Device', osName: device.rname || '', groupId: meshId, groupName: groupMap.get(meshId) || device.meshname || device.groupname || '', online: Number(device.conn || device.pwr || 0) > 0, connection: device.conn || 0, lastConnect: device.lastconnect || device.lastConnect || null, ip: device.ip || device.paddr || device.host || '', raw: device }; } function meshEntityAliases(entity) { const raw = entity && entity.raw && typeof entity.raw === 'object' ? entity.raw : {}; return [ entity && entity.id, entity && entity.name, raw._id, raw.id, raw.meshid, raw.meshId, raw.nodeid, raw.nodeId, raw.name, raw.meshname, raw.desc, raw.domain, raw._id ? shortNodeId(raw._id) : '', entity && entity.id ? shortNodeId(entity.id) : '' ].filter(value => value !== undefined && value !== null).map(value => String(value).trim()).filter(Boolean); } function groupMatchesCandidate(group, candidates = []) { const wanted = new Set(candidates.filter(Boolean).map(value => String(value).trim())); if (!wanted.size) return false; const looseWanted = new Set([...wanted].map(looseMeshKey).filter(Boolean)); return meshEntityAliases(group).some(alias => wanted.has(alias) || looseWanted.has(looseMeshKey(alias))); } function looseMeshKey(value) { return String(value || '').replace(/^mesh\/\//, '').replace(/[^A-Za-z0-9]/g, '').toLowerCase(); } function verifyGroupEdit(group, requestedName, requestedDescription) { const mismatches = []; if (!group) { return { ok: false, mismatches: ['Group was not found after edit attempt.'] }; } if (requestedName && String(group.name || '') !== requestedName) { mismatches.push(`Name is still "${group.name || ''}" instead of "${requestedName}".`); } if (requestedDescription && String(group.description || '') !== requestedDescription) { mismatches.push(`Description is still "${group.description || ''}" instead of requested description.`); } return { ok: mismatches.length === 0, mismatches }; } function normalizeUser(user) { return { id: user._id || user.id || user.userid || user.name || '', name: user.name || user.username || user.id || 'Unnamed User', email: user.email || '', raw: user }; } function normalizeEvent(event) { return { time: event.time || event.date || event.msgid || '', action: event.action || event.event || '', message: event.msg || event.message || event.desc || '', nodeId: event.nodeid || event.node || '', userId: event.userid || event.user || '', raw: event }; } async function getGroups(force = false) { const key = 'groups'; if (!force) { const hit = cacheGet(key); if (hit) return hit; } const raw = await runMeshctrl('listdevicegroups'); return cacheSet(key, asArray(raw).map(normalizeGroup).filter(g => g.id)); } async function getDevices(force = false) { const key = 'devices'; if (!force) { const hit = cacheGet(key); if (hit) return hit; } const groups = await getGroups(force).catch(() => []); const groupMap = new Map(groups.map(g => [g.id, g.name])); const raw = await runMeshctrl('listdevices'); return cacheSet(key, asArray(raw).map(d => normalizeDevice(d, groupMap)).filter(d => d.id)); } async function getUsers(force = false) { const key = 'users'; if (!force) { const hit = cacheGet(key); if (hit) return hit; } const raw = await runMeshctrl('listusers'); return cacheSet(key, asArray(raw).map(normalizeUser).filter(u => u.id || u.name)); } async function getEvents(force = false) { const key = 'events'; if (!force) { const hit = cacheGet(key); if (hit) return hit; } const raw = await runMeshctrl('listevents'); return cacheSet(key, asArray(raw).map(normalizeEvent)); } async function resolveGroupId(input) { const requested = String(input || '').trim(); const groups = await getGroups(true).catch(() => []); const requestedShort = shortNodeId(requested); const wanted = new Set([requested, requestedShort].filter(Boolean)); const looseWanted = new Set([requested, requestedShort].map(looseMeshKey).filter(Boolean)); const group = groups.find(item => { const aliases = meshEntityAliases(item); return aliases.some(alias => wanted.has(alias) || looseWanted.has(looseMeshKey(alias))); }) || null; return { requested, resolved_id: group ? group.id : requested, resolved_short_id: shortNodeId(group ? group.id : requested), group, matched: Boolean(group), known_groups: groups.map(item => ({ id: item.id, short_id: shortNodeId(item.id), name: item.name, description: item.description })) }; } function groupIdCandidates(resolution) { const candidates = []; const add = value => { const text = String(value || '').trim(); if (text && !candidates.includes(text)) candidates.push(text); }; add(resolution.resolved_id); add(resolution.resolved_short_id); if (resolution.group) { for (const alias of meshEntityAliases(resolution.group)) add(alias); add(resolution.group.name); } add(resolution.requested); add(shortNodeId(resolution.requested)); return candidates; } async function resolveDeviceId(input) { const requested = String(input || '').trim(); const devices = await getDevices(true).catch(() => []); const requestedShort = shortNodeId(requested); const device = devices.find(item => { const aliases = meshEntityAliases(item); aliases.push(shortNodeId(item.id)); return aliases.includes(requested) || aliases.includes(requestedShort); }) || null; return { requested, resolved_id: device ? device.id : requested, device, known_devices: devices.map(item => ({ id: item.id, short_id: shortNodeId(item.id), name: item.name, group: item.groupName, online: item.online })) }; } function bridgeStatus() { const meshctrl = findMeshctrl(); const authMethod = config.loginKeyFile && fs.existsSync(config.loginKeyFile) ? 'login_key_file' : (config.pass ? 'username_password' : 'none'); return { success: true, version: VERSION, bind: config.bind, port: config.port, meshctrl_found: Boolean(meshctrl), meshctrl_path: meshctrl, mesh_url: config.meshUrl, public_mesh_url: config.publicMeshUrl, public_mesh_url_configured: Boolean(config.publicMeshUrl), login_key_file: config.loginKeyFile, login_key_file_exists: config.loginKeyFile ? fs.existsSync(config.loginKeyFile) : false, password_configured: Boolean(config.pass), auth_method: authMethod, domain: config.domain, ignore_cert: config.ignoreCert, cache_ttl_seconds: config.cacheTtl, commands_enabled: config.allowCommands, maintenance_enabled: config.allowMaintenance, meshcentral_dir: config.meshCentralDir, meshcentral_data_dir: config.meshCentralDataDir, meshcentral_config_path: meshCentralConfigPath(), meshcentral_service_name: config.meshCentralServiceName, meshcentral_backup_dir: config.meshCentralBackupDir, db_dump_timeout_seconds: Math.round(config.dbDumpTimeoutMs / 1000), backup_stop_service_default: config.backupStopService, link_mode: config.linkMode, allowed_ips_configured: config.allowedIps.length > 0, allowed_ip_count: config.allowedIps.length, auth_required: Boolean(config.token), auth_window_seconds: Math.round(config.authWindowMs / 1000), auth_max_failures: config.authMaxFailures, wp_push_configured: Boolean(config.wpDomain && config.wpApiKey), wp_event_push_enabled: config.pushEvents, wp_domain: config.wpDomain || '' }; } function inferredMeshCentralPackageDir() { const meshctrl = findMeshctrl(); if (!meshctrl) return ''; const normalized = meshctrl.replace(/\\/g, '/'); const marker = '/node_modules/meshcentral/'; const idx = normalized.indexOf(marker); if (idx >= 0) return normalized.slice(0, idx + marker.length + 'meshcentral'.length); if (normalized.endsWith('/meshctrl.js') || normalized.endsWith('/meshctrl')) return path.dirname(normalized); return ''; } function inferredMeshCentralProjectDir() { if (config.meshCentralDir) return config.meshCentralDir; const pkg = inferredMeshCentralPackageDir(); if (!pkg) return ''; const marker = '/node_modules/meshcentral'; const idx = pkg.replace(/\\/g, '/').indexOf(marker); return idx >= 0 ? pkg.slice(0, idx) : path.dirname(pkg); } function readJsonFile(file) { try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch (_) { return null; } } function meshCentralConfigPath() { if (config.meshCentralConfigPath) return config.meshCentralConfigPath; if (config.meshCentralDataDir) return path.join(config.meshCentralDataDir, 'config.json'); const projectDir = inferredMeshCentralProjectDir(); if (projectDir) return path.join(projectDir, 'meshcentral-data', 'config.json'); return ''; } function redactSecrets(value) { if (Array.isArray(value)) return value.map(redactSecrets); if (!value || typeof value !== 'object') return value; const out = {}; for (const [key, item] of Object.entries(value)) { if (/pass|password|token|secret|key|session/i.test(key)) { out[key] = item ? '[redacted]' : item; } else { out[key] = redactSecrets(item); } } return out; } function loadMeshCentralConfig() { const file = meshCentralConfigPath(); if (!file || !fs.existsSync(file)) { return { path: file, exists: false, config: null, error: file ? 'Config file was not found.' : 'Config path is unknown.' }; } try { return { path: file, exists: true, config: JSON.parse(fs.readFileSync(file, 'utf8')), error: '' }; } catch (error) { return { path: file, exists: true, config: null, error: error.message }; } } function meshCentralDataDirFromConfigPath(file) { return file ? path.dirname(file) : config.meshCentralDataDir; } function meshCentralDbConfig(mcConfig) { const settings = mcConfig && mcConfig.settings ? mcConfig.settings : {}; const db = settings.MySQL || settings.mysql || settings.MariaDB || settings.mariadb || null; if (!db || typeof db !== 'object') return { type: 'embedded', configured: false }; return { type: settings.MySQL || settings.mysql ? 'mysql' : 'mariadb', configured: true, host: db.host || 'localhost', port: Number(db.port || 3306), user: db.user || db.username || '', password: db.password || db.pass || '', database: db.database || db.name || '' }; } function meshCentralConfigAudit(mcConfig) { const settings = mcConfig && mcConfig.settings ? mcConfig.settings : {}; const domains = mcConfig && mcConfig.domains ? mcConfig.domains : {}; const recommendations = []; const add = (severity, key, message, suggested) => recommendations.push({ severity, key, message, suggested }); if (settings.allowFraming === true && !settings.allowedFramingOrigins) { add('high', 'settings.allowedFramingOrigins', 'Framing is enabled globally. Restrict it to the WordPress origin used by MeshPress.', ['https://christp2p.com']); } if (!settings.dbRecordsEncryptKey) { add('medium', 'settings.dbRecordsEncryptKey', 'MeshCentral database records are not explicitly configured for at-rest encryption.', 'Generate a long random value and keep it backed up.'); } if (!settings.authLog) { add('medium', 'settings.authLog', 'Authentication events are not explicitly written to a dedicated log file.', '/var/log/meshcentral-auth.log'); } if (!settings.invalidLogin || typeof settings.invalidLogin !== 'object') { add('medium', 'settings.invalidLogin', 'Add invalid-login throttling so repeated login attempts are slowed down.', { time: 10, count: 10, coolofftime: 10 }); } if (settings.selfUpdate !== false) { add('low', 'settings.selfUpdate', 'Consider disabling MeshCentral self-update if MeshPress/bridge will own maintenance workflow.', false); } if (!settings.meshErrorLogPath) { add('low', 'settings.meshErrorLogPath', 'Set a dedicated MeshCentral error log path for easier copy/paste diagnostics.', '/var/log/meshcentral-error.log'); } if (!settings.backupPath) { add('low', 'settings.backupPath', 'Set MeshCentral backupPath even though MeshPress will create full snapshots.', '/var/backups/meshcentral'); } if (!settings.sessionKey || String(settings.sessionKey).length < 24) { add('medium', 'settings.sessionKey', 'Use a long random sessionKey and back it up. Changing it logs users out.', 'long random string'); } if (settings.maintenanceMode === true) { add('info', 'settings.maintenanceMode', 'MeshCentral maintenance mode is currently enabled.', false); } if (settings.ignoreAgentHashCheck === true) { add('info', 'settings.ignoreAgentHashCheck', 'Agent hash checking is intentionally ignored for this installation. MeshPress will not change it.', true); } return { maintenance_mode: settings.maintenanceMode === true, allow_framing: settings.allowFraming === true, allowed_framing_origins: settings.allowedFramingOrigins || [], mysql: meshCentralDbConfig(mcConfig), domain_count: Object.keys(domains).length, recommendations }; } function writeMeshCentralConfig(mcPath, mcConfig) { const backup = `${mcPath}.meshpress-old-${Date.now()}`; fs.copyFileSync(mcPath, backup); fs.chmodSync(backup, 0o600); fs.writeFileSync(mcPath, JSON.stringify(mcConfig, null, 2) + '\n', { mode: 0o600 }); return backup; } async function serviceAction(action) { const serviceName = config.meshCentralServiceName; const pm2Args = action === 'restart' ? ['restart', serviceName, '--update-env'] : [action, serviceName]; const pm2Action = async () => runCommand('pm2', pm2Args, { timeout: 120000 }); const systemctlAction = async () => runCommand('systemctl', [action, serviceName], { timeout: 120000 }); try { return await pm2Action(); } catch (pm2Error) { try { return await systemctlAction(); } catch (systemctlError) { throw new Error(`pm2 ${action} failed: ${pm2Error.message}; systemctl ${action} failed: ${systemctlError.message}`); } } } function sha256File(file) { const hash = crypto.createHash('sha256'); hash.update(fs.readFileSync(file)); return hash.digest('hex'); } function dumpDatabaseToFile(db, targetFile) { return new Promise((resolve, reject) => { const dumpArgs = [ '--single-transaction', '--routines', '--triggers', '--events', '--hex-blob', '--host', db.host, '--port', String(db.port), '--user', db.user, `--password=${db.password}`, db.database ]; const out = fs.openSync(targetFile, 'w', 0o600); const child = spawn(config.mysqlDumpPath, dumpArgs, { stdio: ['ignore', out, 'pipe'] }); const timer = setTimeout(() => { child.kill('SIGTERM'); reject(new Error(`mysqldump exceeded ${Math.round(config.dbDumpTimeoutMs / 1000)} seconds`)); }, config.dbDumpTimeoutMs); let stderr = ''; child.stderr.on('data', chunk => { stderr += chunk.toString(); }); child.on('error', error => { clearTimeout(timer); fs.closeSync(out); reject(error); }); child.on('close', code => { clearTimeout(timer); fs.closeSync(out); if (code !== 0) { reject(new Error(stderr.trim() || `mysqldump exited with code ${code}`)); return; } resolve(); }); }); } function installedMeshCentralPackageVersion() { const packageDir = inferredMeshCentralPackageDir(); const pkg = packageDir ? readJsonFile(path.join(packageDir, 'package.json')) : null; return pkg && pkg.version ? String(pkg.version) : ''; } function semverParts(version) { return String(version || '').split('.').map(part => parseInt(part.replace(/[^0-9].*$/, ''), 10) || 0); } function compareSemver(left, right) { const a = semverParts(left); const b = semverParts(right); for (let i = 0; i < Math.max(a.length, b.length); i += 1) { const diff = (a[i] || 0) - (b[i] || 0); if (diff) return diff; } return 0; } function readInstalledBridgeVersion() { try { return fs.readFileSync(path.join(__dirname, 'VERSION'), 'utf8').trim(); } catch (_) { return VERSION; } } async function bridgeUpdateStatus() { const url = `${config.catalogUrl}${config.catalogUrl.includes('?') ? '&' : '?'}nocache=${Date.now()}`; const response = await fetch(url, { headers: { Accept: 'application/json' } }); if (!response.ok) throw new Error(`catalog returned HTTP ${response.status}`); const manifest = await response.json(); const latest = String(manifest.version || '0.0.0'); const installed = readInstalledBridgeVersion(); return { success: true, service: 'meshpress-bridge', runtime_version: VERSION, installed_version: installed, latest_version: latest, update_available: Boolean(latest && compareSemver(installed, latest) < 0), download_url: manifest.download_url || '', catalog_url: config.catalogUrl, manifest_name: manifest.name || manifest.slug || '' }; } function setEnvValue(key, value) { const line = `${key}="${String(value).replace(/"/g, '\\"')}"`; let body = ''; if (fs.existsSync(ENV_FILE)) body = fs.readFileSync(ENV_FILE, 'utf8'); const backup = `${ENV_FILE}.old.${Date.now()}`; fs.mkdirSync(path.dirname(ENV_FILE), { recursive: true }); if (body) fs.writeFileSync(backup, body, { mode: 0o600 }); const pattern = new RegExp(`^${key}=.*$`, 'm'); body = pattern.test(body) ? body.replace(pattern, line) : `${body.replace(/\s*$/, '')}\n${line}\n`; fs.writeFileSync(ENV_FILE, body, { mode: 0o600 }); } async function latestMeshCentralNpmVersion() { const response = await fetch('https://registry.npmjs.org/meshcentral/latest', { headers: { Accept: 'application/json' } }); if (!response.ok) throw new Error(`npm registry returned HTTP ${response.status}`); const data = await response.json(); return data && data.version ? String(data.version) : ''; } async function meshCentralMaintenanceStatus() { let serverInfo = null; let serverInfoError = ''; try { serverInfo = await runMeshctrl('serverinfo'); } catch (error) { serverInfoError = error.message; } let latestVersion = ''; let updateCheckError = ''; try { latestVersion = await latestMeshCentralNpmVersion(); } catch (error) { updateCheckError = error.message; } const installedVersion = installedMeshCentralPackageVersion(); const projectDir = inferredMeshCentralProjectDir(); const packageDir = inferredMeshCentralPackageDir(); const backups = listMeshCentralBackups(); const configState = loadMeshCentralConfig(); const audit = configState.config ? meshCentralConfigAudit(configState.config) : null; return { success: true, bridge_version: VERSION, bridge_maintenance_enabled: config.allowMaintenance, maintenance_enabled: config.allowMaintenance, commands_enabled: config.allowCommands, meshcentral_project_dir: projectDir, meshcentral_package_dir: packageDir, meshcentral_data_dir: config.meshCentralDataDir, meshcentral_config_path: configState.path, meshcentral_config_exists: configState.exists, meshcentral_config_error: configState.error, meshcentral_maintenance_mode: audit ? audit.maintenance_mode : false, database: audit ? Object.assign({}, audit.mysql, { password: audit.mysql && audit.mysql.password ? '[redacted]' : '' }) : { type: 'unknown', configured: false }, meshcentral_service_name: config.meshCentralServiceName, backup_dir: config.meshCentralBackupDir, db_dump_timeout_seconds: Math.round(config.dbDumpTimeoutMs / 1000), backup_stop_service_default: config.backupStopService, installed_package_version: installedVersion, latest_npm_version: latestVersion, update_available: Boolean(installedVersion && latestVersion && compareSemver(installedVersion, latestVersion) < 0), update_check_error: updateCheckError, serverinfo: serverInfo, serverinfo_error: serverInfoError, backups, config_audit: audit ? Object.assign({}, audit, { mysql: Object.assign({}, audit.mysql, { password: audit.mysql && audit.mysql.password ? '[redacted]' : '' }) }) : null, notes: [ 'MeshCentral browser session links still depend on the browser being logged in to MeshCentral.', 'Bridge maintenance mode unlocks guarded bridge operations; MeshCentral maintenance mode is a separate setting in MeshCentral config.json.', 'Backups include MeshCentral config/data plus a MySQL/MariaDB dump when the config uses MySQL/MariaDB.', 'Set MESHCENTRAL_DATA_DIR or MESHCENTRAL_CONFIG_PATH before using bridge-managed full backups.' ] }; } function listMeshCentralBackups() { try { if (!fs.existsSync(config.meshCentralBackupDir)) return []; return fs.readdirSync(config.meshCentralBackupDir) .filter(name => /^meshcentral-(data|full)-.*\.tar\.gz$/.test(name)) .map(name => { const full = path.join(config.meshCentralBackupDir, name); const stat = fs.statSync(full); return { name, path: full, size: stat.size, modified: stat.mtime.toISOString() }; }) .sort((a, b) => String(b.modified).localeCompare(String(a.modified))); } catch (error) { return [{ error: error.message }]; } } function requireMaintenance(res) { if (config.allowMaintenance) return true; res.status(403).json({ success: false, error: 'MeshCentral maintenance actions are disabled. Set MESHPRESS_ALLOW_MAINTENANCE=1 on the bridge only after restricting the bridge to a private network or strict IP allowlist.' }); return false; } function normalizedSessionMode(mode) { const normalized = String(mode || '').toLowerCase(); if (normalized === 'console' || normalized === 'terminal' || normalized === 'shell') return 'terminal'; if (normalized === 'file' || normalized === 'files') return 'files'; return normalized; } function sessionViewMode(mode) { const viewModes = { desktop: '11', terminal: '12', files: '13' }; const normalized = normalizedSessionMode(mode); const viewmode = viewModes[normalized]; if (!viewmode) throw new Error(`Unknown link mode: ${mode}`); return { normalized, viewmode }; } function shortNodeId(nodeId) { const value = String(nodeId || ''); const parts = value.split('/'); return parts.length ? (parts[parts.length - 1] || value) : value; } function shellQuote(value) { return `'${String(value || '').replace(/'/g, `'\\''`)}'`; } function encodeMeshQueryValue(value, rawDollar = false) { let encoded = encodeURIComponent(String(value || '')); if (rawDollar) encoded = encoded.replace(/%24/g, '$'); return encoded; } function meshQuery(pairs, rawDollar = false) { return pairs.map(([key, value]) => `${encodeURIComponent(key)}=${encodeMeshQueryValue(value, rawDollar)}`).join('&'); } function linkFor(mode, nodeId, linkMode = config.linkMode, order = 'viewmode-first', encoding = 'raw') { if (!config.publicMeshUrl) throw new Error('PUBLIC_MESH_URL is not configured.'); const { viewmode } = sessionViewMode(mode); const nodeParam = linkMode === 'node' ? 'node' : 'gotonode'; const linkNodeId = linkMode === 'gotonode' ? shortNodeId(nodeId) : nodeId; const pairs = []; if (order === 'viewmode-first') { pairs.push(['viewmode', viewmode], [nodeParam, linkNodeId]); } else { pairs.push([nodeParam, linkNodeId], ['viewmode', viewmode]); } if (config.linkHide !== '') pairs.push(['hide', config.linkHide]); return `${config.publicMeshUrl}/?${meshQuery(pairs, encoding === 'raw')}`; } function sessionLinksFor(nodeId, mode) { const { normalized, viewmode } = sessionViewMode(mode); const shortId = shortNodeId(nodeId); return { mode: normalized, viewmode, node_id: nodeId, short_node_id: shortId, primary: linkFor(normalized, nodeId, 'gotonode', 'viewmode-first', 'raw'), gotonode_raw_viewmode_first: linkFor(normalized, nodeId, 'gotonode', 'viewmode-first', 'raw'), gotonode_encoded_viewmode_first: linkFor(normalized, nodeId, 'gotonode', 'viewmode-first', 'encoded'), gotonode_raw_node_first: linkFor(normalized, nodeId, 'gotonode', 'node-first', 'raw'), gotonode_encoded_node_first: linkFor(normalized, nodeId, 'gotonode', 'node-first', 'encoded'), node_raw_viewmode_first: linkFor(normalized, nodeId, 'node', 'viewmode-first', 'raw'), node_encoded_viewmode_first: linkFor(normalized, nodeId, 'node', 'viewmode-first', 'encoded'), gotonode: linkFor(normalized, nodeId, 'gotonode', 'viewmode-first', 'raw'), node: linkFor(normalized, nodeId, 'node', 'viewmode-first', 'raw'), gotonode_viewmode_first: linkFor(normalized, nodeId, 'gotonode', 'viewmode-first', 'raw'), node_viewmode_first: linkFor(normalized, nodeId, 'node', 'viewmode-first', 'raw'), manual_shape: `${config.publicMeshUrl}/?viewmode=${viewmode}&gotonode=${shortId}` }; } function allSessionLinksFor(nodeId) { return { desktop: sessionLinksFor(nodeId, 'desktop'), console: sessionLinksFor(nodeId, 'terminal'), files: sessionLinksFor(nodeId, 'files') }; } async function diagnoseSession(nodeId) { const devices = await getDevices(true).catch(() => []); const resolved = await resolveDeviceId(nodeId); const resolvedId = resolved.resolved_id || nodeId; const normalized = resolved.device || devices.find(device => device.id === nodeId || shortNodeId(device.id) === shortNodeId(nodeId)) || null; let deviceInfo = null; let deviceInfoError = ''; try { deviceInfo = await runMeshctrl('deviceinfo', ['--id', resolvedId]); } catch (error) { deviceInfoError = error.message; } const links = allSessionLinksFor(resolvedId); bridgeLog('session_diagnostics', { requested_node_id: nodeId, resolved_node_id: resolvedId, resolved_device_name: normalized ? normalized.name : '', terminal_primary: links.console.primary }); return { node_id: nodeId, resolved_node_id: resolvedId, node_resolution: { requested: resolved.requested, resolved_id: resolved.resolved_id, matched: Boolean(resolved.device), device: resolved.device, known_device_count: resolved.known_devices.length }, normalized_device: normalized, raw_device_info: deviceInfo, raw_device_info_error: deviceInfoError, links, hints: [ 'Terminal is MeshCentral viewmode 12.', 'The primary URL intentionally preserves dollar signs in gotonode because MeshCentral direct links can fail when those are encoded as %24.', 'If MeshCentral opens Terminal with a blank title, the node id was not resolved by MeshCentral; compare primary with manual_shape and resolved_node_id.', 'If the tab opens but says disconnected, compare the node_id here with the raw MeshCentral device _id and confirm the agent reports conn > 0.' ] }; } function agentLinksFor(meshId, groupRecord = null) { if (!config.publicMeshUrl) throw new Error('PUBLIC_MESH_URL is not configured.'); const canonicalMeshId = groupRecord?.id || groupRecord?._id || meshId; const encodedMesh = encodeURIComponent(canonicalMeshId); const shortMeshId = groupRecord?.short_id || shortNodeId(canonicalMeshId); const linuxInstallerScript = `${config.publicMeshUrl}/meshagents?script=1`; const linuxInstallCommand = `(wget ${shellQuote(linuxInstallerScript)} --no-check-certificate -O ./meshinstall.sh || wget ${shellQuote(linuxInstallerScript)} --no-proxy --no-check-certificate -O ./meshinstall.sh) && chmod 755 ./meshinstall.sh && sudo -E ./meshinstall.sh ${shellQuote(config.publicMeshUrl)} ${shellQuote(shortMeshId)} || ./meshinstall.sh ${shellQuote(config.publicMeshUrl)} ${shellQuote(shortMeshId)}`; const linuxCurlInstallCommand = `curl -fsSL ${shellQuote(linuxInstallerScript)} -o ./meshinstall.sh && chmod 755 ./meshinstall.sh && sudo -E ./meshinstall.sh ${shellQuote(config.publicMeshUrl)} ${shellQuote(shortMeshId)}`; const installerShape = { short_mesh_id_length: shortMeshId.length, has_at: shortMeshId.includes('@'), has_dollar: shortMeshId.includes('$'), canonical_has_prefix: String(canonicalMeshId).startsWith('mesh//') }; return { mesh_id: canonicalMeshId, short_mesh_id: shortMeshId, group_name: groupRecord?.name || '', linux_install_script_url: linuxInstallerScript, installer_verified_shape: installerShape, agent_link_warning: shortMeshId.length < 64 ? 'Resolved MeshCentral group id looks too short. Re-sync the fleet and try again before installing an agent.' : '', meshcentral_group: `${config.publicMeshUrl}/?gotomesh=${encodedMesh}`, all_agent_downloads: `${config.publicMeshUrl}/meshagents?meshid=${encodedMesh}`, mesh_assistant_downloads: `${config.publicMeshUrl}/meshagents?meshid=${encodedMesh}&assistant=1`, windows_x64_agent: `${config.publicMeshUrl}/meshagents?id=4&meshid=${encodedMesh}&installflags=0`, windows_x86_agent: `${config.publicMeshUrl}/meshagents?id=3&meshid=${encodedMesh}&installflags=0`, windows_arm64_agent: `${config.publicMeshUrl}/meshagents?id=43&meshid=${encodedMesh}&installflags=0`, windows_x64_interactive: `${config.publicMeshUrl}/meshagents?id=4&meshid=${encodedMesh}&installflags=1`, linux_x64_agent: `${config.publicMeshUrl}/meshagents?id=6&meshid=${encodedMesh}&installflags=0`, linux_x64_install_script: linuxInstallerScript, linux_x64_install_command: linuxInstallCommand, linux_x64_install_command_curl: linuxCurlInstallCommand, linux_arm64_agent_best_effort: `${config.publicMeshUrl}/meshagents?id=26&meshid=${encodedMesh}&installflags=0`, macos_x64_agent_best_effort: `${config.publicMeshUrl}/meshagents?id=16&meshid=${encodedMesh}&installflags=0` }; } app.get('/api/health', auth, (req, res) => { res.json({ success: true, service: 'meshpress-bridge', version: VERSION }); }); app.get('/api/status', auth, async (req, res) => { res.json(bridgeStatus()); }); app.get('/api/version', auth, async (req, res) => { res.json({ success: true, service: 'meshpress-bridge', runtime_version: VERSION, installed_version: readInstalledBridgeVersion(), directory: __dirname, update_script_found: fs.existsSync(path.join(__dirname, 'update.sh')), catalog_url: config.catalogUrl, auto_update_enabled: String(process.env.AUTO_UPDATE || '1') !== '0', self_update_schedule: 'cron: minute 17 hourly via /etc/cron.d/gracepress-meshpress-bridge', uptime_seconds: Math.round(process.uptime()) }); }); app.get('/api/update-check', auth, async (req, res) => { try { const status = await bridgeUpdateStatus(); bridgeLog('bridge_update_check', status); res.json(status); } catch (error) { bridgeLog('bridge_update_check_failed', { error: error.message, catalog_url: config.catalogUrl }); res.status(500).json(publicErrorPayload(error, { catalog_url: config.catalogUrl })); } }); app.get('/api/self-test', auth, async (req, res) => { const status = bridgeStatus(); const checks = []; checks.push({ name: 'meshctrl_path', ok: status.meshctrl_found, detail: status.meshctrl_path || 'Set MESHCTRL_PATH.' }); checks.push({ name: 'auth_method', ok: status.auth_method !== 'none', detail: status.auth_method }); checks.push({ name: 'public_mesh_url', ok: status.public_mesh_url_configured, detail: status.public_mesh_url || 'Set PUBLIC_MESH_URL.' }); try { const groups = await getGroups(true); checks.push({ name: 'listdevicegroups', ok: true, count: groups.length }); } catch (error) { checks.push({ name: 'listdevicegroups', ok: false, error: error.message }); } try { const devices = await getDevices(true); checks.push({ name: 'listdevices', ok: true, count: devices.length }); } catch (error) { checks.push({ name: 'listdevices', ok: false, error: error.message }); } const ok = checks.every(check => check.ok); res.status(ok ? 200 : 500).json({ success: ok, status, checks }); }); app.get('/api/groups', auth, async (req, res) => { try { res.json({ success: true, groups: await getGroups(req.query.refresh === '1') }); } catch (error) { res.status(500).json({ success: false, error: error.message }); } }); app.get('/api/users', auth, async (req, res) => { try { res.json({ success: true, users: await getUsers(req.query.refresh === '1') }); } catch (error) { res.status(500).json({ success: false, error: error.message }); } }); app.get('/api/events', auth, async (req, res) => { try { const events = await getEvents(req.query.refresh === '1'); const limit = Math.max(1, Math.min(Number(req.query.limit || 100), 500)); res.json({ success: true, events: events.slice(0, limit) }); } catch (error) { res.status(500).json({ success: false, error: error.message }); } }); app.get('/api/serverinfo', auth, async (req, res) => { try { res.json({ success: true, server: await runMeshctrl('serverinfo') }); } catch (error) { res.status(500).json({ success: false, error: error.message }); } }); app.get('/api/routes', auth, (req, res) => { res.json({ success: true, version: VERSION, diagnostic_endpoints: [ 'GET /api/health', 'GET /api/version', 'GET /api/update-check', 'GET /api/status', 'GET /api/self-test', 'GET /api/routes', 'GET /api/groups?refresh=1', 'GET /api/groups/resolve/:groupId', 'GET /api/devices?refresh=1', 'GET /api/device/:nodeId', 'GET /api/session-links/:nodeId', 'GET /api/session-diagnostics/:nodeId', 'GET /api/meshcentral/maintenance', 'GET /api/meshcentral/config-audit', 'GET /api/meshcentral/backups', 'POST /api/maintenance/toggle', 'POST /api/meshcentral/maintenance/toggle', 'POST /api/meshcentral/backup', 'POST /api/meshcentral/update', 'POST /api/groups/create', 'POST /api/groups/edit', 'POST /api/device/rename', 'POST /api/device/move', 'POST /api/device/uninstall', 'GET /api/agent-links/:groupId' ], logging: { format: '[meshpress-bridge] JSON', useful_events: ['meshcentral_maintenance_status', 'meshcentral_config_audit', 'meshcentral_maintenance_toggle', 'meshcentral_backup_started', 'meshcentral_backup_db_dumped', 'meshcentral_backup_finished', 'meshcentral_update_started', 'device_uninstall_attempt', 'session_link', 'session_diagnostics', 'group_resolve', 'group_edit_attempt', 'group_edit_rejected_output', 'group_edit_success', 'group_edit_failed', 'agent_links'] } }); }); app.get('/api/devices', auth, async (req, res) => { try { res.json({ success: true, devices: await getDevices(req.query.refresh === '1') }); } catch (error) { res.status(500).json({ success: false, error: error.message }); } }); app.get('/api/device/:nodeId', auth, async (req, res) => { try { const raw = await runMeshctrl('deviceinfo', ['--id', req.params.nodeId]); res.json({ success: true, device: raw }); } catch (error) { res.status(500).json({ success: false, error: error.message }); } }); app.get('/api/fleet', auth, async (req, res) => { try { res.json(await buildFleet(req.query.refresh === '1')); } catch (error) { res.status(500).json({ success: false, error: error.message }); } }); async function buildFleet(refresh = false) { const groups = await getGroups(refresh); const devices = await getDevices(refresh); return { success: true, groups, devices, generated_at: new Date().toISOString(), bridge_version: VERSION }; } async function pushFleet(reason = 'scheduled') { if (!config.wpDomain || !config.wpApiKey) { return { success: false, error: 'WP_DOMAIN or WP_API_KEY is not configured.' }; } const fleet = await buildFleet(true); fleet.reason = reason; const url = `${config.wpDomain}/wp-json/gracepress/v1/meshpress/fleet`; const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${config.wpApiKey}` }, body: JSON.stringify(fleet) }); const text = await response.text(); let body = {}; try { body = JSON.parse(text); } catch (_) { body = { raw: text.slice(0, 300) }; } if (!response.ok || body.success === false) { const error = new Error(body.error || body.message || `WordPress returned HTTP ${response.status}`); bridgeLog('fleet_push_failed', { reason, status: response.status, error: error.message }); throw error; } bridgeLog('fleet_push_success', { reason, status: response.status, devices: fleet.devices.length, groups: fleet.groups.length }); return { success: true, status: response.status, body }; } app.post('/api/push-now', auth, async (req, res) => { try { res.json(await pushFleet('manual')); } catch (error) { res.status(500).json({ success: false, error: error.message }); } }); app.post('/api/cache/clear', auth, (req, res) => { cache.clear(); res.json({ success: true, message: 'Bridge cache cleared.' }); }); app.get('/api/link/:mode/:nodeId', auth, (req, res) => { try { const requested = req.query.link_mode || req.query.linkMode || config.linkMode; if (requested === 'all') { res.json({ success: true, mode: normalizedSessionMode(req.params.mode), node_id: req.params.nodeId, links: sessionLinksFor(req.params.nodeId, req.params.mode) }); return; } if (requested === 'both') { res.json({ success: true, mode: normalizedSessionMode(req.params.mode), node_id: req.params.nodeId, links: { gotonode: linkFor(req.params.mode, req.params.nodeId, 'gotonode'), node: linkFor(req.params.mode, req.params.nodeId, 'node') } }); return; } const linkMode = requested === 'node' ? 'node' : 'gotonode'; const url = linkFor(req.params.mode, req.params.nodeId, linkMode); bridgeLog('session_link', { mode: normalizedSessionMode(req.params.mode), node_id: req.params.nodeId, link_mode: linkMode, url }); res.json({ success: true, mode: normalizedSessionMode(req.params.mode), node_id: req.params.nodeId, link_mode: linkMode, url }); } catch (error) { res.status(400).json({ success: false, error: error.message }); } }); app.get('/api/session-links/:nodeId', auth, (req, res) => { try { const links = allSessionLinksFor(req.params.nodeId); bridgeLog('session_links', { node_id: req.params.nodeId, terminal_primary: links.console.primary }); res.json({ success: true, node_id: req.params.nodeId, links }); } catch (error) { res.status(400).json({ success: false, error: error.message }); } }); app.get('/api/session-diagnostics/:nodeId', auth, async (req, res) => { try { res.json({ success: true, diagnostics: await diagnoseSession(req.params.nodeId) }); } catch (error) { res.status(400).json({ success: false, error: error.message }); } }); app.get('/api/meshcentral/maintenance', auth, async (req, res) => { try { const status = await meshCentralMaintenanceStatus(); bridgeLog('meshcentral_maintenance_status', { installed_package_version: status.installed_package_version, latest_npm_version: status.latest_npm_version, update_available: status.update_available, maintenance_enabled: status.maintenance_enabled }); res.json(status); } catch (error) { res.status(500).json(publicErrorPayload(error)); } }); app.get('/api/meshcentral/backups', auth, (req, res) => { res.json({ success: true, backup_dir: config.meshCentralBackupDir, backups: listMeshCentralBackups() }); }); app.get('/api/meshcentral/config-audit', auth, (req, res) => { const state = loadMeshCentralConfig(); if (!state.config) { res.status(state.exists ? 500 : 404).json({ success: false, path: state.path, exists: state.exists, error: state.error }); return; } const audit = meshCentralConfigAudit(state.config); bridgeLog('meshcentral_config_audit', { path: state.path, maintenance_mode: audit.maintenance_mode, mysql_configured: audit.mysql.configured, recommendation_count: audit.recommendations.length }); res.json({ success: true, path: state.path, redacted_config: redactSecrets(state.config), audit: Object.assign({}, audit, { mysql: Object.assign({}, audit.mysql, { password: audit.mysql.password ? '[redacted]' : '' }) }) }); }); app.post('/api/meshcentral/maintenance/toggle', auth, async (req, res) => { if (!requireMaintenance(res)) return; const enabled = req.body.enabled === true || req.body.enabled === '1' || req.body.enabled === 1; const restart = req.body.restart !== false && req.body.restart !== '0'; const state = loadMeshCentralConfig(); if (!state.config) { res.status(state.exists ? 500 : 404).json({ success: false, path: state.path, exists: state.exists, error: state.error }); return; } try { state.config.settings = state.config.settings || {}; state.config.settings.maintenanceMode = enabled; const backup = writeMeshCentralConfig(state.path, state.config); let restart_output = ''; if (restart) { restart_output = await serviceAction('restart'); } appendMaintenanceLog('meshcentral_maintenance_toggle', { enabled, restart, config_path: state.path, config_backup: backup }); res.json({ success: true, meshcentral_maintenance_mode: enabled, config_path: state.path, config_backup: backup, restarted: restart, restart_output }); } catch (error) { appendMaintenanceLog('meshcentral_maintenance_toggle_failed', { enabled, error: error.message }); res.status(500).json(publicErrorPayload(error)); } }); app.post('/api/meshcentral/backup', auth, async (req, res) => { if (!requireMaintenance(res)) return; const state = loadMeshCentralConfig(); const dataDir = config.meshCentralDataDir || meshCentralDataDirFromConfigPath(state.path); if (!dataDir) { res.status(400).json({ success: false, error: 'MESHCENTRAL_DATA_DIR or MESHCENTRAL_CONFIG_PATH is not configured on the bridge.' }); return; } if (!fs.existsSync(dataDir)) { res.status(400).json({ success: false, error: `MeshCentral data directory does not exist: ${dataDir}` }); return; } try { fs.mkdirSync(config.meshCentralBackupDir, { recursive: true, mode: 0o700 }); const stamp = new Date().toISOString().replace(/[:.]/g, '-'); const workDir = path.join(config.meshCentralBackupDir, `.meshpress-full-${stamp}`); const backupFile = path.join(config.meshCentralBackupDir, `meshcentral-full-${stamp}.tar.gz`); const manifestFile = path.join(workDir, 'manifest.json'); const db = state.config ? meshCentralDbConfig(state.config) : { type: 'unknown', configured: false }; const stopService = req.body && ('stop_service' in req.body) ? (req.body.stop_service === true || req.body.stop_service === '1' || req.body.stop_service === 1) : config.backupStopService; fs.mkdirSync(workDir, { recursive: true, mode: 0o700 }); appendMaintenanceLog('meshcentral_backup_started', { data_dir: dataDir, config_path: state.path, backup_file: backupFile, database_type: db.type, stop_service: stopService }); let stopped = false; let dbDump = null; try { if (stopService) { await serviceAction('stop'); stopped = true; appendMaintenanceLog('meshcentral_backup_service_stopped', { service_name: config.meshCentralServiceName }); } if (db.configured) { if (!db.user || !db.database) throw new Error('MeshCentral MySQL/MariaDB config is missing user or database.'); dbDump = path.join(workDir, `${db.database}-${stamp}.sql`); await dumpDatabaseToFile(db, dbDump); appendMaintenanceLog('meshcentral_backup_db_dumped', { database: db.database, dump_file: dbDump, size: fs.statSync(dbDump).size }); } const manifest = { created_at: new Date().toISOString(), bridge_version: VERSION, meshcentral_package_version: installedMeshCentralPackageVersion(), meshcentral_project_dir: inferredMeshCentralProjectDir(), meshcentral_data_dir: dataDir, meshcentral_config_path: state.path, database: Object.assign({}, db, { password: db.password ? '[redacted]' : '' }), db_dump_file: dbDump ? path.basename(dbDump) : '', stopped_service_for_backup: stopped, service_name: config.meshCentralServiceName }; fs.writeFileSync(manifestFile, JSON.stringify(manifest, null, 2) + '\n', { mode: 0o600 }); const parent = path.dirname(dataDir); const base = path.basename(dataDir); const tarArgs = ['-czf', backupFile, '-C', parent, base, '-C', workDir, 'manifest.json']; if (dbDump) tarArgs.push('-C', workDir, path.basename(dbDump)); await runCommand('tar', tarArgs, { timeout: 10 * 60 * 1000 }); } finally { if (stopped) { await serviceAction('start').catch(error => appendMaintenanceLog('meshcentral_backup_service_restart_failed', { error: error.message })); } fs.rmSync(workDir, { recursive: true, force: true }); } fs.chmodSync(backupFile, 0o600); const size = fs.statSync(backupFile).size; const sha256 = sha256File(backupFile); appendMaintenanceLog('meshcentral_backup_finished', { backup_file: backupFile, size, sha256 }); res.json({ success: true, backup_file: backupFile, size, sha256, database_included: db.configured, stopped_service_for_backup: stopService, backups: listMeshCentralBackups() }); } catch (error) { appendMaintenanceLog('meshcentral_backup_failed', { error: error.message }); res.status(500).json(publicErrorPayload(error)); } }); app.post('/api/meshcentral/update', auth, async (req, res) => { if (!requireMaintenance(res)) return; const projectDir = inferredMeshCentralProjectDir(); if (!projectDir || !fs.existsSync(projectDir)) { res.status(400).json({ success: false, error: 'Could not locate MeshCentral project directory. Set MESHCENTRAL_DIR on the bridge.' }); return; } try { const logFile = config.maintenanceLogFile; fs.mkdirSync(path.dirname(logFile), { recursive: true }); const out = fs.openSync(logFile, 'a'); const script = [ 'set -euo pipefail', `cd ${JSON.stringify(projectDir)}`, 'echo "[meshcentral-update] $(date -Is) starting npm update"', 'npm install meshcentral@latest', `if command -v pm2 >/dev/null 2>&1; then pm2 restart ${JSON.stringify(config.meshCentralServiceName)} --update-env || true; fi`, 'echo "[meshcentral-update] $(date -Is) finished"' ].join('\n'); const child = spawn('/bin/bash', ['-lc', script], { detached: true, stdio: ['ignore', out, out], env: process.env }); child.unref(); appendMaintenanceLog('meshcentral_update_started', { project_dir: projectDir, service_name: config.meshCentralServiceName, pid: child.pid, log_file: logFile }); res.json({ success: true, message: 'MeshCentral update started.', project_dir: projectDir, service_name: config.meshCentralServiceName, log_file: logFile, pid: child.pid }); } catch (error) { appendMaintenanceLog('meshcentral_update_failed', { error: error.message }); res.status(500).json(publicErrorPayload(error)); } }); app.get('/api/groups/resolve/:groupId', auth, async (req, res) => { try { const resolution = await resolveGroupId(req.params.groupId); bridgeLog('group_resolve', { requested_group_id: resolution.requested, resolved_group_id: resolution.resolved_id, matched_group_name: resolution.group ? resolution.group.name : '' }); res.json({ success: true, resolution }); } catch (error) { bridgeLog('group_resolve_failed', { requested_group_id: req.params.groupId, error: error.message }); res.status(500).json(publicErrorPayload(error)); } }); app.post('/api/update-now', auth, (req, res) => { const script = path.join(__dirname, 'update.sh'); if (!fs.existsSync(script)) { res.status(404).json({ success: false, error: 'update.sh was not found in the bridge install directory.' }); return; } const logFile = '/var/log/gracepress-meshpress-bridge-update.log'; try { const out = fs.openSync(logFile, 'a'); const child = spawn('/bin/bash', [script], { cwd: '/', detached: true, stdio: ['ignore', out, out], env: process.env }); child.unref(); res.json({ success: true, message: 'Bridge update started. The service may restart if an update is available.', log_file: logFile, pid: child.pid }); } catch (error) { res.status(500).json({ success: false, error: error.message }); } }); app.post('/api/maintenance/toggle', auth, (req, res) => { if (!config.allowCommands) { res.status(403).json({ success: false, error: 'Bridge command actions are disabled. Set MESHPRESS_ALLOW_COMMANDS=1 before toggling maintenance mode.' }); return; } const enabled = req.body.enabled === true || req.body.enabled === '1' || req.body.enabled === 1; try { setEnvValue('MESHPRESS_ALLOW_MAINTENANCE', enabled ? '1' : '0'); config.allowMaintenance = enabled; bridgeLog('maintenance_toggle', { enabled }); res.json({ success: true, maintenance_enabled: enabled, message: enabled ? 'Maintenance mode is enabled for this running bridge and persisted in env.' : 'Maintenance mode is disabled for this running bridge and persisted in env.' }); } catch (error) { res.status(500).json(publicErrorPayload(error)); } }); app.get('/api/agent-links/:groupId', auth, async (req, res) => { try { const resolved = await resolveGroupId(req.params.groupId, { refresh: true }); if (!resolved.matched) { res.status(404).json({ success: false, error: 'Group was not found. Refresh the fleet cache, then choose the group again.', requested_group_id: req.params.groupId, candidates: groupIdCandidates(resolved), known_groups: resolved.known_groups }); return; } const links = agentLinksFor(resolved.resolved_id, resolved.group); bridgeLog('agent_links', { group_id: resolved.resolved_id, group_name: resolved.group ? resolved.group.name : '', link_count: Object.keys(links).length }); res.json({ success: true, group: resolved.group, links, hints: [ 'For Linux, copy linux_x64_install_command and run it on the target machine.', 'If a platform-specific link fails, open all_agent_downloads and choose the exact agent from MeshCentral.', 'Best-effort macOS/Linux ARM links depend on MeshCentral agent id support in your installed MeshCentral build.' ] }); } catch (error) { res.status(400).json({ success: false, error: error.message }); } }); app.post('/api/groups/create', auth, async (req, res) => { if (!config.allowCommands) { res.status(403).json({ success: false, error: 'MeshCentral group creation is disabled. Set MESHPRESS_ALLOW_COMMANDS=1 on the bridge to enable provisioning actions.' }); return; } const name = String(req.body.name || '').trim(); const description = String(req.body.description || '').trim(); if (!name) { res.status(400).json({ success: false, error: 'Group name is required.' }); return; } try { const before = await getGroups(true).catch(() => []); const beforeIds = new Set(before.map(g => g.id)); const output = await runMeshctrlText('adddevicegroup', ['--name', name, '--desc', description || `Created by MeshPress Bridge ${new Date().toISOString()}`]); cache.delete('groups'); cache.delete('devices'); const groups = await getGroups(true); const created = groups.find(g => !beforeIds.has(g.id) && g.name === name) || groups.find(g => g.name === name) || null; if (!created) { res.json({ success: true, message: output, group: null, groups }); return; } res.json({ success: true, message: output, group: created, links: agentLinksFor(created.id) }); } catch (error) { res.status(500).json({ success: false, error: error.message }); } }); app.post('/api/groups/remove', auth, async (req, res) => { if (!config.allowCommands) { res.status(403).json({ success: false, error: 'MeshCentral group removal is disabled. Set MESHPRESS_ALLOW_COMMANDS=1 on the bridge to enable provisioning actions.' }); return; } const id = String(req.body.id || req.body.group_id || '').trim(); if (!id) { res.status(400).json({ success: false, error: 'Group id is required.' }); return; } try { const output = await runMeshctrlText('removedevicegroup', ['--id', id]); cache.delete('groups'); cache.delete('devices'); res.json({ success: true, message: output }); } catch (error) { res.status(500).json({ success: false, error: error.message }); } }); app.post('/api/groups/edit', auth, async (req, res) => { if (!config.allowCommands) { res.status(403).json({ success: false, error: 'MeshCentral group editing is disabled. Set MESHPRESS_ALLOW_COMMANDS=1 on the bridge to enable provisioning actions.' }); return; } const id = String(req.body.id || req.body.group_id || '').trim(); const name = String(req.body.name || '').trim(); const description = String(req.body.description || '').trim(); if (!id) { res.status(400).json({ success: false, error: 'Group id is required.' }); return; } try { const resolution = await resolveGroupId(id); const candidates = groupIdCandidates(resolution); const groupArgs = (flag, groupId, descFlag = '--desc') => { const args = [flag, groupId]; if (name) args.push('--name', name); if (description) args.push(descFlag, description); return args; }; const attempts = []; const seenAttempts = new Set(); const addAttempt = (command, args, candidate, flag, descFlag) => { const key = [command].concat(args).join('\u0000'); if (seenAttempts.has(key)) return; seenAttempts.add(key); attempts.push({ command, args, candidate, flag, descFlag }); }; for (const candidate of candidates) { addAttempt('editdevicegroup', groupArgs('--id', candidate, '--desc'), candidate, '--id', '--desc'); addAttempt('editdevicegroup', groupArgs('--groupid', candidate, '--desc'), candidate, '--groupid', '--desc'); addAttempt('editdevicegroup', groupArgs('--id', candidate, '--description'), candidate, '--id', '--description'); addAttempt('editdevicegroup', groupArgs('--groupid', candidate, '--description'), candidate, '--groupid', '--description'); } const errors = []; const rejectedOutputs = []; let output = ''; let successfulAttempt = null; let updatedGroup = null; let refreshedGroups = []; for (const attempt of attempts) { bridgeLog('group_edit_attempt', { requested_group_id: id, resolved_group_id: resolution.resolved_id, candidate_group_id: attempt.candidate, matched_group_name: resolution.group ? resolution.group.name : '', command: attempt.command, args: attempt.args }); try { const attemptOutput = await runMeshctrlText(attempt.command, attempt.args); if (isMeshctrlFailureText(attemptOutput)) { const rejected = { command: attempt.command, args: attempt.args, output: attemptOutput }; rejectedOutputs.push(rejected); bridgeLog('group_edit_rejected_output', { requested_group_id: id, resolved_group_id: resolution.resolved_id, candidate_group_id: attempt.candidate, command: attempt.command, args: attempt.args, output: attemptOutput }); continue; } cache.delete('groups'); cache.delete('devices'); refreshedGroups = await getGroups(true); updatedGroup = refreshedGroups.find(group => groupMatchesCandidate(group, [attempt.candidate, resolution.resolved_id, resolution.resolved_short_id, id])) || null; const verification = verifyGroupEdit(updatedGroup, name, description); if (!verification.ok) { rejectedOutputs.push({ command: attempt.command, args: attempt.args, output: attemptOutput, verification_failed: true, verification, matched_group_after_attempt: updatedGroup }); bridgeLog('group_edit_unverified', { requested_group_id: id, resolved_group_id: resolution.resolved_id, candidate_group_id: attempt.candidate, command: attempt.command, args: attempt.args, output: attemptOutput, verification, matched_group_after_attempt: updatedGroup }); continue; } output = attemptOutput || 'ok'; successfulAttempt = attempt; bridgeLog('group_edit_verified_success', { requested_group_id: id, resolved_group_id: resolution.resolved_id, candidate_group_id: attempt.candidate, command: attempt.command, args: attempt.args, output, updated_group: updatedGroup }); break; } catch (error) { errors.push({ command: attempt.command, args: attempt.args, error: error.message }); output = ''; } } if (!output) { const rejectedSummary = rejectedOutputs.map(item => item.output).filter(Boolean).join(' | '); const verificationSummary = rejectedOutputs .filter(item => item.verification_failed && item.verification && item.verification.mismatches) .flatMap(item => item.verification.mismatches) .join(' | '); const error = new Error(errors.map(item => item.error).join(' | ') || verificationSummary || rejectedSummary || 'Group edit failed.'); bridgeLog('group_edit_failed', { requested_group_id: id, resolved_group_id: resolution.resolved_id, matched_group_name: resolution.group ? resolution.group.name : '', errors, rejected_outputs: rejectedOutputs, known_groups: resolution.known_groups }); res.status(500).json(publicErrorPayload(error, { requested_group_id: id, resolved_group_id: resolution.resolved_id, matched_group: resolution.group, candidates, attempts, errors, rejected_outputs: rejectedOutputs, known_groups: resolution.known_groups })); return; } res.json({ success: true, verified: true, message: output, requested_group_id: id, resolved_group_id: resolution.resolved_id, candidate_group_id: successfulAttempt ? successfulAttempt.candidate : '', matched_group: resolution.group, updated_group: updatedGroup, groups: refreshedGroups }); } catch (error) { bridgeLog('group_edit_failed', { requested_group_id: id, error: error.message }); res.status(500).json(publicErrorPayload(error)); } }); app.post('/api/groups/add-user', auth, async (req, res) => { if (!config.allowCommands) { res.status(403).json({ success: false, error: 'MeshCentral group membership changes are disabled. Set MESHPRESS_ALLOW_COMMANDS=1 on the bridge to enable provisioning actions.' }); return; } const id = String(req.body.id || req.body.group_id || '').trim(); const userId = String(req.body.userid || req.body.user_id || '').trim(); const rights = Array.isArray(req.body.rights) ? req.body.rights : ['fullrights']; if (!id || !userId) { res.status(400).json({ success: false, error: 'Group id and user id are required.' }); return; } const allowedRights = new Set(['fullrights', 'editgroup', 'manageusers', 'managedevices', 'remotecontrol', 'agentconsole', 'serverfiles', 'wakedevices', 'notes', 'desktopviewonly', 'limiteddesktop', 'noterminal', 'nofiles', 'noamt']); const args = ['--id', id, '--userid', userId]; for (const right of rights) { if (allowedRights.has(String(right))) args.push(`--${right}`); } try { const output = await runMeshctrlText('addusertodevicegroup', args); cache.delete('groups'); res.json({ success: true, message: output }); } catch (error) { res.status(500).json({ success: false, error: error.message }); } }); app.post('/api/groups/remove-user', auth, async (req, res) => { if (!config.allowCommands) { res.status(403).json({ success: false, error: 'MeshCentral group membership changes are disabled. Set MESHPRESS_ALLOW_COMMANDS=1 on the bridge to enable provisioning actions.' }); return; } const id = String(req.body.id || req.body.group_id || '').trim(); const userId = String(req.body.userid || req.body.user_id || '').trim(); if (!id || !userId) { res.status(400).json({ success: false, error: 'Group id and user id are required.' }); return; } try { const output = await runMeshctrlText('removeuserfromdevicegroup', ['--id', id, '--userid', userId]); cache.delete('groups'); res.json({ success: true, message: output }); } catch (error) { res.status(500).json({ success: false, error: error.message }); } }); app.post('/api/invite-link', auth, async (req, res) => { if (!config.allowCommands) { res.status(403).json({ success: false, error: 'MeshCentral invite link generation is disabled. Set MESHPRESS_ALLOW_COMMANDS=1 on the bridge to enable provisioning actions.' }); return; } const groupId = String(req.body.group_id || req.body.id || '').trim(); if (!groupId) { res.status(400).json({ success: false, error: 'Group id is required.' }); return; } const args = ['--id', groupId]; if (req.body.hours) args.push('--hours', String(req.body.hours)); if (req.body.flags) args.push('--flags', String(req.body.flags)); try { const output = await runMeshctrlText('generateinvitelink', args); res.json({ success: true, message: output, raw: output }); } catch (error) { res.status(500).json({ success: false, error: error.message }); } }); app.post('/api/device/message', auth, async (req, res) => { if (!config.allowCommands) { res.status(403).json({ success: false, error: 'MeshCentral device messaging is disabled on this bridge.' }); return; } const nodeId = String(req.body.node_id || req.body.nodeId || '').trim(); const message = String(req.body.message || '').trim(); if (!nodeId || !message) { res.status(400).json({ success: false, error: 'Node id and message are required.' }); return; } try { const output = await runMeshctrlText('devicemessage', ['--id', nodeId, '--msg', message]); res.json({ success: true, message: output }); } catch (error) { res.status(500).json({ success: false, error: error.message }); } }); app.post('/api/device/toast', auth, async (req, res) => { if (!config.allowCommands) { res.status(403).json({ success: false, error: 'MeshCentral device toast is disabled on this bridge.' }); return; } const nodeId = String(req.body.node_id || req.body.nodeId || '').trim(); const message = String(req.body.message || '').trim(); if (!nodeId || !message) { res.status(400).json({ success: false, error: 'Node id and message are required.' }); return; } try { const output = await runMeshctrlText('devicetoast', ['--id', nodeId, '--msg', message]); res.json({ success: true, message: output }); } catch (error) { res.status(500).json({ success: false, error: error.message }); } }); app.post('/api/device/rename', auth, async (req, res) => { if (!config.allowCommands) { res.status(403).json({ success: false, error: 'MeshCentral device editing is disabled on this bridge.' }); return; } const nodeId = String(req.body.node_id || req.body.nodeId || req.body.id || '').trim(); const name = String(req.body.name || '').trim(); if (!nodeId || !name) { res.status(400).json({ success: false, error: 'Node id and new name are required.' }); return; } try { const output = await runMeshctrlText('editdevice', ['--id', nodeId, '--name', name]); cache.delete('devices'); res.json({ success: true, message: output }); } catch (error) { res.status(500).json({ success: false, error: error.message }); } }); app.post('/api/device/move', auth, async (req, res) => { if (!config.allowCommands) { res.status(403).json({ success: false, error: 'MeshCentral device moving is disabled on this bridge.' }); return; } const nodeId = String(req.body.node_id || req.body.nodeId || req.body.id || '').trim(); const groupId = String(req.body.group_id || req.body.groupId || req.body.meshid || '').trim(); if (!nodeId || !groupId) { res.status(400).json({ success: false, error: 'Node id and destination group id are required.' }); return; } try { const output = await runMeshctrlText('movedevice', ['--id', nodeId, '--groupid', groupId]); cache.delete('devices'); res.json({ success: true, message: output }); } catch (error) { res.status(500).json({ success: false, error: error.message }); } }); app.post('/api/device/uninstall', auth, async (req, res) => { if (!config.allowCommands) { res.status(403).json({ success: false, error: 'MeshCentral agent uninstall is disabled on this bridge.' }); return; } const nodeId = String(req.body.node_id || req.body.nodeId || req.body.id || '').trim(); if (!nodeId) { res.status(400).json({ success: false, error: 'Node id is required.' }); return; } try { const resolved = await resolveDeviceId(nodeId); const id = resolved.resolved_id || nodeId; const attempts = [ { command: 'deviceuninstall', args: ['--id', id] }, { command: 'uninstallagent', args: ['--id', id] }, { command: 'deviceaction', args: ['--id', id, '--action', 'uninstallagent'] } ]; const errors = []; for (const attempt of attempts) { bridgeLog('device_uninstall_attempt', { requested_node_id: nodeId, resolved_node_id: id, command: attempt.command, args: attempt.args }); try { const output = await runMeshctrlText(attempt.command, attempt.args); if (isMeshctrlFailureText(output)) { errors.push({ command: attempt.command, args: attempt.args, output }); continue; } cache.delete('devices'); bridgeLog('device_uninstall_success', { requested_node_id: nodeId, resolved_node_id: id, command: attempt.command, output }); res.json({ success: true, message: output, command: attempt.command, resolved_node_id: id, matched_device: resolved.device }); return; } catch (error) { errors.push({ command: attempt.command, args: attempt.args, error: error.message }); } } const error = new Error('No supported MeshCtrl uninstall command succeeded.'); bridgeLog('device_uninstall_failed', { requested_node_id: nodeId, resolved_node_id: id, errors }); res.status(500).json(publicErrorPayload(error, { resolved_node_id: id, matched_device: resolved.device, attempts, errors })); } catch (error) { bridgeLog('device_uninstall_failed', { requested_node_id: nodeId, error: error.message }); res.status(500).json(publicErrorPayload(error)); } }); app.post('/api/action', auth, (req, res) => { if (!config.allowCommands) { res.status(403).json({ success: false, error: 'MeshCentral command dispatch is disabled on this bridge.' }); return; } res.status(501).json({ success: false, error: 'Command dispatch is reserved for a later hardening pass.' }); }); app.use((req, res) => { res.status(404).json({ success: false, error: 'Not found' }); }); app.listen(config.port, config.bind, () => { console.log(`[meshpress-bridge] v${VERSION} listening on ${config.bind}:${config.port}`); if (!config.token) console.error('[meshpress-bridge] SECURITY: MESHPRESS_BRIDGE_TOKEN is empty; all protected endpoints will reject requests.'); if (config.bind === '0.0.0.0' && !config.allowedIps.length) console.error('[meshpress-bridge] SECURITY: listening on 0.0.0.0 without MESHPRESS_ALLOWED_IPS. Use firewall/VPN controls before exposing this port.'); if (config.wpDomain && config.wpApiKey && config.pushIntervalSeconds > 0) { console.log(`[meshpress-bridge] fleet push enabled every ${config.pushIntervalSeconds}s to ${config.wpDomain}`); setTimeout(() => pushFleet('startup').catch(error => console.error(`[meshpress-bridge] startup push failed: ${error.message}`)), 5000); setInterval(() => pushFleet('scheduled').catch(error => console.error(`[meshpress-bridge] scheduled push failed: ${error.message}`)), config.pushIntervalSeconds * 1000); } });