fix: comprehensive bug fixes, security improvements, and feature additions
CI / Build & Test (push) Successful in 3m21s

Critical fixes:
- Fix wrong registry key (com.bridgeswarm → com.holesail.browser) in
  update-native-manifest-extension-id.ps1 — script was always failing on Windows
- Create missing wrong-domain.html redirect page for .host.test URLs
- Remove options_ui pointing to non-existent options.html from manifest

High-priority bug fixes:
- ssh-manager: track and kill orphaned printf FIFO writer when key auth succeeds
- ssh-manager: fix uncancelled 2000ms fallback password timer (assign to fallbackTimer,
  clear in cancelPasswordWatch); fix null-check before removeAllListeners
- ssh-manager: add 30s Promise.race timeout to holesailInst.ready()
- backup-manager: fix macOS cp -R nesting bug by removing destination before copy;
  add tar -tzf integrity check after archive creation
- host.js: restoreBackup now stops running tunnels before restore and re-starts them
- holesail-manager: fix stale closure bug in virtual host and service tunnel
  error/close handlers (guard with v.holesail === hs check)
- dashboard.js: remove dead setText('dashTabs', ...) call referencing non-existent element

Medium improvements:
- manifest: remove unused storage and scripting permissions; restrict
  web_accessible_resources match from <all_urls> to chrome-extension://*/*
- background.js: fix self-referential browser alias (globalThis.browser ?? chrome);
  add 30s per-request timeout to send(); clean up dashboardTabs on tab close
- holesail-manager: gate saveStateSync stderr log behind DEBUG flag; updateSettings
  now returns requiresRestart:true when proxy port changes; add backupRetention field
- host.js: pass requiresRestart through in updateSettings response
- dashboard.js: remove dead loadSettings() function; add requiresRestart warning toast;
  add chrome.runtime.lastError guards in fetchState and refreshBackups;
  set dynamic version from chrome.runtime.getManifest()
- dashboard.html: remove stray </button> tag; add id="sidebarVersion" for dynamic version
- install.sh/install.ps1: fetch version from RELEASE_BASE/VERSION instead of hardcoded 1.0.0
- install.ps1: add Firefox .xpi download and Firefox registry key
- update-native-manifest-extension-id.sh: add optional Firefox manifest update
- certificate-authority.js: defer RSA key generation to setImmediate to avoid blocking
  startup; expose caReady promise
- host.js: await caReady before starting HTTPS proxy

Documentation:
- REMOTE-DESKTOP.md: correct RDP WebSocket protocol field names to match rdp-manager.js
  (destLeft/destTop/destRight/destBottom, mouseMove/mouseButton/keyEvent/keyUnicode)

