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
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.<channel>.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(`<version>([^<]+)</version>`).FindSubmatch(data)
|
||||
if m == nil {
|
||||
return "", fmt.Errorf("no <version> 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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user