2064 lines
82 KiB
JavaScript
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);
|
|
}
|
|
});
|