Files
tailscale-custom/cmd/tailscale-tray/main.go
T
huanld dba7b9ba50
checklocks / checklocks (push) Has been cancelled
CodeQL / Analyze (go) (push) Has been cancelled
natlab-integrationtest / natlab-integrationtest (push) Has been cancelled
CI / gomod-cache (push) Has been cancelled
CI / race-root-integration (1/4) (push) Has been cancelled
CI / race-root-integration (2/4) (push) Has been cancelled
CI / race-root-integration (3/4) (push) Has been cancelled
CI / race-root-integration (4/4) (push) Has been cancelled
CI / test (-race, amd64, 1/3) (push) Has been cancelled
CI / test (-race, amd64, 2/3) (push) Has been cancelled
CI / test (-race, amd64, 3/3) (push) Has been cancelled
CI / test (386) (push) Has been cancelled
CI / test (amd64) (push) Has been cancelled
CI / Windows (benchmarks) (push) Has been cancelled
CI / Windows (1/2) (push) Has been cancelled
CI / Windows (2/2) (push) Has been cancelled
CI / macos (push) Has been cancelled
CI / privileged (push) Has been cancelled
CI / vm (push) Has been cancelled
CI / cross (386, linux) (push) Has been cancelled
CI / cross (amd64, darwin) (push) Has been cancelled
CI / cross (amd64, freebsd) (push) Has been cancelled
CI / cross (amd64, openbsd) (push) Has been cancelled
CI / cross (amd64, windows) (push) Has been cancelled
CI / cross (arm, 5, linux) (push) Has been cancelled
CI / cross (arm, 7, linux) (push) Has been cancelled
CI / cross (arm64, darwin) (push) Has been cancelled
CI / cross (arm64, linux) (push) Has been cancelled
CI / cross (arm64, windows) (push) Has been cancelled
CI / cross (loong64, linux) (push) Has been cancelled
CI / ios (push) Has been cancelled
CI / crossmin (amd64, illumos) (push) Has been cancelled
CI / crossmin (amd64, plan9) (push) Has been cancelled
CI / crossmin (amd64, solaris) (push) Has been cancelled
CI / crossmin (ppc64, aix) (push) Has been cancelled
CI / android (push) Has been cancelled
CI / wasm (push) Has been cancelled
CI / tailscale_go (push) Has been cancelled
CI / fuzz (push) Has been cancelled
CI / depaware (push) Has been cancelled
CI / go_generate (push) Has been cancelled
CI / make_tidy (push) Has been cancelled
CI / licenses (push) Has been cancelled
CI / staticcheck (macOS) (push) Has been cancelled
CI / staticcheck (Linux) (push) Has been cancelled
CI / staticcheck (Windows) (push) Has been cancelled
CI / staticcheck (Portable (1/4)) (push) Has been cancelled
CI / staticcheck (Portable (2/4)) (push) Has been cancelled
CI / staticcheck (Portable (3/4)) (push) Has been cancelled
CI / staticcheck (Portable (4/4)) (push) Has been cancelled
CI / notify_slack (push) Has been cancelled
CI / merge_blocker (push) Has been cancelled
CI / check_mergeability_strict (push) Has been cancelled
CI / check_mergeability (push) Has been cancelled
Dockerfile build / deploy (push) Has been cancelled
tailscale.com/cmd/vet / vet (push) Has been cancelled
update-flakehub / flakehub-publish (push) Has been cancelled
fix(tray): connect/login, logout, add-server auth-key; release v1.0.1
Tray client fixes for reconnect/add-server failures diagnosed from tray.log:

- Treat NoState like NeedsLogin so "Connect" runs the login flow instead of
  a no-op EditPrefs (fixes reconnect-after-disconnect/quit doing nothing).
- Run Logout async on bgCtx with a 30s timeout (was 10s on the per-rebuild
  ctx) so a slow Headscale logout no longer hits context-deadline-exceeded.
- Add optional pre-auth key prompt in Add Server, passed via
  ipn.Options.AuthKey, for control servers without OIDC (no browser URL).
- Drain a queued click after a modal dialog so Add Server no longer
  re-popups in a loop.
- Drop the single-domain whitelist in validateAuthURL (it silently blocked
  legit OIDC redirects, so the browser never opened); require HTTPS + host.

Packaging: bump MSI to 1.0.1, enable high cab compression. Add
deploy-tray-1.0.1.ps1 to hot-swap just the tray binary.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 07:52:28 +07:00

1106 lines
30 KiB
Go

// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
// tailscale-tray is a Windows system tray application for Tailscale-Custom.
package main
import (
"context"
"fmt"
"log"
"net"
"net/url"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"sync"
"sync/atomic"
"time"
"unsafe"
"fyne.io/systray"
"golang.org/x/sys/windows"
"tailscale.com/client/local"
"tailscale.com/ipn"
"tailscale.com/ipn/ipnstate"
)
// app holds the systray menu state and IPN client.
type app struct {
lc *local.Client
mu sync.Mutex
status *ipnstate.Status
curProfile ipn.LoginProfile
allProfiles []ipn.LoginProfile
prevState string // track state transitions
inAction int32 // atomic flag to prevent re-entrant actions
bgCtx context.Context
bgCancel context.CancelFunc
rebuildCh chan struct{}
eventCancel context.CancelFunc
}
func main() {
// Single instance mutex. Lock the OS thread so that CreateMutex and
// the subsequent error check reference the same thread-local LastError.
// (Go may otherwise reschedule the goroutine onto a different OS thread
// between the two syscalls, yielding a meaningless LastError value.)
runtime.LockOSThread()
mutexName, _ := windows.UTF16PtrFromString("Global\\Tailscale-Custom-Tray-Mutex")
handle, createErr := windows.CreateMutex(nil, false, mutexName)
lastErr := windows.GetLastError()
runtime.UnlockOSThread()
if handle == 0 {
// Real failure (access denied, invalid args, etc.)
log.Printf("CreateMutex failed: %v", createErr)
os.Exit(1)
}
if lastErr == windows.ERROR_ALREADY_EXISTS {
// Another instance is already running; exit silently with success.
windows.CloseHandle(handle)
os.Exit(0)
}
defer windows.CloseHandle(handle)
setupLogging()
log.Println("tailscale-tray starting")
// Top-level panic recovery so a stray panic in any goroutine that
// eventually reaches main doesn't just vanish the tray icon silently.
defer func() {
if r := recover(); r != nil {
log.Printf("PANIC in main: %v", r)
}
}()
a := &app{
lc: &local.Client{},
rebuildCh: make(chan struct{}, 1),
}
a.bgCtx, a.bgCancel = context.WithCancel(context.Background())
defer a.bgCancel()
a.updateState()
go a.watchIPNBus()
go a.periodicRefresh()
systray.Run(a.onReady, a.onExit)
}
// periodicRefresh acts as a safety-net against a silently-stuck IPN bus
// watcher (e.g. broken pipe on tailscaled restart that does not surface as
// an error on Next()). Every 15s it forces a fresh status snapshot and,
// if the backend state actually changed, triggers a menu rebuild.
func (a *app) periodicRefresh() {
defer func() {
if r := recover(); r != nil {
log.Printf("PANIC in periodicRefresh: %v", r)
}
}()
ticker := time.NewTicker(15 * time.Second)
defer ticker.Stop()
var lastState string
for {
select {
case <-a.bgCtx.Done():
return
case <-ticker.C:
}
a.updateState()
a.mu.Lock()
cur := ""
if a.status != nil {
cur = a.status.BackendState
}
a.mu.Unlock()
if cur != lastState {
log.Printf("periodicRefresh: state changed %q -> %q, rebuilding", lastState, cur)
lastState = cur
a.triggerRebuild()
}
}
}
func setupLogging() {
dirs := []string{
filepath.Join(os.Getenv("ProgramData"), "Tailscale-Custom", "Logs"),
filepath.Join(os.Getenv("LOCALAPPDATA"), "Tailscale-Custom", "Logs"),
}
for _, dir := range dirs {
os.MkdirAll(dir, 0700)
f, err := os.OpenFile(filepath.Join(dir, "tray.log"), os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600)
if err == nil {
log.SetOutput(f)
return
}
}
}
func (a *app) updateState() {
a.mu.Lock()
defer a.mu.Unlock()
ctx, cancel := context.WithTimeout(a.bgCtx, 5*time.Second)
defer cancel()
var err error
a.status, err = a.lc.Status(ctx)
if err != nil {
log.Printf("updateState: Status error: %v", err)
a.status = nil
}
a.curProfile, a.allProfiles, err = a.lc.ProfileStatus(ctx)
if err != nil {
log.Printf("updateState: ProfileStatus error: %v", err)
}
}
func (a *app) onReady() {
log.Println("onReady")
systray.SetIcon(iconDisconnected)
systray.SetTooltip("Tailscale-Custom")
a.rebuild()
}
func (a *app) onExit() {
log.Println("onExit")
a.bgCancel()
}
// onClick registers a per-item click handler goroutine (official Tailscale pattern).
func onClick(ctx context.Context, item *systray.MenuItem, fn func()) {
go func() {
defer func() {
if r := recover(); r != nil {
log.Printf("PANIC in onClick: %v", r)
}
}()
for {
select {
case <-ctx.Done():
return
case <-item.ClickedCh:
fn()
// A modal dialog opened inside fn() runs its own Win32 message
// loop, during which the original click can be re-posted/queued.
// Drain one pending click so we don't immediately re-run fn()
// and spam the same popup over and over.
select {
case <-item.ClickedCh:
case <-ctx.Done():
return
default:
}
}
}
}()
}
func (a *app) rebuild() {
a.mu.Lock()
defer a.mu.Unlock()
log.Println("rebuild: start")
if a.eventCancel != nil {
a.eventCancel()
}
ctx, cancelFn := context.WithCancel(a.bgCtx)
a.eventCancel = cancelFn
systray.ResetMenu()
state := a.backendState()
isRunning := state == "Running"
// NoState means the backend has no usable login/profile yet, so it must go
// through the login flow (not a plain WantRunning toggle). Treat it the same
// as NeedsLogin so the "Connect" button triggers doLogin() instead of a
// no-op EditPrefs.
isNeedsLogin := state == "NeedsLogin" || state == "NoState"
// ── Header ──────────────────────────────────────────
a.setTrayIcon(state)
headerText := a.headerText(state)
header := systray.AddMenuItem(headerText, "")
header.Disable()
// Show IP on a separate line when connected
if isRunning && a.status != nil && a.status.Self != nil && len(a.status.Self.TailscaleIPs) > 0 {
selfIP := a.status.Self.TailscaleIPs[0].String()
ipLine := systray.AddMenuItem(" IP: "+selfIP, "Click to copy")
onClick(ctx, ipLine, func() {
copyToClipboard(selfIP)
})
}
// Show server info
if a.curProfile.ControlURL != "" {
server := strings.TrimPrefix(a.curProfile.ControlURL, "https://")
server = strings.TrimPrefix(server, "http://")
server = strings.TrimSuffix(server, "/")
serverLine := systray.AddMenuItem(" Server: "+server, "")
serverLine.Disable()
}
systray.AddSeparator()
// ── Connection Controls ─────────────────────────────
if isRunning {
disconnect := systray.AddMenuItem("Disconnect", "Disconnect from VPN")
onClick(ctx, disconnect, func() {
log.Println("action: Disconnect")
a.doEditPrefs(false)
})
} else if isNeedsLogin {
login := systray.AddMenuItem("Login", "Login to server")
onClick(ctx, login, func() {
log.Println("action: Login")
a.doLogin()
})
connect := systray.AddMenuItem("Connect", "Connect to VPN")
onClick(ctx, connect, func() {
log.Println("action: Connect")
a.doLogin()
})
} else {
// Stopped or unknown
connect := systray.AddMenuItem("Connect", "Connect to VPN")
onClick(ctx, connect, func() {
log.Println("action: Connect")
a.doEditPrefs(true)
})
}
systray.AddSeparator()
// ── Network Info (when connected) ───────────────────
if isRunning && a.status != nil {
peerCount := len(a.status.Peer)
onlineCount := 0
for _, p := range a.status.Peer {
if p.Online {
onlineCount++
}
}
netInfo := systray.AddMenuItem(fmt.Sprintf("Peers: %d online / %d total", onlineCount, peerCount), "")
netInfo.Disable()
// Show connected peers as submenu
if peerCount > 0 {
peersMenu := systray.AddMenuItem("Peer List", "")
for _, p := range a.status.Peer {
peerName := p.HostName
if peerName == "" {
peerName = p.DNSName
}
peerIP := ""
if len(p.TailscaleIPs) > 0 {
peerIP = p.TailscaleIPs[0].String()
}
statusMark := "-"
if p.Online {
statusMark = "+"
}
title := fmt.Sprintf("[%s] %s", statusMark, peerName)
if peerIP != "" {
title += " (" + peerIP + ")"
}
peerItem := peersMenu.AddSubMenuItem(title, "Click to copy IP")
ip := peerIP
onClick(ctx, peerItem, func() {
if ip != "" {
copyToClipboard(ip)
}
})
}
}
systray.AddSeparator()
}
// ── Account Management ──────────────────────────────
if len(a.allProfiles) > 0 {
accountLabel := a.curProfile.Name
if accountLabel == "" {
accountLabel = "Account"
}
accountMenu := systray.AddMenuItem(accountLabel, "")
// Brief yield to fyne/systray internals: adding sub-menu items
// immediately after creating a parent menu can race with the
// underlying Win32 message pump. 10ms lets the pump process
// the parent-creation message before we append children.
time.Sleep(10 * time.Millisecond)
for _, profile := range a.allProfiles {
title := profileTitle(profile)
var item *systray.MenuItem
if profile.ID == a.curProfile.ID {
item = accountMenu.AddSubMenuItemCheckbox(" "+title, "", true)
} else {
item = accountMenu.AddSubMenuItem(" "+title, "")
}
pid := profile.ID
onClick(ctx, item, func() {
log.Printf("action: switch profile %v", pid)
opCtx, opCancel := context.WithTimeout(a.bgCtx, 10*time.Second)
defer opCancel()
if err := a.lc.SwitchProfile(opCtx, pid); err != nil {
log.Printf("SwitchProfile error: %v", err)
}
})
}
// Delete profile (only if more than 1)
if len(a.allProfiles) > 1 {
accountMenu.AddSubMenuItem("", "")
delProfile := accountMenu.AddSubMenuItem(" Remove current profile", "")
onClick(ctx, delProfile, func() {
log.Println("action: delete profile")
opCtx, opCancel := context.WithTimeout(a.bgCtx, 10*time.Second)
defer opCancel()
if err := a.lc.DeleteProfile(opCtx, a.curProfile.ID); err != nil {
log.Printf("DeleteProfile error: %v", err)
showError(fmt.Sprintf("Failed to remove profile: %v", err))
}
})
}
}
// Logout
logoutItem := systray.AddMenuItem("Logout", "Logout and deregister from server")
onClick(ctx, logoutItem, func() {
log.Println("action: Logout")
// Logout calls the control server to deregister the node and can be
// slow (esp. Headscale). Run it on a detached goroutine tied to bgCtx
// (not the per-rebuild ctx, which dies on the next menu rebuild) with a
// generous timeout, so a slow logout neither blocks this click handler
// nor gets cancelled by an intervening rebuild.
go func() {
defer func() {
if r := recover(); r != nil {
log.Printf("PANIC in Logout: %v", r)
}
}()
opCtx, opCancel := context.WithTimeout(a.bgCtx, 30*time.Second)
defer opCancel()
if err := a.lc.Logout(opCtx); err != nil {
log.Printf("Logout error: %v", err)
showError(fmt.Sprintf("Logout failed: %v", err))
} else {
log.Println("Logout: OK")
}
}()
})
systray.AddSeparator()
// ── Server Management ───────────────────────────────
addServerItem := systray.AddMenuItem("Add Server...", "Connect to a new control server")
onClick(ctx, addServerItem, func() {
log.Println("action: Add Server")
a.addServer()
})
systray.AddSeparator()
// ── Footer ──────────────────────────────────────────
quit := systray.AddMenuItem("Quit", "Quit Tailscale-Custom Tray")
onClick(ctx, quit, func() {
log.Println("action: Quit")
systray.Quit()
})
// Rebuild listener
go func() {
defer func() {
if r := recover(); r != nil {
log.Printf("PANIC in rebuild listener: %v", r)
}
}()
select {
case <-ctx.Done():
return
case <-a.rebuildCh:
log.Println("rebuild triggered by IPNBus")
a.updateState()
a.rebuild()
}
}()
log.Printf("rebuild: done (state=%q, profiles=%d)", state, len(a.allProfiles))
}
// ── Helpers ─────────────────────────────────────────────
func (a *app) backendState() string {
if a.status == nil {
return ""
}
return a.status.BackendState
}
func (a *app) headerText(state string) string {
switch state {
case "Running":
if a.status.Self != nil {
return "Connected - " + a.status.Self.HostName
}
return "Connected"
case "NeedsLogin":
return "Login required"
case "Stopped":
return "Disconnected"
case "Starting":
return "Connecting..."
default:
if state == "" {
return "Service unavailable"
}
return state
}
}
func (a *app) setTrayIcon(state string) {
switch state {
case "Running":
systray.SetIcon(iconConnected)
systray.SetTooltip("Tailscale-Custom - Connected")
case "Starting":
systray.SetIcon(iconConnecting)
systray.SetTooltip("Tailscale-Custom - Connecting...")
default:
systray.SetIcon(iconDisconnected)
systray.SetTooltip("Tailscale-Custom - Disconnected")
}
}
func (a *app) doEditPrefs(wantRunning bool) {
opCtx, opCancel := context.WithTimeout(a.bgCtx, 10*time.Second)
defer opCancel()
_, err := a.lc.EditPrefs(opCtx, &ipn.MaskedPrefs{
Prefs: ipn.Prefs{WantRunning: wantRunning},
WantRunningSet: true,
})
if err != nil {
log.Printf("EditPrefs(wantRunning=%v) error: %v", wantRunning, err)
} else {
log.Printf("EditPrefs(wantRunning=%v): OK", wantRunning)
}
}
func (a *app) doLogin() {
if !atomic.CompareAndSwapInt32(&a.inAction, 0, 1) {
log.Println("doLogin: skipped (action in progress)")
return
}
defer atomic.StoreInt32(&a.inAction, 0)
// Take a thread-safe snapshot of the current profile URL.
a.mu.Lock()
serverURL := a.curProfile.ControlURL
a.mu.Unlock()
if serverURL == "" {
serverURL = "https://vpn.softs.business"
}
log.Printf("doLogin: server=%s", sanitizeURLForLog(serverURL))
opCtx, opCancel := context.WithTimeout(a.bgCtx, 15*time.Second)
defer opCancel()
// Use Start() with UpdatePrefs like official CLI does
err := a.lc.Start(opCtx, ipn.Options{
UpdatePrefs: &ipn.Prefs{
ControlURL: serverURL,
WantRunning: true,
},
})
if err != nil {
log.Printf("doLogin: Start error: %v", err)
} else {
log.Println("doLogin: Start OK")
}
// Then trigger interactive login to get BrowseToURL
if err := a.lc.StartLoginInteractive(opCtx); err != nil {
log.Printf("doLogin: StartLoginInteractive error: %v", err)
} else {
log.Println("doLogin: StartLoginInteractive OK")
}
// Poll for AuthURL and open browser
a.openAuthURL()
}
func (a *app) addServer() {
if !atomic.CompareAndSwapInt32(&a.inAction, 0, 1) {
log.Println("addServer: skipped (action in progress)")
return
}
defer atomic.StoreInt32(&a.inAction, 0)
serverURL := inputDialog("Add Server", "Enter the control server URL:", "https://vpn.softs.business")
if serverURL == "" {
log.Println("addServer: cancelled")
return
}
if !strings.HasPrefix(serverURL, "http://") && !strings.HasPrefix(serverURL, "https://") {
serverURL = "https://" + serverURL
}
if err := validateControlURL(serverURL); err != nil {
log.Printf("addServer: rejected URL %q: %v", serverURL, err)
showError(fmt.Sprintf("Invalid server URL: %v", err))
return
}
log.Printf("addServer: url=%s", sanitizeURLForLog(serverURL))
// Optional pre-auth key. A Headscale server without OIDC configured cannot
// hand out an interactive browser-login URL, so allow key-based
// registration. Leave blank to use the interactive (browser) login flow.
authKey := inputDialog("Auth Key (optional)", "Pre-auth key, or leave blank for browser login:", "")
useAuthKey := authKey != ""
opCtx, opCancel := context.WithTimeout(a.bgCtx, 30*time.Second)
defer opCancel()
// Create new empty profile for this server
if err := a.lc.SwitchToEmptyProfile(opCtx); err != nil {
log.Printf("addServer: SwitchToEmptyProfile error: %v", err)
}
// Use Start() with UpdatePrefs like the official CLI does. When an auth key
// is supplied, Start performs the key-based registration itself and no
// browser flow is needed.
opts := ipn.Options{
UpdatePrefs: &ipn.Prefs{
ControlURL: serverURL,
WantRunning: true,
},
}
if useAuthKey {
opts.AuthKey = authKey
}
if err := a.lc.Start(opCtx, opts); err != nil {
log.Printf("addServer: Start error: %v", err)
showError(fmt.Sprintf("Failed to connect to server: %v", err))
return
}
log.Println("addServer: Start OK")
if useAuthKey {
// Key-based registration completes via Start(); kick the state machine
// once and let the IPN bus watcher reflect the result. No browser.
if err := a.lc.StartLoginInteractive(opCtx); err != nil {
log.Printf("addServer: StartLoginInteractive (authkey) error: %v", err)
}
log.Println("addServer: done (auth key)")
return
}
// Interactive (browser) login: trigger and wait for the BrowseToURL.
if err := a.lc.StartLoginInteractive(opCtx); err != nil {
log.Printf("addServer: StartLoginInteractive error: %v", err)
} else {
log.Println("addServer: StartLoginInteractive OK")
}
a.openAuthURL()
log.Println("addServer: done")
}
// sanitizeURLForLog returns just the host of a URL so we don't spill auth
// tokens or query strings into tray.log. Returns "invalid-url" on parse error.
func sanitizeURLForLog(s string) string {
if s == "" {
return ""
}
u, err := url.Parse(s)
if err != nil || u.Host == "" {
return "invalid-url"
}
return u.Host
}
// validateAuthURL ensures URL is safe to open with rundll32.
// Only allows HTTPS to whitelisted domains.
func validateAuthURL(urlStr string) error {
if urlStr == "" {
return fmt.Errorf("empty URL")
}
u, err := url.Parse(urlStr)
if err != nil {
return fmt.Errorf("invalid URL: %v", err)
}
// The auth URL is produced by our own trusted local tailscaled (via
// Status.AuthURL / BrowseToURL), so we only require that it is HTTPS with a
// host. We deliberately do NOT hardcode a single allowed domain: interactive
// login can legitimately redirect to the control server OR to an external
// OIDC identity provider on a different host, and a fixed whitelist silently
// blocked those (the browser then never opened).
if u.Scheme != "https" {
return fmt.Errorf("only HTTPS allowed, got %s", u.Scheme)
}
if u.Host == "" {
return fmt.Errorf("URL missing host")
}
return nil
}
// validateControlURL blocks private/loopback/cloud-metadata targets so the
// Add Server dialog can't be used to aim the client at internal resources.
// Host may be an IP literal or hostname; DNS is resolved and every returned
// address is checked.
func validateControlURL(urlStr string) error {
if urlStr == "" {
return fmt.Errorf("empty URL")
}
u, err := url.Parse(urlStr)
if err != nil {
return fmt.Errorf("invalid URL: %v", err)
}
if u.Scheme != "https" {
return fmt.Errorf("only HTTPS allowed, got %s", u.Scheme)
}
host := u.Hostname()
if host == "" {
return fmt.Errorf("URL missing host")
}
// AWS/GCP/Azure IMDS — block by literal match even if user wrote a name.
if host == "169.254.169.254" || strings.EqualFold(host, "metadata.google.internal") {
return fmt.Errorf("cloud metadata host not allowed")
}
var ips []net.IP
if ip := net.ParseIP(host); ip != nil {
ips = []net.IP{ip}
} else {
resolved, err := net.LookupIP(host)
if err != nil {
return fmt.Errorf("DNS lookup failed for %s: %v", host, err)
}
ips = resolved
}
for _, ip := range ips {
if ip.IsLoopback() {
return fmt.Errorf("loopback address not allowed: %s", ip)
}
if ip.IsPrivate() {
return fmt.Errorf("private address not allowed: %s", ip)
}
if ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
return fmt.Errorf("link-local address not allowed: %s", ip)
}
if ip.IsUnspecified() {
return fmt.Errorf("unspecified address not allowed: %s", ip)
}
}
return nil
}
// openAuthURL polls Status.AuthURL and opens the browser when available.
// Uses bgCtx (not the caller's short operation context) because the auth URL
// can take longer than 5s to materialise on a cold tailscaled.
func (a *app) openAuthURL() {
deadline := time.Now().Add(30 * time.Second)
for time.Now().Before(deadline) {
if a.bgCtx.Err() != nil {
return
}
ctx, cancel := context.WithTimeout(a.bgCtx, 3*time.Second)
st, err := a.lc.Status(ctx)
cancel()
if err != nil {
log.Printf("openAuthURL: Status error (will retry): %v", err)
time.Sleep(500 * time.Millisecond)
continue
}
if st.AuthURL != "" {
// Validate URL before opening
if err := validateAuthURL(st.AuthURL); err != nil {
log.Printf("openAuthURL: validation failed: %v", err)
showError(fmt.Sprintf("Invalid authentication URL: %v", err))
return
}
log.Printf("openAuthURL: opening host=%s", sanitizeURLForLog(st.AuthURL))
if err := exec.Command("rundll32", "url.dll,FileProtocolHandler", st.AuthURL).Start(); err != nil {
log.Printf("openAuthURL: exec error: %v", err)
showError(fmt.Sprintf("Failed to open browser: %v", err))
}
return
}
if st.BackendState == "Running" {
log.Println("openAuthURL: already running, no auth needed")
return
}
time.Sleep(500 * time.Millisecond)
}
log.Println("openAuthURL: timed out waiting for AuthURL")
}
// ── IPN Bus Watcher ─────────────────────────────────────
func (a *app) watchIPNBus() {
defer func() {
if r := recover(); r != nil {
log.Printf("PANIC in watchIPNBus (outer): %v", r)
}
}()
for {
// Reset prevState before each connection attempt so stale state
// from a prior session does not trigger false transitions.
a.mu.Lock()
a.prevState = ""
a.mu.Unlock()
func() {
defer func() {
if r := recover(); r != nil {
log.Printf("PANIC in watchIPNBusInner: %v", r)
}
}()
if err := a.watchIPNBusInner(); err != nil {
log.Printf("watchIPNBus error: %v", err)
}
}()
// After the bus dies, force a status refresh + rebuild so the UI
// does not freeze on a stale snapshot while we wait to reconnect.
a.updateState()
a.triggerRebuild()
select {
case <-a.bgCtx.Done():
return
case <-time.After(3 * time.Second):
}
}
}
func (a *app) watchIPNBusInner() error {
watcher, err := a.lc.WatchIPNBus(a.bgCtx, 0)
if err != nil {
return err
}
defer watcher.Close()
for {
select {
case <-a.bgCtx.Done():
return nil
default:
}
n, err := watcher.Next()
if err != nil {
return err
}
// Track state transitions for logging.
if n.State != nil {
newState := n.State.String()
a.mu.Lock()
prev := a.prevState
a.prevState = newState
a.mu.Unlock()
if prev != "" && prev != newState {
log.Printf("State transition: %s -> %s", prev, newState)
}
// NOTE: We intentionally do NOT auto-logout on Running->NeedsLogin.
// A NeedsLogin state can be transient (network hiccup, key rotation,
// server maintenance). Calling Logout() here would deregister the node
// entirely, forcing full re-authentication. Let the UI reflect the state
// and allow the user to decide.
}
if n.State != nil || n.Prefs != nil {
a.triggerRebuild()
}
if urlPtr := n.BrowseToURL; urlPtr != nil {
log.Printf("BrowseToURL: host=%s", sanitizeURLForLog(*urlPtr))
if err := validateAuthURL(*urlPtr); err != nil {
log.Printf("BrowseToURL: validation failed: %v", err)
showError(fmt.Sprintf("Invalid authentication URL: %v", err))
continue
}
if err := exec.Command("rundll32", "url.dll,FileProtocolHandler", *urlPtr).Start(); err != nil {
log.Printf("BrowseToURL: exec error: %v", err)
showError(fmt.Sprintf("Failed to open browser: %v", err))
}
}
}
}
func (a *app) triggerRebuild() {
select {
case a.rebuildCh <- struct{}{}:
default:
}
}
// ── Profile & Path Helpers ──────────────────────────────
func profileTitle(p ipn.LoginProfile) string {
name := p.Name
if name == "" {
name = "(new profile)"
}
if p.ControlURL != "" {
u := strings.TrimPrefix(p.ControlURL, "https://")
u = strings.TrimPrefix(u, "http://")
u = strings.TrimSuffix(u, "/")
name += " @ " + u
}
return name
}
// ── Clipboard ───────────────────────────────────────────
func copyToClipboard(text string) {
runtime.LockOSThread()
defer runtime.UnlockOSThread()
openClipboard := windows.NewLazySystemDLL("user32.dll").NewProc("OpenClipboard")
closeClipboard := windows.NewLazySystemDLL("user32.dll").NewProc("CloseClipboard")
emptyClipboard := windows.NewLazySystemDLL("user32.dll").NewProc("EmptyClipboard")
setClipboardData := windows.NewLazySystemDLL("user32.dll").NewProc("SetClipboardData")
globalAlloc := windows.NewLazySystemDLL("kernel32.dll").NewProc("GlobalAlloc")
globalLock := windows.NewLazySystemDLL("kernel32.dll").NewProc("GlobalLock")
globalUnlock := windows.NewLazySystemDLL("kernel32.dll").NewProc("GlobalUnlock")
r, _, _ := openClipboard.Call(0)
if r == 0 {
return
}
defer closeClipboard.Call()
emptyClipboard.Call()
utf16, _ := windows.UTF16FromString(text)
size := len(utf16) * 2
const gmemMoveable = 0x0002
hMem, _, _ := globalAlloc.Call(gmemMoveable, uintptr(size))
if hMem == 0 {
return
}
ptr, _, _ := globalLock.Call(hMem)
if ptr == 0 {
return
}
for i, v := range utf16 {
*(*uint16)(unsafe.Pointer(ptr + uintptr(i*2))) = v
}
globalUnlock.Call(hMem)
const cfUnicodeText = 13
setClipboardData.Call(cfUnicodeText, hMem)
log.Printf("Copied to clipboard: %s", text)
}
// ── Windows Dialogs ─────────────────────────────────────
var (
user32 = windows.NewLazySystemDLL("user32.dll")
pMessageBoxW = user32.NewProc("MessageBoxW")
pDialogBoxIndirectParamW = user32.NewProc("DialogBoxIndirectParamW")
pEndDialog = user32.NewProc("EndDialog")
pGetDlgItemTextW = user32.NewProc("GetDlgItemTextW")
pSetDlgItemTextW = user32.NewProc("SetDlgItemTextW")
pGetDlgItem = user32.NewProc("GetDlgItem")
pSendMessageW = user32.NewProc("SendMessageW")
pSetForegroundWindow = user32.NewProc("SetForegroundWindow")
)
var (
dlgInputResult string
dlgDefaultText string // prefilled text for the next inputDialog call
inputDlgCb = windows.NewCallback(inputDlgProcFn)
)
func showError(msg string) {
log.Printf("ERROR: %s", msg)
title, _ := windows.UTF16PtrFromString("Tailscale-Custom")
text, _ := windows.UTF16PtrFromString(msg)
pMessageBoxW.Call(0, uintptr(unsafe.Pointer(text)), uintptr(unsafe.Pointer(title)),
uintptr(0x00000000|0x00000010))
}
func inputDlgProcFn(hwnd, msg, wParam, lParam uintptr) uintptr {
const (
wmInitDialog = 0x0110
wmCommand = 0x0111
wmClose = 0x0010
idEdit = 101
emSetSel = 0x00B1
)
switch msg {
case wmInitDialog:
pSetForegroundWindow.Call(hwnd)
defText, _ := windows.UTF16PtrFromString(dlgDefaultText)
pSetDlgItemTextW.Call(hwnd, idEdit, uintptr(unsafe.Pointer(defText)))
editHwnd, _, _ := pGetDlgItem.Call(hwnd, idEdit)
// Select the whole prefilled text so the user can overwrite or keep it.
pSendMessageW.Call(editHwnd, emSetSel, 0, ^uintptr(0))
return 1
case wmCommand:
switch int(wParam & 0xFFFF) {
case 1: // IDOK
buf := make([]uint16, 512)
pGetDlgItemTextW.Call(hwnd, idEdit, uintptr(unsafe.Pointer(&buf[0])), 512)
dlgInputResult = windows.UTF16ToString(buf)
pEndDialog.Call(hwnd, 1)
case 2: // IDCANCEL
dlgInputResult = ""
pEndDialog.Call(hwnd, 0)
}
case wmClose:
dlgInputResult = ""
pEndDialog.Call(hwnd, 0)
}
return 0
}
func inputDialog(title, prompt, defaultText string) string {
log.Printf("inputDialog: title=%q", title)
runtime.LockOSThread()
defer runtime.UnlockOSThread()
dlgInputResult = ""
dlgDefaultText = defaultText
tmpl := buildInputDialogTemplate(title, prompt)
ret, _, _ := pDialogBoxIndirectParamW.Call(
0,
uintptr(unsafe.Pointer(&tmpl[0])),
0,
inputDlgCb,
0,
)
log.Printf("inputDialog: ret=%d", ret)
// ret==0 means the dialog was cancelled/closed. An empty (but OK'd) result
// is returned as "" too; callers treat "" as "no value / cancelled".
if ret == 0 {
return ""
}
return strings.TrimSpace(dlgInputResult)
}
type dlgBuilder struct{ buf []byte }
func (d *dlgBuilder) align(n int) {
for len(d.buf)%n != 0 {
d.buf = append(d.buf, 0)
}
}
func (d *dlgBuilder) w16(v uint16) {
d.buf = append(d.buf, byte(v), byte(v>>8))
}
func (d *dlgBuilder) w32(v uint32) {
d.buf = append(d.buf, byte(v), byte(v>>8), byte(v>>16), byte(v>>24))
}
func (d *dlgBuilder) ws16(v int16) { d.w16(uint16(v)) }
func (d *dlgBuilder) wstr(s string) {
for _, c := range s {
d.w16(uint16(c))
}
d.w16(0)
}
func buildInputDialogTemplate(title, prompt string) []byte {
const (
wsChild = 0x40000000
wsVisible = 0x10000000
wsCaption = 0x00C00000
wsSysMenu = 0x00080000
wsPopup = 0x80000000
bsPushBtn = 0x00000000
wsTabStop = 0x00010000
wsBorder = 0x00800000
esAutoHS = 0x00000080
dsSetFont = 0x00000040
dsModalFrm = 0x00000080
ds3DLook = 0x00000004
)
d := &dlgBuilder{}
// DLGTEMPLATE
d.w32(wsPopup | wsCaption | wsSysMenu | dsSetFont | dsModalFrm | ds3DLook)
d.w32(0)
d.w16(4)
d.ws16(0)
d.ws16(0)
d.ws16(280)
d.ws16(90)
d.w16(0)
d.w16(0)
d.wstr(title)
d.w16(9)
d.wstr("Segoe UI")
// Static label id=100
d.align(4)
d.w32(wsChild | wsVisible)
d.w32(0)
d.ws16(12)
d.ws16(12)
d.ws16(256)
d.ws16(14)
d.w16(100)
d.w16(0xFFFF)
d.w16(0x0082)
d.wstr(prompt)
d.w16(0)
// Edit id=101
d.align(4)
d.w32(wsChild | wsVisible | wsTabStop | wsBorder | esAutoHS)
d.w32(0)
d.ws16(12)
d.ws16(32)
d.ws16(256)
d.ws16(14)
d.w16(101)
d.w16(0xFFFF)
d.w16(0x0081)
d.w16(0)
d.w16(0)
// OK id=1
d.align(4)
d.w32(wsChild | wsVisible | wsTabStop | bsPushBtn)
d.w32(0)
d.ws16(150)
d.ws16(62)
d.ws16(55)
d.ws16(16)
d.w16(1)
d.w16(0xFFFF)
d.w16(0x0080)
d.wstr("Connect")
d.w16(0)
// Cancel id=2
d.align(4)
d.w32(wsChild | wsVisible | wsTabStop)
d.w32(0)
d.ws16(213)
d.ws16(62)
d.ws16(55)
d.ws16(16)
d.w16(2)
d.w16(0xFFFF)
d.w16(0x0080)
d.wstr("Cancel")
d.w16(0)
return d.buf
}