Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/DOCUMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

> Access your Tailscale network directly from your browser. No system VPN required.

**Version:** 0.1.10 (native host) | Manifest V3
**Version:** 0.1.11 (native host) | Manifest V3
**Browsers:** Chrome, Firefox
**Platforms:** macOS (amd64, arm64), Linux (amd64), Windows (amd64)
**License:** MIT
Expand Down Expand Up @@ -329,7 +329,7 @@ Defined in `packages/shared/src/constants.ts`:
| `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.10` | Expected native host version (major.minor match required) |
| `EXPECTED_HOST_VERSION` | `0.1.11` | Expected native host version (major.minor match required) |


---
Expand Down
4 changes: 2 additions & 2 deletions docs/FEATURE_PARITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ Last updated: 2026-05-26
| Multiple profiles / accounts | Yes | Yes | Create, switch, delete profiles |
| Per-device identity | Yes | Yes | Each browser profile gets its own isolated Tailscale node via `tsnet` |
| Machine key re-authentication | Yes | No | Extension shows `NeedsMachineAuth` state but cannot trigger re-auth; user must use admin console |
| Custom control server URL | Yes | No | Host `PrefsView` includes `controlURL` but the extension UI does not expose it; hardcoded to default Tailscale control plane |
| Custom control server URL | Yes | Yes | Advanced quick setting "Coordination server" accepts an `https://` URL (e.g. Headscale). Saving sends `ControlURL` through `set-prefs`, which triggers a logout + re-login against the new server. Leave blank to revert to Tailscale's default. Admin Console footer link is hidden while a custom server is configured. |
| Auto-start on boot | Yes | N/A | Extension activates when browser launches; native host is started on demand by the browser |
| Auto-connect on start | Yes | Yes | Opt-in **Auto-connect on start** toggle in quick settings (off by default). When on, the extension sends `up` once per browser session if the first status after `init` reports `Stopped`/`NoState`; skipped for `NeedsLogin`/`NeedsMachineAuth`. A manual disconnect within the same session is respected even if the service worker restarts. Last exit node is restored separately when the node reaches `Running`. |

Expand Down Expand Up @@ -182,7 +182,7 @@ Last updated: 2026-05-26
| Category | Missing Features |
| ----------------- | ------------------------------------------------------------------------------------- |
| **File transfer** | Receiving files (Taildrop inbound), large files (>~750 KB) |
| **Networking** | Advertise subnet routes, split DNS, custom DNS nameservers, custom control server URL |
| **Networking** | Advertise subnet routes, split DNS, custom DNS nameservers |
| **Security** | Network lock, key signing/rotation, key expiry display |
| **Services** | Tailscale Serve, Tailscale Funnel, SSH server |
| **Diagnostics** | `netcheck`, `ping` peers, bug reports |
Expand Down
92 changes: 92 additions & 0 deletions host/control_url_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package main

import (
"strings"
"testing"
)

func TestControlURLForPrefsNormalizesDefaultURLs(t *testing.T) {
for _, raw := range []string{
"",
" https://controlplane.tailscale.com ",
"https://controlplane.tailscale.com/",
"https://controlplane.tailscale.com:443",
"https://login.tailscale.com",
"https://login.tailscale.com/",
"https://login.tailscale.com:443",
} {
t.Run(raw, func(t *testing.T) {
if got := controlURLForPrefs(raw); got != "" {
t.Fatalf("controlURLForPrefs(%q) = %q, want empty default", raw, got)
}
})
}
}

func TestControlURLCompareKeyTreatsDefaultSynonymsAsEqual(t *testing.T) {
keys := []string{
controlURLCompareKey(""),
controlURLCompareKey("https://controlplane.tailscale.com"),
controlURLCompareKey("https://login.tailscale.com"),
}
for i, key := range keys {
if key != "" {
t.Fatalf("key[%d] = %q, want empty default key", i, key)
}
}
}

func TestControlURLCompareKeyKeepsCustomURL(t *testing.T) {
got := controlURLForPrefs(" https://Headscale.example.com ")
if got != "https://Headscale.example.com" {
t.Fatalf("controlURLForPrefs custom = %q", got)
}

a := controlURLCompareKey("https://Headscale.example.com/")
b := controlURLCompareKey("https://headscale.example.com")
if a != b {
t.Fatalf("custom compare keys differ: %q != %q", a, b)
}
}

func TestControlURLCompareKeyKeepsIPv6Brackets(t *testing.T) {
// A non-default port must keep IPv6 brackets so the key stays a well-formed
// authority and distinct IPv6 URLs don't collapse together.
key := controlURLCompareKey("https://[2001:db8::1]:8443")
if !strings.Contains(key, "[2001:db8::1]:8443") {
t.Fatalf("IPv6 compare key dropped brackets: %q", key)
}

// Distinct IPv6 control URLs must produce distinct keys (regression: both
// previously normalized to the bracket-less "2001:db8::1:8443").
if same := controlURLCompareKey("https://[2001:db8::1:8443]"); same == key {
t.Fatalf("distinct IPv6 control URLs collide: %q", key)
}

// The same URL is stable across calls.
if a, b := controlURLCompareKey("https://[2001:db8::1]:8443"), controlURLCompareKey("https://[2001:db8::1]:8443"); a != b {
t.Fatalf("IPv6 keys unstable: %q != %q", a, b)
}
}

