/** * 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 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; function applyPAC() { 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; log('applyPAC: setting PAC port=', port); const pacData = `function FindProxyForURL(url, host) { if (dnsDomainIs(host, ".hole.sail")) return "PROXY 127.0.0.1:${port}"; return "DIRECT"; }`; 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; applyPAC(); } 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; // Sync notifyOnDisconnect from persisted settings if (payload.settings && typeof payload.settings.notifyOnDisconnect === 'boolean') { notifyOnDisconnect = payload.settings.notifyOnDisconnect; } applyPAC(); 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) => 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; if (payload.settings && typeof payload.settings.notifyOnDisconnect === 'boolean') { notifyOnDisconnect = payload.settings.notifyOnDisconnect; } if (!pacConfirmedActive) { log('getState: PAC not confirmed active, re-applying'); applyPAC(); } 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); }); }