diff --git a/hscontrol/policy/v2/filter.go b/hscontrol/policy/v2/filter.go index a0888836..aa2d5355 100644 --- a/hscontrol/policy/v2/filter.go +++ b/hscontrol/policy/v2/filter.go @@ -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(), + }) + } } } } diff --git a/hscontrol/policy/v2/types.go b/hscontrol/policy/v2/types.go index 8d7df81f..090546f0 100644 --- a/hscontrol/policy/v2/types.go +++ b/hscontrol/policy/v2/types.go @@ -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) { diff --git a/hscontrol/policy/v2/types_test.go b/hscontrol/policy/v2/types_test.go index f0b9c9a1..43e4d64a 100644 --- a/hscontrol/policy/v2/types_test.go +++ b/hscontrol/policy/v2/types_test.go @@ -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) + } + }) + } +} diff --git a/hscontrol/policy/v2/utils_test.go b/hscontrol/policy/v2/utils_test.go index aa65bf10..19c723dc 100644 --- a/hscontrol/policy/v2/utils_test.go +++ b/hscontrol/policy/v2/utils_test.go @@ -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