Browse Source

cmd/containerboot,cmd/k8s-operator: enable IPv6 for fqdn egress proxies (#12577)

cmd/containerboot,cmd/k8s-operator: enable IPv6 for fqdn egress proxies

Don't skip installing egress forwarding rules for IPv6 (as long as the host
supports IPv6), and set headless services `ipFamilyPolicy` to
`PreferDualStack` to optionally enable both IP families when possible. Note
that even with `PreferDualStack` set, testing a dual-stack GKE cluster with
the default DNS setup of kube-dns did not correctly set both A and
AAAA records for the headless service, and instead only did so when
switching the cluster DNS to Cloud DNS. For both IPv4 and IPv6 to work
simultaneously in a dual-stack cluster, we require headless services to
return both A and AAAA records.

If the host doesn't support IPv6 but the FQDN specified only has IPv6
addresses available, containerboot will exit with error code 1 and an
error message because there is no viable egress route.

Fixes #12215

Signed-off-by: Tom Proctor <[email protected]>
Tom Proctor 1 year ago
parent
commit
01a7726cf7

+ 11 - 9
cmd/containerboot/main.go

@@ -476,18 +476,20 @@ runLoop:
 					newCurentEgressIPs = deephash.Hash(&egressAddrs)
 					egressIPsHaveChanged = newCurentEgressIPs != currentEgressIPs
 					if egressIPsHaveChanged && len(egressAddrs) != 0 {
+						var rulesInstalled bool
 						for _, egressAddr := range egressAddrs {
 							ea := egressAddr.Addr()
-							// TODO (irbekrm): make it work for IPv6 too.
-							if ea.Is6() {
-								log.Println("Not installing egress forwarding rules for IPv6 as this is currently not supported")
-								continue
-							}
-							log.Printf("Installing forwarding rules for destination %v", ea.String())
-							if err := installEgressForwardingRule(ctx, ea.String(), addrs, nfr); err != nil {
-								log.Fatalf("installing egress proxy rules for destination %s: %v", ea.String(), err)
+							if ea.Is4() || (ea.Is6() && nfr.HasIPV6NAT()) {
+								rulesInstalled = true
+								log.Printf("Installing forwarding rules for destination %v", ea.String())
+								if err := installEgressForwardingRule(ctx, ea.String(), addrs, nfr); err != nil {
+									log.Fatalf("installing egress proxy rules for destination %s: %v", ea.String(), err)
+								}
 							}
 						}
+						if !rulesInstalled {
+							log.Fatalf("no forwarding rules for egress addresses %v, host supports IPv6: %v", egressAddrs, nfr.HasIPV6NAT())
+						}
 					}
 					currentEgressIPs = newCurentEgressIPs
 				}
@@ -941,7 +943,7 @@ func enableIPForwarding(v4Forwarding, v6Forwarding bool, root string) error {
 	return nil
 }
 
