diff options
| author | Brad Fitzpatrick <bradfitz@tailscale.com> | 2025-01-19 12:37:42 -0800 |
|---|---|---|
| committer | Brad Fitzpatrick <bradfitz@tailscale.com> | 2025-01-23 11:08:04 -0800 |
| commit | 572253244411d877100b6d9e3f2348f04e565e0d (patch) | |
| tree | e15080cb32fc4ca1e8dd82ca77bf4c58b4da6e6f | |
| parent | 97a44d6453e83c966cfe109df77f9863830344ff (diff) | |
| download | tailscale-bradfitz/browser_ext.tar.xz tailscale-bradfitz/browser_ext.zip | |
cmd/ts-browser-native-ext: add start of a browser extensionbradfitz/browser_ext
Updates #14689
Change-Id: Ia432ee53dcdee9b43a73adb2ab3be6a3ce235aa6
| -rw-r--r-- | cmd/ts-browser-native-ext/background.js | 154 | ||||
| -rw-r--r-- | cmd/ts-browser-native-ext/chrome.txt | 13 | ||||
| -rw-r--r-- | cmd/ts-browser-native-ext/icon.png | bin | 0 -> 31174 bytes | |||
| -rw-r--r-- | cmd/ts-browser-native-ext/manifest.json | 23 | ||||
| -rw-r--r-- | cmd/ts-browser-native-ext/offline.png | bin | 0 -> 7507 bytes | |||
| -rw-r--r-- | cmd/ts-browser-native-ext/online.png | bin | 0 -> 11971 bytes | |||
| -rw-r--r-- | cmd/ts-browser-native-ext/popup.html | 13 | ||||
| -rw-r--r-- | cmd/ts-browser-native-ext/popup.js | 16 | ||||
| -rw-r--r-- | cmd/ts-browser-native-ext/ts-browser-native-ext.go | 488 |
9 files changed, 707 insertions, 0 deletions
diff --git a/cmd/ts-browser-native-ext/background.js b/cmd/ts-browser-native-ext/background.js new file mode 100644 index 000000000..2da189c22 --- /dev/null +++ b/cmd/ts-browser-native-ext/background.js @@ -0,0 +1,154 @@ +// Flag to track proxy status +let proxyEnabled = false; + + +// Function to change the popup icon +function setPopupIcon(active) { + const iconPath = active ? "online.png" : "offline.png"; + + chrome.action.setIcon({ path: iconPath }, () => { + if (chrome.runtime.lastError) { + console.error("Error setting icon to " + active + ":", chrome.runtime.lastError.message); + } + }); +} + +// Function to enable the proxy +function enableProxy() { + if (disconnected) { + console.error("Cannot enable proxy, disconnected from native host"); + return; + } + + // Send message to port + if (lastProxyPort) { + nmPort.postMessage({ cmd: "get-status" }); + } else { + nmPort.postMessage({ cmd: "up" }); + } +} + +// Function to disable the proxy +function disableProxy() { + setProxy(0); + + if (disconnected) { + console.error("Cannot disable proxy, disconnected from native host"); + return; + } + + // Send message to port + //nmPort.postMessage({ cmd: "down" }); +} + +console.log("starting ts-browser-ext"); + +console.log("Connecting to native messaging host..."); +let nmPort = chrome.runtime.connectNative("com.tailscale.browserext.chrome"); +let disconnected = false; +let portError = ""; // error.message if/when nmPort disconnected + +nmPort.onDisconnect.addListener(() => { + disconnected = true; + const error = chrome.runtime.lastError; + if (error) { + console.error("Connection failed:", error.message); + portError = error.message; + } else { + console.error("Disconnected from native host"); + } +}); +nmPort.onMessage.addListener((message) => { + console.log("message from backend: ", message); + + let st = message.status; + if (st && st.running && st.proxyPort && proxyEnabled) { + setProxy(st.proxyPort); + } +}) + +var lastProxyPort = 0; + +function setProxy(proxyPort) { + if (proxyPort) { + lastProxyPort = proxyPort; + console.log("Enabling proxy at port: " + proxyPort); + } else { + console.log("Disabling proxy..."); + chrome.proxy.settings.set( + { + value: { + mode: "direct" + }, + scope: "regular" + }, + () => { + console.log("Proxy disabled."); + } + ); + return; + } + chrome.proxy.settings.set( + { + value: { + mode: "fixed_servers", + rules: { + singleProxy: { + scheme: "http", + host: "127.0.0.1", + port: proxyPort + }, + bypassList: ["<local>"] + } + }, + scope: "regular" + }, + () => { + console.log("Proxy enabled: 127.0.0.1:" + proxyPort); + } + ); +} + +chrome.storage.local.get("profileId", (result) => { + if (!result.profileId) { + const profileId = crypto.randomUUID(); + chrome.storage.local.set({ profileId }, () => { + console.log("Generated profile ID:", profileId); + nmPort.postMessage({ cmd: "init", initID: profileId }); + }); + } else { + console.log("Profile ID already exists:", result.profileId); + nmPort.postMessage({ cmd: "init", initID: result.profileId }); + } +}); + + +// Listener for messages from the popup +chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { + if (message.command === "queryState") { + if (disconnected) { + sendResponse({ status: "Error", error: portError }); + return; + } + console.log("bg: queryState, proxy=" + proxyEnabled); + sendResponse({ status: proxyEnabled ? "Connected" : "Disconnected" }); + return + } + + if (message.command === "toggleProxy") { + console.log("bg: toggleProxy, proxy=" + proxyEnabled); + proxyEnabled = !proxyEnabled; + if (proxyEnabled) { + enableProxy(); + console.log("bg: toggleProxy on, now proxy=" + proxyEnabled); + sendResponse({ status: "Connected" }); + console.log("bg: toggleProxy on, sent proxy=" + proxyEnabled); + } else { + disableProxy(); + console.log("bg: toggleProxy off, now proxy=" + proxyEnabled); + sendResponse({ status: "Disconnected" }); + console.log("bg: toggleProxy off, sent proxy=" + proxyEnabled); + } + setPopupIcon(proxyEnabled); + } +}); diff --git a/cmd/ts-browser-native-ext/chrome.txt b/cmd/ts-browser-native-ext/chrome.txt new file mode 100644 index 000000000..6c45dccde --- /dev/null +++ b/cmd/ts-browser-native-ext/chrome.txt @@ -0,0 +1,13 @@ +% pwd +/Users/bradfitz/Library/Application Support/Google/Chrome/NativeMessagingHosts + +% cat com.tailscale.chrome-ext.json +{ + "name": "com.tailscale.chrome-ext", + "description": "Tailscale Native Extension", + "path": "/Users/bradfitz/go/bin/ts-browser-native-ext", + "type": "stdio", + "allowed_origins": [ + "chrome-extension://gdopnimobeboikkiagbnnbcijkjdjcad/" + ] +} diff --git a/cmd/ts-browser-native-ext/icon.png b/cmd/ts-browser-native-ext/icon.png Binary files differnew file mode 100644 index 000000000..587f76ab3 --- /dev/null +++ b/cmd/ts-browser-native-ext/icon.png diff --git a/cmd/ts-browser-native-ext/manifest.json b/cmd/ts-browser-native-ext/manifest.json new file mode 100644 index 000000000..ded93dc8e --- /dev/null +++ b/cmd/ts-browser-native-ext/manifest.json @@ -0,0 +1,23 @@ +{ + "manifest_version": 3, + "name": "Tailscale Extension", + "version": "1.0", + "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.", + "permissions": [ + "proxy", + "background", + "storage", + "nativeMessaging" + ], + "host_permissions": [ + "<all_urls>" + ], + "background": { + "service_worker": "background.js" + }, + "action": { + "default_popup": "popup.html", + "default_icon": "icon.png" + }, + "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEArAPB7I6tL6JJaivgzHLpDOmawSn4q8K4riQPWtXPL8N2ashAiGbsOuNW+7zJQUg+So1C/J2M32Wa1RzHExA/Gj4hekBjZvjY0zylTXQgnDJ/RVrQEENVq02Pfi5OpplIDwN5Yt7n8JQbYZP9NkOUUoumh0BFm4WLLal4GLt1S6QrwDctc1kxG1UKtcVgGi40aPz0efB0skn7lw1jzN2WGenqNY1x2BFQj/ol3zUMasb4rO3EdJWfD3kyjfDu5K4MvX4GZ3Stw3u25Z9cfNf6W1StrA/06JcYc/9AAjrfHjxrZGpDBGeKUe1KgU7iMX1J9SkaPooYJJbuiA1AdgTr9QIDAQAB" +} diff --git a/cmd/ts-browser-native-ext/offline.png b/cmd/ts-browser-native-ext/offline.png Binary files differnew file mode 100644 index 000000000..09c2d1c15 --- /dev/null +++ b/cmd/ts-browser-native-ext/offline.png diff --git a/cmd/ts-browser-native-ext/online.png b/cmd/ts-browser-native-ext/online.png Binary files differnew file mode 100644 index 000000000..140c87adf --- /dev/null +++ b/cmd/ts-browser-native-ext/online.png diff --git a/cmd/ts-browser-native-ext/popup.html b/cmd/ts-browser-native-ext/popup.html new file mode 100644 index 000000000..faf31aefc --- /dev/null +++ b/cmd/ts-browser-native-ext/popup.html @@ -0,0 +1,13 @@ +<!DOCTYPE html> +<html> +<head> + <title>Proxy Toggle</title> + <script src="popup.js"></script> +</head> +<body> + + <h1>Tailscale</h1> + <div id='state'></div> + <button id="button">Connect</button> +</body> +</html> diff --git a/cmd/ts-browser-native-ext/popup.js b/cmd/ts-browser-native-ext/popup.js new file mode 100644 index 000000000..9619fd21c --- /dev/null +++ b/cmd/ts-browser-native-ext/popup.js @@ -0,0 +1,16 @@ +document.addEventListener("DOMContentLoaded", () => { + let btn = document.getElementById("button"); + let st = document.getElementById("state"); + + let onState = (response) => { + console.log("popup: onState=" + response.status); + st.innerText = response.status; + btn.innerText = response.status === "Connected" ? "Disconnect" : "Connect"; + }; + + chrome.runtime.sendMessage({ command: "queryState" }, onState); + + btn.addEventListener("click", () => { + chrome.runtime.sendMessage({ command: "toggleProxy" }, onState); + }); +}) diff --git a/cmd/ts-browser-native-ext/ts-browser-native-ext.go b/cmd/ts-browser-native-ext/ts-browser-native-ext.go new file mode 100644 index 000000000..c534b2f53 --- /dev/null +++ b/cmd/ts-browser-native-ext/ts-browser-native-ext.go @@ -0,0 +1,488 @@ +package main + +import ( + "bufio" + "context" + "encoding/binary" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "log" + "log/syslog" + "net" + "net/http" + "net/http/httputil" + "os" + "os/user" + "path/filepath" + "runtime" + "strings" + "sync" + "time" + + "tailscale.com/hostinfo" + "tailscale.com/ipn" + "tailscale.com/net/proxymux" + "tailscale.com/net/socks5" + "tailscale.com/tsnet" + "tailscale.com/types/logger" +) + +var ( + installFlag = flag.String("install", "", "register the browser extension's backend with the given browser, one of: chrome, firefox") +) + +func main() { + flag.Parse() + if *installFlag != "" { + if err := install(*installFlag); err != nil { + log.Fatalf("installation error: %v", err) + } + return + } + if flag.NArg() == 0 { + fmt.Printf(`ts-browser-native-ext is the backend for the Tailscale browser extension, +run as a child process under your browser. + +To register it once, run: + + $ ts-browser-native-ext --install=chrome +`) + return + } + + hostinfo.SetApp("ts-browser-native-ext") + + h := newHost(os.Stdin, os.Stdout) + + if w, err := syslog.Dial("tcp", "localhost:5555", syslog.LOG_INFO, "browser"); err == nil { + log.Printf("syslog dialed") + h.logf = func(f string, a ...any) { + fmt.Fprintf(w, f, a...) + } + } else { + log.Printf("syslog: %v", err) + } + + h.logf("Starting readMessages loop") + err := h.readMessages() + h.logf("readMessage loop ended: %v", err) +} + +func getTargetDir(browser string) (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + var dir string + switch runtime.GOOS { + case "darwin": + dir = filepath.Join(home, "Library", "Application Support", "Google", "Chrome", "NativeMessagingHosts") + default: + return "", fmt.Errorf("TODO: implement support for installing on %q", runtime.GOOS) + } + if err := os.MkdirAll(dir, 0755); err != nil { + return "", err + } + return dir, nil +} + +func install(browser string) error { + switch browser { + case "chrome": + case "firefox": + return errors.New("TODO: firefox") + default: + return fmt.Errorf("unknown browser %q", browser) + } + exe, err := os.Executable() + if err != nil { + return err + } + targetDir, err := getTargetDir(browser) + if err != nil { + return err + } + binary, err := os.ReadFile(exe) + if err != nil { + return err + } + targetBin := filepath.Join(targetDir, "ts-browser-native-ext") + targetJSON := filepath.Join(targetDir, "com.tailscale.browserext.chrome.json") + if err := os.WriteFile(targetBin, binary, 0755); err != nil { + return err + } + log.SetFlags(0) + log.Printf("copied binary to %v", targetBin) + jsonConf := fmt.Appendf(nil, `{ + "name": "com.tailscale.browserext.chrome", + "description": "Tailscale Native Extension", + "path": "%s", + "type": "stdio", + "allowed_origins": [ + "chrome-extension://mldijmhffomelkfhfjcjekhjgaikhood/" + ] + }`, targetBin) + if err := os.WriteFile(targetJSON, jsonConf, 0644); err != nil { + return err + } + log.Printf("wrote registration to %v", targetJSON) + return nil +} + +type host struct { + br *bufio.Reader + w io.Writer + logf logger.Logf + + wmu sync.Mutex // guards writing to w + + lenBuf [4]byte // owned by readMessages + + mu sync.Mutex + ts *tsnet.Server + ln net.Listener + wantUp bool + // ... +} + +func newHost(r io.Reader, w io.Writer) *host { + h := &host{ + br: bufio.NewReaderSize(r, 1<<20), + w: w, + logf: log.Printf, + } + h.ts = &tsnet.Server{ + RunWebClient: true, + + // late-binding, so caller can adjust h.logf. + Logf: func(f string, a ...any) { + h.logf(f, a...) + }, + } + return h +} + +const maxMsgSize = 1 << 20 + +func (h *host) readMessages() error { + for { + msg, err := h.readMessage() + if err != nil { + return err + } + if err := h.handleMessage(msg); err != nil { + h.logf("error handling message %v: %v", msg, err) + return err + } + } +} + +func (h *host) handleMessage(msg *request) error { + switch msg.Cmd { + case CmdInit: + return h.handleInit(msg) + case CmdGetStatus: + h.sendStatus() + case CmdUp: + return h.handleUp() + case CmdDown: + return h.handleDown() + default: + h.logf("unknown command %q", msg.Cmd) + } + return nil +} + +func (h *host) handleUp() error { + return h.setWantRunning(true) +} + +func (h *host) handleDown() error { + return h.setWantRunning(false) +} + +func (h *host) setWantRunning(want bool) error { + defer h.sendStatus() + h.mu.Lock() + defer h.mu.Unlock() + if h.ts.Sys() == nil { + return fmt.Errorf("not init") + } + h.wantUp = want + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + lc, err := h.ts.LocalClient() + if err != nil { + return err + } + if _, err := lc.EditPrefs(ctx, &ipn.MaskedPrefs{ + WantRunningSet: true, + Prefs: ipn.Prefs{ + WantRunning: want, + }, + }); err != nil { + return fmt.Errorf("EditPrefs to wantRunning=%v: %w", want, err) + } + return nil +} + +func (h *host) handleInit(msg *request) (ret error) { + defer func() { + var errMsg string + if ret != nil { + errMsg = ret.Error() + } + h.send(&reply{ + Init: &initResult{Error: errMsg}, + }) + }() + h.mu.Lock() + defer h.mu.Unlock() + + id := msg.InitID + if len(id) == 0 { + return fmt.Errorf("missing initID") + } + if len(id) > 60 { + return fmt.Errorf("initID too long") + } + for i := range len(id) { + b := id[i] + if b == '-' || (b >= 'a' && b <= 'f') || (b >= '0' && b <= '9') { + continue + } + return errors.New("invalid initID character") + } + + if h.ts.Sys() != nil { + return fmt.Errorf("already running") + } + u, err := user.Current() + if err != nil { + return fmt.Errorf("getting current user: %w", err) + } + h.ts.Hostname = u.Username + "-browser-ext" + + confDir, err := os.UserConfigDir() + if err != nil { + return fmt.Errorf("getting user config dir: %w", err) + } + h.ts.Dir = filepath.Join(confDir, "tailscale-browser-ext", id) + + h.logf("Starting...") + if err := h.ts.Start(); err != nil { + return fmt.Errorf("starting tsnet.Server: %w", err) + } + h.logf("Started") + + return nil +} + +func (h *host) send(msg *reply) error { + msgb, err := json.Marshal(msg) + if err != nil { + return fmt.Errorf("json encoding of message: %w", err) + } + h.logf("sent reply: %s", msgb) + if len(msgb) > maxMsgSize { + return fmt.Errorf("message too big (%v)", len(msgb)) + } + binary.LittleEndian.PutUint32(h.lenBuf[:], uint32(len(msgb))) + h.wmu.Lock() + defer h.wmu.Unlock() + if _, err := h.w.Write(h.lenBuf[:]); err != nil { + return err + } + if _, err := h.w.Write(msgb); err != nil { + return err + } + return nil +} + +func (h *host) getProxyListener() net.Listener { + h.mu.Lock() + defer h.mu.Unlock() + return h.getProxyListenerLocked() +} + +func (h *host) getProxyListenerLocked() net.Listener { + if h.ln != nil { + return h.ln + } + var err error + h.ln, err = net.Listen("tcp", "127.0.0.1:0") + if err != nil { + panic(err) // TODO: be more graceful + } + socksListener, httpListener := proxymux.SplitSOCKSAndHTTP(h.ln) + + hs := &http.Server{Handler: httpProxyHandler(h.userDial)} + go func() { + log.Fatalf("HTTP proxy exited: %v", hs.Serve(httpListener)) + }() + ss := &socks5.Server{ + Logf: logger.WithPrefix(h.logf, "socks5: "), + Dialer: h.userDial, + } + go func() { + log.Fatalf("SOCKS5 server exited: %v", ss.Serve(socksListener)) + }() + return h.ln +} + +func (h *host) userDial(ctx context.Context, netw, addr string) (net.Conn, error) { + h.mu.Lock() + sys := h.ts.Sys() + h.mu.Unlock() + + if sys == nil { + h.logf("userDial to %v/%v without a tsnet.Server started", netw, addr) + return nil, fmt.Errorf("no tsnet.Server") + } + return sys.Dialer.Get().UserDial(ctx, netw, addr) +} + +func (h *host) sendStatus() { + h.mu.Lock() + wantUp := h.wantUp + ln := h.getProxyListenerLocked() + h.mu.Unlock() + + if err := h.send(&reply{ + Status: &status{ + Running: wantUp, + ProxyPort: ln.Addr().(*net.TCPAddr).Port, + ProxyURL: "http://" + ln.Addr().String(), + }, + }); err != nil { + h.logf("failed to send status: %v", err) + } +} + +type Cmd string + +const ( + CmdInit Cmd = "init" + CmdUp Cmd = "up" + CmdDown Cmd = "down" + CmdGetStatus Cmd = "get-status" +) + +// request is a message from the browser extension. +type request struct { + // Cmd is the request type. + Cmd Cmd `json:"cmd"` + + // InitID is the unique ID made by the extension (in its local storage) to + // distinguish between different browser profiles using the same extension. + // A given Go process will correspond to a single browser profile. + // This lets us store tsnet state in different directories. + // This string, coming from JavaScript, should not be trusted. It must be + // UUID-ish: hex and hyphens only, and too long. + InitID string `json:"initID,omitempty"` + + // ... +} + +// reply is a message to the browser extension. +type reply struct { + Status *status `json:"status,omitempty"` + Init *initResult `json:"init,omitempty"` +} + +type initResult struct { + Error string `json:"error"` // empty for none +} + +type status struct { + ProxyPort int `json:"proxyPort"` + ProxyURL string `json:"proxyURL"` + Running bool `json:"running"` +} + +func (h *host) readMessage() (*request, error) { + if _, err := io.ReadFull(h.br, h.lenBuf[:]); err != nil { + return nil, err + } + msgSize := binary.LittleEndian.Uint32(h.lenBuf[:]) + if msgSize > maxMsgSize { + return nil, fmt.Errorf("message size too big (%v)", msgSize) + } + msgb := make([]byte, msgSize) + if n, err := io.ReadFull(h.br, msgb); err != nil { + return nil, fmt.Errorf("read %v of %v bytes in message with error %v", n, msgSize, err) + } + msg := new(request) + if err := json.Unmarshal(msgb, msg); err != nil { + return nil, fmt.Errorf("invalid JSON decoding of message: %w", err) + } + h.logf("got command %q: %s", msg.Cmd, msgb) + return msg, nil +} + +// httpProxyHandler returns an HTTP proxy http.Handler using the +// provided backend dialer. +func httpProxyHandler(dialer func(ctx context.Context, netw, addr string) (net.Conn, error)) http.Handler { + rp := &httputil.ReverseProxy{ + Director: func(r *http.Request) {}, // no change + Transport: &http.Transport{ + DialContext: dialer, + }, + } + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "CONNECT" { + backURL := r.RequestURI + if strings.HasPrefix(backURL, "/") || backURL == "*" { + http.Error(w, "bogus RequestURI; must be absolute URL or CONNECT", 400) + return + } + rp.ServeHTTP(w, r) + return + } + + // CONNECT support: + + dst := r.RequestURI + c, err := dialer(r.Context(), "tcp", dst) + if err != nil { + w.Header().Set("Tailscale-Connect-Error", err.Error()) + http.Error(w, err.Error(), 500) + return + } + defer c.Close() + + cc, ccbuf, err := w.(http.Hijacker).Hijack() + if err != nil { + http.Error(w, err.Error(), 500) + return + } + defer cc.Close() + + io.WriteString(cc, "HTTP/1.1 200 OK\r\n\r\n") + + var clientSrc io.Reader = ccbuf + if ccbuf.Reader.Buffered() == 0 { + // In the common case (with no + // buffered data), read directly from + // the underlying client connection to + // save some memory, letting the + // bufio.Reader/Writer get GC'ed. + clientSrc = cc + } + + errc := make(chan error, 1) + go func() { + _, err := io.Copy(cc, c) + errc <- err + }() + go func() { + _, err := io.Copy(c, clientSrc) + errc <- err + }() + <-errc + }) +} |
