Access your Tailscale network directly from your browser. No system VPN required.
Version: 0.1.8 (native host) | Manifest V3
Browsers: Chrome, Firefox
Platforms: macOS (amd64, arm64), Linux (amd64), Windows (amd64)
License: MIT
Website: tesseras.org/tailchrome
Chrome Web Store: Chrome Web Store
Firefox Add-ons: Firefox Add-ons (AMO)
Privacy Policy: privacy-policy.md
- Overview
- Architecture
- Feature Set
- Native Messaging Protocol
- Extension Internals
- Native Host Internals
- Proxy System
- State Management
- Popup UI
- Browser Differences
- Installation and Setup
- Project Structure
- Build System
- CI/CD Pipeline
- Test Infrastructure
- Configuration Reference
- Security Model
- Data Handling and Privacy
- Store Listings
- Contributing
Tailchrome is a browser extension that runs a full Tailscale node per browser profile, without touching system networking. It consists of two components:
- A browser extension (TypeScript, Manifest V3) that manages proxy configuration and provides a popup UI.
- A native messaging host (Go, using
tsnet) that runs the actual Tailscale node and exposes a local SOCKS5/HTTP proxy.
The two communicate over the browser's native messaging protocol. Tailnet traffic from the browser is routed through the local proxy, so Tailchrome works alongside (or without) the Tailscale system app.
Each browser profile gets its own isolated Tailscale identity, meaning you can be logged into different tailnets in different Chrome/Firefox profiles simultaneously.
+---------------------------------------------+
| POPUP UI |
| (popup.ts, views/*.ts, components/*.ts) |
| - Connected view with peer list |
| - Exit node picker |
| - Profile switcher |
| - Disconnected / NeedsLogin / NeedsInstall |
+-------------------+--------------------------+
| chrome.runtime.Port ("popup")
| PopupMessage / BackgroundMessage
v
+---------------------------------------------+
| BACKGROUND SERVICE WORKER |
| (background.ts) |
| - StateStore (TailscaleState) |
| - NativeHostConnection (auto-reconnect) |
| - BadgeManager (icon/text updates) |
| - ProxyManager (Chrome PAC / Firefox API) |
| - Context menu handlers |
| - Keepalive timer |
+-------------------+--------------------------+
| chrome.runtime.connectNative()
| 4-byte LE length + JSON
| NativeRequest / NativeReply
v
+---------------------------------------------+
| NATIVE HOST (Go) |
| - tsnet.Server per browser profile |
| - IPN bus watcher (real-time state) |
| - SOCKS5 + HTTP proxy on 127.0.0.1:0 |
| - Tailscale web client at 100.100.100.100 |
| - Profile management (create/switch/delete) |
| - Taildrop file sender |
+---------------------------------------------+
|
| WireGuard / DERP / Control
v
Tailscale Network
- Startup: Extension opens native messaging connection. Host starts, binds a SOCKS5/HTTP proxy on a random local port, and sends
procRunningwith the port number and version. - Init: Extension sends
initwith a browser-profile UUID (initID). Host creates (or reuses) atsnet.Serverwith state stored at~/.config/tailscale-browser-ext/<initID>/. - State watching: Host starts a goroutine (
watchIPNBus) that monitors the IPN notification bus for state, prefs, netmap, browse-to-URL, and health changes. On any change, it fetches full status and sends aStatusUpdateto the extension. - Proxy routing: The background service worker receives the status, passes it to the
ProxyManager, which configures browser-level proxy rules (PAC script on Chrome,proxy.onRequeston Firefox). - Popup: When the user opens the popup, it connects via a
chrome.runtime.Portnamed"popup". The background immediately sends currentTailscaleState. User actions dispatchBackgroundMessagetypes which the background translates toNativeRequestcommands.
- Tailnet access -- browse devices on your tailnet by IP or MagicDNS name, directly from the browser
- Per-profile isolation -- each browser profile gets its own independent Tailscale node and identity
- Exit nodes -- route all browser traffic through any exit node on your tailnet, with suggested-node optimization
- MagicDNS -- resolve tailnet device names automatically
- Subnet routing -- access resources behind subnet routers (auto-detected from peer info)
- Allow LAN access -- when using an exit node, optionally allow local network access
- Peer list -- online/offline grouping with search, incremental DOM updates
- Copy IP / Copy DNS -- one-click clipboard copy of any peer's Tailscale IP or DNS name
- Open web interface -- launch peer's web UI in a new tab
- SSH access -- open SSH-capable peers via the Tailscale web SSH client (
http://100.100.100.100/ssh/<hostname>) - Custom URLs -- configure per-device custom port/URL for quick open actions
- Taildrop -- send files to other devices on your tailnet
- Context menu -- right-click "Send page URL to Tailscale device" to share the current page URL as a text file via Taildrop
- Progress reporting -- file send progress displayed as persistent toast notifications
- Profiles -- create, switch between, and delete multiple Tailscale profiles (identities)
- Shields Up -- toggle to block all incoming connections
- Run as Exit Node -- advertise this browser node as an exit node
- Login / Logout -- authenticate with Tailscale (validates login URLs against allowed origins)
- Health warnings -- collapsible banner displaying Tailscale health warnings
- Badge status -- extension icon reflects online/offline/warning state with text badge for active exit node
- Auto-reconnect -- exponential backoff reconnection to native host (1s base, 30s max)
- Exit node persistence -- last-selected exit node restored automatically after reconnection
- Toast notifications -- in-popup toasts for operations (file send, errors, suggestions)
- Keyboard navigation -- peer list supports arrow key navigation
- Platform-aware -- detects macOS for platform-specific UI hints
This document describes what Tailchrome implements today. The canonical place for missing or partial features versus the native Tailscale client, plus architectural limits, is FEATURE_PARITY.md. Maintain parity claims there; link from here if the parity doc moves or is renamed.
Communication uses the Chrome native messaging wire format: a 4-byte little-endian length prefix followed by a JSON payload. Maximum message size is 1 MB (Chrome-enforced limit).
| Command | Fields | Description |
|---|---|---|
init |
initID: string |
Initialize tsnet.Server for browser profile UUID |
up |
-- | Set WantRunning=true |
down |
-- | Set WantRunning=false |
get-status |
-- | Request full status update |
ping |
-- | Keepalive; host replies with pong |
set-exit-node |
nodeID: string |
Set exit node (empty string to clear) |
set-prefs |
prefs: Partial<PrefsView> |
Apply partial preference changes |
list-profiles |
-- | List all Tailscale profiles |
switch-profile |
profileID: string |
Switch to a different profile |
new-profile |
-- | Create and switch to a new empty profile |
delete-profile |
profileID: string |
Delete a profile |
send-file |
nodeID, fileName, fileData (base64), fileSize, transferID?, chunkIndex?, chunkCount? |
Send file via Taildrop (supports chunked transfer) |
suggest-exit-node |
-- | Request optimized exit node suggestion |
ping-peer |
nodeID: string |
Ping a specific peer (diagnostic) |
bug-report |
note?: string |
Generate bug report |
logout |
-- | Log out of current Tailscale account |
| Reply Field | When Sent | Payload |
|---|---|---|
procRunning |
Immediately on host startup | { port, pid, version, supportsNetcheck?, supportsPingPeer?, error? } |
init |
After init command |
{ error? } |
pong |
After ping |
{} |
status |
After state changes or get-status |
Full StatusUpdate object |
profiles |
After profile commands | { current, profiles[] } |
exitNodeSuggestion |
After suggest-exit-node |
{ id, hostname, location? } |
fileSendProgress |
During file send | { targetNodeID, name, percent, done, error? } |
diagnostic |
After ping-peer or bug-report |
{ title, body } |
error |
On command failure | { cmd, message } |
interface StatusUpdate {
backendState: "NoState" | "NeedsMachineAuth" | "NeedsLogin" |
"InUseOtherUser" | "Stopped" | "Starting" | "Running";
running: boolean;
tailnet: string | null;
magicDNSSuffix: string;
selfNode: SelfNode | null;
needsLogin: boolean;
browseToURL: string; // Login URL from control plane
exitNode: ExitNodeInfo | null;
peers: PeerInfo[];
prefs: TailscalePrefs | null;
health: string[];
error: string | null;
}Each PeerInfo includes: id, hostname, dnsName, tailscaleIPs[], os, online, active, exitNode, exitNodeOption, isSubnetRouter, subnets[], tags[], rxBytes, txBytes, lastSeen, lastHandshake, location?, taildropTarget, sshHost, userId, userName, userLoginName, userProfilePicURL.
The extension is a pnpm monorepo with two packages:
| Package | Path | Purpose |
|---|---|---|
@tailchrome/extension |
packages/extension/ |
WXT app for Chrome/Firefox packaging, browser-specific proxy managers, entrypoints |
@tailchrome/shared |
packages/shared/ |
Shared TypeScript: types, state management, popup logic, background core, proxy utils |
The shared package contains all the platform-agnostic logic. The extension package contains browser-specific entrypoints, proxy managers, and WXT configuration.
**packages/shared/src/background/**
| File | Lines | Purpose |
|---|---|---|
background.ts |
522 | Core service worker: native host management, popup communication, state subscriptions, context menus, keepalive |
native-host.ts |
164 | NativeHostConnection class with exponential backoff reconnection (1s-30s), profile UUID generation |
state-store.ts |
87 | Redux-like StateStore with subscribe(), update(), applyStatusUpdate() |
badge-manager.ts |
105 | Extension icon/badge updates for online, offline, warning, and exit-node states |
proxy-utils.ts |
89 | IP-to-number conversion, CIDR parsing, MagicDNS suffix sanitization, subnet collection, shouldProxyState() |
timer-service.ts |
54 | Abstract TimerService interface; DefaultTimerService wraps native setInterval/clearInterval |
**packages/shared/src/popup/**
| File | Lines | Purpose |
|---|---|---|
popup.ts |
182 | Popup initialization, view routing based on state, sub-view management |
utils.ts |
155 | HTML escaping, clipboard, toast notifications, keyboard nav, platform detection |
custom-urls.ts |
49 | Per-device custom port/URL storage using chrome.storage.local |
icons.ts |
-- | SVG icon definitions (Tailscale logo, chevrons, warning, lock, plug, etc.) |
**packages/shared/src/popup/views/**
| File | Lines | Purpose |
|---|---|---|
connected.ts |
425 | Main connected view: status bar, quick settings, peer search/list, footer |
exit-nodes.ts |
322 | Exit node picker: search, suggested node, country flags, online/offline indicators |
profiles.ts |
132 | Profile switcher: create, switch, delete actions |
disconnected.ts |
142 | Error recovery view with context-specific hints |
needs-login.ts |
54 | Login prompt when backendState === "NeedsLogin" |
needs-install.ts |
10 | Native host installation guide |
needs-update.ts |
10 | Host version mismatch guide |
install-helpers.ts |
-- | Shared helpers for install/update views |
**packages/shared/src/popup/components/**
| File | Lines | Purpose |
|---|---|---|
peer-list.ts |
160 | Peer list with online/offline grouping, incremental DOM updates |
peer-item.ts |
322 | Peer row: copy IP/DNS, open web UI, SSH, file send, custom URL editor |
header.ts |
73 | Logo + toggle switch component |
health-warnings.ts |
89 | Collapsible health warning banner |
toggle-switch.ts |
36 | Reusable toggle component |
**packages/extension/src/background/**
| File | Purpose |
|---|---|
chrome-proxy-manager.ts |
PAC script generation for Chrome |
firefox-proxy-manager.ts |
proxy.onRequest listener for Firefox with session storage persistence |
**packages/extension/entrypoints/**
| File | Purpose |
|---|---|
background.ts |
Routes to Chrome or Firefox background initialization |
popup/main.ts |
Popup entry; imports shared popup module |
popup/index.html |
Popup HTML shell |
popup/style.css |
Popup styles |
Defined in packages/shared/src/constants.ts:
| Constant | Value | Purpose |
|---|---|---|
TAILSCALE_SERVICE_IP |
100.100.100.100 |
Tailscale service/web client address |
KEEPALIVE_INTERVAL_MS |
25000 |
Ping interval to keep service worker alive |
RECONNECT_BASE_MS |
1000 |
Reconnection backoff base |
RECONNECT_MAX_MS |
30000 |
Reconnection backoff ceiling |
ADMIN_URL |
https://login.tailscale.com/admin |
Tailscale admin console |
EXPECTED_HOST_VERSION |
0.1.8 |
Expected native host version (major.minor match required) |
The native host is a Go binary at host/ using tailscale.com/tsnet v1.94.2.
| File | Lines | Purpose |
|---|---|---|
main.go |
135 | Entry point: --install, --uninstall, --version flags; auto-install for terminal invocation; native messaging mode |
host.go |
572 | Host struct: message read loop, request dispatch, command handlers (init, up, down, status, ping, prefs, ping-peer, bug-report, logout) |
protocol.go |
171 | Wire protocol types: Request, Reply, StatusUpdate, PeerInfo, PrefsView, ProfileInfo, DiagnosticReply, etc. |
status.go |
212 | IPN bus watcher goroutine, full status refresh, buildStatusUpdate() from ipnstate.Status |
proxy.go |
173 | SOCKS5 + HTTP multiplexed proxy on 127.0.0.1:0, web client routing for 100.100.100.100, HTTPS CONNECT tunneling |
profiles.go |
102 | Profile management: list, switch, create, delete via tsnet local client |
taildrop.go |
294 | File send via Taildrop PushFile API with chunked transfer and progress-tracking io.Reader |
install.go |
195 | Native host manifest installation/uninstallation, binary self-copy to install dir |
install_*.go |
-- | Platform-specific manifest directory paths (darwin, linux, windows) |
peers.go |
-- | extractPeers() and peerStatusToPeerInfo() converters from ipnstate types |
exitnode.go |
-- | handleSetExitNode(), handleSuggestExitNode() handlers |
- Browser launches the host via native messaging (stdin/stdout). Not a terminal -- goes directly to messaging mode.
- Proxy starts on
127.0.0.1:0. The port is sent back to the extension viaprocRunning. - Extension sends
initwith the browser profile UUID. Host creates atsnet.Serverat~/.config/tailscale-browser-ext/<UUID>/and starts it. - IPN bus watcher begins monitoring state, prefs, netmap, browse-to-URL, and health changes. Each change triggers a full
StatusUpdatereply. - Host reads commands in a loop from stdin, dispatches to handlers, and writes replies to stdout.
- On stdin EOF (browser closed), the host exits.
The proxy uses Tailscale's proxymux.SplitSOCKSAndHTTP() to multiplex a single listener:
- SOCKS5 traffic is handled by
tailscale.com/net/socks5, dialing throughtsnet.Server.Dial(). - HTTP traffic is handled by an
httputil.ReverseProxythat also dials through tsnet. - Requests to
100.100.100.100are routed to the Tailscale web client (web.ServerinManageServerMode), with aSec-Tailscale: browser-extheader for CSRF protection. - HTTPS CONNECT requests are hijacked for bidirectional tunneling through tsnet.
When the host binary is run in a terminal (detected via term.IsTerminal), it auto-detects installed browsers and installs native messaging manifests for Chrome and/or Firefox. The binary copies itself to ~/.local/share/tailscale-browser-ext/ (or platform equivalent) and writes JSON manifests to the browser's native messaging host directory.
Chrome uses a dynamically generated PAC (Proxy Auto-Config) script set via chrome.proxy.settings.set(). The PAC script routes traffic based on:
- Tailscale service IP (
100.100.100.100) -> proxy - CGNAT range (
100.64.0.0/10) -> proxy (all Tailscale IPs) - MagicDNS suffix (e.g.,
*.ts.net) -> proxy - Subnet routes (from subnet router peers) -> proxy via
isInNet()checks - Exit node active -> all traffic through proxy
- Otherwise ->
DIRECT
The proxy target is SOCKS5 127.0.0.1:<port>.
PAC script regeneration is skipped if proxy-relevant fields haven't changed (keyed on port:suffix:exitNode:subnets).
On service worker suspension, chrome.proxy.settings.set({ mode: "direct" }) is called to prevent stale routing.
Firefox uses the browser.proxy.onRequest API with an event listener that evaluates each request URL:
- Same routing logic as Chrome (service IP, CGNAT, MagicDNS, subnets, exit node)
- Returns
{ type: "socks", host: "127.0.0.1", port, proxyDNS: true }or{ type: "direct" } - IP matching uses numeric comparison (
ipToNum()) instead of PAC'sisInNet()
Session storage persistence: Firefox suspends background event pages aggressively. The proxy config (port, suffix, exit node state, subnet ranges) is persisted to browser.storage.session under the key "proxyConfig". On wake, the listener returns a Promise that waits for both storage restoration and native host reconnection before resolving proxy decisions.
StateStore (packages/shared/src/background/state-store.ts) is a minimal Redux-like store:
interface TailscaleState {
stateVersion: number; // Monotonically increasing counter
hostConnected: boolean;
initialized: boolean;
proxyPort: number | null;
proxyEnabled: boolean;
backendState: BackendState;
tailnet: string | null;
selfNode: SelfNode | null;
peers: PeerInfo[];
exitNode: ExitNodeInfo | null;
magicDNSSuffix: string | null;
browseToURL: string | null;
prefs: TailscalePrefs | null;
health: string[];
currentProfile: ProfileInfo | null;
profiles: ProfileInfo[];
exitNodeSuggestion: ExitNodeSuggestion | null;
error: string | null;
installError: boolean;
hostVersion: string | null;
hostVersionMismatch: boolean;
reconnecting: boolean;
}update(partial)merges fields and incrementsstateVersionapplyStatusUpdate(status)maps aStatusUpdatefrom the host into state fieldssubscribe(callback)registers a listener called on every state change- Listeners receive the full state; ProxyManager, BadgeManager, and popup broadcast all subscribe
PopupMessage (background -> popup):
{ type: "state", state: TailscaleState }-- full state push{ type: "toast", message, level: "info"|"error", persistent? }-- notification
BackgroundMessage (popup -> background):
toggle,login,logout,set-exit-node,clear-exit-node,set-pref,switch-profile,new-profile,delete-profile,send-file,suggest-exit-node,open-admin,open-web-client
The popup is a vanilla TypeScript UI (no framework) rendered into the extension popup HTML. Views are functions that return DOM elements.
popup.ts routes to views based on TailscaleState:
| Condition | View |
|---|---|
installError |
needs-install |
hostVersionMismatch |
needs-update |
!hostConnected |
disconnected |
backendState === "NeedsLogin" |
needs-login |
backendState === "Running" |
connected |
| Everything else | disconnected |
+----------------------------------+
| [Tailscale logo] [Toggle ON/OFF]|
+----------------------------------+
| [Health warning banner] |
+----------------------------------+
| Status: Connected to <tailnet> |
| IP: 100.x.y.z |
+----------------------------------+
| Quick Settings: |
| Exit node: [current / None] >|
| Shields Up [toggle] |
| Run as Exit Node [toggle] |
| MagicDNS [toggle] |
| Profile: [name] > |
+----------------------------------+
| [Search peers...] |
+----------------------------------+
| ONLINE (3) |
| > laptop 100.10.1.1 linux |
| > server 100.10.1.2 linux |
| > phone 100.10.1.3 android |
| OFFLINE (1) |
| > desktop 100.10.1.4 windows |
+----------------------------------+
| [Admin console] [Settings] |
+----------------------------------+
Exit node sub-view: Opening Exit node shows the full picker (search, suggested node, Mullvad grouping where applicable). Allow LAN access is a checkbox there—not in Quick Settings. Each peer item expands to show: Copy IP, Copy DNS, Open, SSH (if capable), Send File (if Taildrop target), and a custom URL editor. A Profile row is added when state.profiles is non-empty (click opens the profile switcher).
| Feature | Chrome | Firefox |
|---|---|---|
| Manifest version | V3 | V3 |
| Proxy mechanism | chrome.proxy.settings (PAC script) |
browser.proxy.onRequest (listener) |
| Background type | Service worker (persistent with keepalive) | Event page (suspended aggressively) |
| Keepalive | setInterval ping every 25s |
browser.alarms every 25s |
| State persistence | Not needed (service worker stays alive) | Session storage for proxy config restoration |
| Native host ID | com.tailscale.browserext.chrome |
com.tailscale.browserext.firefox |
| Permissions | proxy, storage, nativeMessaging, contextMenus |
Same + alarms |
| Min version | -- | Firefox 140+ |
| Distribution | Chrome Web Store | Firefox Add-ons (AMO) |
| Extension ID | bhfeceecialgilpedkoflminjgcjljll (CWS) |
tailchrome@tesseras.org (gecko) |
- Alarms keepalive: Firefox suspends event pages after ~30s of inactivity. A
browser.alarmsalarm fires every 25s to send a keepalive ping and prevent suspension. - Proxy listener registration: The
proxy.onRequestlistener is registered at extension load and persists across suspensions. On wake, it returns aPromisethat waits for session storage restoration and native host reconnection. - Session storage: Proxy config is persisted to
browser.storage.sessionso that routing decisions can be made even before the native host reconnects after a suspension. - Data collection disclosure: Firefox AMO requires explicit data collection permissions declared in the manifest via
gecko.data_collection_permissions.
Chrome:
- Install from the Chrome Web Store
- macOS: Download
tailchrome-helper-macos.pkgfrom GitHub Releases, install it, then open Tailchrome Helper once from Applications. Other platforms: click the extension icon and follow the prompts to install the native host. - Log in to your Tailscale account
Firefox:
- Install from GitHub Releases
- macOS: use the same
tailchrome-helper-macos.pkgand Tailchrome Helper step as Chrome. Other platforms: click the extension icon and follow the prompts to install the native host from GitHub Releases - Log in to your Tailscale account
The native host binary auto-installs when run interactively in a terminal, or non-interactively via tailscale-browser-ext -install-now (used by the macOS Helper app):
- Detects installed browsers (Chrome and/or Firefox)
- Copies itself to
~/.local/share/tailscale-browser-ext/(Linux),~/Library/Application Support/Tailscale/BrowserExt/(macOS), or%LOCALAPPDATA%\tailscale-browser-ext\(Windows) - Writes native messaging manifest JSON files to browser-specific directories
- Manual install:
./tailscale-browser-ext --install C<extensionID>or--install F<extensionID> - Uninstall:
./tailscale-browser-ext --uninstall
Per-profile Tailscale state is stored at:
~/.config/tailscale-browser-ext/<browser-profile-UUID>/
Each browser profile generates a UUID on first connection, stored in chrome.storage.local as profileId.
tailchrome/
+-- packages/
| +-- extension/ # WXT app (browser-specific)
| | +-- entrypoints/
| | | +-- background.ts # Background entry (routes to chrome/firefox)
| | | +-- popup/ # Popup HTML, CSS, entry
| | +-- src/background/
| | | +-- chrome-proxy-manager.ts
| | | +-- firefox-proxy-manager.ts
| | +-- config/
| | | +-- firefox-disclosure.ts # AMO data collection declaration
| | +-- public/ # Icons (online/offline/warning states)
| | +-- wxt.config.ts # WXT manifest & build config
| | +-- package.json
| |
| +-- shared/ # Platform-agnostic code
| +-- src/
| | +-- types.ts # All TypeScript type definitions
| | +-- constants.ts # Configuration constants
| | +-- background/ # Service worker core logic
| | | +-- background.ts # Main background initialization
| | | +-- native-host.ts # Native host connection manager
| | | +-- state-store.ts # State management
| | | +-- badge-manager.ts # Icon/badge updates
| | | +-- proxy-utils.ts # IP/CIDR/DNS utilities
| | | +-- timer-service.ts # Timer abstraction
| | +-- popup/ # Popup UI views & components
| | | +-- popup.ts # View router
| | | +-- utils.ts
| | | +-- custom-urls.ts
| | | +-- icons.ts
| | | +-- views/
| | | | +-- connected.ts # Main connected view
| | | | +-- exit-nodes.ts # Exit node picker
| | | | +-- profiles.ts # Profile switcher
| | | | +-- disconnected.ts # Error recovery view
| | | | +-- needs-login.ts # Login view
| | | | +-- needs-install.ts # Install guide view
| | | | +-- needs-update.ts # Update guide view
| | | | +-- install-helpers.ts
| | | +-- components/
| | | | +-- peer-list.ts
| | | | +-- peer-item.ts
| | | | +-- header.ts
| | | | +-- health-warnings.ts
| | | | +-- toggle-switch.ts
| | +-- __test__/ # Test fixtures and mocks
| +-- package.json
|
+-- host/ # Native messaging host (Go)
| +-- main.go # Entry point
| +-- host.go # Host struct, message loop, handlers
| +-- protocol.go # Wire protocol types
| +-- status.go # IPN bus watcher
| +-- proxy.go # SOCKS5/HTTP proxy
| +-- profiles.go # Profile management
| +-- taildrop.go # File transfer
| +-- install.go # Manifest installation
| +-- install_darwin.go # macOS paths
| +-- install_linux.go # Linux paths
| +-- install_windows.go # Windows paths + registry
| +-- peers.go # Peer info extraction
| +-- exitnode.go # Exit node handlers
| +-- go.mod / go.sum
|
+-- config/
| +-- extension-ids.json # Extension & native host IDs
|
+-- scripts/ # Build/validation scripts
+-- store-assets/ # Store listing images
+-- docs/
| +-- privacy-policy.md
| +-- firefox-amo-launch.md
| +-- firefox-amo-review-notes.md
| +-- firefox-smoke-test.md
| +-- DOCUMENTATION.md # This file
| +-- CONTRIBUTING.md
| +-- SOURCE_CODE_REVIEW.md # Firefox AMO reviewer guide
| +-- STORE_LISTING.md # Chrome/Firefox store descriptions
| +-- SECURITY.md
| +-- CODE_OF_CONDUCT.md
|
+-- .github/workflows/
| +-- ci.yml # PR checks
| +-- release.yml # Tagged release builds
| +-- publish.yml # Store submission
|
+-- Makefile # Top-level build targets
+-- package.json # Root workspace scripts
+-- pnpm-workspace.yaml # Monorepo config
+-- tsconfig.base.json # Shared TS config
+-- README.md
+-- LICENSE # MIT
- Go 1.25+ (per
host/go.mod) - Node.js 22+
- pnpm (via corepack)
- Desktop Chrome or Firefox for testing
| Command | Description |
|---|---|
pnpm install --frozen-lockfile |
Install JS dependencies |
pnpm build:chrome |
Build Chrome extension via WXT |
pnpm build:firefox |
Build Firefox extension via WXT |
pnpm zip:chrome |
Create chrome.zip for distribution |
pnpm zip:firefox |
Create firefox.zip + firefox-sources.zip |
pnpm lint:firefox |
Validate Firefox extension with web-ext lint |
pnpm review:firefox |
Full Firefox review gate (build + lint + zip + publish validation) |
pnpm typecheck |
Run TypeScript type checking |
pnpm test |
Run all tests (vitest) |
pnpm validate:ids |
Validate extension ID consistency |
pnpm validate:release-tag <tag> |
Validate release tag format |
make host |
Build native host for current platform |
make host-all |
Build host binaries for all platforms |
make dev |
Chrome watch mode via WXT |
make all |
Build extension + host |
make clean |
Clean all build outputs |
| Output | Location |
|---|---|
| Chrome extension | packages/extension/.output/chrome-mv3/ |
Chrome extension (dev, make dev) |
packages/extension/.output/chrome-mv3-dev/ |
| Firefox extension | packages/extension/.output/firefox-mv3/ |
| Chrome ZIP | packages/extension/.output/chrome.zip |
| Firefox ZIP | packages/extension/.output/firefox.zip |
| Firefox sources ZIP | packages/extension/.output/firefox-sources.zip |
| Host binary | dist/tailscale-browser-ext |
| Host cross-compile | dist/tailscale-browser-ext-{os}-{arch} |
WXT (packages/extension/wxt.config.ts) handles:
- Manifest V3 generation for Chrome and Firefox
- Icon definitions (online, offline, warning states at 16/32/48/128px)
- Chrome extension key for stable development ID
- Firefox gecko settings (addon ID,
strict_min_version: "140.0", data collection permissions) - Source ZIP configuration for AMO review (allowlisted paths only)
- Vite alias
@tailchrome/shared->packages/shared/src
Four parallel jobs:
- extension-tests --
pnpm validate:ids,pnpm typecheck,pnpm test - build-chrome --
pnpm build:chrome, uploadschrome-buildartifact - review-firefox --
pnpm review:firefox(build + lint + zip + publish gate), uploadsfirefox-buildartifact - host-build --
make host-all, uploads host binaries for all 4 platforms
Single job that:
- Validates extension IDs and release tag
- Builds
chrome.zip,firefox.zip,firefox-sources.zip - Builds host binaries for all platforms
- Verifies Firefox source ZIP: extracts sources, rebuilds from scratch,
diff -qragainst original to ensure reproducibility - Generates
SHA256SUMS.txtfor all release assets - Creates/updates GitHub Release with all assets
Two jobs with environment-gated approvals:
- submit-chrome (environment:
chrome-web-store)
- Downloads
chrome.zipfrom GitHub Release - Verifies SHA256 checksum
- Submits via
pnpm wxt submit --chrome-zip
- submit-firefox (environment:
firefox-amo)
- Downloads
firefox.zip+firefox-sources.zip - Verifies checksums
- Validates Firefox publish gate
- Submits via
pnpm wxt submit --firefox-zip --firefox-sources-zip --firefox-channel listed - Supports
dry_runmode
Vitest with Chrome API mocks (packages/shared/src/__test__/chrome-mock.ts).
Background tests:
| Test File | Covers |
|---|---|
background.test.ts |
Service worker core: native message handling, popup communication, exit node restore, context menus, keepalive |
native-host.test.ts |
Connection lifecycle, reconnection with exponential backoff, error handling |
state-store.test.ts |
State management: update(), applyStatusUpdate(), subscribe(), version incrementing |
badge-manager.test.ts |
Icon/badge updates for all state combinations |
proxy-utils.test.ts |
IP conversion, CIDR parsing, MagicDNS sanitization, subnet collection |
timer-service.test.ts |
Timer abstraction contract |
Proxy tests:
| Test File | Covers |
|---|---|
chrome-proxy-manager.test.ts |
PAC script generation, proxy enable/disable, deduplication |
firefox-proxy-manager.test.ts |
Proxy listener, session storage persistence/restoration |
firefox.test.ts |
Firefox-specific keepalive via alarms |
Popup tests:
| Test File | Covers |
|---|---|
popup.test.ts |
View routing for all state combinations |
peer-item.test.ts |
Peer component rendering and actions |
custom-urls.test.ts |
Custom URL storage |
utils.test.ts |
HTML escaping, clipboard, toast, platform detection |
pnpm test # Run all tests once{
"chromeExtensionId": "bhfeceecialgilpedkoflminjgcjljll",
"firefoxAddonId": "tailchrome@tesseras.org",
"chromeNativeHostId": "com.tailscale.browserext.chrome",
"firefoxNativeHostId": "com.tailscale.browserext.firefox"
}| Permission | Purpose |
|---|---|
proxy |
Configure browser proxy settings |
storage |
Persist profileId, exit node, custom URLs |
nativeMessaging |
Communicate with native host |
contextMenus |
Right-click "Send page URL" menu |
alarms |
Firefox-only: keepalive timer |
<all_urls> (host permission) |
Proxy interception for all URLs |
| Key | Storage | Purpose |
|---|---|---|
profileId |
chrome.storage.local |
Browser profile UUID for tsnet isolation |
lastExitNodeID |
chrome.storage.local |
Persist exit node selection across reconnects |
customUrls |
chrome.storage.local |
Per-device custom open targets |
proxyConfig |
browser.storage.session (Firefox only) |
Proxy state for surviving background suspension |
Login URLs from the native host (browseToURL) are validated against an allowlist before opening:
https://login.tailscale.comhttps://controlplane.tailscale.com
The extension enforces major.minor version matching between the expected version (EXPECTED_HOST_VERSION) and the host's reported version. Patch version differences are tolerated. A mismatch shows the "needs-update" view.
- Only browser traffic is proxied -- system networking is never modified
- The proxy binds to
127.0.0.1only (not exposed to the network) - When the extension is disabled or the service worker suspends, proxy settings are cleared to
DIRECT
Requests to the Tailscale web client (100.100.100.100) include a Sec-Tailscale: browser-ext header for CSRF protection.
Native messaging is restricted to the declared extension IDs in the native host manifest. The Chrome manifest uses allowed_origins, Firefox uses allowed_extensions.
| Data | Where | Purpose |
|---|---|---|
profileId |
Browser local storage | Per-profile Tailscale node isolation |
lastExitNodeID |
Browser local storage | Exit node restoration |
customUrls |
Browser local storage | Custom per-device URLs |
proxyConfig |
Firefox session storage | Proxy state restoration after suspension |
~/.config/tailscale-browser-ext/<UUID>/ |
Filesystem | tsnet state directory (keys, config) |
When enabled, Tailchrome transmits:
- Browsing activity and website content needed for proxy/exit-node traffic
- Authentication data for Tailscale login
- Device and network metadata for peer discovery
- User-initiated file contents for Taildrop transfers
Data is sent only to: the local native host, the user's Tailscale tailnet/control plane, and sites the user accesses through Tailchrome.
Tailchrome does not include analytics, crash telemetry, advertising identifiers, or marketing data.
Full policy: docs/privacy-policy.md
- Category: Productivity
- Short description: "Access your Tailscale tailnet directly from your browser. Per-profile VPN without touching system networking."
- Extension ID:
bhfeceecialgilpedkoflminjgcjljll
- Listing status: Published on Firefox Add-ons (AMO).
- Categories: Privacy & Security, Other
- Addon ID:
tailchrome@tesseras.org - Minimum Firefox version: 140.0
- Source code disclosure:
firefox-sources.zipincluded with each release for AMO reviewer verification
Full listing text: STORE_LISTING.md
- Fork the repo and clone locally
- Install dependencies: Go 1.25+, Node.js 22+, pnpm (via
corepack enable) pnpm install --frozen-lockfile- Build:
pnpm build:chrome,pnpm build:firefox,make host - Load extension in browser for testing:
- Chrome:
chrome://extensions-> Developer Mode -> Load unpackedpackages/extension/.output/chrome-mv3-dev/(withmake dev) orpackages/extension/.output/chrome-mv3/(afterpnpm build:chrome) - Firefox:
about:debugging#/runtime/this-firefox-> Load temporary addonpackages/extension/.output/firefox-mv3/manifest.json
- Install native host by running the built binary directly
- Open an issue first before submitting a PR
- Keep PRs focused -- one feature or fix per PR
- Write clear commit messages
- Test changes in both Chrome and Firefox
- Follow existing code patterns
Full guide: CONTRIBUTING.md
Key dependencies in host/go.mod:
| Dependency | Version | Purpose |
|---|---|---|
tailscale.com |
v1.94.2 | tsnet, local client, IPN, socks5, proxymux, web client |
golang.org/x/term |
v0.38.0 | Terminal detection for auto-install |
golang.org/x/sys |
v0.40.0 | System calls |