CI / Build & Test (push) Successful in 2m54s
Add docs/CONTRIBUTING.md covering the build system, dev workflow, all npm scripts, how to add new native host message types, code style, and debugging guidance. Add CHANGELOG.md at the project root documenting all features and fixes across the 1.0.0 release. Add JSDoc (@param, @returns) to all previously undocumented exported functions across 35 JS files: - native-host/holesail-manager/ (index, virtual-hosts, service-tunnels, servers, port-allocator) - native-host top-level managers (startup, connect-proxy, https-proxy, certificate-authority, ssh-manager, rdp-manager) - extension/background/ (logs, native-messaging, proxy, message-router) - extension/dashboard/core/ (utils, navigation, init) - extension/dashboard/ui/ (modal, toast, state-tag) - extension/dashboard/pages/ (all 10 page files) - extension/dashboard/refresh.js, events.js - extension/dashboard/data/hostname-validator.js - scripts/ (build-host, run-install)
190 lines
8.6 KiB
JavaScript
190 lines
8.6 KiB
JavaScript
/**
|
||
* Service tunnel management.
|
||
* Connects to a remote Holesail peer and binds it directly to a user-chosen local port.
|
||
* Unlike virtual hosts, these are not HTTP-proxied — any TCP client can connect directly.
|
||
*/
|
||
|
||
let Holesail = null;
|
||
try { Holesail = require('holesail'); } catch (e) {
|
||
if (process.stderr) process.stderr.write('[holesail-manager] Holesail not installed: ' + e.message + '\n');
|
||
}
|
||
|
||
const TUNNEL_HOST = '127.0.0.1';
|
||
|
||
const DEBUG = process.env.HOLESAIL_DEBUG === '1' || process.env.HOLESAIL_DEBUG === 'true';
|
||
function debugLog(...args) {
|
||
if (!DEBUG) return;
|
||
const msg = '[holesail-manager:debug] ' + args.map(a => typeof a === 'object' ? JSON.stringify(a) : String(a)).join(' ');
|
||
if (process.stderr) process.stderr.write(msg + '\n');
|
||
}
|
||
|
||
const serviceTunnels = new Map(); // tunnelId -> { label, hsUrl, localPort, holesail, state, createdAt, reconnectTimer, reconnectDelay }
|
||
let nextServiceTunnelId = 0;
|
||
let _saveState = null;
|
||
let _emit = null;
|
||
let _getReadyTimeoutMs = null;
|
||
let _getAutoReconnect = null;
|
||
|
||
const RECONNECT_BASE_MS = 5000;
|
||
const RECONNECT_MAX_MS = 120000;
|
||
|
||
/**
|
||
* Inject shared dependencies from the parent holesail-manager.
|
||
* Must be called once before any other function in this module.
|
||
* @param {Function} saveStateFn - Callback that persists the current state to disk.
|
||
* @param {Function} emitFn - Callback to emit tunnel lifecycle events to the extension.
|
||
* @param {Function} getReadyTimeoutMsFn - Returns the configured ready-timeout in ms (0 = no timeout).
|
||
* @param {Function} getAutoReconnectFn - Returns whether auto-reconnect is enabled.
|
||
*/
|
||
function init(saveStateFn, emitFn, getReadyTimeoutMsFn, getAutoReconnectFn) {
|
||
_saveState = saveStateFn;
|
||
_emit = emitFn;
|
||
_getReadyTimeoutMs = getReadyTimeoutMsFn;
|
||
_getAutoReconnect = getAutoReconnectFn;
|
||
}
|
||
|
||
function _scheduleSvcReconnect(tunnelId) {
|
||
const t = serviceTunnels.get(tunnelId);
|
||
if (!t) return;
|
||
if (t.reconnectTimer) { clearTimeout(t.reconnectTimer); t.reconnectTimer = null; }
|
||
const autoReconnect = _getAutoReconnect ? _getAutoReconnect() : false;
|
||
if (!autoReconnect) return;
|
||
const delay = t.reconnectDelay || RECONNECT_BASE_MS;
|
||
t.reconnectDelay = Math.min(delay * 2, RECONNECT_MAX_MS);
|
||
if (process.stderr) process.stderr.write('[holesail-manager] svc ' + tunnelId + ' reconnecting in ' + Math.round(delay / 1000) + 's\n');
|
||
t.reconnectTimer = setTimeout(async () => {
|
||
t.reconnectTimer = null;
|
||
const cur = serviceTunnels.get(tunnelId);
|
||
if (!cur || cur.state === 'ready') return;
|
||
if (process.stderr) process.stderr.write('[holesail-manager] svc ' + tunnelId + ' auto-reconnect attempt\n');
|
||
await startServiceTunnel({ tunnelId, label: cur.label, hsUrl: cur.hsUrl, localPort: cur.localPort });
|
||
}, delay);
|
||
}
|
||
|
||
/**
|
||
* Advance the service tunnel ID counter to avoid collisions after a restart.
|
||
* @param {number} loadedNextServiceTunnelId - The `nextServiceTunnelId` value read from state.json.
|
||
*/
|
||
function applyLoaded(loadedNextServiceTunnelId) {
|
||
if (loadedNextServiceTunnelId > nextServiceTunnelId) nextServiceTunnelId = loadedNextServiceTunnelId;
|
||
}
|
||
|
||
function generateServiceTunnelId() {
|
||
nextServiceTunnelId += 1;
|
||
return 'svc-' + nextServiceTunnelId;
|
||
}
|
||
|
||
function readyWithTimeout(hs, label) {
|
||
const ms = _getReadyTimeoutMs ? _getReadyTimeoutMs() : 0;
|
||
if (!ms || ms <= 0) return hs.ready();
|
||
return new Promise((resolve, reject) => {
|
||
const t = setTimeout(() => reject(new Error('Tunnel ready timeout after ' + ms + 'ms for ' + label)), ms);
|
||
hs.ready().then(() => { clearTimeout(t); resolve(); }, (e) => { clearTimeout(t); reject(e); });
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Start a service tunnel, connecting a remote Holesail peer to a local port.
|
||
* Any TCP client can connect to `127.0.0.1:<localPort>` directly (no HTTP proxy).
|
||
* @param {object} payload
|
||
* @param {string} payload.label - Human-readable label (required).
|
||
* @param {string} payload.hsUrl - The hs:// key of the remote peer (required).
|
||
* @param {number} payload.localPort - Local port to bind (1–65535, required).
|
||
* @param {string} [payload.tunnelId] - Optional explicit ID; auto-generated if omitted.
|
||
* @returns {Promise<{ok: boolean, tunnelId?: string, label?: string, hsUrl?: string, localPort?: number, state?: string, error?: string}>}
|
||
*/
|
||
async function startServiceTunnel(payload) {
|
||
if (!Holesail) return { ok: false, error: 'Holesail module not installed' };
|
||
const { label, hsUrl, localPort } = payload;
|
||
if (!label) return { ok: false, error: 'label required' };
|
||
if (!hsUrl || !hsUrl.startsWith('hs://')) return { ok: false, error: 'valid hs:// key required' };
|
||
if (!localPort || localPort < 1 || localPort > 65535) return { ok: false, error: 'localPort must be 1–65535' };
|
||
|
||
const tunnelId = payload.tunnelId || generateServiceTunnelId();
|
||
debugLog('startServiceTunnel: id=', tunnelId, 'label=', label, 'localPort=', localPort);
|
||
|
||
const existing = serviceTunnels.get(tunnelId);
|
||
if (existing) {
|
||
if (existing.reconnectTimer) { clearTimeout(existing.reconnectTimer); existing.reconnectTimer = null; }
|
||
if (existing.holesail) { try { await existing.holesail.close(); } catch (_) {} }
|
||
}
|
||
|
||
try {
|
||
const hs = new Holesail({ client: true, key: hsUrl, host: TUNNEL_HOST, port: localPort });
|
||
if (typeof hs.on === 'function') {
|
||
hs.on('error', (err) => {
|
||
if (process.stderr) process.stderr.write('[holesail-manager] service tunnel error ' + tunnelId + ': ' + (err && err.message) + '\n');
|
||
const t = serviceTunnels.get(tunnelId);
|
||
if (t && t.holesail === hs) { t.state = 'error'; _scheduleSvcReconnect(tunnelId); }
|
||
if (_emit) _emit('serviceTunnelError', { tunnelId, label, error: err && err.message });
|
||
});
|
||
hs.on('close', () => {
|
||
const t = serviceTunnels.get(tunnelId);
|
||
if (t && t.holesail === hs) { t.state = 'closed'; _scheduleSvcReconnect(tunnelId); }
|
||
if (_emit) _emit('serviceTunnelClosed', { tunnelId, label });
|
||
});
|
||
}
|
||
await readyWithTimeout(hs, 'svc:' + tunnelId);
|
||
serviceTunnels.set(tunnelId, { label, hsUrl, localPort, holesail: hs, state: 'ready', createdAt: Date.now(), reconnectTimer: null, reconnectDelay: RECONNECT_BASE_MS });
|
||
if (_emit) _emit('serviceTunnelReady', { tunnelId, label, localPort });
|
||
if (_saveState) _saveState();
|
||
debugLog('startServiceTunnel: ok id=', tunnelId, 'localPort=', localPort);
|
||
return { ok: true, tunnelId, label, hsUrl, localPort, state: 'ready' };
|
||
} catch (e) {
|
||
debugLog('startServiceTunnel: error', e.message);
|
||
try { hs.removeAllListeners(); } catch (_) {}
|
||
serviceTunnels.set(tunnelId, { label, hsUrl, localPort, state: 'error', error: e.message, createdAt: Date.now() });
|
||
if (_saveState) _saveState();
|
||
if (_emit) _emit('serviceTunnelError', { tunnelId, label, error: e.message });
|
||
return { ok: false, error: e.message };
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Stop a service tunnel, close the Holesail connection, and remove it from state.
|
||
* @param {object} payload
|
||
* @param {string} payload.tunnelId - ID of the service tunnel to stop.
|
||
* @returns {Promise<{ok: boolean, error?: string}>}
|
||
*/
|
||
async function stopServiceTunnel(payload) {
|
||
const { tunnelId } = payload;
|
||
debugLog('stopServiceTunnel: id=', tunnelId);
|
||
const entry = serviceTunnels.get(tunnelId);
|
||
if (!entry) return { ok: false, error: 'Service tunnel not found' };
|
||
if (entry.reconnectTimer) { clearTimeout(entry.reconnectTimer); entry.reconnectTimer = null; }
|
||
if (entry.holesail) { try { await entry.holesail.close(); } catch (_) {} }
|
||
serviceTunnels.delete(tunnelId);
|
||
if (_saveState) _saveState();
|
||
if (_emit) _emit('serviceTunnelClosed', { tunnelId, label: entry.label });
|
||
return { ok: true };
|
||
}
|
||
|
||
/**
|
||
* Return a snapshot of all service tunnels (including errored/closed ones).
|
||
* @returns {Array<{id: string, label: string, hsUrl: string, localPort: number, state: string, createdAt: number}>}
|
||
*/
|
||
function getServiceTunnels() {
|
||
const list = [];
|
||
for (const [id, t] of serviceTunnels) {
|
||
list.push({ id, label: t.label, hsUrl: t.hsUrl, localPort: t.localPort, state: t.state || 'unknown', createdAt: t.createdAt });
|
||
}
|
||
return list;
|
||
}
|
||
|
||
function getNextServiceTunnelId() { return nextServiceTunnelId; }
|
||
|
||
/**
|
||
* Cancel all reconnect timers and close all service tunnel connections.
|
||
* Called during native host shutdown.
|
||
* @returns {Promise<void>}
|
||
*/
|
||
async function cleanupServiceTunnels() {
|
||
for (const [, t] of serviceTunnels) {
|
||
if (t.reconnectTimer) { clearTimeout(t.reconnectTimer); t.reconnectTimer = null; }
|
||
if (t.holesail) { try { await t.holesail.close(); } catch (_) {} }
|
||
}
|
||
serviceTunnels.clear();
|
||
}
|
||
|
||
module.exports = { init, applyLoaded, startServiceTunnel, stopServiceTunnel, getServiceTunnels, getNextServiceTunnelId, cleanupServiceTunnels };
|