cmd/tailscale/cli,ipn/ipnlocal: restrict logout when AlwaysOn mode is enabled

In this PR, we start passing a LocalAPI actor to (*LocalBackend).Logout to make it subject
to the same access check as disconnects made via tailscale down or the GUI.

We then update the CLI to allow `tailscale logout` to accept a reason, similar to `tailscale down`.

Updates tailscale/corp#26249

Signed-off-by: Nick Khyl <nickk@tailscale.com>
This commit is contained in:
Nick Khyl 2025-07-08 14:37:13 -05:00 committed by Nick Khyl
parent 5b0074729d
commit 1fe82d6ef5
5 changed files with 22 additions and 11 deletions

View File

@ -5,12 +5,18 @@ package cli
import ( import (
"context" "context"
"flag"
"fmt" "fmt"
"strings" "strings"
"github.com/peterbourgon/ff/v3/ffcli" "github.com/peterbourgon/ff/v3/ffcli"
"tailscale.com/client/tailscale/apitype"
) )
var logoutArgs struct {
reason string
}
var logoutCmd = &ffcli.Command{ var logoutCmd = &ffcli.Command{
Name: "logout", Name: "logout",
ShortUsage: "tailscale logout", ShortUsage: "tailscale logout",
@ -22,11 +28,17 @@ the current node key, forcing a future use of it to cause
a reauthentication. a reauthentication.
`), `),
Exec: runLogout, Exec: runLogout,
FlagSet: (func() *flag.FlagSet {
fs := newFlagSet("logout")
fs.StringVar(&logoutArgs.reason, "reason", "", "reason for the logout, if required by a policy")
return fs
})(),
} }
func runLogout(ctx context.Context, args []string) error { func runLogout(ctx context.Context, args []string) error {
if len(args) > 0 { if len(args) > 0 {
return fmt.Errorf("too many non-flag arguments: %q", args) return fmt.Errorf("too many non-flag arguments: %q", args)
} }
ctx = apitype.RequestReasonKey.WithValue(ctx, logoutArgs.reason)
return localClient.Logout(ctx) return localClient.Logout(ctx)
} }

View File

@ -27,6 +27,7 @@ import (
"golang.org/x/crypto/ssh" "golang.org/x/crypto/ssh"
"tailscale.com/control/controlclient" "tailscale.com/control/controlclient"
"tailscale.com/ipn" "tailscale.com/ipn"
"tailscale.com/ipn/ipnauth"
"tailscale.com/ipn/ipnlocal" "tailscale.com/ipn/ipnlocal"
"tailscale.com/ipn/ipnserver" "tailscale.com/ipn/ipnserver"
"tailscale.com/ipn/store/mem" "tailscale.com/ipn/store/mem"
@ -336,7 +337,7 @@ func (i *jsIPN) logout() {
go func() { go func() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel() defer cancel()
i.lb.Logout(ctx) i.lb.Logout(ctx, ipnauth.Self)
}() }()
} }

View File

@ -1077,7 +1077,7 @@ func (b *LocalBackend) Shutdown() {
ctx, cancel := context.WithTimeout(b.ctx, 5*time.Second) ctx, cancel := context.WithTimeout(b.ctx, 5*time.Second)
defer cancel() defer cancel()
t0 := time.Now() t0 := time.Now()
err := b.Logout(ctx) // best effort err := b.Logout(ctx, ipnauth.Self) // best effort
td := time.Since(t0).Round(time.Millisecond) td := time.Since(t0).Round(time.Millisecond)
if err != nil { if err != nil {
b.logf("failed to log out ephemeral node on shutdown after %v: %v", td, err) b.logf("failed to log out ephemeral node on shutdown after %v: %v", td, err)
@ -5884,7 +5884,7 @@ func (b *LocalBackend) ShouldHandleViaIP(ip netip.Addr) bool {
// Logout logs out the current profile, if any, and waits for the logout to // Logout logs out the current profile, if any, and waits for the logout to
// complete. // complete.
func (b *LocalBackend) Logout(ctx context.Context) error { func (b *LocalBackend) Logout(ctx context.Context, actor ipnauth.Actor) error {
unlock := b.lockAndGetUnlock() unlock := b.lockAndGetUnlock()
defer unlock() defer unlock()
@ -5898,11 +5898,8 @@ func (b *LocalBackend) Logout(ctx context.Context) error {
// delete it later. // delete it later.
profile := b.pm.CurrentProfile() profile := b.pm.CurrentProfile()
// TODO(nickkhyl): change [LocalBackend.Logout] to accept an [ipnauth.Actor].
// This will allow enforcing Always On mode when a user tries to log out
// while logged in and connected. See tailscale/corp#26249.
_, err := b.editPrefsLockedOnEntry( _, err := b.editPrefsLockedOnEntry(
ipnauth.TODO, actor,
&ipn.MaskedPrefs{ &ipn.MaskedPrefs{
WantRunningSet: true, WantRunningSet: true,
LoggedOutSet: true, LoggedOutSet: true,

View File

@ -21,6 +21,7 @@ import (
"tailscale.com/control/controlclient" "tailscale.com/control/controlclient"
"tailscale.com/envknob" "tailscale.com/envknob"
"tailscale.com/ipn" "tailscale.com/ipn"
"tailscale.com/ipn/ipnauth"
"tailscale.com/ipn/ipnstate" "tailscale.com/ipn/ipnstate"
"tailscale.com/ipn/store/mem" "tailscale.com/ipn/store/mem"
"tailscale.com/net/dns" "tailscale.com/net/dns"
@ -607,7 +608,7 @@ func TestStateMachine(t *testing.T) {
store.awaitWrite() store.awaitWrite()
t.Logf("\n\nLogout") t.Logf("\n\nLogout")
notifies.expect(5) notifies.expect(5)
b.Logout(context.Background()) b.Logout(context.Background(), ipnauth.Self)
{ {
nn := notifies.drain(5) nn := notifies.drain(5)
previousCC.assertCalls("pause", "Logout", "unpause", "Shutdown") previousCC.assertCalls("pause", "Logout", "unpause", "Shutdown")
@ -637,7 +638,7 @@ func TestStateMachine(t *testing.T) {
// A second logout should be a no-op as we are in the NeedsLogin state. // A second logout should be a no-op as we are in the NeedsLogin state.
t.Logf("\n\nLogout2") t.Logf("\n\nLogout2")
notifies.expect(0) notifies.expect(0)
b.Logout(context.Background()) b.Logout(context.Background(), ipnauth.Self)
{ {
notifies.drain(0) notifies.drain(0)
cc.assertCalls() cc.assertCalls()
@ -650,7 +651,7 @@ func TestStateMachine(t *testing.T) {
// AuthCantContinue state. // AuthCantContinue state.
t.Logf("\n\nLogout3") t.Logf("\n\nLogout3")
notifies.expect(3) notifies.expect(3)
b.Logout(context.Background()) b.Logout(context.Background(), ipnauth.Self)
{ {
notifies.drain(0) notifies.drain(0)
cc.assertCalls() cc.assertCalls()

View File

@ -1460,7 +1460,7 @@ func (h *Handler) serveLogout(w http.ResponseWriter, r *http.Request) {
http.Error(w, "want POST", http.StatusBadRequest) http.Error(w, "want POST", http.StatusBadRequest)
return return
} }
err := h.b.Logout(r.Context()) err := h.b.Logout(r.Context(), h.Actor)
if err == nil { if err == nil {
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
return return