fix(tray): connect/login, logout, add-server auth-key; release v1.0.1
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

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>
This commit is contained in:
huanld
2026-05-31 07:52:28 +07:00
parent 0990478d9c
commit dba7b9ba50
4 changed files with 146 additions and 42 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs">
<Package Name="Tailscale Custom"
Manufacturer="SoftsBusiness"
Version="1.0.0.0"
Version="1.0.1.0"
UpgradeCode="{510A8C57-BA8F-4B9F-84E3-8E5C4E091054}"
Scope="perMachine">
<!-- Nhúng luôn dữ liệu vào MSI thay vì tách riêng ra file cab1.cab -->
<MediaTemplate EmbedCab="yes" />
<MediaTemplate EmbedCab="yes" CompressionLevel="high" />
<!-- Icon hiển thị trong Control Panel (Add/Remove Programs) -->
<Icon Id="TrayIcon.exe" SourceFile="dist\tailscale-tray.exe" />
Binary file not shown.
+84 -40
View File
@@ -185,6 +185,16 @@ func onClick(ctx context.Context, item *systray.MenuItem, fn func()) {
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:
}
}
}
}()
@@ -206,7 +216,11 @@ func (a *app) rebuild() {
state := a.backendState()
isRunning := state == "Running"
isNeedsLogin := state == "NeedsLogin"
// 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)
@@ -361,14 +375,26 @@ func (a *app) rebuild() {
logoutItem := systray.AddMenuItem("Logout", "Logout and deregister from server")
onClick(ctx, logoutItem, func() {
log.Println("action: Logout")
opCtx, opCancel := context.WithTimeout(a.bgCtx, 10*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")
}
// 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()
@@ -517,12 +543,11 @@ func (a *app) addServer() {
}
defer atomic.StoreInt32(&a.inAction, 0)
serverURL := inputDialog("Add Server", "Enter the control server URL:")
serverURL := inputDialog("Add Server", "Enter the control server URL:", "https://vpn.softs.business")
if serverURL == "" {
log.Println("addServer: cancelled")
return
}
serverURL = strings.TrimSpace(serverURL)
if !strings.HasPrefix(serverURL, "http://") && !strings.HasPrefix(serverURL, "https://") {
serverURL = "https://" + serverURL
}
@@ -533,7 +558,13 @@ func (a *app) addServer() {
}
log.Printf("addServer: url=%s", sanitizeURLForLog(serverURL))
opCtx, opCancel := context.WithTimeout(a.bgCtx, 15*time.Second)
// 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
@@ -541,27 +572,41 @@ func (a *app) addServer() {
log.Printf("addServer: SwitchToEmptyProfile error: %v", err)
}
// Use Start() with UpdatePrefs like official CLI does
err := a.lc.Start(opCtx, ipn.Options{
// 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 err != nil {
}
if useAuthKey {
opts.AuthKey = authKey
}
if err := a.lc.Start(opCtx, opts); err != nil {
log.Printf("addServer: Start error: %v", err)
} else {
log.Println("addServer: Start OK")
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
}
// Then trigger interactive login to get BrowseToURL
// 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")
}
// Poll for AuthURL and open browser
a.openAuthURL()
log.Println("addServer: done")
}
@@ -591,19 +636,17 @@ func validateAuthURL(urlStr string) error {
return fmt.Errorf("invalid URL: %v", err)
}
// Only allow HTTPS for remote URLs
// 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)
}
// Whitelist allowed domains (must be explicitly added)
allowedDomains := map[string]bool{
"vpn.softs.business": true,
// Add more trusted domains here if needed
}
if !allowedDomains[u.Host] {
return fmt.Errorf("domain not whitelisted: %s", u.Host)
if u.Host == "" {
return fmt.Errorf("URL missing host")
}
return nil
@@ -872,6 +915,7 @@ var (
var (
dlgInputResult string
dlgDefaultText string // prefilled text for the next inputDialog call
inputDlgCb = windows.NewCallback(inputDlgProcFn)
)
@@ -894,10 +938,11 @@ func inputDlgProcFn(hwnd, msg, wParam, lParam uintptr) uintptr {
switch msg {
case wmInitDialog:
pSetForegroundWindow.Call(hwnd)
defText, _ := windows.UTF16PtrFromString("https://vpn.softs.business")
defText, _ := windows.UTF16PtrFromString(dlgDefaultText)
pSetDlgItemTextW.Call(hwnd, idEdit, uintptr(unsafe.Pointer(defText)))
editHwnd, _, _ := pGetDlgItem.Call(hwnd, idEdit)
pSendMessageW.Call(editHwnd, emSetSel, 8, 8)
// 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) {
@@ -917,13 +962,14 @@ func inputDlgProcFn(hwnd, msg, wParam, lParam uintptr) uintptr {
return 0
}
func inputDialog(title, prompt string) string {
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(
@@ -934,15 +980,13 @@ func inputDialog(title, prompt string) string {
0,
)
log.Printf("inputDialog: result=%q ret=%d", dlgInputResult, ret)
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 ""
}
result := strings.TrimSpace(dlgInputResult)
if result == "" || result == "https://" {
return ""
}
return result
return strings.TrimSpace(dlgInputResult)
}
type dlgBuilder struct{ buf []byte }
+60
View File
@@ -0,0 +1,60 @@
# deploy-tray-1.0.1.ps1 — replace installed tray with the freshly built 1.0.1 binary.
# Only the tray changed (NoState/login, logout, add-server auth-key, popup-loop, auth-URL fixes),
# so the tailscaled service is left untouched (VPN stays up).
$ErrorActionPreference = "Stop"
$isAdmin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole(
[Security.Principal.WindowsBuiltInRole]::Administrator)
if (-not $isAdmin) {
$log = "$env:TEMP\deploy-tray-1.0.1-log.txt"
Remove-Item $log -ErrorAction SilentlyContinue
Start-Process powershell -Verb RunAs -Wait `
-ArgumentList "-ExecutionPolicy Bypass -File `"$PSCommandPath`""
Start-Sleep -Milliseconds 600
if (Test-Path $log) { Get-Content $log } else { Write-Warning "No log / UAC denied" }
exit
}
Start-Transcript -Path "$env:TEMP\deploy-tray-1.0.1-log.txt" -Force | Out-Null
$src = "C:\Users\huanld\tailscale\dist\tailscale-tray.exe"
$dest = "C:\Program Files (x86)\Tailscale-Custom\tailscale-tray.exe"
if (-not (Test-Path $src)) { Write-Error "Khong tim thay build moi: $src"; Stop-Transcript | Out-Null; exit 1 }
# Best-effort code signing (matches existing deploy scripts; tray runs fine unsigned too).
$cert = Get-ChildItem Cert:\LocalMachine\My -ErrorAction SilentlyContinue |
Where-Object { $_.Subject -match "Tailscale-Custom" -and $_.HasPrivateKey } |
Sort-Object NotAfter -Descending | Select-Object -First 1
if ($cert) {
try {
$r = Set-AuthenticodeSignature -FilePath $src -Certificate $cert -HashAlgorithm SHA256 -ErrorAction Stop
Write-Host "[sign] $($r.Status)"
} catch { Write-Host "[sign] SKIP: $($_.Exception.Message)" }
} else { Write-Host "[sign] no cert, deploying unsigned (giong ban cu)" }
Write-Host "==> Dung tray cu..."
Stop-Process -Name tailscale-tray -Force -ErrorAction SilentlyContinue
Start-Sleep -Seconds 1
Write-Host "==> Copy binary 1.0.1 -> Program Files (x86)..."
Copy-Item $src $dest -Force
$fi = Get-Item $dest
Write-Host " $dest"
Write-Host " $([math]::Round($fi.Length/1MB,2)) MB | $($fi.LastWriteTime)"
# Launch the tray in the interactive desktop session (not the elevated session 0
# this script may run in) via a one-shot scheduled task running as the logged-in user.
Write-Host "==> Khoi dong tray moi trong session nguoi dung..."
$activeUser = (Get-CimInstance Win32_ComputerSystem).UserName # DOMAIN\user of console session
$task = "TS-Custom-Tray-Deploy-Launch"
schtasks /Create /TN $task /TR "`"$dest`"" /SC ONCE /ST 23:59 /RL LIMITED /F /IT /RU $activeUser | Out-Null
schtasks /Run /TN $task | Out-Null
Start-Sleep -Seconds 3
schtasks /Delete /TN $task /F | Out-Null
$p = Get-Process tailscale-tray -ErrorAction SilentlyContinue | Select-Object -First 1
Write-Host " Started PID=$($p.Id) Path=$($p.Path)"
Write-Host "==> HOAN THANH (1.0.1)"
Stop-Transcript | Out-Null