From a39e8b615cd82f49202065821d500c2efde93cdd Mon Sep 17 00:00:00 2001 From: Egor Kotov Date: Sun, 1 Feb 2026 01:52:47 +0100 Subject: [PATCH] feat: Add exit node selector and improve popup UI Enhance exit node selection with reliable detection, improve display names, fix 'None' selection clearing, and provide better installation instructions with local dev support. Signed-off-by: Egor Kotov --- background.js | 66 +++++- firefox/background.js | 79 +++++- firefox/popup.html | 531 +++++++++++++++++++---------------------- firefox/popup.js | 118 ++++++++- popup.html | 541 +++++++++++++++++++----------------------- popup.js | 124 +++++++++- ts-browser-ext.go | 156 +++++++++++- 7 files changed, 1011 insertions(+), 604 deletions(-) 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 {