From fdc7e26c8ae91da10e518ed839bd5f38faa1a101 Mon Sep 17 00:00:00 2001 From: Kristoffer Dalby Date: Tue, 16 Jun 2026 07:53:29 +0000 Subject: [PATCH] util: consolidate parsing and encoding helpers --- hscontrol/policy/matcher/matcher.go | 9 +---- hscontrol/policy/policyutil/reduce.go | 17 +-------- hscontrol/util/addr.go | 14 ++------ hscontrol/util/dns.go | 19 ++++------ hscontrol/util/net.go | 25 ++++++++++--- hscontrol/util/test.go | 22 ++++++------ hscontrol/util/util.go | 51 ++++++++++++--------------- 7 files changed, 64 insertions(+), 93 deletions(-) diff --git a/hscontrol/policy/matcher/matcher.go b/hscontrol/policy/matcher/matcher.go index 55b36cd7..acfd35b4 100644 --- a/hscontrol/policy/matcher/matcher.go +++ b/hscontrol/policy/matcher/matcher.go @@ -120,12 +120,5 @@ func (m *Match) DestsIsTheInternet() bool { // Superset-of-[util.TheInternet] check handles merged filter rules // where the internet prefixes are combined with other dests. - theInternet := util.TheInternet() - for _, prefix := range theInternet.Prefixes() { - if !m.dests.ContainsPrefix(prefix) { - return false - } - } - - return true + return util.IPSetSubsetOf(util.TheInternet(), m.dests) } diff --git a/hscontrol/policy/policyutil/reduce.go b/hscontrol/policy/policyutil/reduce.go index 66ee2e67..fc662aac 100644 --- a/hscontrol/policy/policyutil/reduce.go +++ b/hscontrol/policy/policyutil/reduce.go @@ -6,7 +6,6 @@ import ( "github.com/juanfont/headscale/hscontrol/types" "github.com/juanfont/headscale/hscontrol/util" - "go4.org/netipx" "tailscale.com/tailcfg" ) @@ -66,7 +65,7 @@ func ReduceFilterRules(node types.NodeView, rules []tailcfg.FilterRule) []tailcf // Exit-route advertisers need rules targeting the // public internet so the kernel filter accepts // traffic forwarded by autogroup:internet sources. - if hasExitRoutes && ipSetSubsetOf(expanded, util.TheInternet()) { + if hasExitRoutes && util.IPSetSubsetOf(expanded, util.TheInternet()) { dests = append(dests, dest) } } @@ -83,20 +82,6 @@ func ReduceFilterRules(node types.NodeView, rules []tailcfg.FilterRule) []tailcf return ret } -func ipSetSubsetOf(candidate, container *netipx.IPSet) bool { - if candidate == nil || container == nil { - return false - } - - for _, pref := range candidate.Prefixes() { - if !container.ContainsPrefix(pref) { - return false - } - } - - return true -} - // reduceCapGrantRule filters a [tailcfg.CapGrant] rule to only include // [tailcfg.CapGrant] entries whose Dsts match the given node's IPs. When a // broad prefix (e.g. 100.64.0.0/10 from dst:*) contains a node's IP, it is diff --git a/hscontrol/util/addr.go b/hscontrol/util/addr.go index b0b39944..b4434325 100644 --- a/hscontrol/util/addr.go +++ b/hscontrol/util/addr.go @@ -12,10 +12,6 @@ import ( // This is borrowed from, and updated to use [netipx.IPSet] // https://github.com/tailscale/tailscale/blob/71029cea2ddf82007b80f465b256d027eab0f02d/wgengine/filter/tailcfg.go#L97-L162 // TODO(kradalby): contribute upstream and make public. -var ( - zeroIP4 = netip.AddrFrom4([4]byte{}) - zeroIP6 = netip.AddrFrom16([16]byte{}) -) // parseIPSet parses arg as one: // @@ -30,8 +26,8 @@ var ( func ParseIPSet(arg string, bits *int) (*netipx.IPSet, error) { var ipSet netipx.IPSetBuilder if arg == "*" { - ipSet.AddPrefix(netip.PrefixFrom(zeroIP4, 0)) - ipSet.AddPrefix(netip.PrefixFrom(zeroIP6, 0)) + ipSet.AddPrefix(netip.PrefixFrom(netip.IPv4Unspecified(), 0)) + ipSet.AddPrefix(netip.PrefixFrom(netip.IPv6Unspecified(), 0)) return ipSet.IPSet() } @@ -90,13 +86,9 @@ func ParseIPSet(arg string, bits *int) (*netipx.IPSet, error) { } func GetIPPrefixEndpoints(na netip.Prefix) (netip.Addr, netip.Addr) { - var network, broadcast netip.Addr - ipRange := netipx.RangeOfPrefix(na) - network = ipRange.From() - broadcast = ipRange.To() - return network, broadcast + return ipRange.From(), ipRange.To() } func StringToIPPrefix(prefixes []string) ([]netip.Prefix, error) { diff --git a/hscontrol/util/dns.go b/hscontrol/util/dns.go index f2ec215d..c705ee88 100644 --- a/hscontrol/util/dns.go +++ b/hscontrol/util/dns.go @@ -130,8 +130,8 @@ func GenerateIPv4DNSRootDomain(ipPrefix netip.Prefix) []dnsname.FQDN { // here we generate the base domain (e.g., 100.in-addr.arpa., 16.172.in-addr.arpa., etc.) rdnsSlice := []string{} - for i := lastOctet - 1; i >= 0; i-- { - rdnsSlice = append(rdnsSlice, strconv.FormatUint(uint64(netRange.IP[i]), 10)) + for _, b := range slices.Backward(netRange.IP[:lastOctet]) { + rdnsSlice = append(rdnsSlice, strconv.FormatUint(uint64(b), 10)) } rdnsSlice = append(rdnsSlice, "in-addr.arpa.") @@ -158,13 +158,7 @@ func GenerateIPv6DNSRootDomain(ipPrefix netip.Prefix) []dnsname.FQDN { maskBits, _ := netipx.PrefixIPNet(ipPrefix).Mask.Size() expanded := ipPrefix.Addr().StringExpanded() - nibbleStr := strings.Map(func(r rune) rune { - if r == ':' { - return -1 - } - - return r - }, expanded) + nibbleStr := strings.ReplaceAll(expanded, ":", "") // TODO?: that does not look the most efficient implementation, // but the inputs are not so long as to cause problems, @@ -172,12 +166,11 @@ func GenerateIPv6DNSRootDomain(ipPrefix netip.Prefix) []dnsname.FQDN { // function is called only once over the lifetime of a server process. prefixConstantParts := []string{} for i := range maskBits / nibbleLen { - prefixConstantParts = append( - []string{string(nibbleStr[i])}, - prefixConstantParts..., - ) + prefixConstantParts = append(prefixConstantParts, string(nibbleStr[i])) } + slices.Reverse(prefixConstantParts) + makeDomain := func(variablePrefix ...string) (dnsname.FQDN, error) { prefix := strings.Join(append(variablePrefix, prefixConstantParts...), ".") diff --git a/hscontrol/util/net.go b/hscontrol/util/net.go index 9d57c394..b159f765 100644 --- a/hscontrol/util/net.go +++ b/hscontrol/util/net.go @@ -26,13 +26,12 @@ func PrefixesToString(prefixes []netip.Prefix) []string { } func MustStringsToPrefixes(strings []string) []netip.Prefix { - ret := make([]netip.Prefix, 0, len(strings)) - for _, str := range strings { - prefix := netip.MustParsePrefix(str) - ret = append(ret, prefix) + prefixes, err := StringToIPPrefix(strings) + if err != nil { + panic(err) } - return ret + return prefixes } // TheInternet returns the [netipx.IPSet] for the Internet. @@ -61,3 +60,19 @@ var TheInternet = sync.OnceValue(func() *netipx.IPSet { return theInternetSet }) + +// IPSetSubsetOf reports whether every prefix of candidate is contained in +// container. A nil candidate or container returns false. +func IPSetSubsetOf(candidate, container *netipx.IPSet) bool { + if candidate == nil || container == nil { + return false + } + + for _, pref := range candidate.Prefixes() { + if !container.ContainsPrefix(pref) { + return false + } + } + + return true +} diff --git a/hscontrol/util/test.go b/hscontrol/util/test.go index d37f3315..7d7d71e9 100644 --- a/hscontrol/util/test.go +++ b/hscontrol/util/test.go @@ -21,20 +21,20 @@ var AddrPortComparer = cmp.Comparer(func(x, y netip.AddrPort) bool { return x == y }) -var MkeyComparer = cmp.Comparer(func(x, y key.MachinePublic) bool { - return x.String() == y.String() -}) +func strComparer[T interface{ String() string }]() cmp.Option { + return cmp.Comparer(func(x, y T) bool { + return x.String() == y.String() + }) +} -var NkeyComparer = cmp.Comparer(func(x, y key.NodePublic) bool { - return x.String() == y.String() -}) - -var DkeyComparer = cmp.Comparer(func(x, y key.DiscoPublic) bool { - return x.String() == y.String() -}) +var ( + MkeyComparer = strComparer[key.MachinePublic]() + NkeyComparer = strComparer[key.NodePublic]() + DkeyComparer = strComparer[key.DiscoPublic]() +) var ViewSliceIPProtoComparer = cmp.Comparer(views.SliceEqual[ipproto.Proto]) -var Comparers []cmp.Option = []cmp.Option{ +var Comparers = []cmp.Option{ IPComparer, PrefixComparer, AddrPortComparer, MkeyComparer, NkeyComparer, DkeyComparer, ViewSliceIPProtoComparer, } diff --git a/hscontrol/util/util.go b/hscontrol/util/util.go index 0767283a..e61c6b9b 100644 --- a/hscontrol/util/util.go +++ b/hscontrol/util/util.go @@ -24,13 +24,9 @@ var ( ) func TailscaleVersionNewerOrEqual(minimum, toCheck string) bool { - if cmpver.Compare(minimum, toCheck) <= 0 || + return cmpver.Compare(minimum, toCheck) <= 0 || toCheck == "unstable" || - toCheck == "head" { - return true - } - - return false + toCheck == "head" } // ParseLoginURLFromCLILogin parses the output of the tailscale up command to extract the login URL. @@ -94,6 +90,19 @@ type Traceroute struct { Err error } +// parseLatency parses a traceroute latency token such as "1.5" or "<1", +// returning the duration rounded to the nearest microsecond. The second +// return value reports whether the token was a valid number. +func parseLatency(tok string) (time.Duration, bool) { + ms, err := strconv.ParseFloat(strings.TrimPrefix(tok, "<"), 64) + if err != nil { + return 0, false + } + + // Round to nearest microsecond to avoid floating point precision issues. + return time.Duration(ms * float64(time.Millisecond)).Round(time.Microsecond), true +} + // ParseTraceroute parses the output of the traceroute command and returns a [Traceroute] struct. func ParseTraceroute(output string) (Traceroute, error) { lines := strings.Split(strings.TrimSpace(output), "\n") @@ -185,13 +194,8 @@ func ParseTraceroute(output string) (Traceroute, error) { break } // Extract and remove the latency from the beginning - latStr := strings.TrimPrefix(remainder[latMatch[2]:latMatch[3]], "<") - - ms, err := strconv.ParseFloat(latStr, 64) - if err == nil { - // Round to nearest microsecond to avoid floating point precision issues - duration := time.Duration(ms * float64(time.Millisecond)) - latencies = append(latencies, duration.Round(time.Microsecond)) + if d, ok := parseLatency(remainder[latMatch[2]:latMatch[3]]); ok { + latencies = append(latencies, d) } remainder = strings.TrimSpace(remainder[latMatch[1]:]) @@ -232,14 +236,8 @@ func ParseTraceroute(output string) (Traceroute, error) { latencyMatches := latencyRegex.FindAllStringSubmatch(remainder, -1) for _, match := range latencyMatches { if len(match) > 1 { - // Remove '<' prefix if present (e.g., "<1 ms") - latStr := strings.TrimPrefix(match[1], "<") - - ms, err := strconv.ParseFloat(latStr, 64) - if err == nil { - // Round to nearest microsecond to avoid floating point precision issues - duration := time.Duration(ms * float64(time.Millisecond)) - latencies = append(latencies, duration.Round(time.Microsecond)) + if d, ok := parseLatency(match[1]); ok { + latencies = append(latencies, d) } } } @@ -269,15 +267,10 @@ func ParseTraceroute(output string) (Traceroute, error) { } func IsCI() bool { - if _, ok := os.LookupEnv("CI"); ok { - return true - } + _, ci := os.LookupEnv("CI") + _, gh := os.LookupEnv("GITHUB_RUN_ID") - if _, ok := os.LookupEnv("GITHUB_RUN_ID"); ok { - return true - } - - return false + return ci || gh } // GenerateRegistrationKey generates a vanity key for tracking web authentication