Files
tailscale/tstest/integration/service_windows.go
T
yaruk-byteandGitHub def265083b tstest/integration: run a smoke test against a Windows tailscaled service (#20382)
* tstest/integration: run a test against a real Windows tailscaled service

Updates #20381

Signed-off-by: Yaruk Asghar <yaruk@tailscale.com>

* tstest/integration: serialize Windows service tests and clean up state

Updates #20381

Signed-off-by: Yaruk Asghar <yaruk@tailscale.com>

* tstest/integration: address review feedback on Windows service tests

Updates #20381

Signed-off-by: Yaruk Asghar <yaruk@tailscale.com>

* tstest/integration: use background context for service teardown

Updates #20381

Signed-off-by: Yaruk Asghar <yaruk@tailscale.com>

* tstest/integration: run Windows service test via the normal windows CI run

Updates #20381

Signed-off-by: Yaruk Asghar <yaruk@tailscale.com>

* tstest/integration: address review feedback on Windows service tests

Updates #20381

Signed-off-by: Yaruk Asghar <yaruk@tailscale.com>

---------

Signed-off-by: Yaruk Asghar <yaruk@tailscale.com>
2026-07-17 12:29:12 -07:00

271 lines
7.5 KiB
Go

// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: BSD-3-Clause
//go:build windows
package integration
import (
"archive/zip"
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
"golang.org/x/sys/windows/svc"
"golang.org/x/sys/windows/svc/mgr"
"tailscale.com/tsconst/wintun"
)
// serviceName is the Windows service tailscaled installs itself as.
const serviceName = "Tailscale"
// startWindowsServiceDaemon installs and starts tailscaled as a Windows service.
func (n *TestNode) startWindowsServiceDaemon() *Daemon {
t := n.env.t
t.Helper()
// A pre-existing Tailscale service means this isn't a disposable/CI machine;
// fail rather than uninstall it. Stale state is wiped below, not fatal.
if serviceExists(t) {
t.Fatal("existing Tailscale service found; run only on a disposable/CI machine")
}
n.cleanupServiceState()
stageWintun(t, filepath.Dir(n.env.daemon))
n.writeServiceEnvFile()
if out, err := exec.CommandContext(t.Context(), n.env.daemon, "install-system-daemon").CombinedOutput(); err != nil {
t.Fatalf("install-system-daemon: %v\n%s", err, out)
}
// Teardown (LIFO): stop, uninstall, then wipe state for the next test.
t.Cleanup(func() {
n.stopService()
n.uninstallService()
n.cleanupServiceState()
})
n.startService()
n.waitServiceReady(90 * time.Second)
return &Daemon{svc: n}
}
// startService starts the service and waits for the SCM to report it Running.
func (n *TestNode) startService() {
t := n.env.t
t.Helper()
m := connectSCM(t)
defer m.Disconnect()
s, err := m.OpenService(serviceName)
if err != nil {
t.Fatalf("open service %q: %v", serviceName, err)
}
defer s.Close()
if err := s.Start(); err != nil {
t.Fatalf("start service %q: %v", serviceName, err)
}
n.waitServiceState(s, svc.Running, 60*time.Second)
}
// stopService requests a stop and waits until the service is Stopped, failing
// the test if it doesn't stop in time.
func (n *TestNode) stopService() {
t := n.env.t
t.Helper()
m := connectSCM(t)
defer m.Disconnect()
s, err := m.OpenService(serviceName)
if err != nil {
return // not installed; nothing to stop
}
defer s.Close()
st, err := s.Query()
if err != nil {
t.Fatalf("query service %q: %v", serviceName, err)
}
if st.State == svc.Stopped {
return
}
if _, err := s.Control(svc.Stop); err != nil {
t.Fatalf("stop service %q: %v", serviceName, err)
}
n.waitServiceState(s, svc.Stopped, 60*time.Second)
}
// uninstallService removes the service via tailscaled's uninstall-system-daemon
// and waits until it's gone; a missing service is fine.
func (n *TestNode) uninstallService() {
t := n.env.t
t.Helper()
if !serviceExists(t) {
return
}
// Not t.Context(): this also runs from t.Cleanup, where it's canceled.
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if out, err := exec.CommandContext(ctx, n.env.daemon, "uninstall-system-daemon").CombinedOutput(); err != nil {
t.Fatalf("uninstall-system-daemon: %v\n%s", err, out)
}
n.waitServiceGone(30 * time.Second)
}
// writeServiceEnvFile writes the harness env to the file tailscaled reads at
// startup, since a service doesn't inherit the test process's environment.
func (n *TestNode) writeServiceEnvFile() {
t := n.env.t
t.Helper()
dir := serviceStateDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("creating %s: %v", dir, err)
}
dst := filepath.Join(dir, "tailscaled-env.txt")
body := strings.Join(n.daemonEnv("windows"), "\n") + "\n"
if err := os.WriteFile(dst, []byte(body), 0o644); err != nil {
t.Fatalf("writing %s: %v", dst, err)
}
}
// cleanupServiceState removes the service's global state dir so the next test
// doesn't inherit a prior node's identity; call only after the service stops.
func (n *TestNode) cleanupServiceState() {
t := n.env.t
t.Helper()
dir := serviceStateDir()
if err := os.RemoveAll(dir); err != nil {
t.Logf("removing %s: %v", dir, err)
}
}
// serviceStateDir is the global state dir a Tailscale service uses.
func serviceStateDir() string {
return filepath.Join(os.Getenv("ProgramData"), "Tailscale")
}
// waitServiceReady polls the LocalAPI until BackendState leaves NoState,
// guarding the post-start race (tailscale/tailscale#8695).
func (n *TestNode) waitServiceReady(timeout time.Duration) {
t := n.env.t
t.Helper()
deadline := time.Now().Add(timeout)
var last error
for time.Now().Before(deadline) {
st, err := n.Status()
if err == nil && st.BackendState != "" && st.BackendState != "NoState" {
return
}
last = err
time.Sleep(2 * time.Second)
}
t.Fatalf("service LocalAPI not ready within %v (last err: %v)", timeout, last)
}
// waitServiceState polls s until it reaches want, failing the test on timeout.
func (n *TestNode) waitServiceState(s *mgr.Service, want svc.State, timeout time.Duration) {
t := n.env.t
t.Helper()
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
st, err := s.Query()
if err != nil {
t.Fatalf("query service %q: %v", serviceName, err)
}
if st.State == want {
return
}
time.Sleep(time.Second)
}
t.Fatalf("service %q did not reach state %d within %v", serviceName, want, timeout)
}
// waitServiceGone polls until the service no longer exists.
func (n *TestNode) waitServiceGone(timeout time.Duration) {
t := n.env.t
t.Helper()
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
if !serviceExists(t) {
return
}
time.Sleep(time.Second)
}
t.Fatalf("service %q still present after %v", serviceName, timeout)
}
// serviceExists reports whether the Tailscale service is currently installed.
func serviceExists(t testing.TB) bool {
t.Helper()
m := connectSCM(t)
defer m.Disconnect()
s, err := m.OpenService(serviceName)
if err != nil {
return false
}
s.Close()
return true
}
// connectSCM connects to the Windows service manager, failing the test on error.
func connectSCM(t testing.TB) *mgr.Mgr {
t.Helper()
m, err := mgr.Connect()
if err != nil {
t.Fatalf("connect to service manager: %v", err)
}
return m
}
// stageWintun downloads and verifies wintun.dll into dir; tailscaled loads it
// from its executable's dir (cmd/tailscaled.fullyQualifiedWintunPath).
func stageWintun(t testing.TB, dir string) {
t.Helper()
req, err := http.NewRequestWithContext(t.Context(), "GET", wintun.URL, nil)
if err != nil {
t.Fatalf("wintun request: %v", err)
}
res, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("downloading wintun: %v", err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
t.Fatalf("downloading %s: HTTP %s", wintun.URL, res.Status)
}
zipBytes, err := io.ReadAll(res.Body)
if err != nil {
t.Fatalf("reading wintun zip: %v", err)
}
if sum := sha256.Sum256(zipBytes); hex.EncodeToString(sum[:]) != wintun.SHA256 {
t.Fatalf("wintun zip sha256 = %s, want %s", hex.EncodeToString(sum[:]), wintun.SHA256)
}
zr, err := zip.NewReader(bytes.NewReader(zipBytes), int64(len(zipBytes)))
if err != nil {
t.Fatalf("opening wintun zip: %v", err)
}
member := wintun.DLLZipPath(runtime.GOARCH)
f, err := zr.Open(member)
if err != nil {
t.Fatalf("wintun zip missing %q: %v", member, err)
}
defer f.Close()
out, err := os.Create(filepath.Join(dir, "wintun.dll"))
if err != nil {
t.Fatalf("creating wintun.dll: %v", err)
}
if _, err := io.Copy(out, f); err != nil {
out.Close()
t.Fatalf("extracting wintun.dll: %v", err)
}
if err := out.Close(); err != nil {
t.Fatalf("closing wintun.dll: %v", err)
}
}