diff --git a/.github/workflows/test-integration.yaml b/.github/workflows/test-integration.yaml index 45095e03..f2e2ee17 100644 --- a/.github/workflows/test-integration.yaml +++ b/.github/workflows/test-integration.yaml @@ -24,6 +24,7 @@ jobs: - TestPolicyUpdateWhileRunningWithCLIInDatabase - TestAuthKeyLogoutAndReloginSameUser - TestAuthKeyLogoutAndReloginNewUser + - TestAuthKeyLogoutAndReloginSameUserExpiredKey - TestOIDCAuthenticationPingAll - TestOIDCExpireNodesBasedOnTokenExpiry - TestOIDC024UserCreation @@ -68,6 +69,7 @@ jobs: - TestEnableDisableAutoApprovedRoute - TestAutoApprovedSubRoute2068 - TestSubnetRouteACL + - TestEnablingExitRoutes - TestHeadscale - TestCreateTailscale - TestTailscaleNodesJoiningHeadcale diff --git a/CHANGELOG.md b/CHANGELOG.md index 95c14d99..7d952c07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,12 +14,14 @@ - View of config, policy, filter, ssh policy per node, connected nodes and DERPmap -## 0.25.1 (2025-02-18) +## 0.25.1 (2025-02-24) ### Changes - Fix issue where registration errors are sent correctly [#2435](https://github.com/juanfont/headscale/pull/2435) +- Fix issue where routes passed on registration were not saved + [#2444](https://github.com/juanfont/headscale/pull/2444) ## 0.25.0 (2025-02-11) diff --git a/hscontrol/db/node.go b/hscontrol/db/node.go index 0c167856..c9244095 100644 --- a/hscontrol/db/node.go +++ b/hscontrol/db/node.go @@ -453,6 +453,10 @@ func RegisterNode(tx *gorm.DB, node types.Node, ipv4 *netip.Addr, ipv6 *netip.Ad return nil, fmt.Errorf("failed register(save) node in the database: %w", err) } + if _, err := SaveNodeRoutes(tx, &node); err != nil { + return nil, fmt.Errorf("failed to save node routes: %w", err) + } + log.Trace(). Caller(). Str("node", node.Hostname). diff --git a/hscontrol/db/node_test.go b/hscontrol/db/node_test.go index 7dc58819..fc5f6ac3 100644 --- a/hscontrol/db/node_test.go +++ b/hscontrol/db/node_test.go @@ -744,6 +744,7 @@ func TestRenameNode(t *testing.T) { Hostname: "test", UserID: user.ID, RegisterMethod: util.RegisterMethodAuthKey, + Hostinfo: &tailcfg.Hostinfo{}, } node2 := types.Node{ @@ -753,6 +754,7 @@ func TestRenameNode(t *testing.T) { Hostname: "test", UserID: user2.ID, RegisterMethod: util.RegisterMethodAuthKey, + Hostinfo: &tailcfg.Hostinfo{}, } err = db.DB.Save(&node).Error diff --git a/integration/route_test.go b/integration/route_test.go index 644cc992..32e49e7d 100644 --- a/integration/route_test.go +++ b/integration/route_test.go @@ -17,6 +17,8 @@ import ( "github.com/juanfont/headscale/integration/hsic" "github.com/juanfont/headscale/integration/tsic" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "tailscale.com/net/tsaddr" "tailscale.com/types/ipproto" "tailscale.com/types/views" "tailscale.com/wgengine/filter" @@ -1316,3 +1318,123 @@ func TestSubnetRouteACL(t *testing.T) { t.Errorf("Subnet (%s) filter, unexpected result (-want +got):\n%s", subRouter1.Hostname(), diff) } } + +// TestEnablingExitRoutes tests enabling exit routes for clients. +// Its more or less the same as TestEnablingRoutes, but with the --advertise-exit-node flag +// set during login instead of set. +func TestEnablingExitRoutes(t *testing.T) { + IntegrationSkip(t) + t.Parallel() + + user := "user2" + + scenario, err := NewScenario(dockertestMaxWait()) + assertNoErrf(t, "failed to create scenario: %s", err) + defer scenario.ShutdownAssertNoPanics(t) + + spec := map[string]int{ + user: 2, + } + + err = scenario.CreateHeadscaleEnv(spec, []tsic.Option{ + tsic.WithExtraLoginArgs([]string{"--advertise-exit-node"}), + }, hsic.WithTestName("clienableroute")) + assertNoErrHeadscaleEnv(t, err) + + allClients, err := scenario.ListTailscaleClients() + assertNoErrListClients(t, err) + + err = scenario.WaitForTailscaleSync() + assertNoErrSync(t, err) + + headscale, err := scenario.Headscale() + assertNoErrGetHeadscale(t, err) + + err = scenario.WaitForTailscaleSync() + assertNoErrSync(t, err) + + var routes []*v1.Route + err = executeAndUnmarshal( + headscale, + []string{ + "headscale", + "routes", + "list", + "--output", + "json", + }, + &routes, + ) + + assertNoErr(t, err) + assert.Len(t, routes, 4) + + for _, route := range routes { + assert.True(t, route.GetAdvertised()) + assert.False(t, route.GetEnabled()) + assert.False(t, route.GetIsPrimary()) + } + + // Verify that no routes has been sent to the client, + // they are not yet enabled. + for _, client := range allClients { + status, err := client.Status() + assertNoErr(t, err) + + for _, peerKey := range status.Peers() { + peerStatus := status.Peer[peerKey] + + assert.Nil(t, peerStatus.PrimaryRoutes) + } + } + + // Enable all routes + for _, route := range routes { + _, err = headscale.Execute( + []string{ + "headscale", + "routes", + "enable", + "--route", + strconv.Itoa(int(route.GetId())), + }) + assertNoErr(t, err) + } + + var enablingRoutes []*v1.Route + err = executeAndUnmarshal( + headscale, + []string{ + "headscale", + "routes", + "list", + "--output", + "json", + }, + &enablingRoutes, + ) + assertNoErr(t, err) + assert.Len(t, enablingRoutes, 4) + + for _, route := range enablingRoutes { + assert.True(t, route.GetAdvertised()) + assert.True(t, route.GetEnabled()) + } + + time.Sleep(5 * time.Second) + + // Verify that the clients can see the new routes + for _, client := range allClients { + status, err := client.Status() + assertNoErr(t, err) + + for _, peerKey := range status.Peers() { + peerStatus := status.Peer[peerKey] + + require.NotNil(t, peerStatus.AllowedIPs) + assert.Len(t, peerStatus.AllowedIPs.AsSlice(), 4) + assert.Contains(t, peerStatus.AllowedIPs.AsSlice(), tsaddr.AllIPv4()) + assert.Contains(t, peerStatus.AllowedIPs.AsSlice(), tsaddr.AllIPv6()) + } + } +} diff --git a/integration/tsic/tsic.go b/integration/tsic/tsic.go index c5a558cb..964b2662 100644 --- a/integration/tsic/tsic.go +++ b/integration/tsic/tsic.go @@ -80,6 +80,7 @@ type TailscaleInContainer struct { withExtraHosts []string workdir string netfilter string + extraLoginArgs []string // build options, solely for HEAD buildConfig TailscaleInContainerBuildConfig @@ -203,6 +204,14 @@ func WithBuildTag(tag string) Option { } } +// WithExtraLoginArgs adds additional arguments to the `tailscale up` command +// as part of the Login function. +func WithExtraLoginArgs(args []string) Option { + return func(tsic *TailscaleInContainer) { + tsic.extraLoginArgs = args + } +} + // New returns a new TailscaleInContainer instance. func New( pool *dockertest.Pool, @@ -436,6 +445,10 @@ func (t *TailscaleInContainer) Login( "--accept-routes=false", } + if t.extraLoginArgs != nil { + command = append(command, t.extraLoginArgs...) + } + if t.withSSH { command = append(command, "--ssh") } @@ -475,6 +488,10 @@ func (t *TailscaleInContainer) LoginWithURL( "--accept-routes=false", } + if t.extraLoginArgs != nil { + command = append(command, t.extraLoginArgs...) + } + stdout, stderr, err := t.Execute(command) if errors.Is(err, errTailscaleNotLoggedIn) { return nil, errTailscaleCannotUpWithoutAuthkey