various: allow tailscaled shutdown via LocalAPI

A customer wants to allow their employees to restart tailscaled at will, when access rights and MDM policy allow it,
as a way to fully reset client state and re-create the tunnel in case of connectivity issues.

On Windows, the main tailscaled process runs as a child of a service process. The service restarts the child
when it exits (or crashes) until the service itself is stopped. Regular (non-admin) users can't stop the service,
and allowing them to do so isn't ideal, especially in managed or multi-user environments.

In this PR, we add a LocalAPI endpoint that instructs ipnserver.Server, and by extension the tailscaled process,
to shut down. The service then restarts the child tailscaled. Shutting down tailscaled requires LocalAPI write access
and an enabled policy setting.

Updates tailscale/corp#32674
Updates tailscale/corp#32675

Signed-off-by: Nick Khyl <nickk@tailscale.com>
This commit is contained in:
Nick Khyl
2025-09-24 18:37:42 -05:00
committed by Nick Khyl
parent 45d635cc98
commit 892f8a9582
9 changed files with 125 additions and 5 deletions

View File

@@ -5,6 +5,7 @@ package ipnserver_test
import (
"context"
"errors"
"runtime"
"strconv"
"sync"
@@ -14,7 +15,10 @@ import (
"tailscale.com/envknob"
"tailscale.com/ipn"
"tailscale.com/ipn/lapitest"
"tailscale.com/tsd"
"tailscale.com/types/ptr"
"tailscale.com/util/syspolicy/pkey"
"tailscale.com/util/syspolicy/policytest"
)
func TestUserConnectDisconnectNonWindows(t *testing.T) {
@@ -253,6 +257,62 @@ func TestBlockWhileIdentityInUse(t *testing.T) {
}
}
func TestShutdownViaLocalAPI(t *testing.T) {
t.Parallel()
errAccessDeniedByPolicy := errors.New("Access denied: shutdown access denied by policy")
tests := []struct {
name string
allowTailscaledRestart *bool
wantErr error
}{
{
name: "AllowTailscaledRestart/NotConfigured",
allowTailscaledRestart: nil,
wantErr: errAccessDeniedByPolicy,
},
{
name: "AllowTailscaledRestart/False",
allowTailscaledRestart: ptr.To(false),
wantErr: errAccessDeniedByPolicy,
},
{
name: "AllowTailscaledRestart/True",
allowTailscaledRestart: ptr.To(true),
wantErr: nil, // shutdown should be allowed
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
sys := tsd.NewSystem()
var pol policytest.Config
if tt.allowTailscaledRestart != nil {
pol.Set(pkey.AllowTailscaledRestart, *tt.allowTailscaledRestart)
}
sys.Set(pol)
server := lapitest.NewServer(t, lapitest.WithSys(sys))
lc := server.ClientWithName("User")
err := lc.ShutdownTailscaled(t.Context())
checkError(t, err, tt.wantErr)
})
}
}
func checkError(tb testing.TB, got, want error) {
tb.Helper()
if (want == nil) != (got == nil) ||
(want != nil && got != nil && want.Error() != got.Error() && !errors.Is(got, want)) {
tb.Fatalf("gotErr: %v; wantErr: %v", got, want)
}
}
func setGOOSForTest(tb testing.TB, goos string) {
tb.Helper()
envknob.Setenv("TS_DEBUG_FAKE_GOOS", goos)