diff --git a/cmd/containerboot/main.go b/cmd/containerboot/main.go index 51bd84253..2b6f31291 100644 --- a/cmd/containerboot/main.go +++ b/cmd/containerboot/main.go @@ -1114,7 +1114,8 @@ func runHTTPServer(mux *http.ServeMux, addr string) (close func() error) { } // resolveTailnetFQDN resolves a tailnet FQDN to a list of IP prefixes, which -// can be either a peer device or a Tailscale Service. +// can be either a peer device, a Tailscale Service, or a 4via6 synthesized +// DNS name (e.g. "10-1-0-5-via-7.tailnet.ts.net"). func resolveTailnetFQDN(nm netmapState, fqdn string) ([]netip.Prefix, error) { dnsFQDN, err := dnsname.ToFQDN(fqdn) if err != nil { @@ -1137,8 +1138,20 @@ func resolveTailnetFQDN(nm netmapState, fqdn string) ([]netip.Prefix, error) { if svcIPs := serviceIPsFromNetMap(nm, dnsFQDN); len(svcIPs) != 0 { return svcIPs, nil } + // If not found yet, check for a matching 4via6 DNS name. + if addr, ok := kubeutils.ResolveViaDomain(dnsFQDN.WithTrailingDot()); ok { + prefix := netip.PrefixFrom(addr, addr.BitLen()) + for nn := range nm.peers() { + for _, allowedIP := range nn.AllowedIPs().All() { + if allowedIP.Contains(addr) { + return []netip.Prefix{prefix}, nil + } + } + } + return nil, fmt.Errorf("resolved 4via6 address %v for %q but no peer advertises a route containing it", addr, fqdn) + } - return nil, fmt.Errorf("could not find Tailscale node or service %q; it either does not exist, or not reachable because of ACLs", fqdn) + return nil, fmt.Errorf("could not find Tailscale node, service or 4via6 address %q; it either does not exist, or not reachable because of ACLs", fqdn) } // serviceIPsFromNetMap returns all IPs of a Tailscale Service if its FQDN is diff --git a/cmd/k8s-operator/connector.go b/cmd/k8s-operator/connector.go index 0c2d32482..323dc7b86 100644 --- a/cmd/k8s-operator/connector.go +++ b/cmd/k8s-operator/connector.go @@ -29,6 +29,8 @@ import ( tsoperator "tailscale.com/k8s-operator" tsapi "tailscale.com/k8s-operator/apis/v1alpha1" "tailscale.com/kube/kubetypes" + "tailscale.com/net/netutil" + "tailscale.com/net/tsaddr" "tailscale.com/tstime" "tailscale.com/util/clientmetric" "tailscale.com/util/set" @@ -356,6 +358,11 @@ func validateRoutes(routes tsapi.Routes) error { if pfx.Masked() != pfx { errs = append(errs, fmt.Errorf("route %s has non-address bits set; expected %s", pfx, pfx.Masked())) } + if tsaddr.IsViaPrefix(pfx) { + if err := netutil.ValidateViaPrefix(pfx); err != nil { + errs = append(errs, err) + } + } } return errors.Join(errs...) } diff --git a/cmd/k8s-operator/connector_test.go b/cmd/k8s-operator/connector_test.go index 69e8e287d..b366dac38 100644 --- a/cmd/k8s-operator/connector_test.go +++ b/cmd/k8s-operator/connector_test.go @@ -145,6 +145,22 @@ func TestConnector(t *testing.T) { expectReconciled(t, cr, "", "test") expectEqual(t, fc, expectedSTS(t, fc, opts), removeResourceReqs) + // Set an invalid 4via6 route (site ID too large). + mustUpdate[tsapi.Connector](t, fc, "", "test", func(conn *tsapi.Connector) { + conn.Spec.SubnetRouter.AdvertiseRoutes = []tsapi.Route{"fd7a:115c:a1e0:b1a:1:0:a2c:0/116"} + }) + expectReconciled(t, cr, "", "test") + // STS should still have the previous valid route, unchanged. + expectEqual(t, fc, expectedSTS(t, fc, opts), removeResourceReqs) + + // Set a valid 4via6 route. + mustUpdate[tsapi.Connector](t, fc, "", "test", func(conn *tsapi.Connector) { + conn.Spec.SubnetRouter.AdvertiseRoutes = []tsapi.Route{"fd7a:115c:a1e0:b1a:0:1:a2c:0/116"} + }) + opts.subnetRoutes = "fd7a:115c:a1e0:b1a:0:1:a2c:0/116" + expectReconciled(t, cr, "", "test") + expectEqual(t, fc, expectedSTS(t, fc, opts), removeResourceReqs) + // Delete the Connector. if err = fc.Delete(context.Background(), cn); err != nil { t.Fatalf("error deleting Connector: %v", err) diff --git a/k8s-operator/utils.go b/k8s-operator/utils.go index d83d98e0c..8d1391383 100644 --- a/k8s-operator/utils.go +++ b/k8s-operator/utils.go @@ -10,8 +10,13 @@ import ( "crypto/sha256" "encoding/hex" "fmt" + "net/netip" + "strconv" + "strings" + "tailscale.com/net/tsaddr" "tailscale.com/tailcfg" + "tailscale.com/util/dnsname" ) const ( @@ -53,6 +58,43 @@ func CapVerFromFileName(name string) (tailcfg.CapabilityVersion, error) { return cap, err } +// ResolveViaDomain parses an FQDN (with or without trailing dot) as a +// 4via6 domain in the format "-via-[.domain]" +// and returns the synthesized IPv6 via address. +// This borrows heavily from net/dns/resolver.(*Resolver).resolveViaDomain. +// TODO(beckypauley): consider a refactor of the above to remove duplication. +func ResolveViaDomain(name string) (netip.Addr, bool) { + // The minimum length of a valid 4via6 FQDN i.e. "0-0-0-0-via-X". + const minFQDNLength = 13 + fqdn := strings.TrimSuffix(name, ".") + if len(fqdn) < minFQDNLength { + return netip.Addr{}, false // too short to be valid + } + if !strings.Contains(fqdn, "-via-") { + return netip.Addr{}, false + } + firstLabel, domain, _ := strings.Cut(fqdn, ".") + if !(domain == "" || dnsname.HasSuffix(domain, "ts.net") || dnsname.HasSuffix(domain, "tailscale.net")) { + return netip.Addr{}, false + } + v4hyphens, siteIDStr, ok := strings.Cut(firstLabel, "-via-") + if !ok { + return netip.Addr{}, false + } + ip4Str := strings.ReplaceAll(v4hyphens, "-", ".") + ip4, err := netip.ParseAddr(ip4Str) + if err != nil || !ip4.Is4() { + return netip.Addr{}, false + } + prefix, err := strconv.ParseUint(siteIDStr, 0, 32) + if err != nil { + return netip.Addr{}, false + } + // MapVia will never error when given an IPv4 netip.Prefix. + out, _ := tsaddr.MapVia(uint32(prefix), netip.PrefixFrom(ip4, ip4.BitLen())) + return out.Addr(), true +} + // TruncateLabelValue truncates a Kubernetes label value to fit within the // 63-character limit. If the value exceeds the limit, it is truncated and a // short hash suffix is appended to preserve uniqueness. diff --git a/net/netutil/routes.go b/net/netutil/routes.go index 9692cc69a..510572b6d 100644 --- a/net/netutil/routes.go +++ b/net/netutil/routes.go @@ -17,7 +17,7 @@ import ( // It verifies that the prefix is in the Tailscale via range, has a prefix // length between /96 and /128, and that the embedded site ID is in the // range 0–65535. -func validateViaPrefix(ipp netip.Prefix) error { +func ValidateViaPrefix(ipp netip.Prefix) error { if !tsaddr.IsViaPrefix(ipp) { return fmt.Errorf("%v is not a 4-in-6 prefix", ipp) } @@ -55,7 +55,7 @@ func CalcAdvertiseRoutes(advertiseRoutes string, advertiseDefaultRoute bool) ([] return nil, fmt.Errorf("%s has non-address bits set; expected %s", ipp, ipp.Masked()) } if tsaddr.IsViaPrefix(ipp) { - if err := validateViaPrefix(ipp); err != nil { + if err := ValidateViaPrefix(ipp); err != nil { return nil, err } }