func TestIsValidControlURL(t *testing.T) {
for _, raw := range []string{
"https://headscale.example.com",
"http://headscale.test:8080",
"https://[2001:db8::1]:8443",
} {
if !isValidControlURL(raw) {
t.Errorf("isValidControlURL(%q) = false, want true", raw)
}
}
for _, raw := range []string{
"not a url",
"ftp://example.com",
"https://",
"example.com",
} {
if isValidControlURL(raw) {
t.Errorf("isValidControlURL(%q) = true, want false", raw)
}
}
}
179 changes: 176 additions & 3 deletions host/host.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ import (
"fmt"
"io"
"log"
"net"
"net/netip"
"net/url"
"os"
"path/filepath"
"strings"
Expand All @@ -21,6 +23,11 @@ import (
"tailscale.com/tsnet"
)

const (
defaultControlURLOrigin = "https://controlplane.tailscale.com"
defaultLoginURLOrigin = "https://login.tailscale.com"
)

// Host manages the native messaging connection and the Tailscale tsnet instance.
type Host struct {
stdin io.Reader
Expand Down Expand Up @@ -135,6 +142,92 @@ func (h *Host) sendError(cmd, message string) {
})
}

func (h *Host) clearCachedStatus(prefs *ipn.Prefs) {
h.stateMu.Lock()
defer h.stateMu.Unlock()
h.lastState = ""
h.lastBrowseToURL = ""
h.lastPrefs = nil
h.lastHealth = nil
if prefs != nil {
h.lastPrefs = prefsViewFromIPN(prefs.View())
}
}

func controlURLForPrefs(raw string) string {
trimmed := strings.TrimSpace(raw)
if isDefaultControlURL(trimmed) {
return ""
}
return trimmed
}

// isValidControlURL reports whether a non-empty custom control URL is a
// well-formed http/https URL with a host. Validation lives here at the trust
// boundary (not only in the popup) so any caller of set-prefs is guarded.
func isValidControlURL(raw string) bool {
u, err := url.Parse(raw)
if err != nil {
return false
}
scheme := strings.ToLower(u.Scheme)
return (scheme == "http" || scheme == "https") && u.Host != ""
}

func controlURLCompareKey(raw string) string {
trimmed := strings.TrimSpace(raw)
if isDefaultControlURL(trimmed) {
return ""
}

u, err := url.Parse(trimmed)
if err != nil || u.Scheme == "" || u.Host == "" {
return trimmed
}

u.Scheme = strings.ToLower(u.Scheme)
u.Host = normalizedURLHost(u)
if u.Path == "/" && u.RawQuery == "" {
u.Path = ""
}
u.Fragment = ""
return u.String()
}

func isDefaultControlURL(raw string) bool {
if raw == "" {
return true
}
u, err := url.Parse(raw)
if err != nil || u.Scheme == "" || u.Host == "" {
return false
}
switch normalizedURLOrigin(u) {
case defaultControlURLOrigin, defaultLoginURLOrigin:
return true
default:
return false
}
}

func normalizedURLOrigin(u *url.URL) string {
scheme := strings.ToLower(u.Scheme)
host := normalizedURLHost(u)
return scheme + "://" + host
}

func normalizedURLHost(u *url.URL) string {
scheme := strings.ToLower(u.Scheme)
host := strings.ToLower(u.Hostname())
port := u.Port()
if port == "" || (scheme == "https" && port == "443") || (scheme == "http" && port == "80") {
return host
}
// JoinHostPort re-adds brackets for IPv6 literals so distinct hosts don't
// collapse to the same key (e.g. "[::1]:8443" vs "::1:8443").
return net.JoinHostPort(host, port)
}

