mirror of
https://github.com/tailscale/tailscale.git
synced 2025-10-04 20:12:16 +02:00
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:
parent
45d635cc98
commit
892f8a9582
@ -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
|
||||||
|
}
|
||||||
|
@ -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)
|
||||||
}
|
}
|
||||||
|
@ -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)
|
||||||
|
@ -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:
|
||||||
}
|
}
|
||||||
|
@ -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)
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
|
@ -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{})
|
||||||
|
}
|
||||||
|
@ -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.
|
||||||
|
@ -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),
|
||||||
|
Loading…
x
Reference in New Issue
Block a user