diff --git a/background.js b/background.js index 9553ad8..2a8f27b 100644 --- a/background.js +++ b/background.js @@ -95,11 +95,12 @@ function sendPopupStatus() { if (deadPort) { setPopupIcon("need-install"); console.log("sendPopupStatus... no nmPort"); + const suffix = browserByte() + chrome.runtime.id; sendToPopup({ - installCmd: - "go run github.com/tailscale/ts-browser-ext@main --install=" + - browserByte() + - chrome.runtime.id, + installCmds: { + remote: "go run github.com/tailscale/ts-browser-ext@main --install=" + suffix, + local: "go run . --install=" + suffix, + }, }); return; } @@ -130,6 +131,15 @@ function connectToNativeHost() { nmPort.onDisconnect.addListener(() => { deadPort = true; setPopupIcon("need-install"); + // Clean up pending exit node callbacks to prevent hung promises + if (pendingExitNodesCallback) { + pendingExitNodesCallback({ error: "Disconnected from native host" }); + pendingExitNodesCallback = null; + } + if (pendingSetExitNodeCallback) { + pendingSetExitNodeCallback({ error: "Disconnected from native host" }); + pendingSetExitNodeCallback = null; + } disableProxy(); const error = chrome.runtime.lastError; if (error) { @@ -163,6 +173,20 @@ function connectToNativeHost() { if (message.status) { lastStatus = message.status; } + if (message.exitNodes) { + console.log("got exitNodes response:", message.exitNodes); + if (pendingExitNodesCallback) { + pendingExitNodesCallback(message.exitNodes); + pendingExitNodesCallback = null; + } + } + if (message.exitNodeSet) { + console.log("got exitNodeSet response:", message.exitNodeSet); + if (pendingSetExitNodeCallback) { + pendingSetExitNodeCallback(message.exitNodeSet); + pendingSetExitNodeCallback = null; + } + } maybeSendInit(); sendPopupStatus(); }); @@ -239,6 +263,10 @@ chrome.storage.local.get("profileId", (result) => { } }); +// Pending response callbacks for exit node requests +let pendingExitNodesCallback = null; +let pendingSetExitNodeCallback = null; + // Listener for messages from the popup chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { console.log("bg: Received message:", message); @@ -261,4 +289,34 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { setPopupIcon(proxyEnabled); return true; // Keep the message channel open for the async response } + + if (message.command === "getExitNodes") { + console.log("bg: getExitNodes received"); + if (deadPort || !nmPort) { + sendResponse({ error: "Not connected to native host" }); + return true; + } + if (pendingExitNodesCallback) { + sendResponse({ error: "Request already in progress" }); + return true; + } + pendingExitNodesCallback = sendResponse; + nmPort.postMessage({ cmd: "get-exit-nodes" }); + return true; // Keep the message channel open for the async response + } + + if (message.command === "setExitNode") { + console.log("bg: setExitNode received, IP:", message.exitNodeIP); + if (deadPort || !nmPort) { + sendResponse({ error: "Not connected to native host" }); + return true; + } + if (pendingSetExitNodeCallback) { + sendResponse({ error: "Request already in progress" }); + return true; + } + pendingSetExitNodeCallback = sendResponse; + nmPort.postMessage({ cmd: "set-exit-node", exitNodeIP: message.exitNodeIP || "" }); + return true; // Keep the message channel open for the async response + } }); diff --git a/firefox/background.js b/firefox/background.js index e1fde4b..70577f2 100644 --- a/firefox/background.js +++ b/firefox/background.js @@ -87,7 +87,7 @@ function sendPopupStatus() { // firefox requires that extensions settings proxies have private browsing access browser.extension.isAllowedIncognitoAccess().then(isAllowed => { if (!isAllowed) { - sendToPopup({ + sendToPopup({ needsIncognitoPermission: true }); } @@ -96,11 +96,12 @@ function sendPopupStatus() { if (deadPort) { setPopupIcon("need-install"); console.log("sendPopupStatus... no nmPort"); + const suffix = browserByte() + browser.runtime.id; sendToPopup({ - installCmd: - "go run github.com/tailscale/ts-browser-ext@main --install=" + - browserByte() + - browser.runtime.id, + installCmds: { + remote: "go run github.com/tailscale/ts-browser-ext@main --install=" + suffix, + local: "go run . --install=" + suffix, + }, }); return; } @@ -119,6 +120,10 @@ let nmPort = null; // even non-null if lacking permission let deadPort = true; let portError = null; +// Pending promise resolvers for exit node requests +let pendingExitNodesResolve = null; +let pendingSetExitNodeResolve = null; + connectToNativeHost(); function connectToNativeHost() { @@ -131,6 +136,15 @@ function connectToNativeHost() { nmPort.onDisconnect.addListener(() => { deadPort = true; setPopupIcon("need-install"); + // Clean up pending exit node resolvers to prevent hung promises + if (pendingExitNodesResolve) { + pendingExitNodesResolve({ error: "Disconnected from native host" }); + pendingExitNodesResolve = null; + } + if (pendingSetExitNodeResolve) { + pendingSetExitNodeResolve({ error: "Disconnected from native host" }); + pendingSetExitNodeResolve = null; + } disableProxy(); const error = browser.runtime.lastError; if (error) { @@ -164,6 +178,20 @@ function connectToNativeHost() { if (message.status) { lastStatus = message.status; } + if (message.exitNodes) { + console.log("got exitNodes response:", message.exitNodes); + if (pendingExitNodesResolve) { + pendingExitNodesResolve(message.exitNodes); + pendingExitNodesResolve = null; + } + } + if (message.exitNodeSet) { + console.log("got exitNodeSet response:", message.exitNodeSet); + if (pendingSetExitNodeResolve) { + pendingSetExitNodeResolve(message.exitNodeSet); + pendingSetExitNodeResolve = null; + } + } maybeSendInit(); sendPopupStatus(); }); @@ -171,17 +199,24 @@ function connectToNativeHost() { var lastProxyPort = 0; var lastStatus = {}; // last Go status +var activeProxyHandler = null; function setProxy(proxyPort) { - const handleProxyRequest = proxyHandler(proxyPort) + // Remove existing handler if any + if (activeProxyHandler) { + browser.proxy.onRequest.removeListener(activeProxyHandler); + activeProxyHandler = null; + } + if (proxyPort) { proxyEnabled = true; lastProxyPort = proxyPort; console.log("Enabling proxy at port: " + proxyPort); + activeProxyHandler = proxyHandler(proxyPort); + browser.proxy.onRequest.addListener(activeProxyHandler, { urls: [""] }); } else { proxyEnabled = false; console.log("Disabling proxy..."); - browser.proxy.onRequest.removeListener(handleProxyRequest) browser.proxy.settings .set({ value: { @@ -192,9 +227,7 @@ function setProxy(proxyPort) { .then(() => { console.log("Proxy disabled."); }); - return; } - browser.proxy.onRequest.addListener(handleProxyRequest, { urls: [""] }) } var profileID = ""; @@ -253,4 +286,32 @@ browser.runtime.onMessage.addListener((message, sender) => { } return Promise.resolve({ status: lastStatus }); } + + if (message.command === "getExitNodes") { + console.log("bg: getExitNodes received"); + if (deadPort || !nmPort) { + return Promise.resolve({ error: "Not connected to native host" }); + } + if (pendingExitNodesResolve) { + return Promise.resolve({ error: "Request already in progress" }); + } + return new Promise((resolve) => { + pendingExitNodesResolve = resolve; + nmPort.postMessage({ cmd: "get-exit-nodes" }); + }); + } + + if (message.command === "setExitNode") { + console.log("bg: setExitNode received, IP:", message.exitNodeIP); + if (deadPort || !nmPort) { + return Promise.resolve({ error: "Not connected to native host" }); + } + if (pendingSetExitNodeResolve) { + return Promise.resolve({ error: "Request already in progress" }); + } + return new Promise((resolve) => { + pendingSetExitNodeResolve = resolve; + nmPort.postMessage({ cmd: "set-exit-node", exitNodeIP: message.exitNodeIP || "" }); + }); + } }); diff --git a/firefox/popup.html b/firefox/popup.html index 5f6a506..7aef061 100644 --- a/firefox/popup.html +++ b/firefox/popup.html @@ -1,323 +1,284 @@ - - - Proxy Toggle - - - - - - - -
- -
Disconnected
-
- +
+
- +
+
Disconnected
+
+ + +
+
+ +
+ + diff --git a/firefox/popup.js b/firefox/popup.js index 968ff81..bb78b0d 100644 --- a/firefox/popup.js +++ b/firefox/popup.js @@ -5,12 +5,78 @@ document.addEventListener("DOMContentLoaded", () => { const slider = document.querySelector(".slider"); const settingsButton = document.getElementById("settingsButton"); const stateDisplay = document.getElementById("state"); + const exitNodeSelect = document.getElementById("exitNodeSelect"); let isConnected = false; let isLoading = true; let hasReceivedInitialState = false; + let hasLoadedExitNodes = false; + let isLoadingExitNodes = false; const port = browser.runtime.connect({ name: "popup" }); + // Fetch and populate exit nodes + function refreshExitNodes() { + if (isLoadingExitNodes) return; + isLoadingExitNodes = true; + console.log("Refreshing exit nodes..."); + + browser.runtime.sendMessage({ command: "getExitNodes" }).then((response) => { + isLoadingExitNodes = false; + console.log("Got exit nodes response:", response); + if (response && !response.error) { + populateExitNodes(response.nodes || [], response.currentNode || ""); + exitNodeSelect.disabled = false; + hasLoadedExitNodes = true; // Mark as successfully loaded + } else { + console.log("Failed to get exit nodes:", response?.error); + exitNodeSelect.disabled = true; + hasLoadedExitNodes = false; + } + }).catch((error) => { + isLoadingExitNodes = false; + console.error("getExitNodes failed:", error); + exitNodeSelect.disabled = true; + hasLoadedExitNodes = false; + }); + } + + function populateExitNodes(nodes, currentNode) { + // Clear existing options except "None" + exitNodeSelect.innerHTML = ''; + + // Add exit node options + nodes.forEach((node) => { + const option = document.createElement("option"); + option.value = node.ip; + option.textContent = node.name; + if (node.ip === currentNode) { + option.selected = true; + } + exitNodeSelect.appendChild(option); + }); + } + + // Handle exit node selection change + exitNodeSelect.addEventListener("change", () => { + const selectedIP = exitNodeSelect.value; + console.log("Exit node selection changed to:", selectedIP || "None"); + + exitNodeSelect.disabled = true; + browser.runtime.sendMessage({ command: "setExitNode", exitNodeIP: selectedIP }).then((response) => { + console.log("Set exit node response:", response); + exitNodeSelect.disabled = false; + if (response && !response.success && response.error) { + console.error("Failed to set exit node:", response.error); + // Refresh to get the actual current state + refreshExitNodes(); + } + }).catch((error) => { + console.error("setExitNode failed:", error); + exitNodeSelect.disabled = false; + refreshExitNodes(); + }); + }); + function updateSliderState() { if (isLoading) { slider.className = "slider loading"; @@ -32,21 +98,36 @@ document.addEventListener("DOMContentLoaded", () => { if (status.error === "State: Stopped") { stateDisplay.textContent = "Disconnected"; isConnected = false; + exitNodeSelect.disabled = true; updateSliderState(); return; } stateDisplay.textContent = `Error: ${status.error}`; + exitNodeSelect.disabled = true; return; } if (status.needsLogin) { + lastStatus = status; // Save for login link stateDisplay.innerHTML = status.browseToURL - ? `Log in` + ? `Log in` : "Login required; no URL"; + // Add click handler for login link + const loginLink = document.getElementById("loginLink"); + if (loginLink) { + loginLink.addEventListener("click", (e) => { + e.preventDefault(); + if (lastStatus && lastStatus.browseToURL) { + browser.tabs.create({ url: lastStatus.browseToURL }); + } + }); + } + exitNodeSelect.disabled = true; return; } if (typeof status === "string" && status === "Disconnected") { stateDisplay.textContent = "Disconnected"; isConnected = false; + exitNodeSelect.disabled = true; updateSliderState(); return; } @@ -56,6 +137,13 @@ document.addEventListener("DOMContentLoaded", () => { : "Disconnected"; isConnected = status.running; updateSliderState(); + // Refresh exit nodes once when first connected + if (status.running && !hasLoadedExitNodes) { + refreshExitNodes(); + } else if (!status.running) { + hasLoadedExitNodes = false; + exitNodeSelect.disabled = true; + } } } @@ -69,9 +157,31 @@ document.addEventListener("DOMContentLoaded", () => { return; } - if (msg.installCmd) { - console.log("Received install command"); - stateDisplay.innerHTML = `Installation needed. Run:
${msg.installCmd}
`; + if (msg.installCmds) { + console.log("Received install commands"); + stateDisplay.innerHTML = ` +
+ Installation needed. Run: +
${msg.installCmds.remote}
+ + +
Or for local dev:
+
${msg.installCmds.local}
+ +
`; + + document.querySelectorAll(".copy-button").forEach(btn => { + btn.addEventListener("click", () => { + const cmd = btn.getAttribute("data-cmd"); + const originalText = btn.textContent; + navigator.clipboard.writeText(cmd).then(() => { + btn.textContent = "Copied!"; + setTimeout(() => { + btn.textContent = originalText; + }, 2000); + }); + }); + }); toggleSlider.disabled = true; settingsButton.hidden = true; return; diff --git a/popup.html b/popup.html index 42fd122..539b152 100644 --- a/popup.html +++ b/popup.html @@ -1,330 +1,285 @@ - - - Proxy Toggle - - - - - - - -
- -
-
- + "> +
- + +
+
+
+ + +
+
+ +
+ + diff --git a/popup.js b/popup.js index 9d8b28b..a55535e 100644 --- a/popup.js +++ b/popup.js @@ -11,12 +11,84 @@ document.addEventListener("DOMContentLoaded", () => { const slider = document.querySelector(".slider"); const settingsButton = document.getElementById("settingsButton"); const stateDisplay = document.getElementById("state"); + const exitNodeSelect = document.getElementById("exitNodeSelect"); let isConnected = false; let isLoading = true; let hasReceivedInitialState = false; + let hasLoadedExitNodes = false; + let isLoadingExitNodes = false; const port = chrome.runtime.connect({ name: "popup" }); + // Fetch and populate exit nodes + function refreshExitNodes() { + if (isLoadingExitNodes) return; + isLoadingExitNodes = true; + console.log("Refreshing exit nodes..."); + + chrome.runtime.sendMessage({ command: "getExitNodes" }, (response) => { + isLoadingExitNodes = false; + if (chrome.runtime.lastError) { + console.error("getExitNodes failed:", chrome.runtime.lastError.message); + exitNodeSelect.disabled = true; + hasLoadedExitNodes = false; // Allow retry + return; + } + console.log("Got exit nodes response:", response); + if (response && !response.error) { + populateExitNodes(response.nodes || [], response.currentNode || ""); + exitNodeSelect.disabled = false; + hasLoadedExitNodes = true; // Mark as successfully loaded + } else { + console.log("Failed to get exit nodes:", response?.error); + exitNodeSelect.disabled = true; + hasLoadedExitNodes = false; + } + }); + } + + function populateExitNodes(nodes, currentNode) { + // Clear existing options except "None" + exitNodeSelect.innerHTML = ''; + + // Add exit node options + nodes.forEach((node) => { + const option = document.createElement("option"); + option.value = node.ip; + option.textContent = node.name; + if (node.ip === currentNode) { + option.selected = true; + } + exitNodeSelect.appendChild(option); + }); + } + + // Handle exit node selection change + exitNodeSelect.addEventListener("change", () => { + const selectedIP = exitNodeSelect.value; + console.log("Exit node selection changed to:", selectedIP || "None"); + + exitNodeSelect.disabled = true; + chrome.runtime.sendMessage( + { command: "setExitNode", exitNodeIP: selectedIP }, + (response) => { + if (chrome.runtime.lastError) { + console.error("setExitNode failed:", chrome.runtime.lastError.message); + exitNodeSelect.disabled = false; + refreshExitNodes(); + return; + } + console.log("Set exit node response:", response); + exitNodeSelect.disabled = false; + if (response && !response.success && response.error) { + console.error("Failed to set exit node:", response.error); + // Refresh to get the actual current state + refreshExitNodes(); + } + } + ); + }); + function updateSliderState() { if (isLoading) { slider.className = "slider loading"; @@ -38,21 +110,36 @@ document.addEventListener("DOMContentLoaded", () => { if (status.error === "State: Stopped") { stateDisplay.textContent = "Disconnected"; isConnected = false; + exitNodeSelect.disabled = true; updateSliderState(); return; } stateDisplay.textContent = `Error: ${status.error}`; + exitNodeSelect.disabled = true; return; } if (status.needsLogin) { + lastStatus = status; // Save for browseToURL stateDisplay.innerHTML = status.browseToURL - ? `Log in` + ? `Log in` : "Login required; no URL"; + // Add click handler for login link + const loginLink = document.getElementById("loginLink"); + if (loginLink) { + loginLink.addEventListener("click", (e) => { + e.preventDefault(); + if (lastStatus && lastStatus.browseToURL) { + chrome.tabs.create({ url: lastStatus.browseToURL }); + } + }); + } + exitNodeSelect.disabled = true; return; } if (typeof status === "string" && status === "Disconnected") { stateDisplay.textContent = "Disconnected"; isConnected = false; + exitNodeSelect.disabled = true; updateSliderState(); return; } @@ -62,14 +149,43 @@ document.addEventListener("DOMContentLoaded", () => { : "Disconnected"; isConnected = status.running; updateSliderState(); + // Refresh exit nodes once when first connected + if (status.running && !hasLoadedExitNodes) { + refreshExitNodes(); + } else if (!status.running) { + hasLoadedExitNodes = false; + exitNodeSelect.disabled = true; + } } } port.onMessage.addListener((msg) => { console.log("Received from background:", JSON.stringify(msg)); - if (msg.installCmd) { - console.log("Received install command"); - stateDisplay.innerHTML = `Installation needed. Run:
${msg.installCmd}
`; + if (msg.installCmds) { + console.log("Received install commands"); + stateDisplay.innerHTML = ` +
+ Installation needed. Run: +
${msg.installCmds.remote}
+ + +
Or for local dev:
+
${msg.installCmds.local}
+ +
`; + + document.querySelectorAll(".copy-button").forEach(btn => { + btn.addEventListener("click", () => { + const cmd = btn.getAttribute("data-cmd"); + const originalText = btn.textContent; + navigator.clipboard.writeText(cmd).then(() => { + btn.textContent = "Copied!"; + setTimeout(() => { + btn.textContent = originalText; + }, 2000); + }); + }); + }); toggleSlider.disabled = true; settingsButton.hidden = true; return; diff --git a/ts-browser-ext.go b/ts-browser-ext.go index fb8cc54..a4dc743 100644 --- a/ts-browser-ext.go +++ b/ts-browser-ext.go @@ -14,6 +14,7 @@ import ( "net" "net/http" "net/http/httputil" + "net/netip" "os" "os/user" "path/filepath" @@ -275,6 +276,10 @@ func (h *host) handleMessage(msg *request) error { return h.handleUp() case CmdDown: return h.handleDown() + case CmdGetExitNodes: + h.handleGetExitNodes() + case CmdSetExitNode: + h.handleSetExitNode(msg.ExitNodeIP) default: h.logf("unknown command %q", msg.Cmd) } @@ -289,6 +294,118 @@ func (h *host) handleDown() error { return h.setWantRunning(false) } +func (h *host) handleGetExitNodes() { + result := &exitNodesResult{} + + h.mu.Lock() + if h.ts.Sys() == nil { + h.mu.Unlock() + result.Error = "not initialized" + h.send(&reply{ExitNodes: result}) + return + } + h.mu.Unlock() + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + lc, err := h.ts.LocalClient() + if err != nil { + result.Error = err.Error() + h.send(&reply{ExitNodes: result}) + return + } + + st, err := lc.Status(ctx) + if err != nil { + result.Error = err.Error() + h.send(&reply{ExitNodes: result}) + return + } + + // Collect exit nodes from peers + for _, peer := range st.Peer { + if peer.ExitNodeOption { + ip := "" + if len(peer.TailscaleIPs) > 0 { + ip = peer.TailscaleIPs[0].String() + } + if ip != "" { + // Use DNSName (short version) for display, matching the settings UI + // DNSName is like "machine.tailnet.ts.net", we want just "machine" + name := peer.HostName + if peer.DNSName != "" { + name = strings.TrimSuffix(peer.DNSName, ".") + if idx := strings.Index(name, "."); idx > 0 { + name = name[:idx] + } + } + result.Nodes = append(result.Nodes, exitNode{ + Name: name, + IP: ip, + }) + // Use ExitNode bool to detect if this peer is the current exit node + // This avoids IPv4/IPv6 format mismatches when comparing IPs + if peer.ExitNode { + result.CurrentNode = ip + } + } + } + } + + h.send(&reply{ExitNodes: result}) +} + +func (h *host) handleSetExitNode(exitNodeIP string) { + result := &exitNodeSetResult{} + + h.mu.Lock() + if h.ts.Sys() == nil { + h.mu.Unlock() + result.Error = "not initialized" + h.send(&reply{ExitNodeSet: result}) + return + } + h.mu.Unlock() + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + lc, err := h.ts.LocalClient() + if err != nil { + result.Error = err.Error() + h.send(&reply{ExitNodeSet: result}) + return + } + + var exitIP netip.Addr + if exitNodeIP != "" { + exitIP, err = netip.ParseAddr(exitNodeIP) + if err != nil { + result.Error = fmt.Sprintf("invalid exit node IP: %v", err) + h.send(&reply{ExitNodeSet: result}) + return + } + } + + if _, err := lc.EditPrefs(ctx, &ipn.MaskedPrefs{ + ExitNodeIPSet: true, + ExitNodeIDSet: true, // Also clear/set ID to ensure IP takes precedence or both are cleared + Prefs: ipn.Prefs{ + ExitNodeIP: exitIP, + ExitNodeID: "", // Always clear ID when setting by IP (or clearing both) + }, + }); err != nil { + result.Error = fmt.Sprintf("failed to set exit node: %v", err) + h.send(&reply{ExitNodeSet: result}) + return + } + + result.Success = true + h.send(&reply{ExitNodeSet: result}) + h.sendStatus() // Send updated status +} + func (h *host) setWantRunning(want bool) error { defer h.sendStatus() h.mu.Lock() @@ -521,10 +638,12 @@ func (h *host) sendStatus() { type Cmd string const ( - CmdInit Cmd = "init" - CmdUp Cmd = "up" - CmdDown Cmd = "down" - CmdGetStatus Cmd = "get-status" + CmdInit Cmd = "init" + CmdUp Cmd = "up" + CmdDown Cmd = "down" + CmdGetStatus Cmd = "get-status" + CmdGetExitNodes Cmd = "get-exit-nodes" + CmdSetExitNode Cmd = "set-exit-node" ) // request is a message from the browser extension. @@ -540,7 +659,9 @@ type request struct { // UUID-ish: hex and hyphens only, and too long. InitID string `json:"initID,omitempty"` - // ... + // ExitNodeIP is the IP address of the exit node to use. + // Empty string means no exit node (direct connection). + ExitNodeIP string `json:"exitNodeIP,omitempty"` } // reply is a message to the browser extension. @@ -554,6 +675,31 @@ type reply struct { Status *status `json:"status,omitempty"` Init *initResult `json:"init,omitempty"` + + // ExitNodes is sent in response to a [CmdGetExitNodes] [request.Cmd]. + ExitNodes *exitNodesResult `json:"exitNodes,omitempty"` + + // ExitNodeSet is sent in response to a [CmdSetExitNode] [request.Cmd]. + ExitNodeSet *exitNodeSetResult `json:"exitNodeSet,omitempty"` +} + +// exitNode represents an available exit node. +type exitNode struct { + Name string `json:"name"` // Hostname of the exit node + IP string `json:"ip"` // IP address to use when setting +} + +// exitNodesResult is the response to a get-exit-nodes command. +type exitNodesResult struct { + Nodes []exitNode `json:"nodes"` + CurrentNode string `json:"currentNode"` // IP of currently selected exit node, empty if none + Error string `json:"error,omitempty"` +} + +// exitNodeSetResult is the response to a set-exit-node command. +type exitNodeSetResult struct { + Success bool `json:"success"` + Error string `json:"error,omitempty"` } type procRunningResult struct {