Repository: juanfont/headscale

Issue:
[Bug] ACLs with exact ip addresses don't work
### 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

I have deployed Tailscale as a subnet router (`--advertise-routes=10.99.0.0/16`) in my Kubernetes cluster to give access to my developers to postgres server `10.99.0.2`.

**Our developers can't access `10.99.0.2` when using `10.99.0.2/32:*` rule.**

The developer machine:
[ts-netmap-exact-address.json](https://github.com/user-attachments/files/17683795/ts-netmap-exact-address.json)

```
tailscale status
100.64.0.1      maxpain-macbook      m.csgo.com   macOS   -
```

The subnet router on the Kubernetes side:
```
tailscale status
100.64.0.3      subnet-router        admin        linux   -
```

**But `10.99.0.0/16:*` works.**

The developer machine:
[ts-netmap-slash-16-subnet.json](https://github.com/user-attachments/files/17683791/ts-netmap-slash-16-subnet.json)

```
tailscale status
100.64.0.1      maxpain-macbook      m.csgo.com   macOS   -
100.64.0.3      subnet-router        admin        linux   active; direct
```

The subnet router on the Kubernetes side:
```
tailscale status
100.64.0.3      subnet-router        admin        linux   -
100.64.0.1      maxpain-macbook      m.csgo.com   macOS   active; direct 
```

**ACL configuration:**
```json
{
	"groups": {
		"group:admin": [],
		"group:test-project": ["m.csgo.com"]
	},
	"tagOwners": {
		"tag:test-project": ["group:admin"]
	},
	"acls": [{
		"action": "accept",
		"src": ["group:test-project"],
		"dst": [
			// "10.99.0.0/16:*" // Works
			"10.99.0.2/32:*" // Doesn't work
		]
	}]
}
```

### Expected Behavior

`10.99.0.2/32:*` acl rule should work.

### Steps To Reproduce

1. Run tailscale subnet router:
```bash
tailscale up --login-server=https://headscale.example.com --advertise-routes=10.99.0.0/16 --auth-key=REDACTED --hostname=subnet-router
```

2. Run tailscale on client machine

### Environment

```markdown
- OS: Ubuntu 24.04
- Headscale version: 0.23.0
- Tailscale version: 1.76.6
```


### Runtime environment

- [ ] Headscale is behind a (reverse) proxy
- [X] Headscale runs in a container

### Anything else?

_No response_

Hints from issue comments:
@nblock: Can you try with `"dst": ["10.126.0.2/32:5432"],` (adding `/32` after the IP address), please?@maxpain: > Can you try with `"dst": ["10.126.0.2/32:5432"],` (adding `/32` after the IP address), please?

Already tried. Doesn’t work.@kradalby: Hi @maxpain, we have an issue template asking for a series of information and proposing debug information for us to be able to debug this quicker and making our life easier as maintainers. Please follow it and fill it out. 

We need to see the output of certain things to figure out what is happening and it saves us a great deal of time if you help us since you have the setup already instead of us having to replicate it.

If you would have followed the template, it would have proposed to use `tailscale debug netmap` to help us with info we need.
<img width="588" alt="image" src="https://github.com/user-attachments/assets/9529358a-0453-4148-9336-4c5eff87d865">

Please go back and fill out all the information and provide a netmap dump from the relevant clients with the two different settings, the working one and the broken one. If not all the information is filled out, we will have to close the issue, thank you.@maxpain: @kradalby, sorry about this. I've updated the issue.@nblock: Tested with 93afb03f6756983d85fe3f39666d21430a886dae and could reproduce it (with a slightly simplified ACL):

```console
$ headscale user list
ID | Name | Username | Email | Created            
1  |      | admin    |       | 2025-05-02 12:23:59
2  |      | user     |       | 2025-05-02 12:24:00
```

```console
$ headscale nodes list
ID | Hostname | Name   | MachineKey | NodeKey | User  | IP addresses                  | Ephemeral | Last seen | Expiration | Connected | Expired
1  | router   | router | [lPNFs]    | [CT9dL] | admin | 100.64.0.1, fd7a:115c:a1e0::1 | false     |           | N/A        | online    | no     
2  | node     | node   | [XC7jE]    | [nLKKr] | user  | 100.64.0.2, fd7a:115c:a1e0::2 | false     |           | N/A        | online    | no   
```

```console
$ headscale nodes  list-routes
ID | Hostname | Approved     | Available    | Serving (Primary)
1  | router   | 10.99.0.0/16 | 10.99.0.0/16 | 10.99.0.0/16   
```

```json5
{
  "groups": {
    "group:test-project": [
      "user@"
    ]
  },
  "acls": [
    {
      "action": "accept",
      "src": [
        "*"
      ],
      "dst": [
        "router:0"
      ]
    },
    {
      "action": "accept",
      "src": [
        "group:test-project"
      ],
      "dst": [
        "10.99.0.0/16:*"     // works
        // "10.99.0.2/32:*"  // does not work
      ]
    }
  ]
}
```

A service listening on `10.99.0.2:8000` is reachable from `node` when the destination is `10.99.0.0/16:*`. Access is no longer possible if the destination is changed to `10.99.0.2/32:*`.

A dummy interface can be used to simulate a service:
```console
$ ip link add dummy0 type dummy
$ ip addr add 10.99.0.2/16 dev dummy0
$ ip link set dummy0 up
$ python3 -m http.server -b 10.99.0.2
```

* [`tailscale debug netmap` on router with 16 dst](https://github.com/user-attachments/files/20013546/debug_netmap_policy_has_slash_16.json)
* [`tailscale debug netmap` on router with 32 dst](https://github.com/user-attachments/files/20013545/debug_netmap_policy_has_slash_32.json)

Solution patch:
diff --git a/hscontrol/policy/matcher/matcher.go b/hscontrol/policy/matcher/matcher.go
index 1d4f09d2..ec07d19c 100644
--- a/hscontrol/policy/matcher/matcher.go
+++ b/hscontrol/policy/matcher/matcher.go
@@ -3,6 +3,8 @@ package matcher
 import (
 	"net/netip"
 
+	"slices"
+
 	"github.com/juanfont/headscale/hscontrol/util"
 	"go4.org/netipx"
 	"tailscale.com/tailcfg"
@@ -58,41 +60,17 @@ func MatchFromStrings(sources, destinations []string) Match {
 }
 
 func (m *Match) SrcsContainsIPs(ips ...netip.Addr) bool {
-	for _, ip := range ips {
-		if m.srcs.Contains(ip) {
-			return true
-		}
-	}
-
-	return false
+	return slices.ContainsFunc(ips, m.srcs.Contains)
 }
 
 func (m *Match) DestsContainsIP(ips ...netip.Addr) bool {
-	for _, ip := range ips {
-		if m.dests.Contains(ip) {
-			return true
-		}
-	}
-
-	return false
+	return slices.ContainsFunc(ips, m.dests.Contains)
 }
 
 func (m *Match) SrcsOverlapsPrefixes(prefixes ...netip.Prefix) bool {
-	for _, prefix := range prefixes {
-		if m.srcs.ContainsPrefix(prefix) {
-			return true
-		}
-	}
-
-	return false
+	return slices.ContainsFunc(prefixes, m.srcs.OverlapsPrefix)
 }
 
 func (m *Match) DestsOverlapsPrefixes(prefixes ...netip.Prefix) bool {
-	for _, prefix := range prefixes {
-		if m.dests.ContainsPrefix(prefix) {
-			return true
-		}
-	}
-
-	return false
+	return slices.ContainsFunc(prefixes, m.dests.OverlapsPrefix)
 }



Test patch:
diff --git a/hscontrol/policy/policy_test.go b/hscontrol/policy/policy_test.go
index cebda65f..671ed829 100644
--- a/hscontrol/policy/policy_test.go
+++ b/hscontrol/policy/policy_test.go
@@ -2,10 +2,11 @@ package policy
 
 import (
 	"fmt"
-	"github.com/juanfont/headscale/hscontrol/policy/matcher"
 	"net/netip"
 	"testing"
 
+	"github.com/juanfont/headscale/hscontrol/policy/matcher"
+
 	"github.com/google/go-cmp/cmp"
 	"github.com/juanfont/headscale/hscontrol/types"
 	"github.com/juanfont/headscale/hscontrol/util"
@@ -1370,7 +1371,6 @@ func TestFilterNodesByACL(t *testing.T) {
 				},
 			},
 		},
-
 		{
 			name: "subnet-router-with-only-route",
 			args: args{
@@ -1422,6 +1422,108 @@ func TestFilterNodesByACL(t *testing.T) {
 				},
 			},
 		},
+		{
+			name: "subnet-router-with-only-route-smaller-mask-2181",
+			args: args{
+				nodes: []*types.Node{
+					{
+						ID:       1,
+						IPv4:     ap("100.64.0.1"),
+						Hostname: "router",
+						User:     types.User{Name: "router"},
+						Hostinfo: &tailcfg.Hostinfo{
+							RoutableIPs: []netip.Prefix{netip.MustParsePrefix("10.99.0.0/16")},
+						},
+						ApprovedRoutes: []netip.Prefix{netip.MustParsePrefix("10.99.0.0/16")},
+					},
+					{
+						ID:       2,
+						IPv4:     ap("100.64.0.2"),
+						Hostname: "node",
+						User:     types.User{Name: "node"},
+					},
+				},
+				rules: []tailcfg.FilterRule{
+					{
+						SrcIPs: []string{
+							"100.64.0.2/32",
+						},
+						DstPorts: []tailcfg.NetPortRange{
+							{IP: "10.99.0.2/32", Ports: tailcfg.PortRangeAny},
+						},
+					},
+				},
+				node: &types.Node{
+					ID:       1,
+					IPv4:     ap("100.64.0.1"),
+					Hostname: "router",
+					User:     types.User{Name: "router"},
+					Hostinfo: &tailcfg.Hostinfo{
+						RoutableIPs: []netip.Prefix{netip.MustParsePrefix("10.99.0.0/16")},
+					},
+					ApprovedRoutes: []netip.Prefix{netip.MustParsePrefix("10.99.0.0/16")},
+				},
+			},
+			want: []*types.Node{
+				{
+					ID:       2,
+					IPv4:     ap("100.64.0.2"),
+					Hostname: "node",
+					User:     types.User{Name: "node"},
+				},
+			},
+		},
+		{
+			name: "node-to-subnet-router-with-only-route-smaller-mask-2181",
+			args: args{
+				nodes: []*types.Node{
+					{
+						ID:       1,
+						IPv4:     ap("100.64.0.1"),
+						Hostname: "router",
+						User:     types.User{Name: "router"},
+						Hostinfo: &tailcfg.Hostinfo{
+							RoutableIPs: []netip.Prefix{netip.MustParsePrefix("10.99.0.0/16")},
+						},
+						ApprovedRoutes: []netip.Prefix{netip.MustParsePrefix("10.99.0.0/16")},
+					},
+					{
+						ID:       2,
+						IPv4:     ap("100.64.0.2"),
+						Hostname: "node",
+						User:     types.User{Name: "node"},
+					},
+				},
+				rules: []tailcfg.FilterRule{
+					{
+						SrcIPs: []string{
+							"100.64.0.2/32",
+						},
+						DstPorts: []tailcfg.NetPortRange{
+							{IP: "10.99.0.2/32", Ports: tailcfg.PortRangeAny},
+						},
+					},
+				},
+				node: &types.Node{
+					ID:       2,
+					IPv4:     ap("100.64.0.2"),
+					Hostname: "node",
+					User:     types.User{Name: "node"},
+				},
+			},
+			want: []*types.Node{
+				{
+					ID:       1,
+					IPv4:     ap("100.64.0.1"),
+					Hostname: "router",
+					User:     types.User{Name: "router"},
+					Hostinfo: &tailcfg.Hostinfo{
+						RoutableIPs: []netip.Prefix{netip.MustParsePrefix("10.99.0.0/16")},
+					},
+					ApprovedRoutes: []netip.Prefix{netip.MustParsePrefix("10.99.0.0/16")},
+				},
+			},
+		},
 	}
 
 	for _, tt := range tests {