- 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:
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 });
|
||||
|
||||
@@ -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
|
||||
};
|
||||
@@ -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 };
|
||||
Reference in New Issue
Block a user