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

@ -1368,3 +1368,9 @@ func (lc *Client) SuggestExitNode(ctx context.Context) (apitype.ExitNodeSuggesti
} }
return decodeJSON[apitype.ExitNodeSuggestionResponse](body) return decodeJSON[apitype.ExitNodeSuggestionResponse](body)
} }
// ShutdownTailscaled requests a graceful shutdown of tailscaled.
func (lc *Client) ShutdownTailscaled(ctx context.Context) error {
_, err := lc.send(ctx, "POST", "/localapi/v0/shutdown", 200, nil)
return err
}

View File

@ -546,7 +546,7 @@ func startIPNServer(ctx context.Context, logf logger.Logf, logID logid.PublicID,
} }
}() }()
srv := ipnserver.New(logf, logID, sys.NetMon.Get()) srv := ipnserver.New(logf, logID, sys.Bus.Get(), sys.NetMon.Get())
if debugMux != nil { if debugMux != nil {
debugMux.HandleFunc("/debug/ipn", srv.ServeHTMLStatus) debugMux.HandleFunc("/debug/ipn", srv.ServeHTMLStatus)
} }

View File

@ -138,7 +138,7 @@ func newIPN(jsConfig js.Value) map[string]any {
sys.Tun.Get().Start() sys.Tun.Get().Start()
logid := lpc.PublicID logid := lpc.PublicID
srv := ipnserver.New(logf, logid, sys.NetMon.Get()) srv := ipnserver.New(logf, logid, sys.Bus.Get(), sys.NetMon.Get())
lb, err := ipnlocal.NewLocalBackend(logf, logid, sys, controlclient.LoginEphemeral) lb, err := ipnlocal.NewLocalBackend(logf, logid, sys, controlclient.LoginEphemeral)
if err != nil { if err != nil {
log.Fatalf("ipnlocal.NewLocalBackend: %v", err) log.Fatalf("ipnlocal.NewLocalBackend: %v", err)

View File

@ -29,6 +29,7 @@ import (
"tailscale.com/net/netmon" "tailscale.com/net/netmon"
"tailscale.com/types/logger" "tailscale.com/types/logger"
"tailscale.com/types/logid" "tailscale.com/types/logid"
"tailscale.com/util/eventbus"
"tailscale.com/util/mak" "tailscale.com/util/mak"
"tailscale.com/util/set" "tailscale.com/util/set"
"tailscale.com/util/systemd" "tailscale.com/util/systemd"
@ -40,6 +41,7 @@ import (
type Server struct { type Server struct {
lb atomic.Pointer[ipnlocal.LocalBackend] lb atomic.Pointer[ipnlocal.LocalBackend]
logf logger.Logf logf logger.Logf
bus *eventbus.Bus
netMon *netmon.Monitor // must be non-nil netMon *netmon.Monitor // must be non-nil
backendLogID logid.PublicID backendLogID logid.PublicID
@ -446,13 +448,14 @@ func (s *Server) addActiveHTTPRequest(req *http.Request, actor ipnauth.Actor) (o
// //
// At some point, either before or after Run, the Server's SetLocalBackend // At some point, either before or after Run, the Server's SetLocalBackend
// method must also be called before Server can do anything useful. // method must also be called before Server can do anything useful.
func New(logf logger.Logf, logID logid.PublicID, netMon *netmon.Monitor) *Server { func New(logf logger.Logf, logID logid.PublicID, bus *eventbus.Bus, netMon *netmon.Monitor) *Server {
if netMon == nil { if netMon == nil {
panic("nil netMon") panic("nil netMon")
} }
return &Server{ return &Server{
backendLogID: logID, backendLogID: logID,
logf: logf, logf: logf,
bus: bus,
netMon: netMon, netMon: netMon,
} }
} }
@ -494,10 +497,16 @@ func (s *Server) Run(ctx context.Context, ln net.Listener) error {
runDone := make(chan struct{}) runDone := make(chan struct{})
defer close(runDone) defer close(runDone)
// When the context is closed or when we return, whichever is first, close our listener ec := s.bus.Client("ipnserver.Server")
defer ec.Close()
shutdownSub := eventbus.Subscribe[localapi.Shutdown](ec)
// When the context is closed, a [localapi.Shutdown] event is received,
// or when we return, whichever is first, close our listener
// and all open connections. // and all open connections.
go func() { go func() {
select { select {
case <-shutdownSub.Events():
case <-ctx.Done(): case <-ctx.Done():
case <-runDone: case <-runDone:
} }

View File

@ -5,6 +5,7 @@ package ipnserver_test
import ( import (
"context" "context"
"errors"
"runtime" "runtime"
"strconv" "strconv"
"sync" "sync"
@ -14,7 +15,10 @@ import (
"tailscale.com/envknob" "tailscale.com/envknob"
"tailscale.com/ipn" "tailscale.com/ipn"
"tailscale.com/ipn/lapitest" "tailscale.com/ipn/lapitest"
"tailscale.com/tsd"
"tailscale.com/types/ptr" "tailscale.com/types/ptr"
"tailscale.com/util/syspolicy/pkey"
"tailscale.com/util/syspolicy/policytest"
) )
func TestUserConnectDisconnectNonWindows(t *testing.T) { 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) { func setGOOSForTest(tb testing.TB, goos string) {
tb.Helper() tb.Helper()
envknob.Setenv("TS_DEBUG_FAKE_GOOS", goos) envknob.Setenv("TS_DEBUG_FAKE_GOOS", goos)

View File

@ -236,7 +236,7 @@ func (s *Server) Close() {
func newUnstartedIPNServer(opts *options) *ipnserver.Server { func newUnstartedIPNServer(opts *options) *ipnserver.Server {
opts.TB().Helper() opts.TB().Helper()
lb := opts.Backend() lb := opts.Backend()
server := ipnserver.New(opts.Logf(), logid.PublicID{}, lb.NetMon()) server := ipnserver.New(opts.Logf(), logid.PublicID{}, lb.EventBus(), lb.NetMon())
server.SetLocalBackend(lb) server.SetLocalBackend(lb)
return server return server
} }

View File

@ -49,6 +49,7 @@ import (
"tailscale.com/util/mak" "tailscale.com/util/mak"
"tailscale.com/util/osdiag" "tailscale.com/util/osdiag"
"tailscale.com/util/rands" "tailscale.com/util/rands"
"tailscale.com/util/syspolicy/pkey"
"tailscale.com/version" "tailscale.com/version"
"tailscale.com/wgengine/magicsock" "tailscale.com/wgengine/magicsock"
) )
@ -112,6 +113,7 @@ var handler = map[string]LocalAPIHandler{
"set-push-device-token": (*Handler).serveSetPushDeviceToken, "set-push-device-token": (*Handler).serveSetPushDeviceToken,
"set-udp-gro-forwarding": (*Handler).serveSetUDPGROForwarding, "set-udp-gro-forwarding": (*Handler).serveSetUDPGROForwarding,
"set-use-exit-node-enabled": (*Handler).serveSetUseExitNodeEnabled, "set-use-exit-node-enabled": (*Handler).serveSetUseExitNodeEnabled,
"shutdown": (*Handler).serveShutdown,
"start": (*Handler).serveStart, "start": (*Handler).serveStart,
"status": (*Handler).serveStatus, "status": (*Handler).serveStatus,
"suggest-exit-node": (*Handler).serveSuggestExitNode, "suggest-exit-node": (*Handler).serveSuggestExitNode,
@ -2026,3 +2028,38 @@ func (h *Handler) serveSuggestExitNode(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(res) json.NewEncoder(w).Encode(res)
} }
// Shutdown is an eventbus value published when tailscaled shutdown
// is requested via LocalAPI. Its only consumer is [ipnserver.Server].
type Shutdown struct{}
// serveShutdown shuts down tailscaled. It requires write access
// and the [pkey.AllowTailscaledRestart] policy to be enabled.
// See tailscale/corp#32674.
func (h *Handler) serveShutdown(w http.ResponseWriter, r *http.Request) {
if r.Method != httpm.POST {
http.Error(w, "only POST allowed", http.StatusMethodNotAllowed)
return
}
if !h.PermitWrite {
http.Error(w, "shutdown access denied", http.StatusForbidden)
return
}
polc := h.b.Sys().PolicyClientOrDefault()
if permitShutdown, _ := polc.GetBoolean(pkey.AllowTailscaledRestart, false); !permitShutdown {
http.Error(w, "shutdown access denied by policy", http.StatusForbidden)
return
}
ec := h.eventBus.Client("localapi.Handler")
defer ec.Close()
w.WriteHeader(http.StatusOK)
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
eventbus.Publish[Shutdown](ec).Publish(Shutdown{})
}

View File

@ -47,6 +47,13 @@ const (
// An empty string or a zero duration disables automatic reconnection. // An empty string or a zero duration disables automatic reconnection.
ReconnectAfter Key = "ReconnectAfter" ReconnectAfter Key = "ReconnectAfter"
// AllowTailscaledRestart is a boolean key that controls whether users with write access
// to the LocalAPI are allowed to shutdown tailscaled with the intention of restarting it.
// On Windows, tailscaled will be restarted automatically by the service process
// (see babysitProc in cmd/tailscaled/tailscaled_windows.go).
// On other platforms, it is the client's responsibility to restart tailscaled.
AllowTailscaledRestart Key = "AllowTailscaledRestart"
// ExitNodeID is the exit node's node id. default ""; if blank, no exit node is forced. // ExitNodeID is the exit node's node id. default ""; if blank, no exit node is forced.
// Exit node ID takes precedence over exit node IP. // Exit node ID takes precedence over exit node IP.
// To find the node ID, go to /api.md#device. // To find the node ID, go to /api.md#device.

View File

@ -17,6 +17,7 @@ var implicitDefinitions = []*setting.Definition{
// Device policy settings (can only be configured on a per-device basis): // Device policy settings (can only be configured on a per-device basis):
setting.NewDefinition(pkey.AllowedSuggestedExitNodes, setting.DeviceSetting, setting.StringListValue), setting.NewDefinition(pkey.AllowedSuggestedExitNodes, setting.DeviceSetting, setting.StringListValue),
setting.NewDefinition(pkey.AllowExitNodeOverride, setting.DeviceSetting, setting.BooleanValue), setting.NewDefinition(pkey.AllowExitNodeOverride, setting.DeviceSetting, setting.BooleanValue),
setting.NewDefinition(pkey.AllowTailscaledRestart, setting.DeviceSetting, setting.BooleanValue),
setting.NewDefinition(pkey.AlwaysOn, setting.DeviceSetting, setting.BooleanValue), setting.NewDefinition(pkey.AlwaysOn, setting.DeviceSetting, setting.BooleanValue),
setting.NewDefinition(pkey.AlwaysOnOverrideWithReason, setting.DeviceSetting, setting.BooleanValue), setting.NewDefinition(pkey.AlwaysOnOverrideWithReason, setting.DeviceSetting, setting.BooleanValue),
setting.NewDefinition(pkey.ApplyUpdates, setting.DeviceSetting, setting.PreferenceOptionValue), setting.NewDefinition(pkey.ApplyUpdates, setting.DeviceSetting, setting.PreferenceOptionValue),