diff --git a/tstest/integration/integration_test.go b/tstest/integration/integration_test.go index 922d6db3f..7a0d3bfcc 100644 --- a/tstest/integration/integration_test.go +++ b/tstest/integration/integration_test.go @@ -21,6 +21,7 @@ "os/exec" "path" "path/filepath" + "regexp" "runtime" "strings" "sync" @@ -57,11 +58,7 @@ func TestMain(m *testing.M) { os.Exit(0) } -func TestIntegration(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("not tested/working on Windows yet") - } - +func TestOneNodeUp_NoAuth(t *testing.T) { bins := buildTestBinaries(t) 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. // These can be shared by multiple nodes. type testBinaries struct { @@ -168,6 +228,9 @@ type testEnv struct { // // Call Close to shut everything down. 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) logc := new(logCatcher) control := &testcontrol.Server{ @@ -184,6 +247,7 @@ func newTestEnv(t testing.TB, bins *testBinaries) *testEnv { TrafficTrapServer: httptest.NewServer(trafficTrap), derpShutdown: derpShutdown, } + e.Control.BaseURL = e.ControlServer.URL return e } @@ -454,3 +518,22 @@ func runDERPAndStun(t testing.TB, logf logger.Logf) (derpMap *tailcfg.DERPMap, c 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 +} diff --git a/tstest/integration/testcontrol/testcontrol.go b/tstest/integration/testcontrol/testcontrol.go index fe3a12911..9ad44a22c 100644 --- a/tstest/integration/testcontrol/testcontrol.go +++ b/tstest/integration/testcontrol/testcontrol.go @@ -17,6 +17,7 @@ "log" "math/rand" "net/http" + "net/url" "strings" "sync" "time" @@ -34,19 +35,39 @@ // Server is a control plane server. Its zero value is ready for use. // Everything is stored in-memory in one tailnet. type Server struct { - Logf logger.Logf // nil means to use the log package - DERPMap *tailcfg.DERPMap // nil means to use prod DERP map + Logf logger.Logf // nil means to use the log package + 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 mux *http.ServeMux - mu sync.Mutex - pubKey wgkey.Key - privKey wgkey.Private - nodes map[tailcfg.NodeKey]*tailcfg.Node - users map[tailcfg.NodeKey]*tailcfg.User - logins map[tailcfg.NodeKey]*tailcfg.Login - updates map[tailcfg.NodeID]chan updateType + mu sync.Mutex + pubKey wgkey.Key + privKey wgkey.Private + nodes map[tailcfg.NodeKey]*tailcfg.Node + users map[tailcfg.NodeKey]*tailcfg.User + logins map[tailcfg.NodeKey]*tailcfg.Login + 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{}) { @@ -178,6 +199,48 @@ func (s *Server) getUser(nodeKey tailcfg.NodeKey) (*tailcfg.User, *tailcfg.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) { var req tailcfg.RegisterRequest 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") } + // 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) s.mu.Lock() if s.nodes == nil { s.nodes = map[tailcfg.NodeKey]*tailcfg.Node{} } + + machineAuthorized := true // TODO: add Server.RequireMachineAuth + s.nodes[req.NodeKey] = &tailcfg.Node{ ID: tailcfg.NodeID(user.ID), StableID: tailcfg.StableNodeID(fmt.Sprintf("TESTCTRL%08x", int(user.ID))), User: user.ID, Machine: mkey, Key: req.NodeKey, - MachineAuthorized: true, + MachineAuthorized: machineAuthorized, } 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{ User: *user, Login: *login, NodeKeyExpired: false, - MachineAuthorized: true, - AuthURL: "", // all good; TODO(bradfitz): add ways to not start all good. + MachineAuthorized: machineAuthorized, + AuthURL: authURL, }) if err != nil { go panic(fmt.Sprintf("serveRegister: encode: %v", err))