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..ea61de06 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,72 @@ 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 isWildcard(str string) bool { return str == "*" } @@ -1467,9 +1545,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 +1661,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 +1727,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"` + 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 +1777,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 +2184,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/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