first commit
CI / Build & Test (push) Has been cancelled

This commit is contained in:
Raven Scott
2026-02-27 18:13:59 -05:00
commit d58a0b6e2d
64 changed files with 30550 additions and 0 deletions
+685
View File
@@ -0,0 +1,685 @@
/**
* 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.
*/
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 });
}
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);
if (process.stderr) process.stderr.write('[holesail-manager] state saved to ' + file + '\n');
} 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: [],
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;
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;
if (typeof patch.proxyPort === 'number' && patch.proxyPort > 0 && patch.proxyPort < 65536) {
currentSettings.proxyPort = patch.proxyPort;
runtimeProxyPort = patch.proxyPort;
}
if (typeof patch.connectProxyPort === 'number' && patch.connectProxyPort > 0 && patch.connectProxyPort < 65536) {
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;
saveStateSync();
}
// ── 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 serverId = useServerId(payload.serverId);
debugLog('startServer: serverId=', serverId, 'port=', port, 'host=', host, 'secure=', secure, 'udp=', udp);
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,
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
};
} 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,
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);
if (v) v.state = 'error';
emit('tunnelError', { hostname, error: err && err.message });
});
hs.on('close', () => {
const v = virtualHosts.get(hostname);
if (v) v.state = 'closed';
emit('tunnelClosed', { hostname });
});
}
await readyWithTimeout(hs, 'vhost:' + hostname);
virtualHosts.set(hostname, {
hsUrl,
holesail: hs,
localHost,
localPort,
state: 'ready',
createdAt: (existing && existing.createdAt) || Date.now()
});
emit('tunnelReady', { hostname, hsUrl, localHost, localPort });
saveStateSync();
debugLog('setVirtualHost: ok hostname=', hostname, 'localHost=', localHost, 'localPort=', localPort);
return { ok: true, hostname, localHost, localPort, state: 'ready' };
} catch (e) {
debugLog('setVirtualHost: error hostname=', hostname, 'error=', e.message);
releaseTunnelPort(localPort);
// Keep the entry in the map with state 'error' so the dashboard can show it
// and the user can reconnect. getLocalBackend() returns null for localPort==null
// so the proxy will correctly return 502 until the tunnel is reconnected.
const existingCreatedAt = existing ? existing.createdAt : undefined;
virtualHosts.set(hostname, {
hsUrl,
holesail: null,
localHost: null,
localPort: null,
state: 'error',
createdAt: existingCreatedAt || Date.now()
});
emit('tunnelError', { hostname, error: e.message });
return { ok: false, error: e.message };
}
}
async function removeVirtualHost(payload) {
const hostname = payload.hostname || payload.hostName;
debugLog('removeVirtualHost: hostname=', hostname);
const entry = virtualHosts.get(hostname);
if (!entry) {
debugLog('removeVirtualHost: not found');
return { ok: false, error: 'Virtual host not found' };
}
if (entry.localPort) releaseTunnelPort(entry.localPort);
if (entry.holesail) {
try {
await entry.holesail.close();
} catch (_) {}
}
virtualHosts.delete(hostname);
saveStateSync();
emit('tunnelClosed', { hostname });
return { ok: true };
}
function getVirtualHosts() {
const list = [];
for (const [hostname, v] of virtualHosts) {
list.push({
hostname,
hsUrl: v.hsUrl,
localHost: v.localHost ?? null,
localPort: v.localPort ?? null,
state: v.state || 'unknown',
createdAt: v.createdAt
});
}
return list;
}
let runtimeProxyPort = DEFAULT_PROXY_PORT;
function getProxyPort() {
return runtimeProxyPort;
}
function setProxyPort(port) {
if (typeof port === 'number' && port > 0 && port < 65536) {
runtimeProxyPort = port;
debugLog('setProxyPort:', port);
}
}
async function lookup(payload) {
if (!Holesail) {
return { ok: false, error: 'Holesail module not installed' };
}
const url = payload.url || payload.hsUrl;
if (!url) {
return { ok: false, error: 'url required' };
}
try {
const result = await Holesail.lookup(url);
return { ok: true, ...result };
} catch (e) {
return { ok: false, error: e.message };
}
}
function getLocalPortForHostname(hostname) {
const v = virtualHosts.get(hostname);
return v && v.localPort != null ? v.localPort : null;
}
/** Returns { host, port } for the proxy to forward to, or null. */
function getLocalBackend(hostname) {
const v = virtualHosts.get(hostname);
const out = (!v || v.localPort == null) ? null : { host: v.localHost ?? '127.0.0.1', port: v.localPort };
debugLog('getLocalBackend: hostname=', hostname, 'result=', out, 'virtualHostsKeys=', Array.from(virtualHosts.keys()));
return out;
}
function getVirtualHostMap() {
const m = new Map();
for (const [hostname, v] of virtualHosts) {
if (v.localPort != null) m.set(hostname, v.localPort);
}
return m;
}
/**
* Load persisted state (does not start tunnels). Caller should startServer/setVirtualHost for each.
* Also populates currentSettings and sshConnectionsList from the loaded state.
*/
function restorePersistedState() {
const loaded = loadState();
if (loaded.nextServerId > nextServerId) nextServerId = loaded.nextServerId;
if (loaded.nextServiceTunnelId > nextServiceTunnelId) nextServiceTunnelId = loaded.nextServiceTunnelId;
currentSettings = { ...SETTINGS_DEFAULTS, ...loaded.settings };
sshConnectionsList = Array.isArray(loaded.sshConnections) ? loaded.sshConnections : [];
rdpConnectionsList = Array.isArray(loaded.rdpConnections) ? loaded.rdpConnections : [];
// Apply persisted proxy port to runtime
if (currentSettings.proxyPort) runtimeProxyPort = currentSettings.proxyPort;
return {
settings: currentSettings,
servers: loaded.servers,
virtualHosts: loaded.virtualHosts,
serviceTunnels: loaded.serviceTunnels || [],
sshConnections: sshConnectionsList,
rdpConnections: rdpConnectionsList
};
}
async function cleanup() {
for (const [, s] of servers) {
try {
await s.holesail.close();
} catch (_) {}
}
servers.clear();
for (const [, v] of virtualHosts) {
if (v.holesail) {
try {
await v.holesail.close();
} catch (_) {}
}
}
virtualHosts.clear();
for (const [, t] of serviceTunnels) {
if (t.holesail) {
try { await t.holesail.close(); } catch (_) {}
}
}
serviceTunnels.clear();
}
// ── Service Tunnels ──────────────────────────────────────────────────────────
// Client tunnels that forward a remote Holesail peer to a user-chosen local
// port. Unlike virtual hosts, these are not HTTP-proxied — they bind directly
// to 127.0.0.1:<localPort> so any TCP client (game client, DB tool, etc.) can
// connect without going through the HTTPS proxy.
function generateServiceTunnelId() {
nextServiceTunnelId += 1;
return 'svc-' + nextServiceTunnelId;
}
async function startServiceTunnel(payload) {
if (!Holesail) return { ok: false, error: 'Holesail module not installed' };
const { label, hsUrl, localPort } = payload;
if (!label) return { ok: false, error: 'label required' };
if (!hsUrl || !hsUrl.startsWith('hs://')) return { ok: false, error: 'valid hs:// key required' };
if (!localPort || localPort < 1 || localPort > 65535) return { ok: false, error: 'localPort must be 165535' };
const tunnelId = payload.tunnelId || generateServiceTunnelId();
debugLog('startServiceTunnel: id=', tunnelId, 'label=', label, 'localPort=', localPort);
const existing = serviceTunnels.get(tunnelId);
if (existing && existing.holesail) {
try { await existing.holesail.close(); } catch (_) {}
}
try {
const hs = new Holesail({ client: true, key: hsUrl, host: TUNNEL_HOST, port: localPort });
if (typeof hs.on === 'function') {
hs.on('error', (err) => {
if (process.stderr) process.stderr.write('[holesail-manager] service tunnel error ' + tunnelId + ': ' + (err && err.message) + '\n');
const t = serviceTunnels.get(tunnelId);
if (t) t.state = 'error';
emit('serviceTunnelError', { tunnelId, label, error: err && err.message });
});
hs.on('close', () => {
const t = serviceTunnels.get(tunnelId);
if (t) 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() });
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
};