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:
@@ -1,698 +1,5 @@
|
||||
/**
|
||||
* Manages Holesail server tunnels and client (virtual host) tunnels.
|
||||
* One Holesail instance per server or per virtual host.
|
||||
* Persists all state (tunnels, settings, SSH connections) to state.json.
|
||||
* Entry-point shim — delegates to holesail-manager/index.js.
|
||||
* Kept at the top level so host.js require('./holesail-manager.js') continues to work.
|
||||
*/
|
||||
|
||||
const path = require('bare-path');
|
||||
const fs = require('bare-fs');
|
||||
|
||||
let Holesail = null;
|
||||
try {
|
||||
Holesail = require('holesail');
|
||||
} catch (e) {
|
||||
if (process.stderr) process.stderr.write('[holesail-manager] Holesail not installed: ' + e.message + '\n');
|
||||
}
|
||||
|
||||
const DEFAULT_PROXY_PORT = 8443;
|
||||
const DEFAULT_CONNECT_PROXY_PORT = 8442;
|
||||
const VHOST_DOMAIN = 'hole.sail';
|
||||
/** All tunnels bind to 127.0.0.1; each gets a unique port so no IP alias is needed. */
|
||||
const TUNNEL_HOST = '127.0.0.1';
|
||||
const TUNNEL_PORT_BASE = 19000; // start allocating tunnel ports from here
|
||||
|
||||
const STATE_FILENAME = 'state.json';
|
||||
const LEGACY_PERSIST_FILENAME = 'holesail-persist.json';
|
||||
|
||||
const SETTINGS_DEFAULTS = {
|
||||
proxyPort: DEFAULT_PROXY_PORT,
|
||||
connectProxyPort: DEFAULT_CONNECT_PROXY_PORT,
|
||||
readyTimeoutMs: 0,
|
||||
notifyOnDisconnect: true,
|
||||
debug: false,
|
||||
disableOnFileUrls: false,
|
||||
backupRetention: 5
|
||||
};
|
||||
|
||||
const DEBUG = process.env.HOLESAIL_DEBUG === '1' || process.env.HOLESAIL_DEBUG === 'true';
|
||||
function debugLog(...args) {
|
||||
if (!DEBUG) return;
|
||||
const msg = '[holesail-manager:debug] ' + args.map(a => typeof a === 'object' ? JSON.stringify(a) : String(a)).join(' ');
|
||||
if (process.stderr) process.stderr.write(msg + '\n');
|
||||
}
|
||||
|
||||
const servers = new Map(); // serverId -> { holesail, port, host, secure, udp, url, createdAt }
|
||||
const virtualHosts = new Map(); // hostname -> { hsUrl, holesail, localHost, localPort, state, createdAt }
|
||||
const serviceTunnels = new Map(); // tunnelId -> { label, hsUrl, localPort, holesail, state, createdAt }
|
||||
let nextServerId = 0;
|
||||
let nextServiceTunnelId = 0;
|
||||
let eventEmit = null;
|
||||
let stateFilePath = null;
|
||||
|
||||
// In-memory settings — loaded from state.json on startup
|
||||
let currentSettings = { ...SETTINGS_DEFAULTS };
|
||||
|
||||
// In-memory SSH connections — loaded from state.json on startup
|
||||
let sshConnectionsList = [];
|
||||
|
||||
// In-memory RDP/VNC connections — loaded from state.json on startup
|
||||
let rdpConnectionsList = [];
|
||||
|
||||
function setStoragePath(baseDir) {
|
||||
if (baseDir && typeof baseDir === 'string') {
|
||||
stateFilePath = path.join(baseDir, STATE_FILENAME);
|
||||
}
|
||||
}
|
||||
|
||||
function getStatePath() {
|
||||
if (stateFilePath) return stateFilePath;
|
||||
const base = process.env.HOLESAIL_BROWSER_STORAGE || path.join(__dirname, 'holesail-browser-storage');
|
||||
stateFilePath = path.join(base, STATE_FILENAME);
|
||||
return stateFilePath;
|
||||
}
|
||||
|
||||
function getLegacyPersistPath() {
|
||||
const dir = path.dirname(getStatePath());
|
||||
return path.join(dir, LEGACY_PERSIST_FILENAME);
|
||||
}
|
||||
|
||||
function parseServerIdNum(serverId) {
|
||||
if (!serverId || typeof serverId !== 'string') return 0;
|
||||
const m = serverId.match(/^server_(\d+)$/);
|
||||
return m ? Math.max(0, parseInt(m[1], 10)) : 0;
|
||||
}
|
||||
|
||||
function saveStateSync() {
|
||||
const file = getStatePath();
|
||||
const serversList = [];
|
||||
for (const [id, s] of servers) {
|
||||
serversList.push({ id, port: s.port, host: s.host, secure: s.secure, udp: s.udp || false, label: s.label || '' });
|
||||
}
|
||||
const virtualHostsList = [];
|
||||
for (const [hostname, v] of virtualHosts) {
|
||||
virtualHostsList.push({ hostname, hsUrl: v.hsUrl });
|
||||
}
|
||||
const serviceTunnelsList = [];
|
||||
for (const [id, t] of serviceTunnels) {
|
||||
serviceTunnelsList.push({ id, label: t.label, hsUrl: t.hsUrl, localPort: t.localPort });
|
||||
}
|
||||
const data = JSON.stringify({
|
||||
version: 2,
|
||||
settings: { ...currentSettings },
|
||||
nextServerId,
|
||||
nextServiceTunnelId,
|
||||
servers: serversList,
|
||||
virtualHosts: virtualHostsList,
|
||||
serviceTunnels: serviceTunnelsList,
|
||||
sshConnections: sshConnectionsList,
|
||||
rdpConnections: rdpConnectionsList
|
||||
}, null, 2);
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
fs.writeFileSync(file, data, 'utf8');
|
||||
debugLog('state saved path=', file, 'servers=', serversList.length, 'vhosts=', virtualHostsList.length);
|
||||
} catch (e) {
|
||||
if (process.stderr) process.stderr.write('[holesail-manager] state save failed: ' + e.message + ' (path: ' + file + ')\n');
|
||||
}
|
||||
}
|
||||
|
||||
function loadState() {
|
||||
const file = getStatePath();
|
||||
|
||||
// Try loading state.json first
|
||||
try {
|
||||
const raw = fs.readFileSync(file, 'utf8');
|
||||
const data = JSON.parse(raw);
|
||||
if (!data || typeof data !== 'object') return buildDefaultState();
|
||||
const out = {
|
||||
settings: (data.settings && typeof data.settings === 'object') ? { ...SETTINGS_DEFAULTS, ...data.settings } : { ...SETTINGS_DEFAULTS },
|
||||
servers: Array.isArray(data.servers) ? data.servers : [],
|
||||
virtualHosts: Array.isArray(data.virtualHosts) ? data.virtualHosts : [],
|
||||
serviceTunnels: Array.isArray(data.serviceTunnels) ? data.serviceTunnels : [],
|
||||
sshConnections: Array.isArray(data.sshConnections) ? data.sshConnections : [],
|
||||
rdpConnections: Array.isArray(data.rdpConnections) ? data.rdpConnections : [],
|
||||
nextServerId: typeof data.nextServerId === 'number' ? data.nextServerId : 0,
|
||||
nextServiceTunnelId: typeof data.nextServiceTunnelId === 'number' ? data.nextServiceTunnelId : 0
|
||||
};
|
||||
debugLog('state loaded path=', file, 'servers=', out.servers.length, 'vhosts=', out.virtualHosts.length);
|
||||
if (process.stderr && (out.servers.length || out.virtualHosts.length)) {
|
||||
process.stderr.write('[holesail-manager] state loaded from ' + file + ' (servers=' + out.servers.length + ' vhosts=' + out.virtualHosts.length + ')\n');
|
||||
}
|
||||
return out;
|
||||
} catch (e) {
|
||||
if (e && e.code !== 'ENOENT') {
|
||||
if (process.stderr) process.stderr.write('[holesail-manager] state load failed: ' + e.message + '\n');
|
||||
return buildDefaultState();
|
||||
}
|
||||
}
|
||||
|
||||
// state.json not found — try migrating from legacy holesail-persist.json
|
||||
const legacyFile = getLegacyPersistPath();
|
||||
try {
|
||||
const raw = fs.readFileSync(legacyFile, 'utf8');
|
||||
const data = JSON.parse(raw);
|
||||
if (process.stderr) process.stderr.write('[holesail-manager] migrating from ' + legacyFile + ' to ' + file + '\n');
|
||||
const out = {
|
||||
settings: { ...SETTINGS_DEFAULTS },
|
||||
servers: Array.isArray(data.servers) ? data.servers : [],
|
||||
virtualHosts: Array.isArray(data.virtualHosts) ? data.virtualHosts : [],
|
||||
serviceTunnels: Array.isArray(data.serviceTunnels) ? data.serviceTunnels : [],
|
||||
sshConnections: Array.isArray(data.sshConnections) ? data.sshConnections : [],
|
||||
rdpConnections: Array.isArray(data.rdpConnections) ? data.rdpConnections : [],
|
||||
nextServerId: typeof data.nextServerId === 'number' ? data.nextServerId : 0,
|
||||
nextServiceTunnelId: typeof data.nextServiceTunnelId === 'number' ? data.nextServiceTunnelId : 0
|
||||
};
|
||||
// Write the new state.json immediately
|
||||
currentSettings = out.settings;
|
||||
sshConnectionsList = out.sshConnections;
|
||||
rdpConnectionsList = out.rdpConnections;
|
||||
nextServerId = out.nextServerId;
|
||||
nextServiceTunnelId = out.nextServiceTunnelId;
|
||||
saveStateSync();
|
||||
// Remove legacy file
|
||||
try { fs.unlinkSync(legacyFile); } catch (_) {}
|
||||
if (process.stderr) process.stderr.write('[holesail-manager] migration complete, legacy file removed\n');
|
||||
return out;
|
||||
} catch (e) {
|
||||
if (e && e.code !== 'ENOENT') {
|
||||
if (process.stderr) process.stderr.write('[holesail-manager] legacy persist load failed: ' + e.message + '\n');
|
||||
}
|
||||
}
|
||||
|
||||
// Neither file found — fresh start
|
||||
return buildDefaultState();
|
||||
}
|
||||
|
||||
function buildDefaultState() {
|
||||
return {
|
||||
settings: { ...SETTINGS_DEFAULTS },
|
||||
servers: [],
|
||||
virtualHosts: [],
|
||||
serviceTunnels: [],
|
||||
sshConnections: [],
|
||||
rdpConnections: [],
|
||||
nextServerId: 0,
|
||||
nextServiceTunnelId: 0
|
||||
};
|
||||
}
|
||||
|
||||
// Tunnel port allocator: each virtual host gets a unique port on 127.0.0.1
|
||||
// No loopback aliases needed — no password prompts.
|
||||
let nextTunnelPortIndex = 0;
|
||||
const tunnelPortFreeList = [];
|
||||
|
||||
function allocateTunnelPort() {
|
||||
if (tunnelPortFreeList.length > 0) return tunnelPortFreeList.pop();
|
||||
return TUNNEL_PORT_BASE + (nextTunnelPortIndex++);
|
||||
}
|
||||
|
||||
function releaseTunnelPort(port) {
|
||||
if (typeof port === 'number' && port >= TUNNEL_PORT_BASE) {
|
||||
tunnelPortFreeList.push(port);
|
||||
}
|
||||
}
|
||||
|
||||
function setEventEmitter(emit) {
|
||||
eventEmit = emit;
|
||||
}
|
||||
|
||||
function emit(event, payload) {
|
||||
if (eventEmit) eventEmit(event, payload);
|
||||
}
|
||||
|
||||
function generateServerId() {
|
||||
nextServerId += 1;
|
||||
return 'server_' + nextServerId;
|
||||
}
|
||||
|
||||
function useServerId(givenId) {
|
||||
if (givenId && typeof givenId === 'string' && givenId.startsWith('server_')) {
|
||||
const n = parseServerIdNum(givenId);
|
||||
if (n >= nextServerId) nextServerId = n + 1;
|
||||
return givenId;
|
||||
}
|
||||
return generateServerId();
|
||||
}
|
||||
|
||||
// ── Settings ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function getSettings() {
|
||||
return { ...currentSettings };
|
||||
}
|
||||
|
||||
function updateSettings(patch) {
|
||||
if (!patch || typeof patch !== 'object') return { requiresRestart: false };
|
||||
let requiresRestart = false;
|
||||
if (typeof patch.proxyPort === 'number' && patch.proxyPort > 0 && patch.proxyPort < 65536) {
|
||||
if (patch.proxyPort !== currentSettings.proxyPort) requiresRestart = true;
|
||||
currentSettings.proxyPort = patch.proxyPort;
|
||||
runtimeProxyPort = patch.proxyPort;
|
||||
}
|
||||
if (typeof patch.connectProxyPort === 'number' && patch.connectProxyPort > 0 && patch.connectProxyPort < 65536) {
|
||||
if (patch.connectProxyPort !== currentSettings.connectProxyPort) requiresRestart = true;
|
||||
currentSettings.connectProxyPort = patch.connectProxyPort;
|
||||
}
|
||||
if (typeof patch.readyTimeoutMs === 'number') currentSettings.readyTimeoutMs = patch.readyTimeoutMs;
|
||||
if (typeof patch.notifyOnDisconnect === 'boolean') currentSettings.notifyOnDisconnect = patch.notifyOnDisconnect;
|
||||
if (typeof patch.debug === 'boolean') currentSettings.debug = patch.debug;
|
||||
if (typeof patch.disableOnFileUrls === 'boolean') currentSettings.disableOnFileUrls = patch.disableOnFileUrls;
|
||||
if (typeof patch.backupRetention === 'number') currentSettings.backupRetention = patch.backupRetention;
|
||||
saveStateSync();
|
||||
return { requiresRestart };
|
||||
}
|
||||
|
||||
// ── SSH Connections ───────────────────────────────────────────────────────────
|
||||
|
||||
function getSshConnections() {
|
||||
return sshConnectionsList.slice();
|
||||
}
|
||||
|
||||
function setSshConnections(list) {
|
||||
if (!Array.isArray(list)) return;
|
||||
sshConnectionsList = list;
|
||||
saveStateSync();
|
||||
}
|
||||
|
||||
// ── RDP/VNC Connections ───────────────────────────────────────────────────────
|
||||
|
||||
function getRdpConnections() {
|
||||
return rdpConnectionsList.slice();
|
||||
}
|
||||
|
||||
function setRdpConnections(list) {
|
||||
if (!Array.isArray(list)) return;
|
||||
rdpConnectionsList = list;
|
||||
saveStateSync();
|
||||
}
|
||||
|
||||
// ── Tunnel ready helper ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Await hs.ready() with a timeout. If readyTimeoutMs is 0 or not set, waits
|
||||
* indefinitely (original behaviour). If set, rejects after that many ms so
|
||||
* the caller can mark the tunnel as error and move on.
|
||||
*/
|
||||
function readyWithTimeout(hs, label) {
|
||||
const ms = currentSettings.readyTimeoutMs;
|
||||
if (!ms || ms <= 0) return hs.ready();
|
||||
return new Promise((resolve, reject) => {
|
||||
const t = setTimeout(() => reject(new Error('Tunnel ready timeout after ' + ms + 'ms for ' + label)), ms);
|
||||
hs.ready().then(() => { clearTimeout(t); resolve(); }, (e) => { clearTimeout(t); reject(e); });
|
||||
});
|
||||
}
|
||||
|
||||
// ── Servers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
async function startServer(payload) {
|
||||
if (!Holesail) {
|
||||
return { ok: false, error: 'Holesail module not installed' };
|
||||
}
|
||||
const port = payload.port || 3000;
|
||||
const host = payload.host || '127.0.0.1';
|
||||
const secure = payload.secure !== false;
|
||||
const udp = payload.udp === true;
|
||||
const label = (payload.label || '').trim();
|
||||
const serverId = useServerId(payload.serverId);
|
||||
debugLog('startServer: serverId=', serverId, 'port=', port, 'host=', host, 'secure=', secure, 'udp=', udp, 'label=', label);
|
||||
if (servers.has(serverId)) {
|
||||
debugLog('startServer: serverId already in use');
|
||||
return { ok: false, error: 'Server id already in use' };
|
||||
}
|
||||
try {
|
||||
const hs = new Holesail({ server: true, port, host, secure, udp });
|
||||
await readyWithTimeout(hs, 'server:' + serverId);
|
||||
const url = hs.info.url;
|
||||
servers.set(serverId, {
|
||||
holesail: hs,
|
||||
port: hs.info.port,
|
||||
host: hs.info.host,
|
||||
secure,
|
||||
udp,
|
||||
label,
|
||||
url,
|
||||
createdAt: Date.now()
|
||||
});
|
||||
saveStateSync();
|
||||
debugLog('startServer: ok serverId=', serverId, 'url=', url);
|
||||
return {
|
||||
ok: true,
|
||||
serverId,
|
||||
url,
|
||||
port: hs.info.port,
|
||||
host: hs.info.host,
|
||||
secure,
|
||||
udp,
|
||||
label
|
||||
};
|
||||
} catch (e) {
|
||||
debugLog('startServer: error', e.message);
|
||||
return { ok: false, error: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
async function stopServer(payload) {
|
||||
const serverId = payload.serverId;
|
||||
debugLog('stopServer: serverId=', serverId);
|
||||
const entry = servers.get(serverId);
|
||||
if (!entry) {
|
||||
debugLog('stopServer: not found');
|
||||
return { ok: false, error: 'Server not found' };
|
||||
}
|
||||
try {
|
||||
await entry.holesail.close();
|
||||
} catch (_) {}
|
||||
servers.delete(serverId);
|
||||
saveStateSync();
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
function getServers() {
|
||||
const list = [];
|
||||
for (const [id, s] of servers) {
|
||||
list.push({
|
||||
id,
|
||||
serverId: id,
|
||||
port: s.port,
|
||||
host: s.host,
|
||||
url: s.url,
|
||||
secure: s.secure,
|
||||
udp: s.udp || false,
|
||||
label: s.label || '',
|
||||
createdAt: s.createdAt
|
||||
});
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
async function setVirtualHost(payload) {
|
||||
// Sanitize hostname: strip protocol, port, path so users can paste full URLs
|
||||
let hostname = (payload.hostname || payload.hostName || '').trim();
|
||||
hostname = hostname.replace(/^https?:\/\//i, '').replace(/[/:?#].*$/, '').toLowerCase().trim();
|
||||
const hsUrl = payload.hsUrl || payload.url;
|
||||
debugLog('setVirtualHost: hostname=', hostname, 'hsUrl=', hsUrl);
|
||||
if (!Holesail) {
|
||||
debugLog('setVirtualHost: Holesail not installed');
|
||||
return { ok: false, error: 'Holesail module not installed' };
|
||||
}
|
||||
if (!hostname || !hsUrl) {
|
||||
debugLog('setVirtualHost: missing hostname or hsUrl');
|
||||
return { ok: false, error: 'hostname and hsUrl required' };
|
||||
}
|
||||
const existing = virtualHosts.get(hostname);
|
||||
if (existing) {
|
||||
debugLog('setVirtualHost: replacing existing', hostname);
|
||||
if (existing.localPort) releaseTunnelPort(existing.localPort);
|
||||
if (existing.holesail) {
|
||||
try {
|
||||
await existing.holesail.close();
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
const localPort = allocateTunnelPort();
|
||||
const localHost = TUNNEL_HOST;
|
||||
try {
|
||||
const hs = new Holesail({ client: true, key: hsUrl, host: localHost, port: localPort });
|
||||
if (typeof hs.on === 'function') {
|
||||
hs.on('error', (err) => {
|
||||
if (process.stderr) process.stderr.write('[holesail-manager] tunnel error ' + hostname + ': ' + (err && err.message) + '\n');
|
||||
const v = virtualHosts.get(hostname);
|
||||
// Guard against stale closures: only mutate the entry that owns this instance
|
||||
if (v && v.holesail === hs) v.state = 'error';
|
||||
emit('tunnelError', { hostname, error: err && err.message });
|
||||
});
|
||||
hs.on('close', () => {
|
||||
const v = virtualHosts.get(hostname);
|
||||
if (v && v.holesail === hs) v.state = 'closed';
|
||||
emit('tunnelClosed', { hostname });
|
||||
});
|
||||
}
|
||||
await readyWithTimeout(hs, 'vhost:' + hostname);
|
||||
virtualHosts.set(hostname, {
|
||||
hsUrl,
|
||||
holesail: hs,
|
||||
localHost,
|
||||
localPort,
|
||||
state: 'ready',
|
||||
createdAt: (existing && existing.createdAt) || Date.now()
|
||||
});
|
||||
emit('tunnelReady', { hostname, hsUrl, localHost, localPort });
|
||||
saveStateSync();
|
||||
debugLog('setVirtualHost: ok hostname=', hostname, 'localHost=', localHost, 'localPort=', localPort);
|
||||
return { ok: true, hostname, localHost, localPort, state: 'ready' };
|
||||
} catch (e) {
|
||||
debugLog('setVirtualHost: error hostname=', hostname, 'error=', e.message);
|
||||
releaseTunnelPort(localPort);
|
||||
// Keep the entry in the map with state 'error' so the dashboard can show it
|
||||
// and the user can reconnect. getLocalBackend() returns null for localPort==null
|
||||
// so the proxy will correctly return 502 until the tunnel is reconnected.
|
||||
const existingCreatedAt = existing ? existing.createdAt : undefined;
|
||||
virtualHosts.set(hostname, {
|
||||
hsUrl,
|
||||
holesail: null,
|
||||
localHost: null,
|
||||
localPort: null,
|
||||
state: 'error',
|
||||
createdAt: existingCreatedAt || Date.now()
|
||||
});
|
||||
emit('tunnelError', { hostname, error: e.message });
|
||||
return { ok: false, error: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
async function removeVirtualHost(payload) {
|
||||
const hostname = payload.hostname || payload.hostName;
|
||||
debugLog('removeVirtualHost: hostname=', hostname);
|
||||
const entry = virtualHosts.get(hostname);
|
||||
if (!entry) {
|
||||
debugLog('removeVirtualHost: not found');
|
||||
return { ok: false, error: 'Virtual host not found' };
|
||||
}
|
||||
if (entry.localPort) releaseTunnelPort(entry.localPort);
|
||||
if (entry.holesail) {
|
||||
try {
|
||||
await entry.holesail.close();
|
||||
} catch (_) {}
|
||||
}
|
||||
virtualHosts.delete(hostname);
|
||||
saveStateSync();
|
||||
emit('tunnelClosed', { hostname });
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
function getVirtualHosts() {
|
||||
const list = [];
|
||||
for (const [hostname, v] of virtualHosts) {
|
||||
list.push({
|
||||
hostname,
|
||||
hsUrl: v.hsUrl,
|
||||
localHost: v.localHost ?? null,
|
||||
localPort: v.localPort ?? null,
|
||||
state: v.state || 'unknown',
|
||||
createdAt: v.createdAt
|
||||
});
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
let runtimeProxyPort = DEFAULT_PROXY_PORT;
|
||||
|
||||
function getProxyPort() {
|
||||
return runtimeProxyPort;
|
||||
}
|
||||
|
||||
function setProxyPort(port) {
|
||||
if (typeof port === 'number' && port > 0 && port < 65536) {
|
||||
runtimeProxyPort = port;
|
||||
debugLog('setProxyPort:', port);
|
||||
}
|
||||
}
|
||||
|
||||
async function lookup(payload) {
|
||||
if (!Holesail) {
|
||||
return { ok: false, error: 'Holesail module not installed' };
|
||||
}
|
||||
const url = payload.url || payload.hsUrl;
|
||||
if (!url) {
|
||||
return { ok: false, error: 'url required' };
|
||||
}
|
||||
try {
|
||||
const result = await Holesail.lookup(url);
|
||||
return { ok: true, ...result };
|
||||
} catch (e) {
|
||||
return { ok: false, error: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
function getLocalPortForHostname(hostname) {
|
||||
const v = virtualHosts.get(hostname);
|
||||
return v && v.localPort != null ? v.localPort : null;
|
||||
}
|
||||
|
||||
/** Returns { host, port } for the proxy to forward to, or null. */
|
||||
function getLocalBackend(hostname) {
|
||||
const v = virtualHosts.get(hostname);
|
||||
const out = (!v || v.localPort == null) ? null : { host: v.localHost ?? '127.0.0.1', port: v.localPort };
|
||||
debugLog('getLocalBackend: hostname=', hostname, 'result=', out, 'virtualHostsKeys=', Array.from(virtualHosts.keys()));
|
||||
return out;
|
||||
}
|
||||
|
||||
function getVirtualHostMap() {
|
||||
const m = new Map();
|
||||
for (const [hostname, v] of virtualHosts) {
|
||||
if (v.localPort != null) m.set(hostname, v.localPort);
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load persisted state (does not start tunnels). Caller should startServer/setVirtualHost for each.
|
||||
* Also populates currentSettings and sshConnectionsList from the loaded state.
|
||||
*/
|
||||
function restorePersistedState() {
|
||||
const loaded = loadState();
|
||||
if (loaded.nextServerId > nextServerId) nextServerId = loaded.nextServerId;
|
||||
if (loaded.nextServiceTunnelId > nextServiceTunnelId) nextServiceTunnelId = loaded.nextServiceTunnelId;
|
||||
currentSettings = { ...SETTINGS_DEFAULTS, ...loaded.settings };
|
||||
sshConnectionsList = Array.isArray(loaded.sshConnections) ? loaded.sshConnections : [];
|
||||
rdpConnectionsList = Array.isArray(loaded.rdpConnections) ? loaded.rdpConnections : [];
|
||||
// Apply persisted proxy port to runtime
|
||||
if (currentSettings.proxyPort) runtimeProxyPort = currentSettings.proxyPort;
|
||||
return {
|
||||
settings: currentSettings,
|
||||
servers: loaded.servers,
|
||||
virtualHosts: loaded.virtualHosts,
|
||||
serviceTunnels: loaded.serviceTunnels || [],
|
||||
sshConnections: sshConnectionsList,
|
||||
rdpConnections: rdpConnectionsList
|
||||
};
|
||||
}
|
||||
|
||||
async function cleanup() {
|
||||
for (const [, s] of servers) {
|
||||
try {
|
||||
await s.holesail.close();
|
||||
} catch (_) {}
|
||||
}
|
||||
servers.clear();
|
||||
for (const [, v] of virtualHosts) {
|
||||
if (v.holesail) {
|
||||
try {
|
||||
await v.holesail.close();
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
virtualHosts.clear();
|
||||
for (const [, t] of serviceTunnels) {
|
||||
if (t.holesail) {
|
||||
try { await t.holesail.close(); } catch (_) {}
|
||||
}
|
||||
}
|
||||
serviceTunnels.clear();
|
||||
}
|
||||
|
||||
// ── Service Tunnels ──────────────────────────────────────────────────────────
|
||||
// Client tunnels that forward a remote Holesail peer to a user-chosen local
|
||||
// port. Unlike virtual hosts, these are not HTTP-proxied — they bind directly
|
||||
// to 127.0.0.1:<localPort> so any TCP client (game client, DB tool, etc.) can
|
||||
// connect without going through the HTTPS proxy.
|
||||
|
||||
function generateServiceTunnelId() {
|
||||
nextServiceTunnelId += 1;
|
||||
return 'svc-' + nextServiceTunnelId;
|
||||
}
|
||||
|
||||
async function startServiceTunnel(payload) {
|
||||
if (!Holesail) return { ok: false, error: 'Holesail module not installed' };
|
||||
const { label, hsUrl, localPort } = payload;
|
||||
if (!label) return { ok: false, error: 'label required' };
|
||||
if (!hsUrl || !hsUrl.startsWith('hs://')) return { ok: false, error: 'valid hs:// key required' };
|
||||
if (!localPort || localPort < 1 || localPort > 65535) return { ok: false, error: 'localPort must be 1–65535' };
|
||||
|
||||
const tunnelId = payload.tunnelId || generateServiceTunnelId();
|
||||
debugLog('startServiceTunnel: id=', tunnelId, 'label=', label, 'localPort=', localPort);
|
||||
|
||||
const existing = serviceTunnels.get(tunnelId);
|
||||
if (existing && existing.holesail) {
|
||||
try { await existing.holesail.close(); } catch (_) {}
|
||||
}
|
||||
|
||||
try {
|
||||
const hs = new Holesail({ client: true, key: hsUrl, host: TUNNEL_HOST, port: localPort });
|
||||
if (typeof hs.on === 'function') {
|
||||
hs.on('error', (err) => {
|
||||
if (process.stderr) process.stderr.write('[holesail-manager] service tunnel error ' + tunnelId + ': ' + (err && err.message) + '\n');
|
||||
const t = serviceTunnels.get(tunnelId);
|
||||
// Guard against stale closures: only mutate the entry that owns this instance
|
||||
if (t && t.holesail === hs) t.state = 'error';
|
||||
emit('serviceTunnelError', { tunnelId, label, error: err && err.message });
|
||||
});
|
||||
hs.on('close', () => {
|
||||
const t = serviceTunnels.get(tunnelId);
|
||||
if (t && t.holesail === hs) t.state = 'closed';
|
||||
emit('serviceTunnelClosed', { tunnelId, label });
|
||||
});
|
||||
}
|
||||
await readyWithTimeout(hs, 'svc:' + tunnelId);
|
||||
serviceTunnels.set(tunnelId, { label, hsUrl, localPort, holesail: hs, state: 'ready', createdAt: Date.now() });
|
||||
emit('serviceTunnelReady', { tunnelId, label, localPort });
|
||||
saveStateSync();
|
||||
debugLog('startServiceTunnel: ok id=', tunnelId, 'localPort=', localPort);
|
||||
return { ok: true, tunnelId, label, hsUrl, localPort, state: 'ready' };
|
||||
} catch (e) {
|
||||
debugLog('startServiceTunnel: error', e.message);
|
||||
serviceTunnels.set(tunnelId, { label, hsUrl, localPort, state: 'error', error: e.message, createdAt: Date.now() });
|
||||
saveStateSync();
|
||||
emit('serviceTunnelError', { tunnelId, label, error: e.message });
|
||||
return { ok: false, error: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
async function stopServiceTunnel(payload) {
|
||||
const { tunnelId } = payload;
|
||||
debugLog('stopServiceTunnel: id=', tunnelId);
|
||||
const entry = serviceTunnels.get(tunnelId);
|
||||
if (!entry) return { ok: false, error: 'Service tunnel not found' };
|
||||
if (entry.holesail) {
|
||||
try { await entry.holesail.close(); } catch (_) {}
|
||||
}
|
||||
serviceTunnels.delete(tunnelId);
|
||||
saveStateSync();
|
||||
emit('serviceTunnelClosed', { tunnelId, label: entry.label });
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
function getServiceTunnels() {
|
||||
const list = [];
|
||||
for (const [id, t] of serviceTunnels) {
|
||||
list.push({ id, label: t.label, hsUrl: t.hsUrl, localPort: t.localPort, state: t.state || 'unknown', createdAt: t.createdAt });
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
setEventEmitter,
|
||||
setStoragePath,
|
||||
getSettings,
|
||||
updateSettings,
|
||||
getSshConnections,
|
||||
setSshConnections,
|
||||
getRdpConnections,
|
||||
setRdpConnections,
|
||||
startServer,
|
||||
stopServer,
|
||||
getServers,
|
||||
setVirtualHost,
|
||||
removeVirtualHost,
|
||||
getVirtualHosts,
|
||||
getProxyPort,
|
||||
setProxyPort,
|
||||
lookup,
|
||||
getLocalPortForHostname,
|
||||
getLocalBackend,
|
||||
getVirtualHostMap,
|
||||
startServiceTunnel,
|
||||
stopServiceTunnel,
|
||||
getServiceTunnels,
|
||||
restorePersistedState,
|
||||
cleanup
|
||||
};
|
||||
module.exports = require('./holesail-manager/index.js');
|
||||
|
||||
Reference in New Issue
Block a user