diff --git a/cmd/ateom-gvisor/main.go b/cmd/ateom-gvisor/main.go index 0620c09e3f..2157363afd 100644 --- a/cmd/ateom-gvisor/main.go +++ b/cmd/ateom-gvisor/main.go @@ -65,8 +65,10 @@ var ( podUID = pflag.String("pod-uid", "", "The UID of the current pod") // TODO(liorlieberman) have a sub package for all atunnel releated things like that - atunnelListenAddress = pflag.String("atunnel-listen-address", "0.0.0.0:443", "Address for actor ingress HTTPS") - atunnelConnectListenAddress = pflag.String("atunnel-connect-listen-address", "0.0.0.0:444", "Address for actor ingress mTLS CONNECT") + // + // Ingress is dual-stack; egress is pinned to IPv4 by atunnel.ListenEgressIPv4. + atunnelListenAddress = pflag.String("atunnel-listen-address", ":443", "Address for actor ingress HTTPS") + atunnelConnectListenAddress = pflag.String("atunnel-connect-listen-address", ":444", "Address for actor ingress mTLS CONNECT") workerCredentialBundle = pflag.String("atunnel-credential-bundle", "/run/podidentity.podcert.ate.dev/credential-bundle.pem", "Worker Pod credential bundle used by atunnel for inbound serving and outbound mTLS") podIdentityTrustBundle = pflag.String("atunnel-trust-bundle", "/run/podidentity.podcert.ate.dev/trust-bundle.pem", "Pod identity trust bundle used for router clients and the node-local atelet") atunnelClientIdentity = pflag.String("atunnel-client-identity", "spiffe://cluster.local/ns/ate-system/sa/atenet-router", "SPIFFE identity allowed to call actor ingress HTTPS") @@ -277,7 +279,7 @@ func runAtunnel(ctx context.Context, upstream *url.URL) (*atunnel.Server, *atunn if err != nil { return nil, nil, 0, fmt.Errorf("while configuring atunnel egress: %w", err) } - egressListener, err := net.Listen("tcp", *atunnelEgressListenAddress) + egressListener, err := atunnel.ListenEgressIPv4(*atunnelEgressListenAddress) if err != nil { return nil, nil, 0, fmt.Errorf("while opening atunnel egress listener: %w", err) } diff --git a/cmd/ateom-microvm/main.go b/cmd/ateom-microvm/main.go index 6613fcd9b3..67f17c5723 100644 --- a/cmd/ateom-microvm/main.go +++ b/cmd/ateom-microvm/main.go @@ -71,8 +71,9 @@ var ( otlpRelaySocket = flag.String("otlp-relay-socket", ateompath.AteletOTLPSocketPath(), "Unix socket of atelet's OTLP relay to export telemetry through, keeping it off the pod network. Empty, or absent at startup, exports directly to OTEL_EXPORTER_OTLP_ENDPOINT instead.") - atunnelListenAddress = flag.String("atunnel-listen-address", "0.0.0.0:443", "Address for actor ingress HTTPS") - atunnelConnectListenAddress = flag.String("atunnel-connect-listen-address", "0.0.0.0:444", "Address for actor ingress mTLS CONNECT") + // Ingress is dual-stack; egress is pinned to IPv4 by atunnel.ListenEgressIPv4. + atunnelListenAddress = flag.String("atunnel-listen-address", ":443", "Address for actor ingress HTTPS") + atunnelConnectListenAddress = flag.String("atunnel-connect-listen-address", ":444", "Address for actor ingress mTLS CONNECT") workerCredentialBundle = flag.String("atunnel-credential-bundle", "/run/podidentity.podcert.ate.dev/credential-bundle.pem", "Worker Pod credential bundle used by atunnel for inbound serving and outbound mTLS") podIdentityTrustBundle = flag.String("atunnel-trust-bundle", "/run/podidentity.podcert.ate.dev/trust-bundle.pem", "Pod identity trust bundle used for router clients and the node-local atelet") atunnelClientIdentity = flag.String("atunnel-client-identity", "spiffe://cluster.local/ns/ate-system/sa/atenet-router", "SPIFFE identity allowed to call actor ingress HTTPS") @@ -236,7 +237,7 @@ func do(ctx context.Context) error { if err != nil { return fmt.Errorf("while configuring atunnel egress: %w", err) } - egressListener, err := net.Listen("tcp", *atunnelEgressListenAddress) + egressListener, err := atunnel.ListenEgressIPv4(*atunnelEgressListenAddress) if err != nil { return fmt.Errorf("while opening atunnel egress listener: %w", err) } diff --git a/internal/ateomnet/net.go b/internal/ateomnet/net.go index 91203a8e04..6c27f98168 100644 --- a/internal/ateomnet/net.go +++ b/internal/ateomnet/net.go @@ -229,8 +229,10 @@ func InstallActorNftablesRules(egressPort uint16) error { // rules in an ateom-owned table makes cleanup simple and avoids mutating // Kubernetes or CNI-managed chains directly. // - // TODO: Add IPv6 veth addressing, forwarding, and nftables rules once actor - // networking supports dual-stack pods. The current actor network is IPv4-only. + // TODO(#945): Add IPv6 veth addressing, forwarding, and nftables rules once + // actor networking supports dual-stack pods. The current actor network is + // IPv4-only, which is why atunnel opens the listener these rules redirect to + // with ListenEgressIPv4. // // The rules do three things: // diff --git a/internal/atunnel/egress.go b/internal/atunnel/egress.go index c42899c019..d0b7b5574d 100644 --- a/internal/atunnel/egress.go +++ b/internal/atunnel/egress.go @@ -42,6 +42,22 @@ type actorCertificateSource interface { // connection originally targeted. type OriginalDestination func(net.Conn) (string, error) +// ListenEgressIPv4 opens the transparent egress listener on address. The family +// is in the name because it cannot be left to the address: Go treats every +// unspecified address as a wildcard, so net.Listen("tcp", "0.0.0.0:15001") binds +// "::" dual-stack and accepts IPv6 too. TCPOriginalDestination reads only the +// IPv4 SOL_IP/SO_ORIGINAL_DST, so such a connection is accepted and then fails. +// +// TODO(#945): use "tcp" once actor networking is dual-stack; the IPv6 +// original-destination lookup is #686. +func ListenEgressIPv4(address string) (net.Listener, error) { + listener, err := net.Listen("tcp4", address) + if err != nil { + return nil, fmt.Errorf("atunnel: egress listener is IPv4-only: %w", err) + } + return listener, nil +} + // Egress proxies actor TCP connections through an egress CONNECT dialer. It is // long-lived across actor activations, but only carries traffic while an actor // is assigned to its worker. diff --git a/internal/atunnel/egress_test.go b/internal/atunnel/egress_test.go index 18440ec5f2..3bef42ca32 100644 --- a/internal/atunnel/egress_test.go +++ b/internal/atunnel/egress_test.go @@ -22,6 +22,7 @@ import ( "io" "net" "net/http" + "strconv" "strings" "sync/atomic" "testing" @@ -31,6 +32,53 @@ import ( "google.golang.org/grpc/status" ) +// TestListenEgressIPv4Only pins the egress listener to IPv4, because +// TCPOriginalDestination can only recover an IPv4 original destination. The +// wildcard address does not carry that constraint. Every listener here uses the +// same address, "0.0.0.0:0", so the network string is the only variable. +func TestListenEgressIPv4Only(t *testing.T) { + // The negative control comes first because it decides whether the rest means + // anything: "tcp" and "tcp4" differ only on a host that binds "tcp" + // dual-stack, and only there can this test observe the bug it guards. + control, err := net.Listen("tcp", "0.0.0.0:0") + if err != nil { + t.Fatalf("net.Listen(%q, \"0.0.0.0:0\") = %v", "tcp", err) + } + t.Cleanup(func() { control.Close() }) + if controlAddr := control.Addr().(*net.TCPAddr); controlAddr.IP.To4() != nil { + t.Skipf("net.Listen(%q, \"0.0.0.0:0\") bound IPv4 %v, so this host cannot "+ + "distinguish it from %q", "tcp", controlAddr, "tcp4") + } + + listener, err := ListenEgressIPv4("0.0.0.0:0") + if err != nil { + t.Fatalf("ListenEgressIPv4() = %v", err) + } + t.Cleanup(func() { listener.Close() }) + + addr, ok := listener.Addr().(*net.TCPAddr) + if !ok { + t.Fatalf("ListenEgressIPv4() bound %T, want *net.TCPAddr", listener.Addr()) + } + if addr.IP.To4() == nil { + t.Fatalf("ListenEgressIPv4() bound %v, want an IPv4 address; net.Listen(%q, ...) "+ + "bound %v on the same address, and an IPv6 client would be accepted and then "+ + "fail TCPOriginalDestination", addr, "tcp", control.Addr()) + } + + // What the pin is for: there is no IPv6 socket, so an IPv6 client is refused + // at connect rather than accepted and then failed. + t.Run("an IPv6 client is refused", func(t *testing.T) { + port := strconv.Itoa(addr.Port) + conn, err := net.DialTimeout("tcp6", net.JoinHostPort("::1", port), 5*time.Second) + if err == nil { + conn.Close() + t.Errorf("net.Dial(%q, \"[::1]:%s\") succeeded, want refused; the egress listener "+ + "is accepting IPv6 it cannot resolve an original destination for", "tcp6", port) + } + }) +} + func TestEgressActivationFailsClosed(t *testing.T) { egress, err := NewEgress(func(net.Conn) (string, error) { return "", nil }) if err != nil { diff --git a/internal/atunnel/original_dst_linux.go b/internal/atunnel/original_dst_linux.go index 07dd0f9344..01326caa17 100644 --- a/internal/atunnel/original_dst_linux.go +++ b/internal/atunnel/original_dst_linux.go @@ -27,9 +27,9 @@ import ( ) // TCPOriginalDestination reads the IPv4 destination preserved by a Linux -// REDIRECT rule. Actor networking is currently IPv4-only. -// TODO(liorlieberman) add the IPv6 IP6T_SO_ORIGINAL_DST variant -// when actor veth setup gains dual-stack support. +// REDIRECT rule. Actor networking is currently IPv4-only; see ListenEgressIPv4. +// TODO(#686): add the IPv6 IP6T_SO_ORIGINAL_DST variant when actor veth setup +// gains dual-stack support. func TCPOriginalDestination(conn net.Conn) (string, error) { tcpConn, ok := conn.(*net.TCPConn) if !ok {