From fe73a68a8f9200fea932e38f11b8a4fd56bf6077 Mon Sep 17 00:00:00 2001 From: Huan LD Date: Wed, 10 Jun 2026 14:38:06 +0700 Subject: [PATCH] feat(tray): Velopack auto-update + elevated service worker + Quit confirm - update.go: poll release feed, download nupkg, hand off to Update.exe - handle --veloapp-* lifecycle hooks and --ts-apply-service-update worker - sync service binaries from Velopack bundle into Program Files (x86) - Quit now requires Yes/No confirm (default No) so accidental close keeps tray --- cmd/tailscale-tray/main.go | 35 +++ cmd/tailscale-tray/update.go | 443 ++++++++++++++++++++++++++++++ cmd/tailscale-tray/update_test.go | 50 ++++ 3 files changed, 528 insertions(+) create mode 100644 cmd/tailscale-tray/update.go create mode 100644 cmd/tailscale-tray/update_test.go diff --git a/cmd/tailscale-tray/main.go b/cmd/tailscale-tray/main.go index 5871ac952..c96dcec88 100644 --- a/cmd/tailscale-tray/main.go +++ b/cmd/tailscale-tray/main.go @@ -46,6 +46,14 @@ type app struct { } func main() { + // Handle Velopack lifecycle hooks (--veloapp-*) and the elevated + // service-update worker before anything else: they are short-lived + // one-shot invocations that must run even while another tray instance is + // already active, so they precede the single-instance mutex. + if handleStartupArgs() { + return + } + // 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 @@ -88,6 +96,7 @@ func main() { a.updateState() go a.watchIPNBus() go a.periodicRefresh() + a.startAutoUpdater() systray.Run(a.onReady, a.onExit) } @@ -412,6 +421,14 @@ func (a *app) rebuild() { quit := systray.AddMenuItem("Quit", "Quit Tailscale-Custom Tray") onClick(ctx, quit, func() { log.Println("action: Quit") + // Treat the menu close as hide-only: require explicit Yes to actually + // quit, so accidental clicks keep the tray icon and session alive. + if !confirmYesNo("Tailscale-Custom", + "Exit Tailscale-Custom?\n\nThe VPN service will keep running in the background.\nClick No to keep the tray icon.") { + log.Println("action: Quit cancelled (stay in tray)") + return + } + log.Println("action: Quit confirmed") systray.Quit() }) @@ -927,6 +944,24 @@ func showError(msg string) { uintptr(0x00000000|0x00000010)) } +// confirmYesNo shows a modal Yes/No prompt and returns true on Yes. +// Default button is No so pressing Enter/Esc keeps the tray alive. +func confirmYesNo(title, msg string) bool { + const ( + mbYesNo = 0x00000004 + mbIconQuestion = 0x00000020 + mbDefButton2 = 0x00000100 + idYes = 6 + ) + titlePtr, _ := windows.UTF16PtrFromString(title) + textPtr, _ := windows.UTF16PtrFromString(msg) + r, _, _ := pMessageBoxW.Call(0, + uintptr(unsafe.Pointer(textPtr)), + uintptr(unsafe.Pointer(titlePtr)), + uintptr(mbYesNo|mbIconQuestion|mbDefButton2)) + return int(r) == idYes +} + func inputDlgProcFn(hwnd, msg, wParam, lParam uintptr) uintptr { const ( wmInitDialog = 0x0110 diff --git a/cmd/tailscale-tray/update.go b/cmd/tailscale-tray/update.go new file mode 100644 index 000000000..8e9fd7893 --- /dev/null +++ b/cmd/tailscale-tray/update.go @@ -0,0 +1,443 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +// Velopack-based auto-update for the Tailscale-Custom tray. +// +// Velopack manages the per-user app bundle (the tray + a copy of the service +// binaries) under %LocalAppData%\TailscaleCustom and can swap it without admin. +// tailscaled however runs as a LocalSystem Windows service out of a secure +// machine-wide directory (serviceInstallDir) — it must NOT run from the +// user-writable Velopack dir, which would be a privilege-escalation hole. So +// after Velopack updates the bundle we copy the new service binaries into +// serviceInstallDir via a short elevated (UAC) worker run of this same exe. +// +// Everything here no-ops gracefully when the tray is not running from a +// Velopack install (e.g. the MSI/dev deployment), so the same binary works in +// both layouts. +package main + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "os" + "os/exec" + "path/filepath" + "regexp" + "strconv" + "strings" + "time" + "unsafe" + + "golang.org/x/sys/windows" +) + +const ( + // updateFeedURL is the base URL of the Velopack release feed; it must serve + // releases..json and the *-full.nupkg files next to it. + updateFeedURL = "https://vpn.softs.business/updates/" + updateChannel = "win" + + // serviceName is the Windows service that runs tailscaled. + serviceName = "Tailscale-Custom" + + // serviceInstallDir is the secure machine-wide home of the service + // binaries (kept out of the per-user Velopack dir on purpose). + serviceInstallDir = `C:\Program Files (x86)\Tailscale-Custom` + + updateCheckInterval = 6 * time.Hour + updateInitialDelay = 90 * time.Second +) + +// serviceBins are copied from the Velopack bundle into serviceInstallDir. +// tailscaled is the service; tailscale.exe and wintun.dll must match it. +var serviceBins = []string{"tailscaled.exe", "tailscale.exe", "wintun.dll"} + +// handleStartupArgs processes Velopack lifecycle hooks and our internal +// elevated worker mode. It returns true when the process handled a one-shot +// command and should exit instead of showing the tray UI. +// +// Velopack invokes the main exe during its lifecycle, e.g. +// +// tailscale-tray.exe --veloapp-install 1.0.2 +// +// Our elevated service swap re-invokes this same exe as +// +// tailscale-tray.exe --ts-apply-service-update +func handleStartupArgs() (exit bool) { + if len(os.Args) < 2 { + return false + } + switch os.Args[1] { + case "--ts-apply-service-update": + // Elevated worker: replace the machine-wide service binaries. + if err := applyServiceUpdate(exeDir()); err != nil { + log.Printf("applyServiceUpdate: %v", err) + } + return true + case "--veloapp-install", "--veloapp-updated": + log.Printf("velopack hook: %s", os.Args[1]) + syncServiceBinaries(true) + return true + case "--veloapp-obsolete": + return true // a previous version is being retired; nothing to do + case "--veloapp-uninstall": + log.Printf("velopack hook: uninstall") + stopService() + exec.Command("sc.exe", "delete", serviceName).Run() // best-effort + return true + } + return false +} + +// exeDir is the directory of the running executable (the Velopack current\ dir +// when installed via Velopack). +func exeDir() string { + p, err := os.Executable() + if err != nil { + return "." + } + return filepath.Dir(p) +} + +// rootDir is the Velopack application root (parent of current\), where +// Update.exe and packages\ live. +func rootDir() string { return filepath.Dir(exeDir()) } + +// ── Auto-update loop ───────────────────────────────────────────── + +func (a *app) startAutoUpdater() { + go func() { + defer func() { + if r := recover(); r != nil { + log.Printf("PANIC in autoUpdater: %v", r) + } + }() + select { + case <-a.bgCtx.Done(): + return + case <-time.After(updateInitialDelay): + } + for { + if err := checkAndApplyUpdate(); err != nil { + log.Printf("autoUpdate: %v", err) + } + select { + case <-a.bgCtx.Done(): + return + case <-time.After(updateCheckInterval): + } + } + }() +} + +type releaseFeed struct { + Assets []struct { + Version string `json:"Version"` + Type string `json:"Type"` + FileName string `json:"FileName"` + SHA256 string `json:"SHA256"` + Size int64 `json:"Size"` + } `json:"Assets"` +} + +func checkAndApplyUpdate() error { + // Only meaningful inside a Velopack install (Update.exe present). + updateExe := filepath.Join(rootDir(), "Update.exe") + if _, err := os.Stat(updateExe); err != nil { + return nil + } + cur, err := installedVersion() + if err != nil { + return fmt.Errorf("read installed version: %w", err) + } + feed, err := fetchFeed() + if err != nil { + return err + } + var latestVer, fileName string + for _, as := range feed.Assets { + if as.Type != "Full" { + continue + } + if latestVer == "" || compareVersions(as.Version, latestVer) > 0 { + latestVer, fileName = as.Version, as.FileName + } + } + if latestVer == "" || compareVersions(latestVer, cur) <= 0 { + log.Printf("autoUpdate: up to date (installed=%s latest=%s)", cur, latestVer) + return nil + } + log.Printf("autoUpdate: update available %s -> %s", cur, latestVer) + + pkgURL := strings.TrimRight(updateFeedURL, "/") + "/" + fileName + pkgPath := filepath.Join(rootDir(), "packages", fileName) + if err := downloadFile(pkgURL, pkgPath); err != nil { + return fmt.Errorf("download %s: %w", fileName, err) + } + + // Update.exe waits for us to exit, swaps current\, then restarts the tray. + cmd := exec.Command(updateExe, "apply", "--silent", + "--waitPid", strconv.Itoa(os.Getpid()), "--package", pkgPath) + if err := cmd.Start(); err != nil { + return fmt.Errorf("start Update.exe apply: %w", err) + } + log.Printf("autoUpdate: applying %s; exiting for Update.exe", latestVer) + os.Exit(0) + return nil +} + +func fetchFeed() (*releaseFeed, error) { + url := strings.TrimRight(updateFeedURL, "/") + "/releases." + updateChannel + ".json" + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Get(url) + if err != nil { + return nil, fmt.Errorf("fetch feed: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("feed HTTP %d", resp.StatusCode) + } + var f releaseFeed + if err := json.NewDecoder(resp.Body).Decode(&f); err != nil { + return nil, fmt.Errorf("decode feed: %w", err) + } + return &f, nil +} + +// installedVersion reads the current bundle's version from sq.version. +func installedVersion() (string, error) { + data, err := os.ReadFile(filepath.Join(exeDir(), "sq.version")) + if err != nil { + return "", err + } + m := regexp.MustCompile(`([^<]+)`).FindSubmatch(data) + if m == nil { + return "", fmt.Errorf("no in sq.version") + } + return string(m[1]), nil +} + +// compareVersions compares dotted numeric versions (e.g. "1.0.10" vs "1.0.2"). +// Returns -1, 0 or +1. +func compareVersions(a, b string) int { + as, bs := strings.Split(a, "."), strings.Split(b, ".") + for i := 0; i < len(as) || i < len(bs); i++ { + var ai, bi int + if i < len(as) { + ai, _ = strconv.Atoi(strings.TrimSpace(as[i])) + } + if i < len(bs) { + bi, _ = strconv.Atoi(strings.TrimSpace(bs[i])) + } + if ai != bi { + if ai < bi { + return -1 + } + return 1 + } + } + return 0 +} + +func downloadFile(url, dst string) error { + if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { + return err + } + client := &http.Client{Timeout: 10 * time.Minute} + resp, err := client.Get(url) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("HTTP %d", resp.StatusCode) + } + tmp := dst + ".part" + f, err := os.Create(tmp) + if err != nil { + return err + } + if _, err := io.Copy(f, resp.Body); err != nil { + f.Close() + return err + } + if err := f.Close(); err != nil { + return err + } + return os.Rename(tmp, dst) +} + +// ── Service binary syncing (elevated) ──────────────────────────── + +// syncServiceBinaries swaps the machine-wide service binaries when the bundle +// next to this exe differs from what's installed. allowElevate gates the UAC +// prompt. +func syncServiceBinaries(allowElevate bool) { + upToDate, err := serviceBinsUpToDate(exeDir()) + if err != nil { + log.Printf("syncServiceBinaries: %v", err) + return + } + if upToDate { + log.Printf("syncServiceBinaries: service already current") + return + } + if !allowElevate { + return + } + log.Printf("syncServiceBinaries: binaries differ, elevating to swap") + if err := runElevated("--ts-apply-service-update"); err != nil { + log.Printf("syncServiceBinaries: elevate: %v", err) + } +} + +// serviceBinsUpToDate reports whether every serviceBin in serviceInstallDir +// matches (by SHA-256) the copy in srcDir. +func serviceBinsUpToDate(srcDir string) (bool, error) { + for _, b := range serviceBins { + src, err := fileSHA256(filepath.Join(srcDir, b)) + if err != nil { + return false, fmt.Errorf("hash bundle %s: %w", b, err) + } + dst, err := fileSHA256(filepath.Join(serviceInstallDir, b)) + if err != nil || src != dst { + return false, nil + } + } + return true, nil +} + +// applyServiceUpdate runs elevated: stop the service, copy the bundled service +// binaries into serviceInstallDir, (re)register the service, start it. +func applyServiceUpdate(srcDir string) error { + log.Printf("applyServiceUpdate from %s", srcDir) + stopService() + time.Sleep(3 * time.Second) // let the service release file locks + if err := os.MkdirAll(serviceInstallDir, 0o755); err != nil { + return err + } + for _, b := range serviceBins { + if err := copyFile(filepath.Join(srcDir, b), filepath.Join(serviceInstallDir, b)); err != nil { + return fmt.Errorf("copy %s: %w", b, err) + } + log.Printf("applyServiceUpdate: copied %s", b) + } + ensureServiceRegistered() + startService() + return nil +} + +func ensureServiceRegistered() { + if err := exec.Command("sc.exe", "query", serviceName).Run(); err == nil { + return // already exists + } + bin := filepath.Join(serviceInstallDir, "tailscaled.exe") + exec.Command("sc.exe", "create", serviceName, + "binPath=", bin, "start=", "auto", "obj=", "LocalSystem", + "DisplayName=", "Tailscale-Custom").Run() + exec.Command("sc.exe", "description", serviceName, "Tailscale Custom VPN Service").Run() +} + +func stopService() { exec.Command("sc.exe", "stop", serviceName).Run() } +func startService() { exec.Command("sc.exe", "start", serviceName).Run() } + +// ── Helpers ────────────────────────────────────────────────────── + +func fileSHA256(path string) (string, error) { + f, err := os.Open(path) + if err != nil { + return "", err + } + defer f.Close() + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + return "", err + } + return hex.EncodeToString(h.Sum(nil)), nil +} + +func copyFile(src, dst string) error { + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + tmp := dst + ".new" + out, err := os.Create(tmp) + if err != nil { + return err + } + if _, err := io.Copy(out, in); err != nil { + out.Close() + return err + } + if err := out.Close(); err != nil { + return err + } + return os.Rename(tmp, dst) +} + +// runElevated re-launches this exe with arg via ShellExecuteEx "runas" (UAC) +// and waits for the elevated process to finish. +func runElevated(arg string) error { + self, err := os.Executable() + if err != nil { + return err + } + verb, _ := windows.UTF16PtrFromString("runas") + file, _ := windows.UTF16PtrFromString(self) + params, _ := windows.UTF16PtrFromString(arg) + + info := shellExecuteInfo{ + fMask: seeMaskNoCloseProcess, + lpVerb: verb, + lpFile: file, + lpParameters: params, + nShow: swHide, + } + info.cbSize = uint32(unsafe.Sizeof(info)) + + r, _, callErr := procShellExecuteExW.Call(uintptr(unsafe.Pointer(&info))) + if r == 0 { + return fmt.Errorf("ShellExecuteEx: %v", callErr) + } + if info.hProcess != 0 { + windows.WaitForSingleObject(info.hProcess, windows.INFINITE) + windows.CloseHandle(info.hProcess) + } + return nil +} + +var ( + shell32 = windows.NewLazySystemDLL("shell32.dll") + procShellExecuteExW = shell32.NewProc("ShellExecuteExW") +) + +const ( + seeMaskNoCloseProcess = 0x00000040 + swHide = 0 +) + +// shellExecuteInfo mirrors SHELLEXECUTEINFOW. +type shellExecuteInfo struct { + cbSize uint32 + fMask uint32 + hwnd uintptr + lpVerb *uint16 + lpFile *uint16 + lpParameters *uint16 + lpDirectory *uint16 + nShow int32 + hInstApp uintptr + lpIDList uintptr + lpClass *uint16 + hkeyClass uintptr + dwHotKey uint32 + hIconOrMonitor uintptr + hProcess windows.Handle +} diff --git a/cmd/tailscale-tray/update_test.go b/cmd/tailscale-tray/update_test.go new file mode 100644 index 000000000..0e3e67462 --- /dev/null +++ b/cmd/tailscale-tray/update_test.go @@ -0,0 +1,50 @@ +// Copyright (c) Tailscale Inc & contributors +// SPDX-License-Identifier: BSD-3-Clause + +package main + +import "testing" + +func TestCompareVersions(t *testing.T) { + cases := []struct { + a, b string + want int + }{ + {"1.0.2", "1.0.1", 1}, + {"1.0.1", "1.0.2", -1}, + {"1.0.2", "1.0.2", 0}, + {"1.0.10", "1.0.2", 1}, // numeric, not lexical + {"1.1.0", "1.0.9", 1}, + {"2.0.0", "1.99.99", 1}, + } + for _, c := range cases { + if got := compareVersions(c.a, c.b); got != c.want { + t.Errorf("compareVersions(%q,%q)=%d want %d", c.a, c.b, got, c.want) + } + } +} + +// TestFetchLiveFeed verifies the deployed update feed is reachable and parses +// into the shape the updater expects. Network test; skipped in -short mode. +func TestFetchLiveFeed(t *testing.T) { + if testing.Short() { + t.Skip("network") + } + feed, err := fetchFeed() + if err != nil { + t.Fatalf("fetchFeed: %v", err) + } + var full int + for _, a := range feed.Assets { + if a.Type == "Full" { + full++ + if a.Version == "" || a.FileName == "" || a.SHA256 == "" { + t.Errorf("incomplete asset: %+v", a) + } + t.Logf("feed Full asset: version=%s file=%s size=%d", a.Version, a.FileName, a.Size) + } + } + if full == 0 { + t.Fatal("feed has no Full assets") + } +}