Sfoglia il codice sorgente

control/controlclient: move auto_test back to corp repo.

It can't run without corp stuff anyway, and makes it harder to
refactor the control server.
David Anderson 5 anni fa
parent
commit
557b310e67

+ 64 - 31
control/controlclient/auto.go

@@ -17,6 +17,7 @@ import (
 	"sync"
 	"sync"
 	"time"
 	"time"
 
 
+	"github.com/tailscale/wireguard-go/wgcfg"
 	"golang.org/x/oauth2"
 	"golang.org/x/oauth2"
 	"tailscale.com/logtail/backoff"
 	"tailscale.com/logtail/backoff"
 	"tailscale.com/tailcfg"
 	"tailscale.com/tailcfg"
@@ -25,36 +26,37 @@ import (
 	"tailscale.com/types/structs"
 	"tailscale.com/types/structs"
 )
 )
 
 
-// TODO(apenwarr): eliminate the 'state' variable, as it's now obsolete.
-//  It's used only by the unit tests.
-type state int
+// State is the high-level state of the client. It is used only in
+// unit tests for proper sequencing, don't depend on it anywhere else.
+// TODO(apenwarr): eliminate 'state', as it's now obsolete.
+type State int
 
 
 const (
 const (
-	stateNew = state(iota)
-	stateNotAuthenticated
-	stateAuthenticating
-	stateURLVisitRequired
-	stateAuthenticated
-	stateSynchronized // connected and received map update
+	StateNew = State(iota)
+	StateNotAuthenticated
+	StateAuthenticating
+	StateURLVisitRequired
+	StateAuthenticated
+	StateSynchronized // connected and received map update
 )
 )
 
 
-func (s state) MarshalText() ([]byte, error) {
+func (s State) MarshalText() ([]byte, error) {
 	return []byte(s.String()), nil
 	return []byte(s.String()), nil
 }
 }
 
 
