diff --git a/client/tailscale/tailscale.go b/client/tailscale/tailscale.go index 2d1734a80..2a3e0ba74 100644 --- a/client/tailscale/tailscale.go +++ b/client/tailscale/tailscale.go @@ -212,3 +212,8 @@ func GetPrefs(ctx context.Context) (*ipn.Prefs, error) { } return &p, nil } + +func Logout(ctx context.Context) error { + _, err := send(ctx, "POST", "/localapi/v0/logout", http.StatusNoContent, nil) + return err +} diff --git a/cmd/tailscale/cli/cli.go b/cmd/tailscale/cli/cli.go index dd919c82b..d3ba7a5a1 100644 --- a/cmd/tailscale/cli/cli.go +++ b/cmd/tailscale/cli/cli.go @@ -64,6 +64,7 @@ func Run(args []string) error { Subcommands: []*ffcli.Command{ upCmd, downCmd, + logoutCmd, netcheckCmd, ipCmd, statusCmd, diff --git a/cmd/tailscale/cli/logout.go b/cmd/tailscale/cli/logout.go new file mode 100644 index 000000000..7924cc607 --- /dev/null +++ b/cmd/tailscale/cli/logout.go @@ -0,0 +1,34 @@ +// Copyright (c) 2020 Tailscale Inc & AUTHORS All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package cli + +import ( + "context" + "log" + "strings" + + "github.com/peterbourgon/ff/v2/ffcli" + "tailscale.com/client/tailscale" +) + +var logoutCmd = &ffcli.Command{ + Name: "logout", + ShortUsage: "logout [flags]", + ShortHelp: "down + expire current node key", + + LongHelp: strings.TrimSpace(` +"tailscale logout" brings the network down and invalidates +the current node key, forcing a future use of it to cause +a reauthentication. +`), + Exec: runLogout, +} + +func runLogout(ctx context.Context, args []string) error { + if len(args) > 0 { + log.Fatalf("too many non-flag arguments: %q", args) + } + return tailscale.Logout(ctx) +} diff --git a/control/controlclient/auto.go b/control/controlclient/auto.go index 9366bb4b0..123e615f1 100644 --- a/control/controlclient/auto.go +++ b/control/controlclient/auto.go @@ -100,11 +100,22 @@ func (s Status) String() string { } type LoginGoal struct { - _ structs.Incomparable - wantLoggedIn bool // true if we *want* to be logged in - token *tailcfg.Oauth2Token // oauth token to use when logging in - flags LoginFlags // flags to use when logging in - url string // auth url that needs to be visited + _ structs.Incomparable + wantLoggedIn bool // true if we *want* to be logged in + token *tailcfg.Oauth2Token // oauth token to use when logging in + flags LoginFlags // flags to use when logging in + url string // auth url that needs to be visited + loggedOutResult chan<- error +} + +func (g *LoginGoal) sendLogoutError(err error) { + if g.loggedOutResult == nil { + return + } + select { + case g.loggedOutResult <- err: + default: + } } // Client connects to a tailcontrol server for a node. @@ -363,6 +374,7 @@ func (c *Client) authRoutine() { if !goal.wantLoggedIn { err := c.direct.TryLogout(ctx) + goal.sendLogoutError(err) if err != nil { report(err, "TryLogout") bo.BackOff(ctx, err) @@ -402,7 +414,8 @@ func (c *Client) authRoutine() { report(err, f) bo.BackOff(ctx, err) continue - } else if url != "" { + } + if url != "" { if goal.url != "" { err = fmt.Errorf("[unexpected] server required a new URL?") report(err, "WaitLoginURL") @@ -682,18 +695,42 @@ func (c *Client) Login(t *tailcfg.Oauth2Token, flags LoginFlags) { c.cancelAuth() } -func (c *Client) Logout() { - c.logf("client.Logout()") +func (c *Client) StartLogout() { + c.logf("client.StartLogout()") c.mu.Lock() c.loginGoal = &LoginGoal{ wantLoggedIn: false, } c.mu.Unlock() - c.cancelAuth() } +func (c *Client) Logout(ctx context.Context) error { + c.logf("client.Logout()") + + errc := make(chan error, 1) + + c.mu.Lock() + c.loginGoal = &LoginGoal{ + wantLoggedIn: false, + loggedOutResult: errc, + } + c.mu.Unlock() + c.cancelAuth() + + timer := time.NewTimer(10 * time.Second) + defer timer.Stop() + select { + case err := <-errc: + return err + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + return context.DeadlineExceeded + } +} + // UpdateEndpoints sets the client's discovered endpoints and sends // them to the control server if they've changed. // diff --git a/control/controlclient/direct.go b/control/controlclient/direct.go index 7587b8e11..347620df3 100644 --- a/control/controlclient/direct.go +++ b/control/controlclient/direct.go @@ -261,15 +261,14 @@ func (c *Direct) GetPersist() persist.Persist { func (c *Direct) TryLogout(ctx context.Context) error { c.logf("direct.TryLogout()") - c.mu.Lock() - defer c.mu.Unlock() + mustRegen, newURL, err := c.doLogin(ctx, loginOpt{Logout: true}) + c.logf("TryLogout control response: mustRegen=%v, newURL=%v, err=%v", mustRegen, newURL, err) - // TODO(crawshaw): Tell the server. This node key should be - // immediately invalidated. - //if !c.persist.PrivateNodeKey.IsZero() { - //} + c.mu.Lock() c.persist = persist.Persist{} - return nil + c.mu.Unlock() + + return err } func (c *Direct) TryLogin(ctx context.Context, t *tailcfg.Oauth2Token, flags LoginFlags) (url string, err error) { @@ -298,10 +297,11 @@ func (c *Direct) doLoginOrRegen(ctx context.Context, opt loginOpt) (newURL strin } type loginOpt struct { - Token *tailcfg.Oauth2Token - Flags LoginFlags - Regen bool - URL string + Token *tailcfg.Oauth2Token + Flags LoginFlags + Regen bool + URL string + Logout bool } func (c *Direct) doLogin(ctx context.Context, opt loginOpt) (mustRegen bool, newURL string, err error) { @@ -324,14 +324,18 @@ func (c *Direct) doLogin(ctx context.Context, opt loginOpt) (mustRegen bool, new } regen := opt.Regen - if expired { - c.logf("Old key expired -> regen=true") - systemd.Status("key expired; run 'tailscale up' to authenticate") - regen = true - } - if (opt.Flags & LoginInteractive) != 0 { - c.logf("LoginInteractive -> regen=true") - regen = true + if opt.Logout { + c.logf("logging out...") + } else { + if expired { + c.logf("Old key expired -> regen=true") + systemd.Status("key expired; run 'tailscale up' to authenticate") + regen = true + } + if (opt.Flags & LoginInteractive) != 0 { + c.logf("LoginInteractive -> regen=true") + regen = true + } } c.logf("doLogin(regen=%v, hasUrl=%v)", regen, opt.URL != "") @@ -348,8 +352,12 @@ func (c *Direct) doLogin(ctx context.Context, opt loginOpt) (mustRegen bool, new } var oldNodeKey wgkey.Key - if opt.URL != "" { - } else if regen || persist.PrivateNodeKey.IsZero() { + switch { + case opt.Logout: + tryingNewKey = persist.PrivateNodeKey + case opt.URL != "": + // Nothing. + case regen || persist.PrivateNodeKey.IsZero(): c.logf("Generating a new nodekey.") persist.OldPrivateNodeKey = persist.PrivateNodeKey key, err := wgkey.NewPrivate() @@ -358,7 +366,7 @@ func (c *Direct) doLogin(ctx context.Context, opt loginOpt) (mustRegen bool, new return regen, opt.URL, err } tryingNewKey = key - } else { + default: // Try refreshing the current key first tryingNewKey = persist.PrivateNodeKey } @@ -367,6 +375,9 @@ func (c *Direct) doLogin(ctx context.Context, opt loginOpt) (mustRegen bool, new } if tryingNewKey.IsZero() { + if opt.Logout { + return false, "", errors.New("no nodekey to log out") + } log.Fatalf("tryingNewKey is empty, give up") } if backendLogID == "" { @@ -382,6 +393,9 @@ func (c *Direct) doLogin(ctx context.Context, opt loginOpt) (mustRegen bool, new Followup: opt.URL, Timestamp: &now, } + if opt.Logout { + request.Expiry = time.Unix(123, 0) // far in the past + } c.logf("RegisterReq: onode=%v node=%v fup=%v", request.OldNodeKey.ShortString(), request.NodeKey.ShortString(), opt.URL != "") @@ -403,6 +417,11 @@ func (c *Direct) doLogin(ctx context.Context, opt loginOpt) (mustRegen bool, new c.logf("RegisterReq sign error: %v", err) } } + if debugRegister { + j, _ := json.MarshalIndent(request, "", "\t") + c.logf("RegisterRequest: %s", j) + } + bodyData, err := encode(request, &serverKey, &machinePrivKey) if err != nil { return regen, opt.URL, err @@ -431,6 +450,11 @@ func (c *Direct) doLogin(ctx context.Context, opt loginOpt) (mustRegen bool, new c.logf("error decoding RegisterResponse with server key %s and machine key %s: %v", serverKey, machinePrivKey.Public(), err) return regen, opt.URL, fmt.Errorf("register request: %v", err) } + if debugRegister { + j, _ := json.MarshalIndent(resp, "", "\t") + c.logf("RegisterResponse: %s", j) + } + // Log without PII: c.logf("RegisterReq: got response; nodeKeyExpired=%v, machineAuthorized=%v; authURL=%v", resp.NodeKeyExpired, resp.MachineAuthorized, resp.AuthURL != "") @@ -902,7 +926,10 @@ func decode(res *http.Response, v interface{}, serverKey *wgkey.Key, mkey *wgkey return decodeMsg(msg, v, serverKey, mkey) } -var debugMap, _ = strconv.ParseBool(os.Getenv("TS_DEBUG_MAP")) +var ( + debugMap, _ = strconv.ParseBool(os.Getenv("TS_DEBUG_MAP")) + debugRegister, _ = strconv.ParseBool(os.Getenv("TS_DEBUG_REGISTER")) +) var jsonEscapedZero = []byte(`\u0000`) diff --git a/ipn/ipnlocal/local.go b/ipn/ipnlocal/local.go index dc527b451..11c93d06e 100644 --- a/ipn/ipnlocal/local.go +++ b/ipn/ipnlocal/local.go @@ -1961,19 +1961,28 @@ func (b *LocalBackend) requestEngineStatusAndWait() { // transitions the local engine to the logged-out state without // waiting for controlclient to be in that state. // -// TODO(danderson): controlclient Logout does nothing useful, and we -// shouldn't be transitioning to a state based on what we believe -// controlclient may have done. -// // NOTE(apenwarr): No easy way to persist logged-out status. // Maybe that's for the better; if someone logs out accidentally, // rebooting will fix it. func (b *LocalBackend) Logout() { + b.logout(context.Background(), false) +} + +func (b *LocalBackend) LogoutSync(ctx context.Context) error { + return b.logout(ctx, true) +} + +func (b *LocalBackend) logout(ctx context.Context, sync bool) error { b.mu.Lock() cc := b.cc b.setNetMapLocked(nil) b.mu.Unlock() + b.EditPrefs(&ipn.MaskedPrefs{ + WantRunningSet: true, + Prefs: ipn.Prefs{WantRunning: true}, + }) + if cc == nil { // Double Logout can happen via repeated IPN // connections to ipnserver making it repeatedly @@ -1982,16 +1991,22 @@ func (b *LocalBackend) Logout() { // on the transition to zero. // Previously this crashed when we asserted that c was non-nil // here. - return + return errors.New("no controlclient") } - cc.Logout() + var err error + if sync { + err = cc.Logout(ctx) + } else { + cc.StartLogout() + } b.mu.Lock() b.setNetMapLocked(nil) b.mu.Unlock() b.stateMachine() + return err } // assertClientLocked crashes if there is no controlclient in this backend. diff --git a/ipn/localapi/localapi.go b/ipn/localapi/localapi.go index 178690e63..bbdf28351 100644 --- a/ipn/localapi/localapi.go +++ b/ipn/localapi/localapi.go @@ -87,6 +87,8 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.serveGoroutines(w, r) case "/localapi/v0/status": h.serveStatus(w, r) + case "/localapi/v0/logout": + h.serveLogout(w, r) case "/localapi/v0/prefs": h.servePrefs(w, r) case "/localapi/v0/check-ip-forwarding": @@ -200,6 +202,23 @@ func (h *Handler) serveStatus(w http.ResponseWriter, r *http.Request) { e.Encode(st) } +func (h *Handler) serveLogout(w http.ResponseWriter, r *http.Request) { + if !h.PermitWrite { + http.Error(w, "logout access denied", http.StatusForbidden) + return + } + if r.Method != "POST" { + http.Error(w, "want POST", 400) + return + } + err := h.b.LogoutSync(r.Context()) + if err == nil { + w.WriteHeader(http.StatusNoContent) + return + } + http.Error(w, err.Error(), 500) +} + func (h *Handler) servePrefs(w http.ResponseWriter, r *http.Request) { if !h.PermitRead { http.Error(w, "prefs access denied", http.StatusForbidden)