cmd/{containerboot,k8s-operator}: add 4via6 support in singleton egress (#19983)

Add support for configuring egress to destinations reachable via 4via6
subnet routes, using either the synthesized 4via6 address or the MagicDNS
name (in the form <IPv4-with-hyphens>-via-<siteID>[.*]).

Also update the Connector to validate and advertise 4via6 subnet routes.

Export net/netutil.ValidateViaPrefix so it can be reused by the Connector
validation logic.

This change only affects standalone egress proxies — ProxyGroup egress
requires IPv6 support before it can use 4via6.

Updates #19334

Change-Id: I6faecd6eb61ab55fc0cd97fe417af6b6a12fe7fc

Signed-off-by: Becky Pauley <becky@tailscale.com>
This commit is contained in:
BeckyPauley
2026-06-18 16:13:10 +01:00
committed by GitHub
parent e3b16135b2
commit 35a1a413f9
5 changed files with 82 additions and 4 deletions
+15 -2
View File
@@ -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
+7
View File
@@ -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...)
}
+16
View File
@@ -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)
+42
View File
@@ -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 "<ipv4-with-hyphens>-via-<siteID>[.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.
+2 -2
View File
@@ -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 065535.
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
}
}