diff --git a/Setup.wxs b/Setup.wxs index 333816ff3..715810408 100644 --- a/Setup.wxs +++ b/Setup.wxs @@ -1,12 +1,12 @@ - + diff --git a/Tailscale-Custom-Setup.msi b/Tailscale-Custom-Setup.msi index fc12b1ff7..c991ba59e 100644 Binary files a/Tailscale-Custom-Setup.msi and b/Tailscale-Custom-Setup.msi differ diff --git a/cmd/tailscale-tray/main.go b/cmd/tailscale-tray/main.go index 81287de0b..5871ac952 100644 --- a/cmd/tailscale-tray/main.go +++ b/cmd/tailscale-tray/main.go @@ -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 } diff --git a/deploy-tray-1.0.1.ps1 b/deploy-tray-1.0.1.ps1 new file mode 100644 index 000000000..0c82bad38 --- /dev/null +++ b/deploy-tray-1.0.1.ps1 @@ -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