Repository: juanfont/headscale

Issue:
[Bug] autoApprove does not work for 0.0.0.0/0 and ::/0
### 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,
I hope you are doing well!

Sorry to bother but I think the fix #2550 did not help and broke workaround.

I was running Tailscale v1.82.5 on a Linux instance and Headscale v0.26.0-beta.1 as a controller on Linux as well.

The Tailscale client was started like this
```
tailscale up --login-server https://hsc.example.org --hostname='router-01' --advertise-exit-node=false --accept-routes=false --accept-dns=false --snat-subnet-routes=true --advertise-tags='tag:routers-vpn,tag:internet-routers' --advertise-routes='10.99.0.0/16,104.16.208.203/32,104.19.159.190/32,10.99.0.2/32'
```
Routers list looks like this
```
ID   | Hostname  | Approved | Available                                                        | Serving (Primary)
2414 | router-01 |          | 10.99.0.0/16, 10.99.0.2/32, 104.16.208.203/32, 104.19.159.190/32 |
```

Then was started with empty advertise routes and again like this
```
tailscale up --login-server https://hsc.example.org --hostname='router-01' --advertise-exit-node=false --accept-routes=false --accept-dns=false --snat-subnet-routes=true --advertise-tags='tag:routers-vpn,tag:internet-routers' --advertise-routes='10.99.0.0/16,104.16.208.203/32,104.19.159.190/32,10.99.0.2/32'
```
Routers list looks like this
```
ID   | Hostname  | Approved                   | Available                                                        | Serving (Primary)
2414 | router-01 | 10.99.0.0/16, 10.99.0.2/32 | 10.99.0.0/16, 10.99.0.2/32, 104.16.208.203/32, 104.19.159.190/32 | 10.99.0.0/16, 10.99.0.2/32
```

Routes to 104.16.208.203/32 and 104.19.159.190/32 was not approved.

autoApprove in ACL looks like this
```
...
"autoApprovers": {
    "routes": {
      "10.99.0.0/16": [
        "tag:routers-vpn"
      ],
      "0.0.0.0/0": [
        "tag:internet-routers"
      ],
      "::/0": [
        "tag:internet-routers"
      ]
    },
    "exitNode": [
      "tag:exit-nodes"
    ]
},
...
```

### Expected Behavior

Have the routes auto-enabled from start
```
ID   | Hostname  | Approved                                                         | Available                                                        | Serving (Primary)
2414 | router-01 | 10.99.0.0/16, 10.99.0.2/32, 104.16.208.203/32, 104.19.159.190/32 | 10.99.0.0/16, 10.99.0.2/32, 104.16.208.203/32, 104.19.159.190/32 | 10.99.0.0/16, 10.99.0.2/32, 104.16.208.203/32, 104.19.159.190/32
```

### Steps To Reproduce

Config
```
---
server_url: https://hsc.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:users": [
      "user1@example.org",
      "user2@example.org"
    ],
    "group:exit-nodes": [
      "exit-node@"
    ],
    "group:internet-routers": [
      "router-01@",
      "router-02@"
    ],
    "group:routers-vpn": [
      "router-01@",
      "router-02@"
    ]
  },
  "tagOwners": {
    "tag:routers-vpn": [
      "group:routers-vpn"
    ],
    "tag:internet-routers": [
      "group:internet-routers"
    ],
    "tag:exit-nodes": [
      "group:exit-nodes"
    ]
  },
  "autoApprovers": {
    "routes": {
      "10.99.0.0/16": [
        "tag:routers-vpn"
      ],
      "0.0.0.0/0": [
        "tag:internet-routers"
      ],
      "::/0": [
        "tag:internet-routers"
      ]
    },
    "exitNode": [
      "tag:exit-nodes"
    ]
  },
  "hosts": {
    "vpn-dns": "10.99.0.2/32",
    "vpn-network": "10.99.0.0/16"
  },
  "acls": [
    {
      "action": "accept",
      "src": [
        "*"
      ],
      "dst": [
        "vpn-dns:53",
        "0.0.0.0:53",
        "group:routers-vpn:0"
      ]
    },
    {
      "action": "accept",
      "src": [
        "group:users"
      ],
      "dst": [
        "group:exit-nodes:0",
        "autogroup:internet:*"
      ]
    },
    {
      "action": "accept",
      "src": [
        "group:users"
      ],
      "dst": [
        "group:routers-vpn:0",
        "vpn-network:*",
        "104.16.208.203:*",
        "104.19.159.190:*"
      ]
    }
  ]
}
```

Tailscale
```
tailscale up --login-server https://hsc.example.org --hostname='router-01' --advertise-exit-node=false --accept-routes=false --accept-dns=false --snat-subnet-routes=true --advertise-tags='tag:routers-vpn,tag:internet-routers' --advertise-routes='10.99.0.0/16,104.16.208.203/32,104.19.159.190/32,10.99.0.2/32'
```

### Environment

