Skip to content
Merged
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
43 changes: 43 additions & 0 deletions internal/plumbing/ebpf/edgeprog/edgedsr_caps_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
113 changes: 113 additions & 0 deletions internal/plumbing/ebpf/loadcaps/loadcaps.go
Original file line number Diff line number Diff line change
@@ -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
}
84 changes: 84 additions & 0 deletions internal/plumbing/ebpf/loadcaps/loadcaps_test.go
Original file line number Diff line number Diff line change
@@ -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()
}
43 changes: 43 additions & 0 deletions internal/plumbing/ebpf/nat66prog/nat66_caps_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
22 changes: 13 additions & 9 deletions internal/plumbing/ebpf/prog/usid.c
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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;

Expand All @@ -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) {
Expand Down Expand Up @@ -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;

Expand All @@ -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;
Expand Down
43 changes: 43 additions & 0 deletions internal/plumbing/ebpf/prog/usid_caps_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}