Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 35 additions & 5 deletions internal/ateomnet/net.go
Original file line number Diff line number Diff line change
Expand Up @@ -192,8 +192,11 @@ func PodIPv4() (net.IP, error) {
return nil, fmt.Errorf("pod eth0 has no IPv4 address")
}

// EnableIPv4Forwarding enables IPv4 forwarding in the current network namespace.
func EnableIPv4Forwarding() error {
// EnableForwarding enables IPv4 and IPv6 forwarding in the current network
// namespace, so actor traffic (including DNS queries on IPv6-capable clusters)
// is routed between the veth and eth0 instead of being dropped by ip_forward()
// or ip6_forward().
func EnableForwarding() error {
// Forwarding is required because actor packets now enter the worker pod via
// the host-side veth and then leave through the pod's eth0. Without this, the
// kernel would not route traffic between those interfaces even though both
Expand All @@ -203,20 +206,47 @@ func EnableIPv4Forwarding() error {
// The worker holds CAP_SYS_ADMIN and uses no user namespace, so the ro flag
// is not locked: clear it, write the sysctl, restore ro.
const path = "/proc/sys/net/ipv4/ip_forward"
if err := writeSysctlIfUnset(path); err != nil {
return fmt.Errorf("while enabling IPv4 forwarding in worker pod netns: %w", err)
}
// IPv6 forwarding: actor packets that arrive on the veth and leave via eth0
// are IPv6 on dual-stack / IPv6-only clusters. Without
// net.ipv6.conf.all.forwarding the kernel drops every IPv6 packet in
// ip6_forward(), including the actor's DNS queries. conf.all.forwarding=1
// also implies the per-interface default, so a single write covers the veth
// and eth0.
const v6path = "/proc/sys/net/ipv6/conf/all/forwarding"
if err := writeSysctlIfUnset(v6path); err != nil {
return fmt.Errorf("while enabling IPv6 forwarding in worker pod netns: %w", err)
}
return nil
}

// writeSysctlIfUnset writes "1\n" to a sysctl path unless it already reads "1".
// If the path does not exist (e.g. IPv6 sysctls on a kernel with IPv6 disabled),
// it returns nil — IPv6 forwarding is simply unavailable, not an error.
func writeSysctlIfUnset(path string) error {
if b, err := os.ReadFile(path); err == nil && len(b) > 0 && b[0] == '1' {
return nil
}
if err := os.WriteFile(path, []byte("1\n"), 0o644); err == nil {
return nil
}
if _, err := os.Stat(path); os.IsNotExist(err) {
// Path absent (e.g. IPv6 disabled in kernel): nothing to enable.
return nil
}
// Without privileged, the container runtime bind-mounts /proc/sys read-only.
// The worker holds CAP_SYS_ADMIN and uses no user namespace, so the ro flag
// is not locked: clear it, write the sysctl, restore ro.
if err := unix.Mount("none", "/proc/sys", "", unix.MS_BIND|unix.MS_REMOUNT, ""); err != nil {
return fmt.Errorf("while remounting /proc/sys read-write to enable IPv4 forwarding: %w", err)
return fmt.Errorf("while remounting /proc/sys read-write to enable forwarding: %w", err)
}
defer func() {
_ = unix.Mount("none", "/proc/sys", "", unix.MS_BIND|unix.MS_REMOUNT|unix.MS_RDONLY, "")
}()
if err := os.WriteFile(path, []byte("1\n"), 0o644); err != nil {
return fmt.Errorf("while enabling IPv4 forwarding in worker pod netns: %w", err)
return fmt.Errorf("while writing %s: %w", path, err)
}
return nil
}
Expand Down Expand Up @@ -565,7 +595,7 @@ func SetupActorNetwork(ctx context.Context, cfg NetworkConfig) (retErr error) {
return fmt.Errorf("while configuring actor veth in interior netns: %w", err)
}

if err := EnableIPv4Forwarding(); err != nil {
if err := EnableForwarding(); err != nil {
return err
}
if err := InstallActorNftablesRules(cfg.EgressRedirectPort); err != nil {
Expand Down
99 changes: 99 additions & 0 deletions internal/ateomnet/write_sysctl_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
//go:build linux

// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package ateomnet

import (
"os"
"path/filepath"
"testing"
)

// TestWriteSysctlIfUnset verifies writeSysctlIfUnset's fast paths against a
// temp file standing in for a /proc/sys node: it must not rewrite a value
// that already reads "1", and it must write "1\n" when the value is missing
// or unset. The privileged bind-remount path is covered by the netns
// integration tests (withTestNetNS), which require root.
func TestWriteSysctlIfUnset(t *testing.T) {
dir := t.TempDir()

t.Run("already_set", func(t *testing.T) {
p := filepath.Join(dir, "already")
// Sentinel content: if writeSysctlIfUnset rewrote the file, the value
// would change to "1\n" and this assertion would fail. Keeping the
// file larger than the helper's output makes a silent rewrite
// detectable.
if err := os.WriteFile(p, []byte("1 other-content\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := writeSysctlIfUnset(p); err != nil {
t.Fatalf("writeSysctlIfUnset: %v", err)
}
b, err := os.ReadFile(p)
if err != nil {
t.Fatal(err)
}
if string(b) != "1 other-content\n" {
t.Fatalf("already-set file was rewritten: %q", b)
}
})

t.Run("unset_written", func(t *testing.T) {
p := filepath.Join(dir, "unset")
if err := writeSysctlIfUnset(p); err != nil {
t.Fatalf("writeSysctlIfUnset: %v", err)
}
b, err := os.ReadFile(p)
if err != nil {
t.Fatal(err)
}
if len(b) < 1 || b[0] != '1' {
t.Fatalf("expected '1' written, got %q", b)
}
})

t.Run("missing_path_is_noop", func(t *testing.T) {
// A node under a directory that does not exist stands in for
// /proc/sys/net/ipv6/... on a kernel with IPv6 disabled. The other
// subtests' paths can be created, so they return at the os.WriteFile
// fast path; this is the only one that reaches the os.Stat branch,
// which is what procfs always does in production.
p := filepath.Join(dir, "no-such-dir", "forwarding")
if err := writeSysctlIfUnset(p); err != nil {
t.Fatalf("writeSysctlIfUnset on a missing path: %v", err)
}
if _, err := os.Stat(p); !os.IsNotExist(err) {
t.Fatalf("expected %s to stay absent, stat err = %v", p, err)
}
})

t.Run("zero_is_rewritten", func(t *testing.T) {
p := filepath.Join(dir, "zero")
if err := os.WriteFile(p, []byte("0\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := writeSysctlIfUnset(p); err != nil {
t.Fatalf("writeSysctlIfUnset: %v", err)
}
b, err := os.ReadFile(p)
if err != nil {
t.Fatal(err)
}
if len(b) < 1 || b[0] != '1' {
t.Fatalf("expected '1' written, got %q", b)
}
})
}