```markdown
- OS: Ubuntu 24.04
- Headscale version: v0.26.0-beta.1
- 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.

Discussion:
@nblock: Thanks for testing 0.26.0-beta.1! It seems that exit nodes and subnet routers are mixed up and that's causing issues
with your policy:

* Your router does not announce itself as exit node: `… --advertise-exit-node=false …`.
* The routes `0.0.0.0/0` and `::/0` can only be used to approve exit nodes. They cannot be used to approve arbitrary
  routes from a subnet router.
* The routes `104.16.208.203/32` and `104.19.159.190/32` are not covered in the `autoApprovers` section of the policy
  and need to be approved manually.


Here is a similar (but simplified) policy where automatic approval during `tailscale up` works (0.26.0-beta.1).

```json
{
  "groups": {
    "group:users": [
      "user1@",
    ],
    "group:routers": [
      "router@"
    ]
  },
  "tagOwners": {
    "tag:routers-vpn": [
      "group:routers"
    ],
    "tag:exit-nodes": [
      "group:routers"
    ]
  },
  "autoApprovers": {
    "routes": {
      "10.99.0.0/16": [
        "tag:routers-vpn"
      ]
    },
    "exitNode": [
      "tag:exit-nodes"
    ]
  },
  "hosts": {
    "vpn-dns": "10.99.0.2/32",
    "vpn-network": "10.99.0.0/16"
  },
  "acls": [
    {
      "action": "accept",
      "src": [
        "*"
      ],
      "dst": [
        "vpn-dns:53",
        "vpn-network:*",
        "autogroup:internet:*"
      ]
    }
  ]
}
```

Headscale:
```console
$ headscale user list
ID | Name | Username | Email | Created
1  |      | user1    |       | 2025-05-06 11:00:39
2  |      | router   |       | 2025-05-06 11:00:42
```

```console
$ headscale node list
ID | Hostname | Name     | MachineKey | NodeKey | User   | IP addresses                  | Ephemeral | Last seen | Expiration | Connected | Expired
1  | router01 | router01 | [A9J9T]    | [BlMzS] | router | 100.64.0.1, fd7a:115c:a1e0::1 | false     |           | N/A        | online    | no
2  | client   | client   | [teN34]    | [bEsIi] | user1  | 100.64.0.2, fd7a:115c:a1e0::2 | false     |           | N/A        | online    | no
```

```console
$ headscale node list-routes
ID | Hostname | Approved                                    | Available                                                                         | Serving (Primary)
1  | router01 | 0.0.0.0/0, 10.99.0.0/16, 10.99.0.2/32, ::/0 | 0.0.0.0/0, 10.99.0.0/16, 10.99.0.2/32, 104.16.208.203/32, 104.19.159.190/32, ::/0 | 10.99.0.0/16, 10.99.0.2/32, 0.0.0.0/0, ::/0
```

Router:
```console
$ sudo tailscale up \
    --login-server https://headscale.example.net \
    --hostname=router01 \
    --advertise-tags=tag:exit-nodes,tag:routers-vpn \
    --advertise-exit-node=true \
    --advertise-routes=10.99.0.0/16,104.16.208.203/32,104.19.159.190/32,10.99.0.2/32
```

Client:
```console
$ sudo tailscale up \
    --login-server https://headscale.example.net \
    --hostname=client \
    --accept-routes=true

$ sudo tailscale exit-node list

 IP             HOSTNAME                    COUNTRY     CITY      STATUS
 100.64.0.1     router01.tn.example.com     -           -         -
