Просмотр исходного кода

cmd/containerboot: support egress to Tailscale Service FQDNs (#17493)

Adds support for targeting FQDNs that are a Tailscale Service. Uses the
same method of searching for Services as the tailscale configure
kubeconfig command. This fixes using the tailscale.com/tailnet-fqdn
annotation for Kubernetes Service when the specified FQDN is a Tailscale
Service.

Fixes #16534

Change-Id: I422795de76dc83ae30e7e757bc4fbd8eec21cc64

Signed-off-by: Tom Proctor <[email protected]>
Signed-off-by: Becky Pauley <[email protected]>
Tom Proctor 2 месяцев назад
Родитель
Сommit
bb3529fcd4

+ 17 - 22
cmd/containerboot/egressservices.go

@@ -27,7 +27,6 @@ import (
 	"tailscale.com/kube/egressservices"
 	"tailscale.com/kube/kubeclient"
 	"tailscale.com/kube/kubetypes"
-	"tailscale.com/tailcfg"
 	"tailscale.com/util/httpm"
 	"tailscale.com/util/linuxfw"
 	"tailscale.com/util/mak"
@@ -477,30 +476,26 @@ func (ep *egressProxy) tailnetTargetIPsForSvc(svc egressservices.Config, n ipn.N
 		log.Printf("netmap is not available, unable to determine backend addresses for %s", svc.TailnetTarget.FQDN)
 		return addrs, nil
 	}
-	var (
-		node      tailcfg.NodeView
-		nodeFound bool
-	)
-	for _, nn := range n.NetMap.Peers {
-		if equalFQDNs(nn.Name(), svc.TailnetTarget.FQDN) {
-			node = nn
-			nodeFound = true
-			break
-		}
+	egressAddrs, err := resolveTailnetFQDN(n.NetMap, svc.TailnetTarget.FQDN)
+	if err != nil {
+		return nil, fmt.Errorf("error fetching backend addresses for %q: %w", svc.TailnetTarget.FQDN, err)
 	}
-	if nodeFound {
-		for _, addr := range node.Addresses().AsSlice() {
-			if addr.Addr().Is6() && !ep.nfr.HasIPV6NAT() {
-				log.Printf("tailnet target %v is an IPv6 address, but this host does not support IPv6 in the chosen firewall mode, skipping.", addr.Addr().String())
-				continue
-			}
-			addrs = append(addrs, addr.Addr())
+	if len(egressAddrs) == 0 {
+		log.Printf("tailnet target %q does not have any backend addresses, skipping", svc.TailnetTarget.FQDN)
+		return addrs, nil
+	}
+
+	for _, addr := range egressAddrs {
+		if addr.Addr().Is6() && !ep.nfr.HasIPV6NAT() {
+			log.Printf("tailnet target %v is an IPv6 address, but this host does not support IPv6 in the chosen firewall mode, skipping.", addr.Addr().String())
+			continue
 		}
-		// Egress target endpoints configured via FQDN are stored, so
-		// that we can determine if a netmap update should trigger a
-		// resync.
-		mak.Set(&ep.targetFQDNs, svc.TailnetTarget.FQDN, node.Addresses().AsSlice())
+		addrs = append(addrs, addr.Addr())
 	}
+	// Egress target endpoints configured via FQDN are stored, so
+	// that we can determine if a netmap update should trigger a
+	// resync.
+	mak.Set(&ep.targetFQDNs, svc.TailnetTarget.FQDN, egressAddrs)
 	return addrs, nil
 }
 

+ 70 - 19
cmd/containerboot/main.go

@@ -127,8 +127,10 @@ import (
 	"tailscale.com/kube/services"
 	"tailscale.com/tailcfg"
 	"tailscale.com/types/logger"
+	"tailscale.com/types/netmap"
 	"tailscale.com/types/ptr"
 	"tailscale.com/util/deephash"
+	"tailscale.com/util/dnsname"
 	"tailscale.com/util/linuxfw"
 )
 
@@ -526,27 +528,14 @@ runLoop:
 					}
 				}
 				if cfg.TailnetTargetFQDN != "" {
-					var (
-						egressAddrs          []netip.Prefix
-						newCurentEgressIPs   deephash.Sum
-						egressIPsHaveChanged bool
-						node                 tailcfg.NodeView
-						nodeFound            bool
-					)
-					for _, n := range n.NetMap.Peers {
-						if strings.EqualFold(n.Name(), cfg.TailnetTargetFQDN) {
-							node = n
-							nodeFound = true
-							break
-						}
-					}
-					if !nodeFound {
-						log.Printf("Tailscale node %q not found; it either does not exist, or not reachable because of ACLs", cfg.TailnetTargetFQDN)
+					egressAddrs, err := resolveTailnetFQDN(n.NetMap, cfg.TailnetTargetFQDN)
+					if err != nil {
+						log.Print(err.Error())
 						break
 					}
-					egressAddrs = node.Addresses().AsSlice()
-					newCurentEgressIPs = deephash.Hash(&egressAddrs)
-					egressIPsHaveChanged = newCurentEgressIPs != currentEgressIPs
+
+					newCurentEgressIPs := deephash.Hash(&egressAddrs)
+					egressIPsHaveChanged := newCurentEgressIPs != currentEgressIPs
 					// The firewall rules get (re-)installed:
 					// - on startup
 					// - when the tailnet IPs of the tailnet target have changed
@@ -892,3 +881,65 @@ func runHTTPServer(mux *http.ServeMux, addr string) (close func() error) {
 		return errors.Join(err, ln.Close())
 	}
 }
+
+// resolveTailnetFQDN resolves a tailnet FQDN to a list of IP prefixes, which
+// can be either a peer device or a Tailscale Service.
+func resolveTailnetFQDN(nm *netmap.NetworkMap, fqdn string) ([]netip.Prefix, error) {
+	dnsFQDN, err := dnsname.ToFQDN(fqdn)
+	if err != nil {
+		return nil, fmt.Errorf("error parsing %q as FQDN: %w", fqdn, err)
+	}
+
+	// Check all peer devices first.
+	for _, p := range nm.Peers {
+		if strings.EqualFold(p.Name(), dnsFQDN.WithTrailingDot()) {
+			return p.Addresses().AsSlice(), nil
+		}
+	}
+
+	// If not found yet, check for a matching Tailscale Service.
+	if svcIPs := serviceIPsFromNetMap(nm, dnsFQDN); len(svcIPs) != 0 {
+		return svcIPs, nil
+	}
+
+	return nil, fmt.Errorf("could not find Tailscale node or service %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
+// found in the netmap. Note that Tailscale Services are not a first-class
+// object in the netmap, so we guess based on DNS ExtraRecords and AllowedIPs.
+func serviceIPsFromNetMap(nm *netmap.NetworkMap, fqdn dnsname.FQDN) []netip.Prefix {
+	var extraRecords []tailcfg.DNSRecord
+	for _, rec := range nm.DNS.ExtraRecords {
+		recFQDN, err := dnsname.ToFQDN(rec.Name)
+		if err != nil {
+			continue
+		}
+		if strings.EqualFold(fqdn.WithTrailingDot(), recFQDN.WithTrailingDot()) {
+			extraRecords = append(extraRecords, rec)
+		}
+	}
+
+	if len(extraRecords) == 0 {
+		return nil
+	}
+
+	// Validate we can see a peer advertising the Tailscale Service.
+	var prefixes []netip.Prefix
+	for _, extraRecord := range extraRecords {
+		ip, err := netip.ParseAddr(extraRecord.Value)
+		if err != nil {
+			continue
+		}
+		ipPrefix := netip.PrefixFrom(ip, ip.BitLen())
+		for _, ps := range nm.Peers {
+			for _, allowedIP := range ps.AllowedIPs().All() {
+				if allowedIP == ipPrefix {
+					prefixes = append(prefixes, ipPrefix)
+				}
+			}
+		}
+	}
+
+	return prefixes
+}

+ 36 - 14
cmd/containerboot/main_test.go

@@ -46,7 +46,7 @@ func TestContainerBoot(t *testing.T) {
 	if err := exec.Command("go", "build", "-ldflags", "-X main.testSleepDuration=1ms", "-o", boot, "tailscale.com/cmd/containerboot").Run(); err != nil {
 		t.Fatalf("Building containerboot: %v", err)
 	}
-	egressStatus := egressSvcStatus("foo", "foo.tailnetxyz.ts.net")
+	egressStatus := egressSvcStatus("foo", "foo.tailnetxyz.ts.net", "100.64.0.2")
 
 	metricsURL := func(port int) string {
 		return fmt.Sprintf("http://127.0.0.1:%d/metrics", port)
@@ -99,7 +99,7 @@ func TestContainerBoot(t *testing.T) {
 		NetMap: &netmap.NetworkMap{
 			SelfNode: (&tailcfg.Node{
 				StableID:  tailcfg.StableNodeID("myID"),
-				Name:      "test-node.test.ts.net",
+				Name:      "test-node.test.ts.net.",
 				Addresses: []netip.Prefix{netip.MustParsePrefix("100.64.0.1/32")},
 			}).View(),
 		},
@@ -356,7 +356,7 @@ func TestContainerBoot(t *testing.T) {
 			return testCase{
 				Env: map[string]string{
 					"TS_AUTHKEY":               "tskey-key",
-					"TS_TAILNET_TARGET_FQDN":   "ipv6-node.test.ts.net", // resolves to IPv6 address
+					"TS_TAILNET_TARGET_FQDN":   "ipv6-node.test.ts.net.", // resolves to IPv6 address
 					"TS_USERSPACE":             "false",
 					"TS_TEST_FAKE_NETFILTER_6": "false",
 				},
@@ -377,13 +377,13 @@ func TestContainerBoot(t *testing.T) {
 							NetMap: &netmap.NetworkMap{
 								SelfNode: (&tailcfg.Node{
 									StableID:  tailcfg.StableNodeID("myID"),
-									Name:      "test-node.test.ts.net",
+									Name:      "test-node.test.ts.net.",
 									Addresses: []netip.Prefix{netip.MustParsePrefix("100.64.0.1/32")},
 								}).View(),
 								Peers: []tailcfg.NodeView{
 									(&tailcfg.Node{
 										StableID:  tailcfg.StableNodeID("ipv6ID"),
-										Name:      "ipv6-node.test.ts.net",
+										Name:      "ipv6-node.test.ts.net.",
 										Addresses: []netip.Prefix{netip.MustParsePrefix("::1/128")},
 									}).View(),
 								},
@@ -481,7 +481,7 @@ func TestContainerBoot(t *testing.T) {
 						Notify: runningNotify,
 						WantKubeSecret: map[string]string{
 							"authkey":           "tskey-key",
-							"device_fqdn":       "test-node.test.ts.net",
+							"device_fqdn":       "test-node.test.ts.net.",
 							"device_id":         "myID",
 							"device_ips":        `["100.64.0.1"]`,
 							kubetypes.KeyCapVer: capver,
@@ -580,7 +580,7 @@ func TestContainerBoot(t *testing.T) {
 							"/usr/bin/tailscale --socket=/tmp/tailscaled.sock set --accept-dns=false",
 						},
 						WantKubeSecret: map[string]string{
-							"device_fqdn":       "test-node.test.ts.net",
+							"device_fqdn":       "test-node.test.ts.net.",
 							"device_id":         "myID",
 							"device_ips":        `["100.64.0.1"]`,
 							kubetypes.KeyCapVer: capver,
@@ -613,7 +613,7 @@ func TestContainerBoot(t *testing.T) {
 						Notify: runningNotify,
 						WantKubeSecret: map[string]string{
 							"authkey":           "tskey-key",
-							"device_fqdn":       "test-node.test.ts.net",
+							"device_fqdn":       "test-node.test.ts.net.",
 							"device_id":         "myID",
 							"device_ips":        `["100.64.0.1"]`,
 							kubetypes.KeyCapVer: capver,
@@ -625,14 +625,14 @@ func TestContainerBoot(t *testing.T) {
 							NetMap: &netmap.NetworkMap{
 								SelfNode: (&tailcfg.Node{
 									StableID:  tailcfg.StableNodeID("newID"),
-									Name:      "new-name.test.ts.net",
+									Name:      "new-name.test.ts.net.",
 									Addresses: []netip.Prefix{netip.MustParsePrefix("100.64.0.1/32")},
 								}).View(),
 							},
 						},
 						WantKubeSecret: map[string]string{
 							"authkey":           "tskey-key",
-							"device_fqdn":       "new-name.test.ts.net",
+							"device_fqdn":       "new-name.test.ts.net.",
 							"device_id":         "newID",
 							"device_ips":        `["100.64.0.1"]`,
 							kubetypes.KeyCapVer: capver,
@@ -927,7 +927,7 @@ func TestContainerBoot(t *testing.T) {
 						Notify: runningNotify,
 						WantKubeSecret: map[string]string{
 							"authkey":           "tskey-key",
-							"device_fqdn":       "test-node.test.ts.net",
+							"device_fqdn":       "test-node.test.ts.net.",
 							"device_id":         "myID",
 							"device_ips":        `["100.64.0.1"]`,
 							"https_endpoint":    "no-https",
@@ -963,11 +963,27 @@ func TestContainerBoot(t *testing.T) {
 						},
 					},
 					{
-						Notify: runningNotify,
+						Notify: &ipn.Notify{
+							State: ptr.To(ipn.Running),
+							NetMap: &netmap.NetworkMap{
+								SelfNode: (&tailcfg.Node{
+									StableID:  tailcfg.StableNodeID("myID"),
+									Name:      "test-node.test.ts.net.",
+									Addresses: []netip.Prefix{netip.MustParsePrefix("100.64.0.1/32")},
+								}).View(),
+								Peers: []tailcfg.NodeView{
+									(&tailcfg.Node{
+										StableID:  tailcfg.StableNodeID("fooID"),
+										Name:      "foo.tailnetxyz.ts.net.",
+										Addresses: []netip.Prefix{netip.MustParsePrefix("100.64.0.2/32")},
+									}).View(),
+								},
+							},
+						},
 						WantKubeSecret: map[string]string{
 							"egress-services":   string(mustJSON(t, egressStatus)),
 							"authkey":           "tskey-key",
-							"device_fqdn":       "test-node.test.ts.net",
+							"device_fqdn":       "test-node.test.ts.net.",
 							"device_id":         "myID",
 							"device_ips":        `["100.64.0.1"]`,
 							kubetypes.KeyCapVer: capver,
@@ -1338,6 +1354,11 @@ func (lc *localAPI) ServeHTTP(w http.ResponseWriter, r *http.Request) {
 		}
 		w.Write([]byte("fake metrics"))
 		return
+	case "/localapi/v0/prefs":
+		if r.Method != "GET" {
+			panic(fmt.Sprintf("unsupported method %q", r.Method))
+		}
+		return
 	default:
 		panic(fmt.Sprintf("unsupported path %q", r.URL.Path))
 	}
@@ -1563,13 +1584,14 @@ func mustJSON(t *testing.T, v any) []byte {
 }
 
 // egress services status given one named tailnet target specified by FQDN. As written by the proxy to its state Secret.
-func egressSvcStatus(name, fqdn string) egressservices.Status {
+func egressSvcStatus(name, fqdn, ip string) egressservices.Status {
 	return egressservices.Status{
 		Services: map[string]*egressservices.ServiceStatus{
 			name: {
 				TailnetTarget: egressservices.TailnetTarget{
 					FQDN: fqdn,
 				},
+				TailnetTargetIPs: []netip.Addr{netip.MustParseAddr(ip)},
 			},
 		},
 	}

+ 2 - 2
cmd/tailscale/cli/configure-kube.go

@@ -247,7 +247,7 @@ func nodeOrServiceDNSNameFromArg(st *ipnstate.Status, nm *netmap.NetworkMap, arg
 	}
 
 	// If not found, check for a Tailscale Service DNS name.
-	rec, ok := serviceDNSRecordFromNetMap(nm, st.CurrentTailnet.MagicDNSSuffix, arg)
+	rec, ok := serviceDNSRecordFromNetMap(nm, arg)
 	if !ok {
 		return "", fmt.Errorf("no peer found for %q", arg)
 	}
@@ -287,7 +287,7 @@ func getNetMap(ctx context.Context) (*netmap.NetworkMap, error) {
 	return n.NetMap, nil
 }
 
-func serviceDNSRecordFromNetMap(nm *netmap.NetworkMap, tcd, arg string) (rec tailcfg.DNSRecord, ok bool) {
+func serviceDNSRecordFromNetMap(nm *netmap.NetworkMap, arg string) (rec tailcfg.DNSRecord, ok bool) {
 	argIP, _ := netip.ParseAddr(arg)
 	argFQDN, err := dnsname.ToFQDN(arg)
 	argFQDNValid := err == nil