Repository: juanfont/headscale

Issue:
[Bug] Error out on unknown symbol for policy v2
### 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

This policy loads fine with Headscale 0.25 (`user1` has to exist as user):

```json
{
  "acls": [
    {
      "action": "accept",
      "src": [
        "user1"
      ],
      "dst": [
        "user1:*"
      ]
    },
    {
      "action": "accept",
      "src": [
        "user1"
      ],
      "dst": [
        "autogroup:internet:*"
      ]
    }
  ]
}
```

When loading the same policy with 586a20fbff4e97519d68a7fdd0d75e1c7decec30 and `HEADSCALE_EXPERIMENTAL_POLICY_V2=1` it also loads without error/warning. The policy is no longer valid as `user1` does no longer exist (and policy was not updated to `user1@`).

### Expected Behavior

Error out on loading the policy as `user1` can no longer be used to refer to a user and no entry in the `hosts` section exists that could match otherwise.

### Steps To Reproduce

Load the above policy with policy v2 enabled (`HEADSCALE_EXPERIMENTAL_POLICY_V2=1` )

### Environment

```markdown
- OS: Debian 12
- Headscale version: 586a20fbff4e97519d68a7fdd0d75e1c7decec30
- Tailscale version: -
```

### Runtime environment

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

### Anything else?

_No response_

Hints from issue comments:

Solution patch:
diff --git a/cmd/headscale/cli/nodes.go b/cmd/headscale/cli/nodes.go
index baa89820..00d803b2 100644
--- a/cmd/headscale/cli/nodes.go
+++ b/cmd/headscale/cli/nodes.go
@@ -13,6 +13,7 @@ import (
 	v1 "github.com/juanfont/headscale/gen/go/headscale/v1"
 	"github.com/juanfont/headscale/hscontrol/util"
 	"github.com/pterm/pterm"
+	"github.com/samber/lo"
 	"github.com/spf13/cobra"
 	"google.golang.org/grpc/status"
 	"tailscale.com/types/key"
@@ -254,6 +255,10 @@ var listNodeRoutesCmd = &cobra.Command{
 			}
 		}
 
