Repository: juanfont/headscale

Issue:
[Bug] autoApprove does not work from start
### Is this a support request?

- [x] This is not a support request

### Is there an existing issue for this?

- [x] I have searched the existing issues

### Current Behavior

Hello!
Sorry to bother but I think this fix did not help [2506](https://github.com/juanfont/headscale/pull/2506)

I was running Tailscale v1.82.5 on a Linux instance and Headscale which was builded from main branch (commit 9a4d0e1a99a5020d6198cf6751de89ff0b595792) as a controller on Linux as well.

My workaround for auto-approve looks like in this issue [2485](https://github.com/juanfont/headscale/issues/2485):

the tailscale client was started with --advertise-exit-node
then was started without (--advertise-exit-node=false)
then was started again with --advertise-exit-node

the first run the routes list looks like this
```
ID   | Hostname    | Approved        | Available       | Serving (Primary)
2412 | exit-node   |                 | 0.0.0.0/0, ::/0 |
```

without advertise exit node
```
ID   | Hostname    | Approved        | Available       | Serving (Primary)
2412 | exit-node   |                 |                 |
```

again with advertise exit node
```
ID   | Hostname    | Approved        | Available       | Serving (Primary)
2412 | exit-node   | 0.0.0.0/0, ::/0 | 0.0.0.0/0, ::/0 | 0.0.0.0/0, ::/0
```


### Expected Behavior

have the routes auto-enabled from start
```
ID   | Hostname    | Approved        | Available       | Serving (Primary)
2412 | exit-node   | 0.0.0.0/0, ::/0 | 0.0.0.0/0, ::/0 | 0.0.0.0/0, ::/0
```


### Steps To Reproduce

config
```
---
server_url: https://hs.example.org
listen_addr: 127.0.0.1:8080
metrics_listen_addr: 127.0.0.1:9090
grpc_listen_addr: 127.0.0.1:50443
grpc_allow_insecure: false

noise:
  private_key_path: /var/lib/headscale/noise_private.key

prefixes:
  v4: 100.64.0.0/10
  v6: fd7a:115c:a1e0::/48
  allocation: sequential

derp:
  server:
    enabled: false
    region_id: 999
    region_code: "headscale"
    region_name: "Headscale Embedded DERP"
    stun_listen_addr: "0.0.0.0:3478"
    private_key_path: /var/lib/headscale/derp_server_private.key
    automatically_add_embedded_derp_region: true
    ipv4: 1.2.3.4
    ipv6: 2001:db8::1
  urls:
    - https://controlplane.tailscale.com/derpmap/default
  paths: []
  auto_update_enabled: true
  update_frequency: 24h

disable_check_updates: true
ephemeral_node_inactivity_timeout: 30m

database:
  type: postgres
  gorm:
    prepare_stmt: true
    parameterized_queries: true
    skip_err_record_not_found: true
    slow_threshold: 1000

  postgres:
    host: localhost
    port: 5432
    name: headscale
    user: headscale
    pass: xxxxxxxxxx
    max_open_conns: 10
    max_idle_conns: 10
    conn_max_idle_time_secs: 3600

acme_url: https://acme-v02.api.letsencrypt.org/directory
acme_email: admin@example.org

tls_letsencrypt_hostname: ""
tls_letsencrypt_cache_dir: /var/lib/headscale/cache
tls_letsencrypt_challenge_type: HTTP-01
tls_letsencrypt_listen: ":http"
tls_cert_path: ""
tls_key_path: ""

log:
  format: text
  level: debug

policy:
  mode: file
  path: /etc/headscale/acl.hujson

dns:
  magic_dns: false
  base_domain: vpn

  nameservers:
    global:
      - 1.1.1.1
    split:
      {}
  search_domains: []
  extra_records: []

unix_socket: /var/run/headscale/headscale.sock
unix_socket_permission: "0770"

oidc:
  only_start_if_oidc_is_available: true
  issuer: https://issuer.example.org
  client_id: headscale
  client_secret: xxxxxxxxxx
  expiry: 1d
  use_expiry_from_token: false
  scope: ["openid", "profile", "email"]
  allowed_domains:
    - example.org

logtail:
  enabled: false

randomize_client_port: false
```

policy
```
{
  "groups": {
    "group:exit-nodes": [
      "exit-node"
    ]
  },
  "tagOwners": {
    "tag:exit-nodes": [
      "group:exit-nodes"
    ]
  },
  "autoApprovers": {
    "routes": {},
    "exitNode": [
      "tag:exit-nodes"
    ]
  },
  "acls": [
    {
      "action": "accept",
      "src": [
        "*"
      ],
      "dst": [
        "group:exit-nodes:0",
        "autogroup:internet:*"
      ]
    }
  ]
}
```

tailscale
```
tailscale up --login-server https://hs.example.org --hostname=exit-node --advertise-exit-node --accept-routes=false --accept-dns --snat-subnet-routes --advertise-tags=tag:exit-nodes
```


### Environment

```markdown
- OS: Ubuntu 24.04
- Headscale version: main branch (commit 9a4d0e1a99a5020d6198cf6751de89ff0b595792)
- Tailscale version: v1.82.5
```

### Runtime environment

- [x] Headscale is behind a (reverse) proxy
- [ ] Headscale runs in a container

### Debug information

route management is not logged
only this debug messages
```
Apr 24 17:35:27 xxxxxxxxxx headscale[1072116]: 2025-04-24T17:35:27Z DBG Expanding alias=group:exit-nodes
Apr 24 17:35:27 xxxxxxxxxx headscale[1072116]: 2025-04-24T17:35:27Z DBG Expanding alias=group:exit-nodes
Apr 24 17:35:27 xxxxxxxxxx headscale[1072116]: 2025-04-24T17:35:27Z DBG tags provided by policy authorised_tags=["tag:exit-nodes"] node.id=2412 unauthorised_tags=[]
Apr 24 17:35:27 xxxxxxxxxx headscale[1072116]: message repeated 2 times: [ 2025-04-24T17:35:27Z DBG tags provided by policy authorised_tags=["tag:exit-nodes"] node.id=2412 unauthorised_tags=[]]
Apr 24 17:35:33 xxxxxxxxxx headscale[1072116]: 2025-04-24T17:35:33Z DBG tags provided by policy authorised_tags=["tag:exit-nodes"] node.id=2412 unauthorised_tags=[]
Apr 24 17:37:24 xxxxxxxxxx headscale[1072116]: 2025-04-24T17:37:24Z DBG Expanding alias=group:exit-nodes
Apr 24 17:37:24 xxxxxxxxxx headscale[1072116]: 2025-04-24T17:37:24Z DBG Expanding alias=group:exit-nodes
Apr 24 17:37:24 xxxxxxxxxx headscale[1072116]: 2025-04-24T17:37:24Z DBG Expanding alias=tag:exit-nodes
Apr 24 17:37:24 xxxxxxxxxx headscale[1072116]: 2025-04-24T17:37:24Z DBG Expanding alias=tag:exit-nodes
Apr 24 17:37:24 xxxxxxxxxx headscale[1072116]: 2025-04-24T17:37:24Z DBG tags provided by policy authorised_tags=["tag:exit-nodes"] node.id=2412 unauthorised_tags=[]
Apr 24 17:37:24 xxxxxxxxxx headscale[1072116]: message repeated 2 times: [ 2025-04-24T17:37:24Z DBG tags provided by policy authorised_tags=["tag:exit-nodes"] node.id=2412 unauthorised_tags=[]]
Apr 24 17:37:27 xxxxxxxxxx headscale[1072116]: 2025-04-24T17:37:27Z DBG tags provided by policy authorised_tags=["tag:exit-nodes"] node.id=2412 unauthorised_tags=[]
```

Discussion:
@nblock: Thx for your report, a fix is in the works: https://github.com/juanfont/headscale/pull/2528

Solution patch:
diff --git a/cmd/headscale/cli/nodes.go b/cmd/headscale/cli/nodes.go
index 9234cc49..baa89820 100644
--- a/cmd/headscale/cli/nodes.go
+++ b/cmd/headscale/cli/nodes.go
@@ -78,7 +78,7 @@ func init() {
 		log.Fatal(err.Error())
 	}
 
-	moveNodeCmd.Flags().StringP("user", "u", "", "New user")
+	moveNodeCmd.Flags().Uint64P("user", "u", 0, "New user")
 
 	moveNodeCmd.Flags().StringP("namespace", "n", "", "User")
 	moveNodeNamespaceFlag := moveNodeCmd.Flags().Lookup("namespace")
@@ -470,7 +470,7 @@ var moveNodeCmd = &cobra.Command{
 			return
 		}
 
-		user, err := cmd.Flags().GetString("user")
+		user, err := cmd.Flags().GetUint64("user")
 		if err != nil {
 			ErrorOutput(
 				err,
diff --git a/gen/go/headscale/v1/node.pb.go b/gen/go/headscale/v1/node.pb.go
index 1c4f2e3c..db2817fc 100644
--- a/gen/go/headscale/v1/node.pb.go
+++ b/gen/go/headscale/v1/node.pb.go
@@ -1001,7 +1001,7 @@ func (x *ListNodesResponse) GetNodes() []*Node {
 type MoveNodeRequest struct {
 	state         protoimpl.MessageState `protogen:"open.v1"`
 	NodeId        uint64                 `protobuf:"varint,1,opt,name=node_id,json=nodeId,proto3" json:"node_id,omitempty"`
-	User          string                 `protobuf:"bytes,2,opt,name=user,proto3" json:"user,omitempty"`
+	User          uint64                 `protobuf:"varint,2,opt,name=user,proto3" json:"user,omitempty"`
 	unknownFields protoimpl.UnknownFields
 	sizeCache     protoimpl.SizeCache
 }
@@ -1043,11 +1043,11 @@ func (x *MoveNodeRequest) GetNodeId() uint64 {
 	return 0
 }
 
-func (x *MoveNodeRequest) GetUser() string {
+func (x *MoveNodeRequest) GetUser() uint64 {
 	if x != nil {
 		return x.User
 	}
-	return ""
+	return 0
 }
 
 type MoveNodeResponse struct {
@@ -1365,7 +1365,7 @@ const file_headscale_v1_node_proto_rawDesc = "" +
 	"\x05nodes\x18\x01 \x03(\v2\x12.headscale.v1.NodeR\x05nodes\">\n" +
 	"\x0fMoveNodeRequest\x12\x17\n" +
 	"\anode_id\x18\x01 \x01(\x04R\x06nodeId\x12\x12\n" +
-	"\x04user\x18\x02 \x01(\tR\x04user\":\n" +
+	"\x04user\x18\x02 \x01(\x04R\x04user\":\n" +
 	"\x10MoveNodeResponse\x12&\n" +
 	"\x04node\x18\x01 \x01(\v2\x12.headscale.v1.NodeR\x04node\"j\n" +
 	"\x16DebugCreateNodeRequest\x12\x12\n" +
diff --git a/gen/openapiv2/headscale/v1/headscale.swagger.json b/gen/openapiv2/headscale/v1/headscale.swagger.json
index e75b7ee2..c55dc077 100644
--- a/gen/openapiv2/headscale/v1/headscale.swagger.json
+++ b/gen/openapiv2/headscale/v1/headscale.swagger.json
@@ -800,7 +800,8 @@
       "type": "object",
       "properties": {
         "user": {
-          "type": "string"
+          "type": "string",
+          "format": "uint64"
         }
       }
     },
diff --git a/hscontrol/auth.go b/hscontrol/auth.go
index 08de1235..941b51b2 100644
--- a/hscontrol/auth.go
+++ b/hscontrol/auth.go
@@ -213,9 +213,6 @@ func (h *Headscale) handleRegisterWithAuthKey(
 		nodeToRegister.Expiry = &regReq.Expiry
 	}
 
-	// Ensure any auto approved routes are handled before saving.
-	policy.AutoApproveRoutes(h.polMan, &nodeToRegister)
-
 	ipv4, ipv6, err := h.ipAlloc.Next()
 	if err != nil {
 		return nil, fmt.Errorf("allocating IPs: %w", err)
@@ -248,7 +245,23 @@ func (h *Headscale) handleRegisterWithAuthKey(
 		return nil, fmt.Errorf("nodes changed hook: %w", err)
 	}
 
-	if !updateSent {
+	// This is a bit of a back and forth, but we have a bit of a chicken and egg
+	// dependency here.
+	// Because the way the policy manager works, we need to have the node
+	// in the database, then add it to the policy manager and then we can
+	// approve the route. This means we get this dance where the node is
+	// first added to the database, then we add it to the policy manager via
+	// nodesChangedHook and then we can auto approve the routes.
+	// As that only approves the struct object, we need to save it again and
+	// ensure we send an update.
+	// This works, but might be another good candidate for doing some sort of
+	// eventbus.
+	routesChanged := policy.AutoApproveRoutes(h.polMan, node)
+	if err := h.db.DB.Save(node).Error; err != nil {
+		return nil, fmt.Errorf("saving auto approved routes to node: %w", err)
+	}
+
+	if !updateSent || routesChanged {
 		ctx := types.NotifyCtx(context.Background(), "node updated", node.Hostname)
 		h.nodeNotifier.NotifyAll(ctx, types.UpdatePeerChanged(node.ID))
 	}
@@ -285,9 +298,6 @@ func (h *Headscale) handleRegisterInteractive(
 		nodeToRegister.Node.Expiry = &regReq.Expiry
 	}
 
-	// Ensure any auto approved routes are handled before saving.
-	policy.AutoApproveRoutes(h.polMan, &nodeToRegister.Node)
-
 	h.registrationCache.Set(
 		registrationId,
 		nodeToRegister,
diff --git a/hscontrol/grpcv1.go b/hscontrol/grpcv1.go
index d924a1fb..8b516c3e 100644
--- a/hscontrol/grpcv1.go
+++ b/hscontrol/grpcv1.go
@@ -268,7 +268,24 @@ func (api headscaleV1APIServer) RegisterNode(
 	if err != nil {
 		return nil, fmt.Errorf("updating resources using node: %w", err)
 	}
-	if !updateSent {
+
+	// This is a bit of a back and forth, but we have a bit of a chicken and egg
+	// dependency here.
+	// Because the way the policy manager works, we need to have the node
+	// in the database, then add it to the policy manager and then we can
+	// approve the route. This means we get this dance where the node is
+	// first added to the database, then we add it to the policy manager via
+	// nodesChangedHook and then we can auto approve the routes.
+	// As that only approves the struct object, we need to save it again and
+	// ensure we send an update.
+	// This works, but might be another good candidate for doing some sort of
+	// eventbus.
+	routesChanged := policy.AutoApproveRoutes(api.h.polMan, node)
+	if err := api.h.db.DB.Save(node).Error; err != nil {
+		return nil, fmt.Errorf("saving auto approved routes to node: %w", err)
+	}
+
+	if !updateSent || routesChanged {
 		ctx = types.NotifyCtx(context.Background(), "web-node-login", node.Hostname)
 		api.h.nodeNotifier.NotifyAll(ctx, types.UpdatePeerChanged(node.ID))
 	}
@@ -547,22 +564,31 @@ func (api headscaleV1APIServer) MoveNode(
 	ctx context.Context,
 	request *v1.MoveNodeRequest,
 ) (*v1.MoveNodeResponse, error) {
-	// TODO(kradalby): This should be done in one tx.
-	node, err := api.h.db.GetNodeByID(types.NodeID(request.GetNodeId()))
-	if err != nil {
-		return nil, err
-	}
+	node, err := db.Write(api.h.db.DB, func(tx *gorm.DB) (*types.Node, error) {
+		node, err := db.GetNodeByID(tx, types.NodeID(request.GetNodeId()))
+		if err != nil {
+			return nil, err
+		}
 
-	user, err := api.h.db.GetUserByName(request.GetUser())
-	if err != nil {
-		return nil, err
-	}
+		err = db.AssignNodeToUser(tx, node, types.UserID(request.GetUser()))
+		if err != nil {
+			return nil, err
+		}
 
-	err = api.h.db.AssignNodeToUser(node, types.UserID(user.ID))
+		return node, nil
+	})
 	if err != nil {
 		return nil, err
 	}
 
+	ctx = types.NotifyCtx(ctx, "cli-movenode-self", node.Hostname)
+	api.h.nodeNotifier.NotifyByNodeID(
+		ctx,
+		types.UpdateSelf(node.ID),
+		node.ID)
+	ctx = types.NotifyCtx(ctx, "cli-movenode", node.Hostname)
+	api.h.nodeNotifier.NotifyWithIgnore(ctx, types.UpdatePeerChanged(node.ID), node.ID)
+
 	return &v1.MoveNodeResponse{Node: node.Proto()}, nil
 }
 
diff --git a/hscontrol/oidc.go b/hscontrol/oidc.go
index e566d64a..ad2b0fba 100644
--- a/hscontrol/oidc.go
+++ b/hscontrol/oidc.go
@@ -528,7 +528,23 @@ func (a *AuthProviderOIDC) handleRegistration(
 		return false, fmt.Errorf("updating resources using node: %w", err)
 	}
 
-	if !updateSent {
+	// This is a bit of a back and forth, but we have a bit of a chicken and egg
+	// dependency here.
+	// Because the way the policy manager works, we need to have the node
+	// in the database, then add it to the policy manager and then we can
+	// approve the route. This means we get this dance where the node is
+	// first added to the database, then we add it to the policy manager via
+	// nodesChangedHook and then we can auto approve the routes.
+	// As that only approves the struct object, we need to save it again and
+	// ensure we send an update.
+	// This works, but might be another good candidate for doing some sort of
+	// eventbus.
+	routesChanged := policy.AutoApproveRoutes(a.polMan, node)
+	if err := a.db.DB.Save(node).Error; err != nil {
+		return false, fmt.Errorf("saving auto approved routes to node: %w", err)
+	}
+
+	if !updateSent || routesChanged {
 		ctx := types.NotifyCtx(context.Background(), "oidc-expiry-self", node.Hostname)
 		a.notifier.NotifyByNodeID(
 			ctx,
diff --git a/proto/headscale/v1/node.proto b/proto/headscale/v1/node.proto
index 1b6021ce..89d2c347 100644
--- a/proto/headscale/v1/node.proto
+++ b/proto/headscale/v1/node.proto
@@ -99,7 +99,7 @@ message ListNodesResponse { repeated Node nodes = 1; }
 
 message MoveNodeRequest {
   uint64 node_id = 1;
-  string user = 2;
+  uint64 user = 2;
 }
 
 message MoveNodeResponse { Node node = 1; }



Test patch:
diff --git a/integration/cli_test.go b/integration/cli_test.go
index a474f5c2..435b7e55 100644
--- a/integration/cli_test.go
+++ b/integration/cli_test.go
@@ -1580,6 +1580,9 @@ func TestNodeMoveCommand(t *testing.T) {
 	// Randomly generated node key
 	regID := types.MustRegistrationID()
 
+	userMap, err := headscale.MapUsers()
+	assertNoErr(t, err)
+
 	_, err = headscale.Execute(
 		[]string{
 			"headscale",
@@ -1628,9 +1631,9 @@ func TestNodeMoveCommand(t *testing.T) {
 			"nodes",
 			"move",
 			"--identifier",
-			nodeID,
+			strconv.FormatUint(node.GetId(), 10),
 			"--user",
-			"new-user",
+			strconv.FormatUint(userMap["new-user"].GetId(), 10),
 			"--output",
 			"json",
 		},
@@ -1668,7 +1671,7 @@ func TestNodeMoveCommand(t *testing.T) {
 			"--identifier",
 			nodeID,
 			"--user",
-			"non-existing-user",
+			"999",
 			"--output",
 			"json",
 		},
@@ -1689,7 +1692,7 @@ func TestNodeMoveCommand(t *testing.T) {
 			"--identifier",
 			nodeID,
 			"--user",
-			"old-user",
+			strconv.FormatUint(userMap["old-user"].GetId(), 10),
 			"--output",
 			"json",
 		},
@@ -1708,7 +1711,7 @@ func TestNodeMoveCommand(t *testing.T) {
 			"--identifier",
 			nodeID,
 			"--user",
-			"old-user",
+			strconv.FormatUint(userMap["old-user"].GetId(), 10),
 			"--output",
 			"json",
 		},
diff --git a/integration/route_test.go b/integration/route_test.go
index 8801767d..e4b6239b 100644
--- a/integration/route_test.go
+++ b/integration/route_test.go
@@ -1654,6 +1654,11 @@ func TestAutoApproveMultiNetwork(t *testing.T) {
 					assertNoErrGetHeadscale(t, err)
 					assert.NotNil(t, headscale)
 
+					// Set the route of usernet1 to be autoapproved
+					tt.pol.AutoApprovers.Routes[route.String()] = []string{tt.approver}
+					err = headscale.SetPolicy(tt.pol)
+					require.NoError(t, err)
+
 					if advertiseDuringUp {
 						tsOpts = append(tsOpts,
 							tsic.WithExtraLoginArgs([]string{"--advertise-routes=" + route.String()}),
@@ -1691,11 +1696,6 @@ func TestAutoApproveMultiNetwork(t *testing.T) {
 					}
 					// extra creation end.
 
-					// Set the route of usernet1 to be autoapproved
-					tt.pol.AutoApprovers.Routes[route.String()] = []string{tt.approver}
-					err = headscale.SetPolicy(tt.pol)
-					require.NoError(t, err)
-
 					routerUsernet1ID := routerUsernet1.MustID()
 
 					web := services[0]