From 9a4809acb681ca669d94dc6dc5cf76baed364a4f Mon Sep 17 00:00:00 2001 From: Peter Sprygada Date: Wed, 22 Jul 2026 21:59:31 -0400 Subject: [PATCH 1/6] feat(srv6): cut over to a TC-BPF uFMT 48+16 uSID datapath Galactic's per-endpoint seg6local ingress route model installs one static kernel route per (VPC, VPCAttachment) pair -- O(N) FIB entries per node, and no way to decode the shared Function+Argument uSID slot datum-cloud/enhancements#740 specifies (uFMT 48+16, RFC 9800 REPLACE-CSID). Vanilla seg6local can't parse a sub-field argument dynamically, so closing that gap needed a real in-kernel datapath, not a config change. Adds a new eBPF/TC-BPF ingress program (internal/plumbing/ebpf/) that matches a node's own uSID Block/Node-ID, reads Function/Argument directly from the unmutated packet, resolves the Argument to a Linux VRF via bpf_fib_lookup, and redirects into the pod's veth or tap interface -- O(1) FIB cost per node regardless of tenant count. ComputeSID (internal/plumbing/srv6/usid.go) now emits the real uFMT 48+16 layout via a new local per-node Argument allocator (allocateArgument, internal/cni/bgp.go) sourced from BGPVRFInstance CRD state rather than the eBPF maps themselves, so the encoder never depends on the datapath being loaded on a given node. galactic-cni registers/unregisters each attachment's vrf_table entry on ADD and rollback; a new GC sweep reclaims entries orphaned by deleted BGPVRFInstances. Also fixes two correctness bugs found deploying this to a live ContainerLab fabric -- the netlink watcher never re-verified an interface an external event (an FRR restart) had silently detached from, and RouteEgressAdd's full SEG6 encap mode pushed a Segment Routing Header that usid.c's fixed-width header strip didn't account for, misreading it as the inner IP version on every cross-region packet -- plus a bpf_fib_lookup neighbor-priming gap for pods whose address never otherwise triggered ARP/NDP. Separately, EVPN Type 5's GWIPAddress can only carry a gateway matching its own NLRI's address family, so an IPv4 prefix's SRv6 SID was silently dropped in transit; it's now carried via a new RFC 9252 Prefix-SID path attribute instead, independent of prefix family. Co-Authored-By: Claude Sonnet 5 --- AGENTS.md | 2 +- Taskfile.yaml | 25 +- cmd/galactic-cni/main.go | 4 +- config/cni/daemonset.yaml | 49 ++ config/fabric/daemonset.yaml | 11 + containers/galactic-cni/Dockerfile | 16 + .../resources/cni/daemonset-patch.yaml | 16 + docs/agents/ARCHITECTURE.md | 307 ++++---- docs/cni-cmd-sequence.md | 30 +- docs/cni/configuration.md | 57 +- docs/ebpf-datapath-sequence.md | 155 +++++ go.mod | 14 +- go.sum | 16 +- internal/cni/bgp.go | 318 +++++++-- internal/cni/bgp_ebpf_test.go | 165 +++++ internal/cni/bgp_test.go | 201 +++--- internal/cni/cni_test.go | 5 +- internal/cni/ops_add.go | 16 +- internal/cni/resource.go | 34 +- internal/cni/result.go | 18 +- internal/config/cni.go | 7 + internal/gc/gc.go | 146 +++- internal/gc/gc_ebpf_test.go | 156 +++++ internal/installer/installer.go | 229 +++++- internal/installer/installer_test.go | 232 +++++- internal/plumbing/ebpf/attach/attach.go | 281 ++++++++ internal/plumbing/ebpf/attach/attach_test.go | 182 +++++ internal/plumbing/ebpf/attach/doc.go | 59 ++ internal/plumbing/ebpf/attach/health.go | 179 +++++ internal/plumbing/ebpf/attach/health_test.go | 223 ++++++ internal/plumbing/ebpf/attach/hooks.go | 61 ++ internal/plumbing/ebpf/attach/hooks_test.go | 72 ++ internal/plumbing/ebpf/attach/interfaces.go | 119 ++++ .../plumbing/ebpf/attach/interfaces_test.go | 204 ++++++ internal/plumbing/ebpf/attach/watch.go | 283 ++++++++ internal/plumbing/ebpf/attach/watch_test.go | 505 ++++++++++++++ internal/plumbing/ebpf/doc.go | 46 ++ internal/plumbing/ebpf/metrics/collector.go | 183 +++++ .../plumbing/ebpf/metrics/collector_test.go | 277 ++++++++ internal/plumbing/ebpf/metrics/doc.go | 42 ++ internal/plumbing/ebpf/metrics/events.go | 71 ++ .../plumbing/ebpf/metrics/faketable_test.go | 112 +++ internal/plumbing/ebpf/metrics/metrics.go | 50 ++ .../plumbing/ebpf/preflight/kernel_prober.go | 180 +++++ .../ebpf/preflight/kernel_prober_test.go | 82 +++ internal/plumbing/ebpf/preflight/preflight.go | 159 +++++ .../plumbing/ebpf/preflight/preflight_test.go | 249 +++++++ internal/plumbing/ebpf/prog/doc.go | 55 ++ internal/plumbing/ebpf/prog/dropreason.go | 42 ++ internal/plumbing/ebpf/prog/usid.c | 547 +++++++++++++++ internal/plumbing/ebpf/prog/usid_bpfeb.go | 174 +++++ internal/plumbing/ebpf/prog/usid_bpfeb.o | Bin 0 -> 13600 bytes internal/plumbing/ebpf/prog/usid_bpfel.go | 174 +++++ internal/plumbing/ebpf/prog/usid_bpfel.o | Bin 0 -> 13600 bytes internal/plumbing/ebpf/prog/usid_test.go | 658 ++++++++++++++++++ internal/plumbing/ebpf/uformat/uformat.go | 383 ++++++++++ .../plumbing/ebpf/uformat/uformat_test.go | 518 ++++++++++++++ internal/plumbing/ebpf/usidmap/doc.go | 96 +++ internal/plumbing/ebpf/usidmap/egresskind.go | 25 + .../plumbing/ebpf/usidmap/faketable_test.go | 106 +++ internal/plumbing/ebpf/usidmap/function.go | 148 ++++ .../plumbing/ebpf/usidmap/function_test.go | 133 ++++ internal/plumbing/ebpf/usidmap/kernel_test.go | 172 +++++ internal/plumbing/ebpf/usidmap/locator.go | 129 ++++ .../plumbing/ebpf/usidmap/locator_test.go | 132 ++++ internal/plumbing/ebpf/usidmap/registry.go | 93 +++ .../plumbing/ebpf/usidmap/registry_test.go | 89 +++ internal/plumbing/ebpf/usidmap/table.go | 105 +++ internal/plumbing/ebpf/usidmap/vrf.go | 245 +++++++ internal/plumbing/ebpf/usidmap/vrf_test.go | 423 +++++++++++ internal/plumbing/srv6/egress.go | 23 +- internal/plumbing/srv6/srv6.go | 107 --- internal/plumbing/srv6/srv6_test.go | 75 -- internal/plumbing/srv6/usid.go | 99 ++- internal/plumbing/srv6/usid_test.go | 127 ++-- internal/reconcile/reconcile_test.go | 7 +- internal/runtime/gobgp/monitor.go | 50 +- internal/runtime/gobgp/paths.go | 36 +- internal/runtime/gobgp/paths_test.go | 59 ++ 79 files changed, 10225 insertions(+), 653 deletions(-) create mode 100644 docs/ebpf-datapath-sequence.md create mode 100644 internal/cni/bgp_ebpf_test.go create mode 100644 internal/gc/gc_ebpf_test.go create mode 100644 internal/plumbing/ebpf/attach/attach.go create mode 100644 internal/plumbing/ebpf/attach/attach_test.go create mode 100644 internal/plumbing/ebpf/attach/doc.go create mode 100644 internal/plumbing/ebpf/attach/health.go create mode 100644 internal/plumbing/ebpf/attach/health_test.go create mode 100644 internal/plumbing/ebpf/attach/hooks.go create mode 100644 internal/plumbing/ebpf/attach/hooks_test.go create mode 100644 internal/plumbing/ebpf/attach/interfaces.go create mode 100644 internal/plumbing/ebpf/attach/interfaces_test.go create mode 100644 internal/plumbing/ebpf/attach/watch.go create mode 100644 internal/plumbing/ebpf/attach/watch_test.go create mode 100644 internal/plumbing/ebpf/doc.go create mode 100644 internal/plumbing/ebpf/metrics/collector.go create mode 100644 internal/plumbing/ebpf/metrics/collector_test.go create mode 100644 internal/plumbing/ebpf/metrics/doc.go create mode 100644 internal/plumbing/ebpf/metrics/events.go create mode 100644 internal/plumbing/ebpf/metrics/faketable_test.go create mode 100644 internal/plumbing/ebpf/metrics/metrics.go create mode 100644 internal/plumbing/ebpf/preflight/kernel_prober.go create mode 100644 internal/plumbing/ebpf/preflight/kernel_prober_test.go create mode 100644 internal/plumbing/ebpf/preflight/preflight.go create mode 100644 internal/plumbing/ebpf/preflight/preflight_test.go create mode 100644 internal/plumbing/ebpf/prog/doc.go create mode 100644 internal/plumbing/ebpf/prog/dropreason.go create mode 100644 internal/plumbing/ebpf/prog/usid.c create mode 100644 internal/plumbing/ebpf/prog/usid_bpfeb.go create mode 100644 internal/plumbing/ebpf/prog/usid_bpfeb.o create mode 100644 internal/plumbing/ebpf/prog/usid_bpfel.go create mode 100644 internal/plumbing/ebpf/prog/usid_bpfel.o create mode 100644 internal/plumbing/ebpf/prog/usid_test.go create mode 100644 internal/plumbing/ebpf/uformat/uformat.go create mode 100644 internal/plumbing/ebpf/uformat/uformat_test.go create mode 100644 internal/plumbing/ebpf/usidmap/doc.go create mode 100644 internal/plumbing/ebpf/usidmap/egresskind.go create mode 100644 internal/plumbing/ebpf/usidmap/faketable_test.go create mode 100644 internal/plumbing/ebpf/usidmap/function.go create mode 100644 internal/plumbing/ebpf/usidmap/function_test.go create mode 100644 internal/plumbing/ebpf/usidmap/kernel_test.go create mode 100644 internal/plumbing/ebpf/usidmap/locator.go create mode 100644 internal/plumbing/ebpf/usidmap/locator_test.go create mode 100644 internal/plumbing/ebpf/usidmap/registry.go create mode 100644 internal/plumbing/ebpf/usidmap/registry_test.go create mode 100644 internal/plumbing/ebpf/usidmap/table.go create mode 100644 internal/plumbing/ebpf/usidmap/vrf.go create mode 100644 internal/plumbing/ebpf/usidmap/vrf_test.go delete mode 100644 internal/plumbing/srv6/srv6.go delete mode 100644 internal/plumbing/srv6/srv6_test.go diff --git a/AGENTS.md b/AGENTS.md index c5b23b3..d001ee4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,7 +35,7 @@ task test:e2e # Kind cluster lifecycle test task lint # golangci-lint; lint-fix applies safe auto-fixes ``` -There is no production release image build in this repo (`task docker-build` and the release workflow were removed after the shared image was found to advertise `galactic-router` without ever building it — see [docs/agents/ARCHITECTURE.md](docs/agents/ARCHITECTURE.md#known-constraints)). `containers/galactic-cni/Dockerfile` exists solely for `task test:e2e`. +Production images are built by `.github/workflows/publish.yaml`: `publish-galactic-cni-image` and `publish-galactic-router-image` each build and push their own image (`ghcr.io/datum-cloud/galactic-cni`, `ghcr.io/datum-cloud/galactic-router`) from their respective `containers/*/Dockerfile`, and `publish-kustomize-bundles` pushes `config/` as an OCI Kustomize bundle with each job's real published tag stamped in. This replaced the old single-image `release.yaml`, which built one shared image that advertised `galactic-router` without ever building it — see [docs/agents/ARCHITECTURE.md](docs/agents/ARCHITECTURE.md#cicd) for that history. `containers/galactic-cni/Dockerfile` is used by both `task test:e2e` and `publish.yaml`. **Before every PR:** `task ci` (lint → build → test:unit → test:e2e). diff --git a/Taskfile.yaml b/Taskfile.yaml index 5898bdf..a49e959 100644 --- a/Taskfile.yaml +++ b/Taskfile.yaml @@ -37,7 +37,7 @@ tasks: - GOOS=linux go vet ./... ci: - desc: Run the full CI pipeline (lint, build, test:unit, test:e2e) + desc: Run the full CI pipeline cmds: - task: lint - task: build @@ -77,6 +77,29 @@ tasks: build: desc: Build binaries deps: [fmt, vet] + cmds: + - task: build:ebpf + - task: build:binaries + + build:ebpf: + desc: >- + Regenerate the eBPF uSID datapath + run: once + cmds: + - | + if ! command -v clang >/dev/null 2>&1; then + echo "ERROR: clang is required to build the eBPF uSID datapath" >&2 + echo "(internal/plumbing/ebpf/prog/usid.c, via bpf2go)." >&2 + echo "Install it, e.g.:" >&2 + echo " Fedora/RHEL: sudo dnf install clang llvm" >&2 + echo " Debian/Ubuntu: sudo apt install clang llvm" >&2 + echo "then re-run 'task build:ebpf' (or 'task build')." >&2 + exit 1 + fi + - go generate ./internal/plumbing/ebpf/prog/... + + build:binaries: + internal: true vars: VERSION: sh: git describe --tags --always --dirty 2>/dev/null || echo "dev" diff --git a/cmd/galactic-cni/main.go b/cmd/galactic-cni/main.go index 8c07c33..f596866 100644 --- a/cmd/galactic-cni/main.go +++ b/cmd/galactic-cni/main.go @@ -55,15 +55,17 @@ func newInitCommand() *cobra.Command { func newRunCommand() *cobra.Command { var grpcHealthPort int + var metricsPort int runCmd := &cobra.Command{ Use: "run", Short: "Lightweight run loop to refresh credentials and run gRPC health server", RunE: func(cmd *cobra.Command, args []string) error { - return installer.Run(cmd.Context(), grpcHealthPort) + return installer.Run(cmd.Context(), grpcHealthPort, metricsPort) }, } runCmd.Flags().IntVar(&grpcHealthPort, "grpc-health-port", 5180, "gRPC health check port") + runCmd.Flags().IntVar(&metricsPort, "metrics-port", 9091, "Prometheus metrics HTTP port") return runCmd } diff --git a/config/cni/daemonset.yaml b/config/cni/daemonset.yaml index e9d4bbc..5073d8f 100644 --- a/config/cni/daemonset.yaml +++ b/config/cni/daemonset.yaml @@ -72,8 +72,27 @@ spec: # placeholder, not a published tag. image: ghcr.io/datum-cloud/galactic-cni:latest command: ["/galactic-cni", "run"] + # --- PRIVILEGE EXPANSION (Milestone 3.1 of + # .local/implementation-plan-ebpf-xdp-usid-datapath.md) --- + # This container previously ran with allowPrivilegeEscalation: + # false, no added capabilities, and no bpffs mount, because it + # only refreshed credentials and served gRPC health. It now also + # hosts the eBPF/TC-BPF uSID datapath's load/attach/pin control + # daemon (design plan .local/plan-ebpf-xdp-usid-datapath.md §5.4 + # option (a), §9 "Privileges" -- deliberately not a footnote, + # per that section). BPF/NET_ADMIN below and the bpf-fs + # volumeMount are required unconditionally by the binary's BPF + # loader dependencies even though the datapath itself stays + # inert until GALACTIC_CNI_ENABLE_EBPF_DATAPATH=true is set + # (default off, design plan §8 Phase 0) -- so this grant takes + # effect on every node running this manifest regardless of + # whether the flag is ever flipped on. securityContext: runAsUser: 0 + capabilities: + add: + - BPF + - NET_ADMIN allowPrivilegeEscalation: false resources: requests: @@ -86,6 +105,8 @@ spec: mountPath: /host/var/lib/galactic - name: galactic-log mountPath: /host/var/log/galactic + - name: bpf-fs + mountPath: /sys/fs/bpf livenessProbe: grpc: port: 5180 @@ -113,3 +134,31 @@ spec: hostPath: path: /var/log/galactic type: DirectoryOrCreate + - name: bpf-fs + # The host's bpffs mount (design plan §4.4/§9: "All maps pinned + # under /sys/fs/bpf/galactic/"). credential-refresh pins the + # eBPF uSID datapath's maps under a galactic/ subdirectory of + # this mount so a container restart reuses the maps already + # pinned there instead of recreating them empty (pinned-map + # continuity). `Directory` (not DirectoryOrCreate): bpffs must + # already be mounted at this path by the host/kubelet node setup + # for pinning to actually work -- creating a plain directory + # here if it were missing would silently produce a regular + # filesystem path instead of a real bpf filesystem, and pinning + # would then fail at load time with a real, actionable error + # instead of appearing to succeed. + # + # SECURITY NOTE: this mounts the host's entire /sys/fs/bpf, not + # just the galactic/ subtree this container actually uses -- + # a Kubernetes hostPath volume can't mount a subdirectory that + # doesn't exist yet (galactic/ is created by this container on + # first pin, not present beforehand), so this container has + # read/write visibility into every other pinned BPF object any + # other process on this host has placed under bpffs, not just + # its own. This is an accepted, largely inherent tradeoff of + # bpffs pinning on this platform, not an oversight -- call it + # out explicitly to a security reviewer rather than let it pass + # as a footnote. + hostPath: + path: /sys/fs/bpf + type: Directory diff --git a/config/fabric/daemonset.yaml b/config/fabric/daemonset.yaml index 35c6e84..ca628f7 100644 --- a/config/fabric/daemonset.yaml +++ b/config/fabric/daemonset.yaml @@ -19,10 +19,21 @@ spec: # and brings up the node's lo address, both of which galactic-router # depends on before it can start — so this must tolerate NotReady the # same way a CNI plugin does, or it never gets scheduled early enough. + # It must also tolerate the route-reflector role's own + # galactic.datumapis.com/node=control:NoSchedule taint -- the affinity + # below already targets that label, but without this toleration the + # matching taint silently keeps this DaemonSet off the node entirely, + # so the route reflector's lo address (and BGP_LOCAL_ADDRESS + # auto-detection) never gets configured and galactic-router-control + # crashloops forever. tolerations: - key: node.kubernetes.io/not-ready operator: Exists effect: NoSchedule + - key: galactic.datumapis.com/node + operator: Equal + value: control + effect: NoSchedule # Opt-in only, same as galactic-cni and galactic-router: runs on every # node labeled either for regular tenant traffic or for the # galactic-router route-reflector role, since both need underlay diff --git a/containers/galactic-cni/Dockerfile b/containers/galactic-cni/Dockerfile index 7009008..0611f55 100644 --- a/containers/galactic-cni/Dockerfile +++ b/containers/galactic-cni/Dockerfile @@ -10,6 +10,17 @@ ARG SPDX_LICENSE=AGPL-3.0-or-later ARG GIT_URL=https://github.com/datum-cloud/galactic WORKDIR /workspace + +# clang/LLVM builds the eBPF/TC-BPF uSID datapath (internal/plumbing/ebpf/ +# prog/usid.c) via bpf2go, invoked by `go generate` below -- a build-time +# only dependency; the final runtime images below embed the resulting +# compiled object via go:embed in the galactic-cni binary itself and need +# no eBPF toolchain of their own (design plan +# .local/plan-ebpf-xdp-usid-datapath.md §6; Milestone 5.2 of +# .local/implementation-plan-ebpf-xdp-usid-datapath.md). +RUN apt-get update && apt-get install -y --no-install-recommends clang llvm linux-libc-dev \ + && rm -rf /var/lib/apt/lists/* + # Copy the Go Modules manifests COPY go.mod go.mod COPY go.sum go.sum @@ -21,6 +32,11 @@ RUN go mod download COPY cmd/ cmd/ COPY internal/ internal/ +# Regenerate the eBPF uSID datapath's compiled object and Go bindings from +# usid.c -- always, not just when the committed usid_bpfel.o/usid_bpfeb.o +# are stale, matching `task build:ebpf`'s own semantics (Taskfile.yaml). +RUN go generate ./internal/plumbing/ebpf/prog/... + # Build CNI plugin RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} go build \ -ldflags "-s -w \ diff --git a/deploy/containerlab/resources/cni/daemonset-patch.yaml b/deploy/containerlab/resources/cni/daemonset-patch.yaml index 4008327..acba9ef 100644 --- a/deploy/containerlab/resources/cni/daemonset-patch.yaml +++ b/deploy/containerlab/resources/cni/daemonset-patch.yaml @@ -13,3 +13,19 @@ spec: - name: credential-refresh image: galactic-cni:latest imagePullPolicy: Never + env: + # Every lab node is dual-homed: eth0 carries the IPv6 default + # route but only reaches the Kind/ContainerLab management + # bridge (kubectl/API-server traffic), while eth1 is the + # dedicated point-to-point link to the transit fabric (tr1-4) + # that actual SRv6-encapsulated VPC traffic arrives on. + # ResolveInterfaces' auto-detection (default-IPv6-route + # heuristic, internal/plumbing/ebpf/attach/interfaces.go) picks + # eth0 here since it's ambiguous between the two -- confirmed + # live by tcpdump: cross-region SRv6 packets arrive on eth1 but + # the eBPF usid_ingress filter was only ever attached to eth0, + # so decapsulation never ran and every VPC ping between sites + # silently blackholed. This override forces the correct + # interface for this topology; see docs/cni/configuration.md. + - name: GALACTIC_CNI_EBPF_INTERFACES + value: eth1 diff --git a/docs/agents/ARCHITECTURE.md b/docs/agents/ARCHITECTURE.md index f1351a7..c544b0a 100644 --- a/docs/agents/ARCHITECTURE.md +++ b/docs/agents/ARCHITECTURE.md @@ -5,7 +5,7 @@ > networks, and a router that reconciles BGP CRDs and drives an embedded > GoBGP server to distribute EVPN (L2VPN/EVPN AFI/SAFI) paths between nodes. -_Last updated: 2026-07-14_ +_Last updated: 2026-07-22_ --- @@ -32,16 +32,18 @@ CNI config and acts on them. `galactic-router` reconciles BGP CRDs section elsewhere, or remove this note — flagged for a human decision. --> Each container endpoint is assigned a /128 USID (Unique Local SID, RFC 8986 Section 3.2). -There is no longer a companion-operator-injected `srv6_sid` NAD/config field: the CNI -itself computes the SID in `resolveSRv6SID` (`internal/cni/bgp.go`) from the node's -`BGPRouter.spec.srv6Locator` + `spec.nodeID` plus this attachment's VRFID (`srv6.ComputeSID`, -`internal/plumbing/srv6/usid.go`), using the End.DT46 function. If the router lacks either -`srv6Locator` or `nodeID`, SID resolution — and SRv6 ingress setup — is skipped entirely for -that attachment. The CNI installs an END.DT46 decap route for the computed /128 and -advertises it as the EVPN Type 5 GWIPAddress. +There is no companion-operator-injected `srv6_sid` NAD/config field: the SID is computed +(`srv6.ComputeSID`, `internal/plumbing/srv6/usid.go`) from the node's +`BGPRouter.spec.srv6Locator` + `spec.nodeID` plus a locally-allocated 12-bit Argument +(`internal/cni/bgp.go`'s `allocateArgument`), using the End.DT46 function. If the router +lacks either `srv6Locator` or `nodeID`, SRv6 is skipped entirely for that attachment +(no eBPF datapath registration, no SID). The eBPF/TC-BPF uSID datapath — the only +ingress/decap path — matches this attachment's Argument in its `vrf_table` and decodes +into the corresponding VRF; the router independently recomputes the same SID +(`internal/reconcile`) to advertise as the EVPN Type 5 GWIPAddress. All nodes in the same VPC derive the same BGP Route Target by truncating the -48-bit hex VPC identifier to its low 32 bits (`uint32(v)`), formatted as +16-bit hex VPC identifier to its low 32 bits (`uint32(v)`), formatted as `ASN:NN`, enabling automatic cross-node path import without explicit RT configuration. The RT is also used as the `BGPVRFInstance`'s Route Distinguisher and import/export Route Target. @@ -69,6 +71,10 @@ galactic/ │ ├── metadata/ # Build-time version info (Version, GitCommit, etc.) │ ├── gc/ # Orphaned BGPAdvertisement/BGPVRFInstance CRD and │ │ # stale kernel VRF cleanup, driven by the GC controller +│ │ # (galactic-router); also SweepEBPFVRFTable, called +│ │ # from galactic-cni's `run` container instead (see +│ │ # Entry Points below) since only that container has +│ │ # the eBPF datapath's pinned maps │ ├── cni/ # CNI cmdAdd / cmdDel / cmdCheck, PluginConf parsing, │ │ # BGP CRD publish, built-in IPAM wiring │ │ ├── ipam/ # Built-in IPv6 pool + static IP allocators @@ -77,12 +83,26 @@ galactic/ │ │ └── veth/ # veth pair management │ ├── installer/ # galactic-cni DaemonSet init/run logic: binary │ │ # staging, conflist templating, kubeconfig -│ │ # refresh, gRPC health server +│ │ # refresh, gRPC health server + Prometheus +│ │ # metrics, eBPF datapath startup/health/GC wiring │ └── plumbing/ # Low-level kernel and network primitives │ ├── intf/ # Interface naming, base62↔hex encoding -│ ├── srv6/ # SRv6 ingress route add/del (END.DT46) +│ ├── srv6/ # ComputeSID (uFMT 48+16) + RouteEgressAdd/Del +│ │ # (router's SEG6 encap toward remote SIDs) │ ├── sysctl/ # Interface sysctl helpers -│ └── vrf/ # Linux VRF create/delete/lookup +│ ├── vrf/ # Linux VRF create/delete/lookup +│ └── ebpf/ # eBPF/TC-BPF uSID datapath -- the only ingress/ +│ │ # decap path (.local/plan-ebpf-xdp-usid-datapath.md) +│ ├── uformat/ # Pure-Go uFMT 48+16 bit-layout encode/decode +│ ├── prog/ # usid.c (TC-BPF program) + bpf2go-generated +│ │ # Go bindings/compiled object (go:embed) +│ ├── preflight/ # Kernel capability check (SCHED_CLS, HASH maps, +│ │ # BTF, bpf_fib_lookup w/ VRF tbid support) +│ ├── attach/ # Load/pin/attach/detach lifecycle, netlink-driven +│ │ # re-attachment, health check +│ ├── usidmap/ # Read/write API for the three control-plane maps +│ │ # (locator_table, function_table, vrf_table) +│ └── metrics/ # Prometheus collector + event counters ├── config/ # Kustomize-composed; `kubectl apply -k config/` deploys everything │ ├── system/ # galactic-system namespace (shared by both components) │ ├── router/ # Shared RBAC/ServiceAccount, plus: @@ -113,28 +133,36 @@ See [docs/cni-cmd-sequence.md](../cni-cmd-sequence.md) for the full CNI ADD/DEL See [docs/agent-startup.md](../agent-startup.md) for the router startup sequence diagram. +See [docs/ebpf-datapath-sequence.md](../ebpf-datapath-sequence.md) for the eBPF/TC-BPF uSID datapath's `run`-container startup/load/attach/health/GC-sweep sequence and the CNI ADD path's map registration — the only forwarding path, always on. + --- ## Components -| Component | Binary | Role | -|-----------|--------|------| -| `internal/controller` | `galactic-router` | controller-runtime reconcilers; field index registration; CRD status helpers | -| `internal/reconcile` | `galactic-router` | CRD → DesiredRouter translation | -| `internal/runtime/gobgp` | `galactic-router` | Embedded GoBGP server (`--mode=tenant`) | -| `internal/runtime/frr` | `galactic-router` | FRR stub (`--mode=fabric`) — returns "not implemented" for every method | -| `internal/model` | `galactic-router` | Internal BGP model types | -| `internal/hash` | `galactic-router` | Change detection | -| `internal/metadata` | both | Build-time version info stamped via `-ldflags` | -| `internal/gc` | `galactic-router` | Orphaned CRD/VRF cleanup, driven by the GC controller's ticker | -| `internal/cni` | `galactic-cni` | CNI cmdAdd / cmdDel / cmdCheck; BGP CRD publish | -| `internal/cni/ipam` | `galactic-cni` | Built-in IPv6 pool + static allocators | -| `internal/cni/tap` | `galactic-cni` | Tap interface create/delete (VM workloads) | -| `internal/installer` | `galactic-cni` | DaemonSet `init`/`run` logic: binary staging, conflist/kubeconfig templating, credential refresh, gRPC health server | -| `internal/plumbing/intf` | both | Interface naming, base62↔hex encoding | -| `internal/plumbing/srv6` | both | SRv6 ingress route add/del (END.DT46) | -| `internal/plumbing/vrf` | both | Linux VRF create/delete/lookup | -| `internal/plumbing/sysctl` | both | Interface sysctl helpers | +| Component | Binary | Role | +| ---------------------------------- | ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `internal/controller` | `galactic-router` | controller-runtime reconcilers; field index registration; CRD status helpers | +| `internal/reconcile` | `galactic-router` | CRD → DesiredRouter translation | +| `internal/runtime/gobgp` | `galactic-router` | Embedded GoBGP server (`--mode=tenant`) | +| `internal/runtime/frr` | `galactic-router` | FRR stub (`--mode=fabric`) — returns "not implemented" for every method | +| `internal/model` | `galactic-router` | Internal BGP model types | +| `internal/hash` | `galactic-router` | Change detection | +| `internal/metadata` | both | Build-time version info stamped via `-ldflags` | +| `internal/gc` | both | Orphaned CRD/VRF cleanup (`galactic-router`'s GC controller ticker) and `SweepEBPFVRFTable` (`galactic-cni`'s `run` container ticker — see Entry Points) | +| `internal/cni` | `galactic-cni` | CNI cmdAdd / cmdDel / cmdCheck; BGP CRD publish; eBPF datapath registration (`registerEBPFDatapath`) — the only forwarding path | +| `internal/cni/ipam` | `galactic-cni` | Built-in IPv6 pool + static allocators | +| `internal/cni/tap` | `galactic-cni` | Tap interface create/delete (VM workloads) | +| `internal/installer` | `galactic-cni` | DaemonSet `init`/`run` logic: binary staging, conflist/kubeconfig templating, credential refresh, gRPC health server + Prometheus metrics, eBPF datapath startup/health/GC wiring | +| `internal/plumbing/intf` | both | Interface naming, base62↔hex encoding | +| `internal/plumbing/srv6` | both | `ComputeSID` (uFMT 48+16 SID computation, used by both `galactic-cni` and `galactic-router`) and `RouteEgressAdd`/`RouteEgressDel` (`galactic-router`'s SEG6 encap routes toward remote SIDs). The per-endpoint ingress decap path this package used to also hold (`RouteIngressAdd`/`RouteIngressDel`, `seg6local`) was deleted in the eBPF cutover — see `plumbing/ebpf` below, now the only ingress/decap path | +| `internal/plumbing/vrf` | both | Linux VRF create/delete/lookup | +| `internal/plumbing/sysctl` | both | Interface sysctl helpers | +| `internal/plumbing/ebpf/uformat` | `galactic-cni` | Pure-Go uFMT 48+16 bit-layout encode/decode, map key composition | +| `internal/plumbing/ebpf/prog` | `galactic-cni` | Compiled TC-BPF program (`usid.c`) + bpf2go Go bindings; embeds the object via `go:embed` | +| `internal/plumbing/ebpf/preflight` | `galactic-cni` | Kernel capability check before loading the program | +| `internal/plumbing/ebpf/attach` | `galactic-cni` | Load/pin/attach/detach lifecycle, netlink-driven re-attachment, health check | +| `internal/plumbing/ebpf/usidmap` | `galactic-cni` | Read/write API for `locator_table`/`function_table`/`vrf_table`, including cross-process pinned-map opening for the CNI plugin binary | +| `internal/plumbing/ebpf/metrics` | `galactic-cni` | Prometheus collector (live map state) + event counters (load/attach/detach) | --- @@ -167,9 +195,20 @@ Two subcommands support the DaemonSet (see Known Constraints below for the manif calls `installer.Bootstrap(ctx, nodeName)`: stages the `galactic-cni`/`host-device` binaries onto the host, does a one-shot dual-stack node-identity check against the Kubernetes API, and writes `ca.crt`/kubeconfig plus the static conflist. -- `run` — `--grpc-health-port` flag (default `5180`), calls `installer.Run(ctx, - grpcHealthPort)`: serves gRPC health checks and periodically refreshes the - kubeconfig token and rotates the CNI log file. +- `run` — `--grpc-health-port` flag (default `5180`) and `--metrics-port` flag + (default `9091`), calls `installer.Run(ctx, grpcHealthPort, metricsPort)`: + serves gRPC health checks and Prometheus metrics (`/metrics`), and + periodically refreshes the kubeconfig token and rotates the CNI log file. + This same process always loads/pins/attaches the eBPF/TC-BPF uSID + datapath (`internal/plumbing/ebpf/attach`, see + [docs/cni/configuration.md](../cni/configuration.md#ebpf-usid-datapath) + — a load/attach failure is fatal to this container), polls its health on + a ticker (a separate `ebpf-datapath` gRPC health service), and + periodically sweeps stale `vrf_table` map entries against live + `BGPVRFInstance` CRDs (`gc.SweepEBPFVRFTable` — deliberately run from + here, not from `galactic-router`'s GC controller below, since the + pinned maps only exist inside this container; see that function's doc + comment). See [docs/cni-cmd-sequence.md](../cni-cmd-sequence.md) for the full ADD/DEL sequence. @@ -205,31 +244,31 @@ lives in `root.go`'s `runCmd`: ### galactic-router environment variables -| Variable | Required | Default | Description | -|-------------------------------------|----------|--------------------|--------------------------------------------------------------------------| -| `GALACTIC_ROUTER_NODE_NAME` | Yes | — | Kubernetes node name; filters which BGPRouter CRDs this instance owns | -| `GALACTIC_ROUTER_ROUTER_MODE` | Yes | — | `transit` (unsupported stub), `fabric` (FRR stub), or `tenant` (GoBGP) | -| `GALACTIC_ROUTER_REFLECTOR` | No | `false` | Enable route reflector mode; only valid for `fabric`/`tenant` | -| `GALACTIC_ROUTER_BGP_LISTEN_PORT` | No | `179` | BGP TCP listen port; `-1` disables inbound connections (outbound-only) | -| `GALACTIC_ROUTER_BGP_LOCAL_ADDRESS` | No | — | Source address for outgoing BGP TCP connections (numbered underlay use) | -| `GALACTIC_ROUTER_METRICS_PORT` | No | `8080` | controller-runtime Prometheus metrics port | -| `GALACTIC_ROUTER_GRPC_HEALTH_PORT` | No | `5000` | gRPC health check port (liveness/readiness probes) | -| `GALACTIC_ROUTER_GC_NAMESPACE` | No | `galactic-system` | Namespace the GC controller scans for orphaned CRDs | -| `GALACTIC_ROUTER_GC_INTERVAL` | No | `5m` | GC controller sweep interval | +| Variable | Required | Default | Description | +| ----------------------------------- | -------- | ----------------- | ----------------------------------------------------------------------- | +| `GALACTIC_ROUTER_NODE_NAME` | Yes | — | Kubernetes node name; filters which BGPRouter CRDs this instance owns | +| `GALACTIC_ROUTER_ROUTER_MODE` | Yes | — | `transit` (unsupported stub), `fabric` (FRR stub), or `tenant` (GoBGP) | +| `GALACTIC_ROUTER_REFLECTOR` | No | `false` | Enable route reflector mode; only valid for `fabric`/`tenant` | +| `GALACTIC_ROUTER_BGP_LISTEN_PORT` | No | `179` | BGP TCP listen port; `-1` disables inbound connections (outbound-only) | +| `GALACTIC_ROUTER_BGP_LOCAL_ADDRESS` | No | — | Source address for outgoing BGP TCP connections (numbered underlay use) | +| `GALACTIC_ROUTER_METRICS_PORT` | No | `8080` | controller-runtime Prometheus metrics port | +| `GALACTIC_ROUTER_GRPC_HEALTH_PORT` | No | `5000` | gRPC health check port (liveness/readiness probes) | +| `GALACTIC_ROUTER_GC_NAMESPACE` | No | `galactic-system` | Namespace the GC controller scans for orphaned CRDs | +| `GALACTIC_ROUTER_GC_INTERVAL` | No | `5m` | GC controller sweep interval | See [docs/router/configuration.md](../router/configuration.md) for the full reference, including CLI flags and precedence. ### galactic-cni CNI config fields (`PluginConf`) -| Field | Type | Description | -|-----------------|----------|-------------------------------------------------------------------------| -| `vpc` | string | Base62-encoded 48-bit VPC identifier | -| `vpcattachment` | string | Base62-encoded 16-bit VPCAttachment identifier | -| `interface_type`| string | `veth` (default) or `tap`; tap mode omits guest-side/host-device config but still runs IPAM and SRv6/BGP publish (see the ADD result section below) | -| `namespace` | string | Kubernetes namespace for BGP CRDs; resolution order is this field → `GALACTIC_CNI_NAMESPACE` → `HostConf.Namespace` (from the conflist) → `DefaultNamespace` (`galactic-system`) | -| `mtu` | int | MTU for the host-side interface (veth pair or tap); 0 uses kernel default | -| `terminations` | array | Static routes to install on the host-side interface (`network`, `via`) | -| `ipam` | object | Built-in IPv6 pool/static allocator config (Galactic has no external IPAM delegation); used identically in `veth` and `tap` mode — `tap`'s `cmdAdd` calls `allocateIPAM()` unconditionally, so omitting this without `GALACTIC_CNI_ENABLE_LOCAL_IPAM` set is not safely tolerated in tap mode. See [docs/cni/configuration.md](../cni/configuration.md). | +| Field | Type | Description | +| ---------------- | ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `vpc` | string | Base62-encoded 16-bit VPC identifier, cluster-scoped | +| `vpcattachment` | string | Base62-encoded 16-bit VPCAttachment identifier | +| `interface_type` | string | `veth` (default) or `tap`; tap mode omits guest-side/host-device config but still runs IPAM and SRv6/BGP publish (see the ADD result section below) | +| `namespace` | string | Kubernetes namespace for BGP CRDs; resolution order is this field → `GALACTIC_CNI_NAMESPACE` → `HostConf.Namespace` (from the conflist) → `DefaultNamespace` (`galactic-system`) | +| `mtu` | int | MTU for the host-side interface (veth pair or tap); 0 uses kernel default | +| `terminations` | array | Static routes to install on the host-side interface (`network`, `via`) | +| `ipam` | object | Built-in IPv6 pool/static allocator config (Galactic has no external IPAM delegation); used identically in `veth` and `tap` mode — `tap`'s `cmdAdd` calls `allocateIPAM()` unconditionally, so omitting this without `GALACTIC_CNI_ENABLE_LOCAL_IPAM` set is not safely tolerated in tap mode. See [docs/cni/configuration.md](../cni/configuration.md). | ### galactic-cni environment variables @@ -240,14 +279,15 @@ subcommands, and only `init`'s `--node-name` overlaps in purpose). `parseConf()` call, in the listed precedence, and re-exports the result as a process env var for the rest of the invocation: -| Variable | Resolution precedence (highest first) | Default | -|------------------------------------|--------------------------------------------------------------------------------------------------------|---------| -| Node name (`NODE_NAME`) | `GALACTIC_CNI_NODE_NAME` → `NODE_NAME` → `HostConf.NodeName` (conflist) → `detectNodeNameFromAPI()` (matches local interface addrs against Node `InternalIP`) | _(error if still empty)_ | -| Kubeconfig (`KUBECONFIG`) | `GALACTIC_CNI_KUBECONFIG` → `HostConf.Kubeconfig` (conflist) | `/var/lib/galactic/kubeconfig` | -| Namespace | `conf.Namespace` (CNI config JSON) → `GALACTIC_CNI_NAMESPACE` → `HostConf.Namespace` (conflist) | `galactic-system` | -| Log file | `GALACTIC_CNI_LOG_FILE` → `HostConf.LogFile` (conflist) | `/var/log/galactic/galactic-cni.log` | -| Log level | `GALACTIC_CNI_LOG_LEVEL` → `HostConf.LogLevel` (conflist) | `info` | -| `GALACTIC_CNI_ENABLE_LOCAL_IPAM` | Read directly as an env var in `parseConf()` (no conflist or CLI-flag equivalent) | `false` | +| Variable | Resolution precedence (highest first) | Default | +| ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------ | +| Node name (`NODE_NAME`) | `GALACTIC_CNI_NODE_NAME` → `NODE_NAME` → `HostConf.NodeName` (conflist) → `detectNodeNameFromAPI()` (matches local interface addrs against Node `InternalIP`) | _(error if still empty)_ | +| Kubeconfig (`KUBECONFIG`) | `GALACTIC_CNI_KUBECONFIG` → `HostConf.Kubeconfig` (conflist) | `/var/lib/galactic/kubeconfig` | +| Namespace | `conf.Namespace` (CNI config JSON) → `GALACTIC_CNI_NAMESPACE` → `HostConf.Namespace` (conflist) | `galactic-system` | +| Log file | `GALACTIC_CNI_LOG_FILE` → `HostConf.LogFile` (conflist) | `/var/log/galactic/galactic-cni.log` | +| Log level | `GALACTIC_CNI_LOG_LEVEL` → `HostConf.LogLevel` (conflist) | `info` | +| `GALACTIC_CNI_ENABLE_LOCAL_IPAM` | Read directly as an env var in `parseConf()` (no conflist or CLI-flag equivalent) | `false` | +| `GALACTIC_CNI_EBPF_INTERFACES` | Read by `internal/plumbing/ebpf/attach.ResolveInterfaces`, overriding auto-detection for multi-homed nodes | _(auto-detect)_ | `HostConf` (`node_name`, `kubeconfig`, `namespace`, `log_file`, `log_level`) is the JSON shape the `init` installer subcommand writes into the `galactic-cni`-typed plugin entry @@ -277,12 +317,12 @@ On a successful ADD, the plugin returns a CNI spec v1.0.0 result with the follow } ``` -| Field | Description | -|-------|-------------| -| `interfaces[0]` | Host-side veth endpoint (`G{vpc}{att}H`); sandbox is empty (host network namespace) | -| `interfaces[1]` | Guest-side veth endpoint (`args.IfName`, typically `eth0`); sandbox is the container netns path | -| `ips[0].interface` | Index `1` into `interfaces` — the guest veth carries the pod IP | -| `routes` | Default route via IPAM gateway (when IPAM is configured) | +| Field | Description | +| ------------------ | ----------------------------------------------------------------------------------------------- | +| `interfaces[0]` | Host-side veth endpoint (`G{vpc}{att}H`); sandbox is empty (host network namespace) | +| `interfaces[1]` | Guest-side veth endpoint (`args.IfName`, typically `eth0`); sandbox is the container netns path | +| `ips[0].interface` | Index `1` into `interfaces` — the guest veth carries the pod IP | +| `routes` | Default route via IPAM gateway (when IPAM is configured) | The VRF dummy interface (`G{vpc}{att}V`) is **not** reported — it is pre-existing infrastructure created by the `vrf.Add()` plumbing function, not by the CNI attachment itself. @@ -309,53 +349,61 @@ pod's IPAM bookkeeping and does not attempt to unwind kernel/CRD state — see t ## Module / Package Reference -| Package | Binary | Responsibility | Owns state | -|-------------------------------|-----------------|-----------------------------------------------------------------------------------------------------|------------| -| `internal/controller` | galactic-router | controller-runtime reconcilers (BGPRouter, BGPPeer, BGPAdvertisement, BGPVRFInstance, BGPPolicy, Node, Secret, GC); field index registration; CRD status helpers | No | -| `internal/reconcile` | galactic-router | Translates BGPRouter + related CRDs into `model.DesiredRouter`; enforces node/role filtering, timer validation, AFI validation | No | -| `internal/runtime` | galactic-router | `RouterRuntime` interface; `RuntimeManager` (keyed map of live runtimes, double-checked lock create) | Yes (runtime map) | -| `internal/runtime/gobgp` | galactic-router | Embeds GoBGP v4; lazy-starts on first Apply; handles peer/VRF/EVPN-path/policy add/update/delete; tracks established timestamps | Yes (per-router) | -| `internal/runtime/frr` | galactic-router | FRR stub — returns "not implemented" for every method | No | -| `internal/model` | both | `DesiredRouter`, `DesiredPeer`, `DesiredAdvertisement`, `DesiredPolicy`, `DesiredVRFInstance`, `RuntimeStatus`; re-exports BGP API enums | No | -| `internal/hash` | galactic-router | SHA-256 fingerprint of `DesiredRouter` for no-op suppression | No | -| `internal/metadata` | both | Build-time vars (`Version`, `GitCommit`, `GitTreeState`, `BuildDate`) stamped via `-ldflags` | No | -| `internal/gc` | galactic-router | Collects orphaned `BGPAdvertisement`/`BGPVRFInstance` CRDs and stale kernel VRFs; invoked by the GC controller's ticker | No | -| `internal/cni` | galactic-cni | `cmdAdd` / `cmdDel` / `cmdCheck`; CNI PluginConf parsing; BGPVRFInstance/BGPAdvertisement lifecycle; delegates kernel work to plumbing | No | -| `internal/cni/ipam` | galactic-cni | Built-in IPv6 pool allocator (in-memory, ephemeral) and static IP allocator | Yes (pool allocations) | -| `internal/cni/route` | galactic-cni | Host-side static route add/delete via netlink | No | -| `internal/cni/tap` | galactic-cni | Tap interface create/delete for VM workloads (Kata, Firecracker, QEMU) | No | -| `internal/cni/veth` | galactic-cni | veth pair create/delete | No | -| `internal/installer` | galactic-cni | DaemonSet `init`/`run` support: binary staging, node-identity check, conflist/kubeconfig templating, credential refresh ticker, log rotation, gRPC health server | No | -| `internal/plumbing/intf` | both | Deterministic interface naming (`G{vpc9}{att3}V/H/G`); base62↔hex encoding | No | -| `internal/plumbing/srv6` | galactic-cni | SRv6 END.DT46 ingress route add/delete via netlink | No | -| `internal/plumbing/vrf` | galactic-cni | Linux VRF create/delete/lookup via netlink | No | -| `internal/plumbing/sysctl` | galactic-cni | Per-interface sysctl helpers | No | +| Package | Binary | Responsibility | Owns state | +| ---------------------------------- | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------- | +| `internal/controller` | galactic-router | controller-runtime reconcilers (BGPRouter, BGPPeer, BGPAdvertisement, BGPVRFInstance, BGPPolicy, Node, Secret, GC); field index registration; CRD status helpers | No | +| `internal/reconcile` | galactic-router | Translates BGPRouter + related CRDs into `model.DesiredRouter`; enforces node/role filtering, timer validation, AFI validation | No | +| `internal/runtime` | galactic-router | `RouterRuntime` interface; `RuntimeManager` (keyed map of live runtimes, double-checked lock create) | Yes (runtime map) | +| `internal/runtime/gobgp` | galactic-router | Embeds GoBGP v4; lazy-starts on first Apply; handles peer/VRF/EVPN-path/policy add/update/delete; tracks established timestamps | Yes (per-router) | +| `internal/runtime/frr` | galactic-router | FRR stub — returns "not implemented" for every method | No | +| `internal/model` | both | `DesiredRouter`, `DesiredPeer`, `DesiredAdvertisement`, `DesiredPolicy`, `DesiredVRFInstance`, `RuntimeStatus`; re-exports BGP API enums | No | +| `internal/hash` | galactic-router | SHA-256 fingerprint of `DesiredRouter` for no-op suppression | No | +| `internal/metadata` | both | Build-time vars (`Version`, `GitCommit`, `GitTreeState`, `BuildDate`) stamped via `-ldflags` | No | +| `internal/gc` | both | Collects orphaned `BGPAdvertisement`/`BGPVRFInstance` CRDs and stale kernel VRFs (galactic-router's GC controller ticker); `SweepEBPFVRFTable` reconciles stale `vrf_table` entries against live `BGPVRFInstance` CRDs (galactic-cni's `run` container ticker instead — see `internal/installer`) | No | +| `internal/cni` | galactic-cni | `cmdAdd` / `cmdDel` / `cmdCheck`; CNI PluginConf parsing; BGPVRFInstance/BGPAdvertisement lifecycle; delegates kernel work to plumbing; `registerEBPFDatapath`/`unregisterEBPFDatapath` eBPF `vrf_table` registration — the only forwarding path, a registration failure is fatal to the ADD | No | +| `internal/cni/ipam` | galactic-cni | Built-in IPv6 pool allocator (in-memory, ephemeral) and static IP allocator | Yes (pool allocations) | +| `internal/cni/route` | galactic-cni | Host-side static route add/delete via netlink | No | +| `internal/cni/tap` | galactic-cni | Tap interface create/delete for VM workloads (Kata, Firecracker, QEMU) | No | +| `internal/cni/veth` | galactic-cni | veth pair create/delete | No | +| `internal/installer` | galactic-cni | DaemonSet `init`/`run` support: binary staging, node-identity check, conflist/kubeconfig templating, credential refresh ticker, log rotation, gRPC health server + Prometheus metrics; `run` always loads/attaches the eBPF datapath, polls its health, and runs the `vrf_table` GC sweep on their own tickers | No | +| `internal/plumbing/intf` | both | Deterministic interface naming (`G{vpc9}{att3}V/H/G`); base62↔hex encoding | No | +| `internal/plumbing/srv6` | galactic-cni | SRv6 END.DT46 ingress route add/delete via netlink -- the production path, unaffected by `plumbing/ebpf` below | No | +| `internal/plumbing/vrf` | galactic-cni | Linux VRF create/delete/lookup via netlink | No | +| `internal/plumbing/sysctl` | galactic-cni | Per-interface sysctl helpers | No | +| `internal/plumbing/ebpf/uformat` | galactic-cni | Pure-Go uFMT 48+16 field encode/decode and `locator_table`/`function_table`/`vrf_table` key composition, shared by the BPF program and the Go control plane so they can't drift on bit positions | No | +| `internal/plumbing/ebpf/prog` | galactic-cni | `usid.c` (TC-BPF ingress program) + bpf2go-generated Go bindings; embeds the compiled object via `go:embed` | No | +| `internal/plumbing/ebpf/preflight` | galactic-cni | Startup kernel-capability check (`BPF_PROG_TYPE_SCHED_CLS`, `BPF_MAP_TYPE_HASH`, BTF, `bpf_fib_lookup`'s VRF-`tbid` parameter); blocks Load on failure, never a partial fallback | No | +| `internal/plumbing/ebpf/attach` | galactic-cni | Load/pin (`/sys/fs/bpf/galactic`)/attach/detach lifecycle; netlink-driven interface re-attachment; health check | Yes (pinned maps + attached TC filter) | +| `internal/plumbing/ebpf/usidmap` | galactic-cni | Read/write API (`Register`/`Unregister`/`Get`/`List`/`Reconcile`) for the three control-plane maps; `OpenPinnedRegistry` lets the short-lived CNI plugin binary open the `run` container's already-pinned maps | No (wraps state owned by `attach`) | +| `internal/plumbing/ebpf/metrics` | galactic-cni | Prometheus `Collector` (live map state, scraped on demand) + `EventCounters` (load/attach/detach events, pushed via `attach.Hooks`) | No | --- ## External Dependencies -| Dependency | Version | Purpose | -|-----------------------------------------|----------|----------------------------------------------------------| -| `github.com/osrg/gobgp/v4` | v4.7.0 | Embedded BGP server (tenant mode) | -| `go.datum.net/network` | bumped frequently | BGP CRD API types (BGPRouter, BGPPeer, BGPAdvertisement, BGPPolicy, BGPVRFInstance) | -| `sigs.k8s.io/controller-runtime` | v0.24.1 | Reconciler framework, manager, field indexes | -| `github.com/spf13/cobra` | v1.10.2 | CLI command/flag handling for both binaries | -| `github.com/spf13/viper` | v1.21.0 | Config resolution (flags/env/defaults) for `galactic-router` only; `galactic-cni` resolves config itself (conflist/env/API auto-detect in `internal/cni/config.go`) and does not import viper | -| `github.com/containernetworking/cni` | v1.3.0 | CNI plugin spec, skel, invoke | -| `github.com/containernetworking/plugins` | v1.9.1 | `host-device` plugin, delegated to for moving the guest veth into the pod netns | -| `github.com/vishvananda/netlink` | pinned pseudo-version | Linux netlink: VRF, veth, SRv6 routes | -| `github.com/kenshaw/baseconv` | v0.1.1 | Base62↔hex conversion for interface names | -| `github.com/lorenzosaino/go-sysctl` | v0.3.1 | Interface sysctl helpers | -| `github.com/coreos/go-iptables` | v0.8.0 | iptables manipulation (CNI path) | -| `google.golang.org/grpc` | v1.82.0 | gRPC health server (default :5000) | -| `k8s.io/api`, `k8s.io/client-go` | v0.36.0 | Kubernetes client, Node/Secret API types | +| Dependency | Version | Purpose | +| ---------------------------------------- | --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `github.com/osrg/gobgp/v4` | v4.7.0 | Embedded BGP server (tenant mode) | +| `go.datum.net/network` | bumped frequently | BGP CRD API types (BGPRouter, BGPPeer, BGPAdvertisement, BGPPolicy, BGPVRFInstance) | +| `sigs.k8s.io/controller-runtime` | v0.24.1 | Reconciler framework, manager, field indexes | +| `github.com/spf13/cobra` | v1.10.2 | CLI command/flag handling for both binaries | +| `github.com/spf13/viper` | v1.21.0 | Config resolution (flags/env/defaults) for `galactic-router` only; `galactic-cni` resolves config itself (conflist/env/API auto-detect in `internal/cni/config.go`) and does not import viper | +| `github.com/containernetworking/cni` | v1.3.0 | CNI plugin spec, skel, invoke | +| `github.com/containernetworking/plugins` | v1.9.1 | `host-device` plugin, delegated to for moving the guest veth into the pod netns | +| `github.com/vishvananda/netlink` | pinned pseudo-version | Linux netlink: VRF, veth, SRv6 routes | +| `github.com/kenshaw/baseconv` | v0.1.1 | Base62↔hex conversion for interface names | +| `github.com/lorenzosaino/go-sysctl` | v0.3.1 | Interface sysctl helpers | +| `github.com/coreos/go-iptables` | v0.8.0 | iptables manipulation (CNI path) | +| `google.golang.org/grpc` | v1.82.0 | gRPC health server (default :5000) | +| `k8s.io/api`, `k8s.io/client-go` | v0.36.0 | Kubernetes client, Node/Secret API types | +| `github.com/cilium/ebpf` | v0.22.0 | eBPF/TC-BPF loader + `bpf2go` code generator for the uSID datapath (`internal/plumbing/ebpf`, galactic-cni only) | +| `github.com/prometheus/client_golang` | v1.23.2 | Prometheus metrics for the eBPF datapath and its `/metrics` HTTP endpoint (galactic-cni only) | --- ## Key Design Decisions -- **USID per endpoint, router-side computation.** Each (VPC, VPCAttachment) pair is assigned a unique /128 USID computed entirely by the CNI (`resolveSRv6SID`/`srv6.ComputeSID`) from the owning `BGPRouter`'s `srv6Locator` + `nodeID` plus this attachment's VRFID — there is no config-supplied SID field. The CNI installs an END.DT46 decap route for that /128. VPC identity is not encoded in the SID itself — VPC scoping comes from the BGPVRFInstance's route target instead. +- **USID per endpoint, computed independently by both binaries.** Each (VPC, VPCAttachment) pair is assigned a unique /128 USID computed via `srv6.ComputeSID` from the owning `BGPRouter`'s `srv6Locator` + `nodeID` plus this attachment's locally-allocated Argument (`internal/cni/bgp.go`'s `allocateArgument`) — there is no config-supplied SID field. The CNI registers this attachment's Argument in the eBPF datapath's `vrf_table` (the only ingress/decap path); the router independently recomputes the identical SID (`internal/reconcile`) to advertise as the EVPN GWIPAddress — both must agree, since the CRD carries the Argument (as `VRFID`) and Function, not the SID itself. VPC identity is not encoded in the SID itself — VPC scoping comes from the BGPVRFInstance's route target instead. - **Base62 interface names.** Kernel interface names use the format `G{9-char-vpc-base62}{3-char-att-base62}{suffix}` (suffix: `V` = VRF, `H` = host veth/tap, `G` = guest veth pre-move), fitting in the 15-character kernel limit. The hex form is used for BGP route targets; base62 for kernel interfaces. - **GoBGP embedded, lazy-started.** GoBGP runs in-process (`--mode=tenant` only) and starts only when the first `BGPRouter` is reconciled for that router; `Apply` re-runs on every subsequent reconcile too (subject to hash-based no-op suppression), re-applying peers/VRFs/EVPN/policies each time. `listenPort` defaults to `179`; `-1` (outbound-only) is an operator choice for specific deployments, not the codebase default. ASN or RouterID changes trigger a full `Reconfigure` (fresh `BgpServer` — `StopBgp` is not called because it permanently terminates the v4 Serve loop). - **Overlay BGP port.** galactic-router peers connect outbound on port `1790` by default (configurable per-peer via `BGPPeer.spec.remotePort`). Port `179` is occupied by the underlay FRR `bgpd` on every node, so the overlay uses a non-conflicting port. The `BGPPeer` CRD defaults `remotePort` to `179` (the IANA BGP port); galactic-router overrides this to `1790` when the field is unset, so existing CRDs without an explicit value continue to work. Set `remotePort: 179` explicitly when peering with external BGP speakers that listen on the standard port. @@ -370,11 +418,11 @@ pod's IPAM bookkeeping and does not attempt to unwind kernel/CRD state — see t ## Testing -| Layer | Command | Framework | Scope | -|------------|------------------|---------------------|------------------------------------------------------------------------| -| Unit | `task test:unit` | `go test -race` | `internal/cni` (`cni_test.go`, `bgp_test.go`, `netns_test.go` — `buildResult`, `parseConf`, `routeTarget`, `lookupBGPRouter`), `internal/cni/{ipam,tap,veth}`, `internal/installer` (`installer_test.go` — `Bootstrap`/`Run` with mocked k8s client and netlink/host paths), `internal/plumbing/srv6`, `internal/gc`, `internal/reconcile`, `internal/controller`, `internal/plumbing/intf`, `internal/metadata`, `internal/runtime/gobgp` (partial), `internal/runtime/frr` | -| E2E | `task test:e2e` | Kind + `go test` | Full BGPRouter lifecycle in a Kind cluster; builds and loads image | -| CI full | `task ci` | all of the above | lint → build → test:unit → test:e2e | +| Layer | Command | Framework | Scope | +| ------- | ---------------- | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Unit | `task test:unit` | `go test -race` | `internal/cni` (`cni_test.go`, `bgp_test.go`, `netns_test.go`, `bgp_ebpf_test.go` — `buildResult`, `parseConf`, `routeTarget`, `lookupBGPRouter`, `allocateArgument`, `egressKindForInterfaceType`, `registerEBPFDatapath`/rollback), `internal/cni/{ipam,tap,veth}`, `internal/installer` (`installer_test.go` — `Bootstrap`/`Run` with mocked k8s client and netlink/host paths, plus a real-kernel `attach.StartWatching`-backed metrics/health integration test), `internal/plumbing/srv6`, `internal/gc` (incl. `gc_ebpf_test.go`'s `SweepEBPFVRFTable`), `internal/reconcile`, `internal/controller`, `internal/plumbing/intf`, `internal/plumbing/ebpf/{uformat,prog,preflight,attach,usidmap,metrics}` (pure-Go logic unit-tested everywhere; real-kernel `BPF_PROG_TEST_RUN`/map/attach coverage gated behind `requireRoot(t)`, run via `sudo -E env "PATH=$PATH" go test ...`), `internal/metadata`, `internal/runtime/gobgp` (partial), `internal/runtime/frr` | +| E2E | `task test:e2e` | Kind + `go test` | Full BGPRouter lifecycle in a Kind cluster; builds and loads image | +| CI full | `task ci` | all of the above | lint → build → test:unit → test:e2e | `internal/plumbing/vrf` has no unit tests — it requires `CAP_NET_ADMIN` and a real kernel. `internal/cni` and `internal/plumbing/srv6` now have unit coverage for their pure-logic paths (this used to not be the case). `internal/plumbing/intf` is pure-function and fully unit-testable. @@ -392,8 +440,10 @@ Runs on every PR and push to `main`. Two tiers: **Publish pipeline:** `.github/workflows/publish.yaml`, modeled on the `compute` repo's. Runs on every push and on published releases, via reusable `datum-cloud/actions` workflows: `publish-galactic-cni-image` and `publish-galactic-router-image` each build and push their own image (`ghcr.io/datum-cloud/galactic-cni`, `ghcr.io/datum-cloud/galactic-router`), and `publish-kustomize-bundles` (which `needs` both image jobs) pushes `config/` as an OCI Kustomize bundle (`ghcr.io/datum-cloud/galactic-kustomize`), using the `images` input (`datum-cloud/actions` v1.20.0+) to stamp each job's real published tag into `config/cni` and `config/router/base` respectively — the bundle ships with matching versioned image references, not `:latest`. This replaces the old single-image `.github/workflows/release.yaml` (removed — see history below) with two per-binary images, matching the split `deploy/containerlab/` already used for local dev. **Container images:** -- `containers/galactic-cni/Dockerfile` — multi-stage build (golang builder → distroless → final Alpine stage for `iproute2`/`nsenter`); builds `galactic-cni` plus the delegated `host-device` CNI plugin binary, `ENTRYPOINT ["/galactic-cni"]`. Used both by `task test:e2e` (`scripts/ci.sh e2etest` builds it, tags `galactic-cni:e2e`, `kind load`s it into the ephemeral e2e cluster) and by `publish.yaml` (pushed as `ghcr.io/datum-cloud/galactic-cni`). Both the init container (`/galactic-cni init`) and the long-running container (`/galactic-cni run`) run this same image; the DaemonSet no longer shells out to an `install.sh` script, so the Alpine/`iproute2` final stage exists purely for e2e test needs (kernel `ip`/`nsenter` operations exercised via `task test:e2e`) rather than anything the installer subcommands require. Reusing the e2e-tested artifact for publish is preferred over maintaining a second, untested variant. -- `containers/galactic-router/Dockerfile` — golang builder → `gcr.io/distroless/static:nonroot`, `ENTRYPOINT ["/galactic-router"]`. No shell or CLI tools: `galactic-router` drives VRF/SRv6/route/BGP state entirely through the netlink and GoBGP Go libraries, never shells out. Pushed by `publish.yaml` as `ghcr.io/datum-cloud/galactic-router`. +- `containers/galactic-cni/Dockerfile` — multi-stage build (golang builder → distroless → final Alpine stage for `iproute2`/`nsenter`); builds `galactic-cni` plus the delegated `host-device` CNI plugin binary, `ENTRYPOINT ["/galactic-cni"]`. Used both by `task test:e2e` (`scripts/ci.sh e2etest` builds it, tags `galactic-cni:e2e`, `kind load`s it into the ephemeral e2e cluster) and by `publish.yaml` (pushed as `ghcr.io/datum-cloud/galactic-cni`). Both the init container (`/galactic-cni init`) and the long-running container (`/galactic-cni run`) run this same image; the DaemonSet no longer shells out to an `install.sh` script, so the Alpine/`iproute2` final stage exists purely for e2e test needs (kernel `ip`/`nsenter` operations exercised via `task test:e2e`) rather than anything the installer subcommands require. Reusing the e2e-tested artifact for publish is preferred over maintaining a second, untested variant. The builder stage additionally installs `clang`/`llvm`/`linux-libc-dev` and runs `go generate ./internal/plumbing/ebpf/prog/...` before the Go build, to regenerate the eBPF uSID datapath's compiled object fresh every time rather than trusting the committed `usid_bpfel.o`/`usid_bpfeb.o` (see `task build:ebpf` below) — `linux-libc-dev` specifically works around a clang quirk where `-target bpfel`/`bpfeb` drops the Debian multiarch `/usr/include/` search path that ``'s own `` include needs (see `internal/plumbing/ebpf/prog/doc.go`'s `-idirafter` cflags). +- `containers/galactic-router/Dockerfile` — golang builder → `gcr.io/distroless/static:nonroot`, `ENTRYPOINT ["/galactic-router"]`. No shell or CLI tools: `galactic-router` drives VRF/SRv6/route/BGP state entirely through the netlink and GoBGP Go libraries, never shells out. Pushed by `publish.yaml` as `ghcr.io/datum-cloud/galactic-router`. Needs no eBPF toolchain of its own even though it now transitively imports `internal/plumbing/ebpf/{usidmap,uformat,prog}` (via `internal/gc`'s `SweepEBPFVRFTable`) — it never runs `go generate`, so it just compiles against the already-committed generated files like any other Go source. + +**Taskfile:** `task build:ebpf` (clang/LLVM → `bpf2go`, regenerating `internal/plumbing/ebpf/prog`'s compiled object and Go bindings) is a hard prerequisite of `task build` (and so of `task ci`) — any environment building `galactic-cni`, not just the Docker image above, needs `clang` installed. Fails with an actionable, non-cryptic error naming the missing dependency and install commands (Fedora/Debian) rather than a raw exec error when `clang` isn't on `PATH`. **History:** the original `.github/workflows/release.yaml` built and pushed a single `ghcr.io/datum-cloud/galactic:{version,major.minor,major,sha}` image from a shared `containers/galactic/Dockerfile`, but that image only ever built `galactic-cni` while `config/router/base/daemonset.yaml` ran `command: [/galactic-router]` against it — the image advertised a binary it never built. Both were removed. `publish.yaml` and the two per-binary Dockerfiles above fix this by building each binary into its own image, so `config/cni/daemonset.yaml` and `config/router/base/daemonset.yaml` now reference `ghcr.io/datum-cloud/galactic-cni:latest` and `ghcr.io/datum-cloud/galactic-router:latest` respectively — matching images, matching binaries. @@ -406,7 +456,8 @@ Runs on every PR and push to `main`. Two tiers: - **`cmdDel` does not tear down shared kernel/CRD state.** By design (see Key Design Decisions above) — cleanup of VRF, veth/tap, routes, SRv6 ingress, and BGP CRDs is deferred to `galactic-router`'s asynchronous GC controller, not performed synchronously in `cmdDel`. - **`internal/plumbing/vrf` has no unit tests.** It requires `CAP_NET_ADMIN` and a real kernel. `internal/cni` and `internal/plumbing/srv6` do now have unit coverage for their pure-logic paths. `internal/plumbing/intf` is fully unit-testable (pure functions only). Kernel-path coverage otherwise comes from the e2e suite (`task test:e2e`). - **`--mode=transit` is unimplemented.** Accepted by CLI/env validation, but `runCmd` returns an error at startup ("mode=transit is not yet supported"). -- **`galactic-cni`'s install DaemonSet is a Go installer, not a shell script.** `config/cni/configmap.yaml`/`install.sh` were deleted; `config/cni/daemonset.yaml` now runs `hostNetwork: true` with an `install-cni` init container (`command: ["/galactic-cni", "init"]`, calling `installer.Bootstrap`) and a `credential-refresh` main container (`command: ["/galactic-cni", "run"]`, calling `installer.Run`), both on the same image (see CI/CD above). `Bootstrap` writes the CNI binaries to `/opt/cni/bin`, the static conflist to `/etc/cni/net.d/10-galactic.conflist`, and `ca.crt`/kubeconfig to `/var/lib/galactic` (chosen over `/etc/galactic` specifically so it lands under `/var`, the one path immutable-root distros like Talos allow hostPath writes to without a host-level `extraMounts` entry); `Run` refreshes the kubeconfig token every 300s and rotates the CNI log once it exceeds 10MB. `/opt/cni/bin` is fixed by the CNI/kubelet plugin-discovery convention and can't be relocated by this DaemonSet alone — on Talos it needs its own `extraMounts` entry in the machine config if it isn't writable by default. The `run` container also serves gRPC health checks on port `5180` (`livenessProbe`/`readinessProbe` in the DaemonSet spec), and `config/cni/rbac.yaml` grants `get` on `nodes` for `Bootstrap`'s node-identity check. +- **`galactic-cni`'s install DaemonSet is a Go installer, not a shell script.** `config/cni/configmap.yaml`/`install.sh` were deleted; `config/cni/daemonset.yaml` now runs `hostNetwork: true` with an `install-cni` init container (`command: ["/galactic-cni", "init"]`, calling `installer.Bootstrap`) and a `credential-refresh` main container (`command: ["/galactic-cni", "run"]`, calling `installer.Run`), both on the same image (see CI/CD above). `Bootstrap` writes the CNI binaries to `/opt/cni/bin`, the static conflist to `/etc/cni/net.d/10-galactic.conflist`, and `ca.crt`/kubeconfig to `/var/lib/galactic` (chosen over `/etc/galactic` specifically so it lands under `/var`, the one path immutable-root distros like Talos allow hostPath writes to without a host-level `extraMounts` entry); `Run` refreshes the kubeconfig token every 300s and rotates the CNI log once it exceeds 10MB. `/opt/cni/bin` is fixed by the CNI/kubelet plugin-discovery convention and can't be relocated by this DaemonSet alone — on Talos it needs its own `extraMounts` entry in the machine config if it isn't writable by default. The `run` container also serves gRPC health checks on port `5180` (`livenessProbe`/`readinessProbe` in the DaemonSet spec) and Prometheus metrics on port `9091`, and `config/cni/rbac.yaml` grants `get`/`list` on `bgprouters` and `get`/`list`/`create`/`update`/`patch`/`delete` on `bgpvrfinstances`/`bgpadvertisements` (used both by the CNI plugin binary's ADD path and, now, by the `run` container's eBPF `vrf_table` GC sweep) plus `get` on `nodes` for `Bootstrap`'s node-identity check. +- **The `credential-refresh` container always exercises its `CAP_BPF`/`CAP_NET_ADMIN` grant.** `config/cni/daemonset.yaml` grants that container those capabilities and a `/sys/fs/bpf` hostPath mount; `internal/installer.Run` always calls into `internal/plumbing/ebpf/attach` to load/pin/attach the eBPF datapath (no flag gates this anymore — the eBPF datapath is the only forwarding path, 2026-08-02 cutover). Treat any further change to that container's `securityContext`/volumes as security-review-worthy. --- @@ -414,22 +465,22 @@ Runs on every PR and push to `main`. Two tiers: **Where to start for each concern:** -| Concern | Start here | -|--------------------------------------------|--------------------------------------------------------------| -| CNI attach/detach flow | `internal/cni/ops_add.go:cmdAdd`, `internal/cni/ops_del.go:cmdDel` (`internal/cni/cni.go` only holds `RunPlugin`) | -| CNI runtime config resolution (conflist/env/API auto-detect) | `internal/cni/config.go:parseConf`, `loadHostConf`, `detectNodeNameFromAPI` | -| BGP CRD publish (VRF + advertisement) | `internal/cni/bgp.go:publishBGPState` | -| CNI DaemonSet install/refresh | `internal/installer/installer.go:Bootstrap` (init container), `internal/installer/installer.go:Run` (long-running container) | -| CRD → BGP translation | `internal/reconcile/reconcile.go:BuildDesiredRouter` | -| BGP runtime application (GoBGP) | `internal/runtime/gobgp/runtime.go:Apply` | -| BGP peer / VRF / advertisement / policy CRUD | `internal/runtime/gobgp/peers.go`, `runtime.go` (`applyVRFs`), `paths.go`, `policies.go` | -| Controller watch graph | `internal/controller/bgprouter_controller.go:SetupWithManager` | -| CRD status update logic | `internal/controller/status.go`, `bgprouter_controller.go:updateRouterStatus` | -| Orphaned CRD/VRF garbage collection | `internal/controller/gc_controller.go`, `internal/gc/gc.go` | -| RBAC pre-flight self-check | `cmd/galactic-router/main.go:checkWatchPermissions` | -| Interface naming / base62 encoding | `internal/plumbing/intf/intf.go` | -| Hash-based no-op suppression | `internal/hash/hash.go`; annotation `galactic.datum.net/config-hash` on BGPRouter | -| GoBGP server lifecycle (start/reconfigure) | `internal/runtime/gobgp/server.go` | +| Concern | Start here | +| ------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------- | +| CNI attach/detach flow | `internal/cni/ops_add.go:cmdAdd`, `internal/cni/ops_del.go:cmdDel` (`internal/cni/cni.go` only holds `RunPlugin`) | +| CNI runtime config resolution (conflist/env/API auto-detect) | `internal/cni/config.go:parseConf`, `loadHostConf`, `detectNodeNameFromAPI` | +| BGP CRD publish (VRF + advertisement) | `internal/cni/bgp.go:publishBGPState` | +| CNI DaemonSet install/refresh | `internal/installer/installer.go:Bootstrap` (init container), `internal/installer/installer.go:Run` (long-running container) | +| CRD → BGP translation | `internal/reconcile/reconcile.go:BuildDesiredRouter` | +| BGP runtime application (GoBGP) | `internal/runtime/gobgp/runtime.go:Apply` | +| BGP peer / VRF / advertisement / policy CRUD | `internal/runtime/gobgp/peers.go`, `runtime.go` (`applyVRFs`), `paths.go`, `policies.go` | +| Controller watch graph | `internal/controller/bgprouter_controller.go:SetupWithManager` | +| CRD status update logic | `internal/controller/status.go`, `bgprouter_controller.go:updateRouterStatus` | +| Orphaned CRD/VRF garbage collection | `internal/controller/gc_controller.go`, `internal/gc/gc.go` | +| RBAC pre-flight self-check | `cmd/galactic-router/main.go:checkWatchPermissions` | +| Interface naming / base62 encoding | `internal/plumbing/intf/intf.go` | +| Hash-based no-op suppression | `internal/hash/hash.go`; annotation `galactic.datum.net/config-hash` on BGPRouter | +| GoBGP server lifecycle (start/reconfigure) | `internal/runtime/gobgp/server.go` | **Stable vs. frequently changed:** - Stable: `internal/plumbing/` (pure kernel primitives), `internal/model/types.go`, `internal/runtime/runtime.go` (interface) diff --git a/docs/cni-cmd-sequence.md b/docs/cni-cmd-sequence.md index e810b62..4965aed 100644 --- a/docs/cni-cmd-sequence.md +++ b/docs/cni-cmd-sequence.md @@ -64,18 +64,18 @@ sequenceDiagram CNI->>CNI: AddrAdd(gateway/128) on host veth CNI->>CNI: RouteAdd(subnet to host veth) in VRF table - CNI->>CNI: decode VPC hex + VRFID + CNI->>CNI: decode VPC hex CNI->>K8s: newK8sClient() CNI->>CNI: publishBGPStateK8s() (retry loop) activate CNI CNI->>K8s: lookupBGPRouter(node) - CNI->>CNI: resolveSRv6SID(locator, nodeID, vrfID) - CNI->>SRv6: RouteIngressAdd(sid, vpc, attachment) - activate SRv6 - SRv6->>SRv6: seg6local End.DT46 route - SRv6-->>CNI: ok - deactivate SRv6 + CNI->>CNI: allocateArgument(ctx, k8s, namespace, routerName, vrfInstanceName) -> vrfID (local per-node Argument) + CNI->>CNI: registerEBPFDatapath(bgp, vpc, attachment, ifaceType, vrfID, PinDir) + activate EBPF + EBPF->>EBPF: Locator.Register / Function.Register / VRF.Register (locator_table, function_table, vrf_table) + EBPF-->>CNI: ok (fatal to ADD on error -- this is the only forwarding path) + deactivate EBPF CNI->>K8s: CreateOrUpdate BGPVRFInstance CNI->>K8s: CreateOrUpdate BGPAdvertisement(prefix, annotations) CNI-->>Runtime: ok @@ -128,18 +128,18 @@ sequenceDiagram CNI->>CNI: buildTapResult(ipamResult) + PrintResult() - CNI->>CNI: decode VPC hex + VRFID + CNI->>CNI: decode VPC hex CNI->>K8s: newK8sClient() CNI->>CNI: publishBGPStateK8s() (retry loop) activate CNI CNI->>K8s: lookupBGPRouter(node) - CNI->>CNI: resolveSRv6SID(locator, nodeID, vrfID) - CNI->>SRv6: RouteIngressAdd(sid, vpc, attachment) - activate SRv6 - SRv6->>SRv6: seg6local End.DT46 route - SRv6-->>CNI: ok - deactivate SRv6 + CNI->>CNI: allocateArgument(ctx, k8s, namespace, routerName, vrfInstanceName) -> vrfID (local per-node Argument) + CNI->>CNI: registerEBPFDatapath(bgp, vpc, attachment, ifaceType, vrfID, PinDir) + activate EBPF + EBPF->>EBPF: Locator.Register / Function.Register / VRF.Register (locator_table, function_table, vrf_table) + EBPF-->>CNI: ok (fatal to ADD on error -- this is the only forwarding path) + deactivate EBPF CNI->>K8s: CreateOrUpdate BGPVRFInstance CNI->>K8s: CreateOrUpdate BGPAdvertisement(prefix, annotations) CNI-->>Runtime: ok @@ -175,7 +175,7 @@ sequenceDiagram end end - Note over CNI: Shared resources (VRF, interface, routes, SRv6,
BGPAdvertisement, BGPVRFInstance) are NOT deleted here.
They may be in use by another pod on the same (vpc, attachment).
The GC controller collects orphans periodically. + Note over CNI: Shared resources (VRF, interface, routes,
eBPF vrf_table entry, BGPAdvertisement, BGPVRFInstance) are NOT deleted here.
They may be in use by another pod on the same (vpc, attachment).
The GC controller (and, for vrf_table specifically, gc.SweepEBPFVRFTable) collects orphans periodically. CNI->>CNI: slog.Info("DEL: skipping shared resource cleanup (handled by GC)") CNI->>CNI: print empty result diff --git a/docs/cni/configuration.md b/docs/cni/configuration.md index 706119d..179f0e1 100644 --- a/docs/cni/configuration.md +++ b/docs/cni/configuration.md @@ -4,8 +4,11 @@ (or any CNI manager), plus node-local settings resolved at runtime from the conflist, environment variables, and (as a last resort) the Kubernetes API. -> Last verified: 2026-07-28 against the current working tree of `internal/cni/config.go`, -> `internal/cni/ipam_ops.go`, and `internal/installer/installer.go`. +> Last verified: 2026-08-02 against the current working tree of `internal/cni/config.go`, +> `internal/cni/ipam_ops.go`, `internal/installer/installer.go`, `internal/config/cni.go`, +> and `internal/plumbing/ebpf/prog/usid.c` — the eBPF uSID datapath is now the only +> forwarding path (direct cutover, not a phased rollout); `GALACTIC_CNI_ENABLE_EBPF_DATAPATH` +> and `GALACTIC_CNI_EBPF_OBSERVE_ONLY` no longer exist. ## Runtime Configuration @@ -84,6 +87,54 @@ and this environment variable has no effect on the allocation behavior. **Type:** bool **Default:** `false` +### eBPF uSID datapath + +The eBPF/TC-BPF `uFMT 48+16` uSID datapath (`.local/plan-ebpf-xdp-usid-datapath.md`) +is the only forwarding path for SRv6 uSID traffic — there is no legacy +static-route fallback and no feature flag to disable it. The DaemonSet's +long-lived `run` container (`internal/installer.Run`, via +`internal/plumbing/ebpf/attach`) always loads/pins/attaches the compiled +`usid_ingress` program at startup; a kernel preflight-check failure +(`internal/plumbing/ebpf/preflight`) is fatal to that container. The CNI +plugin binary's ADD path (`internal/cni/bgp.go`'s `registerEBPFDatapath`) +always registers this attachment's `vrf_table` entry; a registration +failure is fatal to the ADD. + +**Argument allocation.** `registerEBPFDatapath` uses a real, +per-node-allocated 12-bit Argument value (`internal/cni/bgp.go`'s +`allocateArgument`) — the same value the router independently recomputes +the BGP-advertised SID from (`internal/reconcile`). + +**Both `veth` and `tap` modes supported.** The datapath's final redirect +step (`internal/plumbing/ebpf/prog/usid.c`) picks a redirect helper per +`vrf_table` entry: `bpf_redirect_peer` for `veth` attachments (the +resolved egress interface's peer lives in the container's netns) or plain +`bpf_redirect` for `tap` attachments (`internal/cni/tap` never moves the +interface out of this netns, so there is no peer to cross into). +`registerEBPFDatapath` sets this per-entry from the CNI's own +`interface_type`, so no manual configuration is needed. The branch logic +itself is simple and verifier-accepted, but a real FIB-lookup-and-redirect +success/failure by egress kind requires a live route/interface (a real +net_device backing the packet) to observe — `BPF_PROG_TEST_RUN`, used for +this program's other unit tests, cannot simulate that without one, so this +specific behavior is verified in a live cluster (ContainerLab or e2e), not +by a kernel-level unit test. + +| Variable | Description | Type | Default | +| ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------ | --------------- | +| `GALACTIC_CNI_EBPF_INTERFACES` | Comma-separated list of interface names the eBPF datapath attaches its TC-BPF ingress hook to, overriding auto-detection (the interface(s) currently carrying the default IPv6 route). For multi-homed nodes where auto-detection is ambiguous. | string (comma-separated) | _(auto-detect)_ | + +The `run` container also exposes Prometheus metrics (packets/bytes per +Argument, drops by reason, load/attach/detach event counts, and per-Block +Argument-space utilization — `internal/plumbing/ebpf/metrics`) at +`/metrics` on the port set by `galactic-cni run --metrics-port` +(default `9091`; alongside the existing `--grpc-health-port`, default +`5180`), regardless of whether the flag above is set — datapath-specific +series are simply absent/zero until it is. A separate gRPC health service +named `ebpf-datapath` (distinct from the always-serving `""` overall +service) reports the live result of `internal/plumbing/ebpf/attach.Health` +once the datapath has actually started. + ## CNI Configuration JSON The CNI configuration is a JSON object passed at pod creation time. It extends @@ -93,7 +144,7 @@ the standard CNI `PluginConf` with Galactic-specific fields. | Field | Required | Type | Description | | ---------------- | -------- | --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `vpc` | **Yes** | `string` | Base62-encoded VPC identifier (48-bit value). Used to derive VRF names, interface names, and BGP route targets. | +| `vpc` | **Yes** | `string` | Base62-encoded VPC identifier (16-bit value, cluster-scoped). Used to derive VRF names, interface names, and BGP route targets. | | `vpcattachment` | **Yes** | `string` | Base62-encoded VPC attachment identifier (16-bit value). Paired with `vpc` for deterministic VRF/BGP naming. | | `interface_type` | No | `string` | Interface mode: `"veth"` (default, for containers) or `"tap"` (for VMs such as Kata, Firecracker, QEMU). Both modes run IPAM and SRv6/BGP publish; `tap` mode only skips host-device delegation and guest-netns configuration (see the Tap mode section below). | | `mtu` | No | `int` | MTU for the host-side interface. For `veth` mode this applies to both veth endpoints; for `tap` mode it applies to the tap interface. | diff --git a/docs/ebpf-datapath-sequence.md b/docs/ebpf-datapath-sequence.md new file mode 100644 index 0000000..9cdff30 --- /dev/null +++ b/docs/ebpf-datapath-sequence.md @@ -0,0 +1,155 @@ +# eBPF uSID Datapath Sequence Diagrams + +Sequence diagrams for the eBPF/TC-BPF `uFMT 48+16` uSID datapath (design plan +`.local/plan-ebpf-xdp-usid-datapath.md`; implementation plan +`.local/implementation-plan-ebpf-xdp-usid-datapath.md`), covering the +`run` container's startup/load/attach path and the CNI ADD path's map +registration. This is the only forwarding path — there is no legacy +static-route fallback and no feature flag to disable it (removed in the +2026-08-02 direct cutover; see +[docs/cni/configuration.md](cni/configuration.md#ebpf-usid-datapath)). + +See [docs/cni-cmd-sequence.md](cni-cmd-sequence.md) for the pre-existing +`cmdAdd`/`cmdDel` diagrams this one supplements, not replaces. + +## `run` container startup — datapath load, attach, health, GC sweep + +```mermaid +sequenceDiagram + autonumber + participant Main as cmd/galactic-cni (run) + participant Installer as internal/installer.Run + participant Attach as plumbing/ebpf/attach + participant Preflight as plumbing/ebpf/preflight + participant Kernel + participant Metrics as plumbing/ebpf/metrics + participant GC as internal/gc.SweepEBPFVRFTable + participant K8s + + Main->>Installer: Run(ctx, grpcHealthPort, metricsPort) + activate Installer + Installer->>Installer: startEBPFDatapath(ctx, m) + Installer->>Attach: SetHooks(m.Events.Hooks()) + Installer->>Attach: StartWatching(ctx, PinDir) + activate Attach + Attach->>Preflight: Check() + activate Preflight + Preflight->>Kernel: probe SCHED_CLS, HASH maps, BTF, fib_lookup+tbid + Kernel-->>Preflight: capabilities present/absent + Preflight-->>Attach: nil, or an actionable aggregated error + deactivate Preflight + alt preflight failed + Attach-->>Installer: error + Installer-->>Main: fatal error (container crashes, CrashLoopBackOff -- no fallback path exists) + else preflight passed + Attach->>Kernel: load compiled usid_ingress + pin maps under PinDir + Attach->>Attach: ResolveInterfaces() (GALACTIC_CNI_EBPF_INTERFACES override or auto-detect default-route ifaces) + Attach->>Kernel: Attach TC-BPF ingress filter to resolved interfaces + Attach->>Attach: spawn Watch() goroutine (netlink link/route subscriptions) + Attach-->>Installer: *prog.UsidObjects, ifaces, nil + end + deactivate Attach + Installer->>Metrics: RegisterDatapathCollector(objs) + Installer->>K8s: newK8sClientFn() (best-effort, for the GC sweep below) + Installer->>Installer: loadHostConf(HostConflist) -> namespace, nodeName + + Installer->>Installer: serve /metrics (metricsPort), gRPC health (grpcHealthPort) + Installer->>Installer: SetServingStatus("", SERVING); SetServingStatus("ebpf-datapath", SERVING) + + loop every ebpfHealthCheckInterval (10s) + Installer->>Attach: Health(objs, ifaces) + Attach->>Kernel: confirm TC filter still attached + program/maps still reachable + Kernel-->>Attach: ok / error + Attach-->>Installer: nil / error + Installer->>Installer: SetServingStatus("ebpf-datapath", SERVING/NOT_SERVING) + end + + loop every ebpfGCSweepInterval (5m) + Installer->>GC: SweepEBPFVRFTable(ctx, k8sClient, namespace, nodeName, PinDir) + activate GC + GC->>Kernel: VRF.Generation() (cutoff, captured before listing CRDs) + GC->>K8s: list BGPRouters (this node) + BGPVRFInstances + GC->>GC: derive live (Block, Argument) set via uformat.Block + inst.Spec.VRFID directly + GC->>Kernel: VRF.Reconcile(live, cutoff) -- deletes stale entries, keeps Generation>=cutoff + GC-->>Installer: CleanupResult{EBPFVRFEntriesRemoved, Errors} + deactivate GC + end + + Note over Installer: ctx.Done() -> graceful shutdown; deferred datapath.Close() releases this process's map/program fds (pinned maps persist for the next restart) + deactivate Installer +``` + +## CNI ADD — eBPF `vrf_table` registration + +```mermaid +sequenceDiagram + autonumber + participant Runtime + participant CNI as internal/cni (cmdAdd) + participant BGP as internal/cni/bgp.go + participant USIDMap as plumbing/ebpf/usidmap + participant PinnedMaps as pinned vrf_table/locator_table/function_table + + Runtime->>CNI: ADD + activate CNI + Note over CNI: VRF, veth/tap, IPAM as in docs/cni-cmd-sequence.md + + CNI->>BGP: publishBGPStateK8s(...) + activate BGP + BGP->>BGP: lookupBGPRouter() -> srv6Locator, nodeID + BGP->>BGP: allocateArgument(ctx, k8s, namespace, routerName, vrfInstanceName) -> vrfID (12-bit Argument, local per-node allocation) + BGP->>BGP: egressKindForInterfaceType(pluginConf.InterfaceType) -> EgressKindVeth | EgressKindTap + BGP->>BGP: ComputeSID(srv6Locator, nodeID, vrfID, FunctionEndDT46) (for the router's independent BGP-advertised SID recomputation; the CNI no longer installs a kernel route from it) + + BGP->>BGP: registerEBPFDatapath(bgp, vpc, vpcAttachment, ifaceType, vrfID, attach.PinDir) + activate BGP + alt BGPRouter not configured (no srv6Locator/nodeID) + BGP-->>BGP: registered=false, nil (SRv6 intentionally not set up for this attachment) + else configured + BGP->>BGP: uformat.Block(netip.ParsePrefix(srv6Locator).Addr()) + BGP->>USIDMap: OpenPinnedRegistry(PinDir) + USIDMap->>PinnedMaps: ebpf.LoadPinnedMap x3 (open, don't create) + PinnedMaps-->>USIDMap: map handles + USIDMap-->>BGP: Registry, closer + BGP->>USIDMap: Locator.Register(block, nodeID) + BGP->>USIDMap: Function.Register(block, FunctionEndDT46) + BGP->>USIDMap: VRF.Register(block, vrfID, vrf.TableID(vpc, vpcAttachment), egressKind) + USIDMap->>PinnedMaps: Put x3 + BGP->>USIDMap: closer.Close() (this process's own fd only; pinned maps persist) + BGP-->>BGP: registered=true, block, nil + end + deactivate BGP + BGP->>BGP: on error, return it -- fatal to the ADD (no fallback path exists) + Note over BGP: on registered=true, tracker.ebpfRegistered/ebpfBlock/ebpfArgument recorded for rollback (see below) + deactivate BGP + deactivate CNI +``` + +## Failed-ADD rollback — unregistering the eBPF entry + +```mermaid +sequenceDiagram + autonumber + participant CNI as internal/cni (cmdAdd, failure path) + participant Tracker as resourceTracker.cleanup + participant BGP as internal/cni/bgp.go + participant USIDMap as plumbing/ebpf/usidmap + + CNI->>Tracker: cleanup(ctx) + activate Tracker + Note over Tracker: reverse creation order + alt tracker.ebpfRegistered + Tracker->>BGP: unregisterEBPFDatapath(block, argument, attach.PinDir) + BGP->>USIDMap: OpenPinnedRegistry(PinDir) + BGP->>USIDMap: VRF.Unregister(block, argument) + Note over BGP: idempotent -- not an error if already absent + end + Note over Tracker: veth/tap delete, VRF delete follow, as in docs/cni-cmd-sequence.md + deactivate Tracker +``` + +Steady-state (non-failed-ADD) teardown of the `vrf_table` entry is +deliberately **not** part of `cmdDel` — matching this repo's existing +"DEL is intentionally minimal" design (`docs/agents/ARCHITECTURE.md`'s +Known Constraints) — it is instead the `run` container's periodic +`gc.SweepEBPFVRFTable` shown in the first diagram above. diff --git a/go.mod b/go.mod index 3e0ef29..9196f57 100644 --- a/go.mod +++ b/go.mod @@ -3,12 +3,15 @@ module go.datum.net/galactic go 1.26.0 require ( + github.com/cilium/ebpf v0.22.0 github.com/containernetworking/cni v1.3.0 github.com/containernetworking/plugins v1.9.1 github.com/coreos/go-iptables v0.8.0 github.com/kenshaw/baseconv v0.1.1 github.com/lorenzosaino/go-sysctl v0.3.1 github.com/osrg/gobgp/v4 v4.7.0 + github.com/prometheus/client_golang v1.23.2 + github.com/prometheus/client_model v0.6.2 github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 github.com/spf13/viper v1.21.0 @@ -24,7 +27,7 @@ require ( ) require ( - github.com/BurntSushi/toml v1.5.0 // indirect + github.com/BurntSushi/toml v1.6.0 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect @@ -57,8 +60,6 @@ require ( github.com/orcaman/concurrent-map/v2 v2.0.1 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect - github.com/prometheus/client_golang v1.23.2 // indirect - github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.67.5 // indirect github.com/prometheus/procfs v0.19.2 // indirect github.com/sagikazarmark/locafero v0.11.0 // indirect @@ -73,7 +74,7 @@ require ( go.uber.org/zap v1.28.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/exp/typeparams v0.0.0-20220613132600-b0d781184e0d // indirect + golang.org/x/exp/typeparams v0.0.0-20260209203927-2842357ff358 // indirect golang.org/x/lint v0.0.0-20210508222113-6edffad5e616 // indirect golang.org/x/mod v0.34.0 // indirect golang.org/x/net v0.53.0 // indirect @@ -82,14 +83,13 @@ require ( golang.org/x/text v0.36.0 // indirect golang.org/x/time v0.14.0 // indirect golang.org/x/tools v0.43.0 // indirect - golang.org/x/tools/go/expect v0.1.1-deprecated // indirect gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect - honnef.co/go/tools v0.3.2 // indirect + honnef.co/go/tools v0.7.0 // indirect k8s.io/apiextensions-apiserver v0.36.0 // indirect k8s.io/klog/v2 v2.140.0 // indirect k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a // indirect @@ -99,3 +99,5 @@ require ( sigs.k8s.io/structured-merge-diff/v6 v6.3.3 // indirect sigs.k8s.io/yaml v1.6.0 // indirect ) + +tool github.com/cilium/ebpf/cmd/bpf2go diff --git a/go.sum b/go.sum index 1286253..68ade8c 100644 --- a/go.sum +++ b/go.sum @@ -1,11 +1,13 @@ -github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= -github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= +github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cilium/ebpf v0.22.0 h1:v2ktp0roffpMOj2MMf3idtCQZOsAoC4BJbAJN+ke2bY= +github.com/cilium/ebpf v0.22.0/go.mod h1:CDzZbe2hC5JjlDC+CY3KFCzlYwN4gbxppYM+Z10bQt4= github.com/containernetworking/cni v1.3.0 h1:v6EpN8RznAZj9765HhXQrtXgX+ECGebEYEmnuFjskwo= github.com/containernetworking/cni v1.3.0/go.mod h1:Bs8glZjjFfGPHMw6hQu82RUgEPNGEaBb9KS5KtNMnJ4= github.com/containernetworking/plugins v1.9.1 h1:8oU6WsIsU3bpnNZuvHp74a6cE1MJwbj2P7s4/yTUNlA= @@ -52,6 +54,8 @@ github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= +github.com/go-quicktest/qt v1.101.1-0.20240301121107-c6c8733fa1e6 h1:teYtXy9B7y5lHTp8V9KPxpYRAVA7dozigQcMiBust1s= +github.com/go-quicktest/qt v1.101.1-0.20240301121107-c6c8733fa1e6/go.mod h1:p4lGIVX+8Wa6ZPNDvqcxq36XpUDLh42FLetFU7odllI= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= @@ -192,8 +196,8 @@ go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/exp/typeparams v0.0.0-20220613132600-b0d781184e0d h1:+W8Qf4iJtMGKkyAygcKohjxTk4JPsL9DpzApJ22m5Ic= -golang.org/x/exp/typeparams v0.0.0-20220613132600-b0d781184e0d/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= +golang.org/x/exp/typeparams v0.0.0-20260209203927-2842357ff358 h1:qWFG1Dj7TBjOjOvhEOkmyGPVoquqUKnIU0lEVLp8xyk= +golang.org/x/exp/typeparams v0.0.0-20260209203927-2842357ff358/go.mod h1:4Mzdyp/6jzw9auFDJ3OMF5qksa7UvPnzKqTVGcb04ms= golang.org/x/lint v0.0.0-20210508222113-6edffad5e616 h1:VLliZ0d+/avPrXXH+OakdXhpJuEoBZuwh1m2j7U6Iug= golang.org/x/lint v0.0.0-20210508222113-6edffad5e616/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= @@ -247,8 +251,8 @@ gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -honnef.co/go/tools v0.3.2 h1:ytYb4rOqyp1TSa2EPvNVwtPQJctSELKaMyLfqNP4+34= -honnef.co/go/tools v0.3.2/go.mod h1:jzwdWgg7Jdq75wlfblQxO4neNaFFSvgc1tD5Wv8U0Yw= +honnef.co/go/tools v0.7.0 h1:w6WUp1VbkqPEgLz4rkBzH/CSU6HkoqNLp6GstyTx3lU= +honnef.co/go/tools v0.7.0/go.mod h1:pm29oPxeP3P82ISxZDgIYeOaf9ta6Pi0EWvCFoLG2vc= k8s.io/api v0.36.0 h1:SgqDhZzHdOtMk40xVSvCXkP9ME0H05hPM3p9AB1kL80= k8s.io/api v0.36.0/go.mod h1:m1LVrGPNYax5NBHdO+QuAedXyuzTt4RryI/qnmNvs34= k8s.io/api v0.36.3 h1:NxB+05W2UGqXWFXcLO0RB5cnqnUPP5v5sVlaOH0Iz4w= diff --git a/internal/cni/bgp.go b/internal/cni/bgp.go index 4007d91..378284e 100644 --- a/internal/cni/bgp.go +++ b/internal/cni/bgp.go @@ -10,6 +10,7 @@ import ( "fmt" "log/slog" "net" + "net/netip" "strconv" "syscall" "time" @@ -22,8 +23,10 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "go.datum.net/galactic/internal/plumbing/ebpf/attach" + "go.datum.net/galactic/internal/plumbing/ebpf/uformat" + "go.datum.net/galactic/internal/plumbing/ebpf/usidmap" "go.datum.net/galactic/internal/plumbing/intf" - "go.datum.net/galactic/internal/plumbing/srv6" "go.datum.net/galactic/internal/plumbing/vrf" bgpv1alpha1 "go.datum.net/network/api/v1alpha1" ) @@ -121,7 +124,7 @@ func bgpAdvertisementName(vpc, vpcAttachment string) string { // routeTarget returns the RT in "ASN:NN" format using the low 32 bits of the // VPC identifier. All nodes in the same VRF produce the same value, enabling -// VPC-scoped route import/export. vpcHex is the 48-bit hex VPC identifier. +// VPC-scoped route import/export. vpcHex is the 16-bit hex VPC identifier. func routeTarget(asNumber int64, vpcHex string) (string, error) { v, err := strconv.ParseUint(vpcHex, 16, 64) if err != nil { @@ -130,39 +133,55 @@ func routeTarget(asNumber int64, vpcHex string) (string, error) { return fmt.Sprintf("%d:%d", asNumber, uint32(v)), nil } -// vrfIDFromAttachment decodes the base62-encoded VPCAttachment identifier -// into the numeric 16-bit VRFID required by BGPVRFInstanceSpec.VRFID and -// BGPAdvertisementSpec.VRFID. -func vrfIDFromAttachment(vpcAttachment string) (int32, error) { - hex, err := intf.Base62ToHex(vpcAttachment) - if err != nil { - return 0, fmt.Errorf("decode VPCAttachment %q: %w", vpcAttachment, err) - } - v, err := strconv.ParseUint(hex, 16, 16) - if err != nil { - return 0, fmt.Errorf("parse VPCAttachment hex %q as VRFID: %w", hex, err) - } - return int32(v), nil -} - -// resolveSRv6SID determines the SRv6 SID to use for this endpoint's ingress -// decap route. +// allocateArgument returns the 12-bit Argument value (design plan +// .local/plan-ebpf-xdp-usid-datapath.md §4.2, §5.2) for the VPC attachment +// named vrfInstanceName under routerName: the value already registered if +// a BGPVRFInstance with that exact name exists (an idempotent CNI ADD +// retry, or a repeat ADD on an attachment that is already live), or -- if +// none does -- the lowest unused value in +// [uformat.ArgumentMin, uformat.ArgumentMax] among that router's other +// BGPVRFInstances. // -// - When the router has both srv6Locator and nodeID configured, -// the SID is computed from the locator, nodeID, and vrfID using the -// End.DT46 function — the only endpoint behavior CNI's kernel ingress -// route ever installs (see setupSRv6Ingress/srv6.RouteIngressAdd). -// - Otherwise, an empty string is returned with no error, matching today's -// behavior of skipping SRv6 ingress setup entirely. -func resolveSRv6SID(bgp bgpConfig, vrfID int32) (string, error) { - if bgp.srv6Locator == "" || bgp.nodeID == 0 { - return "", nil +// Scoping this to one BGPRouter -- one per node, in the tenant role -- is +// what makes this a local, per-node allocation rather than a +// platform-wide one: confirmed 2026-08-02 that the eBPF datapath's +// vrf_table is populated and consulted entirely per node (a packet only +// reaches it after locator_table has already routed it to that specific +// node), so no two nodes ever need to agree on a shared Argument +// numbering authority. This mirrors +// internal/plumbing/vrf.findNextAvailableVRFID's own first-available-slot +// pattern for the Linux kernel VRF table ID, just scoped to +// BGPVRFInstance CRD state -- the same "CRD state is the source of +// truth" pattern the GC controller already uses for VRFs -- instead of +// kernel state, so this works whether or not the eBPF datapath is even +// enabled on this node. +func allocateArgument( + ctx context.Context, k8s client.Client, namespace, routerName, vrfInstanceName string, +) (int32, error) { + list := &bgpv1alpha1.BGPVRFInstanceList{} + if err := k8s.List(ctx, list, client.InNamespace(namespace)); err != nil { + return 0, fmt.Errorf("list BGPVRFInstances in namespace %s: %w", namespace, err) + } + + used := make(map[int32]struct{}, len(list.Items)) + for _, inst := range list.Items { + if inst.Spec.RouterRef == nil || inst.Spec.RouterRef.Name != routerName { + continue // not one of this node's router's VRF instances + } + if inst.Name == vrfInstanceName { + // Idempotent: this attachment already has an Argument. + return inst.Spec.VRFID, nil + } + used[inst.Spec.VRFID] = struct{}{} } - sid, err := srv6.ComputeSID(bgp.srv6Locator, bgp.nodeID, vrfID, bgpv1alpha1.SRv6FunctionEndDT46) - if err != nil { - return "", fmt.Errorf("compute SRv6 SID: %w", err) + + for arg := int32(uformat.ArgumentMin); arg <= int32(uformat.ArgumentMax); arg++ { + if _, ok := used[arg]; !ok { + return arg, nil + } } - return sid.String(), nil + return 0, fmt.Errorf("allocate SID argument: router %s has no free Argument in [%#x,%#x] (all %d in use)", + routerName, uint16(uformat.ArgumentMin), uint16(uformat.ArgumentMax), len(used)) } // lookupBGPRouter finds the BGPRouter targeting this node in the given namespace. @@ -222,9 +241,9 @@ func buildVRFInstanceSpec(routerName, rtValue string, vrfID int32) bgpv1alpha1.B // galactic-router's buildEVPNPaths for the corresponding per-family gateway // handling. VRFID and Function record structurally what used to live in the // legacy galactic.datum.net/srv6-sid annotation: which VRF this advertisement -// belongs to, and which SRv6 endpoint behavior the CNI kernel ingress route -// installs (always End.DT46, regardless of pod-subnet address family — see -// setupSRv6Ingress/srv6.RouteIngressAdd). +// belongs to, and which SRv6 endpoint behavior the eBPF uSID datapath +// resolves (always End.DT46, regardless of pod-subnet address family — see +// registerEBPFDatapath). func buildAdvertisementSpec( routerName, rtValue string, prefixes []string, vrfID int32, ) bgpv1alpha1.BGPAdvertisementSpec { @@ -266,10 +285,10 @@ func newK8sClient() (client.Client, error) { // fail immediately without retry. func publishBGPState( args *skel.CmdArgs, pluginConf *PluginConf, nodeName, namespace string, ipamResult *ipamResult, - tracker *resourceTracker, + guestHWAddr net.HardwareAddr, tracker *resourceTracker, ) error { // ---- non-k8s operations (run once) ---- - if err := configureHostGateway(pluginConf.VPC, pluginConf.VPCAttachment, ipamResult); err != nil { + if err := configureHostGateway(pluginConf.VPC, pluginConf.VPCAttachment, ipamResult, guestHWAddr); err != nil { return err } @@ -278,17 +297,16 @@ func publishBGPState( return fmt.Errorf("decode VPC: %w", err) } - vrfID, err := vrfIDFromAttachment(pluginConf.VPCAttachment) - if err != nil { - return err - } - if tracker.k8s == nil { return errors.New("k8s client not set in tracker") } // ---- k8s operations (retry on transient errors) ---- - return publishBGPStateK8s(args, pluginConf, nodeName, namespace, ipamResult, vpcHex, vrfID, tracker.k8s, tracker) + // The SID Argument is allocated inside publishBGPStateK8s's retry + // closure, not here: it depends on this node's BGPRouter (looked up + // there) and must itself be a k8s-retried operation, since it lists + // BGPVRFInstance CRDs. + return publishBGPStateK8s(args, pluginConf, nodeName, namespace, ipamResult, vpcHex, tracker.k8s, tracker) } // ipamAdvertisementPrefixes derives the BGPAdvertisement prefixes to @@ -322,7 +340,7 @@ func ipamAdvertisementPrefixes(ipamResult *ipamResult) (prefixes []string, ipv6S // used by both veth and tap code paths. func publishBGPStateK8s( args *skel.CmdArgs, pluginConf *PluginConf, nodeName, namespace string, ipamResult *ipamResult, - vpcHex string, vrfID int32, k8s client.Client, tracker *resourceTracker, + vpcHex string, k8s client.Client, tracker *resourceTracker, ) error { return retryK8sOps(cniTimeout, func(ctx context.Context) error { bgp, err := lookupBGPRouter(ctx, k8s, nodeName, namespace) @@ -330,26 +348,39 @@ func publishBGPStateK8s( return err } - rtValue, err := routeTarget(int64(bgp.asNumber), vpcHex) + vrfID, err := allocateArgument( + ctx, k8s, namespace, bgp.routerName, bgpVRFInstanceName(pluginConf.VPC, pluginConf.VPCAttachment)) if err != nil { - return fmt.Errorf("compute route target: %w", err) + return err } - // SID resolution needs the router's srv6Locator/nodeID, so it happens - // here rather than in the non-k8s section above. RouteIngressAdd is - // idempotent, so re-running it on retry is safe. - sidStr, err := resolveSRv6SID(bgp, vrfID) + rtValue, err := routeTarget(int64(bgp.asNumber), vpcHex) if err != nil { - return err + return fmt.Errorf("compute route target: %w", err) } - srv6SIDStr, err := setupSRv6Ingress(sidStr, pluginConf.VPC, pluginConf.VPCAttachment) + + // eBPF uSID datapath registration -- the only forwarding path + // (the legacy seg6local static-route path was removed once this + // datapath covered both veth and tap attachments). registered is + // false, with no error, only when the router has no + // srv6Locator/nodeID configured at all -- SRv6 is intentionally + // not set up for this attachment. Any other failure is fatal: + // with no legacy path to fall back to, an attachment with no + // registered datapath entry has no forwarding path at all. + // registerEBPFDatapath is itself idempotent (Register + // overwrites), so re-running it on a k8s-op retry is safe. + registered, ebpfBlock, err := registerEBPFDatapath( + bgp, pluginConf.VPC, pluginConf.VPCAttachment, pluginConf.InterfaceType, uint16(vrfID), attach.PinDir) if err != nil { - return err + return fmt.Errorf("register eBPF uSID datapath: %w", err) } - if srv6SIDStr != "" { - tracker.srv6SID = srv6SIDStr - slog.Debug("BGP: SRv6 ingress route installed", "sid", srv6SIDStr, - "vpc", pluginConf.VPC, "vpcAttachment", pluginConf.VPCAttachment) + if registered { + // Recorded so a failed-ADD rollback (resourceTracker.cleanup) + // can unregister this exact (block, argument) pair -- see + // Milestone 7.2. + tracker.ebpfRegistered = true + tracker.ebpfBlock = ebpfBlock + tracker.ebpfArgument = uint16(vrfID) } // Create the BGPVRFInstance to configure the VRF with its VRFID and @@ -448,7 +479,14 @@ func routeConflicts(existing, desired *netlink.Route) bool { // absorbs seg6local-decapped inner packets before they reach the guest // interface. The explicit subnet route replaces the one the kernel would // have created from the wider mask. -func configureHostGateway(vpc, vpcAttachment string, res *ipamResult) error { +// +// guestHWAddr is the guest-side veth's MAC address, used to prime a +// permanent neighbor table entry for the pod's own address (see +// installGatewayNeighbor). It is nil for tap attachments, which have no +// separate guest-side link in this netns to resolve a MAC from -- tap's +// neighbor resolution, if it turns out to need the same fix, is out of +// scope here since this fix targets the veth-only bug it was found from. +func configureHostGateway(vpc, vpcAttachment string, res *ipamResult, guestHWAddr net.HardwareAddr) error { if res == nil { return nil } @@ -467,6 +505,11 @@ func configureHostGateway(vpc, vpcAttachment string, res *ipamResult) error { if err := installGatewayRoute(hostLink, gwNet, res.ipv6Subnet, netlink.FAMILY_V6, int(tableID)); err != nil { return err } + if guestHWAddr != nil { + if err := installGatewayNeighbor(hostLink, res.ipv6Subnet.IP, netlink.FAMILY_V6, guestHWAddr); err != nil { + return err + } + } } if res.ipv4Gateway != nil { gwNet := &net.IPNet{IP: res.ipv4Gateway, Mask: net.CIDRMask(32, 32)} @@ -474,6 +517,44 @@ func configureHostGateway(vpc, vpcAttachment string, res *ipamResult) error { if err := installGatewayRoute(hostLink, gwNet, ipv4Subnet, netlink.FAMILY_V4, int(tableID)); err != nil { return err } + if guestHWAddr != nil { + if err := installGatewayNeighbor(hostLink, res.ipv4Address, netlink.FAMILY_V4, guestHWAddr); err != nil { + return err + } + } + } + return nil +} + +// installGatewayNeighbor installs a permanent neighbor table entry mapping +// podIP to guestHWAddr on hostLink. +// +// The eBPF uSID ingress datapath (internal/plumbing/ebpf/prog/usid.c) +// decapsulates SRv6 traffic and calls bpf_fib_lookup() to resolve the +// egress path for the inner packet, then redirects it straight to the +// resolved neighbor -- entirely in-kernel, never touching the normal +// forwarding stack. bpf_fib_lookup() does not itself trigger ARP/NDP +// resolution the way ordinary kernel packet forwarding does (that +// resolution happens as a side effect of the slow-path forwarding this +// datapath deliberately bypasses), so without a pre-existing neighbor table +// entry it fails with BPF_FIB_LKUP_RET_NO_NEIGH and the datapath counts and +// drops the packet (DROP_REASON_FIB_LOOKUP_FAILED) -- confirmed live: every +// cross-region packet to a pod that had never otherwise triggered NDP for +// its own address was silently and permanently blackholed, since nothing +// else in this attach path ever resolves it. A permanent entry (installed +// once, at CNI ADD, using the guest veth's own known MAC) means this +// resolution never depends on dynamic ARP/NDP at all. +func installGatewayNeighbor(hostLink netlink.Link, podIP net.IP, family int, guestHWAddr net.HardwareAddr) error { + neigh := &netlink.Neigh{ + LinkIndex: hostLink.Attrs().Index, + Family: family, + State: netlink.NUD_PERMANENT, + IP: podIP, + HardwareAddr: guestHWAddr, + } + if err := netlink.NeighSet(neigh); err != nil { + return fmt.Errorf("add permanent neighbor %s -> %s on host interface %q: %w", + podIP, guestHWAddr, hostLink.Attrs().Name, err) } return nil } @@ -531,14 +612,119 @@ func installGatewayRoute(hostLink netlink.Link, gwNet, subnet *net.IPNet, family return nil } -// setupSRv6Ingress installs the End.DT46 SRv6 ingress decap route for the given -// USID and returns the SID string. Returns empty string when SID is not configured. -func setupSRv6Ingress(sid, vpc, vpcAttachment string) (string, error) { - if sid == "" { - return "", nil +// registerEBPFDatapath registers this attachment against the eBPF uSID +// datapath's pinned maps (design plan §5.1) -- the only forwarding path +// (the legacy seg6local static-route path was removed once this covered +// both veth and tap attachments, Milestone 6.1's tap-mode redirect fix). +// +// Design plan §4.4 assigns locator_table/function_table population to "the +// control daemon, at startup + on locator change." The actual control +// daemon (galactic-cni's "run" subcommand) does not read BGPRouter/watch +// for locator changes -- it only loads/attaches/pins the program -- so +// those two maps would otherwise sit permanently empty and every packet +// would locator_table-miss and pass through unchanged. This function +// registers all three tables (locator_table, function_table, vrf_table) +// from here instead, since the CNI ADD path already independently +// resolves bgp.srv6Locator/bgp.nodeID via lookupBGPRouter on every +// invocation -- an intentional deviation from the design plan's literal +// placement, not an oversight, tracked for revisiting once a real +// control-daemon-side CRD watch exists. +// +// argument is the same real, allocated 12-bit value (Milestone 6.1's +// allocateArgument) the router independently recomputes the BGP-advertised +// SID from (internal/reconcile) -- both must agree on the same value or a +// remote node's encapsulated traffic decodes into the wrong VRF. +// +// registerEBPFDatapath's return values let the caller record exactly what +// (if anything) was registered, so a later failed-ADD rollback +// (resourceTracker.cleanup, Milestone 7.2) can unregister the same +// (block, argument) pair without having to recompute or guess it. +// registered is false, with a nil error, only when this router has no +// srv6Locator/nodeID configured at all -- SRv6 is intentionally not set up +// for this attachment. Any other failure is returned as an error: with no +// legacy path to fall back to, the caller must treat that as fatal. +func registerEBPFDatapath( + bgp bgpConfig, vpc, vpcAttachment, ifaceType string, argument uint16, pinDir string, +) (registered bool, block uint64, err error) { + if bgp.srv6Locator == "" || bgp.nodeID == 0 { + return false, 0, nil } - if err := srv6.RouteIngressAdd(sid, vpc, vpcAttachment); err != nil { - return "", fmt.Errorf("add SRv6 ingress route: %w", err) + + egressKind, err := egressKindForInterfaceType(ifaceType) + if err != nil { + return false, 0, fmt.Errorf("determine eBPF egress kind: %w", err) + } + + prefix, err := netip.ParsePrefix(bgp.srv6Locator) + if err != nil { + return false, 0, fmt.Errorf("parse SRv6 locator %q for eBPF registration: %w", bgp.srv6Locator, err) + } + block, err = uformat.Block(prefix.Addr()) + if err != nil { + return false, 0, fmt.Errorf("derive eBPF uSID Block from locator %q: %w", bgp.srv6Locator, err) + } + + vrfTableID, err := vrf.TableID(vpc, vpcAttachment) + if err != nil { + return false, 0, fmt.Errorf("look up VRF table id for eBPF registration: %w", err) + } + + registry, closer, err := usidmap.OpenPinnedRegistry(pinDir) + if err != nil { + return false, 0, fmt.Errorf("open pinned eBPF uSID maps: %w", err) } - return sid, nil + defer func() { _ = closer.Close() }() + + if err := registry.Locator.Register(block, uint16(bgp.nodeID)); err != nil { + return false, 0, fmt.Errorf("register eBPF locator_table entry: %w", err) + } + if err := registry.Function.Register(block, uformat.FunctionEndDT46); err != nil { + return false, 0, fmt.Errorf("register eBPF function_table entry: %w", err) + } + + if err := registry.VRF.Register(block, argument, vrfTableID, egressKind); err != nil { + return false, 0, fmt.Errorf("register eBPF vrf_table entry: %w", err) + } + return true, block, nil +} + +// egressKindForInterfaceType maps the CNI's InterfaceType field to the +// vrf_table egress_kind value usid.c's step 9 uses to pick between +// bpf_redirect_peer (veth, crosses into the container's netns) and plain +// bpf_redirect (tap, which never leaves this netns -- internal/cni/tap +// creates it here and never moves it). This is what closes the tap-mode +// redirect_failed gap (Milestone 6.1's fix, design plan §4.2 step 9). +func egressKindForInterfaceType(ifaceType string) (uint32, error) { + switch ifaceType { + case interfaceTypeVeth, "": + // Empty matches config.go's own default-to-veth behavior for an + // omitted interface_type field. + return usidmap.EgressKindVeth, nil + case interfaceTypeTap: + return usidmap.EgressKindTap, nil + default: + return 0, fmt.Errorf("unknown interface type %q", ifaceType) + } +} + +// unregisterEBPFDatapath removes the vrf_table entry registerEBPFDatapath +// wrote for this (block, argument) pair, from the failed-ADD rollback path +// (resourceTracker.cleanup, Milestone 7.2). Unlike registerEBPFDatapath, +// this has no flag/config short-circuit of its own -- callers only invoke +// it when resourceTracker recorded a real registration +// (resourceTracker.ebpfRegistered), so by construction the flag was on and +// the maps were reachable at Register time. Idempotent: not an error if +// the entry is already gone (VRFTable.Unregister's own documented +// behavior). +func unregisterEBPFDatapath(block uint64, argument uint16, pinDir string) error { + registry, closer, err := usidmap.OpenPinnedRegistry(pinDir) + if err != nil { + return fmt.Errorf("open pinned eBPF uSID maps: %w", err) + } + defer func() { _ = closer.Close() }() + + if err := registry.VRF.Unregister(block, argument); err != nil { + return fmt.Errorf("unregister eBPF vrf_table entry: %w", err) + } + return nil } diff --git a/internal/cni/bgp_ebpf_test.go b/internal/cni/bgp_ebpf_test.go new file mode 100644 index 0000000..73c6524 --- /dev/null +++ b/internal/cni/bgp_ebpf_test.go @@ -0,0 +1,165 @@ +// Copyright 2026 Datum Cloud, Inc. +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package cni + +import ( + "fmt" + "os" + "testing" + + "go.datum.net/galactic/internal/plumbing/ebpf/attach" + "go.datum.net/galactic/internal/plumbing/ebpf/usidmap" + "go.datum.net/galactic/internal/plumbing/vrf" +) + +// TestRegisterEBPFDatapath_NotConfiguredIsNoOp covers the short-circuit for +// a node whose BGPRouter has no SRv6Locator/NodeID configured at all: SRv6 +// is intentionally not set up for this attachment, so registerEBPFDatapath +// must do nothing (no error, no attempt to open any pinned map). +func TestRegisterEBPFDatapath_NotConfiguredIsNoOp(t *testing.T) { + cfg := bgpConfig{srv6Locator: "", nodeID: 0} + registered, _, err := registerEBPFDatapath( + cfg, testVPC, testAttachment, interfaceTypeVeth, 42, "/sys/fs/bpf/galactic-does-not-exist") + if err != nil { + t.Errorf("registerEBPFDatapath with unconfigured BGPRouter = %v, want nil (no-op)", err) + } + if registered { + t.Error("registerEBPFDatapath with unconfigured BGPRouter reported registered=true, want false") + } +} + +// TestRegisterEBPFDatapath_RegistersAllThreeTables is Milestone 7.1's exit +// criterion: a single registerEBPFDatapath call populates locator_table, +// function_table, and vrf_table consistently for the same (vpc, +// vpcAttachment), against real pinned eBPF maps under a throwaway pin +// directory -- not the production attach.PinDir. +func TestRegisterEBPFDatapath_RegistersAllThreeTables(t *testing.T) { + requireRoot(t) + + const ( + vpc = testVPC + vpcAttachment = testAttachment + locator = "2001:db8:1::/48" + nodeID = int32(5) + vrfID = int32(42) + ) + + if err := vrf.Add(vpc, vpcAttachment); err != nil { + t.Fatalf("vrf.Add: %v", err) + } + t.Cleanup(func() { _ = vrf.Delete(vpc, vpcAttachment) }) + + pinDir := fmt.Sprintf("/sys/fs/bpf/galactic-bgp-test-%d", os.Getpid()) + t.Cleanup(func() { _ = os.RemoveAll(pinDir) }) + loaderObjs, err := attach.Load(pinDir) + if err != nil { + t.Fatalf("attach.Load (simulating the run container having already loaded the datapath): %v", err) + } + t.Cleanup(func() { _ = loaderObjs.Close() }) + + cfg := bgpConfig{srv6Locator: locator, nodeID: nodeID} + registered, _, err := registerEBPFDatapath(cfg, vpc, vpcAttachment, interfaceTypeVeth, uint16(vrfID), pinDir) + if err != nil { + t.Fatalf("registerEBPFDatapath: %v", err) + } + if !registered { + t.Fatal("registerEBPFDatapath reported registered=false, want true") + } + + reg, closer, err := usidmap.OpenPinnedRegistry(pinDir) + if err != nil { + t.Fatalf("OpenPinnedRegistry: %v", err) + } + defer func() { _ = closer.Close() }() + + vrfTableID, err := vrf.TableID(vpc, vpcAttachment) + if err != nil { + t.Fatalf("vrf.TableID: %v", err) + } + + entries, err := reg.VRF.List() + if err != nil { + t.Fatalf("VRF.List: %v", err) + } + if len(entries) != 1 { + t.Fatalf("vrf_table entries = %+v, want exactly 1", entries) + } + if entries[0].VRFTableID != vrfTableID { + t.Errorf("vrf_table entry VRFTableID = %#x, want %#x (this attachment's real VRF table id)", + entries[0].VRFTableID, vrfTableID) + } + if entries[0].EgressKind != usidmap.EgressKindVeth { + t.Errorf("vrf_table entry EgressKind = %d, want %d (EgressKindVeth, from InterfaceType %q)", + entries[0].EgressKind, usidmap.EgressKindVeth, interfaceTypeVeth) + } + + locEntries, err := reg.Locator.List() + if err != nil { + t.Fatalf("Locator.List: %v", err) + } + if len(locEntries) != 1 || locEntries[0].NodeID != uint16(nodeID) { + t.Errorf("locator_table entries = %+v, want exactly one with NodeID %#x", locEntries, nodeID) + } + + fnEntries, err := reg.Function.List() + if err != nil { + t.Fatalf("Function.List: %v", err) + } + if len(fnEntries) != 1 { + t.Errorf("function_table entries = %+v, want exactly 1", fnEntries) + } +} + +// TestResourceTrackerCleanup_UnregistersEBPFVRFEntry is Milestone 7.2's +// exit criterion: a failed ADD's rollback (resourceTracker.cleanup) cleans +// up both the kernel route (existing behavior, already covered by +// TestResourceTrackerCleanupPartialState) and the new eBPF vrf_table map +// entry, when one was actually registered. cleanup's own unregister step +// always targets the real, production attach.PinDir (it is not +// parameterized, unlike registerEBPFDatapath -- see resource.go), so this +// test loads/pins the real datapath there for the duration of the test, +// cleaning it up fully afterward; this mirrors the same "real global +// state" pattern this file's other resourceTracker tests already use for +// vrf.Delete/veth.Delete. +func TestResourceTrackerCleanup_UnregistersEBPFVRFEntry(t *testing.T) { + requireRoot(t) + + loaderObjs, err := attach.Load(attach.PinDir) + if err != nil { + t.Fatalf("attach.Load(attach.PinDir): %v", err) + } + t.Cleanup(func() { _ = loaderObjs.Close() }) + t.Cleanup(func() { _ = os.RemoveAll(attach.PinDir) }) + + reg, closer, err := usidmap.OpenPinnedRegistry(attach.PinDir) + if err != nil { + t.Fatalf("OpenPinnedRegistry(attach.PinDir): %v", err) + } + defer func() { _ = closer.Close() }() + + const testBlock uint64 = 0x0102030405 + const testArgument uint16 = 0x042 + + if err := reg.VRF.Register(testBlock, testArgument, 0x2A2A2A, usidmap.EgressKindVeth); err != nil { + t.Fatalf("seed vrf_table entry: %v", err) + } + if _, ok, err := reg.VRF.Get(testBlock, testArgument); err != nil || !ok { + t.Fatalf("seeded entry not visible before cleanup: ok=%v err=%v", ok, err) + } + + tracker := &resourceTracker{ + vpc: testVPC, + vpcAttachment: testAttachment, + namespace: "ebpf-cleanup-test", + ebpfRegistered: true, + ebpfBlock: testBlock, + ebpfArgument: testArgument, + } + tracker.cleanup(t.Context()) + + if _, ok, err := reg.VRF.Get(testBlock, testArgument); err != nil || ok { + t.Errorf("vrf_table entry after cleanup: ok=%v err=%v, want ok=false (unregistered)", ok, err) + } +} diff --git a/internal/cni/bgp_test.go b/internal/cni/bgp_test.go index 1bc3796..9acb533 100644 --- a/internal/cni/bgp_test.go +++ b/internal/cni/bgp_test.go @@ -5,14 +5,18 @@ package cni import ( + "context" + "fmt" "net" "strings" "testing" "github.com/vishvananda/netlink" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" - "go.datum.net/galactic/internal/plumbing/intf" - "go.datum.net/galactic/internal/plumbing/srv6" + "go.datum.net/galactic/internal/plumbing/ebpf/uformat" + "go.datum.net/galactic/internal/plumbing/ebpf/usidmap" bgpv1alpha1 "go.datum.net/network/api/v1alpha1" ) @@ -108,128 +112,127 @@ func TestRouteConflicts(t *testing.T) { } } -// ---- vrfIDFromAttachment -------------------------------------------------- +// ---- allocateArgument ------------------------------------------------------ -func TestVRFIDFromAttachment(t *testing.T) { - tests := []struct { - name string - input string - want int32 - wantErr string - }{ - { - name: "valid base62 decodes to VRFID", - input: "jU", // 1234 decimal; see internal/plumbing/intf fixtures - want: 1234, +// vrfInstanceForRouter builds a BGPVRFInstance targeting routerName with the +// given VRFID (the allocated Argument), for allocateArgument's test fixtures. +func vrfInstanceForRouter(name, namespace, routerName string, vrfID int32) *bgpv1alpha1.BGPVRFInstance { + return &bgpv1alpha1.BGPVRFInstance{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace}, + Spec: bgpv1alpha1.BGPVRFInstanceSpec{ + RouterTarget: bgpv1alpha1.RouterTarget{RouterRef: &bgpv1alpha1.RouterRef{Name: routerName}}, + VRFID: vrfID, }, - { - name: "invalid base62 fails to decode", - input: testInvalidBase62, - wantErr: "decode VPCAttachment", - }, - { - name: "value exceeding 16 bits is rejected", - input: mustHexToBase62(t, "10000"), // 65536, out of range for VRFID - wantErr: "parse VPCAttachment hex", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, err := vrfIDFromAttachment(tt.input) - if tt.wantErr != "" { - if err == nil { - t.Fatalf("expected error containing %q, got nil", tt.wantErr) - } - if !strings.Contains(err.Error(), tt.wantErr) { - t.Fatalf("error %q does not contain %q", err, tt.wantErr) - } - return - } - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if got != tt.want { - t.Errorf("vrfIDFromAttachment(%q) = %d, want %d", tt.input, got, tt.want) - } - }) } } -func mustHexToBase62(t *testing.T, hex string) string { - t.Helper() - b62, err := intf.HexToBase62(hex) - if err != nil { - t.Fatalf("HexToBase62(%q): %v", hex, err) - } - return b62 +func TestAllocateArgument(t *testing.T) { + const ( + namespace = "default" + routerName = "router-a" + ) + + t.Run("no existing instances allocates the lowest value", func(t *testing.T) { + k8s := fakeClient() + got, err := allocateArgument(context.Background(), k8s, namespace, routerName, "vpc-att-1") + if err != nil { + t.Fatalf("allocateArgument: unexpected error: %v", err) + } + if got != int32(uformat.ArgumentMin) { + t.Errorf("allocateArgument() = %d, want %d", got, uformat.ArgumentMin) + } + }) + + t.Run("existing instance by name is reused idempotently", func(t *testing.T) { + existing := vrfInstanceForRouter("vpc-att-1", namespace, routerName, 99) + k8s := fakeClient(existing) + got, err := allocateArgument(context.Background(), k8s, namespace, routerName, "vpc-att-1") + if err != nil { + t.Fatalf("allocateArgument: unexpected error: %v", err) + } + if got != 99 { + t.Errorf("allocateArgument() = %d, want 99 (reused from existing BGPVRFInstance)", got) + } + }) + + t.Run("skips values used by this router and ignores other routers", func(t *testing.T) { + used1 := vrfInstanceForRouter("other-att-1", namespace, routerName, 1) + used2 := vrfInstanceForRouter("other-att-2", namespace, routerName, 2) + // Same VRFID (1) under a different router -- must not count toward + // this router's used set, since Argument allocation is per node + // (i.e. per BGPRouter), not platform-wide. + differentRouter := vrfInstanceForRouter("different-router-att", namespace, "other-router", 1) + k8s := fakeClient(used1, used2, differentRouter) + got, err := allocateArgument(context.Background(), k8s, namespace, routerName, "new-att") + if err != nil { + t.Fatalf("allocateArgument: unexpected error: %v", err) + } + if got != 3 { + t.Errorf("allocateArgument() = %d, want 3 (lowest free, skipping 1 and 2)", got) + } + }) + + t.Run("ignores instances in a different namespace", func(t *testing.T) { + otherNamespace := vrfInstanceForRouter("vpc-att-1", "other-namespace", routerName, 1) + k8s := fakeClient(otherNamespace) + got, err := allocateArgument(context.Background(), k8s, namespace, routerName, "vpc-att-1") + if err != nil { + t.Fatalf("allocateArgument: unexpected error: %v", err) + } + if got != int32(uformat.ArgumentMin) { + t.Errorf("allocateArgument() = %d, want %d (cross-namespace instance must not be reused or counted as used)", + got, uformat.ArgumentMin) + } + }) + + t.Run("exhausted Argument space returns an error", func(t *testing.T) { + objs := make([]client.Object, 0, uformat.ArgumentMax) + for i := int32(uformat.ArgumentMin); i <= int32(uformat.ArgumentMax); i++ { + objs = append(objs, vrfInstanceForRouter(fmt.Sprintf("att-%d", i), namespace, routerName, i)) + } + k8s := fakeClient(objs...) + _, err := allocateArgument(context.Background(), k8s, namespace, routerName, "new-att") + if err == nil { + t.Fatal("allocateArgument: error = nil, want exhaustion error") + } + if !strings.Contains(err.Error(), "no free Argument") { + t.Errorf("error %q does not contain %q", err, "no free Argument") + } + }) } -// ---- resolveSRv6SID -------------------------------------------------------- - -func TestResolveSRv6SID(t *testing.T) { - const locator = "fd00:10::/48" - const nodeID, vrfID = int32(7), int32(1234) - - computed, err := srv6.ComputeSID(locator, nodeID, vrfID, bgpv1alpha1.SRv6FunctionEndDT46) - if err != nil { - t.Fatalf("srv6.ComputeSID setup: %v", err) - } +// ---- egressKindForInterfaceType -------------------------------------------- +func TestEgressKindForInterfaceType(t *testing.T) { tests := []struct { name string - bgp bgpConfig - vrfID int32 - want string - wantErr string + iface string + want uint32 + wantErr bool }{ - { - name: "computed from router locator+nodeID when explicit is empty", - bgp: bgpConfig{srv6Locator: locator, nodeID: nodeID}, - vrfID: vrfID, - want: computed.String(), - }, - { - name: "no locator configured on router — SID skipped", - bgp: bgpConfig{nodeID: nodeID}, - vrfID: vrfID, - want: "", - }, - { - name: "no nodeID configured on router — SID skipped", - bgp: bgpConfig{srv6Locator: locator}, - vrfID: vrfID, - want: "", - }, - { - name: "ComputeSID error propagates", - bgp: bgpConfig{srv6Locator: "fd00:10::/33", nodeID: nodeID}, // not byte-aligned - vrfID: vrfID, - wantErr: "compute SRv6 SID", - }, + {name: "veth maps to EgressKindVeth", iface: interfaceTypeVeth, want: usidmap.EgressKindVeth}, + {name: "empty defaults to EgressKindVeth", iface: "", want: usidmap.EgressKindVeth}, + {name: "tap maps to EgressKindTap", iface: interfaceTypeTap, want: usidmap.EgressKindTap}, + {name: "unknown type errors", iface: "bogus", wantErr: true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := resolveSRv6SID(tt.bgp, tt.vrfID) - if tt.wantErr != "" { + got, err := egressKindForInterfaceType(tt.iface) + if tt.wantErr { if err == nil { - t.Fatalf("expected error containing %q, got nil", tt.wantErr) - } - if !strings.Contains(err.Error(), tt.wantErr) { - t.Fatalf("error %q does not contain %q", err, tt.wantErr) + t.Fatalf("egressKindForInterfaceType(%q) error = nil, want error", tt.iface) } return } if err != nil { - t.Fatalf("unexpected error: %v", err) + t.Fatalf("egressKindForInterfaceType(%q) unexpected error: %v", tt.iface, err) } if got != tt.want { - t.Errorf("resolveSRv6SID() = %q, want %q", got, tt.want) + t.Errorf("egressKindForInterfaceType(%q) = %d, want %d", tt.iface, got, tt.want) } }) } - } // ---- buildVRFInstanceSpec --------------------------------------------------- diff --git a/internal/cni/cni_test.go b/internal/cni/cni_test.go index 5d316f0..3f9cfb5 100644 --- a/internal/cni/cni_test.go +++ b/internal/cni/cni_test.go @@ -678,7 +678,7 @@ func TestRouteTarget(t *testing.T) { want: "65000:1234", }, { - name: "upper 16 bits of 48-bit VPC stripped", + name: "upper bits beyond 32 stripped", asNumber: 65000, vpcHex: "000100000001", // 0x000100000001; low32 = 1 want: testRD65000_1, @@ -1491,9 +1491,6 @@ func TestResourceTrackerFieldsSet(t *testing.T) { if tracker.vrfCreated { t.Error("vrfCreated should be false by default") } - if tracker.srv6SID != "" { - t.Errorf("srv6SID should be empty by default, got %q", tracker.srv6SID) - } if tracker.advCreated { t.Error("advCreated should be false by default") } diff --git a/internal/cni/ops_add.go b/internal/cni/ops_add.go index 551e7e4..f1b6aa1 100644 --- a/internal/cni/ops_add.go +++ b/internal/cni/ops_add.go @@ -9,6 +9,7 @@ import ( "errors" "fmt" "log/slog" + "net" "os" "github.com/containernetworking/cni/pkg/skel" @@ -133,10 +134,11 @@ func cmdAdd(args *skel.CmdArgs) (err error) { // Host-device delegation and IPAM are veth-only. // In tap mode the guest VM manages its own networking. var ipamResult *ipamResult + var guestHWAddr net.HardwareAddr switch pluginConf.InterfaceType { case interfaceTypeVeth: guestName := intf.GenerateInterfaceNameGuest(pluginConf.VPC, pluginConf.VPCAttachment) - ipamResult, err = buildVethResult(args, pluginConf, hostName, guestName, hostMac, hostMTU) + ipamResult, guestHWAddr, err = buildVethResult(args, pluginConf, hostName, guestName, hostMac, hostMTU) if err != nil { return err } @@ -159,7 +161,7 @@ func cmdAdd(args *skel.CmdArgs) (err error) { } // Configure the gateway address on the host tap and install the VRF route. - if err := configureHostGateway(pluginConf.VPC, pluginConf.VPCAttachment, ipamResult); err != nil { + if err := configureHostGateway(pluginConf.VPC, pluginConf.VPCAttachment, ipamResult, nil); err != nil { return err } if ipamResult != nil && ipamResult.ipv6Gateway != nil { @@ -172,24 +174,20 @@ func cmdAdd(args *skel.CmdArgs) (err error) { return fmt.Errorf("print CNI result: %w", err) } - // Decode VPC/VRFID for BGP state publish. + // Decode VPC for BGP state publish. vpcHex, err := intf.Base62ToHex(pluginConf.VPC) if err != nil { return fmt.Errorf("decode VPC: %w", err) } - vrfID, err := vrfIDFromAttachment(pluginConf.VPCAttachment) - if err != nil { - return fmt.Errorf("decode VPCAttachment: %w", err) - } // Publish BGP state (SRv6 ingress + BGP CRDs). if tracker.k8s == nil { return errors.New("k8s client not set in tracker") } slog.Debug("ADD: publishing BGP state", "containerID", args.ContainerID, "interfaceType", interfaceTypeTap) - return publishBGPStateK8s(args, pluginConf, nodeName, namespace, ipamResult, vpcHex, vrfID, tracker.k8s, tracker) + return publishBGPStateK8s(args, pluginConf, nodeName, namespace, ipamResult, vpcHex, tracker.k8s, tracker) } slog.Debug("ADD: publishing BGP state", "containerID", args.ContainerID, "interfaceType", pluginConf.InterfaceType) - return publishBGPState(args, pluginConf, nodeName, namespace, ipamResult, tracker) + return publishBGPState(args, pluginConf, nodeName, namespace, ipamResult, guestHWAddr, tracker) } diff --git a/internal/cni/resource.go b/internal/cni/resource.go index c8c8c2b..75a6d3e 100644 --- a/internal/cni/resource.go +++ b/internal/cni/resource.go @@ -16,7 +16,7 @@ import ( "go.datum.net/galactic/internal/cni/tap" "go.datum.net/galactic/internal/cni/veth" - "go.datum.net/galactic/internal/plumbing/srv6" + "go.datum.net/galactic/internal/plumbing/ebpf/attach" "go.datum.net/galactic/internal/plumbing/vrf" bgpv1alpha1 "go.datum.net/network/api/v1alpha1" ) @@ -43,11 +43,24 @@ type resourceTracker struct { ifaceType string vrfCreated bool routesCreated int - srv6SID string vrfInstanceCreated bool advCreated bool k8s client.Client namespace string + + // ebpfRegistered, ebpfBlock, and ebpfArgument record the eBPF uSID + // datapath's vrf_table registration (registerEBPFDatapath, Milestone + // 7.1), if one actually happened (the BGPRouter may not be + // configured, in which case ebpfRegistered stays false and cleanup + // has nothing to unregister). Only vrf_table + // is rolled back here -- locator_table/function_table entries are + // keyed by Block, not by this specific (vpc, vpcAttachment), and + // typically shared across many attachments on the same node, so they + // are never this attachment's rollback's responsibility to remove + // (Milestone 7.2). + ebpfRegistered bool + ebpfBlock uint64 + ebpfArgument uint16 } // cleanup rolls back all tracked resources in reverse creation order. @@ -88,13 +101,18 @@ func (rt *resourceTracker) cleanup(ctx context.Context) { } } - // 3. Delete SRv6 ingress route (only if we got a SID) - if rt.srv6SID != "" { - if err := srv6.RouteIngressDel(rt.srv6SID, rt.vpc, rt.vpcAttachment); err != nil { - slog.Error("Rollback: failed to delete SRv6 ingress route", "err", err, - "sid", rt.srv6SID) + // 3. Unregister the eBPF uSID datapath's vrf_table entry (only if + // registerEBPFDatapath actually wrote one, Milestone 7.2). A pinned + // BPF map entry has no implicit teardown when the VRF/interfaces are + // deleted below, so it must be removed explicitly here and nowhere + // else in the normal cmdDel path is expected to (design plan §5.1). + if rt.ebpfRegistered { + if err := unregisterEBPFDatapath(rt.ebpfBlock, rt.ebpfArgument, attach.PinDir); err != nil { + slog.Error("Rollback: failed to unregister eBPF vrf_table entry", "err", err, + "block", rt.ebpfBlock, "argument", rt.ebpfArgument) } else { - slog.Debug("Rollback: deleted SRv6 ingress route", "sid", rt.srv6SID) + slog.Debug("Rollback: unregistered eBPF vrf_table entry", + "block", rt.ebpfBlock, "argument", rt.ebpfArgument) } } diff --git a/internal/cni/result.go b/internal/cni/result.go index 9569e6a..486dfe2 100644 --- a/internal/cni/result.go +++ b/internal/cni/result.go @@ -84,7 +84,7 @@ func buildVethResult( hostName, guestName string, hostMac string, hostMTU int, -) (*ipamResult, error) { +) (*ipamResult, net.HardwareAddr, error) { // Only call host-device ADD if the guest interface is still in the host // namespace. If a prior attempt already moved it to the container netns but // failed at a later step, we must not try to move it again. @@ -93,10 +93,10 @@ func buildVethResult( // previous run. The host-device plugin renames the moved interface // to args.IfName, so a prior run may have left that name behind. if err := cleanupContainerNetns(args.Netns, args.IfName); err != nil { - return nil, fmt.Errorf("cleanup container netns: %w", err) + return nil, nil, fmt.Errorf("cleanup container netns: %w", err) } if err := hostDevice("ADD", args, pluginConf); err != nil { - return nil, fmt.Errorf("host-device ADD: %w", err) + return nil, nil, fmt.Errorf("host-device ADD: %w", err) } } @@ -105,7 +105,7 @@ func buildVethResult( if wantsIPAM(pluginConf) { result, err := configureIPAM(args, pluginConf, args.IfName) if err != nil { - return nil, fmt.Errorf("configure IPAM: %w", err) + return nil, nil, fmt.Errorf("configure IPAM: %w", err) } ipamResult = result } @@ -113,14 +113,18 @@ func buildVethResult( // Read guest veth attributes inside the container netns. guestMac, guestMTU, err := readGuestInterface(args.Netns, args.IfName) if err != nil { - return nil, fmt.Errorf("read guest interface: %w", err) + return nil, nil, fmt.Errorf("read guest interface: %w", err) + } + guestHWAddr, err := net.ParseMAC(guestMac) + if err != nil { + return nil, nil, fmt.Errorf("parse guest interface MAC %q: %w", guestMac, err) } result := buildResult(pluginConf, ipamResult, hostName, args.IfName, hostMac, guestMac, hostMTU, guestMTU, args.Netns) if err := types.PrintResult(result, pluginConf.CNIVersion); err != nil { - return nil, fmt.Errorf("print CNI result: %w", err) + return nil, nil, fmt.Errorf("print CNI result: %w", err) } - return ipamResult, nil + return ipamResult, guestHWAddr, nil } // buildTapResult constructs the CNI result for tap mode: a single host diff --git a/internal/config/cni.go b/internal/config/cni.go index 499ed0f..18cb63d 100644 --- a/internal/config/cni.go +++ b/internal/config/cni.go @@ -20,6 +20,13 @@ const ( EnvLogFile = "GALACTIC_CNI_LOG_FILE" EnvNamespace = "GALACTIC_CNI_NAMESPACE" EnvNodeNameLegacy = "NODE_NAME" + + // EnvCNIEBPFInterfaces overrides auto-detection of the interface(s) + // the eBPF uSID datapath attaches its TC-BPF ingress hook to -- a + // comma-separated list of interface names, for multi-homed nodes + // where auto-detection (the interface(s) carrying the default IPv6 + // route) is ambiguous. + EnvCNIEBPFInterfaces = "GALACTIC_CNI_EBPF_INTERFACES" ) // --- CNIConfig ------------------------------------------------------------- diff --git a/internal/gc/gc.go b/internal/gc/gc.go index 415e99a..42586d6 100644 --- a/internal/gc/gc.go +++ b/internal/gc/gc.go @@ -8,6 +8,8 @@ import ( "context" "fmt" "log/slog" + "net/netip" + "os" "regexp" "strings" @@ -15,6 +17,8 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/controller-runtime/pkg/client" + "go.datum.net/galactic/internal/plumbing/ebpf/uformat" + "go.datum.net/galactic/internal/plumbing/ebpf/usidmap" "go.datum.net/galactic/internal/plumbing/vrf" bgpv1alpha1 "go.datum.net/network/api/v1alpha1" ) @@ -37,7 +41,12 @@ type OrphanedCRD struct { type CleanupResult struct { OrphanedCRDsRemoved int OrphanedVRFsRemoved int - Errors int + // EBPFVRFEntriesRemoved counts stale eBPF uSID datapath vrf_table map + // entries removed by SweepEBPFVRFTable -- a distinct kind of resource + // from OrphanedVRFsRemoved's kernel VRF *interfaces* above (Milestone + // 7.3). + EBPFVRFEntriesRemoved int + Errors int } // vrfNameRegex matches the deterministic VRF interface name pattern used by @@ -57,18 +66,37 @@ var vrfNameRegex = regexp.MustCompile(`^G([A-Za-z0-9]{9})([A-Za-z0-9]{3})V$`) func routerNamesForNode( ctx context.Context, k8s client.Client, namespace, nodeName string, ) (map[string]struct{}, error) { + routers, err := routersForNode(ctx, k8s, namespace, nodeName) + if err != nil { + return nil, err + } + names := make(map[string]struct{}, len(routers)) + for name := range routers { + names[name] = struct{}{} + } + return names, nil +} + +// routersForNode is routerNamesForNode's fuller sibling: it keeps the full +// BGPRouter object (keyed by name) rather than just membership, for +// callers that need more than the name -- SweepEBPFVRFTable (Milestone +// 7.3) needs each router's Spec.SRv6Locator to derive the eBPF uSID Block +// its BGPVRFInstances resolve into. +func routersForNode( + ctx context.Context, k8s client.Client, namespace, nodeName string, +) (map[string]bgpv1alpha1.BGPRouter, error) { routerList := &bgpv1alpha1.BGPRouterList{} if err := k8s.List(ctx, routerList, client.InNamespace(namespace)); err != nil { return nil, fmt.Errorf("list BGPRouters: %w", err) } - names := make(map[string]struct{}) + routers := make(map[string]bgpv1alpha1.BGPRouter) for _, r := range routerList.Items { if r.Spec.TargetRef.Name == nodeName { - names[r.Name] = struct{}{} + routers[r.Name] = r } } - return names, nil + return routers, nil } // CollectOrphanedCRDs scans BGPAdvertisement and BGPVRFInstance CRDs owned by @@ -295,6 +323,116 @@ func RemoveOrphanedVRFs(vrfNames []string) CleanupResult { return result } +// SweepEBPFVRFTable removes eBPF uSID datapath vrf_table map entries whose +// (Block, Argument) key no longer corresponds to a live BGPVRFInstance CRD +// owned by nodeName's BGPRouter(s) (design plan §5.3; Milestone 7.3). +// Unlike RunGC's CRD/kernel-VRF sweep above, this deliberately does NOT run +// from galactic-router's existing GC controller (internal/controller/ +// gc_controller.go): the pinned vrf_table map only exists inside +// galactic-cni's "run" container, which has the /sys/fs/bpf hostPath mount +// and CAP_BPF (Milestone 3.1) that galactic-router's own DaemonSet does +// not and, for this alone, should not need. internal/installer.Run calls +// this directly on its own ticker instead -- see that package's doc +// comment for the full reasoning. The RBAC galactic-cni's ServiceAccount +// already has (get/list bgprouters; get/list/... bgpvrfinstances, see +// config/cni/rbac.yaml) is exactly what this function needs, so no +// permission change was required to place it there. +// +// A pin directory this process can't confirm exists -- either genuinely +// absent (the "run" container's eBPF datapath hasn't finished loading yet) +// or inaccessible (e.g. /sys/fs/bpf's own restrictive mode denying a +// non-root stat, which the production "run" container never hits since it +// runs as root, design plan §9) -- is treated as "nothing to do this +// tick," not an error; any stat failure here means there's no pinned +// datapath this process can reach right now, and it isn't this function's +// job to diagnose why. +func SweepEBPFVRFTable(ctx context.Context, k8s client.Client, namespace, nodeName, pinDir string) CleanupResult { + result := CleanupResult{} + + if _, statErr := os.Stat(pinDir); statErr != nil { + return result + } + + reg, closer, err := usidmap.OpenPinnedRegistry(pinDir) + if err != nil { + slog.Error("GC: failed to open pinned eBPF vrf_table for sweep", "pinDir", pinDir, "err", err) + result.Errors++ + return result + } + defer func() { _ = closer.Close() }() + + // Capture the cutoff *before* listing BGPVRFInstance CRDs below -- + // VRFTable.Generation's own doc comment and doc.go's + // "plugin-binary-vs-run-container race" section explain why the + // ordering matters: a Register landing between this line and the List + // call below must survive this sweep. + cutoff := reg.VRF.Generation() + + routers, err := routersForNode(ctx, k8s, namespace, nodeName) + if err != nil { + slog.Error("GC: failed to list BGPRouters for eBPF vrf_table sweep", "err", err) + result.Errors++ + return result + } + + vrfInstList := &bgpv1alpha1.BGPVRFInstanceList{} + if err := k8s.List(ctx, vrfInstList, client.InNamespace(namespace)); err != nil { + slog.Error("GC: failed to list BGPVRFInstances for eBPF vrf_table sweep", "err", err) + result.Errors++ + return result + } + + live := make(map[usidmap.VRFKey]struct{}, len(vrfInstList.Items)) + for _, inst := range vrfInstList.Items { + if inst.Spec.RouterRef == nil { + continue + } + router, ok := routers[inst.Spec.RouterRef.Name] + if !ok { + continue // not one of this node's routers + } + if router.Spec.SRv6Locator == "" { + continue // this router has no eBPF-relevant locator configured + } + prefix, err := netip.ParsePrefix(router.Spec.SRv6Locator) + if err != nil { + slog.Warn("GC: skipping BGPVRFInstance with unparseable router locator during eBPF sweep", + "vrfInstance", inst.Name, "router", router.Name, "locator", router.Spec.SRv6Locator, "err", err) + continue + } + block, err := uformat.Block(prefix.Addr()) + if err != nil { + slog.Warn("GC: skipping BGPVRFInstance with invalid router locator during eBPF sweep", + "vrfInstance", inst.Name, "router", router.Name, "locator", router.Spec.SRv6Locator, "err", err) + continue + } + // inst.Spec.VRFID is the real, allocated Argument value directly + // (Milestone 6.1) -- no derivation needed. Guard the int32->uint16 + // narrowing explicitly rather than trusting an external CRD value + // (a boundary this GC sweep does not otherwise validate) to + // already be in range. + if inst.Spec.VRFID < int32(uformat.ArgumentMin) || inst.Spec.VRFID > int32(uformat.ArgumentMax) { + slog.Warn("GC: skipping BGPVRFInstance with out-of-range VRFID during eBPF sweep", + "vrfInstance", inst.Name, "vrfID", inst.Spec.VRFID) + continue + } + argument := uint16(inst.Spec.VRFID) + live[usidmap.VRFKey{Block: block, Argument: argument}] = struct{}{} + } + + removed, err := reg.VRF.Reconcile(live, cutoff) + for _, e := range removed { + slog.Info("GC: removed stale eBPF vrf_table entry", "block", e.Block, "argument", e.Argument) + } + result.EBPFVRFEntriesRemoved = len(removed) + if err != nil { + slog.Error("GC: errors while reconciling eBPF vrf_table", "err", err) + result.Errors++ + } + + return result +} + // RunGC performs a full garbage collection pass: removes orphaned BGP CRDs // and orphaned VRF interfaces. Returns a summary of what was cleaned up. // nodeName scopes the pass to CRDs owned by this node's BGPRouter(s) — see diff --git a/internal/gc/gc_ebpf_test.go b/internal/gc/gc_ebpf_test.go new file mode 100644 index 0000000..49c6e25 --- /dev/null +++ b/internal/gc/gc_ebpf_test.go @@ -0,0 +1,156 @@ +// Copyright 2026 Datum Cloud, Inc. +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package gc + +import ( + "context" + "fmt" + "net/netip" + "os" + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + "go.datum.net/galactic/internal/plumbing/ebpf/attach" + "go.datum.net/galactic/internal/plumbing/ebpf/uformat" + "go.datum.net/galactic/internal/plumbing/ebpf/usidmap" + bgpv1alpha1 "go.datum.net/network/api/v1alpha1" +) + +func requireRoot(t *testing.T) { + t.Helper() + if os.Geteuid() != 0 { + t.Skip("test requires root (CAP_BPF/CAP_NET_ADMIN) to load real BPF maps; re-run via sudo") + } +} + +func gcTestScheme(t *testing.T) *runtime.Scheme { + t.Helper() + s := runtime.NewScheme() + if err := clientgoscheme.AddToScheme(s); err != nil { + t.Fatalf("AddToScheme (client-go): %v", err) + } + if err := bgpv1alpha1.AddToScheme(s); err != nil { + t.Fatalf("AddToScheme (bgpv1alpha1): %v", err) + } + return s +} + +// TestSweepEBPFVRFTable_MissingPinDirIsNoOp covers the startup-ordering +// case: the sweep ticker can fire before the "run" container has finished +// loading/pinning the eBPF datapath, so /sys/fs/bpf/galactic (or wherever +// pinDir points) may not exist yet -- SweepEBPFVRFTable must not treat +// that as an error. +func TestSweepEBPFVRFTable_MissingPinDirIsNoOp(t *testing.T) { + k8s := fake.NewClientBuilder().WithScheme(gcTestScheme(t)).Build() + result := SweepEBPFVRFTable(context.Background(), k8s, "default", "node-a", "/sys/fs/bpf/galactic-does-not-exist") + if result.Errors != 0 || result.EBPFVRFEntriesRemoved != 0 { + t.Errorf("result = %+v, want a zero-value result (no-op)", result) + } +} + +// TestSweepEBPFVRFTable_RemovesStaleKeepsLive is Milestone 7.3's exit +// criterion. It seeds two vrf_table entries against a real, pinned map: +// one whose BGPVRFInstance/BGPRouter CRD pair is present in the fake k8s +// client (must survive), and one whose BGPVRFInstance CRD is absent (must +// be removed) -- both keyed by the same (Block, Argument) pair +// registerEBPFDatapath (Milestone 7.1) would register: uformat.Block +// (locator) and inst.Spec.VRFID directly as the Argument (Milestone 6.1). +func TestSweepEBPFVRFTable_RemovesStaleKeepsLive(t *testing.T) { + requireRoot(t) + + const ( + namespace = "default" + nodeName = "node-a" + routerName = "router-a" + locator = "2001:db8:1::/48" + liveVRFID = int32(10) + staleVRFID = int32(20) + ) + + router := &bgpv1alpha1.BGPRouter{ + ObjectMeta: metav1.ObjectMeta{Name: routerName, Namespace: namespace}, + Spec: bgpv1alpha1.BGPRouterSpec{ + TargetRef: bgpv1alpha1.TargetRef{Kind: "Node", Name: nodeName}, + LocalASN: 65000, + SRv6Locator: locator, + NodeID: 5, + }, + } + liveInst := &bgpv1alpha1.BGPVRFInstance{ + ObjectMeta: metav1.ObjectMeta{Name: "live-vrf", Namespace: namespace}, + Spec: bgpv1alpha1.BGPVRFInstanceSpec{ + RouterTarget: bgpv1alpha1.RouterTarget{RouterRef: &bgpv1alpha1.RouterRef{Name: routerName}}, + VRFID: liveVRFID, + ImportRouteTargets: []bgpv1alpha1.RouteTarget{{Value: "65000:1"}}, + ExportRouteTargets: []bgpv1alpha1.RouteTarget{{Value: "65000:1"}}, + }, + } + // staleVRFID's BGPVRFInstance is deliberately NOT created -- only + // live-vrf exists in the fake client. + k8s := fake.NewClientBuilder().WithScheme(gcTestScheme(t)).WithObjects(router, liveInst).Build() + + pinDir := fmt.Sprintf("/sys/fs/bpf/galactic-gcsweep-test-%d", os.Getpid()) + t.Cleanup(func() { _ = os.RemoveAll(pinDir) }) + loaderObjs, err := attach.Load(pinDir) + if err != nil { + t.Fatalf("attach.Load: %v", err) + } + t.Cleanup(func() { _ = loaderObjs.Close() }) + + reg, closer, err := usidmap.OpenPinnedRegistry(pinDir) + if err != nil { + t.Fatalf("OpenPinnedRegistry: %v", err) + } + defer func() { _ = closer.Close() }() + + block, err := blockFromLocator(t, locator) + if err != nil { + t.Fatalf("derive block: %v", err) + } + // inst.Spec.VRFID is the real Argument value directly (Milestone 6.1) -- + // liveVRFID/staleVRFID are chosen small enough to already be valid + // Arguments, no fold needed. + liveArgument := uint16(liveVRFID) + staleArgument := uint16(staleVRFID) + + if err := reg.VRF.Register(block, liveArgument, 0x111111, usidmap.EgressKindVeth); err != nil { + t.Fatalf("seed live entry: %v", err) + } + if err := reg.VRF.Register(block, staleArgument, 0x222222, usidmap.EgressKindVeth); err != nil { + t.Fatalf("seed stale entry: %v", err) + } + + result := SweepEBPFVRFTable(context.Background(), k8s, namespace, nodeName, pinDir) + if result.Errors != 0 { + t.Errorf("result.Errors = %d, want 0", result.Errors) + } + if result.EBPFVRFEntriesRemoved != 1 { + t.Errorf("result.EBPFVRFEntriesRemoved = %d, want 1", result.EBPFVRFEntriesRemoved) + } + + if _, ok, err := reg.VRF.Get(block, liveArgument); err != nil || !ok { + t.Errorf("live entry after sweep: ok=%v err=%v, want ok=true (must survive)", ok, err) + } + if _, ok, err := reg.VRF.Get(block, staleArgument); err != nil || ok { + t.Errorf("stale entry after sweep: ok=%v err=%v, want ok=false (must be removed)", ok, err) + } +} + +// blockFromLocator mirrors registerEBPFDatapath's/SweepEBPFVRFTable's own +// locator-to-Block derivation, kept local to this test file to avoid +// importing internal/cni (which would be a layering inversion) just for +// one helper. +func blockFromLocator(t *testing.T, locator string) (uint64, error) { + t.Helper() + prefix, err := netip.ParsePrefix(locator) + if err != nil { + return 0, err + } + return uformat.Block(prefix.Addr()) +} diff --git a/internal/installer/installer.go b/internal/installer/installer.go index 7d168d7..2fc966b 100644 --- a/internal/installer/installer.go +++ b/internal/installer/installer.go @@ -12,6 +12,7 @@ import ( "io" "log/slog" "net" + "net/http" "os" "path/filepath" "strings" @@ -28,8 +29,36 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" "go.datum.net/galactic/internal/config" + "go.datum.net/galactic/internal/gc" + "go.datum.net/galactic/internal/plumbing/ebpf/attach" + "go.datum.net/galactic/internal/plumbing/ebpf/metrics" + "go.datum.net/galactic/internal/plumbing/ebpf/prog" + bgpv1alpha1 "go.datum.net/network/api/v1alpha1" ) +// ebpfHealthCheckInterval controls how often Run polls +// internal/plumbing/ebpf/attach.Health once the eBPF datapath is running. +// A package-level var (not a const) so tests can shrink it, the same +// override pattern internal/plumbing/ebpf/attach/watch.go's +// debounceInterval already uses. +var ebpfHealthCheckInterval = 10 * time.Second + +// ebpfGCSweepInterval controls how often Run calls gc.SweepEBPFVRFTable +// once the eBPF datapath is running. A package-level var, same override +// pattern as ebpfHealthCheckInterval above -- matches galactic-router's +// own GC controller's documented default period (docs/agents/ +// ARCHITECTURE.md: "ticker-driven, default every 5m"). +var ebpfGCSweepInterval = 5 * time.Minute + +// ebpfHealthServiceName is the gRPC health service name (see +// grpc_health_v1.HealthServer) reporting the live status of the eBPF uSID +// datapath specifically, separate from the overall (""), always-serving +// status the credential-refresh/log-rotation loop reports -- so a BPF +// datapath degradation (e.g. something external detaches the tc filter) +// doesn't get conflated with, or masked by, the rest of this container's +// unrelated responsibilities. +const ebpfHealthServiceName = "ebpf-datapath" + var ( // Host paths, configurable for testing HostBinDir = "/host/opt/cni/bin" @@ -165,6 +194,12 @@ var scheme = runtime.NewScheme() func init() { _ = clientgoscheme.AddToScheme(scheme) + // bgpv1alpha1 registration is required for gc.SweepEBPFVRFTable's + // BGPRouter/BGPVRFInstance List calls (Milestone 7.3) -- newK8sClientFn + // below is shared with Bootstrap's plain Node lookup, which doesn't + // need it, but the client itself must know about every kind either + // caller lists. + _ = bgpv1alpha1.AddToScheme(scheme) } var newK8sClientFn = func() (client.Client, error) { @@ -180,6 +215,23 @@ var addrListFn = func(family int) ([]netlink.Addr, error) { return netlink.AddrList(nil, family) } +// ebpfStartFn loads, pins, and attaches the eBPF/TC-BPF uSID datapath, then +// keeps its resolved interface set re-evaluated against netlink link/route +// change events for the life of ctx (design plan +// .local/plan-ebpf-xdp-usid-datapath.md §4.1, §4.4, §5.4; Milestones 3.1 +// and 3.2 of .local/implementation-plan-ebpf-xdp-usid-datapath.md). It is a +// package-level override point -- like addrListFn and newK8sClientFn above +// -- so tests can exercise Run's wiring without needing root, a real kernel +// BPF stack, or a live network interface. The returned io.Closer is +// internal/plumbing/ebpf/attach.StartWatching's *prog.UsidObjects in +// production; Run keeps it open for the process lifetime and Closes it on +// shutdown (see the attach package doc comment for why that does not +// disrupt already-attached forwarding). Canceling ctx stops the background +// netlink watch loop but does not, by itself, close the returned object. +var ebpfStartFn = func(ctx context.Context, pinDir string) (io.Closer, []string, error) { + return attach.StartWatching(ctx, pinDir) +} + // resolveLogLevel reads GALACTIC_CNI_LOG_LEVEL and returns a validated level // string. Unrecognized values fall back to config.DefaultLogLevel ("info"). func resolveLogLevel() string { @@ -340,13 +392,131 @@ users: return atomicWriteFile(kubeconfigPath, []byte(kubeconfigTemplate), 0600) } +// ebpfDatapathState bundles what startEBPFDatapath resolves for Run to use +// afterward (health polling, metrics, the GC sweep) -- a named type purely +// to avoid a many-value return signature. +type ebpfDatapathState struct { + objs *prog.UsidObjects + ifaces []string + k8sClient client.Client + namespace string + nodeName string +} + +// startEBPFDatapath is Run's eBPF-datapath startup path, split out solely +// to keep Run's own cyclomatic complexity within golangci-lint's gocyclo +// budget -- behaviorally this is inlined exactly where it used to live. A +// failure here (including a failed kernel preflight check, design plan §6) +// is fatal: Run returns an error rather than falling back to a partial or +// unsafe datapath state -- this is the only forwarding path, there is no +// legacy path to fall back to. +func startEBPFDatapath(ctx context.Context, m *metrics.Metrics) (ebpfDatapathState, io.Closer, error) { + attach.SetHooks(m.Events.Hooks()) + + datapath, ifaces, err := ebpfStartFn(ctx, attach.PinDir) + if err != nil { + return ebpfDatapathState{}, nil, fmt.Errorf("start eBPF uSID datapath: %w", err) + } + slog.Info("eBPF uSID datapath loaded, pinned, and attached", "interfaces", ifaces, "pinDir", attach.PinDir) + + state := ebpfDatapathState{ifaces: ifaces} + + // ebpfStartFn's io.Closer is *prog.UsidObjects in production (test + // fakes stand in a plain mock closer, which correctly leaves + // metrics/health/GC wiring inert below -- see installer_test.go's + // fakeDatapathCloser). + if objs, ok := datapath.(*prog.UsidObjects); ok { + state.objs = objs + if err := m.RegisterDatapathCollector(objs); err != nil { + slog.Warn("Failed to register eBPF datapath metrics collector", "err", err) + } + } + + // Best-effort setup for the eBPF vrf_table GC sweep (Milestone 7.3). + // A failure here is not fatal to Run -- unlike the datapath start + // above, GC is a background maintenance task, not a hard requirement + // for the datapath to forward traffic -- it just means this node's + // sweep ticker stays inert until the next restart. + if hostConf, err := loadHostConf(HostConflist); err != nil { + slog.Warn("eBPF vrf_table GC sweep disabled: failed to load host conf", "err", err) + } else if k8sClient, err := newK8sClientFn(); err != nil { + slog.Warn("eBPF vrf_table GC sweep disabled: failed to create k8s client", "err", err) + } else { + state.k8sClient, state.namespace, state.nodeName = k8sClient, hostConf.Namespace, hostConf.NodeName + } + + return state, datapath, nil +} + // Run executes the CNI installer main container tasks: -// 1. Sets up log rotation periodically. -// 2. Starts a simple ServiceAccount token refresh ticker. -// 3. Deferred cleanup of stale .bin wrapper file. -// 4. Starts the gRPC health check server. -func Run(ctx context.Context, grpcHealthPort int) error { - slog.Info("Starting CNI installer run daemon", "grpcHealthPort", grpcHealthPort) +// 1. Loads/pins/attaches the eBPF/TC-BPF uSID datapath and keeps its +// attachment set re-evaluated against netlink link/route change events +// for the life of ctx (design plan §4.1, §4.4, §5.4; Milestones 3.1 +// and 3.2 of .local/implementation-plan-ebpf-xdp-usid-datapath.md) -- +// the only forwarding path. A failure here (including a failed kernel +// preflight check, design plan §6) is fatal: Run returns an error +// rather than falling back to a partial or unsafe datapath state. +// Once running, the netlink-driven re-attachment loop logs and retries +// its own failures rather than propagating them back into Run -- see +// internal/plumbing/ebpf/attach.Watch's doc comment. Datapath +// load/attach/detach events are counted via +// internal/plumbing/ebpf/metrics's EventCounters (Milestone 4), and +// live vrf_table/locator_table/drop_reasons state is exposed through +// the same metrics endpoint. +// 2. Serves Prometheus metrics on metricsPort. +// 3. Sets up log rotation periodically. +// 4. Starts a simple ServiceAccount token refresh ticker. +// 5. Deferred cleanup of stale .bin wrapper file. +// 6. Starts the gRPC health check server -- the overall ("") service +// always reports SERVING once the process is up (credential +// refresh/log rotation have no meaningful "unhealthy" state of their +// own); a separate ebpfHealthServiceName ("ebpf-datapath") service is +// polled on a ticker and reports the live result of +// internal/plumbing/ebpf/attach.Health -- exit criterion "health check +// fails correctly when the program is unloaded". +// 7. Periodically sweeps stale vrf_table entries via gc.SweepEBPFVRFTable +// (design plan §5.3; Milestone 7.3). This runs from here, not from +// galactic-router's existing GC controller +// (internal/controller/gc_controller.go), because the pinned maps +// only exist inside this container -- see gc.SweepEBPFVRFTable's own +// doc comment for the full reasoning. +func Run(ctx context.Context, grpcHealthPort, metricsPort int) error { + slog.Info("Starting CNI installer run daemon", "grpcHealthPort", grpcHealthPort, "metricsPort", metricsPort) + + m := metrics.New() + + ebpfState, datapath, err := startEBPFDatapath(ctx, m) + if err != nil { + return err + } + if datapath != nil { + defer func() { + if err := datapath.Close(); err != nil { + slog.Warn("Failed to close eBPF uSID datapath objects", "err", err) + } + }() + } + + // Serve Prometheus metrics at the conventional /metrics scrape path. + metricsMux := http.NewServeMux() + metricsMux.Handle("/metrics", m.Handler()) + metricsSrv := &http.Server{ + Addr: fmt.Sprintf(":%d", metricsPort), + Handler: metricsMux, + ReadHeaderTimeout: 5 * time.Second, + } + go func() { + if err := metricsSrv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + slog.Error("Metrics server exited with error", "err", err) + } + }() + defer func() { + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := metricsSrv.Shutdown(shutdownCtx); err != nil { + slog.Warn("Failed to gracefully shut down metrics server", "err", err) + } + }() // Start gRPC health check server var lc net.ListenConfig @@ -358,6 +528,9 @@ func Run(ctx context.Context, grpcHealthPort int) error { healthSrv := health.NewServer() grpc_health_v1.RegisterHealthServer(grpcSrv, healthSrv) healthSrv.SetServingStatus("", grpc_health_v1.HealthCheckResponse_SERVING) + if ebpfState.objs != nil { + healthSrv.SetServingStatus(ebpfHealthServiceName, grpc_health_v1.HealthCheckResponse_SERVING) + } go func() { if err := grpcSrv.Serve(lis); err != nil && !errors.Is(err, grpc.ErrServerStopped) { @@ -377,6 +550,20 @@ func Run(ctx context.Context, grpcHealthPort int) error { cleanupTimer := time.NewTimer(2 * time.Minute) defer cleanupTimer.Stop() + // eBPF datapath health poll -- only meaningful once datapathObjs is + // set (eBPF datapath enabled and actually running); otherwise this + // fires harmlessly and does nothing every tick. + ebpfHealthTicker := time.NewTicker(ebpfHealthCheckInterval) + defer ebpfHealthTicker.Stop() + var ebpfLastHealthy = true // matches the initial SetServingStatus(SERVING) above + + // eBPF vrf_table GC sweep (Milestone 7.3) -- only meaningful once + // gcK8sClient is set (eBPF datapath enabled and host conf/k8s client + // setup above succeeded); otherwise this fires harmlessly and does + // nothing every tick, same as the health poll above. + ebpfGCSweepTicker := time.NewTicker(ebpfGCSweepInterval) + defer ebpfGCSweepTicker.Stop() + for { select { case <-ctx.Done(): @@ -406,6 +593,36 @@ func Run(ctx context.Context, grpcHealthPort int) error { if logFileHostPath != "" { rotateLogFile(logFileHostPath) } + + case <-ebpfHealthTicker.C: + if ebpfState.objs == nil { + continue + } + healthErr := attach.Health(ebpfState.objs, ebpfState.ifaces) + healthy := healthErr == nil + if healthy != ebpfLastHealthy { + if healthy { + slog.Info("eBPF uSID datapath health check recovered") + } else { + slog.Error("eBPF uSID datapath health check failed", "err", healthErr) + } + ebpfLastHealthy = healthy + } + status := grpc_health_v1.HealthCheckResponse_NOT_SERVING + if healthy { + status = grpc_health_v1.HealthCheckResponse_SERVING + } + healthSrv.SetServingStatus(ebpfHealthServiceName, status) + + case <-ebpfGCSweepTicker.C: + if ebpfState.k8sClient == nil { + continue + } + result := gc.SweepEBPFVRFTable(ctx, ebpfState.k8sClient, ebpfState.namespace, ebpfState.nodeName, attach.PinDir) + if result.EBPFVRFEntriesRemoved > 0 || result.Errors > 0 { + slog.Info("eBPF vrf_table GC sweep complete", + "removed", result.EBPFVRFEntriesRemoved, "errors", result.Errors) + } } } } diff --git a/internal/installer/installer_test.go b/internal/installer/installer_test.go index 710ba1e..dca20eb 100644 --- a/internal/installer/installer_test.go +++ b/internal/installer/installer_test.go @@ -6,11 +6,15 @@ package installer import ( "context" + "errors" "fmt" + "io" "net" + "net/http" "os" "path/filepath" "strings" + "sync/atomic" "testing" "time" @@ -24,8 +28,17 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client/fake" "go.datum.net/galactic/internal/config" + "go.datum.net/galactic/internal/plumbing/ebpf/attach" ) +func requireRoot(t *testing.T) { + t.Helper() + if os.Geteuid() != 0 { + t.Skip("test requires root (CAP_BPF/CAP_NET_ADMIN/CAP_SYS_ADMIN) to load/attach a real BPF " + + "program; re-run via sudo") + } +} + func TestResolveLogLevel(t *testing.T) { tests := []struct { name string @@ -261,6 +274,16 @@ func TestBootstrap(t *testing.T) { } func TestRun(t *testing.T) { + // This test exercises Run's general daemon behavior (health server, + // log rotation, shutdown) -- not the eBPF datapath itself (covered by + // the TestRun_EBPFDatapathEnabled_* tests below), so stand in a fake + // ebpfStartFn rather than requiring root/a real kernel BPF stack here. + origEBPFStartFn := ebpfStartFn + t.Cleanup(func() { ebpfStartFn = origEBPFStartFn }) + ebpfStartFn = func(_ context.Context, _ string) (io.Closer, []string, error) { + return &fakeDatapathCloser{}, []string{"eth0"}, nil + } + // Set up directories tmpDir := t.TempDir() HostBinDir = filepath.Join(tmpDir, "host", "opt", "cni", "bin") @@ -300,7 +323,7 @@ func TestRun(t *testing.T) { // Run in background errCh := make(chan error, 1) go func() { - errCh <- Run(ctx, healthPort) + errCh <- Run(ctx, healthPort, healthPort+1000) }() // Query gRPC health endpoint @@ -336,3 +359,210 @@ func TestRun(t *testing.T) { t.Fatal("Run did not exit on context cancel") } } + +// fakeDatapathCloser is a mocked io.Closer standing in for +// *prog.UsidObjects in tests, so Run's eBPF datapath wiring can be +// exercised without root, a real kernel BPF stack, or a live network +// interface. closed is an atomic.Bool (not a bare bool) because Run's +// deferred Close() runs on the goroutine Run itself executes on, which +// this test observes from its own goroutine with no other synchronization +// between the two. +type fakeDatapathCloser struct { + closed atomic.Bool + err error +} + +func (f *fakeDatapathCloser) Close() error { + f.closed.Store(true) + return f.err +} + +// TestRun_EBPFDatapathEnabled_StartsAndClosesOnShutdown covers Milestone +// 3.1's wiring of the eBPF uSID datapath into the run subcommand: Run +// calls ebpfStartFn with attach.PinDir and closes the returned object on +// shutdown. +func TestRun_EBPFDatapathEnabled_StartsAndClosesOnShutdown(t *testing.T) { + origEBPFStartFn := ebpfStartFn + t.Cleanup(func() { ebpfStartFn = origEBPFStartFn }) + + fakeDP := &fakeDatapathCloser{} + var gotPinDir atomic.Value + ebpfStartFn = func(_ context.Context, pinDir string) (io.Closer, []string, error) { + gotPinDir.Store(pinDir) + return fakeDP, []string{"eth0"}, nil + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + healthPort := 25180 + + errCh := make(chan error, 1) + go func() { + errCh <- Run(ctx, healthPort, healthPort+1000) + }() + + time.Sleep(100 * time.Millisecond) + if got, _ := gotPinDir.Load().(string); got != attach.PinDir { + t.Errorf("ebpfStartFn pinDir = %q, want %q", got, attach.PinDir) + } + if fakeDP.closed.Load() { + t.Error("datapath closed before shutdown") + } + + cancel() + select { + case err := <-errCh: + if err != nil { + t.Fatalf("Run returned unexpected error: %v", err) + } + case <-time.After(2 * time.Second): + t.Fatal("Run did not exit on context cancel") + } + + if !fakeDP.closed.Load() { + t.Error("datapath was not closed on shutdown") + } +} + +// TestRun_EBPFDatapathEnabled_StartFailureIsFatal covers the milestone's +// requirement that a preflight/load/attach failure blocks the datapath -- +// here, that failure must also be fatal to Run itself, not silently +// swallowed or degraded into a partial datapath state. +func TestRun_EBPFDatapathEnabled_StartFailureIsFatal(t *testing.T) { + origEBPFStartFn := ebpfStartFn + t.Cleanup(func() { ebpfStartFn = origEBPFStartFn }) + + wantErr := errors.New("simulated preflight/load/attach failure") + ebpfStartFn = func(_ context.Context, _ string) (io.Closer, []string, error) { + return nil, nil, wantErr + } + + err := Run(context.Background(), 25181, 26181) + if err == nil { + t.Fatal("Run() error = nil, want the eBPF datapath start failure surfaced") + } + if !errors.Is(err, wantErr) { + t.Errorf("Run() error = %v, want it to wrap %v", err, wantErr) + } +} + +// TestRun_EBPFDatapathEnabled_MetricsAndHealthReflectRealDatapath is +// Milestone 4's real-kernel exit criterion, exercised through Run() itself +// rather than attach/metrics' own lower-level unit tests: metrics are +// visible in a local test run, and the gRPC "ebpf-datapath" health service +// flips to NOT_SERVING when the program is detached, then recovers once +// re-attached. Attaches to "lo" (always present, no test network namespace +// needed -- lo carries no traffic this test could disrupt) via +// GALACTIC_CNI_EBPF_INTERFACES, using a throwaway pin directory under the +// real bpffs so it never collides with attach.PinDir's production path. +func TestRun_EBPFDatapathEnabled_MetricsAndHealthReflectRealDatapath(t *testing.T) { + requireRoot(t) + + pinDir := filepath.Join("/sys/fs/bpf", fmt.Sprintf("galactic-run-test-%d", os.Getpid())) + t.Cleanup(func() { _ = os.RemoveAll(pinDir) }) + + t.Setenv(config.EnvCNIEBPFInterfaces, "lo") + + origEBPFStartFn := ebpfStartFn + t.Cleanup(func() { ebpfStartFn = origEBPFStartFn }) + ebpfStartFn = func(ctx context.Context, _ string) (io.Closer, []string, error) { + return attach.StartWatching(ctx, pinDir) + } + + origInterval := ebpfHealthCheckInterval + t.Cleanup(func() { ebpfHealthCheckInterval = origInterval }) + ebpfHealthCheckInterval = 50 * time.Millisecond + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + const healthPort = 25182 + const metricsPort = 26182 + + errCh := make(chan error, 1) + go func() { + errCh <- Run(ctx, healthPort, metricsPort) + }() + t.Cleanup(func() { + cancel() + select { + case <-errCh: + case <-time.After(2 * time.Second): + t.Error("Run did not exit on context cancel during cleanup") + } + }) + + // Allow Load/Attach and both listeners to come up. + time.Sleep(300 * time.Millisecond) + + conn, err := grpc.NewClient( + fmt.Sprintf("127.0.0.1:%d", healthPort), + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + if err != nil { + t.Fatalf("grpc.NewClient: %v", err) + } + defer func() { _ = conn.Close() }() + hc := grpc_health_v1.NewHealthClient(conn) + + checkStatus := func(t *testing.T, want grpc_health_v1.HealthCheckResponse_ServingStatus) { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + var last grpc_health_v1.HealthCheckResponse_ServingStatus + for time.Now().Before(deadline) { + resp, err := hc.Check(context.Background(), &grpc_health_v1.HealthCheckRequest{Service: ebpfHealthServiceName}) + if err != nil { + t.Fatalf("Health check for %q failed: %v", ebpfHealthServiceName, err) + } + last = resp.Status + if last == want { + return + } + time.Sleep(20 * time.Millisecond) + } + t.Fatalf("Health status for %q = %v, want %v (timed out waiting)", ebpfHealthServiceName, last, want) + } + + // Initially attached: SERVING. + checkStatus(t, grpc_health_v1.HealthCheckResponse_SERVING) + + // Metrics endpoint is up and exposes this milestone's namespace. + metricsReq, err := http.NewRequestWithContext( + context.Background(), http.MethodGet, fmt.Sprintf("http://127.0.0.1:%d/metrics", metricsPort), nil) + if err != nil { + t.Fatalf("build /metrics request: %v", err) + } + metricsResp, err := http.DefaultClient.Do(metricsReq) + if err != nil { + t.Fatalf("GET /metrics: %v", err) + } + defer func() { _ = metricsResp.Body.Close() }() + if metricsResp.StatusCode != http.StatusOK { + t.Errorf("GET /metrics status = %d, want 200", metricsResp.StatusCode) + } + body := make([]byte, 64*1024) + n, _ := metricsResp.Body.Read(body) + if !strings.Contains(string(body[:n]), "galactic_usid_") { + t.Errorf("GET /metrics body does not contain the galactic_usid_ namespace: %q", string(body[:n])) + } + + // Kill the attach (without closing our own fds -- same distinction + // attach's own TestHealth_RealDatapath_FlipsAfterAttachIsKilled draws) + // and confirm the health service flips. + if err := attach.Detach([]string{"lo"}); err != nil { + t.Fatalf("Detach: %v", err) + } + checkStatus(t, grpc_health_v1.HealthCheckResponse_NOT_SERVING) + + // Re-attach and confirm recovery. + objs, ifaces, err := ebpfStartFn(ctx, pinDir) + if err != nil { + t.Fatalf("re-attach via ebpfStartFn: %v", err) + } + t.Cleanup(func() { _ = objs.Close() }) + if len(ifaces) != 1 || ifaces[0] != "lo" { + t.Fatalf("re-attach ifaces = %v, want [lo]", ifaces) + } + checkStatus(t, grpc_health_v1.HealthCheckResponse_SERVING) +} diff --git a/internal/plumbing/ebpf/attach/attach.go b/internal/plumbing/ebpf/attach/attach.go new file mode 100644 index 0000000..7f62a58 --- /dev/null +++ b/internal/plumbing/ebpf/attach/attach.go @@ -0,0 +1,281 @@ +// Copyright 2026 Datum Cloud, Inc. +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package attach + +import ( + "errors" + "fmt" + "os" + + "github.com/cilium/ebpf" + "github.com/cilium/ebpf/rlimit" + "github.com/vishvananda/netlink" + "golang.org/x/sys/unix" + + "go.datum.net/galactic/internal/plumbing/ebpf/preflight" + "go.datum.net/galactic/internal/plumbing/ebpf/prog" +) + +// PinDir is the default bpffs directory every usid_ingress map is pinned +// under (design plan §4.4/§9: "All maps pinned under /sys/fs/bpf/galactic/ +// so a control-daemon restart does not require the datapath to stop +// forwarding"). +const PinDir = "/sys/fs/bpf/galactic" + +// filterName and filterPriority identify this package's own TC-BPF ingress +// filter on an interface, so re-attachment (across a container restart, or +// Watch's netlink-driven re-attachment, Milestone 3.2) replaces the same +// filter instead of stacking a duplicate. +const ( + filterName = "galactic_usid_ingress" + filterPriority = 1 +) + +// preflightCheckFn is a package-level override point so tests can force the +// preflight failure path without touching the real kernel -- the same +// pattern preflight.CheckWith itself uses for Prober. +var preflightCheckFn = preflight.Check + +// Start runs the kernel preflight check (blocking on failure -- design plan +// §6), loads and pins internal/plumbing/ebpf/prog's compiled usid_ingress +// object under pinDir, resolves the interface set per §4.1, and attaches +// the program to each resolved interface's ingress hook. On any failure the +// returned *prog.UsidObjects is nil and any partially-loaded kernel objects +// from this call are cleaned up -- there is no partial/unsafe fallback. +// +// On success, the caller owns the returned objects and the interfaces +// actually attached to; the objects should be kept open for the life of the +// process and Closed on shutdown (see the package doc comment for why that +// is safe). +func Start(pinDir string) (objs *prog.UsidObjects, ifaces []string, err error) { + objs, err = Load(pinDir) + if err != nil { + return nil, nil, err + } + + ifaces, err = ResolveInterfaces() + if err != nil { + _ = objs.Close() + return nil, nil, fmt.Errorf("attach: resolve interfaces: %w", err) + } + + if err := Attach(objs.UsidIngress, ifaces); err != nil { + _ = objs.Close() + return nil, nil, fmt.Errorf("attach: %w", err) + } + + return objs, ifaces, nil +} + +// Load runs the kernel preflight check (design plan §6, Milestone 2.3) and, +// only if it passes, loads internal/plumbing/ebpf/prog's compiled object +// with every map pinned under pinDir. A map already pinned there from a +// previous process is reused as-is (its contents survive); a map with no +// existing pin is created and pinned fresh. Load does not attach the +// program to any interface -- call Attach (or use Start) for that. +func Load(pinDir string) (objs *prog.UsidObjects, err error) { + // loadHook observes every return path below (design plan §9's "BPF + // program load/reload events and failures" metric; Milestone 4) via a + // single defer over the named return, rather than a call at each + // return statement -- so a future return path added here can't + // accidentally forget to report itself. + defer func() { loadHook(err) }() + + if err = preflightCheckFn(); err != nil { + err = fmt.Errorf("attach: kernel preflight check failed, refusing to load the eBPF uSID datapath: %w", err) + return nil, err + } + + if err = rlimit.RemoveMemlock(); err != nil { + err = fmt.Errorf("attach: remove memlock rlimit: %w", err) + return nil, err + } + + if err = os.MkdirAll(pinDir, 0o755); err != nil { + err = fmt.Errorf("attach: create bpf map pin directory %q: %w", pinDir, err) + return nil, err + } + + spec, specErr := prog.LoadUsid() + if specErr != nil { + err = fmt.Errorf("attach: load compiled usid_ingress collection spec: %w", specErr) + return nil, err + } + + // Pin every map by name under pinDir (design plan §4.4: "All maps + // pinned"). usid.c's map definitions don't set a BTF `pinning` + // attribute themselves, so pinning is configured here at load time + // instead -- see github.com/cilium/ebpf's Map.newMapWithOptions: + // PinByName + MapOptions.PinPath together make LoadAndAssign reuse an + // existing pin if one exists at /, rather than + // always creating a fresh map. + for _, m := range spec.Maps { + m.Pinning = ebpf.PinByName + } + + var loaded prog.UsidObjects + opts := &ebpf.CollectionOptions{ + Maps: ebpf.MapOptions{PinPath: pinDir}, + } + if loadErr := spec.LoadAndAssign(&loaded, opts); loadErr != nil { + var ve *ebpf.VerifierError + if errors.As(loadErr, &ve) { + detail := fmt.Sprintf("%+v", ve) + err = fmt.Errorf("attach: verifier rejected usid_ingress program:\n%s: %w", detail, loadErr) + } else { + err = fmt.Errorf("attach: load and pin usid_ingress objects: %w", loadErr) + } + return nil, err + } + + return &loaded, nil +} + +// Attach attaches program to the ingress hook of each named interface via a +// clsact qdisc + direct-action BPF filter (design plan §4.1: "TC-BPF +// (clsact qdisc, ingress)"), creating the clsact qdisc if it doesn't +// already exist. Re-running Attach against an interface that already has +// this package's filter replaces it (github.com/vishvananda/netlink's +// FilterReplace) instead of stacking a duplicate, so repeated calls -- a +// container restart, or Watch's netlink-driven re-attachment (Milestone +// 3.2) -- are idempotent. Every interface is attempted even if one fails; +// all failures are joined and returned together. +func Attach(program *ebpf.Program, ifaceNames []string) error { + if program == nil { + return errors.New("attach: program is nil") + } + if len(ifaceNames) == 0 { + return errors.New("attach: no interfaces to attach to") + } + + var errs []error + for _, name := range ifaceNames { + if err := attachOne(program, name); err != nil { + errs = append(errs, fmt.Errorf("interface %q: %w", name, err)) + } + } + return errors.Join(errs...) +} + +// attachOne attaches program to one interface's ingress hook. It is the +// single internal choke point every attach path in this package goes +// through (Attach's loop below, and Watch's netlink-driven reconcile in +// watch.go), so instrumenting it here with attachHook (hooks.go, Milestone +// 4) observes every attach attempt regardless of caller. +func attachOne(program *ebpf.Program, name string) (err error) { + defer func() { attachHook(name, err) }() + + link, err := netlink.LinkByName(name) + if err != nil { + err = fmt.Errorf("find link: %w", err) + return err + } + + if err = ensureClsact(link); err != nil { + err = fmt.Errorf("ensure clsact qdisc: %w", err) + return err + } + + filter := &netlink.BpfFilter{ + FilterAttrs: netlink.FilterAttrs{ + LinkIndex: link.Attrs().Index, + Parent: netlink.HANDLE_MIN_INGRESS, + Handle: netlink.MakeHandle(0, 1), + Protocol: unix.ETH_P_ALL, + Priority: filterPriority, + }, + Fd: program.FD(), + Name: filterName, + DirectAction: true, + } + if err = netlink.FilterReplace(filter); err != nil { + err = fmt.Errorf("attach tc-bpf ingress filter: %w", err) + return err + } + return nil +} + +// Detach removes this package's own TC-BPF ingress filter (identified by +// filterName) from each named interface, without touching the interface's +// clsact qdisc itself -- another filter, or a future Attach, may still need +// it. It is not an error for an interface to already lack the filter, or to +// no longer exist on the host at all (netlink.LinkNotFoundError): Watch +// (Milestone 3.2) calls Detach for interfaces that just dropped out of the +// resolved interface set, and by the time that runs the interface may +// already be gone entirely. Every interface is attempted even if one fails; +// all failures are joined and returned together, matching Attach's own +// all-attempted semantics. +func Detach(ifaceNames []string) error { + var errs []error + for _, name := range ifaceNames { + if err := detachOne(name); err != nil { + errs = append(errs, fmt.Errorf("interface %q: %w", name, err)) + } + } + return errors.Join(errs...) +} + +// detachOne removes this package's own tc filter from one interface's +// ingress hook, if present. Like attachOne, it is the single internal +// choke point every detach path in this package goes through (Detach's +// loop below, called from Watch's netlink-driven reconcile), so +// instrumenting it here with detachHook (hooks.go, Milestone 4) observes +// every detach attempt regardless of caller. +func detachOne(name string) (err error) { + defer func() { detachHook(name, err) }() + + link, err := netlink.LinkByName(name) + if err != nil { + var notFound netlink.LinkNotFoundError + if errors.As(err, ¬Found) { + err = nil + return nil + } + err = fmt.Errorf("find link: %w", err) + return err + } + + filters, listErr := netlink.FilterList(link, netlink.HANDLE_MIN_INGRESS) + if listErr != nil { + err = fmt.Errorf("list filters: %w", listErr) + return err + } + for _, f := range filters { + bpfFilter, ok := f.(*netlink.BpfFilter) + if !ok || bpfFilter.Name != filterName { + continue + } + if delErr := netlink.FilterDel(f); delErr != nil { + err = fmt.Errorf("delete tc-bpf ingress filter: %w", delErr) + return err + } + } + return nil +} + +// ensureClsact adds a clsact qdisc to link if one isn't already present. +func ensureClsact(link netlink.Link) error { + qdiscs, err := netlink.QdiscList(link) + if err != nil { + return fmt.Errorf("list qdiscs: %w", err) + } + for _, q := range qdiscs { + if _, ok := q.(*netlink.Clsact); ok { + return nil + } + } + + qdisc := &netlink.Clsact{ + QdiscAttrs: netlink.QdiscAttrs{ + LinkIndex: link.Attrs().Index, + Handle: netlink.MakeHandle(0xffff, 0), + Parent: netlink.HANDLE_CLSACT, + }, + } + if err := netlink.QdiscAdd(qdisc); err != nil { + return fmt.Errorf("add clsact qdisc: %w", err) + } + return nil +} diff --git a/internal/plumbing/ebpf/attach/attach_test.go b/internal/plumbing/ebpf/attach/attach_test.go new file mode 100644 index 0000000..d8a2a41 --- /dev/null +++ b/internal/plumbing/ebpf/attach/attach_test.go @@ -0,0 +1,182 @@ +// Copyright 2026 Datum Cloud, Inc. +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package attach + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/containernetworking/plugins/pkg/ns" + "github.com/vishvananda/netlink" + + "go.datum.net/galactic/internal/plumbing/ebpf/prog" +) + +func requireRoot(t *testing.T) { + t.Helper() + if os.Geteuid() != 0 { + t.Skip("test requires root (CAP_BPF/CAP_NET_ADMIN/CAP_SYS_ADMIN) to load/attach BPF programs " + + "and create a test network namespace; re-run via sudo") + } +} + +// TestLoad_PreflightBlocksLoad exercises the milestone's hard requirement +// that the kernel preflight check runs and blocks load on failure -- +// exercised here via a stubbed preflightCheckFn so it needs no root and no +// real kernel capability gap to prove Load never reaches the BPF loader +// once the preflight check fails. +func TestLoad_PreflightBlocksLoad(t *testing.T) { + origPreflight := preflightCheckFn + t.Cleanup(func() { preflightCheckFn = origPreflight }) + + wantErr := errors.New("simulated missing kernel capability") + preflightCheckFn = func() error { return wantErr } + + // A pin directory that does not exist and is not on a bpffs -- if Load + // ever got past the preflight check, os.MkdirAll or the BPF loader + // itself would fail for an entirely different, less specific reason. + // Getting back an error that wraps wantErr proves the preflight check + // is what stopped it, not something else downstream. + pinDir := filepath.Join(t.TempDir(), "does-not-exist", "galactic") + + objs, err := Load(pinDir) + if err == nil { + t.Fatal("Load() error = nil, want a preflight failure") + } + if !errors.Is(err, wantErr) { + t.Errorf("Load() error = %v, want it to wrap the preflight error %v", err, wantErr) + } + if objs != nil { + t.Errorf("Load() objs = %v, want nil on preflight failure", objs) + } + if _, statErr := os.Stat(pinDir); statErr == nil { + t.Error("Load() created the pin directory despite failing preflight -- it must not touch " + + "the filesystem or the kernel before the preflight check passes") + } +} + +// TestLoadAttach_SurvivesRestartWithMapsIntact is this milestone's exit +// criterion: program load/attach survives a container restart with maps +// intact (pinned-map continuity, design plan §4.4/§9). It requires real +// root privileges to load/attach a BPF program and create an isolated test +// network namespace, so it is skipped (not silently passed) when not run +// as root. +// +// "Restart" is simulated by closing the first Load/Attach's objects (which +// releases this process's own FDs, exactly like a container exiting) and +// then calling Load/Attach again against the *same* pin directory and +// interface -- the second call stands in for the replacement container's +// process. Map contents populated before the "restart" must still be +// readable after it, and re-attaching must replace the existing filter +// rather than stack a second one. +func TestLoadAttach_SurvivesRestartWithMapsIntact(t *testing.T) { + requireRoot(t) + + pinDir := filepath.Join("/sys/fs/bpf", fmt.Sprintf("galactic-test-%d", os.Getpid())) + t.Cleanup(func() { _ = os.RemoveAll(pinDir) }) + + const ifaceName = "usidtest0" + + nsObj, err := ns.TempNetNS() + if err != nil { + t.Fatalf("create test netns: %v", err) + } + defer func() { _ = nsObj.Close() }() + + err = nsObj.Do(func(_ ns.NetNS) error { + handle, err := netlink.NewHandle() + if err != nil { + return err + } + defer handle.Close() //nolint:errcheck // best-effort cleanup + + dummy := &netlink.Dummy{LinkAttrs: netlink.LinkAttrs{Name: ifaceName}} + if err := handle.LinkAdd(dummy); err != nil { + return fmt.Errorf("add dummy link: %w", err) + } + return handle.LinkSetUp(dummy) + }) + if err != nil { + t.Fatalf("setup dummy interface: %v", err) + } + + const locatorKey uint64 = 0x0102030405060708 + + // --- pre-restart: first load, attach, and populate a map entry. --- + err = nsObj.Do(func(_ ns.NetNS) error { + objs, err := Load(pinDir) + if err != nil { + return fmt.Errorf("load: %w", err) + } + defer func() { _ = objs.Close() }() + + if err := Attach(objs.UsidIngress, []string{ifaceName}); err != nil { + return fmt.Errorf("attach: %w", err) + } + + if err := objs.LocatorTable.Put(locatorKey, prog.UsidLocatorValue{Generation: 1}); err != nil { + return fmt.Errorf("populate locator_table: %w", err) + } + return nil + }) + if err != nil { + t.Fatalf("pre-restart load/attach/populate: %v", err) + } + + // --- simulate a container restart: fresh Load+Attach against the + // same pinDir/interface, as a brand new process would do. --- + err = nsObj.Do(func(_ ns.NetNS) error { + objs, err := Load(pinDir) + if err != nil { + return fmt.Errorf("reload after restart: %w", err) + } + defer func() { _ = objs.Close() }() + + if err := Attach(objs.UsidIngress, []string{ifaceName}); err != nil { + return fmt.Errorf("re-attach after restart: %w", err) + } + + var val prog.UsidLocatorValue + if err := objs.LocatorTable.Lookup(locatorKey, &val); err != nil { + return fmt.Errorf("lookup locator_table entry after restart: %w", err) + } + if val.Generation != 1 { + return fmt.Errorf("locator_table entry after restart: generation = %d, want 1", val.Generation) + } + + // Re-attach must replace the existing filter, not stack a second + // one alongside it. + link, err := netlink.LinkByName(ifaceName) + if err != nil { + return fmt.Errorf("find link: %w", err) + } + filters, err := netlink.FilterList(link, netlink.HANDLE_MIN_INGRESS) + if err != nil { + return fmt.Errorf("list filters: %w", err) + } + if len(filters) != 1 { + return fmt.Errorf("filter count after re-attach = %d, want 1 (idempotent replace, not a duplicate)", + len(filters)) + } + return nil + }) + if err != nil { + t.Fatalf("post-restart verification: %v", err) + } +} + +// TestAttach_NoInterfacesIsError covers the defensive nil/empty-input +// guards in Attach directly, without needing root. +func TestAttach_NoInterfacesIsError(t *testing.T) { + if err := Attach(nil, []string{testIfaceEth0}); err == nil { + t.Error("Attach(nil program, ...) error = nil, want an error") + } + if err := Attach(nil, nil); err == nil { + t.Error("Attach(nil, nil) error = nil, want an error") + } +} diff --git a/internal/plumbing/ebpf/attach/doc.go b/internal/plumbing/ebpf/attach/doc.go new file mode 100644 index 0000000..37c8465 --- /dev/null +++ b/internal/plumbing/ebpf/attach/doc.go @@ -0,0 +1,59 @@ +// Copyright 2026 Datum Cloud, Inc. +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +// Package attach implements the eBPF/TC-BPF uSID datapath's control-daemon +// load/attach/pin/watch lifecycle (design plan +// .local/plan-ebpf-xdp-usid-datapath.md §4.1, §4.4, §5.4, §9; Milestones +// 3.1 and 3.2 of .local/implementation-plan-ebpf-xdp-usid-datapath.md). +// +// StartWatching (the package's entry point for galactic-cni's `run` +// subcommand, internal/installer.Run) wraps Start (below) and additionally +// launches Watch in a background goroutine: for as long as the caller's +// context isn't canceled, Watch subscribes to netlink link and route change +// events and re-evaluates the resolved interface set whenever one occurs +// (design plan §4.1: "re-evaluate on interface/route change events +// (netlink subscription), not just at startup"), attaching to newly +// resolved interfaces and detaching from ones that dropped out -- Milestone +// 3.2, this package's own gap flagged by Milestone 3.1's handoff notes. +// +// Start itself (still exported directly for tests and any caller that only +// wants the one-time startup behavior) does three things, in order: +// +// 1. Runs internal/plumbing/ebpf/preflight's kernel-capability check and +// refuses to proceed at all if it fails -- there is no partial/unsafe +// fallback (design plan §6). This must run before anything below. +// 2. Loads internal/plumbing/ebpf/prog's compiled usid_ingress object, +// pinning every map under a fixed bpffs directory (PinDir, +// /sys/fs/bpf/galactic by default) so a control-daemon restart reuses +// the maps already pinned there from a previous run instead of +// recreating them empty -- pinned-map continuity across a container +// restart (design plan §4.4/§9), this milestone's exit criterion. +// 3. Resolves the interface set to attach to (design plan §4.1): an +// explicit GALACTIC_CNI_EBPF_INTERFACES override, if set, or +// auto-detection of the interface(s) carrying the default IPv6 route, +// then attaches usid_ingress to each one's ingress hook via a clsact +// qdisc + direct-action BPF filter (classic TC-BPF, not the newer +// kernel-6.6+-only TCX link mechanism -- the design plan explicitly +// says "TC-BPF (clsact qdisc, ingress)", and this keeps the attach path +// working across the widest kernel range the preflight check already +// targets). Re-running Attach against an interface that already has a +// galactic uSID filter replaces it (netlink.FilterReplace) rather than +// stacking a duplicate -- this is what makes Start idempotent across a +// container restart, alongside the maps' own pin persistence. +// +// Once attached, the classic tc filter holds its own kernel reference to +// the loaded program independent of the process that created it (design +// plan §5.4: "BPF programs, once attached, run independently of the +// process that loaded them") -- so the returned *prog.UsidObjects can be +// (and, in internal/installer.Run, is) kept open for the life of the +// process and Closed only on shutdown, without disrupting already-attached +// forwarding: Close releases this process's own map/program file +// descriptors, it does not detach the filter or unpin the maps. +// +// This package does not yet populate locator_table/function_table/ +// vrf_table with real data (that's Milestone 3.3's GC-facing map API and +// Milestones 6/7's control-plane encoder and CNI registration call), and +// does not expose a health-check surface (Milestone 4). It only builds the +// load/attach/pin/watch lifecycle those milestones build on. +package attach diff --git a/internal/plumbing/ebpf/attach/health.go b/internal/plumbing/ebpf/attach/health.go new file mode 100644 index 0000000..b77efb9 --- /dev/null +++ b/internal/plumbing/ebpf/attach/health.go @@ -0,0 +1,179 @@ +// Copyright 2026 Datum Cloud, Inc. +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package attach + +import ( + "errors" + "fmt" + + "github.com/cilium/ebpf" + "github.com/vishvananda/netlink" + + "go.datum.net/galactic/internal/plumbing/ebpf/prog" +) + +// linkByNameFn and filterListFn are package-level override points -- the +// same pattern used throughout this package (interfaces.go's +// routeListFn/linkByIndexFn, watch.go's linkSubscribeFn/routeSubscribeFn) -- +// so Health's own tests can simulate an interface losing its attachment +// without needing root or a live network interface. +var ( + linkByNameFn = netlink.LinkByName + filterListFn = netlink.FilterList +) + +// Health reports whether the eBPF uSID datapath is genuinely healthy right +// now: objs is non-nil and its program/maps still have live, reachable +// kernel file descriptors, and the program is still attached to every +// interface in ifaces via this package's own TC-BPF ingress filter +// (filterName). This is deliberately more than "the process is alive" +// (design plan .local/plan-ebpf-xdp-usid-datapath.md §9: "confirms the BPF +// program is actually attached and the maps are reachable -- not just that +// the process is alive"; Milestone 4 of +// .local/implementation-plan-ebpf-xdp-usid-datapath.md) -- a process can be +// running perfectly well while its BPF program has been unloaded out from +// under it (e.g. someone ran `ip link del` on the attached interface, or +// bpftool prog detach), and this function is what a caller (internal/ +// installer's gRPC health check) uses to notice that gap. +// +// A non-nil error joins every failing check (errors.Join), so a caller +// logging the result sees the complete picture rather than just the first +// problem encountered; every interface in ifaces is checked even after an +// earlier interface or the program/map checks already failed. +func Health(objs *prog.UsidObjects, ifaces []string) error { + if objs == nil { + return errors.New("attach: health: eBPF uSID datapath objects are nil (not loaded)") + } + + return errors.Join( + checkProgramReachable(objs.UsidIngress), + checkMapsReachable(objs), + checkAttached(ifaces), + ) +} + +// checkProgramReachable confirms this process's own handle to usid_ingress +// still refers to a live kernel program, via a lightweight +// BPF_OBJ_GET_INFO_BY_FD query (program.Info()) rather than anything that +// touches the packet path. +func checkProgramReachable(program *ebpf.Program) error { + if program == nil { + return errors.New("attach: health: usid_ingress program handle is nil") + } + if _, err := program.Info(); err != nil { + return fmt.Errorf("attach: health: usid_ingress program not reachable (unloaded?): %w", err) + } + return nil +} + +// checkMapsReachable confirms every one of the three control-plane maps +// (locator_table, function_table, vrf_table) plus drop_reasons still has a +// live, reachable kernel file descriptor, via the same lightweight Info() +// query checkProgramReachable uses for the program. +func checkMapsReachable(objs *prog.UsidObjects) error { + checks := []struct { + name string + m *ebpf.Map + }{ + {prog.UsidMapVrfTable, objs.VrfTable}, + {prog.UsidMapLocatorTable, objs.LocatorTable}, + {prog.UsidMapFunctionTable, objs.FunctionTable}, + {prog.UsidMapDropReasons, objs.DropReasons}, + } + + var errs []error + for _, c := range checks { + if c.m == nil { + errs = append(errs, fmt.Errorf("attach: health: map %q handle is nil", c.name)) + continue + } + if _, err := c.m.Info(); err != nil { + errs = append(errs, fmt.Errorf("attach: health: map %q not reachable: %w", c.name, err)) + } + } + return errors.Join(errs...) +} + +// checkAttached confirms this package's own TC-BPF ingress filter +// (filterName) is currently present on every interface in ifaces -- +// proving actual kernel-level attachment, not merely that this process's +// program/map handles are still open (checkProgramReachable/ +// checkMapsReachable can pass even after the filter itself was removed by +// something outside this process, e.g. `tc filter del` or the interface +// being recreated). +func checkAttached(ifaces []string) error { + if len(ifaces) == 0 { + return errors.New("attach: health: no interfaces resolved to check attachment against") + } + + var errs []error + for _, name := range ifaces { + if err := checkAttachedOne(name); err != nil { + errs = append(errs, fmt.Errorf("attach: health: interface %q: %w", name, err)) + } + } + return errors.Join(errs...) +} + +// checkAttachedOne confirms filterName is present among name's ingress +// filters -- the same identification method detachOne already uses (match +// by filter name, not by comparing file descriptor numbers, which are +// process-local and not meaningfully comparable against a value returned +// from a netlink query). +func checkAttachedOne(name string) error { + link, err := linkByNameFn(name) + if err != nil { + return fmt.Errorf("find link: %w", err) + } + + filters, err := filterListFn(link, netlink.HANDLE_MIN_INGRESS) + if err != nil { + return fmt.Errorf("list filters: %w", err) + } + for _, f := range filters { + if bpfFilter, ok := f.(*netlink.BpfFilter); ok && bpfFilter.Name == filterName { + return nil + } + } + return errors.New("galactic uSID ingress filter not attached") +} + +// Handle bundles a loaded *prog.UsidObjects with a Healthy check +// (Milestone 4), so internal/installer's gRPC health-check handler can +// query datapath health without depending on prog.UsidObjects or this +// package's Health function directly -- and without needing to track the +// datapath's resolved interface set itself (Healthy re-resolves it fresh on +// every call, see below). Objs is exported so a caller that also needs the +// raw maps for something else (e.g. internal/plumbing/ebpf/metrics's +// Prometheus collector, also Milestone 4) can reach them without a second +// load. +type Handle struct { + Objs *prog.UsidObjects +} + +// Close releases this process's own BPF map/program file descriptors -- it +// does not detach the filter or unpin the maps (see the package doc +// comment for why that is safe). +func (h *Handle) Close() error { + return h.Objs.Close() +} + +// Healthy re-resolves the current interface set (the same auto-detect/ +// override logic ResolveInterfaces always uses) and reports whether the +// datapath is still attached to it and its maps are reachable. Re-resolving +// fresh on every call, rather than reusing a cached set captured at +// startup, means a health probe reflects the datapath's *current* desired +// attachment state, consistent with Watch's own netlink-driven +// re-evaluation (Milestone 3.2) -- a transient ResolveInterfaces failure +// (e.g. no default route momentarily) is reported as unhealthy here too, +// which is the correct, if occasionally noisy, behavior for a liveness/ +// readiness signal. +func (h *Handle) Healthy() error { + ifaces, err := ResolveInterfaces() + if err != nil { + return fmt.Errorf("attach: health: resolve interfaces: %w", err) + } + return Health(h.Objs, ifaces) +} diff --git a/internal/plumbing/ebpf/attach/health_test.go b/internal/plumbing/ebpf/attach/health_test.go new file mode 100644 index 0000000..45a574d --- /dev/null +++ b/internal/plumbing/ebpf/attach/health_test.go @@ -0,0 +1,223 @@ +// Copyright 2026 Datum Cloud, Inc. +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package attach + +import ( + "errors" + "fmt" + "path/filepath" + "strings" + "testing" + + "github.com/containernetworking/plugins/pkg/ns" + "github.com/vishvananda/netlink" + + "go.datum.net/galactic/internal/plumbing/ebpf/prog" +) + +// TestHealth_NilObjectsIsUnhealthy covers the guard clause directly, no +// root or kernel required. +func TestHealth_NilObjectsIsUnhealthy(t *testing.T) { + if err := Health(nil, []string{testIfaceEth0}); err == nil { + t.Fatal("Health(nil, ...) error = nil, want an error") + } +} + +// TestCheckAttached_FakeNetlink exercises checkAttached/checkAttachedOne's +// logic against a faked netlink view (linkByNameFn/filterListFn), without +// needing root or a real interface -- covering the three outcomes the real +// integration test below can only exercise one of per run: interface +// missing entirely, interface present but without our filter, and +// interface present with our filter attached. +func TestCheckAttached_FakeNetlink(t *testing.T) { + origLinkByName := linkByNameFn + origFilterList := filterListFn + t.Cleanup(func() { + linkByNameFn = origLinkByName + filterListFn = origFilterList + }) + + dummyLink := &netlink.Dummy{LinkAttrs: netlink.LinkAttrs{Name: testIfaceEth0, Index: 7}} + + t.Run("interface not found is unhealthy", func(t *testing.T) { + linkByNameFn = func(name string) (netlink.Link, error) { + return nil, fmt.Errorf("simulated: no such interface %q", name) + } + if err := checkAttached([]string{testIfaceEth0}); err == nil { + t.Fatal("checkAttached() error = nil, want an error for a missing interface") + } + }) + + t.Run("interface present but filter missing is unhealthy", func(t *testing.T) { + linkByNameFn = func(string) (netlink.Link, error) { return dummyLink, nil } + filterListFn = func(netlink.Link, uint32) ([]netlink.Filter, error) { + return []netlink.Filter{ + &netlink.BpfFilter{Name: "some-other-filter"}, + }, nil + } + err := checkAttached([]string{testIfaceEth0}) + if err == nil { + t.Fatal("checkAttached() error = nil, want an error when the galactic filter is absent") + } + if !strings.Contains(err.Error(), "not attached") { + t.Errorf("checkAttached() error = %v, want it to mention the filter is not attached", err) + } + }) + + t.Run("interface present with our filter attached is healthy", func(t *testing.T) { + linkByNameFn = func(string) (netlink.Link, error) { return dummyLink, nil } + filterListFn = func(netlink.Link, uint32) ([]netlink.Filter, error) { + return []netlink.Filter{ + &netlink.BpfFilter{Name: "some-other-filter"}, + &netlink.BpfFilter{Name: filterName}, + }, nil + } + if err := checkAttached([]string{testIfaceEth0}); err != nil { + t.Fatalf("checkAttached() unexpected error: %v", err) + } + }) + + t.Run("no interfaces at all is unhealthy", func(t *testing.T) { + if err := checkAttached(nil); err == nil { + t.Fatal("checkAttached(nil) error = nil, want an error") + } + }) + + t.Run("FilterList error is unhealthy", func(t *testing.T) { + linkByNameFn = func(string) (netlink.Link, error) { return dummyLink, nil } + filterListFn = func(netlink.Link, uint32) ([]netlink.Filter, error) { + return nil, errors.New("simulated netlink error") + } + if err := checkAttached([]string{testIfaceEth0}); err == nil { + t.Fatal("checkAttached() error = nil, want an error when FilterList itself fails") + } + }) +} + +// TestHealth_RealDatapath_FlipsAfterAttachIsKilled is this milestone's exit +// criterion: "health check fails correctly when the program is unloaded +// (simulate by killing the attach and confirming the health endpoint +// flips)." It requires real root privileges to load/attach a BPF program +// and create an isolated test network namespace, so it is skipped (not +// silently passed) when not run as root -- matching attach_test.go's own +// TestLoadAttach_SurvivesRestartWithMapsIntact. +// +// Two independent ways of "killing the attach" are exercised, since they +// exercise genuinely different Health checks: +// - Detach removes the kernel-level tc filter while this process's own +// program/map handles stay open -- proves checkAttached actually +// queries live kernel state instead of trusting a cached "we called +// Attach once" assumption. +// - objs.Close() releases this process's own file descriptors while the +// kernel-level filter (which holds its own independent reference, see +// the package doc comment) is left untouched -- proves +// checkProgramReachable/checkMapsReachable catch a staleness the +// attachment check alone would miss. +func TestHealth_RealDatapath_FlipsAfterAttachIsKilled(t *testing.T) { + requireRoot(t) + + pinDir := filepath.Join("/sys/fs/bpf", fmt.Sprintf("galactic-health-test-%d", 0)) + nsObj, err := ns.TempNetNS() + if err != nil { + t.Fatalf("create test netns: %v", err) + } + defer func() { _ = nsObj.Close() }() + + const ifaceName = "usidhealth0" + + err = nsObj.Do(func(_ ns.NetNS) error { + handle, err := netlink.NewHandle() + if err != nil { + return err + } + defer handle.Close() //nolint:errcheck // best-effort cleanup + + dummy := &netlink.Dummy{LinkAttrs: netlink.LinkAttrs{Name: ifaceName}} + if err := handle.LinkAdd(dummy); err != nil { + return fmt.Errorf("add dummy link: %w", err) + } + return handle.LinkSetUp(dummy) + }) + if err != nil { + t.Fatalf("setup dummy interface: %v", err) + } + + var objs *prog.UsidObjects + err = nsObj.Do(func(_ ns.NetNS) error { + var loadErr error + objs, loadErr = Load(pinDir) + if loadErr != nil { + return fmt.Errorf("load: %w", loadErr) + } + if attachErr := Attach(objs.UsidIngress, []string{ifaceName}); attachErr != nil { + return fmt.Errorf("attach: %w", attachErr) + } + return nil + }) + if err != nil { + t.Fatalf("load/attach: %v", err) + } + t.Cleanup(func() { + _ = objs.Close() + _ = nsObj.Do(func(_ ns.NetNS) error { return nil }) + }) + + // --- healthy immediately after attach --- + err = nsObj.Do(func(_ ns.NetNS) error { + return Health(objs, []string{ifaceName}) + }) + if err != nil { + t.Fatalf("Health() immediately after attach: unexpected error: %v", err) + } + + // --- "kill the attach" #1: Detach removes the kernel-side filter --- + err = nsObj.Do(func(_ ns.NetNS) error { + return Detach([]string{ifaceName}) + }) + if err != nil { + t.Fatalf("Detach: %v", err) + } + + err = nsObj.Do(func(_ ns.NetNS) error { + return Health(objs, []string{ifaceName}) + }) + if err == nil { + t.Fatal("Health() after Detach: error = nil, want the attachment check to fail") + } + if !strings.Contains(err.Error(), "not attached") { + t.Errorf("Health() after Detach: error = %v, want it to mention the filter is not attached", err) + } + t.Logf("Health() correctly flipped to unhealthy after Detach: %v", err) + + // --- re-attach, confirm healthy again, then "kill the attach" #2: + // closing this process's own handle. --- + err = nsObj.Do(func(_ ns.NetNS) error { + return Attach(objs.UsidIngress, []string{ifaceName}) + }) + if err != nil { + t.Fatalf("re-attach: %v", err) + } + err = nsObj.Do(func(_ ns.NetNS) error { + return Health(objs, []string{ifaceName}) + }) + if err != nil { + t.Fatalf("Health() after re-attach: unexpected error: %v", err) + } + + if err := objs.Close(); err != nil { + t.Fatalf("objs.Close(): %v", err) + } + + err = nsObj.Do(func(_ ns.NetNS) error { + return Health(objs, []string{ifaceName}) + }) + if err == nil { + t.Fatal("Health() after objs.Close(): error = nil, want the program/map reachability checks to fail") + } + if !strings.Contains(err.Error(), "not reachable") { + t.Errorf("Health() after objs.Close(): error = %v, want it to mention the program/maps are not reachable", err) + } + t.Logf("Health() correctly flipped to unhealthy after objs.Close(): %v", err) +} diff --git a/internal/plumbing/ebpf/attach/hooks.go b/internal/plumbing/ebpf/attach/hooks.go new file mode 100644 index 0000000..658d60e --- /dev/null +++ b/internal/plumbing/ebpf/attach/hooks.go @@ -0,0 +1,61 @@ +// Copyright 2026 Datum Cloud, Inc. +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package attach + +// LoadHook and AttachHook are process-wide observability callbacks for BPF +// program load and TC-BPF ingress filter attach/detach outcomes (design +// plan .local/plan-ebpf-xdp-usid-datapath.md §9's "BPF program load/reload +// events and failures" metric; Milestone 4 of +// .local/implementation-plan-ebpf-xdp-usid-datapath.md). They default to +// no-ops so this package never needs a direct dependency on a metrics +// library -- internal/plumbing/ebpf/metrics's Prometheus registration is +// the only production caller of SetHooks, wiring real counters in once at +// process startup (internal/installer.Run), exactly like watch.go's own +// test-only onReconcileDone hook already does for tests within this +// package. +// +// AttachHook is invoked from attachOne and detachOne -- the two internal +// choke points every attach/detach path in this package ultimately goes +// through (Start's initial Attach call, Watch's netlink-driven reconcile, +// and Detach's direct callers) -- so installing it once observes every +// attach/detach event this package ever performs, regardless of whether it +// happened at startup or as part of a later re-attachment ("reload"). +type LoadHook func(err error) + +// AttachHook reports the outcome of attaching or detaching this package's +// own TC-BPF ingress filter (filterName) on one named interface. +type AttachHook func(iface string, err error) + +// Hooks bundles the observability callbacks SetHooks installs. A nil field +// leaves the corresponding hook a no-op. +type Hooks struct { + OnLoad LoadHook + OnAttach AttachHook + OnDetach AttachHook +} + +var ( + loadHook LoadHook = func(error) {} + attachHook AttachHook = func(string, error) {} + detachHook AttachHook = func(string, error) {} +) + +// SetHooks installs h's callbacks, defaulting any nil field to a no-op. Not +// safe to call concurrently with Load/Attach/Detach/Watch -- call once, +// before starting the datapath, exactly like internal/installer.Run does. +func SetHooks(h Hooks) { + loadHook = h.OnLoad + if loadHook == nil { + loadHook = func(error) {} + } + attachHook = h.OnAttach + if attachHook == nil { + attachHook = func(string, error) {} + } + detachHook = h.OnDetach + if detachHook == nil { + detachHook = func(string, error) {} + } +} diff --git a/internal/plumbing/ebpf/attach/hooks_test.go b/internal/plumbing/ebpf/attach/hooks_test.go new file mode 100644 index 0000000..e78f0a2 --- /dev/null +++ b/internal/plumbing/ebpf/attach/hooks_test.go @@ -0,0 +1,72 @@ +// Copyright 2026 Datum Cloud, Inc. +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package attach + +import ( + "errors" + "path/filepath" + "testing" +) + +// resetHooks restores the package-level hook vars to their default no-ops +// after a test installs custom ones, mirroring how every other override-var +// test in this package (preflightCheckFn, routeListFn, ...) cleans up. +func resetHooks(t *testing.T) { + t.Helper() + t.Cleanup(func() { + SetHooks(Hooks{}) + }) +} + +// TestSetHooks_NilFieldsDefaultToNoOps covers SetHooks' own defaulting +// logic directly: passing a zero-value Hooks (or a Hooks with some fields +// nil) must never leave a nil func var that a later Load/attachOne/ +// detachOne call would panic on. +func TestSetHooks_NilFieldsDefaultToNoOps(t *testing.T) { + resetHooks(t) + + SetHooks(Hooks{}) + loadHook(errors.New("must not panic")) + attachHook("eth0", errors.New("must not panic")) + detachHook("eth0", errors.New("must not panic")) + + SetHooks(Hooks{OnLoad: func(error) {}}) + loadHook(nil) + attachHook("eth0", nil) // OnAttach left nil, must still not panic + detachHook("eth0", nil) // OnDetach left nil, must still not panic +} + +// TestLoad_FiresLoadHookOnFailure covers the milestone's "BPF program +// load/reload events and failures" metric hook at its most exercisable +// failure path without root: a stubbed preflightCheckFn failure (the same +// technique TestLoad_PreflightBlocksLoad already uses), asserting loadHook +// observes the exact error Load returns. +func TestLoad_FiresLoadHookOnFailure(t *testing.T) { + origPreflight := preflightCheckFn + t.Cleanup(func() { preflightCheckFn = origPreflight }) + resetHooks(t) + + wantErr := errors.New("simulated missing kernel capability") + preflightCheckFn = func() error { return wantErr } + + var gotErr error + var calls int + SetHooks(Hooks{OnLoad: func(err error) { + calls++ + gotErr = err + }}) + + pinDir := filepath.Join(t.TempDir(), "does-not-exist", "galactic") + if _, err := Load(pinDir); err == nil { + t.Fatal("Load() error = nil, want the preflight failure") + } + + if calls != 1 { + t.Fatalf("loadHook called %d times, want exactly 1", calls) + } + if !errors.Is(gotErr, wantErr) { + t.Errorf("loadHook observed error = %v, want it to wrap %v", gotErr, wantErr) + } +} diff --git a/internal/plumbing/ebpf/attach/interfaces.go b/internal/plumbing/ebpf/attach/interfaces.go new file mode 100644 index 0000000..9c7446a --- /dev/null +++ b/internal/plumbing/ebpf/attach/interfaces.go @@ -0,0 +1,119 @@ +// Copyright 2026 Datum Cloud, Inc. +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package attach + +import ( + "fmt" + "os" + "strings" + + "github.com/vishvananda/netlink" + + "go.datum.net/galactic/internal/config" +) + +// routeListFn and linkByIndexFn are package-level function variables so +// tests can substitute a fake netlink view without touching the real host +// network stack -- the same override-var pattern internal/installer uses +// for addrListFn. +var ( + routeListFn = func() ([]netlink.Route, error) { + return netlink.RouteList(nil, netlink.FAMILY_V6) + } + linkByIndexFn = func(index int) (netlink.Link, error) { + return netlink.LinkByIndex(index) + } +) + +// ResolveInterfaces returns the set of interface names the uSID datapath +// should attach its TC-BPF ingress hook to (design plan §4.1). +// +// If config.EnvCNIEBPFInterfaces is set, it is parsed as a comma-separated +// list of interface names (whitespace trimmed, duplicates and empty +// entries removed) and returned directly -- no auto-detection is +// performed. This is the explicit override for multi-homed nodes where +// auto-detection is ambiguous. +// +// Otherwise, the interface(s) carrying the default IPv6 route are +// auto-detected. Attaching to the wrong (or too few) interfaces fails as +// silent blackholing of overlay traffic (design plan §4.1), so callers +// that get an error here must not proceed with a partial or empty +// interface set. +func ResolveInterfaces() ([]string, error) { + if override := strings.TrimSpace(os.Getenv(config.EnvCNIEBPFInterfaces)); override != "" { + names := parseInterfaceList(override) + if len(names) == 0 { + return nil, fmt.Errorf("attach: %s is set to %q but contains no usable interface names", + config.EnvCNIEBPFInterfaces, override) + } + return names, nil + } + return autoDetectInterfaces() +} + +// parseInterfaceList splits a comma-separated interface list, trimming +// whitespace and removing duplicate/empty entries while preserving order. +func parseInterfaceList(v string) []string { + var out []string + seen := make(map[string]bool) + for part := range strings.SplitSeq(v, ",") { + name := strings.TrimSpace(part) + if name == "" || seen[name] { + continue + } + seen[name] = true + out = append(out, name) + } + return out +} + +// autoDetectInterfaces returns the deduplicated set of interface names +// carrying an IPv6 default route (::/0), in the order netlink reports them +// -- analogous to the existing GALACTIC_ROUTER_BGP_LOCAL_ADDRESS +// auto-detection-from-`lo` pattern (internal/plumbing/loaddr), but over +// routes rather than addresses. +func autoDetectInterfaces() ([]string, error) { + routes, err := routeListFn() + if err != nil { + return nil, fmt.Errorf("attach: list IPv6 routes for auto-detection: %w", err) + } + + var names []string + seen := make(map[string]bool) + for _, r := range routes { + if !isDefaultRoute(r) || r.LinkIndex <= 0 { + continue + } + link, err := linkByIndexFn(r.LinkIndex) + if err != nil { + // A route pointing at an interface we can't resolve isn't + // actionable here; skip it rather than failing the whole + // detection over one stale/racing route. + continue + } + name := link.Attrs().Name + if seen[name] { + continue + } + seen[name] = true + names = append(names, name) + } + + if len(names) == 0 { + return nil, fmt.Errorf( + "attach: no default IPv6 route found to auto-detect the SRv6/underlay-facing interface; "+ + "set %s to override", config.EnvCNIEBPFInterfaces) + } + return names, nil +} + +// isDefaultRoute reports whether r is an IPv6 default route (::/0). +func isDefaultRoute(r netlink.Route) bool { + if r.Dst == nil { + return true + } + ones, bits := r.Dst.Mask.Size() + return ones == 0 && bits == 128 +} diff --git a/internal/plumbing/ebpf/attach/interfaces_test.go b/internal/plumbing/ebpf/attach/interfaces_test.go new file mode 100644 index 0000000..b96f5ec --- /dev/null +++ b/internal/plumbing/ebpf/attach/interfaces_test.go @@ -0,0 +1,204 @@ +// Copyright 2026 Datum Cloud, Inc. +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package attach + +import ( + "net" + "strings" + "testing" + + "github.com/vishvananda/netlink" + + "go.datum.net/galactic/internal/config" +) + +// testIfaceEth0 and testIfaceEth1 are shared interface-name fixtures used +// across this package's tests. +const ( + testIfaceEth0 = "eth0" + testIfaceEth1 = "eth1" +) + +// fakeLink is a minimal netlink.Link implementation for tests. +type fakeLink struct { + attrs netlink.LinkAttrs +} + +func (f *fakeLink) Attrs() *netlink.LinkAttrs { return &f.attrs } +func (f *fakeLink) Type() string { return "fake" } + +func TestResolveInterfaces_EnvOverride(t *testing.T) { + tests := []struct { + name string + envValue string + want []string + wantError bool + }{ + {"SingleInterface", testIfaceEth0, []string{testIfaceEth0}, false}, + { + "MultipleInterfaces", + strings.Join([]string{testIfaceEth0, testIfaceEth1}, ","), + []string{testIfaceEth0, testIfaceEth1}, + false, + }, + { + "WhitespaceTrimmed", + " " + testIfaceEth0 + " , " + testIfaceEth1 + " ", + []string{testIfaceEth0, testIfaceEth1}, + false, + }, + { + "DuplicatesRemoved", + strings.Join([]string{testIfaceEth0, testIfaceEth1, testIfaceEth0}, ","), + []string{testIfaceEth0, testIfaceEth1}, + false, + }, + { + "EmptyEntriesSkipped", + testIfaceEth0 + ",," + testIfaceEth1, + []string{testIfaceEth0, testIfaceEth1}, + false, + }, + {"OnlyCommasIsError", ",,,", nil, true}, + {"OnlyWhitespaceIsError", " ", nil, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Setenv(config.EnvCNIEBPFInterfaces, tt.envValue) + + got, err := ResolveInterfaces() + if (err != nil) != tt.wantError { + t.Fatalf("ResolveInterfaces() error = %v, wantError = %v", err, tt.wantError) + } + if tt.wantError { + return + } + if !equalStringSlices(got, tt.want) { + t.Errorf("ResolveInterfaces() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestResolveInterfaces_AutoDetect(t *testing.T) { + t.Setenv(config.EnvCNIEBPFInterfaces, "") + + origRouteListFn, origLinkByIndexFn := routeListFn, linkByIndexFn + t.Cleanup(func() { + routeListFn, linkByIndexFn = origRouteListFn, origLinkByIndexFn + }) + + links := map[int]netlink.Link{ + 2: &fakeLink{attrs: netlink.LinkAttrs{Index: 2, Name: testIfaceEth0}}, + 3: &fakeLink{attrs: netlink.LinkAttrs{Index: 3, Name: testIfaceEth1}}, + } + linkByIndexFn = func(index int) (netlink.Link, error) { + if l, ok := links[index]; ok { + return l, nil + } + return nil, errFixtureNotFound + } + + t.Run("DefaultRouteFound", func(t *testing.T) { + routeListFn = func() ([]netlink.Route, error) { + return []netlink.Route{ + {LinkIndex: 2, Dst: nil}, // default route, Dst == nil + }, nil + } + got, err := ResolveInterfaces() + if err != nil { + t.Fatalf("ResolveInterfaces() error = %v", err) + } + if !equalStringSlices(got, []string{testIfaceEth0}) { + t.Errorf("ResolveInterfaces() = %v, want [%s]", got, testIfaceEth0) + } + }) + + t.Run("MultipleDefaultRoutesDeduped", func(t *testing.T) { + _, zeroNet, _ := net.ParseCIDR("::/0") + routeListFn = func() ([]netlink.Route, error) { + return []netlink.Route{ + {LinkIndex: 2, Dst: zeroNet}, + {LinkIndex: 3, Dst: zeroNet}, + {LinkIndex: 2, Dst: zeroNet}, // duplicate route to eth0 + }, nil + } + got, err := ResolveInterfaces() + if err != nil { + t.Fatalf("ResolveInterfaces() error = %v", err) + } + if !equalStringSlices(got, []string{testIfaceEth0, testIfaceEth1}) { + t.Errorf("ResolveInterfaces() = %v, want [%s %s]", got, testIfaceEth0, testIfaceEth1) + } + }) + + t.Run("NonDefaultRoutesIgnored", func(t *testing.T) { + _, specific, _ := net.ParseCIDR("2001:db8::/32") + routeListFn = func() ([]netlink.Route, error) { + return []netlink.Route{ + {LinkIndex: 2, Dst: specific}, + }, nil + } + _, err := ResolveInterfaces() + if err == nil { + t.Fatal("ResolveInterfaces() error = nil, want an error (no default route present)") + } + }) + + t.Run("UnresolvableLinkSkipped", func(t *testing.T) { + routeListFn = func() ([]netlink.Route, error) { + return []netlink.Route{ + {LinkIndex: 99, Dst: nil}, // unknown link index + {LinkIndex: 2, Dst: nil}, + }, nil + } + got, err := ResolveInterfaces() + if err != nil { + t.Fatalf("ResolveInterfaces() error = %v", err) + } + if !equalStringSlices(got, []string{testIfaceEth0}) { + t.Errorf("ResolveInterfaces() = %v, want [%s] (unresolvable route skipped)", got, testIfaceEth0) + } + }) + + t.Run("NoDefaultRouteIsActionableError", func(t *testing.T) { + routeListFn = func() ([]netlink.Route, error) { + return nil, nil + } + _, err := ResolveInterfaces() + if err == nil { + t.Fatal("ResolveInterfaces() error = nil, want an error naming the override env var") + } + }) + + t.Run("RouteListError", func(t *testing.T) { + routeListFn = func() ([]netlink.Route, error) { + return nil, errFixtureNotFound + } + _, err := ResolveInterfaces() + if err == nil { + t.Fatal("ResolveInterfaces() error = nil, want the underlying route-list error surfaced") + } + }) +} + +var errFixtureNotFound = fixtureError("not found") + +// fixtureError is a trivial error implementation for test fixtures. +type fixtureError string + +func (e fixtureError) Error() string { return string(e) } + +func equalStringSlices(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} diff --git a/internal/plumbing/ebpf/attach/watch.go b/internal/plumbing/ebpf/attach/watch.go new file mode 100644 index 0000000..cd2d89c --- /dev/null +++ b/internal/plumbing/ebpf/attach/watch.go @@ -0,0 +1,283 @@ +// Copyright 2026 Datum Cloud, Inc. +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package attach + +import ( + "context" + "errors" + "fmt" + "log/slog" + "sort" + "time" + + "github.com/cilium/ebpf" + "github.com/vishvananda/netlink" + + "go.datum.net/galactic/internal/plumbing/ebpf/prog" +) + +// debounceInterval coalesces a burst of netlink link/route change events +// (e.g. an interface flapping, or several routes updating as part of one +// routing-table change) into a single interface-set re-evaluation, instead +// of re-running ResolveInterfaces -- and possibly re-attaching -- once per +// individual netlink message. It is a package-level var (not a const) so +// tests can shrink it and exercise Watch's event-to-reaction path in +// milliseconds instead of real wall-clock time. +var debounceInterval = 250 * time.Millisecond + +// linkSubscribeFn and routeSubscribeFn are package-level override points -- +// the same pattern interfaces.go uses for routeListFn/linkByIndexFn -- so +// tests can simulate real interface/route change events without a live +// netlink socket or root privileges: a fake implementation receives the +// exact channel Watch reads from and can push a synthetic +// netlink.LinkUpdate/netlink.RouteUpdate onto it whenever the test wants to +// simulate a change. +var ( + linkSubscribeFn = netlink.LinkSubscribeWithOptions + routeSubscribeFn = netlink.RouteSubscribeWithOptions +) + +// resolveInterfacesFn is a package-level override point so Watch's own +// tests can control what a re-evaluation resolves to across successive +// calls (the "interface set changed" scenario this milestone exists for) +// without touching the real netlink route table. Production code always +// leaves this at its default, ResolveInterfaces, which has its own +// independent override vars (routeListFn/linkByIndexFn) exercised by +// interfaces_test.go. +var resolveInterfacesFn = ResolveInterfaces + +// onReconcileDone is a test-only hook invoked once after every debounced +// re-evaluation (whether or not it changed anything, and whether or not +// ResolveInterfaces itself failed). It lets tests wait deterministically +// for a reconciliation attempt to finish instead of guessing with a sleep. +// Production code never overrides it. +var onReconcileDone = func() {} + +// Watch subscribes to netlink link and route change events and, for as +// long as ctx is not canceled, re-evaluates the eBPF uSID datapath's +// attachment set whenever one occurs (design plan §4.1: "re-evaluate on +// interface/route change events (netlink subscription), not just at +// startup" -- Milestone 3.2 of the implementation plan). It is meant to run +// in its own goroutine alongside the Start (or Load+Attach) call whose +// resolved interface set seeds initial. +// +// Change events are debounced (see debounceInterval) so a burst of related +// netlink messages triggers one re-evaluation, not one per message. Each +// re-evaluation calls ResolveInterfaces (via resolveInterfacesFn) again and +// reconciles the actually-attached state against it: +// - every interface in the freshly-resolved set is (re-)attached to +// program, not just ones newly present -- Attach's underlying +// FilterReplace semantics make this idempotent and cheap, and it is +// deliberately unconditional (not gated on the interface set having +// changed at all) so that an external event that silently clears this +// package's own tc filter without ever removing the interface from the +// resolved set -- confirmed in a real deployment: the underlay routing +// daemon (FRR) restarting bounced the interface, which cleared the +// filter, but the interface never left the resolved set (still the +// default-route interface), so a diff-only reconcile never noticed and +// never healed it -- gets self-healed on the very next netlink event +// instead of silently blackholing traffic until the pod restarts; +// - every interface no longer present has this package's own tc filter +// removed via Detach, so a downed or reassigned interface stops +// silently forwarding into whatever VRF its Argument used to resolve +// to. +// +// A failure to attach or detach one interface during a re-evaluation is +// logged and does not stop the watch loop or abandon that interface -- it +// is retried on the next re-evaluation for as long as the mismatch between +// the resolved set and the actually-attached set persists (see reconcile). +// A failure of ResolveInterfaces itself during a re-evaluation is likewise +// logged and skipped, leaving the previous attachment set in place rather +// than tearing anything down on a transient resolution error. +// +// Watch returns nil when ctx is canceled. It returns a non-nil error only +// if establishing the initial netlink subscriptions themselves fails. +func Watch(ctx context.Context, program *ebpf.Program, initial []string) error { + if program == nil { + return errors.New("attach: watch: program is nil") + } + + // Buffered by one so the netlink library's own subscription goroutine + // (see vishvananda/netlink's linkSubscribeAt/routeSubscribeAt) can hand + // off one in-flight update without blocking forever if it races with + // this function returning (ctx canceled) right as a message arrives. + linkCh := make(chan netlink.LinkUpdate, 1) + routeCh := make(chan netlink.RouteUpdate, 1) + done := make(chan struct{}) + defer close(done) + + if err := linkSubscribeFn(linkCh, done, netlink.LinkSubscribeOptions{ + ErrorCallback: func(err error) { + slog.Warn("attach: link change subscription error", "err", err) + }, + }); err != nil { + return fmt.Errorf("attach: watch: subscribe to link updates: %w", err) + } + if err := routeSubscribeFn(routeCh, done, netlink.RouteSubscribeOptions{ + ErrorCallback: func(err error) { + slog.Warn("attach: route change subscription error", "err", err) + }, + }); err != nil { + return fmt.Errorf("attach: watch: subscribe to route updates: %w", err) + } + + current := toSet(initial) + + var debounceTimer *time.Timer + var debounceC <-chan time.Time + scheduleReevaluate := func() { + if debounceTimer == nil { + debounceTimer = time.NewTimer(debounceInterval) + debounceC = debounceTimer.C + return + } + if !debounceTimer.Stop() { + select { + case <-debounceTimer.C: + default: + } + } + debounceTimer.Reset(debounceInterval) + debounceC = debounceTimer.C + } + + for { + select { + case <-ctx.Done(): + if debounceTimer != nil { + debounceTimer.Stop() + } + return nil + + case _, ok := <-linkCh: + if !ok { + linkCh = nil // subscription ended; stop selecting on it + continue + } + scheduleReevaluate() + + case _, ok := <-routeCh: + if !ok { + routeCh = nil + continue + } + scheduleReevaluate() + + case <-debounceC: + debounceC = nil + next, err := resolveInterfacesFn() + if err != nil { + slog.Warn("attach: re-evaluate interface set failed, keeping previous attachment", "err", err) + onReconcileDone() + continue + } + current = reconcile(program, current, toSet(next)) + onReconcileDone() + } + } +} + +// StartWatching runs Start and, if it succeeds, launches Watch in its own +// goroutine (stopped when ctx is done) to keep the resolved interface set +// re-evaluated against netlink link/route change events for the life of the +// returned objects (design plan §4.1; Milestone 3.2). It is the production +// entry point internal/installer.Run uses -- Start alone (Milestone 3.1) +// only ever evaluates the interface set once, at startup. +// +// Canceling ctx stops the background watch loop; it does not Close objs -- +// the caller still owns objs and must Close it itself, exactly as with +// Start (see the package doc comment for why that's safe against an +// already-attached filter). +func StartWatching(ctx context.Context, pinDir string) (objs *prog.UsidObjects, ifaces []string, err error) { + objs, ifaces, err = Start(pinDir) + if err != nil { + return nil, nil, err + } + + go func() { + if werr := Watch(ctx, objs.UsidIngress, ifaces); werr != nil { + slog.Error("attach: netlink-driven interface watch loop exited unexpectedly", "err", werr) + } + }() + + return objs, ifaces, nil +} + +// toSet converts a slice of interface names into a set, for +// order-independent comparison across successive ResolveInterfaces calls. +func toSet(names []string) map[string]struct{} { + set := make(map[string]struct{}, len(names)) + for _, n := range names { + set[n] = struct{}{} + } + return set +} + +// diffSets returns, in sorted order (for deterministic logging and +// testing), the names present in next but not current (added) and present +// in current but not next (removed). +func diffSets(current, next map[string]struct{}) (added, removed []string) { + for name := range next { + if _, ok := current[name]; !ok { + added = append(added, name) + } + } + for name := range current { + if _, ok := next[name]; !ok { + removed = append(removed, name) + } + } + sort.Strings(added) + sort.Strings(removed) + return added, removed +} + +// reconcile brings the actual attachment state toward next, starting from +// current (the last-known actually-attached set), and returns the +// resulting actually-attached set. +// +// Every interface in next is (re-)attached unconditionally, not only ones +// added since current -- see Watch's doc comment above for why a +// diff-only reconcile (the original design) misses external drift that +// clears the tc filter without ever changing the resolved interface set. +// attachOne (FilterReplace) is idempotent, so re-asserting an +// already-correctly-attached interface is a cheap no-op. +// +// A per-interface attach or detach failure is logged and that interface is +// simply left out of (for a failed attach) or kept in (for a failed detach) +// the returned set -- which means it is retried again on the next +// reconcile, without any separate retry-tracking state. +func reconcile(program *ebpf.Program, current, next map[string]struct{}) map[string]struct{} { + added, removed := diffSets(current, next) + if len(added) != 0 || len(removed) != 0 { + slog.Info("attach: interface set changed, re-evaluating attachment", "added", added, "removed", removed) + } + + result := make(map[string]struct{}, len(current)+len(added)) + for name := range current { + result[name] = struct{}{} + } + + for name := range next { + if err := attachOne(program, name); err != nil { + slog.Warn("attach: failed to (re)attach resolved interface, will retry on next re-evaluation", + "interface", name, "err", err) + delete(result, name) + continue + } + result[name] = struct{}{} + } + + for _, name := range removed { + if err := Detach([]string{name}); err != nil { + slog.Warn("attach: failed to detach interface no longer resolved, will retry on next re-evaluation", + "interface", name, "err", err) + continue + } + delete(result, name) + } + + return result +} diff --git a/internal/plumbing/ebpf/attach/watch_test.go b/internal/plumbing/ebpf/attach/watch_test.go new file mode 100644 index 0000000..a9844eb --- /dev/null +++ b/internal/plumbing/ebpf/attach/watch_test.go @@ -0,0 +1,505 @@ +// Copyright 2026 Datum Cloud, Inc. +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package attach + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "reflect" + "sort" + "testing" + "time" + + "github.com/cilium/ebpf" + "github.com/containernetworking/plugins/pkg/ns" + "github.com/vishvananda/netlink" +) + +// linkSubscribeFunc and routeSubscribeFunc name linkSubscribeFn's and +// routeSubscribeFn's function types, purely so the stub constructors below +// don't have to re-spell the full three-parameter signature inline. +type ( + linkSubscribeFunc = func(chan<- netlink.LinkUpdate, <-chan struct{}, netlink.LinkSubscribeOptions) error + routeSubscribeFunc = func(chan<- netlink.RouteUpdate, <-chan struct{}, netlink.RouteSubscribeOptions) error +) + +// stubLinkSubscribe and stubRouteSubscribe return a linkSubscribeFn/ +// routeSubscribeFn-shaped fake that, if trigger is non-nil, spawns a +// goroutine forwarding each value received on trigger as one synthetic +// update sent on the channel Watch is reading from. Neither the fake nor +// the goroutine it spawns touches netlink or the host network stack -- +// Watch's own tests only need to prove Watch reacts correctly to *a* +// change event, not that vishvananda/netlink's own subscription plumbing +// works (that's netlink's own test suite's job). +func stubLinkSubscribe(trigger <-chan struct{}) linkSubscribeFunc { + return func(ch chan<- netlink.LinkUpdate, done <-chan struct{}, _ netlink.LinkSubscribeOptions) error { + if trigger == nil { + return nil + } + go func() { + for { + select { + case <-done: + return + case _, ok := <-trigger: + if !ok { + return + } + select { + case ch <- netlink.LinkUpdate{}: + case <-done: + return + } + } + } + }() + return nil + } +} + +func stubRouteSubscribe(trigger <-chan struct{}) routeSubscribeFunc { + return func(ch chan<- netlink.RouteUpdate, done <-chan struct{}, _ netlink.RouteSubscribeOptions) error { + if trigger == nil { + return nil + } + go func() { + for { + select { + case <-done: + return + case _, ok := <-trigger: + if !ok { + return + } + select { + case ch <- netlink.RouteUpdate{}: + case <-done: + return + } + } + } + }() + return nil + } +} + +// withWatchTestDefaults overrides every one of Watch's package-level +// override points to hermetic no-op/no-event fakes and a short +// debounceInterval, restoring the originals on test cleanup. Individual +// tests then override whichever vars they need beyond these defaults. +func withWatchTestDefaults(t *testing.T) { + t.Helper() + + origLink, origRoute := linkSubscribeFn, routeSubscribeFn + origResolve := resolveInterfacesFn + origDebounce := debounceInterval + origHook := onReconcileDone + t.Cleanup(func() { + linkSubscribeFn, routeSubscribeFn = origLink, origRoute + resolveInterfacesFn = origResolve + debounceInterval = origDebounce + onReconcileDone = origHook + }) + + linkSubscribeFn = stubLinkSubscribe(nil) + routeSubscribeFn = stubRouteSubscribe(nil) + resolveInterfacesFn = func() ([]string, error) { return nil, nil } + debounceInterval = 10 * time.Millisecond +} + +func TestWatch_NilProgramIsError(t *testing.T) { + withWatchTestDefaults(t) + + err := Watch(context.Background(), nil, []string{testIfaceEth0}) + if err == nil { + t.Fatal("Watch(nil program, ...) error = nil, want an error") + } +} + +func TestWatch_LinkSubscribeFailurePropagates(t *testing.T) { + withWatchTestDefaults(t) + + wantErr := errors.New("simulated link subscribe failure") + linkSubscribeFn = func(chan<- netlink.LinkUpdate, <-chan struct{}, netlink.LinkSubscribeOptions) error { + return wantErr + } + + err := Watch(context.Background(), fakeProgram, nil) + if !errors.Is(err, wantErr) { + t.Errorf("Watch() error = %v, want it to wrap %v", err, wantErr) + } +} + +func TestWatch_RouteSubscribeFailurePropagates(t *testing.T) { + withWatchTestDefaults(t) + + wantErr := errors.New("simulated route subscribe failure") + routeSubscribeFn = func(chan<- netlink.RouteUpdate, <-chan struct{}, netlink.RouteSubscribeOptions) error { + return wantErr + } + + err := Watch(context.Background(), fakeProgram, nil) + if !errors.Is(err, wantErr) { + t.Errorf("Watch() error = %v, want it to wrap %v", err, wantErr) + } +} + +// TestWatch_ContextCancelReturnsNil covers the no-events steady state: with +// no link/route changes at all, Watch must still return cleanly (nil, no +// hang, no leaked goroutine blocked forever) as soon as ctx is canceled. +func TestWatch_ContextCancelReturnsNil(t *testing.T) { + withWatchTestDefaults(t) + + ctx, cancel := context.WithCancel(context.Background()) + + errCh := make(chan error, 1) + go func() { errCh <- Watch(ctx, fakeProgram, []string{testIfaceEth0}) }() + + cancel() + + select { + case err := <-errCh: + if err != nil { + t.Errorf("Watch() error = %v, want nil on ctx cancel", err) + } + case <-time.After(2 * time.Second): + t.Fatal("Watch() did not return after ctx was canceled") + } +} + +// TestDiffSets covers the pure added/removed set-diff logic Watch's +// reconcile step relies on, independent of any netlink or BPF interaction. +func TestDiffSets(t *testing.T) { + tests := []struct { + name string + current []string + next []string + wantAdded []string + wantRemoved []string + }{ + {"NoChange", []string{testIfaceEth0}, []string{testIfaceEth0}, nil, nil}, + {"Added", []string{testIfaceEth0}, []string{testIfaceEth0, testIfaceEth1}, []string{testIfaceEth1}, nil}, + {"Removed", []string{testIfaceEth0, testIfaceEth1}, []string{testIfaceEth0}, nil, []string{testIfaceEth1}}, + { + "AddedAndRemoved", + []string{testIfaceEth0}, []string{testIfaceEth1}, + []string{testIfaceEth1}, []string{testIfaceEth0}, + }, + {"EmptyToEmpty", nil, nil, nil, nil}, + {"AllRemoved", []string{testIfaceEth0, testIfaceEth1}, nil, nil, []string{testIfaceEth0, testIfaceEth1}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + added, removed := diffSets(toSet(tt.current), toSet(tt.next)) + sort.Strings(added) + sort.Strings(removed) + if !reflect.DeepEqual(added, tt.wantAdded) { + t.Errorf("diffSets() added = %v, want %v", added, tt.wantAdded) + } + if !reflect.DeepEqual(removed, tt.wantRemoved) { + t.Errorf("diffSets() removed = %v, want %v", removed, tt.wantRemoved) + } + }) + } +} + +// fakeProgram is a zero-value, never-loaded *ebpf.Program used only to +// satisfy Watch's non-nil check in tests that never reach a real +// attach/detach call (because no trigger ever fires and/or +// resolveInterfacesFn keeps the resolved set unchanged from initial). +// Tests that actually exercise attachOne/Detach use a real loaded program +// instead (see TestWatch_ReEvaluatesAndReattachesOnInterfaceSetChange). +var fakeProgram = &ebpf.Program{} + +// TestWatch_ReEvaluatesAndReattachesOnInterfaceSetChange is this milestone's +// exit criterion: a test simulating an interface-set change and confirming +// re-attachment without a process restart. It requires real root +// privileges to load/attach a BPF program and create an isolated test +// network namespace, so it is skipped (not silently passed) when not run as +// root. +// +// Setup: two dummy interfaces (A, B) in a fresh netns; the program is +// loaded and, standing in for whatever Start already did, Attach is called +// directly against A only (the "initial" resolved set). Watch is then run +// -- synchronously, on the same namespace-locked goroutine, since Watch's +// own netlink calls must run in the test netns and a plain "go Watch(...)" +// would run on a different, unswitched OS thread -- with: +// - a fake link subscription that, when triggered, sends one +// netlink.LinkUpdate to simulate "a link changed"; +// - resolveInterfacesFn stubbed to report [B] instead of [A], simulating +// the underlying routing state having moved to a different interface; +// - the onReconcileDone test hook used to wait deterministically for +// Watch's reaction to finish, instead of guessing with a sleep. +// +// After Watch reacts (and is then stopped via ctx cancellation, driven by +// the same hook), B must carry the galactic uSID filter and A must not -- +// confirming re-attachment happened without restarting anything. +func TestWatch_ReEvaluatesAndReattachesOnInterfaceSetChange(t *testing.T) { + requireRoot(t) + withWatchTestDefaults(t) + + pinDir := filepath.Join("/sys/fs/bpf", fmt.Sprintf("galactic-watch-test-%d", os.Getpid())) + t.Cleanup(func() { _ = os.RemoveAll(pinDir) }) + + const ifaceA = "usidwatchA" + const ifaceB = "usidwatchB" + + nsObj, err := ns.TempNetNS() + if err != nil { + t.Fatalf("create test netns: %v", err) + } + defer func() { _ = nsObj.Close() }() + + err = nsObj.Do(func(_ ns.NetNS) error { + handle, err := netlink.NewHandle() + if err != nil { + return err + } + defer handle.Close() //nolint:errcheck // best-effort cleanup + + for _, name := range []string{ifaceA, ifaceB} { + dummy := &netlink.Dummy{LinkAttrs: netlink.LinkAttrs{Name: name}} + if err := handle.LinkAdd(dummy); err != nil { + return fmt.Errorf("add dummy link %q: %w", name, err) + } + if err := handle.LinkSetUp(dummy); err != nil { + return fmt.Errorf("set dummy link %q up: %w", name, err) + } + } + return nil + }) + if err != nil { + t.Fatalf("setup dummy interfaces: %v", err) + } + + err = nsObj.Do(func(_ ns.NetNS) error { + objs, err := Load(pinDir) + if err != nil { + return fmt.Errorf("load: %w", err) + } + defer func() { _ = objs.Close() }() + + if err := Attach(objs.UsidIngress, []string{ifaceA}); err != nil { + return fmt.Errorf("initial attach to %q: %w", ifaceA, err) + } + + trigger := make(chan struct{}, 1) + linkSubscribeFn = stubLinkSubscribe(trigger) + + var resolveCalls int + resolveInterfacesFn = func() ([]string, error) { + resolveCalls++ + return []string{ifaceB}, nil + } + + reconciled := make(chan struct{}, 4) + onReconcileDone = func() { + select { + case reconciled <- struct{}{}: + default: + } + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // Drive the scenario from a separate (non-netns-bound) goroutine: + // trigger the simulated link-change event, wait for Watch to have + // reacted at least once, then stop the watch loop. None of this + // driver goroutine's own work touches netlink, so it doesn't need + // to be bound to the test netns -- only Watch's internal + // attachOne/Detach calls do, and Watch runs synchronously below on + // this namespace-locked goroutine. + go func() { + trigger <- struct{}{} + select { + case <-reconciled: + case <-time.After(5 * time.Second): + } + cancel() + }() + + if err := Watch(ctx, objs.UsidIngress, []string{ifaceA}); err != nil { + return fmt.Errorf("watch: %w", err) + } + + if resolveCalls == 0 { + return errors.New("resolveInterfacesFn was never called -- the simulated link event never reached Watch") + } + return nil + }) + if err != nil { + t.Fatalf("watch scenario: %v", err) + } + + // Verify the re-attachment actually happened at the kernel level: B now + // carries the filter, A no longer does. + err = nsObj.Do(func(_ ns.NetNS) error { + linkB, err := netlink.LinkByName(ifaceB) + if err != nil { + return fmt.Errorf("find link %q: %w", ifaceB, err) + } + filtersB, err := netlink.FilterList(linkB, netlink.HANDLE_MIN_INGRESS) + if err != nil { + return fmt.Errorf("list filters on %q: %w", ifaceB, err) + } + if len(filtersB) != 1 { + return fmt.Errorf("filter count on %q = %d, want 1 (re-attached after the interface-set change)", + ifaceB, len(filtersB)) + } + + linkA, err := netlink.LinkByName(ifaceA) + if err != nil { + return fmt.Errorf("find link %q: %w", ifaceA, err) + } + filtersA, err := netlink.FilterList(linkA, netlink.HANDLE_MIN_INGRESS) + if err != nil { + return fmt.Errorf("list filters on %q: %w", ifaceA, err) + } + if len(filtersA) != 0 { + return fmt.Errorf("filter count on %q = %d, want 0 (detached after dropping out of the resolved set)", + ifaceA, len(filtersA)) + } + return nil + }) + if err != nil { + t.Fatalf("post-watch verification: %v", err) + } +} + +// TestWatch_HealsExternallyClearedFilterWithoutSetChange reproduces the +// bug found investigating a live cluster where cross-region VPC pings had +// gone dark: the underlay routing daemon (FRR) restarted, bounced eth0, +// and that cleared this package's own tc-bpf ingress filter -- but eth0 +// never left the resolved interface set (it was, and remained, the +// default-route interface), so the original diff-only reconcile (which +// only ever attached interfaces newly present in added) never noticed and +// never re-attached it, silently blackholing all traffic through that +// interface until the pod restarted. +// +// Setup: one dummy interface (A); Attach it once, then reach through to +// the kernel and remove the filter directly (netlink.FilterDel) to +// simulate the external drift, all without ever telling Watch the +// resolved set changed -- resolveInterfacesFn keeps reporting [A] on every +// call. A real diff-only reconcile would see added=nil, removed=nil and +// do nothing. reconcile must instead re-attach A anyway. +func TestWatch_HealsExternallyClearedFilterWithoutSetChange(t *testing.T) { + requireRoot(t) + withWatchTestDefaults(t) + + pinDir := filepath.Join("/sys/fs/bpf", fmt.Sprintf("galactic-watch-heal-test-%d", os.Getpid())) + t.Cleanup(func() { _ = os.RemoveAll(pinDir) }) + + const ifaceA = "usidhealA" + + nsObj, err := ns.TempNetNS() + if err != nil { + t.Fatalf("create test netns: %v", err) + } + defer func() { _ = nsObj.Close() }() + + err = nsObj.Do(func(_ ns.NetNS) error { + handle, err := netlink.NewHandle() + if err != nil { + return err + } + defer handle.Close() //nolint:errcheck // best-effort cleanup + + dummy := &netlink.Dummy{LinkAttrs: netlink.LinkAttrs{Name: ifaceA}} + if err := handle.LinkAdd(dummy); err != nil { + return fmt.Errorf("add dummy link %q: %w", ifaceA, err) + } + return handle.LinkSetUp(dummy) + }) + if err != nil { + t.Fatalf("setup dummy interface: %v", err) + } + + err = nsObj.Do(func(_ ns.NetNS) error { + objs, err := Load(pinDir) + if err != nil { + return fmt.Errorf("load: %w", err) + } + defer func() { _ = objs.Close() }() + + if err := Attach(objs.UsidIngress, []string{ifaceA}); err != nil { + return fmt.Errorf("initial attach to %q: %w", ifaceA, err) + } + + // Simulate the external drift: clear the filter kernel-side without + // going through this package's own Detach, so Watch's internal + // bookkeeping still believes ifaceA is attached. + link, err := netlink.LinkByName(ifaceA) + if err != nil { + return fmt.Errorf("find link %q: %w", ifaceA, err) + } + filters, err := netlink.FilterList(link, netlink.HANDLE_MIN_INGRESS) + if err != nil { + return fmt.Errorf("list filters on %q: %w", ifaceA, err) + } + if len(filters) != 1 { + return fmt.Errorf("filter count on %q = %d, want 1 before simulated drift", ifaceA, len(filters)) + } + if err := netlink.FilterDel(filters[0]); err != nil { + return fmt.Errorf("simulate external filter clear on %q: %w", ifaceA, err) + } + + trigger := make(chan struct{}, 1) + linkSubscribeFn = stubLinkSubscribe(trigger) + + resolveInterfacesFn = func() ([]string, error) { return []string{ifaceA}, nil } + + reconciled := make(chan struct{}, 4) + onReconcileDone = func() { + select { + case reconciled <- struct{}{}: + default: + } + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go func() { + trigger <- struct{}{} + select { + case <-reconciled: + case <-time.After(5 * time.Second): + } + cancel() + }() + + if err := Watch(ctx, objs.UsidIngress, []string{ifaceA}); err != nil { + return fmt.Errorf("watch: %w", err) + } + return nil + }) + if err != nil { + t.Fatalf("watch scenario: %v", err) + } + + err = nsObj.Do(func(_ ns.NetNS) error { + link, err := netlink.LinkByName(ifaceA) + if err != nil { + return fmt.Errorf("find link %q: %w", ifaceA, err) + } + filters, err := netlink.FilterList(link, netlink.HANDLE_MIN_INGRESS) + if err != nil { + return fmt.Errorf("list filters on %q: %w", ifaceA, err) + } + if len(filters) != 1 { + return fmt.Errorf( + "filter count on %q = %d, want 1 (self-healed after external drift, with no interface-set change)", + ifaceA, len(filters)) + } + return nil + }) + if err != nil { + t.Fatalf("post-watch verification: %v", err) + } +} diff --git a/internal/plumbing/ebpf/doc.go b/internal/plumbing/ebpf/doc.go new file mode 100644 index 0000000..058e6ab --- /dev/null +++ b/internal/plumbing/ebpf/doc.go @@ -0,0 +1,46 @@ +// Copyright 2026 Datum Cloud, Inc. +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +// Package ebpf is the umbrella directory for the TC-BPF uSID datapath that +// replaced galactic's legacy per-endpoint seg6local ingress route model in +// the 2026-08-02 direct cutover (internal/plumbing/srv6/srv6.go, deleted -- +// this datapath is now the only ingress/decap path). It holds no code of +// its own; see design plan .local/plan-ebpf-xdp-usid-datapath.md and its +// milestone breakdown, .local/implementation-plan-ebpf-xdp-usid-datapath.md, +// for the full design. This file only orients a reader across the +// sub-packages, roughly in the order a packet (and a reader) passes through +// them: +// +// - preflight: the kernel-capability check (BTF, HASH maps, SCHED_CLS, +// and specifically bpf_fib_lookup's VRF-tbid parameter) that must pass +// before anything below is loaded -- there is no partial/unsafe +// fallback. +// - uformat: the pure-Go bit-layout encode/decode library for the uFMT +// 48+16 uSID carrier (Block/Node-ID/Function/Argument) -- no kernel +// dependency; shared by prog's map-key arithmetic and by +// internal/plumbing/srv6's ComputeSID so the kernel program and the Go +// control plane can never drift on bit positions. +// - prog: the compiled TC-BPF program itself (usid.c) and its bpf2go- +// generated Go bindings; the single source of truth for the 9-step +// packet path (parse, locator_table match, read Function, function_table +// match, read Argument, vrf_table match, strip outer header, +// bpf_fib_lookup, redirect). +// - attach: the load/pin/attach/detach/watch lifecycle that wires prog's +// compiled object into galactic-cni's `run` subcommand, including +// netlink-driven re-attachment when an interface/route change -- or an +// external event silently clearing the filter -- requires it. +// - usidmap: the read/write API that populates and reconciles +// locator_table/function_table/vrf_table, used by the CNI ADD path's +// registration call (internal/cni/bgp.go) and by the GC controller's +// sweep (internal/gc). +// - metrics: Prometheus metrics and health-check event hooks spanning the +// whole datapath (load/attach events, drops by reason, per-Argument +// hit counters and Argument-space utilization). +// +// internal/cni and internal/gc are the two callers outside this tree that +// drive usidmap's register/unregister/reconcile calls; internal/reconcile +// and internal/plumbing/srv6's ComputeSID independently compute the same +// SID this datapath decodes, for the BGP control-plane side of the same +// design. +package ebpf diff --git a/internal/plumbing/ebpf/metrics/collector.go b/internal/plumbing/ebpf/metrics/collector.go new file mode 100644 index 0000000..9617f0c --- /dev/null +++ b/internal/plumbing/ebpf/metrics/collector.go @@ -0,0 +1,183 @@ +// Copyright 2026 Datum Cloud, Inc. +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package metrics + +import ( + "fmt" + "strconv" + + "github.com/prometheus/client_golang/prometheus" + + "go.datum.net/galactic/internal/plumbing/ebpf/prog" + "go.datum.net/galactic/internal/plumbing/ebpf/uformat" + "go.datum.net/galactic/internal/plumbing/ebpf/usidmap" +) + +const namespace = "galactic_usid" + +// DropReasonsReader abstracts drop_reasons's per-CPU lookup (a +// BPF_MAP_TYPE_PERCPU_ARRAY keyed by drop reason index, design plan §4.4) +// down to the one operation Collector needs, so tests can substitute an +// in-memory fake instead of a real, kernel-loaded map. *ebpf.Map already +// satisfies this interface structurally -- see NewCollectorFromObjects. +type DropReasonsReader interface { + Lookup(key, valueOut any) error +} + +// Collector is a prometheus.Collector reading the eBPF uSID datapath's live +// map state at every scrape (package doc comment): packets/bytes per +// (uSID Block, Argument) vrf_table entry, drops by reason, and current +// Argument-space utilization per uSID Block. +type Collector struct { + vrf *usidmap.VRFTable + locator *usidmap.LocatorTable + dropReasons DropReasonsReader +} + +// NewCollector builds a Collector from already-constructed table/reader +// values. Production callers normally use NewCollectorFromObjects; this +// constructor exists so tests can pass fakes satisfying usidmap.Table (via +// usidmap.NewVRFTable/NewLocatorTable) and DropReasonsReader without a +// kernel. +func NewCollector(vrf *usidmap.VRFTable, locator *usidmap.LocatorTable, dropReasons DropReasonsReader) *Collector { + return &Collector{vrf: vrf, locator: locator, dropReasons: dropReasons} +} + +// NewCollectorFromObjects builds a Collector reading directly from a +// loaded *prog.UsidObjects's vrf_table/locator_table/drop_reasons maps -- +// e.g. the object internal/plumbing/ebpf/attach.Load/.Start/.StartWatching +// returns. +func NewCollectorFromObjects(objs *prog.UsidObjects) *Collector { + return NewCollector( + usidmap.NewVRFTable(usidmap.KernelTable{Map: objs.VrfTable}), + usidmap.NewLocatorTable(usidmap.KernelTable{Map: objs.LocatorTable}), + objs.DropReasons, + ) +} + +// labelBlock is the Prometheus label name for a uSID Block value, shared +// across every metric Desc below that carries one (goconst: avoid repeating +// the "block" string literal at each call site). +const labelBlock = "block" + +var ( + vrfPacketsDesc = prometheus.NewDesc( + prometheus.BuildFQName(namespace, "vrf", "packets_total"), + "Packets forwarded through vrf_table for this (uSID Block, Argument) entry since it was last (re-)registered.", + []string{labelBlock, "argument", "vrf_table_id"}, nil, + ) + vrfBytesDesc = prometheus.NewDesc( + prometheus.BuildFQName(namespace, "vrf", "bytes_total"), + "Bytes forwarded through vrf_table for this (uSID Block, Argument) entry since it was last (re-)registered.", + []string{labelBlock, "argument", "vrf_table_id"}, nil, + ) + dropsDesc = prometheus.NewDesc( + prometheus.BuildFQName(namespace, "", "drops_total"), + "Packets dropped by the usid_ingress program, by reason (drop_reasons map, design plan §4.4).", + []string{"reason"}, nil, + ) + blockArgumentsUsedDesc = prometheus.NewDesc( + prometheus.BuildFQName(namespace, "block", "arguments_used"), + "Number of vrf_table entries (registered Arguments) currently active for this uSID Block.", + []string{labelBlock}, nil, + ) + blockArgumentUtilizationDesc = prometheus.NewDesc( + prometheus.BuildFQName(namespace, "block", "argument_utilization_ratio"), + "galactic_usid_block_arguments_used divided by 4095, the per-Block usable Argument capacity under "+ + "design plan §2's Option 2 -- an exhaustion-alerting input.", + []string{labelBlock}, nil, + ) +) + +// Describe implements prometheus.Collector. +func (c *Collector) Describe(ch chan<- *prometheus.Desc) { + ch <- vrfPacketsDesc + ch <- vrfBytesDesc + ch <- dropsDesc + ch <- blockArgumentsUsedDesc + ch <- blockArgumentUtilizationDesc +} + +// Collect implements prometheus.Collector. +func (c *Collector) Collect(ch chan<- prometheus.Metric) { + c.collectVRF(ch) + c.collectDrops(ch) +} + +// formatBlock renders a uSID Block value as a metric label -- hex, matching +// the %#x formatting usidmap/uformat's own error messages already use for +// Block values throughout this codebase. +func formatBlock(block uint64) string { + return fmt.Sprintf("%#x", block) +} + +func (c *Collector) collectVRF(ch chan<- prometheus.Metric) { + // Seed every currently-active uSID Block (from locator_table, the set + // of Blocks this node is actually configured for) with a zero count, + // so a Block with no vrf_table entries yet still reports + // arguments_used=0 / utilization_ratio=0 rather than simply being + // absent -- important for exhaustion alerting (package doc comment): + // an alert on "utilization > 0.9" needs the series to exist at 0 to + // have something to compare against later, not spring into existence + // only once traffic starts. + perBlockUsed := make(map[uint64]int) + if c.locator != nil { + locatorEntries, err := c.locator.List() + if err != nil { + ch <- prometheus.NewInvalidMetric(blockArgumentsUsedDesc, fmt.Errorf("list locator_table: %w", err)) + } else { + for _, e := range locatorEntries { + if _, ok := perBlockUsed[e.Block]; !ok { + perBlockUsed[e.Block] = 0 + } + } + } + } + + entries, err := c.vrf.List() + if err != nil { + ch <- prometheus.NewInvalidMetric(vrfPacketsDesc, fmt.Errorf("list vrf_table: %w", err)) + return + } + for _, e := range entries { + block := formatBlock(e.Block) + argument := strconv.Itoa(int(e.Argument)) + vrfTableID := strconv.FormatUint(uint64(e.VRFTableID), 10) + ch <- prometheus.MustNewConstMetric( + vrfPacketsDesc, prometheus.CounterValue, float64(e.Packets), block, argument, vrfTableID) + ch <- prometheus.MustNewConstMetric( + vrfBytesDesc, prometheus.CounterValue, float64(e.Bytes), block, argument, vrfTableID) + perBlockUsed[e.Block]++ + } + + for block, used := range perBlockUsed { + label := formatBlock(block) + ch <- prometheus.MustNewConstMetric(blockArgumentsUsedDesc, prometheus.GaugeValue, float64(used), label) + ch <- prometheus.MustNewConstMetric(blockArgumentUtilizationDesc, prometheus.GaugeValue, + float64(used)/float64(uformat.ArgumentMax), label) + } +} + +func (c *Collector) collectDrops(ch chan<- prometheus.Metric) { + if c.dropReasons == nil { + return + } + for i := range prog.DropReasonCount { + var perCPU []uint64 + if err := c.dropReasons.Lookup(i, &perCPU); err != nil { + ch <- prometheus.NewInvalidMetric(dropsDesc, fmt.Errorf("lookup drop_reasons[%d]: %w", i, err)) + continue + } + var total uint64 + for _, v := range perCPU { + total += v + } + name := prog.DropReasonNames[i] + if name == "" { + name = fmt.Sprintf("unknown_%d", i) + } + ch <- prometheus.MustNewConstMetric(dropsDesc, prometheus.CounterValue, float64(total), name) + } +} diff --git a/internal/plumbing/ebpf/metrics/collector_test.go b/internal/plumbing/ebpf/metrics/collector_test.go new file mode 100644 index 0000000..d74cf74 --- /dev/null +++ b/internal/plumbing/ebpf/metrics/collector_test.go @@ -0,0 +1,277 @@ +// Copyright 2026 Datum Cloud, Inc. +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package metrics + +import ( + "errors" + "strconv" + "testing" + + "github.com/prometheus/client_golang/prometheus" + dto "github.com/prometheus/client_model/go" + + "go.datum.net/galactic/internal/plumbing/ebpf/prog" + "go.datum.net/galactic/internal/plumbing/ebpf/uformat" + "go.datum.net/galactic/internal/plumbing/ebpf/usidmap" +) + +const ( + testBlock uint64 = 0x010203040506 + testBlock2 uint64 = 0x0A0B0C0D0E0F + testNodeID uint16 = 0x0010 +) + +// collect runs c's Collect method to completion and returns every emitted +// metric decoded to its protobuf form, so tests can assert on label/value +// pairs without needing a real Prometheus registry or HTTP round-trip. +func collect(t *testing.T, c prometheus.Collector) []*dto.Metric { + t.Helper() + ch := make(chan prometheus.Metric, 256) + go func() { + c.Collect(ch) + close(ch) + }() + var out []*dto.Metric + for m := range ch { + var pb dto.Metric + if err := m.Write(&pb); err != nil { + t.Fatalf("write metric: %v", err) + } + out = append(out, &pb) + } + return out +} + +func labelValue(m *dto.Metric, name string) string { + for _, lp := range m.GetLabel() { + if lp.GetName() == name { + return lp.GetValue() + } + } + return "" +} + +func metricValue(m *dto.Metric) float64 { + switch { + case m.Counter != nil: + return m.Counter.GetValue() + case m.Gauge != nil: + return m.Gauge.GetValue() + case m.Untyped != nil: + return m.Untyped.GetValue() + } + return 0 +} + +// putVRFEntry writes a raw vrf_table entry directly into fake, bypassing +// VRFTable.Register (which always resets Packets/Bytes/LastSeenNs to zero +// on write, per vrf.go's documented behavior) -- these tests need to +// assert on nonzero hit counters, so they construct the map's raw +// key/value shape directly instead. +func putVRFEntry( + t *testing.T, fake *fakeTable, block uint64, argument uint16, vrfTableID uint32, packets, bytesN uint64, +) { + t.Helper() + key, err := uformat.NewVRFKey(block, argument) + if err != nil { + t.Fatalf("uformat.NewVRFKey: %v", err) + } + if err := fake.Put(uint64(key), prog.UsidVrfValue{ + VrfTableId: vrfTableID, + Packets: packets, + Bytes: bytesN, + }); err != nil { + t.Fatalf("fake.Put: %v", err) + } +} + +func TestCollector_VRFPacketsAndBytes(t *testing.T) { + const ( + vrfTableID1 uint32 = 0x2A2A2A + vrfTableID2 uint32 = 0x2B2B2B + ) + + vrfFake := newFakeTable() + putVRFEntry(t, vrfFake, testBlock, 0x001, vrfTableID1, 10, 1000) + putVRFEntry(t, vrfFake, testBlock, 0x002, vrfTableID2, 20, 2000) + + c := NewCollector(usidmap.NewVRFTable(vrfFake), usidmap.NewLocatorTable(newFakeTable()), fakeDropReasons{}) + metrics := collect(t, c) + + wantVRFTableID := map[string]string{ + "1": strconv.FormatUint(uint64(vrfTableID1), 10), + "2": strconv.FormatUint(uint64(vrfTableID2), 10), + } + wantPackets := map[string]float64{"1": 10, "2": 20} + wantBytes := map[string]float64{"1": 1000, "2": 2000} + + var packetSamples, byteSamples int + for _, m := range metrics { + block := labelValue(m, labelBlock) + argument := labelValue(m, "argument") + if block != formatBlock(testBlock) { + continue + } + if labelValue(m, "vrf_table_id") != wantVRFTableID[argument] { + continue + } + switch metricValue(m) { + case wantPackets[argument]: + packetSamples++ + case wantBytes[argument]: + byteSamples++ + } + } + if packetSamples != 2 { + t.Errorf("found %d matching packet samples, want 2 (metrics: %+v)", packetSamples, metrics) + } + if byteSamples != 2 { + t.Errorf("found %d matching byte samples, want 2 (metrics: %+v)", byteSamples, metrics) + } +} + +func TestCollector_BlockUtilization(t *testing.T) { + locFake := newFakeTable() + loc := usidmap.NewLocatorTable(locFake) + if err := loc.Register(testBlock, testNodeID); err != nil { + t.Fatalf("Register locator: %v", err) + } + + vrfFake := newFakeTable() + c := NewCollector(usidmap.NewVRFTable(vrfFake), loc, fakeDropReasons{}) + + t.Run("zero entries reports zero, not absent", func(t *testing.T) { + metrics := collect(t, c) + used, ratio, found := findBlockGauges(metrics, testBlock) + if !found { + t.Fatal("no arguments_used/utilization_ratio sample found for a locator-registered Block " + + "with zero vrf_table entries") + } + if used != 0 || ratio != 0 { + t.Errorf("used=%v ratio=%v, want 0/0", used, ratio) + } + }) + + t.Run("one entry", func(t *testing.T) { + putVRFEntry(t, vrfFake, testBlock, 0x001, 1, 5, 500) + metrics := collect(t, c) + used, ratio, found := findBlockGauges(metrics, testBlock) + if !found { + t.Fatal("no arguments_used/utilization_ratio sample found") + } + if used != 1 { + t.Errorf("used = %v, want 1", used) + } + wantRatio := 1.0 / float64(uformat.ArgumentMax) + if ratio != wantRatio { + t.Errorf("ratio = %v, want %v", ratio, wantRatio) + } + }) +} + +func findBlockGauges(metrics []*dto.Metric, block uint64) (used, ratio float64, found bool) { + var usedFound, ratioFound bool + for _, m := range metrics { + if labelValue(m, labelBlock) != formatBlock(block) { + continue + } + if m.Gauge == nil { + continue + } + // Both gauges share the same "block" label; distinguish by value + // range isn't reliable, so instead we rely on collection order + // being deterministic within a single Collect call: Collector + // always emits arguments_used immediately followed by + // argument_utilization_ratio for a given block (collectVRF's + // single loop). Guard against that assumption breaking silently + // by requiring exactly two gauge samples for this block. + if !usedFound { + used = m.Gauge.GetValue() + usedFound = true + continue + } + ratio = m.Gauge.GetValue() + ratioFound = true + } + return used, ratio, usedFound && ratioFound +} + +func TestCollector_Drops(t *testing.T) { + drops := fakeDropReasons{ + prog.DropReasonUnknownArgument: 42, + prog.DropReasonFibLookupFailed: 7, + } + c := NewCollector(usidmap.NewVRFTable(newFakeTable()), usidmap.NewLocatorTable(newFakeTable()), drops) + + metrics := collect(t, c) + + seen := make(map[string]float64) + for _, m := range metrics { + if reason := labelValue(m, "reason"); reason != "" { + seen[reason] = metricValue(m) + } + } + + want := map[string]float64{ + "unknown_function": 0, + "unknown_argument": 42, + "malformed_inner": 0, + "unknown_inner_version": 0, + "strip_failed": 0, + "fib_lookup_failed": 7, + "redirect_failed": 0, + } + for reason, wantVal := range want { + got, ok := seen[reason] + if !ok { + t.Errorf("reason %q not emitted at all (all %d drop reasons must always be emitted, even at zero)", + reason, prog.DropReasonCount) + continue + } + if got != wantVal { + t.Errorf("reason %q = %v, want %v", reason, got, wantVal) + } + } + if len(seen) != int(prog.DropReasonCount) { + t.Errorf("emitted %d distinct drop reasons, want %d", len(seen), prog.DropReasonCount) + } +} + +// erroringTable is a minimal usidmap.Table whose Iterate().Err() always +// fails, to prove Collector reports a List() failure as an InvalidMetric +// instead of silently dropping the scrape or panicking. +type erroringTable struct{} + +func (erroringTable) Put(any, any) error { return nil } +func (erroringTable) Lookup(any, any) error { return errors.New("not implemented") } +func (erroringTable) Delete(any) error { return nil } +func (erroringTable) Iterate() usidmap.Iterator { + return erroringIterator{} +} + +type erroringIterator struct{} + +func (erroringIterator) Next(any, any) bool { return false } +func (erroringIterator) Err() error { return errors.New("simulated map iteration failure") } + +func TestCollector_VRFListErrorReportsInvalidMetric(t *testing.T) { + c := NewCollector(usidmap.NewVRFTable(erroringTable{}), usidmap.NewLocatorTable(newFakeTable()), fakeDropReasons{}) + + ch := make(chan prometheus.Metric, 16) + c.Collect(ch) + close(ch) + + var sawInvalid bool + for m := range ch { + var pb dto.Metric + err := m.Write(&pb) + if err != nil { + sawInvalid = true + } + } + if !sawInvalid { + t.Error("expected at least one metric to fail Write() (an InvalidMetric) when vrf_table listing fails") + } +} diff --git a/internal/plumbing/ebpf/metrics/doc.go b/internal/plumbing/ebpf/metrics/doc.go new file mode 100644 index 0000000..db406c7 --- /dev/null +++ b/internal/plumbing/ebpf/metrics/doc.go @@ -0,0 +1,42 @@ +// Copyright 2026 Datum Cloud, Inc. +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +// Package metrics implements the eBPF uSID datapath's Prometheus +// instrumentation (design plan .local/plan-ebpf-xdp-usid-datapath.md §9's +// observability bullet: "Export Prometheus metrics for: packets/bytes per +// Argument (VRF), drops by reason (drop_reasons map), BPF program +// load/reload events and failures, and ... current Argument-space +// utilization per uSID Block"; Milestone 4 of +// .local/implementation-plan-ebpf-xdp-usid-datapath.md). +// +// Two different kinds of signal need two different Prometheus +// instrumentation styles here, and this package keeps them in separate +// files for that reason: +// +// - packets/bytes per Argument, drops by reason, and per-Block Argument +// utilization (collector.go) are all *state currently held in a BPF +// map* -- vrf_table, locator_table, drop_reasons. These are read live, +// directly from the map, at every Prometheus scrape via a custom +// prometheus.Collector, rather than mirrored into incrementally- +// `Set()` Gauges: a vrf_table entry the GC sweep removes (Milestone +// 7.3, internal/plumbing/ebpf/usidmap.VRFTable.Reconcile) simply stops +// being emitted on the *next* scrape this way, instead of leaking a +// stale label combination in a Gauge/GaugeVec forever (Prometheus +// Gauges have no way to "expire" a label combination on their own; +// only a Collector that re-derives its label set from the live source +// of truth at every Collect() call gets that for free). +// - BPF program load/reload events and failures (events.go) are +// discrete occurrences at the moment they happen (a Load() call +// succeeding or failing; an interface being attached/detached), +// not values held anywhere the collector could re-read later -- +// so these are ordinary prometheus.CounterVecs, incremented in place +// via internal/plumbing/ebpf/attach's LoadHook/AttachHook callbacks +// (attach/hooks.go), wired once via attach.SetHooks at process +// startup (internal/installer.Run). +// +// Metrics (metrics.go) bundles both into one prometheus.Registry plus an +// http.Handler for internal/installer.Run to serve, so that package only +// needs to call metrics.New() once and doesn't need to import +// prometheus/promhttp directly. +package metrics diff --git a/internal/plumbing/ebpf/metrics/events.go b/internal/plumbing/ebpf/metrics/events.go new file mode 100644 index 0000000..aee8352 --- /dev/null +++ b/internal/plumbing/ebpf/metrics/events.go @@ -0,0 +1,71 @@ +// Copyright 2026 Datum Cloud, Inc. +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package metrics + +import ( + "github.com/prometheus/client_golang/prometheus" + + "go.datum.net/galactic/internal/plumbing/ebpf/attach" +) + +// EventCounters are ordinary Prometheus counters for BPF program +// load/reload events and failures (package doc comment). Unlike Collector +// (map state read live at scrape time), these are discrete events observed +// exactly once, at the moment they happen, via +// internal/plumbing/ebpf/attach's LoadHook/AttachHook callbacks. +type EventCounters struct { + load *prometheus.CounterVec + attach *prometheus.CounterVec +} + +// NewEventCounters builds a fresh, unregistered set of event counters. +func NewEventCounters() *EventCounters { + return &EventCounters{ + load: prometheus.NewCounterVec(prometheus.CounterOpts{ + Namespace: namespace, + Subsystem: "datapath", + Name: "load_events_total", + Help: "BPF program load attempts (internal/plumbing/ebpf/attach.Load), by result.", + }, []string{"result"}), + attach: prometheus.NewCounterVec(prometheus.CounterOpts{ + Namespace: namespace, + Subsystem: "datapath", + Name: "attach_events_total", + Help: "TC-BPF ingress filter attach/detach attempts -- both the initial Start and every " + + "subsequent netlink-driven re-attachment ('reload') Watch performs -- by interface, action, and result.", + }, []string{"interface", "action", "result"}), + } +} + +// MustRegister registers every counter this type owns against reg. Panics +// on a duplicate registration, matching prometheus.Registerer.MustRegister's +// own documented behavior -- callers only ever do this once per process, at +// startup (see Metrics.New). +func (c *EventCounters) MustRegister(reg prometheus.Registerer) { + reg.MustRegister(c.load, c.attach) +} + +// Hooks returns the attach.Hooks wiring these counters. Pass the result to +// attach.SetHooks once at process startup, before starting the datapath. +func (c *EventCounters) Hooks() attach.Hooks { + return attach.Hooks{ + OnLoad: func(err error) { + c.load.WithLabelValues(result(err)).Inc() + }, + OnAttach: func(iface string, err error) { + c.attach.WithLabelValues(iface, "attach", result(err)).Inc() + }, + OnDetach: func(iface string, err error) { + c.attach.WithLabelValues(iface, "detach", result(err)).Inc() + }, + } +} + +func result(err error) string { + if err != nil { + return "failure" + } + return "success" +} diff --git a/internal/plumbing/ebpf/metrics/faketable_test.go b/internal/plumbing/ebpf/metrics/faketable_test.go new file mode 100644 index 0000000..e56cc62 --- /dev/null +++ b/internal/plumbing/ebpf/metrics/faketable_test.go @@ -0,0 +1,112 @@ +// Copyright 2026 Datum Cloud, Inc. +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package metrics + +import ( + "fmt" + "reflect" + + "github.com/cilium/ebpf" + + "go.datum.net/galactic/internal/plumbing/ebpf/usidmap" +) + +// fakeTable is an in-memory usidmap.Table implementation for this +// package's tests, the same technique +// internal/plumbing/ebpf/usidmap/faketable_test.go uses (unexported there, +// so not reusable across package boundaries -- reimplemented here rather +// than promoted to an exported helper, to keep that package's own test +// surface unchanged). +type fakeTable struct { + entries map[uint64]any + order []uint64 +} + +func newFakeTable() *fakeTable { + return &fakeTable{entries: make(map[uint64]any)} +} + +func fakeTableKey(key any) uint64 { + k, ok := key.(uint64) + if !ok { + panic(fmt.Sprintf("fakeTable: key type %T not supported, want uint64", key)) + } + return k +} + +func (f *fakeTable) Put(key, value any) error { + k := fakeTableKey(key) + if _, exists := f.entries[k]; !exists { + f.order = append(f.order, k) + } + f.entries[k] = value + return nil +} + +func (f *fakeTable) Lookup(key, valueOut any) error { + k := fakeTableKey(key) + v, ok := f.entries[k] + if !ok { + return ebpf.ErrKeyNotExist + } + reflect.ValueOf(valueOut).Elem().Set(reflect.ValueOf(v)) + return nil +} + +func (f *fakeTable) Delete(key any) error { + k := fakeTableKey(key) + if _, ok := f.entries[k]; !ok { + return ebpf.ErrKeyNotExist + } + delete(f.entries, k) + for i, kk := range f.order { + if kk == k { + f.order = append(f.order[:i], f.order[i+1:]...) + break + } + } + return nil +} + +func (f *fakeTable) Iterate() usidmap.Iterator { + return &fakeIterator{table: f, idx: -1} +} + +type fakeIterator struct { + table *fakeTable + idx int +} + +func (it *fakeIterator) Next(keyOut, valueOut any) bool { + it.idx++ + if it.idx >= len(it.table.order) { + return false + } + k := it.table.order[it.idx] + reflect.ValueOf(keyOut).Elem().Set(reflect.ValueOf(k)) + reflect.ValueOf(valueOut).Elem().Set(reflect.ValueOf(it.table.entries[k])) + return true +} + +func (it *fakeIterator) Err() error { return nil } + +// fakeDropReasons is an in-memory DropReasonsReader for tests -- a plain +// map from drop_reasons index to a single "per-CPU" value, since tests +// don't need to exercise the real per-CPU summing behavior (that is +// exercised by kernel_test.go's real-map integration test). +type fakeDropReasons map[uint32]uint64 + +func (f fakeDropReasons) Lookup(key, valueOut any) error { + k, ok := key.(uint32) + if !ok { + panic(fmt.Sprintf("fakeDropReasons: key type %T not supported, want uint32", key)) + } + out, ok := valueOut.(*[]uint64) + if !ok { + panic(fmt.Sprintf("fakeDropReasons: valueOut type %T not supported, want *[]uint64", valueOut)) + } + *out = []uint64{f[k]} + return nil +} diff --git a/internal/plumbing/ebpf/metrics/metrics.go b/internal/plumbing/ebpf/metrics/metrics.go new file mode 100644 index 0000000..52c6927 --- /dev/null +++ b/internal/plumbing/ebpf/metrics/metrics.go @@ -0,0 +1,50 @@ +// Copyright 2026 Datum Cloud, Inc. +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package metrics + +import ( + "net/http" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promhttp" + + "go.datum.net/galactic/internal/plumbing/ebpf/prog" +) + +// Metrics bundles this milestone's Prometheus instrumentation into one +// private registry (deliberately not prometheus.DefaultRegisterer -- a +// private registry keeps this package's metrics free of global-registration +// panics if a test, or a future caller, constructs more than one Metrics in +// the same process) plus an http.Handler for internal/installer.Run to +// serve, so that package doesn't need to import prometheus/promhttp +// directly. +type Metrics struct { + Registry *prometheus.Registry + Events *EventCounters +} + +// New builds a Metrics with EventCounters already registered. Call +// RegisterDatapathCollector once the eBPF uSID datapath has actually been +// loaded to also expose live vrf_table/locator_table/drop_reasons state. +func New() *Metrics { + reg := prometheus.NewRegistry() + events := NewEventCounters() + events.MustRegister(reg) + return &Metrics{Registry: reg, Events: events} +} + +// RegisterDatapathCollector registers a Collector reading live +// vrf_table/locator_table/drop_reasons state from objs at every scrape. +// Call once, after the datapath is loaded (internal/installer.Run, right +// after a successful ebpfStartFn call). +func (m *Metrics) RegisterDatapathCollector(objs *prog.UsidObjects) error { + return m.Registry.Register(NewCollectorFromObjects(objs)) +} + +// Handler returns the http.Handler serving this Metrics' registry in the +// Prometheus text exposition format. +func (m *Metrics) Handler() http.Handler { + return promhttp.HandlerFor(m.Registry, promhttp.HandlerOpts{}) +} diff --git a/internal/plumbing/ebpf/preflight/kernel_prober.go b/internal/plumbing/ebpf/preflight/kernel_prober.go new file mode 100644 index 0000000..389ec16 --- /dev/null +++ b/internal/plumbing/ebpf/preflight/kernel_prober.go @@ -0,0 +1,180 @@ +// Copyright 2025 Datum Cloud, Inc. +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package preflight + +import ( + "fmt" + "sync" + + "github.com/cilium/ebpf" + "github.com/cilium/ebpf/btf" + "github.com/cilium/ebpf/features" + "github.com/cilium/ebpf/rlimit" +) + +// fibLookupStructName and tbidMemberName identify the kernel BTF type and +// member this package's FIBLookupTBID check looks for. Named constants so +// both the lookup and its error messages stay in sync if either ever needs +// to change. +const ( + fibLookupStructName = "bpf_fib_lookup" + tbidMemberName = "tbid" +) + +// KernelProber is the real, kernel-backed [Prober] implementation used in +// production. It probes the actual running kernel via +// github.com/cilium/ebpf's features package (for BPF_PROG_TYPE_SCHED_CLS +// and BPF_MAP_TYPE_HASH -- both of which that package detects by actually +// attempting the create/load syscall, the same technique any BPF loader +// uses) and via kernel BTF introspection (for BTF presence and the +// bpf_fib_lookup tbid member specifically -- see this package's doc +// comment for why a struct-field check, not a version-string parse). +// +// The zero value is not ready to use; construct with [NewKernelProber]. +// A *KernelProber may be reused across multiple Check/CheckWith calls -- +// its one piece of internal state (the loaded kernel BTF spec) is cached +// after the first probe that needs it, since re-parsing the kernel's BTF +// blob (several megabytes) on every call would make repeated preflight +// checks (e.g. a health-check loop re-running this at Milestone 3.1/4) +// needlessly expensive. +type KernelProber struct { + specOnce sync.Once + spec *btf.Spec + specErr error +} + +// NewKernelProber returns a ready-to-use [KernelProber]. +func NewKernelProber() *KernelProber { + return &KernelProber{} +} + +// SchedCLS implements [Prober] by attempting to create a minimal +// BPF_PROG_TYPE_SCHED_CLS program and reporting whether the kernel accepted +// the program type. +func (k *KernelProber) SchedCLS() error { + if err := ensureMemlockRemoved(); err != nil { + return err + } + if err := features.HaveProgramType(ebpf.SchedCLS); err != nil { + return fmt.Errorf("kernel rejects BPF_PROG_TYPE_SCHED_CLS: %w", err) + } + return nil +} + +// HashMap implements [Prober] by attempting to create a minimal +// BPF_MAP_TYPE_HASH map and reporting whether the kernel accepted the map +// type. +func (k *KernelProber) HashMap() error { + if err := ensureMemlockRemoved(); err != nil { + return err + } + if err := features.HaveMapType(ebpf.Hash); err != nil { + return fmt.Errorf("kernel rejects BPF_MAP_TYPE_HASH: %w", err) + } + return nil +} + +// BTF implements [Prober] by attempting to load the running kernel's own +// BTF (typically /sys/kernel/btf/vmlinux). +func (k *KernelProber) BTF() error { + if _, err := k.kernelSpec(); err != nil { + return fmt.Errorf("kernel BTF unavailable: %w", err) + } + return nil +} + +// FIBLookupTBID implements [Prober] by loading the running kernel's BTF +// description of `struct bpf_fib_lookup` and checking, recursively (the +// real struct nests `tbid` inside an anonymous union -- see +// hasMemberNamed), for a member literally named `tbid`. Presence of that +// field is a direct, version-string-independent proof that this kernel's +// bpf_fib_lookup() understands the BPF_FIB_LOOKUP_TBID flag and the +// VRF-table-id lookup R5 depends on, since the kernel's BTF is generated +// from the exact same struct definition its bpf_fib_lookup() +// implementation reads. +func (k *KernelProber) FIBLookupTBID() error { + spec, err := k.kernelSpec() + if err != nil { + return fmt.Errorf("cannot determine bpf_fib_lookup tbid support without kernel BTF: %w", err) + } + + var fibLookup *btf.Struct + if err := spec.TypeByName(fibLookupStructName, &fibLookup); err != nil { + return fmt.Errorf("kernel BTF has no %q struct: %w", fibLookupStructName, err) + } + + if !hasMemberNamed(fibLookup, tbidMemberName, 0) { + return fmt.Errorf( + "kernel's struct %s has no %q member: this kernel's bpf_fib_lookup() predates VRF-table-id "+ + "support; upgrade the kernel or exclude this node from the eBPF uSID datapath rollout", + fibLookupStructName, tbidMemberName, + ) + } + return nil +} + +// kernelSpec loads and caches the running kernel's BTF spec, parsing it at +// most once per *KernelProber. +func (k *KernelProber) kernelSpec() (*btf.Spec, error) { + k.specOnce.Do(func() { + k.spec, k.specErr = btf.LoadKernelSpec() + }) + return k.spec, k.specErr +} + +// maxMemberSearchDepth bounds hasMemberNamed's recursion. struct +// bpf_fib_lookup nests at most one level deep (a handful of anonymous +// unions directly inside the outer struct); this ceiling is generous +// headroom against any deeper nesting a future kernel might introduce, +// while still guaranteeing termination against unexpected/malformed BTF. +const maxMemberSearchDepth = 8 + +// hasMemberNamed reports whether t (expected to be a *btf.Struct or +// *btf.Union) has a member named name, searching recursively into any +// nested anonymous struct/union members -- required here because the real +// kernel's `struct bpf_fib_lookup` places `tbid` inside an anonymous +// `union { struct { ... vlan fields ... }; __u32 tbid; }`, not as a +// top-level member. +func hasMemberNamed(t btf.Type, name string, depth int) bool { + if depth > maxMemberSearchDepth { + return false + } + + var members []btf.Member + switch v := t.(type) { + case *btf.Struct: + members = v.Members + case *btf.Union: + members = v.Members + default: + return false + } + + for _, m := range members { + if m.Name == name { + return true + } + if hasMemberNamed(m.Type, name, depth+1) { + return true + } + } + return false +} + +// ensureMemlockRemoved lifts the memlock rlimit that older kernels (pre-5.11 +// cgroup-based BPF memory accounting) enforce against BPF map/program +// creation. It is safe and cheap to call repeatedly -- github.com/cilium/ +// ebpf's rlimit package makes the underlying setrlimit call idempotent -- +// and is required here because this package's probes may be the first BPF +// syscalls a process makes (Milestone 3.1's control daemon is expected to +// call [Check] before doing any other BPF setup of its own). +func ensureMemlockRemoved() error { + if err := rlimit.RemoveMemlock(); err != nil { + return fmt.Errorf("remove memlock rlimit: %w", err) + } + return nil +} + +var _ Prober = (*KernelProber)(nil) diff --git a/internal/plumbing/ebpf/preflight/kernel_prober_test.go b/internal/plumbing/ebpf/preflight/kernel_prober_test.go new file mode 100644 index 0000000..1744e49 --- /dev/null +++ b/internal/plumbing/ebpf/preflight/kernel_prober_test.go @@ -0,0 +1,82 @@ +// Copyright 2025 Datum Cloud, Inc. +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package preflight + +import ( + "os" + "testing" +) + +// requireRoot skips the calling test unless running as root, matching +// internal/plumbing/ebpf/prog's own convention for tests that need +// CAP_BPF/CAP_NET_ADMIN to touch the real kernel. +func requireRoot(t *testing.T) { + t.Helper() + if os.Geteuid() != 0 { + t.Skip("test requires root (CAP_BPF/CAP_NET_ADMIN) to probe real kernel capabilities; re-run via sudo") + } +} + +// TestKernelProber_BTF exercises [KernelProber.BTF] against the real +// running kernel. This sandbox is documented to have BTF at +// /sys/kernel/btf/vmlinux, so this must pass. +func TestKernelProber_BTF(t *testing.T) { + k := NewKernelProber() + if err := k.BTF(); err != nil { + t.Errorf("KernelProber.BTF() = %v, want nil (this sandbox is documented to have kernel BTF)", err) + } +} + +// TestKernelProber_FIBLookupTBID exercises [KernelProber.FIBLookupTBID] +// against the real running kernel's own BTF. This is the milestone's +// central "run the real check and report what it finds" exit criterion: +// it does not assume an outcome, it reports whatever the real kernel BTF +// says. +func TestKernelProber_FIBLookupTBID(t *testing.T) { + k := NewKernelProber() + err := k.FIBLookupTBID() + t.Logf("KernelProber.FIBLookupTBID() on this sandbox kernel: %v", err) + if err != nil { + t.Skip("this sandbox kernel's BTF does not describe a tbid member on struct bpf_fib_lookup; " + + "see the test log above for the exact error -- not treated as a test failure since this " + + "probe's job is to report kernel reality, not assert a specific kernel version") + } +} + +// TestKernelProber_SchedCLS exercises [KernelProber.SchedCLS] against the +// real running kernel. Requires root: creating even a throwaway +// BPF_PROG_TYPE_SCHED_CLS program needs CAP_BPF. +func TestKernelProber_SchedCLS(t *testing.T) { + requireRoot(t) + k := NewKernelProber() + if err := k.SchedCLS(); err != nil { + t.Errorf("KernelProber.SchedCLS() = %v, want nil (environment facts document SCHED_CLS support)", err) + } +} + +// TestKernelProber_HashMap exercises [KernelProber.HashMap] against the +// real running kernel. Requires root: creating even a throwaway +// BPF_MAP_TYPE_HASH map needs CAP_BPF. +func TestKernelProber_HashMap(t *testing.T) { + requireRoot(t) + k := NewKernelProber() + if err := k.HashMap(); err != nil { + t.Errorf("KernelProber.HashMap() = %v, want nil (environment facts document BPF_MAP_TYPE_HASH support)", err) + } +} + +// TestCheck_RealKernel runs the full, real [Check] (via [NewKernelProber], +// not a stub) against this sandbox's actual kernel end to end -- the +// milestone's "run the real check against this actual sandbox kernel" +// exit criterion. Requires root for the program/map probes. +func TestCheck_RealKernel(t *testing.T) { + requireRoot(t) + err := Check() + t.Logf("Check() against this sandbox's real kernel: %v", err) + if err != nil { + t.Errorf("Check() = %v, want nil -- this sandbox is documented to have BTF, SCHED_CLS, "+ + "BPF_MAP_TYPE_HASH, and (per TestKernelProber_FIBLookupTBID's own report) tbid support", err) + } +} diff --git a/internal/plumbing/ebpf/preflight/preflight.go b/internal/plumbing/ebpf/preflight/preflight.go new file mode 100644 index 0000000..a455c1d --- /dev/null +++ b/internal/plumbing/ebpf/preflight/preflight.go @@ -0,0 +1,159 @@ +// Copyright 2025 Datum Cloud, Inc. +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +// Package preflight implements the startup kernel-capability check for the +// `uFMT 48+16` eBPF/TC-BPF uSID datapath (design plan +// .local/plan-ebpf-xdp-usid-datapath.md §6 "Preflight capability check"; +// Milestone 2.3 of .local/implementation-plan-ebpf-xdp-usid-datapath.md). +// +// Before Milestone 3.1's control daemon attempts to load and attach +// internal/plumbing/ebpf/prog's compiled BPF object, it must confirm the +// running kernel actually supports everything that object needs: +// +// - BPF_PROG_TYPE_SCHED_CLS (the TC-BPF ingress hook usid.c attaches as, +// design plan §4.1). +// - BPF_MAP_TYPE_HASH (locator_table/function_table/vrf_table are all +// this type, design plan §4.4). +// - Kernel BTF (usid.c is compiled CO-RE -- Compile Once, Run Everywhere +// -- and needs /sys/kernel/btf/vmlinux to load at all, design plan §6). +// - bpf_fib_lookup()'s VRF-table-id (tbid) parameter, specifically -- +// not just that the bpf_fib_lookup helper exists at all. That +// parameter (the BPF_FIB_LOOKUP_TBID flag plus struct bpf_fib_lookup's +// `tbid` field) was added to the kernel in a later release than the +// base helper. A kernel that has bpf_fib_lookup but predates tbid +// support would pass a naive "is the helper present" check and then +// either fail the load (if usid.c's struct layout doesn't match what +// the running kernel expects) or, worse, silently misroute traffic at +// runtime -- R5 of the design plan depends on FIB lookups being scoped +// to the resolved Argument's Linux VRF table via exactly this +// parameter. This package detects tbid support by walking the running +// kernel's own BTF description of `struct bpf_fib_lookup` (via +// [KernelProber], see kernel_prober.go) for a member literally named +// `tbid`, rather than parsing a kernel version string: the kernel's +// BTF is generated directly from the same struct definition its +// bpf_fib_lookup() implementation reads, so if the field isn't there, +// the running kernel provably doesn't support it, independent of +// whatever version string /proc/version reports (backports/vendor +// kernels routinely change what a given "version" supports). +// +// This check must never produce a partial pass: any missing capability +// fails the whole check, and the caller must not fall back to a +// degraded/unsafe mode (design plan §6). See [Check] and [CheckWith]. +package preflight + +import ( + "errors" + "fmt" +) + +// Prober is the kernel-capability probe interface this package's checks run +// against. [NewKernelProber] returns the real, kernel-backed implementation +// used in production; tests substitute a mocked/stubbed implementation (see +// preflight_test.go) to exercise the pass case and each individual failure +// case in [CheckWith] without touching the real kernel (Milestone 2.3 exit +// criteria). +type Prober interface { + // SchedCLS reports whether the running kernel supports + // BPF_PROG_TYPE_SCHED_CLS. Returns nil if supported, a non-nil error + // otherwise. + SchedCLS() error + + // HashMap reports whether the running kernel supports + // BPF_MAP_TYPE_HASH. Returns nil if supported, a non-nil error + // otherwise. + HashMap() error + + // BTF reports whether the running kernel exposes BTF type + // information (required for usid.c's CO-RE compilation to resolve + // against this kernel). Returns nil if available, a non-nil error + // otherwise. + BTF() error + + // FIBLookupTBID reports whether this kernel's bpf_fib_lookup() + // supports the VRF-table-id (tbid) parameter specifically -- not + // merely that the base helper exists. Returns nil if supported, a + // non-nil error otherwise. + FIBLookupTBID() error +} + +// capabilityCheck names one required capability, binds it to its probe +// function, and carries a one-line, actionable "why this matters" note used +// to build [CheckWith]'s aggregate error. The why text is independent of +// whatever detail the underlying Prober error carries, so the aggregate +// error is equally actionable regardless of which Prober implementation +// (real or stubbed) produced it. +type capabilityCheck struct { + name string + fn func() error + why string +} + +func capabilityChecks(p Prober) []capabilityCheck { + return []capabilityCheck{ + { + name: "BPF_PROG_TYPE_SCHED_CLS", + fn: p.SchedCLS, + why: "the TC-BPF ingress hook this datapath attaches as requires SCHED_CLS program support (design plan §4.1)", + }, + { + name: "BPF_MAP_TYPE_HASH", + fn: p.HashMap, + why: "locator_table, function_table, and vrf_table are all BPF_MAP_TYPE_HASH (design plan §4.4)", + }, + { + name: "kernel BTF", + fn: p.BTF, + why: "the datapath is compiled CO-RE and requires /sys/kernel/btf/vmlinux to resolve against " + + "this kernel (design plan §6)", + }, + { + name: "bpf_fib_lookup VRF-table-id (tbid) parameter", + fn: p.FIBLookupTBID, + why: "R5 requires FIB lookups scoped to the resolved Argument's Linux VRF table via bpf_fib_lookup's tbid " + + "parameter, added to the kernel later than the base helper (design plan §6, §10)", + }, + } +} + +// Check runs every capability probe this datapath depends on against the +// real running kernel (via [NewKernelProber]) and returns a clear, +// actionable error if any is missing. It is the entry point Milestone +// 3.1's control daemon calls before attempting to load +// internal/plumbing/ebpf/prog's compiled object. +func Check() error { + return CheckWith(NewKernelProber()) +} + +// CheckWith runs the same checks as [Check] against an arbitrary [Prober], +// so tests can substitute a mocked/stubbed kernel-feature-probe +// implementation and exercise the pass case and each individual failure +// case without touching the real kernel. +// +// Every check always runs, even after an earlier one fails, so a caller +// sees every missing capability at once rather than one at a time across +// repeated fix-and-rerun cycles. If nothing is missing, CheckWith returns +// nil. If anything is missing, CheckWith returns a single non-nil error +// (built with [errors.Join]) describing every failure -- there is no +// partial-pass return value, and callers must treat any non-nil error as +// "do not load the datapath on this node," never as a signal to fall back +// to a degraded or unsafe mode (design plan §6). +func CheckWith(p Prober) error { + checks := capabilityChecks(p) + + var errs []error + for _, c := range checks { + if err := c.fn(); err != nil { + errs = append(errs, fmt.Errorf("%s: %s: %w", c.name, c.why, err)) + } + } + if len(errs) == 0 { + return nil + } + + return fmt.Errorf( + "eBPF uSID datapath preflight check failed (%d/%d required kernel capabilities missing) -- "+ + "refusing to load the datapath on this node; there is no partial or unsafe fallback: %w", + len(errs), len(checks), errors.Join(errs...), + ) +} diff --git a/internal/plumbing/ebpf/preflight/preflight_test.go b/internal/plumbing/ebpf/preflight/preflight_test.go new file mode 100644 index 0000000..591a775 --- /dev/null +++ b/internal/plumbing/ebpf/preflight/preflight_test.go @@ -0,0 +1,249 @@ +// Copyright 2025 Datum Cloud, Inc. +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package preflight + +import ( + "errors" + "strings" + "testing" + + "github.com/cilium/ebpf/btf" +) + +// stubProber is a mocked/stubbed [Prober] letting each of the four +// milestone-mandated scenarios (the pass case plus each individual failure +// case) be exercised without touching the real kernel (Milestone 2.3 exit +// criteria). +type stubProber struct { + schedCLS error + hashMap error + btf error + fibLookupTBID error +} + +func (s stubProber) SchedCLS() error { return s.schedCLS } +func (s stubProber) HashMap() error { return s.hashMap } +func (s stubProber) BTF() error { return s.btf } +func (s stubProber) FIBLookupTBID() error { return s.fibLookupTBID } + +var _ Prober = stubProber{} + +// TestCheckWith_AllCapabilitiesPresent covers the pass case: every probe +// returns nil, CheckWith must return nil. +func TestCheckWith_AllCapabilitiesPresent(t *testing.T) { + if err := CheckWith(stubProber{}); err != nil { + t.Fatalf("CheckWith() = %v, want nil", err) + } +} + +// TestCheckWith_SchedCLSMissing covers BPF_PROG_TYPE_SCHED_CLS missing in +// isolation: every other probe passes, only this one fails, and the +// returned error must name the failing capability. +func TestCheckWith_SchedCLSMissing(t *testing.T) { + want := errors.New("no SCHED_CLS on this kernel") + err := CheckWith(stubProber{schedCLS: want}) + if err == nil { + t.Fatal("CheckWith() = nil, want an error") + } + if !errors.Is(err, want) { + t.Errorf("CheckWith() = %v, want it to wrap %v", err, want) + } + if !strings.Contains(err.Error(), "BPF_PROG_TYPE_SCHED_CLS") { + t.Errorf("CheckWith() = %q, want it to name BPF_PROG_TYPE_SCHED_CLS", err.Error()) + } +} + +// TestCheckWith_HashMapMissing covers BPF_MAP_TYPE_HASH missing in +// isolation. +func TestCheckWith_HashMapMissing(t *testing.T) { + want := errors.New("no BPF_MAP_TYPE_HASH on this kernel") + err := CheckWith(stubProber{hashMap: want}) + if err == nil { + t.Fatal("CheckWith() = nil, want an error") + } + if !errors.Is(err, want) { + t.Errorf("CheckWith() = %v, want it to wrap %v", err, want) + } + if !strings.Contains(err.Error(), "BPF_MAP_TYPE_HASH") { + t.Errorf("CheckWith() = %q, want it to name BPF_MAP_TYPE_HASH", err.Error()) + } +} + +// TestCheckWith_BTFMissing covers BTF presence missing in isolation. +func TestCheckWith_BTFMissing(t *testing.T) { + want := errors.New("no BTF on this kernel") + err := CheckWith(stubProber{btf: want}) + if err == nil { + t.Fatal("CheckWith() = nil, want an error") + } + if !errors.Is(err, want) { + t.Errorf("CheckWith() = %v, want it to wrap %v", err, want) + } + if !strings.Contains(err.Error(), "BTF") { + t.Errorf("CheckWith() = %q, want it to mention BTF", err.Error()) + } +} + +// TestCheckWith_FIBLookupTBIDMissing covers the VRF-tbid-specific +// bpf_fib_lookup variant missing in isolation -- this is the milestone's +// central case: the base helper existing is not enough, and this failure +// must be distinguishable from a generic "fib_lookup not supported" error. +func TestCheckWith_FIBLookupTBIDMissing(t *testing.T) { + want := errors.New("struct bpf_fib_lookup has no tbid member") + err := CheckWith(stubProber{fibLookupTBID: want}) + if err == nil { + t.Fatal("CheckWith() = nil, want an error") + } + if !errors.Is(err, want) { + t.Errorf("CheckWith() = %v, want it to wrap %v", err, want) + } + if !strings.Contains(err.Error(), "tbid") { + t.Errorf("CheckWith() = %q, want it to mention tbid", err.Error()) + } +} + +// TestCheckWith_AllMissing covers every capability failing at once: the +// aggregate error must mention all four, not just the first (design plan +// §6: never a partial check -- a caller fixing only the first-reported +// problem and re-running should immediately learn about the rest, not +// discover them one at a time). +func TestCheckWith_AllMissing(t *testing.T) { + err := CheckWith(stubProber{ + schedCLS: errors.New("no sched_cls"), + hashMap: errors.New("no hash map"), + btf: errors.New("no btf"), + fibLookupTBID: errors.New("no tbid"), + }) + if err == nil { + t.Fatal("CheckWith() = nil, want an error") + } + for _, want := range []string{ + "BPF_PROG_TYPE_SCHED_CLS", "BPF_MAP_TYPE_HASH", "BTF", "tbid", + "4/4 required kernel capabilities missing", + } { + if !strings.Contains(err.Error(), want) { + t.Errorf("CheckWith() = %q, want it to contain %q", err.Error(), want) + } + } +} + +// TestCheckWith_NeverPartialPass guards against a regression where a +// caller might mistake "some capabilities present" for a pass: as long as +// even one probe fails, CheckWith must return non-nil, regardless of how +// many others succeeded. +func TestCheckWith_NeverPartialPass(t *testing.T) { + cases := []struct { + name string + stub stubProber + }{ + {"only fib lookup tbid missing", stubProber{fibLookupTBID: errors.New("x")}}, + {"only btf missing", stubProber{btf: errors.New("x")}}, + {"only hash map missing", stubProber{hashMap: errors.New("x")}}, + {"only sched cls missing", stubProber{schedCLS: errors.New("x")}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if err := CheckWith(tc.stub); err == nil { + t.Fatal("CheckWith() = nil, want a non-nil error (no partial pass allowed)") + } + }) + } +} + +// TestHasMemberNamed_TopLevel covers a member found directly on the +// struct, with no nesting involved. +func TestHasMemberNamed_TopLevel(t *testing.T) { + s := fakeStruct("outer", fakeMember("family", fakeInt()), fakeMember("tbid", fakeInt())) + if !hasMemberNamed(s, "tbid", 0) { + t.Error("hasMemberNamed() = false, want true for a top-level member") + } +} + +// TestHasMemberNamed_NestedInAnonymousUnion mirrors the real kernel's +// struct bpf_fib_lookup layout: `tbid` lives inside an anonymous union +// nested inside the outer struct, not as a top-level member. This is the +// exact shape [KernelProber.FIBLookupTBID] must handle correctly. +func TestHasMemberNamed_NestedInAnonymousUnion(t *testing.T) { + innerUnion := fakeUnion("", + fakeMember("h_vlan_proto", fakeInt()), + fakeMember("h_vlan_TCI", fakeInt()), + fakeMember("tbid", fakeInt()), + ) + outer := fakeStruct("bpf_fib_lookup", + fakeMember("family", fakeInt()), + fakeMember("ifindex", fakeInt()), + fakeMember("", innerUnion), + ) + if !hasMemberNamed(outer, "tbid", 0) { + t.Error("hasMemberNamed() = false, want true for a member nested in an anonymous union") + } +} + +// TestHasMemberNamed_AbsentField covers the negative case: a struct that +// resembles bpf_fib_lookup's older shape (no tbid anywhere, e.g. an +// anonymous union holding only VLAN fields) must report false, not +// panic or false-positive. +func TestHasMemberNamed_AbsentField(t *testing.T) { + innerUnion := fakeUnion("", + fakeMember("h_vlan_proto", fakeInt()), + fakeMember("h_vlan_TCI", fakeInt()), + ) + outer := fakeStruct("bpf_fib_lookup", + fakeMember("family", fakeInt()), + fakeMember("ifindex", fakeInt()), + fakeMember("", innerUnion), + ) + if hasMemberNamed(outer, "tbid", 0) { + t.Error("hasMemberNamed() = true, want false when tbid is genuinely absent") + } +} + +// TestHasMemberNamed_NonCompositeType covers a leaf type (not a struct or +// union) being passed in -- must return false, not panic. +func TestHasMemberNamed_NonCompositeType(t *testing.T) { + if hasMemberNamed(fakeInt(), "tbid", 0) { + t.Error("hasMemberNamed() = true for a non-composite type, want false") + } +} + +// TestHasMemberNamed_DepthLimitTerminates guards against unbounded +// recursion: a chain of nested anonymous structs deeper than +// maxMemberSearchDepth must terminate (returning false), not recurse +// forever or overflow the stack, even though this shape never occurs in a +// real bpf_fib_lookup. +func TestHasMemberNamed_DepthLimitTerminates(t *testing.T) { + // Build a chain of anonymous structs nested well past the depth + // limit, with the target field only at the very bottom. + var chain btf.Type = fakeStruct("bottom", fakeMember("tbid", fakeInt())) + for range maxMemberSearchDepth + 4 { + chain = fakeStruct("", fakeMember("", chain)) + } + if hasMemberNamed(chain, "tbid", 0) { + t.Error("hasMemberNamed() = true past the depth limit, want false") + } +} + +// fakeInt returns a minimal *btf.Int usable as a leaf member type in test +// fixtures -- its own fields are irrelevant to hasMemberNamed, which never +// inspects a non-struct/union type's contents. +func fakeInt() *btf.Int { + return &btf.Int{Name: "unsigned int", Size: 4} +} + +// fakeMember builds a btf.Member with the given name and type, matching +// the shape hasMemberNamed walks. +func fakeMember(name string, typ btf.Type) btf.Member { + return btf.Member{Name: name, Type: typ} +} + +// fakeStruct builds a *btf.Struct with the given name and members. +func fakeStruct(name string, members ...btf.Member) *btf.Struct { + return &btf.Struct{Name: name, Members: members} +} + +// fakeUnion builds a *btf.Union with the given name and members. +func fakeUnion(name string, members ...btf.Member) *btf.Union { + return &btf.Union{Name: name, Members: members} +} diff --git a/internal/plumbing/ebpf/prog/doc.go b/internal/plumbing/ebpf/prog/doc.go new file mode 100644 index 0000000..ca07d49 --- /dev/null +++ b/internal/plumbing/ebpf/prog/doc.go @@ -0,0 +1,55 @@ +// Copyright 2025 Datum Cloud, Inc. +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +// Package prog holds the compiled TC-BPF program that implements the +// `uFMT 48+16` uSID decode/forward datapath (design plan +// .local/plan-ebpf-xdp-usid-datapath.md §4.2/§4.4; Milestone 2.2 of +// .local/implementation-plan-ebpf-xdp-usid-datapath.md). +// +// usid.c is the single source of truth for the packet path; see its +// header comment for the full 9-step walkthrough. `go generate` (via +// bpf2go, github.com/cilium/ebpf's code generator) compiles it with clang +// into a CO-RE-portable BPF object and generates matching Go bindings +// (UsidObjects, LoadUsid, LoadUsidObjects, plus per-map/per-program +// fields) in this package -- run `go generate ./...` from the repo root, +// or `go generate` from this directory, after editing usid.c. The +// generated *_bpfel.go/*_bpfel.o (and *_bpfeb.go/*_bpfeb.o) files are +// committed alongside the source they're generated from, matching this +// repo's convention for other generated code (see CLAUDE.md: "Generated +// protobuf files ... are committed; never hand-edit them" -- the same +// rule applies here to bpf2go's output). +// +// Placement: sibling of internal/plumbing/ebpf/uformat (Milestone 2.1) +// under the shared internal/plumbing/ebpf/ umbrella -- uformat is the +// pure-Go bit-layout library with no kernel dependency; this package is +// the compiled BPF program itself. The two intentionally share the exact +// same key-composition arithmetic (locator_key = top 8 bytes of the +// address as-is; function_key = Block<<4|Function; vrf_key = +// Block<<12|Argument) so the kernel program and the Go control plane +// (Milestone 3.x, which will populate these maps) can never drift on bit +// positions -- see usid.c's map-key comment block for the details. +// +// This package does not itself load or attach the compiled program to any +// interface -- that is Milestone 3.1's job (extending galactic-cni's `run` +// subcommand). This package only builds the object and exposes typed Go +// handles to its maps and program, via bpf2go's generated loader +// functions, for that later milestone (and this milestone's own +// BPF_PROG_TEST_RUN-based tests) to use. +package prog + +// The -idirafter flags below work around a clang quirk specific to +// Debian/Ubuntu-style multiarch layouts (confirmed via containers/ +// galactic-cni/Dockerfile's real `docker build`, Milestone 5.2): with +// `-target bpfel`/`bpfeb`, clang's default header search list drops +// `/usr/include/` (present for the host GNU target, absent for +// the BPF virtual target), so ``'s own `` +// include goes unresolved even though `linux-libc-dev`/`libc6-dev` did +// install it -- just not somewhere the BPF target's search path looks. +// Listing both the amd64 and arm64 multiarch directories explicitly +// covers this repo's two supported architectures (TARGETARCH in that +// Dockerfile); -idirafter silently skips whichever one doesn't exist on +// the host, so this is harmless on non-Debian systems (Fedora, Alpine, +// macOS) that resolve these headers without any multiarch subdirectory. +// +//go:generate go run github.com/cilium/ebpf/cmd/bpf2go -cc clang -cflags "-O2 -g -Wall -idirafter /usr/include/x86_64-linux-gnu -idirafter /usr/include/aarch64-linux-gnu" -target bpfel,bpfeb -type locator_value -type function_value -type vrf_value Usid usid.c diff --git a/internal/plumbing/ebpf/prog/dropreason.go b/internal/plumbing/ebpf/prog/dropreason.go new file mode 100644 index 0000000..66ce62c --- /dev/null +++ b/internal/plumbing/ebpf/prog/dropreason.go @@ -0,0 +1,42 @@ +// Copyright 2026 Datum Cloud, Inc. +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package prog + +// Drop reason indices into the drop_reasons map (usid.c's `enum +// drop_reason`), exported for callers outside this package -- notably +// internal/plumbing/ebpf/metrics's Prometheus collector (Milestone 4 of +// .local/implementation-plan-ebpf-xdp-usid-datapath.md), which needs a +// stable, human-readable label per index. Hand-kept in sync with usid.c +// for the same reason usidmap's BehaviorEndDT46/BehaviorEndDT2 constants +// are (see usidmap/function.go's identical comment): bpf2go's -type flag +// cannot generate a Go type for a C enum that is only ever used as a +// literal constant, never as a typed variable/field the compiler retains +// distinct BTF for. prog/usid_test.go keeps its own unexported copy of +// these same values (predating this file) for the same reason -- if +// usid.c's enum drop_reason ever changes, update both. +const ( + DropReasonUnknownFunction uint32 = 0 + DropReasonUnknownArgument uint32 = 1 + DropReasonMalformedInner uint32 = 2 + DropReasonUnknownInnerVer uint32 = 3 + DropReasonStripFailed uint32 = 4 + DropReasonFibLookupFailed uint32 = 5 + DropReasonRedirectFailed uint32 = 6 + DropReasonCount uint32 = 7 +) + +// DropReasonNames maps each DropReason* index to a short, stable, +// metrics/log-friendly name, decoupling Prometheus label values (Milestone +// 4) and any other external representation from usid.c's C identifier +// spelling. +var DropReasonNames = map[uint32]string{ + DropReasonUnknownFunction: "unknown_function", + DropReasonUnknownArgument: "unknown_argument", + DropReasonMalformedInner: "malformed_inner", + DropReasonUnknownInnerVer: "unknown_inner_version", + DropReasonStripFailed: "strip_failed", + DropReasonFibLookupFailed: "fib_lookup_failed", + DropReasonRedirectFailed: "redirect_failed", +} diff --git a/internal/plumbing/ebpf/prog/usid.c b/internal/plumbing/ebpf/prog/usid.c new file mode 100644 index 0000000..36e6730 --- /dev/null +++ b/internal/plumbing/ebpf/prog/usid.c @@ -0,0 +1,547 @@ +//go:build ignore + +// Copyright 2025 Datum Cloud, Inc. +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +// usid.c implements the TC-BPF ingress datapath for the `uFMT 48+16` SRv6 +// uSID carrier format described in .local/plan-ebpf-xdp-usid-datapath.md +// (design plan) §4.2/§4.4, and sequenced as Milestone 2.2 of +// .local/implementation-plan-ebpf-xdp-usid-datapath.md. +// +// Packet path (design plan §4.2, steps 1-9; every lookup here is an exact +// hash match -- R1 forbids matching anything looser than a full /64, and +// none of the three lookup maps below are BPF_MAP_TYPE_LPM_TRIE, per the +// design plan's §4.4 map inventory): +// +// 1. Parse the outer Ethernet + IPv6 header (bounds-checked). Not IPv6, +// or too short to parse -- TC_ACT_OK (pass through unmodified, R6). +// 2. Exact-match the destination address's top 64 bits (uSID Block(48) + +// Node-ID(16), read with no shift) against locator_table. No match -- +// TC_ACT_OK (not one of this node's uSID Blocks, R6). +// 3. Read Function directly from the unmutated packet at its fixed +// offset (bits 65-68) -- no shift, no mutation (R2). +// 4. Exact-match (matched Block, Function) against function_table. No +// match -- drop, counted (DROP_REASON_UNKNOWN_FUNCTION): this packet +// was already claimed by step 2's locator match, so silent +// pass-through here would duplicate-deliver it to the normal stack. +// 5. Read Argument directly from the unmutated packet at its fixed +// offset (bits 69-80) -- no shift, no mutation (R2, R4). Argument +// 0x000 is reserved and never registered into vrf_table (R4, design +// plan §5.1), so it always misses step 6 -- no special-cased check +// needed here. +// 6. Exact-match (matched Block, Argument) against vrf_table. No match -- +// drop, counted (DROP_REASON_UNKNOWN_ARGUMENT). Per-Argument hit +// counters (packets, bytes, last_seen) are updated in vrf_table's +// value on every match that reaches this step, supporting R8's +// dual-key migration counters. +// 7. Strip the outer IPv6 header (bpf_skb_adjust_room, BPF_ADJ_ROOM_MAC), +// exposing the inner IPv4/IPv6 packet (dual-stack, uEnd.DT46 -- R5). +// 8. bpf_fib_lookup() against the resolved Linux VRF table id +// (vrf_table's value), using BPF_FIB_LOOKUP_DIRECT | BPF_FIB_LOOKUP_TBID +// so the lookup is scoped to that VRF's routing table exactly like the +// kernel's own SEG6_LOCAL_ACTION_END_DT46 does today (§4.3). +// 9. Redirect to the resolved egress interface: bpf_redirect_peer() for a +// veth attachment (the pod's host-side veth, whose container-side peer +// lives in a different netns -- design plan §4.1), or plain +// bpf_redirect() for a tap attachment (the tap device already sits in +// the same netns this program runs in -- internal/cni/tap never moves +// it -- so no netns-crossing redirect is needed or possible). Which one +// to use is read from vrf_table's own egress_kind field, set at +// registration time from the CNI's InterfaceType (Milestone 6.1's +// tap-mode redirect fix). +// +// This file intentionally has no dependency on libbpf's bpf_helpers.h / +// bpf_helper_defs.h: it declares only the handful of BPF helper functions +// it actually calls (using the enum bpf_func_id constants from the +// system's own , not hand-picked magic numbers), and defines +// its own minimal Ethernet/IPv4/IPv6 header structs rather than pulling in +// //. This keeps the build's +// only external dependency on the kernel-headers package's +// (present on any distro that ships BPF/BTF support at all), and keeps the +// datapath's exact wire-format assumptions visible in one file instead of +// spread across vendored third-party headers. +// +// Compiled with CO-RE (Compile Once - Run Everywhere) via BTF: clang is +// invoked with `-g`, which emits a .BTF section into the object alongside +// the program/map definitions below (all of which already use the +// BTF-defined-map convention -- `__uint`/`__type` inside an anonymous +// struct tagged SEC(".maps") -- rather than the legacy fixed +// `struct bpf_map_def`). This program does not read any unstable +// kernel-internal struct fields (no BPF_CORE_READ of `struct sk_buff` +// internals, etc.), so it needs no `vmlinux.h`: the packet fields it reads +// are all stable, wire-format bytes accessed via direct bounds-checked +// pointer arithmetic on skb->data/skb->data_end, not BTF relocations +// against a kernel struct layout. +// +// The BPF ELF "license" section below is a kernel-required +// self-declaration for the compiled bytecode (governs which helper +// functions the verifier allows), independent of this file's own +// AGPL-3.0-or-later SPDX header above: it says nothing about the licensing +// of the surrounding Go project, exactly as Cilium, Katran, and every +// other AGPL/Apache/BSD-licensed project embedding a BPF datapath declares +// a GPL-compatible license string here for the same reason. + +#include + +// __u8/__u16/__u32/__u64/__s16/__s32/__be16/__be32 all come transitively +// from -> -> ; no +// separate include or manual typedef needed. + +// --------------------------------------------------------------------- +// Minimal BTF-map-definition and section macros (the same idiom used by +// libbpf and every modern eBPF loader, including cilium/ebpf; reproduced +// here directly rather than vendored, since it is a few generic lines with +// no meaningful creative content of its own). +// --------------------------------------------------------------------- + +#define SEC(name) __attribute__((section(name), used)) +#define __uint(name, val) int (*name)[val] +#define __type(name, val) typeof(val) *name +#define USID_ALWAYS_INLINE inline __attribute__((always_inline)) + +// --------------------------------------------------------------------- +// BPF helper function declarations. Only the helpers this program calls +// are declared, using the enum bpf_func_id constants from the system's +// (BPF_FUNC_map_lookup_elem, etc.) rather than hardcoded +// helper IDs. +// --------------------------------------------------------------------- + +static void *(*bpf_map_lookup_elem)(void *map, const void *key) = (void *) BPF_FUNC_map_lookup_elem; + +static long (*bpf_skb_adjust_room)(struct __sk_buff *skb, __s32 len_diff, __u32 mode, + __u64 flags) = (void *) BPF_FUNC_skb_adjust_room; + +static long (*bpf_fib_lookup)(void *ctx, struct bpf_fib_lookup *params, __s32 plen, + __u32 flags) = (void *) BPF_FUNC_fib_lookup; + +// Step 9 calls one of these two, chosen per-entry via vrf_table's +// egress_kind field: bpf_redirect_peer for a veth attachment (crosses into +// the peer's netns, per design plan §4.1), bpf_redirect for a tap +// attachment (same-netns egress -- see its call site for why). +static long (*bpf_redirect_peer)(__u32 ifindex, __u64 flags) = (void *) BPF_FUNC_redirect_peer; +static long (*bpf_redirect)(__u32 ifindex, __u64 flags) = (void *) BPF_FUNC_redirect; + +static __u64 (*bpf_ktime_get_ns)(void) = (void *) BPF_FUNC_ktime_get_ns; + +// --------------------------------------------------------------------- +// TC verdicts (uapi/linux/pkt_cls.h) -- reproduced as plain constants to +// avoid pulling in that header's transitive netlink dependencies for three +// integers. +// --------------------------------------------------------------------- + +#define TC_ACT_OK 0 +#define TC_ACT_SHOT 2 +#define TC_ACT_REDIRECT 7 + +// --------------------------------------------------------------------- +// Address-family constants (uapi asm-generic/socket.h). These values are +// fixed kernel ABI and never change. +// --------------------------------------------------------------------- + +#define USID_AF_INET 2 +#define USID_AF_INET6 10 + +#define USID_ETH_P_IP 0x0800 +#define USID_ETH_P_IPV6 0x86DD + +// --------------------------------------------------------------------- +// Minimal, self-contained header structs. Byte-exact to the real wire +// formats; hand-rolled (rather than // +// ) so this file has exactly one external header dependency +// (, for the map/program/helper/fib-lookup definitions). +// --------------------------------------------------------------------- + +struct usid_ethhdr { + __u8 h_dest[6]; + __u8 h_source[6]; + __be16 h_proto; +} __attribute__((packed)); + +// struct usid_ip6hdr is deliberately NOT the kernel's bitfield-based +// struct ipv6hdr (whose version/traffic-class bitfield layout is +// endian-dependent) -- this program never reads version/traffic-class/flow +// label, so vtc_flow is left as an opaque 4-byte blob. +struct usid_ip6hdr { + __u8 vtc_flow[4]; + __be16 payload_len; + __u8 nexthdr; + __u8 hop_limit; + __u8 saddr[16]; + __u8 daddr[16]; +} __attribute__((packed)); + +struct usid_iphdr { + __u8 ver_ihl; + __u8 tos; + __be16 tot_len; + __be16 id; + __be16 frag_off; + __u8 ttl; + __u8 protocol; + __u16 check; + __u8 saddr[4]; + __u8 daddr[4]; +} __attribute__((packed)); + +// --------------------------------------------------------------------- +// Map value types (design plan §4.4). +// --------------------------------------------------------------------- + +// struct locator_value is locator_table's value: `{ generation }`. +// generation is a __u64, not __u32: userspace (Milestone 3.3's +// internal/plumbing/ebpf/usidmap) stamps it with a nanosecond-resolution +// CLOCK_MONOTONIC reading, which overflows a 32-bit field in a few +// seconds. This program never reads generation's contents itself (the +// locator_table lookup below only tests the returned pointer for a match, +// never dereferences a field of it), so widening this field has no effect +// on the packet path. +struct locator_value { + __u64 generation; +}; + +// struct function_value is function_table's value: `{ behavior_enum }`. +// BEHAVIOR_END_DT46 is the only behavior defined today (design plan R3); +// BEHAVIOR_END_DT2 is reserved for the future L2 uEnd.DT2 path and is not +// otherwise referenced by this program. +enum function_behavior { + BEHAVIOR_END_DT46 = 1, + BEHAVIOR_END_DT2 = 2, +}; + +struct function_value { + __u32 behavior; +}; + +// enum egress_kind indexes vrf_value's egress_kind field (Milestone 6.1's +// tap-mode redirect fix): which redirect helper step 9 must use for this +// entry's resolved egress interface. EGRESS_KIND_VETH (the zero value, so +// an entry registered before this field existed -- there are none, since +// nothing has shipped to production yet -- would default correctly) uses +// bpf_redirect_peer; EGRESS_KIND_TAP uses plain bpf_redirect, since a tap +// device never has a netns-crossing peer (internal/cni/tap creates it in +// the same netns this program runs in and never moves it). +enum egress_kind { + EGRESS_KIND_VETH = 0, + EGRESS_KIND_TAP = 1, +}; + +// struct vrf_value is vrf_table's value: `{ linux_vrf_table_id, packets, +// bytes, last_seen }` per the design plan's §4.4 map inventory, plus +// `generation` (design plan §4.2 step 6's earlier, value-shape description +// of this same map -- `{linux_vrf_table_id, generation, hit counter}` -- +// which §4.4's table narrowed to the counter fields alone; this field +// reconciles the two by carrying generation as an actual struct member, +// same as locator_value already does) and `egress_kind` (Milestone 6.1's +// tap-mode redirect fix, above). generation is written only by userspace +// (internal/plumbing/ebpf/usidmap, Milestone 3.3) at registration time -- +// this program never reads or writes it -- and lets the GC sweep +// (Milestone 7.3) distinguish "existed before this sweep's CRD-list +// snapshot was taken" from "registered after," so a Register call landing +// between the sweep's list-CRDs and delete-stale-entries steps is never +// reaped as stale (design plan §5.4's closing paragraph). egress_kind +// occupies what was previously an explicit alignment-only pad field +// between vrf_table_id and packets; generation is placed last so every +// pre-existing field keeps its original offset. +struct vrf_value { + __u32 vrf_table_id; + __u32 egress_kind; + __u64 packets; + __u64 bytes; + __u64 last_seen_ns; + __u64 generation; +}; + +// enum drop_reason indexes drop_reasons (design plan §4.4's fourth map, +// observability only). +enum drop_reason { + DROP_REASON_UNKNOWN_FUNCTION = 0, + DROP_REASON_UNKNOWN_ARGUMENT = 1, + DROP_REASON_MALFORMED_INNER = 2, + DROP_REASON_UNKNOWN_INNER_VERSION = 3, + DROP_REASON_STRIP_FAILED = 4, + DROP_REASON_FIB_LOOKUP_FAILED = 5, + DROP_REASON_REDIRECT_FAILED = 6, + __DROP_REASON_MAX, +}; + +// --------------------------------------------------------------------- +// Maps (design plan §4.4). All three lookup maps are BPF_MAP_TYPE_HASH -- +// no BPF_MAP_TYPE_LPM_TRIE anywhere in this program, since R1/R2 mean +// every lookup here is a fixed-width exact match, never a variable-length +// prefix match. +// +// Keys are plain u64 exact-match keys, matching +// internal/plumbing/ebpf/uformat's LocatorKey/FunctionKey composition +// (Milestone 2.1): +// locator_key = top 8 bytes of the destination address, as-is +// (Block(48) << 16 | Node-ID(16)). +// function_key = matched Block(48) << 4 | Function(4) (52 significant +// bits) -- Block and Function are never adjacent in the +// wire address (Node-ID sits between them), so this key +// is always composed from two independently-read values, +// never read as one contiguous span. +// vrf_key = matched Block(48) << 12 | Argument(12) (60 significant +// bits), the same composition pattern as function_key. +// --------------------------------------------------------------------- + +struct { + __uint(type, BPF_MAP_TYPE_HASH); + __uint(max_entries, 64); // R7: more than one concurrent uSID Block. + __type(key, __u64); + __type(value, struct locator_value); +} locator_table SEC(".maps"); + +struct { + __uint(type, BPF_MAP_TYPE_HASH); + __uint(max_entries, 128); // one entry per (active Block x defined Function). + __type(key, __u64); + __type(value, struct function_value); +} function_table SEC(".maps"); + +struct { + __uint(type, BPF_MAP_TYPE_HASH); + // Design plan §2: Option 2 caps each uSID Block at 4,095 usable + // Argument values; R8 needs up to 2x that per Block during a + // make-before-break migration. 8192 covers one Block's worst case + // with headroom; tune alongside R7 multi-Block sizing later. + __uint(max_entries, 8192); + __type(key, __u64); + __type(value, struct vrf_value); +} vrf_table SEC(".maps"); + +struct { + __uint(type, BPF_MAP_TYPE_PERCPU_ARRAY); + __uint(max_entries, __DROP_REASON_MAX); + __type(key, __u32); + __type(value, __u64); +} drop_reasons SEC(".maps"); + +// --------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------- + +static USID_ALWAYS_INLINE void count_drop(__u32 reason) +{ + __u64 *count = bpf_map_lookup_elem(&drop_reasons, &reason); + + if (count) + __sync_fetch_and_add(count, 1); +} + +// read_be64 composes an 8-byte big-endian (network order) buffer into a +// host-native __u64, byte by byte -- deliberately not a `*(__u64 *)p` +// cast, since p is not guaranteed to be 8-byte aligned (it points 38 +// bytes into the packet, right after a 14-byte Ethernet header) and some +// architectures reject unaligned wide loads at verification time. +static USID_ALWAYS_INLINE __u64 read_be64(const __u8 *p) +{ + return ((__u64) p[0] << 56) | ((__u64) p[1] << 48) | ((__u64) p[2] << 40) | + ((__u64) p[3] << 32) | ((__u64) p[4] << 24) | ((__u64) p[5] << 16) | + ((__u64) p[6] << 8) | (__u64) p[7]; +} + +// --------------------------------------------------------------------- +// Program +// --------------------------------------------------------------------- + +SEC("tc") +int usid_ingress(struct __sk_buff *skb) +{ + void *data = (void *) (long) skb->data; + void *data_end = (void *) (long) skb->data_end; + + // Step 1: parse the outer Ethernet + IPv6 header (fixed 40B, + // bounds-checked). Not a match -- TC_ACT_OK, pass through + // unmodified (R6). + struct usid_ethhdr *eth = data; + + if ((void *) (eth + 1) > data_end) + return TC_ACT_OK; + + if (eth->h_proto != __builtin_bswap16(USID_ETH_P_IPV6)) + return TC_ACT_OK; + + struct usid_ip6hdr *ip6 = (void *) (eth + 1); + + if ((void *) (ip6 + 1) > data_end) + return TC_ACT_OK; + + // Step 2: exact-match the destination address's top 64 bits + // (Block(48) + Node-ID(16), read with no shift) against + // locator_table. No match -- TC_ACT_OK, pass through (R6). + __u64 locator_key = read_be64(&ip6->daddr[0]); + + struct locator_value *loc = bpf_map_lookup_elem(&locator_table, &locator_key); + + if (!loc) + return TC_ACT_OK; + + __u64 block = locator_key >> 16; + + // Step 3: read Function directly from the unmutated packet at its + // fixed offset -- bits 65-68, the high nibble of daddr byte 8. No + // shift, no mutation (R2). + __u8 fn_arg_byte = ip6->daddr[8]; + __u8 function = fn_arg_byte >> 4; + + // Step 4: exact-match (matched Block, Function) against + // function_table. No match -- drop, counted: this packet was + // already claimed by step 2, so silent pass-through here would + // duplicate-deliver it to the normal stack. + __u64 function_key = (block << 4) | function; + + struct function_value *fn = bpf_map_lookup_elem(&function_table, &function_key); + + if (!fn) { + count_drop(DROP_REASON_UNKNOWN_FUNCTION); + return TC_ACT_SHOT; + } + + // Step 5: read Argument directly from the unmutated packet at its + // fixed offset -- bits 69-80, the low nibble of daddr byte 8 plus + // all of byte 9. No shift, no mutation (R2, R4). + __u16 argument = ((__u16) (fn_arg_byte & 0x0F) << 8) | ip6->daddr[9]; + + // Step 6: exact-match (matched Block, Argument) against vrf_table. + // Argument 0x000 is reserved and never registered (R4, design plan + // §5.1), so it always misses here -- no special-cased check. + __u64 vrf_key = (block << 12) | argument; + + struct vrf_value *vrf = bpf_map_lookup_elem(&vrf_table, &vrf_key); + + if (!vrf) { + count_drop(DROP_REASON_UNKNOWN_ARGUMENT); + return TC_ACT_SHOT; + } + + __sync_fetch_and_add(&vrf->packets, 1); + __sync_fetch_and_add(&vrf->bytes, skb->len); + vrf->last_seen_ns = bpf_ktime_get_ns(); + + __u32 vrf_table_id = vrf->vrf_table_id; + + // The packet is claimed past this point: any failure from here on + // is a drop, never a silent pass-through (the vrf_table hit already + // committed this packet to the datapath). + if ((void *) (ip6 + 1) + 1 > data_end) { + count_drop(DROP_REASON_MALFORMED_INNER); + return TC_ACT_SHOT; + } + + // Step 7: strip the outer IPv6 header, exposing the inner + // IPv4/IPv6 packet (dual-stack, per uEnd.DT46 -- R5). + if (bpf_skb_adjust_room(skb, -(__s32) sizeof(struct usid_ip6hdr), BPF_ADJ_ROOM_MAC, 0)) { + count_drop(DROP_REASON_STRIP_FAILED); + return TC_ACT_SHOT; + } + + // bpf_skb_adjust_room can change the underlying packet buffer: all + // previously derived data/data_end pointers are invalidated and + // must be re-read. + data = (void *) (long) skb->data; + data_end = (void *) (long) skb->data_end; + + struct usid_ethhdr *new_eth = data; + + if ((void *) (new_eth + 1) > data_end) { + count_drop(DROP_REASON_MALFORMED_INNER); + return TC_ACT_SHOT; + } + + __u8 *inner = (__u8 *) (new_eth + 1); + + if ((void *) (inner + 1) > data_end) { + count_drop(DROP_REASON_MALFORMED_INNER); + return TC_ACT_SHOT; + } + + __u8 inner_version = (*inner) >> 4; + + struct bpf_fib_lookup fib_params; + + __builtin_memset(&fib_params, 0, sizeof(fib_params)); + + if (inner_version == 6) { + struct usid_ip6hdr *inner6 = (void *) inner; + + if ((void *) (inner6 + 1) > data_end) { + count_drop(DROP_REASON_MALFORMED_INNER); + return TC_ACT_SHOT; + } + + fib_params.family = USID_AF_INET6; + __builtin_memcpy(fib_params.ipv6_src, inner6->saddr, sizeof(fib_params.ipv6_src)); + __builtin_memcpy(fib_params.ipv6_dst, inner6->daddr, sizeof(fib_params.ipv6_dst)); + new_eth->h_proto = __builtin_bswap16(USID_ETH_P_IPV6); + } else if (inner_version == 4) { + struct usid_iphdr *inner4 = (void *) inner; + + if ((void *) (inner4 + 1) > data_end) { + count_drop(DROP_REASON_MALFORMED_INNER); + return TC_ACT_SHOT; + } + + fib_params.family = USID_AF_INET; + __builtin_memcpy(&fib_params.ipv4_src, inner4->saddr, sizeof(fib_params.ipv4_src)); + __builtin_memcpy(&fib_params.ipv4_dst, inner4->daddr, sizeof(fib_params.ipv4_dst)); + new_eth->h_proto = __builtin_bswap16(USID_ETH_P_IP); + } else { + count_drop(DROP_REASON_UNKNOWN_INNER_VERSION); + return TC_ACT_SHOT; + } + + fib_params.ifindex = skb->ingress_ifindex; + fib_params.tbid = vrf_table_id; + + // Step 8: bpf_fib_lookup() against the resolved Linux VRF table id + // -- a normal FIB lookup scoped to that VRF, exactly like the + // kernel's stock End.DT46 does today, reached via a dynamic + // Argument-keyed lookup instead of a static per-/128 route (§4.3). + long fib_rc = bpf_fib_lookup(skb, &fib_params, sizeof(fib_params), + BPF_FIB_LOOKUP_DIRECT | BPF_FIB_LOOKUP_TBID); + + if (fib_rc != BPF_FIB_LKUP_RET_SUCCESS) { + count_drop(DROP_REASON_FIB_LOOKUP_FAILED); + return TC_ACT_SHOT; + } + + __builtin_memcpy(new_eth->h_dest, fib_params.dmac, sizeof(new_eth->h_dest)); + __builtin_memcpy(new_eth->h_source, fib_params.smac, sizeof(new_eth->h_source)); + + // Step 9: redirect to the resolved egress interface. A veth + // attachment's egress interface is the pod's host-side veth, whose + // container-side peer lives in a different netns, so + // bpf_redirect_peer is required to cross into it (§4.1). A tap + // attachment has no peer at all -- internal/cni/tap creates a plain + // netlink.Tuntap in this same netns and never moves it -- so plain + // bpf_redirect (same-netns egress) is required instead; + // bpf_redirect_peer against a tap ifindex always fails + // (DROP_REASON_REDIRECT_FAILED), which is exactly the tap-mode + // blackhole this per-entry egress_kind field (registered by + // internal/cni's registerEBPFDatapath from the CNI's own + // InterfaceType) fixes. + // + // Verification note: this branch is exercised by unit tests only up + // through egress_kind's control-plane wiring (internal/cni's + // TestEgressKindForInterfaceType) -- a real FIB-lookup-then-redirect + // success/failure by egress kind needs a live route/interface + // (a real net_device) that BPF_PROG_TEST_RUN cannot fabricate, so + // that part is a live-cluster (ContainerLab/e2e) concern, same as + // this file's other FIB-lookup tests already document. + long redirect_rc; + + if (vrf->egress_kind == EGRESS_KIND_TAP) + redirect_rc = bpf_redirect(fib_params.ifindex, 0); + else + redirect_rc = bpf_redirect_peer(fib_params.ifindex, 0); + + if (redirect_rc != TC_ACT_REDIRECT) { + count_drop(DROP_REASON_REDIRECT_FAILED); + return TC_ACT_SHOT; + } + + return redirect_rc; +} + +char __license[] SEC("license") = "Dual BSD/GPL"; diff --git a/internal/plumbing/ebpf/prog/usid_bpfeb.go b/internal/plumbing/ebpf/prog/usid_bpfeb.go new file mode 100644 index 0000000..d978e4d --- /dev/null +++ b/internal/plumbing/ebpf/prog/usid_bpfeb.go @@ -0,0 +1,174 @@ +// Code generated by bpf2go; DO NOT EDIT. +//go:build mips || mips64 || ppc64 || s390x + +package prog + +import ( + "bytes" + _ "embed" + "fmt" + "io" + "structs" + + "github.com/cilium/ebpf" +) + +type UsidFunctionValue struct { + _ structs.HostLayout + Behavior uint32 +} + +type UsidLocatorValue struct { + _ structs.HostLayout + Generation uint64 +} + +type UsidVrfValue struct { + _ structs.HostLayout + VrfTableId uint32 + EgressKind uint32 + Packets uint64 + Bytes uint64 + LastSeenNs uint64 + Generation uint64 +} + +// Names of all BPF objects in the ELF. +// +// Used for safe lookups in a Collection or CollectionSpec. +const ( + UsidMapDropReasons = "drop_reasons" + UsidMapFunctionTable = "function_table" + UsidMapLocatorTable = "locator_table" + UsidMapVrfTable = "vrf_table" + UsidProgUsidIngress = "usid_ingress" +) + +// LoadUsid returns the embedded CollectionSpec for Usid. +func LoadUsid() (*ebpf.CollectionSpec, error) { + reader := bytes.NewReader(_UsidBytes) + spec, err := ebpf.LoadCollectionSpecFromReader(reader) + if err != nil { + return nil, fmt.Errorf("can't load Usid: %w", err) + } + + return spec, err +} + +// LoadUsidObjects loads Usid and converts it into a struct. +// +// The following types are suitable as obj argument: +// +// *UsidObjects +// *UsidPrograms +// *UsidMaps +// +// See ebpf.CollectionSpec.LoadAndAssign documentation for details. +func LoadUsidObjects(obj any, opts *ebpf.CollectionOptions) error { + spec, err := LoadUsid() + if err != nil { + return err + } + + return spec.LoadAndAssign(obj, opts) +} + +// UsidSpecs contains maps and programs before they are loaded into the kernel. +// +// It can be passed ebpf.CollectionSpec.Assign. +type UsidSpecs struct { + UsidProgramSpecs + UsidMapSpecs + UsidVariableSpecs +} + +// UsidProgramSpecs contains programs before they are loaded into the kernel. +// +// It can be passed ebpf.CollectionSpec.Assign. +type UsidProgramSpecs struct { + UsidIngress *ebpf.ProgramSpec `ebpf:"usid_ingress"` +} + +// UsidMapSpecs contains maps before they are loaded into the kernel. +// +// It can be passed ebpf.CollectionSpec.Assign. +type UsidMapSpecs struct { + DropReasons *ebpf.MapSpec `ebpf:"drop_reasons"` + FunctionTable *ebpf.MapSpec `ebpf:"function_table"` + LocatorTable *ebpf.MapSpec `ebpf:"locator_table"` + VrfTable *ebpf.MapSpec `ebpf:"vrf_table"` +} + +// UsidVariableSpecs contains global variables before they are loaded into the kernel. +// +// It can be passed ebpf.CollectionSpec.Assign. +type UsidVariableSpecs struct { +} + +// UsidObjects contains all objects after they have been loaded into the kernel. +// +// It can be passed to LoadUsidObjects or ebpf.CollectionSpec.LoadAndAssign. +type UsidObjects struct { + UsidPrograms + UsidMaps + UsidVariables +} + +func (o *UsidObjects) Close() error { + return _UsidClose( + &o.UsidPrograms, + &o.UsidMaps, + ) +} + +// UsidMaps contains all maps after they have been loaded into the kernel. +// +// It can be passed to LoadUsidObjects or ebpf.CollectionSpec.LoadAndAssign. +type UsidMaps struct { + DropReasons *ebpf.Map `ebpf:"drop_reasons"` + FunctionTable *ebpf.Map `ebpf:"function_table"` + LocatorTable *ebpf.Map `ebpf:"locator_table"` + VrfTable *ebpf.Map `ebpf:"vrf_table"` +} + +func (m *UsidMaps) Close() error { + return _UsidClose( + m.DropReasons, + m.FunctionTable, + m.LocatorTable, + m.VrfTable, + ) +} + +// UsidVariables contains all global variables after they have been loaded into the kernel. +// +// It can be passed to LoadUsidObjects or ebpf.CollectionSpec.LoadAndAssign. +type UsidVariables struct { +} + +// UsidPrograms contains all programs after they have been loaded into the kernel. +// +// It can be passed to LoadUsidObjects or ebpf.CollectionSpec.LoadAndAssign. +type UsidPrograms struct { + UsidIngress *ebpf.Program `ebpf:"usid_ingress"` +} + +func (p *UsidPrograms) Close() error { + return _UsidClose( + p.UsidIngress, + ) +} + +func _UsidClose(closers ...io.Closer) error { + for _, closer := range closers { + if err := closer.Close(); err != nil { + return err + } + } + return nil +} + +// Do not access this directly. +// +//go:embed usid_bpfeb.o +var _UsidBytes []byte diff --git a/internal/plumbing/ebpf/prog/usid_bpfeb.o b/internal/plumbing/ebpf/prog/usid_bpfeb.o new file mode 100644 index 0000000000000000000000000000000000000000..da3878391409c92bffeb6b84491b904f889c6a67 GIT binary patch literal 13600 zcmc&(Z){x0ao;2FA1TUa{>Y(~v|ib=bd)LjDAAORn(EUZN>;6e2Z^c_H^IB(-6MJ7 z@$PtcluXM(FKJ@PMG~-$11PA0&~DlaMO(0+B%nccXwydh#-{$@)=t6?De9m`L8MKq z4@TMF?9Sfptw_013iN^R?fhnEXJ=+-=j}cI=Gd8&9UURjyh7r?KwFGv4~U^Pg*<+4 z7oi)96e9Blks>nlJK_wrxBBTRL`U2apSG8})b(ZEgl;k4#CJz;=y~c| z{G8TvIjsbH=NH8wQMW#(u(+(pg>R|oH2n^b@6!EO-q?6o$+z3_?pAi37ov;gZ?HYt z*Eg0o8Na;M_#Gm2jN~IC^sCfjot~ujLYbc8!D-#b$LAFm(`nWAj9=3D${%g45#_{f zZL9firAZojlos98-bm9^gtCA5bK9-f`|YS2Pxh;P;W&ie&WJemEbi3#nE8;F17Ago z*D-P>E&8Y@wxs$GjlR+r&yM?ur_u*|c96!*%Svz4jy@>_^6D+kh5t!Kg>HD7!*(6O zV0&G|_hCnjG{TNBwaqx};yN(Tb>I;7!hdbY1EY_%&3hmIi#wz*BE-+Ay>*J7(C?|{ z!O`8nu>SW*6ZHRt+WCX@L>xWUh%4XLu|>N|9JIH&ZE$;&+ZArFbGuCKWgUm`j-;x4 z;qytAAJJRwaT-CKewpMs-zZM+6L$8&`&;b;tnWXNJp530I{F`l{tET~0{Xwl`oC-G z>AVj+|C06mh0-JGDY~m~=>0l>NPL1A(>iYP`Mu(o2#+72C)Veb_&BxG9z8`b`gan+ zb-izVH=)-11@dbDuzVc)A6EUxm6({nAXFutPAS}r{)dR-`TNG7(sJX&x@Eq!>`^l| z_3ssrljOLng0T03P;a`X{=MQ+>TlYsq>)#@zpw;Y{NZ`}#wQeCjGxmj^X2$ijj#N{hRR#aqdc?Xtvt5;Q#*qFqj;;8Xxe{wyp_ex zcpMT#G~;+H-s*kOjK?AUKKnW1t>3?%@`Lh2zjs^l*6(ezo_j_6`@I!!wU14{APx!A z*o?y|q3XXCZ++g-^HRL`D0w~4|0doIlD{G8`Gc1uLg%5zqNADrdpZA=9L`IJIRE*) z1g!I4=S45_K12PLc$8-AJ#_r$jAl>Rlm36t#fv?J9SbLqf-u-3g+;r=jgy1v53eFXIHvO&hid)Z6o zc(`4ADZ?7Lzs2@`6tr!xdavJYuY_3BUOEf>8H}KHN#Le;qOtdHn3ib&9=D0{KQdiM z`y-|sXgy79&!E%z=EnaamUaE1+-u-|kNu&#Zu_IoL3jJ3Y&Y>&HGC5pRV#Rnlz)zW zpTJynrEpW-_i&Sa5_cVU8aL@^;QmjxZx?9WzTJSG(EF&C3(@a zG}%jZ8Z^~sqU)eXm|g~boaqK=+N&h92Ks`g$%IgvX$iW_vg%AJq#TxPf6)!d@LtM!4NbU-$7@=1o7zV(9JM4mdq$1&8IGwc?!}V z6woyQdW;m}V!f!enud4>omd}3djU>TKk}ky-N>U=YA6_d(Rq^Wgo>V@ac(AoA= zf9~}2WB$aaQ)9jl-onVRSSVKW)5Rbs3#H<;dWb?Po2iv5{-sP|ArR9+F{or}`BG8T z7R!N{%e>$R#abmFRK;wtD72IrQ_D;i0x`8v%+d%}F&WHcF6B!Vn)2X*)|6yfQ&f+< zRGH#IL@A!1&xv5V5>%`HY`&NiJiU?K45GUalnVea*;DikVDxMu3rpwN<#2%hWRJfk@^=F;mWy zlT(?wd|^>kg1J&H@bl$iwGstIHJKw#=7^Cjmnt>pDw4GOa{;EPRco2KvbdbD1U|Vw zT`eJ2(^XC6Ukq46$7AL)%TEt)+eHh;_x-OOSIk`_6izvuZnb4emnV?+kmk`^3#Pt7)J>Xu# z5X?`>L`zvvn~_h*p?-Np8fBQBWYDw!$PAVl3o3Vw!o_XWO3c*}rkC9YcctAEWrXqb5 z{#}qkK(#580JBhL#*s-!j>w@AZR}w=RrE8JX`gmI#4H03vp*2wvV(qG8fu1{E*lxj zZ^$L|yjVXKTGQL|*l=`JlKZBL7`3U#O-bW&lVSTjRqU5njMMwFr3Gx4Efm@{3=!1T z%mh!3)X^Ink%-K~97+{=M2Sfmk+5U1Mm=22>Y|S$`_1WoP_MG3O)k63W2=>|3>Tn+TA;{YUt1 z@WF!Wbuple3Th##RSN7E2zxPG%g+V=bWp=PC1K2M6%@oy7Zg+#yQt8*QeIPbpmxEF z&n@8PR4J9_62Jp;KbEFSTf2(VTbeSJQCG|I$V{%%KOm2#PWner{F;AueEgICCyyQ< zkjZ`(CR4mz_>6 zr#L9SW)qqcI~UAVgIZ$GRDRMgXDXSwDm=yd=+Lyd{bm6i)uR$Qj)*&+C@EeatKe@X zIlQSWU9IT{^;;RPsCU89lm6*XjZIKhLfTrsWXp>QYgE2`X~eHqvI9CIsHRn_rknUT zXhfBxAFMQ2t+kY@61}BTh-#&lVVkd{<-tVkB^-@$A#ZX8s2(z5Xl9!wwA@YxePLhw}d08;(J#L zT0+&*LM0ot>8oy`Pm3yzR2NiTw2s_zbX1O=Itv&4=T3j>gga+Uy0!s_ydsZl+ad?M_ya`<)kMWp@yaW3}GmO6>B0r7-ml-dM$U5et z?>l5qm@vL!h3bs|N<^Vkgsw9FXAzB)zH1ss;vy;uU$-#wCA4B;vgbDAL--s_@@tG= z5z!><>3|H)AAx;+_{P=2{zbkoB9i)(-2X!nO+5|zu!WJ29iD~pjiKXN3m*cV(m49K zh~|*59TzQ3bEPdzws1V6DG@E;I~(?evM>6oh}OuShQ>{M-(qa+U1RyzMPxTVv2@&F z{MRCS72js)s|)!XeNRNcg8b^l8Z$*SgiC7=}4=-65-vv#B$i5(KO?WA~u8Mig7%;{#3*Yz+Eig z-63MHLcWXTyZ4CLHR@;kx?d8p*MPe@KHc9Jv74v|-G{aQ?*A6C@1S0FvpuRG`np;F zwns$l&0gTLmfw~Yu{C__>%OM3@uz!*F|Sv5gXO;_V*i2l*(MpkBVu<5^Z1@Y5xc=+L*Yi~o&kn#!)lgV2Qq1tDnBPt5|Wr@En|S zaN5CT2iG0EaMHn^gHsMpJGkuNx`US- zyzJl=2R9tN>fkj8ueW2AmNtJodL1ksoOH0~;FN>Y4lX;m?%*W{FFSa}!3_tmI(W^& z>+M*TBWwNX9AdA3r*v@A!JdOt4o*9`?BKeCmmIw8;1vfq9K7n_H3zS^W0m%n|8%ag z{ikzG8%{ddb8yPRX$O}bTzBx2gO?q=;^2mZR~@|O;PrN_UOSfmkzS`?IymWI&%r4N zryX2&aNWU64qkTfih~;tUUl%AgV)=!dM#T1M|+)q>ENV;JqM>8oOW>8!F2~OIe6K@ zD-Lcrc-6sc4qk7^^lG#7JJ#!9>ENV;JqM>8oOW>8;GXRd^nU0;F<1*;sENT!P#8Qm zadMD={ogqL9%BD%K@1iOm*(i3J3iV@2IDofJ-CgZW<2H5F5G5*(+?J1Jy&%r>fD;fj3;~o_ZH)~8#zA5n&C}( zVtmaS-xScq_CDQyB8X#zlku&r6 F{SUZFr3C;0 literal 0 HcmV?d00001 diff --git a/internal/plumbing/ebpf/prog/usid_bpfel.go b/internal/plumbing/ebpf/prog/usid_bpfel.go new file mode 100644 index 0000000..38358c0 --- /dev/null +++ b/internal/plumbing/ebpf/prog/usid_bpfel.go @@ -0,0 +1,174 @@ +// Code generated by bpf2go; DO NOT EDIT. +//go:build 386 || amd64 || arm || arm64 || loong64 || mips64le || mipsle || ppc64le || riscv64 || wasm + +package prog + +import ( + "bytes" + _ "embed" + "fmt" + "io" + "structs" + + "github.com/cilium/ebpf" +) + +type UsidFunctionValue struct { + _ structs.HostLayout + Behavior uint32 +} + +type UsidLocatorValue struct { + _ structs.HostLayout + Generation uint64 +} + +type UsidVrfValue struct { + _ structs.HostLayout + VrfTableId uint32 + EgressKind uint32 + Packets uint64 + Bytes uint64 + LastSeenNs uint64 + Generation uint64 +} + +// Names of all BPF objects in the ELF. +// +// Used for safe lookups in a Collection or CollectionSpec. +const ( + UsidMapDropReasons = "drop_reasons" + UsidMapFunctionTable = "function_table" + UsidMapLocatorTable = "locator_table" + UsidMapVrfTable = "vrf_table" + UsidProgUsidIngress = "usid_ingress" +) + +// LoadUsid returns the embedded CollectionSpec for Usid. +func LoadUsid() (*ebpf.CollectionSpec, error) { + reader := bytes.NewReader(_UsidBytes) + spec, err := ebpf.LoadCollectionSpecFromReader(reader) + if err != nil { + return nil, fmt.Errorf("can't load Usid: %w", err) + } + + return spec, err +} + +// LoadUsidObjects loads Usid and converts it into a struct. +// +// The following types are suitable as obj argument: +// +// *UsidObjects +// *UsidPrograms +// *UsidMaps +// +// See ebpf.CollectionSpec.LoadAndAssign documentation for details. +func LoadUsidObjects(obj any, opts *ebpf.CollectionOptions) error { + spec, err := LoadUsid() + if err != nil { + return err + } + + return spec.LoadAndAssign(obj, opts) +} + +// UsidSpecs contains maps and programs before they are loaded into the kernel. +// +// It can be passed ebpf.CollectionSpec.Assign. +type UsidSpecs struct { + UsidProgramSpecs + UsidMapSpecs + UsidVariableSpecs +} + +// UsidProgramSpecs contains programs before they are loaded into the kernel. +// +// It can be passed ebpf.CollectionSpec.Assign. +type UsidProgramSpecs struct { + UsidIngress *ebpf.ProgramSpec `ebpf:"usid_ingress"` +} + +// UsidMapSpecs contains maps before they are loaded into the kernel. +// +// It can be passed ebpf.CollectionSpec.Assign. +type UsidMapSpecs struct { + DropReasons *ebpf.MapSpec `ebpf:"drop_reasons"` + FunctionTable *ebpf.MapSpec `ebpf:"function_table"` + LocatorTable *ebpf.MapSpec `ebpf:"locator_table"` + VrfTable *ebpf.MapSpec `ebpf:"vrf_table"` +} + +// UsidVariableSpecs contains global variables before they are loaded into the kernel. +// +// It can be passed ebpf.CollectionSpec.Assign. +type UsidVariableSpecs struct { +} + +// UsidObjects contains all objects after they have been loaded into the kernel. +// +// It can be passed to LoadUsidObjects or ebpf.CollectionSpec.LoadAndAssign. +type UsidObjects struct { + UsidPrograms + UsidMaps + UsidVariables +} + +func (o *UsidObjects) Close() error { + return _UsidClose( + &o.UsidPrograms, + &o.UsidMaps, + ) +} + +// UsidMaps contains all maps after they have been loaded into the kernel. +// +// It can be passed to LoadUsidObjects or ebpf.CollectionSpec.LoadAndAssign. +type UsidMaps struct { + DropReasons *ebpf.Map `ebpf:"drop_reasons"` + FunctionTable *ebpf.Map `ebpf:"function_table"` + LocatorTable *ebpf.Map `ebpf:"locator_table"` + VrfTable *ebpf.Map `ebpf:"vrf_table"` +} + +func (m *UsidMaps) Close() error { + return _UsidClose( + m.DropReasons, + m.FunctionTable, + m.LocatorTable, + m.VrfTable, + ) +} + +// UsidVariables contains all global variables after they have been loaded into the kernel. +// +// It can be passed to LoadUsidObjects or ebpf.CollectionSpec.LoadAndAssign. +type UsidVariables struct { +} + +// UsidPrograms contains all programs after they have been loaded into the kernel. +// +// It can be passed to LoadUsidObjects or ebpf.CollectionSpec.LoadAndAssign. +type UsidPrograms struct { + UsidIngress *ebpf.Program `ebpf:"usid_ingress"` +} + +func (p *UsidPrograms) Close() error { + return _UsidClose( + p.UsidIngress, + ) +} + +func _UsidClose(closers ...io.Closer) error { + for _, closer := range closers { + if err := closer.Close(); err != nil { + return err + } + } + return nil +} + +// Do not access this directly. +// +//go:embed usid_bpfel.o +var _UsidBytes []byte diff --git a/internal/plumbing/ebpf/prog/usid_bpfel.o b/internal/plumbing/ebpf/prog/usid_bpfel.o new file mode 100644 index 0000000000000000000000000000000000000000..3dbae1c4215e9de3b469b59d5cf49c1f176c8723 GIT binary patch literal 13600 zcmd5?U2I&(bsmvgl8P;bQbduGE%{23rCpk$DT$($)>KzNR4khb7ZOz~Zi2hZ-Ai)A z<^J8fqG&qk)=df}NCL*OQ57{1Di4KFv<3Sl0S&4}n>OkjllsA}-DDqH)Ip7cNSjs< zLe=lgoVhzYBIT%l>439yzB6aeoO5R8+}XSQ?UB*5EgLonT{ejS77e7iLevj8=(r@~ zVlyOfGCS3ikW^~tsBCY)p*q@IgxCV|(Ce>DwWT*U%Z@4VJ3y7b`?$noM`R-|wtQv7 z@BNby=J+9W9eVv$IeuZ+wfiJldgFi`Kzfc7{?0*#AvFMMY_Einiz+_kh-&a-A>V1@ z_lEqQCjK)azq5(o7xKxUhWW_dV#h_XNe;Zp z_M~?$JurUhA>+HmDLFDCenYmU?>sB@z1%LuCqYf2m2vA<_D#Vr+7V+@3NO4`e_+1) zL*~1U@l-CheL}W3fobCOmmk&cXQaLFC8Zh>5iyA=4n(}SgRB-}{<_+asjU)%PphKV zF@koXjdqa`?Fi#@Xos&g;)$gZj~-98V(T9Km-9!&M%jjO*p`4cAyVC(2Yd-dJM5r5Fnr|W`hQm{jEG;#_IAMf z;RNlc;Zq-1|A(yq=dvvwRr+=O7G8W;#&+Sw6>jfvTjTZ?x69n#;8yLwImChD-PO8T zwijD_1Z~;dMw}?$4IkMfC#L2Xem-_r&5|0fkxl-jx)sMp~_4&IM#{5#>1tm9jTD4@C1F~Vy zQ?i5XQTD2J9NVwtOBIqv-qZTadllC9o|jN8{F>T7+WwNS91i0U?304pj#8i|2;)$B zTIt0+((VzZckGyIX?_Rfm=Y5Uuc&=n@>PBg=P9e_OT)hbsUV!EtS$!Y$6FnsFdjZ- zAIuxZTM=PAd}Scb|EtCOU}%qu_W`9pjCWM^!(X*OVhTSHZ*`6v@#g0}?2vi`buVbd zo1gnwXQ{WMp7-R(uNH5WFK)sPy6;4U+P8%9*VzAM-mhRvJM?~4VH72w_bcp_b_Bd% zVVAUn0Xr|&AdkU33_hDF`n`+W%RG1j-Z;)Erk02kZ;ufjR>_~+3P=*dDZX6L@0EB zlJ<9#b|JFY-{*cfg3{=RfhgJQ|H%Ck8vRft`_=Qp?^gGb#`wo%f9m=>a{TRW63Ted zI{F963OpAKKCP#JP(9!LZgtUV=)*+{C3RiJGc}?7x!BSw?<=&gs8Ad!qekQ|LwpVX zAA)b*J4s{LhQ|c8H{lM=_~`jvO+e!*BJYuNC^u25=Pd&N56YQJD(9TKaOT<1CgjEC zd;hjUo*yzT3dgDLS6i4^h^q338j7?NLLoN>`9^&5S?#1y4jfrp)Cx+vX-r)8(`O6q z0+ks=2d(^5#D!Yo$B9y=lmW(MJo#`Oi?XE*qn0Z=q^?|AuJ??T=A) zjQ@e@D%zhhT|*mZS{t#RKXjWRS=%2v>owF5QBk(gEyeUlWqH}xQ%om8A7Z)+dWh*I(5IQMfgWYL4*F%L$%rJ=4%#BqakN*MrvBeSeH!Dc zXuryQr$vabGaU#0CeumKf53DV^!J%w0{zEK*FgU{({<24V45QTx1bMTE(i3FnO*|@ z9?Lig{=YIE2mK4Clc1>_M6H6p2R`k)8t7KcA>)Yy{5aDN9cC1|;lx3AF~16W4`{m4 zEP?I?E%mTG*swtQGs=93{dLJ=S9MlK~K!+TwZoF!a%}S9xQcoDP$mRp#x+l+{Klugs z!ns#Q-0?3YMqC%X%Fv*wxG=-ktF0QpLkV(Tf+(opDFTUvLxd zxx{Y{#W1k!1(k9h6Ug%ubBez;@FnN@=bd9iG3PateOR&wk6Y{`iah|f*yN~UC_5^< zj~=l`4NCTrL5qD%vJcbLYbhI&-O7T{+~>Z85J}|dQ;W4mNO4>;?u0iq7(I|J4pHbc znKHbKL7o>;50Ho6=>^2NiQ*(3AU9Vi%v6f5m-A+$2ehO-!hKGNF%{{f@b85T0;)`z z0GNR?J&sH|al$z~q>Mf8Oy=EGdCH|z4>7~Q4jHD;llhplpq=hb7b-X|>nJpH7$T^$nGT*DsiJpy z$U$T(vq)9s5p7J`5m7S+2b_T`17~BjQRQ|p;`f=xexUqm$K|lxih6=ZV8Tr8^nzT^ zhcF|<0oJZP*b6LUfgRw_=hN<_7o?}%R6gTk<3*)|eK@yr>DCY+eTPo))8L~8<>R7H zB^BgCQY#gh4-ocZCdkfu?vxkcnG)6JHWCVArxFUXikVbsU1?v_?m+H>7hkU6;Z!aZ zW~0D;&LJ#~PeMcy_%&4+uacDYIj`caG5@+3$XI^yAkBxoK{oKjZea=8khDj$c zTH}w%lqC8vG5Fq`BlF&zyH=jmE;T01=5ZZ(AfNJ^@%SebdL;+RS06&1 zVrRWs-wUD#CbJW6F;z~@`tTI%qe9c*#`FSMs)rryaYWqmL`w1c7zuwp$-y;U+0>Z6 zUpc~qAsYt`cLpEsjuc(Tod>iw{DIbo`7`Khe++l{wuR-9 zw*~7m2EPp1U;-D8VTaO?zF0y)-g`-^te<5B)U!+-Tf^ivLLKWGRjor$nVj$_p|t8dq*-c-R^F?0LB0zI5*MXWa3V2|Rgl6UyLXnMQ`wk<*-}lc4U! zl*VYAX^{s9xkb+_uRoFuGY2EkVZ+_G@p8h_Us--&%d_T0hveILSJ#{U7l=L6WwnEutH=N{}c@NVEsj6?evYx}B< z;~2lAr(G->_zB=!*7y}`e4X($&EMB9RvCW_xaIkFF@SwS_EW!u{^N{)2)zAFyLf@| z&uDz2U0gEoZeXOyru}-2v9=#6Pxf!Z4-Y+6*lXb3z;$cvdG4)f< zF)`&36VsVDF`Z!(S2+Y^50P~X)0v>6`)N;^nAX(9c+AN{(~v26tc93(_Y6#`J6J#U zQI}(294FC-N`?B#hBzt}jS~unIBDUcg{u}`wD6LJmn~eg@QQ`&7GAZmbOJnWKixT~ zXzW;cz`}70CoG(_aM8k53olxD$->JPu331+!gUL;T3GP@)&6!`*s(C(LCpBXElhU| zvp;F!qJ^s#UbOI%g_kW{v+#lR+Muuesx|9oCGb}USH4AZ~3g%cJ|TA1!2=J=|G z7cIPG;bjZgEWBdjx`kIQEH^p~(*AZ5VPMC?0~U^3IAP(Wg^L!hT6odIOBP;>A!^sEF8CR!oo=l7cE@1@S=s6EWB*tnuS*^T(|J5g#|s-P(uGL>{xif z!f^}ZTHK^BY2l)UAK$#CbL%HXf8bpSM1R@K^`9C)+fTs!HICnhm_IFu{#@?zEWLBb zOWP^@-lt!d=$~Tb%d)k8s8t;_7yST;pNPOfJ3lL1HbHsu@jqXM3Qjpq%FgnS;NNZH zsxxJ>Xb)25WedCFA=OOq@eY}DpdL0}-#_&L`&R3t178b7Tnlhpiz=edn66#qryk$Q z{Jm&teme$N`JPr~Io;^-8lM5N*7y#-!0LOfmM2-Wd>g>#QGX8)%+Nnj8O4s@H_*41 z|4T77D8XaN9opL3e(gWLa;!Q3YYsY4s(c<4Qs+?9)Kd3a^RMdpc|6Tea`ya#;I)q4 mFQBQSh9%DX&)!#dtX{_mzA}VkB@tDy##|ala(ezA=Kn8jUz#ld literal 0 HcmV?d00001 diff --git a/internal/plumbing/ebpf/prog/usid_test.go b/internal/plumbing/ebpf/prog/usid_test.go new file mode 100644 index 0000000..d102c05 --- /dev/null +++ b/internal/plumbing/ebpf/prog/usid_test.go @@ -0,0 +1,658 @@ +// Copyright 2025 Datum Cloud, Inc. +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package prog + +import ( + "errors" + "net/netip" + "os" + "testing" + + "github.com/cilium/ebpf" + "github.com/cilium/ebpf/rlimit" + + "go.datum.net/galactic/internal/plumbing/ebpf/uformat" +) + +// drop_reasons map indices. These are not generated from usid.c's +// `enum drop_reason` (bpf2go's -type flag can't produce a Go type for an +// enum whose values are only ever used as literal constants, never as a +// typed variable/field the compiler retains in BTF -- see the comment on +// the go:generate line in doc.go) so they are hand-kept in sync with +// usid.c instead. If usid.c's enum drop_reason ever changes, update these +// too. +const ( + dropReasonUnknownFunction = 0 + dropReasonUnknownArgument = 1 + dropReasonMalformedInner = 2 + dropReasonUnknownInnerVer = 3 + dropReasonStripFailed = 4 + dropReasonFibLookupFailed = 5 + dropReasonRedirectFailed = 6 + dropReasonCount = 7 +) + +const ( + tcActOK = 0 + tcActShot = 2 + tcActRedirect = 7 +) + +func requireRoot(t *testing.T) { + t.Helper() + if os.Geteuid() != 0 { + t.Skip("test requires root (CAP_BPF/CAP_NET_ADMIN) to load BPF programs and maps; re-run via sudo") + } + if err := rlimit.RemoveMemlock(); err != nil { + t.Fatalf("rlimit.RemoveMemlock: %v", err) + } +} + +// loadObjects loads a fresh copy of the compiled program and its maps into +// the kernel, returning a cleanup-registered *UsidObjects. +func loadObjects(t *testing.T) *UsidObjects { + t.Helper() + + var objs UsidObjects + if err := LoadUsidObjects(&objs, nil); err != nil { + var ve *ebpf.VerifierError + if errors.As(err, &ve) { + t.Fatalf("load objects: verifier rejected program:\n%+v", ve) + } + t.Fatalf("load objects: %v", err) + } + t.Cleanup(func() { + if err := objs.Close(); err != nil { + t.Errorf("close objects: %v", err) + } + }) + return &objs +} + +// testUSID is one synthetic uFMT 48+16 address used across the table +// below. Building it through uformat.Encode (Milestone 2.1) rather than +// hand-packing bytes cross-validates that this program's key-composition +// arithmetic (locator_key/function_key/vrf_key -- see usid.c's map-key +// comment block) agrees with uformat's Go-side field layout. +type testUSID struct { + block uint64 + nodeID uint16 + function uint8 + argument uint16 +} + +func (u testUSID) addr(t *testing.T) netip.Addr { + t.Helper() + addr, err := uformat.Encode(uformat.Fields{ + Block: u.block, NodeID: u.nodeID, Function: u.function, Argument: u.argument, + }) + if err != nil { + t.Fatalf("uformat.Encode(%+v): %v", u, err) + } + return addr +} + +func (u testUSID) locatorKey(t *testing.T) uint64 { + t.Helper() + key, err := uformat.LocatorKeyFromAddr(u.addr(t)) + if err != nil { + t.Fatalf("LocatorKeyFromAddr: %v", err) + } + return uint64(key) +} + +func (u testUSID) functionKey(t *testing.T) uint64 { + t.Helper() + key, err := uformat.NewFunctionKey(u.block, u.function) + if err != nil { + t.Fatalf("NewFunctionKey: %v", err) + } + return uint64(key) +} + +// vrfKey mirrors usid.c's `(block << 12) | argument` composition, via +// uformat.NewVRFKey (Milestone 3.3) -- cross-validating that this +// program's vrf_key arithmetic and uformat's Go-side key composition agree, +// the same way testUSID.addr already does for the address encoding itself. +func (u testUSID) vrfKey() uint64 { + key, err := uformat.NewVRFKey(u.block, u.argument) + if err != nil { + panic(err) // test-table values are always in-range; a panic here means the table itself is broken + } + return uint64(key) +} + +const ethHeaderLen = 14 +const ip6HeaderLen = 40 + +// innerKind selects what buildPacketWithInner appends after the outer +// IPv6 header, covering every branch of usid.c's step 7 inner-header +// parse (§4.2): a well-formed IPv6 or IPv4 inner packet, an inner header +// whose version nibble matches neither (unknownInnerVersion), or one +// that's present but too short to read even its first byte +// (malformedInner) -- exercising DROP_REASON_UNKNOWN_INNER_VERSION and +// DROP_REASON_MALFORMED_INNER respectively, alongside the existing +// innerNone/innerV6 coverage. +type innerKind int + +const ( + innerNone innerKind = iota + innerV6 + innerV4 + innerUnknownVersion + innerMalformedTruncated +) + +// buildPacket constructs an Ethernet+IPv6 frame whose destination address +// is dst. If withInnerV6 is true, a minimal (header-only, no payload) +// inner IPv6 packet is appended after the outer header, so a program path +// that reaches step 7 (strip) has something well-formed to decapsulate +// into. Thin wrapper over buildPacketWithInner for the two cases every +// existing test needs; new tests exercising the other inner-header +// branches call buildPacketWithInner directly. +func buildPacket(t *testing.T, dst, src netip.Addr, withInnerV6 bool) []byte { + t.Helper() + if withInnerV6 { + return buildPacketWithInner(t, dst, src, innerV6) + } + return buildPacketWithInner(t, dst, src, innerNone) +} + +// buildPacketWithInner is buildPacket's fuller sibling, selecting the +// inner packet (or lack of one) via kind. See innerKind's doc comment for +// which usid.c branch each value exercises. +func buildPacketWithInner(t *testing.T, dst, src netip.Addr, kind innerKind) []byte { + t.Helper() + + pkt := make([]byte, 0, ethHeaderLen+ip6HeaderLen+ip6HeaderLen) + + // Ethernet header: arbitrary src/dst MACs, ethertype IPv6. + pkt = append(pkt, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA) // h_dest + pkt = append(pkt, 0xBB, 0xBB, 0xBB, 0xBB, 0xBB, 0xBB) // h_source + pkt = append(pkt, 0x86, 0xDD) // h_proto = ETH_P_IPV6 + + // Outer IPv6 header. + pkt = append(pkt, 0x60, 0x00, 0x00, 0x00) // version=6, traffic class/flow label = 0 + pkt = append(pkt, 0x00, 0x00) // payload_len (unchecked by usid_ingress) + pkt = append(pkt, 41) // nexthdr = IPv6-in-IPv6 (unchecked) + pkt = append(pkt, 64) // hop_limit + srcBytes := src.As16() + pkt = append(pkt, srcBytes[:]...) + dstBytes := dst.As16() + pkt = append(pkt, dstBytes[:]...) + + switch kind { + case innerNone: + // no inner packet at all + case innerV6: + pkt = append(pkt, 0x60, 0x00, 0x00, 0x00) // inner version=6 + pkt = append(pkt, 0x00, 0x00) // payload_len + pkt = append(pkt, 59) // nexthdr = no next header + pkt = append(pkt, 64) // hop_limit + innerSrc := netip.MustParseAddr("2001:db8::1").As16() + pkt = append(pkt, innerSrc[:]...) + innerDst := netip.MustParseAddr("2001:db8::2").As16() + pkt = append(pkt, innerDst[:]...) + case innerV4: + // struct usid_iphdr, 20 bytes packed: ver_ihl, tos, tot_len, + // id, frag_off, ttl, protocol, check, saddr[4], daddr[4], plus a + // payload -- bpf_skb_adjust_room (step 7's strip) needs the + // resulting packet to clear a minimum size the kernel enforces + // independent of anything usid_ingress itself checks; a + // header-only inner packet (as innerV6 above effectively is, + // with no payload) is fine at IPv6's larger 40-byte header size, + // but a bare 20-byte IPv4 header is not -- confirmed empirically + // against a real kernel, not a documented constraint. The + // payload length here is arbitrary, chosen only to clear it with + // margin; usid_ingress never reads inner payload bytes. + const innerV4PayloadLen = 60 + pkt = append(pkt, 0x45, 0x00) // version=4, ihl=5; tos=0 + totLen := uint16(20 + innerV4PayloadLen) + pkt = append(pkt, byte(totLen>>8), byte(totLen)) // tot_len + pkt = append(pkt, 0x00, 0x00) // id + pkt = append(pkt, 0x00, 0x00) // frag_off + pkt = append(pkt, 64) // ttl + pkt = append(pkt, 59) // protocol = no next header + pkt = append(pkt, 0x00, 0x00) // checksum (unchecked by usid_ingress) + pkt = append(pkt, 198, 51, 100, 1) // saddr 198.51.100.1 + pkt = append(pkt, 198, 51, 100, 2) // daddr 198.51.100.2 + pkt = append(pkt, make([]byte, innerV4PayloadLen)...) + case innerUnknownVersion: + // A version nibble of 5 matches neither the ==6 nor ==4 branch. + pkt = append(pkt, 0x50, 0x00, 0x00, 0x00) + pkt = append(pkt, 0x00, 0x00) + pkt = append(pkt, 59) + pkt = append(pkt, 64) + innerSrc := netip.MustParseAddr("2001:db8::1").As16() + pkt = append(pkt, innerSrc[:]...) + innerDst := netip.MustParseAddr("2001:db8::2").As16() + pkt = append(pkt, innerDst[:]...) + case innerMalformedTruncated: + // Present but zero bytes long: usid.c's `(void*)(inner+1) > + // data_end` bounds check must reject this before even reading + // the version nibble. + } + + return pkt +} + +// sumPerCPU reads a per-CPU counter map entry and sums every CPU's slot, +// regardless of which CPU BPF_PROG_TEST_RUN happened to execute on. +func sumPerCPU(t *testing.T, m *ebpf.Map, index uint32) uint64 { + t.Helper() + var perCPU []uint64 + if err := m.Lookup(index, &perCPU); err != nil { + t.Fatalf("lookup drop_reasons[%d]: %v", index, err) + } + var total uint64 + for _, v := range perCPU { + total += v + } + return total +} + +// assertOnlyDropReason asserts that exactly one drop_reasons index is +// non-zero (with the expected count), and every other index remains zero +// -- proving the drop was attributed to the right cause and no other path +// was also triggered. +func assertOnlyDropReason(t *testing.T, m *ebpf.Map, want uint32, wantCount uint64) { + t.Helper() + for i := range uint32(dropReasonCount) { + got := sumPerCPU(t, m, i) + switch { + case i == want && got != wantCount: + t.Errorf("drop_reasons[%d] = %d, want %d", i, got, wantCount) + case i != want && got != 0: + t.Errorf("drop_reasons[%d] = %d, want 0 (unexpected drop reason triggered)", i, got) + } + } +} + +var baseUSID = testUSID{block: 0x0102030405AA, nodeID: 0x0010, function: uformat.FunctionEndDT46, argument: 0x123} + +// TestUsidIngress_LocatorMissFailsOpen covers design plan §4.2 step 2 / +// R6: traffic whose destination doesn't match any registered locator_table +// entry (i.e. not one of this node's uSID Blocks) passes through +// completely unmodified -- TC_ACT_OK, no drop counted. +func TestUsidIngress_LocatorMissFailsOpen(t *testing.T) { + requireRoot(t) + objs := loadObjects(t) + + // Register some other, unrelated /64 -- proves this is a real + // per-entry miss, not just "the map happens to be empty". + other := testUSID{block: 0xFFEEDDCCBBAA, nodeID: 0x0002, function: uformat.FunctionEndDT46, argument: 0x001} + if err := objs.LocatorTable.Put(other.locatorKey(t), UsidLocatorValue{Generation: 1}); err != nil { + t.Fatalf("populate locator_table: %v", err) + } + + dst := baseUSID.addr(t) + src := netip.MustParseAddr("2001:db8:ffff::1") + pkt := buildPacket(t, dst, src, false) + + ret, out, err := objs.UsidIngress.Test(pkt) + if err != nil { + t.Fatalf("program test-run: %v", err) + } + if ret != tcActOK { + t.Errorf("verdict = %d, want TC_ACT_OK (%d)", ret, tcActOK) + } + if string(out) != string(pkt) { + t.Errorf("packet was mutated on a locator_table miss:\n in: % x\nout: % x", pkt, out) + } + assertOnlyDropReason(t, objs.DropReasons, 0, 0) // no drop reason should have fired at all +} + +// TestUsidIngress_NonIPv6FailsOpen covers R6 for traffic that isn't IPv6 +// at all -- verifies step 1's parse gate, not just step 2's lookup. +func TestUsidIngress_NonIPv6FailsOpen(t *testing.T) { + requireRoot(t) + objs := loadObjects(t) + + if err := objs.LocatorTable.Put(baseUSID.locatorKey(t), UsidLocatorValue{Generation: 1}); err != nil { + t.Fatalf("populate locator_table: %v", err) + } + + pkt := buildPacket(t, baseUSID.addr(t), netip.MustParseAddr("2001:db8::1"), false) + pkt[12], pkt[13] = 0x08, 0x00 // rewrite ethertype to ETH_P_IP + + ret, out, err := objs.UsidIngress.Test(pkt) + if err != nil { + t.Fatalf("program test-run: %v", err) + } + if ret != tcActOK { + t.Errorf("verdict = %d, want TC_ACT_OK (%d)", ret, tcActOK) + } + if string(out) != string(pkt) { + t.Errorf("packet was mutated on a non-IPv6 frame:\n in: % x\nout: % x", pkt, out) + } +} + +// TestUsidIngress_UnknownFunctionDropsCounted covers design plan §4.2 step +// 4: a locator_table hit whose Function has no function_table entry is +// dropped, not passed through (the packet was already claimed by the +// locator match), and the drop is attributed to +// DROP_REASON_UNKNOWN_FUNCTION specifically. This also exercises R2/step 3 +// indirectly: reaching this drop reason (rather than a locator miss) +// proves Function was correctly read from the unmutated packet. +func TestUsidIngress_UnknownFunctionDropsCounted(t *testing.T) { + requireRoot(t) + objs := loadObjects(t) + + usid := testUSID{block: baseUSID.block, nodeID: baseUSID.nodeID, function: 0x3, argument: 0x123} + if err := objs.LocatorTable.Put(usid.locatorKey(t), UsidLocatorValue{Generation: 1}); err != nil { + t.Fatalf("populate locator_table: %v", err) + } + // Deliberately do not populate function_table for Function 0x3. + + pkt := buildPacket(t, usid.addr(t), netip.MustParseAddr("2001:db8::1"), false) + + ret, out, err := objs.UsidIngress.Test(pkt) + if err != nil { + t.Fatalf("program test-run: %v", err) + } + if ret != tcActShot { + t.Errorf("verdict = %d, want TC_ACT_SHOT (%d)", ret, tcActShot) + } + if string(out) != string(pkt) { + t.Errorf("packet was mutated on a function_table miss:\n in: % x\nout: % x", pkt, out) + } + assertOnlyDropReason(t, objs.DropReasons, dropReasonUnknownFunction, 1) +} + +// TestUsidIngress_UnknownArgumentDropsCounted covers design plan §4.2 step +// 6 and R4: a locator+function match whose Argument has no vrf_table entry +// is dropped and counted as DROP_REASON_UNKNOWN_ARGUMENT. Reaching this +// drop reason (rather than DROP_REASON_UNKNOWN_FUNCTION) proves +// function_table matched and Argument was correctly read at its fixed +// offset with no mutation of the packet -- this is this milestone's "no +// mutation" exit criterion, exercised at the latest point before any +// mutation (bpf_skb_adjust_room) could occur. +func TestUsidIngress_UnknownArgumentDropsCounted(t *testing.T) { + requireRoot(t) + objs := loadObjects(t) + + usid := testUSID{block: baseUSID.block, nodeID: baseUSID.nodeID, function: uformat.FunctionEndDT46, argument: 0x123} + if err := objs.LocatorTable.Put(usid.locatorKey(t), UsidLocatorValue{Generation: 1}); err != nil { + t.Fatalf("populate locator_table: %v", err) + } + if err := objs.FunctionTable.Put(usid.functionKey(t), UsidFunctionValue{Behavior: 1}); err != nil { + t.Fatalf("populate function_table: %v", err) + } + // Deliberately do not populate vrf_table for this Argument. + + pkt := buildPacket(t, usid.addr(t), netip.MustParseAddr("2001:db8::1"), false) + + ret, out, err := objs.UsidIngress.Test(pkt) + if err != nil { + t.Fatalf("program test-run: %v", err) + } + if ret != tcActShot { + t.Errorf("verdict = %d, want TC_ACT_SHOT (%d)", ret, tcActShot) + } + if string(out) != string(pkt) { + t.Errorf("packet mutated on a vrf_table miss (Function/Argument extraction must not mutate, R2):\n in: % x\nout: % x", + pkt, out) + } + assertOnlyDropReason(t, objs.DropReasons, dropReasonUnknownArgument, 1) +} + +// TestUsidIngress_ReservedArgumentZeroAlwaysMisses covers design plan R4 / +// §5.1 specifically: Argument 0x000 is reserved and must never be +// registered into vrf_table, so it always misses -- not because of a +// special-cased runtime check, but simply because nothing ever put an +// entry there. This test proves that by registering locator_table and +// function_table (so the packet gets as far as the vrf_table lookup) and +// confirming Argument 0x000 still drops as DROP_REASON_UNKNOWN_ARGUMENT. +func TestUsidIngress_ReservedArgumentZeroAlwaysMisses(t *testing.T) { + requireRoot(t) + objs := loadObjects(t) + + usid := testUSID{block: baseUSID.block, nodeID: baseUSID.nodeID, function: uformat.FunctionEndDT46, argument: 0x000} + if err := objs.LocatorTable.Put(usid.locatorKey(t), UsidLocatorValue{Generation: 1}); err != nil { + t.Fatalf("populate locator_table: %v", err) + } + if err := objs.FunctionTable.Put(usid.functionKey(t), UsidFunctionValue{Behavior: 1}); err != nil { + t.Fatalf("populate function_table: %v", err) + } + // vrf_table intentionally has no entry at all for this Block -- + // not even at key (block<<12 | 0) -- mirroring the real system, + // where usidmap.Register (design plan §5.1) refuses to ever accept + // argument==0 in the first place. + + pkt := buildPacket(t, usid.addr(t), netip.MustParseAddr("2001:db8::1"), false) + + ret, _, err := objs.UsidIngress.Test(pkt) + if err != nil { + t.Fatalf("program test-run: %v", err) + } + if ret != tcActShot { + t.Errorf("verdict = %d, want TC_ACT_SHOT (%d)", ret, tcActShot) + } + assertOnlyDropReason(t, objs.DropReasons, dropReasonUnknownArgument, 1) +} + +// TestUsidIngress_VRFTableMatchReachesFIBLookup covers design plan §4.2 +// step 6: a full locator+function+vrf_table match. There is no real route +// in the (arbitrary, almost certainly nonexistent) VRF table id used here, +// so bpf_fib_lookup() itself fails -- but reaching DROP_REASON_FIB_LOOKUP_ +// FAILED, rather than DROP_REASON_UNKNOWN_ARGUMENT, is only possible if +// vrf_table's entry was found and its vrf_table_id value was read and +// passed into bpf_fib_lookup (step 8), which is exactly what "vrf_table +// match" means. Verifying an actual successful FIB resolution + redirect +// requires a real kernel route/VRF/interface and belongs at the +// integration/e2e layer (design plan §7's testing-strategy table), not +// this BPF_PROG_TEST_RUN-level unit test. +func TestUsidIngress_VRFTableMatchReachesFIBLookup(t *testing.T) { + requireRoot(t) + objs := loadObjects(t) + + usid := testUSID{block: baseUSID.block, nodeID: baseUSID.nodeID, function: uformat.FunctionEndDT46, argument: 0x123} + if err := objs.LocatorTable.Put(usid.locatorKey(t), UsidLocatorValue{Generation: 1}); err != nil { + t.Fatalf("populate locator_table: %v", err) + } + if err := objs.FunctionTable.Put(usid.functionKey(t), UsidFunctionValue{Behavior: 1}); err != nil { + t.Fatalf("populate function_table: %v", err) + } + const bogusVRFTableID = 0x2A2A2A // astronomically unlikely to exist on the test host + if err := objs.VrfTable.Put(usid.vrfKey(), UsidVrfValue{VrfTableId: bogusVRFTableID}); err != nil { + t.Fatalf("populate vrf_table: %v", err) + } + + pkt := buildPacket(t, usid.addr(t), netip.MustParseAddr("2001:db8::1"), true /* inner IPv6 header present */) + + ret, _, err := objs.UsidIngress.Test(pkt) + if err != nil { + t.Fatalf("program test-run: %v", err) + } + if ret != tcActShot { + t.Errorf("verdict = %d, want TC_ACT_SHOT (%d) (fib_lookup against a nonexistent VRF table must fail, not succeed)", + ret, tcActShot) + } + + got := sumPerCPU(t, objs.DropReasons, dropReasonFibLookupFailed) + if got != 1 { + t.Errorf("drop_reasons[fib_lookup_failed] = %d, want 1 (vrf_table entry must have been found and used)", got) + } + if unknownArg := sumPerCPU(t, objs.DropReasons, dropReasonUnknownArgument); unknownArg != 0 { + t.Errorf("drop_reasons[unknown_argument] = %d, want 0 -- vrf_table should have matched, not missed", unknownArg) + } + + // Confirm the hit counters in vrf_table's own value were updated + // (design plan R8: per-Argument hit counters back the migration + // gate's "confirmed zero hits" check). + var vrfVal UsidVrfValue + if err := objs.VrfTable.Lookup(usid.vrfKey(), &vrfVal); err != nil { + t.Fatalf("lookup vrf_table entry: %v", err) + } + if vrfVal.Packets != 1 { + t.Errorf("vrf_table packets = %d, want 1", vrfVal.Packets) + } + if vrfVal.Bytes == 0 { + t.Errorf("vrf_table bytes = 0, want > 0 (skb->len at time of match)") + } + if vrfVal.LastSeenNs == 0 { + t.Errorf("vrf_table last_seen_ns = 0, want a real bpf_ktime_get_ns() reading") + } +} + +// TestUsidIngress_InnerIPv4ReachesFIBLookup covers step 7's inner-IPv4 +// branch (usid.c's `inner_version == 4` case), which +// TestUsidIngress_VRFTableMatchReachesFIBLookup above never exercises +// (its packets are always inner-IPv6) -- proving the v4 header actually +// parses (family/addr fields populated, h_proto rewritten) and the +// program reaches the FIB lookup, using the same bogus-VRF-table trick to +// prove that without needing real routing state. +func TestUsidIngress_InnerIPv4ReachesFIBLookup(t *testing.T) { + requireRoot(t) + objs := loadObjects(t) + + usid := testUSID{block: baseUSID.block, nodeID: baseUSID.nodeID, function: uformat.FunctionEndDT46, argument: 0x124} + if err := objs.LocatorTable.Put(usid.locatorKey(t), UsidLocatorValue{Generation: 1}); err != nil { + t.Fatalf("populate locator_table: %v", err) + } + if err := objs.FunctionTable.Put(usid.functionKey(t), UsidFunctionValue{Behavior: 1}); err != nil { + t.Fatalf("populate function_table: %v", err) + } + const bogusVRFTableID = 0x2B2B2B + if err := objs.VrfTable.Put(usid.vrfKey(), UsidVrfValue{VrfTableId: bogusVRFTableID}); err != nil { + t.Fatalf("populate vrf_table: %v", err) + } + + pkt := buildPacketWithInner(t, usid.addr(t), netip.MustParseAddr("2001:db8::1"), innerV4) + + ret, _, err := objs.UsidIngress.Test(pkt) + if err != nil { + t.Fatalf("program test-run: %v", err) + } + if ret != tcActShot { + t.Errorf("verdict = %d, want TC_ACT_SHOT (%d) (fib_lookup against a nonexistent VRF table must fail, not succeed)", + ret, tcActShot) + } + if got := sumPerCPU(t, objs.DropReasons, dropReasonFibLookupFailed); got != 1 { + t.Errorf("drop_reasons[fib_lookup_failed] = %d, want 1 (inner-IPv4 must parse and reach FIB lookup)", got) + } + if got := sumPerCPU(t, objs.DropReasons, dropReasonUnknownInnerVer); got != 0 { + t.Errorf("drop_reasons[unknown_inner_version] = %d, want 0 -- a v4 header must not be misclassified", got) + } + if got := sumPerCPU(t, objs.DropReasons, dropReasonMalformedInner); got != 0 { + t.Errorf("drop_reasons[malformed_inner] = %d, want 0 -- a well-formed v4 header must parse cleanly", got) + } +} + +// TestUsidIngress_UnknownInnerVersionDropped covers the inner-header +// version nibble matching neither 6 nor 4 (usid.c's final `else` branch +// of step 7's parse) -- a case no other test exercises. +func TestUsidIngress_UnknownInnerVersionDropped(t *testing.T) { + requireRoot(t) + objs := loadObjects(t) + + usid := testUSID{block: baseUSID.block, nodeID: baseUSID.nodeID, function: uformat.FunctionEndDT46, argument: 0x125} + if err := objs.LocatorTable.Put(usid.locatorKey(t), UsidLocatorValue{Generation: 1}); err != nil { + t.Fatalf("populate locator_table: %v", err) + } + if err := objs.FunctionTable.Put(usid.functionKey(t), UsidFunctionValue{Behavior: 1}); err != nil { + t.Fatalf("populate function_table: %v", err) + } + if err := objs.VrfTable.Put(usid.vrfKey(), UsidVrfValue{VrfTableId: 0x2C2C2C}); err != nil { + t.Fatalf("populate vrf_table: %v", err) + } + + pkt := buildPacketWithInner(t, usid.addr(t), netip.MustParseAddr("2001:db8::1"), innerUnknownVersion) + + ret, _, err := objs.UsidIngress.Test(pkt) + if err != nil { + t.Fatalf("program test-run: %v", err) + } + if ret != tcActShot { + t.Errorf("verdict = %d, want TC_ACT_SHOT (%d)", ret, tcActShot) + } + assertOnlyDropReason(t, objs.DropReasons, dropReasonUnknownInnerVer, 1) +} + +// TestUsidIngress_MalformedInnerDropped covers an inner packet present in +// name only (zero bytes after the stripped outer header) -- usid.c's +// bounds check on `inner+1 > data_end` must reject this before even +// reading the version nibble, rather than reading past the packet. +func TestUsidIngress_MalformedInnerDropped(t *testing.T) { + requireRoot(t) + objs := loadObjects(t) + + usid := testUSID{block: baseUSID.block, nodeID: baseUSID.nodeID, function: uformat.FunctionEndDT46, argument: 0x126} + if err := objs.LocatorTable.Put(usid.locatorKey(t), UsidLocatorValue{Generation: 1}); err != nil { + t.Fatalf("populate locator_table: %v", err) + } + if err := objs.FunctionTable.Put(usid.functionKey(t), UsidFunctionValue{Behavior: 1}); err != nil { + t.Fatalf("populate function_table: %v", err) + } + if err := objs.VrfTable.Put(usid.vrfKey(), UsidVrfValue{VrfTableId: 0x2D2D2D}); err != nil { + t.Fatalf("populate vrf_table: %v", err) + } + + pkt := buildPacketWithInner(t, usid.addr(t), netip.MustParseAddr("2001:db8::1"), innerMalformedTruncated) + + ret, _, err := objs.UsidIngress.Test(pkt) + if err != nil { + t.Fatalf("program test-run: %v", err) + } + if ret != tcActShot { + t.Errorf("verdict = %d, want TC_ACT_SHOT (%d)", ret, tcActShot) + } + assertOnlyDropReason(t, objs.DropReasons, dropReasonMalformedInner, 1) +} + +// TestUsidIngress_VRFTableKeyIncludesBlock covers design plan R8/§4.4's +// requirement that vrf_table's key is (Block, Argument), not Argument +// alone: two uSID Blocks sharing the same Argument value (as R8's +// make-before-break migration deliberately produces -- one live entry +// under an old Block, one under a new one) must be counted and matched +// independently. Registering vrf_table only under Block A and sending a +// packet for the *same* Argument under Block B must still miss, proving +// the program's vrf_key composition genuinely folds in the matched Block +// rather than only the Argument bits. +func TestUsidIngress_VRFTableKeyIncludesBlock(t *testing.T) { + requireRoot(t) + objs := loadObjects(t) + + const sharedArgument = 0x123 + blockA := testUSID{block: 0x0102030405AA, nodeID: 0x0010, function: uformat.FunctionEndDT46, argument: sharedArgument} + blockB := testUSID{block: 0x0A0B0C0D0E0F, nodeID: 0x0011, function: uformat.FunctionEndDT46, argument: sharedArgument} + + for _, u := range []testUSID{blockA, blockB} { + if err := objs.LocatorTable.Put(u.locatorKey(t), UsidLocatorValue{Generation: 1}); err != nil { + t.Fatalf("populate locator_table for block %#x: %v", u.block, err) + } + if err := objs.FunctionTable.Put(u.functionKey(t), UsidFunctionValue{Behavior: 1}); err != nil { + t.Fatalf("populate function_table for block %#x: %v", u.block, err) + } + } + // Only Block A gets a vrf_table entry for the shared Argument. + if err := objs.VrfTable.Put(blockA.vrfKey(), UsidVrfValue{VrfTableId: 0x2A2A2A}); err != nil { + t.Fatalf("populate vrf_table for block A: %v", err) + } + + pkt := buildPacket(t, blockB.addr(t), netip.MustParseAddr("2001:db8::1"), false) + + ret, out, err := objs.UsidIngress.Test(pkt) + if err != nil { + t.Fatalf("program test-run: %v", err) + } + if ret != tcActShot { + t.Errorf("verdict = %d, want TC_ACT_SHOT (%d) -- Block B has no vrf_table entry for this Argument", ret, tcActShot) + } + if string(out) != string(pkt) { + t.Errorf("packet mutated on a vrf_table miss:\n in: % x\nout: % x", pkt, out) + } + assertOnlyDropReason(t, objs.DropReasons, dropReasonUnknownArgument, 1) + + // Block A's entry must be completely untouched by Block B's packet. + var vrfVal UsidVrfValue + if err := objs.VrfTable.Lookup(blockA.vrfKey(), &vrfVal); err != nil { + t.Fatalf("lookup vrf_table entry for block A: %v", err) + } + if vrfVal.Packets != 0 { + t.Errorf("block A's vrf_table packets = %d, want 0 -- Block B's packet must not match Block A's entry", + vrfVal.Packets) + } +} diff --git a/internal/plumbing/ebpf/uformat/uformat.go b/internal/plumbing/ebpf/uformat/uformat.go new file mode 100644 index 0000000..040a4b0 --- /dev/null +++ b/internal/plumbing/ebpf/uformat/uformat.go @@ -0,0 +1,383 @@ +// Copyright 2025 Datum Cloud, Inc. +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +// Package uformat implements the pure-Go bit-layout primitives for the +// `uFMT 48+16` SRv6 uSID carrier format specified in +// datum-cloud/enhancements#740 ("Option 2 — Shared 16-bit Slot") and +// consumed by the eBPF/TC-BPF datapath described in +// .local/plan-ebpf-xdp-usid-datapath.md. It has no kernel, cgo, or BPF +// dependency: it only encodes/decodes the fixed-bit-offset fields inside a +// 128-bit uSID address and derives the map keys the datapath's +// locator_table and function_table use. +// +// Bit layout (RFC 9800 REPLACE-CSID flavor; see the design plan's R2 and +// its "why R2 departs from PR #740's original shift wording" call-out for +// why nothing here ever shifts the address): +// +// bit 1 48 49 64 65 68 69 80 81 128 +// |------ uSID Block (48) ------|-- Node-ID (16) --|-Fn(4)-|-- Argument (12) --|------ Padding (48, zero) ------| +// +// Byte layout of the 16-byte address (bit 1 is the MSB of byte 0, matching +// [net/netip.Addr.As16]'s network-byte-order convention): +// +// bytes 0-5 (48 bits) Block +// bytes 6-7 (16 bits) Node-ID +// byte 8 hi ( 4 bits) Function +// byte 8 lo + byte 9 (12 bits) Argument +// bytes 10-15 (48 bits) Padding (must be zero) +// +// Every accessor in this package reads (or writes) its field at that +// field's fixed offset only. There is deliberately no bit-shift of the +// address anywhere in this package (design plan R2): Block, Node-ID, +// Function, and Argument are always independently readable at their fixed +// offsets from an unmutated address, and locator_table/function_table keys +// are built by directly copying or composing those fixed-offset reads, not +// by shifting the address to bring a field into a canonical position. +// +// Placement note: this package lives under internal/plumbing/ebpf/ rather +// than as a sibling of internal/plumbing/{srv6,vrf,intf,sysctl} directly, +// because the eBPF datapath work is a multi-milestone effort (design plan +// §4, §6) that needs more than one package — this one (pure-Go bit layout, +// Milestone 2.1), the BPF program sources and generated bindings +// (Milestone 2.2), and the load/attach/reconcile control daemon logic +// (Milestone 3.x) all belong under one ebpf/ umbrella rather than +// individually crowding internal/plumbing's top level. uformat has no +// dependency on the other two and can be imported on its own. +package uformat + +import ( + "encoding/binary" + "errors" + "fmt" + "net/netip" +) + +// Field widths, in bits, of the uFMT 48+16 layout. +const ( + BlockBits = 48 + NodeIDBits = 16 + FunctionBits = 4 + ArgumentBits = 12 + PaddingBits = 48 +) + +const ( + // BlockMax is the largest value that fits in the 48-bit Block field. + BlockMax = 1< BlockMax, Node-ID outside [NodeIDMin,NodeIDMax], Function +// not one of the defined enum values, or Argument outside +// [ArgumentMin,ArgumentMax] (which alone excludes the reserved zero value — +// R4, §5.1). Decode does not call this automatically — callers validate +// explicitly at the point a value is about to be registered into a map +// (design plan §5.1), never on the datapath's packet-read path itself. +func (f Fields) Validate() error { + return errors.Join( + ValidateBlock(f.Block), + ValidateNodeID(f.NodeID), + ValidateFunction(f.Function), + ValidateArgument(f.Argument), + ) +} + +// ValidateBlock returns an error if block does not fit in the 48-bit Block +// field. +func ValidateBlock(block uint64) error { + if block > BlockMax { + return fmt.Errorf("uformat: block %#x overflows the 48-bit Block field (max %#x)", block, uint64(BlockMax)) + } + return nil +} + +// ValidateNodeID returns an error if nodeID falls outside PR #740's +// reserved Node-ID range 0x0001-0xDFFF. +func ValidateNodeID(nodeID uint16) error { + if nodeID < NodeIDMin || nodeID > NodeIDMax { + return fmt.Errorf("uformat: node-id %#x out of range [%#x,%#x]", nodeID, uint16(NodeIDMin), uint16(NodeIDMax)) + } + return nil +} + +// ValidateFunction returns an error unless function is one of the two +// Function values PR #740 defines today: FunctionEndDT46 (0xE) or +// FunctionEndDT2 (0xF, reserved for future L2 use — design plan R3). This +// is a registration-time check only — the datapath itself never validates +// Function against this enum; an unrecognized Function is instead detected +// as a function_table miss at forward time (design plan §4.2 step 4). +func ValidateFunction(function uint8) error { + if function != FunctionEndDT46 && function != FunctionEndDT2 { + return fmt.Errorf("uformat: function %#x is not a defined Function value (want %#x or %#x)", + function, uint8(FunctionEndDT46), uint8(FunctionEndDT2)) + } + return nil +} + +// ValidateArgument returns an error if argument is outside +// [ArgumentMin,ArgumentMax]. This includes rejecting the reserved value +// 0x000, which PR #740 forbids ever registering into vrf_table (design +// plan R4, §5.1). Per R4, the datapath's fixed-offset packet read +// (Argument, below) never itself rejects any 12-bit value at forward +// time — this validation applies only at registration time. +func ValidateArgument(argument uint16) error { + if argument < ArgumentMin || argument > ArgumentMax { + return fmt.Errorf("uformat: argument %#x out of range [%#x,%#x]", argument, uint16(ArgumentMin), uint16(ArgumentMax)) + } + return nil +} + +// as16 returns addr's raw 16 bytes, or an error if addr is not a 16-byte +// IPv6 address. +func as16(addr netip.Addr) ([16]byte, error) { + if !addr.Is6() { + return [16]byte{}, fmt.Errorf("uformat: %s is not a 16-byte IPv6 address", addr) + } + return addr.As16(), nil +} + +// Block returns the 48-bit uSID Block at bits 1-48 of addr, read directly +// at its fixed offset with no shift of the address itself. +func Block(addr netip.Addr) (uint64, error) { + b, err := as16(addr) + if err != nil { + return 0, err + } + return binary.BigEndian.Uint64(b[:8]) >> NodeIDBits, nil +} + +// NodeID returns the 16-bit Node-ID at bits 49-64 of addr. +func NodeID(addr netip.Addr) (uint16, error) { + b, err := as16(addr) + if err != nil { + return 0, err + } + return binary.BigEndian.Uint16(b[6:8]), nil +} + +// Function returns the 4-bit Function at bits 65-68 of addr — the upper +// nibble of byte 8 — read directly from the unmutated address (design plan +// R2). The returned value is not checked against ValidateFunction; callers +// on the packet-read path should not reject it, only fail the subsequent +// function_table lookup. +func Function(addr netip.Addr) (uint8, error) { + b, err := as16(addr) + if err != nil { + return 0, err + } + return b[8] >> 4, nil +} + +// Argument returns the 12-bit Argument at bits 69-80 of addr — the lower +// nibble of byte 8 plus all of byte 9 — read directly from the unmutated +// address (design plan R2, R4). This value is never itself part of a match +// key and this function never rejects any 12-bit value; callers that need +// to reject the reserved 0x000 (e.g. before a vrf_table registration) call +// ValidateArgument separately. +func Argument(addr netip.Addr) (uint16, error) { + b, err := as16(addr) + if err != nil { + return 0, err + } + return uint16(b[8]&0x0F)<<8 | uint16(b[9]), nil +} + +// Decode extracts every uFMT 48+16 field from addr at its fixed bit +// offset. It returns an error if addr is not a 16-byte IPv6 address, or if +// the 48-bit zero-padding tail (bits 81-128) is non-zero — a structural +// format check, distinct from the semantic range checks in Validate* +// (which Decode does not call). +func Decode(addr netip.Addr) (Fields, error) { + b, err := as16(addr) + if err != nil { + return Fields{}, err + } + for i := 10; i < 16; i++ { + if b[i] != 0 { + return Fields{}, fmt.Errorf("uformat: %s has non-zero padding at byte %d (bits 81-128 must be zero)", addr, i) + } + } + return Fields{ + Block: binary.BigEndian.Uint64(b[:8]) >> NodeIDBits, + NodeID: binary.BigEndian.Uint16(b[6:8]), + Function: b[8] >> 4, + Argument: uint16(b[8]&0x0F)<<8 | uint16(b[9]), + }, nil +} + +// Encode constructs a uFMT 48+16 IPv6 address from f, placing each field at +// its fixed bit offset with zero padding in bits 81-128. It returns an +// error if Block, Function, or Argument overflow their field width; it +// does not enforce the narrower semantic ranges in Validate* (e.g. +// Argument 0x000 or an out-of-range Node-ID), so callers can construct +// synthetic/placeholder or intentionally-reserved test addresses through +// this function and validate separately when a value is meant to be +// registered for real. +func Encode(f Fields) (netip.Addr, error) { + if err := ValidateBlock(f.Block); err != nil { + return netip.Addr{}, err + } + if f.Function > 0x0F { + return netip.Addr{}, fmt.Errorf("uformat: function %#x overflows the 4-bit Function field", f.Function) + } + if f.Argument > ArgumentMax { + return netip.Addr{}, fmt.Errorf("uformat: argument %#x overflows the 12-bit Argument field", f.Argument) + } + + var b [16]byte + binary.BigEndian.PutUint64(b[:8], f.Block<>8)&0x0F + b[9] = byte(f.Argument) + // bytes 10-15 remain zero (padding). + return netip.AddrFrom16(b), nil +} + +// LocatorKey is the 64-bit exact-match key for the locator_table map: bits +// 1-64 of a uSID address (Block(48) + Node-ID(16)), read directly with no +// shift. Every address sharing the same Block and Node-ID produces the +// same LocatorKey regardless of Function/Argument, which is exactly the +// property R1's "/64 match, not /128" needs. +type LocatorKey uint64 + +// LocatorKeyFromAddr composes the locator_table key directly from a uSID +// address — the raw top 8 bytes, read once with no shift. +func LocatorKeyFromAddr(addr netip.Addr) (LocatorKey, error) { + b, err := as16(addr) + if err != nil { + return 0, err + } + return LocatorKey(binary.BigEndian.Uint64(b[:8])), nil +} + +// NewLocatorKey composes a locator_table key from a Block and Node-ID +// value directly, without needing a full address — used by the control +// daemon (Milestone 3.x) when registering a locator from BGPRouter CRD +// state rather than from a packet. +func NewLocatorKey(block uint64, nodeID uint16) (LocatorKey, error) { + if err := ValidateBlock(block); err != nil { + return 0, err + } + return LocatorKey(block< 0x0F { + return 0, fmt.Errorf("uformat: function %#x overflows the 4-bit Function field", function) + } + return FunctionKey(block< ArgumentMax { + return 0, fmt.Errorf("uformat: argument %#x overflows the 12-bit Argument field", argument) + } + return VRFKey(block<= cutoff was written at or after that snapshot was taken, so +// Reconcile always keeps it regardless of whether its key appears in the +// live set -- it is simply re-evaluated, correctly, on the *next* sweep, +// once the CRD has had a chance to actually appear in a fresh list. Only +// entries older than the snapshot (Generation < cutoff) are ever eligible +// for deletion, and then only if their key is genuinely absent from the +// live set. +// +// vrf_table's kernel-side value struct (prog.UsidVrfValue) carries this +// generation field directly, alongside the per-Argument hit counters +// (packets/bytes/last_seen_ns) that already existed from Milestone 2.2 -- +// see usid.c's struct vrf_value comment for why this reconciles an +// apparent tension between the design plan's §4.2 (which described +// vrf_table's value as `{linux_vrf_table_id, generation, hit counter}`) +// and its later, narrower §4.4 map-inventory table (which listed only the +// counter fields): this milestone treats §4.2's inclusion of generation as +// the one this specific race requires, and implements it as a real +// struct field rather than an out-of-band side table, so it survives a +// control-daemon restart the same way the rest of vrf_table's state does +// (pinned under bpffs, design plan §4.4/§9). locator_table's value +// already had its own `generation` field from Milestone 2.2 (used for a +// different purpose -- R7's multiple-concurrent-Block bookkeeping, not +// this race -- since the GC sweep's scope, design plan §5.3, is vrf_table +// only; locator_table/function_table are not swept, so LocatorTable and +// FunctionTable below expose plain Register/Unregister/Get/List with no +// Reconcile/race handling to match). +// +// # Testability +// +// Every table type in this package (VRFTable, LocatorTable, FunctionTable) +// is built against the Table interface (table.go), not directly against +// *ebpf.Map -- KernelTable adapts a real, loaded map (e.g. +// prog.UsidObjects.VrfTable) to that interface for production use, while +// usidmap_test.go substitutes an in-memory fake implementation to exercise +// register/unregister/reconcile logic, including the race scenario above, +// without a kernel or root privileges (Milestone 3.3's own exit +// criterion). Registry (registry.go) is the convenience entry point that +// wires all three real, kernel-backed tables from a loaded +// *prog.UsidObjects in one call, for whichever later milestone's +// production code needs it. +package usidmap diff --git a/internal/plumbing/ebpf/usidmap/egresskind.go b/internal/plumbing/ebpf/usidmap/egresskind.go new file mode 100644 index 0000000..13945b9 --- /dev/null +++ b/internal/plumbing/ebpf/usidmap/egresskind.go @@ -0,0 +1,25 @@ +// Copyright 2026 Datum Cloud, Inc. +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package usidmap + +// Egress kind values for vrf_table's value field, mirroring usid.c's +// `enum egress_kind` (EGRESS_KIND_VETH/EGRESS_KIND_TAP). Hand-kept in sync +// with usid.c because bpf2go's -type flag cannot generate a Go type for a +// C enum that is only ever used as a literal constant, never as a +// variable/field the compiler retains distinct BTF for (see +// usidmap/function.go's BehaviorEndDT46/BehaviorEndDT2 constants and +// prog/dropreason.go for the identical reason/pattern). +// +// This selects which redirect helper usid_ingress's step 9 uses: a veth +// attachment's egress interface has a peer in a different netns +// (bpf_redirect_peer); a tap attachment (internal/cni/tap) never moves its +// interface out of this netns, so it has no peer and needs plain +// bpf_redirect instead. EgressKindVeth is the zero value so a Register call +// that never sets it explicitly defaults to the pre-existing (and still +// most common) veth behavior. +const ( + EgressKindVeth uint32 = 0 + EgressKindTap uint32 = 1 +) diff --git a/internal/plumbing/ebpf/usidmap/faketable_test.go b/internal/plumbing/ebpf/usidmap/faketable_test.go new file mode 100644 index 0000000..542818e --- /dev/null +++ b/internal/plumbing/ebpf/usidmap/faketable_test.go @@ -0,0 +1,106 @@ +// Copyright 2026 Datum Cloud, Inc. +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package usidmap + +import ( + "fmt" + "reflect" + + "github.com/cilium/ebpf" +) + +// fakeTable is an in-memory Table implementation used across this +// package's tests -- Milestone 3.3's own exit criterion ("unit tests +// against a mocked map interface"). It stores each value as the concrete +// Go struct Put was given (not raw bytes), copying into/out of callers' +// pointer arguments via reflection to mirror *ebpf.Map's own copy-in/ +// copy-out semantics for Put/Lookup/Delete/Iterate, without needing a +// kernel, BTF, or root privileges. +type fakeTable struct { + entries map[uint64]any + order []uint64 // insertion order, for deterministic Iterate/List in tests +} + +func newFakeTable() *fakeTable { + return &fakeTable{entries: make(map[uint64]any)} +} + +func fakeTableKey(key any) uint64 { + k, ok := key.(uint64) + if !ok { + panic(fmt.Sprintf("fakeTable: key type %T not supported, want uint64", key)) + } + return k +} + +func (f *fakeTable) Put(key, value any) error { + k := fakeTableKey(key) + if _, exists := f.entries[k]; !exists { + f.order = append(f.order, k) + } + f.entries[k] = value + return nil +} + +func (f *fakeTable) Lookup(key, valueOut any) error { + k := fakeTableKey(key) + v, ok := f.entries[k] + if !ok { + return ebpf.ErrKeyNotExist + } + reflect.ValueOf(valueOut).Elem().Set(reflect.ValueOf(v)) + return nil +} + +func (f *fakeTable) Delete(key any) error { + k := fakeTableKey(key) + if _, ok := f.entries[k]; !ok { + return ebpf.ErrKeyNotExist + } + delete(f.entries, k) + for i, kk := range f.order { + if kk == k { + f.order = append(f.order[:i], f.order[i+1:]...) + break + } + } + return nil +} + +func (f *fakeTable) Iterate() Iterator { + return &fakeIterator{table: f, idx: -1} +} + +// len reports the number of entries currently stored -- a test-only +// convenience, not part of the Table interface. +func (f *fakeTable) len() int { return len(f.order) } + +type fakeIterator struct { + table *fakeTable + idx int +} + +func (it *fakeIterator) Next(keyOut, valueOut any) bool { + it.idx++ + if it.idx >= len(it.table.order) { + return false + } + k := it.table.order[it.idx] + reflect.ValueOf(keyOut).Elem().Set(reflect.ValueOf(k)) + reflect.ValueOf(valueOut).Elem().Set(reflect.ValueOf(it.table.entries[k])) + return true +} + +func (it *fakeIterator) Err() error { return nil } + +// constClock returns a func() uint64 that always returns value -- used +// across this package's tests to give VRFTable/LocatorTable a +// deterministic, test-controlled Generation source instead of a real +// clock. Tests that need the clock to advance mid-test simply reassign +// the table's clock field directly (they run in-package and can reach the +// unexported field) rather than needing a stateful sequence helper. +func constClock(value uint64) func() uint64 { + return func() uint64 { return value } +} diff --git a/internal/plumbing/ebpf/usidmap/function.go b/internal/plumbing/ebpf/usidmap/function.go new file mode 100644 index 0000000..3320ac7 --- /dev/null +++ b/internal/plumbing/ebpf/usidmap/function.go @@ -0,0 +1,148 @@ +// Copyright 2026 Datum Cloud, Inc. +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package usidmap + +import ( + "errors" + "fmt" + + "github.com/cilium/ebpf" + + "go.datum.net/galactic/internal/plumbing/ebpf/prog" + "go.datum.net/galactic/internal/plumbing/ebpf/uformat" +) + +// Behavior values for function_table's value field, mirroring usid.c's +// `enum function_behavior` (BEHAVIOR_END_DT46/BEHAVIOR_END_DT2). These are +// hand-kept in sync with usid.c because bpf2go's -type flag cannot +// generate a Go type for a C enum that is only ever used as a literal +// constant, never as a variable/field the compiler retains distinct BTF +// for (see prog/doc.go's go:generate comment, and prog/usid_test.go's own +// hand-kept drop_reason constants for the identical reason). +const ( + BehaviorEndDT46 uint32 = 1 + BehaviorEndDT2 uint32 = 2 +) + +// behaviorForFunction returns the function_table Behavior value +// corresponding to function, so FunctionTable.Register's caller supplies +// only (block, function) -- never a Behavior directly -- making a +// mismatched Function/Behavior pair (e.g. Function 0xE stored against +// BEHAVIOR_END_DT2) structurally impossible to register through this API. +func behaviorForFunction(function uint8) (uint32, error) { + switch function { + case uformat.FunctionEndDT46: + return BehaviorEndDT46, nil + case uformat.FunctionEndDT2: + return BehaviorEndDT2, nil + default: + return 0, fmt.Errorf("usidmap: function %#x is not a defined Function value (want %#x or %#x)", + function, uint8(uformat.FunctionEndDT46), uint8(uformat.FunctionEndDT2)) + } +} + +// FunctionEntry is one fully decoded function_table row. +type FunctionEntry struct { + Block uint64 + Function uint8 + Behavior uint32 +} + +// FunctionTable is the read/write API for function_table. +type FunctionTable struct { + table Table +} + +// NewFunctionTable wraps table as a FunctionTable. Production callers pass +// a KernelTable wrapping a loaded *prog.UsidObjects's FunctionTable map (or +// use NewRegistryFromObjects); tests pass a fake Table. +func NewFunctionTable(table Table) *FunctionTable { + return &FunctionTable{table: table} +} + +// Register writes (or overwrites) the function_table entry for (block, +// function), storing the Behavior value behaviorForFunction derives from +// function -- one entry per (active uSID Block x defined Function), per +// design plan §4.4. +func (t *FunctionTable) Register(block uint64, function uint8) error { + if err := uformat.ValidateBlock(block); err != nil { + return fmt.Errorf("usidmap: function_table: register block=%#x function=%#x: %w", block, function, err) + } + behavior, err := behaviorForFunction(function) + if err != nil { + return fmt.Errorf("usidmap: function_table: register block=%#x function=%#x: %w", block, function, err) + } + + key, err := uformat.NewFunctionKey(block, function) + if err != nil { + return fmt.Errorf("usidmap: function_table: register block=%#x function=%#x: %w", block, function, err) + } + + value := prog.UsidFunctionValue{Behavior: behavior} + if err := t.table.Put(uint64(key), value); err != nil { + return fmt.Errorf("usidmap: function_table: register block=%#x function=%#x: %w", block, function, err) + } + return nil +} + +// Unregister removes the function_table entry for (block, function), if +// present. Not an error if already absent. +func (t *FunctionTable) Unregister(block uint64, function uint8) error { + key, err := uformat.NewFunctionKey(block, function) + if err != nil { + return fmt.Errorf("usidmap: function_table: unregister block=%#x function=%#x: %w", block, function, err) + } + if err := t.table.Delete(uint64(key)); err != nil { + if errors.Is(err, ebpf.ErrKeyNotExist) { + return nil + } + return fmt.Errorf("usidmap: function_table: unregister block=%#x function=%#x: %w", block, function, err) + } + return nil +} + +// Get reads the function_table entry for (block, function), reporting +// whether it exists. +func (t *FunctionTable) Get(block uint64, function uint8) (FunctionEntry, bool, error) { + key, err := uformat.NewFunctionKey(block, function) + if err != nil { + return FunctionEntry{}, false, fmt.Errorf( + "usidmap: function_table: get block=%#x function=%#x: %w", block, function, err) + } + + var value prog.UsidFunctionValue + if err := t.table.Lookup(uint64(key), &value); err != nil { + if errors.Is(err, ebpf.ErrKeyNotExist) { + return FunctionEntry{}, false, nil + } + return FunctionEntry{}, false, fmt.Errorf( + "usidmap: function_table: get block=%#x function=%#x: %w", block, function, err) + } + return FunctionEntry{Block: block, Function: function, Behavior: value.Behavior}, true, nil +} + +// List returns every entry currently in function_table, in unspecified +// order. Because function_table's key (Block<<4|Function, see +// uformat.NewFunctionKey) folds Block and Function together, List decodes +// both back out of each raw key. +func (t *FunctionTable) List() ([]FunctionEntry, error) { + var ( + entries []FunctionEntry + rawKey uint64 + value prog.UsidFunctionValue + ) + it := t.table.Iterate() + for it.Next(&rawKey, &value) { + entries = append(entries, FunctionEntry{ + Block: rawKey >> uformat.FunctionBits, + Function: uint8(rawKey & (1<> uformat.NodeIDBits, + NodeID: uint16(rawKey & (1</, e.g. /vrf_table) and +// returns a Registry wrapping them, for a short-lived process -- namely +// the galactic-cni plugin binary's ADD path (Milestones 7.1/7.2) -- that +// did not itself load the datapath but needs to read/write its maps. The +// returned io.Closer must be closed once the caller is done; it does not +// affect the maps' pinned lifetime (design plan §4.4: maps stay pinned +// across any single process's open/close cycle). +func OpenPinnedRegistry(pinDir string) (*Registry, pinnedMaps, error) { + open := func(name string) (*ebpf.Map, error) { + m, err := ebpf.LoadPinnedMap(filepath.Join(pinDir, name), nil) + if err != nil { + return nil, fmt.Errorf("open pinned map %q: %w", name, err) + } + return m, nil + } + + vrfMap, err := open(prog.UsidMapVrfTable) + if err != nil { + return nil, nil, err + } + locatorMap, err := open(prog.UsidMapLocatorTable) + if err != nil { + vrfMap.Close() //nolint:errcheck // best-effort close on partial-open failure + return nil, nil, err + } + functionMap, err := open(prog.UsidMapFunctionTable) + if err != nil { + vrfMap.Close() //nolint:errcheck // best-effort close on partial-open failure + locatorMap.Close() //nolint:errcheck // best-effort close on partial-open failure + return nil, nil, err + } + + closer := pinnedMaps{vrfMap, locatorMap, functionMap} + return &Registry{ + VRF: NewVRFTable(KernelTable{Map: vrfMap}), + Locator: NewLocatorTable(KernelTable{Map: locatorMap}), + Function: NewFunctionTable(KernelTable{Map: functionMap}), + }, closer, nil +} diff --git a/internal/plumbing/ebpf/usidmap/registry_test.go b/internal/plumbing/ebpf/usidmap/registry_test.go new file mode 100644 index 0000000..895d424 --- /dev/null +++ b/internal/plumbing/ebpf/usidmap/registry_test.go @@ -0,0 +1,89 @@ +// Copyright 2026 Datum Cloud, Inc. +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package usidmap + +import ( + "fmt" + "os" + "testing" + + "go.datum.net/galactic/internal/plumbing/ebpf/attach" +) + +// TestOpenPinnedRegistry_RoundTrip covers Milestone 7.1's cross-process +// integration point: a real, separate "open" of an already-pinned set of +// maps -- exactly the situation the short-lived galactic-cni plugin binary +// is in on every ADD, since it does not itself load the datapath (that's +// the long-lived "run" container's job, Milestone 3.1). Loads/pins via +// attach.Load (the real production loader) into a throwaway pin +// directory, then opens a second, independent handle via +// OpenPinnedRegistry and proves a write through one handle is visible +// through the other -- the whole point of pinning. +func TestOpenPinnedRegistry_RoundTrip(t *testing.T) { + requireRoot(t) + + pinDir := fmt.Sprintf("/sys/fs/bpf/galactic-registry-test-%d", os.Getpid()) + t.Cleanup(func() { _ = os.RemoveAll(pinDir) }) + + objs, err := attach.Load(pinDir) + if err != nil { + t.Fatalf("attach.Load: %v", err) + } + t.Cleanup(func() { + if err := objs.Close(); err != nil { + t.Errorf("close loader-side objects: %v", err) + } + }) + + reg, closer, err := OpenPinnedRegistry(pinDir) + if err != nil { + t.Fatalf("OpenPinnedRegistry: %v", err) + } + t.Cleanup(func() { + if err := closer.Close(); err != nil { + t.Errorf("close opened registry: %v", err) + } + }) + + if err := reg.VRF.Register(testBlock, 0x001, 0x2A2A2A, EgressKindVeth); err != nil { + t.Fatalf("Register via opened handle: %v", err) + } + + // Read back through the *original* loader-side objects, not the + // handle that wrote it -- proving the write actually landed in the + // shared, pinned kernel map object, not just some private state. + loaderSideVRF := NewVRFTable(KernelTable{Map: objs.VrfTable}) + entry, ok, err := loaderSideVRF.Get(testBlock, 0x001) + if err != nil { + t.Fatalf("Get via loader-side handle: %v", err) + } + if !ok { + t.Fatal("entry registered via the opened handle is not visible via the original loader-side handle") + } + if entry.VRFTableID != 0x2A2A2A { + t.Errorf("VRFTableID = %#x, want %#x", entry.VRFTableID, 0x2A2A2A) + } + + if err := reg.Locator.Register(testBlock, 0x0010); err != nil { + t.Fatalf("Locator.Register via opened handle: %v", err) + } + if err := reg.Function.Register(testBlock, 0xE); err != nil { + t.Fatalf("Function.Register via opened handle: %v", err) + } +} + +// TestOpenPinnedRegistry_MissingPinDirIsActionableError covers the +// cross-process failure mode: the CNI plugin runs with the eBPF datapath +// flag on, but the "run" container hasn't loaded/pinned the maps yet (or +// never will, e.g. flag misconfiguration skew between the two +// DaemonSet containers). +func TestOpenPinnedRegistry_MissingPinDirIsActionableError(t *testing.T) { + requireRoot(t) + + _, _, err := OpenPinnedRegistry("/sys/fs/bpf/galactic-does-not-exist") + if err == nil { + t.Fatal("OpenPinnedRegistry against a nonexistent pin dir: error = nil, want an error") + } +} diff --git a/internal/plumbing/ebpf/usidmap/table.go b/internal/plumbing/ebpf/usidmap/table.go new file mode 100644 index 0000000..50c18e8 --- /dev/null +++ b/internal/plumbing/ebpf/usidmap/table.go @@ -0,0 +1,105 @@ +// Copyright 2026 Datum Cloud, Inc. +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package usidmap + +import ( + "github.com/cilium/ebpf" + "golang.org/x/sys/unix" +) + +// Table is the minimal map-operation surface every table type in this +// package (VRFTable, LocatorTable, FunctionTable) is written against, +// instead of directly against *ebpf.Map. Its method set matches *ebpf.Map's +// own Put/Lookup/Delete/Iterate closely enough that KernelTable (below) is +// a one-line adapter; usidmap_test.go substitutes a fake, in-memory +// implementation to exercise register/unregister/reconcile logic without a +// kernel or root privileges (Milestone 3.3's own exit criterion: "unit +// tests against a mocked map interface"). +type Table interface { + // Put creates or overwrites the map entry at key with value. + Put(key, value any) error + + // Lookup reads the map entry at key into valueOut. It returns + // ebpf.ErrKeyNotExist (or, for a fake Table, an error satisfying + // errors.Is(err, ebpf.ErrKeyNotExist)) if key is absent. + Lookup(key, valueOut any) error + + // Delete removes the map entry at key. It returns ebpf.ErrKeyNotExist + // (see Lookup) if key is already absent. + Delete(key any) error + + // Iterate returns an Iterator walking every entry currently in the + // map, in unspecified order -- matching *ebpf.Map.Iterate's own + // documented behavior. + Iterate() Iterator +} + +// Iterator matches *ebpf.MapIterator's own Next/Err method set. +type Iterator interface { + // Next decodes the next key/value pair into keyOut/valueOut and + // reports whether one was available. Callers must check Err after + // Next returns false to distinguish "iteration finished" from "an + // error interrupted iteration." + Next(keyOut, valueOut any) bool + + // Err returns the first error encountered during iteration, if any. + Err() error +} + +// KernelTable adapts a real, loaded *ebpf.Map -- e.g. +// prog.UsidObjects.VrfTable, LocatorTable, or FunctionTable, once loaded +// and pinned by internal/plumbing/ebpf/attach.Load -- to the Table +// interface every table type in this package is written against. +type KernelTable struct { + Map *ebpf.Map +} + +func (k KernelTable) Put(key, value any) error { return k.Map.Put(key, value) } +func (k KernelTable) Lookup(key, valueOut any) error { return k.Map.Lookup(key, valueOut) } +func (k KernelTable) Delete(key any) error { return k.Map.Delete(key) } +func (k KernelTable) Iterate() Iterator { return k.Map.Iterate() } + +// clockFn is a package-level override point so tests can control the +// generation values Register/Generation observe deterministically, instead +// of racing against a real, always-advancing clock. Production code always +// leaves this at its default, monotonicNow. +var clockFn = monotonicNow + +// monotonicNow returns a nanosecond reading from CLOCK_MONOTONIC, used as +// the generation value stamped into vrf_table (and locator_table) entries +// at write time (see doc.go's "plugin-binary-vs-run-container race" +// section). +// +// CLOCK_MONOTONIC, not wall-clock time.Now(), deliberately: a wall-clock +// reading can jump backwards (NTP step correction), which would let a +// freshly Registered entry's generation compare as *older* than a cutoff +// captured moments before in real chronological order -- exactly the +// scenario this mechanism exists to prevent misjudging as stale. Go's +// time.Now() does carry an internal monotonic reading, but it is only ever +// comparable between two time.Time values from the same process and is not +// exposed as a raw, storable integer -- unsuitable for a value that must be +// written into a map entry and compared later, possibly by a different +// process (design plan §5.4's two-actor split). unix.CLOCK_MONOTONIC gives +// a raw, storable nanosecond count directly, immune to wall-clock jumps, +// and -- critically -- stable across a control-daemon restart within the +// same boot (it is not reset by a process restart, only by a reboot, and a +// reboot also destroys every pinned bpffs map this generation value would +// otherwise need to outlive, so that reset is harmless). +func monotonicNow() uint64 { + var ts unix.Timespec + // CLOCK_MONOTONIC is a well-known, always-valid clock id on Linux; the + // only realistic failure mode is a syscall-filtering sandbox rejecting + // clock_gettime entirely, in which case ts stays zeroed and every call + // in this process -- both Register's generation stamp and Generation's + // own cutoff snapshot -- reads 0. Reconcile's "keep if Generation >= + // cutoff" check then becomes 0 >= 0, which is true: entries are kept, + // never reaped, for as long as the rejection persists. This fails + // toward a leak (stale entries pile up, requiring a later restart or + // manual cleanup once clock_gettime works again), not toward + // misdelivery -- an entry never gets reaped out from under live + // traffic just because this syscall is unavailable. + _ = unix.ClockGettime(unix.CLOCK_MONOTONIC, &ts) + return uint64(ts.Sec)*1e9 + uint64(ts.Nsec) +} diff --git a/internal/plumbing/ebpf/usidmap/vrf.go b/internal/plumbing/ebpf/usidmap/vrf.go new file mode 100644 index 0000000..c59acac --- /dev/null +++ b/internal/plumbing/ebpf/usidmap/vrf.go @@ -0,0 +1,245 @@ +// Copyright 2026 Datum Cloud, Inc. +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package usidmap + +import ( + "errors" + "fmt" + + "github.com/cilium/ebpf" + + "go.datum.net/galactic/internal/plumbing/ebpf/prog" + "go.datum.net/galactic/internal/plumbing/ebpf/uformat" +) + +// VRFKey identifies one vrf_table row: the uSID Block that matched in +// locator_table, plus the 12-bit Argument. Block is part of the key, not +// Argument alone (design plan R8), so two Blocks can each hold an +// independently counted, independently matched entry for the same Argument +// value during a make-before-break migration. +type VRFKey struct { + Block uint64 + Argument uint16 +} + +// VRFEntry is one fully decoded vrf_table row, decoupled from +// prog.UsidVrfValue's cilium/ebpf/BTF-generated field layout so callers +// outside this package (the GC controller, Milestone 7.3; the CNI +// registration call, Milestone 7.1) don't need to import prog or +// cilium/ebpf directly. +type VRFEntry struct { + VRFKey + + // VRFTableID is the Linux VRF routing table id + // (internal/plumbing/vrf.TableID()) this Argument resolves to. + VRFTableID uint32 + + // EgressKind is EgressKindVeth or EgressKindTap -- which redirect + // helper usid_ingress's step 9 uses for this entry's resolved egress + // interface (Milestone 6.1's tap-mode redirect fix). + EgressKind uint32 + + // Generation is the table's monotonic-clock reading (table.go's + // monotonicNow) at the time this entry was last written by Register. + // See doc.go's "plugin-binary-vs-run-container race" section for how + // Reconcile uses it. + Generation uint64 + + // Packets, Bytes, and LastSeenNs are the datapath's own per-Argument + // hit counters (design plan R8), updated by usid_ingress itself on + // every packet that matches this entry -- Register never sets these; + // they only ever come from a real map read (Get/List/Reconcile). + Packets uint64 + Bytes uint64 + LastSeenNs uint64 +} + +// VRFTable is the read/write API for vrf_table. +type VRFTable struct { + table Table + clock func() uint64 +} + +// NewVRFTable wraps table as a VRFTable. Production callers pass a +// KernelTable wrapping a loaded *prog.UsidObjects's VrfTable map (or use +// NewRegistryFromObjects, which does this for all three tables at once); +// tests pass a fake Table. +func NewVRFTable(table Table) *VRFTable { + return &VRFTable{table: table, clock: clockFn} +} + +// Generation returns a snapshot of this table's monotonic clock. The GC +// controller (Milestone 7.3) must call this immediately *before* listing +// BGPVRFInstance CRDs for Reconcile's live set, and pass the result as +// Reconcile's cutoff argument -- see doc.go's "plugin-binary-vs-run- +// container race" section for why the ordering matters. +func (t *VRFTable) Generation() uint64 { + return t.clock() +} + +// Register writes (or overwrites) the vrf_table entry for (block, +// argument), mapping it to vrfTableID and stamping it with this table's +// current Generation (design plan §5.1, §5.4). +// +// Register rejects argument == 0 outright: PR #740 reserves Instance ID +// 0x000, and the datapath is required to always miss vrf_table for it +// (R4) -- rejecting it here means a caller bug upstream of Register +// (whatever eventually allocates Arguments, out of this plan's scope) +// cannot silently plant a live entry for the one value that must always +// miss. +// +// Re-registering an existing (block, argument) key overwrites its value +// wholesale, including resetting Packets/Bytes/LastSeenNs to zero and +// bumping Generation. This is intentional: R8's make-before-break +// migration needs two independently keyed entries for the same Argument +// (one per Block) to coexist, never the same key registered twice with +// different meanings, so a repeat Register of the *same* key (e.g. a CNI +// ADD retry re-registering after a transient failure) is always a +// legitimate fresh registration, not a collision -- and resetting the hit +// counters on that fresh registration is correct, not a loss: they should +// reflect traffic under the current registration, not accumulate across +// distinct registrations of what the caller now intends as a new +// attachment lifecycle. +func (t *VRFTable) Register(block uint64, argument uint16, vrfTableID uint32, egressKind uint32) error { + if err := uformat.ValidateArgument(argument); err != nil { + return fmt.Errorf("usidmap: vrf_table: register block=%#x argument=%#x: %w", block, argument, err) + } + key, err := uformat.NewVRFKey(block, argument) + if err != nil { + return fmt.Errorf("usidmap: vrf_table: register block=%#x argument=%#x: %w", block, argument, err) + } + + value := prog.UsidVrfValue{ + VrfTableId: vrfTableID, + EgressKind: egressKind, + Generation: t.clock(), + } + if err := t.table.Put(uint64(key), value); err != nil { + return fmt.Errorf("usidmap: vrf_table: register block=%#x argument=%#x: %w", block, argument, err) + } + return nil +} + +// Unregister removes the vrf_table entry for (block, argument), if +// present. It is not an error to unregister an already-absent entry -- +// design plan §5.1 requires this call at both the failed-ADD rollback path +// (Milestone 7.2) and the GC sweep (Milestone 7.3), and either caller may +// legitimately race with the other having already removed the same entry. +func (t *VRFTable) Unregister(block uint64, argument uint16) error { + key, err := uformat.NewVRFKey(block, argument) + if err != nil { + return fmt.Errorf("usidmap: vrf_table: unregister block=%#x argument=%#x: %w", block, argument, err) + } + if err := t.table.Delete(uint64(key)); err != nil { + if errors.Is(err, ebpf.ErrKeyNotExist) { + return nil + } + return fmt.Errorf("usidmap: vrf_table: unregister block=%#x argument=%#x: %w", block, argument, err) + } + return nil +} + +// Get reads the vrf_table entry for (block, argument), reporting whether +// it exists. +func (t *VRFTable) Get(block uint64, argument uint16) (VRFEntry, bool, error) { + key, err := uformat.NewVRFKey(block, argument) + if err != nil { + return VRFEntry{}, false, fmt.Errorf("usidmap: vrf_table: get block=%#x argument=%#x: %w", block, argument, err) + } + + var value prog.UsidVrfValue + if err := t.table.Lookup(uint64(key), &value); err != nil { + if errors.Is(err, ebpf.ErrKeyNotExist) { + return VRFEntry{}, false, nil + } + return VRFEntry{}, false, fmt.Errorf("usidmap: vrf_table: get block=%#x argument=%#x: %w", block, argument, err) + } + return VRFEntry{ + VRFKey: VRFKey{Block: block, Argument: argument}, + VRFTableID: value.VrfTableId, + EgressKind: value.EgressKind, + Generation: value.Generation, + Packets: value.Packets, + Bytes: value.Bytes, + LastSeenNs: value.LastSeenNs, + }, true, nil +} + +// List returns every entry currently in vrf_table, in unspecified order. +// Because vrf_table's key (Block<<12|Argument, see uformat.NewVRFKey) +// folds Block and Argument together, List decodes both back out of each +// raw key rather than needing a separate Block parameter the way +// Get/Register/Unregister do. +func (t *VRFTable) List() ([]VRFEntry, error) { + var ( + entries []VRFEntry + rawKey uint64 + value prog.UsidVrfValue + ) + it := t.table.Iterate() + for it.Next(&rawKey, &value) { + entries = append(entries, VRFEntry{ + VRFKey: VRFKey{ + Block: rawKey >> uformat.ArgumentBits, + Argument: uint16(rawKey & (1<= cutoff. +// +// cutoff must be a value returned by this table's own Generation, captured +// by the caller *before* it lists CRDs to build live (see doc.go's +// "plugin-binary-vs-run-container race" section, and Generation's own doc +// comment). An entry with Generation >= cutoff was registered at or after +// that snapshot was taken, so it is always kept here regardless of whether +// its key is in live -- it is correctly re-evaluated on the caller's +// *next* Reconcile call, once the CRD list has had a chance to catch up. +// Only entries older than the snapshot (Generation < cutoff) are ever +// candidates for deletion, and then only if their key is genuinely absent +// from live. +// +// Reconcile attempts every stale candidate even if deleting one fails, +// joining every such error into the returned error with errors.Join; +// removed lists every entry actually deleted, regardless of whether a +// later deletion in the same call failed. +func (t *VRFTable) Reconcile(live map[VRFKey]struct{}, cutoff uint64) (removed []VRFEntry, err error) { + entries, err := t.List() + if err != nil { + return nil, fmt.Errorf("usidmap: vrf_table: reconcile: %w", err) + } + + var errs []error + for _, e := range entries { + if _, ok := live[e.VRFKey]; ok { + continue // still has a live BGPVRFInstance per the CRD snapshot + } + if e.Generation >= cutoff { + // Registered at or after the CRD-list snapshot was taken -- + // too new to judge against a live set captured before it + // existed. Leave it for the next sweep (design plan §5.4). + continue + } + if err := t.Unregister(e.Block, e.Argument); err != nil { + errs = append(errs, fmt.Errorf("usidmap: vrf_table: reconcile: delete stale entry %+v: %w", e.VRFKey, err)) + continue + } + removed = append(removed, e) + } + return removed, errors.Join(errs...) +} diff --git a/internal/plumbing/ebpf/usidmap/vrf_test.go b/internal/plumbing/ebpf/usidmap/vrf_test.go new file mode 100644 index 0000000..012ccf6 --- /dev/null +++ b/internal/plumbing/ebpf/usidmap/vrf_test.go @@ -0,0 +1,423 @@ +// Copyright 2026 Datum Cloud, Inc. +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package usidmap + +import ( + "errors" + "testing" + + "go.datum.net/galactic/internal/plumbing/ebpf/prog" + "go.datum.net/galactic/internal/plumbing/ebpf/uformat" +) + +// testBlock is an arbitrary 48-bit Block value used across this package's +// tests that don't specifically target Block's own boundaries -- mirrors +// uformat_test.go's own testBlock constant. +const testBlock uint64 = 0x2001_0DB8_FF01 + +// errIntentionalTestFailure is a sentinel error deleteFailingTable returns +// to simulate one specific Delete call failing. +var errIntentionalTestFailure = errors.New("usidmap: intentional test failure") + +func newTestVRFTable(clock func() uint64) (*VRFTable, *fakeTable) { + ft := newFakeTable() + return &VRFTable{table: ft, clock: clock}, ft +} + +// mustVRFKey is a small test helper wrapping uformat.NewVRFKey. +func mustVRFKey(t *testing.T, block uint64, argument uint16) uint64 { + t.Helper() + key, err := uformat.NewVRFKey(block, argument) + if err != nil { + t.Fatalf("NewVRFKey(%#x, %#x): unexpected error: %v", block, argument, err) + } + return uint64(key) +} + +func TestVRFTable_RegisterAndGet(t *testing.T) { + vt, _ := newTestVRFTable(constClock(7)) + + if err := vt.Register(testBlock, 0x123, 42, EgressKindVeth); err != nil { + t.Fatalf("Register: unexpected error: %v", err) + } + + entry, ok, err := vt.Get(testBlock, 0x123) + if err != nil { + t.Fatalf("Get: unexpected error: %v", err) + } + if !ok { + t.Fatalf("Get: entry not found after Register") + } + want := VRFEntry{ + VRFKey: VRFKey{Block: testBlock, Argument: 0x123}, + VRFTableID: 42, + Generation: 7, + } + if entry != want { + t.Errorf("Get = %+v, want %+v", entry, want) + } +} + +func TestVRFTable_GetMissingEntry(t *testing.T) { + vt, _ := newTestVRFTable(constClock(1)) + + _, ok, err := vt.Get(testBlock, 0x123) + if err != nil { + t.Fatalf("Get: unexpected error: %v", err) + } + if ok { + t.Errorf("Get: ok = true for an entry never registered") + } +} + +func TestVRFTable_RegisterRejectsReservedArgumentZero(t *testing.T) { + vt, ft := newTestVRFTable(constClock(1)) + + if err := vt.Register(testBlock, 0x000, 42, EgressKindVeth); err == nil { + t.Errorf("Register(argument=0x000) = nil error, want rejection (design plan R4/§5.1)") + } + if ft.len() != 0 { + t.Errorf("Register(argument=0x000) wrote %d entries into the table, want 0 (reject outright, never partially write)", + ft.len()) + } +} + +func TestVRFTable_RegisterRejectsBlockOverflow(t *testing.T) { + vt, ft := newTestVRFTable(constClock(1)) + + const overflowBlock = uint64(1) << 48 // one past the 48-bit Block field's max + if err := vt.Register(overflowBlock, 0x123, 42, EgressKindVeth); err == nil { + t.Errorf("Register(overflowing block) = nil error, want rejection") + } + if ft.len() != 0 { + t.Errorf("Register(overflowing block) wrote an entry, want none") + } +} + +// TestVRFTable_RegisterOverwritesExistingKeyAndResetsCounters confirms +// that re-registering an existing (block, argument) key overwrites the +// stored VRFTableID/Generation and resets the datapath's own hit counters +// to zero, per Register's documented "always a fresh registration" +// semantics (vrf.go). Packets/Bytes/LastSeenNs are seeded directly into +// the fake table here (bypassing Register, which never sets them) to +// simulate the datapath itself having already recorded traffic against +// this entry before the second Register call. +func TestVRFTable_RegisterOverwritesExistingKeyAndResetsCounters(t *testing.T) { + vt, ft := newTestVRFTable(constClock(1)) + + if err := vt.Register(testBlock, 0x123, 42, EgressKindVeth); err != nil { + t.Fatalf("first Register: unexpected error: %v", err) + } + + key := mustVRFKey(t, testBlock, 0x123) + seeded := prog.UsidVrfValue{VrfTableId: 42, Generation: 1, Packets: 100, Bytes: 5000, LastSeenNs: 123} + if err := ft.Put(key, seeded); err != nil { + t.Fatalf("seed simulated traffic: %v", err) + } + + vt.clock = constClock(99) + if err := vt.Register(testBlock, 0x123, 43, EgressKindVeth); err != nil { + t.Fatalf("second Register: unexpected error: %v", err) + } + + entry, ok, err := vt.Get(testBlock, 0x123) + if err != nil || !ok { + t.Fatalf("Get after re-register: ok=%v err=%v", ok, err) + } + if entry.VRFTableID != 43 { + t.Errorf("VRFTableID = %d after re-register, want 43", entry.VRFTableID) + } + if entry.Generation != 99 { + t.Errorf("Generation = %d after re-register, want 99 (the second Register's clock reading)", entry.Generation) + } + if entry.Packets != 0 || entry.Bytes != 0 || entry.LastSeenNs != 0 { + t.Errorf("hit counters after re-register = %+v, want all zero", entry) + } +} + +func TestVRFTable_Unregister(t *testing.T) { + vt, ft := newTestVRFTable(constClock(1)) + + if err := vt.Register(testBlock, 0x123, 42, EgressKindVeth); err != nil { + t.Fatalf("Register: unexpected error: %v", err) + } + if err := vt.Unregister(testBlock, 0x123); err != nil { + t.Fatalf("Unregister: unexpected error: %v", err) + } + if ft.len() != 0 { + t.Errorf("table has %d entries after Unregister, want 0", ft.len()) + } + if _, ok, err := vt.Get(testBlock, 0x123); err != nil || ok { + t.Errorf("Get after Unregister: ok=%v err=%v, want ok=false", ok, err) + } +} + +// TestVRFTable_UnregisterAbsentIsNotError covers design plan §5.1's +// requirement that Unregister is called from both the failed-ADD rollback +// path and the GC sweep, either of which may race the other having +// already removed the same entry -- Unregister must not treat that as an +// error. +func TestVRFTable_UnregisterAbsentIsNotError(t *testing.T) { + vt, _ := newTestVRFTable(constClock(1)) + + if err := vt.Unregister(testBlock, 0x123); err != nil { + t.Errorf("Unregister(never-registered entry) = %v, want nil", err) + } +} + +// TestVRFTable_VRFKeyIncludesBlock covers design plan R8: two Blocks +// sharing the same Argument must be independently registered, retrieved, +// and unregistered -- registering under one Block must never be visible +// to a Get/Unregister under a different Block. +func TestVRFTable_VRFKeyIncludesBlock(t *testing.T) { + vt, _ := newTestVRFTable(constClock(1)) + + const blockA, blockB = uint64(0x0102030405AA), uint64(0x0A0B0C0D0E0F) + if err := vt.Register(blockA, 0x123, 1, EgressKindVeth); err != nil { + t.Fatalf("Register(blockA): unexpected error: %v", err) + } + + if _, ok, err := vt.Get(blockB, 0x123); err != nil || ok { + t.Errorf("Get(blockB, same Argument) = ok=%v err=%v, want ok=false (Block must be part of the key)", ok, err) + } + + if err := vt.Register(blockB, 0x123, 2, EgressKindVeth); err != nil { + t.Fatalf("Register(blockB): unexpected error: %v", err) + } + entryA, ok, err := vt.Get(blockA, 0x123) + if err != nil || !ok { + t.Fatalf("Get(blockA) after registering blockB: ok=%v err=%v", ok, err) + } + if entryA.VRFTableID != 1 { + t.Errorf("blockA's entry was clobbered by registering blockB: VRFTableID = %d, want 1", entryA.VRFTableID) + } +} + +func TestVRFTable_List(t *testing.T) { + vt, _ := newTestVRFTable(constClock(1)) + + if err := vt.Register(testBlock, 0x001, 10, EgressKindVeth); err != nil { + t.Fatalf("Register: %v", err) + } + if err := vt.Register(testBlock, 0x002, 20, EgressKindVeth); err != nil { + t.Fatalf("Register: %v", err) + } + + entries, err := vt.List() + if err != nil { + t.Fatalf("List: unexpected error: %v", err) + } + if len(entries) != 2 { + t.Fatalf("List returned %d entries, want 2: %+v", len(entries), entries) + } + + byArgument := map[uint16]VRFEntry{} + for _, e := range entries { + byArgument[e.Argument] = e + } + if e, ok := byArgument[0x001]; !ok || e.VRFTableID != 10 || e.Block != testBlock { + t.Errorf("List entry for argument 0x001 = %+v, ok=%v, want Block=%#x VRFTableID=10", e, ok, testBlock) + } + if e, ok := byArgument[0x002]; !ok || e.VRFTableID != 20 || e.Block != testBlock { + t.Errorf("List entry for argument 0x002 = %+v, ok=%v, want Block=%#x VRFTableID=20", e, ok, testBlock) + } +} + +func TestVRFTable_Generation(t *testing.T) { + vt, _ := newTestVRFTable(constClock(123)) + if got := vt.Generation(); got != 123 { + t.Errorf("Generation() = %d, want 123", got) + } +} + +// TestVRFTable_Reconcile_RemovesStaleEntry covers the base case: an entry +// registered before the sweep's CRD-list cutoff, whose key is absent from +// the live set, must be removed. +func TestVRFTable_Reconcile_RemovesStaleEntry(t *testing.T) { + vt, _ := newTestVRFTable(constClock(10)) + + if err := vt.Register(testBlock, 0x100, 1, EgressKindVeth); err != nil { + t.Fatalf("Register: %v", err) + } + + removed, err := vt.Reconcile(map[VRFKey]struct{}{}, 20 /* cutoff, after generation 10 */) + if err != nil { + t.Fatalf("Reconcile: unexpected error: %v", err) + } + if len(removed) != 1 || removed[0].Argument != 0x100 { + t.Fatalf("Reconcile removed = %+v, want exactly one entry for argument 0x100", removed) + } + if _, ok, err := vt.Get(testBlock, 0x100); err != nil || ok { + t.Errorf("Get after Reconcile: ok=%v err=%v, want the stale entry gone", ok, err) + } +} + +// TestVRFTable_Reconcile_KeepsLiveEntry confirms an entry whose key IS in +// the live set is never removed, regardless of its Generation. +func TestVRFTable_Reconcile_KeepsLiveEntry(t *testing.T) { + vt, _ := newTestVRFTable(constClock(10)) + + if err := vt.Register(testBlock, 0x100, 1, EgressKindVeth); err != nil { + t.Fatalf("Register: %v", err) + } + + live := map[VRFKey]struct{}{{Block: testBlock, Argument: 0x100}: {}} + removed, err := vt.Reconcile(live, 20) + if err != nil { + t.Fatalf("Reconcile: unexpected error: %v", err) + } + if len(removed) != 0 { + t.Errorf("Reconcile removed = %+v, want none (entry is live)", removed) + } + if _, ok, err := vt.Get(testBlock, 0x100); err != nil || !ok { + t.Errorf("Get after Reconcile: ok=%v err=%v, want the live entry to remain", ok, err) + } +} + +// TestVRFTable_Reconcile_RegistrationMidSweepSurvives is this milestone's +// exit-criterion race scenario (design plan §5.4's closing paragraph; +// implementation plan Milestone 3.3's exit criteria, mirrored again in +// Milestone 7.3's): a Register call landing between the GC sweep's +// list-CRDs step and its delete-stale-entries step must survive, even +// though its Argument cannot possibly appear in a live-CRD snapshot taken +// before the registration happened. +// +// Sequence modeled here, matching the real GC sweep's steps exactly: +// 1. A pre-existing entry (argument 0x100) is registered at generation 10 +// -- its BGPVRFInstance CRD has since been deleted (this entry really +// is stale). +// 2. The GC controller captures cutoff = VRFTable.Generation() (here, +// 20. *before* listing CRDs. +// 3. Before the GC controller's delete step runs, a brand-new +// registration (argument 0x200) lands -- e.g. a concurrent CNI ADD on +// the plugin-binary side -- stamped with generation 30 (after +// cutoff). +// 4. The GC controller's CRD list (captured at step 2, so it reflects +// neither the already-deleted 0x100 CRD nor the not-yet-visible 0x200 +// CRD) is empty: live contains neither key. +// 5. Reconcile(live, cutoff) must remove 0x100 (older than cutoff, and +// genuinely absent from live) but must NOT remove 0x200 (newer than +// cutoff, even though it is also absent from live) -- reaping it here +// would deliver a live tenant's traffic nowhere until some later, +// lucky sweep re-registers it, which is exactly the "delivered into +// the wrong VRF or dropped" failure design plan §5.1 warns about. +func TestVRFTable_Reconcile_RegistrationMidSweepSurvives(t *testing.T) { + vt, _ := newTestVRFTable(constClock(10)) + + // Step 1: pre-existing, now-genuinely-stale entry. + if err := vt.Register(testBlock, 0x100, 1, EgressKindVeth); err != nil { + t.Fatalf("Register(stale): unexpected error: %v", err) + } + + // Step 2: the GC controller's cutoff, captured before listing CRDs. + const cutoff = 20 + + // Step 3: a Register call lands mid-sweep, after cutoff was captured. + vt.clock = constClock(30) + if err := vt.Register(testBlock, 0x200, 2, EgressKindVeth); err != nil { + t.Fatalf("Register(mid-sweep): unexpected error: %v", err) + } + + // Step 4: the live-CRD snapshot reflects neither key. + live := map[VRFKey]struct{}{} + + // Step 5: reconcile. + removed, err := vt.Reconcile(live, cutoff) + if err != nil { + t.Fatalf("Reconcile: unexpected error: %v", err) + } + + if len(removed) != 1 || removed[0].Argument != 0x100 { + t.Fatalf("Reconcile removed = %+v, want exactly the stale 0x100 entry (mid-sweep 0x200 must survive)", removed) + } + + if _, ok, err := vt.Get(testBlock, 0x100); err != nil || ok { + t.Errorf("Get(0x100) after Reconcile: ok=%v err=%v, want the stale entry gone", ok, err) + } + entry, ok, err := vt.Get(testBlock, 0x200) + if err != nil || !ok { + t.Fatalf("Get(0x200) after Reconcile: ok=%v err=%v, want the mid-sweep registration to have survived", ok, err) + } + if entry.VRFTableID != 2 { + t.Errorf("surviving entry VRFTableID = %d, want 2 (unmodified by Reconcile)", entry.VRFTableID) + } +} + +// TestVRFTable_Reconcile_SurvivorIsCaughtByNextSweep confirms the +// mid-sweep entry that Reconcile deliberately spared is not spared +// forever: once its own generation is safely before a later sweep's +// cutoff, and its Argument is still absent from that later sweep's live +// set (e.g. its CRD really was deleted before its Register call, an +// actually-stale case that merely happened to race the first sweep), the +// next Reconcile call removes it. +func TestVRFTable_Reconcile_SurvivorIsCaughtByNextSweep(t *testing.T) { + vt, _ := newTestVRFTable(constClock(30)) + + if err := vt.Register(testBlock, 0x200, 2, EgressKindVeth); err != nil { + t.Fatalf("Register: unexpected error: %v", err) + } + + // First sweep: cutoff (20) predates this entry's generation (30) -- + // spared, per the race-protection behavior under test elsewhere. + if removed, err := vt.Reconcile(map[VRFKey]struct{}{}, 20); err != nil || len(removed) != 0 { + t.Fatalf("first Reconcile: removed=%+v err=%v, want none removed", removed, err) + } + + // Second sweep: cutoff (40) now postdates this entry's generation + // (30), and it is still absent from live -- must be removed now. + removed, err := vt.Reconcile(map[VRFKey]struct{}{}, 40) + if err != nil { + t.Fatalf("second Reconcile: unexpected error: %v", err) + } + if len(removed) != 1 || removed[0].Argument != 0x200 { + t.Fatalf("second Reconcile removed = %+v, want exactly argument 0x200", removed) + } +} + +// TestVRFTable_Reconcile_ContinuesPastDeleteFailure confirms Reconcile +// attempts every stale candidate even if deleting one of them fails, +// joining the failure into its returned error rather than aborting early. +func TestVRFTable_Reconcile_ContinuesPastDeleteFailure(t *testing.T) { + vt, ft := newTestVRFTable(constClock(10)) + + if err := vt.Register(testBlock, 0x100, 1, EgressKindVeth); err != nil { + t.Fatalf("Register: %v", err) + } + if err := vt.Register(testBlock, 0x200, 2, EgressKindVeth); err != nil { + t.Fatalf("Register: %v", err) + } + + // Sabotage the underlying table so deleting 0x100's key specifically + // fails, while 0x200's still succeeds. + badKey := mustVRFKey(t, testBlock, 0x100) + sabotaged := &deleteFailingTable{fakeTable: ft, failKey: badKey} + sabotagedVT := &VRFTable{table: sabotaged, clock: vt.clock} + + removed, err := sabotagedVT.Reconcile(map[VRFKey]struct{}{}, 20) + if err == nil { + t.Fatalf("Reconcile: want a non-nil error when a delete fails") + } + if !errors.Is(err, errIntentionalTestFailure) { + t.Errorf("Reconcile error = %v, want it to wrap errIntentionalTestFailure", err) + } + if len(removed) != 1 || removed[0].Argument != 0x200 { + t.Fatalf("Reconcile removed = %+v, want exactly argument 0x200 (0x100's delete failed but must not block it)", + removed) + } +} + +// deleteFailingTable wraps a *fakeTable and fails Delete for one specific +// key, to test Reconcile's continue-past-failure behavior. +type deleteFailingTable struct { + *fakeTable + failKey uint64 +} + +func (d *deleteFailingTable) Delete(key any) error { + if k, ok := key.(uint64); ok && k == d.failKey { + return errIntentionalTestFailure + } + return d.fakeTable.Delete(key) +} diff --git a/internal/plumbing/srv6/egress.go b/internal/plumbing/srv6/egress.go index 2941651..6970fee 100644 --- a/internal/plumbing/srv6/egress.go +++ b/internal/plumbing/srv6/egress.go @@ -9,9 +9,28 @@ import ( "net" "github.com/vishvananda/netlink" - "github.com/vishvananda/netlink/nl" ) +// seg6IptunModeEncapRed is the kernel's SEG6_IPTUN_MODE_ENCAP_RED +// (include/uapi/linux/seg6_iptunnel.h) -- "reduced" encapsulation, which +// omits the Segment Routing Header entirely when the segment list has only +// one entry (our uSID case, always a single compressed SID). The +// vishvananda/netlink version pinned in go.mod only exposes +// SEG6_IPTUN_MODE_INLINE/_ENCAP (nl.SEG6_IPTUN_MODE_ENCAP, plain "full" +// encap, always adds an 8+16-byte Routing Header even for one segment) -- +// there is no library constant for this mode, but SEG6Encap.Mode is a plain +// int with no validation, so the raw kernel value works unchanged. +// usid.c's ingress decap strips exactly sizeof(struct usid_ip6hdr) (40 +// bytes, the outer IPv6 header alone) with no allowance for a Routing +// Header; installing routes in "full encap" mode put a live 24-byte SRH +// between that stripped header and the inner packet, so decap +// misidentified the SRH's first byte as the inner IP version and every +// cross-region uSID packet was dropped (DROP_REASON_UNKNOWN_INNER_VERSION) +// -- confirmed live via the datapath's own drops_total metric before this +// fix, and by byte-for-byte comparing tcpdump's outer-packet capture +// (RT6 Routing Header, type 4) against usid.c's step 7 strip width. +const seg6IptunModeEncapRed = 3 + // RouteEgressAdd installs a SEG6 encap route for prefix into routing table // tableID, encapsulating to the given SRv6 SID (gateway). The outgoing // interface and L3 next-hop are resolved from the kernel's routing table for @@ -25,7 +44,7 @@ func RouteEgressAdd(prefix *net.IPNet, gateway net.IP, tableID uint32) error { return fmt.Errorf("no route to gateway %s", gateway) } encap := &netlink.SEG6Encap{ - Mode: nl.SEG6_IPTUN_MODE_ENCAP, + Mode: seg6IptunModeEncapRed, Segments: []net.IP{gateway}, } return netlink.RouteReplace(&netlink.Route{ diff --git a/internal/plumbing/srv6/srv6.go b/internal/plumbing/srv6/srv6.go deleted file mode 100644 index e1cd150..0000000 --- a/internal/plumbing/srv6/srv6.go +++ /dev/null @@ -1,107 +0,0 @@ -// Copyright 2025 Datum Cloud, Inc. -// -// SPDX-License-Identifier: AGPL-3.0-or-later - -// Package srv6 manages kernel SRv6 END.DT46 ingress routes for Galactic VPC -// endpoints and computes compressed SRv6 uSIDs (see ComputeSID). Route -// installation accepts a USID (/128) and VPC identifiers to install the decap -// route on the correct host interface and VRF routing table, and requires -// CAP_NET_ADMIN; ComputeSID is pure computation and requires no privilege. -package srv6 - -import ( - "fmt" - "net" - "strings" - - "github.com/vishvananda/netlink" - "github.com/vishvananda/netlink/nl" - - "go.datum.net/galactic/internal/plumbing/intf" - "go.datum.net/galactic/internal/plumbing/vrf" -) - -// parseSID returns the /128 IPNet for the given SID string. -// It accepts both bare IPv6 addresses and /128 CIDR notation. -func parseSID(sid string) (*net.IPNet, error) { - if strings.Contains(sid, "/") { - _, ipnet, err := net.ParseCIDR(sid) - if err != nil { - return nil, fmt.Errorf("invalid sid %q: %w", sid, err) - } - return ipnet, nil - } - ip := net.ParseIP(sid) - if ip == nil { - return nil, fmt.Errorf("invalid sid %q: not an IP address", sid) - } - return netlink.NewIPNet(ip), nil -} - -// RouteIngressAdd installs an SRv6 END.DT46 ingress route for the given USID. -// The VPC and VPCAttachment identifiers (base62-encoded) are used to resolve -// the host interface and VRF routing table. -func RouteIngressAdd(sid, vpc, vpcAttachment string) error { - ipnet, err := parseSID(sid) - if err != nil { - return err - } - if err := addIngressRoute(ipnet, vpc, vpcAttachment); err != nil { - return fmt.Errorf("add ingress route failed: %w", err) - } - return nil -} - -// RouteIngressDel removes the SRv6 END.DT46 ingress route previously installed -// by RouteIngressAdd for the given USID. -func RouteIngressDel(sid, vpc, vpcAttachment string) error { - ipnet, err := parseSID(sid) - if err != nil { - return err - } - if err := deleteIngressRoute(ipnet, vpc, vpcAttachment); err != nil { - return fmt.Errorf("delete ingress route failed: %w", err) - } - return nil -} - -func addIngressRoute(ip *net.IPNet, vpc, vpcAttachment string) error { - dev := intf.GenerateInterfaceNameHost(vpc, vpcAttachment) - link, err := netlink.LinkByName(dev) - if err != nil { - return err - } - - vrfID, err := vrf.TableID(vpc, vpcAttachment) - if err != nil { - return err - } - - var flags [nl.SEG6_LOCAL_MAX]bool - flags[nl.SEG6_LOCAL_ACTION] = true - flags[nl.SEG6_LOCAL_VRFTABLE] = true - encap := &netlink.SEG6LocalEncap{ - Action: nl.SEG6_LOCAL_ACTION_END_DT46, - Flags: flags, - VrfTable: int(vrfID), - } - return netlink.RouteReplace(&netlink.Route{ - Dst: ip, - LinkIndex: link.Attrs().Index, - Encap: encap, - }) -} - -func deleteIngressRoute(ip *net.IPNet, vpc, vpcAttachment string) error { - dev := intf.GenerateInterfaceNameHost(vpc, vpcAttachment) - link, err := netlink.LinkByName(dev) - if err != nil { - return err - } - - return netlink.RouteDel(&netlink.Route{ - Dst: ip, - LinkIndex: link.Attrs().Index, - Encap: &netlink.SEG6LocalEncap{}, - }) -} diff --git a/internal/plumbing/srv6/srv6_test.go b/internal/plumbing/srv6/srv6_test.go deleted file mode 100644 index 66097a5..0000000 --- a/internal/plumbing/srv6/srv6_test.go +++ /dev/null @@ -1,75 +0,0 @@ -// Copyright 2025 Datum Cloud, Inc. -// -// SPDX-License-Identifier: AGPL-3.0-or-later - -package srv6 - -import ( - "testing" -) - -const testUSID = "2001:db8:ff00:1010::1" - -func TestParseSID(t *testing.T) { - tests := []struct { - name string - sid string - wantIP string - wantLen int - wantErr bool - }{ - { - name: "bare IPv6", - sid: testUSID, - wantIP: testUSID, - wantLen: 128, - }, - { - name: "CIDR /128", - sid: testUSID + "/128", - wantIP: testUSID, - wantLen: 128, - }, - { - name: "invalid IP", - sid: "not-an-ip", - wantErr: true, - }, - { - name: "invalid CIDR", - sid: testUSID + "/256", - wantErr: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, err := parseSID(tt.sid) - if tt.wantErr { - if err == nil { - t.Fatalf("parseSID(%q) error = nil, want error", tt.sid) - } - return - } - if err != nil { - t.Fatalf("parseSID(%q) error = %v, want nil", tt.sid, err) - } - if got.IP.String() != tt.wantIP { - t.Errorf("parseSID(%q).IP = %s, want %s", tt.sid, got.IP, tt.wantIP) - } - if _, bits := got.Mask.Size(); bits != tt.wantLen { - t.Errorf("parseSID(%q).Mask.Size() = %d, want %d", tt.sid, bits, tt.wantLen) - } - }) - } -} - -func Example_parseSID_bareIP() { - // Bare IPv6 is wrapped in a /128 via netlink.NewIPNet. - ipnet, err := parseSID("2001:db8::1") - if err != nil { - panic(err) - } - _, bits := ipnet.Mask.Size() - _ = bits // 128 -} diff --git a/internal/plumbing/srv6/usid.go b/internal/plumbing/srv6/usid.go index 77dbf65..1b8718c 100644 --- a/internal/plumbing/srv6/usid.go +++ b/internal/plumbing/srv6/usid.go @@ -8,44 +8,43 @@ import ( "fmt" "net/netip" + "go.datum.net/galactic/internal/plumbing/ebpf/uformat" bgpv1alpha1 "go.datum.net/network/api/v1alpha1" ) -// usidSuffixBits is the number of host bits ComputeSID consumes after the -// locator prefix: 8 bits NodeID + 16 bits VRFID + 8 bits Function. -const usidSuffixBits = 32 - -// functionByte maps an SRv6Function to the stable byte value ComputeSID -// encodes into the SID's Function octet. End.DT46 maps to 0 since it is the -// behavior CNI has always installed; the values only need to be distinct and -// are not meaningful outside this package. -func functionByte(fn bgpv1alpha1.SRv6Function) (byte, error) { - switch fn { - case bgpv1alpha1.SRv6FunctionEndDT46: - return 0, nil - case bgpv1alpha1.SRv6FunctionEndDT4: - return 4, nil - case bgpv1alpha1.SRv6FunctionEndDT6: - return 6, nil - default: - return 0, fmt.Errorf("unknown SRv6 function %q", fn) +// functionNibble maps an SRv6Function to its uFMT 48+16 Function value +// (uformat.FunctionEndDT46 or FunctionEndDT2). End.DT46 is the only +// officially supported value (go.datum.net/network's SRv6Function enum +// dropped End.DT4/End.DT6 entirely -- neither was ever requested by any +// caller in this codebase, and the uFMT shared Function/Argument slot has +// no distinct wire code for a per-family variant anyway, design plan R3): +// it is the only endpoint behavior the eBPF datapath's vrf_table ever +// installs, regardless of pod-subnet address family (see +// internal/cni/bgp.go's registerEBPFDatapath/buildAdvertisementSpec). +func functionNibble(fn bgpv1alpha1.SRv6Function) (uint8, error) { + if fn == bgpv1alpha1.SRv6FunctionEndDT46 { + return uformat.FunctionEndDT46, nil } + return 0, fmt.Errorf("unsupported SRv6 function %q for uFMT 48+16 encoding", fn) } -// ComputeSID derives the compressed SRv6 uSID for a (locator, nodeID, vrfID, -// function) tuple, per RFC 9800 NEXT-CSID Argument addressing. -// -// The locator's network prefix is preserved unchanged and immediately -// followed by a 32-bit, byte-aligned suffix: +// ComputeSID derives the compressed SRv6 uSID for a (locator, nodeID, +// argument, function) tuple in the `uFMT 48+16` REPLACE-CSID layout (RFC +// 9800 §4.2.7; datum-cloud/enhancements#740 "Option 2 — Shared 16-bit +// Slot"): // -// byte 0 NodeID (1-254; BGPRouterSpec.NodeID, this router's PoP-local slot) -// byte 1-2 VRFID (1-65535, big-endian; the uSID Argument identifying the VRF) -// byte 3 Function (functionByte(function); the endpoint behavior) +// bits 1-48 uSID Block (locator's network prefix; must be an IPv6 /48) +// bits 49-64 Node-ID (nodeID; BGPRouterSpec.NodeID, this router's PoP-local slot) +// bits 65-68 Function (functionNibble(function); the endpoint behavior) +// bits 69-80 Argument (argument; the 12-bit value identifying which +// local Linux VRF this SID's decapped traffic +// resolves to -- allocated per node, not derived +// from the VPCAttachment identifier) +// bits 81-128 Padding (always zero) // -// remaining host bits are zeroed. locator must be an IPv6 CIDR with a -// byte-aligned prefix length that leaves room for the 32-bit suffix (e.g. a -// /48 through /96 locator). -func ComputeSID(locator string, nodeID, vrfID int32, function bgpv1alpha1.SRv6Function) (netip.Addr, error) { +// See internal/plumbing/ebpf/uformat for the field layout and the +// encode/decode primitives this delegates to. +func ComputeSID(locator string, nodeID, argument int32, function bgpv1alpha1.SRv6Function) (netip.Addr, error) { prefix, err := netip.ParsePrefix(locator) if err != nil { return netip.Addr{}, fmt.Errorf("parse SRv6 locator %q: %w", locator, err) @@ -53,33 +52,31 @@ func ComputeSID(locator string, nodeID, vrfID int32, function bgpv1alpha1.SRv6Fu if !prefix.Addr().Is6() { return netip.Addr{}, fmt.Errorf("SRv6 locator %q is not an IPv6 prefix", locator) } - bits := prefix.Bits() - if bits%8 != 0 { - return netip.Addr{}, fmt.Errorf("SRv6 locator %q must have a byte-aligned prefix length", locator) - } - if bits+usidSuffixBits > 128 { + if prefix.Bits() != uformat.BlockBits { return netip.Addr{}, fmt.Errorf( - "SRv6 locator %q leaves no room for a %d-bit NodeID/VRFID/Function suffix", locator, usidSuffixBits) + "SRv6 locator %q must be a /%d uSID Block, got /%d", locator, uformat.BlockBits, prefix.Bits()) } - if nodeID < 1 || nodeID > 254 { - return netip.Addr{}, fmt.Errorf("nodeID %d out of range [1,254]", nodeID) + if nodeID < uformat.NodeIDMin || nodeID > uformat.NodeIDMax { + return netip.Addr{}, fmt.Errorf( + "nodeID %d out of range [%#x,%#x]", nodeID, uint16(uformat.NodeIDMin), uint16(uformat.NodeIDMax)) } - if vrfID < 1 || vrfID > 65535 { - return netip.Addr{}, fmt.Errorf("vrfID %d out of range [1,65535]", vrfID) + if argument < uformat.ArgumentMin || argument > uformat.ArgumentMax { + return netip.Addr{}, fmt.Errorf( + "argument %d out of range [%#x,%#x]", argument, uint16(uformat.ArgumentMin), uint16(uformat.ArgumentMax)) } - fnByte, err := functionByte(function) + fn, err := functionNibble(function) if err != nil { return netip.Addr{}, err } - - addr := prefix.Addr().As16() - offset := bits / 8 - addr[offset] = byte(nodeID) - addr[offset+1] = byte(vrfID >> 8) - addr[offset+2] = byte(vrfID) - addr[offset+3] = fnByte - for i := offset + 4; i < 16; i++ { - addr[i] = 0 + block, err := uformat.Block(prefix.Addr()) + if err != nil { + return netip.Addr{}, fmt.Errorf("derive uSID Block from locator %q: %w", locator, err) } - return netip.AddrFrom16(addr), nil + + return uformat.Encode(uformat.Fields{ + Block: block, + NodeID: uint16(nodeID), + Function: fn, + Argument: uint16(argument), + }) } diff --git a/internal/plumbing/srv6/usid_test.go b/internal/plumbing/srv6/usid_test.go index 0b4da00..bb6004d 100644 --- a/internal/plumbing/srv6/usid_test.go +++ b/internal/plumbing/srv6/usid_test.go @@ -5,8 +5,10 @@ package srv6 import ( + "net/netip" "testing" + "go.datum.net/galactic/internal/plumbing/ebpf/uformat" bgpv1alpha1 "go.datum.net/network/api/v1alpha1" ) @@ -17,96 +19,84 @@ func TestComputeSID(t *testing.T) { name string locator string nodeID int32 - vrfID int32 + argument int32 function bgpv1alpha1.SRv6Function - want string wantErr bool }{ { name: "DT46 at /48 locator", locator: testUSIDLocator, nodeID: 1, - vrfID: 100, + argument: 100, function: bgpv1alpha1.SRv6FunctionEndDT46, - want: "2001:db8:ff01:100:6400::", }, { - name: "DT4 uses distinct function byte from DT46", + name: "max nodeID and argument", locator: testUSIDLocator, - nodeID: 1, - vrfID: 100, - function: bgpv1alpha1.SRv6FunctionEndDT4, - want: "2001:db8:ff01:100:6404::", - }, - { - name: "DT6 uses distinct function byte from DT4/DT46", - locator: testUSIDLocator, - nodeID: 1, - vrfID: 100, - function: bgpv1alpha1.SRv6FunctionEndDT6, - want: "2001:db8:ff01:100:6406::", + nodeID: uformat.NodeIDMax, + argument: uformat.ArgumentMax, + function: bgpv1alpha1.SRv6FunctionEndDT46, }, { - name: "max nodeID and vrfID", + name: "min nodeID and argument", locator: testUSIDLocator, - nodeID: 254, - vrfID: 65535, + nodeID: uformat.NodeIDMin, + argument: uformat.ArgumentMin, function: bgpv1alpha1.SRv6FunctionEndDT46, - want: "2001:db8:ff01:feff:ff00::", }, { name: "not an IPv6 prefix", locator: "203.0.113.0/24", nodeID: 1, - vrfID: 1, + argument: 1, function: bgpv1alpha1.SRv6FunctionEndDT46, wantErr: true, }, { - name: "unaligned prefix length", - locator: "2001:db8:ff01::/49", + name: "not a /48 -- too narrow", + locator: "2001:db8:ff01::/56", nodeID: 1, - vrfID: 1, + argument: 1, function: bgpv1alpha1.SRv6FunctionEndDT46, wantErr: true, }, { - name: "no room for suffix", - locator: "2001:db8:ff01::/104", + name: "not a /48 -- too wide", + locator: "2001:db8::/32", nodeID: 1, - vrfID: 1, + argument: 1, function: bgpv1alpha1.SRv6FunctionEndDT46, wantErr: true, }, { - name: "nodeID 0 reserved", + name: "nodeID 0 (below GIB range) reserved", locator: testUSIDLocator, nodeID: 0, - vrfID: 1, + argument: 1, function: bgpv1alpha1.SRv6FunctionEndDT46, wantErr: true, }, { - name: "nodeID 255 reserved", + name: "nodeID above GIB range reserved", locator: testUSIDLocator, - nodeID: 255, - vrfID: 1, + nodeID: uformat.NodeIDMax + 1, + argument: 1, function: bgpv1alpha1.SRv6FunctionEndDT46, wantErr: true, }, { - name: "vrfID 0 reserved", + name: "argument 0 reserved", locator: testUSIDLocator, nodeID: 1, - vrfID: 0, + argument: 0, function: bgpv1alpha1.SRv6FunctionEndDT46, wantErr: true, }, { - name: "vrfID out of range", + name: "argument out of range", locator: testUSIDLocator, nodeID: 1, - vrfID: 65536, + argument: uformat.ArgumentMax + 1, function: bgpv1alpha1.SRv6FunctionEndDT46, wantErr: true, }, @@ -114,15 +104,31 @@ func TestComputeSID(t *testing.T) { name: "unknown function", locator: testUSIDLocator, nodeID: 1, - vrfID: 1, + argument: 1, function: bgpv1alpha1.SRv6Function("End.Bogus"), wantErr: true, }, + { + name: "End.DT4 no longer supported", + locator: testUSIDLocator, + nodeID: 1, + argument: 1, + function: bgpv1alpha1.SRv6Function("End.DT4"), + wantErr: true, + }, + { + name: "End.DT6 no longer supported", + locator: testUSIDLocator, + nodeID: 1, + argument: 1, + function: bgpv1alpha1.SRv6Function("End.DT6"), + wantErr: true, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := ComputeSID(tt.locator, tt.nodeID, tt.vrfID, tt.function) + got, err := ComputeSID(tt.locator, tt.nodeID, tt.argument, tt.function) if tt.wantErr { if err == nil { t.Fatalf("ComputeSID() error = nil, want error") @@ -132,8 +138,47 @@ func TestComputeSID(t *testing.T) { if err != nil { t.Fatalf("ComputeSID() unexpected error: %v", err) } - if got.String() != tt.want { - t.Errorf("ComputeSID() = %s, want %s", got.String(), tt.want) + + // Cross-check against uformat's independently-tested encoder + // rather than a hand-transcribed literal, so this test verifies + // ComputeSID's locator-parsing/validation wrapper agrees with + // the field layout uformat already exercises directly. + prefix, err := netip.ParsePrefix(tt.locator) + if err != nil { + t.Fatalf("parse locator %q: %v", tt.locator, err) + } + block, err := uformat.Block(prefix.Addr()) + if err != nil { + t.Fatalf("uformat.Block() error: %v", err) + } + want, err := uformat.Encode(uformat.Fields{ + Block: block, + NodeID: uint16(tt.nodeID), + Function: uformat.FunctionEndDT46, + Argument: uint16(tt.argument), + }) + if err != nil { + t.Fatalf("uformat.Encode() error: %v", err) + } + if got != want { + t.Errorf("ComputeSID() = %s, want %s", got, want) + } + + // Decode confirms every field round-trips at its documented + // fixed offset -- catching an offset/shift regression that a + // same-package cross-check against Encode alone would not. + fields, err := uformat.Decode(got) + if err != nil { + t.Fatalf("uformat.Decode() error: %v", err) + } + if fields.NodeID != uint16(tt.nodeID) { + t.Errorf("decoded NodeID = %#x, want %#x", fields.NodeID, uint16(tt.nodeID)) + } + if fields.Argument != uint16(tt.argument) { + t.Errorf("decoded Argument = %#x, want %#x", fields.Argument, uint16(tt.argument)) + } + if fields.Function != uformat.FunctionEndDT46 { + t.Errorf("decoded Function = %#x, want %#x", fields.Function, uint8(uformat.FunctionEndDT46)) } }) } diff --git a/internal/reconcile/reconcile_test.go b/internal/reconcile/reconcile_test.go index 72d8992..bcdf3fc 100644 --- a/internal/reconcile/reconcile_test.go +++ b/internal/reconcile/reconcile_test.go @@ -10,6 +10,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "go.datum.net/galactic/internal/model" + "go.datum.net/galactic/internal/plumbing/ebpf/uformat" bgpv1alpha1 "go.datum.net/network/api/v1alpha1" ) @@ -81,8 +82,8 @@ func TestResolveSRv6SID(t *testing.T) { Function: ptrFunction(bgpv1alpha1.SRv6FunctionEndDT46), }, }, - // locator 2001:db8:ff01::/48 (6 bytes) + NodeID 0x07 + VRFID 0x002a + Function 0x00 - want: "2001:db8:ff01:700:2a00::", + // uFMT 48+16: Block 2001:0db8:ff01 + Node-ID 0x0007 + Function 0xE + Argument 0x02a + want: "2001:db8:ff01:7:e02a::", }, { name: "falls back to legacy annotation when adv VRFID/Function unset", @@ -129,7 +130,7 @@ func TestResolveSRv6SID(t *testing.T) { router: &bgpv1alpha1.BGPRouter{ Spec: bgpv1alpha1.BGPRouterSpec{ SRv6Locator: testLocator, - NodeID: 255, // out of ComputeSID's [1,254] range + NodeID: uformat.NodeIDMax + 1, // out of uFMT's [0x0001,0xDFFF] GIB range }, }, adv: &bgpv1alpha1.BGPAdvertisement{ diff --git a/internal/runtime/gobgp/monitor.go b/internal/runtime/gobgp/monitor.go index 2af4d87..cc70861 100644 --- a/internal/runtime/gobgp/monitor.go +++ b/internal/runtime/gobgp/monitor.go @@ -115,19 +115,59 @@ func (r *GoBGPRuntime) processEVPNPath(path *apiutil.Path, logPrefix string) { } prefix := addrToIPNet(ipPrefix.IPPrefix, int(ipPrefix.IPPrefixLength)) - gw := addrToNetIP(ipPrefix.GWIPAddress) if path.Withdrawal { slog.Info(logPrefix+": withdrawing route", "prefix", prefix, "table", tableID) if delErr := srv6.RouteEgressDel(prefix, tableID); delErr != nil { slog.Error(logPrefix+": RouteEgressDel failed", "prefix", prefix, "table", tableID, "err", delErr) } - } else { - slog.Info(logPrefix+": installing route", "prefix", prefix, "gw", gw, "table", tableID) - if addErr := srv6.RouteEgressAdd(prefix, gw, tableID); addErr != nil { - slog.Error(logPrefix+": RouteEgressAdd failed", "prefix", prefix, "gw", gw, "table", tableID, "err", addErr) + return + } + + // Prefer the Prefix-SID attribute over GWIPAddress: RFC 9136 requires + // GWIPAddress to share the NLRI prefix's address family, so it can never + // carry a 128-bit IPv6 SID for an IPv4 prefix (buildEVPNPaths leaves it + // zeroed there instead — see gatewayForPrefix); the Prefix-SID attribute + // carries the same SID with no such constraint (see prefixSIDAttr). + // Falling back to GWIPAddress keeps this compatible with any peer/path + // that only set the legacy field (e.g. an IPv6 prefix from an older + // build of this same binary). + gw := prefixSIDGateway(path.Attrs) + if gw == nil { + gw = addrToNetIP(ipPrefix.GWIPAddress) + } + slog.Info(logPrefix+": installing route", "prefix", prefix, "gw", gw, "table", tableID) + if addErr := srv6.RouteEgressAdd(prefix, gw, tableID); addErr != nil { + slog.Error(logPrefix+": RouteEgressAdd failed", "prefix", prefix, "gw", gw, "table", tableID, "err", addErr) + } +} + +// prefixSIDGateway extracts the SRv6 SID carried in a BGP Prefix-SID path +// attribute's SRv6 L3 Service TLV / SRv6 Information Sub-TLV (RFC 9252 §2), +// if present, as a 16-byte net.IP. Returns nil if the attribute or a +// matching sub-TLV is absent, so callers can fall back to the NLRI's own +// GWIPAddress field. +func prefixSIDGateway(attrs []bgp.PathAttributeInterface) net.IP { + for _, attr := range attrs { + psid, ok := attr.(*bgp.PathAttributePrefixSID) + if !ok { + continue + } + for _, tlv := range psid.TLVs { + l3, ok := tlv.(*bgp.SRv6ServiceTLV) + if !ok || l3.Type != bgp.TLVTypeSRv6L3Service { + continue + } + for _, sub := range l3.SubTLVs { + info, ok := sub.(*bgp.SRv6InformationSubTLV) + if !ok { + continue + } + return net.IP(info.SID).To16() + } } } + return nil } // matchTableID looks up the kernel VRF table that imports one of the diff --git a/internal/runtime/gobgp/paths.go b/internal/runtime/gobgp/paths.go index b199dd8..e9940e8 100644 --- a/internal/runtime/gobgp/paths.go +++ b/internal/runtime/gobgp/paths.go @@ -49,7 +49,9 @@ func parseSIDAddr(sid string) (netip.Addr, error) { // offer here, so its Gateway is left at the IPv4 zero address — RFC-compliant // per draft-ietf-bess-evpn-prefix-advertisement ("the GW IP field SHOULD be // zero if it is not used as an Overlay Index") rather than fabricating a -// value nothing in this design actually produces. +// value nothing in this design actually produces. The SID itself still +// reaches an IPv4 prefix's remote peers — see prefixSIDAttr below, which +// carries it via a family-independent path attribute instead. func gatewayForPrefix(prefix netip.Prefix, ipv6GW netip.Addr) netip.Addr { if prefix.Addr().Is4() { return netip.IPv4Unspecified() @@ -57,6 +59,25 @@ func gatewayForPrefix(prefix netip.Prefix, ipv6GW netip.Addr) netip.Addr { return ipv6GW } +// prefixSIDAttr builds the RFC 9252 BGP Prefix-SID path attribute (type 40) +// carrying sid as an SRv6 L3 Service TLV / SRv6 Information Sub-TLV +// (End.DT46 — the only behavior this datapath ever installs, per +// internal/plumbing/srv6/usid.go's ComputeSID). Unlike the NLRI's own +// Gateway IP Address field (see gatewayForPrefix), this attribute is not +// constrained to match the NLRI prefix's address family: RFC 9136 requires +// Prefix and Gateway IP Address to share a family, which means GWIPAddress +// can never carry a 128-bit IPv6 SID for an IPv4 EVPN Type 5 prefix — it +// gets truncated/zeroed in transit instead (the vpc20 IPv4 cross-region ping +// bug this attribute exists to fix). This attribute has no such constraint, +// so it is attached to every path — IPv4 and IPv6 prefixes alike — as the +// authoritative carrier of the SID; watchEVPNRIB prefers it over GWIPAddress +// when both are present (see prefixSIDGateway in monitor.go). +func prefixSIDAttr(sid netip.Addr) *bgp.PathAttributePrefixSID { + info := bgp.NewSRv6InformationSubTLV(sid, bgp.END_DT46) + l3Service := bgp.NewSRv6ServiceTLV(bgp.TLVTypeSRv6L3Service, info) + return bgp.NewPathAttributePrefixSID(l3Service) +} + // buildEVPNPaths adds or withdraws EVPN Type 5 IP Prefix paths for each prefix // in adv into the local GoBGP RIB. // @@ -65,9 +86,11 @@ func gatewayForPrefix(prefix netip.Prefix, ipv6GW netip.Addr) netip.Addr { // is set, matching the RD used by applyVRF during VRF registration. adv.NextHop // is the transit-reachable BGP peering address placed in MpReachNLRI. adv.SRv6SID, // when set, is the End.DT46 SID placed in the EVPN GWIPAddress field for IPv6 -// prefixes — this is the SRv6 segment that remote nodes install in their seg6 -// encap kernel routes. When adv.SRv6SID is empty the next-hop is used instead -// (non-SRv6 fallback). See gatewayForPrefix for the IPv4-prefix case. +// prefixes (see gatewayForPrefix for the IPv4 case) and, regardless of prefix +// family, in a BGP Prefix-SID path attribute (see prefixSIDAttr) — this is the +// SRv6 segment that remote nodes install in their seg6 encap kernel routes. +// When adv.SRv6SID is empty the next-hop is used instead (non-SRv6 fallback) +// and no Prefix-SID attribute is attached. func buildEVPNPaths(b *gobgpserver.BgpServer, adv model.DesiredAdvertisement, routerID string, withdraw bool) error { nextHop, err := netip.ParseAddr(adv.NextHop) if err != nil { @@ -75,12 +98,14 @@ func buildEVPNPaths(b *gobgpserver.BgpServer, adv model.DesiredAdvertisement, ro } ipv6GW := nextHop + var sidAttr *bgp.PathAttributePrefixSID if adv.SRv6SID != "" { sid, err := parseSIDAddr(adv.SRv6SID) if err != nil { return fmt.Errorf("invalid SRv6 SID %q: %w", adv.SRv6SID, err) } ipv6GW = sid + sidAttr = prefixSIDAttr(sid) } // Type 1 (IP-address:local-admin) RD, unique per VRF. @@ -136,6 +161,9 @@ func buildEVPNPaths(b *gobgpserver.BgpServer, adv model.DesiredAdvertisement, ro bgp.NewPathAttributeOrigin(bgp.BGP_ORIGIN_ATTR_TYPE_IGP), mpreach, } + if sidAttr != nil { + attrs = append(attrs, sidAttr) + } if len(rts) > 0 { attrs = append(attrs, bgp.NewPathAttributeExtendedCommunities(rts)) } diff --git a/internal/runtime/gobgp/paths_test.go b/internal/runtime/gobgp/paths_test.go index 1a3ae8f..7c1e937 100644 --- a/internal/runtime/gobgp/paths_test.go +++ b/internal/runtime/gobgp/paths_test.go @@ -6,6 +6,7 @@ package gobgp import ( "context" + "net" "net/netip" "testing" @@ -231,6 +232,64 @@ func TestGatewayForPrefix(t *testing.T) { } } +// TestPrefixSIDAttrRoundTrip verifies that prefixSIDAttr's encoding of a SID +// is recovered exactly by prefixSIDGateway's decoding — the pair that lets +// an IPv4 EVPN Type 5 prefix carry its SRv6 SID at all, since GWIPAddress +// can't (see TestGatewayForPrefix and TestBuildEVPNPathsIPv4CarriesSID). +func TestPrefixSIDAttrRoundTrip(t *testing.T) { + sid := netip.MustParseAddr(testSID1) + attr := prefixSIDAttr(sid) + + got := prefixSIDGateway([]bgp.PathAttributeInterface{attr}) + if got == nil { + t.Fatal("prefixSIDGateway returned nil, want the encoded SID") + } + if want := net.IP(sid.AsSlice()).To16(); !got.Equal(want) { + t.Errorf("prefixSIDGateway round-trip = %v, want %v", got, want) + } +} + +// TestPrefixSIDGatewayNoAttribute verifies that prefixSIDGateway returns nil +// (signaling "fall back to GWIPAddress") when no Prefix-SID attribute is +// present, e.g. a path from a peer/build that doesn't send one yet. +func TestPrefixSIDGatewayNoAttribute(t *testing.T) { + attrs := []bgp.PathAttributeInterface{ + bgp.NewPathAttributeOrigin(bgp.BGP_ORIGIN_ATTR_TYPE_IGP), + } + if got := prefixSIDGateway(attrs); got != nil { + t.Errorf("prefixSIDGateway = %v, want nil", got) + } +} + +// TestBuildEVPNPathsIPv4CarriesSID is the regression test for the vpc20 +// cross-region IPv4 ping bug: an EVPN Type 5 path for an IPv4 prefix cannot +// carry its SRv6 SID in GWIPAddress (gatewayForPrefix leaves that zeroed, +// per RFC 9136's family constraint), so before this fix the SID never +// reached the remote node for IPv4-family routes at all. Confirms the SID +// still round-trips end-to-end via the Prefix-SID attribute regardless. +func TestBuildEVPNPathsIPv4CarriesSID(t *testing.T) { + sid := netip.MustParseAddr(testSID1) + ipv4Prefix := netip.MustParsePrefix("10.128.0.5/32") + + // GWIPAddress itself carries nothing usable for this prefix — this is + // the fact that makes the Prefix-SID attribute necessary, not a defect + // introduced by this test. + if gw := gatewayForPrefix(ipv4Prefix, sid); gw != netip.IPv4Unspecified() { + t.Fatalf("gatewayForPrefix(IPv4 prefix) = %v, want the IPv4 zero address", gw) + } + + // The Prefix-SID attribute buildEVPNPaths attaches whenever adv.SRv6SID + // is set recovers the real SID regardless. + attrs := []bgp.PathAttributeInterface{prefixSIDAttr(sid)} + got := prefixSIDGateway(attrs) + if got == nil { + t.Fatal("prefixSIDGateway returned nil for an IPv4-prefix path's Prefix-SID attribute") + } + if want := net.IP(sid.AsSlice()).To16(); !got.Equal(want) { + t.Errorf("prefixSIDGateway(IPv4 prefix path) = %v, want %v", got, want) + } +} + // TestBuildEVPNPathsMixedFamily verifies that a dual-stack DesiredAdvertisement // (one IPv6 prefix, one IPv4 prefix, sharing a single SRv6 SID next-hop) // produces two well-formed EVPN Type 5 NLRIs instead of a malformed one. From 815287bee59c14a723cc569e559cc9054016a374 Mon Sep 17 00:00:00 2001 From: Peter Sprygada Date: Sun, 2 Aug 2026 17:19:21 -0400 Subject: [PATCH 2/6] fix(ci): install clang and llvm in Build job for eBPF codegen The native "Build" CI job runs `task build`, which shells out to bpf2go to compile internal/plumbing/ebpf/prog/usid.c and strip the result with llvm-strip. ubuntu-latest ships clang but not llvm-strip, so the job failed with "executable file not found in $PATH". The Docker-based image jobs already install both in their Dockerfile, which is why only the native Build job was affected. Co-Authored-By: Claude Sonnet 5 --- .github/workflows/ci.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 9a0fe86..f3080d2 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -68,6 +68,9 @@ jobs: version: 3.x repo-token: ${{ secrets.GITHUB_TOKEN }} + - name: Install eBPF build dependencies + run: sudo apt-get update && sudo apt-get install -y clang llvm + - name: Build binary run: task build From c5aaa469b1a9cb500f3ca0174842b50ed495b21e Mon Sep 17 00:00:00 2001 From: Peter Sprygada Date: Sun, 2 Aug 2026 17:36:48 -0400 Subject: [PATCH 3/6] fix(e2e): start the eBPF control daemon before exercising CNI ADD registerEBPFDatapath (internal/cni/bgp.go) is now the only forwarding path and requires locator_table/function_table/vrf_table to already be pinned under attach.PinDir. In production that's guaranteed ahead of time by the CNI DaemonSet's long-running `/galactic-cni run` container (config/cni/daemonset.yaml), but TestCNITapInterface spins up its own bare pod and invokes CNI ADD directly, so nothing ever loaded/pinned those maps -- CNI ADD failed with "open pinned map \"vrf_table\": no such file or directory". Start the same control daemon inside the test pod first and wait for its maps to be pinned, mirroring what the DaemonSet does before any pod attach can happen for real. Co-Authored-By: Claude Sonnet 5 --- tests/e2e/e2e_test.go | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/tests/e2e/e2e_test.go b/tests/e2e/e2e_test.go index 994f0cb..fa24f2c 100644 --- a/tests/e2e/e2e_test.go +++ b/tests/e2e/e2e_test.go @@ -20,6 +20,8 @@ import ( "strings" "testing" "time" + + "go.datum.net/galactic/internal/plumbing/ebpf/attach" ) const ( @@ -198,6 +200,16 @@ func TestCNITapInterface(t *testing.T) { t.Fatalf("pod did not reach Running phase: %v", err) } + // The eBPF uSID datapath is now the only forwarding path (see + // internal/cni/bgp.go's registerEBPFDatapath), so CNI ADD requires this + // node's locator_table/function_table/vrf_table maps to already be + // pinned under attach.PinDir. In production that's done ahead of time by + // the CNI DaemonSet's long-running "credential-refresh" container + // (config/cni/daemonset.yaml, `/galactic-cni run`); this test runs its + // own pod instead of relying on that DaemonSet, so it must start the + // same control daemon itself before exercising CNI ADD below. + startEBPFControlDaemon(t, name) + // Write the CNI config to a file inside the pod, then run the plugin // with the config piped via stdin. The plugin reads config from stdin // (the CNI protocol) and CNI_NETNS from the environment. @@ -292,6 +304,34 @@ GALACTIC_CNI_ENABLE_LOCAL_IPAM=true \ } } +// startEBPFControlDaemon runs `/galactic-cni run` inside the already-running +// pod named name and waits for it to load and pin the eBPF uSID datapath's +// maps under attach.PinDir. GALACTIC_CNI_EBPF_INTERFACES pins the attach +// step to the pod's (hostNetwork) eth0 rather than relying on default-route +// auto-detection -- only the pinning (not the attach itself) matters for +// the vrf_table registration TestCNITapInterface exercises. +func startEBPFControlDaemon(t *testing.T, name string) { + t.Helper() + + _, err := kubectl(t.Context(), "exec", name, "--", "sh", "-c", + "GALACTIC_CNI_EBPF_INTERFACES=eth0 nohup /galactic-cni run >/tmp/run.log 2>&1 &") + if err != nil { + t.Fatalf("start eBPF control daemon: %v", err) + } + + pinnedVRFTable := attach.PinDir + "/vrf_table" + deadline := time.Now().Add(podReadyTimeout) + for time.Now().Before(deadline) { + if _, err := kubectl(t.Context(), "exec", name, "--", "test", "-e", pinnedVRFTable); err == nil { + return + } + time.Sleep(podPollInterval) + } + + log, _ := kubectl(t.Context(), "exec", name, "--", "cat", "/tmp/run.log") + t.Fatalf("timed out waiting for %s to be pinned; control daemon log:\n%s", pinnedVRFTable, log) +} + // nodeName returns the name of the node this pod runs on, or falls back to // "kind-worker" for single-node Kind clusters. func nodeName() string { From e744466a29225fd03557802e33d983036ed0bd32 Mon Sep 17 00:00:00 2001 From: Peter Sprygada Date: Sun, 2 Aug 2026 17:48:36 -0400 Subject: [PATCH 4/6] fix(e2e): mount bpffs on the Kind node and bind it into the test pod The eBPF control daemon started in the previous commit failed with "mkdir /sys/fs/bpf/galactic: no such file or directory" -- a pod's own mount namespace can't create /sys/fs/bpf out of thin air (same reason config/cni/daemonset.yaml's bpf-fs hostPath volume comment gives: the mount has to already exist on the node). A real node's OS/kubelet setup mounts bpffs at boot; Kind node containers don't, so ci.sh now mounts it explicitly, and the test pod gets a bpf-fs hostPath volume (mirroring the DaemonSet's own) so it can see that mount. Co-Authored-By: Claude Sonnet 5 --- scripts/ci.sh | 12 ++++++++++++ tests/e2e/e2e_test.go | 10 +++++++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/scripts/ci.sh b/scripts/ci.sh index b3b669f..ac08b31 100755 --- a/scripts/ci.sh +++ b/scripts/ci.sh @@ -42,6 +42,18 @@ case "$COMMAND" in docker exec "$node" sysctl -w net.vrf.strict_mode=1 done + echo "--- Mounting bpffs on Kind node(s)" + # The eBPF uSID datapath pins its maps under /sys/fs/bpf/galactic (see + # internal/plumbing/ebpf/attach.PinDir). config/cni/daemonset.yaml's + # bpf-fs hostPath volume comment spells out why this can't be created + # from inside a pod's own mount namespace: bpffs must already be + # mounted at this path by the node itself. A real node's OS/kubelet + # setup does this at boot; a Kind node container doesn't, so mount it + # here once per node. + for node in $(kind get nodes --name "$CLUSTER_NAME"); do + docker exec "$node" sh -c 'mkdir -p /sys/fs/bpf && (mountpoint -q /sys/fs/bpf || mount -t bpf bpf /sys/fs/bpf)' + done + echo "--- Installing BGP CRDs (datum-cloud/network)" # Extract the datum-cloud/network commit SHA from go.mod (pseudo-version # suffix after the last hyphen), same approach as diff --git a/tests/e2e/e2e_test.go b/tests/e2e/e2e_test.go index fa24f2c..4b2d998 100644 --- a/tests/e2e/e2e_test.go +++ b/tests/e2e/e2e_test.go @@ -181,6 +181,14 @@ func TestCNITapInterface(t *testing.T) { // hostNetwork is required too: net.vrf.strict_mode (enabled on the Kind // node in scripts/ci.sh) is per-netns, and the SEG6Local VRFTABLE route // this test exercises needs it set in whichever netns the route lands in. + // The bpf-fs hostPath volume mirrors config/cni/daemonset.yaml's own + // bpf-fs mount: the eBPF uSID datapath's maps can only be pinned under + // attach.PinDir if the node's real bpffs (mounted onto the Kind node in + // scripts/ci.sh) is visible inside the pod -- a pod's own mount + // namespace can't create /sys/fs/bpf itself. + overrides := fmt.Sprintf(`{"spec":{"serviceAccountName":"galactic-cni","hostNetwork":true,`+ + `"volumes":[{"name":"bpf-fs","hostPath":{"path":"/sys/fs/bpf","type":"Directory"}}],`+ + `"containers":[{"name":%q,"volumeMounts":[{"name":"bpf-fs","mountPath":"/sys/fs/bpf"}]}]}}`, name) _, err := kubectl( t.Context(), "run", name, @@ -188,7 +196,7 @@ func TestCNITapInterface(t *testing.T) { "--image-pull-policy=Never", "--restart=Never", "--privileged", - "--overrides={\"spec\":{\"serviceAccountName\":\"galactic-cni\",\"hostNetwork\":true}}", + "--overrides="+overrides, "--command", "--", "sleep", "infinity", ) From 1889a6c9c5fadf08dd7de9dab69f7b82860f9c7a Mon Sep 17 00:00:00 2001 From: Peter Sprygada Date: Sun, 2 Aug 2026 17:56:46 -0400 Subject: [PATCH 5/6] fix(e2e): fully specify the test pod's container in --overrides kubectl run's --overrides merge replaces the whole generated "containers" list rather than merging into it once "containers" is set at all, so adding the bpf-fs volumeMount there silently dropped the container's image/command/privileged fields that had been set via the usual --image/--command/--privileged flags -- confirmed via `kubectl run ... --dry-run=client -o yaml`, which showed an empty container (name + volumeMounts only). `kubectl run failed: exit status 1` in CI was the API server rejecting that incomplete pod spec. Moving image/command/privileged into the same --overrides JSON fixes it. Co-Authored-By: Claude Sonnet 5 --- tests/e2e/e2e_test.go | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/tests/e2e/e2e_test.go b/tests/e2e/e2e_test.go index 4b2d998..c72ded8 100644 --- a/tests/e2e/e2e_test.go +++ b/tests/e2e/e2e_test.go @@ -185,23 +185,26 @@ func TestCNITapInterface(t *testing.T) { // bpf-fs mount: the eBPF uSID datapath's maps can only be pinned under // attach.PinDir if the node's real bpffs (mounted onto the Kind node in // scripts/ci.sh) is visible inside the pod -- a pod's own mount - // namespace can't create /sys/fs/bpf itself. + // namespace can't create /sys/fs/bpf itself. The whole container spec + // (image, command, privileged) has to live in --overrides too, not the + // usual --image/--command/--privileged flags: kubectl run's overrides + // merge replaces the generated "containers" list wholesale rather than + // merging into it, so anything set only via those flags would otherwise + // be silently dropped the moment "containers" is also set here. overrides := fmt.Sprintf(`{"spec":{"serviceAccountName":"galactic-cni","hostNetwork":true,`+ `"volumes":[{"name":"bpf-fs","hostPath":{"path":"/sys/fs/bpf","type":"Directory"}}],`+ - `"containers":[{"name":%q,"volumeMounts":[{"name":"bpf-fs","mountPath":"/sys/fs/bpf"}]}]}}`, name) - _, err := kubectl( + `"containers":[{"name":%q,"image":%q,"imagePullPolicy":"Never","command":["sleep","infinity"],`+ + `"securityContext":{"privileged":true},`+ + `"volumeMounts":[{"name":"bpf-fs","mountPath":"/sys/fs/bpf"}]}]}}`, name, image()) + runOut, err := kubectl( t.Context(), "run", name, "--image="+image(), - "--image-pull-policy=Never", "--restart=Never", - "--privileged", "--overrides="+overrides, - "--command", "--", - "sleep", "infinity", ) if err != nil { - t.Fatalf("kubectl run failed: %v", err) + t.Fatalf("kubectl run failed: %v\n%s", err, runOut) } if err := waitForPodPhase(t, name, "Running"); err != nil { From dba53bc5c721a68a86847980ae2d59d843db606b Mon Sep 17 00:00:00 2001 From: Peter Sprygada Date: Sun, 2 Aug 2026 22:40:09 -0400 Subject: [PATCH 6/6] fix(srv6): close uSID Argument allocation and GC data races allocateArgument's list-then-write BGPVRFInstance creation race let two concurrent CNI ADDs claim the same eBPF vrf_table Argument: the prior tie-break only let the lexicographically-losing name error out, so a tight enough create/check interleaving let both attachments pass and permanently share one VRFID, misdelivering one VPC's decapped traffic into the other's VRF. checkArgumentCollision now errors on any other same-VRFID instance regardless of name order, guaranteeing at least one side always detects it and retries via the normal failed-ADD path. That failed-ADD rollback had its own gap: retryK8sOps can re-run the whole publishBGPStateK8s closure without re-registering the eBPF entry, so a later attempt's collision failure could trigger cleanup to unregister a vrf_table slot the colliding (winning) attachment had since overwritten. unregisterEBPFDatapath now takes the caller's own, freshly-recomputed VRF table id and only deletes the entry if it still matches. SweepEBPFVRFTable folded "no BGPRouter found for this node" into the same case as "nothing is live," reconciling the whole vrf_table against an empty live set and blackholing every attachment on a transient router-listing hiccup. It now skips the sweep tick entirely when no router is found, rather than treating that as ground truth. Finally, BuildDesiredRouter aborted the entire DesiredRouter -- every peer, VRF, and advertisement -- when one BGPAdvertisement's VRFID fell outside the eBPF datapath's 12-bit Argument range (as any pre-cutover object allocated under the old, wider VRFID scheme would). That advertisement is now skipped with a warning instead of failing the whole router. Co-Authored-By: Claude Sonnet 5 --- internal/cni/bgp.go | 113 +++++++++++++++++---- internal/cni/bgp_ebpf_test.go | 82 ++++++++++++++- internal/cni/bgp_test.go | 56 ++++++++++ internal/cni/resource.go | 10 +- internal/gc/gc.go | 15 +++ internal/gc/gc_ebpf_test.go | 76 +++++++++++++- internal/installer/installer.go | 3 +- internal/plumbing/ebpf/prog/dropreason.go | 8 +- internal/plumbing/ebpf/prog/usid.c | 12 ++- internal/plumbing/ebpf/prog/usid_bpfeb.o | Bin 13600 -> 14352 bytes internal/plumbing/ebpf/prog/usid_bpfel.o | Bin 13600 -> 14352 bytes internal/plumbing/ebpf/prog/usid_test.go | 5 +- internal/plumbing/ebpf/usidmap/vrf.go | 1 + internal/plumbing/ebpf/usidmap/vrf_test.go | 10 +- internal/reconcile/reconcile.go | 13 ++- internal/reconcile/reconcile_test.go | 102 +++++++++++++++++++ 16 files changed, 470 insertions(+), 36 deletions(-) diff --git a/internal/cni/bgp.go b/internal/cni/bgp.go index 378284e..2dcff42 100644 --- a/internal/cni/bgp.go +++ b/internal/cni/bgp.go @@ -184,6 +184,42 @@ func allocateArgument( routerName, uint16(uformat.ArgumentMin), uint16(uformat.ArgumentMax), len(used)) } +// checkArgumentCollision detects whether a race condition occurred where +// another BGPVRFInstance on the same router was assigned the same VRFID +// concurrently (allocateArgument's list-then-write is not atomic against a +// second, concurrent CNI ADD racing it). Any other instance found still +// holding this VRFID is treated as a collision -- deliberately without a +// tie-breaker: allocateArgument -> create -> checkArgumentCollision runs +// sequentially within one call, so if A's create happens before B's create, +// then either B's own check (which always runs after B's own create) sees +// A's already-committed CRD and B reports a collision, or it doesn't only if +// A's check already ran (and hence already reported the collision itself) +// before B created its CRD. At least one side always detects it this way; a +// tie-breaker that lets exactly one side "win" without that guarantee (as a +// prior version of this function did) can let both sides pass when the two +// creates and checks interleave, leaving two BGPVRFInstances -- and, +// consequently, two vrf_table registrations -- permanently sharing the same +// VRFID. Both sides erroring out is harmless: the caller's non-transient +// error triggers the failed-ADD rollback (resourceTracker.cleanup), which +// deletes each side's own BGPVRFInstance, and the CNI runtime retries ADD. +func checkArgumentCollision( + ctx context.Context, k8s client.Client, namespace, routerName, vrfName string, vrfID int32, +) error { + list := &bgpv1alpha1.BGPVRFInstanceList{} + if err := k8s.List(ctx, list, client.InNamespace(namespace)); err != nil { + return fmt.Errorf("list BGPVRFInstances to verify argument uniqueness: %w", err) + } + for _, inst := range list.Items { + if inst.Spec.RouterRef == nil || inst.Spec.RouterRef.Name != routerName { + continue + } + if inst.Name != vrfName && inst.Spec.VRFID == vrfID { + return fmt.Errorf("argument collision: VRFID %d claimed by both %s and %s, retrying", vrfID, inst.Name, vrfName) + } + } + return nil +} + // lookupBGPRouter finds the BGPRouter targeting this node in the given namespace. // Returns an error if none is found or if multiple are found (ambiguous). func lookupBGPRouter(ctx context.Context, k8s client.Client, nodeName, namespace string) (bgpConfig, error) { @@ -359,6 +395,31 @@ func publishBGPStateK8s( return fmt.Errorf("compute route target: %w", err) } + // Create the BGPVRFInstance to configure the VRF with its VRFID and + // import/export route targets. This must be created before advertisements + // so the BGP runtime has the VRF context when originating EVPN paths. + vrfName := bgpVRFInstanceName(pluginConf.VPC, pluginConf.VPCAttachment) + vrfInst := &bgpv1alpha1.BGPVRFInstance{ + ObjectMeta: metav1.ObjectMeta{ + Name: vrfName, + Namespace: namespace, + }, + } + _, err = controllerutil.CreateOrUpdate(ctx, k8s, vrfInst, func() error { + vrfInst.Spec = buildVRFInstanceSpec(bgp.routerName, rtValue, vrfID) + return nil + }) + if err != nil { + return fmt.Errorf("apply BGPVRFInstance: %w", err) + } + tracker.vrfInstanceCreated = true + slog.Debug("BGP: BGPVRFInstance applied", "name", vrfName, "namespace", namespace, + "vrfID", vrfID, "routeTarget", rtValue, "router", bgp.routerName) + + if err := checkArgumentCollision(ctx, k8s, namespace, bgp.routerName, vrfName, vrfID); err != nil { + return err + } + // eBPF uSID datapath registration -- the only forwarding path // (the legacy seg6local static-route path was removed once this // datapath covered both veth and tap attachments). registered is @@ -383,27 +444,6 @@ func publishBGPStateK8s( tracker.ebpfArgument = uint16(vrfID) } - // Create the BGPVRFInstance to configure the VRF with its VRFID and - // import/export route targets. This must be created before advertisements - // so the BGP runtime has the VRF context when originating EVPN paths. - vrfName := bgpVRFInstanceName(pluginConf.VPC, pluginConf.VPCAttachment) - vrfInst := &bgpv1alpha1.BGPVRFInstance{ - ObjectMeta: metav1.ObjectMeta{ - Name: vrfName, - Namespace: namespace, - }, - } - _, err = controllerutil.CreateOrUpdate(ctx, k8s, vrfInst, func() error { - vrfInst.Spec = buildVRFInstanceSpec(bgp.routerName, rtValue, vrfID) - return nil - }) - if err != nil { - return fmt.Errorf("apply BGPVRFInstance: %w", err) - } - tracker.vrfInstanceCreated = true - slog.Debug("BGP: BGPVRFInstance applied", "name", vrfName, "namespace", namespace, - "vrfID", vrfID, "routeTarget", rtValue, "router", bgp.routerName) - // Create the BGPAdvertisement to originate the pod's subnet prefix(es). adv := &bgpv1alpha1.BGPAdvertisement{ ObjectMeta: metav1.ObjectMeta{ @@ -716,13 +756,42 @@ func egressKindForInterfaceType(ifaceType string) (uint32, error) { // the maps were reachable at Register time. Idempotent: not an error if // the entry is already gone (VRFTable.Unregister's own documented // behavior). -func unregisterEBPFDatapath(block uint64, argument uint16, pinDir string) error { +// +// expectedVRFTableID must be this attachment's own VRF table id (recomputed +// by the caller via vrf.TableID, not read back from the tracker, since it's +// cheap and deterministic to recompute and the whole point here is not to +// trust stale state). A retried k8s-op attempt (retryK8sOps) can re-run the +// same publishBGPStateK8s closure without re-registering the eBPF entry +// (registerEBPFDatapath only runs again if that attempt gets far enough), +// so by the time a later attempt's checkArgumentCollision failure triggers +// this rollback, the (block, argument) slot this attachment originally +// wrote may have since been overwritten by the very other attachment the +// collision was detected against (vrf_table's key is just (block, +// argument); Register always overwrites). Unregistering unconditionally in +// that case would delete a live attachment's forwarding entry instead of +// this rolled-back one's own -- so this only deletes the entry when it +// still resolves to expectedVRFTableID, and leaves it alone otherwise. +func unregisterEBPFDatapath(block uint64, argument uint16, expectedVRFTableID uint32, pinDir string) error { registry, closer, err := usidmap.OpenPinnedRegistry(pinDir) if err != nil { return fmt.Errorf("open pinned eBPF uSID maps: %w", err) } defer func() { _ = closer.Close() }() + entry, ok, err := registry.VRF.Get(block, argument) + if err != nil { + return fmt.Errorf("read eBPF vrf_table entry before unregister: %w", err) + } + if !ok { + return nil // already gone + } + if entry.VRFTableID != expectedVRFTableID { + slog.Warn("Rollback: eBPF vrf_table entry no longer belongs to this attachment, leaving it in place", + "block", block, "argument", argument, + "expectedVRFTableID", expectedVRFTableID, "currentVRFTableID", entry.VRFTableID) + return nil + } + if err := registry.VRF.Unregister(block, argument); err != nil { return fmt.Errorf("unregister eBPF vrf_table entry: %w", err) } diff --git a/internal/cni/bgp_ebpf_test.go b/internal/cni/bgp_ebpf_test.go index 73c6524..97df53f 100644 --- a/internal/cni/bgp_ebpf_test.go +++ b/internal/cni/bgp_ebpf_test.go @@ -123,9 +123,24 @@ func TestRegisterEBPFDatapath_RegistersAllThreeTables(t *testing.T) { // cleaning it up fully afterward; this mirrors the same "real global // state" pattern this file's other resourceTracker tests already use for // vrf.Delete/veth.Delete. +// +// cleanup's unregister step now recomputes this attachment's own VRF table +// id (vrf.TableID) and only deletes the vrf_table entry if it still +// resolves there, so a real VRF interface for (testVPC, testAttachment) +// must exist for the duration of this test -- unlike before this fix, +// where the seeded entry's VRFTableID was an arbitrary, unrelated value. func TestResourceTrackerCleanup_UnregistersEBPFVRFEntry(t *testing.T) { requireRoot(t) + if err := vrf.Add(testVPC, testAttachment); err != nil { + t.Fatalf("vrf.Add: %v", err) + } + t.Cleanup(func() { _ = vrf.Delete(testVPC, testAttachment) }) + vrfTableID, err := vrf.TableID(testVPC, testAttachment) + if err != nil { + t.Fatalf("vrf.TableID: %v", err) + } + loaderObjs, err := attach.Load(attach.PinDir) if err != nil { t.Fatalf("attach.Load(attach.PinDir): %v", err) @@ -142,7 +157,7 @@ func TestResourceTrackerCleanup_UnregistersEBPFVRFEntry(t *testing.T) { const testBlock uint64 = 0x0102030405 const testArgument uint16 = 0x042 - if err := reg.VRF.Register(testBlock, testArgument, 0x2A2A2A, usidmap.EgressKindVeth); err != nil { + if err := reg.VRF.Register(testBlock, testArgument, vrfTableID, usidmap.EgressKindVeth); err != nil { t.Fatalf("seed vrf_table entry: %v", err) } if _, ok, err := reg.VRF.Get(testBlock, testArgument); err != nil || !ok { @@ -163,3 +178,68 @@ func TestResourceTrackerCleanup_UnregistersEBPFVRFEntry(t *testing.T) { t.Errorf("vrf_table entry after cleanup: ok=%v err=%v, want ok=false (unregistered)", ok, err) } } + +// TestResourceTrackerCleanup_LeavesEBPFVRFEntryOwnedByAnotherAttachment +// covers the race this fix closes: retryK8sOps can re-run +// publishBGPStateK8s's whole closure on a later attempt without +// re-registering the eBPF entry (registerEBPFDatapath only runs again if +// that attempt gets that far), so by the time a later attempt's +// checkArgumentCollision failure triggers this rollback, the (block, +// argument) slot this attachment originally wrote may have since been +// overwritten by the very other attachment the collision was detected +// against -- unregistering unconditionally would delete a live +// attachment's forwarding entry instead of this rolled-back one's own. If +// the current entry's VRFTableID no longer matches this attachment's own +// (recomputed fresh, not read from the tracker), cleanup must leave it in +// place. +func TestResourceTrackerCleanup_LeavesEBPFVRFEntryOwnedByAnotherAttachment(t *testing.T) { + requireRoot(t) + + if err := vrf.Add(testVPC, testAttachment); err != nil { + t.Fatalf("vrf.Add: %v", err) + } + t.Cleanup(func() { _ = vrf.Delete(testVPC, testAttachment) }) + + loaderObjs, err := attach.Load(attach.PinDir) + if err != nil { + t.Fatalf("attach.Load(attach.PinDir): %v", err) + } + t.Cleanup(func() { _ = loaderObjs.Close() }) + t.Cleanup(func() { _ = os.RemoveAll(attach.PinDir) }) + + reg, closer, err := usidmap.OpenPinnedRegistry(attach.PinDir) + if err != nil { + t.Fatalf("OpenPinnedRegistry(attach.PinDir): %v", err) + } + defer func() { _ = closer.Close() }() + + const testBlock uint64 = 0x0102030405 + const testArgument uint16 = 0x042 + const anotherAttachmentsVRFTableID uint32 = 0x9999 + + // Simulate the colliding attachment having since overwritten this same + // (block, argument) slot with its own, different VRF table id. + if err := reg.VRF.Register(testBlock, testArgument, anotherAttachmentsVRFTableID, usidmap.EgressKindVeth); err != nil { + t.Fatalf("seed vrf_table entry: %v", err) + } + + tracker := &resourceTracker{ + vpc: testVPC, + vpcAttachment: testAttachment, + namespace: "ebpf-cleanup-test", + ebpfRegistered: true, + ebpfBlock: testBlock, + ebpfArgument: testArgument, + } + tracker.cleanup(t.Context()) + + entry, ok, err := reg.VRF.Get(testBlock, testArgument) + if err != nil || !ok { + t.Fatalf("vrf_table entry after cleanup: ok=%v err=%v, want ok=true (must survive, it's not this attachment's)", + ok, err) + } + if entry.VRFTableID != anotherAttachmentsVRFTableID { + t.Errorf("vrf_table entry VRFTableID after cleanup = %#x, want unchanged %#x", + entry.VRFTableID, anotherAttachmentsVRFTableID) + } +} diff --git a/internal/cni/bgp_test.go b/internal/cni/bgp_test.go index 9acb533..3b4195a 100644 --- a/internal/cni/bgp_test.go +++ b/internal/cni/bgp_test.go @@ -201,6 +201,62 @@ func TestAllocateArgument(t *testing.T) { }) } +// ---- checkArgumentCollision ------------------------------------------------- + +// TestCheckArgumentCollision guards against a regression of the fix where a +// lexicographic-name tie-break let exactly one of two colliding instances +// "win" without ever proving the other side's check would run after this +// one's create -- concurrent create+check interleaving could let both sides +// pass. Detection must not depend on name ordering: it must fire regardless +// of whether the other instance's name sorts before or after this one's. +func TestCheckArgumentCollision(t *testing.T) { + const ( + namespace = "default" + routerName = "router-a" + vrfID = int32(42) + ) + + t.Run("no collision when no other instance shares the VRFID", func(t *testing.T) { + other := vrfInstanceForRouter("other-att", namespace, routerName, vrfID+1) + k8s := fakeClient(other) + if err := checkArgumentCollision(context.Background(), k8s, namespace, routerName, "this-att", vrfID); err != nil { + t.Errorf("checkArgumentCollision() = %v, want nil", err) + } + }) + + t.Run("detects collision when the other name sorts before this one", func(t *testing.T) { + colliding := vrfInstanceForRouter("aaa-att", namespace, routerName, vrfID) + k8s := fakeClient(colliding) + if err := checkArgumentCollision(context.Background(), k8s, namespace, routerName, "zzz-att", vrfID); err == nil { + t.Error("checkArgumentCollision() = nil, want a collision error") + } + }) + + t.Run("detects collision when the other name sorts after this one", func(t *testing.T) { + colliding := vrfInstanceForRouter("zzz-att", namespace, routerName, vrfID) + k8s := fakeClient(colliding) + if err := checkArgumentCollision(context.Background(), k8s, namespace, routerName, "aaa-att", vrfID); err == nil { + t.Error("checkArgumentCollision() = nil, want a collision error") + } + }) + + t.Run("ignores a same-VRFID instance under a different router", func(t *testing.T) { + differentRouter := vrfInstanceForRouter("other-router-att", namespace, "other-router", vrfID) + k8s := fakeClient(differentRouter) + if err := checkArgumentCollision(context.Background(), k8s, namespace, routerName, "this-att", vrfID); err != nil { + t.Errorf("checkArgumentCollision() = %v, want nil", err) + } + }) + + t.Run("ignores this instance's own entry", func(t *testing.T) { + self := vrfInstanceForRouter("this-att", namespace, routerName, vrfID) + k8s := fakeClient(self) + if err := checkArgumentCollision(context.Background(), k8s, namespace, routerName, "this-att", vrfID); err != nil { + t.Errorf("checkArgumentCollision() = %v, want nil", err) + } + }) +} + // ---- egressKindForInterfaceType -------------------------------------------- func TestEgressKindForInterfaceType(t *testing.T) { diff --git a/internal/cni/resource.go b/internal/cni/resource.go index 75a6d3e..5810ecf 100644 --- a/internal/cni/resource.go +++ b/internal/cni/resource.go @@ -107,7 +107,15 @@ func (rt *resourceTracker) cleanup(ctx context.Context) { // deleted below, so it must be removed explicitly here and nowhere // else in the normal cmdDel path is expected to (design plan §5.1). if rt.ebpfRegistered { - if err := unregisterEBPFDatapath(rt.ebpfBlock, rt.ebpfArgument, attach.PinDir); err != nil { + // Recomputed fresh rather than cached at registration time: this is + // exactly the value unregisterEBPFDatapath needs to confirm the + // vrf_table slot still belongs to this attachment before deleting it + // (see its doc comment). The VRF interface itself isn't deleted + // until step 6 below, so it's still resolvable here. + if vrfTableID, err := vrf.TableID(rt.vpc, rt.vpcAttachment); err != nil { + slog.Error("Rollback: failed to resolve VRF table id, skipping eBPF vrf_table unregister", "err", err, + "vpc", rt.vpc, "vpcAttachment", rt.vpcAttachment) + } else if err := unregisterEBPFDatapath(rt.ebpfBlock, rt.ebpfArgument, vrfTableID, attach.PinDir); err != nil { slog.Error("Rollback: failed to unregister eBPF vrf_table entry", "err", err, "block", rt.ebpfBlock, "argument", rt.ebpfArgument) } else { diff --git a/internal/gc/gc.go b/internal/gc/gc.go index 42586d6..01594f1 100644 --- a/internal/gc/gc.go +++ b/internal/gc/gc.go @@ -374,6 +374,21 @@ func SweepEBPFVRFTable(ctx context.Context, k8s client.Client, namespace, nodeNa result.Errors++ return result } + if len(routers) == 0 { + // A node with any live eBPF-registered attachment at all necessarily + // has a BGPRouter targeting it -- registerEBPFDatapath requires one + // to run at all (internal/cni/bgp.go). Finding none here is + // indistinguishable from a transient listing/cache hiccup or the + // router having just been renamed/recreated, so it must not be + // treated the same as "genuinely zero live attachments": doing so + // would fold every entry into the below-cutoff, absent-from-live + // case and wipe the entire vrf_table -- every pod on this node -- + // on what may just be one bad tick. Skip this sweep instead; the + // next tick tries again once router listing is reliable again. + slog.Warn("GC: no BGPRouter found for node during eBPF vrf_table sweep, skipping reconcile this tick", + "nodeName", nodeName) + return result + } vrfInstList := &bgpv1alpha1.BGPVRFInstanceList{} if err := k8s.List(ctx, vrfInstList, client.InNamespace(namespace)); err != nil { diff --git a/internal/gc/gc_ebpf_test.go b/internal/gc/gc_ebpf_test.go index 49c6e25..914a54d 100644 --- a/internal/gc/gc_ebpf_test.go +++ b/internal/gc/gc_ebpf_test.go @@ -22,6 +22,11 @@ import ( bgpv1alpha1 "go.datum.net/network/api/v1alpha1" ) +// testRouteTarget is the import/export route target value shared by every +// test fixture's BGPVRFInstance in this file; its exact value has no +// bearing on the eBPF vrf_table sweep logic under test. +const testRouteTarget = "65000:1" + func requireRoot(t *testing.T) { t.Helper() if os.Geteuid() != 0 { @@ -87,8 +92,8 @@ func TestSweepEBPFVRFTable_RemovesStaleKeepsLive(t *testing.T) { Spec: bgpv1alpha1.BGPVRFInstanceSpec{ RouterTarget: bgpv1alpha1.RouterTarget{RouterRef: &bgpv1alpha1.RouterRef{Name: routerName}}, VRFID: liveVRFID, - ImportRouteTargets: []bgpv1alpha1.RouteTarget{{Value: "65000:1"}}, - ExportRouteTargets: []bgpv1alpha1.RouteTarget{{Value: "65000:1"}}, + ImportRouteTargets: []bgpv1alpha1.RouteTarget{{Value: testRouteTarget}}, + ExportRouteTargets: []bgpv1alpha1.RouteTarget{{Value: testRouteTarget}}, }, } // staleVRFID's BGPVRFInstance is deliberately NOT created -- only @@ -142,6 +147,73 @@ func TestSweepEBPFVRFTable_RemovesStaleKeepsLive(t *testing.T) { } } +// TestSweepEBPFVRFTable_NoRoutersForNodeSkipsSweep guards against a +// regression of the fix where routersForNode returning zero routers (e.g. a +// transient BGPRouter listing/cache hiccup, or the router being +// renamed/recreated between ticks) was indistinguishable from "genuinely +// nothing is live" -- causing every vrf_table entry on the node, including +// ones with a perfectly live BGPVRFInstance, to be reconciled away with no +// repair path. A tick that finds no router for this node at all must leave +// vrf_table untouched. +func TestSweepEBPFVRFTable_NoRoutersForNodeSkipsSweep(t *testing.T) { + requireRoot(t) + + const ( + namespace = "default" + nodeName = "node-a" + locator = "2001:db8:1::/48" + vrfID = int32(10) + ) + + // Deliberately no BGPRouter object at all for nodeName -- only the + // BGPVRFInstance exists, referencing a router name that isn't present. + inst := &bgpv1alpha1.BGPVRFInstance{ + ObjectMeta: metav1.ObjectMeta{Name: "orphaned-router-ref", Namespace: namespace}, + Spec: bgpv1alpha1.BGPVRFInstanceSpec{ + RouterTarget: bgpv1alpha1.RouterTarget{RouterRef: &bgpv1alpha1.RouterRef{Name: "router-a"}}, + VRFID: vrfID, + ImportRouteTargets: []bgpv1alpha1.RouteTarget{{Value: testRouteTarget}}, + ExportRouteTargets: []bgpv1alpha1.RouteTarget{{Value: testRouteTarget}}, + }, + } + k8s := fake.NewClientBuilder().WithScheme(gcTestScheme(t)).WithObjects(inst).Build() + + pinDir := fmt.Sprintf("/sys/fs/bpf/galactic-gcsweep-norouter-test-%d", os.Getpid()) + t.Cleanup(func() { _ = os.RemoveAll(pinDir) }) + loaderObjs, err := attach.Load(pinDir) + if err != nil { + t.Fatalf("attach.Load: %v", err) + } + t.Cleanup(func() { _ = loaderObjs.Close() }) + + reg, closer, err := usidmap.OpenPinnedRegistry(pinDir) + if err != nil { + t.Fatalf("OpenPinnedRegistry: %v", err) + } + defer func() { _ = closer.Close() }() + + block, err := blockFromLocator(t, locator) + if err != nil { + t.Fatalf("derive block: %v", err) + } + argument := uint16(vrfID) + if err := reg.VRF.Register(block, argument, 0x333333, usidmap.EgressKindVeth); err != nil { + t.Fatalf("seed entry: %v", err) + } + + result := SweepEBPFVRFTable(context.Background(), k8s, namespace, nodeName, pinDir) + if result.Errors != 0 { + t.Errorf("result.Errors = %d, want 0 (no-router is a skip, not an error)", result.Errors) + } + if result.EBPFVRFEntriesRemoved != 0 { + t.Errorf("result.EBPFVRFEntriesRemoved = %d, want 0 (must not reconcile against an empty live set)", + result.EBPFVRFEntriesRemoved) + } + if _, ok, err := reg.VRF.Get(block, argument); err != nil || !ok { + t.Errorf("entry after no-router sweep: ok=%v err=%v, want ok=true (must survive)", ok, err) + } +} + // blockFromLocator mirrors registerEBPFDatapath's/SweepEBPFVRFTable's own // locator-to-Block derivation, kept local to this test file to avoid // importing internal/cni (which would be a layering inversion) just for diff --git a/internal/installer/installer.go b/internal/installer/installer.go index 2fc966b..80bfa12 100644 --- a/internal/installer/installer.go +++ b/internal/installer/installer.go @@ -598,7 +598,8 @@ func Run(ctx context.Context, grpcHealthPort, metricsPort int) error { if ebpfState.objs == nil { continue } - healthErr := attach.Health(ebpfState.objs, ebpfState.ifaces) + h := attach.Handle{Objs: ebpfState.objs} + healthErr := h.Healthy() healthy := healthErr == nil if healthy != ebpfLastHealthy { if healthy { diff --git a/internal/plumbing/ebpf/prog/dropreason.go b/internal/plumbing/ebpf/prog/dropreason.go index 66ce62c..2e6a286 100644 --- a/internal/plumbing/ebpf/prog/dropreason.go +++ b/internal/plumbing/ebpf/prog/dropreason.go @@ -24,7 +24,10 @@ const ( DropReasonStripFailed uint32 = 4 DropReasonFibLookupFailed uint32 = 5 DropReasonRedirectFailed uint32 = 6 - DropReasonCount uint32 = 7 + DropReasonFibNoNeigh uint32 = 7 + DropReasonFibUnreachable uint32 = 8 + DropReasonFibFragNeeded uint32 = 9 + DropReasonCount uint32 = 10 ) // DropReasonNames maps each DropReason* index to a short, stable, @@ -39,4 +42,7 @@ var DropReasonNames = map[uint32]string{ DropReasonStripFailed: "strip_failed", DropReasonFibLookupFailed: "fib_lookup_failed", DropReasonRedirectFailed: "redirect_failed", + DropReasonFibNoNeigh: "fib_no_neigh", + DropReasonFibUnreachable: "fib_unreachable", + DropReasonFibFragNeeded: "fib_frag_needed", } diff --git a/internal/plumbing/ebpf/prog/usid.c b/internal/plumbing/ebpf/prog/usid.c index 36e6730..212faf2 100644 --- a/internal/plumbing/ebpf/prog/usid.c +++ b/internal/plumbing/ebpf/prog/usid.c @@ -262,6 +262,9 @@ enum drop_reason { DROP_REASON_STRIP_FAILED = 4, DROP_REASON_FIB_LOOKUP_FAILED = 5, DROP_REASON_REDIRECT_FAILED = 6, + DROP_REASON_FIB_NO_NEIGH = 7, + DROP_REASON_FIB_UNREACHABLE = 8, + DROP_REASON_FIB_FRAG_NEEDED = 9, __DROP_REASON_MAX, }; @@ -502,7 +505,14 @@ int usid_ingress(struct __sk_buff *skb) BPF_FIB_LOOKUP_DIRECT | BPF_FIB_LOOKUP_TBID); if (fib_rc != BPF_FIB_LKUP_RET_SUCCESS) { - count_drop(DROP_REASON_FIB_LOOKUP_FAILED); + if (fib_rc == BPF_FIB_LKUP_RET_NO_NEIGH) + count_drop(DROP_REASON_FIB_NO_NEIGH); + else if (fib_rc == BPF_FIB_LKUP_RET_UNREACHABLE || fib_rc == BPF_FIB_LKUP_RET_BLACKHOLE || fib_rc == BPF_FIB_LKUP_RET_PROHIBIT) + count_drop(DROP_REASON_FIB_UNREACHABLE); + else if (fib_rc == BPF_FIB_LKUP_RET_FRAG_NEEDED) + count_drop(DROP_REASON_FIB_FRAG_NEEDED); + else + count_drop(DROP_REASON_FIB_LOOKUP_FAILED); return TC_ACT_SHOT; } diff --git a/internal/plumbing/ebpf/prog/usid_bpfeb.o b/internal/plumbing/ebpf/prog/usid_bpfeb.o index da3878391409c92bffeb6b84491b904f889c6a67..0947358128d315ac2aaff86a82a76fe05d9d1da9 100644 GIT binary patch delta 2999 zcma);U2GIp6vxlZ?wva`y8}z1<--CKzPiOqO1qRQP`0HDZK$wmAnZevHWCmFnhpBU zhbFrwh&5JIxET9DVofw@3turWj$B(WquG(;1Oiff_~A`cB}Yz(@dGjkTE`=CxT zJM+8eWB%vdJ9l>ch0*NjCOt88rg57#dgesk@;3p#JAJJlyhFO)rRAx(HhktnNjs)4 zXjiN>4GPenRVVa|i=HO>#-LwsbgOCzy>4w_TC1*xa#l4{mFf(?WNV%?CJ3pk;X~G+ zz7hUVwc?uf1JfP#qc~)J#dJ$`8au5om~Kvv7|&uyQ%&i29KvUp9?+Fdf=qVlKAY~TSEHwt>C|aoPc}NA>zX||xA0S4cRo~w zstr}nM+?Xipb<|L<6Ks=Rom8of_h0B9h?0NIB2~t8Ss8)GQIfSXzs{~myfu|kGk=r z$DivzdDz{t!|h1#?%&QOm)_)J?@s(SkEu-d8N8s+7i6t zDRBkJq8fA8=7xNW{yff67DS%V#Czf~5F>G9oGy=Vf*4+hyo~t)hzZJwKAxR;IWqQ} zI8+1Tas)XY;Kj&;%=yb5_dz)9qZ0H=^I1$YZ` zf%!b-L|+vSOg$P9eI#Hy5YU4>6R?w@+W{s)#Q>9_y8%uj&jy$T&6PO^q7N0(BzY~n z2VwvPX=Z>?kQOTkpg#)ImIdr}$ZmjFBFB9WSA!^GE-lHN0&y1wYR8y!?|_)aeMHOh zz&MD9_%LlSz_rME=Ida@s*#78e*$9(B^>ivm|!#zPXrj(jCLu&Bxu^_oY)FRf&z-{ z@CF#$QIL*K)DeLK*Db<(u29?p(LnY_w%-Ax5BE;JHNdzi^<;qYHP^cWycO@$9_PSV z4@MSOt)B56Jf?>Y1enBdM~pNWgSe&P+u;c`&Vi99K~p|I5`3GvEV#)2?}KOz;r$$O z4#4;fjB`4^F2D}=!5G6u7Q$(u4j9B_U*@x10mk_nWH-QAaVXBb3ycC56ygp^Vq6oU zjBhUs3}umX)M8c`8s-Eqg7GC*6e?6OSMVr-SHa`>FbkH`V9c!n2$u!_3dV0Z#bFj~ z)_^q87Djx&vI|=*P?0By98j%jWxfG2jV%_uh|KRn_F)2%VLLGvK-;G$m=~=WW9ANv37;(s zFb6mC?$qG9QvYJ6=@ijFDIDZD@E4aWUcf|S_ju)HYkX%2WB%vzkH2zMazEtoDb$457Tna7V!WWx9N z7`<=?M^qC=dj}FU`70B zMLY{M_`Q#^ASVmv1r3u$xnk!en1G+hXt5&U-28+Ncc9!cCmFQkUm{o4#y9Ftda^6N zQ`5%PaQ&(^q!I+4^rgXr9uj0HJ~>uDA#pj>bpF|TKT1=VEExI$RlFe=Uf|$xTx7iC^&jw4Dt7$Yg n#Lgz#>^0l{K@(J0qQ!0)@KMgF;Y5p`XjD^)Hhp9A>=ySQ{Y$o; delta 2401 zcmZ9OUuauZ9LIn6=ALuzy}7CFx^?MRXRO_tc3nx?np7rj&`$PHSRRB0*+a#GMWRI5 zLy^i2qBBA#EoWkog7vKr6^ngnz_OJ&XniYl4+E#>Vd92Ef;z~9Z2r!@zjS8}B)Om8 zzx)0D&N(-kFD|`3KWGls7E-5;4;Ko(ogV=Fa_L$h1TT>YzS1X>#`_B^kB!&mim~pz ztc(ndF|)oP zpNMwkU3T8tBDx{R`IvK+==-%f-fP7ih3l>A6PV2LlTIqQ1UMnB=pUUCBx60HYycmJ zUk4e0DGT15ti)#IQp}dEExkt1+DOZmX^8LIu9~J-mz(X!_IVFm!W3210Pr5mM*Hcb z?3sW9*H9P$F@RVs=rJ(F4zK0+f8ojZo_|BK?o(ZVeue1(iNFc4AHVS}Ch$Ee^W87_ z7ekNv7HTU33(&@E#T8(sB>J(e~V>~beEs#YB70Ja=O&S~6^ zJVSgAm_Om7q4dNsaS*wzvDyyv8V@2@G(Lj7q;VE`S>rRvs~Qg@SBV$FXSsc7*xWU+ zoD#6DCE%=JNAoK|w>4IRY8op+_cYET*ELpx8cp_r@w%qr^*--2Q@E?GeO*}3@0S|!B;U?Ji7gUrcL4tneHIGzRfmBA68Eupbma;1N$8lRh6UVN5R1qz zO~}0oqKeN2cZB?pKy2WH%Oyd9AM3ePKl&VqTdl~&Fh2Swh#LNO`EDgNG&^!viD^^1 zo0RYwh<|a3q6y+VAR3A(-x~xwk3TlirO+QRJ~^W_axV||1$>S~sh)Qk?5Q?52hkcO zY=K?C4m>;LrXY`Q*gPd3O`v&>y)5Pb1@=dVT!Py~U zKj)l#-?{Hc*UU46$-$5|6*;{!>KZ(KctzO>*YU53#?O$wc{HL1d|LhT!08LKu7l!& z>#{fQk~y7rh{M{&{0+9g?3FPYZ4|5AuX?MwW|{cNo$?m3f}$<&d7r^@yTr$NPkJBn zz+c5;ecbyk>$dn#-{bw9byKt%o4q$!*GEqnEAk3_HoGZI9(=ltSv_KhQSaTvWliFd z?}xSk$~urjQj3!{O+tcI{%n3{Z+0nMpyek)m*;O2MfnF?H#}P zFjyUY*KeO-K`jolsY`zGQ?P0I6@)>%+qHHDk*u7UE0-&$snHz;Z_H2afA)p_)}aGd z=)j?;dXDU~Hf^$6<6C>ScC_|%Ztsrwbj5b}bnonnwY0Ujw06d<{(kFUDqA~S+P1gv z`p>#}*RJ-C){fm_adAn7a9P{ddA^G5Dg!z(U`ok{k-w#Q4V+b63Exp1g{KwA;2Fm$T%vje_b$vLN}Q4WY0l;wigU}K{}Ca#9O zBIn@zDy)!wQ|4>%kg;#W8}=HsUU4BDRlF8%SKJ84ZR7qgH;BrT1|_&a+Jbu(n{XdH zj{KrNteAZkHqr)NWjhVuVml3I*>9oVU5jTxI)y)HgF07tsNcVVUm^w77Et0ufc_J54E$ukrV7i!-^~5G}~Fg z5ZhS*z8vlOyu`diq=$Oh3E2Ij zho*2Vf4OfW$Cyv2M4@H%H z5%LN4l6m-E$$BWs-Us{dc~X?&fU}}8X9BETzUPzd*D*jVHz|apY~U+t%cKl@&3yc` zNt-mRxC9uSX3`}46kIox@{oxSazdv8i|t&}TJ}QxILJR~Qk>lZ2hW<+mot7a zXMBe3y!RvQB*weC-ZN>814j@jAd|9er=c0P(@+R?YQs@B=kbf^2~i%Z(&GNn)=;y{ zl@Uk7OP0zu<%X|W!lkv(qB`UsJrn-GlS<H= ql7SlW5YdfG=c4$T*&1o^_SVdCN*s+eYPAt@CDN=#SBi&`>c0Rv!MKJ1 delta 2380 zcmZA2Urbwd6bJCr+ulEgmJ}$^j=?&t(85NFuI)62HBJl<7;ECRF?+yBA{iK2!UHjw z+Yp@?#elypX$*<=jX2H7`T)t6fw}~IBXbW+oZ3W<+a!ieXZoPd@2{LYf70ak_H)kf z{O?^^Lb z>5@5}j)^htLVZtF-ww-|jK)Qe`yFqH$25scZrf|JJmR?L^_mKgyhq})=LPRJ^R{T! z*Sz1bw#1M6i{1^^RdL)n=DotYRC>?o^3-~(T*2JBc*aP26P!LQ+_k@i-6RjXf(6LW z+t^yL>7GUZom=|u1&pE!N+WubQ*34i>niy|_PPms4E3>+3GIv%not8t=N z(bj})ZBi!-JLv+pSYD?9`)fElty7477Y;7!6jL0AWrwo9*-cctq*GRLBRs`^)=f6m z4C$2rFF-Butl~I4uecX3C~k*~iZk$%;sJPBaS~o-FS>1_hOAEO4+6L-@TO8Qgn}(4 zA4mR%;$FC{xE;QwI0Nq}9)K&3ZJeSc0`iK=Q+69}cpjHPaSI$^FCssL@$znV#&;nf zQQQW{*i9ZH>$*-E_5j>|Q>Rh3eGUQRC!HoZP=o_JxJHWi!+G|X$TxWmnq~hiC$tPI zuq~{mJz~(3;wZeV*n&5AZBtqn#0)C)fOladVUUHhAy;?~t`A{_>}xU~!A-{g9)2uq z&{4%fIHR}^9#ou$vsL5zKWq?%M-0mG0O<%ktk{C5*r=s_<5)5KENn~~w9a-KzQJ}H zF0((ucy|={fV7RTuMp5?WpsDN+xQuO-=KhE4GyvQ;o)tbHz=Yw2*=pRkq>`nP(pDV zoMSr;PQbQ2#ZHH_Jm57}lymoFVMx_-okjU&R?>|AT$QWs@>Wz6tpp`#>%JSFW2h!F~nymra`r9B@wA zvNHizE}!#t_6I1?{xB(my=>s7bj73syBDv=+MGp2#Vzm>JBNJzNsHFmbJ8QvSybl0 z1_BM^7FpQK12_#u*v=_Vu$|{P!~P9>*ISlY@E;T>yQA8muQb!$?{XEy7d;0`fkQ=8 zB#uA@(i-#Vlq{;9>bdepE8NN$&@zh6WytP&GL&=ss5VK?n|9U8pXv_N-M_2 l9Ym)i)e6d+;*oU9yA$7KR-8_!wM?J*G~KUJOx#I#{tHvWQ}zG= diff --git a/internal/plumbing/ebpf/prog/usid_test.go b/internal/plumbing/ebpf/prog/usid_test.go index d102c05..49698ad 100644 --- a/internal/plumbing/ebpf/prog/usid_test.go +++ b/internal/plumbing/ebpf/prog/usid_test.go @@ -31,7 +31,10 @@ const ( dropReasonStripFailed = 4 dropReasonFibLookupFailed = 5 dropReasonRedirectFailed = 6 - dropReasonCount = 7 + dropReasonFibNoNeigh = 7 + dropReasonFibUnreachable = 8 + dropReasonFibFragNeeded = 9 + dropReasonCount = 10 ) const ( diff --git a/internal/plumbing/ebpf/usidmap/vrf.go b/internal/plumbing/ebpf/usidmap/vrf.go index c59acac..eace70e 100644 --- a/internal/plumbing/ebpf/usidmap/vrf.go +++ b/internal/plumbing/ebpf/usidmap/vrf.go @@ -186,6 +186,7 @@ func (t *VRFTable) List() ([]VRFEntry, error) { Argument: uint16(rawKey & (1<