Files
holesail-browser/native-host/host/message-router.js
T
Raven Scott a838c3a345
CI / Build & Test (push) Successful in 2m51s
feat(pwa): experimental — add my.dash.board virtual host for secure PWA install
Serve the dashboard over a local HTTPS virtual host (my.dash.board) so
Chrome treats it as a secure origin and shows the PWA install prompt.

- Add native-host/dashboard-server.js: bare-http1 static file server
  backed by bare-bundle assets in distribution mode, disk fallback in dev
- Add setLocalVirtualHost() to virtual-hosts.js; type:local entries are
  protected from removal and filtered out of saveState persistence
- Export setLocalVirtualHost from holesail-manager/index.js
- Start dashboard server in startup.js after proxies are ready and
  register my.dash.board as a local virtual host
- Stop dashboard server in message-router.js cleanup()
- Update manifest.webmanifest: start_url, id, scope → https://my.dash.board/
- Embed all extension/dashboard/ files as bare-bundle assets in
  build-distributable.js patchBundle() — no installer copy step needed
- Hide checkbox, hs:// URL, Reconnect, and Remove controls for built-in
  my.dash.board row in the virtual hosts table UI
2026-03-01 02:58:52 -05:00

356 lines
14 KiB
JavaScript

/**
* Central message dispatcher for the native host.
* Receives all messages from the browser extension and routes them to the
* appropriate manager. Also wires up all managers and starts the proxy/tunnel
* restore sequence on first require.
*/
const { STORAGE_PATH } = require('./paths.js');
const { log, debugLog } = require('./logger.js');
const { initStartup, getProxiesReadyPromise, getTunnelsRestoredPromise, setTunnelsRestoredPromise, restorePersistedTunnels } = require('./startup.js');
const holesailManager = require('../holesail-manager.js');
holesailManager.setStoragePath(STORAGE_PATH);
const certificateAuthority = require('../certificate-authority.js');
const httpsProxy = require('../https-proxy.js');
const connectProxy = require('../connect-proxy.js');
const sshManager = require('../ssh-manager.js');
const dashboardServer = require('../dashboard-server.js');
const backupManager = require('../backup-manager.js');
backupManager.setStoragePath(STORAGE_PATH);
backupManager.setCertsPath(certificateAuthority.getCertsDir());
const rdpManager = require('../rdp-manager.js');
httpsProxy.setHostnameResolver((hostname) => holesailManager.getLocalBackend(hostname));
initStartup(holesailManager, certificateAuthority, httpsProxy, connectProxy);
// Scheduled auto-backup: fires every backupIntervalHours when > 0.
let _scheduledBackupTimer = null;
function scheduleNextAutoBackup() {
if (_scheduledBackupTimer) { clearTimeout(_scheduledBackupTimer); _scheduledBackupTimer = null; }
const settings = holesailManager.getSettings();
const hours = typeof settings.backupIntervalHours === 'number' ? settings.backupIntervalHours : 0;
if (hours <= 0) return;
const ms = hours * 60 * 60 * 1000;
_scheduledBackupTimer = setTimeout(async () => {
_scheduledBackupTimer = null;
log('Scheduled auto-backup starting (interval=' + hours + 'h)');
const result = await backupManager.createBackup().catch((e) => ({ ok: false, error: e.message }));
if (result.ok) {
const retention = (holesailManager.getSettings().backupRetention) || backupManager.DEFAULT_RETENTION;
backupManager.pruneOldBackups(retention);
log('Scheduled auto-backup complete:', result.filename);
} else {
log('Scheduled auto-backup failed:', result.error);
}
scheduleNextAutoBackup();
}, ms);
}
// Start the auto-backup schedule once proxies are ready.
const _proxiesReady = getProxiesReadyPromise();
if (_proxiesReady) {
_proxiesReady.then(() => scheduleNextAutoBackup()).catch(() => {});
} else {
scheduleNextAutoBackup();
}
// Module-level send reference — updated once on first message so the event emitter
// always routes to the current active port without being re-set on every message.
let _send = null;
holesailManager.setEventEmitter((event, eventPayload) => {
if (!_send) return;
debugLog('emit event:', event, eventPayload && Object.keys(eventPayload));
_send({ type: 'event', event, payload: eventPayload });
});
/**
* @param {Function} send - messenger.send(msg)
* @param {object} msg - { id, type, payload }
*/
async function handleMessageAsync(send, msg) {
_send = send;
const { id, type, payload = {} } = msg;
function reply(result) {
send({ id, type: 'response', payload: result });
}
try {
debugLog('handleMessage: id=', id, 'type=', type, 'payloadKeys=', payload && Object.keys(payload));
const proxiesReadyPromise = getProxiesReadyPromise();
const tunnelsRestoredPromise = getTunnelsRestoredPromise();
switch (type) {
case 'getState': {
if (proxiesReadyPromise) await proxiesReadyPromise;
if (tunnelsRestoredPromise) {
let _fallbackTimer;
await Promise.race([
tunnelsRestoredPromise.finally(() => clearTimeout(_fallbackTimer)),
new Promise((r) => { _fallbackTimer = setTimeout(r, 15000); })
]);
}
const servers = holesailManager.getServers();
const virtualHosts = holesailManager.getVirtualHosts();
const serviceTunnels = holesailManager.getServiceTunnels();
const proxyPort = holesailManager.getProxyPort();
const connectProxyPort = connectProxy.getPort();
const settings = holesailManager.getSettings();
const sshConnections = holesailManager.getSshConnections();
const rdpConnections = holesailManager.getRdpConnections();
debugLog('getState: servers=', servers.length, 'virtualHosts=', virtualHosts.length, 'serviceTunnels=', serviceTunnels.length, 'proxyPort=', proxyPort, 'connectProxyPort=', connectProxyPort);
const caInstalled = await new Promise((resolve) => {
certificateAuthority.isRootCAInstalled((installed) => resolve(!!installed));
});
reply({
ok: true,
servers,
virtualHosts,
serviceTunnels,
proxyPort,
connectProxyPort: connectProxyPort ?? null,
caInstalled,
settings,
sshConnections,
rdpConnections,
trafficStats: httpsProxy.getTrafficStats()
});
break;
}
case 'getSettings': {
reply({ ok: true, settings: holesailManager.getSettings() });
break;
}
case 'updateSettings': {
const { requiresRestart } = holesailManager.updateSettings(payload);
debugLog('updateSettings: applied', JSON.stringify(payload), 'requiresRestart=', requiresRestart);
scheduleNextAutoBackup();
reply({ ok: true, settings: holesailManager.getSettings(), requiresRestart });
break;
}
case 'getSshConnections': {
reply({ ok: true, sshConnections: holesailManager.getSshConnections() });
break;
}
case 'setSshConnections': {
const list = Array.isArray(payload.connections) ? payload.connections : [];
holesailManager.setSshConnections(list);
debugLog('setSshConnections: count=', list.length);
reply({ ok: true });
break;
}
case 'getRdpConnections': {
reply({ ok: true, rdpConnections: holesailManager.getRdpConnections() });
break;
}
case 'setRdpConnections': {
const list = Array.isArray(payload.connections) ? payload.connections : [];
holesailManager.setRdpConnections(list);
debugLog('setRdpConnections: count=', list.length);
reply({ ok: true });
break;
}
case 'startServer': {
debugLog('startServer: payload=', JSON.stringify(payload));
const result = await holesailManager.startServer(payload);
debugLog('startServer: result=', JSON.stringify(result));
reply(result);
break;
}
case 'stopServer': {
debugLog('stopServer: payload=', JSON.stringify(payload));
const result = await holesailManager.stopServer(payload);
debugLog('stopServer: result=', JSON.stringify(result));
reply(result);
break;
}
case 'startServiceTunnel': {
debugLog('startServiceTunnel: payload=', JSON.stringify(payload));
const result = await holesailManager.startServiceTunnel(payload);
debugLog('startServiceTunnel: result=', JSON.stringify(result));
reply(result);
break;
}
case 'updateServiceTunnel': {
debugLog('updateServiceTunnel: payload=', JSON.stringify(payload));
await holesailManager.stopServiceTunnel({ tunnelId: payload.tunnelId }).catch(() => {});
const result = await holesailManager.startServiceTunnel(payload);
debugLog('updateServiceTunnel: result=', JSON.stringify(result));
reply(result);
break;
}
case 'stopServiceTunnel': {
debugLog('stopServiceTunnel: payload=', JSON.stringify(payload));
const result = await holesailManager.stopServiceTunnel(payload);
debugLog('stopServiceTunnel: result=', JSON.stringify(result));
reply(result);
break;
}
case 'getServiceTunnels': {
reply({ ok: true, tunnels: holesailManager.getServiceTunnels() });
break;
}
case 'getVirtualHosts': {
const hosts = holesailManager.getVirtualHosts();
debugLog('getVirtualHosts: count=', hosts.length);
reply({ ok: true, hosts });
break;
}
case 'setVirtualHost': {
debugLog('setVirtualHost: payload=', JSON.stringify(payload));
const result = await holesailManager.setVirtualHost(payload);
debugLog('setVirtualHost: result=', JSON.stringify(result));
reply(result);
break;
}
case 'removeVirtualHost': {
debugLog('removeVirtualHost: payload=', JSON.stringify(payload));
const result = await holesailManager.removeVirtualHost(payload);
debugLog('removeVirtualHost: result=', JSON.stringify(result));
reply(result);
break;
}
case 'getProxyPort': {
const port = holesailManager.getProxyPort();
debugLog('getProxyPort: port=', port);
reply({ ok: true, port });
break;
}
case 'lookup': {
debugLog('lookup: payload=', JSON.stringify(payload));
const result = await holesailManager.lookup(payload);
debugLog('lookup: result=', JSON.stringify(result));
reply(result);
break;
}
case 'installRootCA': {
certificateAuthority.installRootCA((err) => {
if (err) reply({ ok: false, error: err.message });
else reply({ ok: true });
});
break;
}
case 'startSshSession': {
debugLog('startSshSession: payload=', JSON.stringify({ ...payload, hsUrl: payload.hsUrl ? payload.hsUrl.slice(0, 20) + '...' : null }));
const result = await sshManager.startSession(payload);
debugLog('startSshSession: result=', JSON.stringify({ ok: result.ok, sessionId: result.sessionId, wsPort: result.wsPort }));
reply(result);
break;
}
case 'stopSshSession': {
debugLog('stopSshSession: payload=', JSON.stringify(payload));
const result = await sshManager.stopSession(payload);
debugLog('stopSshSession: result=', JSON.stringify(result));
reply(result);
break;
}
case 'resizeSshSession': {
sshManager.resizeSession(payload);
reply({ ok: true });
break;
}
case 'getSshSessions': {
reply({ ok: true, sessions: sshManager.getSessions() });
break;
}
case 'startRdpSession': {
debugLog('startRdpSession: type=', payload.type, 'label=', payload.label);
const result = await rdpManager.startSession(payload);
debugLog('startRdpSession: result=', JSON.stringify({ ok: result.ok, sessionId: result.sessionId, wsPort: result.wsPort }));
reply(result);
break;
}
case 'stopRdpSession': {
debugLog('stopRdpSession: sessionId=', payload.sessionId);
const result = await rdpManager.stopSession(payload);
reply(result);
break;
}
case 'getRdpSessions': {
reply({ ok: true, sessions: rdpManager.getSessions() });
break;
}
case 'createBackup': {
const backupResult = await backupManager.createBackup();
if (backupResult.ok) {
const retention = (holesailManager.getSettings().backupRetention) || backupManager.DEFAULT_RETENTION;
backupManager.pruneOldBackups(retention);
}
reply(backupResult);
break;
}
case 'listBackups': {
reply(backupManager.listBackups());
break;
}
case 'restoreBackup': {
const restoreResult = await backupManager.restoreBackup(payload.filename);
if (restoreResult.ok) {
await holesailManager.cleanup().catch(() => {});
const restored = holesailManager.restorePersistedState();
setTunnelsRestoredPromise(
restorePersistedTunnels(holesailManager, restored).catch((e) => log('Post-restore tunnel start failed:', e.message))
);
}
reply(restoreResult);
break;
}
case 'deleteBackup': {
reply(backupManager.deleteBackup(payload.filename));
break;
}
case 'pingTunnel': {
// Measure TCP connect latency to a local tunnel port.
// payload: { host, port }
const pingHost = payload.host || '127.0.0.1';
const pingPort = typeof payload.port === 'number' ? payload.port : null;
if (!pingPort) { reply({ ok: false, error: 'port required' }); break; }
const tcp = require('bare-tcp');
const t0 = Date.now();
const sock = tcp.connect(pingPort, pingHost);
const pingTimeout = setTimeout(() => {
try { sock.destroy(); } catch (_) {}
reply({ ok: false, error: 'timeout' });
}, 3000);
sock.on('connect', () => {
clearTimeout(pingTimeout);
const latencyMs = Date.now() - t0;
try { sock.destroy(); } catch (_) {}
reply({ ok: true, latencyMs });
});
sock.on('error', (err) => {
clearTimeout(pingTimeout);
try { sock.destroy(); } catch (_) {}
reply({ ok: false, error: err.message });
});
break;
}
default:
reply({ ok: false, error: `Unknown command: ${type}` });
}
} catch (err) {
reply({ ok: false, error: err.message });
if (process.stderr) {
process.stderr.write(`[holesail-browser-host] ${err.stack}\n`);
}
}
}
function cleanup() {
if (_scheduledBackupTimer) { clearTimeout(_scheduledBackupTimer); _scheduledBackupTimer = null; }
dashboardServer.stop();
httpsProxy.stop(() => {});
connectProxy.stop(() => {});
sshManager.cleanup();
rdpManager.cleanup().catch(() => {});
holesailManager.cleanup().catch(() => {});
}
module.exports = { handleMessage: handleMessageAsync, cleanup };