├── LICENSE ├── PATENTS ├── README.md ├── background.js ├── chrome.txt ├── firefox ├── background.js ├── icon.png ├── manifest.json ├── need-install.png ├── offline.png ├── online.png ├── popup.html └── popup.js ├── go.mod ├── go.sum ├── icon.png ├── manifest.json ├── offline.png ├── online.png ├── popup.html ├── popup.js └── ts-browser-ext.go /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2020 Tailscale Inc & AUTHORS. 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are met: 7 | 8 | 1. Redistributions of source code must retain the above copyright notice, this 9 | list of conditions and the following disclaimer. 10 | 11 | 2. Redistributions in binary form must reproduce the above copyright notice, 12 | this list of conditions and the following disclaimer in the documentation 13 | and/or other materials provided with the distribution. 14 | 15 | 3. Neither the name of the copyright holder nor the names of its 16 | contributors may be used to endorse or promote products derived from 17 | this software without specific prior written permission. 18 | 19 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 20 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 21 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 22 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 23 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 24 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 25 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 26 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 27 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 28 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 29 | -------------------------------------------------------------------------------- /PATENTS: -------------------------------------------------------------------------------- 1 | Additional IP Rights Grant (Patents) 2 | 3 | "This implementation" means the copyrightable works distributed by 4 | Tailscale Inc. as part of the Tailscale project. 5 | 6 | Tailscale Inc. hereby grants to You a perpetual, worldwide, 7 | non-exclusive, no-charge, royalty-free, irrevocable (except as stated 8 | in this section) patent license to make, have made, use, offer to 9 | sell, sell, import, transfer and otherwise run, modify and propagate 10 | the contents of this implementation of Tailscale, where such license 11 | applies only to those patent claims, both currently owned or 12 | controlled by Tailscale Inc. and acquired in the future, licensable 13 | by Tailscale Inc. that are necessarily infringed by this 14 | implementation of Tailscale. This grant does not include claims that 15 | would be infringed only as a consequence of further modification of 16 | this implementation. If you or your agent or exclusive licensee 17 | institute or order or agree to the institution of patent litigation 18 | against any entity (including a cross-claim or counterclaim in a 19 | lawsuit) alleging that this implementation of Tailscale or any code 20 | incorporated within this implementation of Tailscale constitutes 21 | direct or contributory patent infringement, or inducement of patent 22 | infringement, then any patent rights granted to you under this License 23 | for this implementation of Tailscale shall terminate as of the date 24 | such litigation is filed. 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Tailscale Browser Extension (Experiment) 2 | 3 | [![status: experimental](https://img.shields.io/badge/status-experimental-blue)](https://tailscale.com/kb/1167/release-stages/#experimental) 4 | 5 | The [Tailscale](https://tailscale.com/) Browser Extension lets you access your tailnet resources 6 | using a browser extension, without necessarily installing Tailscale 7 | system-wide. 8 | 9 | In particular, ... 10 | 11 | * you can **simultaneously use a different tailnet per browser profile** 12 | * separate out your personal tailnet in its own browser profile 13 | * you don't need to be root/admin to install it 14 | * it doesn't interfere with your other OS VPN(s) and route tables and is purely scoped to one browser profile 15 | 16 | ## How it works 17 | 18 | Ideally it would work purely with WASM/WASI, but browser extensions 19 | don't have enough APIs, so it regrettably has to use Native Messaging 20 | ([Chrome](https://developer.chrome.com/docs/extensions/develop/concepts/native-messaging), 21 | [Firefox](https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/Native_messaging)) 22 | where a native binary (using 23 | [`tsnet`](https://tailscale.com/kb/1244/tsnet)) runs as a child 24 | process under the browser and communicates with the browser extension 25 | with JSON messages back and forth. 26 | 27 | The child process then runs an HTTP/SOCKS5 proxy on `localhost:0` 28 | (with the kernel picking a random free port) and the browser extension 29 | uses the browser proxy API to send all web traffic through the child's 30 | proxy, which then sends it out over Tailscale, an exit node, or the 31 | Internet as normal. 32 | 33 | ## Status 34 | 35 | As of 2025-02-25, this is **barely just starting to work** and is not 36 | meant for end users yet. It's barely meant for developers at this 37 | point. 38 | 39 | | Browser | OS | Status | 40 | | -------- | ------- | ---- | 41 | | Chrome | macOS | Works | 42 | | Chrome | Linux | Works in theory, untested | 43 | | Chrome | Windows | Registry install work not yet done | 44 | | Firefox | macOS | Mostly works | 45 | | Firefox | Linux | Mostly works in theory, untested | 46 | | Firefox | Windows | Registry install work not yet done | 47 | | Safari | * | not possible; no support for Native Messaging | 48 | 49 | ## Developer instructions 50 | 51 | * use Chrome (for now) 52 | * Extensions... 53 | * Manage Extensions... 54 | * click "Developer Mode" 55 | * Load Unpacked... 56 | * navigate to directory where you cloned this repo... 57 | * install 58 | * pin the extension 59 | * click it 60 | * follow instructions to `go install` the backend part 61 | * click again, "Log in" 62 | 63 | To log out, for now you need to remove & re-add the extension. 64 | 65 | ## End user instructions 66 | 67 | Don't use it yet. It's too rough. See status above. 68 | 69 | 70 | -------------------------------------------------------------------------------- /background.js: -------------------------------------------------------------------------------- 1 | let proxyEnabled = false; 2 | 3 | // setPopupIcon sets the icon. It takes either a boolean (for online/offline) 4 | // or the base name of the png file. 5 | function setPopupIcon(base) { 6 | if (typeof base === "boolean") { 7 | base = base ? "online" : "offline"; 8 | } 9 | let iconPath = base + ".png"; 10 | console.log("set icon path to: " + iconPath); 11 | 12 | chrome.action.setIcon({ path: iconPath }, () => { 13 | if (chrome.runtime.lastError) { 14 | console.error( 15 | "Error setting icon to " + iconPath + ":", 16 | chrome.runtime.lastError.message 17 | ); 18 | } 19 | }); 20 | } 21 | 22 | function enableProxy() { 23 | if (deadPort) { 24 | console.error("Cannot enable proxy, disconnected from native host"); 25 | return; 26 | } 27 | 28 | if (lastProxyPort) { 29 | nmPort.postMessage({ cmd: "get-status" }); 30 | } else { 31 | nmPort.postMessage({ cmd: "up" }); 32 | } 33 | } 34 | 35 | function disableProxy() { 36 | console.log("disableProxy called"); 37 | if (nmPort && !deadPort) { 38 | console.log("Sending down command to native host"); 39 | nmPort.postMessage({ cmd: "down" }); 40 | } else { 41 | console.log( 42 | "Cannot send down command - nmPort:", 43 | !!nmPort, 44 | "deadPort:", 45 | deadPort 46 | ); 47 | } 48 | proxyEnabled = false; 49 | lastProxyPort = 0; 50 | console.log( 51 | "Proxy disabled, proxyEnabled:", 52 | proxyEnabled, 53 | "lastProxyPort:", 54 | lastProxyPort 55 | ); 56 | } 57 | 58 | console.log("starting ts-browser-ext"); 59 | 60 | let popupPort = null; 61 | 62 | chrome.runtime.onConnect.addListener((port) => { 63 | if (port.name != "popup") { 64 | return; 65 | } 66 | popupPort = port; 67 | 68 | console.log("Popup connected"); 69 | 70 | port.onMessage.addListener((msg) => { 71 | console.log("Message from popup:", msg); 72 | }); 73 | 74 | port.onDisconnect.addListener(() => { 75 | console.log("Popup disconnected"); 76 | popupPort = null; 77 | }); 78 | 79 | sendPopupStatus(); 80 | }); 81 | 82 | // browserByte returns either "F" for Firefox or "C" for chrome. 83 | // Other browsers return "?". 84 | function browserByte() { 85 | if (typeof chrome !== "undefined") { 86 | if (typeof browser !== "undefined") { 87 | return "F"; // Firefox supports both `chrome` and `browser` 88 | } 89 | return "C"; 90 | } 91 | return "?"; 92 | } 93 | 94 | function sendPopupStatus() { 95 | if (deadPort) { 96 | setPopupIcon("need-install"); 97 | console.log("sendPopupStatus... no nmPort"); 98 | sendToPopup({ 99 | installCmd: 100 | "go run github.com/tailscale/ts-browser-ext@main --install=" + 101 | browserByte() + 102 | chrome.runtime.id, 103 | }); 104 | return; 105 | } 106 | setPopupIcon(proxyEnabled ? "online" : "offline"); 107 | 108 | sendToPopup({ status: lastStatus }); 109 | } 110 | 111 | function sendToPopup(v) { 112 | if (popupPort) { 113 | popupPort.postMessage(v); 114 | } 115 | } 116 | 117 | let nmPort = null; // even non-null if lacking permission 118 | let deadPort = true; 119 | let portError = null; 120 | 121 | connectToNativeHost(); 122 | 123 | function connectToNativeHost() { 124 | if (nmPort && !deadPort) { 125 | return; 126 | } 127 | console.log("Connecting to native messaging host..."); 128 | nmPort = chrome.runtime.connectNative("com.tailscale.browserext.chrome"); 129 | 130 | nmPort.onDisconnect.addListener(() => { 131 | deadPort = true; 132 | setPopupIcon("need-install"); 133 | disableProxy(); 134 | const error = chrome.runtime.lastError; 135 | if (error) { 136 | console.error("Connection failed:", error.message); 137 | portError = error.message; 138 | setTimeout(connectToNativeHost, 1000); 139 | } else { 140 | console.error("Disconnected from native host"); 141 | } 142 | }); 143 | nmPort.onMessage.addListener((message) => { 144 | console.log("got message: " + JSON.stringify(message)); 145 | if (deadPort) { 146 | console.log("connected to native backend"); 147 | deadPort = false; 148 | } 149 | if (message.procRunning) { 150 | if (message.procRunning.port) { 151 | setProxy(message.procRunning.port); 152 | } else if (message.procRunning.errror) { 153 | console.log( 154 | "procRunning error from backend: " + message.procRunning.err 155 | ); 156 | disableProxy(); 157 | } 158 | } 159 | if (message.init && message.init.error) { 160 | console.log("init error from backend: " + message.init.err); 161 | disableProxy(); 162 | } 163 | if (message.status) { 164 | lastStatus = message.status; 165 | } 166 | maybeSendInit(); 167 | sendPopupStatus(); 168 | }); 169 | } 170 | 171 | var lastProxyPort = 0; 172 | var lastStatus = {}; // last Go status 173 | 174 | function setProxy(proxyPort) { 175 | if (proxyPort) { 176 | proxyEnabled = true; 177 | lastProxyPort = proxyPort; 178 | console.log("Enabling proxy at port: " + proxyPort); 179 | } else { 180 | proxyEnabled = false; 181 | console.log("Disabling proxy..."); 182 | chrome.proxy.settings.set( 183 | { 184 | value: { 185 | mode: "direct", 186 | }, 187 | scope: "regular", 188 | }, 189 | () => { 190 | console.log("Proxy disabled."); 191 | } 192 | ); 193 | return; 194 | } 195 | chrome.proxy.settings.set( 196 | { 197 | value: { 198 | mode: "fixed_servers", 199 | rules: { 200 | singleProxy: { 201 | scheme: "http", 202 | host: "127.0.0.1", 203 | port: proxyPort, 204 | }, 205 | bypassList: ["localhost", "127.*"], 206 | }, 207 | }, 208 | scope: "regular", 209 | }, 210 | () => { 211 | console.log("Proxy enabled: 127.0.0.1:" + proxyPort); 212 | } 213 | ); 214 | } 215 | 216 | var profileID = ""; 217 | var didInit = false; 218 | 219 | function maybeSendInit() { 220 | if (!profileID || didInit || deadPort) { 221 | return; 222 | } 223 | nmPort.postMessage({ cmd: "init", initID: profileID }); 224 | didInit = true; 225 | } 226 | 227 | chrome.storage.local.get("profileId", (result) => { 228 | if (!result.profileId) { 229 | const profileId = crypto.randomUUID(); 230 | chrome.storage.local.set({ profileId }, () => { 231 | console.log("Generated profile ID:", profileId); 232 | profileID = profileId; 233 | maybeSendInit(); 234 | }); 235 | } else { 236 | console.log("Profile ID already exists:", result.profileId); 237 | profileID = result.profileId; 238 | maybeSendInit(); 239 | } 240 | }); 241 | 242 | // Listener for messages from the popup 243 | chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { 244 | console.log("bg: Received message:", message); 245 | if (message.command === "toggleProxy") { 246 | console.log("bg: toggleProxy received, current proxy=" + proxyEnabled); 247 | proxyEnabled = !proxyEnabled; 248 | if (proxyEnabled) { 249 | console.log("bg: Enabling proxy"); 250 | enableProxy(); 251 | console.log("bg: toggleProxy on, now proxy=" + proxyEnabled); 252 | sendResponse({ status: lastStatus }); 253 | console.log("bg: toggleProxy on, sent status response"); 254 | } else { 255 | console.log("bg: Disabling proxy"); 256 | disableProxy(); 257 | console.log("bg: toggleProxy off, now proxy=" + proxyEnabled); 258 | sendResponse({ status: "Disconnected" }); 259 | console.log("bg: toggleProxy off, sent disconnected response"); 260 | } 261 | setPopupIcon(proxyEnabled); 262 | return true; // Keep the message channel open for the async response 263 | } 264 | }); 265 | -------------------------------------------------------------------------------- /chrome.txt: -------------------------------------------------------------------------------- 1 | % pwd 2 | /Users/bradfitz/Library/Application Support/Google/Chrome/NativeMessagingHosts 3 | 4 | % cat com.tailscale.chrome-ext.json 5 | { 6 | "name": "com.tailscale.chrome-ext", 7 | "description": "Tailscale Native Extension", 8 | "path": "/Users/bradfitz/go/bin/ts-browser-native-ext", 9 | "type": "stdio", 10 | "allowed_origins": [ 11 | "chrome-extension://gdopnimobeboikkiagbnnbcijkjdjcad/" 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /firefox/background.js: -------------------------------------------------------------------------------- 1 | let proxyEnabled = false; 2 | 3 | // setPopupIcon sets the icon. It takes either a boolean (for online/offline) 4 | // or the base name of the png file. 5 | function setPopupIcon(base) { 6 | if (typeof base === "boolean") { 7 | base = base ? "online" : "offline"; 8 | } 9 | let iconPath = base + ".png"; 10 | console.log("set icon path to: " + iconPath); 11 | 12 | browser.action.setIcon({ path: iconPath }).catch((error) => { 13 | console.error("Error setting icon to " + iconPath + ":", error.message); 14 | }); 15 | } 16 | 17 | function enableProxy() { 18 | if (deadPort) { 19 | console.error("Cannot enable proxy, disconnected from native host"); 20 | return; 21 | } 22 | 23 | if (lastProxyPort) { 24 | nmPort.postMessage({ cmd: "get-status" }); 25 | } else { 26 | nmPort.postMessage({ cmd: "up" }); 27 | } 28 | } 29 | 30 | function disableProxy() { 31 | console.log("disableProxy called"); 32 | if (nmPort && !deadPort) { 33 | console.log("Sending down command to native host"); 34 | nmPort.postMessage({ cmd: "down" }); 35 | } else { 36 | console.log( 37 | "Cannot send down command - nmPort:", 38 | !!nmPort, 39 | "deadPort:", 40 | deadPort 41 | ); 42 | } 43 | proxyEnabled = false; 44 | lastProxyPort = 0; 45 | console.log( 46 | "Proxy disabled, proxyEnabled:", 47 | proxyEnabled, 48 | "lastProxyPort:", 49 | lastProxyPort 50 | ); 51 | } 52 | 53 | console.log("starting ts-browser-ext"); 54 | 55 | let popupPort = null; 56 | 57 | browser.runtime.onConnect.addListener((port) => { 58 | if (port.name != "popup") { 59 | return; 60 | } 61 | popupPort = port; 62 | 63 | console.log("Popup connected"); 64 | 65 | port.onMessage.addListener((msg) => { 66 | console.log("Message from popup:", msg); 67 | }); 68 | 69 | port.onDisconnect.addListener(() => { 70 | console.log("Popup disconnected"); 71 | popupPort = null; 72 | }); 73 | 74 | sendPopupStatus(); 75 | }); 76 | 77 | // browserByte returns either "F" for Firefox or "C" for chrome. 78 | // Other browsers return "?". 79 | function browserByte() { 80 | if (typeof browser !== "undefined") { 81 | return "F"; 82 | } 83 | return "?"; 84 | } 85 | 86 | function sendPopupStatus() { 87 | // firefox requires that extensions settings proxies have private browsing access 88 | browser.extension.isAllowedIncognitoAccess().then(isAllowed => { 89 | if (!isAllowed) { 90 | sendToPopup({ 91 | needsIncognitoPermission: true 92 | }); 93 | } 94 | }); 95 | 96 | if (deadPort) { 97 | setPopupIcon("need-install"); 98 | console.log("sendPopupStatus... no nmPort"); 99 | sendToPopup({ 100 | installCmd: 101 | "go run github.com/tailscale/ts-browser-ext@main --install=" + 102 | browserByte() + 103 | browser.runtime.id, 104 | }); 105 | return; 106 | } 107 | setPopupIcon(proxyEnabled ? "online" : "offline"); 108 | 109 | sendToPopup({ status: lastStatus }); 110 | } 111 | 112 | function sendToPopup(v) { 113 | if (popupPort) { 114 | popupPort.postMessage(v); 115 | } 116 | } 117 | 118 | let nmPort = null; // even non-null if lacking permission 119 | let deadPort = true; 120 | let portError = null; 121 | 122 | connectToNativeHost(); 123 | 124 | function connectToNativeHost() { 125 | if (nmPort && !deadPort) { 126 | return; 127 | } 128 | console.log("Connecting to native messaging host..."); 129 | nmPort = browser.runtime.connectNative("com.tailscale.browserext.firefox"); 130 | 131 | nmPort.onDisconnect.addListener(() => { 132 | deadPort = true; 133 | setPopupIcon("need-install"); 134 | disableProxy(); 135 | const error = browser.runtime.lastError; 136 | if (error) { 137 | console.error("Connection failed:", error.message); 138 | portError = error.message; 139 | setTimeout(connectToNativeHost, 1000); 140 | } else { 141 | console.error("Disconnected from native host"); 142 | } 143 | }); 144 | nmPort.onMessage.addListener((message) => { 145 | console.log("got message: " + JSON.stringify(message)); 146 | if (deadPort) { 147 | console.log("connected to native backend"); 148 | deadPort = false; 149 | } 150 | if (message.procRunning) { 151 | if (message.procRunning.port) { 152 | setProxy(message.procRunning.port); 153 | } else if (message.procRunning.errror) { 154 | console.log( 155 | "procRunning error from backend: " + message.procRunning.err 156 | ); 157 | disableProxy(); 158 | } 159 | } 160 | if (message.init && message.init.error) { 161 | console.log("init error from backend: " + message.init.err); 162 | disableProxy(); 163 | } 164 | if (message.status) { 165 | lastStatus = message.status; 166 | } 167 | maybeSendInit(); 168 | sendPopupStatus(); 169 | }); 170 | } 171 | 172 | var lastProxyPort = 0; 173 | var lastStatus = {}; // last Go status 174 | 175 | function setProxy(proxyPort) { 176 | if (proxyPort) { 177 | proxyEnabled = true; 178 | lastProxyPort = proxyPort; 179 | console.log("Enabling proxy at port: " + proxyPort); 180 | } else { 181 | proxyEnabled = false; 182 | console.log("Disabling proxy..."); 183 | browser.proxy.settings 184 | .set({ 185 | value: { 186 | mode: "direct", 187 | }, 188 | scope: "regular", 189 | }) 190 | .then(() => { 191 | console.log("Proxy disabled."); 192 | }); 193 | return; 194 | } 195 | browser.proxy.settings 196 | .set({ 197 | value: { 198 | proxyType: "manual", 199 | http: "127.0.0.1:" + proxyPort, 200 | bypassList: [""], 201 | }, 202 | scope: "regular", 203 | }) 204 | .then(() => { 205 | console.log("Proxy enabled: 127.0.0.1:" + proxyPort); 206 | }); 207 | } 208 | 209 | var profileID = ""; 210 | var didInit = false; 211 | 212 | function maybeSendInit() { 213 | if (!profileID || didInit || deadPort) { 214 | return; 215 | } 216 | nmPort.postMessage({ cmd: "init", initID: profileID }); 217 | didInit = true; 218 | } 219 | 220 | browser.storage.local.get("profileId").then((result) => { 221 | if (!result.profileId) { 222 | const profileId = crypto.randomUUID(); 223 | browser.storage.local.set({ profileId }).then(() => { 224 | console.log("Generated profile ID:", profileId); 225 | profileID = profileId; 226 | maybeSendInit(); 227 | }); 228 | } else { 229 | console.log("Profile ID already exists:", result.profileId); 230 | profileID = result.profileId; 231 | maybeSendInit(); 232 | } 233 | }); 234 | 235 | // Listener for messages from the popup 236 | browser.runtime.onMessage.addListener((message, sender) => { 237 | console.log("bg: Received message:", message); 238 | if (message.command === "toggleProxy") { 239 | console.log("bg: toggleProxy received, current proxy=" + proxyEnabled); 240 | proxyEnabled = !proxyEnabled; 241 | if (proxyEnabled) { 242 | console.log("bg: Enabling proxy"); 243 | enableProxy(); 244 | } else { 245 | console.log("bg: Disabling proxy"); 246 | disableProxy(); 247 | } 248 | return Promise.resolve({ status: lastStatus }); 249 | } 250 | }); 251 | -------------------------------------------------------------------------------- /firefox/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tailscale/ts-browser-ext/be17a46f779a59f78c63a5f4f15d5c312b9d7010/firefox/icon.png -------------------------------------------------------------------------------- /firefox/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 3, 3 | "name": "Tailscale Extension", 4 | "version": "1.0", 5 | "description": "A Tailscale client that runs as a browser extension, permitting use of different tailnets in differenet browser profiles, without affecting the system VPN or networking settings.", 6 | "browser_specific_settings": { 7 | "gecko": { 8 | "id": "browser-ext@tailscale.com", 9 | "strict_min_version": "50.0" 10 | } 11 | }, 12 | "permissions": [ 13 | "proxy", 14 | "storage", 15 | "nativeMessaging" 16 | ], 17 | "host_permissions": [ 18 | "" 19 | ], 20 | "background": { 21 | "scripts": ["background.js"] 22 | }, 23 | "action": { 24 | "default_popup": "popup.html", 25 | "default_icon": "icon.png" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /firefox/need-install.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tailscale/ts-browser-ext/be17a46f779a59f78c63a5f4f15d5c312b9d7010/firefox/need-install.png -------------------------------------------------------------------------------- /firefox/offline.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tailscale/ts-browser-ext/be17a46f779a59f78c63a5f4f15d5c312b9d7010/firefox/offline.png -------------------------------------------------------------------------------- /firefox/online.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tailscale/ts-browser-ext/be17a46f779a59f78c63a5f4f15d5c312b9d7010/firefox/online.png -------------------------------------------------------------------------------- /firefox/popup.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Proxy Toggle 6 | 7 | 8 | 12 | 13 | 124 | 125 | 126 |
127 | 314 |
315 | 316 |
317 |
318 |
Disconnected
319 |
320 | 321 |
322 | 323 | 324 | -------------------------------------------------------------------------------- /firefox/popup.js: -------------------------------------------------------------------------------- 1 | var lastStatus; 2 | 3 | document.addEventListener("DOMContentLoaded", () => { 4 | const toggleSlider = document.getElementById("toggleSlider"); 5 | const slider = document.querySelector(".slider"); 6 | const settingsButton = document.getElementById("settingsButton"); 7 | const stateDisplay = document.getElementById("state"); 8 | let isConnected = false; 9 | let isLoading = true; 10 | let hasReceivedInitialState = false; 11 | 12 | const port = browser.runtime.connect({ name: "popup" }); 13 | 14 | function updateSliderState() { 15 | if (isLoading) { 16 | slider.className = "slider loading"; 17 | toggleSlider.checked = true; // Assume connected while loading 18 | return; 19 | } 20 | // Only remove no-transition after we've received and applied the initial state 21 | if (hasReceivedInitialState) { 22 | slider.classList.remove("no-transition"); 23 | } 24 | slider.className = `slider ${isConnected ? "connected" : ""}`; 25 | toggleSlider.checked = isConnected; 26 | } 27 | 28 | function updateStatus(status) { 29 | isLoading = false; 30 | hasReceivedInitialState = true; 31 | if (status.error) { 32 | if (status.error === "State: Stopped") { 33 | stateDisplay.textContent = "Disconnected"; 34 | isConnected = false; 35 | updateSliderState(); 36 | return; 37 | } 38 | stateDisplay.textContent = `Error: ${status.error}`; 39 | return; 40 | } 41 | if (status.needsLogin) { 42 | stateDisplay.innerHTML = status.browseToURL 43 | ? `Log in` 44 | : "Login required; no URL"; 45 | return; 46 | } 47 | if (typeof status === "string" && status === "Disconnected") { 48 | stateDisplay.textContent = "Disconnected"; 49 | isConnected = false; 50 | updateSliderState(); 51 | return; 52 | } 53 | if (status.running !== undefined) { 54 | stateDisplay.textContent = status.running 55 | ? `Connected as ${status.tailnet || "Not connected"}` 56 | : "Disconnected"; 57 | isConnected = status.running; 58 | updateSliderState(); 59 | } 60 | } 61 | 62 | port.onMessage.addListener((msg) => { 63 | console.log("Received from background:", JSON.stringify(msg)); 64 | 65 | // firefox requires that extensions settings proxies have private browsing access 66 | if (msg.needsIncognitoPermission) { 67 | console.log("Private browsing permission needed") 68 | stateDisplay.innerHTML = `Enable private browsing access.` 69 | return; 70 | } 71 | 72 | if (msg.installCmd) { 73 | console.log("Received install command"); 74 | stateDisplay.innerHTML = `Installation needed. Run:
${msg.installCmd}
`; 75 | toggleSlider.disabled = true; 76 | settingsButton.hidden = true; 77 | return; 78 | } 79 | if (msg.error) { 80 | console.log("Error from background:", msg); 81 | stateDisplay.textContent = msg.error; 82 | toggleSlider.disabled = true; 83 | settingsButton.hidden = true; 84 | return; 85 | } 86 | if (msg.status) { 87 | console.log("Received status update:", msg.status); 88 | updateStatus(msg.status); 89 | } 90 | }); 91 | 92 | toggleSlider.addEventListener("change", () => { 93 | console.log("Toggle slider changed, current state:", isConnected); 94 | browser.runtime.sendMessage({ command: "toggleProxy" }).then((response) => { 95 | console.log("Received response from background:", response); 96 | if (response && response.status) { 97 | updateStatus(response.status); 98 | } 99 | }); 100 | console.log("Sent toggleProxy command to background"); 101 | }); 102 | 103 | settingsButton.addEventListener("click", () => { 104 | console.log("Settings button clicked"); 105 | browser.tabs.create({ url: "http://100.100.100.100" }); 106 | }); 107 | 108 | window.addEventListener("beforeunload", () => { 109 | port.disconnect(); 110 | }); 111 | }); 112 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/tailscale/ts-browser-ext 2 | 3 | go 1.23.4 4 | 5 | require tailscale.com v1.80.1 6 | 7 | require ( 8 | filippo.io/edwards25519 v1.1.0 // indirect 9 | github.com/akutz/memconn v0.1.0 // indirect 10 | github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa // indirect 11 | github.com/aws/aws-sdk-go-v2 v1.24.1 // indirect 12 | github.com/aws/aws-sdk-go-v2/config v1.26.5 // indirect 13 | github.com/aws/aws-sdk-go-v2/credentials v1.16.16 // indirect 14 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.14.11 // indirect 15 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.2.10 // indirect 16 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.5.10 // indirect 17 | github.com/aws/aws-sdk-go-v2/internal/ini v1.7.2 // indirect 18 | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.10.4 // indirect 19 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.10.10 // indirect 20 | github.com/aws/aws-sdk-go-v2/service/ssm v1.44.7 // indirect 21 | github.com/aws/aws-sdk-go-v2/service/sso v1.18.7 // indirect 22 | github.com/aws/aws-sdk-go-v2/service/ssooidc v1.21.7 // indirect 23 | github.com/aws/aws-sdk-go-v2/service/sts v1.26.7 // indirect 24 | github.com/aws/smithy-go v1.19.0 // indirect 25 | github.com/bits-and-blooms/bitset v1.13.0 // indirect 26 | github.com/coder/websocket v1.8.12 // indirect 27 | github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6 // indirect 28 | github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa // indirect 29 | github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e // indirect 30 | github.com/fxamacker/cbor/v2 v2.7.0 // indirect 31 | github.com/gaissmai/bart v0.11.1 // indirect 32 | github.com/go-json-experiment/json v0.0.0-20250103232110-6a9a0fde9288 // indirect 33 | github.com/go-ole/go-ole v1.3.0 // indirect 34 | github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466 // indirect 35 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect 36 | github.com/google/btree v1.1.2 // indirect 37 | github.com/google/go-cmp v0.6.0 // indirect 38 | github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806 // indirect 39 | github.com/google/uuid v1.6.0 // indirect 40 | github.com/gorilla/csrf v1.7.3-0.20250123201450-9dd6af1f6d30 // indirect 41 | github.com/gorilla/securecookie v1.1.2 // indirect 42 | github.com/hdevalence/ed25519consensus v0.2.0 // indirect 43 | github.com/illarion/gonotify/v2 v2.0.3 // indirect 44 | github.com/insomniacslk/dhcp v0.0.0-20231206064809-8c70d406f6d2 // indirect 45 | github.com/jmespath/go-jmespath v0.4.0 // indirect 46 | github.com/jsimonetti/rtnetlink v1.4.0 // indirect 47 | github.com/klauspost/compress v1.17.11 // indirect 48 | github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a // indirect 49 | github.com/mdlayher/genetlink v1.3.2 // indirect 50 | github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 // indirect 51 | github.com/mdlayher/sdnotify v1.0.0 // indirect 52 | github.com/mdlayher/socket v0.5.0 // indirect 53 | github.com/miekg/dns v1.1.58 // indirect 54 | github.com/mitchellh/go-ps v1.0.0 // indirect 55 | github.com/pierrec/lz4/v4 v4.1.21 // indirect 56 | github.com/prometheus-community/pro-bing v0.4.0 // indirect 57 | github.com/safchain/ethtool v0.3.0 // indirect 58 | github.com/tailscale/certstore v0.1.1-0.20231202035212-d3fa0460f47e // indirect 59 | github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 // indirect 60 | github.com/tailscale/golang-x-crypto v0.0.0-20240604161659-3fde5e568aa4 // indirect 61 | github.com/tailscale/goupnp v1.0.1-0.20210804011211-c64d0f06ea05 // indirect 62 | github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a // indirect 63 | github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7 // indirect 64 | github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc // indirect 65 | github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976 // indirect 66 | github.com/tailscale/wireguard-go v0.0.0-20250107165329-0b8b35511f19 // indirect 67 | github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 // indirect 68 | github.com/vishvananda/netns v0.0.4 // indirect 69 | github.com/x448/float16 v0.8.4 // indirect 70 | go4.org/mem v0.0.0-20240501181205-ae6ca9944745 // indirect 71 | go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect 72 | golang.org/x/crypto v0.32.0 // indirect 73 | golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8 // indirect 74 | golang.org/x/mod v0.22.0 // indirect 75 | golang.org/x/net v0.34.0 // indirect 76 | golang.org/x/sync v0.10.0 // indirect 77 | golang.org/x/sys v0.29.1-0.20250107080300-1c14dcadc3ab // indirect 78 | golang.org/x/term v0.28.0 // indirect 79 | golang.org/x/text v0.21.0 // indirect 80 | golang.org/x/time v0.9.0 // indirect 81 | golang.org/x/tools v0.29.0 // indirect 82 | golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect 83 | golang.zx2c4.com/wireguard/windows v0.5.3 // indirect 84 | gvisor.dev/gvisor v0.0.0-20240722211153-64c016c92987 // indirect 85 | ) 86 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= 2 | filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= 3 | filippo.io/mkcert v1.4.4 h1:8eVbbwfVlaqUM7OwuftKc2nuYOoTDQWqsoXmzoXZdbc= 4 | filippo.io/mkcert v1.4.4/go.mod h1:VyvOchVuAye3BoUsPUOOofKygVwLV2KQMVFJNRq+1dA= 5 | github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c h1:pxW6RcqyfI9/kWtOwnv/G+AzdKuy2ZrqINhenH4HyNs= 6 | github.com/BurntSushi/toml v1.4.1-0.20240526193622-a339e1f7089c/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= 7 | github.com/akutz/memconn v0.1.0 h1:NawI0TORU4hcOMsMr11g7vwlCdkYeLKXBcxWu2W/P8A= 8 | github.com/akutz/memconn v0.1.0/go.mod h1:Jo8rI7m0NieZyLI5e2CDlRdRqRRB4S7Xp77ukDjH+Fw= 9 | github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI= 10 | github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4= 11 | github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= 12 | github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= 13 | github.com/aws/aws-sdk-go-v2 v1.24.1 h1:xAojnj+ktS95YZlDf0zxWBkbFtymPeDP+rvUQIH3uAU= 14 | github.com/aws/aws-sdk-go-v2 v1.24.1/go.mod h1:LNh45Br1YAkEKaAqvmE1m8FUx6a5b/V0oAKV7of29b4= 15 | github.com/aws/aws-sdk-go-v2/config v1.26.5 h1:lodGSevz7d+kkFJodfauThRxK9mdJbyutUxGq1NNhvw= 16 | github.com/aws/aws-sdk-go-v2/config v1.26.5/go.mod h1:DxHrz6diQJOc9EwDslVRh84VjjrE17g+pVZXUeSxaDU= 17 | github.com/aws/aws-sdk-go-v2/credentials v1.16.16 h1:8q6Rliyv0aUFAVtzaldUEcS+T5gbadPbWdV1WcAddK8= 18 | github.com/aws/aws-sdk-go-v2/credentials v1.16.16/go.mod h1:UHVZrdUsv63hPXFo1H7c5fEneoVo9UXiz36QG1GEPi0= 19 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.14.11 h1:c5I5iH+DZcH3xOIMlz3/tCKJDaHFwYEmxvlh2fAcFo8= 20 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.14.11/go.mod h1:cRrYDYAMUohBJUtUnOhydaMHtiK/1NZ0Otc9lIb6O0Y= 21 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.2.10 h1:vF+Zgd9s+H4vOXd5BMaPWykta2a6Ih0AKLq/X6NYKn4= 22 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.2.10/go.mod h1:6BkRjejp/GR4411UGqkX8+wFMbFbqsUIimfK4XjOKR4= 23 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.5.10 h1:nYPe006ktcqUji8S2mqXf9c/7NdiKriOwMvWQHgYztw= 24 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.5.10/go.mod h1:6UV4SZkVvmODfXKql4LCbaZUpF7HO2BX38FgBf9ZOLw= 25 | github.com/aws/aws-sdk-go-v2/internal/ini v1.7.2 h1:GrSw8s0Gs/5zZ0SX+gX4zQjRnRsMJDJ2sLur1gRBhEM= 26 | github.com/aws/aws-sdk-go-v2/internal/ini v1.7.2/go.mod h1:6fQQgfuGmw8Al/3M2IgIllycxV7ZW7WCdVSqfBeUiCY= 27 | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.10.4 h1:/b31bi3YVNlkzkBrm9LfpaKoaYZUxIAj4sHfOTmLfqw= 28 | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.10.4/go.mod h1:2aGXHFmbInwgP9ZfpmdIfOELL79zhdNYNmReK8qDfdQ= 29 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.10.10 h1:DBYTXwIGQSGs9w4jKm60F5dmCQ3EEruxdc0MFh+3EY4= 30 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.10.10/go.mod h1:wohMUQiFdzo0NtxbBg0mSRGZ4vL3n0dKjLTINdcIino= 31 | github.com/aws/aws-sdk-go-v2/service/ssm v1.44.7 h1:a8HvP/+ew3tKwSXqL3BCSjiuicr+XTU2eFYeogV9GJE= 32 | github.com/aws/aws-sdk-go-v2/service/ssm v1.44.7/go.mod h1:Q7XIWsMo0JcMpI/6TGD6XXcXcV1DbTj6e9BKNntIMIM= 33 | github.com/aws/aws-sdk-go-v2/service/sso v1.18.7 h1:eajuO3nykDPdYicLlP3AGgOyVN3MOlFmZv7WGTuJPow= 34 | github.com/aws/aws-sdk-go-v2/service/sso v1.18.7/go.mod h1:+mJNDdF+qiUlNKNC3fxn74WWNN+sOiGOEImje+3ScPM= 35 | github.com/aws/aws-sdk-go-v2/service/ssooidc v1.21.7 h1:QPMJf+Jw8E1l7zqhZmMlFw6w1NmfkfiSK8mS4zOx3BA= 36 | github.com/aws/aws-sdk-go-v2/service/ssooidc v1.21.7/go.mod h1:ykf3COxYI0UJmxcfcxcVuz7b6uADi1FkiUz6Eb7AgM8= 37 | github.com/aws/aws-sdk-go-v2/service/sts v1.26.7 h1:NzO4Vrau795RkUdSHKEwiR01FaGzGOH1EETJ+5QHnm0= 38 | github.com/aws/aws-sdk-go-v2/service/sts v1.26.7/go.mod h1:6h2YuIoxaMSCFf5fi1EgZAwdfkGMgDY+DVfa61uLe4U= 39 | github.com/aws/smithy-go v1.19.0 h1:KWFKQV80DpP3vJrrA9sVAHQ5gc2z8i4EzrLhLlWXcBM= 40 | github.com/aws/smithy-go v1.19.0/go.mod h1:NukqUGpCZIILqqiV0NIjeFh24kd/FAa4beRb6nbIUPE= 41 | github.com/bits-and-blooms/bitset v1.13.0 h1:bAQ9OPNFYbGHV6Nez0tmNI0RiEu7/hxlYJRUA0wFAVE= 42 | github.com/bits-and-blooms/bitset v1.13.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= 43 | github.com/cilium/ebpf v0.15.0 h1:7NxJhNiBT3NG8pZJ3c+yfrVdHY8ScgKD27sScgjLMMk= 44 | github.com/cilium/ebpf v0.15.0/go.mod h1:DHp1WyrLeiBh19Cf/tfiSMhqheEiK8fXFZ4No0P1Hso= 45 | github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo= 46 | github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs= 47 | github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6 h1:8h5+bWd7R6AYUslN6c6iuZWTKsKxUFDlpnmilO6R2n0= 48 | github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6/go.mod h1:Qe8Bv2Xik5FyTXwgIbLAnv2sWSBmvWdFETJConOQ//Q= 49 | github.com/creack/pty v1.1.23 h1:4M6+isWdcStXEf15G/RbrMPOQj1dZ7HPZCGwE4kOeP0= 50 | github.com/creack/pty v1.1.23/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= 51 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 52 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= 53 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 54 | github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa h1:h8TfIT1xc8FWbwwpmHn1J5i43Y0uZP97GqasGCzSRJk= 55 | github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa/go.mod h1:Nx87SkVqTKd8UtT+xu7sM/l+LgXs6c0aHrlKusR+2EQ= 56 | github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e h1:vUmf0yezR0y7jJ5pceLHthLaYf4bA5T14B6q39S4q2Q= 57 | github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e/go.mod h1:YTIHhz/QFSYnu/EhlF2SpU2Uk+32abacUYA5ZPljz1A= 58 | github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c= 59 | github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0= 60 | github.com/dsnet/try v0.0.3 h1:ptR59SsrcFUYbT/FhAbKTV6iLkeD6O18qfIWRml2fqI= 61 | github.com/dsnet/try v0.0.3/go.mod h1:WBM8tRpUmnXXhY1U6/S8dt6UWdHTQ7y8A5YSkRCkq40= 62 | github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= 63 | github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= 64 | github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E= 65 | github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ= 66 | github.com/gaissmai/bart v0.11.1 h1:5Uv5XwsaFBRo4E5VBcb9TzY8B7zxFf+U7isDxqOrRfc= 67 | github.com/gaissmai/bart v0.11.1/go.mod h1:KHeYECXQiBjTzQz/om2tqn3sZF1J7hw9m6z41ftj3fg= 68 | github.com/github/fakeca v0.1.0 h1:Km/MVOFvclqxPM9dZBC4+QE564nU4gz4iZ0D9pMw28I= 69 | github.com/github/fakeca v0.1.0/go.mod h1:+bormgoGMMuamOscx7N91aOuUST7wdaJ2rNjeohylyo= 70 | github.com/go-json-experiment/json v0.0.0-20250103232110-6a9a0fde9288 h1:KbX3Z3CgiYlbaavUq3Cj9/MjpO+88S7/AGXzynVDv84= 71 | github.com/go-json-experiment/json v0.0.0-20250103232110-6a9a0fde9288/go.mod h1:BWmvoE1Xia34f3l/ibJweyhrT+aROb/FQ6d+37F0e2s= 72 | github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= 73 | github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= 74 | github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466 h1:sQspH8M4niEijh3PFscJRLDnkL547IeP7kpPe3uUhEg= 75 | github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466/go.mod h1:ZiQxhyQ+bbbfxUKVvjfO498oPYvtYhZzycal3G/NHmU= 76 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= 77 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= 78 | github.com/google/btree v1.1.2 h1:xf4v41cLI2Z6FxbKm+8Bu+m8ifhj15JuZ9sa0jZCMUU= 79 | github.com/google/btree v1.1.2/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= 80 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 81 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 82 | github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= 83 | github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 84 | github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806 h1:wG8RYIyctLhdFk6Vl1yPGtSRtwGpVkWyZww1OCil2MI= 85 | github.com/google/nftables v0.2.1-0.20240414091927-5e242ec57806/go.mod h1:Beg6V6zZ3oEn0JuiUQ4wqwuyqqzasOltcoXPtgLbFp4= 86 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 87 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 88 | github.com/gorilla/csrf v1.7.3-0.20250123201450-9dd6af1f6d30 h1:fiJdrgVBkjZ5B1HJ2WQwNOaXB+QyYcNXTA3t1XYLz0M= 89 | github.com/gorilla/csrf v1.7.3-0.20250123201450-9dd6af1f6d30/go.mod h1:F1Fj3KG23WYHE6gozCmBAezKookxbIvUJT+121wTuLk= 90 | github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= 91 | github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo= 92 | github.com/hdevalence/ed25519consensus v0.2.0 h1:37ICyZqdyj0lAZ8P4D1d1id3HqbbG1N3iBb1Tb4rdcU= 93 | github.com/hdevalence/ed25519consensus v0.2.0/go.mod h1:w3BHWjwJbFU29IRHL1Iqkw3sus+7FctEyM4RqDxYNzo= 94 | github.com/illarion/gonotify/v2 v2.0.3 h1:B6+SKPo/0Sw8cRJh1aLzNEeNVFfzE3c6N+o+vyxM+9A= 95 | github.com/illarion/gonotify/v2 v2.0.3/go.mod h1:38oIJTgFqupkEydkkClkbL6i5lXV/bxdH9do5TALPEE= 96 | github.com/insomniacslk/dhcp v0.0.0-20231206064809-8c70d406f6d2 h1:9K06NfxkBh25x56yVhWWlKFE8YpicaSfHwoV8SFbueA= 97 | github.com/insomniacslk/dhcp v0.0.0-20231206064809-8c70d406f6d2/go.mod h1:3A9PQ1cunSDF/1rbTq99Ts4pVnycWg+vlPkfeD2NLFI= 98 | github.com/jellydator/ttlcache/v3 v3.1.0 h1:0gPFG0IHHP6xyUyXq+JaD8fwkDCqgqwohXNJBcYE71g= 99 | github.com/jellydator/ttlcache/v3 v3.1.0/go.mod h1:hi7MGFdMAwZna5n2tuvh63DvFLzVKySzCVW6+0gA2n4= 100 | github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= 101 | github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= 102 | github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= 103 | github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= 104 | github.com/jsimonetti/rtnetlink v1.4.0 h1:Z1BF0fRgcETPEa0Kt0MRk3yV5+kF1FWTni6KUFKrq2I= 105 | github.com/jsimonetti/rtnetlink v1.4.0/go.mod h1:5W1jDvWdnthFJ7fxYX1GMK07BUpI4oskfOqvPteYS6E= 106 | github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc= 107 | github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= 108 | github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a h1:+RR6SqnTkDLWyICxS1xpjCi/3dhyV+TgZwA6Ww3KncQ= 109 | github.com/kortschak/wol v0.0.0-20200729010619-da482cc4850a/go.mod h1:YTtCCM3ryyfiu4F7t8HQ1mxvp1UBdWM2r6Xa+nGWvDk= 110 | github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= 111 | github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= 112 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 113 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 114 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 115 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 116 | github.com/mdlayher/genetlink v1.3.2 h1:KdrNKe+CTu+IbZnm/GVUMXSqBBLqcGpRDa0xkQy56gw= 117 | github.com/mdlayher/genetlink v1.3.2/go.mod h1:tcC3pkCrPUGIKKsCsp0B3AdaaKuHtaxoJRz3cc+528o= 118 | github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 h1:A1Cq6Ysb0GM0tpKMbdCXCIfBclan4oHk1Jb+Hrejirg= 119 | github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42/go.mod h1:BB4YCPDOzfy7FniQ/lxuYQ3dgmM2cZumHbK8RpTjN2o= 120 | github.com/mdlayher/sdnotify v1.0.0 h1:Ma9XeLVN/l0qpyx1tNeMSeTjCPH6NtuD6/N9XdTlQ3c= 121 | github.com/mdlayher/sdnotify v1.0.0/go.mod h1:HQUmpM4XgYkhDLtd+Uad8ZFK1T9D5+pNxnXQjCeJlGE= 122 | github.com/mdlayher/socket v0.5.0 h1:ilICZmJcQz70vrWVes1MFera4jGiWNocSkykwwoy3XI= 123 | github.com/mdlayher/socket v0.5.0/go.mod h1:WkcBFfvyG8QENs5+hfQPl1X6Jpd2yeLIYgrGFmJiJxI= 124 | github.com/miekg/dns v1.1.58 h1:ca2Hdkz+cDg/7eNF6V56jjzuZ4aCAE+DbVkILdQWG/4= 125 | github.com/miekg/dns v1.1.58/go.mod h1:Ypv+3b/KadlvW9vJfXOTf300O4UqaHFzFCuHz+rPkBY= 126 | github.com/mitchellh/go-ps v1.0.0 h1:i6ampVEEF4wQFF+bkYfwYgY+F/uYJDktmvLPf7qIgjc= 127 | github.com/mitchellh/go-ps v1.0.0/go.mod h1:J4lOc8z8yJs6vUwklHw2XEIiT4z4C40KtWVN3nvg8Pg= 128 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= 129 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= 130 | github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= 131 | github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= 132 | github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ= 133 | github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= 134 | github.com/pkg/sftp v1.13.6 h1:JFZT4XbOU7l77xGSpOdW+pwIMqP044IyjXX6FGyEKFo= 135 | github.com/pkg/sftp v1.13.6/go.mod h1:tz1ryNURKu77RL+GuCzmoJYxQczL3wLNNpPWagdg4Qk= 136 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 137 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= 138 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 139 | github.com/prometheus-community/pro-bing v0.4.0 h1:YMbv+i08gQz97OZZBwLyvmmQEEzyfyrrjEaAchdy3R4= 140 | github.com/prometheus-community/pro-bing v0.4.0/go.mod h1:b7wRYZtCcPmt4Sz319BykUU241rWLe1VFXyiyWK/dH4= 141 | github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= 142 | github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= 143 | github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc= 144 | github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8= 145 | github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= 146 | github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= 147 | github.com/safchain/ethtool v0.3.0 h1:gimQJpsI6sc1yIqP/y8GYgiXn/NjgvpM0RNoWLVVmP0= 148 | github.com/safchain/ethtool v0.3.0/go.mod h1:SA9BwrgyAqNo7M+uaL6IYbxpm5wk3L7Mm6ocLW+CJUs= 149 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 150 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 151 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 152 | github.com/tailscale/certstore v0.1.1-0.20231202035212-d3fa0460f47e h1:PtWT87weP5LWHEY//SWsYkSO3RWRZo4OSWagh3YD2vQ= 153 | github.com/tailscale/certstore v0.1.1-0.20231202035212-d3fa0460f47e/go.mod h1:XrBNfAFN+pwoWuksbFS9Ccxnopa15zJGgXRFN90l3K4= 154 | github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 h1:Gzfnfk2TWrk8Jj4P4c1a3CtQyMaTVCznlkLZI++hok4= 155 | github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55/go.mod h1:4k4QO+dQ3R5FofL+SanAUZe+/QfeK0+OIuwDIRu2vSg= 156 | github.com/tailscale/golang-x-crypto v0.0.0-20240604161659-3fde5e568aa4 h1:rXZGgEa+k2vJM8xT0PoSKfVXwFGPQ3z3CJfmnHJkZZw= 157 | github.com/tailscale/golang-x-crypto v0.0.0-20240604161659-3fde5e568aa4/go.mod h1:ikbF+YT089eInTp9f2vmvy4+ZVnW5hzX1q2WknxSprQ= 158 | github.com/tailscale/goupnp v1.0.1-0.20210804011211-c64d0f06ea05 h1:4chzWmimtJPxRs2O36yuGRW3f9SYV+bMTTvMBI0EKio= 159 | github.com/tailscale/goupnp v1.0.1-0.20210804011211-c64d0f06ea05/go.mod h1:PdCqy9JzfWMJf1H5UJW2ip33/d4YkoKN0r67yKH1mG8= 160 | github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a h1:SJy1Pu0eH1C29XwJucQo73FrleVK6t4kYz4NVhp34Yw= 161 | github.com/tailscale/hujson v0.0.0-20221223112325-20486734a56a/go.mod h1:DFSS3NAGHthKo1gTlmEcSBiZrRJXi28rLNd/1udP1c8= 162 | github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7 h1:uFsXVBE9Qr4ZoF094vE6iYTLDl0qCiKzYXlL6UeWObU= 163 | github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7/go.mod h1:NzVQi3Mleb+qzq8VmcWpSkcSYxXIg0DkI6XDzpVkhJ0= 164 | github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc h1:24heQPtnFR+yfntqhI3oAu9i27nEojcQ4NuBQOo5ZFA= 165 | github.com/tailscale/peercred v0.0.0-20250107143737-35a0c7bd7edc/go.mod h1:f93CXfllFsO9ZQVq+Zocb1Gp4G5Fz0b0rXHLOzt/Djc= 166 | github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976 h1:UBPHPtv8+nEAy2PD8RyAhOYvau1ek0HDJqLS/Pysi14= 167 | github.com/tailscale/web-client-prebuilt v0.0.0-20250124233751-d4cd19a26976/go.mod h1:agQPE6y6ldqCOui2gkIh7ZMztTkIQKH049tv8siLuNQ= 168 | github.com/tailscale/wf v0.0.0-20240214030419-6fbb0a674ee6 h1:l10Gi6w9jxvinoiq15g8OToDdASBni4CyJOdHY1Hr8M= 169 | github.com/tailscale/wf v0.0.0-20240214030419-6fbb0a674ee6/go.mod h1:ZXRML051h7o4OcI0d3AaILDIad/Xw0IkXaHM17dic1Y= 170 | github.com/tailscale/wireguard-go v0.0.0-20250107165329-0b8b35511f19 h1:BcEJP2ewTIK2ZCsqgl6YGpuO6+oKqqag5HHb7ehljKw= 171 | github.com/tailscale/wireguard-go v0.0.0-20250107165329-0b8b35511f19/go.mod h1:BOm5fXUBFM+m9woLNBoxI9TaBXXhGNP50LX/TGIvGb4= 172 | github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e h1:zOGKqN5D5hHhiYUp091JqK7DPCqSARyUfduhGUY8Bek= 173 | github.com/tailscale/xnet v0.0.0-20240729143630-8497ac4dab2e/go.mod h1:orPd6JZXXRyuDusYilywte7k094d7dycXXU5YnWsrwg= 174 | github.com/tc-hib/winres v0.2.1 h1:YDE0FiP0VmtRaDn7+aaChp1KiF4owBiJa5l964l5ujA= 175 | github.com/tc-hib/winres v0.2.1/go.mod h1:C/JaNhH3KBvhNKVbvdlDWkbMDO9H4fKKDaN7/07SSuk= 176 | github.com/u-root/u-root v0.12.0 h1:K0AuBFriwr0w/PGS3HawiAw89e3+MU7ks80GpghAsNs= 177 | github.com/u-root/u-root v0.12.0/go.mod h1:FYjTOh4IkIZHhjsd17lb8nYW6udgXdJhG1c0r6u0arI= 178 | github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701 h1:pyC9PaHYZFgEKFdlp3G8RaCKgVpHZnecvArXvPXcFkM= 179 | github.com/u-root/uio v0.0.0-20240224005618-d2acac8f3701/go.mod h1:P3a5rG4X7tI17Nn3aOIAYr5HbIMukwXG0urG0WuL8OA= 180 | github.com/vishvananda/netns v0.0.0-20200728191858-db3c7e526aae/go.mod h1:DD4vA1DwXk04H54A1oHXtwZmA0grkVMdPxx/VGLCah0= 181 | github.com/vishvananda/netns v0.0.4 h1:Oeaw1EM2JMxD51g9uhtC0D7erkIjgmj8+JZc26m1YX8= 182 | github.com/vishvananda/netns v0.0.4/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM= 183 | github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= 184 | github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= 185 | go4.org/mem v0.0.0-20240501181205-ae6ca9944745 h1:Tl++JLUCe4sxGu8cTpDzRLd3tN7US4hOxG5YpKCzkek= 186 | go4.org/mem v0.0.0-20240501181205-ae6ca9944745/go.mod h1:reUoABIJ9ikfM5sgtSF3Wushcza7+WeD01VB9Lirh3g= 187 | go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M= 188 | go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y= 189 | golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= 190 | golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= 191 | golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8 h1:yqrTHse8TCMW1M1ZCP+VAR/l0kKxwaAIqN/il7x4voA= 192 | golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8/go.mod h1:tujkw807nyEEAamNbDrEGzRav+ilXA7PCRAd6xsmwiU= 193 | golang.org/x/exp/typeparams v0.0.0-20240314144324-c7f7c6466f7f h1:phY1HzDcf18Aq9A8KkmRtY9WvOFIxN8wgfvy6Zm1DV8= 194 | golang.org/x/exp/typeparams v0.0.0-20240314144324-c7f7c6466f7f/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= 195 | golang.org/x/image v0.23.0 h1:HseQ7c2OpPKTPVzNjG5fwJsOTCiiwS4QdsYi5XU6H68= 196 | golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY= 197 | golang.org/x/mod v0.22.0 h1:D4nJWe9zXqHOmWqj4VMOJhvzj7bEZg4wEYa759z1pH4= 198 | golang.org/x/mod v0.22.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= 199 | golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0= 200 | golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= 201 | golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 202 | golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= 203 | golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 204 | golang.org/x/sys v0.0.0-20200217220822-9197077df867/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 205 | golang.org/x/sys v0.0.0-20200728102440-3e129f6d46b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 206 | golang.org/x/sys v0.0.0-20220817070843-5a390386f1f2/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 207 | golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 208 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 209 | golang.org/x/sys v0.29.1-0.20250107080300-1c14dcadc3ab h1:BMkEEWYOjkvOX7+YKOGbp6jCyQ5pR2j0Ah47p1Vdsx4= 210 | golang.org/x/sys v0.29.1-0.20250107080300-1c14dcadc3ab/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 211 | golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg= 212 | golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek= 213 | golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= 214 | golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= 215 | golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY= 216 | golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= 217 | golang.org/x/tools v0.29.0 h1:Xx0h3TtM9rzQpQuR4dKLrdglAmCEN5Oi+P74JdhdzXE= 218 | golang.org/x/tools v0.29.0/go.mod h1:KMQVMRsVxU6nHCFXrBPhDB8XncLNLM0lIy/F14RP588= 219 | golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg= 220 | golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI= 221 | golang.zx2c4.com/wireguard/windows v0.5.3 h1:On6j2Rpn3OEMXqBq00QEDC7bWSZrPIHKIus8eIuExIE= 222 | golang.zx2c4.com/wireguard/windows v0.5.3/go.mod h1:9TEe8TJmtwyQebdFwAkEWOPr3prrtqm+REGFifP60hI= 223 | google.golang.org/protobuf v1.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFytA= 224 | google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= 225 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 226 | gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 227 | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 228 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 229 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 230 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 231 | gvisor.dev/gvisor v0.0.0-20240722211153-64c016c92987 h1:TU8z2Lh3Bbq77w0t1eG8yRlLcNHzZu3x6mhoH2Mk0c8= 232 | gvisor.dev/gvisor v0.0.0-20240722211153-64c016c92987/go.mod h1:sxc3Uvk/vHcd3tj7/DHVBoR5wvWT/MmRq2pj7HRJnwU= 233 | honnef.co/go/tools v0.5.1 h1:4bH5o3b5ZULQ4UrBmP+63W9r7qIkqJClEA9ko5YKx+I= 234 | honnef.co/go/tools v0.5.1/go.mod h1:e9irvo83WDG9/irijV44wr3tbhcFeRnfpVlRqVwpzMs= 235 | howett.net/plist v1.0.0 h1:7CrbWYbPPO/PyNy38b2EB/+gYbjCe2DXBxgtOOZbSQM= 236 | howett.net/plist v1.0.0/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g= 237 | software.sslmate.com/src/go-pkcs12 v0.4.0 h1:H2g08FrTvSFKUj+D309j1DPfk5APnIdAQAB8aEykJ5k= 238 | software.sslmate.com/src/go-pkcs12 v0.4.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI= 239 | tailscale.com v1.80.1 h1:4BcwzqesagV4hFBOZ4Y7D6asI44i4Qpe7JSvmToSfH4= 240 | tailscale.com v1.80.1/go.mod h1:4tasV1xjJAMHuX2xWMWAnXEmlrAA6M3w1xnc32DlpMk= 241 | -------------------------------------------------------------------------------- /icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tailscale/ts-browser-ext/be17a46f779a59f78c63a5f4f15d5c312b9d7010/icon.png -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 3, 3 | "name": "Tailscale Extension", 4 | "version": "1.0", 5 | "description": "A Tailscale client that runs as a browser extension, permitting use of different tailnets in differenet browser profiles, without affecting the system VPN or networking settings.", 6 | "permissions": [ 7 | "proxy", 8 | "background", 9 | "storage", 10 | "nativeMessaging" 11 | ], 12 | "host_permissions": [ 13 | "" 14 | ], 15 | "background": { 16 | "service_worker": "background.js" 17 | }, 18 | "action": { 19 | "default_popup": "popup.html", 20 | "default_icon": "icon.png" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /offline.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tailscale/ts-browser-ext/be17a46f779a59f78c63a5f4f15d5c312b9d7010/offline.png -------------------------------------------------------------------------------- /online.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tailscale/ts-browser-ext/be17a46f779a59f78c63a5f4f15d5c312b9d7010/online.png -------------------------------------------------------------------------------- /popup.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Proxy Toggle 6 | 7 | 8 | 12 | 13 | 124 | 125 | 126 |
127 | 314 | 324 |
325 |
326 |
327 | 328 |
329 | 330 | 331 | -------------------------------------------------------------------------------- /popup.js: -------------------------------------------------------------------------------- 1 | var lastStatus; 2 | 3 | function browseToURL() { 4 | if (lastStatus && lastStatus.browseToURL) { 5 | chrome.tabs.create({ url: lastStatus.browseToURL }); 6 | } 7 | } 8 | 9 | document.addEventListener("DOMContentLoaded", () => { 10 | const toggleSlider = document.getElementById("toggleSlider"); 11 | const slider = document.querySelector(".slider"); 12 | const settingsButton = document.getElementById("settingsButton"); 13 | const stateDisplay = document.getElementById("state"); 14 | let isConnected = false; 15 | let isLoading = true; 16 | let hasReceivedInitialState = false; 17 | 18 | const port = chrome.runtime.connect({ name: "popup" }); 19 | 20 | function updateSliderState() { 21 | if (isLoading) { 22 | slider.className = "slider loading"; 23 | toggleSlider.checked = true; // Assume connected while loading 24 | return; 25 | } 26 | // Only remove no-transition after we've received and applied the initial state 27 | if (hasReceivedInitialState) { 28 | slider.classList.remove("no-transition"); 29 | } 30 | slider.className = `slider ${isConnected ? "connected" : ""}`; 31 | toggleSlider.checked = isConnected; 32 | } 33 | 34 | function updateStatus(status) { 35 | isLoading = false; 36 | hasReceivedInitialState = true; 37 | if (status.error) { 38 | if (status.error === "State: Stopped") { 39 | stateDisplay.textContent = "Disconnected"; 40 | isConnected = false; 41 | updateSliderState(); 42 | return; 43 | } 44 | stateDisplay.textContent = `Error: ${status.error}`; 45 | return; 46 | } 47 | if (status.needsLogin) { 48 | stateDisplay.innerHTML = status.browseToURL 49 | ? `Log in` 50 | : "Login required; no URL"; 51 | return; 52 | } 53 | if (typeof status === "string" && status === "Disconnected") { 54 | stateDisplay.textContent = "Disconnected"; 55 | isConnected = false; 56 | updateSliderState(); 57 | return; 58 | } 59 | if (status.running !== undefined) { 60 | stateDisplay.textContent = status.running 61 | ? `Connected as ${status.tailnet || "Not connected"}` 62 | : "Disconnected"; 63 | isConnected = status.running; 64 | updateSliderState(); 65 | } 66 | } 67 | 68 | port.onMessage.addListener((msg) => { 69 | console.log("Received from background:", JSON.stringify(msg)); 70 | if (msg.installCmd) { 71 | console.log("Received install command"); 72 | stateDisplay.innerHTML = `Installation needed. Run:
${msg.installCmd}
`; 73 | toggleSlider.disabled = true; 74 | settingsButton.hidden = true; 75 | return; 76 | } 77 | if (msg.error) { 78 | console.log("Error from background:", msg); 79 | stateDisplay.textContent = msg.error; 80 | toggleSlider.disabled = true; 81 | settingsButton.hidden = true; 82 | return; 83 | } 84 | if (msg.status) { 85 | console.log("Received status update:", msg.status); 86 | updateStatus(msg.status); 87 | } 88 | }); 89 | 90 | toggleSlider.addEventListener("change", () => { 91 | console.log("Toggle slider changed, current state:", isConnected); 92 | chrome.runtime.sendMessage({ command: "toggleProxy" }, (response) => { 93 | console.log("Received response from background:", response); 94 | if (response && response.status) { 95 | updateStatus(response.status); 96 | } 97 | }); 98 | console.log("Sent toggleProxy command to background"); 99 | }); 100 | 101 | settingsButton.addEventListener("click", () => { 102 | console.log("Settings button clicked"); 103 | chrome.tabs.create({ url: "http://100.100.100.100" }); 104 | }); 105 | 106 | window.addEventListener("beforeunload", () => { 107 | port.disconnect(); 108 | }); 109 | }); 110 | -------------------------------------------------------------------------------- /ts-browser-ext.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "context" 6 | "encoding/binary" 7 | "encoding/json" 8 | "errors" 9 | "flag" 10 | "fmt" 11 | "io" 12 | "log" 13 | "log/syslog" 14 | "net" 15 | "net/http" 16 | "net/http/httputil" 17 | "os" 18 | "os/user" 19 | "path/filepath" 20 | "regexp" 21 | "runtime" 22 | "strings" 23 | "sync" 24 | "time" 25 | 26 | "github.com/gorilla/csrf" 27 | "tailscale.com/client/tailscale" 28 | "tailscale.com/client/web" 29 | "tailscale.com/hostinfo" 30 | "tailscale.com/ipn" 31 | "tailscale.com/net/proxymux" 32 | "tailscale.com/net/socks5" 33 | "tailscale.com/tsnet" 34 | "tailscale.com/types/logger" 35 | "tailscale.com/types/netmap" 36 | ) 37 | 38 | var ( 39 | installFlag = flag.String("install", "", "register the browser extension; string is 'C' (Chrome) or 'F' (Firefox) followed by extension ID") 40 | uninstallFlag = flag.Bool("uninstall", false, "unregister the browser extension") 41 | ) 42 | 43 | func main() { 44 | flag.Parse() 45 | if *installFlag != "" { 46 | if err := install(*installFlag); err != nil { 47 | log.Fatalf("installation error: %v", err) 48 | } 49 | return 50 | } 51 | if *uninstallFlag { 52 | if err := uninstall(); err != nil { 53 | log.Fatalf("uninstallation error: %v", err) 54 | } 55 | return 56 | } 57 | 58 | if flag.NArg() == 0 { 59 | fmt.Printf(`ts-browser-ext is the backend for the Tailscale browser extension, 60 | running as a child process HTTP/SOCKS5 under your browser. 61 | 62 | To register it once, run: 63 | 64 | $ ts-browser-ext --install=chrome 65 | `) 66 | return 67 | } 68 | 69 | hostinfo.SetApp("ts-browser-ext") 70 | 71 | h := newHost(os.Stdin, os.Stdout) 72 | 73 | if w, err := syslog.Dial("tcp", "localhost:5555", syslog.LOG_INFO, "browser"); err == nil { 74 | log.Printf("syslog dialed") 75 | h.logf = func(f string, a ...any) { 76 | fmt.Fprintf(w, f, a...) 77 | } 78 | log.SetOutput(w) 79 | } else { 80 | log.Printf("syslog: %v", err) 81 | } 82 | 83 | ln := h.getProxyListener() 84 | port := ln.Addr().(*net.TCPAddr).Port 85 | h.logf("Proxy listening on localhost:%v", port) 86 | 87 | h.send(&reply{ProcRunning: &procRunningResult{ 88 | Port: port, 89 | Pid: os.Getpid(), 90 | }}) 91 | h.logf("Starting readMessages loop") 92 | err := h.readMessages() 93 | h.logf("readMessage loop ended: %v", err) 94 | } 95 | 96 | func getTargetDir(browserByte string) (string, error) { 97 | home, err := os.UserHomeDir() 98 | if err != nil { 99 | return "", err 100 | } 101 | var dir string 102 | switch runtime.GOOS { 103 | case "linux": 104 | if browserByte == "C" { 105 | dir = filepath.Join(home, ".config", "google-chrome", "NativeMessagingHosts") 106 | } else if browserByte == "F" { 107 | dir = filepath.Join(home, ".mozilla", "native-messaging-hosts") 108 | } 109 | case "darwin": 110 | if browserByte == "C" { 111 | dir = filepath.Join(home, "Library", "Application Support", "Google", "Chrome", "NativeMessagingHosts") 112 | } else if browserByte == "F" { 113 | dir = filepath.Join(home, "Library", "Application Support", "Mozilla", "NativeMessagingHosts") 114 | } 115 | default: 116 | return "", fmt.Errorf("TODO: implement support for installing on %q", runtime.GOOS) 117 | } 118 | if err := os.MkdirAll(dir, 0755); err != nil { 119 | return "", err 120 | } 121 | return dir, nil 122 | } 123 | 124 | func uninstall() error { 125 | for _, browserByte := range []string{"C", "F"} { 126 | targetDir, err := getTargetDir(browserByte) 127 | if err != nil { 128 | return err 129 | } 130 | targetBin := filepath.Join(targetDir, "ts-browser-ext") 131 | targetJSON := filepath.Join(targetDir, "com.tailscale.browserext.chrome.json") 132 | if browserByte == "F" { 133 | targetJSON = filepath.Join(targetDir, "com.tailscale.browserext.firefox.json") 134 | } 135 | if err := os.Remove(targetBin); err != nil && !os.IsNotExist(err) { 136 | return err 137 | } 138 | if err := os.Remove(targetJSON); err != nil && !os.IsNotExist(err) { 139 | return err 140 | } 141 | } 142 | return nil 143 | } 144 | 145 | func install(installArg string) error { 146 | browserByte, extension := installArg[0:1], installArg[1:] 147 | switch browserByte { 148 | case "C": 149 | extensionRE := regexp.MustCompile(`^[a-z0-9]{32}$`) 150 | if !extensionRE.MatchString(extension) { 151 | return fmt.Errorf("invalid extension ID %q", extension) 152 | } 153 | case "F": 154 | default: 155 | return fmt.Errorf("unknown browser prefix byte %q", browserByte) 156 | } 157 | 158 | exe, err := os.Executable() 159 | if err != nil { 160 | return err 161 | } 162 | targetDir, err := getTargetDir(browserByte) 163 | if err != nil { 164 | return err 165 | } 166 | binary, err := os.ReadFile(exe) 167 | if err != nil { 168 | return err 169 | } 170 | targetBin := filepath.Join(targetDir, "ts-browser-ext") 171 | if err := os.WriteFile(targetBin, binary, 0755); err != nil { 172 | return err 173 | } 174 | log.SetFlags(0) 175 | log.Printf("copied binary to %v", targetBin) 176 | 177 | var targetJSON string 178 | var jsonConf []byte 179 | 180 | switch browserByte { 181 | case "C": 182 | targetJSON = filepath.Join(targetDir, "com.tailscale.browserext.chrome.json") 183 | jsonConf = fmt.Appendf(nil, `{ 184 | "name": "com.tailscale.browserext.chrome", 185 | "description": "Tailscale Browser Extension", 186 | "path": "%s", 187 | "type": "stdio", 188 | "allowed_origins": [ 189 | "chrome-extension://%s/" 190 | ] 191 | }`, targetBin, extension) 192 | case "F": 193 | targetJSON = filepath.Join(targetDir, "com.tailscale.browserext.firefox.json") 194 | jsonConf = fmt.Appendf(nil, `{ 195 | "name": "com.tailscale.browserext.firefox", 196 | "description": "Tailscale Browser Extension", 197 | "path": "%s", 198 | "type": "stdio", 199 | "allowed_extensions": [ 200 | "browser-ext@tailscale.com" 201 | ] 202 | }`, targetBin) 203 | default: 204 | return fmt.Errorf("unknown browser prefix byte %q", browserByte) 205 | } 206 | if err := os.WriteFile(targetJSON, jsonConf, 0644); err != nil { 207 | return err 208 | } 209 | log.Printf("wrote registration to %v", targetJSON) 210 | return nil 211 | } 212 | 213 | type host struct { 214 | br *bufio.Reader 215 | w io.Writer 216 | logf logger.Logf 217 | 218 | wmu sync.Mutex // guards writing to w 219 | 220 | lenBuf [4]byte // owned by readMessages 221 | 222 | mu sync.Mutex 223 | watchDead bool 224 | lastNetmap *netmap.NetworkMap 225 | lastState ipn.State 226 | lastBrowseToURL string 227 | ctx context.Context // for IPN bus; canceled by cancelCtx 228 | cancelCtx context.CancelFunc 229 | ts *tsnet.Server 230 | ws *web.Server 231 | ln net.Listener 232 | wantUp bool 233 | // ... 234 | } 235 | 236 | func newHost(r io.Reader, w io.Writer) *host { 237 | h := &host{ 238 | br: bufio.NewReaderSize(r, 1<<20), 239 | w: w, 240 | logf: log.Printf, 241 | } 242 | h.ts = &tsnet.Server{ 243 | RunWebClient: true, 244 | 245 | // late-binding, so caller can adjust h.logf. 246 | Logf: func(f string, a ...any) { 247 | h.logf(f, a...) 248 | }, 249 | } 250 | return h 251 | } 252 | 253 | const maxMsgSize = 1 << 20 254 | 255 | func (h *host) readMessages() error { 256 | for { 257 | msg, err := h.readMessage() 258 | if err != nil { 259 | return err 260 | } 261 | if err := h.handleMessage(msg); err != nil { 262 | h.logf("error handling message %v: %v", msg, err) 263 | return err 264 | } 265 | } 266 | } 267 | 268 | func (h *host) handleMessage(msg *request) error { 269 | switch msg.Cmd { 270 | case CmdInit: 271 | return h.handleInit(msg) 272 | case CmdGetStatus: 273 | h.sendStatus() 274 | case CmdUp: 275 | return h.handleUp() 276 | case CmdDown: 277 | return h.handleDown() 278 | default: 279 | h.logf("unknown command %q", msg.Cmd) 280 | } 281 | return nil 282 | } 283 | 284 | func (h *host) handleUp() error { 285 | return h.setWantRunning(true) 286 | } 287 | 288 | func (h *host) handleDown() error { 289 | return h.setWantRunning(false) 290 | } 291 | 292 | func (h *host) setWantRunning(want bool) error { 293 | defer h.sendStatus() 294 | h.mu.Lock() 295 | defer h.mu.Unlock() 296 | if h.ts.Sys() == nil { 297 | return fmt.Errorf("not init") 298 | } 299 | h.wantUp = want 300 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 301 | defer cancel() 302 | 303 | lc, err := h.ts.LocalClient() 304 | if err != nil { 305 | return err 306 | } 307 | if _, err := lc.EditPrefs(ctx, &ipn.MaskedPrefs{ 308 | WantRunningSet: true, 309 | Prefs: ipn.Prefs{ 310 | WantRunning: want, 311 | }, 312 | }); err != nil { 313 | return fmt.Errorf("EditPrefs to wantRunning=%v: %w", want, err) 314 | } 315 | return nil 316 | } 317 | 318 | func (h *host) handleInit(msg *request) (ret error) { 319 | defer func() { 320 | var errMsg string 321 | if ret != nil { 322 | errMsg = ret.Error() 323 | } 324 | h.send(&reply{ 325 | Init: &initResult{Error: errMsg}, 326 | }) 327 | }() 328 | h.mu.Lock() 329 | defer h.mu.Unlock() 330 | 331 | if h.cancelCtx != nil { 332 | h.cancelCtx() 333 | } 334 | h.ctx, h.cancelCtx = context.WithCancel(context.Background()) 335 | 336 | id := msg.InitID 337 | if len(id) == 0 { 338 | return fmt.Errorf("missing initID") 339 | } 340 | if len(id) > 60 { 341 | return fmt.Errorf("initID too long") 342 | } 343 | for i := range len(id) { 344 | b := id[i] 345 | if b == '-' || (b >= 'a' && b <= 'f') || (b >= '0' && b <= '9') { 346 | continue 347 | } 348 | return errors.New("invalid initID character") 349 | } 350 | 351 | if h.ts.Sys() != nil { 352 | return fmt.Errorf("already running") 353 | } 354 | u, err := user.Current() 355 | if err != nil { 356 | return fmt.Errorf("getting current user: %w", err) 357 | } 358 | h.ts.Hostname = u.Username + "-browser-ext" 359 | 360 | confDir, err := os.UserConfigDir() 361 | if err != nil { 362 | return fmt.Errorf("getting user config dir: %w", err) 363 | } 364 | h.ts.Dir = filepath.Join(confDir, "tailscale-browser-ext", id) 365 | 366 | h.logf("Starting...") 367 | if err := h.ts.Start(); err != nil { 368 | return fmt.Errorf("starting tsnet.Server: %w", err) 369 | } 370 | h.logf("Started") 371 | 372 | lc, err := h.ts.LocalClient() 373 | if err != nil { 374 | return fmt.Errorf("getting local client: %w", err) 375 | } 376 | 377 | wc, err := lc.WatchIPNBus(h.ctx, ipn.NotifyInitialState|ipn.NotifyRateLimit) 378 | if err != nil { 379 | return fmt.Errorf("watching IPN bus: %w", err) 380 | } 381 | go h.watchIPNBus(wc) 382 | 383 | h.ws, err = web.NewServer(web.ServerOpts{ 384 | Mode: web.LoginServerMode, // TODO: manage? 385 | LocalClient: lc, 386 | }) 387 | if err != nil { 388 | return fmt.Errorf("NewServer: %w", err) 389 | } 390 | 391 | return nil 392 | } 393 | 394 | func (h *host) watchIPNBus(wc *tailscale.IPNBusWatcher) { 395 | h.mu.Lock() 396 | h.watchDead = false 397 | h.mu.Unlock() 398 | 399 | for h.updateFromWatcher(wc) { 400 | // Keep going. 401 | } 402 | } 403 | 404 | func (h *host) updateFromWatcher(wc *tailscale.IPNBusWatcher) bool { 405 | n, err := wc.Next() 406 | 407 | defer h.sendStatus() 408 | 409 | h.mu.Lock() 410 | defer h.mu.Unlock() 411 | 412 | if err != nil { 413 | log.Printf("watchIPNBus: %v", err) 414 | h.watchDead = true 415 | return false 416 | } 417 | 418 | if n.NetMap != nil { 419 | h.lastNetmap = n.NetMap 420 | } 421 | if n.State != nil { 422 | h.lastState = *n.State 423 | } 424 | 425 | if n.BrowseToURL != nil { 426 | h.lastBrowseToURL = *n.BrowseToURL 427 | // TODO: pop a browser for Tailscale SSH check mode etc, even 428 | // if already logged in. 429 | } 430 | return true 431 | } 432 | 433 | func (h *host) send(msg *reply) error { 434 | msgb, err := json.Marshal(msg) 435 | if err != nil { 436 | return fmt.Errorf("json encoding of message: %w", err) 437 | } 438 | h.logf("sent reply: %s", msgb) 439 | if len(msgb) > maxMsgSize { 440 | return fmt.Errorf("message too big (%v)", len(msgb)) 441 | } 442 | binary.LittleEndian.PutUint32(h.lenBuf[:], uint32(len(msgb))) 443 | h.wmu.Lock() 444 | defer h.wmu.Unlock() 445 | if _, err := h.w.Write(h.lenBuf[:]); err != nil { 446 | return err 447 | } 448 | if _, err := h.w.Write(msgb); err != nil { 449 | return err 450 | } 451 | return nil 452 | } 453 | 454 | func (h *host) getProxyListener() net.Listener { 455 | h.mu.Lock() 456 | defer h.mu.Unlock() 457 | return h.getProxyListenerLocked() 458 | } 459 | 460 | func (h *host) getProxyListenerLocked() net.Listener { 461 | if h.ln != nil { 462 | return h.ln 463 | } 464 | var err error 465 | h.ln, err = net.Listen("tcp", "127.0.0.1:0") 466 | if err != nil { 467 | panic(err) // TODO: be more graceful 468 | } 469 | socksListener, httpListener := proxymux.SplitSOCKSAndHTTP(h.ln) 470 | 471 | hs := &http.Server{Handler: h.httpProxyHandler()} 472 | go func() { 473 | log.Fatalf("HTTP proxy exited: %v", hs.Serve(httpListener)) 474 | }() 475 | ss := &socks5.Server{ 476 | Logf: logger.WithPrefix(h.logf, "socks5: "), 477 | Dialer: h.userDial, 478 | } 479 | go func() { 480 | log.Fatalf("SOCKS5 server exited: %v", ss.Serve(socksListener)) 481 | }() 482 | return h.ln 483 | } 484 | 485 | func (h *host) userDial(ctx context.Context, netw, addr string) (net.Conn, error) { 486 | h.mu.Lock() 487 | sys := h.ts.Sys() 488 | h.mu.Unlock() 489 | 490 | if sys == nil { 491 | h.logf("userDial to %v/%v without a tsnet.Server started", netw, addr) 492 | return nil, fmt.Errorf("no tsnet.Server") 493 | } 494 | 495 | return sys.Dialer.Get().UserDial(ctx, netw, addr) 496 | } 497 | 498 | func (h *host) sendStatus() { 499 | st := &status{} 500 | h.mu.Lock() 501 | st.Running = h.lastState == ipn.Running 502 | if nm := h.lastNetmap; nm != nil { 503 | st.Tailnet = nm.Domain 504 | } 505 | if h.lastState == ipn.NeedsLogin { 506 | st.NeedsLogin = true 507 | st.BrowseToURL = h.lastBrowseToURL 508 | } else if !st.Running { 509 | st.Error = "State: " + h.lastState.String() 510 | } 511 | if h.watchDead { 512 | st.Error = "WatchIPNBus stopped" 513 | } 514 | h.mu.Unlock() 515 | 516 | if err := h.send(&reply{Status: st}); err != nil { 517 | h.logf("failed to send status: %v", err) 518 | } 519 | } 520 | 521 | type Cmd string 522 | 523 | const ( 524 | CmdInit Cmd = "init" 525 | CmdUp Cmd = "up" 526 | CmdDown Cmd = "down" 527 | CmdGetStatus Cmd = "get-status" 528 | ) 529 | 530 | // request is a message from the browser extension. 531 | type request struct { 532 | // Cmd is the request type. 533 | Cmd Cmd `json:"cmd"` 534 | 535 | // InitID is the unique ID made by the extension (in its local storage) to 536 | // distinguish between different browser profiles using the same extension. 537 | // A given Go process will correspond to a single browser profile. 538 | // This lets us store tsnet state in different directories. 539 | // This string, coming from JavaScript, should not be trusted. It must be 540 | // UUID-ish: hex and hyphens only, and too long. 541 | InitID string `json:"initID,omitempty"` 542 | 543 | // ... 544 | } 545 | 546 | // reply is a message to the browser extension. 547 | type reply struct { 548 | // ProcRunning is set on the first message when the Go process starts up. 549 | // It's the message that makes the browser recognize that the native 550 | // messaging port is up. 551 | ProcRunning *procRunningResult `json:"procRunning,omitempty"` 552 | 553 | // Status is sent in response to a [CmdGetStatus] [request.Cmd]. 554 | Status *status `json:"status,omitempty"` 555 | 556 | Init *initResult `json:"init,omitempty"` 557 | } 558 | 559 | type procRunningResult struct { 560 | Port int `json:"port"` // HTTP+SOCKS5 localhost proxy port 561 | Pid int `json:"pid"` 562 | Error string `json:"error"` 563 | } 564 | 565 | type initResult struct { 566 | Error string `json:"error"` // empty for none 567 | } 568 | 569 | type status struct { 570 | Running bool `json:"running"` 571 | Tailnet string `json:"tailnet"` 572 | Error string `json:"error,omitempty"` 573 | 574 | NeedsLogin bool `json:"needsLogin,omitempty"` // true if the user needs to log in 575 | BrowseToURL string `json:"browseToURL"` 576 | } 577 | 578 | func (h *host) readMessage() (*request, error) { 579 | if _, err := io.ReadFull(h.br, h.lenBuf[:]); err != nil { 580 | return nil, err 581 | } 582 | msgSize := binary.LittleEndian.Uint32(h.lenBuf[:]) 583 | if msgSize > maxMsgSize { 584 | return nil, fmt.Errorf("message size too big (%v)", msgSize) 585 | } 586 | msgb := make([]byte, msgSize) 587 | if n, err := io.ReadFull(h.br, msgb); err != nil { 588 | return nil, fmt.Errorf("read %v of %v bytes in message with error %v", n, msgSize, err) 589 | } 590 | msg := new(request) 591 | if err := json.Unmarshal(msgb, msg); err != nil { 592 | return nil, fmt.Errorf("invalid JSON decoding of message: %w", err) 593 | } 594 | h.logf("got command %q: %s", msg.Cmd, msgb) 595 | return msg, nil 596 | } 597 | 598 | // httpProxyHandler returns an HTTP proxy http.Handler using the 599 | // provided backend dialer. 600 | func (h *host) httpProxyHandler() http.Handler { 601 | rp := &httputil.ReverseProxy{ 602 | Director: func(r *http.Request) {}, // no change 603 | Transport: &http.Transport{ 604 | DialContext: h.userDial, 605 | }, 606 | } 607 | 608 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 609 | if r.Host == "100.100.100.100" { 610 | h.ws.ServeHTTP(w, csrf.PlaintextHTTPRequest(r)) 611 | return 612 | } 613 | 614 | if r.Method != "CONNECT" { 615 | backURL := r.RequestURI 616 | if strings.HasPrefix(backURL, "/") || backURL == "*" { 617 | http.Error(w, "bogus RequestURI; must be absolute URL or CONNECT", 400) 618 | return 619 | } 620 | rp.ServeHTTP(w, r) 621 | return 622 | } 623 | 624 | // CONNECT support: 625 | 626 | dst := r.RequestURI 627 | c, err := h.userDial(r.Context(), "tcp", dst) 628 | if err != nil { 629 | w.Header().Set("Tailscale-Connect-Error", err.Error()) 630 | http.Error(w, err.Error(), 500) 631 | return 632 | } 633 | defer c.Close() 634 | 635 | cc, ccbuf, err := w.(http.Hijacker).Hijack() 636 | if err != nil { 637 | http.Error(w, err.Error(), 500) 638 | return 639 | } 640 | defer cc.Close() 641 | 642 | io.WriteString(cc, "HTTP/1.1 200 OK\r\n\r\n") 643 | 644 | var clientSrc io.Reader = ccbuf 645 | if ccbuf.Reader.Buffered() == 0 { 646 | // In the common case (with no 647 | // buffered data), read directly from 648 | // the underlying client connection to 649 | // save some memory, letting the 650 | // bufio.Reader/Writer get GC'ed. 651 | clientSrc = cc 652 | } 653 | 654 | errc := make(chan error, 1) 655 | go func() { 656 | _, err := io.Copy(cc, c) 657 | errc <- err 658 | }() 659 | go func() { 660 | _, err := io.Copy(c, clientSrc) 661 | errc <- err 662 | }() 663 | <-errc 664 | }) 665 | } 666 | --------------------------------------------------------------------------------