Files
holesail-browser/extension/background.js
T
Raven Scott 82c75b289e
CI / Build & Test (push) Successful in 3m18s
fix: correct SSL cert for custom TLDs and immediate PAC update on vhost changes
- Replace SNI callback approach (unsupported by bare-tls) with a multi-SAN
  wildcard cert covering all active base domains (e.g. *.hole.sail, *.heheh.jungle)
- Add buildMultiSanCert() in https-proxy.js — cert key encodes sorted domain list
  so cache invalidates automatically when TLDs are added or removed
- Add restart(baseDomains, callback) to https-proxy.js to stop and restart the
  proxy with a fresh cert without requiring a native host restart
- Add getActiveBaseDomains() and refreshProxyCert() in host.js; call after every
  successful setVirtualHost and removeVirtualHost
- Fix background.js send handler to fetch updated virtualHosts via getState and
  re-apply PAC immediately after setVirtualHost or removeVirtualHost succeeds,
  so new TLDs are routed without waiting for the next dashboard refresh
- Remove unused tls require from https-proxy.js
- Remove invalid "permissions" entry from manifest.json permissions array
2026-02-28 20:30:26 -05:00

610 lines
22 KiB
JavaScript

/**
* Background service worker: maintains native messaging port and routes messages
* between content scripts and the native host. Reconnects with backoff on disconnect.
*/
const HOST_NAME = 'com.holesail.browser';
const MAX_RECONNECT_DELAY = 30000;
const INITIAL_RECONNECT_DELAY = 100;
const SW_VERSION = 3; // increment to force service worker reload detection
/** Set to true for super detailed debug logging (every message, payload, storage, PAC). Set to false to reduce console noise. */
const DEBUG_VERBOSE = false;
function log(...args) {
console.log('[Holesail-bg]', ...args);
// Store logs for dashboard
const timestamp = Date.now();
const message = args.map(a => typeof a === 'object' ? JSON.stringify(a) : String(a)).join(' ');
logs.push({ timestamp, message, level: 'info' });
if (logs.length > MAX_LOGS) logs.shift();
broadcastLogs();
}
function debugLog(...args) {
if (!DEBUG_VERBOSE) return;
log('[debug]', ...args);
}
const logs = [];
const MAX_LOGS = 500;
function broadcastLogs() {
// Send all buffered logs to any dashboard that's open
for (const tabId of dashboardTabs) {
browser.tabs.sendMessage(tabId, { type: 'holesail-logs', logs: logs.slice(0) }).catch(() => {});
}
}
// Track dashboard tabs
const dashboardTabs = new Set();
const browser = (typeof chrome !== 'undefined' && chrome.runtime?.connectNative)
? chrome
: (globalThis.browser ?? chrome);
let port = null;
let reconnectDelay = INITIAL_RECONNECT_DELAY;
let reconnectTimer = null;
// Pending requests: id -> { resolve, reject }
const pending = new Map();
// Tabs that have subscribed to native host events (content script registered)
const subscribedTabs = new Set();
// Track which swarmIds belong to which tab: tabId -> Set<swarmId>
const tabSwarms = new Map();
// Track how many tabs are using each swarm: swarmId -> count
const swarmRefCount = new Map();
// Track active connections from native host: connId -> { swarmId, peerKey, createdAt }
const activeConnections = new Map();
let notifyOnDisconnect = false;
// Extension state for the UI
let pacConfirmedActive = false; // true once proxy.settings.get confirms mode=pac_script controlled_by_this_extension
const extensionState = {
hostConnected: false,
proxyPort: 8443,
connectProxyPort: null, // PAC uses this when set (CONNECT proxy); else proxyPort
servers: [], // Holesail server tunnels
virtualHosts: [], // virtual hostname -> hsUrl mappings
stats: {
totalConnections: 0,
uptime: Date.now()
}
};
const DEFAULT_PROXY_PORT = 8443;
/** CONNECT proxy port (browser must send CONNECT here, not to HTTPS port). Use when host omits connectProxyPort. */
const DEFAULT_CONNECT_PROXY_PORT = 8442;
/** Extract unique two-label base domains from virtualHosts array, always including hole.sail */
function getActiveTlds(virtualHosts) {
const seen = new Set(['hole.sail']);
for (const v of (virtualHosts || [])) {
if (v && v.hostname) {
const parts = v.hostname.split('.');
if (parts.length >= 3) seen.add(parts.slice(-2).join('.'));
}
}
return Array.from(seen).map(b => '.' + b);
}
function applyPAC(tlds) {
if (!browser.proxy || !browser.proxy.settings) {
log('applyPAC: SKIPPED - no browser.proxy.settings API');
return;
}
const port = extensionState.connectProxyPort ?? DEFAULT_CONNECT_PROXY_PORT ?? extensionState.proxyPort ?? DEFAULT_PROXY_PORT;
const activeTlds = tlds || getActiveTlds(extensionState.virtualHosts);
log('applyPAC: setting PAC port=', port, 'tlds=', activeTlds);
const clauses = activeTlds.map(t =>
` if (dnsDomainIs(host, "${t}")) return "PROXY 127.0.0.1:${port}";`
).join('\n');
const pacData = `function FindProxyForURL(url, host) {\n${clauses}\n return "DIRECT";\n}`;
browser.proxy.settings.set(
{ value: { mode: 'pac_script', pacScript: { data: pacData, mandatory: false } }, scope: 'regular' },
() => {
if (browser.runtime.lastError) {
log('applyPAC: ERROR:', browser.runtime.lastError.message);
pacConfirmedActive = false;
return;
}
browser.proxy.settings.get({}, (details) => {
if (browser.runtime.lastError) {
log('applyPAC: get ERROR:', browser.runtime.lastError.message);
return;
}
const mode = details && details.value && details.value.mode;
const loc = details && details.levelOfControl;
log('applyPAC: mode=', mode, 'levelOfControl=', loc);
if (loc === 'controlled_by_this_extension' && mode === 'pac_script') {
pacConfirmedActive = true;
log('applyPAC: ACTIVE');
} else {
pacConfirmedActive = false;
log('applyPAC: WARNING not active - mode=', mode, 'loc=', loc);
}
});
}
);
}
// Re-apply PAC whenever proxy settings change (e.g. another extension or system override)
if (browser.proxy && browser.proxy.settings && browser.proxy.settings.onChange) {
browser.proxy.settings.onChange.addListener((details) => {
const loc = details && details.levelOfControl;
const mode = details && details.value && details.value.mode;
log('proxy.settings.onChange: levelOfControl=', loc, 'mode=', mode);
if (loc === 'controlled_by_this_extension' && mode === 'pac_script') {
pacConfirmedActive = true;
return;
}
pacConfirmedActive = false;
log('proxy settings changed externally - reapplying PAC');
applyPAC();
});
}
function clearProxy() {
debugLog('clearProxy');
if (!browser.proxy || !browser.proxy.settings) return;
browser.proxy.settings.set({ value: { mode: 'direct' }, scope: 'regular' }).catch(() => {});
}
function retryGetStateForConnectProxy(delaySeconds, attempt = 0) {
const maxAttempts = 3;
if (attempt >= maxAttempts || !port) return;
const delayMs = delaySeconds * (attempt + 1) * 1000;
setTimeout(() => {
if (!port) return;
send({ type: 'getState', payload: {} })
.then((payload) => {
if (payload && payload.ok && payload.connectProxyPort != null) {
extensionState.connectProxyPort = payload.connectProxyPort;
extensionState.proxyPort = payload.proxyPort ?? null;
extensionState.virtualHosts = payload.virtualHosts || extensionState.virtualHosts;
applyPAC(getActiveTlds(extensionState.virtualHosts));
} else if (payload && payload.ok && attempt + 1 < maxAttempts) {
retryGetStateForConnectProxy(delaySeconds, attempt + 1);
}
})
.catch(() => {});
}, delayMs);
}
function updateExtensionState() {
extensionState.hostConnected = !!port;
extensionState.stats.totalConnections = activeConnections.size;
}
function connect() {
debugLog('connect: attempting connectNative', HOST_NAME);
try {
port = browser.runtime.connectNative(HOST_NAME);
log('Connected to native host');
debugLog('connect: connected, fetching getState then apply/clear PAC');
send({ type: 'getState', payload: {} })
.then((payload) => {
if (payload && payload.ok) {
extensionState.proxyPort = payload.proxyPort ?? null;
extensionState.connectProxyPort = payload.connectProxyPort ?? null;
extensionState.virtualHosts = payload.virtualHosts || [];
// Sync notifyOnDisconnect from persisted settings
if (payload.settings && typeof payload.settings.notifyOnDisconnect === 'boolean') {
notifyOnDisconnect = payload.settings.notifyOnDisconnect;
}
applyPAC(getActiveTlds(extensionState.virtualHosts));
if (extensionState.connectProxyPort == null) retryGetStateForConnectProxy(2);
} else {
applyPAC(); // Apply PAC even if getState fails - use default port
}
})
.catch(() => applyPAC()); // Apply PAC even on error - use default port
} catch (e) {
log('connectNative failed:', e);
debugLog('connect: failed', e.message, e.stack);
scheduleReconnect();
return;
}
reconnectDelay = INITIAL_RECONNECT_DELAY;
port.onMessage.addListener((msg) => {
debugLog('onMessage: full msg=', JSON.stringify(msg));
log('Received from native:', msg.type, msg.event || '');
// Track connection events
if (msg.type === 'event') {
const payload = msg.payload || {};
if (msg.event === 'connection') {
const connId = payload.connId;
const swarmId = payload.swarmId;
const peerInfo = payload.peerInfo || {};
log('Connection established:', connId, 'peer:', peerInfo.publicKey?.slice(0, 8));
activeConnections.set(connId, {
connId,
swarmId,
peerKey: peerInfo.publicKey || '',
createdAt: Date.now()
});
updateExtensionState();
} else if (msg.event === 'error') {
const connId = payload.connId;
const errorMsg = payload.message || 'Unknown error';
log('Connection ERROR:', connId, errorMsg);
activeConnections.delete(connId);
updateExtensionState();
} else if (msg.event === 'end') {
const connId = payload.connId;
log('Connection ended:', connId);
activeConnections.delete(connId);
updateExtensionState();
}
}
if (msg.type === 'response' && msg.id != null) {
debugLog('onMessage: response id=', msg.id, 'payloadKeys=', msg.payload && Object.keys(msg.payload), 'payload=', msg.payload && JSON.stringify(msg.payload).slice(0, 300));
const p = pending.get(msg.id);
if (p) {
pending.delete(msg.id);
if (msg.payload && msg.payload.error) {
p.reject(new Error(msg.payload.error));
} else {
p.resolve(msg.payload);
}
} else {
debugLog('onMessage: response id=', msg.id, 'no pending handler');
}
return;
}
if (msg.type === 'event') {
const p = msg.payload || {};
const eventSwarmId = p.swarmId;
const isTunnelEvent = msg.event === 'tunnelReady' || msg.event === 'tunnelClosed' || msg.event === 'tunnelError';
if (isTunnelEvent) {
log('Broadcasting event to tabs:', msg.event, 'hostname:', p.hostname);
} else {
log('Broadcasting event to tabs:', msg.event, 'connId:', p.connId, 'swarmId:', eventSwarmId);
}
if (eventSwarmId != null) {
for (const [tabId, swarmIds] of tabSwarms) {
if (swarmIds.has(eventSwarmId)) {
browser.tabs.sendMessage(tabId, { type: 'holesail-event', payload: msg }).catch(() => {});
}
}
}
if (isTunnelEvent) {
for (const tabId of subscribedTabs) {
browser.tabs.sendMessage(tabId, { type: 'holesail-event', payload: msg }).catch(() => {});
}
}
}
});
port.onDisconnect.addListener(() => {
debugLog('onDisconnect');
log('Native host disconnected');
extensionState.hostConnected = false;
activeConnections.clear();
updateExtensionState();
clearProxy();
port = null;
if (notifyOnDisconnect && browser.notifications) {
browser.notifications.create('holesail-host-disconnect', {
type: 'basic',
title: 'Holesail Browser',
message: 'Native host disconnected.',
iconUrl: browser.runtime.getURL('icons/48.png'),
}).catch(() => {});
}
const err = new Error('Native host disconnected');
for (const [id, p] of pending) {
p.reject(err);
}
pending.clear();
for (const tabId of subscribedTabs) {
browser.tabs.sendMessage(tabId, { type: 'holesail-host-disconnect' }).catch(() => {});
}
scheduleReconnect();
});
}
function scheduleReconnect() {
if (reconnectTimer) return;
reconnectTimer = setTimeout(() => {
reconnectTimer = null;
connect();
reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY);
}, 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;
debugLog('send: id=', id, 'type=', msg.type, 'payload=', msg.payload && JSON.stringify(msg.payload));
log('Sending to native:', msg.type, msg.payload?.swarmId || '', msg.payload?.connId || '');
return new Promise((resolve, reject) => {
if (!port) {
debugLog('send: no port, rejecting');
reject(new Error('Native host not connected'));
return;
}
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);
}
});
}
log('background: SW started v' + SW_VERSION);
applyPAC(); // Apply PAC immediately on startup using default port; updated once native host connects
connect();
// Redirect *.host.test (and similar typos) to extension page so we can show "use .hole.sail"
const WRONG_DOMAIN_RULE_ID = 1;
if (browser.declarativeNetRequest && browser.runtime.getURL) {
const redirectUrl = browser.runtime.getURL('wrong-domain.html') + '?host=\\1';
browser.declarativeNetRequest.updateDynamicRules({
removeRuleIds: [WRONG_DOMAIN_RULE_ID],
addRules: [{
id: WRONG_DOMAIN_RULE_ID,
priority: 1,
condition: {
regexFilter: '^https?://([^/:]+)\\.host\\.test($|/)',
resourceTypes: ['main_frame']
},
action: {
type: 'redirect',
redirect: { regexSubstitution: redirectUrl }
}
}]
}).catch((err) => log('declarativeNetRequest rule failed:', err.message));
}
// Handle tab closure - destroy swarms only when last tab using them closes
browser.tabs.onRemoved.addListener((tabId) => {
log('Tab closed:', tabId);
const swarmIds = tabSwarms.get(tabId);
if (swarmIds) {
for (const swarmId of swarmIds) {
const count = (swarmRefCount.get(swarmId) || 1) - 1;
swarmRefCount.set(swarmId, count);
log('Swarm', swarmId, 'refcount now:', count);
// Only destroy when last tab closes
if (count <= 0) {
log('Last tab for swarm', swarmId, '- destroying');
send({ type: 'destroy', payload: { swarmId } }).catch(() => {});
swarmRefCount.delete(swarmId);
}
}
tabSwarms.delete(tabId);
}
subscribedTabs.delete(tabId);
dashboardTabs.delete(tabId);
});
// Content script talks to background via runtime.sendMessage / onMessage
browser.runtime.onMessage.addListener((message, sender, sendResponse) => {
let responded = false;
function reply(value) {
if (responded) return;
responded = true;
try { sendResponse(value); } catch (_) {}
}
if (message.target !== 'holesail-native') {
reply({ error: 'Unknown target' });
return false;
}
// Handle registerSwarm locally (legacy) - don't forward to native host
if (message.action === 'send' && message.payload?.type === 'registerSwarm') {
const swarmId = message.payload?.payload?.swarmId;
if (swarmId && sender.tab?.id != null) {
if (!tabSwarms.has(sender.tab.id)) {
tabSwarms.set(sender.tab.id, new Set());
}
tabSwarms.get(sender.tab.id).add(swarmId);
// Increment reference count
const count = swarmRefCount.get(swarmId) || 0;
swarmRefCount.set(swarmId, count + 1);
log('Registered swarm', swarmId, 'for tab', sender.tab.id, '- refcount:', count + 1);
}
reply({ ok: true });
return false;
}
if (message.action === 'send') {
send(message.payload)
.then((r) => {
// After a successful setVirtualHost or removeVirtualHost, refresh virtualHosts
// and re-apply the PAC so the new TLD is routed immediately
if (r && r.ok && (
message.payload?.type === 'setVirtualHost' ||
message.payload?.type === 'removeVirtualHost'
)) {
send({ type: 'getState', payload: {} }).then((state) => {
if (state && state.ok && Array.isArray(state.virtualHosts)) {
extensionState.virtualHosts = state.virtualHosts;
const newTlds = getActiveTlds(extensionState.virtualHosts);
applyPAC(newTlds);
// Request host permission for any new TLD not already covered
if (message.payload?.type === 'setVirtualHost' && message.payload?.payload?.hostname) {
const hostname = message.payload.payload.hostname;
const parts = hostname.split('.');
if (parts.length >= 3) {
const baseDomain = parts.slice(-2).join('.');
if (baseDomain !== 'hole.sail') {
const origin = '*://*.' + baseDomain + '/*';
browser.permissions.request({ origins: [origin] }, (granted) => {
log('permissions.request for', origin, ':', granted ? 'granted' : 'denied');
});
}
}
}
}
}).catch(() => {});
}
reply(r);
})
.catch((err) => reply({ error: err.message }));
return true; // async response
}
if (message.action === 'subscribe' && sender.tab && sender.tab.id != null) {
subscribedTabs.add(sender.tab.id);
reply({ ok: true });
return false;
}
if (message.action === 'unsubscribe' && sender.tab && sender.tab.id != null) {
subscribedTabs.delete(sender.tab.id);
reply({ ok: true });
return false;
}
// Handle dashboard registration
if (message.action === 'registerDashboard' && sender.tab && sender.tab.id != null) {
dashboardTabs.add(sender.tab.id);
log('Dashboard registered for tab', sender.tab.id);
reply({ ok: true, logs: logs.slice(0) });
return false;
}
if (message.action === 'unregisterDashboard' && sender.tab && sender.tab.id != null) {
dashboardTabs.delete(sender.tab.id);
reply({ ok: true });
return false;
}
// Handle getState request from UI (host may augment via getState response when implemented)
if (message.action === 'getState') {
debugLog('getState action: port=', !!port);
updateExtensionState();
if (port) {
send({ type: 'getState', payload: {} })
.then((payload) => {
debugLog('getState response: ok=', payload && payload.ok, 'servers=', payload && payload.servers && payload.servers.length, 'virtualHosts=', payload && payload.virtualHosts && payload.virtualHosts.length, 'proxyPort=', payload && payload.proxyPort, 'connectProxyPort=', payload && payload.connectProxyPort);
if (payload && payload.ok === true && (payload.servers || payload.virtualHosts || payload.proxyPort != null || payload.connectProxyPort != null)) {
extensionState.proxyPort = payload.proxyPort ?? null;
extensionState.connectProxyPort = payload.connectProxyPort ?? null;
extensionState.virtualHosts = payload.virtualHosts || [];
if (payload.settings && typeof payload.settings.notifyOnDisconnect === 'boolean') {
notifyOnDisconnect = payload.settings.notifyOnDisconnect;
}
if (!pacConfirmedActive) {
log('getState: PAC not confirmed active, re-applying');
applyPAC(getActiveTlds(extensionState.virtualHosts));
}
reply({
ok: true,
state: {
hostConnected: true,
servers: payload.servers || [],
virtualHosts: payload.virtualHosts || [],
serviceTunnels: payload.serviceTunnels || [],
proxyPort: payload.proxyPort ?? null,
connectProxyPort: payload.connectProxyPort ?? null,
caInstalled: payload.caInstalled ?? false,
settings: payload.settings || {},
sshConnections: payload.sshConnections || [],
rdpConnections: payload.rdpConnections || [],
stats: { ...extensionState.stats, ...payload.stats }
}
});
} else {
reply({
ok: true,
state: {
hostConnected: true,
servers: extensionState.servers || [],
virtualHosts: extensionState.virtualHosts || [],
serviceTunnels: [],
sshConnections: [],
rdpConnections: [],
proxyPort: null,
connectProxyPort: null,
caInstalled: false,
stats: extensionState.stats
}
});
}
})
.catch((err) => {
debugLog('getState send failed:', err && err.message);
reply({
ok: true,
state: {
hostConnected: extensionState.hostConnected,
servers: extensionState.servers || [],
virtualHosts: extensionState.virtualHosts || [],
serviceTunnels: [],
sshConnections: [],
rdpConnections: [],
proxyPort: null,
connectProxyPort: null,
caInstalled: false,
stats: extensionState.stats
}
});
});
} else {
debugLog('getState: no port, replying hostConnected=false');
reply({
ok: true,
state: {
hostConnected: false,
servers: [],
virtualHosts: [],
serviceTunnels: [],
sshConnections: [],
rdpConnections: [],
proxyPort: null,
connectProxyPort: null,
caInstalled: false,
stats: extensionState.stats
}
});
}
return true;
}
// Unknown action: respond so the port is not left hanging
reply({ error: 'Unknown action' });
return false;
});
// Open dashboard when extension icon is clicked
browser.action?.onClicked?.addListener((tab) => {
browser.tabs.create({ url: 'dashboard.html' });
});
// Log proxy errors for *.hole.sail to help diagnose issues
if (browser.proxy && browser.proxy.onError) {
browser.proxy.onError.addListener((details) => {
log('Proxy error:', details.error, 'url=', details.url);
});
}