// handleRequest dispatches a request to the appropriate handler based on the cmd field.
func (h *Host) handleRequest(req Request) {
switch req.Cmd {
Expand Down Expand Up @@ -473,6 +566,7 @@ func (h *Host) handleSetPrefs(req Request) {

// Decode the partial prefs from the extension.
var partial struct {
ControlURL *string `json:"controlURL,omitempty"`
ExitNodeID *string `json:"exitNodeID,omitempty"`
ExitNodeAllowLANAccess *bool `json:"exitNodeAllowLANAccess,omitempty"`
CorpDNS *bool `json:"corpDNS,omitempty"`
Expand All @@ -490,6 +584,15 @@ func (h *Host) handleSetPrefs(req Request) {
}

mp := &ipn.MaskedPrefs{}
if partial.ControlURL != nil {
normalized := controlURLForPrefs(*partial.ControlURL)
if normalized != "" && !isValidControlURL(normalized) {
h.sendError("set-prefs", fmt.Sprintf("invalid control server URL: %q", normalized))
return
}
mp.ControlURLSet = true
mp.Prefs.ControlURL = normalized
}
if partial.ExitNodeID != nil {
mp.ExitNodeIDSet = true
mp.Prefs.ExitNodeID = tailcfg.StableNodeID(*partial.ExitNodeID)
Expand Down Expand Up @@ -525,12 +628,20 @@ func (h *Host) handleSetPrefs(req Request) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()

if partial.AdvertiseRoutes != nil || partial.AdvertiseExitNode != nil {
currentPrefs, err := h.lc.GetPrefs(ctx)
// Fetch current prefs when we need to merge against them (route advertise) or
// detect a real ControlURL change. Reuse the single GetPrefs call.
var currentPrefs *ipn.Prefs
needCurrent := partial.AdvertiseRoutes != nil || partial.AdvertiseExitNode != nil || partial.ControlURL != nil
if needCurrent {
cp, err := h.lc.GetPrefs(ctx)
if err != nil {
h.sendError("set-prefs", fmt.Sprintf("failed to get current prefs: %v", err))
return
}
currentPrefs = cp
}

if partial.AdvertiseRoutes != nil || partial.AdvertiseExitNode != nil {
exitWas := currentPrefs.AdvertisesExitNode()
if partial.AdvertiseRoutes != nil {
prefixes := make([]netip.Prefix, 0, len(*partial.AdvertiseRoutes))
Expand All @@ -553,12 +664,74 @@ func (h *Host) handleSetPrefs(req Request) {
mp.Prefs.AdvertiseRoutes = currentPrefs.AdvertiseRoutes
}

_, err := h.lc.EditPrefs(ctx, mp)
controlURLChanged := partial.ControlURL != nil &&
currentPrefs != nil &&
controlURLCompareKey(mp.Prefs.ControlURL) != controlURLCompareKey(currentPrefs.ControlURL)

updatedPrefs, err := h.lc.EditPrefs(ctx, mp)
if err != nil {
h.sendError("set-prefs", fmt.Sprintf("failed to set prefs: %v", err))
return
}

if controlURLChanged {
// Exit-node selection and node identity are scoped to the previous
// tailnet; clear them so a now-invalid ExitNodeID (a StableNodeID from
// the old control server) isn't carried onto the new one.
updatedPrefs.ExitNodeID = ""
updatedPrefs.ExitNodeIP = netip.Addr{}

h.clearCachedStatus(updatedPrefs)

// Explicitly log out so the previous control plane's node key and
// profile identity are discarded. Without this, restarting with the
// new ControlURL would carry the old identity to the new server and
// the IPN engine could skip the interactive login prompt.
logoutCtx, logoutCancel := context.WithTimeout(context.Background(), 10*time.Second)
if err := h.lc.Logout(logoutCtx); err != nil {
logoutCancel()
log.Printf("logout during controlURL change failed, restoring previous URL: %v", err)

rollbackCtx, rollbackCancel := context.WithTimeout(context.Background(), 30*time.Second)
rollbackErr := h.lc.Start(rollbackCtx, ipn.Options{UpdatePrefs: currentPrefs})
rollbackCancel()
if rollbackErr != nil {
h.clearCachedStatus(currentPrefs)
h.sendError("set-prefs", fmt.Sprintf("failed to logout before changing control URL: %v; also failed to restore previous control URL: %v", err, rollbackErr))
return
}

h.clearCachedStatus(currentPrefs)
h.handleGetStatus()
h.sendError("set-prefs", fmt.Sprintf("failed to logout before changing control URL: %v; restored previous control URL", err))
return
}
logoutCancel()

restartCtx, restartCancel := context.WithTimeout(context.Background(), 30*time.Second)
if err := h.lc.Start(restartCtx, ipn.Options{UpdatePrefs: updatedPrefs}); err != nil {
restartCancel()
log.Printf("restart with updated control URL failed, restoring previous URL: %v", err)

rollbackCtx, rollbackCancel := context.WithTimeout(context.Background(), 30*time.Second)
rollbackErr := h.lc.Start(rollbackCtx, ipn.Options{UpdatePrefs: currentPrefs})
rollbackCancel()
if rollbackErr != nil {
h.clearCachedStatus(currentPrefs)
h.sendError("set-prefs", fmt.Sprintf("failed to restart with updated control URL: %v; also failed to restore previous control URL: %v", err, rollbackErr))
return
}

h.clearCachedStatus(currentPrefs)
h.handleGetStatus()
// The logout above already discarded the previous session, so the
// prefs are restored but the node is signed out and must log in again.
h.sendError("set-prefs", fmt.Sprintf("failed to switch control server: %v; reverted to your previous server, but you have been signed out and must log in again", err))
return
}
restartCancel()
}

h.handleGetStatus()
}

Expand Down
Loading
Loading