policy/v2: compile a policy without rules as explicit allow-all

As SaaS, sources are the tailnet ranges and approved routes. They were
"*", so a host on an exit node's network could reach its clients.
This commit is contained in:
Kristoffer Dalby committed 2026-11-03 17:05:00 +01:00
1 parent 5741dba87d
commit d2a85b1d16
6 files changed
+25 -17

No files matched your search

+20 -2
View File
@@ -6,6 +6,7 @@ import (
"net/netip"
"slices"
"strings"
"sync"
"time"
"github.com/juanfont/headscale/hscontrol/types"
@@ -132,12 +133,29 @@ func (pol *Policy) compileFilterRules(
nodes views.Slice[types.NodeView],
) []tailcfg.FilterRule {
if pol == nil || (pol.ACLs == nil && pol.Grants == nil) {
return tailcfg.FilterAllowAll
return noRulesFilter(users, nodes)
}
return globalFilterRules(pol.compileGrants(users, nodes))
}
// allowAllPolicy is the rule a policy without acls or grants stands for.
var allowAllPolicy = sync.OnceValue(func() *Policy {
pol, err := unmarshalPolicy([]byte(`{"acls":[{"action":"accept","src":["*"],"dst":["*:*"]}]}`))
if err != nil {
panic(err)
}
return pol
})
// noRulesFilter is the filter of a policy without acls or grants. As on SaaS,
// its sources are the tailnet's ranges and approved routes, not every address,
// or a host on an exit node's network could open connections to its clients.
func noRulesFilter(users types.Users, nodes views.Slice[types.NodeView]) []tailcfg.FilterRule {
return globalFilterRules(allowAllPolicy().compileGrants(users, nodes))
}
func (pol *Policy) destinationsToNetPortRange(
users types.Users,
nodes views.Slice[types.NodeView],
@@ -201,7 +219,7 @@ func (pol *Policy) compileFilterRulesForNode(
nodes views.Slice[types.NodeView],
) []tailcfg.FilterRule {
if pol == nil {
return tailcfg.FilterAllowAll
return noRulesFilter(users, nodes)
}
grants := pol.compileGrants(users, nodes)
+1 -1
View File
@@ -3687,7 +3687,7 @@ func TestFilterAllowAllFix(t *testing.T) {
rules := tt.pol.compileFilterRules(users, nodes)
isFilterAllowAll := cmp.Diff(tailcfg.FilterAllowAll, rules) == ""
isFilterAllowAll := cmp.Diff(noRulesFilter(users, nodes), rules) == ""
assert.Equal(t, tt.wantFilterAllow, isFilterAllowAll,
"FilterAllowAll mismatch: got rules=%v", rules)
})
+1 -1
View File
@@ -342,7 +342,7 @@ func (pm *PolicyManager) updateLocked() (bool, error) {
var filter []tailcfg.FilterRule
if pm.pol == nil || (pm.pol.ACLs == nil && pm.pol.Grants == nil) {
filter = tailcfg.FilterAllowAll
filter = noRulesFilter(pm.users, pm.nodes)
} else {
filter = globalFilterRules(pm.compiledGrants)
}
+2 -2
View File
@@ -74,8 +74,8 @@ func TestPolicyManager(t *testing.T) {
name: "empty-policy",
pol: "{}",
nodes: types.Nodes{},
wantFilter: tailcfg.FilterAllowAll,
wantMatchers: matcher.MatchesFromFilterRules(tailcfg.FilterAllowAll),
wantFilter: noRulesFilter(users, types.Nodes{}.ViewSlice()),
wantMatchers: matcher.MatchesFromFilterRules(noRulesFilter(users, types.Nodes{}.ViewSlice())),
},
{
name: "empty-acls-denies-all",
@@ -208,15 +208,6 @@ func loadACLTestFile(t *testing.T, path string) *testcapture.Capture {
// aclSkipReasons lists checks where headscale differs from SaaS, keyed
// <test_id>[/<node>]/<check>.
var aclSkipReasons = map[string]string{
"acl-n01/beedrill/filter": allowAllForm,
"acl-n01/bulbasaur/filter": allowAllForm,
"acl-n01/charmander/filter": allowAllForm,
"acl-n01/ivysaur/filter": allowAllForm,
"acl-n01/kakuna/filter": allowAllForm,
"acl-n01/squirtle/filter": allowAllForm,
"acl-n01/venusaur/filter": allowAllForm,
"acl-n01/weedle/filter": allowAllForm,
"acl-au06/beedrill/filter": srcAddressForm,
"acl-k03/beedrill/filter": srcAddressForm,
"acl-k03/bulbasaur/filter": srcAddressForm,
@@ -246,7 +237,6 @@ var aclSkipReasons = map[string]string{
}
const (
allowAllForm = "ALLOW_ALL_FORM: SaaS writes the allow-all default's sources as the tailnet ranges, headscale as \"*\""
srcAddressForm = "FILTER_ADDRESS_FORM: SaaS lists contiguous source addresses one by one, headscale as a range"
srcAndSelfDstForm = "FILTER_ADDRESS_FORM+SELF_DST_REPEAT: sources listed one by one, the autogroup:self destination written once per source alias"
aclDefaultRouteAccepted = "ACL_DEFAULT_ROUTE_ACCEPTED: headscale accepts literal 0.0.0.0/0 and ::/0 ACL destinations; SaaS rejects them and requires * or autogroup:internet"
+1 -1
View File
@@ -339,7 +339,7 @@ func evaluateTests(pol *Policy, users []types.User, nodes views.Slice[types.Node
var filter []tailcfg.FilterRule
if pol.ACLs == nil && pol.Grants == nil {
filter = tailcfg.FilterAllowAll
filter = noRulesFilter(users, nodes)
} else {
filter = globalFilterRules(grants)
}