docs: add CONTRIBUTING.md, CHANGELOG.md, and JSDoc to entire codebase
CI / Build & Test (push) Successful in 2m54s

Add docs/CONTRIBUTING.md covering the build system, dev workflow, all
npm scripts, how to add new native host message types, code style, and
debugging guidance.

Add CHANGELOG.md at the project root documenting all features and fixes
across the 1.0.0 release.

Add JSDoc (@param, @returns) to all previously undocumented exported
functions across 35 JS files:
- native-host/holesail-manager/ (index, virtual-hosts, service-tunnels,
  servers, port-allocator)
- native-host top-level managers (startup, connect-proxy, https-proxy,
  certificate-authority, ssh-manager, rdp-manager)
- extension/background/ (logs, native-messaging, proxy, message-router)
- extension/dashboard/core/ (utils, navigation, init)
- extension/dashboard/ui/ (modal, toast, state-tag)
- extension/dashboard/pages/ (all 10 page files)
- extension/dashboard/refresh.js, events.js
- extension/dashboard/data/hostname-validator.js
- scripts/ (build-host, run-install)
This commit is contained in:
Raven Scott
2026-03-01 00:40:53 -05:00
parent f0917a2d31
commit f1e98a7edd
38 changed files with 1018 additions and 41 deletions
+84
View File
@@ -0,0 +1,84 @@
# Changelog
All notable changes to Holesail Browser are documented here.
## [1.0.0] — 2026
### Added
**Holesail Lookup**
- Renamed "Peer Lookup" to "Holesail Lookup" throughout the dashboard
- Lookup results now display in a modal showing host, port, protocol, and privacy status
- Uses `Holesail.lookup()` to return structured connection metadata
**Bug fixes and stability (35 issues resolved)**
- Fixed `ReferenceError` for undeclared `regenerated` variable in `certificate-authority.js` on Windows
- Added `hs.removeAllListeners()` in error paths of `setVirtualHost` and `startServiceTunnel` to prevent stale listener leaks
- Cleared `reconnectTimer` when replacing an existing virtual host or service tunnel entry
- Removed dead `prevReconnectDelay` variable in `virtual-hosts.js`
- Added `rawSocket.destroy()` in the TLS error handler of `https-proxy.js`
- Fixed pre-connect error handler in `https-proxy.js` to avoid incorrect HTTP writes to a piped TLS stream
- Moved `HOP_BY_HOP` Set to module-level constant to prevent re-allocation on every request
- Wrapped `setImmediate` body in `startup.js` in `try/finally` to ensure `proxiesReadyPromise` always resolves
- Fixed `swarmRefCount` fallback from `|| 1` to `|| 0` in `tab-lifecycle.js` to prevent premature swarm destruction
- Fixed race condition in import handler (`events.js`) where SSH/RDP-only imports showed "Nothing to import"
- Added `void chrome.runtime.lastError` checks throughout the extension
- Added error handler to `WsServer` instances in `ssh-manager.js` and `rdp-manager.js`
- Added `_cancelPasswordWatchFn` shared reference so the WebSocket close handler can cancel password collection
- Removed unused `remotePort` variable in `rdp-manager.js`
- Added `upstreamSocket.destroy()` in `clientSocket.on('close')` handler in `connect-proxy.js`
- Added active socket tracking Set in `connect-proxy.js` to ensure `server.close()` does not hang
- Moved `holesailManager.setEventEmitter()` to module-level in `message-router.js`
- Added `sock.destroy()` on error path in the `pingTunnel` handler
- Cleared 15s fallback timer in `getState`'s `Promise.race` using `.finally()`
- Improved `cleanupStaging` in `backup-manager.js` to log errors and non-zero exit codes
- Added deduplication check in `releaseTunnelPort` to prevent double-free of ports
- Added error listener to server-mode Holesail instances in `servers.js`
- Added `_retryTimers` array in `native-messaging.js` to cancel retry chains on disconnect
- Stored `setInterval` ID for `refresh` in `init.js` and cleared it on `beforeunload`
- Added `_logsSetup` guard in `setupLogsEvents()` to prevent duplicate listener registration
- Named and removed `_onLogsMessage` listener on `beforeunload` in `logs.js`
- Stored `resizeTimer` on `activeSshSession` and cleared it in `disconnectSsh()`
- Added `_sshConnecting` lock flag to prevent auto-reconnect race conditions in `ssh.js`
- Disconnected `MutationObserver` instances in `ssh.js` and `rdp.js` on `beforeunload`
- Introduced reusable offscreen canvas variables in `rdp.js` to avoid per-frame canvas allocation
- Nulled `ws.onclose`, `ws.onerror`, `ws.onmessage` before calling `ws.close()` in `disconnectRdp`
- Pruned stale entries from `validationResults` Map in `proxy-ca.js`
- Added `_pingInFlight` Set in `refresh.js` to prevent redundant concurrent ping requests
- Added `void chrome.runtime.lastError` in `sendToNative` callback in `core/messaging.js`
- Added null check for `$('serverEditId')` in `servers.js`
**13 new features**
- Auto-reconnect for virtual hosts and service tunnels with exponential backoff
- Auto-reconnect for SSH sessions with configurable retry limit
- Browser notifications for tunnel errors and disconnections
- Bulk table actions (select all, remove selected) for virtual hosts, servers, and service tunnels
- Latency badges on tunnel rows with live ping display
- Traffic counters (bytes in/out) displayed in the dashboard
- Dark/light theme toggle persisted across sessions
- Export and import configuration (virtual hosts, servers, service tunnels, SSH, RDP connections)
- Scheduled automatic backups with configurable interval and retention
- Peer/Holesail lookup UI for resolving hs:// keys
- SSH auto-reconnect on unexpected disconnection
- Log level filtering (error, warn, info, debug) in the Logs page
- Keyboard shortcut `Alt+Shift+H` to open the dashboard
**14 initial bug fixes**
- Memory leak and resource management fixes across native host and extension
- See git history for full details
**Initial features**
- Virtual host tunneling via PAC script and HTTPS proxy with SNI support
- Server tunnel mode (expose local ports to the Holesail P2P network)
- Service tunnel mode (direct TCP port forwarding)
- In-browser SSH terminal via xterm.js over WebSocket
- In-browser VNC viewer via noVNC over WebSocket
- In-browser RDP viewer via node-rdpjs-2 over WebSocket
- Certificate Authority with automatic root CA installation (macOS, Linux, Windows)
- Per-domain wildcard TLS certificates generated on demand
- Backup and restore of state and certificates as `.tar.gz` archives
- Native messaging host compatible with Chrome and Firefox
- Bare runtime native host with cross-platform standalone binaries
- Dashboard UI with Overview, Connections, Servers, Service Tunnels, SSH, Remote Desktop, Proxy & CA, Backups, Logs, and Settings pages
- State persistence via `state.json`
- CI pipeline with rolling `latest-main` release on every push to main
+208
View File
@@ -0,0 +1,208 @@
# Contributing
This document covers the development workflow, build system, project structure, and how to extend the codebase.
## Prerequisites
- [Node.js](https://nodejs.org/) v18 or later (for build scripts and dev tooling)
- [Bare](https://github.com/nicolo-ribaudo/bare) runtime — the native host runs under Bare, not Node.js
- A Chromium-based browser or Firefox for testing the extension
Install root-level dev dependencies:
```bash
npm install
```
Install native host dependencies:
```bash
cd native-host && npm install
```
## Repository layout
```
Holesail-Browser/
├── extension/ # Browser extension (MV3, Chrome + Firefox)
│ ├── background.js # Service worker entry point
│ ├── manifest.json # Extension manifest
│ ├── background/ # Background module files
│ └── dashboard/ # Dashboard UI (HTML + JS modules)
├── native-host/ # Native host process (runs under Bare runtime)
│ ├── index.mjs # Entry point
│ ├── host/ # Startup, routing, logging, paths
│ ├── holesail-manager/ # Tunnel lifecycle sub-modules
│ └── ... # Proxy, CA, SSH, RDP, backup managers
├── scripts/ # Build and install scripts (Node.js)
├── docs/ # Documentation
├── releases/ # Build output (generated, not committed)
└── package.json # Root build scripts
```
## Running from source
### 1. Build the native host launcher
```bash
npm run build:host
```
This runs `scripts/build-host.js`, which generates `native-host/holesail-browser-host` — a small shell script that invokes `bare native-host/index.mjs`. The script auto-detects the `bare` binary location.
### 2. Install (register the native messaging host)
```bash
npm run setup
```
This runs `scripts/run-install.js`, which delegates to `scripts/install.sh` (macOS/Linux) or `scripts/install.ps1` (Windows). The installer:
1. Copies the native host files to `~/.holesail-browser/`
2. Writes the native messaging manifest (`com.holesail.browser.json`) to the OS-specific location Chrome/Firefox reads
3. Generates a unique extension ID via `scripts/generate-extension-id.js`
### 3. Load the extension in Chrome
1. Open `chrome://extensions`
2. Enable **Developer mode**
3. Click **Load unpacked** and select the `extension/` directory
### 4. Load the extension in Firefox
1. Open `about:debugging#/runtime/this-firefox`
2. Click **Load Temporary Add-on**
3. Select `extension/manifest.json`
## npm scripts reference
| Script | Command | Description |
|---|---|---|
| `npm run setup` | `node scripts/run-install.js` | Install native host and register native messaging |
| `npm run build:host` | `node scripts/build-host.js` | Generate the `holesail-browser-host` launcher script |
| `npm run build` | alias for `build:host` | Default build |
| `npm run pack` | `node scripts/pack-extension.js` | Pack extension into `.zip` and `.xpi` for distribution |
| `npm run build:dist` | `node scripts/build-distributable.js` | Build standalone binary for the current host platform |
| `npm run build:dist:all` | `...--all` | Build standalone binaries for all 5 platforms |
| `npm run build:dist:mac` | `...--host darwin-arm64 --host darwin-x64` | macOS only |
| `npm run build:dist:linux` | `...--host linux-arm64 --host linux-x64` | Linux only |
| `npm run build:dist:win` | `...--host win32-x64` | Windows only |
| `npm run build:dist:package` | `...--all --package` | All platforms + zip archives |
## Scripts in detail
### `scripts/build-host.js`
Generates `native-host/holesail-browser-host`, a bash launcher that invokes `bare index.mjs`. Searches for the `bare` binary via `which bare`, then common Homebrew paths, then falls back to a sibling of the current `node` binary.
### `scripts/build-distributable.js`
Produces self-contained native host binaries using the `bare-pack` + `bare-build` pipeline:
1. `bare-pack` bundles the entire JS module graph (with optional-dep stubs) into a single `.bundle` file
2. `bare-build` embeds the bundle into a pre-built Bare runtime binary for the target platform
Output goes to `releases/<platform>/holesail-browser-host[.exe]`. Supported targets: `darwin-arm64`, `darwin-x64`, `linux-arm64`, `linux-x64`, `win32-x64`.
### `scripts/pack-extension.js`
Reads the version from `extension/manifest.json` and creates `releases/Holesail-Browser-<version>.zip` (Chrome) and `.xpi` (Firefox) by archiving the `extension/` directory, excluding `.map` files.
### `scripts/generate-extension-id.js`
Generates a unique Chrome extension ID (RSA-2048 public key → SHA-256 → first 16 bytes → base-26 `ap` encoding) and a unique Firefox ID (`holesail-browser-<8 random hex bytes>@example.org`). Updates both `extension/manifest.json` and `com.holesail.browser.json`. Run automatically by the installer so each installation gets its own ID.
### `scripts/run-install.js`
Cross-platform launcher: spawns `install.sh` on Unix or `install.ps1` on Windows via PowerShell with `-ExecutionPolicy Bypass`. Inherits stdio and forwards the exit code.
### `scripts/install.sh` / `scripts/install.ps1`
Full install scripts that:
1. Detect platform and architecture
2. Download or copy the native host binary to `~/.holesail-browser/`
3. Write the native messaging manifest to the correct OS path
4. Optionally download the extension package
## Adding a new native host message type
The extension communicates with the native host via typed JSON messages. To add a new command:
### 1. Add the handler in `native-host/host/message-router.js`
```js
case 'myNewCommand': {
debugLog('myNewCommand: payload=', JSON.stringify(payload));
const result = await someManager.doSomething(payload);
reply(result);
break;
}
```
All handlers receive `payload` (the message payload object) and call `reply(result)` to send the response back to the extension. Async handlers must `await` before calling `reply`.
### 2. Implement the logic in the appropriate manager
- Tunnel operations → `native-host/holesail-manager/`
- SSH sessions → `native-host/ssh-manager.js`
- RDP/VNC sessions → `native-host/rdp-manager.js`
- Certificates → `native-host/certificate-authority.js`
- Backups → `native-host/backup-manager.js`
### 3. Call the command from the extension
In any dashboard JS file, use `sendToNative`:
```js
const result = await sendToNative('myNewCommand', { someParam: 'value' });
if (result && result.ok) {
// handle success
}
```
Or via `chrome.runtime.sendMessage` directly from the background:
```js
chrome.runtime.sendMessage({
target: 'holesail-native',
action: 'send',
payload: { type: 'myNewCommand', payload: { someParam: 'value' } }
}, (response) => { void chrome.runtime.lastError; /* ... */ });
```
### 4. Document the new message type in `docs/NATIVE-HOST.md`
Add an entry under the Commands section with the request payload shape, response shape, and any notes on optional fields.
## Code style
- **No build step for the extension** — the dashboard JS files are loaded directly by the browser in dependency order (see the `<script>` tags at the bottom of `dashboard.html`). There is no bundler or transpiler for the extension.
- **Native host uses CommonJS** (`require`/`module.exports`) with the exception of the entry point `index.mjs` which uses static `import` for Bare compatibility.
- **JSDoc style** — use `/** ... */` blocks with `@param {type} name`, `@returns {type}`, and `@throws {Error}` tags where relevant.
- **Error handling** — all async functions should catch errors and return `{ ok: false, error: e.message }` rather than throwing, so the extension always gets a structured response.
- **Timers and listeners** — always store timer IDs and remove event listeners in cleanup paths to avoid leaks (see `docs/ARCHITECTURE.md` for the resource management patterns used throughout).
## Testing
There is currently no automated test suite. Manual testing workflow:
1. Make changes to the native host source
2. Run `npm run build:host` to regenerate the launcher
3. Reload the extension in the browser (`chrome://extensions` → reload button)
4. The native host process is restarted automatically on the next message from the extension
For distributable builds, run `npm run build:dist` and replace the binary in `~/.holesail-browser/`.
## Debugging
### Native host logs
The native host writes logs to `~/.holesail-browser/holesail-browser.log` (macOS/Linux) or `%APPDATA%\holesail-browser\holesail-browser.log` (Windows). Set `DEBUG_VERBOSE = true` in `extension/background.js` to enable verbose logging from the extension side.
### Extension background logs
Open the service worker DevTools from `chrome://extensions`**Inspect views: service worker**.
### Dashboard logs
The Logs page in the dashboard shows all native host log output in real time.
+14 -2
View File
@@ -1,5 +1,8 @@
// Log buffer and broadcasting to open dashboard tabs.
// Depends on: (none - loaded first)
/**
* In-memory log buffer (capped at MAX_LOGS entries) and batched broadcast to
* open dashboard tabs. Provides log() and debugLog() used by all background modules.
* Depends on: (none — loaded first)
*/
const logs = [];
const MAX_LOGS = 500;
@@ -7,6 +10,11 @@ const MAX_LOGS = 500;
// Track dashboard tabs
const dashboardTabs = new Set();
/**
* Append a log entry to the in-memory buffer and broadcast to dashboard tabs.
* Automatically classifies the entry as 'error', 'warn', or 'info' based on keywords.
* @param {...*} args - Values to log; objects are JSON-stringified.
*/
function log(...args) {
console.log('[Holesail-bg]', ...args);
const timestamp = Date.now();
@@ -22,6 +30,10 @@ function log(...args) {
broadcastLogs();
}
/**
* Log a debug-level entry. No-op unless `DEBUG_VERBOSE` is true.
* @param {...*} args - Values to log.
*/
function debugLog(...args) {
if (!DEBUG_VERBOSE) return;
log('[debug]', ...args);
+14 -3
View File
@@ -1,6 +1,17 @@
// Routes chrome.runtime.onMessage requests from content scripts and the dashboard.
// Depends on: state.js, logs.js, proxy.js (getActiveTlds, applyPAC, pacConfirmedActive),
// native-messaging.js (send, port, subscribedTabs, dashboardTabs)
/**
* Central chrome.runtime.onMessage handler for the background service worker.
* Routes messages from content scripts and the dashboard to local handlers or
* the native host. Handled actions:
* - send Forward a typed payload to the native host (async).
* - registerSwarm Track a swarm ID for a tab (local, not forwarded).
* - subscribe Subscribe a tab to tunnel lifecycle events.
* - unsubscribe Unsubscribe a tab from tunnel lifecycle events.
* - registerDashboard Add a tab to the dashboard broadcast set.
* - unregisterDashboard Remove a tab from the dashboard broadcast set.
* - getState Fetch full extension + native host state (async).
* Depends on: state.js, logs.js, proxy.js (getActiveTlds, applyPAC, pacConfirmedActive),
* native-messaging.js (send, port, subscribedTabs, dashboardTabs)
*/
browser.runtime.onMessage.addListener((message, sender, sendResponse) => {
let responded = false;
+23 -2
View File
@@ -1,5 +1,9 @@
// Native messaging port management: connect, reconnect, send, and message dispatch.
// Depends on: state.js, logs.js, proxy.js
/**
* Native messaging port lifecycle: connect to the native host, reconnect with
* exponential backoff on disconnect, send promise-based requests with timeout,
* and dispatch incoming responses and events to subscribers.
* Depends on: state.js, logs.js, proxy.js
*/
const HOST_NAME = 'com.holesail.browser';
const MAX_RECONNECT_DELAY = 30000;
@@ -17,6 +21,13 @@ const pending = new Map();
// Tabs that have subscribed to native host events (content script registered)
const subscribedTabs = new Set();
/**
* Send a message to the native host and return a Promise that resolves with the response payload.
* Rejects if the port is not connected, if the request times out (30 s), or if the
* response contains an error field.
* @param {object} msg - Message object; must include a `type` field.
* @returns {Promise<object>} The response payload from the native host.
*/
function send(msg) {
const id = msg.id || `req_${Date.now()}_${Math.random().toString(36).slice(2)}`;
msg.id = id;
@@ -74,6 +85,11 @@ function retryGetStateForConnectProxy(delaySeconds, attempt = 0) {
_retryTimers.push(timerId);
}
/**
* Schedule a reconnect attempt after the current backoff delay.
* Doubles the delay on each call, capped at MAX_RECONNECT_DELAY.
* No-op if a reconnect is already scheduled.
*/
function scheduleReconnect() {
if (reconnectTimer) return;
reconnectTimer = setTimeout(() => {
@@ -83,6 +99,11 @@ function scheduleReconnect() {
}, reconnectDelay);
}
/**
* Open the native messaging port, fetch initial state, apply the PAC proxy,
* and wire up message and disconnect listeners.
* On disconnect: rejects all pending requests, clears the PAC, and schedules a reconnect.
*/
function connect() {
debugLog('connect: attempting connectNative', HOST_NAME);
try {
+17 -3
View File
@@ -1,6 +1,11 @@
// PAC proxy management.
// Depends on: state.js (extensionState, pacConfirmedActive, DEFAULT_CONNECT_PROXY_PORT, DEFAULT_PROXY_PORT)
// Depends on: logs.js (log, debugLog)
/**
* PAC (Proxy Auto-Config) proxy management.
* Builds and applies a PAC script that routes virtual host traffic through the
* CONNECT proxy (127.0.0.1:8442). Re-applies automatically if another extension
* or system setting overrides the proxy configuration.
* Depends on: state.js (extensionState, pacConfirmedActive, DEFAULT_CONNECT_PROXY_PORT, DEFAULT_PROXY_PORT)
* Depends on: logs.js (log, debugLog)
*/
/** Extract unique two-label base domains from virtualHosts array, always including hole.sail */
function getActiveTlds(virtualHosts) {
@@ -14,6 +19,11 @@ function getActiveTlds(virtualHosts) {
return Array.from(seen).map(b => '.' + b);
}
/**
* Build and apply a PAC script routing all virtual host TLDs through the CONNECT proxy.
* If `tlds` is omitted, derives the active TLD list from `extensionState.virtualHosts`.
* @param {string[]} [tlds] - Array of TLD suffixes (e.g. `['.hole.sail', '.hs']`).
*/
function applyPAC(tlds) {
if (!browser.proxy || !browser.proxy.settings) {
log('applyPAC: SKIPPED - no browser.proxy.settings API');
@@ -72,6 +82,10 @@ if (browser.proxy && browser.proxy.settings && browser.proxy.settings.onChange)
});
}
/**
* Clear the PAC proxy setting, reverting to direct connections.
* Called when the native host disconnects.
*/
function clearProxy() {
debugLog('clearProxy');
if (!browser.proxy || !browser.proxy.settings) return;
+11 -1
View File
@@ -1,4 +1,9 @@
// Depends on: all other modules (loaded before this file)
/**
* Dashboard entry point. Guards against loading outside the dashboard context,
* initialises navigation and all page event handlers, then starts the 2-second
* refresh cycle.
* Depends on: all other dashboard modules (loaded before this file)
*/
/**
* Guard: only run when loaded as the actual dashboard page.
@@ -7,6 +12,11 @@ if (!document.getElementById('page-dashboard') && !document.querySelector('.side
throw new Error('dashboard scripts loaded outside dashboard context — aborting');
}
/**
* Initialise the dashboard: set the version string, wire up navigation and all
* page event handlers, run the first state refresh, and start the 2-second polling interval.
* @returns {Promise<void>}
*/
async function init() {
log('Dashboard initializing…');
+15 -1
View File
@@ -1,4 +1,8 @@
// Depends on: core/utils.js ($)
/**
* Sidebar navigation for the dashboard.
* Manages the active nav item and visible page section, and wires up click listeners.
* Depends on: core/utils.js ($)
*/
const PAGE_TITLES = {
dashboard: 'Overview',
@@ -13,6 +17,12 @@ const PAGE_TITLES = {
settings: 'Settings'
};
/**
* Switch the dashboard to the specified page.
* Updates the active nav item, shows the corresponding `.page` section,
* and sets the topbar title.
* @param {string} page - Page key (e.g. `'dashboard'`, `'connections'`, `'ssh'`).
*/
function navigateTo(page) {
document.querySelectorAll('.nav-item').forEach(i => i.classList.remove('active'));
document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
@@ -24,6 +34,10 @@ function navigateTo(page) {
if (titleEl) titleEl.textContent = PAGE_TITLES[page] || page;
}
/**
* Attach click listeners to all `.nav-item` elements.
* Called once during dashboard initialisation.
*/
function setupNavigation() {
document.querySelectorAll('.nav-item').forEach(item => {
item.addEventListener('click', () => navigateTo(item.dataset.page));
+38
View File
@@ -1,6 +1,27 @@
/**
* Shared utility functions for the Holesail dashboard.
* Provides DOM helpers, logging, time formatting, string utilities, and HTML escaping.
* Loaded first — no dependencies on other dashboard modules.
*/
/**
* Shorthand for `document.getElementById`.
* @param {string} id - Element ID.
* @returns {HTMLElement|null}
*/
function $(id) { return document.getElementById(id); }
/**
* Log a message to the browser console with a `[Holesail-dashboard]` prefix.
* @param {...*} args - Values to log.
*/
function log(...args) { console.log('[Holesail-dashboard]', ...args); }
/**
* Format a Unix timestamp as a human-readable relative time string (e.g. "5m ago").
* @param {number} timestamp - Unix timestamp in milliseconds.
* @returns {string}
*/
function timeAgo(timestamp) {
const seconds = Math.floor((Date.now() - timestamp) / 1000);
if (seconds < 60) return seconds + 's ago';
@@ -11,6 +32,11 @@ function timeAgo(timestamp) {
return Math.floor(hours / 24) + 'd ago';
}
/**
* Format a duration in milliseconds as a human-readable uptime string (e.g. "2h 15m").
* @param {number} ms - Duration in milliseconds.
* @returns {string}
*/
function formatUptime(ms) {
const seconds = Math.floor(ms / 1000);
if (seconds < 60) return seconds + 's';
@@ -20,11 +46,23 @@ function formatUptime(ms) {
return hours + 'h ' + (minutes % 60) + 'm';
}
/**
* Truncate a string to `len` characters, appending an ellipsis if truncated.
* @param {string} str
* @param {number} [len=20] - Maximum length before truncation.
* @returns {string}
*/
function truncate(str, len = 20) {
if (!str) return '';
return str.length > len ? str.slice(0, len) + '…' : str;
}
/**
* Escape a string for safe insertion into HTML content.
* Uses a temporary DOM element to leverage the browser's own escaping.
* @param {string} str - Raw string that may contain HTML special characters.
* @returns {string} HTML-escaped string.
*/
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
+12 -2
View File
@@ -28,13 +28,23 @@ function isValidVhostHostname(hostname) {
return { ok: true };
}
/** Extract the two-label base domain from a hostname (e.g. "hole.sail" from "myapp.hole.sail") */
/**
* Extract the two-label base domain from a hostname.
* @example extractBaseDomain('myapp.hole.sail') // => 'hole.sail'
* @param {string} hostname
* @returns {string}
*/
function extractBaseDomain(hostname) {
const parts = hostname.split('.');
return parts.slice(-2).join('.');
}
/** Extract unique two-label base domains from a list of virtual host objects */
/**
* Extract unique two-label base domains from a list of virtual host objects.
* Always includes `hole.sail` as the baseline TLD.
* @param {Array<{hostname: string}>} virtualHosts
* @returns {string[]} Array of TLD suffixes with leading dot (e.g. `['.hole.sail', '.hs']`).
*/
function extractActiveTlds(virtualHosts) {
const seen = new Set();
seen.add('hole.sail'); // always include baseline
+10 -2
View File
@@ -1,6 +1,14 @@
// Global event wiring — toggle switches, settings buttons, and per-page setup calls.
// Depends on: all page modules
/**
* Global event wiring for the dashboard.
* Wires toggle switches (theme, notifications, auto-reconnect), settings buttons,
* export/import handlers, Holesail Lookup, and calls each page module's setup function.
* Depends on: all page modules
*/
/**
* Attach all global dashboard event listeners.
* Guarded by `document._holesailEventsSetup` to prevent duplicate registration.
*/
function setupEvents() {
// Guard against duplicate listener registration if setupEvents() is called
// more than once (e.g. after a hot-reload). Without this, anonymous listeners
+17 -2
View File
@@ -1,5 +1,9 @@
// Depends on: core/utils.js ($, escapeHtml), core/utils.js (timeAgo),
// ui/toast.js (showToast), ui/modal.js (openModal, closeModal, showModalError)
/**
* Backups page — lists backup archives with size/date, and wires up
* create/restore/delete operations via native host messages.
* Depends on: core/utils.js ($, escapeHtml, timeAgo),
* ui/toast.js (showToast), ui/modal.js (openModal, closeModal, showModalError)
*/
let pendingRestoreFilename = null;
let pendingDeleteFilename = null;
@@ -10,6 +14,10 @@ function formatBytes(bytes) {
return (bytes / (1024 * 1024)).toFixed(2) + ' MB';
}
/**
* Re-render the backups table with the provided list of backup entries.
* @param {Array<{filename: string, size: number, createdAt: number}>} backups
*/
function updateBackupsTable(backups) {
const tbody = $('backupsTable');
if (!tbody) return;
@@ -41,6 +49,9 @@ function updateBackupsTable(backups) {
}).join('');
}
/**
* Fetch the current backup list from the native host and re-render the table.
*/
function refreshBackups() {
chrome.runtime.sendMessage(
{ target: 'holesail-native', action: 'send', payload: { type: 'listBackups' } },
@@ -53,6 +64,10 @@ function refreshBackups() {
);
}
/**
* Attach all event listeners for the Backups page.
* Called once during dashboard initialisation.
*/
function setupBackupEvents() {
$('btnTakeBackup')?.addEventListener('click', () => {
const btn = $('btnTakeBackup');
+9 -1
View File
@@ -1,7 +1,15 @@
// Depends on: core/utils.js ($, escapeHtml), ui/toast.js (showToast)
/**
* Logs page — registers the dashboard with the background to receive live log events,
* renders a filterable/searchable log list with level filtering and auto-scroll toggle.
* Depends on: core/utils.js ($, escapeHtml), ui/toast.js (showToast)
*/
let _logsSetup = false;
/**
* Set up the Logs page: register for live log broadcasts, wire up filter/search/scroll controls.
* Guarded by `_logsSetup` to prevent duplicate registration.
*/
function setupLogsEvents() {
if (_logsSetup) return;
_logsSetup = true;
+20 -3
View File
@@ -1,9 +1,17 @@
// Depends on: core/utils.js ($, escapeHtml), ui/state-tag.js (stateTag),
// ui/modal.js (openModal), pages/ssh.js (openAddSshModal),
// pages/rdp.js (openAddRdpModal), core/state.js (sshConnections, rdpConnections)
/**
* Overview page — summary stats, quick-action cards, and virtualized connection lists.
* Uses the OvList class for infinite-scroll, searchable tables of connections and servers.
* Depends on: core/utils.js ($, escapeHtml), ui/state-tag.js (stateTag),
* ui/modal.js (openModal), pages/ssh.js (openAddSshModal),
* pages/rdp.js (openAddRdpModal), core/state.js (sshConnections, rdpConnections)
*/
const OV_PAGE = 20; // rows per page
/**
* Virtualized, searchable, infinite-scroll list for the Overview page.
* Renders rows in pages of OV_PAGE, loading more when the sentinel element scrolls into view.
*/
class OvList {
constructor({ bodyId, sentinelId, searchId, subtitleId, rowFn, emptyMsg, cols }) {
this.body = $(bodyId);
@@ -78,6 +86,10 @@ let _ovSvc = null;
let _ovSsh = null;
let _ovRdp = null;
/**
* Create and wire up the OvList instances for virtual hosts and server tunnels.
* Called once during dashboard initialisation.
*/
function initOvLists() {
_ovVhost = new OvList({
bodyId: 'recentConnections', sentinelId: 'ovVhostSentinel',
@@ -175,6 +187,11 @@ function initOvLists() {
$('qaInstallCA') ?.addEventListener('click', () => openModal('modal-installCA'));
}
/**
* Render the Overview page with the latest extension state.
* Updates summary stat cards, quick-action buttons, and the connection/server lists.
* @param {object} state - Full extension state from `fetchState()`.
*/
function updateDashboard(state) {
currentState = state;
const servers = state.servers || [];
+25 -2
View File
@@ -1,9 +1,17 @@
// Depends on: core/utils.js ($, escapeHtml), core/state.js (currentState),
// ui/toast.js (showToast), ui/modal.js (openModal, closeModal)
/**
* Proxy & CA page — displays proxy port/CA status, renders a per-virtual-host
* certificate validator table, and wires up CA install/uninstall events.
* Depends on: core/utils.js ($, escapeHtml), core/state.js (currentState),
* ui/toast.js (showToast), ui/modal.js (openModal, closeModal)
*/
// Per-host test results: hostname -> { status, tlsOk, httpStatus, ms, error }
const validationResults = new Map();
/**
* Update the proxy port and CA installation status display.
* @param {object} state - Full extension state from `fetchState()`.
*/
function updateTabsTable(state) {
const portEl = $('proxyInfoPort');
const caEl = $('proxyInfoCA');
@@ -96,6 +104,12 @@ function updateValidatorRow(hostname) {
}
}
/**
* Run a TLS + HTTP validation check for a single virtual hostname.
* Updates `validationResults` and re-renders the row on completion.
* @param {string} hostname - The virtual hostname to validate (e.g. `myapp.hs`).
* @returns {Promise<void>}
*/
async function runValidation(hostname) {
validationResults.set(hostname, { status: 'running' });
updateValidatorRow(hostname);
@@ -119,6 +133,11 @@ async function runValidation(hostname) {
updateValidatorRow(hostname);
}
/**
* Run validation checks for all virtual hosts sequentially.
* @param {Array<{hostname: string}>} virtualHosts
* @returns {Promise<void>}
*/
async function runAllValidations(virtualHosts) {
if (!virtualHosts || virtualHosts.length === 0) return;
for (const v of virtualHosts) {
@@ -126,6 +145,10 @@ async function runAllValidations(virtualHosts) {
}
}
/**
* Attach all event listeners for the Proxy & CA page.
* Called once during dashboard initialisation.
*/
function setupCertValidator() {
$('runAllValidationsBtn')?.addEventListener('click', () => {
if (currentState) runAllValidations(currentState.virtualHosts || []);
+27 -2
View File
@@ -1,5 +1,9 @@
// Depends on: core/utils.js ($, escapeHtml, log), core/messaging.js (sendToNative),
// ui/toast.js (showToast), ui/modal.js (openModal, closeModal, showModalError)
/**
* Remote Desktop page — connection grid, add/edit modal, VNC viewer (noVNC over WebSocket),
* and RDP bitmap viewer (node-rdpjs-2 over WebSocket with offscreen canvas rendering).
* Depends on: core/utils.js ($, escapeHtml, log), core/messaging.js (sendToNative),
* ui/toast.js (showToast), ui/modal.js (openModal, closeModal, showModalError)
*/
let rdpConnections = [];
let activeRdpSession = null; // { sessionId, wsPort, type, ws, rfb, conn }
@@ -34,6 +38,9 @@ function saveRdpConnections(cb) {
);
}
/**
* Re-render the RDP/VNC connection grid from the current `rdpConnections` array.
*/
function renderRdpGrid() {
const grid = $('rdpGrid');
if (!grid) return;
@@ -108,6 +115,10 @@ function renderRdpGrid() {
});
}
/**
* Open the Add/Edit Remote Desktop Connection modal, pre-populated with `conn` if provided.
* @param {object} [conn] - Existing connection to edit; omit to add a new one.
*/
function openAddRdpModal(conn) {
const isEdit = !!conn;
$('modal-addRdp-title').textContent = isEdit ? 'Edit Remote Desktop Connection' : 'Add Remote Desktop Connection';
@@ -359,6 +370,11 @@ function renderRdpBitmap(ctx, bitmap) {
ctx.drawImage(_rdpOffscreen, destLeft, destTop, drawW, drawH);
}
/**
* Open the viewer modal and start a VNC or RDP session for the given connection.
* @param {object} conn - RDP/VNC connection object with `type`, `hsUrl`, `port`, etc.
* @returns {Promise<void>}
*/
async function connectRdp(conn) {
openModal('modal-rdpViewer');
@@ -410,6 +426,11 @@ async function connectRdp(conn) {
}
}
/**
* Disconnect the active RDP/VNC session: close the WebSocket, stop the protocol client,
* and send a stopRdpSession message to the native host.
* @returns {Promise<void>}
*/
async function disconnectRdp() {
if (!activeRdpSession) return;
const { sessionId, rfb, ws } = activeRdpSession;
@@ -431,6 +452,10 @@ async function disconnectRdp() {
}
}
/**
* Attach all event listeners for the Remote Desktop page.
* Called once during dashboard initialisation.
*/
function setupRdpEvents() {
$('addRdpBtn')?.addEventListener('click', () => openAddRdpModal(null));
+14 -2
View File
@@ -1,5 +1,9 @@
// Depends on: core/utils.js ($, escapeHtml, truncate), ui/toast.js (showToast, copyToClipboard),
// ui/modal.js (openModal, closeModal, showModalError)
/**
* Server Tunnels page — renders the server table, manages bulk selection,
* and wires up start/stop/edit modal events.
* Depends on: core/utils.js ($, escapeHtml, truncate), ui/toast.js (showToast, copyToClipboard),
* ui/modal.js (openModal, closeModal, showModalError)
*/
function _updateServerBulkBar() {
const checked = document.querySelectorAll('#swarmsTable input[type="checkbox"]:checked');
@@ -14,6 +18,10 @@ function _updateServerBulkBar() {
}
}
/**
* Re-render the server tunnels table with the latest state.
* @param {object} state - Full extension state from `fetchState()`.
*/
function updateSwarmsTable(state) {
const tbody = $('swarmsTable');
if (!tbody) return;
@@ -118,6 +126,10 @@ function updateSwarmsTable(state) {
_updateServerBulkBar();
}
/**
* Attach all event listeners for the Server Tunnels page.
* Called once during dashboard initialisation.
*/
function setupServerEvents() {
$('serverSelectAll')?.addEventListener('change', (e) => {
document.querySelectorAll('#swarmsTable .server-row-cb').forEach(cb => { cb.checked = e.target.checked; });
+14 -2
View File
@@ -1,5 +1,9 @@
// Depends on: core/utils.js ($, escapeHtml, truncate), ui/state-tag.js (stateTag),
// ui/toast.js (showToast, copyToClipboard), ui/modal.js (openModal, closeModal, showModalError)
/**
* Service Tunnels page — renders the service tunnels table, manages bulk selection,
* and wires up add/edit/remove modal events.
* Depends on: core/utils.js ($, escapeHtml, truncate), ui/state-tag.js (stateTag),
* ui/toast.js (showToast, copyToClipboard), ui/modal.js (openModal, closeModal, showModalError)
*/
function _updateSvcBulkBar() {
const checked = document.querySelectorAll('#serviceTunnelsTable input[type="checkbox"]:checked');
@@ -14,6 +18,10 @@ function _updateSvcBulkBar() {
}
}
/**
* Re-render the service tunnels table with the latest state.
* @param {object} state - Full extension state from `fetchState()`.
*/
function updateServiceTunnelsTable(state) {
const tbody = $('serviceTunnelsTable');
if (!tbody) return;
@@ -149,6 +157,10 @@ function updateServiceTunnelsTable(state) {
_updateSvcBulkBar();
}
/**
* Attach all event listeners for the Service Tunnels page.
* Called once during dashboard initialisation.
*/
function setupServiceTunnelEvents() {
$('svcSelectAll')?.addEventListener('change', (e) => {
document.querySelectorAll('#serviceTunnelsTable .svc-row-cb').forEach(cb => { cb.checked = e.target.checked; });
+9 -1
View File
@@ -1,5 +1,13 @@
// Depends on: core/utils.js ($), core/state.js (settings, SETTINGS_DEFAULTS), ui/toast.js (showToast)
/**
* Settings page syncs the settings form with the persisted settings object
* and saves changes to the native host.
* Depends on: core/utils.js ($), core/state.js (settings, SETTINGS_DEFAULTS), ui/toast.js (showToast)
*/
/**
* Sync all settings form controls with the current `settings` object.
* Called on every refresh cycle to keep the UI in sync with persisted state.
*/
function updateSettingsUI() {
$('toggleNotify')?.classList.toggle('active', settings.notifyOnDisconnect === true);
$('toggleNotifyTunnelError')?.classList.toggle('active', settings.notifyOnTunnelError !== false);
+30 -2
View File
@@ -1,5 +1,11 @@
// Depends on: core/utils.js ($, escapeHtml, log), core/messaging.js (sendToNative),
// ui/toast.js (showToast, copyToClipboard), ui/modal.js (openModal, closeModal, showModalError)
/**
* SSH Connections page connection grid, add/edit modal, and full in-browser SSH terminal.
* Session lifecycle: start Holesail tunnel open WebSocket spawn SSH via PTY on native host
* bridge PTY WebSocket xterm.js in the browser.
* Handles auto-reconnect with exponential backoff and PTY resize via ResizeObserver.
* Depends on: core/utils.js ($, escapeHtml, log), core/messaging.js (sendToNative),
* ui/toast.js (showToast, copyToClipboard), ui/modal.js (openModal, closeModal, showModalError)
*/
let sshConnections = [];
let activeSshSession = null; // { sessionId, wsPort, term, fitAddon, ws, resizeObserver, conn, dataDisposable }
@@ -51,6 +57,9 @@ function saveSshConnections(cb) {
);
}
/**
* Re-render the SSH connection grid from the current `sshConnections` array.
*/
function renderSshGrid() {
const grid = $('sshGrid');
if (!grid) return;
@@ -118,6 +127,10 @@ function renderSshGrid() {
});
}
/**
* Open the Add/Edit SSH Connection modal, pre-populated with `conn` if provided.
* @param {object} [conn] - Existing connection to edit; omit to add a new one.
*/
function openAddSshModal(conn) {
const isEdit = !!conn;
$('modal-addSsh-title').textContent = isEdit ? 'Edit SSH Connection' : 'Add SSH Connection';
@@ -137,6 +150,12 @@ function updateTermSizeDisplay(term) {
if (el && term) el.textContent = term.cols + '×' + term.rows;
}
/**
* Open the SSH terminal modal and start a new SSH session for the given connection.
* Guards against concurrent calls with `_sshConnecting`. Disconnects any existing session first.
* @param {object} conn - SSH connection object with `hsUrl`, `username`, `password`, etc.
* @returns {Promise<void>}
*/
async function connectSsh(conn) {
if (_sshConnecting) return;
_sshConnecting = true;
@@ -328,6 +347,11 @@ async function _connectSshImpl(conn) {
activeSshSession.resizeObserver = resizeObserver;
}
/**
* Disconnect the active SSH session: close the WebSocket, dispose the xterm terminal,
* disconnect the ResizeObserver, and send a stopSshSession message to the native host.
* @returns {Promise<void>}
*/
async function disconnectSsh() {
if (!activeSshSession) return;
const { sessionId, ws, term, fitAddon, resizeObserver, resizeTimer, dataDisposable } = activeSshSession;
@@ -344,6 +368,10 @@ async function disconnectSsh() {
}
}
/**
* Attach all event listeners for the SSH Connections page.
* Called once during dashboard initialisation.
*/
function setupSshEvents() {
$('addSshBtn')?.addEventListener('click', () => openAddSshModal(null));
+15 -3
View File
@@ -1,6 +1,10 @@
// Depends on: core/utils.js ($, escapeHtml, truncate), ui/state-tag.js (stateTag),
// ui/toast.js (showToast, copyToClipboard), ui/modal.js (openModal),
// data/hostname-validator.js (isValidVhostHostname)
/**
* Virtual Hosts page renders the connections table, manages bulk selection,
* and wires up add/edit/remove modal events.
* Depends on: core/utils.js ($, escapeHtml, truncate), ui/state-tag.js (stateTag),
* ui/toast.js (showToast, copyToClipboard), ui/modal.js (openModal),
* data/hostname-validator.js (isValidVhostHostname)
*/
function _updateVhostBulkBar() {
const checked = document.querySelectorAll('#connectionsTable input[type="checkbox"]:checked');
@@ -15,6 +19,10 @@ function _updateVhostBulkBar() {
}
}
/**
* Re-render the virtual hosts table with the latest state.
* @param {object} state - Full extension state from `fetchState()`.
*/
function updateConnectionsTable(state) {
const tbody = $('connectionsTable');
if (!tbody) return;
@@ -118,6 +126,10 @@ function updateConnectionsTable(state) {
_updateVhostBulkBar();
}
/**
* Attach all event listeners for the Virtual Hosts page.
* Called once during dashboard initialisation.
*/
function setupVirtualHostEvents() {
$('vhostSelectAll')?.addEventListener('change', (e) => {
document.querySelectorAll('#connectionsTable .vhost-row-cb').forEach(cb => { cb.checked = e.target.checked; });
+11
View File
@@ -1,3 +1,8 @@
/**
* Orchestrates a full dashboard state refresh cycle.
* Fetches state from the background service worker, dispatches it to all page
* renderers, pings latency badges, and merges settings defaults.
*/
// Orchestrates a full state refresh cycle.
// Depends on: core/messaging.js (fetchState), core/state.js (settings, SETTINGS_DEFAULTS, currentState),
// pages/ssh.js (sshConnections, renderSshGrid),
@@ -41,6 +46,12 @@ function _pingLatencyBadges() {
});
}
/**
* Fetch the latest extension state and update all dashboard page renderers.
* Also pings latency badges and syncs settings defaults.
* Called on a 2-second interval by `init.js`.
* @returns {Promise<void>}
*/
async function refresh() {
const state = await fetchState();
if (state) {
+19 -1
View File
@@ -1,5 +1,13 @@
// Depends on: core/utils.js ($), core/navigation.js (navigateTo)
/**
* Generic modal helpers for the dashboard.
* Handles open/close animations, error display, backdrop click, and Escape key.
* Depends on: core/utils.js ($), core/navigation.js (navigateTo)
*/
/**
* Open a modal by adding the `open` class and focusing the first input.
* @param {string} id - The modal backdrop element ID.
*/
function openModal(id) {
const el = $(id);
if (!el) return;
@@ -10,6 +18,10 @@ function openModal(id) {
}, 50);
}
/**
* Close a modal by removing the `open` class and clearing any error messages.
* @param {string} id - The modal backdrop element ID.
*/
function closeModal(id) {
const el = $(id);
if (!el) return;
@@ -17,6 +29,12 @@ function closeModal(id) {
el.querySelectorAll('.modal-error').forEach(e => { e.style.display = 'none'; e.textContent = ''; });
}
/**
* Display an error message inside a modal's error element.
* @param {string} modalId - Unused; kept for API symmetry.
* @param {string} errorId - ID of the error display element.
* @param {string} msg - Error message text.
*/
function showModalError(modalId, errorId, msg) {
const el = $(errorId);
if (!el) return;
+10 -1
View File
@@ -1,5 +1,14 @@
// Depends on: core/utils.js (escapeHtml)
/**
* Tunnel state badge renderer.
* Returns an HTML string with a coloured dot and label for the given tunnel state.
* Depends on: core/utils.js (escapeHtml)
*/
/**
* Render a coloured badge for a tunnel state value.
* @param {string} state - Tunnel state: `'ready'`, `'error'`, `'closed'`, `'connecting'`, or custom.
* @returns {string} HTML string for the badge element.
*/
function stateTag(state) {
const dot = (color) => `<span style="display:inline-block;width:7px;height:7px;border-radius:50%;background:var(--${color});margin-right:5px;flex-shrink:0;${color === 'green' ? 'box-shadow:0 0 5px var(--green);' : ''}"></span>`;
if (!state || state === '—') return `<span class="badge badge-neutral">${dot('text4')}—</span>`;
+15 -1
View File
@@ -1,7 +1,15 @@
// Depends on: core/utils.js ($)
/**
* Toast notification and clipboard copy helpers for the dashboard.
* Depends on: core/utils.js ($)
*/
let toastTimer = null;
/**
* Show a toast notification for 2.8 seconds.
* @param {string} msg - Message to display.
* @param {'default'|'success'|'error'} [type='default'] - Visual style class.
*/
function showToast(msg, type = 'default') {
const el = $('toast');
if (!el) return;
@@ -11,6 +19,12 @@ function showToast(msg, type = 'default') {
toastTimer = setTimeout(() => { el.className = 'toast'; }, 2800);
}
/**
* Copy text to the clipboard and show a success toast.
* Briefly adds a `copied` class to `btnEl` for visual feedback.
* @param {string} text - Text to copy.
* @param {HTMLElement} [btnEl] - Optional button element to animate.
*/
function copyToClipboard(text, btnEl) {
navigator.clipboard.writeText(text).then(() => {
if (btnEl) {
+28
View File
@@ -169,6 +169,12 @@ if (_needsGeneration) {
}
}
/**
* Install the root CA certificate into the OS trust store.
* Uses `security add-trusted-cert` on macOS (with GUI sudo prompt),
* `update-ca-certificates` on Linux, and `certutil` on Windows.
* @param {Function} callback - Called as `callback(err)` on completion.
*/
function installRootCA(callback) {
if (typeof callback !== 'function') callback = () => {};
if (!spawn) {
@@ -307,6 +313,15 @@ function installRootCA(callback) {
callback(null);
}
/**
* Get or create a TLS certificate for the given domain, signed by the root CA.
* Certificates are cached on disk in `<certsDir>/<domain>/`. Returns `{ cert, key }`
* buffers, or null if the CA is not yet ready.
* @param {string} domain - Certificate common name / directory key.
* @param {Array<{type: number, value: string}>} altNames - Subject Alternative Names.
* @param {boolean} [forceRegenerate=false] - If true, regenerate even if a cached cert exists.
* @returns {{cert: Buffer, key: Buffer}|null}
*/
function getOrCreateDomainCert(domain, altNames, forceRegenerate) {
if (!domain) return null;
const domainDir = path.join(certsDir, domain.replace(/\*/g, 'wildcard'));
@@ -376,14 +391,27 @@ function getOrCreateDomainCert(domain, altNames, forceRegenerate) {
}
}
/**
* Return the absolute path to the root CA certificate PEM file.
* @returns {string}
*/
function getCaCertPath() {
return caCertPath;
}
/**
* Return the absolute path to the directory where all certificates are stored.
* @returns {string}
*/
function getCertsDir() {
return certsDir;
}
/**
* Check whether the root CA is currently trusted by the OS.
* The check is platform-specific and compares SHA-256 fingerprints.
* @param {Function} callback - Called as `callback(isInstalled: boolean)`.
*/
function isRootCAInstalled(callback) {
if (platform === 'darwin') {
// Check SSL trust policy AND that the fingerprint matches the current CA on disk.
+18
View File
@@ -29,6 +29,16 @@ let server = null;
let listenPort = null;
const activeClientSockets = new Set();
/**
* Start the HTTP CONNECT proxy server.
* If the server is already running the callback is called immediately with no error.
* Exits the process if the port is already in use (EADDRINUSE) this indicates
* another native host instance is running.
* @param {number} [port=8442] - Port to listen on.
* @param {number} [upstreamPort=8443] - Port of the upstream HTTPS proxy to tunnel to.
* @param {Function} [callback] - Called as `callback(err)` once listening (or on error).
* @returns {object|null} The bare-tcp server instance, or null on error.
*/
function start(port, upstreamPort, callback) {
if (server) {
if (callback) callback(null);
@@ -180,10 +190,18 @@ function bufferIndexOf(buf, needle) {
return -1;
}
/**
* Return the port the server is currently listening on, or null if not started.
* @returns {number|null}
*/
function getPort() {
return listenPort;
}
/**
* Destroy all active client sockets and close the server.
* @param {Function} [callback] - Called as `callback(null)` once closed.
*/
function stop(callback) {
if (!server) {
if (callback) callback(null);
+27
View File
@@ -14,6 +14,11 @@ const svcModule = require('./service-tunnels.js');
let eventEmit = null;
/**
* Register the callback used to push tunnel lifecycle events to the extension.
* Must be called before any tunnels are started so events are not lost.
* @param {Function} emit - Called as `emit(eventName, payloadObject)`.
*/
function setEventEmitter(emit) { eventEmit = emit; }
function emit(event, payload) { if (eventEmit) eventEmit(event, payload); }
@@ -52,12 +57,22 @@ svcModule.init(saveState, emit, settingsModule.getReadyTimeoutMs, settingsModule
// ── Storage path ──────────────────────────────────────────────────────────────
/**
* Set the base directory used for state.json persistence.
* Must be called before `restorePersistedState`.
* @param {string} baseDir - Absolute path to the storage directory.
*/
function setStoragePath(baseDir) {
stateModule.setStoragePath(baseDir);
}
// ── Restore persisted state ───────────────────────────────────────────────────
/**
* Load state.json and apply the persisted settings, connection lists, and ID counters
* to all sub-modules. Returns the raw persisted data so the caller can re-start tunnels.
* @returns {{settings: object, servers: Array, virtualHosts: Array, serviceTunnels: Array, sshConnections: Array, rdpConnections: Array}}
*/
function restorePersistedState() {
const loaded = stateModule.loadState();
settingsModule.applyLoaded(loaded.settings);
@@ -76,6 +91,11 @@ function restorePersistedState() {
// ── Cleanup ───────────────────────────────────────────────────────────────────
/**
* Close all active tunnels across all sub-modules.
* Called during native host shutdown to release resources cleanly.
* @returns {Promise<void>}
*/
async function cleanup() {
await serversModule.cleanupServers();
await vhostsModule.cleanupVirtualHosts();
@@ -126,6 +146,13 @@ module.exports = {
let Holesail = null;
try { Holesail = require('holesail'); } catch (_) {}
/**
* Resolve an hs:// key via the DHT and return connection metadata without
* establishing a full tunnel.
* @param {object} payload
* @param {string} payload.hsUrl - The hs:// key to look up.
* @returns {Promise<{ok: boolean, host?: string, port?: number, protocol?: string, secure?: boolean, error?: string}>}
*/
async function lookup(payload) {
if (!Holesail) return { ok: false, error: 'Holesail module not installed' };
const url = payload.url || payload.hsUrl;
@@ -9,11 +9,21 @@ const TUNNEL_PORT_BASE = 19000;
let nextTunnelPortIndex = 0;
const tunnelPortFreeList = [];
/**
* Allocate a unique local port for a virtual host tunnel.
* Recycles ports from the free list before incrementing the counter.
* @returns {number} An available port number >= TUNNEL_PORT_BASE.
*/
function allocateTunnelPort() {
if (tunnelPortFreeList.length > 0) return tunnelPortFreeList.pop();
return TUNNEL_PORT_BASE + (nextTunnelPortIndex++);
}
/**
* Return a port to the free list so it can be reused by a future allocation.
* Silently ignores invalid ports and duplicate releases.
* @param {number} port - The port to release. Must be >= TUNNEL_PORT_BASE.
*/
function releaseTunnelPort(port) {
if (typeof port === 'number' && port >= TUNNEL_PORT_BASE && !tunnelPortFreeList.includes(port)) {
tunnelPortFreeList.push(port);
+36
View File
@@ -20,11 +20,22 @@ let nextServerId = 0;
let _saveState = null;
let _getReadyTimeoutMs = null;
/**
* Inject shared dependencies from the parent holesail-manager.
* Must be called once before any other function in this module.
* @param {Function} saveStateFn - Callback that persists the current state to disk.
* @param {Function} getReadyTimeoutMsFn - Returns the configured ready-timeout in ms (0 = no timeout).
*/
function init(saveStateFn, getReadyTimeoutMsFn) {
_saveState = saveStateFn;
_getReadyTimeoutMs = getReadyTimeoutMsFn;
}
/**
* Advance the server ID counter to be at least as high as the persisted value,
* ensuring IDs generated after a restart do not collide with existing ones.
* @param {number} loadedNextServerId - The `nextServerId` value read from state.json.
*/
function applyLoaded(loadedNextServerId) {
if (loadedNextServerId > nextServerId) nextServerId = loadedNextServerId;
}
@@ -58,6 +69,17 @@ function readyWithTimeout(hs, label) {
});
}
/**
* Start a Holesail server tunnel, exposing a local port to the P2P network.
* @param {object} payload
* @param {number} [payload.port=3000] - Local TCP port to expose.
* @param {string} [payload.host='127.0.0.1'] - Local host to bind.
* @param {boolean} [payload.secure=true] - Whether to use an encrypted (private) tunnel.
* @param {boolean} [payload.udp=false] - Whether to use UDP mode.
* @param {string} [payload.label=''] - Human-readable label for the UI.
* @param {string} [payload.serverId] - Optional explicit ID; auto-generated if omitted.
* @returns {Promise<{ok: boolean, serverId?: string, url?: string, port?: number, host?: string, secure?: boolean, udp?: boolean, label?: string, error?: string}>}
*/
async function startServer(payload) {
if (!Holesail) return { ok: false, error: 'Holesail module not installed' };
const port = payload.port || 3000;
@@ -90,6 +112,12 @@ async function startServer(payload) {
}
}
/**
* Stop a running server tunnel and remove it from state.
* @param {object} payload
* @param {string} payload.serverId - ID of the server tunnel to stop.
* @returns {Promise<{ok: boolean, error?: string}>}
*/
async function stopServer(payload) {
const serverId = payload.serverId;
debugLog('stopServer: serverId=', serverId);
@@ -104,6 +132,10 @@ async function stopServer(payload) {
return { ok: true };
}
/**
* Return a snapshot of all active server tunnels.
* @returns {Array<{id: string, serverId: string, port: number, host: string, url: string, secure: boolean, udp: boolean, label: string, createdAt: number}>}
*/
function getServers() {
const list = [];
for (const [id, s] of servers) {
@@ -114,6 +146,10 @@ function getServers() {
function getNextServerId() { return nextServerId; }
/**
* Close all active server tunnels. Called during native host shutdown.
* @returns {Promise<void>}
*/
async function cleanupServers() {
for (const [, s] of servers) {
try { await s.holesail.close(); } catch (_) {}
@@ -28,6 +28,14 @@ let _getAutoReconnect = null;
const RECONNECT_BASE_MS = 5000;
const RECONNECT_MAX_MS = 120000;
/**
* Inject shared dependencies from the parent holesail-manager.
* Must be called once before any other function in this module.
* @param {Function} saveStateFn - Callback that persists the current state to disk.
* @param {Function} emitFn - Callback to emit tunnel lifecycle events to the extension.
* @param {Function} getReadyTimeoutMsFn - Returns the configured ready-timeout in ms (0 = no timeout).
* @param {Function} getAutoReconnectFn - Returns whether auto-reconnect is enabled.
*/
function init(saveStateFn, emitFn, getReadyTimeoutMsFn, getAutoReconnectFn) {
_saveState = saveStateFn;
_emit = emitFn;
@@ -53,6 +61,10 @@ function _scheduleSvcReconnect(tunnelId) {
}, delay);
}
/**
* Advance the service tunnel ID counter to avoid collisions after a restart.
* @param {number} loadedNextServiceTunnelId - The `nextServiceTunnelId` value read from state.json.
*/
function applyLoaded(loadedNextServiceTunnelId) {
if (loadedNextServiceTunnelId > nextServiceTunnelId) nextServiceTunnelId = loadedNextServiceTunnelId;
}
@@ -71,6 +83,16 @@ function readyWithTimeout(hs, label) {
});
}
/**
* Start a service tunnel, connecting a remote Holesail peer to a local port.
* Any TCP client can connect to `127.0.0.1:<localPort>` directly (no HTTP proxy).
* @param {object} payload
* @param {string} payload.label - Human-readable label (required).
* @param {string} payload.hsUrl - The hs:// key of the remote peer (required).
* @param {number} payload.localPort - Local port to bind (165535, required).
* @param {string} [payload.tunnelId] - Optional explicit ID; auto-generated if omitted.
* @returns {Promise<{ok: boolean, tunnelId?: string, label?: string, hsUrl?: string, localPort?: number, state?: string, error?: string}>}
*/
async function startServiceTunnel(payload) {
if (!Holesail) return { ok: false, error: 'Holesail module not installed' };
const { label, hsUrl, localPort } = payload;
@@ -118,6 +140,12 @@ async function startServiceTunnel(payload) {
}
}
/**
* Stop a service tunnel, close the Holesail connection, and remove it from state.
* @param {object} payload
* @param {string} payload.tunnelId - ID of the service tunnel to stop.
* @returns {Promise<{ok: boolean, error?: string}>}
*/
async function stopServiceTunnel(payload) {
const { tunnelId } = payload;
debugLog('stopServiceTunnel: id=', tunnelId);
@@ -131,6 +159,10 @@ async function stopServiceTunnel(payload) {
return { ok: true };
}
/**
* Return a snapshot of all service tunnels (including errored/closed ones).
* @returns {Array<{id: string, label: string, hsUrl: string, localPort: number, state: string, createdAt: number}>}
*/
function getServiceTunnels() {
const list = [];
for (const [id, t] of serviceTunnels) {
@@ -141,6 +173,11 @@ function getServiceTunnels() {
function getNextServiceTunnelId() { return nextServiceTunnelId; }
/**
* Cancel all reconnect timers and close all service tunnel connections.
* Called during native host shutdown.
* @returns {Promise<void>}
*/
async function cleanupServiceTunnels() {
for (const [, t] of serviceTunnels) {
if (t.reconnectTimer) { clearTimeout(t.reconnectTimer); t.reconnectTimer = null; }
@@ -28,6 +28,14 @@ let _getAutoReconnect = null;
const RECONNECT_BASE_MS = 5000;
const RECONNECT_MAX_MS = 120000;
/**
* Inject shared dependencies from the parent holesail-manager.
* Must be called once before any other function in this module.
* @param {Function} saveStateFn - Callback that persists the current state to disk.
* @param {Function} emitFn - Callback to emit tunnel lifecycle events to the extension.
* @param {Function} getReadyTimeoutMsFn - Returns the configured ready-timeout in ms (0 = no timeout).
* @param {Function} getAutoReconnectFn - Returns whether auto-reconnect is enabled.
*/
function init(saveStateFn, emitFn, getReadyTimeoutMsFn, getAutoReconnectFn) {
_saveState = saveStateFn;
_emit = emitFn;
@@ -62,6 +70,16 @@ function readyWithTimeout(hs, label) {
});
}
/**
* Create or replace a virtual host tunnel.
* Connects to the remote Holesail peer and binds it to a dynamically allocated
* local port so the HTTPS proxy can forward browser requests to it.
* If a tunnel already exists for the hostname it is closed and replaced.
* @param {object} payload
* @param {string} payload.hostname - The virtual hostname (e.g. `myapp.hs`). Normalised to lowercase.
* @param {string} payload.hsUrl - The hs:// key of the remote peer.
* @returns {Promise<{ok: boolean, hostname?: string, localHost?: string, localPort?: number, state?: string, error?: string}>}
*/
async function setVirtualHost(payload) {
let hostname = (payload.hostname || payload.hostName || '').trim();
hostname = hostname.replace(/^https?:\/\//i, '').replace(/[/:?#].*$/, '').toLowerCase().trim();
@@ -111,6 +129,12 @@ async function setVirtualHost(payload) {
}
}
/**
* Remove a virtual host, close its tunnel, and release its local port.
* @param {object} payload
* @param {string} payload.hostname - The hostname to remove.
* @returns {Promise<{ok: boolean, error?: string}>}
*/
async function removeVirtualHost(payload) {
const hostname = payload.hostname || payload.hostName;
debugLog('removeVirtualHost: hostname=', hostname);
@@ -125,6 +149,10 @@ async function removeVirtualHost(payload) {
return { ok: true };
}
/**
* Return a snapshot of all virtual hosts (including errored/closed ones).
* @returns {Array<{hostname: string, hsUrl: string, localHost: string|null, localPort: number|null, state: string, createdAt: number}>}
*/
function getVirtualHosts() {
const list = [];
for (const [hostname, v] of virtualHosts) {
@@ -133,11 +161,22 @@ function getVirtualHosts() {
return list;
}
/**
* Return the local tunnel port for a hostname, or null if not ready.
* @param {string} hostname
* @returns {number|null}
*/
function getLocalPortForHostname(hostname) {
const v = virtualHosts.get(hostname);
return v && v.localPort != null ? v.localPort : null;
}
/**
* Return the `{ host, port }` backend object for the HTTPS proxy SNI resolver,
* or null if the virtual host does not exist or is not yet ready.
* @param {string} hostname
* @returns {{host: string, port: number}|null}
*/
function getLocalBackend(hostname) {
const v = virtualHosts.get(hostname);
const out = (!v || v.localPort == null) ? null : { host: v.localHost ?? '127.0.0.1', port: v.localPort };
@@ -153,6 +192,11 @@ function getVirtualHostMap() {
return m;
}
/**
* Cancel all reconnect timers and close all virtual host tunnels.
* Called during native host shutdown.
* @returns {Promise<void>}
*/
async function cleanupVirtualHosts() {
for (const [, v] of virtualHosts) {
if (v.reconnectTimer) { clearTimeout(v.reconnectTimer); v.reconnectTimer = null; }
+25
View File
@@ -12,6 +12,14 @@ const CONNECT_PROXY_PORT = 8442;
let proxiesReadyPromise = null;
let tunnelsRestoredPromise = null;
/**
* Re-start all tunnels that were persisted in state.json.
* Runs after the proxies are ready so tunnels have a working backend to connect through.
* Logs per-type success/failure counts but never throws failures are non-fatal.
* @param {object} holesailManager - The holesail manager module.
* @param {object} restored - The persisted state object returned by `holesailManager.restorePersistedState()`.
* @returns {Promise<void>}
*/
async function restorePersistedTunnels(holesailManager, restored) {
try {
debugLog('restorePersistedTunnels: starting');
@@ -48,6 +56,16 @@ async function restorePersistedTunnels(holesailManager, restored) {
}
}
/**
* Kick off the async startup sequence: restore settings, start the CA, start both
* proxies, then begin restoring persisted tunnels in the background.
* Sets `proxiesReadyPromise` which resolves once the proxies are up (or have failed).
* Sets `tunnelsRestoredPromise` which resolves once all tunnels have been re-started.
* @param {object} holesailManager
* @param {object} certificateAuthority
* @param {object} httpsProxy
* @param {object} connectProxy
*/
function initStartup(holesailManager, certificateAuthority, httpsProxy, connectProxy) {
if (typeof setImmediate !== 'function') return;
@@ -106,8 +124,15 @@ function initStartup(holesailManager, certificateAuthority, httpsProxy, connectP
});
}
/** @returns {Promise<void>|null} Resolves when both proxies have started (or failed). */
function getProxiesReadyPromise() { return proxiesReadyPromise; }
/** @returns {Promise<void>|null} Resolves when all persisted tunnels have been re-started. */
function getTunnelsRestoredPromise() { return tunnelsRestoredPromise; }
/**
* Override the tunnels-restored promise (used by message-router to assign it
* before proxiesReadyPromise resolves, avoiding a one-microtask null window).
* @param {Promise<void>} p
*/
function setTunnelsRestoredPromise(p) { tunnelsRestoredPromise = p; }
module.exports = {
+35
View File
@@ -42,12 +42,24 @@ let proxyCertsDirOrCA = null;
const trafficStats = { bytesIn: 0, bytesOut: 0, requests: 0 };
/**
* Return a snapshot of cumulative traffic counters since the last reset.
* @returns {{bytesIn: number, bytesOut: number, requests: number}}
*/
function getTrafficStats() { return { ...trafficStats }; }
/**
* Reset all traffic counters to zero.
*/
function resetTrafficStats() { trafficStats.bytesIn = 0; trafficStats.bytesOut = 0; trafficStats.requests = 0; }
/** Resolver: hostname -> { host, port } | port | null */
let getBackendForHostname = null;
/**
* Register the function used to resolve a virtual hostname to its local backend.
* Must be called before `start`. Called by message-router with `holesailManager.getLocalBackend`.
* @param {Function} fn - Called as `fn(hostname)` and should return `{host, port}` or null.
*/
function setHostnameResolver (fn) {
getBackendForHostname = fn;
}
@@ -513,6 +525,15 @@ class FakeHttpServer extends EventEmitter {
// Public API
// ---------------------------------------------------------------------------
/**
* Start the SNI-aware HTTPS reverse proxy.
* Exits the process if the port is already in use (another instance running).
* @param {number} [port=8443] - Port to listen on.
* @param {object} certsDirOrCA - The certificate-authority module (provides `getOrCreateWildcardCert`).
* @param {Function} [callback] - Called as `callback(err)` once listening.
* @param {string[]} [_baseDomains] - Reserved; unused in current implementation.
* @returns {object} The fake HTTP server EventEmitter.
*/
function start (port, certsDirOrCA, callback, _baseDomains) {
if (proxyServer) {
if (callback) callback(null);
@@ -562,6 +583,10 @@ function start (port, certsDirOrCA, callback, _baseDomains) {
return fakeServer;
}
/**
* Destroy all active connections and close the TCP server.
* @param {Function} [callback] - Called once the server is fully closed.
*/
function stop (callback) {
if (!proxyServer) {
if (callback) callback();
@@ -590,6 +615,12 @@ function stop (callback) {
});
}
/**
* Stop and restart the proxy on the same port, re-using the existing CA.
* Used when the proxy port setting changes.
* @param {string[]} baseDomains - Unused; reserved for future pre-generation of certs.
* @param {Function} [callback] - Called as `callback(err)` once the new server is listening.
*/
function restart (baseDomains, callback) {
const savedPort = proxyPort || DEFAULT_PORT;
const ca = proxyCertsDirOrCA;
@@ -602,6 +633,10 @@ function restart (baseDomains, callback) {
});
}
/**
* Return the port the proxy is currently listening on, or null if not started.
* @returns {number|null}
*/
function getPort () {
return proxyPort;
}
+26
View File
@@ -352,6 +352,17 @@ async function startRdpSession(sessionId, holesailInst, tunnelPort, wsPort, payl
// ── Public API ───────────────────────────────────────────────────────────────
/**
* Start a VNC or RDP session over a Holesail tunnel.
* Allocates a tunnel port and a WebSocket port, connects to the remote peer,
* then delegates to `startVncSession` or `startRdpSession`.
* @param {object} payload
* @param {'vnc'|'rdp'} payload.type - Protocol to use.
* @param {string} payload.hsUrl - hs:// key of the remote desktop server.
* @param {number} [payload.port] - Remote desktop port (default: 5900 for VNC, 3389 for RDP).
* @param {string} [payload.label=''] - Human-readable label for the UI.
* @returns {Promise<{ok: boolean, sessionId?: string, wsPort?: number, error?: string}>}
*/
async function startSession(payload) {
const { type, hsUrl, port, label = '' } = payload;
@@ -401,6 +412,13 @@ async function startSession(payload) {
return result;
}
/**
* Stop a VNC or RDP session: close the protocol client, WebSocket server,
* Holesail tunnel, and release all allocated ports.
* @param {object} payload
* @param {string} payload.sessionId
* @returns {Promise<{ok: boolean, error?: string}>}
*/
async function stopSession(payload) {
const { sessionId } = payload;
const sess = sessions.get(sessionId);
@@ -425,6 +443,10 @@ async function stopSession(payload) {
return { ok: true };
}
/**
* Return a snapshot of all active RDP/VNC sessions.
* @returns {Array<{sessionId: string, type: string, label: string, wsPort: number, state: string, width: number, height: number, createdAt: number}>}
*/
function getSessions() {
const list = [];
for (const [sessionId, s] of sessions) {
@@ -442,6 +464,10 @@ function getSessions() {
return list;
}
/**
* Stop all active RDP/VNC sessions. Called during native host shutdown.
* @returns {Promise<void>}
*/
async function cleanup() {
for (const [sessionId] of sessions) {
await stopSession({ sessionId }).catch(() => {});
+35
View File
@@ -74,6 +74,18 @@ let nextSessionId = 1;
// Session management
// ---------------------------------------------------------------------------
/**
* Start a new SSH session: allocate a Holesail tunnel, start a WebSocket server,
* wait for the browser terminal to connect, then spawn the SSH process via PTY.
* @param {object} payload
* @param {string} payload.hsUrl - hs:// key of the remote SSH server.
* @param {string} payload.username - SSH username.
* @param {string} [payload.password=''] - SSH password (empty for key-based auth).
* @param {number} [payload.cols=80] - Initial terminal width in columns.
* @param {number} [payload.rows=24] - Initial terminal height in rows.
* @param {string} [payload.label=''] - Human-readable label for the UI.
* @returns {Promise<{ok: boolean, sessionId?: string, wsPort?: number, error?: string}>}
*/
async function startSession(payload) {
const { hsUrl, username, password = '', cols = 80, rows = 24, label = '' } = payload;
@@ -497,6 +509,14 @@ async function startSession(payload) {
return { ok: true, sessionId, wsPort };
}
/**
* Resize the PTY of an active SSH session.
* Silently ignored if the session does not exist or has no PTY.
* @param {object} payload
* @param {string} payload.sessionId
* @param {number} payload.cols - New terminal width.
* @param {number} payload.rows - New terminal height.
*/
function resizeSession(payload) {
const { sessionId, cols, rows } = payload;
const sess = sessions.get(sessionId);
@@ -510,6 +530,13 @@ function resizeSession(payload) {
try { sess.pty.resize(cols, rows); } catch (_) {}
}
/**
* Stop an SSH session: kill the PTY process, close the WebSocket server,
* close the Holesail tunnel, and release all allocated ports.
* @param {object} payload
* @param {string} payload.sessionId
* @returns {Promise<{ok: boolean, error?: string}>}
*/
async function stopSession(payload) {
const { sessionId } = payload;
const sess = sessions.get(sessionId);
@@ -529,6 +556,10 @@ async function stopSession(payload) {
return { ok: true };
}
/**
* Return a snapshot of all active SSH sessions.
* @returns {Array<{sessionId: string, label: string, username: string, hsUrl: string, wsPort: number, state: string, cols: number, rows: number, createdAt: number}>}
*/
function getSessions() {
const list = [];
for (const [sessionId, s] of sessions) {
@@ -547,6 +578,10 @@ function getSessions() {
return list;
}
/**
* Stop all active SSH sessions. Called during native host shutdown.
* @returns {Promise<void>}
*/
async function cleanup() {
for (const [sessionId] of sessions) {
await stopSession({ sessionId }).catch(() => {});
+9 -2
View File
@@ -1,7 +1,14 @@
#!/usr/bin/env node
/**
* Build script for the native host.
* Generates the holesail-browser-host launcher script.
* Build script for the native host launcher.
*
* Generates `native-host/holesail-browser-host` a bash script that invokes
* `bare native-host/index.mjs`. The `bare` binary is located by:
* 1. `which bare` (PATH lookup)
* 2. Common Homebrew paths (/opt/homebrew/bin/bare, /usr/local/bin/bare)
* 3. Sibling of the current `node` binary as a last resort
*
* Run via: npm run build:host
*/
const fs = require('fs');
+7
View File
@@ -1,5 +1,12 @@
#!/usr/bin/env node
'use strict';
/**
* Cross-platform installer launcher.
* Spawns `scripts/install.sh` on macOS/Linux or `scripts/install.ps1` on Windows
* (via PowerShell with -ExecutionPolicy Bypass). Inherits stdio and forwards the exit code.
*
* Run via: npm run setup
*/
var path = require('path');
var spawn = require('child_process').spawn;
var isWin = process.platform === 'win32';