diff --git a/integration/auth_approval_test.go b/integration/auth_approval_test.go new file mode 100644 index 00000000..f6a1fe51 --- /dev/null +++ b/integration/auth_approval_test.go @@ -0,0 +1,281 @@ +package integration + +import ( + "context" + "fmt" + v1 "github.com/juanfont/headscale/gen/go/headscale/v1" + "github.com/juanfont/headscale/integration/hsic" + "github.com/samber/lo" + "github.com/stretchr/testify/assert" + "io" + "log" + "net/http" + "net/netip" + "net/url" + "strings" + "testing" +) + +type AuthApprovalScenario struct { + *Scenario +} + +func TestAuthNodeApproval(t *testing.T) { + IntegrationSkip(t) + t.Parallel() + + baseScenario, err := NewScenario(dockertestMaxWait()) + assertNoErr(t, err) + + scenario := AuthApprovalScenario{ + Scenario: baseScenario, + } + defer scenario.ShutdownAssertNoPanics(t) + + spec := map[string]int{ + "user1": len(MustTestVersions), + } + + err = scenario.CreateHeadscaleEnv( + spec, + hsic.WithTestName("approval"), + hsic.WithManualApproveNewNode(), + ) + assertNoErrHeadscaleEnv(t, err) + + allClients, err := scenario.ListTailscaleClients() + assertNoErrListClients(t, err) + + err = scenario.WaitForTailscaleSyncWithPeerCount(0) + assertNoErrSync(t, err) + + for _, client := range allClients { + status, err := client.Status() + assertNoErr(t, err) + assert.Equal(t, "NeedsMachineAuth", status.BackendState) + assert.Len(t, status.Peers(), 0) + } + + headscale, err := scenario.Headscale() + assertNoErr(t, err) + + var allNodes []*v1.Node + err = executeAndUnmarshal( + headscale, + []string{ + "headscale", + "nodes", + "list", + "--output", + "json", + }, + &allNodes, + ) + assert.Nil(t, err) + + for _, node := range allNodes { + _, err = headscale.Execute([]string{ + "headscale", "nodes", "approve", "--identifier", fmt.Sprintf("%d", node.Id), + }) + assertNoErr(t, err) + } + + for _, client := range allClients { + err = client.Logout() + if err != nil { + t.Fatalf("failed to logout client %s: %s", client.Hostname(), err) + } + } + + err = scenario.WaitForTailscaleLogout() + assertNoErrLogout(t, err) + + t.Logf("all clients logged out") + + for userName := range spec { + err = scenario.runTailscaleUp(userName, headscale.GetEndpoint(), true) + if err != nil { + t.Fatalf("failed to run tailscale up: %s", err) + } + } + + t.Logf("all clients logged in again") + + allClients, err = scenario.ListTailscaleClients() + assertNoErrListClients(t, err) + + allIps, err := scenario.ListTailscaleClientsIPs() + assertNoErrListClientIPs(t, err) + + err = scenario.WaitForTailscaleSync() + assertNoErrSync(t, err) + + //assertClientsState(t, allClients) + + allAddrs := lo.Map(allIps, func(x netip.Addr, index int) string { + return x.String() + }) + + success := pingAllHelper(t, allClients, allAddrs) + t.Logf("before expire: %d successful pings out of %d", success, len(allClients)*len(allIps)) + + for _, client := range allClients { + status, err := client.Status() + assertNoErr(t, err) + + // Assert that we have the original count - self + assert.Len(t, status.Peers(), len(MustTestVersions)-1) + } +} + +func (s *AuthApprovalScenario) CreateHeadscaleEnv( + users map[string]int, + opts ...hsic.Option, +) error { + headscale, err := s.Headscale(opts...) + if err != nil { + return err + } + + err = headscale.WaitForRunning() + if err != nil { + return err + } + + for userName, clientCount := range users { + log.Printf("creating user %s with %d clients", userName, clientCount) + err = s.CreateUser(userName) + if err != nil { + return err + } + + err = s.CreateTailscaleNodesInUser(userName, "all", clientCount) + if err != nil { + return err + } + + err = s.runTailscaleUp(userName, headscale.GetEndpoint(), false) + if err != nil { + return err + } + } + + return nil +} + +func (s *AuthApprovalScenario) runTailscaleUp( + userStr, loginServer string, + withApproved bool, +) error { + log.Printf("running tailscale up for user %s", userStr) + if user, ok := s.users[userStr]; ok { + for _, client := range user.Clients { + c := client + user.joinWaitGroup.Go(func() error { + loginURL, err := c.LoginWithURL(loginServer) + if err != nil { + log.Printf("failed to run tailscale up (%s): %s", c.Hostname(), err) + + return err + } + + err = s.runHeadscaleRegister(userStr, loginURL) + if err != nil { + log.Printf("failed to register client (%s): %s", c.Hostname(), err) + + return err + } + + return nil + }) + + if withApproved { + err := client.WaitForRunning() + if err != nil { + log.Printf("error waiting for client %s to be approval: %s", client.Hostname(), err) + } + } else { + err := client.WaitForNeedsApprove() + if err != nil { + log.Printf("error waiting for client %s to be approval: %s", client.Hostname(), err) + } + } + } + + if err := user.joinWaitGroup.Wait(); err != nil { + return err + } + + for _, client := range user.Clients { + if withApproved { + err := client.WaitForRunning() + if err != nil { + return fmt.Errorf("%s failed to up tailscale node: %w", client.Hostname(), err) + } + } else { + err := client.WaitForNeedsApprove() + if err != nil { + return fmt.Errorf("%s failed to up tailscale node: %w", client.Hostname(), err) + } + } + } + + return nil + } + + return fmt.Errorf("failed to up tailscale node: %w", errNoUserAvailable) +} + +func (s *AuthApprovalScenario) runHeadscaleRegister(userStr string, loginURL *url.URL) error { + headscale, err := s.Headscale() + if err != nil { + return err + } + + log.Printf("loginURL: %s", loginURL) + loginURL.Host = fmt.Sprintf("%s:8080", headscale.GetIP()) + loginURL.Scheme = "http" + + httpClient := &http.Client{} + ctx := context.Background() + req, _ := http.NewRequestWithContext(ctx, http.MethodGet, loginURL.String(), nil) + resp, err := httpClient.Do(req) + if err != nil { + return err + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + + defer resp.Body.Close() + + // see api.go HTML template + codeSep := strings.Split(string(body), "") + if len(codeSep) != 2 { + return errParseAuthPage + } + + keySep := strings.Split(codeSep[0], "key ") + if len(keySep) != 2 { + return errParseAuthPage + } + key := keySep[1] + log.Printf("registering node %s", key) + + if headscale, err := s.Headscale(); err == nil { + _, err = headscale.Execute( + []string{"headscale", "nodes", "register", "--user", userStr, "--key", key}, + ) + if err != nil { + log.Printf("failed to register node: %s", err) + + return err + } + + return nil + } + + return fmt.Errorf("failed to find headscale: %w", errNoHeadscaleAvailable) +} diff --git a/integration/cli_test.go b/integration/cli_test.go index 9def16f7..2ed69f51 100644 --- a/integration/cli_test.go +++ b/integration/cli_test.go @@ -390,6 +390,62 @@ func TestPreAuthKeyCommandReusableEphemeral(t *testing.T) { assert.Len(t, listedPreAuthKeys, 3) } +func TestPreAuthKeyCommandPreApproved(t *testing.T) { + IntegrationSkip(t) + t.Parallel() + + user := "pre-auth-key-pre-approved-user" + + scenario, err := NewScenario(dockertestMaxWait()) + assertNoErr(t, err) + defer scenario.ShutdownAssertNoPanics(t) + + spec := map[string]int{ + user: 0, + } + + err = scenario.CreateHeadscaleEnv(spec, []tsic.Option{}, hsic.WithTestName("clipakresueeph")) + assertNoErr(t, err) + + headscale, err := scenario.Headscale() + assertNoErr(t, err) + + var preAuthGeneralKey v1.PreAuthKey + err = executeAndUnmarshal( + headscale, + []string{ + "headscale", + "preauthkeys", + "--user", + user, + "create", + "--output", + "json", + }, + &preAuthGeneralKey, + ) + assertNoErr(t, err) + assert.False(t, preAuthGeneralKey.GetPreApproved()) + + var preAuthPreApprovedKey v1.PreAuthKey + err = executeAndUnmarshal( + headscale, + []string{ + "headscale", + "preauthkeys", + "--user", + user, + "create", + "--pre-approved", + "--output", + "json", + }, + &preAuthPreApprovedKey, + ) + assertNoErr(t, err) + assert.True(t, preAuthPreApprovedKey.GetPreApproved()) +} + func TestPreAuthKeyCorrectUserLoggedInCommand(t *testing.T) { IntegrationSkip(t) t.Parallel() diff --git a/integration/control.go b/integration/control.go index b5699577..af67edb2 100644 --- a/integration/control.go +++ b/integration/control.go @@ -16,7 +16,7 @@ type ControlServer interface { GetEndpoint() string WaitForRunning() error CreateUser(user string) error - CreateAuthKey(user string, reusable bool, ephemeral bool) (*v1.PreAuthKey, error) + CreateAuthKey(user string, reusable bool, preApproved bool, ephemeral bool) (*v1.PreAuthKey, error) ListNodesInUser(user string) ([]*v1.Node, error) GetCert() []byte GetHostname() string diff --git a/integration/embedded_derp_test.go b/integration/embedded_derp_test.go index d5fdb161..c5a4ad85 100644 --- a/integration/embedded_derp_test.go +++ b/integration/embedded_derp_test.go @@ -254,7 +254,7 @@ func (s *EmbeddedDERPServerScenario) CreateHeadscaleEnv( } } - key, err := s.CreatePreAuthKey(userName, true, false) + key, err := s.CreatePreAuthKey(userName, true, true, false) if err != nil { return err } diff --git a/integration/general_test.go b/integration/general_test.go index 985c9529..90f8e96a 100644 --- a/integration/general_test.go +++ b/integration/general_test.go @@ -176,7 +176,7 @@ func TestAuthKeyLogoutAndRelogin(t *testing.T) { } for userName := range spec { - key, err := scenario.CreatePreAuthKey(userName, true, false) + key, err := scenario.CreatePreAuthKey(userName, true, true, false) if err != nil { t.Fatalf("failed to create pre-auth key for user %s: %s", userName, err) } @@ -279,7 +279,7 @@ func testEphemeralWithOptions(t *testing.T, opts ...hsic.Option) { t.Fatalf("failed to create tailscale nodes in user %s: %s", userName, err) } - key, err := scenario.CreatePreAuthKey(userName, true, true) + key, err := scenario.CreatePreAuthKey(userName, true, true, true) if err != nil { t.Fatalf("failed to create pre-auth key for user %s: %s", userName, err) } @@ -369,7 +369,7 @@ func TestEphemeral2006DeletedTooQuickly(t *testing.T) { t.Fatalf("failed to create tailscale nodes in user %s: %s", userName, err) } - key, err := scenario.CreatePreAuthKey(userName, true, true) + key, err := scenario.CreatePreAuthKey(userName, true, true, true) if err != nil { t.Fatalf("failed to create pre-auth key for user %s: %s", userName, err) } diff --git a/integration/hsic/hsic.go b/integration/hsic/hsic.go index a008d9d5..2a6848cf 100644 --- a/integration/hsic/hsic.go +++ b/integration/hsic/hsic.go @@ -259,6 +259,13 @@ func WithTuning(batchTimeout time.Duration, mapSessionChanSize int) Option { } } +// WithManualApproveNewNode allows devices to access the network only after manual approval +func WithManualApproveNewNode() Option { + return func(hsic *HeadscaleInContainer) { + hsic.env["HEADSCALE_NODE_MANAGEMENT_MANUAL_APPROVE_NEW_NODE"] = "true" + } +} + func WithTimezone(timezone string) Option { return func(hsic *HeadscaleInContainer) { hsic.env["TZ"] = timezone @@ -720,6 +727,7 @@ func (t *HeadscaleInContainer) CreateUser( func (t *HeadscaleInContainer) CreateAuthKey( user string, reusable bool, + preApproved bool, ephemeral bool, ) (*v1.PreAuthKey, error) { command := []string{ @@ -738,6 +746,10 @@ func (t *HeadscaleInContainer) CreateAuthKey( command = append(command, "--reusable") } + if preApproved { + command = append(command, "--pre-approved") + } + if ephemeral { command = append(command, "--ephemeral") } diff --git a/integration/scenario.go b/integration/scenario.go index 801987af..854bcde4 100644 --- a/integration/scenario.go +++ b/integration/scenario.go @@ -301,10 +301,11 @@ func (s *Scenario) Headscale(opts ...hsic.Option) (ControlServer, error) { func (s *Scenario) CreatePreAuthKey( user string, reusable bool, + preApproved bool, ephemeral bool, ) (*v1.PreAuthKey, error) { if headscale, err := s.Headscale(); err == nil { - key, err := headscale.CreateAuthKey(user, reusable, ephemeral) + key, err := headscale.CreateAuthKey(user, reusable, preApproved, ephemeral) if err != nil { return nil, fmt.Errorf("failed to create user: %w", err) } @@ -513,7 +514,7 @@ func (s *Scenario) CreateHeadscaleEnv( return err } - key, err := s.CreatePreAuthKey(userName, true, false) + key, err := s.CreatePreAuthKey(userName, true, true, false) if err != nil { return err } diff --git a/integration/scenario_test.go b/integration/scenario_test.go index aec6cb5c..caf11c90 100644 --- a/integration/scenario_test.go +++ b/integration/scenario_test.go @@ -61,7 +61,7 @@ func TestHeadscale(t *testing.T) { }) t.Run("create-auth-key", func(t *testing.T) { - _, err := scenario.CreatePreAuthKey(user, true, false) + _, err := scenario.CreatePreAuthKey(user, true, true, false) if err != nil { t.Fatalf("failed to create preauthkey: %s", err) } @@ -153,7 +153,7 @@ func TestTailscaleNodesJoiningHeadcale(t *testing.T) { }) t.Run("join-headscale", func(t *testing.T) { - key, err := scenario.CreatePreAuthKey(user, true, false) + key, err := scenario.CreatePreAuthKey(user, true, true, false) if err != nil { t.Fatalf("failed to create preauthkey: %s", err) } diff --git a/integration/tailscale.go b/integration/tailscale.go index 66cc1ca3..7f5f177e 100644 --- a/integration/tailscale.go +++ b/integration/tailscale.go @@ -33,6 +33,7 @@ type TailscaleClient interface { DebugDERPRegion(region string) (*ipnstate.DebugDERPRegionReport, error) Netcheck() (*netcheck.Report, error) WaitForNeedsLogin() error + WaitForNeedsApprove() error WaitForRunning() error WaitForPeers(expected int) error Ping(hostnameOrIP string, opts ...tsic.PingOption) error diff --git a/integration/tsic/tsic.go b/integration/tsic/tsic.go index 023cc430..b37dcb44 100644 --- a/integration/tsic/tsic.go +++ b/integration/tsic/tsic.go @@ -848,6 +848,28 @@ func (t *TailscaleInContainer) WaitForNeedsLogin() error { }) } +// WaitForNeedsApprove blocks until the Tailscale (tailscaled) instance is logged in +// and gets approved. +func (t *TailscaleInContainer) WaitForNeedsApprove() error { + return t.pool.Retry(func() error { + status, err := t.Status() + if err != nil { + return errTailscaleStatus(t.hostname, err) + } + + // ipnstate.Status.CurrentTailnet was added in Tailscale 1.22.0 + // https://github.com/tailscale/tailscale/pull/3865 + // + // Before that, we can check the BackendState to see if the + // tailscaled daemon is connected to the control system. + if status.BackendState == "NeedsMachineAuth" { + return nil + } + + return errTailscaledNotReadyForLogin + }) +} + // WaitForRunning blocks until the Tailscale (tailscaled) instance is logged in // and ready to be used. func (t *TailscaleInContainer) WaitForRunning() error {