Files
2026-07-11 10:44:42 -05:00

2064 lines
82 KiB
JavaScript

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);
}
});