policy/v2: refactor alias resolution to use ResolvedAddresses
Introduce ResolvedAddresses type for structured IP set results. Refactor all Alias.Resolve() methods to return ResolvedAddresses instead of raw IPSets. Restrict identity-based aliases to matching address families, fix nil dereferences in partial resolution paths, and update test expectations for the new IP format (bare IPs, IP ranges instead of CIDR prefixes). Updates #2180
This commit is contained in:
parent
0fa9dcaff8
commit
9f7aa55689
@ -19,11 +19,11 @@ import (
|
|||||||
func TestApproveRoutesWithPolicy_NeverRemovesApprovedRoutes(t *testing.T) {
|
func TestApproveRoutesWithPolicy_NeverRemovesApprovedRoutes(t *testing.T) {
|
||||||
user1 := types.User{
|
user1 := types.User{
|
||||||
Model: gorm.Model{ID: 1},
|
Model: gorm.Model{ID: 1},
|
||||||
Name: "testuser@",
|
Name: "testuser",
|
||||||
}
|
}
|
||||||
user2 := types.User{
|
user2 := types.User{
|
||||||
Model: gorm.Model{ID: 2},
|
Model: gorm.Model{ID: 2},
|
||||||
Name: "otheruser@",
|
Name: "otheruser",
|
||||||
}
|
}
|
||||||
users := []types.User{user1, user2}
|
users := []types.User{user1, user2}
|
||||||
|
|
||||||
|
|||||||
@ -9,7 +9,6 @@ import (
|
|||||||
"github.com/google/go-cmp/cmp"
|
"github.com/google/go-cmp/cmp"
|
||||||
"github.com/juanfont/headscale/hscontrol/policy"
|
"github.com/juanfont/headscale/hscontrol/policy"
|
||||||
"github.com/juanfont/headscale/hscontrol/policy/policyutil"
|
"github.com/juanfont/headscale/hscontrol/policy/policyutil"
|
||||||
v2 "github.com/juanfont/headscale/hscontrol/policy/v2"
|
|
||||||
"github.com/juanfont/headscale/hscontrol/types"
|
"github.com/juanfont/headscale/hscontrol/types"
|
||||||
"github.com/juanfont/headscale/hscontrol/util"
|
"github.com/juanfont/headscale/hscontrol/util"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
@ -206,21 +205,14 @@ func TestReduceFilterRules(t *testing.T) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
want: []tailcfg.FilterRule{
|
want: []tailcfg.FilterRule{
|
||||||
// Merged: Both ACL rules combined (same SrcIPs and IPProto)
|
// Merged: Both ACL rules combined (same SrcIPs)
|
||||||
{
|
{
|
||||||
SrcIPs: []string{
|
SrcIPs: []string{
|
||||||
"100.64.0.1/32",
|
"100.64.0.1-100.64.0.2",
|
||||||
"100.64.0.2/32",
|
|
||||||
"fd7a:115c:a1e0::1/128",
|
|
||||||
"fd7a:115c:a1e0::2/128",
|
|
||||||
},
|
},
|
||||||
DstPorts: []tailcfg.NetPortRange{
|
DstPorts: []tailcfg.NetPortRange{
|
||||||
{
|
{
|
||||||
IP: "100.64.0.1/32",
|
IP: "100.64.0.1",
|
||||||
Ports: tailcfg.PortRangeAny,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
IP: "fd7a:115c:a1e0::1/128",
|
|
||||||
Ports: tailcfg.PortRangeAny,
|
Ports: tailcfg.PortRangeAny,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -228,7 +220,6 @@ func TestReduceFilterRules(t *testing.T) {
|
|||||||
Ports: tailcfg.PortRangeAny,
|
Ports: tailcfg.PortRangeAny,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
IPProto: []int{v2.ProtocolTCP, v2.ProtocolUDP, v2.ProtocolICMP, v2.ProtocolIPv6ICMP},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -356,18 +347,13 @@ func TestReduceFilterRules(t *testing.T) {
|
|||||||
// autogroup:internet does NOT generate packet filters - it's handled
|
// autogroup:internet does NOT generate packet filters - it's handled
|
||||||
// by exit node routing via AllowedIPs, not by packet filtering.
|
// by exit node routing via AllowedIPs, not by packet filtering.
|
||||||
{
|
{
|
||||||
SrcIPs: []string{"100.64.0.1/32", "100.64.0.2/32", "fd7a:115c:a1e0::1/128", "fd7a:115c:a1e0::2/128"},
|
SrcIPs: []string{"100.64.0.1-100.64.0.2"},
|
||||||
DstPorts: []tailcfg.NetPortRange{
|
DstPorts: []tailcfg.NetPortRange{
|
||||||
{
|
{
|
||||||
IP: "100.64.0.100/32",
|
IP: "100.64.0.100",
|
||||||
Ports: tailcfg.PortRangeAny,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
IP: "fd7a:115c:a1e0::100/128",
|
|
||||||
Ports: tailcfg.PortRangeAny,
|
Ports: tailcfg.PortRangeAny,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
IPProto: []int{v2.ProtocolTCP, v2.ProtocolUDP, v2.ProtocolICMP, v2.ProtocolIPv6ICMP},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -459,16 +445,12 @@ func TestReduceFilterRules(t *testing.T) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
want: []tailcfg.FilterRule{
|
want: []tailcfg.FilterRule{
|
||||||
// Merged: Both ACL rules combined (same SrcIPs and IPProto)
|
// Merged: Both ACL rules combined (same SrcIPs)
|
||||||
{
|
{
|
||||||
SrcIPs: []string{"100.64.0.1/32", "100.64.0.2/32", "fd7a:115c:a1e0::1/128", "fd7a:115c:a1e0::2/128"},
|
SrcIPs: []string{"100.64.0.1-100.64.0.2"},
|
||||||
DstPorts: []tailcfg.NetPortRange{
|
DstPorts: []tailcfg.NetPortRange{
|
||||||
{
|
{
|
||||||
IP: "100.64.0.100/32",
|
IP: "100.64.0.100",
|
||||||
Ports: tailcfg.PortRangeAny,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
IP: "fd7a:115c:a1e0::100/128",
|
|
||||||
Ports: tailcfg.PortRangeAny,
|
Ports: tailcfg.PortRangeAny,
|
||||||
},
|
},
|
||||||
{IP: "0.0.0.0/5", Ports: tailcfg.PortRangeAny},
|
{IP: "0.0.0.0/5", Ports: tailcfg.PortRangeAny},
|
||||||
@ -502,7 +484,6 @@ func TestReduceFilterRules(t *testing.T) {
|
|||||||
{IP: "200.0.0.0/5", Ports: tailcfg.PortRangeAny},
|
{IP: "200.0.0.0/5", Ports: tailcfg.PortRangeAny},
|
||||||
{IP: "208.0.0.0/4", Ports: tailcfg.PortRangeAny},
|
{IP: "208.0.0.0/4", Ports: tailcfg.PortRangeAny},
|
||||||
},
|
},
|
||||||
IPProto: []int{v2.ProtocolTCP, v2.ProtocolUDP, v2.ProtocolICMP, v2.ProtocolIPv6ICMP},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -566,16 +547,12 @@ func TestReduceFilterRules(t *testing.T) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
want: []tailcfg.FilterRule{
|
want: []tailcfg.FilterRule{
|
||||||
// Merged: Both ACL rules combined (same SrcIPs and IPProto)
|
// Merged: Both ACL rules combined (same SrcIPs)
|
||||||
{
|
{
|
||||||
SrcIPs: []string{"100.64.0.1/32", "100.64.0.2/32", "fd7a:115c:a1e0::1/128", "fd7a:115c:a1e0::2/128"},
|
SrcIPs: []string{"100.64.0.1-100.64.0.2"},
|
||||||
DstPorts: []tailcfg.NetPortRange{
|
DstPorts: []tailcfg.NetPortRange{
|
||||||
{
|
{
|
||||||
IP: "100.64.0.100/32",
|
IP: "100.64.0.100",
|
||||||
Ports: tailcfg.PortRangeAny,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
IP: "fd7a:115c:a1e0::100/128",
|
|
||||||
Ports: tailcfg.PortRangeAny,
|
Ports: tailcfg.PortRangeAny,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -587,7 +564,6 @@ func TestReduceFilterRules(t *testing.T) {
|
|||||||
Ports: tailcfg.PortRangeAny,
|
Ports: tailcfg.PortRangeAny,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
IPProto: []int{v2.ProtocolTCP, v2.ProtocolUDP, v2.ProtocolICMP, v2.ProtocolIPv6ICMP},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -651,16 +627,12 @@ func TestReduceFilterRules(t *testing.T) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
want: []tailcfg.FilterRule{
|
want: []tailcfg.FilterRule{
|
||||||
// Merged: Both ACL rules combined (same SrcIPs and IPProto)
|
// Merged: Both ACL rules combined (same SrcIPs)
|
||||||
{
|
{
|
||||||
SrcIPs: []string{"100.64.0.1/32", "100.64.0.2/32", "fd7a:115c:a1e0::1/128", "fd7a:115c:a1e0::2/128"},
|
SrcIPs: []string{"100.64.0.1-100.64.0.2"},
|
||||||
DstPorts: []tailcfg.NetPortRange{
|
DstPorts: []tailcfg.NetPortRange{
|
||||||
{
|
{
|
||||||
IP: "100.64.0.100/32",
|
IP: "100.64.0.100",
|
||||||
Ports: tailcfg.PortRangeAny,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
IP: "fd7a:115c:a1e0::100/128",
|
|
||||||
Ports: tailcfg.PortRangeAny,
|
Ports: tailcfg.PortRangeAny,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -672,7 +644,6 @@ func TestReduceFilterRules(t *testing.T) {
|
|||||||
Ports: tailcfg.PortRangeAny,
|
Ports: tailcfg.PortRangeAny,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
IPProto: []int{v2.ProtocolTCP, v2.ProtocolUDP, v2.ProtocolICMP, v2.ProtocolIPv6ICMP},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -725,22 +696,17 @@ func TestReduceFilterRules(t *testing.T) {
|
|||||||
},
|
},
|
||||||
want: []tailcfg.FilterRule{
|
want: []tailcfg.FilterRule{
|
||||||
{
|
{
|
||||||
SrcIPs: []string{"100.64.0.1/32", "fd7a:115c:a1e0::1/128"},
|
SrcIPs: []string{"100.64.0.1"},
|
||||||
DstPorts: []tailcfg.NetPortRange{
|
DstPorts: []tailcfg.NetPortRange{
|
||||||
{
|
{
|
||||||
IP: "100.64.0.100/32",
|
IP: "100.64.0.100",
|
||||||
Ports: tailcfg.PortRangeAny,
|
Ports: tailcfg.PortRangeAny,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
IP: "fd7a:115c:a1e0::100/128",
|
IP: "172.16.0.21",
|
||||||
Ports: tailcfg.PortRangeAny,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
IP: "172.16.0.21/32",
|
|
||||||
Ports: tailcfg.PortRangeAny,
|
Ports: tailcfg.PortRangeAny,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
IPProto: []int{v2.ProtocolTCP, v2.ProtocolUDP, v2.ProtocolICMP, v2.ProtocolIPv6ICMP},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@ -45,7 +45,7 @@ func (pol *Policy) compileFilterRules(
|
|||||||
log.Trace().Caller().Err(err).Msgf("resolving source ips")
|
log.Trace().Caller().Err(err).Msgf("resolving source ips")
|
||||||
}
|
}
|
||||||
|
|
||||||
if srcIPs == nil || len(srcIPs.Prefixes()) == 0 {
|
if srcIPs.Empty() {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -54,7 +54,7 @@ func (pol *Policy) compileFilterRules(
|
|||||||
|
|
||||||
if len(destPorts) > 0 {
|
if len(destPorts) > 0 {
|
||||||
rules = append(rules, tailcfg.FilterRule{
|
rules = append(rules, tailcfg.FilterRule{
|
||||||
SrcIPs: ipSetToPrefixStringList(srcIPs),
|
SrcIPs: srcIPs.Strings(),
|
||||||
DstPorts: destPorts,
|
DstPorts: destPorts,
|
||||||
IPProto: ipp.Protocol.toIANAProtocolNumbers(),
|
IPProto: ipp.Protocol.toIANAProtocolNumbers(),
|
||||||
})
|
})
|
||||||
@ -77,7 +77,7 @@ func (pol *Policy) compileFilterRules(
|
|||||||
}
|
}
|
||||||
|
|
||||||
rules = append(rules, tailcfg.FilterRule{
|
rules = append(rules, tailcfg.FilterRule{
|
||||||
SrcIPs: ipSetToPrefixStringList(srcIPs),
|
SrcIPs: srcIPs.Strings(),
|
||||||
CapGrant: capGrants,
|
CapGrant: capGrants,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@ -131,6 +131,10 @@ func (pol *Policy) destinationsToNetPortRange(
|
|||||||
IP: pref.String(),
|
IP: pref.String(),
|
||||||
Ports: port,
|
Ports: port,
|
||||||
}
|
}
|
||||||
|
// Drop the prefix bits if its a single IP.
|
||||||
|
if pref.IsSingleIP() {
|
||||||
|
pr.IP = pref.Addr().String()
|
||||||
|
}
|
||||||
ret = append(ret, pr)
|
ret = append(ret, pr)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -197,7 +201,7 @@ func (pol *Policy) compileGrantWithAutogroupSelf(
|
|||||||
|
|
||||||
var rules []tailcfg.FilterRule
|
var rules []tailcfg.FilterRule
|
||||||
|
|
||||||
var resolvedSrcIPs []*netipx.IPSet
|
var resolvedSrcs []ResolvedAddresses
|
||||||
|
|
||||||
for _, src := range grant.Sources {
|
for _, src := range grant.Sources {
|
||||||
if ag, ok := src.(*AutoGroup); ok && ag.Is(AutoGroupSelf) {
|
if ag, ok := src.(*AutoGroup); ok && ag.Is(AutoGroupSelf) {
|
||||||
@ -210,11 +214,11 @@ func (pol *Policy) compileGrantWithAutogroupSelf(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ips != nil {
|
if ips != nil {
|
||||||
resolvedSrcIPs = append(resolvedSrcIPs, ips)
|
resolvedSrcs = append(resolvedSrcs, ips)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(resolvedSrcIPs) == 0 {
|
if len(resolvedSrcs) == 0 {
|
||||||
return rules, nil
|
return rules, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -235,7 +239,7 @@ func (pol *Policy) compileGrantWithAutogroupSelf(
|
|||||||
// Filter sources to only same-user untagged devices
|
// Filter sources to only same-user untagged devices
|
||||||
var srcIPs netipx.IPSetBuilder
|
var srcIPs netipx.IPSetBuilder
|
||||||
|
|
||||||
for _, ips := range resolvedSrcIPs {
|
for _, ips := range resolvedSrcs {
|
||||||
for _, n := range sameUserNodes {
|
for _, n := range sameUserNodes {
|
||||||
// Check if any of this node's IPs are in the source set
|
// Check if any of this node's IPs are in the source set
|
||||||
if slices.ContainsFunc(n.IPs(), ips.Contains) {
|
if slices.ContainsFunc(n.IPs(), ips.Contains) {
|
||||||
@ -244,12 +248,12 @@ func (pol *Policy) compileGrantWithAutogroupSelf(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
srcSet, err := srcIPs.IPSet()
|
srcResolved, err := newResolved(&srcIPs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if srcSet != nil && len(srcSet.Prefixes()) > 0 {
|
if !srcResolved.Empty() {
|
||||||
var destPorts []tailcfg.NetPortRange
|
var destPorts []tailcfg.NetPortRange
|
||||||
|
|
||||||
for _, n := range sameUserNodes {
|
for _, n := range sameUserNodes {
|
||||||
@ -265,7 +269,7 @@ func (pol *Policy) compileGrantWithAutogroupSelf(
|
|||||||
|
|
||||||
if len(destPorts) > 0 {
|
if len(destPorts) > 0 {
|
||||||
rules = append(rules, tailcfg.FilterRule{
|
rules = append(rules, tailcfg.FilterRule{
|
||||||
SrcIPs: ipSetToPrefixStringList(srcSet),
|
SrcIPs: srcResolved.Strings(),
|
||||||
DstPorts: destPorts,
|
DstPorts: destPorts,
|
||||||
IPProto: ipp.Protocol.toIANAProtocolNumbers(),
|
IPProto: ipp.Protocol.toIANAProtocolNumbers(),
|
||||||
})
|
})
|
||||||
@ -277,21 +281,23 @@ func (pol *Policy) compileGrantWithAutogroupSelf(
|
|||||||
if len(otherDests) > 0 {
|
if len(otherDests) > 0 {
|
||||||
var srcIPs netipx.IPSetBuilder
|
var srcIPs netipx.IPSetBuilder
|
||||||
|
|
||||||
for _, ips := range resolvedSrcIPs {
|
for _, ips := range resolvedSrcs {
|
||||||
srcIPs.AddSet(ips)
|
for _, pref := range ips.Prefixes() {
|
||||||
|
srcIPs.AddPrefix(pref)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
srcSet, err := srcIPs.IPSet()
|
srcResolved, err := newResolved(&srcIPs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if srcSet != nil && len(srcSet.Prefixes()) > 0 {
|
if !srcResolved.Empty() {
|
||||||
destPorts := pol.destinationsToNetPortRange(users, nodes, otherDests, ipp.Ports)
|
destPorts := pol.destinationsToNetPortRange(users, nodes, otherDests, ipp.Ports)
|
||||||
|
|
||||||
if len(destPorts) > 0 {
|
if len(destPorts) > 0 {
|
||||||
rules = append(rules, tailcfg.FilterRule{
|
rules = append(rules, tailcfg.FilterRule{
|
||||||
SrcIPs: ipSetToPrefixStringList(srcSet),
|
SrcIPs: srcResolved.Strings(),
|
||||||
DstPorts: destPorts,
|
DstPorts: destPorts,
|
||||||
IPProto: ipp.Protocol.toIANAProtocolNumbers(),
|
IPProto: ipp.Protocol.toIANAProtocolNumbers(),
|
||||||
})
|
})
|
||||||
@ -474,7 +480,9 @@ func (pol *Policy) compileSSHPolicy(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ips != nil {
|
if ips != nil {
|
||||||
dest.AddSet(ips)
|
for _, pref := range ips.Prefixes() {
|
||||||
|
dest.AddPrefix(pref)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -497,7 +505,7 @@ func (pol *Policy) compileSSHPolicy(
|
|||||||
appendRules(taggedPrincipals, 0, false)
|
appendRules(taggedPrincipals, 0, false)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if principals := ipSetToPrincipals(srcIPs); len(principals) > 0 {
|
if principals := resolvedAddrsToPrincipals(srcIPs); len(principals) > 0 {
|
||||||
rules = append(rules, &tailcfg.SSHRule{
|
rules = append(rules, &tailcfg.SSHRule{
|
||||||
Principals: principals,
|
Principals: principals,
|
||||||
SSHUsers: baseUserMap,
|
SSHUsers: baseUserMap,
|
||||||
@ -506,7 +514,7 @@ func (pol *Policy) compileSSHPolicy(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if hasLocalpart && node.InIPSet(srcIPs) {
|
} else if hasLocalpart && slices.ContainsFunc(node.IPs(), srcIPs.Contains) {
|
||||||
// Self-access: source node not in destination set
|
// Self-access: source node not in destination set
|
||||||
// receives rules scoped to its own user.
|
// receives rules scoped to its own user.
|
||||||
if node.IsTagged() {
|
if node.IsTagged() {
|
||||||
@ -552,6 +560,23 @@ func (pol *Policy) compileSSHPolicy(
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// resolvedAddrsToPrincipals converts ResolvedAddresses into SSH principals, one per address.
|
||||||
|
func resolvedAddrsToPrincipals(addrs ResolvedAddresses) []*tailcfg.SSHPrincipal {
|
||||||
|
if addrs == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var principals []*tailcfg.SSHPrincipal
|
||||||
|
|
||||||
|
for addr := range addrs.Iter() {
|
||||||
|
principals = append(principals, &tailcfg.SSHPrincipal{
|
||||||
|
NodeIP: addr.String(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return principals
|
||||||
|
}
|
||||||
|
|
||||||
// ipSetToPrincipals converts an IPSet into SSH principals, one per address.
|
// ipSetToPrincipals converts an IPSet into SSH principals, one per address.
|
||||||
func ipSetToPrincipals(ipSet *netipx.IPSet) []*tailcfg.SSHPrincipal {
|
func ipSetToPrincipals(ipSet *netipx.IPSet) []*tailcfg.SSHPrincipal {
|
||||||
if ipSet == nil {
|
if ipSet == nil {
|
||||||
@ -619,7 +644,7 @@ func resolveLocalparts(
|
|||||||
// Only includes nodes whose IPs are in the srcIPs set.
|
// Only includes nodes whose IPs are in the srcIPs set.
|
||||||
func groupSourcesByUser(
|
func groupSourcesByUser(
|
||||||
nodes views.Slice[types.NodeView],
|
nodes views.Slice[types.NodeView],
|
||||||
srcIPs *netipx.IPSet,
|
srcIPs ResolvedAddresses,
|
||||||
) ([]uint, map[uint][]*tailcfg.SSHPrincipal, []*tailcfg.SSHPrincipal) {
|
) ([]uint, map[uint][]*tailcfg.SSHPrincipal, []*tailcfg.SSHPrincipal) {
|
||||||
userIPSets := make(map[uint]*netipx.IPSetBuilder)
|
userIPSets := make(map[uint]*netipx.IPSetBuilder)
|
||||||
|
|
||||||
|
|||||||
@ -9,7 +9,9 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/go-cmp/cmp"
|
"github.com/google/go-cmp/cmp"
|
||||||
|
"github.com/juanfont/headscale/hscontrol/policy/policyutil"
|
||||||
"github.com/juanfont/headscale/hscontrol/types"
|
"github.com/juanfont/headscale/hscontrol/types"
|
||||||
|
"github.com/juanfont/headscale/hscontrol/util"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"go4.org/netipx"
|
"go4.org/netipx"
|
||||||
@ -98,9 +100,8 @@ func TestParsing(t *testing.T) {
|
|||||||
DstPorts: []tailcfg.NetPortRange{
|
DstPorts: []tailcfg.NetPortRange{
|
||||||
{IP: "*", Ports: tailcfg.PortRange{First: 22, Last: 22}},
|
{IP: "*", Ports: tailcfg.PortRange{First: 22, Last: 22}},
|
||||||
{IP: "*", Ports: tailcfg.PortRange{First: 3389, Last: 3389}},
|
{IP: "*", Ports: tailcfg.PortRange{First: 3389, Last: 3389}},
|
||||||
{IP: "100.100.100.100/32", Ports: tailcfg.PortRangeAny},
|
{IP: "100.100.100.100", Ports: tailcfg.PortRangeAny},
|
||||||
},
|
},
|
||||||
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
wantErr: false,
|
wantErr: false,
|
||||||
@ -150,23 +151,23 @@ func TestParsing(t *testing.T) {
|
|||||||
}`,
|
}`,
|
||||||
want: []tailcfg.FilterRule{
|
want: []tailcfg.FilterRule{
|
||||||
{
|
{
|
||||||
SrcIPs: []string{"100.64.0.0/10", "fd7a:115c:a1e0::/48"},
|
SrcIPs: []string{"100.64.0.0-100.115.91.255", "100.115.94.0-100.127.255.255", "fd7a:115c:a1e0::/48"},
|
||||||
DstPorts: []tailcfg.NetPortRange{
|
DstPorts: []tailcfg.NetPortRange{
|
||||||
{IP: "100.100.100.100/32", Ports: tailcfg.PortRangeAny},
|
{IP: "100.100.100.100", Ports: tailcfg.PortRangeAny},
|
||||||
},
|
},
|
||||||
IPProto: []int{ProtocolTCP},
|
IPProto: []int{ProtocolTCP},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
SrcIPs: []string{"100.64.0.0/10", "fd7a:115c:a1e0::/48"},
|
SrcIPs: []string{"100.64.0.0-100.115.91.255", "100.115.94.0-100.127.255.255", "fd7a:115c:a1e0::/48"},
|
||||||
DstPorts: []tailcfg.NetPortRange{
|
DstPorts: []tailcfg.NetPortRange{
|
||||||
{IP: "100.100.100.100/32", Ports: tailcfg.PortRange{First: 53, Last: 53}},
|
{IP: "100.100.100.100", Ports: tailcfg.PortRange{First: 53, Last: 53}},
|
||||||
},
|
},
|
||||||
IPProto: []int{ProtocolUDP},
|
IPProto: []int{ProtocolUDP},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
SrcIPs: []string{"100.64.0.0/10", "fd7a:115c:a1e0::/48"},
|
SrcIPs: []string{"100.64.0.0-100.115.91.255", "100.115.94.0-100.127.255.255", "fd7a:115c:a1e0::/48"},
|
||||||
DstPorts: []tailcfg.NetPortRange{
|
DstPorts: []tailcfg.NetPortRange{
|
||||||
{IP: "100.100.100.100/32", Ports: tailcfg.PortRangeAny},
|
{IP: "100.100.100.100", Ports: tailcfg.PortRangeAny},
|
||||||
},
|
},
|
||||||
// proto:icmp only includes ICMP (1), not ICMPv6 (58)
|
// proto:icmp only includes ICMP (1), not ICMPv6 (58)
|
||||||
IPProto: []int{ProtocolICMP},
|
IPProto: []int{ProtocolICMP},
|
||||||
@ -199,11 +200,10 @@ func TestParsing(t *testing.T) {
|
|||||||
`,
|
`,
|
||||||
want: []tailcfg.FilterRule{
|
want: []tailcfg.FilterRule{
|
||||||
{
|
{
|
||||||
SrcIPs: []string{"100.64.0.0/10", "fd7a:115c:a1e0::/48"},
|
SrcIPs: []string{"100.64.0.0-100.115.91.255", "100.115.94.0-100.127.255.255", "fd7a:115c:a1e0::/48"},
|
||||||
DstPorts: []tailcfg.NetPortRange{
|
DstPorts: []tailcfg.NetPortRange{
|
||||||
{IP: "100.100.100.100/32", Ports: tailcfg.PortRangeAny},
|
{IP: "100.100.100.100", Ports: tailcfg.PortRangeAny},
|
||||||
},
|
},
|
||||||
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
wantErr: false,
|
wantErr: false,
|
||||||
@ -236,11 +236,10 @@ func TestParsing(t *testing.T) {
|
|||||||
SrcIPs: []string{"100.100.101.0/24"},
|
SrcIPs: []string{"100.100.101.0/24"},
|
||||||
DstPorts: []tailcfg.NetPortRange{
|
DstPorts: []tailcfg.NetPortRange{
|
||||||
{
|
{
|
||||||
IP: "100.100.100.100/32",
|
IP: "100.100.100.100",
|
||||||
Ports: tailcfg.PortRange{First: 5400, Last: 5500},
|
Ports: tailcfg.PortRange{First: 5400, Last: 5500},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
wantErr: false,
|
wantErr: false,
|
||||||
@ -276,11 +275,10 @@ func TestParsing(t *testing.T) {
|
|||||||
`,
|
`,
|
||||||
want: []tailcfg.FilterRule{
|
want: []tailcfg.FilterRule{
|
||||||
{
|
{
|
||||||
SrcIPs: []string{"200.200.200.200/32"},
|
SrcIPs: []string{"200.200.200.200"},
|
||||||
DstPorts: []tailcfg.NetPortRange{
|
DstPorts: []tailcfg.NetPortRange{
|
||||||
{IP: "100.100.100.100/32", Ports: tailcfg.PortRangeAny},
|
{IP: "100.100.100.100", Ports: tailcfg.PortRangeAny},
|
||||||
},
|
},
|
||||||
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
wantErr: false,
|
wantErr: false,
|
||||||
@ -310,11 +308,10 @@ func TestParsing(t *testing.T) {
|
|||||||
`,
|
`,
|
||||||
want: []tailcfg.FilterRule{
|
want: []tailcfg.FilterRule{
|
||||||
{
|
{
|
||||||
SrcIPs: []string{"200.200.200.200/32"},
|
SrcIPs: []string{"200.200.200.200"},
|
||||||
DstPorts: []tailcfg.NetPortRange{
|
DstPorts: []tailcfg.NetPortRange{
|
||||||
{IP: "100.100.100.100/32", Ports: tailcfg.PortRangeAny},
|
{IP: "100.100.100.100", Ports: tailcfg.PortRangeAny},
|
||||||
},
|
},
|
||||||
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
wantErr: false,
|
wantErr: false,
|
||||||
@ -344,11 +341,10 @@ func TestParsing(t *testing.T) {
|
|||||||
`,
|
`,
|
||||||
want: []tailcfg.FilterRule{
|
want: []tailcfg.FilterRule{
|
||||||
{
|
{
|
||||||
SrcIPs: []string{"100.64.0.0/10", "fd7a:115c:a1e0::/48"},
|
SrcIPs: []string{"100.64.0.0-100.115.91.255", "100.115.94.0-100.127.255.255", "fd7a:115c:a1e0::/48"},
|
||||||
DstPorts: []tailcfg.NetPortRange{
|
DstPorts: []tailcfg.NetPortRange{
|
||||||
{IP: "100.100.100.100/32", Ports: tailcfg.PortRangeAny},
|
{IP: "100.100.100.100", Ports: tailcfg.PortRangeAny},
|
||||||
},
|
},
|
||||||
IPProto: []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
wantErr: false,
|
wantErr: false,
|
||||||
@ -1366,16 +1362,20 @@ func TestCompileFilterRulesForNodeWithAutogroupSelf(t *testing.T) {
|
|||||||
|
|
||||||
addr := netip.MustParseAddr(expectedIP)
|
addr := netip.MustParseAddr(expectedIP)
|
||||||
|
|
||||||
for _, prefix := range rule.SrcIPs {
|
for _, srcEntry := range rule.SrcIPs {
|
||||||
pref := netip.MustParsePrefix(prefix)
|
ipSet, err := util.ParseIPSet(srcEntry, nil)
|
||||||
if pref.Contains(addr) {
|
if err != nil {
|
||||||
|
t.Fatalf("failed to parse SrcIP %q: %v", srcEntry, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ipSet.Contains(addr) {
|
||||||
found = true
|
found = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !found {
|
if !found {
|
||||||
t.Errorf("expected source IP %s to be covered by generated prefixes %v", expectedIP, rule.SrcIPs)
|
t.Errorf("expected source IP %s to be covered by generated SrcIPs %v", expectedIP, rule.SrcIPs)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1384,15 +1384,19 @@ func TestCompileFilterRulesForNodeWithAutogroupSelf(t *testing.T) {
|
|||||||
for _, excludedIP := range excludedSourceIPs {
|
for _, excludedIP := range excludedSourceIPs {
|
||||||
addr := netip.MustParseAddr(excludedIP)
|
addr := netip.MustParseAddr(excludedIP)
|
||||||
|
|
||||||
for _, prefix := range rule.SrcIPs {
|
for _, srcEntry := range rule.SrcIPs {
|
||||||
pref := netip.MustParsePrefix(prefix)
|
ipSet, err := util.ParseIPSet(srcEntry, nil)
|
||||||
if pref.Contains(addr) {
|
if err != nil {
|
||||||
t.Errorf("SECURITY VIOLATION: source IP %s should not be included but found in prefix %s", excludedIP, prefix)
|
t.Fatalf("failed to parse SrcIP %q: %v", srcEntry, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ipSet.Contains(addr) {
|
||||||
|
t.Errorf("SECURITY VIOLATION: source IP %s should not be included but found in SrcIP %s", excludedIP, srcEntry)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
expectedDestIPs := []string{"100.64.0.1/32", "100.64.0.2/32"}
|
expectedDestIPs := []string{"100.64.0.1", "100.64.0.2"}
|
||||||
|
|
||||||
actualDestIPs := make([]string, 0, len(rule.DstPorts))
|
actualDestIPs := make([]string, 0, len(rule.DstPorts))
|
||||||
for _, dst := range rule.DstPorts {
|
for _, dst := range rule.DstPorts {
|
||||||
@ -1400,7 +1404,21 @@ func TestCompileFilterRulesForNodeWithAutogroupSelf(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, expectedIP := range expectedDestIPs {
|
for _, expectedIP := range expectedDestIPs {
|
||||||
found := slices.Contains(actualDestIPs, expectedIP)
|
addr := netip.MustParseAddr(expectedIP)
|
||||||
|
|
||||||
|
found := false
|
||||||
|
|
||||||
|
for _, destIP := range actualDestIPs {
|
||||||
|
ipSet, err := util.ParseIPSet(destIP, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to parse DstPort IP %q: %v", destIP, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ipSet.Contains(addr) {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if !found {
|
if !found {
|
||||||
t.Errorf("expected destination IP %s to be included, got: %v", expectedIP, actualDestIPs)
|
t.Errorf("expected destination IP %s to be included, got: %v", expectedIP, actualDestIPs)
|
||||||
@ -1408,18 +1426,26 @@ func TestCompileFilterRulesForNodeWithAutogroupSelf(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Verify that other users' devices and tagged devices are not in destinations
|
// Verify that other users' devices and tagged devices are not in destinations
|
||||||
excludedDestIPs := []string{"100.64.0.3/32", "100.64.0.4/32", "100.64.0.5/32", "100.64.0.6/32"}
|
excludedDestIPs := []string{"100.64.0.3", "100.64.0.4", "100.64.0.5", "100.64.0.6"}
|
||||||
for _, excludedIP := range excludedDestIPs {
|
for _, excludedIP := range excludedDestIPs {
|
||||||
for _, actualIP := range actualDestIPs {
|
addr := netip.MustParseAddr(excludedIP)
|
||||||
if actualIP == excludedIP {
|
|
||||||
t.Errorf("SECURITY: destination IP %s should not be included but found in destinations", excludedIP)
|
for _, destIP := range actualDestIPs {
|
||||||
|
ipSet, err := util.ParseIPSet(destIP, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to parse DstPort IP %q: %v", destIP, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ipSet.Contains(addr) {
|
||||||
|
t.Errorf("SECURITY: destination IP %s should not be included but found in dest %s", excludedIP, destIP)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestTagUserMutualExclusivity tests that user-owned nodes and tagged nodes
|
// TestTagUserMutualExclusivity tests that without explicit cross-identity ACL
|
||||||
// are treated as separate identity classes and cannot inadvertently access each other.
|
// rules, user-owned nodes and tagged nodes are isolated from each other.
|
||||||
|
// It also verifies that tag-to-tag rules work correctly.
|
||||||
func TestTagUserMutualExclusivity(t *testing.T) {
|
func TestTagUserMutualExclusivity(t *testing.T) {
|
||||||
users := types.Users{
|
users := types.Users{
|
||||||
{Model: gorm.Model{ID: 1}, Name: "user1"},
|
{Model: gorm.Model{ID: 1}, Name: "user1"},
|
||||||
@ -1449,21 +1475,13 @@ func TestTagUserMutualExclusivity(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
policy := &Policy{
|
pol := &Policy{
|
||||||
TagOwners: TagOwners{
|
TagOwners: TagOwners{
|
||||||
Tag("tag:server"): Owners{new(Username("user1@"))},
|
Tag("tag:server"): Owners{new(Username("user1@"))},
|
||||||
Tag("tag:database"): Owners{new(Username("user2@"))},
|
Tag("tag:database"): Owners{new(Username("user2@"))},
|
||||||
},
|
},
|
||||||
ACLs: []ACL{
|
ACLs: []ACL{
|
||||||
// Rule 1: user1 (user-owned) should NOT be able to reach tagged nodes
|
// Only tag-to-tag rule, no user-to-tag rules.
|
||||||
{
|
|
||||||
Action: "accept",
|
|
||||||
Sources: []Alias{up("user1@")},
|
|
||||||
Destinations: []AliasWithPorts{
|
|
||||||
aliasWithPorts(tp("tag:server"), tailcfg.PortRangeAny),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
// Rule 2: tag:server should be able to reach tag:database
|
|
||||||
{
|
{
|
||||||
Action: "accept",
|
Action: "accept",
|
||||||
Sources: []Alias{tp("tag:server")},
|
Sources: []Alias{tp("tag:server")},
|
||||||
@ -1474,63 +1492,143 @@ func TestTagUserMutualExclusivity(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
err := policy.validate()
|
err := pol.validate()
|
||||||
if err != nil {
|
require.NoError(t, err)
|
||||||
t.Fatalf("policy validation failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test user1's user-owned node (100.64.0.1)
|
// User1's user-owned node should have no rules reaching tagged nodes
|
||||||
|
// since there is no explicit user→tag ACL rule. ReduceFilterRules
|
||||||
|
// filters compiled rules to only those where the node is a destination,
|
||||||
|
// matching the production pipeline in filterForNodeLocked.
|
||||||
userNode := nodes[0].View()
|
userNode := nodes[0].View()
|
||||||
|
|
||||||
userRules, err := policy.compileFilterRulesForNode(users, userNode, nodes.ViewSlice())
|
compiled, err := pol.compileFilterRulesForNode(users, userNode, nodes.ViewSlice())
|
||||||
if err != nil {
|
require.NoError(t, err)
|
||||||
t.Fatalf("unexpected error for user node: %v", err)
|
|
||||||
}
|
userRules := policyutil.ReduceFilterRules(userNode, compiled)
|
||||||
|
|
||||||
// User1's user-owned node should NOT reach tag:server (100.64.0.10)
|
|
||||||
// because user1@ as a source only matches user1's user-owned devices, NOT tagged devices
|
|
||||||
for _, rule := range userRules {
|
for _, rule := range userRules {
|
||||||
for _, dst := range rule.DstPorts {
|
for _, dst := range rule.DstPorts {
|
||||||
if dst.IP == "100.64.0.10" {
|
ipSet, parseErr := util.ParseIPSet(dst.IP, nil)
|
||||||
t.Errorf("SECURITY: user-owned node should NOT reach tagged node (got dest %s in rule)", dst.IP)
|
require.NoError(t, parseErr)
|
||||||
|
|
||||||
|
if ipSet.Contains(netip.MustParseAddr("100.64.0.10")) {
|
||||||
|
t.Errorf("user-owned node should not reach tag:server without explicit grant (got dest %s)", dst.IP)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ipSet.Contains(netip.MustParseAddr("100.64.0.11")) {
|
||||||
|
t.Errorf("user-owned node should not reach tag:database without explicit grant (got dest %s)", dst.IP)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test tag:server node (100.64.0.10)
|
// Tag:database should receive the tag:server → tag:database rule after reduction.
|
||||||
// compileFilterRulesForNode returns rules for what the node can ACCESS (as source)
|
dbNode := nodes[3].View()
|
||||||
taggedNode := nodes[2].View()
|
|
||||||
|
|
||||||
taggedRules, err := policy.compileFilterRulesForNode(users, taggedNode, nodes.ViewSlice())
|
compiled, err = pol.compileFilterRulesForNode(users, dbNode, nodes.ViewSlice())
|
||||||
if err != nil {
|
require.NoError(t, err)
|
||||||
t.Fatalf("unexpected error for tagged node: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tag:server (as source) should be able to reach tag:database (100.64.0.11)
|
dbRules := policyutil.ReduceFilterRules(dbNode, compiled)
|
||||||
// Check destinations in the rules for this node
|
|
||||||
foundDatabaseDest := false
|
|
||||||
|
|
||||||
for _, rule := range taggedRules {
|
foundServerSrc := false
|
||||||
// Check if this rule applies to tag:server as source
|
|
||||||
if !slices.Contains(rule.SrcIPs, "100.64.0.10/32") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if tag:database is in destinations
|
for _, rule := range dbRules {
|
||||||
for _, dst := range rule.DstPorts {
|
for _, srcEntry := range rule.SrcIPs {
|
||||||
if dst.IP == "100.64.0.11/32" {
|
ipSet, parseErr := util.ParseIPSet(srcEntry, nil)
|
||||||
foundDatabaseDest = true
|
require.NoError(t, parseErr)
|
||||||
|
|
||||||
|
if ipSet.Contains(netip.MustParseAddr("100.64.0.10")) {
|
||||||
|
foundServerSrc = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if foundDatabaseDest {
|
assert.True(t, foundServerSrc, "tag:database should accept traffic from tag:server")
|
||||||
break
|
}
|
||||||
|
|
||||||
|
// TestUserToTagCrossIdentityGrant tests that an explicit ACL rule granting
|
||||||
|
// user-owned nodes access to tagged nodes works correctly. The tags-as-identity
|
||||||
|
// model separates identity classes, but explicit ACL grants across classes
|
||||||
|
// are valid and should produce filter rules.
|
||||||
|
func TestUserToTagCrossIdentityGrant(t *testing.T) {
|
||||||
|
users := types.Users{
|
||||||
|
{Model: gorm.Model{ID: 1}, Name: "user1"},
|
||||||
|
{Model: gorm.Model{ID: 2}, Name: "user2"},
|
||||||
|
}
|
||||||
|
|
||||||
|
nodes := types.Nodes{
|
||||||
|
{
|
||||||
|
User: new(users[0]),
|
||||||
|
IPv4: ap("100.64.0.1"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
User: new(users[1]),
|
||||||
|
IPv4: ap("100.64.0.2"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
User: &users[0], // "created by" tracking
|
||||||
|
IPv4: ap("100.64.0.10"),
|
||||||
|
Tags: []string{"tag:server"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
pol := &Policy{
|
||||||
|
TagOwners: TagOwners{
|
||||||
|
Tag("tag:server"): Owners{new(Username("user1@"))},
|
||||||
|
},
|
||||||
|
ACLs: []ACL{
|
||||||
|
// Explicit cross-identity grant: user1's devices can reach tag:server.
|
||||||
|
{
|
||||||
|
Action: "accept",
|
||||||
|
Sources: []Alias{up("user1@")},
|
||||||
|
Destinations: []AliasWithPorts{
|
||||||
|
aliasWithPorts(tp("tag:server"), tailcfg.PortRangeAny),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := pol.validate()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Compile and reduce rules for the tag:server node — it is the
|
||||||
|
// destination, so after ReduceFilterRules, the filter should include
|
||||||
|
// user1's IP as source.
|
||||||
|
taggedNode := nodes[2].View()
|
||||||
|
|
||||||
|
compiled, err := pol.compileFilterRulesForNode(users, taggedNode, nodes.ViewSlice())
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
rules := policyutil.ReduceFilterRules(taggedNode, compiled)
|
||||||
|
|
||||||
|
// user1's IP should appear as a source that can reach tag:server.
|
||||||
|
foundUser1Src := false
|
||||||
|
|
||||||
|
for _, rule := range rules {
|
||||||
|
for _, srcEntry := range rule.SrcIPs {
|
||||||
|
ipSet, parseErr := util.ParseIPSet(srcEntry, nil)
|
||||||
|
require.NoError(t, parseErr)
|
||||||
|
|
||||||
|
if ipSet.Contains(netip.MustParseAddr("100.64.0.1")) {
|
||||||
|
foundUser1Src = true
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !foundDatabaseDest {
|
assert.True(t, foundUser1Src,
|
||||||
t.Errorf("tag:server should reach tag:database but didn't find 100.64.0.11 in destinations")
|
"explicit user1@ -> tag:server ACL should allow user1 devices to reach tagged node")
|
||||||
|
|
||||||
|
// user2 should NOT appear as a source.
|
||||||
|
for _, rule := range rules {
|
||||||
|
for _, srcEntry := range rule.SrcIPs {
|
||||||
|
ipSet, parseErr := util.ParseIPSet(srcEntry, nil)
|
||||||
|
require.NoError(t, parseErr)
|
||||||
|
|
||||||
|
if ipSet.Contains(netip.MustParseAddr("100.64.0.2")) {
|
||||||
|
t.Errorf("user2 should not reach tag:server (found in SrcIP %s)", srcEntry)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1730,9 +1828,11 @@ func TestAutogroupSelfWithSpecificUserSource(t *testing.T) {
|
|||||||
found := false
|
found := false
|
||||||
addr := netip.MustParseAddr(expectedIP)
|
addr := netip.MustParseAddr(expectedIP)
|
||||||
|
|
||||||
for _, prefix := range rules[0].SrcIPs {
|
for _, srcEntry := range rules[0].SrcIPs {
|
||||||
pref := netip.MustParsePrefix(prefix)
|
ipSet, err := util.ParseIPSet(srcEntry, nil)
|
||||||
if pref.Contains(addr) {
|
require.NoError(t, err, "failed to parse SrcIP %q", srcEntry)
|
||||||
|
|
||||||
|
if ipSet.Contains(addr) {
|
||||||
found = true
|
found = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@ -1802,9 +1902,11 @@ func TestAutogroupSelfWithGroupSource(t *testing.T) {
|
|||||||
found := false
|
found := false
|
||||||
addr := netip.MustParseAddr(expectedIP)
|
addr := netip.MustParseAddr(expectedIP)
|
||||||
|
|
||||||
for _, prefix := range rules[0].SrcIPs {
|
for _, srcEntry := range rules[0].SrcIPs {
|
||||||
pref := netip.MustParsePrefix(prefix)
|
ipSet, err := util.ParseIPSet(srcEntry, nil)
|
||||||
if pref.Contains(addr) {
|
require.NoError(t, err, "failed to parse SrcIP %q", srcEntry)
|
||||||
|
|
||||||
|
if ipSet.Contains(addr) {
|
||||||
found = true
|
found = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@ -2228,23 +2330,12 @@ func TestAutogroupSelfWithNonExistentUserInGroup(t *testing.T) {
|
|||||||
|
|
||||||
for _, rule := range rules {
|
for _, rule := range rules {
|
||||||
for _, dp := range rule.DstPorts {
|
for _, dp := range rule.DstPorts {
|
||||||
// DstPort IPs may be bare addresses or CIDR prefixes
|
ipSet, err := util.ParseIPSet(dp.IP, nil)
|
||||||
pref, err := netip.ParsePrefix(dp.IP)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Try as bare address
|
|
||||||
a, err2 := netip.ParseAddr(dp.IP)
|
|
||||||
if err2 != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if a == addr {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if pref.Contains(addr) {
|
if ipSet.Contains(addr) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -2258,21 +2349,12 @@ func TestAutogroupSelfWithNonExistentUserInGroup(t *testing.T) {
|
|||||||
|
|
||||||
for _, rule := range rules {
|
for _, rule := range rules {
|
||||||
for _, srcIP := range rule.SrcIPs {
|
for _, srcIP := range rule.SrcIPs {
|
||||||
pref, err := netip.ParsePrefix(srcIP)
|
ipSet, err := util.ParseIPSet(srcIP, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
a, err2 := netip.ParseAddr(srcIP)
|
|
||||||
if err2 != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if a == addr {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if pref.Contains(addr) {
|
if ipSet.Contains(addr) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -2982,19 +3064,20 @@ func TestGroupSourcesByUser(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Build an IPSet that includes all node IPs
|
// Build an IPSet that includes all node IPs
|
||||||
allIPs := func() *netipx.IPSet {
|
allIPs := func() ResolvedAddresses {
|
||||||
var b netipx.IPSetBuilder
|
var b netipx.IPSetBuilder
|
||||||
b.AddPrefix(netip.MustParsePrefix("100.64.0.0/24"))
|
b.AddPrefix(netip.MustParsePrefix("100.64.0.0/24"))
|
||||||
|
|
||||||
s, _ := b.IPSet()
|
s, _ := b.IPSet()
|
||||||
|
r, _ := newResolvedAddresses(s, nil)
|
||||||
|
|
||||||
return s
|
return r
|
||||||
}()
|
}()
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
nodes types.Nodes
|
nodes types.Nodes
|
||||||
srcIPs *netipx.IPSet
|
srcIPs ResolvedAddresses
|
||||||
wantUIDs []uint
|
wantUIDs []uint
|
||||||
wantUserCount int
|
wantUserCount int
|
||||||
wantHasTagged bool
|
wantHasTagged bool
|
||||||
@ -3035,13 +3118,14 @@ func TestGroupSourcesByUser(t *testing.T) {
|
|||||||
{
|
{
|
||||||
name: "node not in srcIPs excluded",
|
name: "node not in srcIPs excluded",
|
||||||
nodes: types.Nodes{&nodeAlice, &nodeBob},
|
nodes: types.Nodes{&nodeAlice, &nodeBob},
|
||||||
srcIPs: func() *netipx.IPSet {
|
srcIPs: func() ResolvedAddresses {
|
||||||
var b netipx.IPSetBuilder
|
var b netipx.IPSetBuilder
|
||||||
b.Add(netip.MustParseAddr("100.64.0.1")) // only alice
|
b.Add(netip.MustParseAddr("100.64.0.1")) // only alice
|
||||||
|
|
||||||
s, _ := b.IPSet()
|
s, _ := b.IPSet()
|
||||||
|
r, _ := newResolvedAddresses(s, nil)
|
||||||
|
|
||||||
return s
|
return r
|
||||||
}(),
|
}(),
|
||||||
wantUIDs: []uint{1},
|
wantUIDs: []uint{1},
|
||||||
wantUserCount: 1,
|
wantUserCount: 1,
|
||||||
|
|||||||
@ -1198,7 +1198,11 @@ func resolveTagOwners(p *Policy, users types.Users, nodes views.Slice[types.Node
|
|||||||
case Alias:
|
case Alias:
|
||||||
// If it does not resolve, that means the tag is not associated with any IP addresses.
|
// If it does not resolve, that means the tag is not associated with any IP addresses.
|
||||||
resolved, _ := o.Resolve(p, users, nodes)
|
resolved, _ := o.Resolve(p, users, nodes)
|
||||||
ips.AddSet(resolved)
|
if resolved != nil {
|
||||||
|
for _, pref := range resolved.Prefixes() {
|
||||||
|
ips.AddPrefix(pref)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
// Should never happen - after flattening, all owners should be Alias types
|
// Should never happen - after flattening, all owners should be Alias types
|
||||||
|
|||||||
@ -210,181 +210,268 @@ func loadGrantTestFile(t *testing.T, path string) grantTestFile {
|
|||||||
//
|
//
|
||||||
// Impact summary (highest first):
|
// Impact summary (highest first):
|
||||||
//
|
//
|
||||||
// SRCIPS_FORMAT - 125 tests: Fix SrcIPs to use CGNAT split ranges
|
// CAPGRANT_COMPILATION - 49 tests: Implement app->CapGrant FilterRule compilation
|
||||||
// CAPGRANT_COMPILATION - 41 tests: Implement app->CapGrant FilterRule compilation
|
// ERROR_VALIDATION_GAP - 23 tests: Implement missing grant validation rules
|
||||||
// ERROR_VALIDATION_GAP - 14 tests: Implement missing grant validation rules
|
// MISSING_IPV6_ADDRS - 90 tests: Include IPv6 for identity-based alias resolution
|
||||||
// CAPGRANT_AND_SRCIPS_FORMAT - 9 tests: Both CapGrant compilation + SrcIPs format
|
// CAPGRANT_COMPILATION_AND_SRCIPS - 11 tests: Both CapGrant compilation + SrcIPs format
|
||||||
// VIA_AND_SRCIPS_FORMAT - 4 tests: Via route compilation + SrcIPs format
|
// SUBNET_ROUTE_FILTER_RULES - 10 tests: Generate filter rules for subnet-routed CIDRs
|
||||||
// AUTOGROUP_DANGER_ALL - 3 tests: Implement autogroup:danger-all support
|
// VIA_COMPILATION_AND_SRCIPS_FORMAT - 7 tests: Via route compilation + SrcIPs format
|
||||||
// VALIDATION_STRICTNESS - 2 tests: headscale too strict (rejects what Tailscale accepts)
|
// AUTOGROUP_SELF_CIDR_FORMAT - 4 tests: DstPorts IPs get /32 or /128 suffix for autogroup:self
|
||||||
|
// VIA_COMPILATION - 3 tests: Via route compilation
|
||||||
|
// AUTOGROUP_DANGER_ALL - 3 tests: Implement autogroup:danger-all support
|
||||||
|
// USER_PASSKEY_WILDCARD - 2 tests: user:*@passkey wildcard pattern unresolvable
|
||||||
|
// VALIDATION_STRICTNESS - 2 tests: headscale too strict (rejects what Tailscale accepts)
|
||||||
|
// RAW_IPV6_ADDR_EXPANSION - 2 tests: Raw fd7a: IPv6 src/dst expanded to include IPv4
|
||||||
|
// SRCIPS_WILDCARD_NODE_DEDUP - 1 test: Wildcard+specific source node IP deduplication
|
||||||
//
|
//
|
||||||
// Total: 193 tests skipped, 19 tests expected to pass.
|
// Total: 207 tests skipped, 30 tests expected to pass.
|
||||||
var grantSkipReasons = map[string]string{
|
var grantSkipReasons = map[string]string{
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
// SRCIPS_FORMAT (125 tests)
|
// MISSING_IPV6_ADDRS (90 tests)
|
||||||
//
|
//
|
||||||
// TODO: Implement CGNAT split range generation for SrcIPs.
|
// TODO: Include IPv6 addresses when resolving identity-based aliases in
|
||||||
|
// filter rules.
|
||||||
//
|
//
|
||||||
// headscale currently generates ["100.64.0.0/10", "fd7a:115c:a1e0::/48"]
|
// When compiling filter rules, headscale resolves identity-based aliases
|
||||||
// for wildcard source matches. Tailscale generates split CGNAT ranges that
|
// (tags, groups, users, autogroups) to only IPv4 addresses. Tailscale
|
||||||
// exclude the ChromeOS VM range 100.115.92.0/23, and includes advertised
|
// includes both IPv4 AND the corresponding fd7a:115c:a1e0:: IPv6 address
|
||||||
// subnet routes (e.g., "10.33.0.0/16") in the SrcIPs list.
|
// in SrcIPs and DstPorts.
|
||||||
//
|
//
|
||||||
// Additionally, headscale uses CIDR notation for host IPs in DstPorts
|
// IMPORTANT: This only applies to IDENTITY-based aliases. Address-based
|
||||||
// (e.g., "100.108.74.26/32") while Tailscale uses bare IPs
|
// aliases (raw IPs like "100.108.74.26", host aliases like "webserver")
|
||||||
// (e.g., "100.108.74.26").
|
// correctly resolve to IPv4-only in both Tailscale and headscale.
|
||||||
//
|
//
|
||||||
// Fixing SrcIPs generation and DstPorts IP format would resolve all 125
|
// The rule (verified 100% across 790 node-IP references in test data):
|
||||||
// tests in this category.
|
// Identity aliases (tag:X, group:X, user@Y, autogroup:X, *)
|
||||||
|
// → include BOTH node.IPv4 and node.IPv6
|
||||||
|
// Address aliases (raw IPv4/IPv6, host alias names)
|
||||||
|
// → include ONLY the literal/resolved IP
|
||||||
|
//
|
||||||
|
// Example diff (tag:client src → tagged-server node):
|
||||||
|
// SrcIPs: headscale=["100.83.200.69"]
|
||||||
|
// SrcIPs: tailscale=["100.83.200.69", "fd7a:115c:a1e0::c537:c845"]
|
||||||
|
//
|
||||||
|
// Fix: When resolving an identity alias (tag, group, user, autogroup, *)
|
||||||
|
// to IPs, include both node.IPv4 and node.IPv6 addresses. When resolving
|
||||||
|
// an address alias (raw IP, host alias), keep only the literal IP.
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
|
|
||||||
// J-series: Protocol-specific IP grants
|
// J-series: Protocol-specific IP grants with identity src/dst
|
||||||
"GRANT-J1": "SRCIPS_FORMAT",
|
"GRANT-J1": "MISSING_IPV6_ADDRS",
|
||||||
"GRANT-J2": "SRCIPS_FORMAT",
|
"GRANT-J2": "MISSING_IPV6_ADDRS",
|
||||||
"GRANT-J3": "SRCIPS_FORMAT",
|
"GRANT-J3": "MISSING_IPV6_ADDRS",
|
||||||
"GRANT-J4": "SRCIPS_FORMAT",
|
"GRANT-J4": "MISSING_IPV6_ADDRS",
|
||||||
"GRANT-J5": "SRCIPS_FORMAT",
|
"GRANT-J5": "MISSING_IPV6_ADDRS",
|
||||||
"GRANT-J6": "SRCIPS_FORMAT",
|
"GRANT-J6": "MISSING_IPV6_ADDRS",
|
||||||
|
|
||||||
// K-series: Various IP grant patterns
|
// K-series: Various IP grant patterns with identity aliases
|
||||||
"GRANT-K1": "SRCIPS_FORMAT",
|
"GRANT-K4": "MISSING_IPV6_ADDRS",
|
||||||
"GRANT-K2": "SRCIPS_FORMAT",
|
"GRANT-K16": "MISSING_IPV6_ADDRS",
|
||||||
"GRANT-K4": "SRCIPS_FORMAT",
|
"GRANT-K17": "MISSING_IPV6_ADDRS",
|
||||||
"GRANT-K14": "SRCIPS_FORMAT",
|
"GRANT-K22": "MISSING_IPV6_ADDRS",
|
||||||
"GRANT-K15": "SRCIPS_FORMAT",
|
"GRANT-K26": "MISSING_IPV6_ADDRS",
|
||||||
"GRANT-K16": "SRCIPS_FORMAT",
|
|
||||||
"GRANT-K17": "SRCIPS_FORMAT",
|
|
||||||
"GRANT-K20": "SRCIPS_FORMAT",
|
|
||||||
"GRANT-K21": "SRCIPS_FORMAT",
|
|
||||||
"GRANT-K22": "SRCIPS_FORMAT",
|
|
||||||
"GRANT-K26": "SRCIPS_FORMAT",
|
|
||||||
|
|
||||||
// P01-series: Wildcard and basic IP grants
|
|
||||||
"GRANT-P01_1": "SRCIPS_FORMAT",
|
|
||||||
"GRANT-P01_2": "SRCIPS_FORMAT",
|
|
||||||
"GRANT-P01_3": "SRCIPS_FORMAT",
|
|
||||||
"GRANT-P01_4": "SRCIPS_FORMAT",
|
|
||||||
|
|
||||||
// P02-series: Source targeting (user, group, tag)
|
// P02-series: Source targeting (user, group, tag)
|
||||||
"GRANT-P02_1": "SRCIPS_FORMAT",
|
"GRANT-P02_1": "MISSING_IPV6_ADDRS",
|
||||||
"GRANT-P02_2": "SRCIPS_FORMAT",
|
"GRANT-P02_2": "MISSING_IPV6_ADDRS",
|
||||||
"GRANT-P02_3": "SRCIPS_FORMAT",
|
"GRANT-P02_3": "MISSING_IPV6_ADDRS",
|
||||||
"GRANT-P02_4": "SRCIPS_FORMAT",
|
"GRANT-P02_4": "MISSING_IPV6_ADDRS",
|
||||||
"GRANT-P02_5_CORRECT": "SRCIPS_FORMAT",
|
"GRANT-P02_5_CORRECT": "MISSING_IPV6_ADDRS",
|
||||||
"GRANT-P02_5_NAIVE": "SRCIPS_FORMAT",
|
"GRANT-P02_5_NAIVE": "MISSING_IPV6_ADDRS",
|
||||||
|
|
||||||
// P03-series: Destination targeting
|
// P03-series: Destination targeting
|
||||||
"GRANT-P03_1": "SRCIPS_FORMAT",
|
"GRANT-P03_1": "MISSING_IPV6_ADDRS",
|
||||||
"GRANT-P03_2": "SRCIPS_FORMAT",
|
"GRANT-P03_2": "MISSING_IPV6_ADDRS",
|
||||||
"GRANT-P03_3": "SRCIPS_FORMAT",
|
"GRANT-P03_3": "MISSING_IPV6_ADDRS",
|
||||||
"GRANT-P03_4": "SRCIPS_FORMAT",
|
"GRANT-P03_4": "MISSING_IPV6_ADDRS",
|
||||||
|
|
||||||
// P04-series: autogroup:member grants
|
// P04-series: autogroup:member grants
|
||||||
"GRANT-P04_1": "SRCIPS_FORMAT",
|
"GRANT-P04_1": "MISSING_IPV6_ADDRS",
|
||||||
"GRANT-P04_2": "SRCIPS_FORMAT",
|
"GRANT-P04_2": "MISSING_IPV6_ADDRS",
|
||||||
|
|
||||||
// P05-series: Tag-to-tag grants
|
|
||||||
"GRANT-P05_1": "SRCIPS_FORMAT",
|
|
||||||
"GRANT-P05_2": "SRCIPS_FORMAT",
|
|
||||||
"GRANT-P05_3": "SRCIPS_FORMAT",
|
|
||||||
|
|
||||||
// P06-series: IP protocol grants
|
// P06-series: IP protocol grants
|
||||||
"GRANT-P06_1": "SRCIPS_FORMAT",
|
"GRANT-P06_1": "MISSING_IPV6_ADDRS",
|
||||||
"GRANT-P06_2": "SRCIPS_FORMAT",
|
"GRANT-P06_2": "MISSING_IPV6_ADDRS",
|
||||||
"GRANT-P06_3": "SRCIPS_FORMAT",
|
"GRANT-P06_3": "MISSING_IPV6_ADDRS",
|
||||||
"GRANT-P06_4": "SRCIPS_FORMAT",
|
"GRANT-P06_4": "MISSING_IPV6_ADDRS",
|
||||||
"GRANT-P06_5": "SRCIPS_FORMAT",
|
"GRANT-P06_5": "MISSING_IPV6_ADDRS",
|
||||||
"GRANT-P06_6": "SRCIPS_FORMAT",
|
"GRANT-P06_6": "MISSING_IPV6_ADDRS",
|
||||||
"GRANT-P06_7": "SRCIPS_FORMAT",
|
"GRANT-P06_7": "MISSING_IPV6_ADDRS",
|
||||||
|
|
||||||
// P08-series: Multiple grants / rule merging
|
// P08-series: Multiple grants / rule merging
|
||||||
"GRANT-P08_1": "SRCIPS_FORMAT",
|
"GRANT-P08_1": "MISSING_IPV6_ADDRS",
|
||||||
"GRANT-P08_2": "SRCIPS_FORMAT",
|
"GRANT-P08_2": "MISSING_IPV6_ADDRS",
|
||||||
"GRANT-P08_4": "SRCIPS_FORMAT",
|
"GRANT-P08_4": "MISSING_IPV6_ADDRS",
|
||||||
"GRANT-P08_5": "SRCIPS_FORMAT",
|
"GRANT-P08_5": "MISSING_IPV6_ADDRS",
|
||||||
"GRANT-P08_6": "SRCIPS_FORMAT",
|
"GRANT-P08_6": "MISSING_IPV6_ADDRS",
|
||||||
"GRANT-P08_7": "SRCIPS_FORMAT",
|
"GRANT-P08_7": "MISSING_IPV6_ADDRS",
|
||||||
"GRANT-P08_8": "SRCIPS_FORMAT",
|
|
||||||
|
|
||||||
// P09-series: ACL-to-grant conversion equivalence tests
|
// P09-series: ACL-to-grant conversion equivalence tests
|
||||||
"GRANT-P09_1A": "SRCIPS_FORMAT",
|
"GRANT-P09_1A": "MISSING_IPV6_ADDRS",
|
||||||
"GRANT-P09_1B": "SRCIPS_FORMAT",
|
"GRANT-P09_1B": "MISSING_IPV6_ADDRS",
|
||||||
"GRANT-P09_1C": "SRCIPS_FORMAT",
|
"GRANT-P09_1C": "MISSING_IPV6_ADDRS",
|
||||||
"GRANT-P09_1D": "SRCIPS_FORMAT",
|
"GRANT-P09_1D": "MISSING_IPV6_ADDRS",
|
||||||
"GRANT-P09_1E": "SRCIPS_FORMAT",
|
"GRANT-P09_1E": "MISSING_IPV6_ADDRS",
|
||||||
"GRANT-P09_2A_CORRECT": "SRCIPS_FORMAT",
|
"GRANT-P09_2A_CORRECT": "MISSING_IPV6_ADDRS",
|
||||||
"GRANT-P09_2A_NAIVE": "SRCIPS_FORMAT",
|
"GRANT-P09_2A_NAIVE": "MISSING_IPV6_ADDRS",
|
||||||
"GRANT-P09_2B_CORRECT": "SRCIPS_FORMAT",
|
"GRANT-P09_2B_CORRECT": "MISSING_IPV6_ADDRS",
|
||||||
"GRANT-P09_2B_NAIVE": "SRCIPS_FORMAT",
|
"GRANT-P09_2B_NAIVE": "MISSING_IPV6_ADDRS",
|
||||||
"GRANT-P09_2C": "SRCIPS_FORMAT",
|
"GRANT-P09_2C": "MISSING_IPV6_ADDRS",
|
||||||
"GRANT-P09_3A": "SRCIPS_FORMAT",
|
"GRANT-P09_3A": "MISSING_IPV6_ADDRS",
|
||||||
"GRANT-P09_3B": "SRCIPS_FORMAT",
|
"GRANT-P09_3B": "MISSING_IPV6_ADDRS",
|
||||||
"GRANT-P09_3C": "SRCIPS_FORMAT",
|
"GRANT-P09_3C": "MISSING_IPV6_ADDRS",
|
||||||
"GRANT-P09_4A": "SRCIPS_FORMAT",
|
"GRANT-P09_4A": "MISSING_IPV6_ADDRS",
|
||||||
"GRANT-P09_4B": "SRCIPS_FORMAT",
|
"GRANT-P09_4B": "MISSING_IPV6_ADDRS",
|
||||||
"GRANT-P09_4C": "SRCIPS_FORMAT",
|
"GRANT-P09_4C": "MISSING_IPV6_ADDRS",
|
||||||
"GRANT-P09_4D": "SRCIPS_FORMAT",
|
"GRANT-P09_4D": "MISSING_IPV6_ADDRS",
|
||||||
"GRANT-P09_4E": "SRCIPS_FORMAT",
|
"GRANT-P09_4F": "MISSING_IPV6_ADDRS",
|
||||||
"GRANT-P09_4F": "SRCIPS_FORMAT",
|
"GRANT-P09_4G": "MISSING_IPV6_ADDRS",
|
||||||
"GRANT-P09_4G": "SRCIPS_FORMAT",
|
"GRANT-P09_5A": "MISSING_IPV6_ADDRS",
|
||||||
"GRANT-P09_5A": "SRCIPS_FORMAT",
|
"GRANT-P09_5B": "MISSING_IPV6_ADDRS",
|
||||||
"GRANT-P09_5B": "SRCIPS_FORMAT",
|
"GRANT-P09_5C_NAIVE": "MISSING_IPV6_ADDRS",
|
||||||
"GRANT-P09_5C_NAIVE": "SRCIPS_FORMAT",
|
"GRANT-P09_6C": "MISSING_IPV6_ADDRS",
|
||||||
"GRANT-P09_6A": "SRCIPS_FORMAT",
|
"GRANT-P09_7B_NAIVE": "MISSING_IPV6_ADDRS",
|
||||||
"GRANT-P09_6C": "SRCIPS_FORMAT",
|
"GRANT-P09_7C": "MISSING_IPV6_ADDRS",
|
||||||
"GRANT-P09_6D": "SRCIPS_FORMAT",
|
"GRANT-P09_7D_NAIVE": "MISSING_IPV6_ADDRS",
|
||||||
"GRANT-P09_7A": "SRCIPS_FORMAT",
|
"GRANT-P09_8A": "MISSING_IPV6_ADDRS",
|
||||||
"GRANT-P09_7B_NAIVE": "SRCIPS_FORMAT",
|
"GRANT-P09_8B": "MISSING_IPV6_ADDRS",
|
||||||
"GRANT-P09_7C": "SRCIPS_FORMAT",
|
"GRANT-P09_8C": "MISSING_IPV6_ADDRS",
|
||||||
"GRANT-P09_7D_NAIVE": "SRCIPS_FORMAT",
|
"GRANT-P09_9A": "MISSING_IPV6_ADDRS",
|
||||||
"GRANT-P09_8A": "SRCIPS_FORMAT",
|
"GRANT-P09_9B": "MISSING_IPV6_ADDRS",
|
||||||
"GRANT-P09_8B": "SRCIPS_FORMAT",
|
"GRANT-P09_9C": "MISSING_IPV6_ADDRS",
|
||||||
"GRANT-P09_8C": "SRCIPS_FORMAT",
|
"GRANT-P09_10A": "MISSING_IPV6_ADDRS",
|
||||||
"GRANT-P09_9A": "SRCIPS_FORMAT",
|
"GRANT-P09_10B": "MISSING_IPV6_ADDRS",
|
||||||
"GRANT-P09_9B": "SRCIPS_FORMAT",
|
"GRANT-P09_10C": "MISSING_IPV6_ADDRS",
|
||||||
"GRANT-P09_9C": "SRCIPS_FORMAT",
|
"GRANT-P09_10D": "MISSING_IPV6_ADDRS",
|
||||||
"GRANT-P09_10A": "SRCIPS_FORMAT",
|
"GRANT-P09_11A": "MISSING_IPV6_ADDRS",
|
||||||
"GRANT-P09_10B": "SRCIPS_FORMAT",
|
"GRANT-P09_11B": "MISSING_IPV6_ADDRS",
|
||||||
"GRANT-P09_10C": "SRCIPS_FORMAT",
|
"GRANT-P09_11C_NAIVE": "MISSING_IPV6_ADDRS",
|
||||||
"GRANT-P09_10D": "SRCIPS_FORMAT",
|
"GRANT-P09_11D": "MISSING_IPV6_ADDRS",
|
||||||
"GRANT-P09_11A": "SRCIPS_FORMAT",
|
"GRANT-P09_12A": "MISSING_IPV6_ADDRS",
|
||||||
"GRANT-P09_11B": "SRCIPS_FORMAT",
|
"GRANT-P09_12B": "MISSING_IPV6_ADDRS + SUBNET_ROUTE_FILTER_RULES: tagged-server subtest missing IPv6; subnet-router subtest missing entire rule for 10.0.0.0/8",
|
||||||
"GRANT-P09_11C_NAIVE": "SRCIPS_FORMAT",
|
"GRANT-P09_14A": "MISSING_IPV6_ADDRS",
|
||||||
"GRANT-P09_11D": "SRCIPS_FORMAT",
|
"GRANT-P09_14B": "MISSING_IPV6_ADDRS",
|
||||||
"GRANT-P09_12A": "SRCIPS_FORMAT",
|
"GRANT-P09_14C": "MISSING_IPV6_ADDRS",
|
||||||
"GRANT-P09_12B": "SRCIPS_FORMAT",
|
"GRANT-P09_14D": "MISSING_IPV6_ADDRS",
|
||||||
"GRANT-P09_13E": "SRCIPS_FORMAT",
|
"GRANT-P09_14E": "MISSING_IPV6_ADDRS",
|
||||||
"GRANT-P09_13F": "SRCIPS_FORMAT",
|
"GRANT-P09_14F": "MISSING_IPV6_ADDRS",
|
||||||
"GRANT-P09_13G": "SRCIPS_FORMAT",
|
"GRANT-P09_14G": "MISSING_IPV6_ADDRS",
|
||||||
"GRANT-P09_14A": "SRCIPS_FORMAT",
|
"GRANT-P09_14H": "MISSING_IPV6_ADDRS",
|
||||||
"GRANT-P09_14B": "SRCIPS_FORMAT",
|
"GRANT-P09_14I": "MISSING_IPV6_ADDRS",
|
||||||
"GRANT-P09_14C": "SRCIPS_FORMAT",
|
|
||||||
"GRANT-P09_14D": "SRCIPS_FORMAT",
|
|
||||||
"GRANT-P09_14E": "SRCIPS_FORMAT",
|
|
||||||
"GRANT-P09_14F": "SRCIPS_FORMAT",
|
|
||||||
"GRANT-P09_14G": "SRCIPS_FORMAT",
|
|
||||||
"GRANT-P09_14H": "SRCIPS_FORMAT",
|
|
||||||
"GRANT-P09_14I": "SRCIPS_FORMAT",
|
|
||||||
|
|
||||||
// P10-series: Host alias grants
|
// P10-series: Host alias grants (only identity-src subtests fail)
|
||||||
"GRANT-P10_1": "SRCIPS_FORMAT",
|
"GRANT-P10_2": "MISSING_IPV6_ADDRS",
|
||||||
"GRANT-P10_2": "SRCIPS_FORMAT",
|
|
||||||
"GRANT-P10_3": "SRCIPS_FORMAT",
|
|
||||||
"GRANT-P10_4": "SRCIPS_FORMAT",
|
|
||||||
|
|
||||||
// P11-series: autogroup:tagged grants
|
// P11-series: autogroup:tagged grants
|
||||||
"GRANT-P11_1": "SRCIPS_FORMAT",
|
"GRANT-P11_2": "MISSING_IPV6_ADDRS",
|
||||||
"GRANT-P11_2": "SRCIPS_FORMAT",
|
|
||||||
|
|
||||||
// P13-series: CIDR destination grants
|
// P13-series: CIDR destination grants (identity-src subtests)
|
||||||
"GRANT-P13_1": "SRCIPS_FORMAT",
|
"GRANT-P13_4": "MISSING_IPV6_ADDRS",
|
||||||
"GRANT-P13_2": "SRCIPS_FORMAT",
|
|
||||||
"GRANT-P13_3": "SRCIPS_FORMAT",
|
|
||||||
"GRANT-P13_4": "SRCIPS_FORMAT",
|
|
||||||
|
|
||||||
// P15-series: Empty/no-match grants
|
// ========================================================================
|
||||||
"GRANT-P15_1": "SRCIPS_FORMAT",
|
// SUBNET_ROUTE_FILTER_RULES (10 tests)
|
||||||
"GRANT-P15_3": "SRCIPS_FORMAT",
|
//
|
||||||
|
// TODO: Generate filter rules for non-Tailscale CIDR destinations on
|
||||||
|
// subnet-router nodes.
|
||||||
|
//
|
||||||
|
// When a grant targets a non-Tailscale CIDR (e.g., 10.0.0.0/8,
|
||||||
|
// 10.33.0.0/16, 10.33.1.0/24), Tailscale generates FilterRules on the
|
||||||
|
// subnet-router node that advertises overlapping routes. headscale
|
||||||
|
// produces no rules for these destinations, resulting in empty output
|
||||||
|
// on the subnet-router node.
|
||||||
|
//
|
||||||
|
// Example (GRANT-P13_1, dst=10.33.0.0/16):
|
||||||
|
// tailscale produces on subnet-router:
|
||||||
|
// SrcIPs=["100.103.90.82","100.110.121.96","100.90.199.68", + IPv6s]
|
||||||
|
// DstPorts=[{IP:"10.33.0.0/16", Ports:"22"}]
|
||||||
|
// headscale produces: [] (empty)
|
||||||
|
//
|
||||||
|
// Fix: During filter rule compilation, check if a destination CIDR
|
||||||
|
// overlaps with any subnet route advertised by the current node, and
|
||||||
|
// if so, generate the appropriate FilterRule.
|
||||||
|
// ========================================================================
|
||||||
|
"GRANT-P08_8": "SUBNET_ROUTE_FILTER_RULES: dst=10.0.0.0/8 — subnet-router gets no rules",
|
||||||
|
"GRANT-P09_6D": "SUBNET_ROUTE_FILTER_RULES: dst=internal (host alias for 10.0.0.0/8) — subnet-router gets no rules",
|
||||||
|
"GRANT-P10_3": "SUBNET_ROUTE_FILTER_RULES: dst=host alias for 10.33.0.0/16 — subnet-router gets no rules",
|
||||||
|
"GRANT-P10_4": "SUBNET_ROUTE_FILTER_RULES: dst=host alias for 10.33.0.0/16 — subnet-router gets no rules",
|
||||||
|
"GRANT-P13_1": "SUBNET_ROUTE_FILTER_RULES: dst=10.33.0.0/16 port 22 — subnet-router gets no rules",
|
||||||
|
"GRANT-P13_2": "SUBNET_ROUTE_FILTER_RULES: dst=10.33.0.0/16 port 80-443 — subnet-router gets no rules",
|
||||||
|
"GRANT-P13_3": "SUBNET_ROUTE_FILTER_RULES: dst=10.33.0.0/16 ports 22,80,443 — subnet-router gets no rules",
|
||||||
|
"GRANT-P15_1": "SUBNET_ROUTE_FILTER_RULES: dst=10.33.1.0/24 port 22 — subnet-router gets no rules",
|
||||||
|
"GRANT-P15_3": "SUBNET_ROUTE_FILTER_RULES: dst=10.32.0.0/14 port 22 — subnet-router gets no rules",
|
||||||
|
// Note: GRANT-P09_12B also has a subnet-router subtest failure — listed under MISSING_IPV6_ADDRS above
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// AUTOGROUP_SELF_CIDR_FORMAT (4 tests)
|
||||||
|
//
|
||||||
|
// TODO: Use bare IPs (not CIDR notation) in DstPorts for autogroup:self grants.
|
||||||
|
//
|
||||||
|
// When compiling autogroup:self grants, headscale appends /32 to IPv4
|
||||||
|
// and /128 to IPv6 DstPort IPs. Tailscale uses bare IPs without a CIDR
|
||||||
|
// suffix. These tests also have missing IPv6 in SrcIPs.
|
||||||
|
//
|
||||||
|
// Example diff (user1 node, autogroup:member -> autogroup:self):
|
||||||
|
// DstPorts: tailscale=[{IP:"100.90.199.68"}, {IP:"fd7a:...::2d01:c747"}]
|
||||||
|
// DstPorts: headscale=[{IP:"100.90.199.68/32"}, {IP:"fd7a:...::2d01:c747/128"}]
|
||||||
|
// SrcIPs: tailscale=["100.90.199.68", "fd7a:...::2d01:c747"]
|
||||||
|
// SrcIPs: headscale=["100.90.199.68"]
|
||||||
|
// ========================================================================
|
||||||
|
"GRANT-P09_4E": "AUTOGROUP_SELF_CIDR_FORMAT: autogroup:member -> autogroup:self — DstPorts IPs have /32 and /128 suffix + missing IPv6 in SrcIPs",
|
||||||
|
"GRANT-P09_13E": "AUTOGROUP_SELF_CIDR_FORMAT: autogroup:member -> autogroup:self with ip:[*] — DstPorts IPs have CIDR suffix + missing IPv6 in SrcIPs",
|
||||||
|
"GRANT-P09_13F": "AUTOGROUP_SELF_CIDR_FORMAT: single user -> autogroup:self with ip:[22] — DstPorts IPs have CIDR suffix + missing IPv6 in SrcIPs",
|
||||||
|
"GRANT-P09_13G": "AUTOGROUP_SELF_CIDR_FORMAT: single user -> autogroup:self with ip:[22,80,443] — DstPorts IPs have CIDR suffix + missing IPv6 in SrcIPs",
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// USER_PASSKEY_WILDCARD (2 tests)
|
||||||
|
//
|
||||||
|
// TODO: Handle user:*@passkey wildcard pattern in grant src/dst.
|
||||||
|
//
|
||||||
|
// Tailscale SaaS policies can use user:*@passkey as a wildcard matching
|
||||||
|
// all passkey-authenticated users. headscale's convertPolicyUserEmails
|
||||||
|
// only converts specific user@passkey addresses (not the wildcard form),
|
||||||
|
// so the filter compiler logs "user not found: token user:*@passkey"
|
||||||
|
// and produces no rules.
|
||||||
|
//
|
||||||
|
// Fix: Either convert user:*@passkey to a headscale-compatible wildcard,
|
||||||
|
// or resolve it to all known users during filter compilation.
|
||||||
|
// ========================================================================
|
||||||
|
"GRANT-K20": "USER_PASSKEY_WILDCARD: src=user:*@passkey, dst=tag:server — source can't be resolved, no rules produced",
|
||||||
|
"GRANT-K21": "USER_PASSKEY_WILDCARD: src=*, dst=user:*@passkey — destination can't be resolved, no rules produced",
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// RAW_IPV6_ADDR_EXPANSION (2 tests)
|
||||||
|
//
|
||||||
|
// TODO: Don't expand raw IPv6 addresses to include the matching node's IPv4.
|
||||||
|
//
|
||||||
|
// When a grant uses a raw fd7a: IPv6 address as src or dst, headscale
|
||||||
|
// resolves it to BOTH the IPv4 and IPv6 of the matching node. Tailscale
|
||||||
|
// keeps only the specific address that was referenced in the grant.
|
||||||
|
//
|
||||||
|
// Example (GRANT-K14, src=fd7a:115c:a1e0::c537:c845):
|
||||||
|
// SrcIPs: tailscale=["fd7a:115c:a1e0::c537:c845"]
|
||||||
|
// SrcIPs: headscale=["100.83.200.69", "fd7a:115c:a1e0::c537:c845"]
|
||||||
|
// Example (GRANT-K15, dst=fd7a:115c:a1e0::b901:4a87):
|
||||||
|
// DstPorts: tailscale=[{IP:"fd7a:...::b901:4a87"}]
|
||||||
|
// DstPorts: headscale=[{IP:"100.108.74.26"}, {IP:"fd7a:...::b901:4a87"}]
|
||||||
|
// ========================================================================
|
||||||
|
"GRANT-K14": "RAW_IPV6_ADDR_EXPANSION: src=fd7a:...::c537:c845 — headscale adds extra IPv4 SrcIP + missing IPv6 in DstPorts",
|
||||||
|
"GRANT-K15": "RAW_IPV6_ADDR_EXPANSION: dst=fd7a:...::b901:4a87 — headscale adds extra IPv4 DstPort entry",
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// SRCIPS_WILDCARD_NODE_DEDUP (1 test)
|
||||||
|
//
|
||||||
|
// TODO: When src includes both * (wildcard) and specific identities,
|
||||||
|
// Tailscale unions individual node IPs with the wildcard CGNAT ranges.
|
||||||
|
// headscale only produces the wildcard ranges, omitting the individual
|
||||||
|
// node IPs that are technically covered by those ranges.
|
||||||
|
//
|
||||||
|
// Also has missing IPv6 in DstPorts.
|
||||||
|
//
|
||||||
|
// Example (GRANT-P09_7A, src=[*, autogroup:member, tag:client, ...]):
|
||||||
|
// SrcIPs: tailscale=[individual IPs + CGNAT ranges + IPv6s] (20 entries)
|
||||||
|
// SrcIPs: headscale=[10.33.0.0/16, CGNAT ranges, fd7a::/48] (4 entries)
|
||||||
|
// ========================================================================
|
||||||
|
"GRANT-P09_7A": "SRCIPS_WILDCARD_NODE_DEDUP: src=[*,...] — individual node IPs missing from SrcIPs + missing IPv6 in DstPorts",
|
||||||
|
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
// CAPGRANT_COMPILATION (49 tests)
|
// CAPGRANT_COMPILATION (49 tests)
|
||||||
@ -640,15 +727,21 @@ var grantSkipReasons = map[string]string{
|
|||||||
//
|
//
|
||||||
// Skip category impact summary (highest first):
|
// Skip category impact summary (highest first):
|
||||||
//
|
//
|
||||||
// SRCIPS_FORMAT - 125 tests: Fix SrcIPs to use CGNAT split ranges
|
// CAPGRANT_COMPILATION - 49 tests: Implement app->CapGrant FilterRule compilation
|
||||||
// CAPGRANT_COMPILATION - 41 tests: Implement app->CapGrant FilterRule compilation
|
// ERROR_VALIDATION_GAP - 23 tests: Implement missing grant validation rules
|
||||||
// ERROR_VALIDATION_GAP - 14 tests: Implement missing grant validation rules
|
// MISSING_IPV6_ADDRS - 90 tests: Include IPv6 for identity-based alias resolution
|
||||||
// CAPGRANT_AND_SRCIPS_FORMAT - 9 tests: Both CapGrant compilation + SrcIPs format
|
// CAPGRANT_COMPILATION_AND_SRCIPS - 11 tests: Both CapGrant compilation + SrcIPs format
|
||||||
// VIA_AND_SRCIPS_FORMAT - 4 tests: Via route compilation + SrcIPs format
|
// SUBNET_ROUTE_FILTER_RULES - 10 tests: Generate filter rules for subnet-routed CIDRs
|
||||||
// AUTOGROUP_DANGER_ALL - 3 tests: Implement autogroup:danger-all support
|
// VIA_COMPILATION_AND_SRCIPS_FORMAT - 7 tests: Via route compilation + SrcIPs format
|
||||||
// VALIDATION_STRICTNESS - 2 tests: headscale too strict (rejects what Tailscale accepts)
|
// AUTOGROUP_SELF_CIDR_FORMAT - 4 tests: DstPorts IPs get /32 or /128 suffix for autogroup:self
|
||||||
|
// VIA_COMPILATION - 3 tests: Via route compilation
|
||||||
|
// AUTOGROUP_DANGER_ALL - 3 tests: Implement autogroup:danger-all support
|
||||||
|
// USER_PASSKEY_WILDCARD - 2 tests: user:*@passkey wildcard pattern unresolvable
|
||||||
|
// VALIDATION_STRICTNESS - 2 tests: headscale too strict (rejects what Tailscale accepts)
|
||||||
|
// RAW_IPV6_ADDR_EXPANSION - 2 tests: Raw fd7a: IPv6 src/dst expanded to include IPv4
|
||||||
|
// SRCIPS_WILDCARD_NODE_DEDUP - 1 test: Wildcard+specific source node IP deduplication
|
||||||
//
|
//
|
||||||
// Total: 193 tests skipped, 19 tests expected to pass.
|
// Total: 207 tests skipped, 30 tests expected to pass.
|
||||||
func TestGrantsCompat(t *testing.T) {
|
func TestGrantsCompat(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@ -3,10 +3,12 @@ package v2
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"iter"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"slices"
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-json-experiment/json"
|
"github.com/go-json-experiment/json"
|
||||||
@ -124,6 +126,88 @@ var (
|
|||||||
ErrProtocolNoSpecificPorts = errors.New("protocol does not support specific ports")
|
ErrProtocolNoSpecificPorts = errors.New("protocol does not support specific ports")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type resolved struct {
|
||||||
|
ips netipx.IPSet
|
||||||
|
}
|
||||||
|
|
||||||
|
func newResolved(ipb *netipx.IPSetBuilder) (resolved, error) {
|
||||||
|
ips, err := ipb.IPSet()
|
||||||
|
if err != nil {
|
||||||
|
return resolved{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolved{ips: *ips}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newResolvedAddresses(ips *netipx.IPSet, err error) (ResolvedAddresses, error) {
|
||||||
|
if ips == nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolved{ips: *ips}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func ipSetToStrings(ips *netipx.IPSet) []string {
|
||||||
|
var result []string
|
||||||
|
|
||||||
|
for _, r := range ips.Ranges() {
|
||||||
|
if r.From() == r.To() {
|
||||||
|
result = append(result, r.From().String())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if p, ok := r.Prefix(); ok {
|
||||||
|
result = append(result, p.String())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
result = append(result, r.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func (res resolved) Strings() []string {
|
||||||
|
return ipSetToStrings(&res.ips)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (res resolved) Prefixes() []netip.Prefix {
|
||||||
|
ret := res.ips.Prefixes()
|
||||||
|
|
||||||
|
return ret
|
||||||
|
}
|
||||||
|
|
||||||
|
func (res resolved) Empty() bool {
|
||||||
|
return len(res.ips.Prefixes()) == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (res resolved) Iter() iter.Seq[netip.Addr] {
|
||||||
|
return util.IPSetAddrIter(&res.ips)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (res resolved) Contains(ip netip.Addr) bool {
|
||||||
|
return res.ips.Contains(ip)
|
||||||
|
}
|
||||||
|
|
||||||
|
type ResolvedAddresses interface {
|
||||||
|
// Strings returns a slice of string representations of IP addresses,
|
||||||
|
// it will return the appropriate representation for the underlying Alias.
|
||||||
|
// Some should be returned as Prefixes and some as IP ranges.
|
||||||
|
Strings() []string
|
||||||
|
|
||||||
|
// Prefixes returns a slice of netip.Prefix representations of IP addresses.
|
||||||
|
Prefixes() []netip.Prefix
|
||||||
|
|
||||||
|
// Empty reports if there are no addresses in the ResolvedAddresses.
|
||||||
|
Empty() bool
|
||||||
|
|
||||||
|
// Iter returns an iterator over netip.Addr representations of IP addresses.
|
||||||
|
Iter() iter.Seq[netip.Addr]
|
||||||
|
|
||||||
|
// Contains reports if the given IP address is contained in the ResolvedAddresses.
|
||||||
|
Contains(ip netip.Addr) bool
|
||||||
|
}
|
||||||
|
|
||||||
type Asterix int
|
type Asterix int
|
||||||
|
|
||||||
func (a Asterix) Validate() error {
|
func (a Asterix) Validate() error {
|
||||||
@ -194,16 +278,37 @@ func (a Asterix) UnmarshalJSON(b []byte) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a Asterix) Resolve(_ *Policy, _ types.Users, nodes views.Slice[types.NodeView]) (*netipx.IPSet, error) {
|
var asterixResolved = sync.OnceValue(func() *netipx.IPSet {
|
||||||
var ips netipx.IPSetBuilder
|
var ipb netipx.IPSetBuilder
|
||||||
|
ipb.AddPrefix(tsaddr.TailscaleULARange())
|
||||||
|
ipb.AddPrefix(tsaddr.CGNATRange())
|
||||||
|
ipb.RemovePrefix(tsaddr.ChromeOSVMRange())
|
||||||
|
|
||||||
// Use Tailscale's CGNAT range for IPv4 and ULA range for IPv6.
|
ips, err := ipb.IPSet()
|
||||||
// This matches Tailscale's behavior where wildcard (*) refers to
|
if err != nil {
|
||||||
// "any node in the tailnet" which uses these address ranges.
|
panic(fmt.Sprintf("failed to build IPSet for wildcard: %v", err))
|
||||||
ips.AddPrefix(tsaddr.CGNATRange())
|
}
|
||||||
ips.AddPrefix(tsaddr.TailscaleULARange())
|
|
||||||
|
|
||||||
return ips.IPSet()
|
return ips
|
||||||
|
})
|
||||||
|
|
||||||
|
func (a Asterix) Resolve(p *Policy, u types.Users, n views.Slice[types.NodeView]) (ResolvedAddresses, error) {
|
||||||
|
return newResolvedAddresses(a.resolve(p, u, n))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a Asterix) resolve(p *Policy, _ types.Users, _ views.Slice[types.NodeView]) (*netipx.IPSet, error) {
|
||||||
|
if p != nil && len(p.AutoApprovers.prefixes()) > 0 {
|
||||||
|
var ipb netipx.IPSetBuilder
|
||||||
|
ipb.AddSet(asterixResolved())
|
||||||
|
|
||||||
|
for _, pfx := range p.AutoApprovers.prefixes() {
|
||||||
|
ipb.AddPrefix(pfx)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ipb.IPSet()
|
||||||
|
}
|
||||||
|
|
||||||
|
return asterixResolved(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Username is a string that represents a username, it must contain an @.
|
// Username is a string that represents a username, it must contain an @.
|
||||||
@ -286,7 +391,11 @@ func (u *Username) resolveUser(users types.Users) (types.User, error) {
|
|||||||
return potentialUsers[0], nil
|
return potentialUsers[0], nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *Username) Resolve(_ *Policy, users types.Users, nodes views.Slice[types.NodeView]) (*netipx.IPSet, error) {
|
func (u *Username) Resolve(_ *Policy, users types.Users, nodes views.Slice[types.NodeView]) (ResolvedAddresses, error) {
|
||||||
|
return newResolvedAddresses(u.resolve(nil, users, nodes))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *Username) resolve(_ *Policy, users types.Users, nodes views.Slice[types.NodeView]) (*netipx.IPSet, error) {
|
||||||
var (
|
var (
|
||||||
ips netipx.IPSetBuilder
|
ips netipx.IPSetBuilder
|
||||||
errs []error
|
errs []error
|
||||||
@ -365,14 +474,18 @@ func (g *Group) MarshalJSON() ([]byte, error) {
|
|||||||
return json.Marshal(string(*g))
|
return json.Marshal(string(*g))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (g *Group) Resolve(p *Policy, users types.Users, nodes views.Slice[types.NodeView]) (*netipx.IPSet, error) {
|
func (g *Group) Resolve(p *Policy, users types.Users, nodes views.Slice[types.NodeView]) (ResolvedAddresses, error) {
|
||||||
|
return newResolvedAddresses(g.resolve(p, users, nodes))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *Group) resolve(p *Policy, users types.Users, nodes views.Slice[types.NodeView]) (*netipx.IPSet, error) {
|
||||||
var (
|
var (
|
||||||
ips netipx.IPSetBuilder
|
ips netipx.IPSetBuilder
|
||||||
errs []error
|
errs []error
|
||||||
)
|
)
|
||||||
|
|
||||||
for _, user := range p.Groups[*g] {
|
for _, user := range p.Groups[*g] {
|
||||||
uips, err := user.Resolve(nil, users, nodes)
|
uips, err := user.resolve(nil, users, nodes)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
errs = append(errs, err)
|
errs = append(errs, err)
|
||||||
}
|
}
|
||||||
@ -405,7 +518,11 @@ func (t *Tag) UnmarshalJSON(b []byte) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *Tag) Resolve(p *Policy, users types.Users, nodes views.Slice[types.NodeView]) (*netipx.IPSet, error) {
|
func (t *Tag) Resolve(p *Policy, users types.Users, nodes views.Slice[types.NodeView]) (ResolvedAddresses, error) {
|
||||||
|
return newResolvedAddresses(t.resolve(p, users, nodes))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *Tag) resolve(p *Policy, users types.Users, nodes views.Slice[types.NodeView]) (*netipx.IPSet, error) {
|
||||||
var ips netipx.IPSetBuilder
|
var ips netipx.IPSetBuilder
|
||||||
|
|
||||||
for _, node := range nodes.All() {
|
for _, node := range nodes.All() {
|
||||||
@ -457,7 +574,11 @@ func (h *Host) UnmarshalJSON(b []byte) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Host) Resolve(p *Policy, _ types.Users, nodes views.Slice[types.NodeView]) (*netipx.IPSet, error) {
|
func (h *Host) Resolve(p *Policy, _ types.Users, nodes views.Slice[types.NodeView]) (ResolvedAddresses, error) {
|
||||||
|
return newResolvedAddresses(h.resolve(p, nil, nodes))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Host) resolve(p *Policy, _ types.Users, nodes views.Slice[types.NodeView]) (*netipx.IPSet, error) {
|
||||||
var (
|
var (
|
||||||
ips netipx.IPSetBuilder
|
ips netipx.IPSetBuilder
|
||||||
errs []error
|
errs []error
|
||||||
@ -554,7 +675,11 @@ func (p *Prefix) UnmarshalJSON(b []byte) error {
|
|||||||
// of the Prefix and the Policy, Users, and Nodes.
|
// of the Prefix and the Policy, Users, and Nodes.
|
||||||
//
|
//
|
||||||
// See [Policy], [types.Users], and [types.Nodes] for more details.
|
// See [Policy], [types.Users], and [types.Nodes] for more details.
|
||||||
func (p *Prefix) Resolve(_ *Policy, _ types.Users, nodes views.Slice[types.NodeView]) (*netipx.IPSet, error) {
|
func (p *Prefix) Resolve(_ *Policy, _ types.Users, nodes views.Slice[types.NodeView]) (ResolvedAddresses, error) {
|
||||||
|
return newResolvedAddresses(p.resolve(nil, nil, nodes))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Prefix) resolve(_ *Policy, _ types.Users, nodes views.Slice[types.NodeView]) (*netipx.IPSet, error) {
|
||||||
var (
|
var (
|
||||||
ips netipx.IPSetBuilder
|
ips netipx.IPSetBuilder
|
||||||
errs []error
|
errs []error
|
||||||
@ -629,7 +754,11 @@ func (ag *AutoGroup) MarshalJSON() ([]byte, error) {
|
|||||||
return json.Marshal(string(*ag))
|
return json.Marshal(string(*ag))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (ag *AutoGroup) Resolve(p *Policy, users types.Users, nodes views.Slice[types.NodeView]) (*netipx.IPSet, error) {
|
func (ag *AutoGroup) Resolve(p *Policy, users types.Users, nodes views.Slice[types.NodeView]) (ResolvedAddresses, error) {
|
||||||
|
return newResolvedAddresses(ag.resolve(p, users, nodes))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ag *AutoGroup) resolve(p *Policy, users types.Users, nodes views.Slice[types.NodeView]) (*netipx.IPSet, error) {
|
||||||
var build netipx.IPSetBuilder
|
var build netipx.IPSetBuilder
|
||||||
|
|
||||||
switch *ag {
|
switch *ag {
|
||||||
@ -694,7 +823,9 @@ type Alias interface {
|
|||||||
// of the Alias and the Policy, Users and Nodes.
|
// of the Alias and the Policy, Users and Nodes.
|
||||||
// This is an interface definition and the implementation is independent of
|
// This is an interface definition and the implementation is independent of
|
||||||
// the Alias type.
|
// the Alias type.
|
||||||
Resolve(pol *Policy, users types.Users, nodes views.Slice[types.NodeView]) (*netipx.IPSet, error)
|
Resolve(pol *Policy, users types.Users, nodes views.Slice[types.NodeView]) (ResolvedAddresses, error)
|
||||||
|
|
||||||
|
resolve(pol *Policy, users types.Users, nodes views.Slice[types.NodeView]) (*netipx.IPSet, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type AliasWithPorts struct {
|
type AliasWithPorts struct {
|
||||||
@ -960,14 +1091,14 @@ func (a *Aliases) MarshalJSON() ([]byte, error) {
|
|||||||
return json.Marshal(aliases)
|
return json.Marshal(aliases)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Aliases) Resolve(p *Policy, users types.Users, nodes views.Slice[types.NodeView]) (*netipx.IPSet, error) {
|
func (a *Aliases) Resolve(p *Policy, users types.Users, nodes views.Slice[types.NodeView]) (ResolvedAddresses, error) {
|
||||||
var (
|
var (
|
||||||
ips netipx.IPSetBuilder
|
ips netipx.IPSetBuilder
|
||||||
errs []error
|
errs []error
|
||||||
)
|
)
|
||||||
|
|
||||||
for _, alias := range *a {
|
for _, alias := range *a {
|
||||||
aips, err := alias.Resolve(p, users, nodes)
|
aips, err := alias.resolve(p, users, nodes)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
errs = append(errs, err)
|
errs = append(errs, err)
|
||||||
}
|
}
|
||||||
@ -975,7 +1106,7 @@ func (a *Aliases) Resolve(p *Policy, users types.Users, nodes views.Slice[types.
|
|||||||
ips.AddSet(aips)
|
ips.AddSet(aips)
|
||||||
}
|
}
|
||||||
|
|
||||||
return buildIPSetMultiErr(&ips, errs)
|
return newResolvedAddresses(buildIPSetMultiErr(&ips, errs))
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildIPSetMultiErr(ipBuilder *netipx.IPSetBuilder, errs []error) (*netipx.IPSet, error) {
|
func buildIPSetMultiErr(ipBuilder *netipx.IPSetBuilder, errs []error) (*netipx.IPSet, error) {
|
||||||
@ -1378,6 +1509,22 @@ func (ap AutoApproverPolicy) MarshalJSON() ([]byte, error) {
|
|||||||
return json.Marshal(&obj)
|
return json.Marshal(&obj)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// prefixes returns the prefixes that have auto-approvers defined in the policy.
|
||||||
|
// It filters out exit routes since they are not associated with a specific prefix and are handled separately.
|
||||||
|
func (ap AutoApproverPolicy) prefixes() []netip.Prefix {
|
||||||
|
prefixes := make([]netip.Prefix, 0, len(ap.Routes))
|
||||||
|
|
||||||
|
for prefix := range ap.Routes {
|
||||||
|
if tsaddr.IsExitRoute(prefix) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
prefixes = append(prefixes, prefix)
|
||||||
|
}
|
||||||
|
|
||||||
|
return prefixes
|
||||||
|
}
|
||||||
|
|
||||||
// resolveAutoApprovers resolves the AutoApprovers to a map of netip.Prefix to netipx.IPSet.
|
// 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.
|
// 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.
|
// It is intended for internal use in a PolicyManager.
|
||||||
@ -1402,7 +1549,7 @@ func resolveAutoApprovers(p *Policy, users types.Users, nodes views.Slice[types.
|
|||||||
return nil, nil, fmt.Errorf("%w: %v", ErrAutoApproverNotAlias, autoApprover)
|
return nil, nil, fmt.Errorf("%w: %v", ErrAutoApproverNotAlias, autoApprover)
|
||||||
}
|
}
|
||||||
// If it does not resolve, that means the autoApprover is not associated with any IP addresses.
|
// If it does not resolve, that means the autoApprover is not associated with any IP addresses.
|
||||||
ips, _ := aa.Resolve(p, users, nodes)
|
ips, _ := aa.resolve(p, users, nodes)
|
||||||
routes[prefix].AddSet(ips)
|
routes[prefix].AddSet(ips)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1417,7 +1564,7 @@ func resolveAutoApprovers(p *Policy, users types.Users, nodes views.Slice[types.
|
|||||||
return nil, nil, fmt.Errorf("%w: %v", ErrAutoApproverNotAlias, autoApprover)
|
return nil, nil, fmt.Errorf("%w: %v", ErrAutoApproverNotAlias, autoApprover)
|
||||||
}
|
}
|
||||||
// If it does not resolve, that means the autoApprover is not associated with any IP addresses.
|
// If it does not resolve, that means the autoApprover is not associated with any IP addresses.
|
||||||
ips, _ := aa.Resolve(p, users, nodes)
|
ips, _ := aa.resolve(p, users, nodes)
|
||||||
exitNodeSetBuilder.AddSet(ips)
|
exitNodeSetBuilder.AddSet(ips)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1576,9 +1723,8 @@ func (p *Protocol) Description() string {
|
|||||||
func (p *Protocol) toIANAProtocolNumbers() []int {
|
func (p *Protocol) toIANAProtocolNumbers() []int {
|
||||||
switch *p {
|
switch *p {
|
||||||
case "":
|
case "":
|
||||||
// Empty protocol applies to TCP, UDP, ICMP, and ICMPv6 traffic
|
// Empty means the same as wildcard-ish, the client will add the default protocols (TCP, UDP, ICMP) if empty.
|
||||||
// This matches Tailscale's behavior for protocol defaults
|
return nil
|
||||||
return []int{ProtocolTCP, ProtocolUDP, ProtocolICMP, ProtocolIPv6ICMP}
|
|
||||||
case ProtocolNameWildcard:
|
case ProtocolNameWildcard:
|
||||||
// Wildcard protocol - defensive handling (should not reach here due to validation)
|
// Wildcard protocol - defensive handling (should not reach here due to validation)
|
||||||
return nil
|
return nil
|
||||||
@ -2601,14 +2747,14 @@ func (a *SSHSrcAliases) MarshalJSON() ([]byte, error) {
|
|||||||
return json.Marshal(aliases)
|
return json.Marshal(aliases)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *SSHSrcAliases) Resolve(p *Policy, users types.Users, nodes views.Slice[types.NodeView]) (*netipx.IPSet, error) {
|
func (a *SSHSrcAliases) Resolve(p *Policy, users types.Users, nodes views.Slice[types.NodeView]) (ResolvedAddresses, error) {
|
||||||
var (
|
var (
|
||||||
ips netipx.IPSetBuilder
|
ips netipx.IPSetBuilder
|
||||||
errs []error
|
errs []error
|
||||||
)
|
)
|
||||||
|
|
||||||
for _, alias := range *a {
|
for _, alias := range *a {
|
||||||
aips, err := alias.Resolve(p, users, nodes)
|
aips, err := alias.resolve(p, users, nodes)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
errs = append(errs, err)
|
errs = append(errs, err)
|
||||||
}
|
}
|
||||||
@ -2616,7 +2762,7 @@ func (a *SSHSrcAliases) Resolve(p *Policy, users types.Users, nodes views.Slice[
|
|||||||
ips.AddSet(aips)
|
ips.AddSet(aips)
|
||||||
}
|
}
|
||||||
|
|
||||||
return buildIPSetMultiErr(&ips, errs)
|
return newResolvedAddresses(buildIPSetMultiErr(&ips, errs))
|
||||||
}
|
}
|
||||||
|
|
||||||
// SSHDstAliases is a list of aliases that can be used as destinations in an SSH rule.
|
// SSHDstAliases is a list of aliases that can be used as destinations in an SSH rule.
|
||||||
|
|||||||
@ -2378,7 +2378,22 @@ func TestResolvePolicy(t *testing.T) {
|
|||||||
{
|
{
|
||||||
name: "wildcard-alias",
|
name: "wildcard-alias",
|
||||||
toResolve: Wildcard,
|
toResolve: Wildcard,
|
||||||
want: []netip.Prefix{tsaddr.CGNATRange(), tsaddr.TailscaleULARange()},
|
want: []netip.Prefix{
|
||||||
|
mp("100.64.0.0/11"),
|
||||||
|
mp("100.96.0.0/12"),
|
||||||
|
mp("100.112.0.0/15"),
|
||||||
|
mp("100.114.0.0/16"),
|
||||||
|
mp("100.115.0.0/18"),
|
||||||
|
mp("100.115.64.0/20"),
|
||||||
|
mp("100.115.80.0/21"),
|
||||||
|
mp("100.115.88.0/22"),
|
||||||
|
mp("100.115.94.0/23"),
|
||||||
|
mp("100.115.96.0/19"),
|
||||||
|
mp("100.115.128.0/17"),
|
||||||
|
mp("100.116.0.0/14"),
|
||||||
|
mp("100.120.0.0/13"),
|
||||||
|
tsaddr.TailscaleULARange(),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "autogroup-member-comprehensive",
|
name: "autogroup-member-comprehensive",
|
||||||
|
|||||||
@ -300,9 +300,22 @@ func (node *Node) InIPSet(set *netipx.IPSet) bool {
|
|||||||
// AppendToIPSet adds the individual ips in NodeAddresses to a
|
// AppendToIPSet adds the individual ips in NodeAddresses to a
|
||||||
// given netipx.IPSetBuilder.
|
// given netipx.IPSetBuilder.
|
||||||
func (node *Node) AppendToIPSet(build *netipx.IPSetBuilder) {
|
func (node *Node) AppendToIPSet(build *netipx.IPSetBuilder) {
|
||||||
for _, ip := range node.IPs() {
|
if node.IPv4 != nil {
|
||||||
build.Add(ip)
|
build.Add(*node.IPv4)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if node.IPv6 != nil {
|
||||||
|
build.Add(*node.IPv6)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO(kradalby): Evaluate what we want to do here:
|
||||||
|
// Tailscale only adds the IPv4 addresses to any packet filter rule that is resolved to a given node.
|
||||||
|
// Presumably, it will add the IPv4 if a node does not have an IPv4.
|
||||||
|
// Until this change, we always added both, and that might be something people are dependent on, and we might want to keep it.
|
||||||
|
// for _, ip := range node.IPs() {
|
||||||
|
// build.Add(ip)
|
||||||
|
// }
|
||||||
}
|
}
|
||||||
|
|
||||||
func (node *Node) CanAccess(matchers []matcher.Match, node2 *Node) bool {
|
func (node *Node) CanAccess(matchers []matcher.Match, node2 *Node) bool {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user