Keep transports in one browser window

This commit is contained in:
jwinterm
2026-07-11 10:20:54 -04:00
parent 2c68c072ba
commit ce874fec3c
20 changed files with 346 additions and 423 deletions
+222 -147
View File
@@ -107,7 +107,7 @@ def prepare_open(
profile_path=config.profile_path(BROKER_PROFILE),
proxy="-",
setup_steps=(),
message="broker entry URLs must use clearnet; alternate routes open in their isolated profile",
message="broker entry URLs must use clearnet; alternate routes continue in isolated container tabs",
broker=True,
)
profile_name = (
@@ -178,7 +178,7 @@ def prepare_open(
profile_path=profile_path,
proxy=proxy,
setup_steps=(),
message="ready to open with isolated profile",
message="ready to open in the one-window broker" if broker else "ready to open with isolated profile",
broker=broker,
launch_spec=launch_spec,
)
@@ -415,6 +415,14 @@ def _prefs_for(browse_plan: BrowsePlan, *, broker: bool = False) -> tuple[str, .
prefs.extend(
[
("network.proxy.type", 0),
("network.http.speculative-parallel-limit", 0),
("network.prefetch-next", False),
("network.dns.disablePrefetchFromHTTPS", True),
("browser.urlbar.speculativeConnect.enabled", False),
("media.peerconnection.enabled", False),
("network.http.http3.enable", False),
("network.trr.mode", 5),
("privacy.userContext.enabled", True),
("extensions.autoDisableScopes", 0),
("extensions.enabledScopes", 5),
("xpinstall.signatures.required", False),
@@ -461,8 +469,6 @@ def _route_helper_extension_assets(extension_path: str) -> tuple[ProfileAsset, .
ProfileAsset(str(base / "setup.html"), _route_helper_setup_html()),
ProfileAsset(str(base / "setup.js"), _route_helper_setup_js()),
ProfileAsset(str(base / "setup.css"), _route_helper_setup_css()),
ProfileAsset(str(base / "handoff.html"), _route_helper_handoff_html()),
ProfileAsset(str(base / "handoff.js"), _route_helper_handoff_js()),
)
@@ -470,22 +476,20 @@ def _route_helper_manifest() -> str:
return json.dumps(
{
"manifest_version": 2,
"name": "AMPB Route Helper",
"name": "AMPB Transport Contexts",
"version": "0.1.0",
"description": "Hands alternate-network links to isolated AMPB transport profiles.",
"applications": {"gecko": {"id": ROUTE_HELPER_EXTENSION_ID}},
"description": "Keeps AMPB transports in isolated, fail-closed container tabs.",
"browser_specific_settings": {
"gecko": {"id": ROUTE_HELPER_EXTENSION_ID, "strict_min_version": "128.0"}
},
"permissions": [
"tabs",
"cookies",
"contextualIdentities",
"proxy",
"webRequest",
"webRequestBlocking",
"http://*.onion/*",
"https://*.onion/*",
"ws://*.onion/*",
"wss://*.onion/*",
"http://*.i2p/*",
"https://*.i2p/*",
"ws://*.i2p/*",
"wss://*.i2p/*",
"<all_urls>",
"http://127.0.0.1/*",
],
"background": {"scripts": ["background.js"]},
@@ -496,86 +500,197 @@ def _route_helper_manifest() -> str:
def _route_helper_background_js() -> str:
return "\n".join(
(
'"use strict";',
"",
f'const HELPER_URL = "{HELPER_URL_PLACEHOLDER}";',
f'const HELPER_TOKEN = "{HELPER_TOKEN_PLACEHOLDER}";',
"const pendingByTab = new Map();",
"",
"function transportForUrl(rawUrl) {",
" try {",
" const parsed = new URL(rawUrl);",
" const host = parsed.hostname.toLowerCase().replace(/\\.$/, '');",
" if (host.endsWith('.onion')) return 'tor';",
" if (host.endsWith('.i2p')) return 'i2p';",
" } catch (_error) {}",
" return '';",
"}",
"",
"async function helper(action, transport, url, consent = false) {",
" const response = await fetch(HELPER_URL, {",
" method: 'POST',",
" headers: {",
" 'Content-Type': 'application/json',",
" 'X-AMPB-Token': HELPER_TOKEN,",
" },",
" body: JSON.stringify({ action, transport, url, consent }),",
" });",
" return response.json();",
"}",
"",
"function internalUrl(page, transport, url, result) {",
" const installCommand = Array.isArray(result.install_command) ? result.install_command.join(' ') : '';",
" const params = new URLSearchParams({",
" transport,",
" url,",
" message: result.message || '',",
" hint: result.setup_hint || '',",
" install: installCommand,",
" });",
" return browser.runtime.getURL(`${page}.html?${params.toString()}`);",
"}",
"",
"async function handoff(details, transport) {",
" let result;",
" try {",
" result = await helper('open', transport, details.url, false);",
" } catch (error) {",
" result = { ok: false, launched: false, message: String(error) };",
" }",
" const page = result.launched ? 'handoff' : 'setup';",
" try {",
" await browser.tabs.update(details.tabId, { url: internalUrl(page, transport, details.url, result) });",
" } finally {",
" pendingByTab.delete(details.tabId);",
" }",
"}",
"",
"function interceptRequest(details) {",
" const transport = transportForUrl(details.url);",
" if (!transport) return {};",
" if (details.type !== 'main_frame' || details.tabId < 0) return { cancel: true };",
" if (pendingByTab.get(details.tabId) === details.url) return { cancel: true };",
" pendingByTab.set(details.tabId, details.url);",
" void handoff(details, transport);",
" return { cancel: true };",
"}",
"",
"browser.webRequest.onBeforeRequest.addListener(",
" interceptRequest,",
" { urls: ['http://*.onion/*', 'https://*.onion/*', 'ws://*.onion/*', 'wss://*.onion/*', 'http://*.i2p/*', 'https://*.i2p/*', 'ws://*.i2p/*', 'wss://*.i2p/*'] },",
" ['blocking']",
");",
"",
"browser.runtime.onMessage.addListener((message) => {",
" if (!message || message.type !== 'ampb-open') return false;",
" return helper('open', message.transport, message.url, message.consent === true);",
"});",
"",
)
tor_adapter = adapter_for("tor")
i2p_adapter = adapter_for("i2p")
tor_host, tor_port = _endpoint_host_port(
tor_adapter.endpoint if tor_adapter else "socks5://127.0.0.1:9050"
)
i2p_host, i2p_port = _endpoint_host_port(
i2p_adapter.endpoint if i2p_adapter else "http://127.0.0.1:4444"
)
lines = (
'"use strict";',
"",
f'const HELPER_URL = "{HELPER_URL_PLACEHOLDER}";',
f'const HELPER_TOKEN = "{HELPER_TOKEN_PLACEHOLDER}";',
f'const TOR_PROXY = {{ host: "{tor_host}", port: {tor_port} }};',
f'const I2P_PROXY = {{ host: "{i2p_host}", port: {i2p_port} }};',
"const CONTAINERS = {",
" clearnet: { name: 'AMPB Clearnet', color: 'blue', icon: 'circle' },",
" tor: { name: 'AMPB Tor', color: 'purple', icon: 'fingerprint' },",
" i2p: { name: 'AMPB I2P', color: 'orange', icon: 'circle' },",
"};",
"const storesByTransport = new Map();",
"const transportsByStore = new Map();",
"const pendingByTab = new Map();",
"const containersReady = initializeContainers();",
"",
"function transportForUrl(rawUrl) {",
" try {",
" const parsed = new URL(rawUrl);",
" const host = parsed.hostname.toLowerCase().replace(/\\.$/, '');",
" if (host.endsWith('.onion')) return 'tor';",
" if (host.endsWith('.i2p')) return 'i2p';",
" if (['http:', 'https:', 'ws:', 'wss:'].includes(parsed.protocol)) return 'clearnet';",
" } catch (_error) {}",
" return '';",
"}",
"",
"function hostname(rawUrl) {",
" try { return new URL(rawUrl).hostname.toLowerCase().replace(/\\.$/, ''); } catch (_error) { return ''; }",
"}",
"",
"function transportForStore(cookieStoreId) {",
" return transportsByStore.get(cookieStoreId) || '';",
"}",
"",
"function isHelperRequest(details) {",
" const origin = details.originUrl || details.documentUrl || '';",
" return details.url.startsWith(HELPER_URL) && details.tabId < 0 && origin.startsWith(browser.runtime.getURL(''));",
"}",
"",
"async function initializeContainers() {",
" const identities = await browser.contextualIdentities.query({});",
" for (const [transport, metadata] of Object.entries(CONTAINERS)) {",
" let identity = identities.find((candidate) => candidate.name === metadata.name);",
" if (!identity) identity = await browser.contextualIdentities.create(metadata);",
" storesByTransport.set(transport, identity.cookieStoreId);",
" transportsByStore.set(identity.cookieStoreId, transport);",
" }",
"}",
"",
"async function helper(action, transport, url) {",
" const response = await fetch(HELPER_URL, {",
" method: 'POST',",
" headers: { 'Content-Type': 'application/json', 'X-AMPB-Token': HELPER_TOKEN },",
" body: JSON.stringify({ action, transport, url }),",
" });",
" return response.json();",
"}",
"",
"function setupUrl(mode, transport, url, result) {",
" const installCommand = Array.isArray(result.install_command) ? result.install_command.join(' ') : '';",
" const params = new URLSearchParams({",
" mode, transport, url, message: result.message || '', hint: result.setup_hint || '', install: installCommand,",
" });",
" return browser.runtime.getURL(`setup.html?${params.toString()}`);",
"}",
"",
"function isolationToken(details) {",
" const store = details.cookieStoreId || 'unknown';",
" const firstParty = hostname(details.documentUrl || details.originUrl || details.url) || 'unknown';",
" return `ampb:${store}:${firstParty}`.slice(0, 220);",
"}",
"",
"function torProxy(details) {",
" const token = isolationToken(details);",
" return {",
" type: 'socks', host: TOR_PROXY.host, port: TOR_PROXY.port, proxyDNS: true,",
" username: '<torS0X>0', password: token, connectionIsolationKey: token, failoverTimeout: 1,",
" };",
"}",
"",
"function i2pProxy() {",
" return { type: 'http', host: I2P_PROXY.host, port: I2P_PROXY.port, failoverTimeout: 1 };",
"}",
"",
"function blockedProxy() {",
" return { type: 'http', host: '127.0.0.1', port: 9, failoverTimeout: 1 };",
"}",
"",
"function proxyForRequest(details) {",
" if (isHelperRequest(details)) return { type: 'direct' };",
" if (details.url.startsWith(HELPER_URL)) return blockedProxy();",
" const target = transportForUrl(details.url);",
" const current = transportForStore(details.cookieStoreId);",
" if (target === 'tor' && current !== 'tor') return torProxy(details);",
" if (target === 'i2p' && current !== 'i2p') return i2pProxy();",
" if (current === 'tor') return torProxy(details);",
" if (current === 'i2p') return target === 'i2p' ? i2pProxy() : blockedProxy();",
" return { type: 'direct' };",
"}",
"",
"async function replaceTab(tabId, transport, url) {",
" await containersReady;",
" const oldTab = await browser.tabs.get(tabId);",
" const cookieStoreId = storesByTransport.get(transport);",
" if (!cookieStoreId) throw new Error(`No container for ${transport}`);",
" const newTab = await browser.tabs.create({",
" windowId: oldTab.windowId, index: oldTab.index, active: oldTab.active,",
" pinned: oldTab.pinned, cookieStoreId, url,",
" });",
" if (oldTab.mutedInfo && oldTab.mutedInfo.muted) await browser.tabs.update(newTab.id, { muted: true });",
" await browser.tabs.remove(tabId);",
"}",
"",
"async function routeTopLevel(details, transport, current) {",
" try {",
" await containersReady;",
" if (transport === 'clearnet') {",
" if (!current) await replaceTab(details.tabId, transport, details.url);",
" else await browser.tabs.update(details.tabId, {",
" url: setupUrl('switch', transport, details.url, { message: 'Switching to clearnet can reveal your network address.' }),",
" });",
" return;",
" }",
" const result = await helper('status', transport, details.url);",
" if (result.ready) await replaceTab(details.tabId, transport, details.url);",
" else await browser.tabs.update(details.tabId, { url: setupUrl('setup', transport, details.url, result) });",
" } catch (error) {",
" await browser.tabs.update(details.tabId, {",
" url: setupUrl('error', transport, details.url, { message: String(error) }),",
" });",
" } finally {",
" pendingByTab.delete(details.tabId);",
" }",
"}",
"",
"function interceptRequest(details) {",
" if (isHelperRequest(details)) return {};",
" if (details.url.startsWith(HELPER_URL)) return { cancel: true };",
" const target = transportForUrl(details.url);",
" if (!target) return {};",
" const current = transportForStore(details.cookieStoreId);",
" if (current === target) return {};",
" if (current === 'tor' && target === 'clearnet') return {};",
" if (details.type !== 'main_frame' || details.tabId < 0) return { cancel: true };",
" if (pendingByTab.get(details.tabId) === details.url) return { cancel: true };",
" pendingByTab.set(details.tabId, details.url);",
" void routeTopLevel(details, target, current);",
" return { cancel: true };",
"}",
"",
"browser.proxy.onRequest.addListener(proxyForRequest, { urls: ['<all_urls>'] });",
"browser.webRequest.onBeforeRequest.addListener(",
" interceptRequest, { urls: ['<all_urls>'] }, ['blocking']",
");",
"",
"browser.runtime.onMessage.addListener(async (message, sender) => {",
" if (!message || !sender.tab || sender.tab.id < 0) return false;",
" if (message.type === 'ampb-confirm-switch') {",
" setTimeout(() => void replaceTab(sender.tab.id, message.transport, message.url), 0);",
" return { ok: true, switching: true };",
" }",
" if (message.type !== 'ampb-ensure-and-switch') return false;",
" const result = await helper('ensure', message.transport, message.url);",
" if (result.ready) setTimeout(() => void replaceTab(sender.tab.id, message.transport, message.url), 0);",
" return { ...result, switching: result.ready };",
"});",
"browser.tabs.onRemoved.addListener((tabId) => pendingByTab.delete(tabId));",
"",
"async function normalizeOpenTabs() {",
" await containersReady;",
" const tabs = await browser.tabs.query({});",
" for (const tab of tabs) {",
" if (tab.id < 0 || transportForStore(tab.cookieStoreId)) continue;",
" const transport = transportForUrl(tab.url || '');",
" if (transport === 'clearnet') await replaceTab(tab.id, transport, tab.url);",
" }",
"}",
"void normalizeOpenTabs();",
"",
)
return "\n".join(lines)
def _route_helper_setup_html() -> str:
@@ -591,7 +706,7 @@ def _route_helper_setup_html() -> str:
"</head>",
"<body>",
' <main class="panel">',
' <p class="eyebrow">AMPB Route Helper</p>',
' <p class="eyebrow">AMPB</p>',
' <h1 id="title">Set Up Transport</h1>',
' <p id="body">AMPB needs to start a local transport before opening this route.</p>',
' <pre id="url"></pre>',
@@ -614,6 +729,7 @@ def _route_helper_setup_js() -> str:
'"use strict";',
"",
"const params = new URLSearchParams(window.location.search);",
"const mode = params.get('mode') || 'setup';",
"const transport = params.get('transport') || '';",
"const url = params.get('url') || '';",
"const message = params.get('message') || '';",
@@ -632,21 +748,23 @@ def _route_helper_setup_js() -> str:
" installCommand.textContent = value;",
"}",
"",
"document.getElementById('title').textContent = `Set up ${label}?`;",
"document.getElementById('body').textContent = `AMPB can start a managed ${label} session and keep its state isolated.`;",
"const switching = mode === 'switch';",
"document.getElementById('title').textContent = switching ? `Switch to ${label}?` : `Set up ${label}?`;",
"document.getElementById('body').textContent = switching",
" ? `This tab will continue in the isolated ${label} container.`",
" : `AMPB can start a managed ${label} session and continue in an isolated ${label} container tab.`;",
"document.getElementById('url').textContent = url;",
"document.getElementById('message').textContent = hint || message;",
"document.getElementById('approve').textContent = `Start ${label} and open`;",
"document.getElementById('approve').textContent = switching ? `Switch to ${label}` : `Start ${label} and continue`;",
"showInstallCommand(install);",
"",
"document.getElementById('approve').addEventListener('click', async () => {",
" const status = document.getElementById('status');",
" status.textContent = `Starting ${label}...`;",
" const result = await browser.runtime.sendMessage({ type: 'ampb-open', transport, url, consent: true });",
" if (result && result.launched) {",
" status.textContent = `${label} opened in its isolated browser profile.`;",
" const params = new URLSearchParams({ transport, url });",
" window.location.href = browser.runtime.getURL(`handoff.html?${params.toString()}`);",
" status.textContent = switching ? `Switching to ${label}...` : `Starting ${label}...`;",
" const type = switching ? 'ampb-confirm-switch' : 'ampb-ensure-and-switch';",
" const result = await browser.runtime.sendMessage({ type, transport, url });",
" if (result && result.switching) {",
" status.textContent = `Continuing in the ${label} tab...`;",
" return;",
" }",
" if (result && Array.isArray(result.install_command)) {",
@@ -676,49 +794,6 @@ def _route_helper_setup_css() -> str:
)
def _route_helper_handoff_html() -> str:
return "\n".join(
(
"<!doctype html>",
'<html lang="en">',
"<head>",
' <meta charset="utf-8">',
' <meta name="viewport" content="width=device-width, initial-scale=1">',
" <title>Route Opened</title>",
' <link rel="stylesheet" href="setup.css">',
"</head>",
"<body>",
' <main class="panel">',
' <p class="eyebrow">AMPB Transport Broker</p>',
' <h1 id="title">Route opened</h1>',
' <p id="body">The destination is open in its isolated transport profile.</p>',
' <pre id="url"></pre>',
" </main>",
' <script src="handoff.js"></script>',
"</body>",
"</html>",
"",
)
)
def _route_helper_handoff_js() -> str:
return "\n".join(
(
'"use strict";',
"",
"const params = new URLSearchParams(window.location.search);",
"const transport = params.get('transport') || '';",
"const url = params.get('url') || '';",
"const label = transport === 'tor' ? 'Tor' : transport === 'i2p' ? 'I2P' : transport;",
"document.getElementById('title').textContent = `${label} route opened`;",
"document.getElementById('body').textContent = `This destination opened in the isolated ${label} profile. This clearnet profile did not load it.`;",
"document.getElementById('url').textContent = url;",
"",
)
)
def _endpoint_host_port(endpoint: str) -> tuple[str, int]:
parsed = urlparse(endpoint)
return parsed.hostname or "127.0.0.1", parsed.port or 0