summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorBrad Fitzpatrick <bradfitz@tailscale.com>2025-01-19 12:37:42 -0800
committerBrad Fitzpatrick <bradfitz@tailscale.com>2025-01-23 11:08:04 -0800
commit572253244411d877100b6d9e3f2348f04e565e0d (patch)
treee15080cb32fc4ca1e8dd82ca77bf4c58b4da6e6f
parent97a44d6453e83c966cfe109df77f9863830344ff (diff)
downloadtailscale-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.js154
-rw-r--r--cmd/ts-browser-native-ext/chrome.txt13
-rw-r--r--cmd/ts-browser-native-ext/icon.pngbin0 -> 31174 bytes
-rw-r--r--cmd/ts-browser-native-ext/manifest.json23
-rw-r--r--cmd/ts-browser-native-ext/offline.pngbin0 -> 7507 bytes
-rw-r--r--cmd/ts-browser-native-ext/online.pngbin0 -> 11971 bytes
-rw-r--r--cmd/ts-browser-native-ext/popup.html13
-rw-r--r--cmd/ts-browser-native-ext/popup.js16
-rw-r--r--cmd/ts-browser-native-ext/ts-browser-native-ext.go488
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
new file mode 100644
index 000000000..587f76ab3
--- /dev/null
+++ b/cmd/ts-browser-native-ext/icon.png
Binary files differ
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
new file mode 100644
index 000000000..09c2d1c15
--- /dev/null
+++ b/cmd/ts-browser-native-ext/offline.png
Binary files differ
diff --git a/cmd/ts-browser-native-ext/online.png b/cmd/ts-browser-native-ext/online.png
new file mode 100644
index 000000000..140c87adf
--- /dev/null
+++ b/cmd/ts-browser-native-ext/online.png
Binary files differ
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
+ })
+}