From e4ef05ce30c6beb4b8008c898d5de43a75ab7afc Mon Sep 17 00:00:00 2001 From: Scot Wells Date: Sat, 12 Sep 2026 23:05:32 -0500 Subject: [PATCH] fix(ebpf): Load the uSID datapath with galactic-cni's capabilities The GRO-merged length check stepped a packet pointer past the inner IPv4 header by its IHL-derived length. The verifier only allows a variable packet pointer offset for a loader holding CAP_PERFMON. galactic-cni holds BPF, NET_ADMIN, and NET_RAW, so every node rejected the datapath and galactic-cni crash-looped. CI loaded as full root and never saw the rule. The transport header is now read by offset instead, which keeps the per-segment MTU check unchanged for both families. Each datapath now also has a load test that runs with only the capabilities its DaemonSet grants, taken from the manifest, so a program the verifier rejects for that loader fails CI. Co-Authored-By: Claude Opus 5 (1M context) --- .../ebpf/edgeprog/edgedsr_caps_test.go | 43 +++++++ internal/plumbing/ebpf/loadcaps/loadcaps.go | 113 ++++++++++++++++++ .../plumbing/ebpf/loadcaps/loadcaps_test.go | 84 +++++++++++++ .../ebpf/nat66prog/nat66_caps_test.go | 43 +++++++ internal/plumbing/ebpf/prog/usid.c | 22 ++-- internal/plumbing/ebpf/prog/usid_caps_test.go | 43 +++++++ 6 files changed, 339 insertions(+), 9 deletions(-) create mode 100644 internal/plumbing/ebpf/edgeprog/edgedsr_caps_test.go create mode 100644 internal/plumbing/ebpf/loadcaps/loadcaps.go create mode 100644 internal/plumbing/ebpf/loadcaps/loadcaps_test.go create mode 100644 internal/plumbing/ebpf/nat66prog/nat66_caps_test.go create mode 100644 internal/plumbing/ebpf/prog/usid_caps_test.go diff --git a/internal/plumbing/ebpf/edgeprog/edgedsr_caps_test.go b/internal/plumbing/ebpf/edgeprog/edgedsr_caps_test.go new file mode 100644 index 00000000..5cc0b4a6 --- /dev/null +++ b/internal/plumbing/ebpf/edgeprog/edgedsr_caps_test.go @@ -0,0 +1,43 @@ +// Copyright 2026 Datum Cloud, Inc. +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package edgeprog + +import ( + "errors" + "path/filepath" + "testing" + + "github.com/cilium/ebpf" + + "go.datum.net/galactic/internal/plumbing/ebpf/loadcaps" +) + +// TestEdgedsr_LoadsWithEdgedsrContainerCapabilities loads the datapath with only +// the capabilities its galactic-gateway container holds. Every other test here +// loads as full root, which hides verifier rules that depend on +// capabilities. +func TestEdgedsr_LoadsWithEdgedsrContainerCapabilities(t *testing.T) { + requireRoot(t) + + manifest := filepath.Join("..", "..", "..", "..", "config", "galactic-gateway", "base", "daemonset.yaml") + caps, err := loadcaps.ContainerCapabilities(manifest, "galactic-gateway") + if err != nil { + t.Fatal(err) + } + err = loadcaps.Run(caps, func() error { + var objs EdgedsrObjects + if err := LoadEdgedsrObjects(&objs, nil); err != nil { + return err + } + return objs.Close() + }) + var ve *ebpf.VerifierError + if errors.As(err, &ve) { + t.Fatalf("verifier rejected the datapath with capabilities %v:\n%+v", caps, ve) + } + if err != nil { + t.Fatalf("load datapath with capabilities %v: %v", caps, err) + } +} diff --git a/internal/plumbing/ebpf/loadcaps/loadcaps.go b/internal/plumbing/ebpf/loadcaps/loadcaps.go new file mode 100644 index 00000000..c11fb314 --- /dev/null +++ b/internal/plumbing/ebpf/loadcaps/loadcaps.go @@ -0,0 +1,113 @@ +// Copyright 2026 Datum Cloud, Inc. +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +// Package loadcaps lets tests load eBPF programs with the capabilities a +// production container actually holds. +// +// The verifier applies a stricter ruleset to a loader without CAP_PERFMON. For +// example, it rejects adding a variable offset to a packet pointer. A test that +// loads as full root never sees that ruleset, so a program can pass CI and +// still be rejected on every node. The capability lists come from the +// DaemonSet manifests, so a test cannot drift from what a cluster runs. +package loadcaps + +import ( + "errors" + "fmt" + "os" + "runtime" + "slices" + + "golang.org/x/sys/unix" + appsv1 "k8s.io/api/apps/v1" + "sigs.k8s.io/yaml" +) + +var capabilityBits = map[string]int{ + "BPF": unix.CAP_BPF, + "CHOWN": unix.CAP_CHOWN, + "DAC_OVERRIDE": unix.CAP_DAC_OVERRIDE, + "FOWNER": unix.CAP_FOWNER, + "NET_ADMIN": unix.CAP_NET_ADMIN, + "NET_BIND_SERVICE": unix.CAP_NET_BIND_SERVICE, + "NET_RAW": unix.CAP_NET_RAW, + "PERFMON": unix.CAP_PERFMON, + "SYS_ADMIN": unix.CAP_SYS_ADMIN, + "SYS_RESOURCE": unix.CAP_SYS_RESOURCE, +} + +// ContainerCapabilities returns the capabilities that the named container in a +// DaemonSet manifest adds. +// +// It requires the container to drop ALL. Otherwise the runtime's default set +// also applies, and the added list alone would understate what the container +// holds. +func ContainerCapabilities(manifestPath, containerName string) ([]string, error) { + data, err := os.ReadFile(manifestPath) + if err != nil { + return nil, fmt.Errorf("read %s: %w", manifestPath, err) + } + var ds appsv1.DaemonSet + if err := yaml.Unmarshal(data, &ds); err != nil { + return nil, fmt.Errorf("unmarshal %s: %w", manifestPath, err) + } + for _, c := range ds.Spec.Template.Spec.Containers { + if c.Name != containerName { + continue + } + sc := c.SecurityContext + if sc == nil || sc.Capabilities == nil || !slices.Contains(sc.Capabilities.Drop, "ALL") { + return nil, fmt.Errorf("container %q in %s does not drop ALL capabilities", containerName, manifestPath) + } + added := make([]string, 0, len(sc.Capabilities.Add)) + for _, capName := range sc.Capabilities.Add { + added = append(added, string(capName)) + } + return added, nil + } + return nil, fmt.Errorf("no container %q in %s", containerName, manifestPath) +} + +// Run calls fn on an OS thread whose effective and permitted capability sets +// hold exactly caps, and returns fn's error. +// +// Capabilities belong to a thread, so fn must make its bpf() calls from the +// calling goroutine. The thread is never unlocked, which makes the runtime +// discard it when fn returns instead of reusing it with reduced capabilities. +func Run(caps []string, fn func() error) error { + var want [2]unix.CapUserData + for _, name := range caps { + bit, ok := capabilityBits[name] + if !ok { + return fmt.Errorf("unknown capability %q", name) + } + want[bit/32].Effective |= 1 << (bit % 32) + want[bit/32].Permitted |= 1 << (bit % 32) + } + + errc := make(chan error, 1) + go func() { + runtime.LockOSThread() + + hdr := unix.CapUserHeader{Version: unix.LINUX_CAPABILITY_VERSION_3} + if err := unix.Capset(&hdr, &want[0]); err != nil { + errc <- fmt.Errorf("capset %v: %w", caps, err) + return + } + var got [2]unix.CapUserData + hdr = unix.CapUserHeader{Version: unix.LINUX_CAPABILITY_VERSION_3} + if err := unix.Capget(&hdr, &got[0]); err != nil { + errc <- fmt.Errorf("capget: %w", err) + return + } + for i := range got { + if got[i].Effective != want[i].Effective { + errc <- errors.New("thread capabilities do not match the requested set") + return + } + } + errc <- fn() + }() + return <-errc +} diff --git a/internal/plumbing/ebpf/loadcaps/loadcaps_test.go b/internal/plumbing/ebpf/loadcaps/loadcaps_test.go new file mode 100644 index 00000000..318713d9 --- /dev/null +++ b/internal/plumbing/ebpf/loadcaps/loadcaps_test.go @@ -0,0 +1,84 @@ +// Copyright 2026 Datum Cloud, Inc. +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package loadcaps + +import ( + "errors" + "os" + "slices" + "strings" + "testing" + + "github.com/cilium/ebpf" + "github.com/cilium/ebpf/asm" + "github.com/cilium/ebpf/rlimit" +) + +func requireRoot(t *testing.T) { + t.Helper() + if os.Geteuid() != 0 { + t.Skip("test requires root to reduce capabilities from; re-run via sudo") + } + if err := rlimit.RemoveMemlock(); err != nil { + t.Fatalf("rlimit.RemoveMemlock: %v", err) + } +} + +// TestRun_EnforcesUnprivilegedVerifierRules proves the restricted thread +// reaches the verifier's reduced ruleset. Without this control, a kernel or +// runner that ignored the capability drop would let every load test pass +// vacuously. +func TestRun_EnforcesUnprivilegedVerifierRules(t *testing.T) { + requireRoot(t) + + // The two cases differ only by PERFMON, so a rejection can only come from + // that capability. + withoutPerfmon := []string{"BPF", "NET_ADMIN", "NET_RAW"} + tests := []struct { + name string + caps []string + wantReject bool + }{ + {"WithoutPerfmon", withoutPerfmon, true}, + {"WithPerfmon", append(slices.Clone(withoutPerfmon), "PERFMON"), false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := Run(tt.caps, loadVariablePacketOffsetProgram) + var ve *ebpf.VerifierError + switch { + case tt.wantReject && !errors.As(err, &ve): + t.Fatalf("load with %v: got %v, want a verifier rejection", tt.caps, err) + case tt.wantReject && !strings.Contains(strings.Join(ve.Log, "\n"), "prohibited for !root"): + t.Fatalf("load with %v: verifier rejected for another reason:\n%+v", tt.caps, ve) + case !tt.wantReject && err != nil: + t.Fatalf("load with %v: %+v", tt.caps, err) + } + }) + } +} + +// loadVariablePacketOffsetProgram loads a tc program that advances a packet +// pointer by a bounded but variable offset, which only a loader with +// CAP_PERFMON may do. +func loadVariablePacketOffsetProgram() error { + const skbLenOff, skbDataOff = 0, 76 + p, err := ebpf.NewProgram(&ebpf.ProgramSpec{ + Type: ebpf.SchedCLS, + License: "GPL", + Instructions: asm.Instructions{ + asm.LoadMem(asm.R2, asm.R1, skbDataOff, asm.Word), + asm.LoadMem(asm.R3, asm.R1, skbLenOff, asm.Word), + asm.And.Imm(asm.R3, 0x3c), + asm.Add.Reg(asm.R2, asm.R3), + asm.Mov.Imm(asm.R0, 0), + asm.Return(), + }, + }) + if err != nil { + return err + } + return p.Close() +} diff --git a/internal/plumbing/ebpf/nat66prog/nat66_caps_test.go b/internal/plumbing/ebpf/nat66prog/nat66_caps_test.go new file mode 100644 index 00000000..1c1a01a7 --- /dev/null +++ b/internal/plumbing/ebpf/nat66prog/nat66_caps_test.go @@ -0,0 +1,43 @@ +// Copyright 2026 Datum Cloud, Inc. +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package nat66prog + +import ( + "errors" + "path/filepath" + "testing" + + "github.com/cilium/ebpf" + + "go.datum.net/galactic/internal/plumbing/ebpf/loadcaps" +) + +// TestNat66_LoadsWithNat66ContainerCapabilities loads the datapath with only +// the capabilities its galactic-nat66 container holds. Every other test here +// loads as full root, which hides verifier rules that depend on +// capabilities. +func TestNat66_LoadsWithNat66ContainerCapabilities(t *testing.T) { + requireRoot(t) + + manifest := filepath.Join("..", "..", "..", "..", "config", "galactic-nat66", "base", "daemonset.yaml") + caps, err := loadcaps.ContainerCapabilities(manifest, "galactic-nat66") + if err != nil { + t.Fatal(err) + } + err = loadcaps.Run(caps, func() error { + var objs Nat66Objects + if err := LoadNat66Objects(&objs, nil); err != nil { + return err + } + return objs.Close() + }) + var ve *ebpf.VerifierError + if errors.As(err, &ve) { + t.Fatalf("verifier rejected the datapath with capabilities %v:\n%+v", caps, ve) + } + if err != nil { + t.Fatalf("load datapath with capabilities %v: %v", caps, err) + } +} diff --git a/internal/plumbing/ebpf/prog/usid.c b/internal/plumbing/ebpf/prog/usid.c index 7be90af9..bd507c5d 100644 --- a/internal/plumbing/ebpf/prog/usid.c +++ b/internal/plumbing/ebpf/prog/usid.c @@ -123,6 +123,8 @@ static __u64 (*bpf_ktime_get_ns)(void) = (void *) BPF_FUNC_ktime_get_ns; // approach. static long (*bpf_l4_csum_replace)(struct __sk_buff *skb, __u32 offset, __u64 from, __u64 to, __u64 flags) = (void *) BPF_FUNC_l4_csum_replace; +static long (*bpf_skb_load_bytes)(const struct __sk_buff *skb, __u32 offset, void *to, + __u32 len) = (void *) BPF_FUNC_skb_load_bytes; static long (*bpf_skb_store_bytes)(struct __sk_buff *skb, __u32 offset, const void *from, __u32 len, __u64 flags) = (void *) BPF_FUNC_skb_store_bytes; @@ -912,10 +914,14 @@ static USID_ALWAYS_INLINE long apply_vip_xlat(struct __sk_buff *skb, __u32 addr_ // // A GSO packet whose transport header can't be read falls back to the total // length, which can only over-reject. -static USID_ALWAYS_INLINE __u16 usid_fib_tot_len(struct __sk_buff *skb, __u8 *l4, __u16 l3_len, __u32 l3_hdr_len, +// +// The transport header is read with bpf_skb_load_bytes at a scalar offset +// rather than through a packet pointer. IPv4 options make the header's position +// variable, and the verifier rejects adding a variable offset to a packet +// pointer unless the loader holds CAP_PERFMON, which galactic-cni does not. +static USID_ALWAYS_INLINE __u16 usid_fib_tot_len(struct __sk_buff *skb, __u16 l3_len, __u32 l3_hdr_len, __u8 l4proto) { - void *data_end = (void *) (long) skb->data_end; __u32 gso_size = skb->gso_size; __u32 l4_hdr_len; @@ -925,11 +931,11 @@ static USID_ALWAYS_INLINE __u16 usid_fib_tot_len(struct __sk_buff *skb, __u8 *l4 return l3_len; if (l4proto == USID_IPPROTO_TCP) { - __u8 *doff = l4 + USID_TCP_DOFF_OFFSET; + __u8 doff; - if ((void *) (doff + 1) > data_end) + if (bpf_skb_load_bytes(skb, USID_L3_OFFSET + l3_hdr_len + USID_TCP_DOFF_OFFSET, &doff, sizeof(doff))) return l3_len; - l4_hdr_len = (__u32) (*doff >> 4) * 4; + l4_hdr_len = (__u32) (doff >> 4) * 4; if (l4_hdr_len < USID_TCP_MIN_HDR_LEN) return l3_len; } else if (l4proto == USID_IPPROTO_UDP) { @@ -1269,8 +1275,7 @@ int usid_ingress(struct __sk_buff *skb) // fixed 40-byte header plus payload_len, in host order. __u16 l3_len = (__u16) sizeof(struct usid_ip6hdr) + __builtin_bswap16(inner6->payload_len); - fib_params.tot_len = usid_fib_tot_len(skb, (__u8 *) (inner6 + 1), l3_len, sizeof(struct usid_ip6hdr), - inner6->nexthdr); + fib_params.tot_len = usid_fib_tot_len(skb, l3_len, sizeof(struct usid_ip6hdr), inner6->nexthdr); } else { struct usid_iphdr *inner4 = (void *) inner; @@ -1288,8 +1293,7 @@ int usid_ingress(struct __sk_buff *skb) // IHL, since options move the transport header. __u32 ihl_len = (__u32) (inner4->ver_ihl & 0x0F) * 4; - fib_params.tot_len = usid_fib_tot_len(skb, (__u8 *) inner4 + ihl_len, __builtin_bswap16(inner4->tot_len), - ihl_len, inner4->protocol); + fib_params.tot_len = usid_fib_tot_len(skb, __builtin_bswap16(inner4->tot_len), ihl_len, inner4->protocol); } fib_params.ifindex = skb->ingress_ifindex; diff --git a/internal/plumbing/ebpf/prog/usid_caps_test.go b/internal/plumbing/ebpf/prog/usid_caps_test.go new file mode 100644 index 00000000..79849845 --- /dev/null +++ b/internal/plumbing/ebpf/prog/usid_caps_test.go @@ -0,0 +1,43 @@ +// Copyright 2026 Datum Cloud, Inc. +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package prog + +import ( + "errors" + "path/filepath" + "testing" + + "github.com/cilium/ebpf" + + "go.datum.net/galactic/internal/plumbing/ebpf/loadcaps" +) + +// TestUsid_LoadsWithGalacticCNICapabilities loads the datapath with only the +// capabilities galactic-cni's loader container holds. Every other test here +// loads as full root, which hides verifier rules that apply without +// CAP_PERFMON. +func TestUsid_LoadsWithGalacticCNICapabilities(t *testing.T) { + requireRoot(t) + + manifest := filepath.Join("..", "..", "..", "..", "config", "galactic-cni", "daemonset.yaml") + caps, err := loadcaps.ContainerCapabilities(manifest, "credential-refresh") + if err != nil { + t.Fatal(err) + } + err = loadcaps.Run(caps, func() error { + var objs UsidObjects + if err := LoadUsidObjects(&objs, nil); err != nil { + return err + } + return objs.Close() + }) + var ve *ebpf.VerifierError + if errors.As(err, &ve) { + t.Fatalf("verifier rejected the datapath with capabilities %v:\n%+v", caps, ve) + } + if err != nil { + t.Fatalf("load datapath with capabilities %v: %v", caps, err) + } +}