feat: implement 13 features — auto-reconnect, notifications, bulk actions, latency, traffic, theme, export/import, scheduled backups, peer lookup, SSH auto-reconnect, log filters, keyboard shortcut, and readyTimeout default
CI / Build & Test (push) Successful in 2m44s

- Change readyTimeoutMs default from 0 to 30000 in both state files
- Register Alt+Shift+H keyboard shortcut via manifest _execute_action command
- Add severity filter buttons (All/Info/Warn/Error) and Download .txt to Logs page; background logs.js now tags entries with proper level field
- Fire browser notifications on tunnelError events (notifyOnTunnelError setting)
- Add exponential-backoff auto-reconnect for virtual hosts and service tunnels (tunnelAutoReconnect setting, 5s–120s backoff)
- Add backupIntervalHours setting and scheduled auto-backup timer in message-router.js
- Add TCP connect latency badges to Virtual Hosts and Service Tunnels tables via new pingTunnel native message
- Add bytesIn/bytesOut/requests counters to https-proxy.js; expose in Overview status bar via getState
- Add Export/Import connection configs (JSON, no CA key) in Settings
- Add autoReconnect flag and exponential-backoff reconnect to SSH connection cards
- Add light theme CSS variables and theme toggle in Settings (persisted to localStorage)
- Add checkbox column and bulk Stop/Remove actions to Virtual Hosts, Service Tunnels, and Servers tables
- Add Peer Lookup UI card on Overview page using existing lookup message handler
This commit is contained in:
Raven Scott
2026-02-28 23:51:14 -05:00
parent 31f30b2974
commit 849897f324
24 changed files with 726 additions and 47 deletions
+7 -1
View File
@@ -11,7 +11,13 @@ function log(...args) {
console.log('[Holesail-bg]', ...args); console.log('[Holesail-bg]', ...args);
const timestamp = Date.now(); const timestamp = Date.now();
const message = args.map(a => typeof a === 'object' ? JSON.stringify(a) : String(a)).join(' '); const message = args.map(a => typeof a === 'object' ? JSON.stringify(a) : String(a)).join(' ');
logs.push({ timestamp, message, level: 'info' }); const lower = message.toLowerCase();
const level = (lower.includes('error') || lower.includes('fail') || lower.includes('err:'))
? 'error'
: (lower.includes('warn') || lower.includes('warning'))
? 'warn'
: 'info';
logs.push({ timestamp, message, level });
if (logs.length > MAX_LOGS) logs.shift(); if (logs.length > MAX_LOGS) logs.shift();
broadcastLogs(); broadcastLogs();
} }
+4
View File
@@ -104,6 +104,9 @@ browser.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (payload.settings && typeof payload.settings.notifyOnDisconnect === 'boolean') { if (payload.settings && typeof payload.settings.notifyOnDisconnect === 'boolean') {
notifyOnDisconnect = payload.settings.notifyOnDisconnect; notifyOnDisconnect = payload.settings.notifyOnDisconnect;
} }
if (payload.settings && typeof payload.settings.notifyOnTunnelError === 'boolean') {
notifyOnTunnelError = payload.settings.notifyOnTunnelError;
}
if (!pacConfirmedActive) { if (!pacConfirmedActive) {
log('getState: PAC not confirmed active, re-applying'); log('getState: PAC not confirmed active, re-applying');
applyPAC(getActiveTlds(extensionState.virtualHosts)); applyPAC(getActiveTlds(extensionState.virtualHosts));
@@ -121,6 +124,7 @@ browser.runtime.onMessage.addListener((message, sender, sendResponse) => {
settings: payload.settings || {}, settings: payload.settings || {},
sshConnections: payload.sshConnections || [], sshConnections: payload.sshConnections || [],
rdpConnections: payload.rdpConnections || [], rdpConnections: payload.rdpConnections || [],
trafficStats: payload.trafficStats || null,
stats: { ...extensionState.stats, ...payload.stats } stats: { ...extensionState.stats, ...payload.stats }
} }
}); });
+13
View File
@@ -94,6 +94,9 @@ function connect() {
if (payload.settings && typeof payload.settings.notifyOnDisconnect === 'boolean') { if (payload.settings && typeof payload.settings.notifyOnDisconnect === 'boolean') {
notifyOnDisconnect = payload.settings.notifyOnDisconnect; notifyOnDisconnect = payload.settings.notifyOnDisconnect;
} }
if (payload.settings && typeof payload.settings.notifyOnTunnelError === 'boolean') {
notifyOnTunnelError = payload.settings.notifyOnTunnelError;
}
applyPAC(getActiveTlds(extensionState.virtualHosts)); applyPAC(getActiveTlds(extensionState.virtualHosts));
if (extensionState.connectProxyPort == null) retryGetStateForConnectProxy(2); if (extensionState.connectProxyPort == null) retryGetStateForConnectProxy(2);
} else { } else {
@@ -177,6 +180,16 @@ function connect() {
for (const tabId of subscribedTabs) { for (const tabId of subscribedTabs) {
browser.tabs.sendMessage(tabId, { type: 'holesail-event', payload: msg }).catch(() => {}); browser.tabs.sendMessage(tabId, { type: 'holesail-event', payload: msg }).catch(() => {});
} }
if (msg.event === 'tunnelError' && notifyOnTunnelError && browser.notifications) {
const label = p.hostname || p.label || p.swarmId || 'tunnel';
const errMsg = p.error || p.message || 'Tunnel error';
browser.notifications.create('holesail-tunnel-error-' + Date.now(), {
type: 'basic',
title: 'Holesail — Tunnel Error',
message: label + ': ' + errMsg,
iconUrl: browser.runtime.getURL('icons/48.png'),
}).catch(() => {});
}
} }
} }
}); });
+1
View File
@@ -13,6 +13,7 @@ const swarmRefCount = new Map();
const activeConnections = new Map(); const activeConnections = new Map();
let notifyOnDisconnect = false; let notifyOnDisconnect = false;
let notifyOnTunnelError = true;
// True once proxy.settings.get confirms mode=pac_script controlled_by_this_extension // True once proxy.settings.get confirms mode=pac_script controlled_by_this_extension
let pacConfirmedActive = false; let pacConfirmedActive = false;
+5 -2
View File
@@ -4,11 +4,14 @@
const SETTINGS_DEFAULTS = { const SETTINGS_DEFAULTS = {
proxyPort: 8443, proxyPort: 8443,
connectProxyPort: 8442, connectProxyPort: 8442,
readyTimeoutMs: 0, readyTimeoutMs: 30000,
notifyOnDisconnect: true, notifyOnDisconnect: true,
notifyOnTunnelError: true,
debug: false, debug: false,
disableOnFileUrls: false, disableOnFileUrls: false,
backupRetention: 5 backupRetention: 5,
backupIntervalHours: 0,
tunnelAutoReconnect: false
}; };
let currentState = null; let currentState = null;
+23
View File
@@ -28,6 +28,29 @@
--transition: 0.15s ease; --transition: 0.15s ease;
} }
[data-theme="light"] {
--bg: #f4f4f5;
--surface: #ffffff;
--card: #ffffff;
--elevated: #f9f9fb;
--border: #e4e4e7;
--border2: #d4d4d8;
--text: #18181b;
--text2: #3f3f46;
--text3: #71717a;
--text4: #a1a1aa;
--cyan: #0891b2;
--cyan-dim: rgba(8,145,178,.10);
--cyan-mid: rgba(8,145,178,.20);
--green: #16a34a;
--green-dim: rgba(22,163,74,.10);
--amber: #d97706;
--amber-dim: rgba(217,119,6,.10);
--red: #e11d48;
--red-dim: rgba(225,29,72,.10);
--red-mid: rgba(225,29,72,.20);
}
html, body { html, body {
height: 100%; height: 100%;
background: var(--bg); background: var(--bg);
+122 -6
View File
@@ -162,6 +162,12 @@
<span style="font-size:12.5px;font-weight:500;color:var(--text2);">CONNECT Proxy</span> <span style="font-size:12.5px;font-weight:500;color:var(--text2);">CONNECT Proxy</span>
<span id="ovConnectLabel" style="font-size:11.5px;color:var(--text3);"></span> <span id="ovConnectLabel" style="font-size:11.5px;color:var(--text3);"></span>
</div> </div>
<div style="width:1px;height:18px;background:var(--border);flex-shrink:0;"></div>
<div style="display:flex;align-items:center;gap:7px;flex:1;min-width:180px;">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:13px;height:13px;color:var(--text4);flex-shrink:0;"><polyline points="16,3 21,3 21,8"/><line x1="4" y1="20" x2="21" y2="3"/><polyline points="21,16 21,21 16,21"/><line x1="15" y1="15" x2="21" y2="21"/></svg>
<span style="font-size:12.5px;font-weight:500;color:var(--text2);">Traffic</span>
<span id="ovTrafficLabel" style="font-size:11.5px;color:var(--text3);"></span>
</div>
</div> </div>
<!-- Stat cards — 6 across --> <!-- Stat cards — 6 across -->
@@ -354,11 +360,29 @@
</button> </button>
</div> </div>
</div> </div>
<!-- Peer Lookup -->
<div class="card" style="flex-shrink:0;">
<div class="card-header"><div class="card-title">Peer Lookup</div><div class="card-subtitle">Resolve an hs:// key to check if the peer is reachable on the DHT</div></div>
<div class="card-body" style="display:flex;gap:8px;align-items:center;padding-top:10px;padding-bottom:10px;flex-wrap:wrap;">
<input type="text" id="peerLookupInput" class="input mono" placeholder="hs://s000…" autocomplete="off" spellcheck="false" style="flex:1;min-width:220px;">
<button class="btn btn-primary btn-sm" id="btnPeerLookup">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
Lookup
</button>
</div>
<div id="peerLookupResult" style="padding:0 16px 12px;font-size:12.5px;display:none;"></div>
</div>
</div> </div>
<!-- ── Virtual Hosts page ──────────────────────── --> <!-- ── Virtual Hosts page ──────────────────────── -->
<div class="page" id="page-connections"> <div class="page" id="page-connections">
<div style="margin-bottom:16px;display:flex;justify-content:flex-end;"> <div style="margin-bottom:16px;display:flex;justify-content:space-between;align-items:center;gap:8px;flex-wrap:wrap;">
<div id="vhostBulkBar" style="display:none;align-items:center;gap:8px;">
<span id="vhostBulkCount" style="font-size:12.5px;color:var(--text2);"></span>
<button class="btn btn-danger btn-sm" id="btnVhostBulkRemove">Remove Selected</button>
</div>
<div style="flex:1;"></div>
<button class="btn btn-primary" id="addVhostBtn"> <button class="btn btn-primary" id="addVhostBtn">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
Add Host Add Host
@@ -369,6 +393,7 @@
<table class="table"> <table class="table">
<thead> <thead>
<tr> <tr>
<th style="width:32px;"><input type="checkbox" id="vhostSelectAll" title="Select all"></th>
<th>Hostname</th> <th>Hostname</th>
<th>hs:// URL</th> <th>hs:// URL</th>
<th>Backend</th> <th>Backend</th>
@@ -377,7 +402,7 @@
</tr> </tr>
</thead> </thead>
<tbody id="connectionsTable"> <tbody id="connectionsTable">
<tr><td colspan="5" class="empty">No virtual hosts. Click "Add Host" to get started.</td></tr> <tr><td colspan="6" class="empty">No virtual hosts. Click "Add Host" to get started.</td></tr>
</tbody> </tbody>
</table> </table>
</div> </div>
@@ -386,7 +411,12 @@
<!-- ── Servers page ────────────────────────────── --> <!-- ── Servers page ────────────────────────────── -->
<div class="page" id="page-swarms"> <div class="page" id="page-swarms">
<div style="margin-bottom:16px;display:flex;justify-content:flex-end;"> <div style="margin-bottom:16px;display:flex;justify-content:space-between;align-items:center;gap:8px;flex-wrap:wrap;">
<div id="serverBulkBar" style="display:none;align-items:center;gap:8px;">
<span id="serverBulkCount" style="font-size:12.5px;color:var(--text2);"></span>
<button class="btn btn-danger btn-sm" id="btnServerBulkStop">Stop Selected</button>
</div>
<div style="flex:1;"></div>
<button class="btn btn-primary" id="startServerBtn"> <button class="btn btn-primary" id="startServerBtn">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
New Server New Server
@@ -397,6 +427,7 @@
<table class="table"> <table class="table">
<thead> <thead>
<tr> <tr>
<th style="width:32px;"><input type="checkbox" id="serverSelectAll" title="Select all"></th>
<th>ID</th> <th>ID</th>
<th>Port</th> <th>Port</th>
<th>hs:// URL</th> <th>hs:// URL</th>
@@ -405,7 +436,7 @@
</tr> </tr>
</thead> </thead>
<tbody id="swarmsTable"> <tbody id="swarmsTable">
<tr><td colspan="5" class="empty">No server tunnels. Click "New Server" to start one.</td></tr> <tr><td colspan="6" class="empty">No server tunnels. Click "New Server" to start one.</td></tr>
</tbody> </tbody>
</table> </table>
</div> </div>
@@ -525,6 +556,17 @@
<div class="page" id="page-settings"> <div class="page" id="page-settings">
<div class="card"> <div class="card">
<div class="card-body"> <div class="card-body">
<div class="section-heading">Appearance</div>
<div class="setting-row">
<div class="setting-info">
<div class="setting-label">Light Theme</div>
<div class="setting-desc">Switch between dark (default) and light color scheme</div>
</div>
<div class="setting-control">
<div class="toggle" id="toggleTheme"></div>
</div>
</div>
<div class="section-heading">Notifications</div> <div class="section-heading">Notifications</div>
<div class="setting-row"> <div class="setting-row">
<div class="setting-info"> <div class="setting-info">
@@ -535,6 +577,24 @@
<div class="toggle" id="toggleNotify" data-setting="notifyOnDisconnect"></div> <div class="toggle" id="toggleNotify" data-setting="notifyOnDisconnect"></div>
</div> </div>
</div> </div>
<div class="setting-row">
<div class="setting-info">
<div class="setting-label">Tunnel Error Alert</div>
<div class="setting-desc">Show a notification when a virtual host or service tunnel enters an error state</div>
</div>
<div class="setting-control">
<div class="toggle" id="toggleNotifyTunnelError" data-setting="notifyOnTunnelError"></div>
</div>
</div>
<div class="setting-row">
<div class="setting-info">
<div class="setting-label">Tunnel Auto-Reconnect</div>
<div class="setting-desc">Automatically reconnect virtual hosts and service tunnels on error or disconnect (exponential backoff)</div>
</div>
<div class="setting-control">
<div class="toggle" id="toggleTunnelAutoReconnect" data-setting="tunnelAutoReconnect"></div>
</div>
</div>
<div class="setting-row"> <div class="setting-row">
<div class="setting-info"> <div class="setting-info">
<div class="setting-label">Debug Mode</div> <div class="setting-label">Debug Mode</div>
@@ -584,6 +644,42 @@
<input type="number" id="backupRetention" min="1" max="100" class="input" style="width:90px;" placeholder="5"> <input type="number" id="backupRetention" min="1" max="100" class="input" style="width:90px;" placeholder="5">
</div> </div>
</div> </div>
<div class="setting-row">
<div class="setting-info">
<div class="setting-label">Auto-Backup Interval (hours)</div>
<div class="setting-desc">Automatically create a backup every N hours. Set to 0 to disable.</div>
</div>
<div class="setting-control">
<input type="number" id="backupIntervalHours" min="0" max="720" class="input" style="width:90px;" placeholder="0">
</div>
</div>
<div class="section-heading">Import / Export</div>
<div class="setting-row" style="align-items:flex-start;">
<div class="setting-info">
<div class="setting-label">Export Configuration</div>
<div class="setting-desc">Download virtual hosts, service tunnels, SSH &amp; RDP connections as JSON. CA private key is never included.</div>
</div>
<div class="setting-control">
<button class="btn btn-secondary btn-sm" id="btnExportConfig">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7,10 12,15 17,10"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
Export
</button>
</div>
</div>
<div class="setting-row" style="align-items:flex-start;">
<div class="setting-info">
<div class="setting-label">Import Configuration</div>
<div class="setting-desc">Merge connections from a previously exported JSON file. Existing entries are not overwritten.</div>
</div>
<div class="setting-control">
<label class="btn btn-secondary btn-sm" style="cursor:pointer;">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17,8 12,3 7,8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
Import
<input type="file" id="importConfigFile" accept=".json" style="display:none;">
</label>
</div>
</div>
<div style="margin-top:20px;display:flex;gap:8px;"> <div style="margin-top:20px;display:flex;gap:8px;">
<button class="btn btn-primary" id="btnSaveSettings">Save Settings</button> <button class="btn btn-primary" id="btnSaveSettings">Save Settings</button>
@@ -596,7 +692,12 @@
<!-- ── Service Tunnels page ──────────────────────── --> <!-- ── Service Tunnels page ──────────────────────── -->
<div class="page" id="page-service-tunnels"> <div class="page" id="page-service-tunnels">
<div style="margin-bottom:16px;display:flex;justify-content:flex-end;"> <div style="margin-bottom:16px;display:flex;justify-content:space-between;align-items:center;gap:8px;flex-wrap:wrap;">
<div id="svcBulkBar" style="display:none;align-items:center;gap:8px;">
<span id="svcBulkCount" style="font-size:12.5px;color:var(--text2);"></span>
<button class="btn btn-danger btn-sm" id="btnSvcBulkRemove">Remove Selected</button>
</div>
<div style="flex:1;"></div>
<button class="btn btn-primary" id="addServiceTunnelBtn"> <button class="btn btn-primary" id="addServiceTunnelBtn">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
Add Tunnel Add Tunnel
@@ -607,6 +708,7 @@
<table class="table"> <table class="table">
<thead> <thead>
<tr> <tr>
<th style="width:32px;"><input type="checkbox" id="svcSelectAll" title="Select all"></th>
<th>Label</th> <th>Label</th>
<th>hs:// Key</th> <th>hs:// Key</th>
<th>Local Port</th> <th>Local Port</th>
@@ -615,7 +717,7 @@
</tr> </tr>
</thead> </thead>
<tbody id="serviceTunnelsTable"> <tbody id="serviceTunnelsTable">
<tr><td colspan="5" class="empty">No service tunnels. Click "Add Tunnel" to connect a remote service.</td></tr> <tr><td colspan="6" class="empty">No service tunnels. Click "Add Tunnel" to connect a remote service.</td></tr>
</tbody> </tbody>
</table> </table>
</div> </div>
@@ -643,10 +745,20 @@
<div class="page" id="page-logs"> <div class="page" id="page-logs">
<div class="logs-toolbar"> <div class="logs-toolbar">
<input type="text" class="input logs-filter" id="logsFilter" placeholder="Filter logs…"> <input type="text" class="input logs-filter" id="logsFilter" placeholder="Filter logs…">
<div style="display:flex;gap:4px;">
<button class="btn btn-secondary btn-sm log-level-btn active" data-level="all">All</button>
<button class="btn btn-secondary btn-sm log-level-btn" data-level="info">Info</button>
<button class="btn btn-secondary btn-sm log-level-btn" data-level="warn" style="color:var(--amber);">Warn</button>
<button class="btn btn-secondary btn-sm log-level-btn" data-level="error" style="color:var(--red);">Error</button>
</div>
<button class="btn btn-secondary" id="btnAutoScroll"> <button class="btn btn-secondary" id="btnAutoScroll">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="7,13 12,18 17,13"/><polyline points="7,6 12,11 17,6"/></svg> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="7,13 12,18 17,13"/><polyline points="7,6 12,11 17,6"/></svg>
Auto-scroll: ON Auto-scroll: ON
</button> </button>
<button class="btn btn-secondary" id="btnDownloadLogs">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7,10 12,15 17,10"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
Download
</button>
<button class="btn btn-ghost" id="btnClearLogs"> <button class="btn btn-ghost" id="btnClearLogs">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3,6 5,6 21,6"/><path d="M19,6l-1,14a2,2,0,0,1-2,2H8a2,2,0,0,1-2-2L5,6"/><path d="M10,11v6"/><path d="M14,11v6"/></svg> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3,6 5,6 21,6"/><path d="M19,6l-1,14a2,2,0,0,1-2,2H8a2,2,0,0,1-2-2L5,6"/><path d="M10,11v6"/><path d="M14,11v6"/></svg>
Clear Clear
@@ -893,6 +1005,10 @@
<label class="form-label" for="sshConnPassword">Password <span style="color:var(--text4);font-weight:400;">(optional — saved, or leave blank to type in terminal)</span></label> <label class="form-label" for="sshConnPassword">Password <span style="color:var(--text4);font-weight:400;">(optional — saved, or leave blank to type in terminal)</span></label>
<input type="password" id="sshConnPassword" class="input" placeholder="Leave blank to enter manually" autocomplete="new-password"> <input type="password" id="sshConnPassword" class="input" placeholder="Leave blank to enter manually" autocomplete="new-password">
</div> </div>
<label class="checkbox-row" style="margin-top:8px;">
<input type="checkbox" id="sshConnAutoReconnect">
<label for="sshConnAutoReconnect">Auto-reconnect on disconnect</label>
</label>
<input type="hidden" id="sshConnEditId"> <input type="hidden" id="sshConnEditId">
<div class="modal-error" id="sshConnError"></div> <div class="modal-error" id="sshConnError"></div>
</div> </div>
+159 -1
View File
@@ -8,9 +8,20 @@ function setupEvents() {
if (document._holesailEventsSetup) return; if (document._holesailEventsSetup) return;
document._holesailEventsSetup = true; document._holesailEventsSetup = true;
// Restore saved theme before any toggle listeners fire
const savedTheme = localStorage.getItem('holesail-theme');
if (savedTheme === 'light') document.documentElement.setAttribute('data-theme', 'light');
// Toggle switches // Toggle switches
document.querySelectorAll('.toggle').forEach(toggle => { document.querySelectorAll('.toggle').forEach(toggle => {
toggle.addEventListener('click', () => toggle.classList.toggle('active')); toggle.addEventListener('click', () => {
toggle.classList.toggle('active');
if (toggle.id === 'toggleTheme') {
const isLight = toggle.classList.contains('active');
document.documentElement.setAttribute('data-theme', isLight ? 'light' : 'dark');
localStorage.setItem('holesail-theme', isLight ? 'light' : 'dark');
}
});
}); });
// Save settings // Save settings
@@ -29,6 +40,153 @@ function setupEvents() {
); );
}); });
// Export configuration
$('btnExportConfig')?.addEventListener('click', () => {
chrome.runtime.sendMessage(
{ target: 'holesail-native', action: 'send', payload: { type: 'getState', payload: {} } },
(response) => {
if (chrome.runtime.lastError || !response || !response.ok) {
showToast('Export failed: could not read state', 'error');
return;
}
const state = response;
const exportData = {
exportedAt: new Date().toISOString(),
version: 1,
virtualHosts: (state.virtualHosts || []).map(v => ({ hostname: v.hostname, hsUrl: v.hsUrl })),
serviceTunnels: (state.serviceTunnels || []).map(t => ({ label: t.label, hsUrl: t.hsUrl, localPort: t.localPort })),
sshConnections: (state.sshConnections || []).map(s => ({ label: s.label, hsUrl: s.hsUrl, username: s.username })),
rdpConnections: (state.rdpConnections || []).map(r => ({ label: r.label, hsUrl: r.hsUrl, type: r.type, port: r.port, width: r.width, height: r.height, username: r.username }))
};
const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'holesail-config-' + new Date().toISOString().slice(0, 10) + '.json';
a.click();
setTimeout(() => URL.revokeObjectURL(url), 5000);
showToast('Configuration exported', 'success');
}
);
});
// Import configuration
$('importConfigFile')?.addEventListener('change', (e) => {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (ev) => {
let data;
try { data = JSON.parse(ev.target.result); } catch (_) { showToast('Invalid JSON file', 'error'); return; }
if (!data || typeof data !== 'object') { showToast('Invalid config file', 'error'); return; }
const vhosts = Array.isArray(data.virtualHosts) ? data.virtualHosts : [];
const svcTunnels = Array.isArray(data.serviceTunnels) ? data.serviceTunnels : [];
const sshConns = Array.isArray(data.sshConnections) ? data.sshConnections : [];
const rdpConns = Array.isArray(data.rdpConnections) ? data.rdpConnections : [];
let pending = 0;
let done = 0;
function onDone() {
done++;
if (done >= pending) {
showToast('Import complete', 'success');
e.target.value = '';
if (typeof refreshAll === 'function') refreshAll();
}
}
for (const v of vhosts) {
if (!v.hostname || !v.hsUrl) continue;
pending++;
chrome.runtime.sendMessage(
{ target: 'holesail-native', action: 'send', payload: { type: 'setVirtualHost', payload: { hostname: v.hostname, hsUrl: v.hsUrl } } },
() => onDone()
);
}
for (const t of svcTunnels) {
if (!t.hsUrl) continue;
pending++;
chrome.runtime.sendMessage(
{ target: 'holesail-native', action: 'send', payload: { type: 'startServiceTunnel', payload: { label: t.label || '', hsUrl: t.hsUrl, localPort: t.localPort } } },
() => onDone()
);
}
if (sshConns.length || rdpConns.length) {
chrome.runtime.sendMessage(
{ target: 'holesail-native', action: 'send', payload: { type: 'getState', payload: {} } },
(response) => {
const existing = response || {};
const mergedSsh = [...(existing.sshConnections || [])];
for (const s of sshConns) {
if (!s.hsUrl) continue;
if (!mergedSsh.find(x => x.hsUrl === s.hsUrl && x.username === s.username)) {
mergedSsh.push({ id: 'ssh-' + Date.now() + '-' + Math.random().toString(36).slice(2), label: s.label || '', hsUrl: s.hsUrl, username: s.username || '' });
}
}
const mergedRdp = [...(existing.rdpConnections || [])];
for (const r of rdpConns) {
if (!r.hsUrl) continue;
if (!mergedRdp.find(x => x.hsUrl === r.hsUrl)) {
mergedRdp.push({ id: 'rdp-' + Date.now() + '-' + Math.random().toString(36).slice(2), label: r.label || '', hsUrl: r.hsUrl, type: r.type || 'vnc', port: r.port || 5900, width: r.width || 1280, height: r.height || 720, username: r.username || '' });
}
}
pending++;
chrome.runtime.sendMessage(
{ target: 'holesail-native', action: 'send', payload: { type: 'setSshConnections', payload: { connections: mergedSsh } } },
() => {
chrome.runtime.sendMessage(
{ target: 'holesail-native', action: 'send', payload: { type: 'setRdpConnections', payload: { connections: mergedRdp } } },
() => onDone()
);
}
);
}
);
}
if (pending === 0) {
showToast('Nothing to import', 'default');
e.target.value = '';
}
};
reader.readAsText(file);
});
// Peer Lookup
function runPeerLookup() {
const input = $('peerLookupInput');
const resultEl = $('peerLookupResult');
if (!input || !resultEl) return;
const key = input.value.trim();
if (!key) { showToast('Enter an hs:// key', 'default'); return; }
resultEl.style.display = 'block';
resultEl.innerHTML = '<span style="color:var(--text3);">Looking up…</span>';
chrome.runtime.sendMessage(
{ target: 'holesail-native', action: 'send', payload: { type: 'lookup', payload: { hsUrl: key } } },
(response) => {
if (chrome.runtime.lastError || !response) {
resultEl.innerHTML = '<span style="color:var(--red);">Lookup failed: ' + (chrome.runtime.lastError?.message || 'No response') + '</span>';
return;
}
if (!response.ok) {
resultEl.innerHTML = '<span style="color:var(--red);">Error: ' + escapeHtml(response.error || 'Unknown error') + '</span>';
return;
}
const peers = response.peers || [];
if (peers.length === 0) {
resultEl.innerHTML = '<span style="color:var(--amber);">No peers found for this key.</span>';
} else {
resultEl.innerHTML = '<span style="color:var(--green);">Found ' + peers.length + ' peer(s):</span> ' +
peers.map(p => '<code style="font-family:\'JetBrains Mono\',monospace;font-size:11px;color:var(--cyan);">' + escapeHtml(String(p)) + '</code>').join(', ');
}
}
);
}
$('btnPeerLookup')?.addEventListener('click', runPeerLookup);
$('peerLookupInput')?.addEventListener('keydown', (e) => { if (e.key === 'Enter') runPeerLookup(); });
setupVirtualHostEvents(); setupVirtualHostEvents();
setupServerEvents(); setupServerEvents();
setupServiceTunnelEvents(); setupServiceTunnelEvents();
+41 -10
View File
@@ -4,6 +4,7 @@ function setupLogsEvents() {
let logs = []; let logs = [];
let autoScroll = true; let autoScroll = true;
let logFilter = ''; let logFilter = '';
let logLevel = 'all'; // 'all' | 'info' | 'warn' | 'error'
chrome.runtime.sendMessage( chrome.runtime.sendMessage(
{ target: 'holesail-native', action: 'registerDashboard' }, { target: 'holesail-native', action: 'registerDashboard' },
@@ -19,31 +20,34 @@ function setupLogsEvents() {
} }
}); });
function getLogClass(msg) { function getLogClass(level) {
const m = (msg || '').toLowerCase(); if (level === 'error') return 'is-error';
if (m.includes('error') || m.includes('fail') || m.includes('err:')) return 'is-error'; if (level === 'warn') return 'is-warn';
if (m.includes('warn') || m.includes('warning')) return 'is-warn';
return ''; return '';
} }
function updateLogsDisplay() { function updateLogsDisplay() {
const container = $('logsContainer'); const container = $('logsContainer');
if (!container) return; if (!container) return;
const filtered = logFilter let filtered = logs;
? logs.filter(e => (e.message || '').toLowerCase().includes(logFilter.toLowerCase())) if (logFilter) {
: logs; filtered = filtered.filter(e => (e.message || '').toLowerCase().includes(logFilter.toLowerCase()));
}
if (logLevel !== 'all') {
filtered = filtered.filter(e => (e.level || 'info') === logLevel);
}
if (filtered.length === 0) { if (filtered.length === 0) {
container.innerHTML = ` container.innerHTML = `
<div class="empty-state"> <div class="empty-state">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><polyline points="22,12 18,12 15,21 9,3 6,12 2,12"/></svg> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><polyline points="22,12 18,12 15,21 9,3 6,12 2,12"/></svg>
<div class="empty-state-title">${logFilter ? 'No matching logs' : 'No logs yet'}</div> <div class="empty-state-title">${(logFilter || logLevel !== 'all') ? 'No matching logs' : 'No logs yet'}</div>
<div class="empty-state-desc">${logFilter ? 'Try a different filter' : 'Logs will appear here as the extension runs'}</div> <div class="empty-state-desc">${(logFilter || logLevel !== 'all') ? 'Try a different filter' : 'Logs will appear here as the extension runs'}</div>
</div>`; </div>`;
return; return;
} }
container.innerHTML = filtered.map(entry => { container.innerHTML = filtered.map(entry => {
const time = new Date(entry.timestamp).toLocaleTimeString(); const time = new Date(entry.timestamp).toLocaleTimeString();
const cls = getLogClass(entry.message); const cls = getLogClass(entry.level || 'info');
return `<div class="log-entry"> return `<div class="log-entry">
<span class="log-time">${time}</span> <span class="log-time">${time}</span>
<span class="log-msg ${cls}">${escapeHtml(entry.message)}</span> <span class="log-msg ${cls}">${escapeHtml(entry.message)}</span>
@@ -68,6 +72,33 @@ function setupLogsEvents() {
updateLogsDisplay(); updateLogsDisplay();
}); });
// Severity filter buttons
document.querySelectorAll('.log-level-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.log-level-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
logLevel = btn.dataset.level;
updateLogsDisplay();
});
});
// Download logs as .txt
$('btnDownloadLogs')?.addEventListener('click', () => {
if (!logs.length) { showToast('No logs to download', 'default'); return; }
const lines = logs.map(e => {
const time = new Date(e.timestamp).toISOString();
return `[${time}] ${e.message}`;
}).join('\n');
const blob = new Blob([lines], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'holesail-logs-' + new Date().toISOString().slice(0, 19).replace(/:/g, '-') + '.txt';
a.click();
setTimeout(() => URL.revokeObjectURL(url), 5000);
showToast('Logs downloaded', 'success');
});
window.addEventListener('beforeunload', () => { window.addEventListener('beforeunload', () => {
chrome.runtime.sendMessage({ target: 'holesail-native', action: 'unregisterDashboard' }, () => {}); chrome.runtime.sendMessage({ target: 'holesail-native', action: 'unregisterDashboard' }, () => {});
}); });
+5
View File
@@ -222,6 +222,11 @@ function updateDashboard(state) {
setText('ovCaLabel', state.caInstalled ? 'Installed & trusted' : 'Not installed'); setText('ovCaLabel', state.caInstalled ? 'Installed & trusted' : 'Not installed');
setText('ovProxyLabel', state.proxyPort != null ? `port ${state.proxyPort}` : '—'); setText('ovProxyLabel', state.proxyPort != null ? `port ${state.proxyPort}` : '—');
setText('ovConnectLabel', state.connectProxyPort != null ? `port ${state.connectProxyPort}` : '—'); setText('ovConnectLabel', state.connectProxyPort != null ? `port ${state.connectProxyPort}` : '—');
if (state.trafficStats) {
const { bytesIn, bytesOut, requests } = state.trafficStats;
const fmt = (n) => n >= 1048576 ? (n / 1048576).toFixed(1) + ' MB' : n >= 1024 ? (n / 1024).toFixed(1) + ' KB' : n + ' B';
setText('ovTrafficLabel', `${fmt(bytesIn)}${fmt(bytesOut)} (${requests} req)`);
}
const lu = $('overviewLastUpdated'); const lu = $('overviewLastUpdated');
if (lu) lu.textContent = 'Updated ' + new Date().toLocaleTimeString(); if (lu) lu.textContent = 'Updated ' + new Date().toLocaleTimeString();
+41 -1
View File
@@ -1,6 +1,19 @@
// Depends on: core/utils.js ($, escapeHtml, truncate), ui/toast.js (showToast, copyToClipboard), // Depends on: core/utils.js ($, escapeHtml, truncate), ui/toast.js (showToast, copyToClipboard),
// ui/modal.js (openModal, closeModal, showModalError) // ui/modal.js (openModal, closeModal, showModalError)
function _updateServerBulkBar() {
const checked = document.querySelectorAll('#swarmsTable input[type="checkbox"]:checked');
const bar = $('serverBulkBar');
const countEl = $('serverBulkCount');
if (!bar) return;
if (checked.length > 0) {
bar.style.display = 'flex';
if (countEl) countEl.textContent = checked.length + ' selected';
} else {
bar.style.display = 'none';
}
}
function updateSwarmsTable(state) { function updateSwarmsTable(state) {
const tbody = $('swarmsTable'); const tbody = $('swarmsTable');
if (!tbody) return; if (!tbody) return;
@@ -8,13 +21,14 @@ function updateSwarmsTable(state) {
if (servers.length === 0) { if (servers.length === 0) {
tbody.innerHTML = ` tbody.innerHTML = `
<tr><td colspan="5"> <tr><td colspan="6">
<div class="empty-state"> <div class="empty-state">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="2" y="3" width="6" height="6" rx="1"/><rect x="16" y="3" width="6" height="6" rx="1"/><rect x="9" y="15" width="6" height="6" rx="1"/><line x1="5" y1="9" x2="12" y2="15"/><line x1="19" y1="9" x2="12" y2="15"/></svg> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="2" y="3" width="6" height="6" rx="1"/><rect x="16" y="3" width="6" height="6" rx="1"/><rect x="9" y="15" width="6" height="6" rx="1"/><line x1="5" y1="9" x2="12" y2="15"/><line x1="19" y1="9" x2="12" y2="15"/></svg>
<div class="empty-state-title">No server tunnels</div> <div class="empty-state-title">No server tunnels</div>
<div class="empty-state-desc">Click "New Server" to expose a local port as an hs:// tunnel</div> <div class="empty-state-desc">Click "New Server" to expose a local port as an hs:// tunnel</div>
</div> </div>
</td></tr>`; </td></tr>`;
_updateServerBulkBar();
return; return;
} }
@@ -25,6 +39,7 @@ function updateSwarmsTable(state) {
const label = s.label || ''; const label = s.label || '';
return ` return `
<tr> <tr>
<td style="width:32px;"><input type="checkbox" class="server-row-cb" data-server-id="${safeId}"></td>
<td> <td>
${label ? `<div style="font-weight:600;color:var(--text);margin-bottom:2px;">${escapeHtml(label)}</div>` : ''} ${label ? `<div style="font-weight:600;color:var(--text);margin-bottom:2px;">${escapeHtml(label)}</div>` : ''}
<div class="mono" style="font-size:11px;color:var(--text3);">${escapeHtml(truncate(id, 20))}</div> <div class="mono" style="font-size:11px;color:var(--text3);">${escapeHtml(truncate(id, 20))}</div>
@@ -57,6 +72,10 @@ function updateSwarmsTable(state) {
</tr>`; </tr>`;
}).join(''); }).join('');
tbody.querySelectorAll('.server-row-cb').forEach(cb => {
cb.addEventListener('change', _updateServerBulkBar);
});
tbody.querySelectorAll('[data-copy]').forEach(btn => { tbody.querySelectorAll('[data-copy]').forEach(btn => {
btn.addEventListener('click', () => copyToClipboard(btn.dataset.copy, btn)); btn.addEventListener('click', () => copyToClipboard(btn.dataset.copy, btn));
}); });
@@ -94,9 +113,30 @@ function updateSwarmsTable(state) {
openModal('modal-stopServer'); openModal('modal-stopServer');
}); });
}); });
_updateServerBulkBar();
} }
function setupServerEvents() { function setupServerEvents() {
$('serverSelectAll')?.addEventListener('change', (e) => {
document.querySelectorAll('#swarmsTable .server-row-cb').forEach(cb => { cb.checked = e.target.checked; });
_updateServerBulkBar();
});
$('btnServerBulkStop')?.addEventListener('click', () => {
const checked = Array.from(document.querySelectorAll('#swarmsTable .server-row-cb:checked'));
if (!checked.length) return;
const ids = checked.map(cb => cb.dataset.serverId);
if (!confirm('Stop ' + ids.length + ' server(s)?')) return;
let done = 0;
for (const serverId of ids) {
chrome.runtime.sendMessage(
{ target: 'holesail-native', action: 'send', payload: { type: 'stopServer', payload: { serverId } } },
() => { done++; if (done === ids.length) { showToast(ids.length + ' server(s) stopped', 'success'); refresh(); } }
);
}
});
$('startServerBtn')?.addEventListener('click', () => { $('startServerBtn')?.addEventListener('click', () => {
const editIdEl = $('serverEditId'); const editIdEl = $('serverEditId');
if (editIdEl) editIdEl.value = ''; if (editIdEl) editIdEl.value = '';
+45 -2
View File
@@ -1,6 +1,19 @@
// Depends on: core/utils.js ($, escapeHtml, truncate), ui/state-tag.js (stateTag), // Depends on: core/utils.js ($, escapeHtml, truncate), ui/state-tag.js (stateTag),
// ui/toast.js (showToast, copyToClipboard), ui/modal.js (openModal, closeModal, showModalError) // ui/toast.js (showToast, copyToClipboard), ui/modal.js (openModal, closeModal, showModalError)
function _updateSvcBulkBar() {
const checked = document.querySelectorAll('#serviceTunnelsTable input[type="checkbox"]:checked');
const bar = $('svcBulkBar');
const countEl = $('svcBulkCount');
if (!bar) return;
if (checked.length > 0) {
bar.style.display = 'flex';
if (countEl) countEl.textContent = checked.length + ' selected';
} else {
bar.style.display = 'none';
}
}
function updateServiceTunnelsTable(state) { function updateServiceTunnelsTable(state) {
const tbody = $('serviceTunnelsTable'); const tbody = $('serviceTunnelsTable');
if (!tbody) return; if (!tbody) return;
@@ -11,13 +24,14 @@ function updateServiceTunnelsTable(state) {
if (tunnels.length === 0) { if (tunnels.length === 0) {
tbody.innerHTML = ` tbody.innerHTML = `
<tr><td colspan="5"> <tr><td colspan="6">
<div class="empty-state"> <div class="empty-state">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>
<div class="empty-state-title">No service tunnels</div> <div class="empty-state-title">No service tunnels</div>
<div class="empty-state-desc">Click "Add Tunnel" to connect a remote TCP/UDP service via Holesail</div> <div class="empty-state-desc">Click "Add Tunnel" to connect a remote TCP/UDP service via Holesail</div>
</div> </div>
</td></tr>`; </td></tr>`;
_updateSvcBulkBar();
return; return;
} }
@@ -29,6 +43,7 @@ function updateServiceTunnelsTable(state) {
const safeId = id.replace(/"/g, '&quot;'); const safeId = id.replace(/"/g, '&quot;');
return ` return `
<tr> <tr>
<td style="width:32px;"><input type="checkbox" class="svc-row-cb" data-tunnel-id="${safeId}"></td>
<td style="font-weight:600;color:var(--text);">${escapeHtml(label)}</td> <td style="font-weight:600;color:var(--text);">${escapeHtml(label)}</td>
<td> <td>
<div style="display:flex;align-items:center;gap:6px;"> <div style="display:flex;align-items:center;gap:6px;">
@@ -48,7 +63,10 @@ function updateServiceTunnelsTable(state) {
</button>` : ''} </button>` : ''}
</div> </div>
</td> </td>
<td>${stateTag(t.state)}</td> <td>
${stateTag(t.state)}
${t.localPort != null ? `<span class="latency-badge" data-ping-host="127.0.0.1" data-ping-port="${t.localPort}" style="margin-left:4px;font-size:10px;color:var(--text4);">…ms</span>` : ''}
</td>
<td> <td>
<div style="display:flex;align-items:center;gap:6px;"> <div style="display:flex;align-items:center;gap:6px;">
${(t.state === 'error' || t.state === 'closed') ? ` ${(t.state === 'error' || t.state === 'closed') ? `
@@ -69,6 +87,10 @@ function updateServiceTunnelsTable(state) {
</tr>`; </tr>`;
}).join(''); }).join('');
tbody.querySelectorAll('.svc-row-cb').forEach(cb => {
cb.addEventListener('change', _updateSvcBulkBar);
});
tbody.querySelectorAll('[data-copy]').forEach(btn => { tbody.querySelectorAll('[data-copy]').forEach(btn => {
btn.addEventListener('click', () => copyToClipboard(btn.dataset.copy, btn)); btn.addEventListener('click', () => copyToClipboard(btn.dataset.copy, btn));
}); });
@@ -123,9 +145,30 @@ function updateServiceTunnelsTable(state) {
openModal('modal-removeServiceTunnel'); openModal('modal-removeServiceTunnel');
}); });
}); });
_updateSvcBulkBar();
} }
function setupServiceTunnelEvents() { function setupServiceTunnelEvents() {
$('svcSelectAll')?.addEventListener('change', (e) => {
document.querySelectorAll('#serviceTunnelsTable .svc-row-cb').forEach(cb => { cb.checked = e.target.checked; });
_updateSvcBulkBar();
});
$('btnSvcBulkRemove')?.addEventListener('click', () => {
const checked = Array.from(document.querySelectorAll('#serviceTunnelsTable .svc-row-cb:checked'));
if (!checked.length) return;
const ids = checked.map(cb => cb.dataset.tunnelId);
if (!confirm('Remove ' + ids.length + ' service tunnel(s)?')) return;
let done = 0;
for (const tunnelId of ids) {
chrome.runtime.sendMessage(
{ target: 'holesail-native', action: 'send', payload: { type: 'stopServiceTunnel', payload: { tunnelId } } },
() => { done++; if (done === ids.length) { showToast(ids.length + ' tunnel(s) removed', 'success'); refresh(); } }
);
}
});
$('addServiceTunnelBtn')?.addEventListener('click', () => { $('addServiceTunnelBtn')?.addEventListener('click', () => {
$('serviceTunnelEditId').value = ''; $('serviceTunnelEditId').value = '';
$('serviceTunnelLabel').value = ''; $('serviceTunnelLabel').value = '';
+8
View File
@@ -2,23 +2,31 @@
function updateSettingsUI() { function updateSettingsUI() {
$('toggleNotify')?.classList.toggle('active', settings.notifyOnDisconnect === true); $('toggleNotify')?.classList.toggle('active', settings.notifyOnDisconnect === true);
$('toggleNotifyTunnelError')?.classList.toggle('active', settings.notifyOnTunnelError !== false);
$('toggleTunnelAutoReconnect')?.classList.toggle('active', settings.tunnelAutoReconnect === true);
$('toggleDebug')?.classList.toggle('active', settings.debug === true); $('toggleDebug')?.classList.toggle('active', settings.debug === true);
$('toggleDisableFileUrls')?.classList.toggle('active', settings.disableOnFileUrls === true); $('toggleDisableFileUrls')?.classList.toggle('active', settings.disableOnFileUrls === true);
$('toggleTheme')?.classList.toggle('active', document.documentElement.getAttribute('data-theme') === 'light');
const proxyPortEl = $('proxyPort'); const proxyPortEl = $('proxyPort');
const readyTimeoutMsEl = $('readyTimeoutMs'); const readyTimeoutMsEl = $('readyTimeoutMs');
const backupRetentionEl = $('backupRetention'); const backupRetentionEl = $('backupRetention');
const backupIntervalEl = $('backupIntervalHours');
if (proxyPortEl) proxyPortEl.value = settings.proxyPort ?? SETTINGS_DEFAULTS.proxyPort; if (proxyPortEl) proxyPortEl.value = settings.proxyPort ?? SETTINGS_DEFAULTS.proxyPort;
if (readyTimeoutMsEl) readyTimeoutMsEl.value = settings.readyTimeoutMs ?? SETTINGS_DEFAULTS.readyTimeoutMs; if (readyTimeoutMsEl) readyTimeoutMsEl.value = settings.readyTimeoutMs ?? SETTINGS_DEFAULTS.readyTimeoutMs;
if (backupRetentionEl) backupRetentionEl.value = settings.backupRetention ?? SETTINGS_DEFAULTS.backupRetention; if (backupRetentionEl) backupRetentionEl.value = settings.backupRetention ?? SETTINGS_DEFAULTS.backupRetention;
if (backupIntervalEl) backupIntervalEl.value = settings.backupIntervalHours ?? SETTINGS_DEFAULTS.backupIntervalHours;
} }
function saveSettings() { function saveSettings() {
settings.notifyOnDisconnect = $('toggleNotify')?.classList.contains('active') ?? SETTINGS_DEFAULTS.notifyOnDisconnect; settings.notifyOnDisconnect = $('toggleNotify')?.classList.contains('active') ?? SETTINGS_DEFAULTS.notifyOnDisconnect;
settings.notifyOnTunnelError = $('toggleNotifyTunnelError')?.classList.contains('active') ?? SETTINGS_DEFAULTS.notifyOnTunnelError;
settings.tunnelAutoReconnect = $('toggleTunnelAutoReconnect')?.classList.contains('active') ?? SETTINGS_DEFAULTS.tunnelAutoReconnect;
settings.debug = $('toggleDebug')?.classList.contains('active') ?? SETTINGS_DEFAULTS.debug; settings.debug = $('toggleDebug')?.classList.contains('active') ?? SETTINGS_DEFAULTS.debug;
settings.disableOnFileUrls = $('toggleDisableFileUrls')?.classList.contains('active') ?? SETTINGS_DEFAULTS.disableOnFileUrls; settings.disableOnFileUrls = $('toggleDisableFileUrls')?.classList.contains('active') ?? SETTINGS_DEFAULTS.disableOnFileUrls;
settings.proxyPort = parseInt($('proxyPort')?.value, 10) || SETTINGS_DEFAULTS.proxyPort; settings.proxyPort = parseInt($('proxyPort')?.value, 10) || SETTINGS_DEFAULTS.proxyPort;
settings.readyTimeoutMs = parseInt($('readyTimeoutMs')?.value, 10) || SETTINGS_DEFAULTS.readyTimeoutMs; settings.readyTimeoutMs = parseInt($('readyTimeoutMs')?.value, 10) || SETTINGS_DEFAULTS.readyTimeoutMs;
settings.backupRetention = Math.max(1, parseInt($('backupRetention')?.value, 10) || SETTINGS_DEFAULTS.backupRetention); settings.backupRetention = Math.max(1, parseInt($('backupRetention')?.value, 10) || SETTINGS_DEFAULTS.backupRetention);
settings.backupIntervalHours = Math.max(0, parseInt($('backupIntervalHours')?.value, 10) || 0);
chrome.runtime.sendMessage( chrome.runtime.sendMessage(
{ target: 'holesail-native', action: 'send', payload: { type: 'updateSettings', payload: { ...settings } } }, { target: 'holesail-native', action: 'send', payload: { type: 'updateSettings', payload: { ...settings } } },
(response) => { (response) => {
+23 -2
View File
@@ -3,6 +3,9 @@
let sshConnections = []; let sshConnections = [];
let activeSshSession = null; // { sessionId, wsPort, term, fitAddon, ws, resizeObserver, conn, dataDisposable } let activeSshSession = null; // { sessionId, wsPort, term, fitAddon, ws, resizeObserver, conn, dataDisposable }
let _sshReconnectTimer = null;
const SSH_RECONNECT_BASE_MS = 3000;
const SSH_RECONNECT_MAX_MS = 60000;
function generateSshId() { function generateSshId() {
return 'ssh-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 7); return 'ssh-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 7);
@@ -70,6 +73,7 @@ function renderSshGrid() {
</div> </div>
<div class="ssh-conn-info"> <div class="ssh-conn-info">
<div class="ssh-conn-label">${escapeHtml(conn.label || conn.username + '@ssh')}</div> <div class="ssh-conn-label">${escapeHtml(conn.label || conn.username + '@ssh')}</div>
${conn.autoReconnect ? '<div style="font-size:10.5px;color:var(--cyan);margin-top:2px;">Auto-reconnect enabled</div>' : ''}
</div> </div>
<div class="ssh-conn-actions"> <div class="ssh-conn-actions">
<button class="btn btn-primary" data-ssh-connect="${escapeHtml(conn.id)}" style="padding:6px 14px;font-size:12px;"> <button class="btn btn-primary" data-ssh-connect="${escapeHtml(conn.id)}" style="padding:6px 14px;font-size:12px;">
@@ -120,6 +124,8 @@ function openAddSshModal(conn) {
$('sshConnHsUrl').value = conn ? conn.hsUrl : ''; $('sshConnHsUrl').value = conn ? conn.hsUrl : '';
$('sshConnUsername').value = conn ? conn.username : ''; $('sshConnUsername').value = conn ? conn.username : '';
$('sshConnPassword').value = conn ? (conn.password || '') : ''; $('sshConnPassword').value = conn ? (conn.password || '') : '';
const arEl = $('sshConnAutoReconnect');
if (arEl) arEl.checked = conn ? !!conn.autoReconnect : false;
$('sshConnEditId').value = conn ? conn.id : ''; $('sshConnEditId').value = conn ? conn.id : '';
$('sshConnSubmit').textContent = isEdit ? 'Save Changes' : 'Save Connection'; $('sshConnSubmit').textContent = isEdit ? 'Save Changes' : 'Save Connection';
openModal('modal-addSsh'); openModal('modal-addSsh');
@@ -261,11 +267,24 @@ async function connectSsh(conn) {
term.write(data); term.write(data);
}; };
let _reconnectDelay = SSH_RECONNECT_BASE_MS;
ws.onclose = () => { ws.onclose = () => {
$('termStatusDot').className = 'terminal-status-dot disconnected'; $('termStatusDot').className = 'terminal-status-dot disconnected';
$('termStateDisplay').textContent = 'Disconnected'; $('termStateDisplay').textContent = 'Disconnected';
$('termStateDisplay').style.color = 'var(--text3)'; $('termStateDisplay').style.color = 'var(--text3)';
term.writeln('\r\n\x1b[90m[Session closed]\x1b[0m'); term.writeln('\r\n\x1b[90m[Session closed]\x1b[0m');
if (conn.autoReconnect && activeSshSession) {
const delay = _reconnectDelay;
_reconnectDelay = Math.min(_reconnectDelay * 2, SSH_RECONNECT_MAX_MS);
term.writeln('\x1b[33mAuto-reconnect in ' + Math.round(delay / 1000) + 's…\x1b[0m');
$('termStateDisplay').textContent = 'Reconnecting…';
$('termStateDisplay').style.color = 'var(--amber)';
if (_sshReconnectTimer) clearTimeout(_sshReconnectTimer);
_sshReconnectTimer = setTimeout(() => {
_sshReconnectTimer = null;
if (activeSshSession && conn.autoReconnect) connectSsh(conn);
}, delay);
}
}; };
ws.onerror = () => { ws.onerror = () => {
@@ -299,6 +318,7 @@ async function disconnectSsh() {
if (!activeSshSession) return; if (!activeSshSession) return;
const { sessionId, ws, term, fitAddon, resizeObserver, dataDisposable } = activeSshSession; const { sessionId, ws, term, fitAddon, resizeObserver, dataDisposable } = activeSshSession;
activeSshSession = null; activeSshSession = null;
if (_sshReconnectTimer) { clearTimeout(_sshReconnectTimer); _sshReconnectTimer = null; }
if (resizeObserver) resizeObserver.disconnect(); if (resizeObserver) resizeObserver.disconnect();
if (dataDisposable) dataDisposable.dispose(); if (dataDisposable) dataDisposable.dispose();
@@ -323,13 +343,14 @@ function setupSshEvents() {
if (!username) { showModalError('modal-addSsh', 'sshConnError', 'Username is required'); return; } if (!username) { showModalError('modal-addSsh', 'sshConnError', 'Username is required'); return; }
if (!hsUrl.startsWith('hs://')) { showModalError('modal-addSsh', 'sshConnError', 'Key must start with hs://'); return; } if (!hsUrl.startsWith('hs://')) { showModalError('modal-addSsh', 'sshConnError', 'Key must start with hs://'); return; }
const autoReconnect = !!$('sshConnAutoReconnect')?.checked;
if (editId) { if (editId) {
const idx = sshConnections.findIndex(c => c.id === editId); const idx = sshConnections.findIndex(c => c.id === editId);
if (idx !== -1) { if (idx !== -1) {
sshConnections[idx] = { ...sshConnections[idx], label, hsUrl, username, password }; sshConnections[idx] = { ...sshConnections[idx], label, hsUrl, username, password, autoReconnect };
} }
} else { } else {
sshConnections.push({ id: generateSshId(), label, hsUrl, username, password }); sshConnections.push({ id: generateSshId(), label, hsUrl, username, password, autoReconnect });
} }
saveSshConnections(); saveSshConnections();
closeModal('modal-addSsh'); closeModal('modal-addSsh');
+45 -2
View File
@@ -2,6 +2,19 @@
// ui/toast.js (showToast, copyToClipboard), ui/modal.js (openModal), // ui/toast.js (showToast, copyToClipboard), ui/modal.js (openModal),
// data/hostname-validator.js (isValidVhostHostname) // data/hostname-validator.js (isValidVhostHostname)
function _updateVhostBulkBar() {
const checked = document.querySelectorAll('#connectionsTable input[type="checkbox"]:checked');
const bar = $('vhostBulkBar');
const countEl = $('vhostBulkCount');
if (!bar) return;
if (checked.length > 0) {
bar.style.display = 'flex';
if (countEl) countEl.textContent = checked.length + ' selected';
} else {
bar.style.display = 'none';
}
}
function updateConnectionsTable(state) { function updateConnectionsTable(state) {
const tbody = $('connectionsTable'); const tbody = $('connectionsTable');
if (!tbody) return; if (!tbody) return;
@@ -9,13 +22,14 @@ function updateConnectionsTable(state) {
if (virtualHosts.length === 0) { if (virtualHosts.length === 0) {
tbody.innerHTML = ` tbody.innerHTML = `
<tr><td colspan="5"> <tr><td colspan="6">
<div class="empty-state"> <div class="empty-state">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="12" r="2"/><circle cx="4" cy="6" r="2"/><circle cx="20" cy="6" r="2"/><circle cx="4" cy="18" r="2"/><circle cx="20" cy="18" r="2"/><line x1="6" y1="6" x2="10" y2="11"/><line x1="18" y1="6" x2="14" y2="11"/><line x1="6" y1="18" x2="10" y2="13"/><line x1="18" y1="18" x2="14" y2="13"/></svg> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="12" r="2"/><circle cx="4" cy="6" r="2"/><circle cx="20" cy="6" r="2"/><circle cx="4" cy="18" r="2"/><circle cx="20" cy="18" r="2"/><line x1="6" y1="6" x2="10" y2="11"/><line x1="18" y1="6" x2="14" y2="11"/><line x1="6" y1="18" x2="10" y2="13"/><line x1="18" y1="18" x2="14" y2="13"/></svg>
<div class="empty-state-title">No virtual hosts</div> <div class="empty-state-title">No virtual hosts</div>
<div class="empty-state-desc">Click "Add Host" to assign a hostname to an hs:// tunnel</div> <div class="empty-state-desc">Click "Add Host" to assign a hostname to an hs:// tunnel</div>
</div> </div>
</td></tr>`; </td></tr>`;
_updateVhostBulkBar();
return; return;
} }
@@ -28,6 +42,7 @@ function updateConnectionsTable(state) {
const needsReconnect = v.state === 'error' || v.state === 'closed'; const needsReconnect = v.state === 'error' || v.state === 'closed';
return ` return `
<tr> <tr>
<td style="width:32px;"><input type="checkbox" class="vhost-row-cb" data-hostname="${safeHostname}"></td>
<td><span class="mono-chip">${escapeHtml(hostname)}</span></td> <td><span class="mono-chip">${escapeHtml(hostname)}</span></td>
<td> <td>
<div style="display:flex;align-items:center;gap:6px;"> <div style="display:flex;align-items:center;gap:6px;">
@@ -39,7 +54,10 @@ function updateConnectionsTable(state) {
</div> </div>
</td> </td>
<td class="mono" style="font-size:11px;color:var(--text3);">${escapeHtml(backend)}</td> <td class="mono" style="font-size:11px;color:var(--text3);">${escapeHtml(backend)}</td>
<td>${stateTag(v.state)}</td> <td>
${stateTag(v.state)}
${v.localPort != null ? `<span class="latency-badge" data-ping-host="127.0.0.1" data-ping-port="${v.localPort}" style="margin-left:4px;font-size:10px;color:var(--text4);">…ms</span>` : ''}
</td>
<td> <td>
<div style="display:flex;align-items:center;gap:6px;"> <div style="display:flex;align-items:center;gap:6px;">
<a href="${openUrl}" target="_blank" rel="noopener" class="btn btn-secondary btn-sm"> <a href="${openUrl}" target="_blank" rel="noopener" class="btn btn-secondary btn-sm">
@@ -59,6 +77,10 @@ function updateConnectionsTable(state) {
</tr>`; </tr>`;
}).join(''); }).join('');
tbody.querySelectorAll('.vhost-row-cb').forEach(cb => {
cb.addEventListener('change', _updateVhostBulkBar);
});
tbody.querySelectorAll('[data-copy]').forEach(btn => { tbody.querySelectorAll('[data-copy]').forEach(btn => {
btn.addEventListener('click', () => copyToClipboard(btn.dataset.copy, btn)); btn.addEventListener('click', () => copyToClipboard(btn.dataset.copy, btn));
}); });
@@ -92,9 +114,30 @@ function updateConnectionsTable(state) {
openModal('modal-removeVhost'); openModal('modal-removeVhost');
}); });
}); });
_updateVhostBulkBar();
} }
function setupVirtualHostEvents() { function setupVirtualHostEvents() {
$('vhostSelectAll')?.addEventListener('change', (e) => {
document.querySelectorAll('#connectionsTable .vhost-row-cb').forEach(cb => { cb.checked = e.target.checked; });
_updateVhostBulkBar();
});
$('btnVhostBulkRemove')?.addEventListener('click', () => {
const checked = Array.from(document.querySelectorAll('#connectionsTable .vhost-row-cb:checked'));
if (!checked.length) return;
const hostnames = checked.map(cb => cb.dataset.hostname);
if (!confirm('Remove ' + hostnames.length + ' virtual host(s)?')) return;
let done = 0;
for (const hostname of hostnames) {
chrome.runtime.sendMessage(
{ target: 'holesail-native', action: 'send', payload: { type: 'removeVirtualHost', payload: { hostname } } },
() => { done++; if (done === hostnames.length) { showToast(hostnames.length + ' host(s) removed', 'success'); refresh(); } }
);
}
});
$('addVhostBtn')?.addEventListener('click', () => openModal('modal-addVhost')); $('addVhostBtn')?.addEventListener('click', () => openModal('modal-addVhost'));
$('addVhostSubmit')?.addEventListener('click', () => { $('addVhostSubmit')?.addEventListener('click', () => {
+27
View File
@@ -10,6 +10,31 @@
// pages/settings.js (updateSettingsUI), // pages/settings.js (updateSettingsUI),
// pages/backups.js (refreshBackups) // pages/backups.js (refreshBackups)
function _pingLatencyBadges() {
const badges = document.querySelectorAll('.latency-badge[data-ping-port]');
badges.forEach(badge => {
const port = parseInt(badge.dataset.pingPort, 10);
const host = badge.dataset.pingHost || '127.0.0.1';
if (!port) return;
const t0 = Date.now();
chrome.runtime.sendMessage(
{ target: 'holesail-native', action: 'send', payload: { type: 'pingTunnel', payload: { host, port } } },
(response) => {
if (!badge.isConnected) return;
if (response && response.ok) {
const ms = response.latencyMs;
const color = ms < 50 ? 'var(--green)' : ms < 200 ? 'var(--amber)' : 'var(--red)';
badge.style.color = color;
badge.textContent = ms + 'ms';
} else {
badge.style.color = 'var(--text4)';
badge.textContent = '—';
}
}
);
});
}
async function refresh() { async function refresh() {
const state = await fetchState(); const state = await fetchState();
if (state) { if (state) {
@@ -51,4 +76,6 @@ async function refresh() {
const sshCountEl = $('sshCount'); const sshCountEl = $('sshCount');
if (sshCountEl) sshCountEl.textContent = sshConnections.length; if (sshCountEl) sshCountEl.textContent = sshConnections.length;
refreshBackups(); refreshBackups();
// Ping latency badges after a short delay so the DOM has been updated
setTimeout(_pingLatencyBadges, 200);
} }
+9
View File
@@ -11,6 +11,15 @@
"declarativeNetRequest", "declarativeNetRequest",
"proxy" "proxy"
], ],
"commands": {
"_execute_action": {
"suggested_key": {
"default": "Alt+Shift+H",
"mac": "Alt+Shift+H"
},
"description": "Open Holesail Dashboard"
}
},
"action": { "action": {
"default_title": "Holesail Dashboard", "default_title": "Holesail Dashboard",
"default_icon": { "default_icon": {
+2 -2
View File
@@ -47,8 +47,8 @@ function saveState() {
settingsModule.init(saveState); settingsModule.init(saveState);
connectionsModule.init(saveState); connectionsModule.init(saveState);
serversModule.init(saveState, settingsModule.getReadyTimeoutMs); serversModule.init(saveState, settingsModule.getReadyTimeoutMs);
vhostsModule.init(saveState, emit, settingsModule.getReadyTimeoutMs); vhostsModule.init(saveState, emit, settingsModule.getReadyTimeoutMs, settingsModule.getTunnelAutoReconnect);
svcModule.init(saveState, emit, settingsModule.getReadyTimeoutMs); svcModule.init(saveState, emit, settingsModule.getReadyTimeoutMs, settingsModule.getTunnelAutoReconnect);
// ── Storage path ────────────────────────────────────────────────────────────── // ── Storage path ──────────────────────────────────────────────────────────────
@@ -18,16 +18,39 @@ function debugLog(...args) {
if (process.stderr) process.stderr.write(msg + '\n'); if (process.stderr) process.stderr.write(msg + '\n');
} }
const serviceTunnels = new Map(); // tunnelId -> { label, hsUrl, localPort, holesail, state, createdAt } const serviceTunnels = new Map(); // tunnelId -> { label, hsUrl, localPort, holesail, state, createdAt, reconnectTimer, reconnectDelay }
let nextServiceTunnelId = 0; let nextServiceTunnelId = 0;
let _saveState = null; let _saveState = null;
let _emit = null; let _emit = null;
let _getReadyTimeoutMs = null; let _getReadyTimeoutMs = null;
let _getAutoReconnect = null;
function init(saveStateFn, emitFn, getReadyTimeoutMsFn) { const RECONNECT_BASE_MS = 5000;
const RECONNECT_MAX_MS = 120000;
function init(saveStateFn, emitFn, getReadyTimeoutMsFn, getAutoReconnectFn) {
_saveState = saveStateFn; _saveState = saveStateFn;
_emit = emitFn; _emit = emitFn;
_getReadyTimeoutMs = getReadyTimeoutMsFn; _getReadyTimeoutMs = getReadyTimeoutMsFn;
_getAutoReconnect = getAutoReconnectFn;
}
function _scheduleSvcReconnect(tunnelId) {
const t = serviceTunnels.get(tunnelId);
if (!t) return;
if (t.reconnectTimer) { clearTimeout(t.reconnectTimer); t.reconnectTimer = null; }
const autoReconnect = _getAutoReconnect ? _getAutoReconnect() : false;
if (!autoReconnect) return;
const delay = t.reconnectDelay || RECONNECT_BASE_MS;
t.reconnectDelay = Math.min(delay * 2, RECONNECT_MAX_MS);
if (process.stderr) process.stderr.write('[holesail-manager] svc ' + tunnelId + ' reconnecting in ' + Math.round(delay / 1000) + 's\n');
t.reconnectTimer = setTimeout(async () => {
t.reconnectTimer = null;
const cur = serviceTunnels.get(tunnelId);
if (!cur || cur.state === 'ready') return;
if (process.stderr) process.stderr.write('[holesail-manager] svc ' + tunnelId + ' auto-reconnect attempt\n');
await startServiceTunnel({ tunnelId, label: cur.label, hsUrl: cur.hsUrl, localPort: cur.localPort });
}, delay);
} }
function applyLoaded(loadedNextServiceTunnelId) { function applyLoaded(loadedNextServiceTunnelId) {
@@ -67,17 +90,17 @@ async function startServiceTunnel(payload) {
hs.on('error', (err) => { hs.on('error', (err) => {
if (process.stderr) process.stderr.write('[holesail-manager] service tunnel error ' + tunnelId + ': ' + (err && err.message) + '\n'); if (process.stderr) process.stderr.write('[holesail-manager] service tunnel error ' + tunnelId + ': ' + (err && err.message) + '\n');
const t = serviceTunnels.get(tunnelId); const t = serviceTunnels.get(tunnelId);
if (t && t.holesail === hs) t.state = 'error'; if (t && t.holesail === hs) { t.state = 'error'; _scheduleSvcReconnect(tunnelId); }
if (_emit) _emit('serviceTunnelError', { tunnelId, label, error: err && err.message }); if (_emit) _emit('serviceTunnelError', { tunnelId, label, error: err && err.message });
}); });
hs.on('close', () => { hs.on('close', () => {
const t = serviceTunnels.get(tunnelId); const t = serviceTunnels.get(tunnelId);
if (t && t.holesail === hs) t.state = 'closed'; if (t && t.holesail === hs) { t.state = 'closed'; _scheduleSvcReconnect(tunnelId); }
if (_emit) _emit('serviceTunnelClosed', { tunnelId, label }); if (_emit) _emit('serviceTunnelClosed', { tunnelId, label });
}); });
} }
await readyWithTimeout(hs, 'svc:' + tunnelId); await readyWithTimeout(hs, 'svc:' + tunnelId);
serviceTunnels.set(tunnelId, { label, hsUrl, localPort, holesail: hs, state: 'ready', createdAt: Date.now() }); serviceTunnels.set(tunnelId, { label, hsUrl, localPort, holesail: hs, state: 'ready', createdAt: Date.now(), reconnectTimer: null, reconnectDelay: RECONNECT_BASE_MS });
if (_emit) _emit('serviceTunnelReady', { tunnelId, label, localPort }); if (_emit) _emit('serviceTunnelReady', { tunnelId, label, localPort });
if (_saveState) _saveState(); if (_saveState) _saveState();
debugLog('startServiceTunnel: ok id=', tunnelId, 'localPort=', localPort); debugLog('startServiceTunnel: ok id=', tunnelId, 'localPort=', localPort);
@@ -96,6 +119,7 @@ async function stopServiceTunnel(payload) {
debugLog('stopServiceTunnel: id=', tunnelId); debugLog('stopServiceTunnel: id=', tunnelId);
const entry = serviceTunnels.get(tunnelId); const entry = serviceTunnels.get(tunnelId);
if (!entry) return { ok: false, error: 'Service tunnel not found' }; if (!entry) return { ok: false, error: 'Service tunnel not found' };
if (entry.reconnectTimer) { clearTimeout(entry.reconnectTimer); entry.reconnectTimer = null; }
if (entry.holesail) { try { await entry.holesail.close(); } catch (_) {} } if (entry.holesail) { try { await entry.holesail.close(); } catch (_) {} }
serviceTunnels.delete(tunnelId); serviceTunnels.delete(tunnelId);
if (_saveState) _saveState(); if (_saveState) _saveState();
@@ -115,6 +139,7 @@ function getNextServiceTunnelId() { return nextServiceTunnelId; }
async function cleanupServiceTunnels() { async function cleanupServiceTunnels() {
for (const [, t] of serviceTunnels) { for (const [, t] of serviceTunnels) {
if (t.reconnectTimer) { clearTimeout(t.reconnectTimer); t.reconnectTimer = null; }
if (t.holesail) { try { await t.holesail.close(); } catch (_) {} } if (t.holesail) { try { await t.holesail.close(); } catch (_) {} }
} }
serviceTunnels.clear(); serviceTunnels.clear();
+5 -1
View File
@@ -36,9 +36,12 @@ function updateSettings(patch) {
} }
if (typeof patch.readyTimeoutMs === 'number') currentSettings.readyTimeoutMs = patch.readyTimeoutMs; if (typeof patch.readyTimeoutMs === 'number') currentSettings.readyTimeoutMs = patch.readyTimeoutMs;
if (typeof patch.notifyOnDisconnect === 'boolean') currentSettings.notifyOnDisconnect = patch.notifyOnDisconnect; if (typeof patch.notifyOnDisconnect === 'boolean') currentSettings.notifyOnDisconnect = patch.notifyOnDisconnect;
if (typeof patch.notifyOnTunnelError === 'boolean') currentSettings.notifyOnTunnelError = patch.notifyOnTunnelError;
if (typeof patch.debug === 'boolean') currentSettings.debug = patch.debug; if (typeof patch.debug === 'boolean') currentSettings.debug = patch.debug;
if (typeof patch.disableOnFileUrls === 'boolean') currentSettings.disableOnFileUrls = patch.disableOnFileUrls; if (typeof patch.disableOnFileUrls === 'boolean') currentSettings.disableOnFileUrls = patch.disableOnFileUrls;
if (typeof patch.backupRetention === 'number') currentSettings.backupRetention = patch.backupRetention; if (typeof patch.backupRetention === 'number') currentSettings.backupRetention = patch.backupRetention;
if (typeof patch.backupIntervalHours === 'number') currentSettings.backupIntervalHours = patch.backupIntervalHours;
if (typeof patch.tunnelAutoReconnect === 'boolean') currentSettings.tunnelAutoReconnect = patch.tunnelAutoReconnect;
if (_saveState) _saveState(); if (_saveState) _saveState();
return { requiresRestart }; return { requiresRestart };
} }
@@ -49,5 +52,6 @@ function setProxyPort(port) {
} }
function getReadyTimeoutMs() { return currentSettings.readyTimeoutMs; } function getReadyTimeoutMs() { return currentSettings.readyTimeoutMs; }
function getTunnelAutoReconnect() { return !!currentSettings.tunnelAutoReconnect; }
module.exports = { init, applyLoaded, getSettings, updateSettings, getProxyPort, setProxyPort, getReadyTimeoutMs }; module.exports = { init, applyLoaded, getSettings, updateSettings, getProxyPort, setProxyPort, getReadyTimeoutMs, getTunnelAutoReconnect };
+5 -2
View File
@@ -13,11 +13,14 @@ const LEGACY_PERSIST_FILENAME = 'holesail-persist.json';
const SETTINGS_DEFAULTS = { const SETTINGS_DEFAULTS = {
proxyPort: 8443, proxyPort: 8443,
connectProxyPort: 8442, connectProxyPort: 8442,
readyTimeoutMs: 0, readyTimeoutMs: 30000,
notifyOnDisconnect: true, notifyOnDisconnect: true,
notifyOnTunnelError: true,
debug: false, debug: false,
disableOnFileUrls: false, disableOnFileUrls: false,
backupRetention: 5 backupRetention: 5,
backupIntervalHours: 0,
tunnelAutoReconnect: false
}; };
const DEBUG = process.env.HOLESAIL_DEBUG === '1' || process.env.HOLESAIL_DEBUG === 'true'; const DEBUG = process.env.HOLESAIL_DEBUG === '1' || process.env.HOLESAIL_DEBUG === 'true';
+32 -6
View File
@@ -19,15 +19,38 @@ function debugLog(...args) {
if (process.stderr) process.stderr.write(msg + '\n'); if (process.stderr) process.stderr.write(msg + '\n');
} }
const virtualHosts = new Map(); // hostname -> { hsUrl, holesail, localHost, localPort, state, createdAt } const virtualHosts = new Map(); // hostname -> { hsUrl, holesail, localHost, localPort, state, createdAt, reconnectTimer, reconnectDelay }
let _saveState = null; let _saveState = null;
let _emit = null; let _emit = null;
let _getReadyTimeoutMs = null; let _getReadyTimeoutMs = null;
let _getAutoReconnect = null;
function init(saveStateFn, emitFn, getReadyTimeoutMsFn) { const RECONNECT_BASE_MS = 5000;
const RECONNECT_MAX_MS = 120000;
function init(saveStateFn, emitFn, getReadyTimeoutMsFn, getAutoReconnectFn) {
_saveState = saveStateFn; _saveState = saveStateFn;
_emit = emitFn; _emit = emitFn;
_getReadyTimeoutMs = getReadyTimeoutMsFn; _getReadyTimeoutMs = getReadyTimeoutMsFn;
_getAutoReconnect = getAutoReconnectFn;
}
function _scheduleVhostReconnect(hostname) {
const v = virtualHosts.get(hostname);
if (!v) return;
if (v.reconnectTimer) { clearTimeout(v.reconnectTimer); v.reconnectTimer = null; }
const autoReconnect = _getAutoReconnect ? _getAutoReconnect() : false;
if (!autoReconnect) return;
const delay = v.reconnectDelay || RECONNECT_BASE_MS;
v.reconnectDelay = Math.min(delay * 2, RECONNECT_MAX_MS);
if (process.stderr) process.stderr.write('[holesail-manager] vhost ' + hostname + ' reconnecting in ' + Math.round(delay / 1000) + 's\n');
v.reconnectTimer = setTimeout(async () => {
v.reconnectTimer = null;
const cur = virtualHosts.get(hostname);
if (!cur || cur.state === 'ready') return;
if (process.stderr) process.stderr.write('[holesail-manager] vhost ' + hostname + ' auto-reconnect attempt\n');
await setVirtualHost({ hostname, hsUrl: cur.hsUrl });
}, delay);
} }
function readyWithTimeout(hs, label) { function readyWithTimeout(hs, label) {
@@ -61,17 +84,18 @@ async function setVirtualHost(payload) {
hs.on('error', (err) => { hs.on('error', (err) => {
if (process.stderr) process.stderr.write('[holesail-manager] tunnel error ' + hostname + ': ' + (err && err.message) + '\n'); if (process.stderr) process.stderr.write('[holesail-manager] tunnel error ' + hostname + ': ' + (err && err.message) + '\n');
const v = virtualHosts.get(hostname); const v = virtualHosts.get(hostname);
if (v && v.holesail === hs) v.state = 'error'; if (v && v.holesail === hs) { v.state = 'error'; _scheduleVhostReconnect(hostname); }
if (_emit) _emit('tunnelError', { hostname, error: err && err.message }); if (_emit) _emit('tunnelError', { hostname, error: err && err.message });
}); });
hs.on('close', () => { hs.on('close', () => {
const v = virtualHosts.get(hostname); const v = virtualHosts.get(hostname);
if (v && v.holesail === hs) v.state = 'closed'; if (v && v.holesail === hs) { v.state = 'closed'; _scheduleVhostReconnect(hostname); }
if (_emit) _emit('tunnelClosed', { hostname }); if (_emit) _emit('tunnelClosed', { hostname });
}); });
} }
await readyWithTimeout(hs, 'vhost:' + hostname); await readyWithTimeout(hs, 'vhost:' + hostname);
virtualHosts.set(hostname, { hsUrl, holesail: hs, localHost: TUNNEL_HOST, localPort, state: 'ready', createdAt: (existing && existing.createdAt) || Date.now() }); const prevReconnectDelay = existing ? existing.reconnectDelay : undefined;
virtualHosts.set(hostname, { hsUrl, holesail: hs, localHost: TUNNEL_HOST, localPort, state: 'ready', createdAt: (existing && existing.createdAt) || Date.now(), reconnectTimer: null, reconnectDelay: RECONNECT_BASE_MS });
if (_emit) _emit('tunnelReady', { hostname, hsUrl, localHost: TUNNEL_HOST, localPort }); if (_emit) _emit('tunnelReady', { hostname, hsUrl, localHost: TUNNEL_HOST, localPort });
if (_saveState) _saveState(); if (_saveState) _saveState();
debugLog('setVirtualHost: ok hostname=', hostname, 'localPort=', localPort); debugLog('setVirtualHost: ok hostname=', hostname, 'localPort=', localPort);
@@ -91,6 +115,7 @@ async function removeVirtualHost(payload) {
debugLog('removeVirtualHost: hostname=', hostname); debugLog('removeVirtualHost: hostname=', hostname);
const entry = virtualHosts.get(hostname); const entry = virtualHosts.get(hostname);
if (!entry) return { ok: false, error: 'Virtual host not found' }; if (!entry) return { ok: false, error: 'Virtual host not found' };
if (entry.reconnectTimer) { clearTimeout(entry.reconnectTimer); entry.reconnectTimer = null; }
if (entry.localPort) releaseTunnelPort(entry.localPort); if (entry.localPort) releaseTunnelPort(entry.localPort);
if (entry.holesail) { try { await entry.holesail.close(); } catch (_) {} } if (entry.holesail) { try { await entry.holesail.close(); } catch (_) {} }
virtualHosts.delete(hostname); virtualHosts.delete(hostname);
@@ -129,9 +154,10 @@ function getVirtualHostMap() {
async function cleanupVirtualHosts() { async function cleanupVirtualHosts() {
for (const [, v] of virtualHosts) { for (const [, v] of virtualHosts) {
if (v.reconnectTimer) { clearTimeout(v.reconnectTimer); v.reconnectTimer = null; }
if (v.holesail) { try { await v.holesail.close(); } catch (_) {} } if (v.holesail) { try { await v.holesail.close(); } catch (_) {} }
} }
virtualHosts.clear(); virtualHosts.clear();
} }
module.exports = { init, setVirtualHost, removeVirtualHost, getVirtualHosts, getLocalPortForHostname, getLocalBackend, getVirtualHostMap, cleanupVirtualHosts }; module.exports = { init, setVirtualHost, removeVirtualHost, getVirtualHosts, getLocalPortForHostname, getLocalBackend, getVirtualHostMap, cleanupVirtualHosts, RECONNECT_BASE_MS, RECONNECT_MAX_MS };
+61 -1
View File
@@ -25,6 +25,37 @@ httpsProxy.setHostnameResolver((hostname) => holesailManager.getLocalBackend(hos
initStartup(holesailManager, certificateAuthority, httpsProxy, connectProxy); initStartup(holesailManager, certificateAuthority, httpsProxy, connectProxy);
// Scheduled auto-backup: fires every backupIntervalHours when > 0.
let _scheduledBackupTimer = null;
function scheduleNextAutoBackup() {
if (_scheduledBackupTimer) { clearTimeout(_scheduledBackupTimer); _scheduledBackupTimer = null; }
const settings = holesailManager.getSettings();
const hours = typeof settings.backupIntervalHours === 'number' ? settings.backupIntervalHours : 0;
if (hours <= 0) return;
const ms = hours * 60 * 60 * 1000;
_scheduledBackupTimer = setTimeout(async () => {
_scheduledBackupTimer = null;
log('Scheduled auto-backup starting (interval=' + hours + 'h)');
const result = await backupManager.createBackup().catch((e) => ({ ok: false, error: e.message }));
if (result.ok) {
const retention = (holesailManager.getSettings().backupRetention) || backupManager.DEFAULT_RETENTION;
backupManager.pruneOldBackups(retention);
log('Scheduled auto-backup complete:', result.filename);
} else {
log('Scheduled auto-backup failed:', result.error);
}
scheduleNextAutoBackup();
}, ms);
}
// Start the auto-backup schedule once proxies are ready.
const _proxiesReady = getProxiesReadyPromise();
if (_proxiesReady) {
_proxiesReady.then(() => scheduleNextAutoBackup()).catch(() => {});
} else {
scheduleNextAutoBackup();
}
/** /**
* @param {Function} send - messenger.send(msg) * @param {Function} send - messenger.send(msg)
* @param {object} msg - { id, type, payload } * @param {object} msg - { id, type, payload }
@@ -78,7 +109,8 @@ async function handleMessageAsync(send, msg) {
caInstalled, caInstalled,
settings, settings,
sshConnections, sshConnections,
rdpConnections rdpConnections,
trafficStats: httpsProxy.getTrafficStats()
}); });
break; break;
} }
@@ -89,6 +121,7 @@ async function handleMessageAsync(send, msg) {
case 'updateSettings': { case 'updateSettings': {
const { requiresRestart } = holesailManager.updateSettings(payload); const { requiresRestart } = holesailManager.updateSettings(payload);
debugLog('updateSettings: applied', JSON.stringify(payload), 'requiresRestart=', requiresRestart); debugLog('updateSettings: applied', JSON.stringify(payload), 'requiresRestart=', requiresRestart);
scheduleNextAutoBackup();
reply({ ok: true, settings: holesailManager.getSettings(), requiresRestart }); reply({ ok: true, settings: holesailManager.getSettings(), requiresRestart });
break; break;
} }
@@ -264,6 +297,32 @@ async function handleMessageAsync(send, msg) {
break; break;
} }
case 'pingTunnel': {
// Measure TCP connect latency to a local tunnel port.
// payload: { host, port }
const pingHost = payload.host || '127.0.0.1';
const pingPort = typeof payload.port === 'number' ? payload.port : null;
if (!pingPort) { reply({ ok: false, error: 'port required' }); break; }
const tcp = require('bare-tcp');
const t0 = Date.now();
const sock = tcp.connect(pingPort, pingHost);
const pingTimeout = setTimeout(() => {
try { sock.destroy(); } catch (_) {}
reply({ ok: false, error: 'timeout' });
}, 3000);
sock.on('connect', () => {
clearTimeout(pingTimeout);
const latencyMs = Date.now() - t0;
try { sock.destroy(); } catch (_) {}
reply({ ok: true, latencyMs });
});
sock.on('error', (err) => {
clearTimeout(pingTimeout);
reply({ ok: false, error: err.message });
});
break;
}
default: default:
reply({ ok: false, error: `Unknown command: ${type}` }); reply({ ok: false, error: `Unknown command: ${type}` });
} }
@@ -276,6 +335,7 @@ async function handleMessageAsync(send, msg) {
} }
function cleanup() { function cleanup() {
if (_scheduledBackupTimer) { clearTimeout(_scheduledBackupTimer); _scheduledBackupTimer = null; }
httpsProxy.stop(() => {}); httpsProxy.stop(() => {});
connectProxy.stop(() => {}); connectProxy.stop(() => {});
sshManager.cleanup(); sshManager.cleanup();
+13 -3
View File
@@ -35,6 +35,11 @@ let proxyServer = null;
let proxyPort = null; let proxyPort = null;
let proxyCertsDirOrCA = null; let proxyCertsDirOrCA = null;
const trafficStats = { bytesIn: 0, bytesOut: 0, requests: 0 };
function getTrafficStats() { return { ...trafficStats }; }
function resetTrafficStats() { trafficStats.bytesIn = 0; trafficStats.bytesOut = 0; trafficStats.requests = 0; }
/** Resolver: hostname -> { host, port } | port | null */ /** Resolver: hostname -> { host, port } | port | null */
let getBackendForHostname = null; let getBackendForHostname = null;
@@ -313,7 +318,7 @@ h1{color:#c0392b}code{background:#f4f4f4;padding:2px 6px;border-radius:3px;font-
try { res.setHeader(k, proxyRes.headers[k]); } catch (_) {} try { res.setHeader(k, proxyRes.headers[k]); } catch (_) {}
} }
} }
proxyRes.on('data', (chunk) => !timedOut && res.write(chunk)); proxyRes.on('data', (chunk) => { if (!timedOut) { trafficStats.bytesOut += chunk.length; res.write(chunk); } });
proxyRes.on('end', () => !timedOut && !res.writableEnded && res.end()); proxyRes.on('end', () => !timedOut && !res.writableEnded && res.end());
// Destroy the client socket on a mid-stream backend error so the browser // Destroy the client socket on a mid-stream backend error so the browser
// receives a connection reset rather than a silently truncated 200 body. // receives a connection reset rather than a silently truncated 200 body.
@@ -327,7 +332,8 @@ h1{color:#c0392b}code{background:#f4f4f4;padding:2px 6px;border-radius:3px;font-
res.end('Proxy error: ' + (timedOut ? 'timeout' : err.message)); res.end('Proxy error: ' + (timedOut ? 'timeout' : err.message));
} }
}); });
req.on('data', (chunk) => proxyReq.write(chunk)); trafficStats.requests++;
req.on('data', (chunk) => { trafficStats.bytesIn += chunk.length; proxyReq.write(chunk); });
req.on('end', () => proxyReq.end()); req.on('end', () => proxyReq.end());
} }
@@ -367,6 +373,8 @@ function onUpgrade (req, socket, head) {
const requestLine = (req.method || 'GET') + ' ' + (req.url || '/') + ' HTTP/1.1\r\n'; const requestLine = (req.method || 'GET') + ' ' + (req.url || '/') + ' HTTP/1.1\r\n';
upstream.write(requestLine + headers + '\r\n\r\n'); upstream.write(requestLine + headers + '\r\n\r\n');
if (head && head.length > 0) upstream.write(head); if (head && head.length > 0) upstream.write(head);
socket.on('data', (c) => { trafficStats.bytesIn += c.length; });
upstream.on('data', (c) => { trafficStats.bytesOut += c.length; });
socket.pipe(upstream); socket.pipe(upstream);
upstream.pipe(socket); upstream.pipe(socket);
}); });
@@ -595,5 +603,7 @@ module.exports = {
start, start,
stop, stop,
restart, restart,
getPort getPort,
getTrafficStats,
resetTrafficStats
}; };