-func installEgressForwardingRule(ctx context.Context, dstStr string, tsIPs []netip.Prefix, nfr linuxfw.NetfilterRunner) error {
+func installEgressForwardingRule(_ context.Context, dstStr string, tsIPs []netip.Prefix, nfr linuxfw.NetfilterRunner) error {
 	dst, err := netip.ParseAddr(dstStr)
 	if err != nil {
 		return err

+ 68 - 1
cmd/containerboot/main_test.go

@@ -52,7 +52,7 @@ func TestContainerBoot(t *testing.T) {
 	}
 	defer kube.Close()
 
-	tailscaledConf := &ipn.ConfigVAlpha{AuthKey: func(s string) *string { return &s }("foo"), Version: "alpha0"}
+	tailscaledConf := &ipn.ConfigVAlpha{AuthKey: ptr.To("foo"), Version: "alpha0"}
 	tailscaledConfBytes, err := json.Marshal(tailscaledConf)
 	if err != nil {
 		t.Fatalf("error unmarshaling tailscaled config: %v", err)
@@ -116,6 +116,9 @@ func TestContainerBoot(t *testing.T) {
 		// WantFiles files that should exist in the container and their
 		// contents.
 		WantFiles map[string]string
+		// WantFatalLog is the fatal log message we expect from containerboot.
+		// If set for a phase, the test will finish on that phase.
+		WantFatalLog string
 	}
 	runningNotify := &ipn.Notify{
 		State: ptr.To(ipn.Running),
@@ -349,12 +352,57 @@ func TestContainerBoot(t *testing.T) {
 						"/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=mem: --statedir=/tmp",
 						"/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false --authkey=tskey-key",
 					},
+					WantFiles: map[string]string{
+						"proc/sys/net/ipv4/ip_forward":          "1",
+						"proc/sys/net/ipv6/conf/all/forwarding": "0",
+					},
 				},
 				{
 					Notify: runningNotify,
 				},
 			},
 		},
+		{
+			Name: "egress_proxy_fqdn_ipv6_target_on_ipv4_host",
+			Env: map[string]string{
+				"TS_AUTHKEY":               "tskey-key",
+				"TS_TAILNET_TARGET_FQDN":   "ipv6-node.test.ts.net", // resolves to IPv6 address
+				"TS_USERSPACE":             "false",
+				"TS_TEST_FAKE_NETFILTER_6": "false",
+			},
+			Phases: []phase{
+				{
+					WantCmds: []string{
+						"/usr/bin/tailscaled --socket=/tmp/tailscaled.sock --state=mem: --statedir=/tmp",
+						"/usr/bin/tailscale --socket=/tmp/tailscaled.sock up --accept-dns=false --authkey=tskey-key",
+					},
+					WantFiles: map[string]string{
+						"proc/sys/net/ipv4/ip_forward":          "1",
+						"proc/sys/net/ipv6/conf/all/forwarding": "0",
+					},
+				},
+				{
+					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("ipv6ID"),
+									Name:      "ipv6-node.test.ts.net",
+									Addresses: []netip.Prefix{netip.MustParsePrefix("::1/128")},
+								}).View(),
+							},
+						},
+					},
+					WantFatalLog: "no forwarding rules for egress addresses [::1/128], host supports IPv6: false",
+				},
+			},
+		},
 		{
 			Name: "authkey_once",
 			Env: map[string]string{
@@ -697,6 +745,25 @@ func TestContainerBoot(t *testing.T) {
 			var wantCmds []string
 			for i, p := range test.Phases {
 				lapi.Notify(p.Notify)
+				if p.WantFatalLog != "" {
+					err := tstest.WaitFor(2*time.Second, func() error {
+						state, err := cmd.Process.Wait()
+						if err != nil {
+							return err
+						}
+						if state.ExitCode() != 1 {
+							return fmt.Errorf("process exited with code %d but wanted %d", state.ExitCode(), 1)
+						}
+						waitLogLine(t, time.Second, cbOut, p.WantFatalLog)
+						return nil
+					})
+					if err != nil {
+						t.Fatal(err)
+					}
+
+					// Early test return, we don't expect the successful startup log message.
+					return
+				}
 				wantCmds = append(wantCmds, p.WantCmds...)
 				waitArgs(t, 2*time.Second, d, argFile, strings.Join(wantCmds, "\n"))
 				err := tstest.WaitFor(2*time.Second, func() error {

+ 1 - 0
cmd/k8s-operator/sts.go

@@ -294,6 +294,7 @@ func (a *tailscaleSTSReconciler) reconcileHeadlessService(ctx context.Context, l
 			Selector: map[string]string{
 				"app": sts.ParentResourceUID,
 			},
+			IPFamilyPolicy: ptr.To(corev1.IPFamilyPolicyPreferDualStack),
 		},
 	}
 	logger.Debugf("reconciling headless service for StatefulSet")

+ 2 - 1
cmd/k8s-operator/testutils_test.go

@@ -319,7 +319,8 @@ func expectedHeadlessService(name string, parentType string) *corev1.Service {
 			Selector: map[string]string{
 				"app": "1234-UID",
 			},
-			ClusterIP: "None",
+			ClusterIP:      "None",
+			IPFamilyPolicy: ptr.To(corev1.IPFamilyPolicyPreferDualStack),
 		},
 	}
 }

+ 9 - 2
util/linuxfw/fake.go

@@ -8,6 +8,8 @@ package linuxfw
 import (
 	"errors"
 	"fmt"
+	"os"
+	"strconv"
 	"strings"
 )
 
@@ -128,8 +130,13 @@ func (n *fakeIPTables) DeleteChain(table, chain string) error {
 
 func NewFakeIPTablesRunner() *iptablesRunner {
 	ipt4 := newFakeIPTables()
-	ipt6 := newFakeIPTables()
+	v6Available := false
+	var ipt6 iptablesInterface
+	if use6, err := strconv.ParseBool(os.Getenv("TS_TEST_FAKE_NETFILTER_6")); use6 || err != nil {
+		ipt6 = newFakeIPTables()
+		v6Available = true
+	}
 
-	iptr := &iptablesRunner{ipt4, ipt6, true, true, true}
+	iptr := &iptablesRunner{ipt4, ipt6, v6Available, v6Available, v6Available}
 	return iptr
 }