cmd/ts-browser-native-ext: add start of a browser extension

Updates #14689

Change-Id: Ia432ee53dcdee9b43a73adb2ab3be6a3ce235aa6
This commit is contained in:
Brad Fitzpatrick 2025-01-19 12:37:42 -08:00
parent 97a44d6453
commit 5722532444
9 changed files with 707 additions and 0 deletions

View File

@ -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);
}
});

View File

@ -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/"
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

View File

@ -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"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@ -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>

View File

@ -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);
});
})

View File

@ -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
})
}