+		nodes = lo.Filter(nodes, func(n *v1.Node, _ int) bool {
+			return (n.GetSubnetRoutes() != nil && len(n.GetSubnetRoutes()) > 0) || (n.GetApprovedRoutes() != nil && len(n.GetApprovedRoutes()) > 0) || (n.GetAvailableRoutes() != nil && len(n.GetAvailableRoutes()) > 0)
+		})
+
 		tableData, err := nodeRoutesToPtables(nodes)
 		if err != nil {
 			ErrorOutput(err, fmt.Sprintf("Error converting to table: %s", err), output)
diff --git a/hscontrol/debug.go b/hscontrol/debug.go
index 2b245b58..ef28a955 100644
--- a/hscontrol/debug.go
+++ b/hscontrol/debug.go
@@ -40,7 +40,7 @@ func (h *Headscale) debugHTTPServer() *http.Server {
 		w.Write(pol)
 	}))
 	debug.Handle("filter", "Current filter", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-		filter := h.polMan.Filter()
+		filter, _ := h.polMan.Filter()
 
 		filterJSON, err := json.MarshalIndent(filter, "", "  ")
 		if err != nil {
diff --git a/hscontrol/mapper/mapper.go b/hscontrol/mapper/mapper.go
index e49057e7..662e491c 100644
--- a/hscontrol/mapper/mapper.go
+++ b/hscontrol/mapper/mapper.go
@@ -536,7 +536,7 @@ func appendPeerChanges(
 	changed types.Nodes,
 	cfg *types.Config,
 ) error {
-	filter := polMan.Filter()
+	filter, matchers := polMan.Filter()
 
 	sshPolicy, err := polMan.SSHPolicy(node)
 	if err != nil {
@@ -546,7 +546,7 @@ func appendPeerChanges(
 	// If there are filter rules present, see if there are any nodes that cannot
 	// access each-other at all and remove them from the peers.
 	if len(filter) > 0 {
-		changed = policy.FilterNodesByACL(node, changed, filter)
+		changed = policy.FilterNodesByACL(node, changed, matchers)
 	}
 
 	profiles := generateUserProfiles(node, changed)
diff --git a/hscontrol/policy/matcher/matcher.go b/hscontrol/policy/matcher/matcher.go
index 2b86416e..1d4f09d2 100644
--- a/hscontrol/policy/matcher/matcher.go
+++ b/hscontrol/policy/matcher/matcher.go
@@ -13,6 +13,14 @@ type Match struct {
 	dests *netipx.IPSet
 }
 
+func MatchesFromFilterRules(rules []tailcfg.FilterRule) []Match {
+	matches := make([]Match, 0, len(rules))
+	for _, rule := range rules {
+		matches = append(matches, MatchFromFilterRule(rule))
+	}
+	return matches
+}
+
 func MatchFromFilterRule(rule tailcfg.FilterRule) Match {
 	dests := []string{}
 	for _, dest := range rule.DstPorts {
diff --git a/hscontrol/policy/pm.go b/hscontrol/policy/pm.go
index 29b55fc1..0df1bcc4 100644
--- a/hscontrol/policy/pm.go
+++ b/hscontrol/policy/pm.go
@@ -1,6 +1,7 @@
 package policy
 
 import (
+	"github.com/juanfont/headscale/hscontrol/policy/matcher"
 	"net/netip"
 
 	policyv1 "github.com/juanfont/headscale/hscontrol/policy/v1"
@@ -15,7 +16,8 @@ var (
 )
 
 type PolicyManager interface {
-	Filter() []tailcfg.FilterRule
+	// Filter returns the current filter rules for the entire tailnet and the associated matchers.
+	Filter() ([]tailcfg.FilterRule, []matcher.Match)
 	SSHPolicy(*types.Node) (*tailcfg.SSHPolicy, error)
 	SetPolicy([]byte) (bool, error)
 	SetUsers(users []types.User) (bool, error)
diff --git a/hscontrol/policy/policy.go b/hscontrol/policy/policy.go
index ba375beb..d86de29b 100644
--- a/hscontrol/policy/policy.go
+++ b/hscontrol/policy/policy.go
@@ -1,6 +1,7 @@
 package policy
 
 import (
+	"github.com/juanfont/headscale/hscontrol/policy/matcher"
 	"net/netip"
 	"slices"
 
@@ -15,7 +16,7 @@ import (
 func FilterNodesByACL(
 	node *types.Node,
 	nodes types.Nodes,
-	filter []tailcfg.FilterRule,
+	matchers []matcher.Match,
 ) types.Nodes {
 	var result types.Nodes
 
@@ -24,7 +25,7 @@ func FilterNodesByACL(
 			continue
 		}
 
-		if node.CanAccess(filter, nodes[index]) || peer.CanAccess(filter, node) {
+		if node.CanAccess(matchers, nodes[index]) || peer.CanAccess(matchers, node) {
 			result = append(result, peer)
 		}
 	}
diff --git a/hscontrol/policy/v1/policy.go b/hscontrol/policy/v1/policy.go
index 89625ce3..c2e9520a 100644
--- a/hscontrol/policy/v1/policy.go
+++ b/hscontrol/policy/v1/policy.go
@@ -2,6 +2,7 @@ package v1
 
 import (
 	"fmt"
+	"github.com/juanfont/headscale/hscontrol/policy/matcher"
 	"io"
 	"net/netip"
 	"os"
@@ -88,10 +89,10 @@ func (pm *PolicyManager) updateLocked() (bool, error) {
 	return true, nil
 }
 
-func (pm *PolicyManager) Filter() []tailcfg.FilterRule {
+func (pm *PolicyManager) Filter() ([]tailcfg.FilterRule, []matcher.Match) {
 	pm.mu.Lock()
 	defer pm.mu.Unlock()
-	return pm.filter
+	return pm.filter, matcher.MatchesFromFilterRules(pm.filter)
 }
 
 func (pm *PolicyManager) SSHPolicy(node *types.Node) (*tailcfg.SSHPolicy, error) {
diff --git a/hscontrol/policy/v2/policy.go b/hscontrol/policy/v2/policy.go
index 4060b6a6..ec4b7737 100644
--- a/hscontrol/policy/v2/policy.go
+++ b/hscontrol/policy/v2/policy.go
@@ -7,6 +7,8 @@ import (
 	"strings"
 	"sync"
 
+	"github.com/juanfont/headscale/hscontrol/policy/matcher"
+
 	"slices"
 
 	"github.com/juanfont/headscale/hscontrol/types"
@@ -24,6 +26,7 @@ type PolicyManager struct {
 
 	filterHash deephash.Sum
 	filter     []tailcfg.FilterRule
+	matchers   []matcher.Match
 
 	tagOwnerMapHash deephash.Sum
 	tagOwnerMap     map[Tag]*netipx.IPSet
@@ -39,7 +42,7 @@ type PolicyManager struct {
 // It returns an error if the policy file is invalid.
 // The policy manager will update the filter rules based on the users and nodes.
 func NewPolicyManager(b []byte, users []types.User, nodes types.Nodes) (*PolicyManager, error) {
-	policy, err := policyFromBytes(b)
+	policy, err := unmarshalPolicy(b)
 	if err != nil {
 		return nil, fmt.Errorf("parsing policy: %w", err)
 	}
@@ -62,15 +65,24 @@ func NewPolicyManager(b []byte, users []types.User, nodes types.Nodes) (*PolicyM
 // updateLocked updates the filter rules based on the current policy and nodes.
 // It must be called with the lock held.
 func (pm *PolicyManager) updateLocked() (bool, error) {
+	// Clear the SSH policy map to ensure it's recalculated with the new policy.
+	// TODO(kradalby): This could potentially be optimized by only clearing the
+	// policies for nodes that have changed. Particularly if the only difference is
+	// that nodes has been added or removed.
+	defer clear(pm.sshPolicyMap)
+
 	filter, err := pm.pol.compileFilterRules(pm.users, pm.nodes)
 	if err != nil {
 		return false, fmt.Errorf("compiling filter rules: %w", err)
 	}
 
 	filterHash := deephash.Hash(&filter)
-	filterChanged := filterHash == pm.filterHash
+	filterChanged := filterHash != pm.filterHash
 	pm.filter = filter
 	pm.filterHash = filterHash
+	if filterChanged {
+		pm.matchers = matcher.MatchesFromFilterRules(pm.filter)
+	}
 
 	// Order matters, tags might be used in autoapprovers, so we need to ensure
 	// that the map for tag owners is resolved before resolving autoapprovers.
@@ -100,12 +112,6 @@ func (pm *PolicyManager) updateLocked() (bool, error) {
 		return false, nil
 	}
 
-	// Clear the SSH policy map to ensure it's recalculated with the new policy.
-	// TODO(kradalby): This could potentially be optimized by only clearing the
-	// policies for nodes that have changed. Particularly if the only difference is
-	// that nodes has been added or removed.
-	clear(pm.sshPolicyMap)
-
 	return true, nil
 }
 
@@ -131,7 +137,7 @@ func (pm *PolicyManager) SetPolicy(polB []byte) (bool, error) {
 		return false, nil
 	}
 
-	pol, err := policyFromBytes(polB)
+	pol, err := unmarshalPolicy(polB)
 	if err != nil {
 		return false, fmt.Errorf("parsing policy: %w", err)
 	}
@@ -144,11 +150,11 @@ func (pm *PolicyManager) SetPolicy(polB []byte) (bool, error) {
 	return pm.updateLocked()
 }
 
-// Filter returns the current filter rules for the entire tailnet.
-func (pm *PolicyManager) Filter() []tailcfg.FilterRule {
+// Filter returns the current filter rules for the entire tailnet and the associated matchers.
+func (pm *PolicyManager) Filter() ([]tailcfg.FilterRule, []matcher.Match) {
 	pm.mu.Lock()
 	defer pm.mu.Unlock()
-	return pm.filter
+	return pm.filter, pm.matchers
 }
 
 // SetUsers updates the users in the policy manager and updates the filter rules.
diff --git a/hscontrol/policy/v2/types.go b/hscontrol/policy/v2/types.go
index 55376b97..0e292f3a 100644
--- a/hscontrol/policy/v2/types.go
+++ b/hscontrol/policy/v2/types.go
@@ -532,7 +532,7 @@ Please check the format and try again.`, vs)
 type AliasEnc struct{ Alias }
 
 func (ve *AliasEnc) UnmarshalJSON(b []byte) error {
-	ptr, err := unmarshalPointer[Alias](
+	ptr, err := unmarshalPointer(
 		b,
 		parseAlias,
 	)
@@ -639,7 +639,7 @@ Please check the format and try again.`, s)
 type AutoApproverEnc struct{ AutoApprover }
 
 func (ve *AutoApproverEnc) UnmarshalJSON(b []byte) error {
-	ptr, err := unmarshalPointer[AutoApprover](
+	ptr, err := unmarshalPointer(
 		b,
 		parseAutoApprover,
 	)
@@ -659,7 +659,7 @@ type Owner interface {
 type OwnerEnc struct{ Owner }
 
 func (ve *OwnerEnc) UnmarshalJSON(b []byte) error {
-	ptr, err := unmarshalPointer[Owner](
+	ptr, err := unmarshalPointer(
 		b,
 		parseOwner,
 	)
@@ -769,6 +769,11 @@ func (h *Hosts) UnmarshalJSON(b []byte) error {
 	return nil
 }
 
+func (h Hosts) exist(name Host) bool {
+	_, ok := h[name]
+	return ok
+}
+
 // TagOwners are a map of Tag to a list of the UserEntities that own the tag.
 type TagOwners map[Tag]Owners
 
@@ -902,6 +907,39 @@ type Policy struct {
 	SSHs          []SSH              `json:"ssh"`
 }
 
+// validate reports if there are any errors in a policy after
+// the unmarshaling process.
+// It runs through all rules and checks if there are any inconsistencies
+// in the policy that needs to be addressed before it can be used.
+func (p *Policy) validate() error {
+	if p == nil {
+		panic("passed nil policy")
+	}
+
+	// All errors are collected and presented to the user,
+	// when adding more validation, please add to the list of errors.
+	var errs []error
+
+	for _, acl := range p.ACLs {
+		for _, src := range acl.Sources {
+			switch src.(type) {
+			case *Host:
+				h := src.(*Host)
+				if !p.Hosts.exist(*h) {
+					errs = append(errs, fmt.Errorf(`Host %q is not defined in the Policy, please define or remove the reference to it`, *h))
+				}
+			}
+		}
+	}
+
+	if len(errs) > 0 {
+		return multierr.New(errs...)
+	}
+
+	p.validated = true
+	return nil
+}
+
 // SSH controls who can ssh into which machines.
 type SSH struct {
 	Action       string        `json:"action"` // TODO(kradalby): add strict type
@@ -986,7 +1024,10 @@ func (u SSHUser) String() string {
 	return string(u)
 }
 
-func policyFromBytes(b []byte) (*Policy, error) {
+// unmarshalPolicy takes a byte slice and unmarshals it into a Policy struct.
+// In addition to unmarshalling, it will also validate the policy.
+// This is the only entrypoint of reading a policy from a file or other source.
+func unmarshalPolicy(b []byte) (*Policy, error) {
 	if b == nil || len(b) == 0 {
 		return nil, nil
 	}
@@ -1000,11 +1041,14 @@ func policyFromBytes(b []byte) (*Policy, error) {
 	ast.Standardize()
 	acl := ast.Pack()
 
-	err = json.Unmarshal(acl, &policy)
-	if err != nil {
+	if err = json.Unmarshal(acl, &policy); err != nil {
 		return nil, fmt.Errorf("parsing policy from bytes: %w", err)
 	}
 
+	if err := policy.validate(); err != nil {
+		return nil, err
+	}
+
 	return &policy, nil
 }
 
diff --git a/hscontrol/types/node.go b/hscontrol/types/node.go
index 3567c4f1..826867eb 100644
--- a/hscontrol/types/node.go
+++ b/hscontrol/types/node.go
@@ -270,18 +270,10 @@ func (node *Node) AppendToIPSet(build *netipx.IPSetBuilder) {
 	}
 }
 
-func (node *Node) CanAccess(filter []tailcfg.FilterRule, node2 *Node) bool {
+func (node *Node) CanAccess(matchers []matcher.Match, node2 *Node) bool {
 	src := node.IPs()
 	allowedIPs := node2.IPs()
 
-	// TODO(kradalby): Regenerate this every time the filter change, instead of
-	// every time we use it.
-	// Part of #2416
-	matchers := make([]matcher.Match, len(filter))
-	for i, rule := range filter {
-		matchers[i] = matcher.MatchFromFilterRule(rule)
-	}
-
 	for _, matcher := range matchers {
 		if !matcher.SrcsContainsIPs(src...) {
 			continue



Test patch:
diff --git a/hscontrol/policy/policy_test.go b/hscontrol/policy/policy_test.go
index cfd38765..cebda65f 100644
--- a/hscontrol/policy/policy_test.go
+++ b/hscontrol/policy/policy_test.go
@@ -2,6 +2,7 @@ package policy
 
 import (
 	"fmt"
+	"github.com/juanfont/headscale/hscontrol/policy/matcher"
 	"net/netip"
 	"testing"
 
@@ -769,7 +770,7 @@ func TestReduceFilterRules(t *testing.T) {
 				var err error
 				pm, err = pmf(users, append(tt.peers, tt.node))
 				require.NoError(t, err)
-				got := pm.Filter()
+				got, _ := pm.Filter()
 				got = ReduceFilterRules(tt.node, got)
 
 				if diff := cmp.Diff(tt.want, got); diff != "" {
@@ -1425,10 +1426,11 @@ func TestFilterNodesByACL(t *testing.T) {
 
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
+			matchers := matcher.MatchesFromFilterRules(tt.args.rules)
 			got := FilterNodesByACL(
 				tt.args.node,
 				tt.args.nodes,
-				tt.args.rules,
+				matchers,
 			)
 			if diff := cmp.Diff(tt.want, got, util.Comparers...); diff != "" {
 				t.Errorf("FilterNodesByACL() unexpected result (-want +got):\n%s", diff)
diff --git a/hscontrol/policy/v1/policy_test.go b/hscontrol/policy/v1/policy_test.go
index e250db2a..c9f98079 100644
--- a/hscontrol/policy/v1/policy_test.go
+++ b/hscontrol/policy/v1/policy_test.go
@@ -1,6 +1,7 @@
 package v1
 
 import (
+	"github.com/juanfont/headscale/hscontrol/policy/matcher"
 	"testing"
 
 	"github.com/google/go-cmp/cmp"
@@ -27,6 +28,7 @@ func TestPolicySetChange(t *testing.T) {
 		wantNodesChange  bool
 		wantPolicyChange bool
 		wantFilter       []tailcfg.FilterRule
+		wantMatchers     []matcher.Match
 	}{
 		{
 			name: "set-nodes",
@@ -42,6 +44,9 @@ func TestPolicySetChange(t *testing.T) {
 					DstPorts: []tailcfg.NetPortRange{{IP: "100.64.0.1/32", Ports: tailcfg.PortRangeAny}},
 				},
 			},
+			wantMatchers: []matcher.Match{
+				matcher.MatchFromStrings([]string{}, []string{"100.64.0.1/32"}),
+			},
 		},
 		{
 			name:            "set-users",
@@ -52,6 +57,9 @@ func TestPolicySetChange(t *testing.T) {
 					DstPorts: []tailcfg.NetPortRange{{IP: "100.64.0.1/32", Ports: tailcfg.PortRangeAny}},
 				},
 			},
+			wantMatchers: []matcher.Match{
+				matcher.MatchFromStrings([]string{}, []string{"100.64.0.1/32"}),
+			},
 		},
 		{
 			name:  "set-users-and-node",
@@ -70,6 +78,9 @@ func TestPolicySetChange(t *testing.T) {
 					DstPorts: []tailcfg.NetPortRange{{IP: "100.64.0.1/32", Ports: tailcfg.PortRangeAny}},
 				},
 			},
+			wantMatchers: []matcher.Match{
+				matcher.MatchFromStrings([]string{"100.64.0.2/32"}, []string{"100.64.0.1/32"}),
+			},
 		},
 		{
 			name: "set-policy",
@@ -95,6 +106,9 @@ func TestPolicySetChange(t *testing.T) {
 					DstPorts: []tailcfg.NetPortRange{{IP: "100.64.0.62/32", Ports: tailcfg.PortRangeAny}},
 				},
 			},
+			wantMatchers: []matcher.Match{
+				matcher.MatchFromStrings([]string{"100.64.0.61/32"}, []string{"100.64.0.62/32"}),
+			},
 		},
 	}
 
@@ -150,8 +164,16 @@ func TestPolicySetChange(t *testing.T) {
 				assert.Equal(t, tt.wantNodesChange, change)
 			}
 
-			if diff := cmp.Diff(tt.wantFilter, pm.Filter()); diff != "" {
-				t.Errorf("TestPolicySetChange() unexpected result (-want +got):\n%s", diff)
+			filter, matchers := pm.Filter()
+			if diff := cmp.Diff(tt.wantFilter, filter); diff != "" {
+				t.Errorf("TestPolicySetChange() unexpected filter (-want +got):\n%s", diff)
+			}
+			if diff := cmp.Diff(
+				tt.wantMatchers,
+				matchers,
+				cmp.AllowUnexported(matcher.Match{}),
+			); diff != "" {
+				t.Errorf("TestPolicySetChange() unexpected matchers (-want +got):\n%s", diff)
 			}
 		})
 	}
diff --git a/hscontrol/policy/v2/filter_test.go b/hscontrol/policy/v2/filter_test.go
index e0b12520..b5f08164 100644
--- a/hscontrol/policy/v2/filter_test.go
+++ b/hscontrol/policy/v2/filter_test.go
@@ -336,7 +336,7 @@ func TestParsing(t *testing.T) {
 
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
-			pol, err := policyFromBytes([]byte(tt.acl))
+			pol, err := unmarshalPolicy([]byte(tt.acl))
 			if tt.wantErr && err == nil {
 				t.Errorf("parsing() error = %v, wantErr %v", err, tt.wantErr)
 
diff --git a/hscontrol/policy/v2/policy_test.go b/hscontrol/policy/v2/policy_test.go
index ee26c596..b61c5758 100644
--- a/hscontrol/policy/v2/policy_test.go
+++ b/hscontrol/policy/v2/policy_test.go
@@ -1,6 +1,7 @@
 package v2
 
 import (
+	"github.com/juanfont/headscale/hscontrol/policy/matcher"
 	"testing"
 
 	"github.com/google/go-cmp/cmp"
@@ -29,16 +30,18 @@ func TestPolicyManager(t *testing.T) {
 	}
 
 	tests := []struct {
-		name       string
-		pol        string
-		nodes      types.Nodes
-		wantFilter []tailcfg.FilterRule
+		name         string
+		pol          string
+		nodes        types.Nodes
+		wantFilter   []tailcfg.FilterRule
+		wantMatchers []matcher.Match
 	}{
 		{
-			name:       "empty-policy",
-			pol:        "{}",
-			nodes:      types.Nodes{},
-			wantFilter: nil,
+			name:         "empty-policy",
+			pol:          "{}",
+			nodes:        types.Nodes{},
+			wantFilter:   nil,
+			wantMatchers: []matcher.Match{},
 		},
 	}
 
@@ -47,9 +50,16 @@ func TestPolicyManager(t *testing.T) {
 			pm, err := NewPolicyManager([]byte(tt.pol), users, tt.nodes)
 			require.NoError(t, err)
 
-			filter := pm.Filter()
-			if diff := cmp.Diff(filter, tt.wantFilter); diff != "" {
-				t.Errorf("Filter() mismatch (-want +got):\n%s", diff)
+			filter, matchers := pm.Filter()
+			if diff := cmp.Diff(tt.wantFilter, filter); diff != "" {
+				t.Errorf("Filter() filter mismatch (-want +got):\n%s", diff)
+			}
+			if diff := cmp.Diff(
+				tt.wantMatchers,
+				matchers,
+				cmp.AllowUnexported(matcher.Match{}),
+			); diff != "" {
+				t.Errorf("Filter() matchers mismatch (-want +got):\n%s", diff)
 			}
 
 			// TODO(kradalby): Test SSH Policy
diff --git a/hscontrol/policy/v2/types_test.go b/hscontrol/policy/v2/types_test.go
index 2218685e..6a89efd3 100644
--- a/hscontrol/policy/v2/types_test.go
+++ b/hscontrol/policy/v2/types_test.go
@@ -361,6 +361,65 @@ func TestUnmarshalPolicy(t *testing.T) {
 `,
 			wantErr: `AutoGroup is invalid, got: "autogroup:invalid", must be one of [autogroup:internet]`,
 		},
+		{
+			name: "undefined-hostname-errors-2490",
+			input: `
+{
+  "acls": [
+    {
+      "action": "accept",
+      "src": [
+        "user1"
+      ],
+      "dst": [
+        "user1:*"
+      ]
+    }
+  ]
+}
+`,
+			wantErr: `Host "user1" is not defined in the Policy, please define or remove the reference to it`,
+		},
+		{
+			name: "defined-hostname-does-not-err-2490",
+			input: `
+{
+  "hosts": {
+		"user1": "100.100.100.100",
+  },
+  "acls": [
+    {
+      "action": "accept",
+      "src": [
+        "user1"
+      ],
+      "dst": [
+        "user1:*"
+      ]
+    }
+  ]
+}
+`,
+			want: &Policy{
+				Hosts: Hosts{
+					"user1": Prefix(mp("100.100.100.100/32")),
+				},
+				ACLs: []ACL{
+					{
+						Action: "accept",
+						Sources: Aliases{
+							hp("user1"),
+						},
+						Destinations: []AliasWithPorts{
+							{
+								Alias: hp("user1"),
+								Ports: []tailcfg.PortRange{tailcfg.PortRangeAny},
+							},
+						},
+					},
+				},
+			},
+		},
 	}
 
 	cmps := append(util.Comparers, cmp.Comparer(func(x, y Prefix) bool {
@@ -370,7 +429,7 @@ func TestUnmarshalPolicy(t *testing.T) {
 
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
-			policy, err := policyFromBytes([]byte(tt.input))
+			policy, err := unmarshalPolicy([]byte(tt.input))
 			if tt.wantErr == "" {
 				if err != nil {
 					t.Fatalf("got %v; want no error", err)
diff --git a/hscontrol/types/node_test.go b/hscontrol/types/node_test.go
index 702fa251..c7261587 100644
--- a/hscontrol/types/node_test.go
+++ b/hscontrol/types/node_test.go
@@ -2,6 +2,7 @@ package types
 
 import (
 	"fmt"
+	"github.com/juanfont/headscale/hscontrol/policy/matcher"
 	"net/netip"
 	"strings"
 	"testing"
@@ -116,7 +117,8 @@ func Test_NodeCanAccess(t *testing.T) {
 
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
-			got := tt.node1.CanAccess(tt.rules, &tt.node2)
+			matchers := matcher.MatchesFromFilterRules(tt.rules)
+			got := tt.node1.CanAccess(matchers, &tt.node2)
 
 			if got != tt.want {
 				t.Errorf("canAccess() failed: want (%t), got (%t)", tt.want, got)