refactor(native-host): modularize host.js and holesail-manager.js
CI / Build & Test (push) Successful in 2m46s
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:
@@ -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
@@ -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
|
||||
|
||||
@@ -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 1–65535' };
|
||||
|
||||
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 };
|
||||
@@ -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 };
|
||||
@@ -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 1–65535' };
|
||||
|
||||
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 };
|
||||
@@ -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 };
|
||||
@@ -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
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
@@ -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
|
||||
};
|
||||
@@ -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');
|
||||
Reference in New Issue
Block a user