tstest/integration: add testcontrol.RequireAuth mode, new test

This commit is contained in:
Brad Fitzpatrick 2021-05-11 21:57:25 -07:00
parent ebcd7ab890
commit 3a95b4f8f8
2 changed files with 193 additions and 17 deletions

View File

@ -21,6 +21,7 @@
"os/exec" "os/exec"
"path" "path"
"path/filepath" "path/filepath"
"regexp"
"runtime" "runtime"
"strings" "strings"
"sync" "sync"
@ -57,11 +58,7 @@ func TestMain(m *testing.M) {
os.Exit(0) os.Exit(0)
} }
func TestIntegration(t *testing.T) { func TestOneNodeUp_NoAuth(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("not tested/working on Windows yet")
}
bins := buildTestBinaries(t) bins := buildTestBinaries(t)
env := newTestEnv(t, bins) env := newTestEnv(t, bins)
@ -127,6 +124,69 @@ func TestIntegration(t *testing.T) {
} }
} }
func TestOneNodeUp_Auth(t *testing.T) {
bins := buildTestBinaries(t)
env := newTestEnv(t, bins)
defer env.Close()
env.Control.RequireAuth = true
n1 := newTestNode(t, env)
dcmd := n1.StartDaemon(t)
defer dcmd.Process.Kill()
n1.AwaitListening(t)
st := n1.MustStatus(t)
t.Logf("Status: %s", st.BackendState)
t.Logf("Running up --login-server=%s ...", env.ControlServer.URL)
cmd := n1.Tailscale("up", "--login-server="+env.ControlServer.URL)
cmd.Stdout = &authURLParserWriter{fn: func(urlStr string) error {
if env.Control.CompleteAuth(urlStr) {
t.Logf("completed auth path")
return nil
}
err := fmt.Errorf("Failed to complete auth path to %q", urlStr)
t.Log(err)
return err
}}
cmd.Stderr = cmd.Stdout
if err := cmd.Run(); err != nil {
t.Fatalf("up: %v", err)
}
var ip string
if err := tstest.WaitFor(20*time.Second, func() error {
out, err := n1.Tailscale("ip").Output()
if err != nil {
return err
}
ip = string(out)
return nil
}); err != nil {
t.Error(err)
}
t.Logf("Got IP: %v", ip)
dcmd.Process.Signal(os.Interrupt)
ps, err := dcmd.Process.Wait()
if err != nil {
t.Fatalf("tailscaled Wait: %v", err)
}
if ps.ExitCode() != 0 {
t.Errorf("tailscaled ExitCode = %d; want 0", ps.ExitCode())
}
t.Logf("number of HTTP logcatcher requests: %v", env.LogCatcher.numRequests())
if err := env.TrafficTrap.Err(); err != nil {
t.Errorf("traffic trap: %v", err)
t.Logf("logs: %s", env.LogCatcher.logsString())
}
}
// testBinaries are the paths to a tailscaled and tailscale binary. // testBinaries are the paths to a tailscaled and tailscale binary.
// These can be shared by multiple nodes. // These can be shared by multiple nodes.
type testBinaries struct { type testBinaries struct {
@ -168,6 +228,9 @@ type testEnv struct {
// //
// Call Close to shut everything down. // Call Close to shut everything down.
func newTestEnv(t testing.TB, bins *testBinaries) *testEnv { func newTestEnv(t testing.TB, bins *testBinaries) *testEnv {
if runtime.GOOS == "windows" {
t.Skip("not tested/working on Windows yet")
}
derpMap, derpShutdown := runDERPAndStun(t, logger.Discard) derpMap, derpShutdown := runDERPAndStun(t, logger.Discard)
logc := new(logCatcher) logc := new(logCatcher)
control := &testcontrol.Server{ control := &testcontrol.Server{
@ -184,6 +247,7 @@ func newTestEnv(t testing.TB, bins *testBinaries) *testEnv {
TrafficTrapServer: httptest.NewServer(trafficTrap), TrafficTrapServer: httptest.NewServer(trafficTrap),
derpShutdown: derpShutdown, derpShutdown: derpShutdown,
} }
e.Control.BaseURL = e.ControlServer.URL
return e return e
} }
@ -454,3 +518,22 @@ func runDERPAndStun(t testing.TB, logf logger.Logf) (derpMap *tailcfg.DERPMap, c
return m, cleanup return m, cleanup
} }
type authURLParserWriter struct {
buf bytes.Buffer
fn func(urlStr string) error
}
var authURLRx = regexp.MustCompile(`(https?://\S+/auth/\S+)`)
func (w *authURLParserWriter) Write(p []byte) (n int, err error) {
n, err = w.buf.Write(p)
m := authURLRx.FindSubmatch(w.buf.Bytes())
if m != nil {
urlStr := string(m[1])
if err := w.fn(urlStr); err != nil {
return 0, err
}
}
return n, err
}

View File

@ -17,6 +17,7 @@
"log" "log"
"math/rand" "math/rand"
"net/http" "net/http"
"net/url"
"strings" "strings"
"sync" "sync"
"time" "time"
@ -34,19 +35,39 @@
// Server is a control plane server. Its zero value is ready for use. // Server is a control plane server. Its zero value is ready for use.
// Everything is stored in-memory in one tailnet. // Everything is stored in-memory in one tailnet.
type Server struct { type Server struct {
Logf logger.Logf // nil means to use the log package Logf logger.Logf // nil means to use the log package
DERPMap *tailcfg.DERPMap // nil means to use prod DERP map DERPMap *tailcfg.DERPMap // nil means to use prod DERP map
RequireAuth bool
BaseURL string // must be set to e.g. "http://127.0.0.1:1234" with no trailing URL
initMuxOnce sync.Once initMuxOnce sync.Once
mux *http.ServeMux mux *http.ServeMux
mu sync.Mutex mu sync.Mutex
pubKey wgkey.Key pubKey wgkey.Key
privKey wgkey.Private privKey wgkey.Private
nodes map[tailcfg.NodeKey]*tailcfg.Node nodes map[tailcfg.NodeKey]*tailcfg.Node
users map[tailcfg.NodeKey]*tailcfg.User users map[tailcfg.NodeKey]*tailcfg.User
logins map[tailcfg.NodeKey]*tailcfg.Login logins map[tailcfg.NodeKey]*tailcfg.Login
updates map[tailcfg.NodeID]chan updateType updates map[tailcfg.NodeID]chan updateType
authPath map[string]*AuthPath
}
type AuthPath struct {
closeOnce sync.Once
ch chan struct{}
success bool
}
func (ap *AuthPath) completeSuccessfully() {
ap.success = true
close(ap.ch)
}
// CompleteSuccessfully completes the login path successfully, as if
// the user did the whole auth dance.
func (ap *AuthPath) CompleteSuccessfully() {
ap.closeOnce.Do(ap.completeSuccessfully)
} }
func (s *Server) logf(format string, a ...interface{}) { func (s *Server) logf(format string, a ...interface{}) {
@ -178,6 +199,48 @@ func (s *Server) getUser(nodeKey tailcfg.NodeKey) (*tailcfg.User, *tailcfg.Login
return user, login return user, login
} }
// authPathDone returns a close-only struct that's closed when the
// authPath ("/auth/XXXXXX") has authenticated.
func (s *Server) authPathDone(authPath string) <-chan struct{} {
s.mu.Lock()
defer s.mu.Unlock()
if a, ok := s.authPath[authPath]; ok {
return a.ch
}
return nil
}
func (s *Server) addAuthPath(authPath string) {
s.mu.Lock()
defer s.mu.Unlock()
if s.authPath == nil {
s.authPath = map[string]*AuthPath{}
}
s.authPath[authPath] = &AuthPath{
ch: make(chan struct{}),
}
}
// CompleteAuth marks the provided path or URL (containing
// "/auth/...") as successfully authenticated, unblocking any
// requests blocked on that in serveRegister.
func (s *Server) CompleteAuth(authPathOrURL string) bool {
i := strings.Index(authPathOrURL, "/auth/")
if i == -1 {
return false
}
authPath := authPathOrURL[i:]
s.mu.Lock()
defer s.mu.Unlock()
ap, ok := s.authPath[authPath]
if !ok {
return false
}
ap.CompleteSuccessfully()
return true
}
func (s *Server) serveRegister(w http.ResponseWriter, r *http.Request, mkey tailcfg.MachineKey) { func (s *Server) serveRegister(w http.ResponseWriter, r *http.Request, mkey tailcfg.MachineKey) {
var req tailcfg.RegisterRequest var req tailcfg.RegisterRequest
if err := s.decode(mkey, r.Body, &req); err != nil { if err := s.decode(mkey, r.Body, &req); err != nil {
@ -190,27 +253,57 @@ func (s *Server) serveRegister(w http.ResponseWriter, r *http.Request, mkey tail
panic("serveRegister: request has zero node key") panic("serveRegister: request has zero node key")
} }
// If this is a followup request, wait until interactive followup URL visit complete.
if req.Followup != "" {
followupURL, err := url.Parse(req.Followup)
if err != nil {
panic(err)
}
doneCh := s.authPathDone(followupURL.Path)
select {
case <-r.Context().Done():
return
case <-doneCh:
}
// TODO(bradfitz): support a side test API to mark an
// auth as failued so we can send an error response in
// some follow-ups? For now all are successes.
}
user, login := s.getUser(req.NodeKey) user, login := s.getUser(req.NodeKey)
s.mu.Lock() s.mu.Lock()
if s.nodes == nil { if s.nodes == nil {
s.nodes = map[tailcfg.NodeKey]*tailcfg.Node{} s.nodes = map[tailcfg.NodeKey]*tailcfg.Node{}
} }
machineAuthorized := true // TODO: add Server.RequireMachineAuth
s.nodes[req.NodeKey] = &tailcfg.Node{ s.nodes[req.NodeKey] = &tailcfg.Node{
ID: tailcfg.NodeID(user.ID), ID: tailcfg.NodeID(user.ID),
StableID: tailcfg.StableNodeID(fmt.Sprintf("TESTCTRL%08x", int(user.ID))), StableID: tailcfg.StableNodeID(fmt.Sprintf("TESTCTRL%08x", int(user.ID))),
User: user.ID, User: user.ID,
Machine: mkey, Machine: mkey,
Key: req.NodeKey, Key: req.NodeKey,
MachineAuthorized: true, MachineAuthorized: machineAuthorized,
} }
s.mu.Unlock() s.mu.Unlock()
authURL := ""
if s.RequireAuth {
machineAuthorized = false
randHex := make([]byte, 10)
crand.Read(randHex)
authPath := fmt.Sprintf("/auth/%x", randHex)
s.addAuthPath(authPath)
authURL = s.BaseURL + authPath
}
res, err := s.encode(mkey, false, tailcfg.RegisterResponse{ res, err := s.encode(mkey, false, tailcfg.RegisterResponse{
User: *user, User: *user,
Login: *login, Login: *login,
NodeKeyExpired: false, NodeKeyExpired: false,
MachineAuthorized: true, MachineAuthorized: machineAuthorized,
AuthURL: "", // all good; TODO(bradfitz): add ways to not start all good. AuthURL: authURL,
}) })
if err != nil { if err != nil {
go panic(fmt.Sprintf("serveRegister: encode: %v", err)) go panic(fmt.Sprintf("serveRegister: encode: %v", err))