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/https-proxy.js
|
||||||
node --check native-host/connect-proxy.js
|
node --check native-host/connect-proxy.js
|
||||||
node --check native-host/messenger.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
|
- name: Lint — syntax check build scripts
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
+26
-2
@@ -55,12 +55,14 @@ Holesail Browser is composed of three parts: a browser extension, a native host
|
|||||||
|
|
||||||
### Native host (`native-host/`)
|
### Native host (`native-host/`)
|
||||||
|
|
||||||
|
#### Top-level files
|
||||||
|
|
||||||
| File | Purpose |
|
| 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. |
|
| `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. |
|
| `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). |
|
| `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` | Manages all tunnel types (server, virtual host, service tunnel). Owns in-memory maps, port allocator (starting at 19000), and all persistence to `state.json`. |
|
| `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. |
|
| `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`. |
|
| `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. |
|
| `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. |
|
| `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. |
|
| `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/`)
|
### Extension (`extension/`)
|
||||||
|
|
||||||
#### Top-level files
|
#### Top-level files
|
||||||
|
|||||||
@@ -1,698 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Manages Holesail server tunnels and client (virtual host) tunnels.
|
* Entry-point shim — delegates to holesail-manager/index.js.
|
||||||
* One Holesail instance per server or per virtual host.
|
* Kept at the top level so host.js require('./holesail-manager.js') continues to work.
|
||||||
* Persists all state (tunnels, settings, SSH connections) to state.json.
|
|
||||||
*/
|
*/
|
||||||
|
module.exports = require('./holesail-manager/index.js');
|
||||||
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
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -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.
|
* Entry-point shim — delegates to host/message-router.js.
|
||||||
* Handles tunnel management, SSH sessions, proxy control, and CA installation.
|
* Kept at the top level so index.mjs require('./host.js') continues to work.
|
||||||
*/
|
*/
|
||||||
|
const { handleMessage, cleanup } = require('./host/message-router.js');
|
||||||
const path = require('bare-path');
|
module.exports = { handleMessage, cleanup };
|
||||||
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 };
|
|
||||||
|
|||||||
@@ -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