diff --git a/AGENTS.md b/AGENTS.md index 8016a789..204af8e4 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/docs/agents/ARCHITECTURE.md b/docs/agents/ARCHITECTURE.md index 678ae246..3459a0ef 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 +│ ├── 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. @@ -315,53 +355,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. @@ -376,11 +424,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. @@ -398,8 +446,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. @@ -412,7 +462,9 @@ 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. +- **A registered locator claims its entire `Block:NodeID::/64`, not just the SIDs inside it.** Once `locator_table` has an entry for a Block/Node-ID pair, `usid.c`'s ingress program matches on the destination address's top 64 bits alone (`R1`/`R6`); any packet landing in that /64 whose Function nibble has no `function_table` entry is dropped (`TC_ACT_SHOT`, counted `DROP_REASON_UNKNOWN_FUNCTION`) rather than passed through to the normal stack — correct for a /64 reserved purely for SRv6 uSIDs, since nothing else should ever be addressed there, but silent for an operator who also numbers a real host/interface address (loopback, management IP, etc.) inside that same /64: that traffic matches the locator and is dropped with no signal beyond the `drop_reasons` Prometheus counter. Dedicate the uSID Block's /64 to SRv6 SIDs alone. --- @@ -420,22 +472,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 e810b623..4965aed2 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 7abd5e9c..8df0c32a 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 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 00000000..97f37b91 --- /dev/null +++ b/docs/ebpf-datapath-sequence.md @@ -0,0 +1,153 @@ +# eBPF uSID Datapath Sequence Diagrams + +Sequence diagrams for the eBPF/TC-BPF `uFMT 48+16` uSID datapath, 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/docs/vmtap-cni/configuration.md b/docs/vmtap-cni/configuration.md index d7cc2481..20b3c621 100644 --- a/docs/vmtap-cni/configuration.md +++ b/docs/vmtap-cni/configuration.md @@ -4,9 +4,7 @@ Unikraft microVM managed by `kraftlet` access to the pod's real Cilium-assigned identity. It has no VPC/VPCAttachment configuration and no Kubernetes API dependency — it never creates a `BGPAdvertisement`, never touches a VRF, and -these pods have no `vpc`/`vpcattachment`. See -[.local/kraftlet-cilium-tap-plan.md](../../.local/kraftlet-cilium-tap-plan.md) -for the full design. +these pods have no `vpc`/`vpcattachment`. > This plugin has not yet been validated against a real Cilium-managed cluster > — see [Open items / unvalidated caveats](#open-items--unvalidated-caveats) below @@ -136,6 +134,3 @@ cluster to confirm: - **kraftlet hand-off convention.** The `sandbox: CNI_CONTAINERID` convention described above is this plugin's own choice, not something confirmed against kraftlet's actual CRI/containerd integration. - -See [.local/kraftlet-cilium-tap-plan.md](../../.local/kraftlet-cilium-tap-plan.md) -section 7 for the full list. diff --git a/go.mod b/go.mod index 3f147376..3b678efb 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.35.0 // indirect golang.org/x/net v0.55.0 // indirect @@ -89,7 +90,7 @@ require ( 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 +100,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 96392dfe..eb1c522b 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= @@ -194,8 +198,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= @@ -261,8 +265,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/plumbing/ebpf/prog/doc.go b/internal/plumbing/ebpf/prog/doc.go new file mode 100644 index 00000000..ca07d495 --- /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 00000000..92ec85c4 --- /dev/null +++ b/internal/plumbing/ebpf/prog/dropreason.go @@ -0,0 +1,50 @@ +// 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 + DropReasonFibNoNeigh uint32 = 7 + DropReasonFibUnreachable uint32 = 8 + DropReasonFibFragNeeded uint32 = 9 + DropReasonUnexpectedNextHdr uint32 = 10 + DropReasonCount uint32 = 11 +) + +// 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", + DropReasonFibNoNeigh: "fib_no_neigh", + DropReasonFibUnreachable: "fib_unreachable", + DropReasonFibFragNeeded: "fib_frag_needed", + DropReasonUnexpectedNextHdr: "unexpected_nexthdr", +} diff --git a/internal/plumbing/ebpf/prog/usid.c b/internal/plumbing/ebpf/prog/usid.c new file mode 100644 index 00000000..5c45cab4 --- /dev/null +++ b/internal/plumbing/ebpf/prog/usid.c @@ -0,0 +1,671 @@ +//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; + +// Only used on the IPv4-inner decap path, to retag skb->protocol -- see +// the long comment at its call site (step 7) for why a helper is needed +// here at all instead of a plain assignment. +static long (*bpf_skb_change_proto)(struct __sk_buff *skb, __be16 proto, + __u64 flags) = (void *) BPF_FUNC_skb_change_proto; + +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 + +// IPv6 Next Header values (RFC 8200 §4 / IANA protocol numbers) that name +// an inner IPv4 or IPv6 packet directly -- i.e. no extension header sits +// between the outer uSID header and the real inner packet. Fixed kernel +// ABI, never change. +#define USID_IPPROTO_IPIP 4 +#define USID_IPPROTO_IPV6 41 + +// --------------------------------------------------------------------- +// 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_FIB_NO_NEIGH = 7, + DROP_REASON_FIB_UNREACHABLE = 8, + DROP_REASON_FIB_FRAG_NEEDED = 9, + // Outer ip6->nexthdr names neither IPIP(4) nor IPv6-in-IPv6(41) -- + // an extension header (Routing header/SRH, Fragment header, etc.) + // sits between the outer header and the real inner packet, so byte + // 40 is that extension header, not the inner packet's version + // nibble. Counted apart from DROP_REASON_UNKNOWN_INNER_VERSION, + // which this case would otherwise be silently folded into. + DROP_REASON_UNEXPECTED_NEXTHDR = 10, + __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). + // + // ip6->nexthdr must name the inner packet's AF directly (IPIP=4 or + // IPv6-in-IPv6=41) for byte 40 to legitimately be the inner packet's + // version nibble. Any other value means an extension header -- + // Routing header/SRH (43, e.g. from a peer still on full encap + // rather than uSID reduced encap), Fragment header (44), Destination + // Options (60), etc. -- sits between the outer header and the real + // inner packet, so byte 40 is that extension header's own first + // byte, not a version nibble. Reading it as one anyway would + // silently fold every such packet into DROP_REASON_UNKNOWN_INNER_VERSION, + // masking a distinct, actionable failure mode -- checked and counted + // apart here, before that peek. ip6->nexthdr itself is already + // covered by the `(void *) (ip6 + 1) > data_end` bounds check above, + // so no further bounds check is needed to read it. + if (ip6->nexthdr != USID_IPPROTO_IPIP && ip6->nexthdr != USID_IPPROTO_IPV6) { + count_drop(DROP_REASON_UNEXPECTED_NEXTHDR); + return TC_ACT_SHOT; + } + + // Peek the inner packet's version nibble now, on the still-unmutated + // outer header, rather than after stripping (as a prior version of + // this function did) -- step 7 below needs to know the AF *before* + // it decides how to strip, not after. + if ((void *) (ip6 + 1) + 1 > data_end) { + count_drop(DROP_REASON_MALFORMED_INNER); + return TC_ACT_SHOT; + } + + __u8 *inner_peek = (__u8 *) (ip6 + 1); + __u8 inner_version = (*inner_peek) >> 4; + + if (inner_version != 4 && inner_version != 6) { + count_drop(DROP_REASON_UNKNOWN_INNER_VERSION); + return TC_ACT_SHOT; + } + + // Step 7: strip the outer IPv6 header, exposing the inner + // IPv4/IPv6 packet (dual-stack, per uEnd.DT46 -- R5). + // + // An IPv6 inner packet is a plain 40-byte carve: outer and inner + // share the same skb->protocol (ETH_P_IPV6), so nothing else is + // needed -- this is the path a prior version of this function + // always took. + // + // An IPv4 inner packet needs skb->protocol changed from ETH_P_IPV6 + // to ETH_P_IP, and there is no direct way to do that: __sk_buff's + // protocol field is not in tc_cls_act_is_valid_access's BPF_WRITE + // whitelist (mark, tc_index, priority, tc_classid, cb[0..4], tstamp, + // queue_mapping -- confirmed against upstream net/core/filter.c; + // protocol is deliberately absent, and a direct `skb->protocol = ...` + // here is rejected at load with "invalid bpf_context access"). Left + // stale at ETH_P_IPV6, step 9's bpf_redirect_peer() hands the skb to + // the peer netns via skb_do_redirect()'s BPF_F_PEER branch, which + // only does skb->dev = dev and skb_scrub_packet() -- no + // eth_type_trans() to re-derive skb->protocol from the Ethernet + // header this function rewrites below (also confirmed against + // upstream: __netif_receive_skb_core()'s "another_round" re-entry on + // the peer device reuses skb->protocol as-is). The peer's IP stack + // then dispatches the IPv4 payload to ipv6_rcv(), which drops it on + // the version-nibble mismatch -- invisible to every counter in this + // file, since the packet has already left via a *successful* + // redirect. Confirmed live: an IPv4 uSID packet lands on the peer + // device (its RX byte/packet counters advance, and a raw AF_PACKET + // capture there shows a well-formed IPv4 frame) while + // Ip6InReceives/Ip6InHdrErrors in that netns's /proc/net/snmp6 both + // advance by exactly the same count, and the pod never sees it at + // the socket layer -- IPv4 InMsgs/InEchos in /proc/net/snmp never + // move. + // + // bpf_skb_change_proto() is the one BPF-legal way to update + // skb->protocol, but it is built for in-place v4<->v6 header + // translation (NAT64-style, RFC 6145), not encap/decap: called here + // while skb->protocol is still ETH_P_IPV6 (i.e. before any stripping + // -- exactly the point we're at), it removes + // sizeof(usid_ip6hdr)-sizeof(usid_iphdr) (20) bytes from the *front* + // of the current L3 header -- the first 20 bytes of our 40-byte + // outer header -- shifts everything after (the outer header's last + // 20 bytes, then our untouched real inner packet) up by 20 to fill + // the gap, and sets skb->protocol = ETH_P_IP. That leaves exactly + // sizeof(usid_iphdr) (20) bytes of outer-header leftover sitting in + // front of our real inner IPv4 header; the plain carve below removes + // exactly that leftover, exposing the real header at offset 0 same + // as the IPv6 case. Net bytes removed is sizeof(usid_ip6hdr) (40) + // either way -- change_proto's 20 plus this carve's 20 for the v4 + // case, this carve's 40 alone for the v6 case -- only the + // skb->protocol side effect differs. + __s32 strip_len = (__s32) sizeof(struct usid_ip6hdr); + + if (inner_version == 4) { + if (bpf_skb_change_proto(skb, __builtin_bswap16(USID_ETH_P_IP), 0)) { + count_drop(DROP_REASON_STRIP_FAILED); + return TC_ACT_SHOT; + } + strip_len = (__s32) sizeof(struct usid_iphdr); + } + + if (bpf_skb_adjust_room(skb, -strip_len, BPF_ADJ_ROOM_MAC, 0)) { + count_drop(DROP_REASON_STRIP_FAILED); + return TC_ACT_SHOT; + } + + // bpf_skb_change_proto/bpf_skb_adjust_room can both 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); + 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); + + // fib_params.tot_len is the L3 length the kernel's MTU check + // (step 8, below) compares against the egress route's MTU -- + // but only if it's nonzero; left at memset's zero, that check + // is silently skipped and BPF_FIB_LKUP_RET_FRAG_NEEDED can + // never fire. IPv6 has no total-length field of its own, so + // this is the fixed 40-byte header plus payload_len (host + // order; the wire field is big-endian). + fib_params.tot_len = (__u16) sizeof(struct usid_ip6hdr) + + __builtin_bswap16(inner6->payload_len); + } else { + 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); + + // Same fib_params.tot_len requirement as the IPv6 branch + // above, except IPv4 already carries its own total-length + // field (host order; the wire field is big-endian) -- no + // header-size arithmetic needed. + fib_params.tot_len = __builtin_bswap16(inner4->tot_len); + } + + 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) { + 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; + } + + __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 00000000..d978e4da --- /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 00000000..3838bc8e Binary files /dev/null and b/internal/plumbing/ebpf/prog/usid_bpfeb.o differ diff --git a/internal/plumbing/ebpf/prog/usid_bpfel.go b/internal/plumbing/ebpf/prog/usid_bpfel.go new file mode 100644 index 00000000..38358c0e --- /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 00000000..05228079 Binary files /dev/null and b/internal/plumbing/ebpf/prog/usid_bpfel.o differ diff --git a/internal/plumbing/ebpf/prog/usid_test.go b/internal/plumbing/ebpf/prog/usid_test.go new file mode 100644 index 00000000..edb6398e --- /dev/null +++ b/internal/plumbing/ebpf/prog/usid_test.go @@ -0,0 +1,728 @@ +// 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 + dropReasonFibNoNeigh = 7 + dropReasonFibUnreachable = 8 + dropReasonFibFragNeeded = 9 + dropReasonUnexpectedNextHdr = 10 + dropReasonCount = 11 +) + +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), 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 -- and innerExtHeaderSRH, which exercises the +// nexthdr gate that now runs before any of those: outer nexthdr names an +// extension header (Routing header/SRH) rather than IPIP/IPv6-in-IPv6, with +// an inner byte that would otherwise misparse as a valid IPv6 version +// nibble if that gate didn't reject the packet first. +type innerKind int + +const ( + innerNone innerKind = iota + innerV6 + innerV4 + innerUnknownVersion + innerMalformedTruncated + innerExtHeaderSRH +) + +// 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. nexthdr must now pass usid.c's gate (IPIP=4 or + // IPv6-in-IPv6=41) for the program to ever peek the inner byte below, + // so it's set per kind: 4 for the inner-IPv4 case, 43 (Routing + // header/SRH) for innerExtHeaderSRH specifically to fail that gate, + // and 41 (the common case) otherwise. + 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) + switch kind { + case innerV4: + pkt = append(pkt, 4) // nexthdr = IPIP + case innerExtHeaderSRH: + pkt = append(pkt, 43) // nexthdr = Routing header (SRH), not a direct inner AF + default: + pkt = append(pkt, 41) // nexthdr = IPv6-in-IPv6 + } + 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. + case innerExtHeaderSRH: + // A byte that looks exactly like a well-formed IPv6 inner + // header's version nibble (6) -- proving the program rejects + // this on outerNextHdr (43, set above) alone, before it ever + // gets here to read this byte as a version nibble. + pkt = append(pkt, 0x60, 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[:]...) + } + + 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_UnexpectedNextHdrDropped covers the nexthdr gate ahead of +// step 7's inner-header parse: outer ip6->nexthdr naming an extension +// header (here, a Routing header/SRH -- the shape a peer still on full +// encap, rather than uSID reduced encap, would send) must be dropped and +// counted as DROP_REASON_UNEXPECTED_NEXTHDR, distinctly from +// DROP_REASON_UNKNOWN_INNER_VERSION -- even though the byte sitting at the +// inner-header offset is a well-formed IPv6 version nibble that would +// otherwise pass the version check. Before this gate existed, this exact +// packet shape was misread as the inner packet itself and produced no +// distinguishable signal from a genuinely malformed/garbled inner header. +func TestUsidIngress_UnexpectedNextHdrDropped(t *testing.T) { + requireRoot(t) + objs := loadObjects(t) + + usid := testUSID{block: baseUSID.block, nodeID: baseUSID.nodeID, function: uformat.FunctionEndDT46, argument: 0x127} + 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: 0x2E2E2E}); err != nil { + t.Fatalf("populate vrf_table: %v", err) + } + + pkt := buildPacketWithInner(t, usid.addr(t), netip.MustParseAddr("2001:db8::1"), innerExtHeaderSRH) + + 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, dropReasonUnexpectedNextHdr, 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 00000000..040a4b06 --- /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<