-func (s state) String() string {
+func (s State) String() string {
 	switch s {
 	switch s {
-	case stateNew:
+	case StateNew:
 		return "state:new"
 		return "state:new"
-	case stateNotAuthenticated:
+	case StateNotAuthenticated:
 		return "state:not-authenticated"
 		return "state:not-authenticated"
-	case stateAuthenticating:
+	case StateAuthenticating:
 		return "state:authenticating"
 		return "state:authenticating"
-	case stateURLVisitRequired:
+	case StateURLVisitRequired:
 		return "state:url-visit-required"
 		return "state:url-visit-required"
-	case stateAuthenticated:
+	case StateAuthenticated:
 		return "state:authenticated"
 		return "state:authenticated"
-	case stateSynchronized:
+	case StateSynchronized:
 		return "state:synchronized"
 		return "state:synchronized"
 	default:
 	default:
 		return fmt.Sprintf("state:unknown:%d", int(s))
 		return fmt.Sprintf("state:unknown:%d", int(s))
@@ -69,7 +71,7 @@ type Status struct {
 	Persist       *Persist          // locally persisted configuration
 	Persist       *Persist          // locally persisted configuration
 	NetMap        *NetworkMap       // server-pushed configuration
 	NetMap        *NetworkMap       // server-pushed configuration
 	Hostinfo      *tailcfg.Hostinfo // current Hostinfo data
 	Hostinfo      *tailcfg.Hostinfo // current Hostinfo data
-	state         state
+	State         State
 }
 }
 
 
 // Equal reports whether s and s2 are equal.
 // Equal reports whether s and s2 are equal.
@@ -84,7 +86,7 @@ func (s *Status) Equal(s2 *Status) bool {
 		reflect.DeepEqual(s.Persist, s2.Persist) &&
 		reflect.DeepEqual(s.Persist, s2.Persist) &&
 		reflect.DeepEqual(s.NetMap, s2.NetMap) &&
 		reflect.DeepEqual(s.NetMap, s2.NetMap) &&
 		reflect.DeepEqual(s.Hostinfo, s2.Hostinfo) &&
 		reflect.DeepEqual(s.Hostinfo, s2.Hostinfo) &&
-		s.state == s2.state
+		s.State == s2.State
 }
 }
 
 
 func (s Status) String() string {
 func (s Status) String() string {
@@ -92,7 +94,7 @@ func (s Status) String() string {
 	if err != nil {
 	if err != nil {
 		panic(err)
 		panic(err)
 	}
 	}
-	return s.state.String() + " " + string(b)
+	return s.State.String() + " " + string(b)
 }
 }
 
 
 type LoginGoal struct {
 type LoginGoal struct {
@@ -121,7 +123,7 @@ type Client struct {
 	hostinfo     *tailcfg.Hostinfo
 	hostinfo     *tailcfg.Hostinfo
 	inPollNetMap bool // true if currently running a PollNetMap
 	inPollNetMap bool // true if currently running a PollNetMap
 	inSendStatus int  // number of sendStatus calls currently in progress
 	inSendStatus int  // number of sendStatus calls currently in progress
-	state        state
+	state        State
 
 
 	authCtx    context.Context // context used for auth requests
 	authCtx    context.Context // context used for auth requests
 	mapCtx     context.Context // context used for netmap requests
 	mapCtx     context.Context // context used for netmap requests
@@ -319,7 +321,7 @@ func (c *Client) authRoutine() {
 			c.mu.Lock()
 			c.mu.Lock()
 			c.loggedIn = false
 			c.loggedIn = false
 			c.loginGoal = nil
 			c.loginGoal = nil
-			c.state = stateNotAuthenticated
+			c.state = StateNotAuthenticated
 			c.synced = false
 			c.synced = false
 			c.mu.Unlock()
 			c.mu.Unlock()
 
 
@@ -328,9 +330,9 @@ func (c *Client) authRoutine() {
 		} else { // ie. goal.wantLoggedIn
 		} else { // ie. goal.wantLoggedIn
 			c.mu.Lock()
 			c.mu.Lock()
 			if goal.url != "" {
 			if goal.url != "" {
-				c.state = stateURLVisitRequired
+				c.state = StateURLVisitRequired
 			} else {
 			} else {
-				c.state = stateAuthenticating
+				c.state = StateAuthenticating
 			}
 			}
 			c.mu.Unlock()
 			c.mu.Unlock()
 
 
@@ -359,7 +361,7 @@ func (c *Client) authRoutine() {
 
 
 				c.mu.Lock()
 				c.mu.Lock()
 				c.loginGoal = goal
 				c.loginGoal = goal
-				c.state = stateURLVisitRequired
+				c.state = StateURLVisitRequired
 				c.synced = false
 				c.synced = false
 				c.mu.Unlock()
 				c.mu.Unlock()
 
 
@@ -372,7 +374,7 @@ func (c *Client) authRoutine() {
 			c.mu.Lock()
 			c.mu.Lock()
 			c.loggedIn = true
 			c.loggedIn = true
 			c.loginGoal = nil
 			c.loginGoal = nil
-			c.state = stateAuthenticated
+			c.state = StateAuthenticated
 			c.mu.Unlock()
 			c.mu.Unlock()
 
 
 			c.sendStatus("authRoutine4", nil, "", nil)
 			c.sendStatus("authRoutine4", nil, "", nil)
@@ -382,6 +384,20 @@ func (c *Client) authRoutine() {
 	}
 	}
 }
 }
 
 
+// Expiry returns the credential expiration time, or the zero time if
+// the expiration time isn't known. Used in tests only.
+func (c *Client) Expiry() *time.Time {
+	c.mu.Lock()
+	defer c.mu.Unlock()
+	return c.expiry
+}
+
+// Direct returns the underlying direct client object. Used in tests
+// only.
+func (c *Client) Direct() *Direct {
+	return c.direct
+}
+
 func (c *Client) mapRoutine() {
 func (c *Client) mapRoutine() {
 	defer close(c.mapDone)
 	defer close(c.mapDone)
 	bo := backoff.NewBackoff("mapRoutine", c.logf)
 	bo := backoff.NewBackoff("mapRoutine", c.logf)
@@ -449,7 +465,7 @@ func (c *Client) mapRoutine() {
 				c.synced = true
 				c.synced = true
 				c.inPollNetMap = true
 				c.inPollNetMap = true
 				if c.loggedIn {
 				if c.loggedIn {
-					c.state = stateSynchronized
+					c.state = StateSynchronized
 				}
 				}
 				exp := nm.Expiry
 				exp := nm.Expiry
 				c.expiry = &exp
 				c.expiry = &exp
@@ -467,8 +483,8 @@ func (c *Client) mapRoutine() {
 			c.mu.Lock()
 			c.mu.Lock()
 			c.synced = false
 			c.synced = false
 			c.inPollNetMap = false
 			c.inPollNetMap = false
-			if c.state == stateSynchronized {
-				c.state = stateAuthenticated
+			if c.state == StateSynchronized {
+				c.state = StateAuthenticated
 			}
 			}
 			c.mu.Unlock()
 			c.mu.Unlock()
 
 
@@ -537,7 +553,7 @@ func (c *Client) sendStatus(who string, err error, url string, nm *NetworkMap) {
 
 
 	var p *Persist
 	var p *Persist
 	var fin *empty.Message
 	var fin *empty.Message
-	if state == stateAuthenticated {
+	if state == StateAuthenticated {
 		fin = new(empty.Message)
 		fin = new(empty.Message)
 	}
 	}
 	if nm != nil && loggedIn && synced {
 	if nm != nil && loggedIn && synced {
@@ -554,7 +570,7 @@ func (c *Client) sendStatus(who string, err error, url string, nm *NetworkMap) {
 		Persist:       p,
 		Persist:       p,
 		NetMap:        nm,
 		NetMap:        nm,
 		Hostinfo:      hi,
 		Hostinfo:      hi,
-		state:         state,
+		State:         state,
 	}
 	}
 	if err != nil {
 	if err != nil {
 		new.Err = err.Error()
 		new.Err = err.Error()
@@ -623,3 +639,20 @@ func (c *Client) Shutdown() {
 		c.logf("Client.Shutdown done.")
 		c.logf("Client.Shutdown done.")
 	}
 	}
 }
 }
+
+// NodePublicKey returns the node public key currently in use. This is
+// used exclusively in tests.
+func (c *Client) TestOnlyNodePublicKey() wgcfg.Key {
+	priv := c.direct.GetPersist()
+	return priv.PrivateNodeKey.Public()
+}
+
+func (c *Client) TestOnlySetAuthKey(authkey string) {
+	c.direct.mu.Lock()
+	defer c.direct.mu.Unlock()
+	c.direct.authKey = authkey
+}
+
+func (c *Client) TestOnlyTimeNow() time.Time {
+	return c.timeNow()
+}

+ 0 - 1337
control/controlclient/auto_test.go

@@ -1,1337 +0,0 @@
-// 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.
-
-// +build depends_on_currently_unreleased
-
-package controlclient
-
-import (
-	"bytes"
-	"context"
-	"encoding/json"
-	"fmt"
-	"io/ioutil"
-	"net/http"
-	"net/http/cookiejar"
-	"net/http/httptest"
-	"net/url"
-	"os"
-	"reflect"
-	"runtime/pprof"
-	"strconv"
-	"strings"
-	"sync"
-	"testing"
-	"time"
-
-	"github.com/klauspost/compress/zstd"
-	"github.com/tailscale/wireguard-go/wgcfg"
-	"tailscale.com/tailcfg"
-	"tailscale.com/tstest"
-	"tailscale.com/types/logger"
-	"tailscale.io/control" // not yet released
-	"tailscale.io/control/cfgdb"
-)
-
-func TestTest(t *testing.T) {
-	check := tstest.NewResourceCheck()
-	defer check.Assert(t)
-}
-
-func TestServerStartStop(t *testing.T) {
-	s := newServer(t)
-	defer s.close()
-}
-
-func TestControlBasics(t *testing.T) {
-	s := newServer(t)
-	defer s.close()
-
-	c := s.newClient(t, "c")
-	c.Login(nil, 0)
-	status := c.waitStatus(t, stateURLVisitRequired)
-	c.postAuthURL(t, "[email protected]", status.New)
-}
-
-// A function with the same semantics as t.Run(), but which doesn't rearrange
-// the logs by creating a new sub-t.Logf, and doesn't support parallelism.
-// This makes it possible to actually figure out what happened by looking
-// at the logs.
-func runSub(t *testing.T, name string, fn func(t *testing.T)) {
-	t.Helper()
-	t.Logf("\n")
-	t.Logf("\n\n--- Starting: %v\n\n", name)
-	defer func() {
-		if t.Failed() {
-			t.Logf("\n\n--- FAILED: %v\n\n", name)
-		} else {
-			t.Logf("\n\n--- PASS: %v\n\n", name)
-		}
-	}()
-
-	fn(t)
-}
-
-func fatal(t *testing.T, args ...interface{}) {
-	t.Helper()
-	t.Fatal("FAILED: ", fmt.Sprint(args...))
-}
-
-func fatalf(t *testing.T, s string, args ...interface{}) {
-	t.Helper()
-	t.Fatalf("FAILED: "+s, args...)
-}
-
-func TestControl(t *testing.T) {
-	s := newServer(t)
-	defer s.close()
-
-	c1 := s.newClient(t, "c1")
-
-	runSub(t, "authorize first tailscale.com client", func(t *testing.T) {
-		const loginName = "[email protected]"
-		c1.checkNoStatus(t)
-		c1.loginAs(t, loginName)
-		c1.waitStatus(t, stateAuthenticated)
-		status := c1.waitStatus(t, stateSynchronized)
-		if got, want := status.New.NetMap.MachineStatus, tailcfg.MachineUnauthorized; got != want {
-			fatalf(t, "MachineStatus=%v, want %v", got, want)
-		}
-		c1.checkNoStatus(t)
-		affectedPeers, err := s.control.AuthorizeMachine(c1.mkey, c1.nkey)
-		if err != nil {
-			fatal(t, err)
-		}
-		status = c1.status(t)
-		if got := status.New.Persist.LoginName; got != loginName {
-			fatalf(t, "LoginName=%q, want %q", got, loginName)
-		}
-		if got := status.New.Persist.Provider; got != "google" {
-			fatalf(t, "Provider=%q, want google", got)
-		}
-		if len(affectedPeers) != 1 || affectedPeers[0] != c1.id {
-			fatalf(t, "authorization should notify the node being authorized (%v), got: %v", c1.id, affectedPeers)
-		}
-		if peers := status.New.NetMap.Peers; len(peers) != 0 {
-			fatalf(t, "peers=%v, want none", peers)
-		}
-		if userID := status.New.NetMap.User; userID == 0 {
-			fatalf(t, "NetMap.User is missing")
-		} else {
-			profile := status.New.NetMap.UserProfiles[userID]
-			if profile.LoginName != loginName {
-				fatalf(t, "NetMap user LoginName=%q, want %q", profile.LoginName, loginName)
-			}
-		}
-		c1.checkNoStatus(t)
-	})
-
-	c2 := s.newClient(t, "c2")
-
-	runSub(t, "authorize second tailscale.io client", func(t *testing.T) {
-		c2.loginAs(t, "[email protected]")
-		c2.waitStatus(t, stateAuthenticated)
-		c2.waitStatus(t, stateSynchronized)
-		c2.checkNoStatus(t)
-
-		// Make sure not to call operations like this on a client in a
-		// test until the initial map read is done. Otherwise the
-		// initial map read will trigger a map update to peers, and
-		// there will sometimes be a spurious map update.
-		affectedPeers, err := s.control.AuthorizeMachine(c2.mkey, c2.nkey)
-		if err != nil {
-			fatal(t, err)
-		}
-		status := c2.waitStatus(t, stateSynchronized)
-		c1Status := c1.waitStatus(t, stateSynchronized)
-
-		if len(affectedPeers) != 2 {
-			fatalf(t, "affectedPeers=%v, want two entries", affectedPeers)
-		}
-		if want := []tailcfg.NodeID{c1.id, c2.id}; !nodeIDsEqual(affectedPeers, want) {
-			fatalf(t, "affectedPeers=%v, want %v", affectedPeers, want)
-		}
-
-		c1NetMap := c1Status.New.NetMap
-		c2NetMap := status.New.NetMap
-		if len(c1NetMap.Peers) != 1 || len(c2NetMap.Peers) != 1 {
-			t.Error("wrong number of peers")
-		} else {
-			if c2NetMap.Peers[0].Key != c1.nkey {
-				fatalf(t, "c2 has wrong peer key %v, want %v", c2NetMap.Peers[0].Key, c1.nkey)
-			}
-			if c1NetMap.Peers[0].Key != c2.nkey {
-				fatalf(t, "c1 has wrong peer key %v, want %v", c1NetMap.Peers[0].Key, c2.nkey)
-			}
-		}
-		if t.Failed() {
-			fatalf(t, "client1 network map:\n%s", c1Status.New.NetMap)
-			fatalf(t, "client2 network map:\n%s", status.New.NetMap)
-		}
-
-		c1.checkNoStatus(t)
-		c2.checkNoStatus(t)
-	})
-
-	// c3/c4 are on a different domain to c1/c2.
-	// The two domains should never affect one another.
-	c3 := s.newClient(t, "c3")
-
-	runSub(t, "authorize first onmicrosoft client", func(t *testing.T) {
-		c3.loginAs(t, "[email protected]")
-		c3.waitStatus(t, stateAuthenticated)
-		c3Status := c3.waitStatus(t, stateSynchronized)
-		// no machine authorization for tailscale.onmicrosoft.com
-		c1.checkNoStatus(t)
-		c2.checkNoStatus(t)
-
-		netMap := c3Status.New.NetMap
-		if netMap.NodeKey != c3.nkey {
-			fatalf(t, "netMap.NodeKey=%v, want %v", netMap.NodeKey, c3.nkey)
-		}
-		if len(netMap.Peers) != 0 {
-			fatalf(t, "netMap.Peers=%v, want none", netMap.Peers)
-		}
-
-		c1.checkNoStatus(t)
-		c2.checkNoStatus(t)
-		c3.checkNoStatus(t)
-	})
-
-	c4 := s.newClient(t, "c4")
-
-	runSub(t, "authorize second onmicrosoft client", func(t *testing.T) {
-		c4.loginAs(t, "[email protected]")
-		c4.waitStatus(t, stateAuthenticated)
-		c3Status := c3.waitStatus(t, stateSynchronized)
-		c4Status := c4.waitStatus(t, stateSynchronized)
-		c3NetMap := c3Status.New.NetMap
-		c4NetMap := c4Status.New.NetMap
-
-		c1.checkNoStatus(t)
-		c2.checkNoStatus(t)
-
-		if len(c3NetMap.Peers) != 1 {
-			fatalf(t, "wrong number of c3 peers: %d", len(c3NetMap.Peers))
-		} else if len(c4NetMap.Peers) != 1 {
-			fatalf(t, "wrong number of c4 peers: %d", len(c4NetMap.Peers))
-		} else {
-			if c3NetMap.Peers[0].Key != c4.nkey || c4NetMap.Peers[0].Key != c3.nkey {
-				t.Error("wrong peer key")
-			}
-		}
-		if t.Failed() {
-			fatalf(t, "client3 network map:\n%s", c3NetMap)
-			fatalf(t, "client4 network map:\n%s", c4NetMap)
-		}
-	})
-
-	var c1NetMap *NetworkMap
-	runSub(t, "update c1 and c2 endpoints", func(t *testing.T) {
-		c1Endpoints := []string{"172.16.1.5:12345", "4.4.4.4:4444"}
-		c1.checkNoStatus(t)
-		c1.UpdateEndpoints(1234, c1Endpoints)
-		c1NetMap = c1.status(t).New.NetMap
-		c2NetMap := c2.status(t).New.NetMap
-		c1.checkNoStatus(t)
-		c2.checkNoStatus(t)
-
-		if c1NetMap.LocalPort != 1234 {
-			fatalf(t, "c1 netmap localport=%d, want 1234", c1NetMap.LocalPort)
-		}
-		if len(c2NetMap.Peers) != 1 {
-			fatalf(t, "wrong peer count: %d", len(c2NetMap.Peers))
-		}
-		if got := c2NetMap.Peers[0].Endpoints; !hasStringsSuffix(got, c1Endpoints) {
-			fatalf(t, "c2 peer endpoints=%v, want %v", got, c1Endpoints)
-		}
-		c3.checkNoStatus(t)
-		c4.checkNoStatus(t)
-
-		c2Endpoints := []string{"172.16.1.7:6543", "5.5.5.5.3333"}
-		c2.UpdateEndpoints(9876, c2Endpoints)
-		c1NetMap = c1.status(t).New.NetMap
-		c2NetMap = c2.status(t).New.NetMap
-
-		if c1NetMap.LocalPort != 1234 {
-			fatalf(t, "c1 netmap localport=%d, want 1234", c1NetMap.LocalPort)
-		}
-		if c2NetMap.LocalPort != 9876 {
-			fatalf(t, "c2 netmap localport=%d, want 9876", c2NetMap.LocalPort)
-		}
-		if got := c2NetMap.Peers[0].Endpoints; !hasStringsSuffix(got, c1Endpoints) {
-			fatalf(t, "c2 peer endpoints=%v, want suffix %v", got, c1Endpoints)
-		}
-		if got := c1NetMap.Peers[0].Endpoints; !hasStringsSuffix(got, c2Endpoints) {
-			fatalf(t, "c1 peer endpoints=%v, want suffix %v", got, c2Endpoints)
-		}
-
-		c1.checkNoStatus(t)
-		c2.checkNoStatus(t)
-		c3.checkNoStatus(t)
-		c4.checkNoStatus(t)
-	})
-
-	allZeros, err := wgcfg.ParseCIDR("0.0.0.0/0")
-	if err != nil {
-		fatal(t, err)
-	}
-
-	runSub(t, "route all traffic via client 1", func(t *testing.T) {
-		aips := []wgcfg.CIDR{}
-		aips = append(aips, c1NetMap.Addresses...)
-		aips = append(aips, allZeros)
-
-		affectedPeers, err := s.control.SetAllowedIPs(c1.nkey, aips)
-		if err != nil {
-			fatal(t, err)
-		}
-		c2Status := c2.status(t)
-		c2NetMap := c2Status.New.NetMap
-
-		if want := []tailcfg.NodeID{c2.id}; !nodeIDsEqual(affectedPeers, want) {
-			fatalf(t, "affectedPeers=%v, want %v", affectedPeers, want)
-		}
-
-		_ = c2NetMap
-		foundAllZeros := false
-		for _, cidr := range c2NetMap.Peers[0].AllowedIPs {
-			if cidr == allZeros {
-				foundAllZeros = true
-			}
-		}
-		if !foundAllZeros {
-			fatalf(t, "client2 peer does not contain %s: %v", allZeros, c2NetMap.Peers[0].AllowedIPs)
-		}
-
-		c1.checkNoStatus(t)
-		c3.checkNoStatus(t)
-		c4.checkNoStatus(t)
-	})
-
-	runSub(t, "remove route all traffic", func(t *testing.T) {
-		affectedPeers, err := s.control.SetAllowedIPs(c1.nkey, c1NetMap.Addresses)
-		if err != nil {
-			fatal(t, err)
-		}
-		c2NetMap := c2.status(t).New.NetMap
-
-		if want := []tailcfg.NodeID{c2.id}; !nodeIDsEqual(affectedPeers, want) {
-			fatalf(t, "affectedPeers=%v, want %v", affectedPeers, want)
-		}
-
-		foundAllZeros := false
-		for _, cidr := range c2NetMap.Peers[0].AllowedIPs {
-			if cidr == allZeros {
-				foundAllZeros = true
-			}
-		}
-		if foundAllZeros {
-			fatalf(t, "client2 peer still contains %s: %v", allZeros, c2NetMap.Peers[0].AllowedIPs)
-		}
-
-		c1.checkNoStatus(t)
-		c3.checkNoStatus(t)
-		c4.checkNoStatus(t)
-	})
-
-	runSub(t, "refresh client key", func(t *testing.T) {
-		oldKey := c1.nkey
-
-		c1.Login(nil, LoginInteractive)
-		status := c1.waitStatus(t, stateURLVisitRequired)
-		c1.postAuthURL(t, "[email protected]", status.New)
-		c1.waitStatus(t, stateAuthenticated)
-		status = c1.waitStatus(t, stateSynchronized)
-		if status.New.Err != "" {
-			fatal(t, status.New.Err)
-		}
-
-		c1NetMap := status.New.NetMap
-		c1.nkey = c1NetMap.NodeKey
-		if c1.nkey == oldKey {
-			fatalf(t, "new key is the same as the old key: %s", oldKey)
-		}
-		c2NetMap := c2.status(t).New.NetMap
-		if len(c2NetMap.Peers) != 1 || c2NetMap.Peers[0].Key != c1.nkey {
-			fatalf(t, "c2 peer: %v, want new node key %v", c1.nkey, c2NetMap.Peers[0].Key)
-		}
-
-		c3.checkNoStatus(t)
-		c4.checkNoStatus(t)
-	})
-
-	runSub(t, "set hostinfo", func(t *testing.T) {
-		c3.Login(nil, LoginDefault)
-		c4.Login(nil, LoginDefault)
-		c3.waitStatus(t, stateAuthenticated)
-		c4.waitStatus(t, stateAuthenticated)
-		c3.waitStatus(t, stateSynchronized)
-		c4.waitStatus(t, stateSynchronized)
-
-		c3.UpdateEndpoints(9876, []string{"1.2.3.4:3333"})
-		c3.waitStatus(t, stateSynchronized)
-		c4.waitStatus(t, stateSynchronized)
-
-		c4.UpdateEndpoints(9876, []string{"5.6.7.8:1111"})
-		c3.waitStatus(t, stateSynchronized)
-		c4.waitStatus(t, stateSynchronized)
-
-		c3.SetHostinfo(&tailcfg.Hostinfo{
-			BackendLogID: "set-hostinfo-test",
-			OS:           "linux",
-		})
-		c3.waitStatus(t, stateSynchronized)
-		c4NetMap := c4.status(t).New.NetMap
-		if len(c4NetMap.Peers) != 1 {
-			fatalf(t, "wrong number of peers: %v", c4NetMap.Peers)
-		}
-		peer := c4NetMap.Peers[0]
-		if !peer.KeepAlive {
-			fatalf(t, "peer KeepAlive=false, want true")
-		}
-		if peer.Hostinfo.OS != "linux" {
-			fatalf(t, "peer OS is not linux: %v", peer.Hostinfo)
-		}
-
-		c4.SetHostinfo(&tailcfg.Hostinfo{
-			BackendLogID: "set-hostinfo-test",
-			OS:           "iOS",
-		})
-		c3NetMap := c3.status(t).New.NetMap
-		c4NetMap = c4.status(t).New.NetMap
-		if len(c3NetMap.Peers) != 1 {
-			fatalf(t, "wrong number of peers: %v", c3NetMap.Peers)
-		}
-		if len(c4NetMap.Peers) != 1 {
-			fatalf(t, "wrong number of peers: %v", c4NetMap.Peers)
-		}
-		peer = c3NetMap.Peers[0]
-		if peer.KeepAlive {
-			fatalf(t, "peer KeepAlive=true, want false")
-		}
-		if peer.Hostinfo.OS != "iOS" {
-			fatalf(t, "peer OS is not iOS: %v", peer.Hostinfo)
-		}
-		peer = c4NetMap.Peers[0]
-		if peer.KeepAlive {
-			fatalf(t, "peer KeepAlive=true, want false")
-		}
-		if peer.Hostinfo.OS != "linux" {
-			fatalf(t, "peer OS is not linux: %v", peer.Hostinfo)
-		}
-
-	})
-}
-
-func hasStringsSuffix(list, suffix []string) bool {
-	if len(list) < len(suffix) {
-		return false
-	}
-	return reflect.DeepEqual(list[len(list)-len(suffix):], suffix)
-}
-
-func TestLoginInterrupt(t *testing.T) {
-	s := newServer(t)
-	defer s.close()
-
-	c := s.newClient(t, "c")
-
-	const loginName = "[email protected]"
-	c.checkNoStatus(t)
-	c.loginAs(t, loginName)
-	c.waitStatus(t, stateAuthenticated)
-	c.waitStatus(t, stateSynchronized)
-	t.Logf("authorizing: %v %v %v\n", s, c.mkey, c.nkey)
-	if _, err := s.control.AuthorizeMachine(c.mkey, c.nkey); err != nil {
-		fatal(t, err)
-	}
-	status := c.waitStatus(t, stateSynchronized)
-	if got, want := status.New.NetMap.MachineStatus, tailcfg.MachineAuthorized; got != want {
-		fatalf(t, "MachineStatus=%v, want %v", got, want)
-	}
-	origAddrs := status.New.NetMap.Addresses
-	if len(origAddrs) == 0 {
-		fatalf(t, "Addresses empty, want something")
-	}
-
-	c.Logout()
-	c.waitStatus(t, stateNotAuthenticated)
-	c.Login(nil, 0)
-	status = c.waitStatus(t, stateURLVisitRequired)
-	authURL := status.New.URL
-
-	// Interrupt, and do login again.
-	c.Login(nil, 0)
-	status = c.waitStatus(t, stateURLVisitRequired)
-	authURL2 := status.New.URL
-
-	if authURL == authURL2 {
-		fatalf(t, "auth URLs match for subsequent logins: %s", authURL)
-	}
-
-	// Direct auth URL visit is not enough because our cookie is no longer fresh.
-	req, err := http.NewRequest("GET", authURL2, nil)
-	if err != nil {
-		fatal(t, err)
-	}
-	req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
-	resp, err := c.httpc.Do(req.WithContext(c.ctx))
-	if err != nil {
-		fatal(t, err)
-	}
-	b, err := ioutil.ReadAll(resp.Body)
-	if err != nil {
-		fatal(t, err)
-	}
-	resp.Body.Close()
-	if i := bytes.Index(b, []byte("<body")); i != -1 { // strip off noisy <style> header
-		b = b[i:]
-	}
-	if !bytes.Contains(b, []byte("<form")) {
-		fatalf(t, "missing login page: %s", b)
-	}
-
-	form := url.Values{"user": []string{loginName}}
-	req, err = http.NewRequest("POST", authURLForPOST(authURL2), strings.NewReader(form.Encode()))
-	if err != nil {
-		fatal(t, err)
-	}
-	req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
-	resp, err = c.httpc.Do(req.WithContext(c.ctx))
-	if err != nil {
-		fatal(t, err)
-	}
-	resp.Body.Close()
-	if resp.StatusCode != 200 {
-		fatalf(t, "POST %s failed: %q", authURL2, resp.Status)
-	}
-	cookieURL, err := url.Parse(authURL)
-	if err != nil {
-		fatal(t, err)
-	}
-	cookies := c.httpc.Jar.Cookies(cookieURL)
-	if len(cookies) == 0 || cookies[0].Name != "tailcontrol" {
-		fatalf(t, "POST %s: bad cookie: %v", authURL2, cookies)
-	}
-
-	c.waitStatus(t, stateAuthenticated)
-	status = c.status(t)
-	if got := status.New.NetMap.NodeKey; got != c.nkey {
-		fatalf(t, "netmap has wrong node key: %v, want %v", got, c.nkey)
-	}
-	if got := status.New.NetMap.Addresses; len(got) == 0 {
-		fatalf(t, "Addresses empty after re-login, want something")
-	} else if len(origAddrs) > 0 && origAddrs[0] != got[0] {
-		fatalf(t, "Addresses=%v after re-login, originally was %v, want IP to be unchanged", got, origAddrs)
-	}
-}
-
-func TestSpinUpdateEndpoints(t *testing.T) {
-	s := newServer(t)
-	defer s.close()
-
-	c1 := s.newClient(t, "c1")
-	c2 := s.newClient(t, "c2")
-
-	const loginName = "[email protected]"
-	c1.loginAs(t, loginName)
-	c1.waitStatus(t, stateAuthenticated)
-	c1.waitStatus(t, stateSynchronized)
-	if _, err := s.control.AuthorizeMachine(c1.mkey, c1.nkey); err != nil {
-		fatal(t, err)
-	}
-	c1.waitStatus(t, stateSynchronized)
-
-	c2.loginAs(t, loginName)
-	c2.waitStatus(t, stateAuthenticated)
-	c2.waitStatus(t, stateSynchronized)
-	if _, err := s.control.AuthorizeMachine(c2.mkey, c2.nkey); err != nil {
-		fatal(t, err)
-	}
-	c2.waitStatus(t, stateSynchronized)
-	c1.waitStatus(t, stateSynchronized)
-
-	const portBase = 1200
-	const portCount = 50
-	const portLast = portBase + portCount - 1
-
-	errCh := make(chan error, 1)
-	collectPorts := func() error {
-		t := time.After(10 * time.Second)
-		var port int
-		for i := 0; i < portCount; i++ {
-			var status statusChange
-			select {
-			case status = <-c2.statusCh:
-			case <-t:
-				return fmt.Errorf("c2 status timeout (i=%d)", i)
-			}
-			peers := status.New.NetMap.Peers
-			if len(peers) != 1 {
-				return fmt.Errorf("c2 len(peers)=%d, want 1", len(peers))
-			}
-			eps := peers[0].Endpoints
-			for len(eps) > 1 && eps[0] != "127.0.0.1:1234" {
-				eps = eps[1:]
-			}
-			if len(eps) != 2 {
-				return fmt.Errorf("c2 peer len(eps)=%d, want 2", len(eps))
-			}
-			ep := eps[1]
-			const prefix = "192.168.1.45:"
-			if !strings.HasPrefix(ep, prefix) {
-				return fmt.Errorf("c2 peer endpoint=%s, want prefix %s", ep, prefix)
-			}
-			var err error
-			port, err = strconv.Atoi(strings.TrimPrefix(ep, prefix))
-			if err != nil {
-				return fmt.Errorf("c2 peer endpoint port: %v", err)
-			}
-			if port == portLast {
-				return nil // got it
-			}
-		}
-		return fmt.Errorf("c2 peer endpoint did not see portLast (saw %d)", port)
-	}
-	go func() {
-		errCh <- collectPorts()
-	}()
-
-	// Very quickly call UpdateEndpoints several times.
-	// Some (most) of these calls will never make it to the server, they
-	// will be canceled by subsequent calls.
-	// The last call goes through, so we can see portLast.
-	eps := []string{"127.0.0.1:1234", ""}
-	for i := 0; i < portCount; i++ {
-		eps[1] = fmt.Sprintf("192.168.1.45:%d", portBase+i)
-		c1.UpdateEndpoints(1234, eps)
-	}
-
-	if err := <-errCh; err != nil {
-		fatalf(t, "collect ports: %v", err)
-	}
-}
-
-func TestLogout(t *testing.T) {
-	s := newServer(t)
-	defer s.close()
-
-	c1 := s.newClient(t, "c1")
-
-	const loginName = "[email protected]"
-	c1.loginAs(t, loginName)
-
-	c1.waitStatus(t, stateAuthenticated)
-	c1.waitStatus(t, stateSynchronized)
-	if _, err := s.control.AuthorizeMachine(c1.mkey, c1.nkey); err != nil {
-		fatal(t, err)
-	}
-	nkey1 := c1.status(t).New.NetMap.NodeKey
-
-	c1.Logout()
-	c1.waitStatus(t, stateNotAuthenticated)
-
-	c1.loginAs(t, loginName)
-	c1.waitStatus(t, stateAuthenticated)
-	status := c1.waitStatus(t, stateSynchronized)
-	if got, want := status.New.NetMap.MachineStatus, tailcfg.MachineAuthorized; got != want {
-		fatalf(t, "re-login MachineStatus=%v, want %v", got, want)
-	}
-	nkey2 := status.New.NetMap.NodeKey
-	if nkey1 == nkey2 {
-		fatalf(t, "key not changed after re-login: %v", nkey1)
-	}
-
-	c1.checkNoStatus(t)
-}
-
-func TestExpiry(t *testing.T) {
-	var nowMu sync.Mutex
-	now := time.Now() // Server and Client use this variable as the current time
-	timeNow := func() time.Time {
-		nowMu.Lock()
-		defer nowMu.Unlock()
-		return now
-	}
-	timeInc := func(d time.Duration) {
-		nowMu.Lock()
-		defer nowMu.Unlock()
-		now = now.Add(d)
-	}
-
-	s := newServer(t)
-	s.control.TimeNow = timeNow
-	defer s.close()
-
-	c1 := s.newClient(t, "c1")
-
-	const loginName = "[email protected]"
-	c1.loginAs(t, loginName)
-
-	c1.waitStatus(t, stateAuthenticated)
-	c1.waitStatus(t, stateSynchronized)
-	if _, err := s.control.AuthorizeMachine(c1.mkey, c1.nkey); err != nil {
-		fatal(t, err)
-	}
-	status := c1.waitStatus(t, stateSynchronized).New
-	nkey1 := c1.direct.persist.PrivateNodeKey
-	nkey1Expiry := status.NetMap.Expiry
-	if wantExpiry := timeNow().Add(180 * 24 * time.Hour); !nkey1Expiry.Equal(wantExpiry) {
-		fatalf(t, "node key expiry = %v, want %v", nkey1Expiry, wantExpiry)
-	}
-
-	timeInc(1 * time.Hour)          // move the clock forward
-	c1.Login(nil, LoginInteractive) // refresh the key
-	status = c1.waitStatus(t, stateURLVisitRequired).New
-	c1.postAuthURL(t, loginName, status)
-	c1.waitStatus(t, stateAuthenticated)
-	status = c1.waitStatus(t, stateSynchronized).New
-	if newKey := c1.direct.persist.PrivateNodeKey; newKey == nkey1 {
-		fatalf(t, "node key unchanged after LoginInteractive: %v", nkey1)
-	}
-	if want, got := timeNow().Add(180*24*time.Hour), status.NetMap.Expiry; !got.Equal(want) {
-		fatalf(t, "node key expiry = %v, want %v", got, want)
-	}
-
-	timeInc(2 * time.Hour) // move the clock forward
-	c1.Login(nil, 0)
-	c1.waitStatus(t, stateAuthenticated)
-	c1.waitStatus(t, stateSynchronized)
-	c1.checkNoStatus(t) // nothing happens, network map stays the same
-
-	timeInc(180 * 24 * time.Hour) // move the clock past expiry
-	c1.loginAs(t, loginName)
-	c1.waitStatus(t, stateAuthenticated)
-	status = c1.waitStatus(t, stateSynchronized).New
-	if got, want := c1.expiry, timeNow(); got.Equal(want) {
-		fatalf(t, "node key expiry = %v, want %v", got, want)
-	}
-	if c1.direct.persist.PrivateNodeKey == nkey1 {
-		fatalf(t, "node key after 37 hours is still %v", status.NetMap.NodeKey)
-	}
-}
-
-func TestRefresh(t *testing.T) {
-	var nowMu sync.Mutex
-	now := time.Now() // Server and Client use this variable as the current time
-	timeNow := func() time.Time {
-		nowMu.Lock()
-		defer nowMu.Unlock()
-		return now
-	}
-
-	s := newServer(t)
-	s.control.TimeNow = timeNow
-	defer s.close()
-
-	c1 := s.newClient(t, "c1")
-
-	const loginName = "[email protected]" // versabank cfgdb has 72 hour key expiry configured
-	c1.loginAs(t, loginName)
-
-	c1.waitStatus(t, stateAuthenticated)
-	c1.waitStatus(t, stateSynchronized)
-	if _, err := s.control.AuthorizeMachine(c1.mkey, c1.nkey); err != nil {
-		fatal(t, err)
-	}
-	status := c1.status(t).New
-	nkey1 := status.NetMap.NodeKey
-	nkey1Expiry := status.NetMap.Expiry
-	if wantExpiry := timeNow().Add(72 * time.Hour); !nkey1Expiry.Equal(wantExpiry) {
-		fatalf(t, "node key expiry = %v, want %v", nkey1Expiry, wantExpiry)
-	}
-
-	c1.Login(nil, LoginInteractive)
-	c1.waitStatus(t, stateURLVisitRequired)
-	// Until authorization happens, old netmap is still valid.
-	exp := c1.expiry
-	if exp == nil {
-		fatalf(t, "expiry==nil during refresh\n")
-	}
-	if got := *exp; !nkey1Expiry.Equal(got) {
-		fatalf(t, "node key expiry = %v, want %v", got, nkey1Expiry)
-	}
-	k := tailcfg.NodeKey(c1.direct.persist.PrivateNodeKey.Public())
-	if k != nkey1 {
-		fatalf(t, "node key after 2 hours is %v, want %v", k, nkey1)
-	}
-	c1.Shutdown()
-}
-
-func TestAuthKey(t *testing.T) {
-	var nowMu sync.Mutex
-	now := time.Now() // Server and Client use this variable as the current time
-	timeNow := func() time.Time {
-		nowMu.Lock()
-		defer nowMu.Unlock()
-		return now
-	}
-
-	s := newServer(t)
-	s.control.TimeNow = timeNow
-	defer s.close()
-
-	const loginName = "[email protected]"
-	user, err := s.control.DB().FindOrCreateUser("google", loginName, "", "")
-	if err != nil {
-		fatal(t, err)
-	}
-
-	runSub(t, "one-off", func(t *testing.T) {
-		oneOffKey, err := s.control.DB().NewAPIKey(user.ID, cfgdb.KeyCapabilities{
-			Bits: cfgdb.KeyCapOneOffNodeAuth,
-		})
-		if err != nil {
-			fatal(t, err)
-		}
-
-		c1 := s.newClientWithKey(t, "c1", string(oneOffKey))
-		c1.Login(nil, 0)
-		c1.waitStatus(t, stateAuthenticated)
-		c1.waitStatus(t, stateSynchronized)
-		c1.Shutdown()
-
-		// Key won't work a second time.
-		c2 := s.newClientWithKey(t, "c2", string(oneOffKey))
-		c2.Login(nil, 0)
-		status := c2.readStatus(t)
-		if e, substr := status.New.Err, `revoked`; !strings.Contains(e, substr) {
-			fatalf(t, "Err=%q, expect substring %q", e, substr)
-		}
-		c2.Shutdown()
-	})
-
-	runSub(t, "followup", func(t *testing.T) {
-		key, err := s.control.DB().NewAPIKey(user.ID, cfgdb.KeyCapabilities{
-			Bits: cfgdb.KeyCapNodeAuth,
-		})
-		if err != nil {
-			fatal(t, err)
-		}
-
-		c1 := s.newClient(t, "c1")
-		c1.Login(nil, 0)
-		c1.waitStatus(t, stateURLVisitRequired)
-
-		c1.direct.mu.Lock()
-		c1.direct.authKey = string(key)
-		c1.direct.mu.Unlock()
-
-		c1.Login(nil, 0)
-		c1.waitStatus(t, stateAuthenticated)
-		c1.waitStatus(t, stateSynchronized)
-		c1.Shutdown()
-	})
-}
-
-func TestExpectedProvider(t *testing.T) {
-	s := newServer(t)
-	defer s.close()
-
-	c := s.newClient(t, "c1")
-
-	c.direct.persist.LoginName = "[email protected]"
-	c.direct.persist.Provider = "microsoft"
-	c.Login(nil, 0)
-	status := c.readStatus(t)
-	if e, substr := status.New.Err, `provider "microsoft" is not supported`; !strings.Contains(e, substr) {
-		fatalf(t, "Err=%q, expect substring %q", e, substr)
-	}
-}
-
-func TestNewUserWebFlow(t *testing.T) {
-	s := newServer(t)
-	defer s.close()
-	s.control.DB().SetSegmentAPIKey(segmentKey)
-
-	c := s.newClient(t, "c1")
-	c.Login(nil, 0)
-	status := c.waitStatus(t, stateURLVisitRequired)
-	authURL := status.New.URL
-	resp, err := c.httpc.Get(authURL)
-	if err != nil {
-		fatal(t, err)
-	}
-	if resp.StatusCode != 200 {
-		fatalf(t, "statuscode=%d, want 200", resp.StatusCode)
-	}
-	b, err := ioutil.ReadAll(resp.Body)
-	if err != nil {
-		fatal(t, err)
-	}
-	got := string(b)
-	if !strings.Contains(got, `<input type="email"`) {
-		fatalf(t, "page does not mention email field:\n\n%s", got)
-	}
-
-	loginWith := "[email protected]"
-	authURL = authURLForPOST(authURL)
-	resp, err = c.httpc.PostForm(authURL, url.Values{"user": []string{loginWith}})
-	if err != nil {
-		fatal(t, err)
-	}
-	if resp.StatusCode != 200 {
-		fatalf(t, "statuscode=%d, want 200", resp.StatusCode)
-	}
-	b, err = ioutil.ReadAll(resp.Body)
-	if err != nil {
-		fatal(t, err)
-	}
-	if i := bytes.Index(b, []byte("<body")); i != -1 { // strip off noisy <style> header
-		b = b[i:]
-	}
-	got = string(b)
-	if !strings.Contains(got, "This is a new machine") {
-		fatalf(t, "no machine authorization message:\n\n%s", got)
-	}
-
-	c.waitStatus(t, stateAuthenticated)
-	c.waitStatus(t, stateSynchronized)
-	if _, err := s.control.AuthorizeMachine(c.mkey, c.nkey); err != nil {
-		fatal(t, err)
-	}
-	netmap := c.status(t).New.NetMap
-	loginname := netmap.UserProfiles[netmap.User].LoginName
-	if loginname != loginWith {
-		fatalf(t, "loginame=%s want %s", loginname, loginWith)
-	}
-
-	runSub(t, "segment POST", func(t *testing.T) {
-		select {
-		case msg := <-s.segmentMsg:
-			if got, want := msg["userId"], control.UserIDHash(netmap.User); got != want {
-				fatalf(t, "segment hashed user ID = %q, want %q", got, want)
-			}
-			if got, want := msg["event"], "new node activated"; got != want {
-				fatalf(t, "event=%q, want %q", got, want)
-			}
-			if t.Failed() {
-				t.Log(msg)
-			}
-		case <-time.After(3 * time.Second):
-			fatalf(t, "timeout waiting for segment identify req")
-		}
-	})
-
-	runSub(t, "user expiry", func(t *testing.T) {
-		peers, err := s.control.ExpireUserNodes(netmap.User)
-		if err != nil {
-			fatal(t, err)
-		}
-		if len(peers) != 1 {
-			fatalf(t, "len(peers)=%d, want 1", len(peers))
-		}
-		var nodeExp time.Time
-		if nodes, err := s.control.DB().AllNodes(netmap.User); err != nil {
-			fatal(t, err)
-		} else if len(nodes) != 1 {
-			fatalf(t, "len(nodes)=%d, want 1", len(nodes))
-		} else if nodeExp = nodes[0].KeyExpiry; c.timeNow().Sub(nodeExp) > 24*time.Hour {
-			fatalf(t, "node[0] expiry=%v, want it to be in less than a day", nodeExp)
-		} else if got := c.status(t).New.NetMap.Expiry; !got.Equal(nodeExp) {
-			fatalf(t, "expiry=%v, want it to be %v", got, nodeExp)
-		}
-	})
-}
-
-func TestGoogleSigninButton(t *testing.T) {
-	s := newServer(t)
-	defer s.close()
-
-	c := s.newClient(t, "c1")
-	c.Login(nil, 0)
-	status := c.waitStatus(t, stateURLVisitRequired)
-	authURL := status.New.URL
-	resp, err := c.httpc.Get(authURL)
-	if err != nil {
-		fatal(t, err)
-	}
-	if resp.StatusCode != 200 {
-		fatalf(t, "statuscode=%d, want 200", resp.StatusCode)
-	}
-	b, err := ioutil.ReadAll(resp.Body)
-	if err != nil {
-		fatal(t, err)
-	}
-	if i := bytes.Index(b, []byte("<body")); i != -1 { // strip off noisy <style> header
-		b = b[i:]
-	}
-	got := string(b)
-	if !strings.Contains(got, `Sign in with Google`) {
-		fatalf(t, "page does not mention google signin button:\n\n%s", got)
-	}
-
-	authURL = authURLForPOST(authURL)
-	resp, err = c.httpc.PostForm(authURL, url.Values{"provider": []string{"google"}})
-	if err != nil {
-		fatal(t, err)
-	}
-	if resp.StatusCode != 200 {
-		fatalf(t, "statuscode=%d, want 200", resp.StatusCode)
-	}
-	b, err = ioutil.ReadAll(resp.Body)
-	if err != nil {
-		fatal(t, err)
-	}
-	if i := bytes.Index(b, []byte("<body")); i != -1 { // strip off noisy <style> header
-		b = b[i:]
-	}
-	got = string(b)
-	if !strings.Contains(got, "Authorization successful") {
-		fatalf(t, "no machine authorization message:\n\n%s", got)
-	}
-
-	c.waitStatus(t, stateAuthenticated)
-	netmap := c.status(t).New.NetMap
-	loginname := netmap.UserProfiles[netmap.User].LoginName
-	if want := "[email protected]"; loginname != want {
-		fatalf(t, "loginame=%s want %s", loginname, want)
-	}
-}
-
-func nodeIDsEqual(n1, n2 []tailcfg.NodeID) bool {
-	if len(n1) != len(n2) {
-		return false
-	}
-	n1s := make(map[tailcfg.NodeID]bool)
-	for _, id := range n1 {
-		n1s[id] = true
-	}
-	for _, id := range n2 {
-		if !n1s[id] {
-			return false
-		}
-	}
-	return true
-}
-
-type server struct {
-	t          *testing.T
-	tmpdir     string
-	control    *control.Server
-	http       *httptest.Server
-	clients    []*client
-	check      *tstest.ResourceCheck
-	segmentMsg chan map[string]interface{}
-}
-
-const segmentKey = "segkey"
-
-func newServer(t *testing.T) *server {
-	t.Helper()
-	tstest.PanicOnLog()
-
-	logf := t.Logf
-
-	s := &server{
-		t:          t,
-		check:      tstest.NewResourceCheck(),
-		segmentMsg: make(chan map[string]interface{}, 8),
-	}
-
-	tmpdir, err := ioutil.TempDir("", "control-test-")
-	if err != nil {
-		fatal(t, err)
-	}
-	s.tmpdir = tmpdir
-
-	serveSegment := func(w http.ResponseWriter, r *http.Request) {
-		errorf := func(format string, args ...interface{}) {
-			msg := fmt.Sprintf(format, args...)
-			s.segmentMsg <- map[string]interface{}{
-				"error": msg,
-			}
-			http.Error(w, "segment error: "+msg, 400)
-		}
-
-		user, pass, ok := r.BasicAuth()
-		if pass != "" {
-			errorf("unexpected auth passworkd : %s", user)
-			return
-		}
-		if user != segmentKey {
-			errorf("got basic auth user %q, want %q", user, segmentKey)
-			return
-		}
-		if !ok {
-			errorf("no basic auth")
-		}
-		b, err := ioutil.ReadAll(r.Body)
-		if err != nil {
-			errorf("readall: %v", err)
-			return
-		}
-
-		m := make(map[string]interface{})
-		if err := json.Unmarshal(b, &m); err != nil {
-			errorf("unmarshal failed: %v, text:\n%s", err, string(b))
-			return
-		}
-		s.segmentMsg <- m
-	}
-
-	s.http = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-		switch r.URL.Path {
-		case "/v1/identify", "/v1/track":
-			serveSegment(w, r)
-		default:
-			s.control.ServeHTTP(w, r)
-		}
-	}))
-	s.http.Config.ErrorLog = logger.StdLogger(logf)
-
-	s.control, err = control.New(tmpdir, tmpdir, tmpdir, s.http.URL, true, logf)
-	if err != nil {
-		fatal(t, err)
-	}
-	s.control.QuietLogging = true
-	control.SegmentServer = s.http.URL
-
-	return s
-}
-
-func (s *server) close() {
-	t := s.t
-	t.Helper()
-	t.Logf("server.close: shutting down %d clients...\n", len(s.clients))
-	for i, c := range s.clients {
-		t.Logf("   %d\n", i)
-		c.Shutdown()
-		t.Logf("   %d CloseIdle\n", i)
-		c.cancel()
-	}
-	// TODO: remove CloseClientConnections when we have a real client shutdown mechanism.
-	// The client shutdown should clean up all HTTP connections and calling this will
-	// hide any cleanup failures.
-	t.Logf("server.close: CloseClientConnections...\n")
-	s.http.CloseClientConnections()
-	t.Logf("server.close: http.Close...\n")
-	s.http.Close()
-	s.control.Shutdown()
-	// TODO: s.control.Shutdown
-	t.Logf("server.close: RemoveAll...\n")
-	os.RemoveAll(s.tmpdir)
-	t.Logf("server.close: done.\n")
-	s.check.Assert(s.t)
-}
-
-type statusChange struct {
-	New Status
-}
-
-func (s *server) newClient(t *testing.T, name string) *client {
-	return s.newClientWithKey(t, name, "")
-}
-
-func (s *server) newClientWithKey(t *testing.T, name, authKey string) *client {
-	t.Helper()
-
-	ch := make(chan statusChange, 1024)
-	httpc := s.http.Client()
-	var err error
-	httpc.Jar, err = cookiejar.New(nil)
-	if err != nil {
-		fatal(t, err)
-	}
-	hi := NewHostinfo()
-	hi.FrontendLogID = "go-test-only"
-	hi.BackendLogID = "go-test-only"
-	ctlc, err := NewNoStart(Options{
-		ServerURL:      s.http.URL,
-		HTTPTestClient: httpc,
-		TimeNow:        s.control.TimeNow,
-		Logf: func(fmt string, args ...interface{}) {
-			t.Helper()
-			t.Logf(name+": "+fmt, args...)
-		},
-		Hostinfo: hi,
-		NewDecompressor: func() (Decompressor, error) {
-			return zstd.NewReader(nil,
-				zstd.WithDecoderLowmem(true),
-				zstd.WithDecoderConcurrency(1),
-				zstd.WithDecoderMaxMemory(65536),
-			)
-		},
-		KeepAlive: true,
-		AuthKey:   authKey,
-	})
-	ctlc.SetStatusFunc(func(new Status) {
-		select {
-		case ch <- statusChange{New: new}:
-		case <-time.After(5 * time.Second):
-			fatalf(t, "newClient.statusFunc: stuck.\n")
-		}
-	})
-	if err != nil {
-		fatal(t, err)
-	}
-
-	c := &client{
-		Client:   ctlc,
-		s:        s,
-		name:     name,
-		httpc:    httpc,
-		statusCh: ch,
-	}
-	c.ctx, c.cancel = context.WithCancel(context.Background())
-	s.clients = append(s.clients, c)
-	ctlc.Start()
-
-	return c
-}
-
-type client struct {
-	*Client
-	s        *server
-	name     string
-	ctx      context.Context
-	cancel   func()
-	httpc    *http.Client
-	mkey     tailcfg.MachineKey
-	nkey     tailcfg.NodeKey
-	id       tailcfg.NodeID
-	statusCh <-chan statusChange
-}
-
-func (c *client) loginAs(t *testing.T, user string) *http.Cookie {
-	t.Helper()
-
-	c.Login(nil, 0)
-	status := c.waitStatus(t, stateURLVisitRequired)
-
-	return c.postAuthURL(t, user, status.New)
-}
-
-func (c *client) postAuthURL(t *testing.T, user string, status Status) *http.Cookie {
-	t.Helper()
-	authURL := status.URL
-	if authURL == "" {
-		fatalf(t, "expecting auth URL, got: %v", status)
-	}
-	return postAuthURL(t, c.ctx, c.httpc, user, authURL)
-}
-
-func authURLForPOST(authURL string) string {
-	i := strings.Index(authURL, "/a/")
-	if i == -1 {
-		panic("bad authURL: " + authURL)
-	}
-	return authURL[:i] + "/login?refresh=true&next_url=" + url.PathEscape(authURL[i:])
-}
-
-// postAuthURL manually executes the OAuth login flow, starting at
-// authURL and claiming to be user. This flow will only work correctly
-// if the control server is configured with the "None" auth provider,
-// which blindly accepts the provided user and produces a cookie for
-// them. postAuthURL returns the auth cookie produced by the control
-// server.
-func postAuthURL(t *testing.T, ctx context.Context, httpc *http.Client, user string, authURL string) *http.Cookie {
-	t.Helper()
-
-	authURL = authURLForPOST(authURL)
-	form := url.Values{"user": []string{user}}
-	req, err := http.NewRequest("POST", authURL, strings.NewReader(form.Encode()))
-	if err != nil {
-		fatal(t, err)
-	}
-	req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
-	resp, err := httpc.Do(req.WithContext(ctx))
-	if err != nil {
-		fatal(t, err)
-	}
-	b, _ := ioutil.ReadAll(resp.Body)
-	resp.Body.Close()
-	if i := bytes.Index(b, []byte("<body")); i != -1 { // strip off noisy <style> header
-		b = b[i:]
-	}
-	if resp.StatusCode != 200 {
-		fatalf(t, "POST %s failed: %q, body: %s", authURL, resp.Status, b)
-	}
-	cookieURL, err := url.Parse(authURL)
-	if err != nil {
-		fatal(t, err)
-	}
-	cookies := httpc.Jar.Cookies(cookieURL)
-	if len(cookies) == 0 || cookies[0].Name != "tailcontrol" {
-		fatalf(t, "POST %s: bad cookie: %v, body: %s", authURL, cookies, b)
-	}
-	return cookies[0]
-}
-
-func (c *client) checkNoStatus(t *testing.T) {
-	t.Helper()
-	select {
-	case status := <-c.statusCh:
-		fatalf(t, "%s: unexpected status change: %v", c.name, status)
-	default:
-	}
-}
-
-func (c *client) readStatus(t *testing.T) (status statusChange) {
-	t.Helper()
-	select {
-	case status = <-c.statusCh:
-	case <-time.After(3 * time.Second):
-		// TODO(crawshaw): every ~1000 test runs on macOS sees a login get
-		// suck in the httpc.Do GET request of loadServerKey.
-		// Why? Is this a timing problem, with something causing a pause
-		// long enough that the timeout expires? Or is something more
-		// sinister going on in the server (or even the HTTP stack)?
-		//
-		// Extending the timeout to 6 seconds does not solve the problem
-		// but does seem to reduce the frequency of flakes.
-		//
-		// (I have added a runtime.ReadMemStats call here, and have not
-		// observed any global pauses greater than 50 microseconds.)
-		//
-		// NOTE(apenwarr): I can reproduce this more quickly by
-		//  running multiple copies of 'go test -count 100' in
-		//  parallel, but only on macOS. Increasing the timeout to
-		//  10 seconds doesn't seem to help in that case. The
-		//  timeout is often, but not always, in fetching the
-		//  control key, but I think that's not the essential element.
-		pprof.Lookup("goroutine").WriteTo(os.Stdout, 1)
-		t.Logf("%s: timeout: no status received\n", c.name)
-		fatalf(t, "%s: timeout: no status received", c.name)
-	}
-	return status
-}
-
-func (c *client) status(t *testing.T) (status statusChange) {
-	t.Helper()
-	status = c.readStatus(t)
-	if status.New.Err != "" {
-		fatalf(t, "%s state %s: status error: %s", c.name, status.New.state, status.New.Err)
-	} else {
-		t.Logf("%s state: %s", c.name, status.New.state)
-		if status.New.NetMap != nil {
-			c.mkey = tailcfg.MachineKey(status.New.Persist.PrivateMachineKey.Public())
-			if nkey := status.New.NetMap.NodeKey; nkey != (tailcfg.NodeKey{}) && nkey != c.nkey {
-				c.nkey = nkey
-				c.id = c.s.control.DB().Node(c.nkey).ID
-			}
-		}
-	}
-	return status
-}
-
-func (c *client) waitStatus(t *testing.T, want state) statusChange {
-	t.Helper()
-	status := c.status(t)
-	if status.New.state != want {
-		fatalf(t, "%s bad state=%s, want %s (%v)", c.name, status.New.state, want, status.New)
-	}
-	return status
-}
-
-// TODO: test client shutdown + recreate
-// TODO: test server disconnect/reconnect during followup
-// TODO: test network outage downgrade from stateSynchronized -> stateAuthenticated
-// TODO: test os/hostname gets sent to server
-// TODO: test vpn IP not assigned until machine is authorized
-// TODO: test overlapping calls to RefreshLogin
-// TODO: test registering a new node for a user+machine key replaces the old
-//       node even if the OldNodeKey is not specified by the client.
-// TODO: test "does not expire" on server extends expiry in sent network map

+ 5 - 5
control/controlclient/controlclient_test.go

@@ -22,7 +22,7 @@ func fieldsOf(t reflect.Type) (fields []string) {
 
 
 func TestStatusEqual(t *testing.T) {
 func TestStatusEqual(t *testing.T) {
 	// Verify that the Equal method stays in sync with reality
 	// Verify that the Equal method stays in sync with reality
-	equalHandles := []string{"LoginFinished", "Err", "URL", "Persist", "NetMap", "Hostinfo", "state"}
+	equalHandles := []string{"LoginFinished", "Err", "URL", "Persist", "NetMap", "Hostinfo", "State"}
 	if have := fieldsOf(reflect.TypeOf(Status{})); !reflect.DeepEqual(have, equalHandles) {
 	if have := fieldsOf(reflect.TypeOf(Status{})); !reflect.DeepEqual(have, equalHandles) {
 		t.Errorf("Status.Equal check might be out of sync\nfields: %q\nhandled: %q\n",
 		t.Errorf("Status.Equal check might be out of sync\nfields: %q\nhandled: %q\n",
 			have, equalHandles)
 			have, equalHandles)
@@ -48,13 +48,13 @@ func TestStatusEqual(t *testing.T) {
 			true,
 			true,
 		},
 		},
 		{
 		{
-			&Status{state: stateNew},
-			&Status{state: stateNew},
+			&Status{State: StateNew},
+			&Status{State: StateNew},
 			true,
 			true,
 		},
 		},
 		{
 		{
-			&Status{state: stateNew},
-			&Status{state: stateAuthenticated},
+			&Status{State: StateNew},
+			&Status{State: StateAuthenticated},
 			false,
 			false,
 		},
 		},
 		{
 		{