Feature additions:
- dashboard.js: add Reconnect button for service tunnels in error/closed state
- https-proxy.js: add WebSocket upgrade handler to support ws:// over *.hole.sail
- connect-proxy.js: add 10s header-read timeout to protect against idle connections
- native-host: add bare-fs as explicit dependency
This commit is contained in:
Raven Scott
2026-02-28 19:00:13 -05:00
parent a03f45a439
commit e5a1fa71fe
19 changed files with 436 additions and 110 deletions
+32 -8
View File
@@ -164,25 +164,49 @@ The dashboard connects to `ws://127.0.0.1:<wsPort>`. All frames are binary — r
### RDP
**Native host → browser (connection established):**
```json
{ "type": "connected", "width": 1280, "height": 720 }
```
**Native host → browser (bitmap updates):**
```json
{
"type": "bitmap",
"x": 100, "y": 50,
"width": 200, "height": 100,
"destLeft": 100,
"destTop": 50,
"destRight": 300,
"destBottom": 150,
"width": 200,
"height": 100,
"bitsPerPixel": 32,
"isCompress": false,
"data": "<base64-encoded bitmap>"
}
```
**Browser → native host (input):**
**Native host → browser (session events):**
```json
{ "type": "mousemove", "x": 150, "y": 75 }
{ "type": "mousedown", "button": 1 }
{ "type": "mouseup", "button": 1 }
{ "type": "keydown", "key": 65 }
{ "type": "keyup", "key": 65 }
{ "type": "close" }
{ "type": "error", "message": "Authentication failed" }
```
**Browser → native host (mouse input):**
```json
{ "type": "mouseMove", "x": 150, "y": 75 }
{ "type": "mouseButton", "x": 150, "y": 75, "button": 1, "isDown": true }
{ "type": "mouseButton", "x": 150, "y": 75, "button": 1, "isDown": false }
```
**Browser → native host (keyboard input):**
```json
{ "type": "keyEvent", "code": 65, "isDown": true }
{ "type": "keyEvent", "code": 65, "isDown": false }
{ "type": "keyUnicode", "code": 65, "isDown": true }
```
> **Note:** `keyEvent` sends a scancode via `sendKeyEventScancode`; `keyUnicode` sends a Unicode code point via `sendKeyEventUnicode`. Use `keyEvent` for special keys (arrows, function keys, modifiers) and `keyUnicode` for printable characters.
---
## Port allocation
+17 -5
View File
@@ -39,11 +39,9 @@ function broadcastLogs() {
// Track dashboard tabs
const dashboardTabs = new Set();
const browser = typeof chrome !== 'undefined' && chrome.runtime?.connectNative
const browser = (typeof chrome !== 'undefined' && chrome.runtime?.connectNative)
? chrome
: typeof browser !== 'undefined'
? browser
: chrome;
: (globalThis.browser ?? chrome);
let port = null;
let reconnectDelay = INITIAL_RECONNECT_DELAY;
@@ -313,6 +311,8 @@ function scheduleReconnect() {
}, reconnectDelay);
}
const REQUEST_TIMEOUT_MS = 30000;
function send(msg) {
const id = msg.id || `req_${Date.now()}_${Math.random().toString(36).slice(2)}`;
msg.id = id;
@@ -324,11 +324,22 @@ function send(msg) {
reject(new Error('Native host not connected'));
return;
}
pending.set(id, { resolve, reject });
const timeoutId = setTimeout(() => {
if (pending.has(id)) {
pending.delete(id);
debugLog('send: timeout for id=', id, 'type=', msg.type);
reject(new Error('Request timed out: ' + msg.type));
}
}, REQUEST_TIMEOUT_MS);
pending.set(id, {
resolve: (v) => { clearTimeout(timeoutId); resolve(v); },
reject: (e) => { clearTimeout(timeoutId); reject(e); }
});
try {
port.postMessage(msg);
} catch (e) {
pending.delete(id);
clearTimeout(timeoutId);
debugLog('send: postMessage threw', e.message);
reject(e);
}
@@ -379,6 +390,7 @@ browser.tabs.onRemoved.addListener((tabId) => {
tabSwarms.delete(tabId);
}
subscribedTabs.delete(tabId);
dashboardTabs.delete(tabId);
});
// Content script talks to background via runtime.sendMessage / onMessage
+1 -3
View File
@@ -885,7 +885,7 @@
<div class="sidebar-logo-icon"></div>
<div class="sidebar-logo-text">
<div class="sidebar-logo-name">Holesail</div>
<div class="sidebar-logo-version">v1.0.0 · hole.sail</div>
<div class="sidebar-logo-version" id="sidebarVersion">v1.0.0 · hole.sail</div>
</div>
</div>
@@ -1531,8 +1531,6 @@
</main>
</div><!-- /layout -->
</button>
<!-- ── Toast ─────────────────────────────────────────── -->
<div class="toast" id="toast"></div>
+50 -14
View File
@@ -176,18 +176,6 @@ function setupNavigation() {
// ── Settings ────────────────────────────────────────────────────────────────
function loadSettings() {
chrome.runtime.sendMessage(
{ target: 'holesail-native', action: 'send', payload: { type: 'getSettings' } },
(response) => {
if (response && response.ok && response.settings) {
settings = { ...SETTINGS_DEFAULTS, ...response.settings };
}
updateSettingsUI();
}
);
}
function updateSettingsUI() {
$('toggleNotify')?.classList.toggle('active', settings.notifyOnDisconnect === true);
$('toggleDebug')?.classList.toggle('active', settings.debug === true);
@@ -210,9 +198,14 @@ function saveSettings() {
chrome.runtime.sendMessage(
{ target: 'holesail-native', action: 'send', payload: { type: 'updateSettings', payload: { ...settings } } },
(response) => {
if (chrome.runtime.lastError) { showToast('Settings save failed: ' + chrome.runtime.lastError.message, 'error'); return; }
if (response && response.ok) {
if (response.settings) settings = { ...SETTINGS_DEFAULTS, ...response.settings };
showToast('Settings saved', 'success');
if (response.requiresRestart) {
showToast('Settings saved — restart the native host for proxy port changes to take effect', 'warning');
} else {
showToast('Settings saved', 'success');
}
} else {
showToast(response?.error || 'Failed to save settings', 'error');
}
@@ -766,6 +759,7 @@ function refreshBackups() {
chrome.runtime.sendMessage(
{ target: 'holesail-native', action: 'send', payload: { type: 'listBackups' } },
(response) => {
if (chrome.runtime.lastError) return;
if (response && response.ok) {
updateBackupsTable(response.backups || []);
}
@@ -865,6 +859,11 @@ async function fetchState() {
chrome.runtime.sendMessage(
{ target: 'holesail-native', action: 'getState' },
(response) => {
if (chrome.runtime.lastError) {
log('fetchState error:', chrome.runtime.lastError.message);
resolve(null);
return;
}
if (response && response.ok) resolve(response.state);
else resolve(null);
}
@@ -1065,7 +1064,6 @@ function updateDashboard(state) {
setText('dashSsh', sshConnections.length);
setText('dashRdp', rdpConnections.length);
setText('dashUptime', state.caInstalled ? 'Trusted' : 'Not trusted');
setText('dashTabs', state.proxyPort != null ? state.proxyPort : '—');
const caIcon = $('caStatIcon');
if (caIcon) {
@@ -1495,6 +1493,11 @@ function updateServiceTunnelsTable(state) {
<td>${stateTag(t.state)}</td>
<td>
<div style="display:flex;align-items:center;gap:6px;">
${(t.state === 'error' || t.state === 'closed') ? `
<button class="btn btn-ghost btn-sm" data-reconnect-svc="${safeId}" data-hsurl="${escapeHtml(hsUrl)}" data-label="${escapeHtml(label)}" data-localport="${t.localPort != null ? t.localPort : ''}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23,4 23,10 17,10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
Reconnect
</button>` : ''}
<button class="btn btn-ghost btn-sm" data-edit-service-tunnel="${safeId}" title="Edit tunnel">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
Edit
@@ -1512,6 +1515,29 @@ function updateServiceTunnelsTable(state) {
btn.addEventListener('click', () => copyToClipboard(btn.dataset.copy, btn));
});
tbody.querySelectorAll('[data-reconnect-svc]').forEach(btn => {
btn.addEventListener('click', () => {
const tunnelId = btn.dataset.reconnectSvc;
const hsUrl = btn.dataset.hsurl;
const label = btn.dataset.label;
const localPort = parseInt(btn.dataset.localport, 10);
btn.disabled = true;
btn.textContent = 'Reconnecting…';
chrome.runtime.sendMessage(
{ target: 'holesail-native', action: 'send', payload: { type: 'updateServiceTunnel', payload: { tunnelId, hsUrl, label, localPort } } },
(response) => {
if (chrome.runtime.lastError) { showToast('Reconnect failed: ' + chrome.runtime.lastError.message, 'error'); return; }
if (response?.ok) {
showToast('Service tunnel reconnecting…', 'success');
} else {
showToast(response?.error || 'Reconnect failed', 'error');
}
refresh();
}
);
});
});
tbody.querySelectorAll('[data-edit-service-tunnel]').forEach(btn => {
btn.addEventListener('click', () => {
const tunnelId = btn.dataset.editServiceTunnel;
@@ -2302,6 +2328,16 @@ function setupSshEvents() {
async function init() {
log('Dashboard initializing…');
// Set dynamic version from manifest
try {
const manifest = chrome.runtime.getManifest();
const versionEl = $('sidebarVersion');
if (versionEl && manifest.version) {
versionEl.textContent = 'v' + manifest.version + ' · hole.sail';
}
} catch (_) {}
setupNavigation();
setupEvents();
setupCertValidator();
+3 -7
View File
@@ -6,8 +6,6 @@
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAmpSyoCuwrSnyspandk7hcQ3rTVegPWPyABcn9qX9EMleN7FIPRN81Z31IPXnbMDfXavnAnUc0vEH1ujtv/PAjew58kN4CFUTE1opU15v6dzn+ltrtG1aX+9dwYAOBsxKFU5TzfXI4SvixWzEGjJ1Irh4QEYJh4/C1A8mOdF+VvIA4y5ko7npH+7SRmCnxQ+c4Jh7gmG7qvblEHZzvM5VxT0H4gXivxD7+ABDQFnlet8ojVgyN6azND4BuPER5672vRdzjr4Emd9bL1L3N9QzQzvT7tUdFkJ2Giph9Bog9yyymnxbb8S4u0u8OlDdLBQlCWrERoYxHG9QGZse1EFWsQIDAQAB",
"permissions": [
"nativeMessaging",
"storage",
"scripting",
"notifications",
"tabs",
"declarativeNetRequest",
@@ -37,10 +35,11 @@
"vendor/xterm.js",
"vendor/xterm-addon-fit.js",
"vendor/xterm.css",
"vendor/novnc.js"
"vendor/novnc.js",
"wrong-domain.html"
],
"matches": [
"<all_urls>"
"chrome-extension://*/*"
]
}
],
@@ -55,9 +54,6 @@
"strict_min_version": "109.0"
}
},
"options_ui": {
"page": "options.html"
},
"host_permissions": [
"*://*.host.local/*",
"*://*.hole.sail/*"
+54 -24
View File
@@ -2,46 +2,76 @@
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Holesail Browser Use .hs</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Wrong Domain — Holesail Browser</title>
<style>
* { box-sizing: border-box; }
:root {
--bg: #0f1117;
--surface: #1a1d27;
--card: #21253a;
--accent: #4f8ef7;
--text: #e2e8f0;
--muted: #8892a4;
--border: #2d3352;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: var(--bg);
color: var(--text);
font-family: system-ui, -apple-system, sans-serif;
background: #0f0f14;
color: #e4e4e7;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
margin: 0;
padding: 1.5rem;
min-height: 100vh;
padding: 2rem;
}
.card {
background: #18181b;
border: 1px solid #27272a;
background: var(--card);
border: 1px solid var(--border);
border-radius: 12px;
padding: 2rem;
max-width: 440px;
padding: 2.5rem 3rem;
max-width: 520px;
width: 100%;
text-align: center;
}
h1 { font-size: 1.25rem; margin: 0 0 0.5rem; color: #fff; }
p { font-size: 0.9rem; color: #a1a1aa; margin: 0 0 1rem; line-height: 1.5; }
.suggestion { background: #27272a; border-radius: 8px; padding: 1rem; margin: 1.25rem 0; text-align: left; word-break: break-all; }
.suggestion a { color: #22d3ee; text-decoration: none; }
.suggestion a:hover { text-decoration: underline; }
.hint { font-size: 0.85rem; color: #71717a; margin-top: 1rem; }
.icon { font-size: 3rem; margin-bottom: 1rem; }
h1 { font-size: 1.4rem; margin-bottom: 0.75rem; }
p { color: var(--muted); line-height: 1.6; margin-bottom: 1rem; }
code {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 4px;
padding: 0.15em 0.4em;
font-family: 'JetBrains Mono', monospace;
font-size: 0.9em;
color: var(--accent);
}
.host-display {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.75rem 1rem;
margin: 1rem 0;
font-family: 'JetBrains Mono', monospace;
color: var(--accent);
word-break: break-all;
}
</style>
</head>
<body>
<div class="card">
<h1>Holesail Browser</h1>
<p id="message">Virtual hosts use the <strong>.hs</strong> domain, not .host.hs.</p>
<p id="sub" class="hint"></p>
<div class="suggestion" id="suggestion"></div>
<p class="hint">Add the hostname and hs:// URL in the dashboard, then open the link above.</p>
<div class="icon"></div>
<h1>Wrong Domain</h1>
<p>You tried to visit a <code>.host.test</code> address. Holesail Browser uses the <code>.hole.sail</code> domain instead.</p>
<div class="host-display" id="hostDisplay"></div>
<p>Replace <code>.host.test</code> with <code>.hole.sail</code> in the URL and try again.</p>
</div>
<script src="wrong-domain.js"></script>
<script>
const params = new URLSearchParams(location.search);
const host = params.get('host') || '';
const suggested = host.replace(/\.host\.test$/, '.hole.sail');
document.getElementById('hostDisplay').textContent =
host ? host + ' → ' + suggested : '(unknown host)';
</script>
</body>
</html>
+20 -2
View File
@@ -148,6 +148,14 @@ async function createBackup() {
cleanupStaging(stagingDir);
// Verify archive integrity before reporting success
try {
await runCommand('tar', ['-tzf', outPath]);
} catch (e) {
try { fs.unlinkSync(outPath); } catch (_) {}
return { ok: false, error: 'Backup archive failed integrity check: ' + e.message };
}
let size = 0;
try {
const stat = fs.statSync(outPath);
@@ -281,12 +289,18 @@ async function restoreBackup(filename) {
let restoredCerts = false;
// Restore storage/
// Remove existing destination entries before copying to avoid macOS cp -R
// nesting bug (when dst already exists, cp -R src dst/ creates dst/src/).
const extractedStorage = path.join(stagingDir, 'storage');
if (fs.existsSync(extractedStorage)) {
try {
const entries = fs.readdirSync(extractedStorage);
for (const entry of entries) {
await runCommand('cp', ['-R', path.join(extractedStorage, entry), path.join(storageDir, entry)]);
const dest = path.join(storageDir, entry);
if (fs.existsSync(dest)) {
await runCommand('rm', ['-rf', dest]);
}
await runCommand('cp', ['-R', path.join(extractedStorage, entry), dest]);
}
restoredStorage = true;
} catch (e) {
@@ -302,7 +316,11 @@ async function restoreBackup(filename) {
if (!fs.existsSync(certsDir)) fs.mkdirSync(certsDir, { recursive: true });
const entries = fs.readdirSync(extractedCerts);
for (const entry of entries) {
await runCommand('cp', ['-R', path.join(extractedCerts, entry), path.join(certsDir, entry)]);
const dest = path.join(certsDir, entry);
if (fs.existsSync(dest)) {
await runCommand('rm', ['-rf', dest]);
}
await runCommand('cp', ['-R', path.join(extractedCerts, entry), dest]);
}
restoredCerts = true;
} catch (e) {
+46 -20
View File
@@ -91,27 +91,17 @@ if (!fs.existsSync(certsDir)) {
}
}
let regenerated = false;
if (fs.existsSync(caKeyPath) && fs.existsSync(caCertPath)) {
try {
const caCertPem = fs.readFileSync(caCertPath, 'utf8');
const cert = forge.pki.certificateFromPem(caCertPem);
if (cert.validity.notAfter > new Date()) {
logDebug('CA', 'Existing Root CA is valid.');
} else {
logWarn('CA', 'Existing Root CA expired, regenerating.');
regenerated = true;
}
} catch (e) {
logWarn('CA', 'Could not read existing CA: ' + e.message + '; regenerating.');
regenerated = true;
}
} else {
regenerated = true;
}
// Determine whether CA generation is needed without doing the expensive RSA work yet.
let _caReadyResolve = null;
let _caReadyReject = null;
const caReady = new Promise((resolve, reject) => {
_caReadyResolve = resolve;
_caReadyReject = reject;
});
if (regenerated) {
function _generateCA() {
try {
logInfo('CA', 'Generating Root CA (2048-bit RSA)...');
const keys = forge.pki.rsa.generateKeyPair(2048);
const cert = forge.pki.createCertificate();
cert.publicKey = keys.publicKey;
@@ -140,8 +130,42 @@ if (regenerated) {
fs.writeFileSync(caKeyPath, forge.pki.privateKeyToPem(keys.privateKey));
fs.writeFileSync(caCertPath, forge.pki.certificateToPem(cert));
logInfo('CA', 'Root CA generated.');
_caReadyResolve();
} catch (e) {
logError('CA', 'Failed to generate Root CA: ' + e.message);
_caReadyReject(e);
}
}
// Defer CA generation to the next event-loop tick so it does not block
// the native host startup (RSA key generation can take several seconds).
let _needsGeneration = false;
if (fs.existsSync(caKeyPath) && fs.existsSync(caCertPath)) {
try {
const caCertPem = fs.readFileSync(caCertPath, 'utf8');
const cert = forge.pki.certificateFromPem(caCertPem);
if (cert.validity.notAfter > new Date()) {
logDebug('CA', 'Existing Root CA is valid.');
_caReadyResolve();
} else {
logWarn('CA', 'Existing Root CA expired, regenerating.');
_needsGeneration = true;
}
} catch (e) {
logWarn('CA', 'Could not read existing CA: ' + e.message + '; regenerating.');
_needsGeneration = true;
}
} else {
_needsGeneration = true;
}
if (_needsGeneration) {
// Use setImmediate so the event loop can process the first native message
// before the blocking RSA generation starts.
if (typeof setImmediate === 'function') {
setImmediate(_generateCA);
} else {
setTimeout(_generateCA, 0);
}
}
@@ -416,5 +440,7 @@ module.exports = {
isRootCAInstalled,
getOrCreateDomainCert,
getCaCertPath,
getCertsDir
getCertsDir,
/** Resolves when the CA is ready (either already existed or was generated). */
caReady
};
+15
View File
@@ -50,6 +50,8 @@ function start(port, upstreamPort, callback) {
stderrLog('starting on 127.0.0.1:' + connectPort + ' -> ' + UPSTREAM_HOST + ':' + upstream);
const HEADER_READ_TIMEOUT_MS = 10000;
try {
server = tcp.createServer((clientSocket) => {
debugLog('new client connection');
@@ -58,6 +60,14 @@ function start(port, upstreamPort, callback) {
const pendingChunks = [];
let upstreamSocket = null;
// Protect against clients that connect but never send the CONNECT header
const headerTimeout = setTimeout(() => {
if (!tunneled) {
debugLog('header read timeout — destroying idle client socket');
clientSocket.destroy();
}
}, HEADER_READ_TIMEOUT_MS);
function flushPending() {
if (!upstreamSocket) return;
for (const c of pendingChunks) upstreamSocket.write(c);
@@ -74,6 +84,7 @@ function start(port, upstreamPort, callback) {
const rest = buffer.subarray(idx + HEADER_END.length);
buffer = null;
tunneled = true;
clearTimeout(headerTimeout);
if (rest.length > 0) pendingChunks.push(rest);
clientSocket.write(RESPONSE_200, (err) => {
@@ -114,8 +125,12 @@ function start(port, upstreamPort, callback) {
tryTunnel();
});
clientSocket.on('error', (err) => {
clearTimeout(headerTimeout);
debugLog('client socket error:', err.message);
});
clientSocket.on('close', () => {
clearTimeout(headerTimeout);
});
});
} catch (err) {
stderrLog('createServer threw:', err.message);
+12 -6
View File
@@ -111,7 +111,6 @@ function saveStateSync() {
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');
}
@@ -240,19 +239,24 @@ function getSettings() {
}
function updateSettings(patch) {
if (!patch || typeof patch !== 'object') return;
if (!patch || typeof patch !== 'object') return { requiresRestart: false };
let requiresRestart = false;
if (typeof patch.proxyPort === 'number' && patch.proxyPort > 0 && patch.proxyPort < 65536) {
if (patch.proxyPort !== currentSettings.proxyPort) requiresRestart = true;
currentSettings.proxyPort = patch.proxyPort;
runtimeProxyPort = patch.proxyPort;
}
if (typeof patch.connectProxyPort === 'number' && patch.connectProxyPort > 0 && patch.connectProxyPort < 65536) {
if (patch.connectProxyPort !== currentSettings.connectProxyPort) requiresRestart = true;
currentSettings.connectProxyPort = patch.connectProxyPort;
}
if (typeof patch.readyTimeoutMs === 'number') currentSettings.readyTimeoutMs = patch.readyTimeoutMs;
if (typeof patch.notifyOnDisconnect === 'boolean') currentSettings.notifyOnDisconnect = patch.notifyOnDisconnect;
if (typeof patch.debug === 'boolean') currentSettings.debug = patch.debug;
if (typeof patch.disableOnFileUrls === 'boolean') currentSettings.disableOnFileUrls = patch.disableOnFileUrls;
if (typeof patch.backupRetention === 'number') currentSettings.backupRetention = patch.backupRetention;
saveStateSync();
return { requiresRestart };
}
// ── SSH Connections ───────────────────────────────────────────────────────────
@@ -406,12 +410,13 @@ async function setVirtualHost(payload) {
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';
// Guard against stale closures: only mutate the entry that owns this instance
if (v && v.holesail === hs) v.state = 'error';
emit('tunnelError', { hostname, error: err && err.message });
});
hs.on('close', () => {
const v = virtualHosts.get(hostname);
if (v) v.state = 'closed';
if (v && v.holesail === hs) v.state = 'closed';
emit('tunnelClosed', { hostname });
});
}
@@ -611,12 +616,13 @@ async function startServiceTunnel(payload) {
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';
// Guard against stale closures: only mutate the entry that owns this instance
if (t && t.holesail === hs) t.state = 'error';
emit('serviceTunnelError', { tunnelId, label, error: err && err.message });
});
hs.on('close', () => {
const t = serviceTunnels.get(tunnelId);
if (t) t.state = 'closed';
if (t && t.holesail === hs) t.state = 'closed';
emit('serviceTunnelClosed', { tunnelId, label });
});
}
+15 -4
View File
@@ -49,6 +49,13 @@ if (typeof setImmediate === 'function') {
const savedProxyPort = (restored.settings && restored.settings.proxyPort) || PROXY_PORT;
const savedConnectPort = (restored.settings && restored.settings.connectProxyPort) || CONNECT_PROXY_PORT;
// Wait for CA to be ready before starting the HTTPS proxy (which needs the CA cert)
try {
await certificateAuthority.caReady;
} catch (e) {
log('CA generation failed:', e.message, '— HTTPS proxy may not work correctly');
}
try {
await new Promise((res, rej) => {
httpsProxy.start(savedProxyPort, certificateAuthority, (err) => {
@@ -223,9 +230,9 @@ async function handleMessageAsync(send, msg) {
break;
}
case 'updateSettings': {
holesailManager.updateSettings(payload);
debugLog('updateSettings: applied', JSON.stringify(payload));
reply({ ok: true, settings: holesailManager.getSettings() });
const { requiresRestart } = holesailManager.updateSettings(payload);
debugLog('updateSettings: applied', JSON.stringify(payload), 'requiresRestart=', requiresRestart);
reply({ ok: true, settings: holesailManager.getSettings(), requiresRestart });
break;
}
case 'getSshConnections': {
@@ -386,8 +393,12 @@ async function handleMessageAsync(send, msg) {
case 'restoreBackup': {
const restoreResult = await backupManager.restoreBackup(payload.filename);
if (restoreResult.ok) {
// Stop all running tunnels so in-memory state matches the restored state.json
await holesailManager.cleanup().catch(() => {});
// Reload state from disk after restore
holesailManager.restorePersistedState();
const restored = holesailManager.restorePersistedState();
// Re-start the tunnels described in the restored state
tunnelsRestoredPromise = restorePersistedTunnels(restored).catch((e) => log('Post-restore tunnel start failed:', e.message));
}
reply(restoreResult);
break;
+63
View File
@@ -77,6 +77,8 @@ function start(port, certsDirOrCA, callback) {
}
try {
proxyServer = https.createServer(opts, onRequest);
// Forward WebSocket upgrade requests to the backend tunnel
proxyServer.on('upgrade', onUpgrade);
} catch (err) {
if (process.stderr) process.stderr.write('[https-proxy] createServer threw: ' + err.message + '\n');
done(err);
@@ -236,6 +238,67 @@ h1{color:#c0392b}code{background:#f4f4f4;padding:2px 6px;border-radius:3px;font-
req.on('end', () => proxyReq.end());
}
/**
* Handle WebSocket upgrade requests by piping the raw socket to the backend.
* This allows ws:// / wss:// connections through *.hole.sail virtual hosts.
*/
function onUpgrade(req, socket, head) {
const hostHeader = req.headers && (req.headers.host || req.headers.Host);
const hostname = hostHeader ? hostHeader.split(':')[0].trim() : '';
debugLog('upgrade: hostname=', hostname, 'url=', req.url);
if (!getBackendForHostname || !hostname) {
socket.destroy();
return;
}
const backend = getBackendForHostname(hostname);
let targetHost = '127.0.0.1';
let targetPort = null;
if (backend != null && typeof backend === 'object' && typeof backend.port === 'number') {
targetHost = backend.host ?? '127.0.0.1';
targetPort = backend.port;
} else if (typeof backend === 'number') {
targetPort = backend;
}
if (targetPort == null) {
debugLog('upgrade: no backend for hostname=', hostname);
socket.write('HTTP/1.1 502 Bad Gateway\r\n\r\n');
socket.destroy();
return;
}
// Open a raw TCP connection to the backend and pipe the socket
let net = null;
try { net = require('bare-tcp'); } catch (_) {}
if (!net) {
debugLog('upgrade: bare-tcp not available');
socket.write('HTTP/1.1 502 Bad Gateway\r\n\r\n');
socket.destroy();
return;
}
const upstream = net.connect(targetPort, targetHost, () => {
// Reconstruct the HTTP upgrade request and forward it
const headers = Object.entries(req.headers)
.map(([k, v]) => k + ': ' + v)
.join('\r\n');
const requestLine = (req.method || 'GET') + ' ' + (req.url || '/') + ' HTTP/1.1\r\n';
upstream.write(requestLine + headers + '\r\n\r\n');
if (head && head.length > 0) upstream.write(head);
socket.pipe(upstream);
upstream.pipe(socket);
});
upstream.on('error', (err) => {
debugLog('upgrade: upstream error hostname=', hostname, 'err=', err.message);
try { socket.write('HTTP/1.1 502 Bad Gateway\r\n\r\n'); socket.destroy(); } catch (_) {}
});
socket.on('error', () => {
try { upstream.destroy(); } catch (_) {}
});
}
function stop(callback) {
if (!proxyServer) {
if (callback) callback();
+1
View File
@@ -10,6 +10,7 @@
"dependencies": {
"assert": "npm:bare-node-assert@^1.0.0",
"b4a": "^1.6.7",
"bare-fs": "^4.5.5",
"bare-http1": "^4.0.0",
"bare-https": "^2.1.2",
"bare-module": "^6.1.3",
+1
View File
@@ -11,6 +11,7 @@
"dependencies": {
"assert": "npm:bare-node-assert@^1.0.0",
"b4a": "^1.6.7",
"bare-fs": "^4.5.5",
"bare-http1": "^4.0.0",
"bare-https": "^2.1.2",
"bare-module": "^6.1.3",
+27 -5
View File
@@ -89,13 +89,18 @@ async function startSession(payload) {
log('startSession id=' + sessionId + ' user=' + username + ' tunnelPort=' + tunnelPort + ' wsPort=' + wsPort);
// 1. Start Holesail client tunnel
// 1. Start Holesail client tunnel (with timeout to avoid hanging indefinitely)
let holesailInst = null;
try {
holesailInst = new Holesail({ client: true, key: hsUrl, host: '127.0.0.1', port: tunnelPort });
await holesailInst.ready();
const TUNNEL_READY_TIMEOUT_MS = 30000;
await Promise.race([
holesailInst.ready(),
new Promise((_, reject) => setTimeout(() => reject(new Error('Tunnel ready timeout after ' + TUNNEL_READY_TIMEOUT_MS + 'ms')), TUNNEL_READY_TIMEOUT_MS))
]);
log('tunnel ready on 127.0.0.1:' + tunnelPort);
} catch (e) {
if (holesailInst) try { holesailInst.close(); } catch (_) {}
releaseTunnelPort(tunnelPort);
releaseWsPort(wsPort);
return { ok: false, error: 'Tunnel failed: ' + e.message };
@@ -213,6 +218,7 @@ async function startSession(payload) {
// invokes the askpass helper (i.e. key auth failed).
let askpassFile = null;
let fifoPath = null;
let fifoWriteChild = null; // tracked so we can kill it if key auth succeeds
const sshEnv = Object.assign({}, process.env, { TERM: 'xterm-256color' });
try {
@@ -243,6 +249,12 @@ async function startSession(payload) {
}
function cleanupAskpass() {
// Kill the FIFO writer if it is still blocked (e.g. key auth succeeded
// and the askpass helper was never invoked, so the FIFO was never read).
if (fifoWriteChild) {
try { fifoWriteChild.kill('SIGTERM'); } catch (_) {}
fifoWriteChild = null;
}
try { if (fifoPath) require('bare-fs').unlinkSync(fifoPath); } catch (_) {}
try { if (askpassFile) require('bare-fs').unlinkSync(askpassFile); } catch (_) {}
}
@@ -255,7 +267,9 @@ async function startSession(payload) {
['-c', 'printf "%s\n" "$PW" > "' + fifoPath + '"'],
{ env: Object.assign({}, process.env, { PW: pw }) }
);
fifoWriteChild = child;
child.on('error', (e) => log('FIFO write error: ' + e.message));
child.on('exit', () => { if (fifoWriteChild === child) fifoWriteChild = null; });
} catch (e) {
log('deliverPassword spawn error: ' + e.message);
}
@@ -276,6 +290,7 @@ async function startSession(payload) {
}
pty.once('exit', cleanupAskpass);
pty.once('error', cleanupAskpass);
// Update session record with the live PTY reference
const sess = sessions.get(sessionId);
@@ -302,12 +317,16 @@ async function startSession(payload) {
let passwordDelivered = false;
let collectedPw = '';
let quietTimer = null;
let fallbackTimer = null;
// Only watch for the password prompt within the first 8 seconds.
const authDeadline = Date.now() + 8000;
function cancelPasswordWatch() {
passwordDelivered = true;
clearTimeout(quietTimer);
clearTimeout(fallbackTimer);
quietTimer = null;
fallbackTimer = null;
pty.removeListener('data', onPtyDataForAuth);
}
@@ -316,9 +335,12 @@ async function startSession(payload) {
// Past the deadline — key auth must have succeeded, bail out.
if (Date.now() > authDeadline) { cancelPasswordWatch(); return; }
collectingPassword = true;
if (activeWsConn) {
try { activeWsConn.write(Buffer.from(username + '@127.0.0.1\'s password: ')); } catch (_) {}
if (!activeWsConn) {
// Terminal disconnected — deliver empty password to unblock the FIFO
deliverPasswordToFifo('');
return;
}
try { activeWsConn.write(Buffer.from(username + '@127.0.0.1\'s password: ')); } catch (_) {}
activeWsConn.removeAllListeners('data');
activeWsConn.on('data', (data) => {
const str = Buffer.isBuffer(data) ? data.toString('utf8') : String(data);
@@ -400,7 +422,7 @@ async function startSession(payload) {
pty.on('data', onPtyDataForAuth);
// If SSH produces absolutely no output within 2 s (e.g. very slow tunnel)
// fall through to the password prompt anyway.
quietTimer = setTimeout(startPasswordCollection, 2000);
fallbackTimer = setTimeout(startPasswordCollection, 2000);
}
// PTY output → WebSocket (live, no buffering needed — browser is already open)
+36 -2
View File
@@ -11,7 +11,14 @@ $Downloads = "$env:USERPROFILE\Downloads"
$ManifestName = "com.holesail.browser"
$HostZip = "holesail-browser-host-win32-x64.zip"
$ExtZip = "Holesail-Browser-1.0.0.zip"
# Resolve the current release version from the server so filenames stay accurate
try {
$ExtVersion = (Invoke-WebRequest "$ReleaseBase/VERSION" -UseBasicParsing -ErrorAction Stop).Content.Trim()
} catch {
$ExtVersion = "latest"
}
$ExtZip = "Holesail-Browser-$ExtVersion.zip"
$ExtXpi = "Holesail-Browser-$ExtVersion.xpi"
Write-Host ""
Write-Host "Holesail Browser Installer" -ForegroundColor Cyan
@@ -97,6 +104,13 @@ Get-ChildItem -Path $Downloads -Filter "Holesail-Browser-*.xpi" -ErrorAction Sil
Write-Host "Downloading extension..."
Invoke-WebRequest "$ReleaseBase/$ExtZip" -OutFile "$Downloads\$ExtZip"
Write-Host " Saved: $Downloads\$ExtZip"
# Also grab the .xpi for Firefox
try {
Invoke-WebRequest "$ReleaseBase/$ExtXpi" -OutFile "$Downloads\$ExtXpi" -ErrorAction Stop
Write-Host " Saved: $Downloads\$ExtXpi"
} catch {
Write-Host " Note: Firefox .xpi not available for this release" -ForegroundColor Yellow
}
# ── Native messaging manifest ──────────────────────────────────────────────────
Write-Host "Installing native messaging manifest..."
@@ -112,12 +126,27 @@ $Manifest = @{
} | ConvertTo-Json -Depth 4
Set-Content $ManifestFile -Value $Manifest -Encoding UTF8
foreach ($p in $RegPaths) {
# Chrome / Chromium registry keys
$ChromeRegPaths = @(
"HKCU:\Software\Google\Chrome\NativeMessagingHosts\$ManifestName",
"HKCU:\Software\Chromium\NativeMessagingHosts\$ManifestName"
)
foreach ($p in $ChromeRegPaths) {
New-Item -Path $p -Force | Out-Null
Set-ItemProperty -Path $p -Name "(Default)" -Value $ManifestFile
Write-Host " Registry: $p"
}
# Firefox registry key (native messaging on Windows)
$FirefoxRegPath = "HKCU:\Software\Mozilla\NativeMessagingHosts\$ManifestName"
try {
New-Item -Path $FirefoxRegPath -Force | Out-Null
Set-ItemProperty -Path $FirefoxRegPath -Name "(Default)" -Value $ManifestFile
Write-Host " Registry: $FirefoxRegPath"
} catch {
Write-Host " Note: Could not write Firefox registry key: $_" -ForegroundColor Yellow
}
# ── Done ───────────────────────────────────────────────────────────────────────
Write-Host ""
Write-Host "==========================" -ForegroundColor Green
@@ -131,5 +160,10 @@ Write-Host " 2. Enable Developer mode"
Write-Host " 3. Drag & drop $Downloads\$ExtZip onto the page"
Write-Host " (or click 'Load unpacked' after extracting)"
Write-Host ""
Write-Host " Firefox Developer Edition / Nightly (permanent install):"
Write-Host " 1. Open about:config -> set xpinstall.signatures.required = false"
Write-Host " 2. Open about:addons -> gear icon -> Install Add-on From File"
Write-Host " 3. Select $Downloads\$ExtXpi"
Write-Host ""
Write-Host " Then restart your browser."
Write-Host ""
+4 -2
View File
@@ -24,8 +24,10 @@ if [[ -z "${PLATFORM:-}" ]]; then
fi
HOST_ZIP="holesail-browser-host-${PLATFORM}-${ARCH}.zip"
EXT_ZIP="Holesail-Browser-1.0.0.zip"
EXT_XPI="Holesail-Browser-1.0.0.xpi"
# Resolve the current release version from the server so filenames stay accurate
EXT_VERSION="$(curl -fsSL "${RELEASE_BASE}/VERSION" 2>/dev/null || echo "latest")"
EXT_ZIP="Holesail-Browser-${EXT_VERSION}.zip"
EXT_XPI="Holesail-Browser-${EXT_VERSION}.xpi"
echo ""
echo "Holesail Browser Installer"
@@ -3,7 +3,7 @@
param([Parameter(Mandatory=$true)] [string] $ExtensionId)
$ExtensionId = $ExtensionId -replace '^chrome-extension://', '' -replace '/$', ''
$Origin = "chrome-extension://$ExtensionId/"
$manifestFile = (Get-ItemProperty -Path "HKCU:\Software\Google\Chrome\NativeMessagingHosts\com.bridgeswarm" -ErrorAction SilentlyContinue).'(Default)'
$manifestFile = (Get-ItemProperty -Path "HKCU:\Software\Google\Chrome\NativeMessagingHosts\com.holesail.browser" -ErrorAction SilentlyContinue).'(Default)'
if (-not $manifestFile -or -not (Test-Path $manifestFile)) { Write-Host "Run scripts/install.ps1 first."; exit 1 }
$m = Get-Content $manifestFile -Raw | ConvertFrom-Json
$m.allowed_origins = @($Origin)
+38 -7
View File
@@ -1,15 +1,29 @@
#!/usr/bin/env bash
# Update native host manifest with your Chrome extension ID (fix "Access forbidden").
# Usage: ./scripts/update-native-manifest-extension-id.sh YOUR_EXTENSION_ID
# Also accepts a Firefox extension ID as a second argument to update allowed_extensions.
# Usage: ./scripts/update-native-manifest-extension-id.sh CHROME_EXT_ID [FIREFOX_EXT_ID]
set -e
[[ -z "$1" ]] && echo "Usage: $0 YOUR_CHROME_EXTENSION_ID (from chrome://extensions)" && exit 1
[[ -z "$1" ]] && echo "Usage: $0 CHROME_EXT_ID [FIREFOX_EXT_ID]" && exit 1
EXT_ID="${1#chrome-extension://}"; EXT_ID="${EXT_ID%/}"
ORIGIN="chrome-extension://${EXT_ID}/"
CHROME_DIR="$HOME/.config/google-chrome/NativeMessagingHosts"
CHROMIUM_DIR="$HOME/.config/chromium/NativeMessagingHosts"
[[ "$OSTYPE" == "darwin"* ]] && CHROME_DIR="$HOME/Library/Application Support/Google/Chrome/NativeMessagingHosts" && CHROMIUM_DIR="$HOME/Library/Application Support/Chromium/NativeMessagingHosts"
FIREFOX_EXT_ID="${2:-}"
MANIFEST_NAME="com.holesail.browser"
# Chrome / Chromium manifest directories
if [[ "$OSTYPE" == "darwin"* ]]; then
CHROME_DIR="$HOME/Library/Application Support/Google/Chrome/NativeMessagingHosts"
CHROMIUM_DIR="$HOME/Library/Application Support/Chromium/NativeMessagingHosts"
FIREFOX_DIR="$HOME/Library/Application Support/Mozilla/NativeMessagingHosts"
else
CHROME_DIR="$HOME/.config/google-chrome/NativeMessagingHosts"
CHROMIUM_DIR="$HOME/.config/chromium/NativeMessagingHosts"
FIREFOX_DIR="$HOME/.mozilla/native-messaging-hosts"
fi
updated=0
# Update Chrome / Chromium manifests (allowed_origins)
for dir in "$CHROME_DIR" "$CHROMIUM_DIR"; do
f="$dir/${MANIFEST_NAME}.json"
[[ ! -f "$f" ]] && continue
@@ -18,8 +32,25 @@ for dir in "$CHROME_DIR" "$CHROMIUM_DIR"; do
else
sed -i.bak "s|\"chrome-extension://[^\"]*/\"|\"$ORIGIN\"|g" "$f" && rm -f "${f}.bak"
fi
echo "Updated $f"
echo "Updated $f (allowed_origins)"
updated=1
done
# Update Firefox manifest (allowed_extensions) if a Firefox extension ID was provided
if [[ -n "$FIREFOX_EXT_ID" ]]; then
f="$FIREFOX_DIR/${MANIFEST_NAME}.json"
if [[ -f "$f" ]]; then
if command -v node >/dev/null 2>&1; then
node -e "const fs=require('fs');const j=JSON.parse(fs.readFileSync('$f','utf8'));j.allowed_extensions=['$FIREFOX_EXT_ID'];fs.writeFileSync('$f',JSON.stringify(j,null,2));"
else
sed -i.bak "s|\"holesail-browser[^\"]*@[^\"]*\"|\"$FIREFOX_EXT_ID\"|g" "$f" && rm -f "${f}.bak"
fi
echo "Updated $f (allowed_extensions)"
updated=1
else
echo "Note: Firefox manifest not found at $f — skipping Firefox update"
fi
fi
[[ $updated -eq 0 ]] && echo "No manifest found. Run scripts/install.sh first." && exit 1
echo "Done. Restart Chrome."
echo "Done. Restart your browser."