```

Can you please update your policy based on the example above, especially the `autoApprovers` section? The docs for routes cover subnet routers and exit nodes with more explanation and examples: https://headscale.net/development/ref/routes/@spymobilfon: Thank you for your answer!
This news is upset me(
Our scheme has worked until v0.25.1, is broke only in 0.26.0-beta.1.
I showed only one simple example. We have many special routes to hosts in Internet (/32) and this IPs is changed often. Traffic is routed to this hosts via general routers, not via exit nodes. Manual approve many routes is so difficult and braeks scheme which we used in v0.23.0 and v0.22.3.
It looks like a breaking changes in 0.26.0-beta.1.
Will this behaviour to be in the next versions?@nblock: > We have many special routes to hosts in Internet (/32) and this IPs is changed often. Traffic is routed to this hosts via general routers, not via exit nodes.

Can you elaborate a bit on your use-case? Do those `/32` hosts share some prefix that could be auto approved at once?@spymobilfon: Of course.

Example we have a few Tailscale router nodes which responsible for access to special resources. It can be hosts (endpoints) our clients or our services or something else in Internet, it doesn't matter, example service desk portal or another portal or SSH service or RDP service. This endpoints can often change, dynamically add or remove. We restrict access to this routes via ACL.

We know how use DNS names with Tailscale instead of IPs. We don't know sometimes IPs which we should manually approve because IPs of DNS records can change. That's why advertise-routes can dynamically change.

This Tailscale routers have special tags like tag:internet-routers. We allow auto-approve anything routes (0.0.0.0/0) for this tags. We don't want approve each route to host (/32) because it's overhead. We don't want use exit-nodes for this purpose because we don't want to route all traffic via exit-nodes, only for special routes.

I hope that I describe my scheme understandable.@nblock: > I hope that I describe my scheme understandable.

Thanks for describing your use case. [Headscale 0.26.0-beta.2](https://github.com/juanfont/headscale/releases/tag/v0.26.0-beta.2) is now available and should support your use case. You may want to check if everything works as expected.@spymobilfon: Yes, it works. Thank you.

Solution patch:
diff --git a/cmd/headscale/cli/root.go b/cmd/headscale/cli/root.go
index 1f08411d..f3a16018 100644
--- a/cmd/headscale/cli/root.go
+++ b/cmd/headscale/cli/root.go
@@ -4,6 +4,7 @@ import (
 	"fmt"
 	"os"
 	"runtime"
+	"slices"
 
 	"github.com/juanfont/headscale/hscontrol/types"
 	"github.com/rs/zerolog"
@@ -25,6 +26,11 @@ func init() {
 		return
 	}
 
+	if slices.Contains(os.Args, "policy") && slices.Contains(os.Args, "check") {
+		zerolog.SetGlobalLevel(zerolog.Disabled)
+		return
+	}
+
 	cobra.OnInitialize(initConfig)
 	rootCmd.PersistentFlags().
 		StringVarP(&cfgFile, "config", "c", "", "config file (default is /etc/headscale/config.yaml)")
@@ -60,7 +66,7 @@ func initConfig() {
 
 	logFormat := viper.GetString("log.format")
 	if logFormat == types.JSONLogFormat {
-	 	log.Logger = log.Output(os.Stdout)
+		log.Logger = log.Output(os.Stdout)
 	}
 
 	disableUpdateCheck := viper.GetBool("disable_check_updates")
diff --git a/hscontrol/policy/v2/policy.go b/hscontrol/policy/v2/policy.go
index 4dec2bd4..80235354 100644
--- a/hscontrol/policy/v2/policy.go
+++ b/hscontrol/policy/v2/policy.go
@@ -31,6 +31,8 @@ type PolicyManager struct {
 	tagOwnerMapHash deephash.Sum
 	tagOwnerMap     map[Tag]*netipx.IPSet
 
+	exitSetHash        deephash.Sum
+	exitSet            *netipx.IPSet
 	autoApproveMapHash deephash.Sum
 	autoApproveMap     map[netip.Prefix]*netipx.IPSet
 
@@ -97,7 +99,7 @@ func (pm *PolicyManager) updateLocked() (bool, error) {
 	pm.tagOwnerMap = tagMap
 	pm.tagOwnerMapHash = tagOwnerMapHash
 
-	autoMap, err := resolveAutoApprovers(pm.pol, pm.users, pm.nodes)
+	autoMap, exitSet, err := resolveAutoApprovers(pm.pol, pm.users, pm.nodes)
 	if err != nil {
 		return false, fmt.Errorf("resolving auto approvers map: %w", err)
 	}
@@ -107,8 +109,13 @@ func (pm *PolicyManager) updateLocked() (bool, error) {
 	pm.autoApproveMap = autoMap
 	pm.autoApproveMapHash = autoApproveMapHash
 
+	exitSetHash := deephash.Hash(&autoMap)
+	exitSetChanged := exitSetHash != pm.exitSetHash
+	pm.exitSet = exitSet
+	pm.exitSetHash = exitSetHash
+
 	// If neither of the calculated values changed, no need to update nodes
-	if !filterChanged && !tagOwnerChanged && !autoApproveChanged {
+	if !filterChanged && !tagOwnerChanged && !autoApproveChanged && !exitSetChanged {
 		return false, nil
 	}
 
@@ -207,6 +214,23 @@ func (pm *PolicyManager) NodeCanApproveRoute(node *types.Node, route netip.Prefi
 		return false
 	}
 
+	// If the route to-be-approved is an exit route, then we need to check
+	// if the node is in allowed to approve it. This is treated differently
+	// than the auto-approvers, as the auto-approvers are not allowed to
+	// approve the whole /0 range.
+	// However, an auto approver might be /0, meaning that they can approve
+	// all routes available, just not exit nodes.
+	if tsaddr.IsExitRoute(route) {
+		if pm.exitSet == nil {
+			return false
+		}
+		if slices.ContainsFunc(node.IPs(), pm.exitSet.Contains) {
+			return true
+		}
+
+		return false
+	}
+
 	pm.mu.Lock()
 	defer pm.mu.Unlock()
 
@@ -224,14 +248,6 @@ func (pm *PolicyManager) NodeCanApproveRoute(node *types.Node, route netip.Prefi
 	// cannot just lookup in the prefix map and have to check
 	// if there is a "parent" prefix available.
 	for prefix, approveAddrs := range pm.autoApproveMap {
-		// We do not want the exit node entry to approve all
-		// sorts of routes. The logic here is that it would be
-		// unexpected behaviour to have specific routes approved
-		// just because the node is allowed to designate itself as
-		// an exit.
-		if tsaddr.IsExitRoute(prefix) {
-			continue
-		}
 
 		// Check if prefix is larger (so containing) and then overlaps
 		// the route to see if the node can approve a subset of an autoapprover
diff --git a/hscontrol/policy/v2/types.go b/hscontrol/policy/v2/types.go
index 78b1fdbe..a49f55de 100644
--- a/hscontrol/policy/v2/types.go
+++ b/hscontrol/policy/v2/types.go
@@ -862,10 +862,11 @@ type AutoApproverPolicy struct {
 // resolveAutoApprovers resolves the AutoApprovers to a map of netip.Prefix to netipx.IPSet.
 // The resulting map can be used to quickly look up if a node can self-approve a route.
 // It is intended for internal use in a PolicyManager.
-func resolveAutoApprovers(p *Policy, users types.Users, nodes types.Nodes) (map[netip.Prefix]*netipx.IPSet, error) {
+func resolveAutoApprovers(p *Policy, users types.Users, nodes types.Nodes) (map[netip.Prefix]*netipx.IPSet, *netipx.IPSet, error) {
 	if p == nil {
-		return nil, nil
+		return nil, nil, nil
 	}
+	var err error
 
 	routes := make(map[netip.Prefix]*netipx.IPSetBuilder)
 
@@ -877,7 +878,7 @@ func resolveAutoApprovers(p *Policy, users types.Users, nodes types.Nodes) (map[
 			aa, ok := autoApprover.(Alias)
 			if !ok {
 				// Should never happen
-				return nil, fmt.Errorf("autoApprover %v is not an Alias", autoApprover)
+				return nil, nil, fmt.Errorf("autoApprover %v is not an Alias", autoApprover)
 			}
 			// If it does not resolve, that means the autoApprover is not associated with any IP addresses.
 			ips, _ := aa.Resolve(p, users, nodes)
@@ -891,7 +892,7 @@ func resolveAutoApprovers(p *Policy, users types.Users, nodes types.Nodes) (map[
 			aa, ok := autoApprover.(Alias)
 			if !ok {
 				// Should never happen
-				return nil, fmt.Errorf("autoApprover %v is not an Alias", autoApprover)
+				return nil, nil, fmt.Errorf("autoApprover %v is not an Alias", autoApprover)
 			}
 			// If it does not resolve, that means the autoApprover is not associated with any IP addresses.
 			ips, _ := aa.Resolve(p, users, nodes)
@@ -903,22 +904,20 @@ func resolveAutoApprovers(p *Policy, users types.Users, nodes types.Nodes) (map[
 	for prefix, builder := range routes {
 		ipSet, err := builder.IPSet()
 		if err != nil {
-			return nil, err
+			return nil, nil, err
 		}
 		ret[prefix] = ipSet
 	}
 
+	var exitNodeSet *netipx.IPSet
 	if len(p.AutoApprovers.ExitNode) > 0 {
-		exitNodeSet, err := exitNodeSetBuilder.IPSet()
+		exitNodeSet, err = exitNodeSetBuilder.IPSet()
 		if err != nil {
-			return nil, err
+			return nil, nil, err
 		}
-
-		ret[tsaddr.AllIPv4()] = exitNodeSet
-		ret[tsaddr.AllIPv6()] = exitNodeSet
 	}
 
-	return ret, nil
+	return ret, exitNodeSet, nil
 }
 
 type ACL struct {



Test patch:
diff --git a/hscontrol/policy/route_approval_test.go b/hscontrol/policy/route_approval_test.go
new file mode 100644
index 00000000..90d5f98e
--- /dev/null
+++ b/hscontrol/policy/route_approval_test.go
@@ -0,0 +1,809 @@
+package policy
+
+import (
+	"fmt"
+	"net/netip"
+	"testing"
+
+	"github.com/google/go-cmp/cmp"
+	"github.com/juanfont/headscale/hscontrol/types"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+	"gorm.io/gorm"
+)
+
+func TestNodeCanApproveRoute(t *testing.T) {
+	users := []types.User{
+		{Name: "user1", Model: gorm.Model{ID: 1}},
+		{Name: "user2", Model: gorm.Model{ID: 2}},
+		{Name: "user3", Model: gorm.Model{ID: 3}},
+	}
+
+	// Create standard node setups used across tests
+	normalNode := types.Node{
+		ID:       1,
+		Hostname: "user1-device",
+		IPv4:     ap("100.64.0.1"),
+		UserID:   1,
+		User:     users[0],
+	}
+
+	exitNode := types.Node{
+		ID:       2,
+		Hostname: "user2-device",
+		IPv4:     ap("100.64.0.2"),
+		UserID:   2,
+		User:     users[1],
+	}
+
+	taggedNode := types.Node{
+		ID:         3,
+		Hostname:   "tagged-server",
+		IPv4:       ap("100.64.0.3"),
+		UserID:     3,
+		User:       users[2],
+		ForcedTags: []string{"tag:router"},
+	}
+
+	multiTagNode := types.Node{
+		ID:         4,
+		Hostname:   "multi-tag-node",
+		IPv4:       ap("100.64.0.4"),
+		UserID:     2,
+		User:       users[1],
+		ForcedTags: []string{"tag:router", "tag:server"},
+	}
+
+	tests := []struct {
+		name       string
+		node       types.Node
+		route      netip.Prefix
+		policy     string
+		canApprove bool
+		skipV1     bool
+	}{
+		{
+			name:  "allow-all-routes-for-admin-user",
+			node:  normalNode,
+			route: p("192.168.1.0/24"),
+			policy: `{
+				"groups": {
+					"group:admin": ["user1@"]
+				},
+				"acls": [
+					{"action": "accept", "src": ["group:admin"], "dst": ["*:*"]}
+				],
+				"autoApprovers": {
+					"routes": {
+						"192.168.0.0/16": ["group:admin"]
+					}
+				}
+			}`,
+			canApprove: true,
+		},
+		{
+			name:  "deny-route-that-doesnt-match-autoApprovers",
+			node:  normalNode,
+			route: p("10.0.0.0/24"),
+			policy: `{
+				"groups": {
+					"group:admin": ["user1@"]
+				},
+				"acls": [
+					{"action": "accept", "src": ["group:admin"], "dst": ["*:*"]}
+				],
+				"autoApprovers": {
+					"routes": {
+						"192.168.0.0/16": ["group:admin"]
+					}
+				}
+			}`,
+			canApprove: false,
+		},
+		{
+			name:  "user-not-in-group",
+			node:  exitNode,
+			route: p("192.168.1.0/24"),
+			policy: `{
+				"groups": {
+					"group:admin": ["user1@"]
+				},
+				"acls": [
+					{"action": "accept", "src": ["group:admin"], "dst": ["*:*"]}
+				],
+				"autoApprovers": {
+					"routes": {
+						"192.168.0.0/16": ["group:admin"]
+					}
+				}
+			}`,
+			canApprove: false,
+		},
+		{
+			name:  "tagged-node-can-approve",
+			node:  taggedNode,
+			route: p("10.0.0.0/8"),
+			policy: `{
+				"tagOwners": {
+					"tag:router": ["user3@"]
+				},
+				"groups": {
+					"group:admin": ["user1@"]
+				},
+				"acls": [
+					{"action": "accept", "src": ["group:admin"], "dst": ["*:*"]}
+				],
+				"autoApprovers": {
+					"routes": {
+						"10.0.0.0/8": ["tag:router"]
+					}
+				}
+			}`,
+			canApprove: true,
+		},
+		{
+			name:  "multiple-routes-in-policy",
+			node:  normalNode,
+			route: p("172.16.10.0/24"),
+			policy: `{
+				"tagOwners": {
+					"tag:router": ["user3@"]
+				},
+				"groups": {
+					"group:admin": ["user1@"]
+				},
+				"acls": [
+					{"action": "accept", "src": ["group:admin"], "dst": ["*:*"]}
+				],
+				"autoApprovers": {
+					"routes": {
+						"192.168.0.0/16": ["group:admin"],
+						"172.16.0.0/12": ["group:admin"],
+						"10.0.0.0/8": ["tag:router"]
+					}
+				}
+			}`,
+			canApprove: true,
+		},
+		{
+			name:  "match-specific-route-within-range",
+			node:  normalNode,
+			route: p("192.168.5.0/24"),
+			policy: `{
+				"groups": {
+					"group:admin": ["user1@"]
+				},
+				"acls": [
+					{"action": "accept", "src": ["group:admin"], "dst": ["*:*"]}
+				],
+				"autoApprovers": {
+					"routes": {
+						"192.168.0.0/16": ["group:admin"]
+					}
+				}
+			}`,
+			canApprove: true,
+		},
+		{
+			name:  "ip-address-within-range",
+			node:  normalNode,
+			route: p("192.168.1.5/32"),
+			policy: `{
+				"groups": {
+					"group:admin": ["user1@"]
+				},
+				"acls": [
+					{"action": "accept", "src": ["group:admin"], "dst": ["*:*"]}
+				],
+				"autoApprovers": {
+					"routes": {
+						"192.168.1.0/24": ["group:admin"],
+						"192.168.1.128/25": ["group:admin"]
+					}
+				}
+			}`,
+			canApprove: true,
+		},
+		{
+			name:  "all-IPv4-routes-(0.0.0.0/0)-approval",
+			node:  normalNode,
+			route: p("0.0.0.0/0"),
+			policy: `{
+				"groups": {
+					"group:admin": ["user1@"]
+				},
+				"acls": [
+					{"action": "accept", "src": ["group:admin"], "dst": ["*:*"]}
+				],
+				"autoApprovers": {
+					"routes": {
+						"0.0.0.0/0": ["group:admin"]
+					}
+				}
+			}`,
+			canApprove: false,
+		},
+		{
+			name:  "all-IPv4-routes-exitnode-approval",
+			node:  normalNode,
+			route: p("0.0.0.0/0"),
+			policy: `{
+				"groups": {
+					"group:admin": ["user1@"]
+				},
+				"acls": [
+					{"action": "accept", "src": ["group:admin"], "dst": ["*:*"]}
+				],
+				"autoApprovers": {
+					"exitNode": ["group:admin"]
+				}
+			}`,
+			canApprove: true,
+		},
+		{
+			name:  "all-IPv6-routes-exitnode-approval",
+			node:  normalNode,
+			route: p("::/0"),
+			policy: `{
+				"groups": {
+					"group:admin": ["user1@"]
+				},
+				"acls": [
+					{"action": "accept", "src": ["group:admin"], "dst": ["*:*"]}
+				],
+				"autoApprovers": {
+					"exitNode": ["group:admin"]
+				}
+			}`,
+			canApprove: true,
+		},
+		{
+			name:  "specific-IPv4-route-with-exitnode-only-approval",
+			node:  normalNode,
+			route: p("192.168.1.0/24"),
+			policy: `{
+				"groups": {
+					"group:admin": ["user1@"]
+				},
+				"acls": [
+					{"action": "accept", "src": ["group:admin"], "dst": ["*:*"]}
+				],
+				"autoApprovers": {
+					"exitNode": ["group:admin"]
+				}
+			}`,
+			canApprove: false,
+		},
+		{
+			name:  "specific-IPv6-route-with-exitnode-only-approval",
+			node:  normalNode,
+			route: p("fd00::/8"),
+			policy: `{
+				"groups": {
+					"group:admin": ["user1@"]
+				},
+				"acls": [
+					{"action": "accept", "src": ["group:admin"], "dst": ["*:*"]}
+				],
+				"autoApprovers": {
+					"exitNode": ["group:admin"]
+				}
+			}`,
+			canApprove: false,
+		},
+		{
+			name:  "specific-IPv4-route-with-all-routes-policy",
+			node:  normalNode,
+			route: p("10.0.0.0/8"),
+			policy: `{
+				"groups": {
+					"group:admin": ["user1@"]
+				},
+				"acls": [
+					{"action": "accept", "src": ["group:admin"], "dst": ["*:*"]}
+				],
+				"autoApprovers": {
+					"routes": {
+						"0.0.0.0/0": ["group:admin"]
+					}
+				}
+			}`,
+			canApprove: true,
+		},
+		{
+			name:  "all-IPv6-routes-(::0/0)-approval",
+			node:  normalNode,
+			route: p("::/0"),
+			policy: `{
+				"groups": {
+					"group:admin": ["user1@"]
+				},
+				"acls": [
+					{"action": "accept", "src": ["group:admin"], "dst": ["*:*"]}
+				],
+				"autoApprovers": {
+					"routes": {
+						"::/0": ["group:admin"]
+					}
+				}
+			}`,
+			canApprove: false,
+		},
+		{
+			name:  "specific-IPv6-route-with-all-routes-policy",
+			node:  normalNode,
+			route: p("fd00::/8"),
+			policy: `{
+				"groups": {
+					"group:admin": ["user1@"]
+				},
+				"acls": [
+					{"action": "accept", "src": ["group:admin"], "dst": ["*:*"]}
+				],
+				"autoApprovers": {
+					"routes": {
+						"::/0": ["group:admin"]
+					}
+				}
+			}`,
+			canApprove: true,
+		},
+		{
+			name:  "IPv6-route-with-IPv4-all-routes-policy",
+			node:  normalNode,
+			route: p("fd00::/8"),
+			policy: `{
+				"groups": {
+					"group:admin": ["user1@"]
+				},
+				"acls": [
+					{"action": "accept", "src": ["group:admin"], "dst": ["*:*"]}
+				],
+				"autoApprovers": {
+					"routes": {
+						"0.0.0.0/0": ["group:admin"]
+					}
+				}
+			}`,
+			canApprove: false,
+		},
+		{
+			name:  "IPv4-route-with-IPv6-all-routes-policy",
+			node:  normalNode,
+			route: p("10.0.0.0/8"),
+			policy: `{
+				"groups": {
+					"group:admin": ["user1@"]
+				},
+				"acls": [
+					{"action": "accept", "src": ["group:admin"], "dst": ["*:*"]}
+				],
+				"autoApprovers": {
+					"routes": {
+						"::/0": ["group:admin"]
+					}
+				}
+			}`,
+			canApprove: false,
+		},
+		{
+			name:  "both-IPv4-and-IPv6-all-routes-policy",
+			node:  normalNode,
+			route: p("192.168.1.0/24"),
+			policy: `{
+				"groups": {
+					"group:admin": ["user1@"]
+				},
+				"acls": [
+					{"action": "accept", "src": ["group:admin"], "dst": ["*:*"]}
+				],
+				"autoApprovers": {
+					"routes": {
+						"0.0.0.0/0": ["group:admin"],
+						"::/0": ["group:admin"]
+					}
+				}
+			}`,
+			canApprove: true,
+		},
+		{
+			name:  "ip-address-with-all-routes-policy",
+			node:  normalNode,
+			route: p("192.168.101.5/32"),
+			policy: `{
+				"groups": {
+					"group:admin": ["user1@"]
+				},
+				"acls": [
+					{"action": "accept", "src": ["group:admin"], "dst": ["*:*"]}
+				],
+				"autoApprovers": {
+					"routes": {
+						"0.0.0.0/0": ["group:admin"]
+					}
+				}
+			}`,
+			canApprove: true,
+		},
+		{
+			name:  "specific-IPv6-host-route-with-all-routes-policy",
+			node:  normalNode,
+			route: p("2001:db8::1/128"),
+			policy: `{
+				"groups": {
+					"group:admin": ["user1@"]
+				},
+				"acls": [
+					{"action": "accept", "src": ["group:admin"], "dst": ["*:*"]}
+				],
+				"autoApprovers": {
+					"routes": {
+						"::/0": ["group:admin"]
+					}
+				}
+			}`,
+			canApprove: true,
+		},
+		{
+			name:  "multiple-groups-allowed-to-approve-same-route",
+			node:  normalNode,
+			route: p("192.168.1.0/24"),
+			policy: `{
+				"groups": {
+					"group:admin": ["user1@"],
+					"group:netadmin": ["user1@"]
+				},
+				"acls": [
+					{"action": "accept", "src": ["group:admin"], "dst": ["*:*"]}
+				],
+				"autoApprovers": {
+					"routes": {
+						"192.168.1.0/24": ["group:admin", "group:netadmin"]
+					}
+				}
+			}`,
+			canApprove: true,
+		},
+		{
+			name:  "overlapping-routes-with-different-groups",
+			node:  normalNode,
+			route: p("192.168.1.0/24"),
+			policy: `{
+				"groups": {
+					"group:admin": ["user1@"],
+					"group:restricted": ["user2@"]
+				},
+				"acls": [
+					{"action": "accept", "src": ["group:admin"], "dst": ["*:*"]}
+				],
+				"autoApprovers": {
+					"routes": {
+						"192.168.0.0/16": ["group:restricted"],
+						"192.168.1.0/24": ["group:admin"]
+					}
+				}
+			}`,
+			canApprove: true,
+		},
+		{
+			name:  "unique-local-IPv6-address-with-all-routes-policy",
+			node:  normalNode,
+			route: p("fc00::/7"),
+			policy: `{
+				"groups": {
+					"group:admin": ["user1@"]
+				},
+				"acls": [
+					{"action": "accept", "src": ["group:admin"], "dst": ["*:*"]}
+				],
+				"autoApprovers": {
+					"routes": {
+						"::/0": ["group:admin"]
+					}
+				}
+			}`,
+			canApprove: true,
+		},
+		{
+			name:  "exact-prefix-match-in-policy",
+			node:  normalNode,
+			route: p("203.0.113.0/24"),
+			policy: `{
+				"groups": {
+					"group:admin": ["user1@"]
+				},
+				"acls": [
+					{"action": "accept", "src": ["group:admin"], "dst": ["*:*"]}
+				],
+				"autoApprovers": {
+					"routes": {
+						"203.0.113.0/24": ["group:admin"]
+					}
+				}
+			}`,
+			canApprove: true,
+		},
+		{
+			name:  "narrower-range-than-policy",
+			node:  normalNode,
+			route: p("203.0.113.0/26"),
+			policy: `{
+				"groups": {
+					"group:admin": ["user1@"]
+				},
+				"acls": [
+					{"action": "accept", "src": ["group:admin"], "dst": ["*:*"]}
+				],
+				"autoApprovers": {
+					"routes": {
+						"203.0.113.0/24": ["group:admin"]
+					}
+				}
+			}`,
+			canApprove: true,
+		},
+		{
+			name:  "wider-range-than-policy-should-fail",
+			node:  normalNode,
+			route: p("203.0.113.0/23"),
+			policy: `{
+				"groups": {
+					"group:admin": ["user1@"]
+				},
+				"acls": [
+					{"action": "accept", "src": ["group:admin"], "dst": ["*:*"]}
+				],
+				"autoApprovers": {
+					"routes": {
+						"203.0.113.0/24": ["group:admin"]
+					}
+				}
+			}`,
+			canApprove: false,
+		},
+		{
+			name:  "adjacent-route-to-policy-route-should-fail",
+			node:  normalNode,
+			route: p("203.0.114.0/24"),
+			policy: `{
+				"groups": {
+					"group:admin": ["user1@"]
+				},
+				"acls": [
+					{"action": "accept", "src": ["group:admin"], "dst": ["*:*"]}
+				],
+				"autoApprovers": {
+					"routes": {
+						"203.0.113.0/24": ["group:admin"]
+					}
+				}
+			}`,
+			canApprove: false,
+		},
+		{
+			name:  "combined-routes-and-exitnode-approvers-specific-route",
+			node:  normalNode,
+			route: p("192.168.1.0/24"),
+			policy: `{
+				"groups": {
+					"group:admin": ["user1@"]
+				},
+				"acls": [
+					{"action": "accept", "src": ["group:admin"], "dst": ["*:*"]}
+				],
+				"autoApprovers": {
+					"exitNode": ["group:admin"],
+					"routes": {
+						"192.168.1.0/24": ["group:admin"]
+					}
+				}
+			}`,
+			canApprove: true,
+		},
+		{
+			name:  "partly-overlapping-route-with-policy-should-fail",
+			node:  normalNode,
+			route: p("203.0.113.128/23"),
+			policy: `{
+				"groups": {
+					"group:admin": ["user1@"]
+				},
+				"acls": [
+					{"action": "accept", "src": ["group:admin"], "dst": ["*:*"]}
+				],
+				"autoApprovers": {
+					"routes": {
+						"203.0.113.0/24": ["group:admin"]
+					}
+				}
+			}`,
+			canApprove: false,
+		},
+		{
+			name:  "multiple-routes-with-aggregatable-ranges",
+			node:  normalNode,
+			route: p("10.0.0.0/8"),
+			policy: `{
+				"groups": {
+					"group:admin": ["user1@"]
+				},
+				"acls": [
+					{"action": "accept", "src": ["group:admin"], "dst": ["*:*"]}
+				],
+				"autoApprovers": {
+					"routes": {
+						"10.0.0.0/9": ["group:admin"],
+						"10.128.0.0/9": ["group:admin"]
+					}
+				}
+			}`,
+			canApprove: false,
+		},
+		{
+			name:  "non-standard-IPv6-notation",
+			node:  normalNode,
+			route: p("2001:db8::1/128"),
+			policy: `{
+				"groups": {
+					"group:admin": ["user1@"]
+				},
+				"acls": [
+					{"action": "accept", "src": ["group:admin"], "dst": ["*:*"]}
+				],
+				"autoApprovers": {
+					"routes": {
+						"2001:db8::/32": ["group:admin"]
+					}
+				}
+			}`,
+			canApprove: true,
+		},
+		{
+			name:  "node-with-multiple-tags-all-required",
+			node:  multiTagNode,
+			route: p("10.10.0.0/16"),
+			policy: `{
+				"tagOwners": {
+					"tag:router": ["user2@"],
+					"tag:server": ["user2@"]
+				},
+				"groups": {
+					"group:admin": ["user1@"]
+				},
+				"acls": [
+					{"action": "accept", "src": ["group:admin"], "dst": ["*:*"]}
+				],
+				"autoApprovers": {
+					"routes": {
+						"10.10.0.0/16": ["tag:router", "tag:server"]
+					}
+				}
+			}`,
+			canApprove: true,
+		},
+		{
+			name:  "node-with-multiple-tags-one-matching-is-sufficient",
+			node:  multiTagNode,
+			route: p("10.10.0.0/16"),
+			policy: `{
+				"tagOwners": {
+					"tag:router": ["user2@"],
+					"tag:server": ["user2@"]
+				},
+				"groups": {
+					"group:admin": ["user1@"]
+				},
+				"acls": [
+					{"action": "accept", "src": ["group:admin"], "dst": ["*:*"]}
+				],
+				"autoApprovers": {
+					"routes": {
+						"10.10.0.0/16": ["tag:router", "group:admin"]
+					}
+				}
+			}`,
+			canApprove: true,
+		},
+		{
+			name:  "node-with-multiple-tags-missing-required-tag",
+			node:  multiTagNode,
+			route: p("10.10.0.0/16"),
+			policy: `{
+				"tagOwners": {
+				    "tag:othertag": ["user1@"]
+				},
+				"groups": {
+					"group:admin": ["user1@"]
+				},
+				"acls": [
+					{"action": "accept", "src": ["group:admin"], "dst": ["*:*"]}
+				],
+				"autoApprovers": {
+					"routes": {
+						"10.10.0.0/16": ["tag:othertag"]
+					}
+				}
+			}`,
+			canApprove: false,
+		},
+		{
+			name:  "node-with-tag-and-group-membership",
+			node:  normalNode,
+			route: p("10.20.0.0/16"),
+			policy: `{
+				"tagOwners": {
+					"tag:router": ["user3@"]
+				},
+				"groups": {
+					"group:admin": ["user1@"]
+				},
+				"acls": [
+					{"action": "accept", "src": ["group:admin"], "dst": ["*:*"]}
+				],
+				"autoApprovers": {
+					"routes": {
+						"10.20.0.0/16": ["group:admin", "tag:router"]
+					}
+				}
+			}`,
+			canApprove: true,
+		},
+		{
+			name:  "small-subnet-with-exitnode-only-approval",
+			node:  normalNode,
+			route: p("192.168.1.1/32"),
+			policy: `{
+				"groups": {
+					"group:admin": ["user1@"]
+				},
+				"acls": [
+					{"action": "accept", "src": ["group:admin"], "dst": ["*:*"]}
+				],
+				"autoApprovers": {
+					"exitNode": ["group:admin"]
+				}
+			}`,
+			canApprove: false,
+		},
+		{
+			name:  "empty-policy",
+			node:  normalNode,
+			route: p("192.168.1.0/24"),
+			policy: `{"acls":[{"action":"accept","src":["*"],"dst":["*:*"]}]}`,
+			canApprove: false,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			// Initialize all policy manager implementations
+			policyManagers, err := PolicyManagersForTest([]byte(tt.policy), users, types.Nodes{&tt.node})
+			if tt.name == "empty policy" {
+				// We expect this one to have a valid but empty policy
+				require.NoError(t, err)
+				if err != nil {
+					return
+				}
+			} else {
+				require.NoError(t, err)
+			}
+
+			for i, pm := range policyManagers {
+				versionNum := i + 1
+				if versionNum == 1 && tt.skipV1 {
+					// Skip V1 policy manager for specific tests
+					continue
+				}
+
+				t.Run(fmt.Sprintf("PolicyV%d", versionNum), func(t *testing.T) {
+					result := pm.NodeCanApproveRoute(&tt.node, tt.route)
+
+					if diff := cmp.Diff(tt.canApprove, result); diff != "" {
+						t.Errorf("NodeCanApproveRoute() mismatch (-want +got):\n%s", diff)
+					}
+					assert.Equal(t, tt.canApprove, result, "Unexpected route approval result")
+				})
+			}
+		})
+	}
+}
diff --git a/hscontrol/policy/v2/types_test.go b/hscontrol/policy/v2/types_test.go
index c25c14a9..3808b547 100644
--- a/hscontrol/policy/v2/types_test.go
+++ b/hscontrol/policy/v2/types_test.go
@@ -1024,10 +1024,11 @@ func TestResolveAutoApprovers(t *testing.T) {
 	}
 
 	tests := []struct {
-		name    string
-		policy  *Policy
-		want    map[netip.Prefix]*netipx.IPSet
-		wantErr bool
+		name            string
+		policy          *Policy
+		want            map[netip.Prefix]*netipx.IPSet
+		wantAllIPRoutes *netipx.IPSet
+		wantErr         bool
 	}{
 		{
 			name: "single-route",
@@ -1041,7 +1042,8 @@ func TestResolveAutoApprovers(t *testing.T) {
 			want: map[netip.Prefix]*netipx.IPSet{
 				mp("10.0.0.0/24"): mustIPSet("100.64.0.1/32"),
 			},
-			wantErr: false,
+			wantAllIPRoutes: nil,
+			wantErr:         false,
 		},
 		{
 			name: "multiple-routes",
@@ -1057,7 +1059,8 @@ func TestResolveAutoApprovers(t *testing.T) {
 				mp("10.0.0.0/24"): mustIPSet("100.64.0.1/32"),
 				mp("10.0.1.0/24"): mustIPSet("100.64.0.2/32"),
 			},
-			wantErr: false,
+			wantAllIPRoutes: nil,
+			wantErr:         false,
 		},
 		{
 			name: "exit-node",
@@ -1066,11 +1069,9 @@ func TestResolveAutoApprovers(t *testing.T) {
 					ExitNode: AutoApprovers{ptr.To(Username("user1@"))},
 				},
 			},
-			want: map[netip.Prefix]*netipx.IPSet{
-				tsaddr.AllIPv4(): mustIPSet("100.64.0.1/32"),
-				tsaddr.AllIPv6(): mustIPSet("100.64.0.1/32"),
-			},
-			wantErr: false,
+			want:            map[netip.Prefix]*netipx.IPSet{},
+			wantAllIPRoutes: mustIPSet("100.64.0.1/32"),
+			wantErr:         false,
 		},
 		{
 			name: "group-route",
@@ -1087,7 +1088,8 @@ func TestResolveAutoApprovers(t *testing.T) {
 			want: map[netip.Prefix]*netipx.IPSet{
 				mp("10.0.0.0/24"): mustIPSet("100.64.0.1/32", "100.64.0.2/32"),
 			},
-			wantErr: false,
+			wantAllIPRoutes: nil,
+			wantErr:         false,
 		},
 		{
 			name: "tag-route-and-exit",
@@ -1113,10 +1115,9 @@ func TestResolveAutoApprovers(t *testing.T) {
 			},
 			want: map[netip.Prefix]*netipx.IPSet{
 				mp("10.0.1.0/24"): mustIPSet("100.64.0.4/32"),
-				tsaddr.AllIPv4():  mustIPSet("100.64.0.5/32"),
-				tsaddr.AllIPv6():  mustIPSet("100.64.0.5/32"),
 			},
-			wantErr: false,
+			wantAllIPRoutes: mustIPSet("100.64.0.5/32"),
+			wantErr:         false,
 		},
 		{
 			name: "mixed-routes-and-exit-nodes",
@@ -1135,10 +1136,9 @@ func TestResolveAutoApprovers(t *testing.T) {
 			want: map[netip.Prefix]*netipx.IPSet{
 				mp("10.0.0.0/24"): mustIPSet("100.64.0.1/32", "100.64.0.2/32"),
 				mp("10.0.1.0/24"): mustIPSet("100.64.0.3/32"),
-				tsaddr.AllIPv4():  mustIPSet("100.64.0.1/32"),
-				tsaddr.AllIPv6():  mustIPSet("100.64.0.1/32"),
 			},
-			wantErr: false,
+			wantAllIPRoutes: mustIPSet("100.64.0.1/32"),
+			wantErr:         false,
 		},
 	}
 
@@ -1146,7 +1146,7 @@ func TestResolveAutoApprovers(t *testing.T) {
 
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
-			got, err := resolveAutoApprovers(tt.policy, users, nodes)
+			got, gotAllIPRoutes, err := resolveAutoApprovers(tt.policy, users, nodes)
 			if (err != nil) != tt.wantErr {
 				t.Errorf("resolveAutoApprovers() error = %v, wantErr %v", err, tt.wantErr)
 				return
@@ -1154,6 +1154,15 @@ func TestResolveAutoApprovers(t *testing.T) {
 			if diff := cmp.Diff(tt.want, got, cmps...); diff != "" {
 				t.Errorf("resolveAutoApprovers() mismatch (-want +got):\n%s", diff)
 			}
+			if tt.wantAllIPRoutes != nil {
+				if gotAllIPRoutes == nil {
+					t.Error("resolveAutoApprovers() expected non-nil allIPRoutes, got nil")
+				} else if diff := cmp.Diff(tt.wantAllIPRoutes, gotAllIPRoutes, cmps...); diff != "" {
+					t.Errorf("resolveAutoApprovers() allIPRoutes mismatch (-want +got):\n%s", diff)
+				}
+			} else if gotAllIPRoutes != nil {
+				t.Error("resolveAutoApprovers() expected nil allIPRoutes, got non-nil")
+			}
 		})
 	}
 }