* 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>
271 lines
7.5 KiB
Go
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)
|
|
}
|
|
}
|