546 lines
19 KiB
JavaScript
546 lines
19 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
|
|
: typeof browser !== 'undefined'
|
|
? 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;
|
|
|
|
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);
|
|
}
|
|
|
|
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;
|
|
}
|
|
pending.set(id, { resolve, reject });
|
|
try {
|
|
port.postMessage(msg);
|
|
} catch (e) {
|
|
pending.delete(id);
|
|
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);
|
|
});
|
|
|
|
// 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 || [],
|
|
stats: { ...extensionState.stats, ...payload.stats }
|
|
}
|
|
});
|
|
} else {
|
|
reply({
|
|
ok: true,
|
|
state: {
|
|
hostConnected: true,
|
|
servers: extensionState.servers || [],
|
|
virtualHosts: extensionState.virtualHosts || [],
|
|
serviceTunnels: [],
|
|
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: [],
|
|
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: [],
|
|
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);
|
|
});
|
|
}
|