mirror of
https://github.com/juanfont/headscale.git
synced 2026-04-07 21:47:46 +09:00
hscontrol/policy/v2: add Grant policy format support
Add support for the Grant policy format as an alternative to ACL format, following Tailscale's policy v2 specification. Grants provide a more structured way to define network access rules with explicit separation of IP-based and capability-based permissions. Key changes: - Add Grant struct with Sources, Destinations, InternetProtocols (ip), and App (capabilities) fields - Add ProtocolPort type for unmarshaling protocol:port strings - Add Grant validation in Policy.validate() to enforce: - Mutual exclusivity of ip and app fields - Required ip or app field presence - Non-empty sources and destinations - Refactor compileFilterRules to support both ACLs and Grants - Convert ACLs to Grants internally via aclToGrants() for unified processing - Extract destinationsToNetPortRange() helper for cleaner code - Rename parseProtocol() to toIANAProtocolNumbers() for clarity - Add ProtocolNumberToName mapping for reverse lookups The Grant format allows policies to be written using either the legacy ACL format or the new Grant format. ACLs are converted to Grants internally, ensuring backward compatibility while enabling the new format's benefits. Updates #2180
This commit is contained in:
committed by
Kristoffer Dalby
parent
53b8a81d48
commit
f74ea5b8ed
@@ -34,12 +34,13 @@ func (pol *Policy) compileFilterRules(
|
||||
|
||||
var rules []tailcfg.FilterRule
|
||||
|
||||
grants := pol.Grants
|
||||
for _, acl := range pol.ACLs {
|
||||
if acl.Action != ActionAccept {
|
||||
return nil, ErrInvalidAction
|
||||
}
|
||||
grants = append(grants, aclToGrants(acl)...)
|
||||
}
|
||||
|
||||
srcIPs, err := acl.Sources.Resolve(pol, users, nodes)
|
||||
for _, grant := range grants {
|
||||
srcIPs, err := grant.Sources.Resolve(pol, users, nodes)
|
||||
if err != nil {
|
||||
log.Trace().Caller().Err(err).Msgf("resolving source ips")
|
||||
}
|
||||
@@ -48,66 +49,96 @@ func (pol *Policy) compileFilterRules(
|
||||
continue
|
||||
}
|
||||
|
||||
protocols := acl.Protocol.parseProtocol()
|
||||
for _, ipp := range grant.InternetProtocols {
|
||||
destPorts := pol.destinationsToNetPortRange(users, nodes, grant.Destinations, ipp.Ports)
|
||||
|
||||
var destPorts []tailcfg.NetPortRange
|
||||
|
||||
for _, dest := range acl.Destinations {
|
||||
// Check if destination is a wildcard - use "*" directly instead of expanding
|
||||
if _, isWildcard := dest.Alias.(Asterix); isWildcard {
|
||||
for _, port := range dest.Ports {
|
||||
destPorts = append(destPorts, tailcfg.NetPortRange{
|
||||
IP: "*",
|
||||
Ports: port,
|
||||
})
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
// autogroup:internet does not generate packet filters - it's handled
|
||||
// by exit node routing via AllowedIPs, not by packet filtering.
|
||||
if ag, isAutoGroup := dest.Alias.(*AutoGroup); isAutoGroup && ag.Is(AutoGroupInternet) {
|
||||
continue
|
||||
}
|
||||
|
||||
ips, err := dest.Resolve(pol, users, nodes)
|
||||
if err != nil {
|
||||
log.Trace().Caller().Err(err).Msgf("resolving destination ips")
|
||||
}
|
||||
|
||||
if ips == nil {
|
||||
log.Debug().Caller().Msgf("destination resolved to nil ips: %v", dest)
|
||||
continue
|
||||
}
|
||||
|
||||
prefixes := ips.Prefixes()
|
||||
|
||||
for _, pref := range prefixes {
|
||||
for _, port := range dest.Ports {
|
||||
pr := tailcfg.NetPortRange{
|
||||
IP: pref.String(),
|
||||
Ports: port,
|
||||
}
|
||||
destPorts = append(destPorts, pr)
|
||||
}
|
||||
if len(destPorts) > 0 {
|
||||
rules = append(rules, tailcfg.FilterRule{
|
||||
SrcIPs: ipSetToPrefixStringList(srcIPs),
|
||||
DstPorts: destPorts,
|
||||
IPProto: ipp.Protocol.toIANAProtocolNumbers(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if len(destPorts) == 0 {
|
||||
continue
|
||||
}
|
||||
if grant.App != nil {
|
||||
var capGrants []tailcfg.CapGrant
|
||||
|
||||
rules = append(rules, tailcfg.FilterRule{
|
||||
SrcIPs: ipSetToPrefixStringList(srcIPs),
|
||||
DstPorts: destPorts,
|
||||
IPProto: protocols,
|
||||
})
|
||||
for _, dst := range grant.Destinations {
|
||||
ips, err := dst.Resolve(pol, users, nodes)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
capGrants = append(capGrants, tailcfg.CapGrant{
|
||||
Dsts: ips.Prefixes(),
|
||||
CapMap: grant.App,
|
||||
})
|
||||
}
|
||||
|
||||
rules = append(rules, tailcfg.FilterRule{
|
||||
SrcIPs: ipSetToPrefixStringList(srcIPs),
|
||||
CapGrant: capGrants,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return mergeFilterRules(rules), nil
|
||||
}
|
||||
|
||||
func (pol *Policy) destinationsToNetPortRange(
|
||||
users types.Users,
|
||||
nodes views.Slice[types.NodeView],
|
||||
dests Aliases,
|
||||
ports []tailcfg.PortRange,
|
||||
) []tailcfg.NetPortRange {
|
||||
var ret []tailcfg.NetPortRange
|
||||
|
||||
for _, dest := range dests {
|
||||
// Check if destination is a wildcard - use "*" directly instead of expanding
|
||||
if _, isWildcard := dest.(Asterix); isWildcard {
|
||||
for _, port := range ports {
|
||||
ret = append(ret, tailcfg.NetPortRange{
|
||||
IP: "*",
|
||||
Ports: port,
|
||||
})
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
// autogroup:internet does not generate packet filters - it's handled
|
||||
// by exit node routing via AllowedIPs, not by packet filtering.
|
||||
if ag, isAutoGroup := dest.(*AutoGroup); isAutoGroup && ag.Is(AutoGroupInternet) {
|
||||
continue
|
||||
}
|
||||
|
||||
ips, err := dest.Resolve(pol, users, nodes)
|
||||
if err != nil {
|
||||
log.Trace().Caller().Err(err).Msgf("resolving destination ips")
|
||||
}
|
||||
|
||||
if ips == nil {
|
||||
log.Debug().Caller().Msgf("destination resolved to nil ips: %v", dest)
|
||||
continue
|
||||
}
|
||||
|
||||
prefixes := ips.Prefixes()
|
||||
|
||||
for _, pref := range prefixes {
|
||||
for _, port := range ports {
|
||||
pr := tailcfg.NetPortRange{
|
||||
IP: pref.String(),
|
||||
Ports: port,
|
||||
}
|
||||
ret = append(ret, pr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ret
|
||||
}
|
||||
|
||||
// compileFilterRulesForNode compiles filter rules for a specific node.
|
||||
func (pol *Policy) compileFilterRulesForNode(
|
||||
users types.Users,
|
||||
@@ -120,60 +151,55 @@ func (pol *Policy) compileFilterRulesForNode(
|
||||
|
||||
var rules []tailcfg.FilterRule
|
||||
|
||||
grants := pol.Grants
|
||||
for _, acl := range pol.ACLs {
|
||||
if acl.Action != ActionAccept {
|
||||
return nil, ErrInvalidAction
|
||||
}
|
||||
grants = append(grants, aclToGrants(acl)...)
|
||||
}
|
||||
|
||||
aclRules, err := pol.compileACLWithAutogroupSelf(acl, users, node, nodes)
|
||||
for _, grant := range grants {
|
||||
res, err := pol.compileGrantWithAutogroupSelf(grant, users, node, nodes)
|
||||
if err != nil {
|
||||
log.Trace().Err(err).Msgf("compiling ACL")
|
||||
continue
|
||||
}
|
||||
|
||||
for _, rule := range aclRules {
|
||||
if rule != nil {
|
||||
rules = append(rules, *rule)
|
||||
}
|
||||
}
|
||||
rules = append(rules, res...)
|
||||
}
|
||||
|
||||
return mergeFilterRules(rules), nil
|
||||
}
|
||||
|
||||
// compileACLWithAutogroupSelf compiles a single ACL rule, handling
|
||||
// compileGrantWithAutogroupSelf compiles a single Grant rule, handling
|
||||
// autogroup:self per-node while supporting all other alias types normally.
|
||||
// It returns a slice of filter rules because when an ACL has both autogroup:self
|
||||
// It returns a slice of filter rules because when an Grant has both autogroup:self
|
||||
// and other destinations, they need to be split into separate rules with different
|
||||
// source filtering logic.
|
||||
//
|
||||
//nolint:gocyclo // complex ACL compilation logic
|
||||
func (pol *Policy) compileACLWithAutogroupSelf(
|
||||
acl ACL,
|
||||
func (pol *Policy) compileGrantWithAutogroupSelf(
|
||||
grant Grant,
|
||||
users types.Users,
|
||||
node types.NodeView,
|
||||
nodes views.Slice[types.NodeView],
|
||||
) ([]*tailcfg.FilterRule, error) {
|
||||
) ([]tailcfg.FilterRule, error) {
|
||||
var (
|
||||
autogroupSelfDests []AliasWithPorts
|
||||
otherDests []AliasWithPorts
|
||||
autogroupSelfDests []Alias
|
||||
otherDests []Alias
|
||||
)
|
||||
|
||||
for _, dest := range acl.Destinations {
|
||||
if ag, ok := dest.Alias.(*AutoGroup); ok && ag.Is(AutoGroupSelf) {
|
||||
for _, dest := range grant.Destinations {
|
||||
if ag, ok := dest.(*AutoGroup); ok && ag.Is(AutoGroupSelf) {
|
||||
autogroupSelfDests = append(autogroupSelfDests, dest)
|
||||
} else {
|
||||
otherDests = append(otherDests, dest)
|
||||
}
|
||||
}
|
||||
|
||||
protocols := acl.Protocol.parseProtocol()
|
||||
|
||||
var rules []*tailcfg.FilterRule
|
||||
var rules []tailcfg.FilterRule
|
||||
|
||||
var resolvedSrcIPs []*netipx.IPSet
|
||||
|
||||
for _, src := range acl.Sources {
|
||||
for _, src := range grant.Sources {
|
||||
if ag, ok := src.(*AutoGroup); ok && ag.Is(AutoGroupSelf) {
|
||||
return nil, errSelfInSources
|
||||
}
|
||||
@@ -192,42 +218,42 @@ func (pol *Policy) compileACLWithAutogroupSelf(
|
||||
return rules, nil
|
||||
}
|
||||
|
||||
// Handle autogroup:self destinations (if any)
|
||||
// Tagged nodes don't participate in autogroup:self (identity is tag-based, not user-based)
|
||||
if len(autogroupSelfDests) > 0 && !node.IsTagged() {
|
||||
// Pre-filter to same-user untagged devices once - reuse for both sources and destinations
|
||||
sameUserNodes := make([]types.NodeView, 0)
|
||||
for _, ipp := range grant.InternetProtocols {
|
||||
// Handle autogroup:self destinations (if any)
|
||||
// Tagged nodes don't participate in autogroup:self (identity is tag-based, not user-based)
|
||||
if len(autogroupSelfDests) > 0 && !node.IsTagged() {
|
||||
// Pre-filter to same-user untagged devices once - reuse for both sources and destinations
|
||||
sameUserNodes := make([]types.NodeView, 0)
|
||||
|
||||
for _, n := range nodes.All() {
|
||||
if !n.IsTagged() && n.User().ID() == node.User().ID() {
|
||||
sameUserNodes = append(sameUserNodes, n)
|
||||
}
|
||||
}
|
||||
|
||||
if len(sameUserNodes) > 0 {
|
||||
// Filter sources to only same-user untagged devices
|
||||
var srcIPs netipx.IPSetBuilder
|
||||
|
||||
for _, ips := range resolvedSrcIPs {
|
||||
for _, n := range sameUserNodes {
|
||||
// Check if any of this node's IPs are in the source set
|
||||
if slices.ContainsFunc(n.IPs(), ips.Contains) {
|
||||
n.AppendToIPSet(&srcIPs)
|
||||
}
|
||||
for _, n := range nodes.All() {
|
||||
if !n.IsTagged() && n.User().ID() == node.User().ID() {
|
||||
sameUserNodes = append(sameUserNodes, n)
|
||||
}
|
||||
}
|
||||
|
||||
srcSet, err := srcIPs.IPSet()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(sameUserNodes) > 0 {
|
||||
// Filter sources to only same-user untagged devices
|
||||
var srcIPs netipx.IPSetBuilder
|
||||
|
||||
if srcSet != nil && len(srcSet.Prefixes()) > 0 {
|
||||
var destPorts []tailcfg.NetPortRange
|
||||
|
||||
for _, dest := range autogroupSelfDests {
|
||||
for _, ips := range resolvedSrcIPs {
|
||||
for _, n := range sameUserNodes {
|
||||
for _, port := range dest.Ports {
|
||||
// Check if any of this node's IPs are in the source set
|
||||
if slices.ContainsFunc(n.IPs(), ips.Contains) {
|
||||
n.AppendToIPSet(&srcIPs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
srcSet, err := srcIPs.IPSet()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if srcSet != nil && len(srcSet.Prefixes()) > 0 {
|
||||
var destPorts []tailcfg.NetPortRange
|
||||
|
||||
for _, n := range sameUserNodes {
|
||||
for _, port := range ipp.Ports {
|
||||
for _, ip := range n.IPs() {
|
||||
destPorts = append(destPorts, tailcfg.NetPortRange{
|
||||
IP: netip.PrefixFrom(ip, ip.BitLen()).String(),
|
||||
@@ -236,82 +262,40 @@ func (pol *Policy) compileACLWithAutogroupSelf(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(destPorts) > 0 {
|
||||
rules = append(rules, &tailcfg.FilterRule{
|
||||
SrcIPs: ipSetToPrefixStringList(srcSet),
|
||||
DstPorts: destPorts,
|
||||
IPProto: protocols,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(otherDests) > 0 {
|
||||
var srcIPs netipx.IPSetBuilder
|
||||
|
||||
for _, ips := range resolvedSrcIPs {
|
||||
srcIPs.AddSet(ips)
|
||||
}
|
||||
|
||||
srcSet, err := srcIPs.IPSet()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if srcSet != nil && len(srcSet.Prefixes()) > 0 {
|
||||
var destPorts []tailcfg.NetPortRange
|
||||
|
||||
for _, dest := range otherDests {
|
||||
// Check if destination is a wildcard - use "*" directly instead of expanding
|
||||
if _, isWildcard := dest.Alias.(Asterix); isWildcard {
|
||||
for _, port := range dest.Ports {
|
||||
destPorts = append(destPorts, tailcfg.NetPortRange{
|
||||
IP: "*",
|
||||
Ports: port,
|
||||
if len(destPorts) > 0 {
|
||||
rules = append(rules, tailcfg.FilterRule{
|
||||
SrcIPs: ipSetToPrefixStringList(srcSet),
|
||||
DstPorts: destPorts,
|
||||
IPProto: ipp.Protocol.toIANAProtocolNumbers(),
|
||||
})
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
// autogroup:internet does not generate packet filters - it's handled
|
||||
// by exit node routing via AllowedIPs, not by packet filtering.
|
||||
if ag, isAutoGroup := dest.Alias.(*AutoGroup); isAutoGroup && ag.Is(AutoGroupInternet) {
|
||||
continue
|
||||
}
|
||||
|
||||
ips, err := dest.Resolve(pol, users, nodes)
|
||||
if err != nil {
|
||||
log.Trace().Caller().Err(err).Msgf("resolving destination ips")
|
||||
}
|
||||
|
||||
if ips == nil {
|
||||
log.Debug().Caller().Msgf("destination resolved to nil ips: %v", dest)
|
||||
continue
|
||||
}
|
||||
|
||||
prefixes := ips.Prefixes()
|
||||
|
||||
for _, pref := range prefixes {
|
||||
for _, port := range dest.Ports {
|
||||
pr := tailcfg.NetPortRange{
|
||||
IP: pref.String(),
|
||||
Ports: port,
|
||||
}
|
||||
destPorts = append(destPorts, pr)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(destPorts) > 0 {
|
||||
rules = append(rules, &tailcfg.FilterRule{
|
||||
SrcIPs: ipSetToPrefixStringList(srcSet),
|
||||
DstPorts: destPorts,
|
||||
IPProto: protocols,
|
||||
})
|
||||
if len(otherDests) > 0 {
|
||||
var srcIPs netipx.IPSetBuilder
|
||||
|
||||
for _, ips := range resolvedSrcIPs {
|
||||
srcIPs.AddSet(ips)
|
||||
}
|
||||
|
||||
srcSet, err := srcIPs.IPSet()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if srcSet != nil && len(srcSet.Prefixes()) > 0 {
|
||||
destPorts := pol.destinationsToNetPortRange(users, nodes, otherDests, ipp.Ports)
|
||||
|
||||
if len(destPorts) > 0 {
|
||||
rules = append(rules, tailcfg.FilterRule{
|
||||
SrcIPs: ipSetToPrefixStringList(srcSet),
|
||||
DstPorts: destPorts,
|
||||
IPProto: ipp.Protocol.toIANAProtocolNumbers(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,6 +63,18 @@ var (
|
||||
ErrACLAutogroupSelfInvalidSource = errors.New("autogroup:self destination requires sources to be users, groups, or autogroup:member only")
|
||||
)
|
||||
|
||||
// Grant validation errors.
|
||||
var (
|
||||
ErrGrantIPAndAppMutuallyExclusive = errors.New("grants cannot specify both 'ip' and 'app' fields")
|
||||
ErrGrantMissingIPOrApp = errors.New("grants must specify either 'ip' or 'app' field")
|
||||
ErrGrantInvalidViaTag = errors.New("grant 'via' tag is not defined in policy")
|
||||
ErrGrantViaNotSupported = errors.New("grant 'via' routing is not yet supported in headscale")
|
||||
ErrGrantAppProtocolConflict = errors.New("grants with 'app' cannot specify 'ip' protocols")
|
||||
ErrGrantEmptySources = errors.New("grant sources cannot be empty")
|
||||
ErrGrantEmptyDestinations = errors.New("grant destinations cannot be empty")
|
||||
ErrProtocolPortInvalidFormat = errors.New("expected only one colon in Internet protocol and port type")
|
||||
)
|
||||
|
||||
// Policy validation errors.
|
||||
var (
|
||||
ErrUnknownAliasType = errors.New("unknown alias type")
|
||||
@@ -738,6 +750,98 @@ func (ve *AliasWithPorts) UnmarshalJSON(b []byte) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ProtocolPort is a representation of the "network layer capabilities"
|
||||
// of a Grant.
|
||||
type ProtocolPort struct {
|
||||
Ports []tailcfg.PortRange
|
||||
Protocol Protocol
|
||||
}
|
||||
|
||||
func (ve *ProtocolPort) UnmarshalJSON(b []byte) error {
|
||||
var v any
|
||||
|
||||
err := json.Unmarshal(b, &v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch vs := v.(type) {
|
||||
case string:
|
||||
if vs == "*" {
|
||||
ve.Protocol = ProtocolNameWildcard
|
||||
ve.Ports = []tailcfg.PortRange{tailcfg.PortRangeAny}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Only contains a port, no protocol
|
||||
if !strings.Contains(vs, ":") {
|
||||
ports, err := parsePortRange(vs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ve.Protocol = ProtocolNameWildcard
|
||||
ve.Ports = ports
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
parts := strings.Split(vs, ":")
|
||||
if len(parts) != 2 {
|
||||
return fmt.Errorf("%w, got: %v(%d)", ErrProtocolPortInvalidFormat, parts, len(parts))
|
||||
}
|
||||
|
||||
protocol := Protocol(parts[0])
|
||||
|
||||
err := protocol.validate()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
portsPart := parts[1]
|
||||
|
||||
ports, err := parsePortRange(portsPart)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ve.Protocol = protocol
|
||||
ve.Ports = ports
|
||||
|
||||
default:
|
||||
return fmt.Errorf("%w: %T", ErrTypeNotSupported, vs)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ve ProtocolPort) MarshalJSON() ([]byte, error) {
|
||||
// Handle wildcard protocol with all ports
|
||||
if ve.Protocol == ProtocolNameWildcard && len(ve.Ports) == 1 &&
|
||||
ve.Ports[0].First == 0 && ve.Ports[0].Last == 65535 {
|
||||
return json.Marshal("*")
|
||||
}
|
||||
|
||||
// Build port string
|
||||
var portParts []string
|
||||
|
||||
for _, portRange := range ve.Ports {
|
||||
if portRange.First == portRange.Last {
|
||||
portParts = append(portParts, strconv.FormatUint(uint64(portRange.First), 10))
|
||||
} else {
|
||||
portParts = append(portParts, fmt.Sprintf("%d-%d", portRange.First, portRange.Last))
|
||||
}
|
||||
}
|
||||
|
||||
portStr := strings.Join(portParts, ",")
|
||||
|
||||
// Combine protocol and ports
|
||||
result := fmt.Sprintf("%s:%s", ve.Protocol, portStr)
|
||||
|
||||
return json.Marshal(result)
|
||||
}
|
||||
|
||||
func isWildcard(str string) bool {
|
||||
return str == "*"
|
||||
}
|
||||
@@ -1467,9 +1571,9 @@ func (p *Protocol) Description() string {
|
||||
}
|
||||
}
|
||||
|
||||
// parseProtocol converts a Protocol to its IANA protocol numbers.
|
||||
// toIANAProtocolNumbers converts a Protocol to its IANA protocol numbers.
|
||||
// Since validation happens during UnmarshalJSON, this method should not fail for valid Protocol values.
|
||||
func (p *Protocol) parseProtocol() []int {
|
||||
func (p *Protocol) toIANAProtocolNumbers() []int {
|
||||
switch *p {
|
||||
case "":
|
||||
// Empty protocol applies to TCP, UDP, ICMP, and ICMPv6 traffic
|
||||
@@ -1583,6 +1687,23 @@ const (
|
||||
ProtocolFC = 133 // Fibre Channel
|
||||
)
|
||||
|
||||
// ProtocolNumberToName maps IANA protocol numbers to their protocol name strings.
|
||||
var ProtocolNumberToName = map[int]Protocol{
|
||||
ProtocolICMP: ProtocolNameICMP,
|
||||
ProtocolIGMP: ProtocolNameIGMP,
|
||||
ProtocolIPv4: ProtocolNameIPv4,
|
||||
ProtocolTCP: ProtocolNameTCP,
|
||||
ProtocolEGP: ProtocolNameEGP,
|
||||
ProtocolIGP: ProtocolNameIGP,
|
||||
ProtocolUDP: ProtocolNameUDP,
|
||||
ProtocolGRE: ProtocolNameGRE,
|
||||
ProtocolESP: ProtocolNameESP,
|
||||
ProtocolAH: ProtocolNameAH,
|
||||
ProtocolIPv6ICMP: ProtocolNameIPv6ICMP,
|
||||
ProtocolSCTP: ProtocolNameSCTP,
|
||||
ProtocolFC: ProtocolNameFC,
|
||||
}
|
||||
|
||||
type ACL struct {
|
||||
Action Action `json:"action"`
|
||||
Protocol Protocol `json:"proto"`
|
||||
@@ -1632,6 +1753,39 @@ func (a *ACL) UnmarshalJSON(b []byte) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type Grant struct {
|
||||
// TODO(kradalby): Validate grant src/dst according to ts docs
|
||||
Sources Aliases `json:"src"`
|
||||
Destinations Aliases `json:"dst"`
|
||||
|
||||
// TODO(kradalby): validate that either of these fields are included
|
||||
InternetProtocols []ProtocolPort `json:"ip,omitempty"`
|
||||
App tailcfg.PeerCapMap `json:"app,omitzero"`
|
||||
|
||||
// TODO(kradalby): implement via
|
||||
Via []Tag `json:"via,omitzero"`
|
||||
}
|
||||
|
||||
// aclToGrants converts an ACL rule to one or more equivalent Grant rules.
|
||||
func aclToGrants(acl ACL) []Grant {
|
||||
ret := make([]Grant, 0, len(acl.Destinations))
|
||||
|
||||
for _, dst := range acl.Destinations {
|
||||
g := Grant{
|
||||
Sources: acl.Sources,
|
||||
Destinations: Aliases{dst.Alias},
|
||||
InternetProtocols: []ProtocolPort{{
|
||||
Protocol: acl.Protocol,
|
||||
Ports: dst.Ports,
|
||||
}},
|
||||
}
|
||||
|
||||
ret = append(ret, g)
|
||||
}
|
||||
|
||||
return ret
|
||||
}
|
||||
|
||||
// Policy represents a Tailscale Network Policy.
|
||||
// TODO(kradalby):
|
||||
// Add validation method checking:
|
||||
@@ -1649,6 +1803,7 @@ type Policy struct {
|
||||
Hosts Hosts `json:"hosts,omitempty"`
|
||||
TagOwners TagOwners `json:"tagOwners,omitempty"`
|
||||
ACLs []ACL `json:"acls,omitempty"`
|
||||
Grants []Grant `json:"grants,omitempty"`
|
||||
AutoApprovers AutoApproverPolicy `json:"autoApprovers"`
|
||||
SSHs []SSH `json:"ssh,omitempty"`
|
||||
}
|
||||
@@ -2055,6 +2210,124 @@ func (p *Policy) validate() error {
|
||||
}
|
||||
}
|
||||
|
||||
for _, grant := range p.Grants {
|
||||
// Validate ip/app mutual exclusivity
|
||||
hasIP := len(grant.InternetProtocols) > 0
|
||||
hasApp := len(grant.App) > 0
|
||||
|
||||
if hasIP && hasApp {
|
||||
errs = append(errs, ErrGrantIPAndAppMutuallyExclusive)
|
||||
}
|
||||
|
||||
if !hasIP && !hasApp {
|
||||
errs = append(errs, ErrGrantMissingIPOrApp)
|
||||
}
|
||||
|
||||
// Validate sources
|
||||
if len(grant.Sources) == 0 {
|
||||
errs = append(errs, ErrGrantEmptySources)
|
||||
}
|
||||
|
||||
for _, src := range grant.Sources {
|
||||
switch src := src.(type) {
|
||||
case *Host:
|
||||
h := src
|
||||
if !p.Hosts.exist(*h) {
|
||||
errs = append(errs, fmt.Errorf("%w: %q", ErrHostNotDefined, *h))
|
||||
}
|
||||
case *AutoGroup:
|
||||
ag := src
|
||||
|
||||
err := validateAutogroupSupported(ag)
|
||||
if err != nil {
|
||||
errs = append(errs, err)
|
||||
continue
|
||||
}
|
||||
|
||||
err = validateAutogroupForSrc(ag)
|
||||
if err != nil {
|
||||
errs = append(errs, err)
|
||||
continue
|
||||
}
|
||||
case *Group:
|
||||
g := src
|
||||
|
||||
err := p.Groups.Contains(g)
|
||||
if err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
case *Tag:
|
||||
tagOwner := src
|
||||
|
||||
err := p.TagOwners.Contains(tagOwner)
|
||||
if err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate destinations
|
||||
if len(grant.Destinations) == 0 {
|
||||
errs = append(errs, ErrGrantEmptyDestinations)
|
||||
}
|
||||
|
||||
for _, dst := range grant.Destinations {
|
||||
switch h := dst.(type) {
|
||||
case *Host:
|
||||
if !p.Hosts.exist(*h) {
|
||||
errs = append(errs, fmt.Errorf("%w: %q", ErrHostNotDefined, *h))
|
||||
}
|
||||
case *AutoGroup:
|
||||
err := validateAutogroupSupported(h)
|
||||
if err != nil {
|
||||
errs = append(errs, err)
|
||||
continue
|
||||
}
|
||||
|
||||
err = validateAutogroupForDst(h)
|
||||
if err != nil {
|
||||
errs = append(errs, err)
|
||||
continue
|
||||
}
|
||||
case *Group:
|
||||
err := p.Groups.Contains(h)
|
||||
if err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
case *Tag:
|
||||
err := p.TagOwners.Contains(h)
|
||||
if err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate via tags
|
||||
for _, viaTag := range grant.Via {
|
||||
err := p.TagOwners.Contains(&viaTag)
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Errorf("%w in grant via: %q", ErrGrantInvalidViaTag, viaTag))
|
||||
}
|
||||
}
|
||||
|
||||
// Validate ACL source/destination combinations follow Tailscale's security model
|
||||
// (Grants use same rules as ACLs for autogroup:self and other constraints)
|
||||
// Convert grant destinations to AliasWithPorts format for validation
|
||||
var dstWithPorts []AliasWithPorts
|
||||
for _, dst := range grant.Destinations {
|
||||
// For grants, we don't have per-destination ports, so use wildcard
|
||||
dstWithPorts = append(dstWithPorts, AliasWithPorts{
|
||||
Alias: dst,
|
||||
Ports: []tailcfg.PortRange{tailcfg.PortRangeAny},
|
||||
})
|
||||
}
|
||||
|
||||
err := validateACLSrcDstCombination(grant.Sources, dstWithPorts)
|
||||
if err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
|
||||
for _, tagOwners := range p.TagOwners {
|
||||
for _, tagOwner := range tagOwners {
|
||||
switch tagOwner := tagOwner.(type) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package v2
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/netip"
|
||||
"strings"
|
||||
@@ -4283,3 +4284,879 @@ func TestSSHCheckPeriodPolicyValidation(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnmarshalGrants(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want *Policy
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "valid-grant-with-ip-field",
|
||||
input: `
|
||||
{
|
||||
"groups": {
|
||||
"group:eng": ["alice@example.com"]
|
||||
},
|
||||
"tagOwners": {
|
||||
"tag:server": ["group:eng"]
|
||||
},
|
||||
"grants": [
|
||||
{
|
||||
"src": ["group:eng"],
|
||||
"dst": ["tag:server"],
|
||||
"ip": ["tcp:443", "tcp:80"]
|
||||
}
|
||||
]
|
||||
}
|
||||
`,
|
||||
want: &Policy{
|
||||
Groups: Groups{
|
||||
Group("group:eng"): []Username{Username("alice@example.com")},
|
||||
},
|
||||
TagOwners: TagOwners{
|
||||
Tag("tag:server"): Owners{gp("group:eng")},
|
||||
},
|
||||
Grants: []Grant{
|
||||
{
|
||||
Sources: Aliases{
|
||||
gp("group:eng"),
|
||||
},
|
||||
Destinations: Aliases{
|
||||
tp("tag:server"),
|
||||
},
|
||||
InternetProtocols: []ProtocolPort{
|
||||
{Protocol: "tcp", Ports: []tailcfg.PortRange{{First: 443, Last: 443}}},
|
||||
{Protocol: "tcp", Ports: []tailcfg.PortRange{{First: 80, Last: 80}}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "valid-grant-with-app-field",
|
||||
input: `
|
||||
{
|
||||
"groups": {
|
||||
"group:eng": ["alice@example.com"]
|
||||
},
|
||||
"tagOwners": {
|
||||
"tag:relay": ["group:eng"]
|
||||
},
|
||||
"grants": [
|
||||
{
|
||||
"src": ["group:eng"],
|
||||
"dst": ["tag:relay"],
|
||||
"app": {
|
||||
"tailscale.com/cap/relay": []
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
`,
|
||||
want: &Policy{
|
||||
Groups: Groups{
|
||||
Group("group:eng"): []Username{Username("alice@example.com")},
|
||||
},
|
||||
TagOwners: TagOwners{
|
||||
Tag("tag:relay"): Owners{gp("group:eng")},
|
||||
},
|
||||
Grants: []Grant{
|
||||
{
|
||||
Sources: Aliases{
|
||||
gp("group:eng"),
|
||||
},
|
||||
Destinations: Aliases{
|
||||
tp("tag:relay"),
|
||||
},
|
||||
App: tailcfg.PeerCapMap{
|
||||
"tailscale.com/cap/relay": []tailcfg.RawMessage{},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "valid-grant-with-via-tags",
|
||||
input: `
|
||||
{
|
||||
"groups": {
|
||||
"group:eng": ["alice@example.com"]
|
||||
},
|
||||
"tagOwners": {
|
||||
"tag:server": ["group:eng"],
|
||||
"tag:router": ["group:eng"]
|
||||
},
|
||||
"grants": [
|
||||
{
|
||||
"src": ["group:eng"],
|
||||
"dst": ["autogroup:internet"],
|
||||
"ip": ["*"],
|
||||
"via": ["tag:router"]
|
||||
}
|
||||
]
|
||||
}
|
||||
`,
|
||||
want: &Policy{
|
||||
Groups: Groups{
|
||||
Group("group:eng"): []Username{Username("alice@example.com")},
|
||||
},
|
||||
TagOwners: TagOwners{
|
||||
Tag("tag:server"): Owners{gp("group:eng")},
|
||||
Tag("tag:router"): Owners{gp("group:eng")},
|
||||
},
|
||||
Grants: []Grant{
|
||||
{
|
||||
Sources: Aliases{
|
||||
gp("group:eng"),
|
||||
},
|
||||
Destinations: Aliases{
|
||||
agp("autogroup:internet"),
|
||||
},
|
||||
InternetProtocols: []ProtocolPort{
|
||||
{Protocol: "*", Ports: []tailcfg.PortRange{tailcfg.PortRangeAny}},
|
||||
},
|
||||
Via: []Tag{Tag("tag:router")},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "valid-grant-with-wildcard",
|
||||
input: `
|
||||
{
|
||||
"grants": [
|
||||
{
|
||||
"src": ["*"],
|
||||
"dst": ["*"],
|
||||
"ip": ["*"]
|
||||
}
|
||||
]
|
||||
}
|
||||
`,
|
||||
want: &Policy{
|
||||
Grants: []Grant{
|
||||
{
|
||||
Sources: Aliases{
|
||||
Wildcard,
|
||||
},
|
||||
Destinations: Aliases{
|
||||
Wildcard,
|
||||
},
|
||||
InternetProtocols: []ProtocolPort{
|
||||
{Protocol: "*", Ports: []tailcfg.PortRange{tailcfg.PortRangeAny}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "valid-grant-with-multiple-sources-destinations",
|
||||
input: `
|
||||
{
|
||||
"groups": {
|
||||
"group:eng": ["alice@example.com"],
|
||||
"group:ops": ["bob@example.com"]
|
||||
},
|
||||
"tagOwners": {
|
||||
"tag:web": ["group:eng"],
|
||||
"tag:db": ["group:ops"]
|
||||
},
|
||||
"hosts": {
|
||||
"server1": "100.64.0.1"
|
||||
},
|
||||
"grants": [
|
||||
{
|
||||
"src": ["group:eng", "alice@example.com", "100.64.0.10"],
|
||||
"dst": ["tag:web", "tag:db", "server1"],
|
||||
"ip": ["tcp:443", "udp:53"]
|
||||
}
|
||||
]
|
||||
}
|
||||
`,
|
||||
want: &Policy{
|
||||
Groups: Groups{
|
||||
Group("group:eng"): []Username{Username("alice@example.com")},
|
||||
Group("group:ops"): []Username{Username("bob@example.com")},
|
||||
},
|
||||
TagOwners: TagOwners{
|
||||
Tag("tag:web"): Owners{gp("group:eng")},
|
||||
Tag("tag:db"): Owners{gp("group:ops")},
|
||||
},
|
||||
Hosts: Hosts{
|
||||
"server1": Prefix(mp("100.64.0.1/32")),
|
||||
},
|
||||
Grants: []Grant{
|
||||
{
|
||||
Sources: Aliases{
|
||||
gp("group:eng"),
|
||||
up("alice@example.com"),
|
||||
func() *Prefix { p := Prefix(mp("100.64.0.10/32")); return &p }(),
|
||||
},
|
||||
Destinations: Aliases{
|
||||
tp("tag:web"),
|
||||
tp("tag:db"),
|
||||
hp("server1"),
|
||||
},
|
||||
InternetProtocols: []ProtocolPort{
|
||||
{Protocol: "tcp", Ports: []tailcfg.PortRange{{First: 443, Last: 443}}},
|
||||
{Protocol: "udp", Ports: []tailcfg.PortRange{{First: 53, Last: 53}}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "valid-grant-with-port-ranges",
|
||||
input: `
|
||||
{
|
||||
"grants": [
|
||||
{
|
||||
"src": ["*"],
|
||||
"dst": ["*"],
|
||||
"ip": ["tcp:8000-9000", "80", "443"]
|
||||
}
|
||||
]
|
||||
}
|
||||
`,
|
||||
want: &Policy{
|
||||
Grants: []Grant{
|
||||
{
|
||||
Sources: Aliases{
|
||||
Wildcard,
|
||||
},
|
||||
Destinations: Aliases{
|
||||
Wildcard,
|
||||
},
|
||||
InternetProtocols: []ProtocolPort{
|
||||
{Protocol: "tcp", Ports: []tailcfg.PortRange{{First: 8000, Last: 9000}}},
|
||||
{Protocol: "*", Ports: []tailcfg.PortRange{{First: 80, Last: 80}}},
|
||||
{Protocol: "*", Ports: []tailcfg.PortRange{{First: 443, Last: 443}}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "valid-grant-with-autogroups",
|
||||
input: `
|
||||
{
|
||||
"grants": [
|
||||
{
|
||||
"src": ["autogroup:member"],
|
||||
"dst": ["autogroup:self"],
|
||||
"ip": ["*"]
|
||||
}
|
||||
]
|
||||
}
|
||||
`,
|
||||
want: &Policy{
|
||||
Grants: []Grant{
|
||||
{
|
||||
Sources: Aliases{
|
||||
agp("autogroup:member"),
|
||||
},
|
||||
Destinations: Aliases{
|
||||
agp("autogroup:self"),
|
||||
},
|
||||
InternetProtocols: []ProtocolPort{
|
||||
{Protocol: "*", Ports: []tailcfg.PortRange{tailcfg.PortRangeAny}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid-grant-both-ip-and-app",
|
||||
input: `
|
||||
{
|
||||
"grants": [
|
||||
{
|
||||
"src": ["*"],
|
||||
"dst": ["*"],
|
||||
"ip": ["tcp:443"],
|
||||
"app": {
|
||||
"tailscale.com/cap/relay": []
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
`,
|
||||
wantErr: "grants cannot specify both 'ip' and 'app' fields",
|
||||
},
|
||||
{
|
||||
name: "invalid-grant-missing-ip-and-app",
|
||||
input: `
|
||||
{
|
||||
"grants": [
|
||||
{
|
||||
"src": ["*"],
|
||||
"dst": ["*"]
|
||||
}
|
||||
]
|
||||
}
|
||||
`,
|
||||
wantErr: "grants must specify either 'ip' or 'app' field",
|
||||
},
|
||||
{
|
||||
name: "invalid-grant-empty-sources",
|
||||
input: `
|
||||
{
|
||||
"grants": [
|
||||
{
|
||||
"src": [],
|
||||
"dst": ["*"],
|
||||
"ip": ["*"]
|
||||
}
|
||||
]
|
||||
}
|
||||
`,
|
||||
wantErr: "grant sources cannot be empty",
|
||||
},
|
||||
{
|
||||
name: "invalid-grant-empty-destinations",
|
||||
input: `
|
||||
{
|
||||
"grants": [
|
||||
{
|
||||
"src": ["*"],
|
||||
"dst": [],
|
||||
"ip": ["*"]
|
||||
}
|
||||
]
|
||||
}
|
||||
`,
|
||||
wantErr: "grant destinations cannot be empty",
|
||||
},
|
||||
{
|
||||
name: "invalid-grant-undefined-via-tag",
|
||||
input: `
|
||||
{
|
||||
"tagOwners": {
|
||||
"tag:server": ["alice@example.com"]
|
||||
},
|
||||
"grants": [
|
||||
{
|
||||
"src": ["*"],
|
||||
"dst": ["autogroup:internet"],
|
||||
"ip": ["*"],
|
||||
"via": ["tag:undefined-router"]
|
||||
}
|
||||
]
|
||||
}
|
||||
`,
|
||||
wantErr: "grant 'via' tag is not defined in policy",
|
||||
},
|
||||
{
|
||||
name: "invalid-grant-undefined-source-group",
|
||||
input: `
|
||||
{
|
||||
"grants": [
|
||||
{
|
||||
"src": ["group:undefined"],
|
||||
"dst": ["*"],
|
||||
"ip": ["*"]
|
||||
}
|
||||
]
|
||||
}
|
||||
`,
|
||||
wantErr: "group not defined in policy",
|
||||
},
|
||||
{
|
||||
name: "invalid-grant-undefined-source-tag",
|
||||
input: `
|
||||
{
|
||||
"grants": [
|
||||
{
|
||||
"src": ["tag:undefined"],
|
||||
"dst": ["*"],
|
||||
"ip": ["*"]
|
||||
}
|
||||
]
|
||||
}
|
||||
`,
|
||||
wantErr: "tag not defined in policy",
|
||||
},
|
||||
{
|
||||
name: "invalid-grant-undefined-destination-host",
|
||||
input: `
|
||||
{
|
||||
"grants": [
|
||||
{
|
||||
"src": ["*"],
|
||||
"dst": ["host-undefined"],
|
||||
"ip": ["*"]
|
||||
}
|
||||
]
|
||||
}
|
||||
`,
|
||||
wantErr: "host not defined",
|
||||
},
|
||||
{
|
||||
name: "invalid-grant-autogroup-self-with-tag-source",
|
||||
input: `
|
||||
{
|
||||
"tagOwners": {
|
||||
"tag:server": ["alice@example.com"]
|
||||
},
|
||||
"grants": [
|
||||
{
|
||||
"src": ["tag:server"],
|
||||
"dst": ["autogroup:self"],
|
||||
"ip": ["*"]
|
||||
}
|
||||
]
|
||||
}
|
||||
`,
|
||||
wantErr: "autogroup:self destination requires sources to be users, groups, or autogroup:member only",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
policy, err := unmarshalPolicy([]byte(tt.input))
|
||||
if tt.wantErr != "" {
|
||||
// Unmarshal succeeded, try validate
|
||||
if err == nil {
|
||||
err = policy.validate()
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
t.Fatalf("expected error containing %q, got nil", tt.wantErr)
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("expected error containing %q, got %q", tt.wantErr, err.Error())
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Validate the policy
|
||||
err = policy.validate()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected validation error: %v", err)
|
||||
}
|
||||
|
||||
if diff := cmp.Diff(tt.want, policy, cmpopts.IgnoreUnexported(Policy{}, Prefix{})); diff != "" {
|
||||
t.Errorf("Policy unmarshal mismatch (-want +got):\n%s", diff)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestACLToGrants(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
acl ACL
|
||||
want []Grant
|
||||
}{
|
||||
{
|
||||
name: "single-destination-tcp",
|
||||
acl: ACL{
|
||||
Action: ActionAccept,
|
||||
Protocol: ProtocolNameTCP,
|
||||
Sources: Aliases{gp("group:eng")},
|
||||
Destinations: []AliasWithPorts{
|
||||
{
|
||||
Alias: tp("tag:server"),
|
||||
Ports: []tailcfg.PortRange{{First: 443, Last: 443}},
|
||||
},
|
||||
},
|
||||
},
|
||||
want: []Grant{
|
||||
{
|
||||
Sources: Aliases{gp("group:eng")},
|
||||
Destinations: Aliases{tp("tag:server")},
|
||||
InternetProtocols: []ProtocolPort{
|
||||
{
|
||||
Protocol: ProtocolNameTCP,
|
||||
Ports: []tailcfg.PortRange{{First: 443, Last: 443}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "multiple-destinations-creates-multiple-grants",
|
||||
acl: ACL{
|
||||
Action: ActionAccept,
|
||||
Protocol: ProtocolNameTCP,
|
||||
Sources: Aliases{gp("group:eng")},
|
||||
Destinations: []AliasWithPorts{
|
||||
{
|
||||
Alias: tp("tag:web"),
|
||||
Ports: []tailcfg.PortRange{{First: 80, Last: 80}},
|
||||
},
|
||||
{
|
||||
Alias: tp("tag:db"),
|
||||
Ports: []tailcfg.PortRange{{First: 5432, Last: 5432}},
|
||||
},
|
||||
},
|
||||
},
|
||||
want: []Grant{
|
||||
{
|
||||
Sources: Aliases{gp("group:eng")},
|
||||
Destinations: Aliases{tp("tag:web")},
|
||||
InternetProtocols: []ProtocolPort{
|
||||
{
|
||||
Protocol: ProtocolNameTCP,
|
||||
Ports: []tailcfg.PortRange{{First: 80, Last: 80}},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Sources: Aliases{gp("group:eng")},
|
||||
Destinations: Aliases{tp("tag:db")},
|
||||
InternetProtocols: []ProtocolPort{
|
||||
{
|
||||
Protocol: ProtocolNameTCP,
|
||||
Ports: []tailcfg.PortRange{{First: 5432, Last: 5432}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "wildcard-protocol",
|
||||
acl: ACL{
|
||||
Action: ActionAccept,
|
||||
Protocol: ProtocolNameWildcard,
|
||||
Sources: Aliases{gp("group:admin")},
|
||||
Destinations: []AliasWithPorts{
|
||||
{
|
||||
Alias: up("alice@example.com"),
|
||||
Ports: []tailcfg.PortRange{tailcfg.PortRangeAny},
|
||||
},
|
||||
},
|
||||
},
|
||||
want: []Grant{
|
||||
{
|
||||
Sources: Aliases{gp("group:admin")},
|
||||
Destinations: Aliases{up("alice@example.com")},
|
||||
InternetProtocols: []ProtocolPort{
|
||||
{
|
||||
Protocol: ProtocolNameWildcard,
|
||||
Ports: []tailcfg.PortRange{tailcfg.PortRangeAny},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "udp-with-port-range",
|
||||
acl: ACL{
|
||||
Action: ActionAccept,
|
||||
Protocol: ProtocolNameUDP,
|
||||
Sources: Aliases{up("bob@example.com")},
|
||||
Destinations: []AliasWithPorts{
|
||||
{
|
||||
Alias: tp("tag:voip"),
|
||||
Ports: []tailcfg.PortRange{{First: 10000, Last: 20000}},
|
||||
},
|
||||
},
|
||||
},
|
||||
want: []Grant{
|
||||
{
|
||||
Sources: Aliases{up("bob@example.com")},
|
||||
Destinations: Aliases{tp("tag:voip")},
|
||||
InternetProtocols: []ProtocolPort{
|
||||
{
|
||||
Protocol: ProtocolNameUDP,
|
||||
Ports: []tailcfg.PortRange{{First: 10000, Last: 20000}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "icmp-protocol",
|
||||
acl: ACL{
|
||||
Action: ActionAccept,
|
||||
Protocol: ProtocolNameICMP,
|
||||
Sources: Aliases{gp("group:monitoring")},
|
||||
Destinations: []AliasWithPorts{
|
||||
{
|
||||
Alias: new(Asterix),
|
||||
Ports: []tailcfg.PortRange{tailcfg.PortRangeAny},
|
||||
},
|
||||
},
|
||||
},
|
||||
want: []Grant{
|
||||
{
|
||||
Sources: Aliases{gp("group:monitoring")},
|
||||
Destinations: Aliases{new(Asterix)},
|
||||
InternetProtocols: []ProtocolPort{
|
||||
{
|
||||
Protocol: ProtocolNameICMP,
|
||||
Ports: []tailcfg.PortRange{tailcfg.PortRangeAny},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := aclToGrants(tt.acl)
|
||||
|
||||
if diff := cmp.Diff(tt.want, got); diff != "" {
|
||||
t.Errorf("aclToGrants() mismatch (-want +got):\n%s", diff)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGrantMarshalJSON(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
grant Grant
|
||||
wantJSON string
|
||||
}{
|
||||
{
|
||||
name: "ip-based-grant-tcp-single-port",
|
||||
grant: Grant{
|
||||
Sources: Aliases{gp("group:eng")},
|
||||
Destinations: Aliases{tp("tag:server")},
|
||||
InternetProtocols: []ProtocolPort{
|
||||
{
|
||||
Protocol: ProtocolNameTCP,
|
||||
Ports: []tailcfg.PortRange{{First: 443, Last: 443}},
|
||||
},
|
||||
},
|
||||
},
|
||||
wantJSON: `{
|
||||
"src": ["group:eng"],
|
||||
"dst": ["tag:server"],
|
||||
"ip": ["tcp:443"]
|
||||
}`,
|
||||
},
|
||||
{
|
||||
name: "ip-based-grant-udp-port-range",
|
||||
grant: Grant{
|
||||
Sources: Aliases{up("alice@example.com")},
|
||||
Destinations: Aliases{tp("tag:voip")},
|
||||
InternetProtocols: []ProtocolPort{
|
||||
{
|
||||
Protocol: ProtocolNameUDP,
|
||||
Ports: []tailcfg.PortRange{{First: 10000, Last: 20000}},
|
||||
},
|
||||
},
|
||||
},
|
||||
wantJSON: `{
|
||||
"src": ["alice@example.com"],
|
||||
"dst": ["tag:voip"],
|
||||
"ip": ["udp:10000-20000"]
|
||||
}`,
|
||||
},
|
||||
{
|
||||
name: "ip-based-grant-wildcard-protocol",
|
||||
grant: Grant{
|
||||
Sources: Aliases{gp("group:admin")},
|
||||
Destinations: Aliases{Asterix(0)},
|
||||
InternetProtocols: []ProtocolPort{
|
||||
{
|
||||
Protocol: ProtocolNameWildcard,
|
||||
Ports: []tailcfg.PortRange{tailcfg.PortRangeAny},
|
||||
},
|
||||
},
|
||||
},
|
||||
wantJSON: `{
|
||||
"src": ["group:admin"],
|
||||
"dst": ["*"],
|
||||
"ip": ["*"]
|
||||
}`,
|
||||
},
|
||||
{
|
||||
name: "ip-based-grant-icmp",
|
||||
grant: Grant{
|
||||
Sources: Aliases{gp("group:monitoring")},
|
||||
Destinations: Aliases{tp("tag:servers")},
|
||||
InternetProtocols: []ProtocolPort{
|
||||
{
|
||||
Protocol: ProtocolNameICMP,
|
||||
Ports: []tailcfg.PortRange{tailcfg.PortRangeAny},
|
||||
},
|
||||
},
|
||||
},
|
||||
wantJSON: `{
|
||||
"src": ["group:monitoring"],
|
||||
"dst": ["tag:servers"],
|
||||
"ip": ["icmp:0-65535"]
|
||||
}`,
|
||||
},
|
||||
{
|
||||
name: "ip-based-grant-multiple-protocols",
|
||||
grant: Grant{
|
||||
Sources: Aliases{gp("group:web")},
|
||||
Destinations: Aliases{tp("tag:lb")},
|
||||
InternetProtocols: []ProtocolPort{
|
||||
{
|
||||
Protocol: ProtocolNameTCP,
|
||||
Ports: []tailcfg.PortRange{{First: 80, Last: 80}},
|
||||
},
|
||||
{
|
||||
Protocol: ProtocolNameTCP,
|
||||
Ports: []tailcfg.PortRange{{First: 443, Last: 443}},
|
||||
},
|
||||
},
|
||||
},
|
||||
wantJSON: `{
|
||||
"src": ["group:web"],
|
||||
"dst": ["tag:lb"],
|
||||
"ip": ["tcp:80", "tcp:443"]
|
||||
}`,
|
||||
},
|
||||
{
|
||||
name: "capability-based-grant",
|
||||
grant: Grant{
|
||||
Sources: Aliases{gp("group:admins")},
|
||||
Destinations: Aliases{tp("tag:database")},
|
||||
App: tailcfg.PeerCapMap{
|
||||
"backup": []tailcfg.RawMessage{
|
||||
tailcfg.RawMessage(`{"action":"read"}`),
|
||||
tailcfg.RawMessage(`{"action":"write"}`),
|
||||
},
|
||||
},
|
||||
},
|
||||
wantJSON: `{
|
||||
"src": ["group:admins"],
|
||||
"dst": ["tag:database"],
|
||||
"app": {
|
||||
"backup": [
|
||||
{"action":"read"},
|
||||
{"action":"write"}
|
||||
]
|
||||
}
|
||||
}`,
|
||||
},
|
||||
{
|
||||
name: "grant-with-both-ip-and-app",
|
||||
grant: Grant{
|
||||
Sources: Aliases{up("bob@example.com")},
|
||||
Destinations: Aliases{tp("tag:app-server")},
|
||||
InternetProtocols: []ProtocolPort{
|
||||
{
|
||||
Protocol: ProtocolNameTCP,
|
||||
Ports: []tailcfg.PortRange{{First: 8080, Last: 8080}},
|
||||
},
|
||||
},
|
||||
App: tailcfg.PeerCapMap{
|
||||
"admin": []tailcfg.RawMessage{
|
||||
tailcfg.RawMessage(`{"level":"superuser"}`),
|
||||
},
|
||||
},
|
||||
},
|
||||
wantJSON: `{
|
||||
"src": ["bob@example.com"],
|
||||
"dst": ["tag:app-server"],
|
||||
"ip": ["tcp:8080"],
|
||||
"app": {
|
||||
"admin": [{"level":"superuser"}]
|
||||
}
|
||||
}`,
|
||||
},
|
||||
{
|
||||
name: "grant-with-via",
|
||||
grant: Grant{
|
||||
Sources: Aliases{gp("group:remote-workers")},
|
||||
Destinations: Aliases{tp("tag:internal")},
|
||||
InternetProtocols: []ProtocolPort{
|
||||
{
|
||||
Protocol: ProtocolNameTCP,
|
||||
Ports: []tailcfg.PortRange{tailcfg.PortRangeAny},
|
||||
},
|
||||
},
|
||||
Via: []Tag{
|
||||
*tp("tag:gateway1"),
|
||||
*tp("tag:gateway2"),
|
||||
},
|
||||
},
|
||||
wantJSON: `{
|
||||
"src": ["group:remote-workers"],
|
||||
"dst": ["tag:internal"],
|
||||
"ip": ["tcp:0-65535"],
|
||||
"via": ["tag:gateway1", "tag:gateway2"]
|
||||
}`,
|
||||
},
|
||||
{
|
||||
name: "grant-omitzero-app-field",
|
||||
grant: Grant{
|
||||
Sources: Aliases{gp("group:users")},
|
||||
Destinations: Aliases{tp("tag:web")},
|
||||
InternetProtocols: []ProtocolPort{
|
||||
{
|
||||
Protocol: ProtocolNameTCP,
|
||||
Ports: []tailcfg.PortRange{{First: 80, Last: 80}},
|
||||
},
|
||||
},
|
||||
App: nil,
|
||||
},
|
||||
wantJSON: `{
|
||||
"src": ["group:users"],
|
||||
"dst": ["tag:web"],
|
||||
"ip": ["tcp:80"]
|
||||
}`,
|
||||
},
|
||||
{
|
||||
name: "grant-omitzero-via-field",
|
||||
grant: Grant{
|
||||
Sources: Aliases{gp("group:users")},
|
||||
Destinations: Aliases{tp("tag:api")},
|
||||
InternetProtocols: []ProtocolPort{
|
||||
{
|
||||
Protocol: ProtocolNameTCP,
|
||||
Ports: []tailcfg.PortRange{{First: 443, Last: 443}},
|
||||
},
|
||||
},
|
||||
Via: nil,
|
||||
},
|
||||
wantJSON: `{
|
||||
"src": ["group:users"],
|
||||
"dst": ["tag:api"],
|
||||
"ip": ["tcp:443"]
|
||||
}`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Marshal the Grant to JSON
|
||||
gotJSON, err := json.Marshal(tt.grant)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to marshal Grant: %v", err)
|
||||
}
|
||||
|
||||
// Compact the expected JSON to remove whitespace for comparison
|
||||
var wantCompact bytes.Buffer
|
||||
|
||||
err = json.Compact(&wantCompact, []byte(tt.wantJSON))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to compact expected JSON: %v", err)
|
||||
}
|
||||
|
||||
// Compare JSON strings
|
||||
if string(gotJSON) != wantCompact.String() {
|
||||
t.Errorf("Grant.MarshalJSON() mismatch:\ngot: %s\nwant: %s", string(gotJSON), wantCompact.String())
|
||||
}
|
||||
|
||||
// Test round-trip: unmarshal and compare with original
|
||||
var unmarshaled Grant
|
||||
|
||||
err = json.Unmarshal(gotJSON, &unmarshaled)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to unmarshal JSON: %v", err)
|
||||
}
|
||||
|
||||
if diff := cmp.Diff(tt.grant, unmarshaled); diff != "" {
|
||||
t.Errorf("Grant round-trip mismatch (-original +unmarshaled):\n%s", diff)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
)
|
||||
|
||||
// TestParseDestinationAndPort tests the splitDestinationAndPort function using table-driven tests.
|
||||
func TestParseDestinationAndPort(t *testing.T) {
|
||||
func TestSplitDestinationAndPort(t *testing.T) {
|
||||
testCases := []struct {
|
||||
input string
|
||||
wantDst string
|
||||
|
||||
Reference in New Issue
Block a user