refactor(native-host): modularize host.js and holesail-manager.js
CI / Build & Test (push) Successful in 2m46s

Split host.js (435 lines) into host/{paths,logger,startup,message-router}.js.
Split holesail-manager.js (698 lines) into holesail-manager/{state,settings,
connections,port-allocator,servers,virtual-hosts,service-tunnels,index}.js.

Top-level host.js and holesail-manager.js become thin shims so index.mjs
requires no changes. Deleted dev scratch file test-cp.mjs.

Updated CI with 12 new node --check lines for all sub-modules.
Updated docs/ARCHITECTURE.md with per-file tables for host/ and
holesail-manager/ sub-modules.

No functionality changed. No new dependencies.
This commit is contained in:
Raven Scott
2026-02-28 23:15:08 -05:00
parent 82ab44c4b1
commit 15caac7032
17 changed files with 1274 additions and 1136 deletions
+12
View File
@@ -76,6 +76,18 @@ jobs:
node --check native-host/https-proxy.js
node --check native-host/connect-proxy.js
node --check native-host/messenger.js
node --check native-host/host/paths.js
node --check native-host/host/logger.js
node --check native-host/host/startup.js
node --check native-host/host/message-router.js
node --check native-host/holesail-manager/state.js
node --check native-host/holesail-manager/settings.js
node --check native-host/holesail-manager/connections.js
node --check native-host/holesail-manager/port-allocator.js
node --check native-host/holesail-manager/servers.js
node --check native-host/holesail-manager/virtual-hosts.js
node --check native-host/holesail-manager/service-tunnels.js
node --check native-host/holesail-manager/index.js
- name: Lint — syntax check build scripts
run: |
+26 -2
View File
@@ -55,12 +55,14 @@ Holesail Browser is composed of three parts: a browser extension, a native host
### Native host (`native-host/`)
#### Top-level files
| File | Purpose |
|------|---------|
| `index.mjs` | Entry point. Bootstraps Bare globals, creates the native messaging `messenger`, wires `handleMessage` from `host.js`. Handles `SIGTERM`/`SIGINT` for graceful shutdown. |
| `messenger.js` | Chrome/Firefox native messaging framing: 4-byte little-endian length prefix + UTF-8 JSON body. Max 1 MB per message. |
| `host.js` | Central command dispatcher. On startup: loads persisted state, starts both proxies, then restores all persisted tunnels asynchronously. Handles all message types from the extension, including `updateServiceTunnel` (stop + restart a service tunnel in one call). |
| `holesail-manager.js` | Manages all tunnel types (server, virtual host, service tunnel). Owns in-memory maps, port allocator (starting at 19000), and all persistence to `state.json`. |
| `host.js` | Thin shim — re-exports `handleMessage` and `cleanup` from `host/message-router.js`. Kept at the top level so `index.mjs` requires it unchanged. |
| `holesail-manager.js` | Thin shim — re-exports the full API from `holesail-manager/index.js`. Kept at the top level so `host/message-router.js` requires it unchanged. |
| `https-proxy.js` | SNI-aware HTTPS reverse proxy on `127.0.0.1:8443`. Uses a pure-JS TLS ClientHello parser to extract the SNI hostname from each incoming connection, derives the wildcard parent domain, and presents a per-TLD wildcard cert. Supports any hostname depth (e.g. `i.love.hole.sail`). Returns a 502 HTML page for unknown hostnames. |
| `connect-proxy.js` | HTTP CONNECT proxy on `127.0.0.1:8442`. Accepts `CONNECT hostname:443`, replies `200 Connection established`, then pipes the raw TCP stream to `127.0.0.1:8443`. |
| `certificate-authority.js` | Generates a 2048-bit RSA root CA (10-year validity) using `node-forge`. Signs per-TLD wildcard domain certs on demand (1-year). Installs the CA into the OS trust store. Fingerprint-verifies to detect stale entries. |
@@ -68,6 +70,28 @@ Holesail Browser is composed of three parts: a browser extension, a native host
| `rdp-manager.js` | Per RDP/VNC session: starts a Holesail client tunnel, starts a WebSocket server. VNC: transparent byte pipe. RDP: `node-rdpjs-2` client, converts bitmap updates to JSON. |
| `backup-manager.js` | Creates/restores `tar.gz` backups of `state.json` + all certificates. Supports create, list, restore, delete, and auto-prune by retention count. |
#### `host/` — host orchestration modules
| File | Purpose |
|------|---------|
| `paths.js` | `resolveBase()` — detects standalone binary vs. dev mode and returns the correct base directory. Exports `BASE_DIR` and `STORAGE_PATH` constants used by all other host modules. |
| `logger.js` | `log()` and `debugLog()` — timestamped writes to `holesail-browser.log` (or `BRIDGE_SWARM_LOG` override) and `process.stderr`. Respects `HOLESAIL_DEBUG` env var. |
| `startup.js` | `initStartup()` — deferred proxy startup sequence: loads persisted settings, waits for CA readiness, starts the HTTPS and CONNECT proxies, then calls `restorePersistedTunnels()`. Exports promise accessors used by the message router. |
| `message-router.js` | `handleMessageAsync()` — the 30-case `switch` that dispatches every browser message to the appropriate manager. Wires all managers together on first require. Exports `handleMessage` and `cleanup`. |
#### `holesail-manager/` — tunnel and state sub-modules
| File | Purpose |
|------|---------|
| `state.js` | `loadState()`, `saveStateSync()`, `buildDefaultState()`. Handles `state.json` read/write and migration from the legacy `holesail-persist.json` format. |
| `settings.js` | `getSettings()`, `updateSettings()`, `getProxyPort()`, `setProxyPort()`. Owns `currentSettings` and `runtimeProxyPort`. |
| `connections.js` | `getSshConnections/set`, `getRdpConnections/set`. Owns the in-memory SSH and RDP connection lists (persisted in `state.json`, not live sessions). |
| `port-allocator.js` | `allocateTunnelPort()`, `releaseTunnelPort()`. Manages the virtual-host tunnel port pool starting at 19000 with a free list for recycling. |
| `servers.js` | `startServer()`, `stopServer()`, `getServers()`. Manages Holesail server tunnels (server mode — exposes a local port to the P2P network). |
| `virtual-hosts.js` | `setVirtualHost()`, `removeVirtualHost()`, `getVirtualHosts()`, `getLocalBackend()`, `getLocalPortForHostname()`, `getVirtualHostMap()`. Manages virtual host client tunnels routed through the HTTPS proxy. |
| `service-tunnels.js` | `startServiceTunnel()`, `stopServiceTunnel()`, `getServiceTunnels()`. Manages direct TCP client tunnels (not HTTP-proxied). |
| `index.js` | Assembles all sub-modules, injects shared `saveState` and `emit` callbacks, exposes `restorePersistedState()` and `cleanup()`, and re-exports the complete original `holesail-manager.js` API. |
### Extension (`extension/`)
#### Top-level files
+3 -696
View File
@@ -1,698 +1,5 @@
/**
* Manages Holesail server tunnels and client (virtual host) tunnels.
* One Holesail instance per server or per virtual host.
* Persists all state (tunnels, settings, SSH connections) to state.json.
* Entry-point shim — delegates to holesail-manager/index.js.
* Kept at the top level so host.js require('./holesail-manager.js') continues to work.
*/
const path = require('bare-path');
const fs = require('bare-fs');
let Holesail = null;
try {
Holesail = require('holesail');
} catch (e) {
if (process.stderr) process.stderr.write('[holesail-manager] Holesail not installed: ' + e.message + '\n');
}
const DEFAULT_PROXY_PORT = 8443;
const DEFAULT_CONNECT_PROXY_PORT = 8442;
const VHOST_DOMAIN = 'hole.sail';
/** All tunnels bind to 127.0.0.1; each gets a unique port so no IP alias is needed. */
const TUNNEL_HOST = '127.0.0.1';
const TUNNEL_PORT_BASE = 19000; // start allocating tunnel ports from here
const STATE_FILENAME = 'state.json';
const LEGACY_PERSIST_FILENAME = 'holesail-persist.json';
const SETTINGS_DEFAULTS = {
proxyPort: DEFAULT_PROXY_PORT,
connectProxyPort: DEFAULT_CONNECT_PROXY_PORT,
readyTimeoutMs: 0,
notifyOnDisconnect: true,
debug: false,
disableOnFileUrls: false,
backupRetention: 5
};
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 servers = new Map(); // serverId -> { holesail, port, host, secure, udp, url, createdAt }
const virtualHosts = new Map(); // hostname -> { hsUrl, holesail, localHost, localPort, state, createdAt }
const serviceTunnels = new Map(); // tunnelId -> { label, hsUrl, localPort, holesail, state, createdAt }
let nextServerId = 0;
let nextServiceTunnelId = 0;
let eventEmit = null;
let stateFilePath = null;
// In-memory settings — loaded from state.json on startup
let currentSettings = { ...SETTINGS_DEFAULTS };
// In-memory SSH connections — loaded from state.json on startup
let sshConnectionsList = [];
// In-memory RDP/VNC connections — loaded from state.json on startup
let rdpConnectionsList = [];
function setStoragePath(baseDir) {
if (baseDir && typeof baseDir === 'string') {
stateFilePath = path.join(baseDir, STATE_FILENAME);
}
}
function getStatePath() {
if (stateFilePath) return stateFilePath;
const base = process.env.HOLESAIL_BROWSER_STORAGE || path.join(__dirname, 'holesail-browser-storage');
stateFilePath = path.join(base, STATE_FILENAME);
return stateFilePath;
}
function getLegacyPersistPath() {
const dir = path.dirname(getStatePath());
return path.join(dir, LEGACY_PERSIST_FILENAME);
}
function parseServerIdNum(serverId) {
if (!serverId || typeof serverId !== 'string') return 0;
const m = serverId.match(/^server_(\d+)$/);
return m ? Math.max(0, parseInt(m[1], 10)) : 0;
}
function saveStateSync() {
const file = getStatePath();
const serversList = [];
for (const [id, s] of servers) {
serversList.push({ id, port: s.port, host: s.host, secure: s.secure, udp: s.udp || false, label: s.label || '' });
}
const virtualHostsList = [];
for (const [hostname, v] of virtualHosts) {
virtualHostsList.push({ hostname, hsUrl: v.hsUrl });
}
const serviceTunnelsList = [];
for (const [id, t] of serviceTunnels) {
serviceTunnelsList.push({ id, label: t.label, hsUrl: t.hsUrl, localPort: t.localPort });
}
const data = JSON.stringify({
version: 2,
settings: { ...currentSettings },
nextServerId,
nextServiceTunnelId,
servers: serversList,
virtualHosts: virtualHostsList,
serviceTunnels: serviceTunnelsList,
sshConnections: sshConnectionsList,
rdpConnections: rdpConnectionsList
}, null, 2);
try {
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, data, 'utf8');
debugLog('state saved path=', file, 'servers=', serversList.length, 'vhosts=', virtualHostsList.length);
} catch (e) {
if (process.stderr) process.stderr.write('[holesail-manager] state save failed: ' + e.message + ' (path: ' + file + ')\n');
}
}
function loadState() {
const file = getStatePath();
// Try loading state.json first
try {
const raw = fs.readFileSync(file, 'utf8');
const data = JSON.parse(raw);
if (!data || typeof data !== 'object') return buildDefaultState();
const out = {
settings: (data.settings && typeof data.settings === 'object') ? { ...SETTINGS_DEFAULTS, ...data.settings } : { ...SETTINGS_DEFAULTS },
servers: Array.isArray(data.servers) ? data.servers : [],
virtualHosts: Array.isArray(data.virtualHosts) ? data.virtualHosts : [],
serviceTunnels: Array.isArray(data.serviceTunnels) ? data.serviceTunnels : [],
sshConnections: Array.isArray(data.sshConnections) ? data.sshConnections : [],
rdpConnections: Array.isArray(data.rdpConnections) ? data.rdpConnections : [],
nextServerId: typeof data.nextServerId === 'number' ? data.nextServerId : 0,
nextServiceTunnelId: typeof data.nextServiceTunnelId === 'number' ? data.nextServiceTunnelId : 0
};
debugLog('state loaded path=', file, 'servers=', out.servers.length, 'vhosts=', out.virtualHosts.length);
if (process.stderr && (out.servers.length || out.virtualHosts.length)) {
process.stderr.write('[holesail-manager] state loaded from ' + file + ' (servers=' + out.servers.length + ' vhosts=' + out.virtualHosts.length + ')\n');
}
return out;
} catch (e) {
if (e && e.code !== 'ENOENT') {
if (process.stderr) process.stderr.write('[holesail-manager] state load failed: ' + e.message + '\n');
return buildDefaultState();
}
}
// state.json not found — try migrating from legacy holesail-persist.json
const legacyFile = getLegacyPersistPath();
try {
const raw = fs.readFileSync(legacyFile, 'utf8');
const data = JSON.parse(raw);
if (process.stderr) process.stderr.write('[holesail-manager] migrating from ' + legacyFile + ' to ' + file + '\n');
const out = {
settings: { ...SETTINGS_DEFAULTS },
servers: Array.isArray(data.servers) ? data.servers : [],
virtualHosts: Array.isArray(data.virtualHosts) ? data.virtualHosts : [],
serviceTunnels: Array.isArray(data.serviceTunnels) ? data.serviceTunnels : [],
sshConnections: Array.isArray(data.sshConnections) ? data.sshConnections : [],
rdpConnections: Array.isArray(data.rdpConnections) ? data.rdpConnections : [],
nextServerId: typeof data.nextServerId === 'number' ? data.nextServerId : 0,
nextServiceTunnelId: typeof data.nextServiceTunnelId === 'number' ? data.nextServiceTunnelId : 0
};
// Write the new state.json immediately
currentSettings = out.settings;
sshConnectionsList = out.sshConnections;
rdpConnectionsList = out.rdpConnections;
nextServerId = out.nextServerId;
nextServiceTunnelId = out.nextServiceTunnelId;
saveStateSync();
// Remove legacy file
try { fs.unlinkSync(legacyFile); } catch (_) {}
if (process.stderr) process.stderr.write('[holesail-manager] migration complete, legacy file removed\n');
return out;
} catch (e) {
if (e && e.code !== 'ENOENT') {
if (process.stderr) process.stderr.write('[holesail-manager] legacy persist load failed: ' + e.message + '\n');
}
}
// Neither file found — fresh start
return buildDefaultState();
}
function buildDefaultState() {
return {
settings: { ...SETTINGS_DEFAULTS },
servers: [],
virtualHosts: [],
serviceTunnels: [],
sshConnections: [],
rdpConnections: [],
nextServerId: 0,
nextServiceTunnelId: 0
};
}
// Tunnel port allocator: each virtual host gets a unique port on 127.0.0.1
// No loopback aliases needed — no password prompts.
let nextTunnelPortIndex = 0;
const tunnelPortFreeList = [];
function allocateTunnelPort() {
if (tunnelPortFreeList.length > 0) return tunnelPortFreeList.pop();
return TUNNEL_PORT_BASE + (nextTunnelPortIndex++);
}
function releaseTunnelPort(port) {
if (typeof port === 'number' && port >= TUNNEL_PORT_BASE) {
tunnelPortFreeList.push(port);
}
}
function setEventEmitter(emit) {
eventEmit = emit;
}
function emit(event, payload) {
if (eventEmit) eventEmit(event, payload);
}
function generateServerId() {
nextServerId += 1;
return 'server_' + nextServerId;
}
function useServerId(givenId) {
if (givenId && typeof givenId === 'string' && givenId.startsWith('server_')) {
const n = parseServerIdNum(givenId);
if (n >= nextServerId) nextServerId = n + 1;
return givenId;
}
return generateServerId();
}
// ── Settings ──────────────────────────────────────────────────────────────────
function getSettings() {
return { ...currentSettings };
}
function updateSettings(patch) {
if (!patch || typeof patch !== 'object') return { requiresRestart: false };
let requiresRestart = false;
if (typeof patch.proxyPort === 'number' && patch.proxyPort > 0 && patch.proxyPort < 65536) {
if (patch.proxyPort !== currentSettings.proxyPort) requiresRestart = true;
currentSettings.proxyPort = patch.proxyPort;
runtimeProxyPort = patch.proxyPort;
}
if (typeof patch.connectProxyPort === 'number' && patch.connectProxyPort > 0 && patch.connectProxyPort < 65536) {
if (patch.connectProxyPort !== currentSettings.connectProxyPort) requiresRestart = true;
currentSettings.connectProxyPort = patch.connectProxyPort;
}
if (typeof patch.readyTimeoutMs === 'number') currentSettings.readyTimeoutMs = patch.readyTimeoutMs;
if (typeof patch.notifyOnDisconnect === 'boolean') currentSettings.notifyOnDisconnect = patch.notifyOnDisconnect;
if (typeof patch.debug === 'boolean') currentSettings.debug = patch.debug;
if (typeof patch.disableOnFileUrls === 'boolean') currentSettings.disableOnFileUrls = patch.disableOnFileUrls;
if (typeof patch.backupRetention === 'number') currentSettings.backupRetention = patch.backupRetention;
saveStateSync();
return { requiresRestart };
}
// ── SSH Connections ───────────────────────────────────────────────────────────
function getSshConnections() {
return sshConnectionsList.slice();
}
function setSshConnections(list) {
if (!Array.isArray(list)) return;
sshConnectionsList = list;
saveStateSync();
}
// ── RDP/VNC Connections ───────────────────────────────────────────────────────
function getRdpConnections() {
return rdpConnectionsList.slice();
}
function setRdpConnections(list) {
if (!Array.isArray(list)) return;
rdpConnectionsList = list;
saveStateSync();
}
// ── Tunnel ready helper ───────────────────────────────────────────────────────
/**
* Await hs.ready() with a timeout. If readyTimeoutMs is 0 or not set, waits
* indefinitely (original behaviour). If set, rejects after that many ms so
* the caller can mark the tunnel as error and move on.
*/
function readyWithTimeout(hs, label) {
const ms = currentSettings.readyTimeoutMs;
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); });
});
}
// ── Servers ───────────────────────────────────────────────────────────────────
async function startServer(payload) {
if (!Holesail) {
return { ok: false, error: 'Holesail module not installed' };
}
const port = payload.port || 3000;
const host = payload.host || '127.0.0.1';
const secure = payload.secure !== false;
const udp = payload.udp === true;
const label = (payload.label || '').trim();
const serverId = useServerId(payload.serverId);
debugLog('startServer: serverId=', serverId, 'port=', port, 'host=', host, 'secure=', secure, 'udp=', udp, 'label=', label);
if (servers.has(serverId)) {
debugLog('startServer: serverId already in use');
return { ok: false, error: 'Server id already in use' };
}
try {
const hs = new Holesail({ server: true, port, host, secure, udp });
await readyWithTimeout(hs, 'server:' + serverId);
const url = hs.info.url;
servers.set(serverId, {
holesail: hs,
port: hs.info.port,
host: hs.info.host,
secure,
udp,
label,
url,
createdAt: Date.now()
});
saveStateSync();
debugLog('startServer: ok serverId=', serverId, 'url=', url);
return {
ok: true,
serverId,
url,
port: hs.info.port,
host: hs.info.host,
secure,
udp,
label
};
} catch (e) {
debugLog('startServer: error', e.message);
return { ok: false, error: e.message };
}
}
async function stopServer(payload) {
const serverId = payload.serverId;
debugLog('stopServer: serverId=', serverId);
const entry = servers.get(serverId);
if (!entry) {
debugLog('stopServer: not found');
return { ok: false, error: 'Server not found' };
}
try {
await entry.holesail.close();
} catch (_) {}
servers.delete(serverId);
saveStateSync();
return { ok: true };
}
function getServers() {
const list = [];
for (const [id, s] of servers) {
list.push({
id,
serverId: id,
port: s.port,
host: s.host,
url: s.url,
secure: s.secure,
udp: s.udp || false,
label: s.label || '',
createdAt: s.createdAt
});
}
return list;
}
async function setVirtualHost(payload) {
// Sanitize hostname: strip protocol, port, path so users can paste full URLs
let hostname = (payload.hostname || payload.hostName || '').trim();
hostname = hostname.replace(/^https?:\/\//i, '').replace(/[/:?#].*$/, '').toLowerCase().trim();
const hsUrl = payload.hsUrl || payload.url;
debugLog('setVirtualHost: hostname=', hostname, 'hsUrl=', hsUrl);
if (!Holesail) {
debugLog('setVirtualHost: Holesail not installed');
return { ok: false, error: 'Holesail module not installed' };
}
if (!hostname || !hsUrl) {
debugLog('setVirtualHost: missing hostname or hsUrl');
return { ok: false, error: 'hostname and hsUrl required' };
}
const existing = virtualHosts.get(hostname);
if (existing) {
debugLog('setVirtualHost: replacing existing', hostname);
if (existing.localPort) releaseTunnelPort(existing.localPort);
if (existing.holesail) {
try {
await existing.holesail.close();
} catch (_) {}
}
}
const localPort = allocateTunnelPort();
const localHost = TUNNEL_HOST;
try {
const hs = new Holesail({ client: true, key: hsUrl, host: localHost, port: localPort });
if (typeof hs.on === 'function') {
hs.on('error', (err) => {
if (process.stderr) process.stderr.write('[holesail-manager] tunnel error ' + hostname + ': ' + (err && err.message) + '\n');
const v = virtualHosts.get(hostname);
// Guard against stale closures: only mutate the entry that owns this instance
if (v && v.holesail === hs) v.state = 'error';
emit('tunnelError', { hostname, error: err && err.message });
});
hs.on('close', () => {
const v = virtualHosts.get(hostname);
if (v && v.holesail === hs) v.state = 'closed';
emit('tunnelClosed', { hostname });
});
}
await readyWithTimeout(hs, 'vhost:' + hostname);
virtualHosts.set(hostname, {
hsUrl,
holesail: hs,
localHost,
localPort,
state: 'ready',
createdAt: (existing && existing.createdAt) || Date.now()
});
emit('tunnelReady', { hostname, hsUrl, localHost, localPort });
saveStateSync();
debugLog('setVirtualHost: ok hostname=', hostname, 'localHost=', localHost, 'localPort=', localPort);
return { ok: true, hostname, localHost, localPort, state: 'ready' };
} catch (e) {
debugLog('setVirtualHost: error hostname=', hostname, 'error=', e.message);
releaseTunnelPort(localPort);
// Keep the entry in the map with state 'error' so the dashboard can show it
// and the user can reconnect. getLocalBackend() returns null for localPort==null
// so the proxy will correctly return 502 until the tunnel is reconnected.
const existingCreatedAt = existing ? existing.createdAt : undefined;
virtualHosts.set(hostname, {
hsUrl,
holesail: null,
localHost: null,
localPort: null,
state: 'error',
createdAt: existingCreatedAt || Date.now()
});
emit('tunnelError', { hostname, error: e.message });
return { ok: false, error: e.message };
}
}
async function removeVirtualHost(payload) {
const hostname = payload.hostname || payload.hostName;
debugLog('removeVirtualHost: hostname=', hostname);
const entry = virtualHosts.get(hostname);
if (!entry) {
debugLog('removeVirtualHost: not found');
return { ok: false, error: 'Virtual host not found' };
}
if (entry.localPort) releaseTunnelPort(entry.localPort);
if (entry.holesail) {
try {
await entry.holesail.close();
} catch (_) {}
}
virtualHosts.delete(hostname);
saveStateSync();
emit('tunnelClosed', { hostname });
return { ok: true };
}
function getVirtualHosts() {
const list = [];
for (const [hostname, v] of virtualHosts) {
list.push({
hostname,
hsUrl: v.hsUrl,
localHost: v.localHost ?? null,
localPort: v.localPort ?? null,
state: v.state || 'unknown',
createdAt: v.createdAt
});
}
return list;
}
let runtimeProxyPort = DEFAULT_PROXY_PORT;
function getProxyPort() {
return runtimeProxyPort;
}
function setProxyPort(port) {
if (typeof port === 'number' && port > 0 && port < 65536) {
runtimeProxyPort = port;
debugLog('setProxyPort:', port);
}
}
async function lookup(payload) {
if (!Holesail) {
return { ok: false, error: 'Holesail module not installed' };
}
const url = payload.url || payload.hsUrl;
if (!url) {
return { ok: false, error: 'url required' };
}
try {
const result = await Holesail.lookup(url);
return { ok: true, ...result };
} catch (e) {
return { ok: false, error: e.message };
}
}
function getLocalPortForHostname(hostname) {
const v = virtualHosts.get(hostname);
return v && v.localPort != null ? v.localPort : null;
}
/** Returns { host, port } for the proxy to forward to, or null. */
function getLocalBackend(hostname) {
const v = virtualHosts.get(hostname);
const out = (!v || v.localPort == null) ? null : { host: v.localHost ?? '127.0.0.1', port: v.localPort };
debugLog('getLocalBackend: hostname=', hostname, 'result=', out, 'virtualHostsKeys=', Array.from(virtualHosts.keys()));
return out;
}
function getVirtualHostMap() {
const m = new Map();
for (const [hostname, v] of virtualHosts) {
if (v.localPort != null) m.set(hostname, v.localPort);
}
return m;
}
/**
* Load persisted state (does not start tunnels). Caller should startServer/setVirtualHost for each.
* Also populates currentSettings and sshConnectionsList from the loaded state.
*/
function restorePersistedState() {
const loaded = loadState();
if (loaded.nextServerId > nextServerId) nextServerId = loaded.nextServerId;
if (loaded.nextServiceTunnelId > nextServiceTunnelId) nextServiceTunnelId = loaded.nextServiceTunnelId;
currentSettings = { ...SETTINGS_DEFAULTS, ...loaded.settings };
sshConnectionsList = Array.isArray(loaded.sshConnections) ? loaded.sshConnections : [];
rdpConnectionsList = Array.isArray(loaded.rdpConnections) ? loaded.rdpConnections : [];
// Apply persisted proxy port to runtime
if (currentSettings.proxyPort) runtimeProxyPort = currentSettings.proxyPort;
return {
settings: currentSettings,
servers: loaded.servers,
virtualHosts: loaded.virtualHosts,
serviceTunnels: loaded.serviceTunnels || [],
sshConnections: sshConnectionsList,
rdpConnections: rdpConnectionsList
};
}
async function cleanup() {
for (const [, s] of servers) {
try {
await s.holesail.close();
} catch (_) {}
}
servers.clear();
for (const [, v] of virtualHosts) {
if (v.holesail) {
try {
await v.holesail.close();
} catch (_) {}
}
}
virtualHosts.clear();
for (const [, t] of serviceTunnels) {
if (t.holesail) {
try { await t.holesail.close(); } catch (_) {}
}
}
serviceTunnels.clear();
}
// ── Service Tunnels ──────────────────────────────────────────────────────────
// Client tunnels that forward a remote Holesail peer to a user-chosen local
// port. Unlike virtual hosts, these are not HTTP-proxied — they bind directly
// to 127.0.0.1:<localPort> so any TCP client (game client, DB tool, etc.) can
// connect without going through the HTTPS proxy.
function generateServiceTunnelId() {
nextServiceTunnelId += 1;
return 'svc-' + nextServiceTunnelId;
}
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 165535' };
const tunnelId = payload.tunnelId || generateServiceTunnelId();
debugLog('startServiceTunnel: id=', tunnelId, 'label=', label, 'localPort=', localPort);
const existing = serviceTunnels.get(tunnelId);
if (existing && 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);
// Guard against stale closures: only mutate the entry that owns this instance
if (t && t.holesail === hs) t.state = 'error';
emit('serviceTunnelError', { tunnelId, label, error: err && err.message });
});
hs.on('close', () => {
const t = serviceTunnels.get(tunnelId);
if (t && t.holesail === hs) t.state = 'closed';
emit('serviceTunnelClosed', { tunnelId, label });
});
}
await readyWithTimeout(hs, 'svc:' + tunnelId);
serviceTunnels.set(tunnelId, { label, hsUrl, localPort, holesail: hs, state: 'ready', createdAt: Date.now() });
emit('serviceTunnelReady', { tunnelId, label, localPort });
saveStateSync();
debugLog('startServiceTunnel: ok id=', tunnelId, 'localPort=', localPort);
return { ok: true, tunnelId, label, hsUrl, localPort, state: 'ready' };
} catch (e) {
debugLog('startServiceTunnel: error', e.message);
serviceTunnels.set(tunnelId, { label, hsUrl, localPort, state: 'error', error: e.message, createdAt: Date.now() });
saveStateSync();
emit('serviceTunnelError', { tunnelId, label, error: e.message });
return { ok: false, error: e.message };
}
}
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.holesail) {
try { await entry.holesail.close(); } catch (_) {}
}
serviceTunnels.delete(tunnelId);
saveStateSync();
emit('serviceTunnelClosed', { tunnelId, label: entry.label });
return { ok: true };
}
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;
}
module.exports = {
setEventEmitter,
setStoragePath,
getSettings,
updateSettings,
getSshConnections,
setSshConnections,
getRdpConnections,
setRdpConnections,
startServer,
stopServer,
getServers,
setVirtualHost,
removeVirtualHost,
getVirtualHosts,
getProxyPort,
setProxyPort,
lookup,
getLocalPortForHostname,
getLocalBackend,
getVirtualHostMap,
startServiceTunnel,
stopServiceTunnel,
getServiceTunnels,
restorePersistedState,
cleanup
};
module.exports = require('./holesail-manager/index.js');
@@ -0,0 +1,34 @@
/**
* In-memory SSH and RDP/VNC connection lists.
* These are persisted in state.json but are not live sessions —
* live sessions are managed by ssh-manager.js and rdp-manager.js.
*/
let sshConnectionsList = [];
let rdpConnectionsList = [];
let _saveState = null;
function init(saveStateFn) {
_saveState = saveStateFn;
}
function applyLoaded(ssh, rdp) {
sshConnectionsList = Array.isArray(ssh) ? ssh : [];
rdpConnectionsList = Array.isArray(rdp) ? rdp : [];
}
function getSshConnections() { return sshConnectionsList.slice(); }
function setSshConnections(list) {
if (!Array.isArray(list)) return;
sshConnectionsList = list;
if (_saveState) _saveState();
}
function getRdpConnections() { return rdpConnectionsList.slice(); }
function setRdpConnections(list) {
if (!Array.isArray(list)) return;
rdpConnectionsList = list;
if (_saveState) _saveState();
}
module.exports = { init, applyLoaded, getSshConnections, setSshConnections, getRdpConnections, setRdpConnections };
+139
View File
@@ -0,0 +1,139 @@
/**
* Holesail manager — assembles all sub-modules and exposes the same API
* as the original monolithic holesail-manager.js.
*/
const stateModule = require('./state.js');
const settingsModule = require('./settings.js');
const connectionsModule = require('./connections.js');
const serversModule = require('./servers.js');
const vhostsModule = require('./virtual-hosts.js');
const svcModule = require('./service-tunnels.js');
// ── Event emitter ─────────────────────────────────────────────────────────────
let eventEmit = null;
function setEventEmitter(emit) { eventEmit = emit; }
function emit(event, payload) { if (eventEmit) eventEmit(event, payload); }
// ── Shared saveState snapshot ─────────────────────────────────────────────────
// Collects current state from all sub-modules and persists it.
function saveState() {
const serversList = serversModule.getServers().map(s => ({
id: s.id, port: s.port, host: s.host, secure: s.secure, udp: s.udp || false, label: s.label || ''
}));
const virtualHostsList = vhostsModule.getVirtualHosts().map(v => ({
hostname: v.hostname, hsUrl: v.hsUrl
}));
const serviceTunnelsList = svcModule.getServiceTunnels().map(t => ({
id: t.id, label: t.label, hsUrl: t.hsUrl, localPort: t.localPort
}));
stateModule.saveStateSync({
version: 2,
settings: settingsModule.getSettings(),
nextServerId: serversModule.getNextServerId(),
nextServiceTunnelId: svcModule.getNextServiceTunnelId(),
servers: serversList,
virtualHosts: virtualHostsList,
serviceTunnels: serviceTunnelsList,
sshConnections: connectionsModule.getSshConnections(),
rdpConnections: connectionsModule.getRdpConnections()
});
}
// Inject the shared saveState and emit callbacks into each sub-module
settingsModule.init(saveState);
connectionsModule.init(saveState);
serversModule.init(saveState, settingsModule.getReadyTimeoutMs);
vhostsModule.init(saveState, emit, settingsModule.getReadyTimeoutMs);
svcModule.init(saveState, emit, settingsModule.getReadyTimeoutMs);
// ── Storage path ──────────────────────────────────────────────────────────────
function setStoragePath(baseDir) {
stateModule.setStoragePath(baseDir);
}
// ── Restore persisted state ───────────────────────────────────────────────────
function restorePersistedState() {
const loaded = stateModule.loadState();
settingsModule.applyLoaded(loaded.settings);
connectionsModule.applyLoaded(loaded.sshConnections, loaded.rdpConnections);
serversModule.applyLoaded(loaded.nextServerId);
svcModule.applyLoaded(loaded.nextServiceTunnelId);
return {
settings: settingsModule.getSettings(),
servers: loaded.servers,
virtualHosts: loaded.virtualHosts,
serviceTunnels: loaded.serviceTunnels || [],
sshConnections: connectionsModule.getSshConnections(),
rdpConnections: connectionsModule.getRdpConnections()
};
}
// ── Cleanup ───────────────────────────────────────────────────────────────────
async function cleanup() {
await serversModule.cleanupServers();
await vhostsModule.cleanupVirtualHosts();
await svcModule.cleanupServiceTunnels();
}
// ── Re-export full original API ───────────────────────────────────────────────
module.exports = {
// Event emitter
setEventEmitter,
// Storage
setStoragePath,
// Settings
getSettings: settingsModule.getSettings,
updateSettings: settingsModule.updateSettings,
getProxyPort: settingsModule.getProxyPort,
setProxyPort: settingsModule.setProxyPort,
// SSH/RDP connection lists
getSshConnections: connectionsModule.getSshConnections,
setSshConnections: connectionsModule.setSshConnections,
getRdpConnections: connectionsModule.getRdpConnections,
setRdpConnections: connectionsModule.setRdpConnections,
// Servers
startServer: serversModule.startServer,
stopServer: serversModule.stopServer,
getServers: serversModule.getServers,
// Virtual hosts
setVirtualHost: vhostsModule.setVirtualHost,
removeVirtualHost: vhostsModule.removeVirtualHost,
getVirtualHosts: vhostsModule.getVirtualHosts,
getLocalPortForHostname: vhostsModule.getLocalPortForHostname,
getLocalBackend: vhostsModule.getLocalBackend,
getVirtualHostMap: vhostsModule.getVirtualHostMap,
// Service tunnels
startServiceTunnel: svcModule.startServiceTunnel,
stopServiceTunnel: svcModule.stopServiceTunnel,
getServiceTunnels: svcModule.getServiceTunnels,
// Lookup
lookup,
// Lifecycle
restorePersistedState,
cleanup
};
// ── Lookup (standalone, only needs Holesail) ──────────────────────────────────
let Holesail = null;
try { Holesail = require('holesail'); } catch (_) {}
async function lookup(payload) {
if (!Holesail) return { ok: false, error: 'Holesail module not installed' };
const url = payload.url || payload.hsUrl;
if (!url) return { ok: false, error: 'url required' };
try {
const result = await Holesail.lookup(url);
return { ok: true, ...result };
} catch (e) {
return { ok: false, error: e.message };
}
}
@@ -0,0 +1,23 @@
/**
* Port allocator for virtual host tunnel local ports.
* Each virtual host gets a unique port on 127.0.0.1 starting at TUNNEL_PORT_BASE.
* Released ports are recycled via a free list.
*/
const TUNNEL_PORT_BASE = 19000;
let nextTunnelPortIndex = 0;
const tunnelPortFreeList = [];
function allocateTunnelPort() {
if (tunnelPortFreeList.length > 0) return tunnelPortFreeList.pop();
return TUNNEL_PORT_BASE + (nextTunnelPortIndex++);
}
function releaseTunnelPort(port) {
if (typeof port === 'number' && port >= TUNNEL_PORT_BASE) {
tunnelPortFreeList.push(port);
}
}
module.exports = { TUNNEL_PORT_BASE, allocateTunnelPort, releaseTunnelPort };
+119
View File
@@ -0,0 +1,119 @@
/**
* Holesail server tunnel management.
* Exposes a local TCP/UDP port to the Holesail P2P network (server mode).
*/
let Holesail = null;
try { Holesail = require('holesail'); } catch (e) {
if (process.stderr) process.stderr.write('[holesail-manager] Holesail not installed: ' + e.message + '\n');
}
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 servers = new Map(); // serverId -> { holesail, port, host, secure, udp, label, url, createdAt }
let nextServerId = 0;
let _saveState = null;
let _getReadyTimeoutMs = null;
function init(saveStateFn, getReadyTimeoutMsFn) {
_saveState = saveStateFn;
_getReadyTimeoutMs = getReadyTimeoutMsFn;
}
function applyLoaded(loadedNextServerId) {
if (loadedNextServerId > nextServerId) nextServerId = loadedNextServerId;
}
function parseServerIdNum(serverId) {
if (!serverId || typeof serverId !== 'string') return 0;
const m = serverId.match(/^server_(\d+)$/);
return m ? Math.max(0, parseInt(m[1], 10)) : 0;
}
function generateServerId() {
nextServerId += 1;
return 'server_' + nextServerId;
}
function useServerId(givenId) {
if (givenId && typeof givenId === 'string' && givenId.startsWith('server_')) {
const n = parseServerIdNum(givenId);
if (n >= nextServerId) nextServerId = n + 1;
return givenId;
}
return generateServerId();
}
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); });
});
}
async function startServer(payload) {
if (!Holesail) return { ok: false, error: 'Holesail module not installed' };
const port = payload.port || 3000;
const host = payload.host || '127.0.0.1';
const secure = payload.secure !== false;
const udp = payload.udp === true;
const label = (payload.label || '').trim();
const serverId = useServerId(payload.serverId);
debugLog('startServer: serverId=', serverId, 'port=', port, 'host=', host, 'secure=', secure, 'udp=', udp, 'label=', label);
if (servers.has(serverId)) {
debugLog('startServer: serverId already in use');
return { ok: false, error: 'Server id already in use' };
}
try {
const hs = new Holesail({ server: true, port, host, secure, udp });
await readyWithTimeout(hs, 'server:' + serverId);
const url = hs.info.url;
servers.set(serverId, { holesail: hs, port: hs.info.port, host: hs.info.host, secure, udp, label, url, createdAt: Date.now() });
if (_saveState) _saveState();
debugLog('startServer: ok serverId=', serverId, 'url=', url);
return { ok: true, serverId, url, port: hs.info.port, host: hs.info.host, secure, udp, label };
} catch (e) {
debugLog('startServer: error', e.message);
return { ok: false, error: e.message };
}
}
async function stopServer(payload) {
const serverId = payload.serverId;
debugLog('stopServer: serverId=', serverId);
const entry = servers.get(serverId);
if (!entry) {
debugLog('stopServer: not found');
return { ok: false, error: 'Server not found' };
}
try { await entry.holesail.close(); } catch (_) {}
servers.delete(serverId);
if (_saveState) _saveState();
return { ok: true };
}
function getServers() {
const list = [];
for (const [id, s] of servers) {
list.push({ id, serverId: id, port: s.port, host: s.host, url: s.url, secure: s.secure, udp: s.udp || false, label: s.label || '', createdAt: s.createdAt });
}
return list;
}
function getNextServerId() { return nextServerId; }
async function cleanupServers() {
for (const [, s] of servers) {
try { await s.holesail.close(); } catch (_) {}
}
servers.clear();
}
module.exports = { init, applyLoaded, startServer, stopServer, getServers, getNextServerId, cleanupServers };
@@ -0,0 +1,123 @@
/**
* 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 }
let nextServiceTunnelId = 0;
let _saveState = null;
let _emit = null;
let _getReadyTimeoutMs = null;
function init(saveStateFn, emitFn, getReadyTimeoutMsFn) {
_saveState = saveStateFn;
_emit = emitFn;
_getReadyTimeoutMs = getReadyTimeoutMsFn;
}
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); });
});
}
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 165535' };
const tunnelId = payload.tunnelId || generateServiceTunnelId();
debugLog('startServiceTunnel: id=', tunnelId, 'label=', label, 'localPort=', localPort);
const existing = serviceTunnels.get(tunnelId);
if (existing && 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';
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';
if (_emit) _emit('serviceTunnelClosed', { tunnelId, label });
});
}
await readyWithTimeout(hs, 'svc:' + tunnelId);
serviceTunnels.set(tunnelId, { label, hsUrl, localPort, holesail: hs, state: 'ready', createdAt: Date.now() });
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);
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 };
}
}
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.holesail) { try { await entry.holesail.close(); } catch (_) {} }
serviceTunnels.delete(tunnelId);
if (_saveState) _saveState();
if (_emit) _emit('serviceTunnelClosed', { tunnelId, label: entry.label });
return { ok: true };
}
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; }
async function cleanupServiceTunnels() {
for (const [, t] of serviceTunnels) {
if (t.holesail) { try { await t.holesail.close(); } catch (_) {} }
}
serviceTunnels.clear();
}
module.exports = { init, applyLoaded, startServiceTunnel, stopServiceTunnel, getServiceTunnels, getNextServiceTunnelId, cleanupServiceTunnels };
+53
View File
@@ -0,0 +1,53 @@
/**
* Settings management — in-memory settings with persistence via saveStateSync.
* Depends on: state.js (SETTINGS_DEFAULTS, saveStateSync via the index snapshot callback)
*/
const { SETTINGS_DEFAULTS } = require('./state.js');
let currentSettings = { ...SETTINGS_DEFAULTS };
let runtimeProxyPort = SETTINGS_DEFAULTS.proxyPort;
let _saveState = null; // injected by index.js
function init(saveStateFn) {
_saveState = saveStateFn;
}
function applyLoaded(loaded) {
currentSettings = { ...SETTINGS_DEFAULTS, ...loaded };
if (currentSettings.proxyPort) runtimeProxyPort = currentSettings.proxyPort;
}
function getSettings() {
return { ...currentSettings };
}
function updateSettings(patch) {
if (!patch || typeof patch !== 'object') return { requiresRestart: false };
let requiresRestart = false;
if (typeof patch.proxyPort === 'number' && patch.proxyPort > 0 && patch.proxyPort < 65536) {
if (patch.proxyPort !== currentSettings.proxyPort) requiresRestart = true;
currentSettings.proxyPort = patch.proxyPort;
runtimeProxyPort = patch.proxyPort;
}
if (typeof patch.connectProxyPort === 'number' && patch.connectProxyPort > 0 && patch.connectProxyPort < 65536) {
if (patch.connectProxyPort !== currentSettings.connectProxyPort) requiresRestart = true;
currentSettings.connectProxyPort = patch.connectProxyPort;
}
if (typeof patch.readyTimeoutMs === 'number') currentSettings.readyTimeoutMs = patch.readyTimeoutMs;
if (typeof patch.notifyOnDisconnect === 'boolean') currentSettings.notifyOnDisconnect = patch.notifyOnDisconnect;
if (typeof patch.debug === 'boolean') currentSettings.debug = patch.debug;
if (typeof patch.disableOnFileUrls === 'boolean') currentSettings.disableOnFileUrls = patch.disableOnFileUrls;
if (typeof patch.backupRetention === 'number') currentSettings.backupRetention = patch.backupRetention;
if (_saveState) _saveState();
return { requiresRestart };
}
function getProxyPort() { return runtimeProxyPort; }
function setProxyPort(port) {
if (typeof port === 'number' && port > 0 && port < 65536) runtimeProxyPort = port;
}
function getReadyTimeoutMs() { return currentSettings.readyTimeoutMs; }
module.exports = { init, applyLoaded, getSettings, updateSettings, getProxyPort, setProxyPort, getReadyTimeoutMs };
+132
View File
@@ -0,0 +1,132 @@
/**
* State persistence for the native host.
* Loads and saves all persistent data (tunnels, settings, connections) to state.json.
* Handles migration from the legacy holesail-persist.json format.
*/
const path = require('bare-path');
const fs = require('bare-fs');
const STATE_FILENAME = 'state.json';
const LEGACY_PERSIST_FILENAME = 'holesail-persist.json';
const SETTINGS_DEFAULTS = {
proxyPort: 8443,
connectProxyPort: 8442,
readyTimeoutMs: 0,
notifyOnDisconnect: true,
debug: false,
disableOnFileUrls: false,
backupRetention: 5
};
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');
}
let stateFilePath = null;
function setStoragePath(baseDir) {
if (baseDir && typeof baseDir === 'string') {
stateFilePath = path.join(baseDir, STATE_FILENAME);
}
}
function getStatePath() {
if (stateFilePath) return stateFilePath;
const base = process.env.HOLESAIL_BROWSER_STORAGE || path.join(__dirname, '..', 'holesail-browser-storage');
stateFilePath = path.join(base, STATE_FILENAME);
return stateFilePath;
}
function getLegacyPersistPath() {
const dir = path.dirname(getStatePath());
return path.join(dir, LEGACY_PERSIST_FILENAME);
}
function buildDefaultState() {
return {
settings: { ...SETTINGS_DEFAULTS },
servers: [],
virtualHosts: [],
serviceTunnels: [],
sshConnections: [],
rdpConnections: [],
nextServerId: 0,
nextServiceTunnelId: 0
};
}
function saveStateSync(data) {
const file = getStatePath();
const json = JSON.stringify(data, null, 2);
try {
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, json, 'utf8');
debugLog('state saved path=', file);
} catch (e) {
if (process.stderr) process.stderr.write('[holesail-manager] state save failed: ' + e.message + ' (path: ' + file + ')\n');
}
}
function loadState() {
const file = getStatePath();
try {
const raw = fs.readFileSync(file, 'utf8');
const data = JSON.parse(raw);
if (!data || typeof data !== 'object') return buildDefaultState();
const out = {
settings: (data.settings && typeof data.settings === 'object') ? { ...SETTINGS_DEFAULTS, ...data.settings } : { ...SETTINGS_DEFAULTS },
servers: Array.isArray(data.servers) ? data.servers : [],
virtualHosts: Array.isArray(data.virtualHosts) ? data.virtualHosts : [],
serviceTunnels: Array.isArray(data.serviceTunnels) ? data.serviceTunnels : [],
sshConnections: Array.isArray(data.sshConnections) ? data.sshConnections : [],
rdpConnections: Array.isArray(data.rdpConnections) ? data.rdpConnections : [],
nextServerId: typeof data.nextServerId === 'number' ? data.nextServerId : 0,
nextServiceTunnelId: typeof data.nextServiceTunnelId === 'number' ? data.nextServiceTunnelId : 0
};
debugLog('state loaded path=', file, 'servers=', out.servers.length, 'vhosts=', out.virtualHosts.length);
if (process.stderr && (out.servers.length || out.virtualHosts.length)) {
process.stderr.write('[holesail-manager] state loaded from ' + file + ' (servers=' + out.servers.length + ' vhosts=' + out.virtualHosts.length + ')\n');
}
return out;
} catch (e) {
if (e && e.code !== 'ENOENT') {
if (process.stderr) process.stderr.write('[holesail-manager] state load failed: ' + e.message + '\n');
return buildDefaultState();
}
}
// state.json not found — try migrating from legacy holesail-persist.json
const legacyFile = getLegacyPersistPath();
try {
const raw = fs.readFileSync(legacyFile, 'utf8');
const data = JSON.parse(raw);
if (process.stderr) process.stderr.write('[holesail-manager] migrating from ' + legacyFile + ' to ' + file + '\n');
const out = {
settings: { ...SETTINGS_DEFAULTS },
servers: Array.isArray(data.servers) ? data.servers : [],
virtualHosts: Array.isArray(data.virtualHosts) ? data.virtualHosts : [],
serviceTunnels: Array.isArray(data.serviceTunnels) ? data.serviceTunnels : [],
sshConnections: Array.isArray(data.sshConnections) ? data.sshConnections : [],
rdpConnections: Array.isArray(data.rdpConnections) ? data.rdpConnections : [],
nextServerId: typeof data.nextServerId === 'number' ? data.nextServerId : 0,
nextServiceTunnelId: typeof data.nextServiceTunnelId === 'number' ? data.nextServiceTunnelId : 0
};
try { fs.unlinkSync(legacyFile); } catch (_) {}
if (process.stderr) process.stderr.write('[holesail-manager] migration complete, legacy file removed\n');
return out;
} catch (e) {
if (e && e.code !== 'ENOENT') {
if (process.stderr) process.stderr.write('[holesail-manager] legacy persist load failed: ' + e.message + '\n');
}
}
return buildDefaultState();
}
module.exports = { SETTINGS_DEFAULTS, setStoragePath, loadState, saveStateSync, buildDefaultState };
@@ -0,0 +1,137 @@
/**
* Virtual host tunnel management.
* Connects to a remote Holesail peer and binds it to a local port for HTTP proxying.
*/
let Holesail = null;
try { Holesail = require('holesail'); } catch (e) {
if (process.stderr) process.stderr.write('[holesail-manager] Holesail not installed: ' + e.message + '\n');
}
const { allocateTunnelPort, releaseTunnelPort } = require('./port-allocator.js');
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 virtualHosts = new Map(); // hostname -> { hsUrl, holesail, localHost, localPort, state, createdAt }
let _saveState = null;
let _emit = null;
let _getReadyTimeoutMs = null;
function init(saveStateFn, emitFn, getReadyTimeoutMsFn) {
_saveState = saveStateFn;
_emit = emitFn;
_getReadyTimeoutMs = getReadyTimeoutMsFn;
}
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); });
});
}
async function setVirtualHost(payload) {
let hostname = (payload.hostname || payload.hostName || '').trim();
hostname = hostname.replace(/^https?:\/\//i, '').replace(/[/:?#].*$/, '').toLowerCase().trim();
const hsUrl = payload.hsUrl || payload.url;
debugLog('setVirtualHost: hostname=', hostname, 'hsUrl=', hsUrl);
if (!Holesail) return { ok: false, error: 'Holesail module not installed' };
if (!hostname || !hsUrl) return { ok: false, error: 'hostname and hsUrl required' };
const existing = virtualHosts.get(hostname);
if (existing) {
debugLog('setVirtualHost: replacing existing', hostname);
if (existing.localPort) releaseTunnelPort(existing.localPort);
if (existing.holesail) { try { await existing.holesail.close(); } catch (_) {} }
}
const localPort = allocateTunnelPort();
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] tunnel error ' + hostname + ': ' + (err && err.message) + '\n');
const v = virtualHosts.get(hostname);
if (v && v.holesail === hs) v.state = 'error';
if (_emit) _emit('tunnelError', { hostname, error: err && err.message });
});
hs.on('close', () => {
const v = virtualHosts.get(hostname);
if (v && v.holesail === hs) v.state = 'closed';
if (_emit) _emit('tunnelClosed', { hostname });
});
}
await readyWithTimeout(hs, 'vhost:' + hostname);
virtualHosts.set(hostname, { hsUrl, holesail: hs, localHost: TUNNEL_HOST, localPort, state: 'ready', createdAt: (existing && existing.createdAt) || Date.now() });
if (_emit) _emit('tunnelReady', { hostname, hsUrl, localHost: TUNNEL_HOST, localPort });
if (_saveState) _saveState();
debugLog('setVirtualHost: ok hostname=', hostname, 'localPort=', localPort);
return { ok: true, hostname, localHost: TUNNEL_HOST, localPort, state: 'ready' };
} catch (e) {
debugLog('setVirtualHost: error hostname=', hostname, 'error=', e.message);
releaseTunnelPort(localPort);
const existingCreatedAt = existing ? existing.createdAt : undefined;
virtualHosts.set(hostname, { hsUrl, holesail: null, localHost: null, localPort: null, state: 'error', createdAt: existingCreatedAt || Date.now() });
if (_emit) _emit('tunnelError', { hostname, error: e.message });
return { ok: false, error: e.message };
}
}
async function removeVirtualHost(payload) {
const hostname = payload.hostname || payload.hostName;
debugLog('removeVirtualHost: hostname=', hostname);
const entry = virtualHosts.get(hostname);
if (!entry) return { ok: false, error: 'Virtual host not found' };
if (entry.localPort) releaseTunnelPort(entry.localPort);
if (entry.holesail) { try { await entry.holesail.close(); } catch (_) {} }
virtualHosts.delete(hostname);
if (_saveState) _saveState();
if (_emit) _emit('tunnelClosed', { hostname });
return { ok: true };
}
function getVirtualHosts() {
const list = [];
for (const [hostname, v] of virtualHosts) {
list.push({ hostname, hsUrl: v.hsUrl, localHost: v.localHost ?? null, localPort: v.localPort ?? null, state: v.state || 'unknown', createdAt: v.createdAt });
}
return list;
}
function getLocalPortForHostname(hostname) {
const v = virtualHosts.get(hostname);
return v && v.localPort != null ? v.localPort : null;
}
function getLocalBackend(hostname) {
const v = virtualHosts.get(hostname);
const out = (!v || v.localPort == null) ? null : { host: v.localHost ?? '127.0.0.1', port: v.localPort };
debugLog('getLocalBackend: hostname=', hostname, 'result=', out, 'virtualHostsKeys=', Array.from(virtualHosts.keys()));
return out;
}
function getVirtualHostMap() {
const m = new Map();
for (const [hostname, v] of virtualHosts) {
if (v.localPort != null) m.set(hostname, v.localPort);
}
return m;
}
async function cleanupVirtualHosts() {
for (const [, v] of virtualHosts) {
if (v.holesail) { try { await v.holesail.close(); } catch (_) {} }
}
virtualHosts.clear();
}
module.exports = { init, setVirtualHost, removeVirtualHost, getVirtualHosts, getLocalPortForHostname, getLocalBackend, getVirtualHostMap, cleanupVirtualHosts };
+4 -433
View File
@@ -1,435 +1,6 @@
/**
* Holesail Browser native messaging host.
* Handles tunnel management, SSH sessions, proxy control, and CA installation.
* Entry-point shim — delegates to host/message-router.js.
* Kept at the top level so index.mjs require('./host.js') continues to work.
*/
const path = require('bare-path');
const fs = require('bare-fs');
const holesailManager = require('./holesail-manager.js');
// Resolve storage path relative to the executable when running as a standalone
// binary (__dirname is bare:/app.bundle/ which is not a real filesystem path).
// Fall back to __dirname for development (bare index.mjs).
function resolveBase() {
try {
const os = require('bare-os');
const execPath = os.execPath();
// execPath is the standalone binary itself; store data next to it
if (execPath && !execPath.startsWith('bare:')) {
return path.dirname(execPath);
}
} catch (_) {}
// Development: __dirname is the native-host/ directory
return __dirname;
}
const BASE_DIR = resolveBase();
const STORAGE_PATH = path.join(BASE_DIR, 'holesail-browser-storage');
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 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));
// With SNI-aware proxy, certs are selected per-connection — no restart needed
// when virtual hosts change. refreshProxyCert() is kept only for port changes.
const PROXY_PORT = 8443;
const CONNECT_PROXY_PORT = 8442;
let proxiesReadyPromise = null;
let tunnelsRestoredPromise = null;
if (typeof setImmediate === 'function') {
proxiesReadyPromise = new Promise((resolve) => {
setImmediate(async () => {
// Load persisted state first so we can use the saved proxy port
const restored = holesailManager.restorePersistedState();
const savedProxyPort = (restored.settings && restored.settings.proxyPort) || PROXY_PORT;
const savedConnectPort = (restored.settings && restored.settings.connectProxyPort) || CONNECT_PROXY_PORT;
// Wait for CA to be ready before starting the HTTPS proxy (which needs the CA cert)
try {
await certificateAuthority.caReady;
} catch (e) {
log('CA generation failed:', e.message, '— HTTPS proxy may not work correctly');
}
try {
await new Promise((res, rej) => {
httpsProxy.start(savedProxyPort, certificateAuthority, (err) => {
if (err) return rej(err);
res();
});
});
log('HTTPS proxy ready on port', savedProxyPort);
} catch (e) {
log('HTTPS proxy startup failed:', e.message);
}
try {
await new Promise((res, rej) => {
const timeout = setTimeout(() => {
rej(new Error('CONNECT proxy listen timed out after 5s'));
}, 5000);
connectProxy.start(savedConnectPort, savedProxyPort, (err) => {
clearTimeout(timeout);
if (err) return rej(err);
res();
});
});
log('CONNECT proxy ready on port', savedConnectPort);
} catch (e) {
log('CONNECT proxy startup failed:', e.message);
}
log('Proxies startup complete: HTTPS', savedProxyPort, 'CONNECT', connectProxy.getPort() ?? 'FAILED');
resolve();
tunnelsRestoredPromise = restorePersistedTunnels(restored).catch((e) => log('Restore tunnels failed:', e.message));
});
});
}
async function restorePersistedTunnels(restored) {
try {
debugLog('restorePersistedTunnels: starting');
const { servers: savedServers, virtualHosts: savedVhosts, serviceTunnels: savedServiceTunnels } = restored;
log('Restore: loaded', savedServers.length, 'servers,', savedVhosts.length, 'virtual hosts,', savedServiceTunnels.length, 'service tunnels');
let serverOk = 0;
let serverFail = 0;
for (const s of savedServers) {
const result = await holesailManager.startServer({
serverId: s.id,
port: s.port,
host: s.host || '127.0.0.1',
secure: s.secure !== false,
udp: s.udp === true,
label: s.label || ''
});
if (result.ok) serverOk++;
else serverFail++;
}
let vhostOk = 0;
let vhostFail = 0;
for (const v of savedVhosts) {
const result = await holesailManager.setVirtualHost({ hostname: v.hostname, hsUrl: v.hsUrl });
if (result.ok) vhostOk++;
else vhostFail++;
}
let svcOk = 0;
let svcFail = 0;
for (const t of savedServiceTunnels) {
const result = await holesailManager.startServiceTunnel({ tunnelId: t.id, label: t.label, hsUrl: t.hsUrl, localPort: t.localPort });
if (result.ok) svcOk++;
else svcFail++;
}
if (savedServers.length || savedVhosts.length || savedServiceTunnels.length) {
log('Restored on startup: servers', serverOk + '/' + savedServers.length, 'virtual hosts', vhostOk + '/' + savedVhosts.length, 'service tunnels', svcOk + '/' + savedServiceTunnels.length);
if (serverFail || vhostFail || svcFail) log('Restore failures: servers', serverFail, 'virtual hosts', vhostFail, 'service tunnels', svcFail);
}
} catch (e) {
log('Restore persisted tunnels failed:', e.message);
}
}
/** Enable with HOLESAIL_DEBUG=1 or HOLESAIL_DEBUG=true for detailed debug logs. */
const DEBUG = process.env.HOLESAIL_DEBUG === '1' || process.env.HOLESAIL_DEBUG === 'true';
// File-based logging
const LOG_FILE = path.join(BASE_DIR, 'holesail-browser.log');
let logStream = null;
function getLogStream() {
if (!logStream) {
try {
const logPath = process.env.BRIDGE_SWARM_LOG || LOG_FILE;
logStream = fs.createWriteStream(logPath, { flags: 'a' });
} catch (e) {
return null;
}
}
return logStream;
}
function log(...args) {
const stream = getLogStream();
if (stream) {
const msg = '[' + new Date().toISOString() + '] ' + args.map(a => typeof a === 'object' ? JSON.stringify(a) : String(a)).join(' ') + '\n';
stream.write(msg);
}
if (process.stderr) {
process.stderr.write('[' + new Date().toISOString() + '] ' + args.join(' ') + '\n');
}
}
function debugLog(...args) {
if (!DEBUG) return;
const msg = '[host:debug] ' + args.map(a => typeof a === 'object' ? JSON.stringify(a) : String(a)).join(' ');
if (process.stderr) process.stderr.write(msg + '\n');
const stream = getLogStream();
if (stream) stream.write('[' + new Date().toISOString() + '] ' + msg + '\n');
}
/**
* @param {object} send - messenger.send(msg)
* @param {object} msg - { id, type, payload }
*/
async function handleMessageAsync(send, msg) {
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));
holesailManager.setEventEmitter((event, payload) => {
debugLog('emit event:', event, payload && Object.keys(payload));
send({ type: 'event', event, payload });
});
switch (type) {
case 'getState': {
if (proxiesReadyPromise) await proxiesReadyPromise;
// Wait for tunnel restore to complete (up to 15s) so the response
// includes the actual connected tunnel state, not an empty map.
if (tunnelsRestoredPromise) {
await Promise.race([
tunnelsRestoredPromise,
new Promise((r) => 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
});
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);
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) {
// Stop all running tunnels so in-memory state matches the restored state.json
await holesailManager.cleanup().catch(() => {});
// Reload state from disk after restore
const restored = holesailManager.restorePersistedState();
// Re-start the tunnels described in the restored state
tunnelsRestoredPromise = restorePersistedTunnels(restored).catch((e) => log('Post-restore tunnel start failed:', e.message));
}
reply(restoreResult);
break;
}
case 'deleteBackup': {
reply(backupManager.deleteBackup(payload.filename));
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() {
httpsProxy.stop(() => {});
connectProxy.stop(() => {});
sshManager.cleanup();
rdpManager.cleanup().catch(() => {});
holesailManager.cleanup().catch(() => {});
}
module.exports = { handleMessage: handleMessageAsync, cleanup };
const { handleMessage, cleanup } = require('./host/message-router.js');
module.exports = { handleMessage, cleanup };
+47
View File
@@ -0,0 +1,47 @@
/**
* File-based logging for the native host.
* Writes timestamped lines to holesail-browser.log (or BRIDGE_SWARM_LOG override)
* and to process.stderr for native messaging host visibility.
*/
const path = require('bare-path');
const fs = require('bare-fs');
const { BASE_DIR } = require('./paths.js');
const DEBUG = process.env.HOLESAIL_DEBUG === '1' || process.env.HOLESAIL_DEBUG === 'true';
const LOG_FILE = path.join(BASE_DIR, 'holesail-browser.log');
let logStream = null;
function getLogStream() {
if (!logStream) {
try {
const logPath = process.env.BRIDGE_SWARM_LOG || LOG_FILE;
logStream = fs.createWriteStream(logPath, { flags: 'a' });
} catch (e) {
return null;
}
}
return logStream;
}
function log(...args) {
const stream = getLogStream();
if (stream) {
const msg = '[' + new Date().toISOString() + '] ' + args.map(a => typeof a === 'object' ? JSON.stringify(a) : String(a)).join(' ') + '\n';
stream.write(msg);
}
if (process.stderr) {
process.stderr.write('[' + new Date().toISOString() + '] ' + args.join(' ') + '\n');
}
}
function debugLog(...args) {
if (!DEBUG) return;
const msg = '[host:debug] ' + args.map(a => typeof a === 'object' ? JSON.stringify(a) : String(a)).join(' ');
if (process.stderr) process.stderr.write(msg + '\n');
const stream = getLogStream();
if (stream) stream.write('[' + new Date().toISOString() + '] ' + msg + '\n');
}
module.exports = { log, debugLog };
+286
View File
@@ -0,0 +1,286 @@
/**
* 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 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);
/**
* @param {Function} send - messenger.send(msg)
* @param {object} msg - { id, type, payload }
*/
async function handleMessageAsync(send, msg) {
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));
holesailManager.setEventEmitter((event, eventPayload) => {
debugLog('emit event:', event, eventPayload && Object.keys(eventPayload));
send({ type: 'event', event, payload: eventPayload });
});
const proxiesReadyPromise = getProxiesReadyPromise();
const tunnelsRestoredPromise = getTunnelsRestoredPromise();
switch (type) {
case 'getState': {
if (proxiesReadyPromise) await proxiesReadyPromise;
if (tunnelsRestoredPromise) {
await Promise.race([
tunnelsRestoredPromise,
new Promise((r) => 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
});
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);
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;
}
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() {
httpsProxy.stop(() => {});
connectProxy.stop(() => {});
sshManager.cleanup();
rdpManager.cleanup().catch(() => {});
holesailManager.cleanup().catch(() => {});
}
module.exports = { handleMessage: handleMessageAsync, cleanup };
+24
View File
@@ -0,0 +1,24 @@
/**
* Resolves the base directory for storage and logging.
* When running as a standalone Bare binary, __dirname is bare:/app.bundle/
* which is not a real filesystem path — use the executable's directory instead.
* In development (bare index.mjs), __dirname is the native-host/ directory.
*/
const path = require('bare-path');
function resolveBase() {
try {
const os = require('bare-os');
const execPath = os.execPath();
if (execPath && !execPath.startsWith('bare:')) {
return path.dirname(execPath);
}
} catch (_) {}
return __dirname.replace(/[/\\]host$/, ''); // strip the /host suffix to get native-host/
}
const BASE_DIR = resolveBase();
const STORAGE_PATH = path.join(BASE_DIR, 'holesail-browser-storage');
module.exports = { BASE_DIR, STORAGE_PATH };
+112
View File
@@ -0,0 +1,112 @@
/**
* Proxy startup and persisted tunnel restore.
* Starts the HTTPS and CONNECT proxies after loading persisted settings,
* then asynchronously restores all tunnels from state.json.
*/
const { log, debugLog } = require('./logger.js');
const PROXY_PORT = 8443;
const CONNECT_PROXY_PORT = 8442;
let proxiesReadyPromise = null;
let tunnelsRestoredPromise = null;
async function restorePersistedTunnels(holesailManager, restored) {
try {
debugLog('restorePersistedTunnels: starting');
const { servers: savedServers, virtualHosts: savedVhosts, serviceTunnels: savedServiceTunnels } = restored;
log('Restore: loaded', savedServers.length, 'servers,', savedVhosts.length, 'virtual hosts,', savedServiceTunnels.length, 'service tunnels');
let serverOk = 0, serverFail = 0;
for (const s of savedServers) {
const result = await holesailManager.startServer({
serverId: s.id,
port: s.port,
host: s.host || '127.0.0.1',
secure: s.secure !== false,
udp: s.udp === true,
label: s.label || ''
});
if (result.ok) serverOk++; else serverFail++;
}
let vhostOk = 0, vhostFail = 0;
for (const v of savedVhosts) {
const result = await holesailManager.setVirtualHost({ hostname: v.hostname, hsUrl: v.hsUrl });
if (result.ok) vhostOk++; else vhostFail++;
}
let svcOk = 0, svcFail = 0;
for (const t of savedServiceTunnels) {
const result = await holesailManager.startServiceTunnel({ tunnelId: t.id, label: t.label, hsUrl: t.hsUrl, localPort: t.localPort });
if (result.ok) svcOk++; else svcFail++;
}
if (savedServers.length || savedVhosts.length || savedServiceTunnels.length) {
log('Restored on startup: servers', serverOk + '/' + savedServers.length, 'virtual hosts', vhostOk + '/' + savedVhosts.length, 'service tunnels', svcOk + '/' + savedServiceTunnels.length);
if (serverFail || vhostFail || svcFail) log('Restore failures: servers', serverFail, 'virtual hosts', vhostFail, 'service tunnels', svcFail);
}
} catch (e) {
log('Restore persisted tunnels failed:', e.message);
}
}
function initStartup(holesailManager, certificateAuthority, httpsProxy, connectProxy) {
if (typeof setImmediate !== 'function') return;
proxiesReadyPromise = new Promise((resolve) => {
setImmediate(async () => {
const restored = holesailManager.restorePersistedState();
const savedProxyPort = (restored.settings && restored.settings.proxyPort) || PROXY_PORT;
const savedConnectPort = (restored.settings && restored.settings.connectProxyPort) || CONNECT_PROXY_PORT;
try {
await certificateAuthority.caReady;
} catch (e) {
log('CA generation failed:', e.message, '— HTTPS proxy may not work correctly');
}
try {
await new Promise((res, rej) => {
httpsProxy.start(savedProxyPort, certificateAuthority, (err) => {
if (err) return rej(err);
res();
});
});
log('HTTPS proxy ready on port', savedProxyPort);
} catch (e) {
log('HTTPS proxy startup failed:', e.message);
}
try {
await new Promise((res, rej) => {
const timeout = setTimeout(() => {
rej(new Error('CONNECT proxy listen timed out after 5s'));
}, 5000);
connectProxy.start(savedConnectPort, savedProxyPort, (err) => {
clearTimeout(timeout);
if (err) return rej(err);
res();
});
});
log('CONNECT proxy ready on port', savedConnectPort);
} catch (e) {
log('CONNECT proxy startup failed:', e.message);
}
log('Proxies startup complete: HTTPS', savedProxyPort, 'CONNECT', connectProxy.getPort() ?? 'FAILED');
resolve();
tunnelsRestoredPromise = restorePersistedTunnels(holesailManager, restored).catch((e) => log('Restore tunnels failed:', e.message));
});
});
}
function getProxiesReadyPromise() { return proxiesReadyPromise; }
function getTunnelsRestoredPromise() { return tunnelsRestoredPromise; }
function setTunnelsRestoredPromise(p) { tunnelsRestoredPromise = p; }
module.exports = {
initStartup,
restorePersistedTunnels,
getProxiesReadyPromise,
getTunnelsRestoredPromise,
setTunnelsRestoredPromise
};
-5
View File
@@ -1,5 +0,0 @@
import 'bare-process/global';
import { createRequire } from 'bare-module';
const require = createRequire(import.meta.url);
const cp = require('child_process');
process.stderr.write('child_process keys: ' + Object.keys(cp).join(', ') + '\n');