eat: implement Holesail-Browser enhancement plan
CI / Build & Test (push) Successful in 3m19s

- Add unit tests (hostname-validator, TLDs, payload-schemas) and integration tests for message handler registry
- Refactor native host message router into handler registry (handlers/state, tunnels, ssh, rdp, backup, ca, connections)
- Add ESLint config and npm test + lint steps in CI
- Dashboard: visibility-based refresh pause, configurable refresh interval (2s/5s/10s/paused)
- Accessibility: ARIA on nav and modals, focus trap and restore, prefers-reduced-motion
- Empty states: primary action buttons for virtual hosts, servers, service tunnels
- Native host rate limiting for backup and CA operations; update SECURITY.md
- CONTRIBUTING: "Adding a new dashboard page", dev workflow; add npm run dev script
This commit is contained in:
Raven Scott
2026-03-15 00:24:31 -04:00
parent 8df1ef3ec6
commit 6fcd9fcf2b
38 changed files with 2028 additions and 279 deletions
+3
View File
@@ -44,6 +44,9 @@ function updateSettings(patch) {
if (typeof patch.tunnelAutoReconnect === 'boolean') currentSettings.tunnelAutoReconnect = patch.tunnelAutoReconnect;
if (typeof patch.latencyPingEnabled === 'boolean') currentSettings.latencyPingEnabled = patch.latencyPingEnabled;
if (typeof patch.latencyPingIntervalMs === 'number' && patch.latencyPingIntervalMs > 0) currentSettings.latencyPingIntervalMs = patch.latencyPingIntervalMs;
if (typeof patch.dashboardRefreshIntervalMs === 'number' && patch.dashboardRefreshIntervalMs >= 0 && [0, 2000, 5000, 10000].includes(patch.dashboardRefreshIntervalMs)) {
currentSettings.dashboardRefreshIntervalMs = patch.dashboardRefreshIntervalMs;
}
if (_saveState) _saveState();
return { requiresRestart };
}
+2 -1
View File
@@ -22,7 +22,8 @@ const SETTINGS_DEFAULTS = {
backupIntervalHours: 0,
tunnelAutoReconnect: true,
latencyPingEnabled: false,
latencyPingIntervalMs: 5000
latencyPingIntervalMs: 5000,
dashboardRefreshIntervalMs: 2000
};
const DEBUG = process.env.HOLESAIL_DEBUG === '1' || process.env.HOLESAIL_DEBUG === 'true';
+55
View File
@@ -0,0 +1,55 @@
/**
* Handlers for backup create, list, restore, delete.
* Context: backupManager, holesailManager, setTunnelsRestoredPromise, restorePersistedTunnels, log
*/
const rateLimit = require('../rate-limit.js');
function register(deps) {
const { backupManager, holesailManager, setTunnelsRestoredPromise, restorePersistedTunnels, log } = deps;
return [
{
type: 'createBackup',
handle: async (payload, reply) => {
const rl = rateLimit.check('createBackup');
if (!rl.allowed) { reply({ ok: false, error: rl.error }); return; }
const backupResult = await backupManager.createBackup();
if (backupResult.ok) {
const retention = (holesailManager.getSettings().backupRetention) || backupManager.DEFAULT_RETENTION;
backupManager.pruneOldBackups(retention);
}
reply(backupResult);
}
},
{
type: 'listBackups',
handle: async (payload, reply) => reply(backupManager.listBackups())
},
{
type: 'restoreBackup',
handle: async (payload, reply) => {
const rl = rateLimit.check('restoreBackup');
if (!rl.allowed) { reply({ ok: false, error: rl.error }); return; }
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);
}
},
{
type: 'deleteBackup',
handle: async (payload, reply) => {
const rl = rateLimit.check('deleteBackup');
if (!rl.allowed) { reply({ ok: false, error: rl.error }); return; }
reply(backupManager.deleteBackup(payload.filename));
}
}
];
}
module.exports = { register };
+25
View File
@@ -0,0 +1,25 @@
/**
* Handlers for certificate authority (installRootCA).
* Context: certificateAuthority
*/
const rateLimit = require('../rate-limit.js');
function register(deps) {
const { certificateAuthority } = deps;
return [
{
type: 'installRootCA',
handle: async (payload, reply) => {
const rl = rateLimit.check('installRootCA');
if (!rl.allowed) { reply({ ok: false, error: rl.error }); return; }
certificateAuthority.installRootCA((err) => {
if (err) reply({ ok: false, error: err.message });
else reply({ ok: true });
});
}
}
];
}
module.exports = { register };
+32
View File
@@ -0,0 +1,32 @@
/**
* Handlers for SSH and RDP connection list get/set.
* Context: holesailManager, debugLog
*/
function register(deps) {
const { holesailManager, debugLog } = deps;
return [
{ type: 'getSshConnections', handle: async (payload, reply) => reply({ ok: true, sshConnections: holesailManager.getSshConnections() }) },
{
type: 'setSshConnections',
handle: async (payload, reply) => {
const list = Array.isArray(payload.connections) ? payload.connections : [];
holesailManager.setSshConnections(list);
debugLog('setSshConnections: count=', list.length);
reply({ ok: true });
}
},
{ type: 'getRdpConnections', handle: async (payload, reply) => reply({ ok: true, rdpConnections: holesailManager.getRdpConnections() }) },
{
type: 'setRdpConnections',
handle: async (payload, reply) => {
const list = Array.isArray(payload.connections) ? payload.connections : [];
holesailManager.setRdpConnections(list);
debugLog('setRdpConnections: count=', list.length);
reply({ ok: true });
}
}
];
}
module.exports = { register };
+38
View File
@@ -0,0 +1,38 @@
/**
* Aggregates all message handlers into a single type -> handle map.
* Each handler module exports register(deps) and returns an array of { type, handle }.
* deps must include all manager refs plus debugLog and log (for handlers that log).
*/
const stateHandlers = require('./state.js');
const connectionsHandlers = require('./connections.js');
const tunnelsHandlers = require('./tunnels.js');
const caHandlers = require('./ca.js');
const sshHandlers = require('./ssh.js');
const rdpHandlers = require('./rdp.js');
const backupHandlers = require('./backup.js');
/**
* Build the handler registry. Pass the same deps that message-router has.
* @returns {Map<string, Function>} type -> async (payload, reply) => void
*/
function buildHandlers(deps) {
const entries = [
...stateHandlers.register(deps),
...connectionsHandlers.register(deps),
...tunnelsHandlers.register(deps),
...caHandlers.register(deps),
...sshHandlers.register(deps),
...rdpHandlers.register(deps),
...backupHandlers.register(deps)
];
const map = new Map();
for (const { type, handle } of entries) {
if (map.has(type)) throw new Error(`Duplicate handler for type: ${type}`);
map.set(type, handle);
}
return map;
}
module.exports = { buildHandlers };
+33
View File
@@ -0,0 +1,33 @@
/**
* Handlers for RDP/VNC sessions.
* Context: rdpManager, debugLog
*/
function register(deps) {
const { rdpManager, debugLog } = deps;
return [
{
type: 'startRdpSession',
handle: async (payload, reply) => {
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);
}
},
{
type: 'stopRdpSession',
handle: async (payload, reply) => {
debugLog('stopRdpSession: sessionId=', payload.sessionId);
const result = await rdpManager.stopSession(payload);
reply(result);
}
},
{
type: 'getRdpSessions',
handle: async (payload, reply) => reply({ ok: true, sessions: rdpManager.getSessions() })
}
];
}
module.exports = { register };
+41
View File
@@ -0,0 +1,41 @@
/**
* Handlers for SSH sessions.
* Context: sshManager, debugLog
*/
function register(deps) {
const { sshManager, debugLog } = deps;
return [
{
type: 'startSshSession',
handle: async (payload, reply) => {
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);
}
},
{
type: 'stopSshSession',
handle: async (payload, reply) => {
debugLog('stopSshSession: payload=', JSON.stringify(payload));
const result = await sshManager.stopSession(payload);
debugLog('stopSshSession: result=', JSON.stringify(result));
reply(result);
}
},
{
type: 'resizeSshSession',
handle: async (payload, reply) => {
sshManager.resizeSession(payload);
reply({ ok: true });
}
},
{
type: 'getSshSessions',
handle: async (payload, reply) => reply({ ok: true, sessions: sshManager.getSessions() })
}
];
}
module.exports = { register };
+67
View File
@@ -0,0 +1,67 @@
/**
* Handlers for getState, getSettings, updateSettings.
* Context: holesailManager, certificateAuthority, httpsProxy, connectProxy, getProxiesReadyPromise, getTunnelsRestoredPromise, scheduleNextAutoBackup, debugLog
*/
function register(deps) {
const { holesailManager, certificateAuthority, httpsProxy, connectProxy, getProxiesReadyPromise, getTunnelsRestoredPromise, scheduleNextAutoBackup, debugLog } = deps;
return [
{
type: 'getState',
handle: async (payload, reply) => {
const proxiesReadyPromise = getProxiesReadyPromise();
const tunnelsRestoredPromise = getTunnelsRestoredPromise();
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()
});
}
},
{
type: 'getSettings',
handle: async (payload, reply) => {
reply({ ok: true, settings: holesailManager.getSettings() });
}
},
{
type: 'updateSettings',
handle: async (payload, reply) => {
const { requiresRestart } = holesailManager.updateSettings(payload);
debugLog('updateSettings: applied', JSON.stringify(payload), 'requiresRestart=', requiresRestart);
scheduleNextAutoBackup();
reply({ ok: true, settings: holesailManager.getSettings(), requiresRestart });
}
}
];
}
module.exports = { register };
+131
View File
@@ -0,0 +1,131 @@
/**
* Handlers for servers, service tunnels, virtual hosts, proxy port, lookup, pingTunnel.
* Context: holesailManager, debugLog
*/
function register(deps) {
const { holesailManager, debugLog } = deps;
return [
{
type: 'startServer',
handle: async (payload, reply) => {
debugLog('startServer: payload=', JSON.stringify(payload));
const result = await holesailManager.startServer(payload);
debugLog('startServer: result=', JSON.stringify(result));
reply(result);
}
},
{
type: 'stopServer',
handle: async (payload, reply) => {
debugLog('stopServer: payload=', JSON.stringify(payload));
const result = await holesailManager.stopServer(payload);
debugLog('stopServer: result=', JSON.stringify(result));
reply(result);
}
},
{
type: 'startServiceTunnel',
handle: async (payload, reply) => {
debugLog('startServiceTunnel: payload=', JSON.stringify(payload));
const result = await holesailManager.startServiceTunnel(payload);
debugLog('startServiceTunnel: result=', JSON.stringify(result));
reply(result);
}
},
{
type: 'updateServiceTunnel',
handle: async (payload, reply) => {
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);
}
},
{
type: 'stopServiceTunnel',
handle: async (payload, reply) => {
debugLog('stopServiceTunnel: payload=', JSON.stringify(payload));
const result = await holesailManager.stopServiceTunnel(payload);
debugLog('stopServiceTunnel: result=', JSON.stringify(result));
reply(result);
}
},
{
type: 'getServiceTunnels',
handle: async (payload, reply) => reply({ ok: true, tunnels: holesailManager.getServiceTunnels() })
},
{
type: 'getVirtualHosts',
handle: async (payload, reply) => {
const hosts = holesailManager.getVirtualHosts();
debugLog('getVirtualHosts: count=', hosts.length);
reply({ ok: true, hosts });
}
},
{
type: 'setVirtualHost',
handle: async (payload, reply) => {
debugLog('setVirtualHost: payload=', JSON.stringify(payload));
const result = await holesailManager.setVirtualHost(payload);
debugLog('setVirtualHost: result=', JSON.stringify(result));
reply(result);
}
},
{
type: 'removeVirtualHost',
handle: async (payload, reply) => {
debugLog('removeVirtualHost: payload=', JSON.stringify(payload));
const result = await holesailManager.removeVirtualHost(payload);
debugLog('removeVirtualHost: result=', JSON.stringify(result));
reply(result);
}
},
{
type: 'getProxyPort',
handle: async (payload, reply) => {
const port = holesailManager.getProxyPort();
debugLog('getProxyPort: port=', port);
reply({ ok: true, port });
}
},
{
type: 'lookup',
handle: async (payload, reply) => {
debugLog('lookup: payload=', JSON.stringify(payload));
const result = await holesailManager.lookup(payload);
debugLog('lookup: result=', JSON.stringify(result));
reply(result);
}
},
{
type: 'pingTunnel',
handle: async (payload, reply) => {
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' }); return; }
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 });
});
}
}
];
}
module.exports = { register };
+25 -258
View File
@@ -1,14 +1,14 @@
/**
* 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.
* appropriate handler via a registry. Wires 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 { buildHandlers } = require('./handlers/index.js');
const holesailManager = require('../holesail-manager/index.js');
holesailManager.setStoragePath(STORAGE_PATH);
@@ -50,7 +50,6 @@ function scheduleNextAutoBackup() {
}, ms);
}
// Start the auto-backup schedule once proxies are ready.
const _proxiesReady = getProxiesReadyPromise();
if (_proxiesReady) {
_proxiesReady.then(() => scheduleNextAutoBackup()).catch(() => {});
@@ -58,8 +57,6 @@ if (_proxiesReady) {
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;
@@ -67,6 +64,23 @@ holesailManager.setEventEmitter((event, eventPayload) => {
_send({ type: 'event', event, payload: eventPayload });
});
const _handlers = buildHandlers({
holesailManager,
certificateAuthority,
httpsProxy,
connectProxy,
sshManager,
backupManager,
rdpManager,
getProxiesReadyPromise,
getTunnelsRestoredPromise,
setTunnelsRestoredPromise,
restorePersistedTunnels,
scheduleNextAutoBackup,
log,
debugLog
});
/**
* @param {Function} send - messenger.send(msg)
* @param {object} msg - { id, type, payload }
@@ -82,258 +96,11 @@ async function handleMessageAsync(send, msg) {
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}` });
const handle = _handlers.get(type);
if (handle) {
await handle(payload, reply);
} else {
reply({ ok: false, error: `Unknown command: ${type}` });
}
} catch (err) {
reply({ ok: false, error: err.message });
+76
View File
@@ -0,0 +1,76 @@
/**
* Lightweight payload validation for native host message types.
* Used for testing and optional runtime checks. Returns { ok: true } or { ok: false, error: string }.
*/
function nonEmptyString(v, name) {
if (typeof v !== 'string' || v.trim() === '') return { ok: false, error: `${name} must be a non-empty string` };
return { ok: true };
}
function optionalString(v) {
if (v === undefined || v === null) return { ok: true };
if (typeof v !== 'string') return { ok: false, error: 'must be a string' };
return { ok: true };
}
function positiveInteger(v, name) {
if (typeof v !== 'number' || !Number.isInteger(v) || v < 1) return { ok: false, error: `${name} must be a positive integer` };
return { ok: true };
}
/**
* @param {object} payload
* @returns {{ ok: true } | { ok: false, error: string }}
*/
function validateSetVirtualHost(payload) {
if (!payload || typeof payload !== 'object') return { ok: false, error: 'payload must be an object' };
const hostname = nonEmptyString(payload.hostname, 'hostname');
if (!hostname.ok) return hostname;
const hsUrl = nonEmptyString(payload.hsUrl, 'hsUrl');
if (!hsUrl.ok) return hsUrl;
if (payload.hsUrl && !payload.hsUrl.startsWith('hs://')) return { ok: false, error: 'hsUrl must start with hs://' };
return { ok: true };
}
/**
* @param {object} payload
* @returns {{ ok: true } | { ok: false, error: string }}
*/
function validateRemoveVirtualHost(payload) {
if (!payload || typeof payload !== 'object') return { ok: false, error: 'payload must be an object' };
return nonEmptyString(payload.hostname, 'hostname');
}
/**
* @param {object} payload
* @returns {{ ok: true } | { ok: false, error: string }}
*/
function validateStartServer(payload) {
if (!payload || typeof payload !== 'object') return { ok: false, error: 'payload must be an object' };
const port = positiveInteger(payload.port, 'port');
if (!port.ok) return port;
if (payload.host !== undefined) {
const h = nonEmptyString(payload.host, 'host');
if (!h.ok) return h;
}
const label = optionalString(payload.label);
if (!label.ok) return { ok: false, error: 'label must be a string' };
return { ok: true };
}
/**
* @param {object} payload
* @returns {{ ok: true } | { ok: false, error: string }}
*/
function validateStopServer(payload) {
if (!payload || typeof payload !== 'object') return { ok: false, error: 'payload must be an object' };
return nonEmptyString(payload.serverId, 'serverId');
}
module.exports = {
validateSetVirtualHost,
validateRemoveVirtualHost,
validateStartServer,
validateStopServer
};
+41
View File
@@ -0,0 +1,41 @@
/**
* Simple rate limiting for expensive or sensitive native host operations.
* Used to avoid abuse from a compromised extension (e.g. backup spam, CA install loops).
*/
const WINDOW_MS = 60 * 1000; // 1 minute
const MAX_PER_WINDOW = Object.freeze({
createBackup: 3,
restoreBackup: 2,
deleteBackup: 10,
installRootCA: 5
});
const _counts = new Map();
const _windowStart = new Map();
/**
* Check if an operation is allowed under rate limit. If allowed, increments the counter.
* @param {string} key - Operation key (e.g. 'createBackup', 'installRootCA')
* @returns {{ allowed: true } | { allowed: false, error: string }}
*/
function check(key) {
const max = MAX_PER_WINDOW[key];
if (typeof max !== 'number') return { allowed: true };
const now = Date.now();
let start = _windowStart.get(key);
if (start == null || now - start >= WINDOW_MS) {
start = now;
_windowStart.set(key, start);
_counts.set(key, 0);
}
let n = _counts.get(key) || 0;
if (n >= max) {
return { allowed: false, error: `Rate limit exceeded: max ${max} ${key} per minute. Try again later.` };
}
_counts.set(key, n + 1);
return { allowed: true };
}
module.exports = { check };