diff --git a/cmd/galactic-router/main.go b/cmd/galactic-router/main.go index 6015b843..66de1801 100644 --- a/cmd/galactic-router/main.go +++ b/cmd/galactic-router/main.go @@ -26,6 +26,9 @@ const ( resourceBGPPolicies = "bgppolicies" resourceBGPVRFInstances = "bgpvrfinstances" resourceServiceVIPBindings = "servicevipbindings" + resourceServiceEndpoints = "serviceendpoints" + resourceServiceRoutePolicy = "serviceroutepolicies" + resourceVPCAttachments = "vpcattachments" resourceSecrets = "secrets" resourceNodes = "nodes" ) @@ -65,6 +68,9 @@ func checkWatchPermissions(mgr ctrl.Manager) { {group: bgpAPIGroup, version: bgpAPIVersion, resource: resourceBGPPolicies}, {group: bgpAPIGroup, version: bgpAPIVersion, resource: resourceBGPVRFInstances}, {group: bgpAPIGroup, version: bgpAPIVersion, resource: resourceServiceVIPBindings}, + {group: bgpAPIGroup, version: bgpAPIVersion, resource: resourceServiceEndpoints}, + {group: bgpAPIGroup, version: bgpAPIVersion, resource: resourceServiceRoutePolicy}, + {group: "cloud.datumapis.com", version: "v1alpha1", resource: resourceVPCAttachments}, {version: "v1", resource: resourceSecrets}, {version: "v1", resource: resourceNodes}, } diff --git a/cmd/galactic-router/root.go b/cmd/galactic-router/root.go index 5e9e43ee..9461838f 100644 --- a/cmd/galactic-router/root.go +++ b/cmd/galactic-router/root.go @@ -18,6 +18,7 @@ import ( grpchealth "google.golang.org/grpc/health" "google.golang.org/grpc/health/grpc_health_v1" "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" utilruntime "k8s.io/apimachinery/pkg/util/runtime" clientgoscheme "k8s.io/client-go/kubernetes/scheme" ctrl "sigs.k8s.io/controller-runtime" @@ -25,6 +26,7 @@ import ( metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" "sigs.k8s.io/controller-runtime/pkg/webhook" + cloudv1alpha1 "go.datum.net/cloud/api/v1alpha1" "go.datum.net/galactic/internal/config" "go.datum.net/galactic/internal/controller" "go.datum.net/galactic/internal/hash" @@ -35,6 +37,7 @@ import ( "go.datum.net/galactic/internal/reconcile" galacticruntime "go.datum.net/galactic/internal/runtime" "go.datum.net/galactic/internal/runtime/gobgp" + "go.datum.net/galactic/internal/serviceroute" networkwebhook "go.datum.net/galactic/internal/webhook" bgpv1alpha1 "go.datum.net/network/api/v1alpha1" ) @@ -80,6 +83,7 @@ func runCmd(cfg *config.RouterConfig) error { scheme := runtime.NewScheme() utilruntime.Must(clientgoscheme.AddToScheme(scheme)) utilruntime.Must(bgpv1alpha1.AddToScheme(scheme)) + utilruntime.Must(cloudv1alpha1.AddToScheme(scheme)) mgrOptions := ctrl.Options{ Scheme: scheme, @@ -268,6 +272,16 @@ func runCmd(cfg *config.RouterConfig) error { return fmt.Errorf("setup ServiceVIPBinding controller: %w", err) } + // Register the node-local platform service route controller. It resolves + // ServiceRoutePolicy against Cloud VPCAttachment.status.node and programs + // only this node's local eBPF state. Clear any pinned state left by a prior + // controller process before informer reconciliation starts. A missing map is + // tolerated because the datapath loader may start after galactic-router; + // the first Apply retries initialization. + if err := setupServiceRouteController(mgr, nodeName, cfg.GCNamespace); err != nil { + return fmt.Errorf("setup ServiceRoutePolicy controller: %w", err) + } + // Register GC controller for cleaning up orphaned BGP CRDs and VRFs. gcRec := &controller.GCReconciler{ Client: mgr.GetClient(), @@ -332,6 +346,22 @@ func runCmd(cfg *config.RouterConfig) error { return nil } +func setupServiceRouteController(mgr ctrl.Manager, nodeName, bgpNamespace string) error { + programmer := &serviceroute.EBPFRouteProgrammer{} + if err := programmer.Initialize(); err != nil { + ctrl.Log.Error(err, "service route eBPF maps are not available yet; initialization will retry on reconcile") + } + + return (&controller.ServiceRoutePolicyReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + NodeName: nodeName, + BGPNamespace: bgpNamespace, + Programmer: programmer, + Applied: make(map[types.NamespacedName]map[types.NamespacedName]serviceroute.RouteIntent), + }).SetupWithManager(mgr) +} + // newRootCommand builds the root cobra command with all flags and the // application startup logic. func newRootCommand() *cobra.Command { diff --git a/config/galactic-cni/daemonset.yaml b/config/galactic-cni/daemonset.yaml index b99334eb..240a7a52 100644 --- a/config/galactic-cni/daemonset.yaml +++ b/config/galactic-cni/daemonset.yaml @@ -140,6 +140,10 @@ spec: mountPath: /host/var/log/galactic - name: bpf-fs mountPath: /sys/fs/bpf + # Shared with host-executed CNI ADD so both processes take + # the same per-interface TC transaction lock. + - name: galactic-run + mountPath: /run/galactic # radv's DefaultStateDir, shared with galactic-tap on the host -- # see config/galactic-cni/README.md. Writable: the run daemon # removes the record of a tap that has gone missing. @@ -202,3 +206,7 @@ spec: hostPath: path: /sys/fs/bpf type: Directory + - name: galactic-run + hostPath: + path: /run/galactic + type: DirectoryOrCreate diff --git a/config/galactic-router/rbac.yaml b/config/galactic-router/rbac.yaml index 8e312570..a0286d84 100644 --- a/config/galactic-router/rbac.yaml +++ b/config/galactic-router/rbac.yaml @@ -33,6 +33,11 @@ rules: resources: - servicevipbindings verbs: ["get", "list", "watch", "update", "patch"] + - apiGroups: ["network.datumapis.com"] + resources: + - serviceendpoints + - serviceroutepolicies + verbs: ["get", "list", "watch"] - apiGroups: ["network.datumapis.com"] resources: - bgprouters/status @@ -41,7 +46,12 @@ rules: - bgppolicies/status - bgpvrfinstances/status - servicevipbindings/status + - serviceroutepolicies/status verbs: ["get", "update", "patch"] + - apiGroups: ["cloud.datumapis.com"] + resources: + - vpcattachments + verbs: ["get", "list", "watch"] - apiGroups: [""] resources: - secrets diff --git a/docs/README.md b/docs/README.md index 699c41cc..b9bf5460 100644 --- a/docs/README.md +++ b/docs/README.md @@ -32,6 +32,9 @@ data flow, module reference, known constraints): - [router/configuration.md](router/configuration.md) — `galactic-router` env vars/CLI flags, the webhook options, and DaemonSet examples. +- [router/private-service-routes.md](router/private-service-routes.md) — the + direct-endpoint contract for `ServiceEndpoint`, producer selection, and why + consumers receive no guest-visible service route. - [gateway/configuration.md](gateway/configuration.md) — `galactic-gateway` deployment (node labeling, RBAC, per-node overlay), its config reference, and the `NetworkGateway`/`NetworkRule`/`ServiceVIPBinding` CRD fields. diff --git a/docs/router/private-service-routes.md b/docs/router/private-service-routes.md new file mode 100644 index 00000000..40adf08a --- /dev/null +++ b/docs/router/private-service-routes.md @@ -0,0 +1,65 @@ +# Private service routes + +`ServiceEndpoint` uses a direct-endpoint contract. Its declared +`spec.address`, `spec.protocol`, and `spec.port` are the exact destination tuple +that the selected producer attachment must accept. Galactic does not interpret +the address as a Kubernetes Service frontend: it does not discover +EndpointSlices, select a backend address, or translate the destination before +delivery. + +The controller that publishes the `ServiceEndpoint` owns the service +implementation and health. It must ensure every attachment selected by +`attachmentRef` or `attachmentSelector` is ready to receive the declared tuple. +`VPCAttachment`'s `Ready` and `Programmed` conditions are necessary networking +prerequisites; they do not attest that a service listener is healthy. A +listener may bind or configure the address in the producer, use a transparent +socket, or use another service-owned mechanism. Galactic does not configure the +producer VIP; that mechanism and its lifecycle are owned by the service. +Whatever mechanism is used must make the exact VIP routable through the +selected producer attachment and neighbor-resolvable on that attachment. A +transparent socket bind by itself is insufficient when the producer VPC's FIB +or neighbor discovery cannot deliver the VIP to that interface. + +Galactic selects one ready producer for each consumer. A node-local producer is +preferred and ties are resolved deterministically by attachment namespace and +name. More than one ready producer in the same node and VPC is rejected because +the direct-VIP dataplane cannot distinguish those replicas. Replicas on +different nodes or in distinct VPCs remain valid. `NodeLocal` stops there when +no local producer is ready; `PreferNodeLocal` may select a remote producer and +carries the original packet through the SRv6 service tunnel. In both cases the +producer receives the declared destination and the original consumer source +unchanged. + +## No consumer-visible route + +Galactic does not add the service address or a service-specific route to a +consumer, and does not inject a route into a consumer guest. Consumers send the +packet through their ordinary default gateway. The attachment's TC-eBPF +classifier matches the exact service tuple and redirects it to the selected +producer; unauthorized ports are dropped. Galactic also does not install a +host route for the service address. The producer service may still need to +configure or bind the address locally so its own network stack or listener +accepts the packet; that producer-side configuration is not consumer-visible. + +Operators should therefore expect all of the following: + +- `ip route` inside a consumer contains no route for the service address; +- the node has no service-address route created by the service-route + controller; +- the producer receives the original service destination rather than a hidden + backend address; and +- publishing an address that the producer does not accept produces an + unavailable service, not automatic backend discovery or translation. + +## Not a ServiceVIPBinding + +`ServiceVIPBinding` belongs to Galactic's gateway DSR dataplane. It explicitly +maps a gateway frontend VIP and port to a real backend address and port, and its +controller programs the separate VIP translation table. A `ServiceEndpoint` +does not reference a `ServiceVIPBinding`, and the private service-route +dataplane does not consult that translation to discover a backend. Reusing the +same address in both APIs does not connect the two mechanisms. + +If a service needs a distinct frontend and backend address or port, its API +must identify that backend explicitly and define its selection and health +semantics. `ServiceEndpoint` intentionally carries no such fields. diff --git a/go.mod b/go.mod index fd7dd82b..4eb05486 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module go.datum.net/galactic -go 1.26.0 +go 1.26.4 require ( github.com/cilium/ebpf v0.22.0 @@ -20,7 +20,8 @@ require ( github.com/spf13/pflag v1.0.10 github.com/spf13/viper v1.21.0 github.com/vishvananda/netlink v1.3.2-0.20261002201547-1d1c62507e3c - go.datum.net/network v0.3.0 + go.datum.net/cloud v0.1.1-0.20260918213150-260f797bbc5c + go.datum.net/network v0.3.1-0.20261006185716-0f099eb3b0ea golang.org/x/sys v0.48.0 golang.org/x/term v0.46.0 google.golang.org/grpc v1.84.0 @@ -42,20 +43,30 @@ require ( github.com/eapache/queue v1.1.0 // indirect github.com/emicklei/go-restful/v3 v3.13.0 // indirect github.com/evanphx/json-patch/v5 v5.9.11 // indirect - github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/fsnotify/fsnotify v1.10.1 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/gaissmai/bart v0.26.1 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/zapr v1.3.0 // indirect - github.com/go-openapi/jsonpointer v0.21.0 // indirect - github.com/go-openapi/jsonreference v0.20.2 // indirect - github.com/go-openapi/swag v0.23.0 // indirect + github.com/go-openapi/jsonpointer v0.23.1 // indirect + github.com/go-openapi/jsonreference v0.21.6 // indirect + github.com/go-openapi/swag v0.26.0 // indirect + github.com/go-openapi/swag/cmdutils v0.26.0 // indirect + github.com/go-openapi/swag/conv v0.26.0 // indirect + github.com/go-openapi/swag/fileutils v0.26.0 // indirect + github.com/go-openapi/swag/jsonname v0.26.0 // indirect + github.com/go-openapi/swag/jsonutils v0.26.0 // indirect + github.com/go-openapi/swag/loading v0.26.0 // indirect + github.com/go-openapi/swag/mangling v0.26.0 // indirect + github.com/go-openapi/swag/netutils v0.26.0 // indirect + github.com/go-openapi/swag/stringutils v0.26.0 // indirect + github.com/go-openapi/swag/typeutils v0.26.0 // indirect + github.com/go-openapi/swag/yamlutils v0.26.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect - github.com/google/gnostic-models v0.7.0 // indirect + github.com/google/gnostic-models v0.7.1 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/k-sone/critbitgo v1.4.0 // indirect github.com/kylelemons/godebug v1.1.0 // indirect @@ -93,18 +104,17 @@ require ( golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sync v0.22.0 // indirect golang.org/x/text v0.41.0 // indirect - golang.org/x/time v0.14.0 // indirect + golang.org/x/time v0.15.0 // indirect golang.org/x/tools v0.48.0 // indirect - gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect + gomodules.xyz/jsonpatch/v2 v2.5.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260706201446-f0a921348800 // indirect gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect honnef.co/go/tools v0.7.0 // indirect - k8s.io/apiextensions-apiserver v0.36.0 // indirect + k8s.io/apiextensions-apiserver v0.36.1 // indirect k8s.io/klog/v2 v2.140.0 // indirect - k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a // indirect - k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 // indirect + k8s.io/kube-openapi v0.0.0-20260330154417-16be699c7b31 // indirect + k8s.io/utils v0.0.0-20260707023825-cf1189d6abe3 // indirect sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect sigs.k8s.io/knftables v0.0.18 // indirect sigs.k8s.io/randfill v1.0.0 // indirect diff --git a/go.sum b/go.sum index ae86116e..c2b7db66 100644 --- a/go.sum +++ b/go.sum @@ -15,7 +15,6 @@ github.com/containernetworking/plugins v1.9.1/go.mod h1:fj7kS55qg3o/RgS+WGsF3+Zx github.com/coreos/go-iptables v0.8.0 h1:MPc2P89IhuVpLI7ETL/2tx3XZ61VeICZjYqDEgNsPRc= github.com/coreos/go-iptables v0.8.0/go.mod h1:Qe8Bv2Xik5FyTXwgIbLAnv2sWSBmvWdFETJConOQ//Q= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= -github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= @@ -34,8 +33,8 @@ github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjT github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= -github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= -github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= +github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/gaissmai/bart v0.26.1 h1:+w4rnLGNlA2GDVn382Tfe3jOsK5vOr5n4KmigJ9lbTo= @@ -46,14 +45,40 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= -github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= -github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= -github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= -github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE= -github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= -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-openapi/jsonpointer v0.23.1 h1:1HBACs7XIwR2RcmItfdSFlALhGbe6S92p0ry4d1GWg4= +github.com/go-openapi/jsonpointer v0.23.1/go.mod h1:iWRmZTrGn7XwYhtPt/fvdSFj1OfNBngqRT2UG3BxSqY= +github.com/go-openapi/jsonreference v0.21.6 h1:NZ5nGfnaM1n4I43Xjm1e5/M2GjOwQwndQz22uhxwD+Y= +github.com/go-openapi/jsonreference v0.21.6/go.mod h1:xzbgtQ3ZbWxvET3AxdzCJlJt6vkovbf+IfSPJjD0tUY= +github.com/go-openapi/swag v0.26.0 h1:GVDXCmfvhfu1BxiHo8/FA+BbKmhecHnG3varjON5/RI= +github.com/go-openapi/swag v0.26.0/go.mod h1:82g3193sZJRbocs7bNCqGfIgq8pkuwVwCfhKIRlEQF0= +github.com/go-openapi/swag/cmdutils v0.26.0 h1:iowihOcvq7y4egO8cOq0dmfohz6wfeQ63U1EnuhO2TU= +github.com/go-openapi/swag/cmdutils v0.26.0/go.mod h1:Sm1MVFMkF6guJJ+pQqHnQA3N0j9qALV3NxzDSv6bETM= +github.com/go-openapi/swag/conv v0.26.0 h1:5yGGsPYI1ZCva93U0AoKi/iZrNhaJEjr324YVsiD89I= +github.com/go-openapi/swag/conv v0.26.0/go.mod h1:tpAmIL7X58VPnHHiSO4uE3jBeRamGsFsfdDeDtb5ECE= +github.com/go-openapi/swag/fileutils v0.26.0 h1:WJoPRvsA7QRiiWluowkLJa9jaYR7FCuxmDvnCgaRRxU= +github.com/go-openapi/swag/fileutils v0.26.0/go.mod h1:0WDJ7lp67eNjPMO50wAWYlKvhOb6CQ37rzR7wrgI8Tc= +github.com/go-openapi/swag/jsonname v0.26.0 h1:gV1NFX9M8avo0YSpmWogqfQISigCmpaiNci8cGECU5w= +github.com/go-openapi/swag/jsonname v0.26.0/go.mod h1:urBBR8bZNoDYGr653ynhIx+gTeIz0ARZxHkAPktJK2M= +github.com/go-openapi/swag/jsonutils v0.26.0 h1:FawFML2iAXsPqmERscuMPIHmFsoP1tOqWkxBaKNMsnA= +github.com/go-openapi/swag/jsonutils v0.26.0/go.mod h1:2VmA0CJlyFqgawOaPI9psnjFDqzyivIqLYN34t9p91E= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.26.0 h1:apqeINu/ICHouqiRZbyFvuDge5jCmmLTqGQ9V95EaOM= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.26.0/go.mod h1:AyM6QT8uz5IdKxk5akv0y6u4QvcL9GWERt0Jx/F/R8Y= +github.com/go-openapi/swag/loading v0.26.0 h1:Apg6zaKhCJurpJer0DCxq99qwmhFddBhaMX7kilDcko= +github.com/go-openapi/swag/loading v0.26.0/go.mod h1:dBxQ/6V2uBaAQdevN18VELE6xSpJWZxLX4txe12JwDg= +github.com/go-openapi/swag/mangling v0.26.0 h1:Du2YC4YLA/Y5m/YKQd7AnY5qq0wRKSFZTTt8ktFaXcQ= +github.com/go-openapi/swag/mangling v0.26.0/go.mod h1:jifS7W9vbg+pw63bT+GI53otluMQL3CeemuyCHKwVx0= +github.com/go-openapi/swag/netutils v0.26.0 h1:CmZp+ZT7HrmFwrC3GdGsXBq2+42T1bjKBapcqVpIs3c= +github.com/go-openapi/swag/netutils v0.26.0/go.mod h1:5iK+Ok3ZohWWex1C50BFTPexi03UaPwjW4Oj8kgrpwo= +github.com/go-openapi/swag/stringutils v0.26.0 h1:qZQngLxs5s7SLijc3N2ZO+fUq2o8LjuWAASSrJuh+xg= +github.com/go-openapi/swag/stringutils v0.26.0/go.mod h1:sWn5uY+QIIspwPhvgnqJsH8xqFT2ZbYcvbcFanRyhFE= +github.com/go-openapi/swag/typeutils v0.26.0 h1:2kdEwdiNWy+JJdOvu5MA2IIg2SylWAFuuyQIKYybfq4= +github.com/go-openapi/swag/typeutils v0.26.0/go.mod h1:oovDuIUvTrEHVMqWilQzKzV4YlSKgyZmFh7AlfABNVE= +github.com/go-openapi/swag/yamlutils v0.26.0 h1:H7O8l/8NJJQ/oiReEN+oMpnGMyt8G0hl460nRZxhLMQ= +github.com/go-openapi/swag/yamlutils v0.26.0/go.mod h1:1evKEGAtP37Pkwcc7EWMF0hedX0/x3Rkvei2wtG/TbU= +github.com/go-openapi/testify/enable/yaml/v2 v2.4.2 h1:5zRca5jw7lzVREKCZVNBpysDNBjj74rBh0N2BGQbSR0= +github.com/go-openapi/testify/enable/yaml/v2 v2.4.2/go.mod h1:XVevPw5hUXuV+5AkI1u1PeAm27EQVrhXTTCPAF85LmE= +github.com/go-openapi/testify/v2 v2.5.1 h1:TMdhCaw8fUNraVSf3Omoob1dO/AzBfhtFAPW0an6sBo= +github.com/go-openapi/testify/v2 v2.5.1/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw= 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= @@ -64,8 +89,8 @@ github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9L github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= -github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= -github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= +github.com/google/gnostic-models v0.7.1 h1:SisTfuFKJSKM5CPZkffwi6coztzzeYUhc3v4yxLWH8c= +github.com/google/gnostic-models v0.7.1/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= @@ -77,7 +102,6 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/jsimonetti/rtnetlink/v2 v2.0.1 h1:xda7qaHDSVOsADNouv7ukSuicKZO7GgVUCXxpaIEIlM= github.com/jsimonetti/rtnetlink/v2 v2.0.1/go.mod h1:7MoNYNbb3UaDHtF8udiJo/RH6VsTKP1pqKLUTVCvToE= @@ -89,11 +113,8 @@ github.com/kenshaw/baseconv v0.1.1 h1:oAu/C7ipUT2PqT9DT0mZDGDg4URIglizZMjPv9oCu0 github.com/kenshaw/baseconv v0.1.1/go.mod h1:yy9zGmnnR6vgOxOQb702nVdAG30JhyYZpj/5/m0siRI= github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk= github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= -github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= -github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= @@ -102,7 +123,6 @@ github.com/lithammer/dedent v1.1.0 h1:VNzHMVCBNG1j0fh3OrsFRkVUwStdDArbgBWoPAffkt github.com/lithammer/dedent v1.1.0/go.mod h1:jrXYCQtgg0nJiN+StA2KgR7w6CiQNv9Fd/Z9BP0jIOc= github.com/lorenzosaino/go-sysctl v0.3.1 h1:3phX80tdITw2fJjZlwbXQnDWs4S30beNcMbw0cn0HtY= github.com/lorenzosaino/go-sysctl v0.3.1/go.mod h1:5grcsBRpspKknNS1qzt1eIeRDLrhpKZAtz8Fcuvs1Rc= -github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mdlayher/genetlink v1.4.0 h1:f/Xs7Y2T+GyX9b3dbiUhnLE9InGs5F9RxJ2JwBMl71o= github.com/mdlayher/genetlink v1.4.0/go.mod h1:d1hrKr8fwZU2JkcAtQUAzeTrI7nbgQSl+5k1cC0biSA= @@ -168,14 +188,9 @@ github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3A github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= -github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= @@ -188,8 +203,18 @@ github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zd github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= -go.datum.net/network v0.3.0 h1:QdG5wqnqA1BvN1rn536flZmhTw/OrQuRUahLYQLzxuk= -go.datum.net/network v0.3.0/go.mod h1:dqzM8WZczbiZ9bCvsxjkoI10GJqQ24NVWnc9boXgOkE= +go.datum.net/cloud v0.1.1-0.20260918213150-260f797bbc5c h1:k0P5980+kS90zCa0M4MohyS0woJL3bh/uEarRrW9PmY= +go.datum.net/cloud v0.1.1-0.20260918213150-260f797bbc5c/go.mod h1:HTeaepXL0EqiqNsQ13K4/kGEwv+VLJ//BGrUfVIFfEA= +go.datum.net/network v0.1.1-0.20260930210653-3a8d02541780 h1:RPC/5vwvalvHlVzCRL55XH6Wyvgi7GwQ4m4mYLvz39A= +go.datum.net/network v0.1.1-0.20260930210653-3a8d02541780/go.mod h1:dqzM8WZczbiZ9bCvsxjkoI10GJqQ24NVWnc9boXgOkE= +go.datum.net/network v0.3.1-0.20261006163011-bf0955ada028 h1:+y/NPSFPLuC1spzQPmg4d30MuB9bggZgmJ/Uttw/QvM= +go.datum.net/network v0.3.1-0.20261006163011-bf0955ada028/go.mod h1:dqzM8WZczbiZ9bCvsxjkoI10GJqQ24NVWnc9boXgOkE= +go.datum.net/network v0.3.1-0.20261006181027-ce5fa0d7dd95 h1:+dbgzYC0pCoJTVBJW+BUQX59w/Vicu8PC14spJkK/nY= +go.datum.net/network v0.3.1-0.20261006181027-ce5fa0d7dd95/go.mod h1:dqzM8WZczbiZ9bCvsxjkoI10GJqQ24NVWnc9boXgOkE= +go.datum.net/network v0.3.1-0.20261006181206-248c62692a8f h1:DTXKelzYW0LWGRhMzGIEoOl+HKw8qaSEG8oHySCDfnU= +go.datum.net/network v0.3.1-0.20261006181206-248c62692a8f/go.mod h1:dqzM8WZczbiZ9bCvsxjkoI10GJqQ24NVWnc9boXgOkE= +go.datum.net/network v0.3.1-0.20261006185716-0f099eb3b0ea h1:q/8ZPV2fjLwFTpTepNn/9HigADL9c9nQHWKKGWMRGHQ= +go.datum.net/network v0.3.1-0.20261006185716-0f099eb3b0ea/go.mod h1:dqzM8WZczbiZ9bCvsxjkoI10GJqQ24NVWnc9boXgOkE= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= @@ -235,16 +260,16 @@ golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= -golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= -golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM= golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw= -gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= +gomodules.xyz/jsonpatch/v2 v2.5.0 h1:JELs8RLM12qJGXU4u/TO3V25KW8GreMKl9pdkk14RM0= +gomodules.xyz/jsonpatch/v2 v2.5.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= google.golang.org/genproto/googleapis/rpc v0.0.0-20260706201446-f0a921348800 h1:qEHAMpSaUhtD0p3NbEEI83HwNGFxEwaSJ1G9PLnCBZE= @@ -260,25 +285,24 @@ gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnf gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= 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.7.0 h1:w6WUp1VbkqPEgLz4rkBzH/CSU6HkoqNLp6GstyTx3lU= honnef.co/go/tools v0.7.0/go.mod h1:pm29oPxeP3P82ISxZDgIYeOaf9ta6Pi0EWvCFoLG2vc= k8s.io/api v0.36.3 h1:NxB+05W2UGqXWFXcLO0RB5cnqnUPP5v5sVlaOH0Iz4w= k8s.io/api v0.36.3/go.mod h1:JzLQKqRHC5+I8RVj/lS3lCg0mg6nWI9Fo/Sk3ElxHzg= -k8s.io/apiextensions-apiserver v0.36.0 h1:Wt7E8J+VBCbj4FjiBfDTK/neXDDjyJVJc7xfuOHImZ0= -k8s.io/apiextensions-apiserver v0.36.0/go.mod h1:kGDjH0msuiIB3tgsYRV0kS9GqpMYMUsQ3GHv7TApyug= +k8s.io/apiextensions-apiserver v0.36.1 h1:6JfYmPUsuUIHuN+3QxutXYWj492RqF5fBSx67GYK5Ks= +k8s.io/apiextensions-apiserver v0.36.1/go.mod h1:pLzZin90riwisdzKwv/GoTwENooytoIx5zWJb4Hkby8= k8s.io/apimachinery v0.36.3 h1:PkzMRBRG8joFD8EhCuQAtNPvJlxb82FwplP26HIzvAM= k8s.io/apimachinery v0.36.3/go.mod h1:cTSjBWgPe/6CQyBKzY/hDIRWCQQQeK0mfLbml0UYFHE= k8s.io/client-go v0.36.3 h1:M4JdVzXxYcZk4fGpfDdYnxSwhLKWCFoQsHW6t+z8Hfg= k8s.io/client-go v0.36.3/go.mod h1:gcPwr0c87vjjG6HB6pWEqOeuYVoXSsREjzux2j6GF30= k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc= k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0= -k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a h1:xCeOEAOoGYl2jnJoHkC3hkbPJgdATINPMAxaynU2Ovg= -k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a/go.mod h1:uGBT7iTA6c6MvqUvSXIaYZo9ukscABYi2btjhvgKGZ0= -k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 h1:AZYQSJemyQB5eRxqcPky+/7EdBj0xi3g0ZcxxJ7vbWU= -k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= +k8s.io/kube-openapi v0.0.0-20260330154417-16be699c7b31 h1:V+sn9a/1fEYDGwnllCmqXBk8x7obZ+hl869Q3Abumkg= +k8s.io/kube-openapi v0.0.0-20260330154417-16be699c7b31/go.mod h1:uGBT7iTA6c6MvqUvSXIaYZo9ukscABYi2btjhvgKGZ0= +k8s.io/utils v0.0.0-20260707023825-cf1189d6abe3 h1:jVkFFVfXdXP74B/zbO3hM3hpSFD0xvhQ5U686DPurkE= +k8s.io/utils v0.0.0-20260707023825-cf1189d6abe3/go.mod h1:M2s5JB1lIYP3jzZdorPLHXIPJzt9vv2muW5a6L9DtNM= sigs.k8s.io/controller-runtime v0.24.1 h1:miPEwrmirImAvgME1L9qebGHrOnGJoVmVdtOU9fRfo4= sigs.k8s.io/controller-runtime v0.24.1/go.mod h1:vFkfY5fGt5xAC/sKb8IBFKgWPNKG9OUG29dR8Y2wImw= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= diff --git a/internal/cnibgp/bgp_ebpf_test.go b/internal/cnibgp/bgp_ebpf_test.go index dd6e87e4..df88fc99 100644 --- a/internal/cnibgp/bgp_ebpf_test.go +++ b/internal/cnibgp/bgp_ebpf_test.go @@ -207,15 +207,23 @@ func TestRegisterEBPFDatapath_RegistersAllThreeTables(t *testing.T) { if err != nil { t.Fatalf("FilterList(ingress) on host interface: %v", err) } - var foundEgressFilter bool + foundFilters := map[string]bool{ + "galactic_usid_service_egress": false, + "galactic_usid_egress": false, + } for _, f := range filters { - if bpfFilter, ok := f.(*netlink.BpfFilter); ok && bpfFilter.Name == "galactic_usid_egress" { - foundEgressFilter = true + if bpfFilter, ok := f.(*netlink.BpfFilter); ok { + if _, expected := foundFilters[bpfFilter.Name]; expected { + foundFilters[bpfFilter.Name] = true + } } } - if !foundEgressFilter { - t.Errorf("no galactic_usid_egress tc filter found on host interface %q ingress hook -- "+ - "registerEBPFDatapath must attach usid_egress there", intf.GenerateInterfaceNameHost(vpc, testAttachment)) + for name, found := range foundFilters { + if !found { + t.Errorf("no %s tc filter found on host interface %q ingress hook -- "+ + "registerEBPFDatapath must attach the service egress chain there", + name, intf.GenerateInterfaceNameHost(vpc, testAttachment)) + } } } diff --git a/internal/controller/serviceroute_controller.go b/internal/controller/serviceroute_controller.go new file mode 100644 index 00000000..8015338e --- /dev/null +++ b/internal/controller/serviceroute_controller.go @@ -0,0 +1,490 @@ +// Copyright 2026 Datum Cloud, Inc. +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package controller + +import ( + "context" + "errors" + "fmt" + "net" + "reflect" + "sort" + "sync" + "time" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/manager" + + cloudv1alpha1 "go.datum.net/cloud/api/v1alpha1" + "go.datum.net/galactic/internal/config" + "go.datum.net/galactic/internal/crdnames" + "go.datum.net/galactic/internal/plumbing/srv6" + "go.datum.net/galactic/internal/serviceroute" + networkv1alpha1 "go.datum.net/network/api/v1alpha1" +) + +// ServiceRoutePolicyReconciler resolves service policies against local Cloud +// API attachments and programs the resulting stateful eBPF forwarding policy +// directly on this node. It intentionally does not create child Kubernetes +// resources or Linux routes. +type ServiceRoutePolicyReconciler struct { + client.Client + Scheme *runtime.Scheme + NodeName string + BGPNamespace string + Programmer serviceroute.RouteProgrammer + + mu sync.Mutex + Applied map[types.NamespacedName]map[types.NamespacedName]serviceroute.RouteIntent +} + +const serviceRouteMapResyncInterval = 30 * time.Second + +type serviceRouteStartupSyncError struct { + err error + retry bool +} + +type serviceRoutePolicyCleanupError struct{ err error } + +func (e *serviceRoutePolicyCleanupError) Error() string { return e.err.Error() } +func (e *serviceRoutePolicyCleanupError) Unwrap() error { return e.err } + +func isServiceRoutePolicyCleanupError(err error) bool { + var cleanupErr *serviceRoutePolicyCleanupError + return errors.As(err, &cleanupErr) +} + +func (e *serviceRouteStartupSyncError) Error() string { return e.err.Error() } +func (e *serviceRouteStartupSyncError) Unwrap() error { return e.err } + +func startupSyncError(err error, retry bool) error { + if err == nil { + return nil + } + return &serviceRouteStartupSyncError{err: err, retry: retry} +} + +func startupSyncNeedsRetry(err error) bool { + var syncErr *serviceRouteStartupSyncError + return !errors.As(err, &syncErr) || syncErr.retry +} + +// Reconcile resolves one policy and replaces the local routes previously +// programmed for that policy. +func (r *ServiceRoutePolicyReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + r.mu.Lock() + defer r.mu.Unlock() + + policy := &networkv1alpha1.ServiceRoutePolicy{} + if err := r.Get(ctx, req.NamespacedName, policy); err != nil { + if apierrors.IsNotFound(err) { + return ctrl.Result{}, r.removePolicyLocked(req.NamespacedName) + } + return ctrl.Result{}, err + } + endpoint := &networkv1alpha1.ServiceEndpoint{} + endpointKey := types.NamespacedName{Namespace: policy.Namespace, Name: policy.Spec.ServiceRef.Name} + if err := r.Get(ctx, endpointKey, endpoint); err != nil { + if apierrors.IsNotFound(err) { + return ctrl.Result{}, errors.Join( + r.replacePolicyLocked(req.NamespacedName, nil), + r.setAccepted(ctx, policy, metav1.ConditionFalse, "EndpointNotFound", "referenced ServiceEndpoint does not exist"), + ) + } + return ctrl.Result{}, fmt.Errorf("get ServiceEndpoint %s/%s: %w", policy.Namespace, policy.Spec.ServiceRef.Name, err) + } + attachments := &cloudv1alpha1.VPCAttachmentList{} + if err := r.List(ctx, attachments); err != nil { + return ctrl.Result{}, fmt.Errorf("list VPCAttachments: %w", err) + } + all := make([]*cloudv1alpha1.VPCAttachment, 0, len(attachments.Items)) + for i := range attachments.Items { + all = append(all, &attachments.Items[i]) + } + intents, err := serviceroute.Compile(policy, endpoint, all, r.NodeName, r.sidResolver(ctx)) + if err != nil { + var dependencyErr *serviceroute.DependencyNotReadyError + if errors.As(err, &dependencyErr) { + // Accepted describes the shared policy contract. SID readiness is a + // node-local dependency, so publishing Invalid here would race with + // uninvolved nodes that correctly compile no local intents. Revoke + // this node's stale state and return the error for controller-runtime + // to retry while leaving the policy accepted cluster-wide. + return ctrl.Result{}, errors.Join( + err, + r.removePolicyLocked(req.NamespacedName), + r.setAccepted(ctx, policy, metav1.ConditionTrue, "Accepted", "policy is valid"), + ) + } + return ctrl.Result{}, errors.Join( + err, + r.removePolicyLocked(req.NamespacedName), + r.setAccepted(ctx, policy, metav1.ConditionFalse, "Invalid", err.Error()), + ) + } + if len(intents) != 0 { + // The datapath loader may replace an incompatible pinned map while this + // controller process stays alive. Initialize detects the map identity + // change and reconstructs all desired state before an unchanged intent + // is otherwise skipped by replacePolicy. + if err := r.Programmer.Initialize(); err != nil { + return ctrl.Result{}, fmt.Errorf("refresh service route maps: %w", err) + } + } + if err := r.replacePolicyLocked(req.NamespacedName, intents); err != nil { + return ctrl.Result{}, err + } + result := ctrl.Result{} + if len(intents) != 0 { + result.RequeueAfter = serviceRouteMapResyncInterval + } + return result, r.setAccepted(ctx, policy, metav1.ConditionTrue, "Accepted", "policy is valid") +} + +func (r *ServiceRoutePolicyReconciler) setAccepted( + ctx context.Context, + policy *networkv1alpha1.ServiceRoutePolicy, + status metav1.ConditionStatus, + reason, message string, +) error { + before := policy.Status.DeepCopy() + policy.Status.ObservedGeneration = policy.Generation + meta.SetStatusCondition(&policy.Status.Conditions, metav1.Condition{ + Type: networkv1alpha1.ConditionTypeServiceRouteAccepted, + Status: status, + ObservedGeneration: policy.Generation, + Reason: reason, + Message: message, + }) + if reflect.DeepEqual(before, &policy.Status) { + return nil + } + if err := r.Status().Update(ctx, policy); err != nil { + return fmt.Errorf("update ServiceRoutePolicy status: %w", err) + } + return nil +} + +// SetupWithManager watches policies, endpoints, and attachments. Events from +// either backing API requeue matching policies so status.node moves and service +// endpoint changes converge without child resources. +func (r *ServiceRoutePolicyReconciler) SetupWithManager(mgr ctrl.Manager) error { + if err := mgr.Add(manager.RunnableFunc(func(ctx context.Context) error { + if !mgr.GetCache().WaitForCacheSync(ctx) { + return nil + } + return r.runStartupSync(ctx) + })); err != nil { + return fmt.Errorf("add initial service route policy map sync: %w", err) + } + return ctrl.NewControllerManagedBy(mgr). + For(&networkv1alpha1.ServiceRoutePolicy{}). + Watches(&networkv1alpha1.ServiceEndpoint{}, handler.EnqueueRequestsFromMapFunc(r.endpointPolicies)). + Watches(&cloudv1alpha1.VPCAttachment{}, handler.EnqueueRequestsFromMapFunc(r.attachmentPolicies)). + Watches(&networkv1alpha1.BGPRouter{}, handler.EnqueueRequestsFromMapFunc(r.routingDependencyPolicies)). + Watches(&networkv1alpha1.BGPVRFInstance{}, handler.EnqueueRequestsFromMapFunc(r.routingDependencyPolicies)). + Complete(r) +} + +func (r *ServiceRoutePolicyReconciler) runStartupSync(ctx context.Context) error { + for { + err := r.syncAllPolicies(ctx) + if err == nil { + return nil + } + log.FromContext(ctx).Error(err, "initial service route policy map adoption and sweep") + if !startupSyncNeedsRetry(err) { + // The complete snapshot was finalized and policy forwarding was + // enabled. Per-policy errors are retried by their ordinary + // reconciles; globally gating the datapath again would disrupt all + // successfully established services. + return nil + } + timer := time.NewTimer(time.Second) + select { + case <-ctx.Done(): + timer.Stop() + return nil + case <-timer.C: + } + } +} + +// syncAllPolicies adopts a complete cache-synchronized desired set before +// sweeping stale policy entries. The reconciler mutex spans compilation, +// programming, and the sweep so an ordinary reconcile cannot reintroduce an +// object from a snapshot older than the one being finalized. +func (r *ServiceRoutePolicyReconciler) syncAllPolicies(ctx context.Context) error { + r.mu.Lock() + defer r.mu.Unlock() + + policies := &networkv1alpha1.ServiceRoutePolicyList{} + if err := r.List(ctx, policies); err != nil { + return startupSyncError(fmt.Errorf("list ServiceRoutePolicies for startup sync: %w", err), true) + } + attachments := &cloudv1alpha1.VPCAttachmentList{} + if err := r.List(ctx, attachments); err != nil { + return startupSyncError(fmt.Errorf("list VPCAttachments for startup sync: %w", err), true) + } + allAttachments := make([]*cloudv1alpha1.VPCAttachment, 0, len(attachments.Items)) + for index := range attachments.Items { + allAttachments = append(allAttachments, &attachments.Items[index]) + } + + type compiledPolicy struct { + key types.NamespacedName + intents []serviceroute.RouteIntent + } + compiled := make([]compiledPolicy, 0, len(policies.Items)) + currentPolicies := make(map[types.NamespacedName]struct{}, len(policies.Items)) + for index := range policies.Items { + policy := &policies.Items[index] + key := types.NamespacedName{Namespace: policy.Namespace, Name: policy.Name} + currentPolicies[key] = struct{}{} + endpoint := &networkv1alpha1.ServiceEndpoint{} + endpointKey := types.NamespacedName{Namespace: policy.Namespace, Name: policy.Spec.ServiceRef.Name} + if err := r.Get(ctx, endpointKey, endpoint); err != nil { + if apierrors.IsNotFound(err) { + compiled = append(compiled, compiledPolicy{key: key}) + continue + } + return startupSyncError(fmt.Errorf("get ServiceEndpoint %s for startup sync: %w", endpointKey, err), true) + } + intents, err := serviceroute.Compile(policy, endpoint, allAttachments, r.NodeName, r.sidResolver(ctx)) + if err != nil { + // Invalid policies and node-local dependencies that are not ready + // both fail closed in the startup snapshot. Ordinary reconciliation + // publishes their precise status and retries dependency failures. + compiled = append(compiled, compiledPolicy{key: key}) + continue + } + compiled = append(compiled, compiledPolicy{key: key, intents: intents}) + } + sort.Slice(compiled, func(i, j int) bool { return compiled[i].key.String() < compiled[j].key.String() }) + + if err := r.Programmer.Initialize(); err != nil { + return startupSyncError(fmt.Errorf("initialize service route maps for startup sync: %w", err), true) + } + var removed []types.NamespacedName + for key := range r.Applied { + if _, exists := currentPolicies[key]; !exists { + removed = append(removed, key) + } + } + sort.Slice(removed, func(i, j int) bool { return removed[i].String() < removed[j].String() }) + var syncErrs []error + cleanupFailed := false + for _, key := range removed { + if err := r.removePolicyLocked(key); err != nil { + cleanupFailed = cleanupFailed || isServiceRoutePolicyCleanupError(err) + syncErrs = append(syncErrs, + fmt.Errorf("remove obsolete service route policy %s during startup sync: %w", key, err)) + } + } + for _, policy := range compiled { + if err := r.replacePolicyLocked(policy.key, policy.intents); err != nil { + cleanupFailed = cleanupFailed || isServiceRoutePolicyCleanupError(err) + syncErrs = append(syncErrs, + fmt.Errorf("apply service route policy %s during startup sync: %w", policy.key, err)) + // A permanently broken policy must not keep its previous desired + // references alive and thereby exempt them from the stale sweep. + // Remove any intents established before the failure and continue + // adopting independent policies. + if removeErr := r.removePolicyLocked(policy.key); removeErr != nil { + cleanupFailed = true + syncErrs = append(syncErrs, + fmt.Errorf("fail closed service route policy %s during startup sync: %w", policy.key, removeErr)) + } + } + } + if cleanupFailed { + // Finalize enables service policy after sweeping against the + // programmer's retained desired references. A failed rollback or + // removal can leave an authorization reference in that snapshot, so + // keep the global gate closed and retry cleanup instead. + return startupSyncError(errors.Join(syncErrs...), true) + } + finalized := true + if err := r.Programmer.Finalize(); err != nil { + finalized = false + syncErrs = append(syncErrs, fmt.Errorf("sweep stale service route policy maps: %w", err)) + } + return startupSyncError(errors.Join(syncErrs...), !finalized) +} + +func (r *ServiceRoutePolicyReconciler) endpointPolicies(ctx context.Context, obj client.Object) []ctrl.Request { + return r.policiesForEndpoint(ctx, obj.GetNamespace(), obj.GetName()) +} + +func (r *ServiceRoutePolicyReconciler) attachmentPolicies(ctx context.Context, _ client.Object) []ctrl.Request { + return r.allPolicies(ctx) +} + +func (r *ServiceRoutePolicyReconciler) routingDependencyPolicies(ctx context.Context, _ client.Object) []ctrl.Request { + return r.allPolicies(ctx) +} + +// sidResolver derives an attachment's End.DT46 SID from the same BGPRouter and +// BGPVRFInstance identities the CNI publish path used. It deliberately does not +// resolve by address containment: a guest-managed attachment may advertise no +// prefix, and tenant address spaces may overlap. +func (r *ServiceRoutePolicyReconciler) sidResolver(ctx context.Context) serviceroute.SIDResolver { + cache := make(map[string]net.IP) + return func(attachment *cloudv1alpha1.VPCAttachment) (net.IP, error) { + identity := attachment.Status.VPC + "|" + attachment.Status.Node + if sid, ok := cache[identity]; ok { + return append(net.IP(nil), sid...), nil + } + namespace := r.BGPNamespace + if namespace == "" { + namespace = config.DefaultNamespace + } + vrf := &networkv1alpha1.BGPVRFInstance{} + vrfKey := types.NamespacedName{ + Namespace: namespace, + Name: crdnames.BGPVRFInstanceName(attachment.Status.VPC, attachment.Status.Node), + } + if err := r.Get(ctx, vrfKey, vrf); err != nil { + return nil, fmt.Errorf("get BGPVRFInstance %s: %w", vrfKey, err) + } + if vrf.Spec.RouterRef == nil || vrf.Spec.RouterRef.Name == "" { + return nil, fmt.Errorf("BGPVRFInstance %s does not have an explicit routerRef", vrfKey) + } + routerKey := types.NamespacedName{Namespace: namespace, Name: vrf.Spec.RouterRef.Name} + router := &networkv1alpha1.BGPRouter{} + if err := r.Get(ctx, routerKey, router); err != nil { + return nil, fmt.Errorf("get BGPRouter %s: %w", routerKey, err) + } + if router.Spec.TargetRef.Name != attachment.Status.Node { + return nil, fmt.Errorf("BGPRouter %s targets node %q, not attachment node %q", + routerKey, router.Spec.TargetRef.Name, attachment.Status.Node) + } + sid, err := srv6.ComputeSID(router.Spec.SRv6Locator, router.Spec.NodeID, vrf.Spec.VRFID, + networkv1alpha1.SRv6FunctionEndDT46) + if err != nil { + return nil, fmt.Errorf("compute attachment SID from BGPRouter %s and BGPVRFInstance %s: %w", + routerKey, vrfKey, err) + } + resolved := net.IP(sid.AsSlice()) + cache[identity] = append(net.IP(nil), resolved...) + return resolved, nil + } +} + +func (r *ServiceRoutePolicyReconciler) policiesForEndpoint(ctx context.Context, namespace, name string) []ctrl.Request { + list := &networkv1alpha1.ServiceRoutePolicyList{} + if err := r.List(ctx, list, client.InNamespace(namespace)); err != nil { + return nil + } + requests := make([]ctrl.Request, 0) + for _, policy := range list.Items { + if policy.Spec.ServiceRef.Name == name { + requests = append(requests, ctrl.Request{NamespacedName: types.NamespacedName{ + Namespace: policy.Namespace, + Name: policy.Name, + }}) + } + } + return requests +} + +func (r *ServiceRoutePolicyReconciler) allPolicies(ctx context.Context) []ctrl.Request { + list := &networkv1alpha1.ServiceRoutePolicyList{} + if err := r.List(ctx, list); err != nil { + return nil + } + requests := make([]ctrl.Request, 0, len(list.Items)) + for _, policy := range list.Items { + requests = append(requests, ctrl.Request{NamespacedName: types.NamespacedName{ + Namespace: policy.Namespace, + Name: policy.Name, + }}) + } + return requests +} + +func (r *ServiceRoutePolicyReconciler) replacePolicy( + key types.NamespacedName, + intents []serviceroute.RouteIntent, +) error { + r.mu.Lock() + defer r.mu.Unlock() + return r.replacePolicyLocked(key, intents) +} + +func (r *ServiceRoutePolicyReconciler) replacePolicyLocked( + key types.NamespacedName, + intents []serviceroute.RouteIntent, +) error { + if r.Applied == nil { + r.Applied = make(map[types.NamespacedName]map[types.NamespacedName]serviceroute.RouteIntent) + } + current := r.Applied[key] + if current == nil { + current = make(map[types.NamespacedName]serviceroute.RouteIntent) + r.Applied[key] = current + } + desired := make(map[types.NamespacedName]serviceroute.RouteIntent, len(intents)) + for _, intent := range intents { + desired[intent.Attachment] = intent + } + + // Revoke obsolete or changed intents first. Each successful operation is + // reflected in Applied immediately, so a later failure leaves an accurate, + // retryable snapshot rather than leaking untracked kernel state. + for attachment, existing := range current { + next, ok := desired[attachment] + if ok && reflect.DeepEqual(existing, next) { + continue + } + if err := r.Programmer.Remove(existing); err != nil { + return &serviceRoutePolicyCleanupError{err: err} + } + delete(current, attachment) + } + for attachment, intent := range desired { + if existing, ok := current[attachment]; ok && reflect.DeepEqual(existing, intent) { + continue + } + if err := r.Programmer.Apply(intent); err != nil { + if cleanupErr := r.Programmer.Cleanup(intent); cleanupErr != nil { + return errors.Join(err, &serviceRoutePolicyCleanupError{err: cleanupErr}) + } + return err + } + current[attachment] = intent + } + if len(current) == 0 { + delete(r.Applied, key) + } + return nil +} + +func (r *ServiceRoutePolicyReconciler) removePolicy(key types.NamespacedName) error { + r.mu.Lock() + defer r.mu.Unlock() + return r.removePolicyLocked(key) +} + +func (r *ServiceRoutePolicyReconciler) removePolicyLocked(key types.NamespacedName) error { + current := r.Applied[key] + for attachment, intent := range current { + if err := r.Programmer.Remove(intent); err != nil { + return &serviceRoutePolicyCleanupError{err: err} + } + delete(current, attachment) + } + delete(r.Applied, key) + return nil +} diff --git a/internal/controller/serviceroute_controller_test.go b/internal/controller/serviceroute_controller_test.go new file mode 100644 index 00000000..065d2780 --- /dev/null +++ b/internal/controller/serviceroute_controller_test.go @@ -0,0 +1,426 @@ +// Copyright 2026 Datum Cloud, Inc. +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package controller + +import ( + "context" + "errors" + "net" + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + cloudv1alpha1 "go.datum.net/cloud/api/v1alpha1" + "go.datum.net/galactic/internal/crdnames" + "go.datum.net/galactic/internal/plumbing/srv6" + "go.datum.net/galactic/internal/serviceroute" + networkv1alpha1 "go.datum.net/network/api/v1alpha1" +) + +const ( + testServiceRouteTenantNamespace = "tenant" + testServiceRouteName = "dns" + testServiceRouteConsumerName = "consumer" + testServiceRouteAccessLabel = "access" + testServiceRoutePolicyNamespace = "platform" +) + +type retryRouteProgrammer struct { + initializeCalls int + applyCalls int + cleanupCalls int + removeCalls int + finalizeCalls int + failApply int + failRemove int + finalizeErr error + failAttachment string + cleanupErr error + staleAuthorization bool +} + +func (p *retryRouteProgrammer) Initialize() error { + p.initializeCalls++ + return nil +} + +func (p *retryRouteProgrammer) Apply(intent serviceroute.RouteIntent) error { + p.applyCalls++ + if intent.Attachment.Name == p.failAttachment || p.applyCalls == p.failApply { + return errors.New("apply failed") + } + return nil +} + +func (p *retryRouteProgrammer) Cleanup(serviceroute.RouteIntent) error { + p.cleanupCalls++ + return p.cleanupErr +} + +func (p *retryRouteProgrammer) Remove(serviceroute.RouteIntent) error { + p.removeCalls++ + if p.removeCalls == p.failRemove { + return errors.New("remove failed") + } + return nil +} + +func (p *retryRouteProgrammer) Finalize() error { + p.finalizeCalls++ + p.staleAuthorization = false + return p.finalizeErr +} + +func TestServiceRouteReplacePolicyResumesPartialApply(t *testing.T) { + programmer := &retryRouteProgrammer{failApply: 2} + reconciler := &ServiceRoutePolicyReconciler{Programmer: programmer} + policy := types.NamespacedName{Namespace: testServiceRoutePolicyNamespace, Name: testServiceRouteName} + intents := []serviceroute.RouteIntent{ + {Attachment: types.NamespacedName{Namespace: testServiceRouteTenantNamespace, Name: "a"}}, + {Attachment: types.NamespacedName{Namespace: testServiceRouteTenantNamespace, Name: "b"}}, + } + + if err := reconciler.replacePolicy(policy, intents); err == nil { + t.Fatal("first replacePolicy succeeded, want injected failure") + } + if got := len(reconciler.Applied[policy]); got != 1 { + t.Fatalf("tracked intents after partial apply = %d, want 1", got) + } + programmer.failApply = 0 + if err := reconciler.replacePolicy(policy, intents); err != nil { + t.Fatalf("retry replacePolicy: %v", err) + } + if programmer.applyCalls != 3 { + t.Fatalf("Apply calls = %d, want 3 (successful intent was not replayed)", programmer.applyCalls) + } + if got := len(reconciler.Applied[policy]); got != 2 { + t.Fatalf("tracked intents after retry = %d, want 2", got) + } +} + +func TestServiceRouteReplacePolicyReportsUnresolvedApplyRollback(t *testing.T) { + rollbackFailure := errors.New("rollback failed") + programmer := &retryRouteProgrammer{failApply: 1, cleanupErr: rollbackFailure} + reconciler := &ServiceRoutePolicyReconciler{Programmer: programmer} + err := reconciler.replacePolicy(types.NamespacedName{ + Namespace: testServiceRoutePolicyNamespace, Name: testServiceRouteName, + }, + []serviceroute.RouteIntent{{ + Attachment: types.NamespacedName{ + Namespace: testServiceRouteTenantNamespace, + Name: testServiceRouteConsumerName, + }, + }}) + if err == nil || !errors.Is(err, rollbackFailure) || !isServiceRoutePolicyCleanupError(err) { + t.Fatalf("replacePolicy error = %v, want unresolved cleanup wrapping %v", err, rollbackFailure) + } + if programmer.cleanupCalls != 1 { + t.Fatalf("Cleanup calls = %d, want 1", programmer.cleanupCalls) + } +} + +func TestServiceRouteRemovePolicyResumesPartialRemove(t *testing.T) { + programmer := &retryRouteProgrammer{failRemove: 2} + policy := types.NamespacedName{Namespace: testServiceRoutePolicyNamespace, Name: testServiceRouteName} + reconciler := &ServiceRoutePolicyReconciler{ + Programmer: programmer, + Applied: map[types.NamespacedName]map[types.NamespacedName]serviceroute.RouteIntent{ + policy: { + {Namespace: testServiceRouteTenantNamespace, Name: "a"}: { + Attachment: types.NamespacedName{Namespace: testServiceRouteTenantNamespace, Name: "a"}, + }, + {Namespace: testServiceRouteTenantNamespace, Name: "b"}: { + Attachment: types.NamespacedName{Namespace: testServiceRouteTenantNamespace, Name: "b"}, + }, + }, + }, + } + + if err := reconciler.removePolicy(policy); err == nil { + t.Fatal("first removePolicy succeeded, want injected failure") + } + if got := len(reconciler.Applied[policy]); got != 1 { + t.Fatalf("tracked intents after partial remove = %d, want 1", got) + } + programmer.failRemove = 0 + if err := reconciler.removePolicy(policy); err != nil { + t.Fatalf("retry removePolicy: %v", err) + } + if programmer.removeCalls != 3 { + t.Fatalf("Remove calls = %d, want 3 (successful removal was not replayed)", programmer.removeCalls) + } + if _, ok := reconciler.Applied[policy]; ok { + t.Fatal("policy remains tracked after successful retry") + } +} + +func TestServiceRouteStartupSyncRemovesOfflineDeletedPolicyBeforeSweep(t *testing.T) { + scheme := runtime.NewScheme() + if err := networkv1alpha1.AddToScheme(scheme); err != nil { + t.Fatal(err) + } + if err := cloudv1alpha1.AddToScheme(scheme); err != nil { + t.Fatal(err) + } + programmer := &retryRouteProgrammer{} + deletedPolicy := types.NamespacedName{Namespace: testServiceRoutePolicyNamespace, Name: "deleted-while-offline"} + reconciler := &ServiceRoutePolicyReconciler{ + Client: fake.NewClientBuilder().WithScheme(scheme).Build(), + Programmer: programmer, + Applied: map[types.NamespacedName]map[types.NamespacedName]serviceroute.RouteIntent{ + deletedPolicy: { + {Namespace: testServiceRouteTenantNamespace, Name: testServiceRouteConsumerName}: { + Attachment: types.NamespacedName{ + Namespace: testServiceRouteTenantNamespace, + Name: testServiceRouteConsumerName, + }, + }, + }, + }, + } + + if err := reconciler.syncAllPolicies(context.Background()); err != nil { + t.Fatalf("syncAllPolicies: %v", err) + } + if programmer.removeCalls != 1 || programmer.finalizeCalls != 1 { + t.Fatalf("remove/finalize calls = %d/%d, want 1/1", programmer.removeCalls, programmer.finalizeCalls) + } + if len(reconciler.Applied) != 0 { + t.Fatalf("applied policies after startup sync = %#v, want empty", reconciler.Applied) + } +} + +func TestServiceRouteStartupSyncRemovalFailureKeepsGateClosed(t *testing.T) { + scheme := runtime.NewScheme() + if err := networkv1alpha1.AddToScheme(scheme); err != nil { + t.Fatal(err) + } + if err := cloudv1alpha1.AddToScheme(scheme); err != nil { + t.Fatal(err) + } + programmer := &retryRouteProgrammer{failRemove: 1, staleAuthorization: true} + deletedPolicy := types.NamespacedName{Namespace: testServiceRoutePolicyNamespace, Name: "deleted-while-offline"} + reconciler := &ServiceRoutePolicyReconciler{ + Client: fake.NewClientBuilder().WithScheme(scheme).Build(), + Programmer: programmer, + Applied: map[types.NamespacedName]map[types.NamespacedName]serviceroute.RouteIntent{ + deletedPolicy: { + {Namespace: testServiceRouteTenantNamespace, Name: testServiceRouteConsumerName}: { + Attachment: types.NamespacedName{ + Namespace: testServiceRouteTenantNamespace, + Name: testServiceRouteConsumerName, + }, + }, + }, + }, + } + + err := reconciler.syncAllPolicies(context.Background()) + if err == nil { + t.Fatal("syncAllPolicies succeeded, want injected removal failure") + } + if !startupSyncNeedsRetry(err) { + t.Fatalf("startup sync error %v is not retryable", err) + } + if programmer.finalizeCalls != 0 || !programmer.staleAuthorization { + t.Fatalf("Finalize calls/stale authorization after removal failure = %d/%t, want 0/true", + programmer.finalizeCalls, programmer.staleAuthorization) + } +} + +func TestServiceRouteStartupPermanentApplyFailureDoesNotRetryFinalizedGlobalSync(t *testing.T) { + scheme := runtime.NewScheme() + if err := networkv1alpha1.AddToScheme(scheme); err != nil { + t.Fatal(err) + } + if err := cloudv1alpha1.AddToScheme(scheme); err != nil { + t.Fatal(err) + } + const node = "node-a" + policy := &networkv1alpha1.ServiceRoutePolicy{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: testServiceRoutePolicyNamespace, Name: "a-bad", UID: "bad-policy-uid", + }, + Spec: networkv1alpha1.ServiceRoutePolicySpec{ + ServiceRef: networkv1alpha1.ServiceEndpointReference{Name: testServiceRouteName}, + AttachmentSelector: metav1.LabelSelector{ + MatchLabels: map[string]string{testServiceRouteAccessLabel: "bad"}, + }, + }, + } + goodPolicy := policy.DeepCopy() + goodPolicy.Name = "z-good" + goodPolicy.UID = "good-policy-uid" + goodPolicy.Spec.AttachmentSelector.MatchLabels[testServiceRouteAccessLabel] = "good" + endpoint := &networkv1alpha1.ServiceEndpoint{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: testServiceRoutePolicyNamespace, Name: testServiceRouteName, UID: "endpoint-uid", + }, + Spec: networkv1alpha1.ServiceEndpointSpec{ + Address: "10.0.0.53", Protocol: networkv1alpha1.NetworkRuleProtocolUDP, Port: 53, + DeliveryMode: networkv1alpha1.ServiceEndpointDeliveryModeNodeLocal, + AttachmentSelector: &metav1.LabelSelector{MatchLabels: map[string]string{"producer": "yes"}}, + }, + } + readyAttachment := func(namespace, name, host string, labels map[string]string) *cloudv1alpha1.VPCAttachment { + return &cloudv1alpha1.VPCAttachment{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: namespace, + Name: name, + UID: types.UID(name + "-uid"), + Generation: 1, + Labels: labels, + }, + Status: cloudv1alpha1.VPCAttachmentStatus{ + ObservedGeneration: 1, Node: node, VPC: "vpc-a", VPCAttachment: "attachment-" + name, + HostInterface: host, + Conditions: []metav1.Condition{ + {Type: cloudv1alpha1.ConditionTypeReady, Status: metav1.ConditionTrue}, + {Type: cloudv1alpha1.ConditionTypeProgrammed, Status: metav1.ConditionTrue}, + }, + }, + } + } + consumer := readyAttachment( + testServiceRouteTenantNamespace, + testServiceRouteConsumerName, + "consumer0", + map[string]string{testServiceRouteAccessLabel: "bad"}, + ) + goodConsumer := readyAttachment( + testServiceRouteTenantNamespace, + "good-consumer", + "consumer1", + map[string]string{testServiceRouteAccessLabel: "good"}, + ) + producer := readyAttachment("service", "producer", "producer0", map[string]string{"producer": "yes"}) + programmer := &retryRouteProgrammer{failAttachment: consumer.Name, staleAuthorization: true} + reconciler := &ServiceRoutePolicyReconciler{ + Client: fake.NewClientBuilder().WithScheme(scheme).WithObjects( + policy, goodPolicy, endpoint, consumer, goodConsumer, producer, + ).Build(), + NodeName: node, + Programmer: programmer, + } + + if err := reconciler.runStartupSync(context.Background()); err != nil { + t.Fatalf("runStartupSync: %v", err) + } + if programmer.applyCalls != 2 { + t.Fatalf("Apply calls = %d, want 2 (independent policy was not attempted)", programmer.applyCalls) + } + if programmer.finalizeCalls != 1 || programmer.staleAuthorization { + t.Fatalf("Finalize calls/stale authorization = %d/%t, want 1/false", + programmer.finalizeCalls, programmer.staleAuthorization) + } + if programmer.initializeCalls != 1 { + t.Fatalf("Initialize calls = %d, want one global sync attempt", programmer.initializeCalls) + } + failedPolicyKey := types.NamespacedName{Namespace: policy.Namespace, Name: policy.Name} + if _, retained := reconciler.Applied[failedPolicyKey]; retained { + t.Fatal("failed policy retained desired refs and would be exempt from sweep") + } + goodPolicyKey := types.NamespacedName{Namespace: goodPolicy.Namespace, Name: goodPolicy.Name} + if _, retained := reconciler.Applied[goodPolicyKey]; !retained { + t.Fatal("independent valid policy was not retained after another policy failed") + } + + rollbackFailure := errors.New("rollback cleanup failed") + blockedProgrammer := &retryRouteProgrammer{ + failAttachment: consumer.Name, + cleanupErr: rollbackFailure, + } + blockedReconciler := &ServiceRoutePolicyReconciler{ + Client: reconciler.Client, + NodeName: node, + Programmer: blockedProgrammer, + } + err := blockedReconciler.syncAllPolicies(context.Background()) + if err == nil || !errors.Is(err, rollbackFailure) || !startupSyncNeedsRetry(err) { + t.Fatalf("syncAllPolicies error = %v, want retryable rollback failure", err) + } + if blockedProgrammer.finalizeCalls != 0 { + t.Fatalf("Finalize calls with unresolved rollback = %d, want 0", blockedProgrammer.finalizeCalls) + } +} + +func TestServiceRouteSIDResolverUsesConfiguredBGPNamespace(t *testing.T) { + const ( + bgpNamespace = "routing-system" + node = testNAT66NodeB + vpc = "vpc-b" + locator = "fd00:1234::/48" + vrfID = int32(42) + ) + scheme := runtime.NewScheme() + if err := networkv1alpha1.AddToScheme(scheme); err != nil { + t.Fatal(err) + } + router := &networkv1alpha1.BGPRouter{ + ObjectMeta: metav1.ObjectMeta{Namespace: bgpNamespace, Name: "router-b"}, + Spec: networkv1alpha1.BGPRouterSpec{ + TargetRef: networkv1alpha1.TargetRef{Name: node}, SRv6Locator: locator, NodeID: 7, + }, + } + vrf := &networkv1alpha1.BGPVRFInstance{ + ObjectMeta: metav1.ObjectMeta{Namespace: bgpNamespace, Name: crdnames.BGPVRFInstanceName(vpc, node)}, + Spec: networkv1alpha1.BGPVRFInstanceSpec{ + RouterTarget: networkv1alpha1.RouterTarget{RouterRef: &networkv1alpha1.RouterRef{Name: router.Name}}, + VRFID: vrfID, + }, + } + reconciler := &ServiceRoutePolicyReconciler{ + Client: fake.NewClientBuilder().WithScheme(scheme).WithObjects(router, vrf).Build(), + BGPNamespace: bgpNamespace, + } + attachment := &cloudv1alpha1.VPCAttachment{Status: cloudv1alpha1.VPCAttachmentStatus{Node: node, VPC: vpc}} + got, err := reconciler.sidResolver(context.Background())(attachment) + if err != nil { + t.Fatalf("resolve SID: %v", err) + } + want, err := srv6.ComputeSID(locator, 7, vrfID, networkv1alpha1.SRv6FunctionEndDT46) + if err != nil { + t.Fatal(err) + } + if !got.Equal(net.IP(want.AsSlice())) { + t.Fatalf("SID = %s, want %s", got, want) + } +} + +func TestServiceRouteSIDResolverRejectsWrongRouterNode(t *testing.T) { + const bgpNamespace = "routing-system" + scheme := runtime.NewScheme() + if err := networkv1alpha1.AddToScheme(scheme); err != nil { + t.Fatal(err) + } + router := &networkv1alpha1.BGPRouter{ + ObjectMeta: metav1.ObjectMeta{Namespace: bgpNamespace, Name: "router-b"}, + Spec: networkv1alpha1.BGPRouterSpec{ + TargetRef: networkv1alpha1.TargetRef{Name: "wrong-node"}, SRv6Locator: "fd00:1234::/48", NodeID: 7, + }, + } + vrf := &networkv1alpha1.BGPVRFInstance{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: bgpNamespace, Name: crdnames.BGPVRFInstanceName("vpc-b", testNAT66NodeB), + }, + Spec: networkv1alpha1.BGPVRFInstanceSpec{ + RouterTarget: networkv1alpha1.RouterTarget{RouterRef: &networkv1alpha1.RouterRef{Name: router.Name}}, + VRFID: 42, + }, + } + reconciler := &ServiceRoutePolicyReconciler{ + Client: fake.NewClientBuilder().WithScheme(scheme).WithObjects(router, vrf).Build(), + BGPNamespace: bgpNamespace, + } + attachment := &cloudv1alpha1.VPCAttachment{ + Status: cloudv1alpha1.VPCAttachmentStatus{Node: testNAT66NodeB, VPC: "vpc-b"}, + } + if _, err := reconciler.sidResolver(context.Background())(attachment); err == nil { + t.Fatal("resolve SID succeeded for a router targeting the wrong node") + } +} diff --git a/internal/controller/usidresolver_test.go b/internal/controller/usidresolver_test.go index c071745e..00b20edf 100644 --- a/internal/controller/usidresolver_test.go +++ b/internal/controller/usidresolver_test.go @@ -154,7 +154,7 @@ func TestBackendSIDIndex_TenantOwnershipDisambiguatesCollidingPrefixes(t *testin routerA := &bgpv1alpha1.BGPRouter{ ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: "router-vpc-a"}, Spec: bgpv1alpha1.BGPRouterSpec{ - TargetRef: bgpv1alpha1.TargetRef{Kind: testTargetRefKind, Name: "node-a"}, + TargetRef: bgpv1alpha1.TargetRef{Kind: testTargetRefKind, Name: testNAT66NodeA}, LocalASN: 65000, RouterID: "1.1.1.1", SRv6Locator: vpcALocator, @@ -175,7 +175,7 @@ func TestBackendSIDIndex_TenantOwnershipDisambiguatesCollidingPrefixes(t *testin advA := &bgpv1alpha1.BGPAdvertisement{ ObjectMeta: metav1.ObjectMeta{ Namespace: testNamespace, - Name: crdnames.BGPAdvertisementName(vpcA, "attach-a", "node-a"), + Name: crdnames.BGPAdvertisementName(vpcA, "attach-a", testNAT66NodeA), }, Spec: bgpv1alpha1.BGPAdvertisementSpec{ RouterRef: bgpv1alpha1.RouterRef{Name: routerA.Name}, @@ -200,7 +200,10 @@ func TestBackendSIDIndex_TenantOwnershipDisambiguatesCollidingPrefixes(t *testin } vrfA := &bgpv1alpha1.BGPVRFInstance{ - ObjectMeta: metav1.ObjectMeta{Namespace: testNamespace, Name: crdnames.BGPVRFInstanceName(vpcA, "node-a")}, + ObjectMeta: metav1.ObjectMeta{ + Namespace: testNamespace, + Name: crdnames.BGPVRFInstanceName(vpcA, testNAT66NodeA), + }, Spec: bgpv1alpha1.BGPVRFInstanceSpec{ RouterTarget: bgpv1alpha1.RouterTarget{RouterRef: &bgpv1alpha1.RouterRef{Name: routerA.Name}}, VRFID: vpcAVRFID, diff --git a/internal/controller/vpcattachment_index.go b/internal/controller/vpcattachment_index.go new file mode 100644 index 00000000..09275bca --- /dev/null +++ b/internal/controller/vpcattachment_index.go @@ -0,0 +1,96 @@ +// Copyright 2026 Datum Cloud, Inc. +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package controller + +import ( + "sort" + "sync" + + "k8s.io/apimachinery/pkg/types" + + cloudv1alpha1 "go.datum.net/cloud/api/v1alpha1" +) + +// VPCAttachmentIndex indexes Cloud API attachments by their observed node. +// +// VPCAttachmentStatus.Node is the source of truth. This index is deliberately +// in-memory: it is a read optimization for node-local Galactic reconcilers, not +// a second API-level ownership field. +type VPCAttachmentIndex struct { + mu sync.RWMutex + byNode map[string]map[types.NamespacedName]*cloudv1alpha1.VPCAttachment + byKey map[types.NamespacedName]string +} + +// NewVPCAttachmentIndex creates an empty attachment index. +func NewVPCAttachmentIndex() *VPCAttachmentIndex { + return &VPCAttachmentIndex{ + byNode: make(map[string]map[types.NamespacedName]*cloudv1alpha1.VPCAttachment), + byKey: make(map[types.NamespacedName]string), + } +} + +// Upsert records the latest attachment state. An attachment with no observed +// node is retained nowhere because it cannot be programmed by a node-local +// dataplane controller yet. +func (i *VPCAttachmentIndex) Upsert(attachment *cloudv1alpha1.VPCAttachment) { + if attachment == nil { + return + } + + key := types.NamespacedName{Namespace: attachment.Namespace, Name: attachment.Name} + i.mu.Lock() + defer i.mu.Unlock() + i.removeLocked(key) + + node := attachment.Status.Node + if node == "" { + return + } + if i.byNode[node] == nil { + i.byNode[node] = make(map[types.NamespacedName]*cloudv1alpha1.VPCAttachment) + } + i.byNode[node][key] = attachment.DeepCopy() + i.byKey[key] = node +} + +// Delete removes an attachment from the index. +func (i *VPCAttachmentIndex) Delete(attachment *cloudv1alpha1.VPCAttachment) { + if attachment == nil { + return + } + i.mu.Lock() + defer i.mu.Unlock() + i.removeLocked(types.NamespacedName{Namespace: attachment.Namespace, Name: attachment.Name}) +} + +// ForNode returns a deterministic snapshot of attachments currently assigned +// to node. Callers may safely mutate returned objects. +func (i *VPCAttachmentIndex) ForNode(node string) []*cloudv1alpha1.VPCAttachment { + i.mu.RLock() + defer i.mu.RUnlock() + + attachments := make([]*cloudv1alpha1.VPCAttachment, 0, len(i.byNode[node])) + for _, attachment := range i.byNode[node] { + attachments = append(attachments, attachment.DeepCopy()) + } + sort.Slice(attachments, func(a, b int) bool { + return types.NamespacedName{Namespace: attachments[a].Namespace, Name: attachments[a].Name}.String() < + types.NamespacedName{Namespace: attachments[b].Namespace, Name: attachments[b].Name}.String() + }) + return attachments +} + +func (i *VPCAttachmentIndex) removeLocked(key types.NamespacedName) { + node, ok := i.byKey[key] + if !ok { + return + } + delete(i.byKey, key) + delete(i.byNode[node], key) + if len(i.byNode[node]) == 0 { + delete(i.byNode, node) + } +} diff --git a/internal/controller/vpcattachment_index_test.go b/internal/controller/vpcattachment_index_test.go new file mode 100644 index 00000000..962657bd --- /dev/null +++ b/internal/controller/vpcattachment_index_test.go @@ -0,0 +1,61 @@ +// Copyright 2026 Datum Cloud, Inc. +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package controller + +import ( + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + cloudv1alpha1 "go.datum.net/cloud/api/v1alpha1" +) + +func TestVPCAttachmentIndexTracksObservedNode(t *testing.T) { + index := NewVPCAttachmentIndex() + attachment := &cloudv1alpha1.VPCAttachment{ + ObjectMeta: metav1.ObjectMeta{Namespace: "tenant-a", Name: "attachment-a"}, + Status: cloudv1alpha1.VPCAttachmentStatus{Node: testNAT66NodeA, PodSubnet: "fd20:0:19::2:0:0/96"}, + } + + index.Upsert(attachment) + got := index.ForNode(testNAT66NodeA) + if len(got) != 1 || got[0].Status.PodSubnet != attachment.Status.PodSubnet { + t.Fatalf("ForNode(node-a) = %+v, want attachment", got) + } + if got := index.ForNode("node-b"); len(got) != 0 { + t.Fatalf("ForNode(node-b) = %+v, want empty", got) + } +} + +func TestVPCAttachmentIndexMovesAndDeletes(t *testing.T) { + index := NewVPCAttachmentIndex() + attachment := &cloudv1alpha1.VPCAttachment{ + ObjectMeta: metav1.ObjectMeta{Namespace: "tenant-a", Name: "attachment-a"}, + Status: cloudv1alpha1.VPCAttachmentStatus{Node: testNAT66NodeA}, + } + index.Upsert(attachment) + + attachment.Status.Node = "node-b" + index.Upsert(attachment) + if len(index.ForNode(testNAT66NodeA)) != 0 || len(index.ForNode("node-b")) != 1 { + t.Fatal("attachment was not moved between node indexes") + } + + index.Delete(attachment) + if len(index.ForNode("node-b")) != 0 { + t.Fatal("attachment remained after delete") + } +} + +func TestVPCAttachmentIndexIgnoresUnassignedAttachment(t *testing.T) { + index := NewVPCAttachmentIndex() + index.Upsert(&cloudv1alpha1.VPCAttachment{ + ObjectMeta: metav1.ObjectMeta{Name: "pending"}, + Status: cloudv1alpha1.VPCAttachmentStatus{}, + }) + if got := index.ForNode(testNAT66NodeA); len(got) != 0 { + t.Fatalf("unassigned attachment indexed: %+v", got) + } +} diff --git a/internal/ingresssidecar/ebpfdatapath.go b/internal/ingresssidecar/ebpfdatapath.go index b567c67f..468b0595 100644 --- a/internal/ingresssidecar/ebpfdatapath.go +++ b/internal/ingresssidecar/ebpfdatapath.go @@ -338,13 +338,19 @@ func ensureEgressDatapath(vpc string, tableID uint32) error { return fmt.Errorf("register eBPF ifindex_vrf_table entry: %w", err) } + serviceProgram, err := ebpf.LoadPinnedProgram(filepath.Join(ebpfPinDir, attach.UsidServiceEgressPinName), nil) + if err != nil { + return fmt.Errorf("load pinned usid_service_egress program: %w", err) + } + defer func() { _ = serviceProgram.Close() }() + program, err := ebpf.LoadPinnedProgram(filepath.Join(ebpfPinDir, attach.UsidEgressPinName), nil) if err != nil { return fmt.Errorf("load pinned usid_egress program: %w", err) } defer func() { _ = program.Close() }() - if err := attach.AttachEgress(program, peerLink.Attrs().Name); err != nil { + if err := attach.AttachEgress(serviceProgram, program, peerLink.Attrs().Name); err != nil { return fmt.Errorf("attach usid_egress to VRF %d's veth peer %q: %w", tableID, peerLink.Attrs().Name, err) } return nil diff --git a/internal/installer/installer.go b/internal/installer/installer.go index 2881cf51..3fa4cd5d 100644 --- a/internal/installer/installer.go +++ b/internal/installer/installer.go @@ -1134,6 +1134,13 @@ func Run(ctx context.Context, grpcHealthPort, metricsPort int) error { ebpfState.mssClamp.reconcile() case <-ebpfGCSweepTicker.C: + if ebpfState.objs != nil { + if removed, err := attach.SweepAttachmentIdentities(ebpfState.objs.AttachmentIdentityTable); err != nil { + slog.Error("GC: failed to sweep attachment identity map", "err", err) + } else if removed != 0 { + slog.Info("GC: removed stale attachment identities", "removed", removed) + } + } if ebpfState.k8sClient == nil { continue } diff --git a/internal/installer/installer_test.go b/internal/installer/installer_test.go index ee952451..71102c25 100644 --- a/internal/installer/installer_test.go +++ b/internal/installer/installer_test.go @@ -597,17 +597,24 @@ func TestRun_EBPFDatapathEnabled_MetricsAndHealthReflectRealDatapath(t *testing. t.Helper() deadline := time.Now().Add(2 * time.Second) var last grpc_health_v1.HealthCheckResponse_ServingStatus + var lastErr error for time.Now().Before(deadline) { resp, err := hc.Check(context.Background(), &grpc_health_v1.HealthCheckRequest{Service: ebpfHealthServiceName}) if err != nil { - t.Fatalf("Health check for %q failed: %v", ebpfHealthServiceName, err) + lastErr = err + time.Sleep(20 * time.Millisecond) + continue } + lastErr = nil last = resp.Status if last == want { return } time.Sleep(20 * time.Millisecond) } + if lastErr != nil { + t.Fatalf("Health check for %q did not become available: %v", ebpfHealthServiceName, lastErr) + } t.Fatalf("Health status for %q = %v, want %v (timed out waiting)", ebpfHealthServiceName, last, want) } diff --git a/internal/installer/manifest_test.go b/internal/installer/manifest_test.go index c0492c7b..60b503bb 100644 --- a/internal/installer/manifest_test.go +++ b/internal/installer/manifest_test.go @@ -76,6 +76,20 @@ func TestDaemonsetManifest_RunContainerMountsHostConflistDirWritable(t *testing. t.Errorf("container %q in %s does not mount %s", c.Name, manifestPath, hostConflistDir) } +func TestDaemonsetManifest_RunContainerMountsSharedTCLockDirWritable(t *testing.T) { + c := findRunContainer(t) + const lockDir = "/run/galactic" + for _, vm := range c.VolumeMounts { + if vm.MountPath == lockDir { + if vm.ReadOnly { + t.Fatalf("container %q mounts shared TC lock directory %s read-only", c.Name, lockDir) + } + return + } + } + t.Fatalf("container %q does not mount host TC lock directory %s", c.Name, lockDir) +} + // TestDaemonsetManifest_RunContainerMountsRadvStateDir is a regression test // for the same class of manifest/code mismatch as // TestDaemonsetManifest_RunContainerMountsHostConflistDir above, this time diff --git a/internal/plumbing/ebpf/attach/attach.go b/internal/plumbing/ebpf/attach/attach.go index 7abd09b9..0eb97219 100644 --- a/internal/plumbing/ebpf/attach/attach.go +++ b/internal/plumbing/ebpf/attach/attach.go @@ -12,6 +12,7 @@ import ( "path/filepath" "strconv" "strings" + "sync" "github.com/cilium/ebpf" "github.com/cilium/ebpf/rlimit" @@ -19,6 +20,7 @@ import ( "golang.org/x/sys/unix" "go.datum.net/galactic/internal/config" + "go.datum.net/galactic/internal/plumbing/ebpf/attachmentidentity" "go.datum.net/galactic/internal/plumbing/ebpf/mappin" "go.datum.net/galactic/internal/plumbing/ebpf/preflight" "go.datum.net/galactic/internal/plumbing/ebpf/prog" @@ -43,6 +45,25 @@ const defaultFilterPriority = 1 // environment variable. var filterPriorityFn = resolveFilterPriority +var ( + filterReplaceFn = netlink.FilterReplace + filterDeleteFn = netlink.FilterDel + programFromIDFn = ebpf.NewProgramFromID + snapshotEgressChainFn = snapshotEgressChain + restoreEgressChainFn = func(snapshot *egressChainSnapshot, ifaceName string) error { + return snapshot.restore(ifaceName) + } + attachOneAtPriorityFn = func(program *ebpf.Program, name, ifaceName string, parent uint32, + priority uint16, handle uint32) error { + return attachOneAtPriority(program, ifaceName, name, parent, priority, handle) + } + withInterfaceLockFn = withInterfaceLock + withIdentityLockFn = withIdentityLock + validateEgressTargetSlotsFn = validateEgressTargetSlots +) + +var localEgressAttachMu sync.Mutex + // resolveFilterPriority returns the configured tc priority when the environment // sets a valid uint16, and defaultFilterPriority otherwise. func resolveFilterPriority() uint16 { @@ -75,6 +96,15 @@ func Start(pinDir string) (objs *prog.UsidObjects, ifaces []string, err error) { if err != nil { return nil, nil, err } + // Close the service-policy gate before rotating any attachment identity. + // The controller reopens it only after its cache-synchronized rebuild and + // sweep. Doing this here, rather than waiting for that controller runnable, + // prevents unchanged interfaces from continuing against the previous + // generation while startup reattach rotates their peers one by one. + if err := objs.ServicePolicyStateTable.Put(uint32(0), prog.UsidServicePolicyStateValue{}); err != nil { + _ = objs.Close() + return nil, nil, fmt.Errorf("attach: disable service policy before startup reattach: %w", err) + } ifaces, err = ResolveInterfaces() if err != nil { @@ -87,16 +117,16 @@ func Start(pinDir string) (objs *prog.UsidObjects, ifaces []string, err error) { return nil, nil, fmt.Errorf("attach: %w", err) } - // Not fatal: an attachment left on the previous usid_egress still forwards, - // only without what changed since. Failing startup would drop the ingress - // datapath for the whole node over it. - replaced, err := ReattachEgress(objs.UsidEgress) + // Reattach is readiness-fatal. After an incompatible policy-map rollout an + // old classifier may reference the deliberately drained, unpinned map set; + // reporting ready before every owned interface moves to this collection + // would leave private services unavailable indefinitely. + replaced, err := ReattachEgress(objs.UsidServiceEgress, objs.UsidEgress, objs.AttachmentIdentityTable) if err != nil { - slog.Error("attach: could not move every attachment onto the new usid_egress; "+ - "those keep running the previous program and maps until their next CNI ADD", - "replaced", replaced, "err", err) + _ = objs.Close() + return nil, nil, fmt.Errorf("attach: reattach service egress chain (replaced %d): %w", replaced, err) } else if replaced > 0 { - slog.Info("attach: moved existing attachments onto the new usid_egress", "replaced", replaced) + slog.Info("attach: moved existing attachments onto the new service egress chain", "replaced", replaced) } return objs, ifaces, nil @@ -158,6 +188,14 @@ func Load(pinDir string) (objs *prog.UsidObjects, err error) { // Recreating them empties them on a compute node until every workload // re-attaches, which blackholes every attachment on the node. An // unchanged map keeps its rows. + // An already-attached old classifier keeps map FDs after their pins + // are removed. Revoke its service authorization before replacing any + // incompatible pin so it cannot keep forwarding against an orphaned + // policy generation if the subsequent reattach fails. + if drainErr := drainLegacyServiceAuthorization(pinDir); drainErr != nil { + err = fmt.Errorf("attach: revoke legacy service authorization before map replacement: %w", drainErr) + return nil, err + } unpinned, unpinErr := mappin.UnpinIncompatible(spec, pinDir, nil) if unpinErr != nil { err = fmt.Errorf("attach: recreate incompatible pinned maps: %w", unpinErr) @@ -203,6 +241,15 @@ func Load(pinDir string) (objs *prog.UsidObjects, err error) { err = fmt.Errorf("attach: pin usid_egress program: %w", pinErr) return nil, err } + serviceEgressPinPath := filepath.Join(pinDir, UsidServiceEgressPinName) + if rmErr := os.Remove(serviceEgressPinPath); rmErr != nil && !os.IsNotExist(rmErr) { + err = fmt.Errorf("attach: remove stale usid_service_egress pin: %w", rmErr) + return nil, err + } + if pinErr := loaded.UsidServiceEgress.Pin(serviceEgressPinPath); pinErr != nil { + err = fmt.Errorf("attach: pin usid_service_egress program: %w", pinErr) + return nil, err + } return &loaded, nil } @@ -211,6 +258,10 @@ func Load(pinDir string) (objs *prog.UsidObjects, err error) { // from every map name in the same directory so the two can never collide. const UsidEgressPinName = "usid_egress_prog" +// UsidServiceEgressPinName is the bpffs filename for the private-service +// classifier that runs immediately before usid_egress in the TC chain. +const UsidServiceEgressPinName = "usid_service_egress_prog" + // Attach attaches program to the ingress hook of each named interface, through a // clsact qdisc and a direct-action BPF filter, creating the qdisc if it does not // exist. @@ -243,6 +294,8 @@ func Attach(program *ebpf.Program, ifaceNames []string) error { // or tap. const egressFilterName = "galactic_usid_egress" +const serviceEgressFilterName = "galactic_usid_service_egress" + // AttachEgress attaches program, usid_egress loaded from its pin, to ifaceName's // ingress hook. That interface is the tenant's own host-side veth or tap, not // the shared uplink usid_ingress uses, because that is where the tenant's egress @@ -250,11 +303,302 @@ const egressFilterName = "galactic_usid_egress" // // A thin wrapper around the same idempotency Attach provides, for one interface // at a time, since each CNI ADD attaches only its own attachment's interface. -func AttachEgress(program *ebpf.Program, ifaceName string) error { - if program == nil { - return errors.New("attach: program is nil") +func AttachEgress(serviceProgram, legacyProgram *ebpf.Program, ifaceName string) error { + localEgressAttachMu.Lock() + defer localEgressAttachMu.Unlock() + return attachEgressLocked(serviceProgram, legacyProgram, ifaceName) +} + +// AttachEgressWithIdentity performs CNI ADD's identity decision and TC update +// under one cross-process interface lock. A genuinely idempotent ADD keeps its +// token only when the exact current two-program chain is already present; +// every new/reused interface incarnation rotates before a classifier can run. +func AttachEgressWithIdentity(serviceProgram, legacyProgram *ebpf.Program, identityMap *ebpf.Map, + ifindex uint32, ifaceName string, +) error { + return withIdentityLockFn(func() error { + return withInterfaceLockFn(ifaceName, func() error { + current, err := exactEgressChainCurrent(ifaceName, serviceProgram, legacyProgram) + if err != nil { + return err + } + var token uint64 + if err := identityMap.Lookup(ifindex, &token); err != nil && !errors.Is(err, ebpf.ErrKeyNotExist) { + return fmt.Errorf("lookup attachment identity: %w", err) + } + if !current || token == 0 { + if _, err := attachmentidentity.RotateMap(identityMap, ifindex); err != nil { + return err + } + } + return attachEgressLocked(serviceProgram, legacyProgram, ifaceName) + }) + }) +} + +func withIdentityLock(fn func() error) error { + return withInterfaceLock("attachment-identity-global", fn) +} + +func exactEgressChainCurrent(ifaceName string, serviceProgram, legacyProgram *ebpf.Program) (bool, error) { + serviceID, err := egressProgramID(serviceProgram, "usid_service_egress") + if err != nil { + return false, err + } + legacyID, err := egressProgramID(legacyProgram, "usid_egress") + if err != nil { + return false, err + } + link, err := linkByNameFn(ifaceName) + if err != nil { + return false, err + } + filters, err := filterListFn(link, netlink.HANDLE_MIN_INGRESS) + if err != nil { + return false, err } - return attachOne(program, ifaceName, egressFilterName, netlink.HANDLE_MIN_INGRESS) + priority := filterPriorityFn() + serviceMatches, legacyMatches, managed := 0, 0, 0 + for _, filter := range filters { + bpfFilter, ok := filter.(*netlink.BpfFilter) + if !ok || !isManagedEgressFilter(bpfFilter.Name) { + continue + } + managed++ + if bpfFilter.Name == serviceEgressFilterName && uint32(bpfFilter.Id) == serviceID && + bpfFilter.Priority == priority && bpfFilter.Handle == netlink.MakeHandle(0, 1) { + serviceMatches++ + } + if bpfFilter.Name == egressFilterName && uint32(bpfFilter.Id) == legacyID && + bpfFilter.Priority == priority+1 && bpfFilter.Handle == netlink.MakeHandle(0, 2) { + legacyMatches++ + } + } + return managed == 2 && serviceMatches == 1 && legacyMatches == 1, nil +} + +func attachEgressLocked(serviceProgram, legacyProgram *ebpf.Program, ifaceName string) error { + if serviceProgram == nil || legacyProgram == nil { + return errors.New("attach: egress program is nil") + } + priority := filterPriorityFn() + if priority == ^uint16(0) { + return errors.New("attach: egress filter priority leaves no room for legacy continuation") + } + snapshot, err := snapshotEgressChainFn(ifaceName, priority) + if err != nil { + return fmt.Errorf("snapshot existing egress chain: %w", err) + } + defer snapshot.close() + + // Install the continuation first. During a legacy-only migration the old + // priority slot remains the active program until the final replace, so a + // failure here cannot interrupt forwarding. + if err := validateEgressTargetSlotsFn(ifaceName, priority); err != nil { + return err + } + if err := attachOneAtPriorityFn(legacyProgram, egressFilterName, ifaceName, + netlink.HANDLE_MIN_INGRESS, priority+1, netlink.MakeHandle(0, 2)); err != nil { + // A netlink error does not prove the kernel rejected the mutation (for + // example, the request may have committed before an acknowledgement was + // lost). Restore even this make-before-break step so every failure path + // converges on the exact snapshot. + restoreErr := restoreEgressChainFn(snapshot, ifaceName) + return errors.Join(fmt.Errorf("legacy continuation: %w", err), restoreErr) + } + if err := validateEgressTargetSlotsFn(ifaceName, priority); err != nil { + restoreErr := restoreEgressChainFn(snapshot, ifaceName) + return errors.Join(err, restoreErr) + } + if err := attachOneAtPriorityFn(serviceProgram, serviceEgressFilterName, ifaceName, + netlink.HANDLE_MIN_INGRESS, priority, netlink.MakeHandle(0, 1)); err != nil { + restoreErr := restoreEgressChainFn(snapshot, ifaceName) + return errors.Join(fmt.Errorf("service classifier: %w", err), restoreErr) + } + if err := removeDuplicateManagedEgressFilters(ifaceName, priority); err != nil { + return fmt.Errorf("remove duplicate egress filters: %w", err) + } + return nil +} + +func withInterfaceLock(ifaceName string, fn func() error) error { + const lockDir = "/run/galactic" + if err := os.MkdirAll(lockDir, 0o755); err != nil { + return fmt.Errorf("attach: create interface lock directory: %w", err) + } + lockPath := filepath.Join(lockDir, "tc-"+filepath.Base(ifaceName)+".lock") + f, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0o600) + if err != nil { + return fmt.Errorf("attach: open interface lock: %w", err) + } + defer func() { _ = f.Close() }() + if err := unix.Flock(int(f.Fd()), unix.LOCK_EX); err != nil { + return fmt.Errorf("attach: lock interface: %w", err) + } + defer func() { _ = unix.Flock(int(f.Fd()), unix.LOCK_UN) }() + return fn() +} + +func validateEgressTargetSlots(ifaceName string, priority uint16) error { + link, err := linkByNameFn(ifaceName) + if err != nil { + return fmt.Errorf("revalidate target slots: find link: %w", err) + } + filters, err := filterListFn(link, netlink.HANDLE_MIN_INGRESS) + if err != nil { + return fmt.Errorf("revalidate target slots: list filters: %w", err) + } + for _, filter := range filters { + if !isEgressTargetSlot(filter.Attrs(), priority) { + continue + } + bpfFilter, ok := filter.(*netlink.BpfFilter) + if !ok || !allowedEgressSlotOwner(bpfFilter, priority) { + return fmt.Errorf("target priority=%d handle=%#x became occupied by a foreign tc filter", + filter.Attrs().Priority, filter.Attrs().Handle) + } + } + return nil +} + +func removeDuplicateManagedEgressFilters(ifaceName string, priority uint16) error { + link, err := linkByNameFn(ifaceName) + if err != nil { + return err + } + filters, err := filterListFn(link, netlink.HANDLE_MIN_INGRESS) + if err != nil { + return err + } + var errs []error + for _, filter := range filters { + bpfFilter, ok := filter.(*netlink.BpfFilter) + if !ok || !isManagedEgressFilter(bpfFilter.Name) { + continue + } + wanted := bpfFilter.Name == serviceEgressFilterName && bpfFilter.Priority == priority && + bpfFilter.Handle == netlink.MakeHandle(0, 1) || + bpfFilter.Name == egressFilterName && bpfFilter.Priority == priority+1 && + bpfFilter.Handle == netlink.MakeHandle(0, 2) + if !wanted { + errs = append(errs, filterDeleteFn(filter)) + } + } + return errors.Join(errs...) +} + +type egressFilterSnapshot struct { + program *ebpf.Program + name string + priority uint16 + handle uint32 +} + +type egressChainSnapshot struct { + filters []egressFilterSnapshot +} + +func snapshotEgressChain(ifaceName string, priority uint16) (*egressChainSnapshot, error) { + link, err := linkByNameFn(ifaceName) + if err != nil { + return nil, fmt.Errorf("find link: %w", err) + } + filters, err := filterListFn(link, netlink.HANDLE_MIN_INGRESS) + if err != nil { + return nil, fmt.Errorf("list filters: %w", err) + } + snapshot := &egressChainSnapshot{} + for _, filter := range filters { + attrs := filter.Attrs() + bpfFilter, ok := filter.(*netlink.BpfFilter) + if isEgressTargetSlot(attrs, priority) && (!ok || !allowedEgressSlotOwner(bpfFilter, priority)) { + snapshot.close() + return nil, fmt.Errorf("target priority=%d handle=%#x is occupied by a foreign tc filter", + attrs.Priority, attrs.Handle) + } + if !ok || (bpfFilter.Name != egressFilterName && bpfFilter.Name != serviceEgressFilterName) { + continue + } + program, err := programFromIDFn(ebpf.ProgramID(bpfFilter.Id)) + if err != nil { + snapshot.close() + return nil, fmt.Errorf("open prior program %d for %s: %w", bpfFilter.Id, bpfFilter.Name, err) + } + snapshot.filters = append(snapshot.filters, egressFilterSnapshot{ + program: program, name: bpfFilter.Name, priority: bpfFilter.Priority, handle: bpfFilter.Handle, + }) + } + return snapshot, nil +} + +func isEgressTargetSlot(attrs *netlink.FilterAttrs, priority uint16) bool { + return (attrs.Priority == priority && attrs.Handle == netlink.MakeHandle(0, 1)) || + (attrs.Priority == priority+1 && attrs.Handle == netlink.MakeHandle(0, 2)) +} + +func allowedEgressSlotOwner(filter *netlink.BpfFilter, priority uint16) bool { + if filter.Priority == priority && filter.Handle == netlink.MakeHandle(0, 1) { + return filter.Name == serviceEgressFilterName || filter.Name == egressFilterName + } + return filter.Priority == priority+1 && filter.Handle == netlink.MakeHandle(0, 2) && + filter.Name == egressFilterName +} + +func (s *egressChainSnapshot) close() { + for _, filter := range s.filters { + _ = filter.program.Close() + } +} + +func (s *egressChainSnapshot) restore(ifaceName string) error { + var errs []error + // Put the old filters back before removing anything. In the legacy-only + // migration this restores the old first-stage filter while the newly added + // continuation still exists, so rollback never deliberately creates an + // interval with no working egress program. + for _, filter := range s.filters { + if err := attachOneAtPriorityFn(filter.program, filter.name, ifaceName, netlink.HANDLE_MIN_INGRESS, + filter.priority, filter.handle); err != nil { + errs = append(errs, fmt.Errorf("restore %s: %w", filter.name, err)) + } + } + + link, err := linkByNameFn(ifaceName) + if err != nil { + errs = append(errs, fmt.Errorf("find link while removing new filters: %w", err)) + } else { + filters, listErr := filterListFn(link, netlink.HANDLE_MIN_INGRESS) + if listErr != nil { + errs = append(errs, fmt.Errorf("list filters while removing new filters: %w", listErr)) + } else { + for _, current := range filters { + bpfFilter, ok := current.(*netlink.BpfFilter) + if !ok || !isManagedEgressFilter(bpfFilter.Name) || s.contains(bpfFilter) { + continue + } + if err := filterDeleteFn(current); err != nil { + errs = append(errs, fmt.Errorf("remove newly installed %s: %w", bpfFilter.Name, err)) + } + } + } + } + if err := errors.Join(errs...); err != nil { + return fmt.Errorf("restore prior egress chain: %w", err) + } + return nil +} + +func (s *egressChainSnapshot) contains(filter *netlink.BpfFilter) bool { + for _, saved := range s.filters { + if saved.name == filter.Name && saved.priority == filter.Priority && saved.handle == filter.Handle { + return true + } + } + return false +} + +func isManagedEgressFilter(name string) bool { + return name == egressFilterName || name == serviceEgressFilterName } // attachOne attaches program to one interface's TC hook, named by @@ -262,6 +606,11 @@ func AttachEgress(program *ebpf.Program, ifaceName string) error { // the choke point every attach path in this package goes through, so // instrumenting it here observes every attempt whatever the caller. func attachOne(program *ebpf.Program, name, tcFilterName string, parent uint32) (err error) { + return attachOneAtPriority(program, name, tcFilterName, parent, filterPriorityFn(), netlink.MakeHandle(0, 1)) +} + +func attachOneAtPriority(program *ebpf.Program, name, tcFilterName string, parent uint32, + priority uint16, handle uint32) (err error) { defer func() { attachHook(name, err) }() link, err := netlink.LinkByName(name) @@ -279,15 +628,15 @@ func attachOne(program *ebpf.Program, name, tcFilterName string, parent uint32) FilterAttrs: netlink.FilterAttrs{ LinkIndex: link.Attrs().Index, Parent: parent, - Handle: netlink.MakeHandle(0, 1), + Handle: handle, Protocol: unix.ETH_P_ALL, - Priority: filterPriorityFn(), + Priority: priority, }, Fd: program.FD(), Name: tcFilterName, DirectAction: true, } - if err = netlink.FilterReplace(filter); err != nil { + if err = filterReplaceFn(filter); err != nil { err = fmt.Errorf("attach tc-bpf filter: %w", err) return err } @@ -341,7 +690,7 @@ func detachOne(name, tcFilterName string, parent uint32) (err error) { if !ok || bpfFilter.Name != tcFilterName { continue } - if delErr := netlink.FilterDel(f); delErr != nil { + if delErr := filterDeleteFn(f); delErr != nil { err = fmt.Errorf("delete tc-bpf filter: %w", delErr) return err } diff --git a/internal/plumbing/ebpf/attach/attach_test.go b/internal/plumbing/ebpf/attach/attach_test.go index 4488447e..a0e401d1 100644 --- a/internal/plumbing/ebpf/attach/attach_test.go +++ b/internal/plumbing/ebpf/attach/attach_test.go @@ -178,9 +178,9 @@ func TestLoadAttach_SurvivesRestartWithMapsIntact(t *testing.T) { // different shape (e.g. a changed value struct size, as vrf_value grew // this cycle) must be recreated from scratch on the next Load, not treated // as a fatal error that crashloops the node until an operator manually -// deletes the stale pin. Every map here is control-plane-owned and -// reconstructable (usidmap.Register/the GC controller repopulate it), so -// losing its contents across a schema change is the correct trade-off. +// deletes the stale pin. That map is reconstructable, but compatible maps +// must retain their contents and remain connected to long-lived handles held +// by other processes. func TestLoad_RecreatesIncompatiblyPinnedMap(t *testing.T) { requireRoot(t) @@ -189,6 +189,26 @@ func TestLoad_RecreatesIncompatiblyPinnedMap(t *testing.T) { if err := os.MkdirAll(pinDir, 0o755); err != nil { t.Fatalf("create pin dir: %v", err) } + const locatorKey uint64 = 0x0102030405060708 + initial, err := Load(pinDir) + if err != nil { + t.Fatalf("initial Load(): %v", err) + } + if err := initial.LocatorTable.Put(locatorKey, prog.UsidLocatorValue{Generation: 7}); err != nil { + t.Fatalf("populate compatible locator map: %v", err) + } + _ = initial.Close() + + // Replace only vrf_table's pin with an old, incompatible shape. All other + // pins stand in for live maps owned by the preceding process version. + oldVRF, err := ebpf.LoadPinnedMap(filepath.Join(pinDir, prog.UsidMapVrfTable), nil) + if err != nil { + t.Fatalf("open current vrf_table pin: %v", err) + } + if err := oldVRF.Unpin(); err != nil { + t.Fatalf("unpin current vrf_table: %v", err) + } + _ = oldVRF.Close() // Pre-pin a vrf_table with a deliberately incompatible shape (wrong // ValueSize) -- standing in for a previous process version's now-stale @@ -222,6 +242,13 @@ func TestLoad_RecreatesIncompatiblyPinnedMap(t *testing.T) { t.Errorf("vrf_table ValueSize after recreation = %d, want the real program's value size, not the stale 4-byte one", info.ValueSize) } + var locatorValue prog.UsidLocatorValue + if err := objs.LocatorTable.Lookup(locatorKey, &locatorValue); err != nil { + t.Fatalf("compatible locator map entry was lost during selective recreation: %v", err) + } + if locatorValue.Generation != 7 { + t.Errorf("compatible locator map generation = %d, want 7", locatorValue.Generation) + } } // TestAttach_NoInterfacesIsError covers the defensive nil/empty-input diff --git a/internal/plumbing/ebpf/attach/egress_atomic_test.go b/internal/plumbing/ebpf/attach/egress_atomic_test.go new file mode 100644 index 00000000..27c22a3a --- /dev/null +++ b/internal/plumbing/ebpf/attach/egress_atomic_test.go @@ -0,0 +1,215 @@ +// Copyright 2026 Datum Cloud, Inc. +// SPDX-License-Identifier: AGPL-3.0-or-later + +package attach + +import ( + "errors" + "reflect" + "testing" + + "github.com/cilium/ebpf" + "github.com/vishvananda/netlink" +) + +func TestAttachEgressFailureAtomicOrdering(t *testing.T) { + oldPriority := filterPriorityFn + oldSnapshot := snapshotEgressChainFn + oldRestore := restoreEgressChainFn + oldAttach := attachOneAtPriorityFn + oldLock := withInterfaceLockFn + oldValidate := validateEgressTargetSlotsFn + t.Cleanup(func() { + filterPriorityFn = oldPriority + snapshotEgressChainFn = oldSnapshot + restoreEgressChainFn = oldRestore + attachOneAtPriorityFn = oldAttach + withInterfaceLockFn = oldLock + validateEgressTargetSlotsFn = oldValidate + }) + // Plain AttachEgress is used by an unprivileged sidecar that cannot open + // the host-shared flock directory. It must remain process-local. + withInterfaceLockFn = func(string, func() error) error { return errors.New("cross-process lock unavailable") } + validateEgressTargetSlotsFn = func(string, uint16) error { return nil } + filterPriorityFn = func() uint16 { return 7 } + service, legacy := &ebpf.Program{}, &ebpf.Program{} + + t.Run("snapshot failure mutates nothing", func(t *testing.T) { + wantErr := errors.New("snapshot failed") + snapshotEgressChainFn = func(string, uint16) (*egressChainSnapshot, error) { return nil, wantErr } + var calls []string + attachOneAtPriorityFn = func(_ *ebpf.Program, name, _ string, _ uint32, _ uint16, _ uint32) error { + calls = append(calls, name) + return nil + } + if err := AttachEgress(service, legacy, "tenant0"); !errors.Is(err, wantErr) { + t.Fatalf("error = %v, want %v", err, wantErr) + } + if len(calls) != 0 { + t.Fatalf("attach calls = %v, want none", calls) + } + }) + + t.Run("continuation failure restores snapshot", func(t *testing.T) { + snapshotEgressChainFn = func(string, uint16) (*egressChainSnapshot, error) { return &egressChainSnapshot{}, nil } + wantErr := errors.New("continuation failed") + var calls []string + attachOneAtPriorityFn = func(_ *ebpf.Program, name, _ string, _ uint32, _ uint16, _ uint32) error { + calls = append(calls, name) + return wantErr + } + restored := false + restoreEgressChainFn = func(*egressChainSnapshot, string) error { restored = true; return nil } + if err := AttachEgress(service, legacy, "tenant0"); !errors.Is(err, wantErr) { + t.Fatalf("error = %v, want %v", err, wantErr) + } + if !reflect.DeepEqual(calls, []string{egressFilterName}) { + t.Fatalf("attach calls = %v", calls) + } + if !restored { + t.Fatal("prior chain was not restored") + } + }) + + t.Run("classifier failure restores snapshot", func(t *testing.T) { + snapshot := &egressChainSnapshot{} + snapshotEgressChainFn = func(string, uint16) (*egressChainSnapshot, error) { return snapshot, nil } + wantErr := errors.New("classifier failed") + var calls []string + attachOneAtPriorityFn = func(_ *ebpf.Program, name, _ string, _ uint32, priority uint16, _ uint32) error { + calls = append(calls, name) + if name == serviceEgressFilterName { + return wantErr + } + if priority != 8 { + t.Fatalf("continuation priority = %d, want 8", priority) + } + return nil + } + restored := false + restoreEgressChainFn = func(got *egressChainSnapshot, iface string) error { + restored = got == snapshot && iface == "tenant0" + return nil + } + if err := AttachEgress(service, legacy, "tenant0"); !errors.Is(err, wantErr) { + t.Fatalf("error = %v, want %v", err, wantErr) + } + if !reflect.DeepEqual(calls, []string{egressFilterName, serviceEgressFilterName}) { + t.Fatalf("attach calls = %v, want continuation then classifier", calls) + } + if !restored { + t.Fatal("prior chain was not restored") + } + }) +} + +func TestSnapshotEgressChainRefusesForeignTargetSlot(t *testing.T) { + oldLinkByName := linkByNameFn + oldFilterList := filterListFn + t.Cleanup(func() { + linkByNameFn = oldLinkByName + filterListFn = oldFilterList + }) + linkByNameFn = func(string) (netlink.Link, error) { return &netlink.Dummy{}, nil } + filters := []netlink.Filter{ + bpfFilter("another-cni", 7, netlink.MakeHandle(0, 1)), + bpfFilter("unrelated-elsewhere", 20, netlink.MakeHandle(0, 9)), + } + filterListFn = func(netlink.Link, uint32) ([]netlink.Filter, error) { + return filters, nil + } + if _, err := snapshotEgressChain("tenant0", 7); err == nil { + t.Fatal("snapshot accepted a foreign filter in the classifier target slot") + } + + filters = []netlink.Filter{ + bpfFilter("unrelated-elsewhere", 20, netlink.MakeHandle(0, 9)), + } + filterListFn = func(netlink.Link, uint32) ([]netlink.Filter, error) { + return filters, nil + } + if _, err := snapshotEgressChain("tenant0", 7); err != nil { + t.Fatalf("snapshot rejected unrelated filter outside target slots: %v", err) + } +} + +func TestEgressChainSnapshotRestoreRemovesOnlyNewSlots(t *testing.T) { + oldAttach := attachOneAtPriorityFn + oldLinkByName := linkByNameFn + oldFilterList := filterListFn + oldFilterDelete := filterDeleteFn + t.Cleanup(func() { + attachOneAtPriorityFn = oldAttach + linkByNameFn = oldLinkByName + filterListFn = oldFilterList + filterDeleteFn = oldFilterDelete + }) + + tests := []struct { + name string + snapshot []egressFilterSnapshot + current []netlink.Filter + wantPut []string + wantDel []uint16 + }{ + { + name: "legacy-only migration", + snapshot: []egressFilterSnapshot{{ + program: &ebpf.Program{}, name: egressFilterName, priority: 7, handle: netlink.MakeHandle(0, 1), + }}, + current: []netlink.Filter{ + bpfFilter(egressFilterName, 7, netlink.MakeHandle(0, 1)), + bpfFilter(egressFilterName, 8, netlink.MakeHandle(0, 2)), + }, + wantPut: []string{egressFilterName}, + wantDel: []uint16{8}, + }, + { + name: "existing two-program chain", + snapshot: []egressFilterSnapshot{ + {program: &ebpf.Program{}, name: serviceEgressFilterName, priority: 7, handle: netlink.MakeHandle(0, 1)}, + {program: &ebpf.Program{}, name: egressFilterName, priority: 8, handle: netlink.MakeHandle(0, 2)}, + }, + current: []netlink.Filter{ + bpfFilter(serviceEgressFilterName, 7, netlink.MakeHandle(0, 1)), + bpfFilter(egressFilterName, 8, netlink.MakeHandle(0, 2)), + }, + wantPut: []string{serviceEgressFilterName, egressFilterName}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var put []string + var deleted []uint16 + attachOneAtPriorityFn = func(_ *ebpf.Program, name, _ string, _ uint32, _ uint16, _ uint32) error { + put = append(put, name) + return nil + } + linkByNameFn = func(string) (netlink.Link, error) { return &netlink.Dummy{}, nil } + filterListFn = func(netlink.Link, uint32) ([]netlink.Filter, error) { return tt.current, nil } + filterDeleteFn = func(filter netlink.Filter) error { + deleted = append(deleted, filter.(*netlink.BpfFilter).Priority) + return nil + } + + snapshot := &egressChainSnapshot{filters: tt.snapshot} + if err := snapshot.restore("tenant0"); err != nil { + t.Fatalf("restore: %v", err) + } + if !reflect.DeepEqual(put, tt.wantPut) { + t.Errorf("restored filters = %v, want %v", put, tt.wantPut) + } + if !reflect.DeepEqual(deleted, tt.wantDel) { + t.Errorf("deleted filters = %v, want %v", deleted, tt.wantDel) + } + }) + } +} + +func bpfFilter(name string, priority uint16, handle uint32) *netlink.BpfFilter { + return &netlink.BpfFilter{ + FilterAttrs: netlink.FilterAttrs{Priority: priority, Handle: handle}, + Name: name, + } +} diff --git a/internal/plumbing/ebpf/attach/egress_reattach.go b/internal/plumbing/ebpf/attach/egress_reattach.go index c776c7d8..e7b9d111 100644 --- a/internal/plumbing/ebpf/attach/egress_reattach.go +++ b/internal/plumbing/ebpf/attach/egress_reattach.go @@ -11,27 +11,39 @@ import ( "github.com/cilium/ebpf" "github.com/vishvananda/netlink" "golang.org/x/sys/unix" + + "go.datum.net/galactic/internal/plumbing/ebpf/attachmentidentity" ) -// ReattachEgress moves every usid_egress filter on this node's veths and taps -// onto program, the usid_egress just loaded, and returns how many filters it -// replaced. +// ReattachEgress moves every usid_egress attachment on this node's veths and +// taps onto the service classifier followed by the legacy egress program, and +// returns how many attachment chains it replaced. // -// A filter is recognized by egressFilterName on a link's ingress hook and is -// replaced in place, keeping its handle and priority. A filter already running -// program is left alone, and a link deleted during the scan is skipped. Every -// link is attempted, and the failures are joined and returned together. -func ReattachEgress(program *ebpf.Program) (int, error) { - if program == nil { - return 0, errors.New("attach: program is nil") +// An attachment is recognized by egressFilterName on a link's ingress hook. +// Links already running both programs are left alone, and a link deleted +// during the scan is skipped. Every link is attempted, and failures are joined +// and returned together. +func ReattachEgress(serviceProgram, legacyProgram *ebpf.Program, identityMap *ebpf.Map) (int, error) { + if identityMap == nil { + return 0, errors.New("attach: attachment identity map is nil") } - info, err := program.Info() + var replaced int + err := withIdentityLockFn(func() error { + var err error + replaced, err = reattachEgressLocked(serviceProgram, legacyProgram, identityMap) + return err + }) + return replaced, err +} + +func reattachEgressLocked(serviceProgram, legacyProgram *ebpf.Program, identityMap *ebpf.Map) (int, error) { + serviceID, err := egressProgramID(serviceProgram, "usid_service_egress") if err != nil { - return 0, fmt.Errorf("attach: read usid_egress program info: %w", err) + return 0, err } - wantID, ok := info.ID() - if !ok { - return 0, errors.New("attach: kernel did not report usid_egress's program id") + legacyID, err := egressProgramID(legacyProgram, "usid_egress") + if err != nil { + return 0, err } links, err := listLinksFn() @@ -49,38 +61,93 @@ func ReattachEgress(program *ebpf.Program) (int, error) { default: continue } - filters, err := netlink.FilterList(link, netlink.HANDLE_MIN_INGRESS) + name := link.Attrs().Name + didReplace := false + err := withInterfaceLockFn(name, func() error { + var lockErr error + didReplace, lockErr = reattachEgressLinkLocked(link, serviceProgram, legacyProgram, identityMap, + serviceID, legacyID) + return lockErr + }) if err != nil { if linkGone(err) { continue } - errs = append(errs, fmt.Errorf("interface %q: list ingress filters: %w", link.Attrs().Name, err)) + errs = append(errs, fmt.Errorf("interface %q: %w", name, err)) continue } - for _, f := range filters { - bpf, ok := f.(*netlink.BpfFilter) - if !ok || bpf.Name != egressFilterName || uint32(bpf.Id) == uint32(wantID) { - continue - } - replacement := &netlink.BpfFilter{ - FilterAttrs: bpf.FilterAttrs, - Fd: program.FD(), - Name: egressFilterName, - DirectAction: true, - } - if err := netlink.FilterReplace(replacement); err != nil { - if linkGone(err) { - continue - } - errs = append(errs, fmt.Errorf("interface %q: replace usid_egress filter: %w", link.Attrs().Name, err)) - continue - } + if didReplace { replaced++ } } + if _, err := sweepAttachmentIdentitiesLocked(identityMap); err != nil { + errs = append(errs, fmt.Errorf("sweep attachment identities: %w", err)) + } return replaced, errors.Join(errs...) } +func reattachEgressLinkLocked(link netlink.Link, serviceProgram, legacyProgram *ebpf.Program, identityMap *ebpf.Map, + serviceID, legacyID uint32, +) (bool, error) { + filters, err := netlink.FilterList(link, netlink.HANDLE_MIN_INGRESS) + if err != nil { + return false, fmt.Errorf("list ingress filters: %w", err) + } + serviceMatches := 0 + legacyMatches := 0 + managedCount := 0 + for _, f := range filters { + bpf, ok := f.(*netlink.BpfFilter) + if !ok { + continue + } + switch bpf.Name { + case serviceEgressFilterName: + managedCount++ + if uint32(bpf.Id) == serviceID && bpf.Priority == filterPriorityFn() && + bpf.Handle == netlink.MakeHandle(0, 1) { + serviceMatches++ + } + case egressFilterName: + managedCount++ + if uint32(bpf.Id) == legacyID && bpf.Priority == filterPriorityFn()+1 && + bpf.Handle == netlink.MakeHandle(0, 2) { + legacyMatches++ + } + } + } + if managedCount == 0 { + return false, nil + } + // Rotate on every startup, including an already-current chain. Policy + // remains disabled until the controller rebuilds values with this token. + if _, err := attachmentidentity.RotateMap(identityMap, uint32(link.Attrs().Index)); err != nil { + return false, fmt.Errorf("rotate attachment identity: %w", err) + } + if serviceMatches == 1 && legacyMatches == 1 && managedCount == 2 { + return false, nil + } + if err := attachEgressLocked(serviceProgram, legacyProgram, link.Attrs().Name); err != nil { + return false, fmt.Errorf("replace egress filter chain: %w", err) + } + return true, nil +} + +func egressProgramID(program *ebpf.Program, name string) (uint32, error) { + if program == nil { + return 0, fmt.Errorf("attach: %s program is nil", name) + } + info, err := program.Info() + if err != nil { + return 0, fmt.Errorf("attach: read %s program info: %w", name, err) + } + id, ok := info.ID() + if !ok { + return 0, fmt.Errorf("attach: kernel did not report %s's program id", name) + } + return uint32(id), nil +} + // linkGone reports whether err means the link was deleted after it was // listed, which a pod or VM being torn down during the scan causes. That // attachment needs no new program, so it is skipped rather than reported. diff --git a/internal/plumbing/ebpf/attach/egress_reattach_test.go b/internal/plumbing/ebpf/attach/egress_reattach_test.go index bda68eaf..2c0af3a3 100644 --- a/internal/plumbing/ebpf/attach/egress_reattach_test.go +++ b/internal/plumbing/ebpf/attach/egress_reattach_test.go @@ -5,6 +5,7 @@ package attach import ( + "errors" "fmt" "os" "path/filepath" @@ -15,12 +16,18 @@ import ( "github.com/vishvananda/netlink" ) -// A new load moves an attachment off the previous usid_egress and onto its -// own. Without that, the attachment keeps running the old program against the -// old copies of every map, and rows written after the reload never reach it. -// Only usid_egress filters are touched, and a second pass changes nothing. -func TestReattachEgress_MovesAttachmentsToTheNewProgram(t *testing.T) { +// A new load moves both legacy-only and two-program attachments onto its own +// service egress chain. Without that, the attachment keeps running old +// programs against old copies of maps, and rows written after the reload never +// reach it. Unrelated filters are untouched, and a second pass changes nothing. +func TestReattachEgress_MovesAttachmentsToTheNewProgram(t *testing.T) { //nolint:gocyclo // privileged end-to-end matrix requireRoot(t) + const ( + legacyLink = "legacy0" + tenantLink = "tenant0" + serviceOnlyLink = "service0" + duplicateLink = "dupsvc0" + ) oldPin := filepath.Join("/sys/fs/bpf", fmt.Sprintf("galactic-reattach-old-%d", os.Getpid())) newPin := filepath.Join("/sys/fs/bpf", fmt.Sprintf("galactic-reattach-new-%d", os.Getpid())) @@ -33,10 +40,14 @@ func TestReattachEgress_MovesAttachmentsToTheNewProgram(t *testing.T) { defer func() { _ = nsObj.Close() }() err = nsObj.Do(func(_ ns.NetNS) error { - // tenant0 stands in for an attachment's host-side veth, and uplink0 - // for a shared uplink, which carries usid_ingress instead. + // legacy0 stands in for an attachment created before the service + // classifier existed, tenant0 for a current attachment, and uplink0 + // for a shared uplink carrying usid_ingress instead. links := []netlink.Link{ - &netlink.Veth{LinkAttrs: netlink.LinkAttrs{Name: "tenant0"}, PeerName: "tenant0p"}, + &netlink.Veth{LinkAttrs: netlink.LinkAttrs{Name: legacyLink}, PeerName: "legacy0p"}, + &netlink.Veth{LinkAttrs: netlink.LinkAttrs{Name: tenantLink}, PeerName: "tenant0p"}, + &netlink.Veth{LinkAttrs: netlink.LinkAttrs{Name: serviceOnlyLink}, PeerName: "service0p"}, + &netlink.Veth{LinkAttrs: netlink.LinkAttrs{Name: duplicateLink}, PeerName: "dupsvc0p"}, &netlink.Dummy{LinkAttrs: netlink.LinkAttrs{Name: "uplink0"}}, } for _, l := range links { @@ -53,8 +64,23 @@ func TestReattachEgress_MovesAttachmentsToTheNewProgram(t *testing.T) { return fmt.Errorf("load previous build: %w", err) } defer func() { _ = previous.Close() }() - if err := AttachEgress(previous.UsidEgress, "tenant0"); err != nil { - return fmt.Errorf("attach previous usid_egress: %w", err) + if err := attachOne(previous.UsidEgress, legacyLink, egressFilterName, netlink.HANDLE_MIN_INGRESS); err != nil { + return fmt.Errorf("attach legacy-only usid_egress: %w", err) + } + if err := AttachEgress(previous.UsidServiceEgress, previous.UsidEgress, tenantLink); err != nil { + return fmt.Errorf("attach previous egress chain: %w", err) + } + if err := attachOneAtPriority(previous.UsidServiceEgress, serviceOnlyLink, serviceEgressFilterName, + netlink.HANDLE_MIN_INGRESS, filterPriorityFn(), netlink.MakeHandle(0, 1)); err != nil { + return fmt.Errorf("attach service-only chain: %w", err) + } + if err := attachOneAtPriority(previous.UsidServiceEgress, duplicateLink, serviceEgressFilterName, + netlink.HANDLE_MIN_INGRESS, filterPriorityFn(), netlink.MakeHandle(0, 1)); err != nil { + return fmt.Errorf("attach first duplicate service filter: %w", err) + } + if err := attachOneAtPriority(previous.UsidServiceEgress, duplicateLink, serviceEgressFilterName, + netlink.HANDLE_MIN_INGRESS, filterPriorityFn()+2, netlink.MakeHandle(0, 3)); err != nil { + return fmt.Errorf("attach second duplicate service filter: %w", err) } if err := Attach(previous.UsidIngress, []string{"uplink0"}); err != nil { return fmt.Errorf("attach previous usid_ingress: %w", err) @@ -65,30 +91,70 @@ func TestReattachEgress_MovesAttachmentsToTheNewProgram(t *testing.T) { return fmt.Errorf("load current build: %w", err) } defer func() { _ = current.Close() }() + staleLink := &netlink.Dummy{LinkAttrs: netlink.LinkAttrs{Name: "stale0"}} + if err := netlink.LinkAdd(staleLink); err != nil { + return err + } + staleIfindex := uint32(staleLink.Attrs().Index) + if err := netlink.LinkDel(staleLink); err != nil { + return err + } + if err := current.AttachmentIdentityTable.Put(staleIfindex, uint64(99)); err != nil { + return fmt.Errorf("seed stale attachment identity: %w", err) + } - replaced, err := ReattachEgress(current.UsidEgress) + replaced, err := ReattachEgress(current.UsidServiceEgress, current.UsidEgress, current.AttachmentIdentityTable) if err != nil { return fmt.Errorf("ReattachEgress: %w", err) } - if replaced != 1 { - t.Errorf("first pass replaced %d filters, want 1", replaced) + if replaced != 4 { + t.Errorf("first pass replaced %d attachment chains, want 4", replaced) } - if got, want := filterProgID(t, "tenant0", egressFilterName), progID(t, current.UsidEgress); got != want { - t.Errorf("tenant0's usid_egress filter runs program %d, want the current build's %d", got, want) + var staleToken uint64 + if err := current.AttachmentIdentityTable.Lookup(staleIfindex, &staleToken); !errors.Is(err, ebpf.ErrKeyNotExist) { + t.Errorf("stale attachment identity lookup = %v, want key absent", err) + } + for _, link := range []string{legacyLink, tenantLink, serviceOnlyLink, duplicateLink} { + if got, want := filterProgID(t, link, serviceEgressFilterName), progID(t, current.UsidServiceEgress); got != want { + t.Errorf("%s's service filter runs program %d, want the current build's %d", link, got, want) + } + if got, want := filterProgID(t, link, egressFilterName), progID(t, current.UsidEgress); got != want { + t.Errorf("%s's usid_egress filter runs program %d, want the current build's %d", link, got, want) + } + } + tenant, err := netlink.LinkByName(tenantLink) + if err != nil { + return err + } + var tokenBefore, tokenAfter uint64 + if err := current.AttachmentIdentityTable.Lookup(uint32(tenant.Attrs().Index), &tokenBefore); err != nil { + return err + } + if err := AttachEgressWithIdentity(current.UsidServiceEgress, current.UsidEgress, + current.AttachmentIdentityTable, uint32(tenant.Attrs().Index), tenantLink); err != nil { + return fmt.Errorf("idempotent CNI attach: %w", err) + } + if err := current.AttachmentIdentityTable.Lookup(uint32(tenant.Attrs().Index), &tokenAfter); err != nil { + return err + } + if tokenAfter != tokenBefore { + t.Errorf("idempotent CNI ADD rotated token from %d to %d", tokenBefore, tokenAfter) } if got, want := filterProgID(t, "uplink0", filterName), progID(t, previous.UsidIngress); got != want { t.Errorf("uplink0's usid_ingress filter runs program %d, want it untouched at %d", got, want) } - replaced, err = ReattachEgress(current.UsidEgress) + replaced, err = ReattachEgress(current.UsidServiceEgress, current.UsidEgress, current.AttachmentIdentityTable) if err != nil { return fmt.Errorf("second ReattachEgress: %w", err) } if replaced != 0 { t.Errorf("second pass replaced %d filters, want 0", replaced) } - if n := countFilters(t, "tenant0"); n != 1 { - t.Errorf("tenant0 has %d ingress filters after two passes, want 1", n) + for _, link := range []string{legacyLink, tenantLink, serviceOnlyLink, duplicateLink} { + if n := countFilters(t, link); n != 2 { + t.Errorf("%s has %d ingress filters after two passes, want 2", link, n) + } } return nil }) diff --git a/internal/plumbing/ebpf/attach/health.go b/internal/plumbing/ebpf/attach/health.go index 84ee03e1..0080c417 100644 --- a/internal/plumbing/ebpf/attach/health.go +++ b/internal/plumbing/ebpf/attach/health.go @@ -233,8 +233,8 @@ var programNameFn = func(id ebpf.ProgramID) (string, error) { // compared names, could not read them, and treated the failure as nothing to // report, so it sat inert on a node that genuinely was preempted. func ownProgramIDs(objs *prog.UsidObjects) map[ebpf.ProgramID]struct{} { - out := make(map[ebpf.ProgramID]struct{}, 2) - for _, p := range []*ebpf.Program{objs.UsidIngress, objs.UsidEgress} { + out := make(map[ebpf.ProgramID]struct{}, 3) + for _, p := range []*ebpf.Program{objs.UsidIngress, objs.UsidServiceEgress, objs.UsidEgress} { if p == nil { continue } diff --git a/internal/plumbing/ebpf/attach/identity_sweep.go b/internal/plumbing/ebpf/attach/identity_sweep.go new file mode 100644 index 00000000..b39a5372 --- /dev/null +++ b/internal/plumbing/ebpf/attach/identity_sweep.go @@ -0,0 +1,89 @@ +// Copyright 2026 Datum Cloud, Inc. +// SPDX-License-Identifier: AGPL-3.0-or-later + +package attach + +import ( + "errors" + "fmt" + + "github.com/cilium/ebpf" + "github.com/vishvananda/netlink" +) + +// SweepAttachmentIdentities removes identities that no longer belong to a +// live Galactic-managed interface. Each ownership check shares the same lock +// as CNI attach and startup reattach, so a concurrent new incarnation either +// completes first and is retained or rotates a fresh token after deletion. +func SweepAttachmentIdentities(identityMap *ebpf.Map) (int, error) { + if identityMap == nil { + return 0, errors.New("attach: attachment identity map is nil") + } + var removed int + var sweepErr error + err := withIdentityLockFn(func() error { + removed, sweepErr = sweepAttachmentIdentitiesLocked(identityMap) + return sweepErr + }) + return removed, err +} + +func sweepAttachmentIdentitiesLocked(identityMap *ebpf.Map) (int, error) { + var keys []uint32 + iterator := identityMap.Iterate() + var key uint32 + var token uint64 + for iterator.Next(&key, &token) { + keys = append(keys, key) + } + if err := iterator.Err(); err != nil { + return 0, fmt.Errorf("attach: iterate attachment identities: %w", err) + } + removed := 0 + var errs []error + for _, ifindex := range keys { + link, err := netlink.LinkByIndex(int(ifindex)) + if err != nil { + if linkGone(err) { + if err := identityMap.Delete(ifindex); err == nil || errors.Is(err, ebpf.ErrKeyNotExist) { + removed++ + } else { + errs = append(errs, fmt.Errorf("delete identity ifindex=%d: %w", ifindex, err)) + } + continue + } + errs = append(errs, fmt.Errorf("resolve identity ifindex=%d: %w", ifindex, err)) + continue + } + name := link.Attrs().Name + err = withInterfaceLockFn(name, func() error { + current, err := netlink.LinkByIndex(int(ifindex)) + if err != nil { + if linkGone(err) { + return identityMap.Delete(ifindex) + } + return err + } + filters, err := netlink.FilterList(current, netlink.HANDLE_MIN_INGRESS) + if err != nil { + return err + } + for _, filter := range filters { + if bpfFilter, ok := filter.(*netlink.BpfFilter); ok && isManagedEgressFilter(bpfFilter.Name) { + return nil + } + } + return identityMap.Delete(ifindex) + }) + if err != nil && !errors.Is(err, ebpf.ErrKeyNotExist) { + errs = append(errs, fmt.Errorf("sweep identity ifindex=%d: %w", ifindex, err)) + continue + } + // Recheck existence to count only actual removals. + var remaining uint64 + if lookupErr := identityMap.Lookup(ifindex, &remaining); errors.Is(lookupErr, ebpf.ErrKeyNotExist) { + removed++ + } + } + return removed, errors.Join(errs...) +} diff --git a/internal/plumbing/ebpf/attach/service_rollout.go b/internal/plumbing/ebpf/attach/service_rollout.go new file mode 100644 index 00000000..8864d720 --- /dev/null +++ b/internal/plumbing/ebpf/attach/service_rollout.go @@ -0,0 +1,96 @@ +// Copyright 2026 Datum Cloud, Inc. +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package attach + +import ( + "errors" + "fmt" + "os" + "path/filepath" + + "github.com/cilium/ebpf" +) + +// drainLegacyServiceAuthorization revokes the authorization held by a +// classifier that may continue running briefly while an incompatible map set +// is replaced. Protocol-zero access markers are deliberately retained: they +// make fragmented traffic to known service addresses fail closed in the old +// classifier. Real access rows, reverse state, and remote grants are removed. +func drainLegacyServiceAuthorization(pinDir string) error { + if err := disablePinnedServicePolicy(pinDir); err != nil { + return err + } + if err := deletePinnedMapEntries(filepath.Join(pinDir, "service_access_table"), func(key []byte) bool { + // service_access_key has a stable prefix: ifindex(4), family(1), + // protocol(1), port(2). Keep only the address marker tuple. + return len(key) >= 8 && key[5] == 0 && key[6] == 0 && key[7] == 0 + }); err != nil { + return fmt.Errorf("drain service access: %w", err) + } + for _, name := range []string{"service_reverse_table", "service_remote_grant_table"} { + if err := deletePinnedMapEntries(filepath.Join(pinDir, name), nil); err != nil { + return fmt.Errorf("drain %s: %w", name, err) + } + } + return nil +} + +func disablePinnedServicePolicy(pinDir string) error { + path := filepath.Join(pinDir, "service_policy_state_table") + m, err := ebpf.LoadPinnedMap(path, nil) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil + } + return fmt.Errorf("open service policy state: %w", err) + } + defer func() { _ = m.Close() }() + info, err := m.Info() + if err != nil { + return fmt.Errorf("inspect service policy state: %w", err) + } + value := make([]byte, info.ValueSize) + if err := m.Put(uint32(0), value); err != nil { + return fmt.Errorf("disable service policy: %w", err) + } + return nil +} + +// deletePinnedMapEntries deletes every entry for which keep returns false. +// Missing maps are valid when upgrading a version that predates service +// routing entirely. +func deletePinnedMapEntries(path string, keep func([]byte) bool) error { + m, err := ebpf.LoadPinnedMap(path, nil) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil + } + return err + } + defer func() { _ = m.Close() }() + info, err := m.Info() + if err != nil { + return err + } + key := make([]byte, info.KeySize) + value := make([]byte, info.ValueSize) + keys := make([][]byte, 0) + iterator := m.Iterate() + for iterator.Next(&key, &value) { + if keep != nil && keep(key) { + continue + } + keys = append(keys, append([]byte(nil), key...)) + } + if err := iterator.Err(); err != nil { + return err + } + for _, doomed := range keys { + if err := m.Delete(doomed); err != nil && !errors.Is(err, ebpf.ErrKeyNotExist) { + return err + } + } + return nil +} diff --git a/internal/plumbing/ebpf/attach/service_rollout_test.go b/internal/plumbing/ebpf/attach/service_rollout_test.go new file mode 100644 index 00000000..66c8fd19 --- /dev/null +++ b/internal/plumbing/ebpf/attach/service_rollout_test.go @@ -0,0 +1,93 @@ +// Copyright 2026 Datum Cloud, Inc. +// SPDX-License-Identifier: AGPL-3.0-or-later + +package attach + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/cilium/ebpf" +) + +func TestDrainLegacyServiceAuthorization(t *testing.T) { + requireRoot(t) + pinDir := filepath.Join("/sys/fs/bpf", fmt.Sprintf("galactic-rollout-test-%d", os.Getpid())) + if err := os.MkdirAll(pinDir, 0o755); err != nil { + t.Fatalf("create pin directory: %v", err) + } + t.Cleanup(func() { _ = os.RemoveAll(pinDir) }) + + access := createPinnedTestMap(t, pinDir, "service_access_table", ebpf.Hash, 24, 1, 8) + reverse := createPinnedTestMap(t, pinDir, "service_reverse_table", ebpf.Hash, 44, 8, 8) + grant := createPinnedTestMap(t, pinDir, "service_remote_grant_table", ebpf.Hash, 40, 8, 8) + state := createPinnedTestMap(t, pinDir, "service_policy_state_table", ebpf.Array, 4, 1, 1) + + markerKey := make([]byte, 24) + markerKey[4] = 1 + realKey := append([]byte(nil), markerKey...) + realKey[5] = 6 + realKey[6], realKey[7] = 1, 187 + for _, key := range [][]byte{markerKey, realKey} { + if err := access.Put(key, []byte{1}); err != nil { + t.Fatalf("seed access: %v", err) + } + } + if err := reverse.Put(make([]byte, 44), make([]byte, 8)); err != nil { + t.Fatalf("seed reverse: %v", err) + } + if err := grant.Put(make([]byte, 40), make([]byte, 8)); err != nil { + t.Fatalf("seed grant: %v", err) + } + if err := state.Put(uint32(0), []byte{1}); err != nil { + t.Fatalf("enable policy: %v", err) + } + + if err := drainLegacyServiceAuthorization(pinDir); err != nil { + t.Fatalf("drainLegacyServiceAuthorization: %v", err) + } + var one [1]byte + if err := access.Lookup(markerKey, &one); err != nil { + t.Fatalf("fragment marker removed: %v", err) + } + if err := access.Lookup(realKey, &one); !errors.Is(err, ebpf.ErrKeyNotExist) { + t.Fatalf("real access lookup = %v, want key absent", err) + } + if countMapEntries(reverse) != 0 || countMapEntries(grant) != 0 { + t.Fatalf("reverse/grant entries remain after drain: %d/%d", countMapEntries(reverse), countMapEntries(grant)) + } + if err := state.Lookup(uint32(0), &one); err != nil || one[0] != 0 { + t.Fatalf("policy state after drain = %v, %v, want disabled", one, err) + } +} + +func createPinnedTestMap( + t *testing.T, dir, name string, typ ebpf.MapType, keySize, valueSize, maxEntries uint32, +) *ebpf.Map { + t.Helper() + m, err := ebpf.NewMap(&ebpf.MapSpec{ + Name: name, Type: typ, KeySize: keySize, ValueSize: valueSize, MaxEntries: maxEntries, + }) + if err != nil { + t.Fatalf("create %s: %v", name, err) + } + t.Cleanup(func() { _ = m.Close() }) + if err := m.Pin(filepath.Join(dir, name)); err != nil { + t.Fatalf("pin %s: %v", name, err) + } + return m +} + +func countMapEntries(m *ebpf.Map) int { + count := 0 + iterator := m.Iterate() + info, _ := m.Info() + key, value := make([]byte, info.KeySize), make([]byte, info.ValueSize) + for iterator.Next(&key, &value) { + count++ + } + return count +} diff --git a/internal/plumbing/ebpf/attachmentidentity/table.go b/internal/plumbing/ebpf/attachmentidentity/table.go new file mode 100644 index 00000000..5aa381fe --- /dev/null +++ b/internal/plumbing/ebpf/attachmentidentity/table.go @@ -0,0 +1,58 @@ +// Copyright 2026 Datum Cloud, Inc. +// SPDX-License-Identifier: AGPL-3.0-or-later + +// Package attachmentidentity manages per-interface incarnation tokens used to +// prevent a recycled kernel ifindex from inheriting private-service policy. +package attachmentidentity + +import ( + "crypto/rand" + "encoding/binary" + "errors" + "fmt" + "io" + "path/filepath" + + "github.com/cilium/ebpf" + + "go.datum.net/galactic/internal/plumbing/ebpf/prog" +) + +var randomReader io.Reader = rand.Reader + +// Rotate replaces ifindex's identity before a service classifier can run on +// that interface. Zero is reserved for "no identity". +func Rotate(table interface{ Put(key, value any) error }, ifindex uint32) (uint64, error) { + var raw [8]byte + for { + if _, err := io.ReadFull(randomReader, raw[:]); err != nil { + return 0, fmt.Errorf("attachmentidentity: generate token: %w", err) + } + token := binary.LittleEndian.Uint64(raw[:]) + if token == 0 { + continue + } + if err := table.Put(ifindex, token); err != nil { + return 0, fmt.Errorf("attachmentidentity: rotate ifindex=%d: %w", ifindex, err) + } + return token, nil + } +} + +// RotateMap rotates an identity in an already-open kernel map. +func RotateMap(m *ebpf.Map, ifindex uint32) (uint64, error) { + if m == nil { + return 0, errors.New("attachmentidentity: map is nil") + } + return Rotate(m, ifindex) +} + +// RotatePinned opens the pinned identity map, rotates ifindex, and closes it. +func RotatePinned(pinDir string, ifindex uint32) (uint64, error) { + m, err := ebpf.LoadPinnedMap(filepath.Join(pinDir, prog.UsidMapAttachmentIdentityTable), nil) + if err != nil { + return 0, fmt.Errorf("attachmentidentity: open pinned map: %w", err) + } + defer func() { _ = m.Close() }() + return RotateMap(m, ifindex) +} diff --git a/internal/plumbing/ebpf/attachmentidentity/table_test.go b/internal/plumbing/ebpf/attachmentidentity/table_test.go new file mode 100644 index 00000000..d468b706 --- /dev/null +++ b/internal/plumbing/ebpf/attachmentidentity/table_test.go @@ -0,0 +1,58 @@ +// Copyright 2026 Datum Cloud, Inc. +// SPDX-License-Identifier: AGPL-3.0-or-later + +package attachmentidentity + +import ( + "bytes" + "errors" + "testing" +) + +func TestRotateSkipsZeroAndStoresNonzeroToken(t *testing.T) { + original := randomReader + t.Cleanup(func() { randomReader = original }) + randomReader = bytes.NewReader([]byte{ + 0, 0, 0, 0, 0, 0, 0, 0, + 8, 7, 6, 5, 4, 3, 2, 1, + }) + table := &testTable{} + token, err := Rotate(table, 42) + if err != nil { + t.Fatalf("Rotate: %v", err) + } + if token != 0x0102030405060708 { + t.Fatalf("token = %#x, want %#x", token, uint64(0x0102030405060708)) + } + if table.key != uint32(42) || table.value != token { + t.Fatalf("Put(%v, %v), want (42, %v)", table.key, table.value, token) + } +} + +func TestRotatePropagatesRandomAndPutErrors(t *testing.T) { + original := randomReader + t.Cleanup(func() { randomReader = original }) + wantRandom := errors.New("random failed") + randomReader = errorReader{wantRandom} + if _, err := Rotate(&testTable{}, 1); !errors.Is(err, wantRandom) { + t.Fatalf("Rotate random error = %v, want %v", err, wantRandom) + } + + randomReader = bytes.NewReader([]byte{1, 0, 0, 0, 0, 0, 0, 0}) + wantPut := errors.New("put failed") + if _, err := Rotate(&testTable{putErr: wantPut}, 1); !errors.Is(err, wantPut) { + t.Fatalf("Rotate put error = %v, want %v", err, wantPut) + } +} + +type errorReader struct{ err error } + +func (r errorReader) Read([]byte) (int, error) { return 0, r.err } + +type testTable struct { + key any + value any + putErr error +} + +func (t *testTable) Put(key, value any) error { t.key, t.value = key, value; return t.putErr } diff --git a/internal/plumbing/ebpf/attachreg/register.go b/internal/plumbing/ebpf/attachreg/register.go index 3df0d17a..70fe795e 100644 --- a/internal/plumbing/ebpf/attachreg/register.go +++ b/internal/plumbing/ebpf/attachreg/register.go @@ -21,6 +21,7 @@ import ( "go.datum.net/galactic/internal/plumbing/ebpf/attach" "go.datum.net/galactic/internal/plumbing/ebpf/egressroutemap" "go.datum.net/galactic/internal/plumbing/ebpf/ifindexvrfmap" + "go.datum.net/galactic/internal/plumbing/ebpf/prog" "go.datum.net/galactic/internal/plumbing/ebpf/uformat" "go.datum.net/galactic/internal/plumbing/ebpf/usidmap" "go.datum.net/galactic/internal/plumbing/intf" @@ -177,7 +178,6 @@ func RegisterDatapath(pinDir string, r Registration) (registered bool, err error if err := registerEgressKind(pinDir, hostIfindex, egressKind); err != nil { return false, err } - // Attach usid_egress to this attachment's host-side interface. This is // what translates a reply's source address on the way back out. hostName := intf.GenerateInterfaceNameHost(r.VPC, r.VPCAttachment) @@ -283,17 +283,33 @@ func registerPublicUplink(pinDir string) error { return uplink.Set(linkIndex, dmac, smac) } -// attachUsidEgress loads usid_egress from its pin and attaches it to -// ifaceName's TC ingress hook. Without it a reply leaves with its source -// address untranslated and the client discards it. Idempotent. +// attachUsidEgress loads the service classifier and usid_egress continuation +// from their pins and attaches the chain to ifaceName's TC ingress hook. +// Without it a reply leaves with its source address untranslated and the +// client discards it. Idempotent. func attachUsidEgress(pinDir, ifaceName string) error { + serviceProgram, err := ebpf.LoadPinnedProgram(filepath.Join(pinDir, attach.UsidServiceEgressPinName), nil) + if err != nil { + return fmt.Errorf("load pinned usid_service_egress program: %w", err) + } + defer func() { _ = serviceProgram.Close() }() + program, err := ebpf.LoadPinnedProgram(filepath.Join(pinDir, attach.UsidEgressPinName), nil) if err != nil { return fmt.Errorf("load pinned usid_egress program: %w", err) } defer func() { _ = program.Close() }() + identityMap, err := ebpf.LoadPinnedMap(filepath.Join(pinDir, prog.UsidMapAttachmentIdentityTable), nil) + if err != nil { + return fmt.Errorf("load pinned attachment identity map: %w", err) + } + defer func() { _ = identityMap.Close() }() + link, err := netlink.LinkByName(ifaceName) + if err != nil { + return fmt.Errorf("resolve host interface for identity: %w", err) + } - return attach.AttachEgress(program, ifaceName) + return attach.AttachEgressWithIdentity(serviceProgram, program, identityMap, uint32(link.Attrs().Index), ifaceName) } // HostPrefix returns ip as a host prefix, "/32" for IPv4 and "/128" for IPv6, diff --git a/internal/plumbing/ebpf/preflight/kernel_prober.go b/internal/plumbing/ebpf/preflight/kernel_prober.go index f425b344..e158abf0 100644 --- a/internal/plumbing/ebpf/preflight/kernel_prober.go +++ b/internal/plumbing/ebpf/preflight/kernel_prober.go @@ -9,6 +9,7 @@ import ( "sync" "github.com/cilium/ebpf" + "github.com/cilium/ebpf/asm" "github.com/cilium/ebpf/btf" "github.com/cilium/ebpf/features" "github.com/cilium/ebpf/rlimit" @@ -66,6 +67,18 @@ func (k *KernelProber) HashMap() error { return nil } +// RedirectNeigh reports whether SCHED_CLS programs may call +// bpf_redirect_neigh on this kernel. +func (k *KernelProber) RedirectNeigh() error { + if err := ensureMemlockRemoved(); err != nil { + return err + } + if err := features.HaveProgramHelper(ebpf.SchedCLS, asm.FnRedirectNeigh); err != nil { + return fmt.Errorf("kernel rejects bpf_redirect_neigh for BPF_PROG_TYPE_SCHED_CLS: %w", err) + } + return nil +} + // BTF implements [Prober] by attempting to load the running kernel's own // BTF (typically /sys/kernel/btf/vmlinux). func (k *KernelProber) BTF() error { diff --git a/internal/plumbing/ebpf/preflight/preflight.go b/internal/plumbing/ebpf/preflight/preflight.go index 2279b3ea..7b82105a 100644 --- a/internal/plumbing/ebpf/preflight/preflight.go +++ b/internal/plumbing/ebpf/preflight/preflight.go @@ -56,6 +56,10 @@ type Prober interface { // VRF-table-id parameter, not merely that the helper exists. Returns nil // when supported. FIBLookupTBID() error + + // RedirectNeigh reports whether TC programs can invoke bpf_redirect_neigh, + // which resolves the destination neighbor while redirecting service traffic. + RedirectNeigh() error } // capabilityCheck names one required capability, binds it to its probe, and @@ -92,6 +96,12 @@ func capabilityChecks(p Prober) []capabilityCheck { why: "R5 requires FIB lookups scoped to the resolved Argument's Linux VRF table via bpf_fib_lookup's tbid " + "parameter, added to the kernel later than the base helper (design plan §6, §10)", }, + { + name: "bpf_redirect_neigh helper", + fn: p.RedirectNeigh, + why: "private-service forwarding redirects directly between attachment interfaces and requires " + + "kernel neighbor resolution", + }, } } diff --git a/internal/plumbing/ebpf/preflight/preflight_test.go b/internal/plumbing/ebpf/preflight/preflight_test.go index 591a7755..a66f6bd8 100644 --- a/internal/plumbing/ebpf/preflight/preflight_test.go +++ b/internal/plumbing/ebpf/preflight/preflight_test.go @@ -21,12 +21,14 @@ type stubProber struct { hashMap error btf error fibLookupTBID error + redirectNeigh error } func (s stubProber) SchedCLS() error { return s.schedCLS } func (s stubProber) HashMap() error { return s.hashMap } func (s stubProber) BTF() error { return s.btf } func (s stubProber) FIBLookupTBID() error { return s.fibLookupTBID } +func (s stubProber) RedirectNeigh() error { return s.redirectNeigh } var _ Prober = stubProber{} @@ -115,13 +117,14 @@ func TestCheckWith_AllMissing(t *testing.T) { hashMap: errors.New("no hash map"), btf: errors.New("no btf"), fibLookupTBID: errors.New("no tbid"), + redirectNeigh: errors.New("no redirect_neigh"), }) if err == nil { t.Fatal("CheckWith() = nil, want an error") } for _, want := range []string{ - "BPF_PROG_TYPE_SCHED_CLS", "BPF_MAP_TYPE_HASH", "BTF", "tbid", - "4/4 required kernel capabilities missing", + "BPF_PROG_TYPE_SCHED_CLS", "BPF_MAP_TYPE_HASH", "BTF", "tbid", "bpf_redirect_neigh", + "5/5 required kernel capabilities missing", } { if !strings.Contains(err.Error(), want) { t.Errorf("CheckWith() = %q, want it to contain %q", err.Error(), want) @@ -142,6 +145,7 @@ func TestCheckWith_NeverPartialPass(t *testing.T) { {"only btf missing", stubProber{btf: errors.New("x")}}, {"only hash map missing", stubProber{hashMap: errors.New("x")}}, {"only sched cls missing", stubProber{schedCLS: errors.New("x")}}, + {"only redirect neigh missing", stubProber{redirectNeigh: errors.New("x")}}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { diff --git a/internal/plumbing/ebpf/prog/doc.go b/internal/plumbing/ebpf/prog/doc.go index fa19b30b..debe2dd3 100644 --- a/internal/plumbing/ebpf/prog/doc.go +++ b/internal/plumbing/ebpf/prog/doc.go @@ -40,4 +40,4 @@ package prog // -cc is deliberately omitted: bpf2go defaults to $BPF2GO_CC or "clang", so CI // can pin an exact version without this directive changing. // -//go:generate go run github.com/cilium/ebpf/cmd/bpf2go -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 -type ifindex_vrf_value -type nptv6_value -type vip_xlat_key -type vip_xlat_value -type egress_route_key -type egress_route_value -type public_uplink_value -type mss_clamp_value -type tenant_gw_value -type vpc_attribution_value Usid usid.c +//go:generate go run github.com/cilium/ebpf/cmd/bpf2go -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 -type ifindex_vrf_value -type nptv6_value -type vip_xlat_key -type vip_xlat_value -type egress_route_key -type egress_route_value -type service_route_key -type service_route_value -type service_access_key -type service_access_value -type service_deny_key -type service_deny_value -type service_reverse_key -type service_reverse_value -type service_remote_grant_key -type service_remote_grant_value -type service_policy_state_value -type public_uplink_value -type mss_clamp_value -type tenant_gw_value -type vpc_attribution_value Usid usid.c diff --git a/internal/plumbing/ebpf/prog/usid.c b/internal/plumbing/ebpf/prog/usid.c index 97d3ff23..cefaeb29 100644 --- a/internal/plumbing/ebpf/prog/usid.c +++ b/internal/plumbing/ebpf/prog/usid.c @@ -75,6 +75,7 @@ #define __uint(name, val) int (*name)[val] #define __type(name, val) typeof(val) *name #define USID_ALWAYS_INLINE inline __attribute__((always_inline)) +#define USID_NOINLINE __attribute__((noinline)) // USID_OFFSETOF reproduces offsetof, which is not otherwise available since // this file includes no headers beyond . @@ -96,6 +97,9 @@ // --------------------------------------------------------------------- static void *(*bpf_map_lookup_elem)(void *map, const void *key) = (void *) BPF_FUNC_map_lookup_elem; +static long (*bpf_map_update_elem)(void *map, const void *key, const void *value, __u64 flags) = + (void *) BPF_FUNC_map_update_elem; +static long (*bpf_map_delete_elem)(void *map, const void *key) = (void *) BPF_FUNC_map_delete_elem; // Called once, unconditionally, at the top of usid_ingress. See its call site // for why a defensive linearization pass runs before any direct packet read. @@ -138,6 +142,8 @@ static __u64 (*bpf_ktime_get_ns)(void) = (void *) BPF_FUNC_ktime_get_ns; // approach. static long (*bpf_l4_csum_replace)(struct __sk_buff *skb, __u32 offset, __u64 from, __u64 to, __u64 flags) = (void *) BPF_FUNC_l4_csum_replace; +static long (*bpf_l3_csum_replace)(struct __sk_buff *skb, __u32 offset, __u64 from, __u64 to, + __u64 flags) = (void *) BPF_FUNC_l3_csum_replace; static long (*bpf_skb_load_bytes)(const struct __sk_buff *skb, __u32 offset, void *to, __u32 len) = (void *) BPF_FUNC_skb_load_bytes; static long (*bpf_skb_store_bytes)(struct __sk_buff *skb, __u32 offset, const void *from, __u32 len, @@ -179,12 +185,14 @@ static long (*bpf_skb_change_head)(struct __sk_buff *skb, __u32 len, __u64 flags // packet. Fixed kernel ABI. #define USID_IPPROTO_IPIP 4 #define USID_IPPROTO_IPV6 41 +#define USID_IPPROTO_SERVICE 253 // Transport protocol numbers, needed for the vip_xlat_table lookup. TCP and UDP // place source and destination port at the same offset, so one struct covers // both for the fields this file touches. #define USID_IPPROTO_TCP 6 #define USID_IPPROTO_UDP 17 +#define USID_IPPROTO_AH 51 // ICMPv6, needed to recognize Neighbor Discovery messages regardless of their // destination address. See usid_egress's NDP carve-out. @@ -250,6 +258,7 @@ static long (*bpf_skb_change_head)(struct __sk_buff *skb, __u32 len, __u64 flags // The IPv4 fragment offset, the low 13 bits of frag_off. Nonzero means a // non-first fragment, which carries no TCP header to read. #define USID_IPV4_FRAG_OFFSET_MASK 0x1FFF +#define USID_IPV4_MORE_FRAGMENTS 0x2000 #define USID_IPV4_MIN_HDR_LEN 20 // IPv6 next-header values that are extension headers, which clamp_tcp_mss @@ -578,6 +587,167 @@ struct egress_route_value { __u8 smac[6]; } __attribute__((packed)); +// service_route_key scopes a private-service address to the exact consumer +// attachment that policy selected. Using the ingress ifindex instead of the +// shared VRF table prevents one selected attachment from granting every other +// attachment in that VPC access to the service. +struct service_route_key { + __u32 ingress_ifindex; + __u8 family; + __u8 protocol; + __be16 port; + __u8 addr[16]; +} __attribute__((packed)); + +struct service_route_value { + __u32 target_ifindex; + __u8 mode; + __u8 pad[3]; + __u64 consumer_token; + __u64 producer_token; + __u8 grant_id[16]; + __u8 target_sid[16]; +}; + +#define SERVICE_ROUTE_MODE_LOCAL 1 +#define SERVICE_ROUTE_MODE_REMOTE 2 +#define SERVICE_TUNNEL_VERSION 1 +#define SERVICE_TUNNEL_REQUEST 1 +#define SERVICE_TUNNEL_REPLY 2 +#define SERVICE_MARKER_REQUEST 1 +#define SERVICE_MARKER_REPLY 2 + +struct service_tunnel_header { + __u8 version; + __u8 inner_nexthdr; + __u8 direction; + __u8 reserved; + __u8 grant_id[16]; +} __attribute__((packed)); + +// service_access_key authorizes one transport endpoint on one consumer +// attachment. +// Address is always the ServiceEndpoint's exact IP, not a prefix. Port is in +// network byte order, matching the packet header read in usid_egress. +struct service_access_key { + __u32 ingress_ifindex; + __u8 family; + __u8 protocol; + __be16 port; + __u8 addr[16]; +} __attribute__((packed)); + +struct service_access_value { + __u64 attachment_token; + __u8 marker_directions; + __u8 pad[7]; +}; + +// service_deny_table is deliberately a small, stable ABI independent of the +// forwarding route/access value layouts. It survives an incompatible policy +// map replacement and keeps known service addresses fail closed until the +// controller has rebuilt and finalized the new generation. +struct service_deny_key { + __u32 ingress_ifindex; + __u8 family; + __u8 pad[3]; + __u8 addr[16]; +} __attribute__((packed)); + +struct service_deny_value { + __u64 attachment_token; + __u8 directions; + __u8 pad[7]; +}; + +// service_reverse_key is the exact producer reply tuple. A row is created only +// after an authorized consumer packet is observed, so producer-originated +// traffic has no route back into the consumer attachment. +struct service_reverse_key { + __u32 ingress_ifindex; + __u8 family; + __u8 protocol; + __be16 source_port; + __be16 dest_port; + __u8 pad[2]; + __u8 source_addr[16]; + __u8 dest_addr[16]; +} __attribute__((packed)); + +struct service_reverse_value { + __u32 consumer_ifindex; + __u8 mode; + __u8 pad[3]; + __u64 consumer_token; + __u64 producer_token; + __u8 grant_id[16]; + __u8 return_sid[16]; + __u64 last_seen_ns; +}; + +struct service_remote_grant_key { + __u32 producer_ifindex; + __u8 family; + __u8 protocol; + __be16 port; + __u8 grant_id[16]; + __u8 addr[16]; +} __attribute__((packed)); + +struct service_remote_grant_value { + __u8 consumer_sid[16]; + __u64 producer_token; +}; + +struct service_policy_state_value { + __u8 enabled; +}; + +// Flat byte-for-byte form of bpf_fib_lookup. The kernel UAPI type contains +// anonymous unions that bpf2go cannot emit when it discovers the type through +// a map definition. Keep this named shape as the map value and cast it back to +// the UAPI type at the helper call. +struct service_fib_scratch_value { + __u8 family; + __u8 l4_protocol; + __be16 sport; + __be16 dport; + __u16 tot_len; + __u32 ifindex; + __u32 flowinfo; + __u32 src[4]; + __u32 dst[4]; + __u32 tbid; + __u8 smac[6]; + __u8 dmac[6]; + __u32 l4_offset; + __u8 meta_family; + __u8 protocol; + __be16 source_port; + __be16 dest_port; + __u8 meta_pad[2]; + __u8 source_addr[16]; + __u8 dest_addr[16]; + struct service_route_key route; + struct service_access_key access; + struct service_deny_key deny; + struct service_reverse_key reverse; + struct service_reverse_value reverse_value; + struct service_remote_grant_key remote_grant; + __u8 tunnel_grant_id[16]; + __u8 tunnel_return_sid[16]; + __u8 tunnel_target_sid[16]; + __u8 tunnel_direction; + struct bpf_redir_neigh neigh; +}; + +_Static_assert(__builtin_offsetof(struct service_fib_scratch_value, l4_offset) == sizeof(struct bpf_fib_lookup), + "service FIB scratch prefix must match the kernel UAPI"); + +union egress_lookup_key { + struct egress_route_key route; +}; + // --------------------------------------------------------------------- // enum drop_reason indexes drop_reasons, which is observability only. @@ -698,6 +868,8 @@ enum drop_reason { // the host-side NDP handling that must answer it, and the container's // gateway neighbor entry went to FAILED. Remove once resolved. DROP_REASON_TRACE_NDP_BAIL = 39, + DROP_REASON_SERVICE_ROUTE_DENIED = 40, + DROP_REASON_SERVICE_ROUTE_REDIRECT_FAILED = 41, __DROP_REASON_MAX, }; @@ -845,6 +1017,75 @@ struct { __type(value, struct egress_route_value); } egress_route_table SEC(".maps"); +// Node-local private-service routes are exact attachment/service matches. They +// remain separate from fabric routes so policy never creates a Linux route. +struct { + __uint(type, BPF_MAP_TYPE_HASH); + __uint(max_entries, 32768); + __type(key, struct service_route_key); + __type(value, struct service_route_value); +} service_route_table SEC(".maps"); + +// attachment_identity_table binds service authorization to an interface +// incarnation, not merely its recyclable kernel ifindex. CNI ADD overwrites +// the token before attaching the service classifier. +struct { + __uint(type, BPF_MAP_TYPE_HASH); + __uint(max_entries, 32768); + __type(key, __u32); + __type(value, __u64); +} attachment_identity_table SEC(".maps"); + +struct { + __uint(type, BPF_MAP_TYPE_HASH); + __uint(max_entries, 65536); + __type(key, struct service_access_key); + __type(value, struct service_access_value); +} service_access_table SEC(".maps"); + +struct { + __uint(type, BPF_MAP_TYPE_HASH); + __uint(max_entries, 32768); + __type(key, struct service_deny_key); + __type(value, struct service_deny_value); +} service_deny_table SEC(".maps"); + +// Reverse rows are bounded LRU flow state. Policy is checked again on every +// reply, so a stale row cannot survive policy revocation as authorization. +struct { + __uint(type, BPF_MAP_TYPE_LRU_HASH); + __uint(max_entries, 131072); + __type(key, struct service_reverse_key); + __type(value, struct service_reverse_value); +} service_reverse_table SEC(".maps"); + +// Producer-side authorization. Reverse flow state is only a routing cache; +// this table is rechecked for every reply so deleting policy revokes traffic +// immediately even while an LRU row remains. +struct { + __uint(type, BPF_MAP_TYPE_HASH); + __uint(max_entries, 65536); + __type(key, struct service_remote_grant_key); + __type(value, struct service_remote_grant_value); +} service_remote_grant_table SEC(".maps"); + +struct { + __uint(type, BPF_MAP_TYPE_ARRAY); + __uint(max_entries, 1); + __type(key, __u32); + __type(value, struct service_policy_state_value); +} service_policy_state_table SEC(".maps"); + +// service_fib_scratch keeps the comparatively large bpf_fib_lookup parameter +// block out of usid_egress's stack. One value per CPU is safe because BPF +// programs do not migrate between CPUs while running. +struct { + __uint(type, BPF_MAP_TYPE_PERCPU_ARRAY); + __uint(max_entries, 1); + __type(key, __u32); + __type(value, struct service_fib_scratch_value); +} service_fib_scratch SEC(".maps"); + // node_src_addr_table: this node's own End.DT46 SID with the Argument field // left zero, the base usid_egress completes per packet and writes into the // outer header it pushes. A per-node constant rather than a per-route one, @@ -1301,7 +1542,7 @@ static USID_ALWAYS_INLINE void count_mss_clamp_stat(__u32 stat) // verifier checks it once on its own instead of again inside every path that // reaches each of its three call sites. Global functions taking the program // context need nothing newer than the kernel preflight already requires. -__attribute__((noinline)) int clamp_tcp_mss(struct __sk_buff *skb, __u32 ip_version) +__attribute__((noinline)) int clamp_tcp_mss(struct __sk_buff *skb, __u32 ip_version, __u32 extra_overhead) { __u32 cfg_key = 0; struct mss_clamp_value *cfg = bpf_map_lookup_elem(&mss_clamp_table, &cfg_key); @@ -1317,6 +1558,7 @@ __attribute__((noinline)) int clamp_tcp_mss(struct __sk_buff *skb, __u32 ip_vers USID_BARRIER_VAR(limit_v6); __u16 limit = ip_version == 4 ? limit_v4 : limit_v6; + limit = limit > extra_overhead ? limit - (__u16) extra_overhead : 0; if (limit == 0) return 0; // clamping off for this family, or not configured yet @@ -1657,7 +1899,8 @@ static USID_ALWAYS_INLINE __u32 sum_words(const __u16 *w, int n) // counted in pmtu_stats. // // A global function, for the same reason as clamp_tcp_mss. -__attribute__((noinline)) int send_too_big(struct __sk_buff *skb, __u32 ip_version, __u64 vrf_key) +__attribute__((noinline)) int send_too_big(struct __sk_buff *skb, __u32 ip_version, __u64 vrf_key, + __u32 extra_overhead) { __u32 cfg_key = 0; __u32 *limit_p = bpf_map_lookup_elem(&encap_mtu_table, &cfg_key); @@ -1666,6 +1909,7 @@ __attribute__((noinline)) int send_too_big(struct __sk_buff *skb, __u32 ip_versi return PMTU_FITS; __u32 limit = *limit_p; + limit = limit > extra_overhead ? limit - extra_overhead : 0; if (limit == 0) return PMTU_FITS; // check off, or not configured yet @@ -1890,6 +2134,81 @@ __attribute__((noinline)) int send_too_big(struct __sk_buff *skb, __u32 ip_versi return PMTU_SENT; } +static USID_ALWAYS_INLINE int parse_service_packet(struct __sk_buff *skb, __be16 h_proto, + struct service_fib_scratch_value *scratch); +static USID_NOINLINE int service_install_reverse(struct service_fib_scratch_value *s); +static USID_ALWAYS_INLINE int service_identity_matches(__u32 ifindex, __u64 expected); +static USID_ALWAYS_INLINE int service_policy_enabled(void); +static USID_ALWAYS_INLINE int service_id_equal(const __u8 a[16], const __u8 b[16]) +{ + __u8 diff = 0; +#pragma unroll + for (int i = 0; i < 16; i++) + diff |= a[i] ^ b[i]; + return diff == 0; +} + +// Validate a decapsulated service tunnel at its resolved attachment. Keeping +// this branch in a BPF subprogram prevents its request/reply states from being +// multiplied through the legacy ingress redirect path. Return 1 when request +// reverse state was installed, 0 for an authorized reply, and -1 on denial. +static USID_NOINLINE int service_ingress_authorize(struct __sk_buff *skb, __be16 h_proto, + __u32 ifindex, struct service_fib_scratch_value *s) +{ + if (!s || parse_service_packet(skb, h_proto, s)) + return -1; + if (s->tunnel_direction == SERVICE_TUNNEL_REQUEST) { + __builtin_memset(&s->remote_grant, 0, sizeof(s->remote_grant)); + s->remote_grant.producer_ifindex = ifindex; + s->remote_grant.family = s->meta_family; + s->remote_grant.protocol = s->protocol; + s->remote_grant.port = s->dest_port; + __builtin_memcpy(s->remote_grant.grant_id, s->tunnel_grant_id, 16); + __builtin_memcpy(s->remote_grant.addr, s->dest_addr, 16); + struct service_remote_grant_value *grant = + bpf_map_lookup_elem(&service_remote_grant_table, &s->remote_grant); + if (!grant || !service_id_equal(grant->consumer_sid, s->tunnel_return_sid) || + !service_identity_matches(ifindex, grant->producer_token)) + return -1; + __builtin_memset(&s->reverse, 0, sizeof(s->reverse)); + s->reverse.ingress_ifindex = ifindex; + s->reverse.family = s->meta_family; + s->reverse.protocol = s->protocol; + s->reverse.source_port = s->dest_port; + s->reverse.dest_port = s->source_port; + __builtin_memcpy(s->reverse.source_addr, s->dest_addr, 16); + __builtin_memcpy(s->reverse.dest_addr, s->source_addr, 16); + __builtin_memset(&s->reverse_value, 0, sizeof(s->reverse_value)); + s->reverse_value.mode = SERVICE_ROUTE_MODE_REMOTE; + s->reverse_value.producer_token = grant->producer_token; + __builtin_memcpy(s->reverse_value.grant_id, s->tunnel_grant_id, 16); + __builtin_memcpy(s->reverse_value.return_sid, s->tunnel_return_sid, 16); + return service_install_reverse(s) ? -1 : 1; + } + + __builtin_memset(&s->route, 0, sizeof(s->route)); + s->route.ingress_ifindex = ifindex; + s->route.family = s->meta_family; + s->route.protocol = s->protocol; + s->route.port = s->source_port; + __builtin_memcpy(s->route.addr, s->source_addr, 16); + struct service_route_value *route = bpf_map_lookup_elem(&service_route_table, &s->route); + __builtin_memset(&s->access, 0, sizeof(s->access)); + s->access.ingress_ifindex = ifindex; + s->access.family = s->meta_family; + s->access.protocol = s->protocol; + s->access.port = s->source_port; + __builtin_memcpy(s->access.addr, s->source_addr, 16); + struct service_access_value *access = bpf_map_lookup_elem(&service_access_table, &s->access); + if (!route || route->mode != SERVICE_ROUTE_MODE_REMOTE || + !service_id_equal(route->grant_id, s->tunnel_grant_id) || + !service_id_equal(route->target_sid, s->tunnel_return_sid) || + !access || access->attachment_token != route->consumer_token || + !service_identity_matches(ifindex, route->consumer_token)) + return -1; + return 0; +} + SEC("tc") int usid_ingress(struct __sk_buff *skb) { @@ -2020,7 +2339,29 @@ int usid_ingress(struct __sk_buff *skb) // UNKNOWN_INNER_VERSION and mask a distinct, actionable failure, so it is // checked and counted apart before that peek. The field is already covered // by the bounds check above. - if (ip6->nexthdr != USID_IPPROTO_IPIP && ip6->nexthdr != USID_IPPROTO_IPV6) { + __u8 service_tunnel = ip6->nexthdr == USID_IPPROTO_SERVICE; + __u8 service_reverse_installed = 0; + __u32 service_scratch_key = 0; + struct service_fib_scratch_value *service_scratch = 0; + if (service_tunnel) { + service_scratch = bpf_map_lookup_elem(&service_fib_scratch, &service_scratch_key); + struct service_tunnel_header *tunnel = (void *) (ip6 + 1); + if (!service_scratch || (void *) (tunnel + 1) > data_end || + tunnel->version != SERVICE_TUNNEL_VERSION || tunnel->reserved != 0 || + (tunnel->direction != SERVICE_TUNNEL_REQUEST && tunnel->direction != SERVICE_TUNNEL_REPLY) || + (tunnel->inner_nexthdr != USID_IPPROTO_IPIP && tunnel->inner_nexthdr != USID_IPPROTO_IPV6)) { + count_claimed_drop(DROP_REASON_UNEXPECTED_NEXTHDR, vrf); + return TC_ACT_SHOT; + } + if (!service_policy_enabled()) { + count_claimed_drop(DROP_REASON_SERVICE_ROUTE_DENIED, vrf); + return TC_ACT_SHOT; + } + __builtin_memcpy(service_scratch->tunnel_grant_id, tunnel->grant_id, 16); + __builtin_memcpy(service_scratch->tunnel_return_sid, ip6->saddr, 16); + service_scratch->tunnel_direction = tunnel->direction; + } + if (!service_tunnel && ip6->nexthdr != USID_IPPROTO_IPIP && ip6->nexthdr != USID_IPPROTO_IPV6) { count_claimed_drop(DROP_REASON_UNEXPECTED_NEXTHDR, vrf); return TC_ACT_SHOT; } @@ -2028,12 +2369,12 @@ int usid_ingress(struct __sk_buff *skb) // Peek the inner version nibble now, on the still-unmutated outer header: // step 7 needs to know the family before it decides how to strip, not // after. - if ((void *) (ip6 + 1) + 1 > data_end) { + __u8 *inner_peek = (__u8 *) (ip6 + 1) + (service_tunnel ? sizeof(struct service_tunnel_header) : 0); + if ((void *) inner_peek + 1 > data_end) { count_claimed_drop(DROP_REASON_MALFORMED_INNER, vrf); return TC_ACT_SHOT; } - __u8 *inner_peek = (__u8 *) (ip6 + 1); __u8 inner_version = (*inner_peek) >> 4; if (inner_version != 4 && inner_version != 6) { @@ -2072,7 +2413,7 @@ int usid_ingress(struct __sk_buff *skb) // A packet the NIC merged with GRO keeps its sender's segment size, and // the DECAP flag names the inner header it is left with. A kernel that // predates the DECAP flags rejects them, so the strip retries without. - __s32 strip_len = (__s32) sizeof(struct usid_ip6hdr); + __s32 strip_len = (__s32) sizeof(struct usid_ip6hdr) + (service_tunnel ? (__s32) sizeof(struct service_tunnel_header) : 0); __u64 strip_flags = USID_BPF_F_ADJ_ROOM_FIXED_GSO; __u64 decap_flag = USID_BPF_F_ADJ_ROOM_DECAP_L3_IPV6; @@ -2081,7 +2422,7 @@ int usid_ingress(struct __sk_buff *skb) count_claimed_drop(DROP_REASON_STRIP_FAILED, vrf); return TC_ACT_SHOT; } - strip_len = (__s32) sizeof(struct usid_iphdr); + strip_len = (__s32) sizeof(struct usid_iphdr) + (service_tunnel ? (__s32) sizeof(struct service_tunnel_header) : 0); decap_flag = USID_BPF_F_ADJ_ROOM_DECAP_L3_IPV4; } @@ -2132,7 +2473,7 @@ int usid_ingress(struct __sk_buff *skb) // Placed before the pointer re-read below, which covers the rewrite // helpers' invalidation too, and before the NPTv6 and VIP rewrites, whose // checksum updates are incremental and so independent of this one. - if (clamp_tcp_mss(skb, inner_version)) { + if (clamp_tcp_mss(skb, inner_version, 0)) { count_claimed_drop(DROP_REASON_MALFORMED_INNER, vrf); return TC_ACT_SHOT; } @@ -2183,7 +2524,7 @@ int usid_ingress(struct __sk_buff *skb) // the outer one checked above, which only ever names the encap format. // The outer pointer is invalid here regardless, since the strip can // relocate the buffer. - if (inner6->nexthdr == USID_IPPROTO_TCP || inner6->nexthdr == USID_IPPROTO_UDP) { + if (!service_tunnel && (inner6->nexthdr == USID_IPPROTO_TCP || inner6->nexthdr == USID_IPPROTO_UDP)) { struct usid_l4ports *ports = (void *) (inner6 + 1); if ((void *) (ports + 1) <= data_end) { @@ -2223,7 +2564,7 @@ int usid_ingress(struct __sk_buff *skb) } } - if (npt && !vip_hit) + if (!service_tunnel && npt && !vip_hit) apply_nptv6(inner6->daddr, npt, 0 /* inbound: public -> ULA */); fib_params.family = USID_AF_INET6; @@ -2284,27 +2625,7 @@ int usid_ingress(struct __sk_buff *skb) // // This sends through the host-side end of a veth, not straight into its // peer the way step 9 does, which works for a veth and a tap alike. - if (fib_rc == BPF_FIB_LKUP_RET_NO_NEIGH && fib_params.ifindex > 0) { - struct bpf_redir_neigh nh; - - __builtin_memset(&nh, 0, sizeof(nh)); - if (inner_version == 6) { - nh.nh_family = USID_AF_INET6; - __builtin_memcpy(nh.ipv6_nh, fib_params.ipv6_dst, sizeof(nh.ipv6_nh)); - } else { - nh.nh_family = USID_AF_INET; - nh.ipv4_nh = fib_params.ipv4_dst; - } - - long neigh_rc = bpf_redirect_neigh(fib_params.ifindex, &nh, sizeof(nh), 0); - - if (neigh_rc == TC_ACT_REDIRECT) - return neigh_rc; - count_claimed_drop(DROP_REASON_FIB_NO_NEIGH, vrf); - return TC_ACT_SHOT; - } - - if (fib_rc != BPF_FIB_LKUP_RET_SUCCESS) { + if (fib_rc != BPF_FIB_LKUP_RET_SUCCESS && fib_rc != BPF_FIB_LKUP_RET_NO_NEIGH) { if (fib_rc == BPF_FIB_LKUP_RET_NO_NEIGH) count_claimed_drop(DROP_REASON_FIB_NO_NEIGH, vrf); else if (fib_rc == BPF_FIB_LKUP_RET_UNREACHABLE || fib_rc == BPF_FIB_LKUP_RET_BLACKHOLE || fib_rc == BPF_FIB_LKUP_RET_PROHIBIT) @@ -2329,6 +2650,39 @@ int usid_ingress(struct __sk_buff *skb) return TC_ACT_SHOT; } + if (service_tunnel) { + int authorization = service_ingress_authorize(skb, new_eth->h_proto, fib_params.ifindex, + service_scratch); + if (authorization < 0) { + count_claimed_drop(DROP_REASON_SERVICE_ROUTE_DENIED, vrf); + return TC_ACT_SHOT; + } + service_reverse_installed = authorization; + } + + // Authorization above must run before cold-neighbor handling. Otherwise a + // forged or revoked service tunnel can bypass its grant/route checks simply + // by targeting an attachment whose neighbor entry is not warm. + if (fib_rc == BPF_FIB_LKUP_RET_NO_NEIGH) { + struct bpf_redir_neigh nh; + + __builtin_memset(&nh, 0, sizeof(nh)); + if (inner_version == 6) { + nh.nh_family = USID_AF_INET6; + __builtin_memcpy(nh.ipv6_nh, fib_params.ipv6_dst, sizeof(nh.ipv6_nh)); + } else { + nh.nh_family = USID_AF_INET; + nh.ipv4_nh = fib_params.ipv4_dst; + } + long neigh_rc = bpf_redirect_neigh(fib_params.ifindex, &nh, sizeof(nh), 0); + if (neigh_rc == TC_ACT_REDIRECT) + return neigh_rc; + if (service_reverse_installed && service_scratch) + bpf_map_delete_elem(&service_reverse_table, &service_scratch->reverse); + count_claimed_drop(DROP_REASON_FIB_NO_NEIGH, vrf); + 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)); @@ -2356,6 +2710,8 @@ int usid_ingress(struct __sk_buff *skb) redirect_rc = bpf_redirect(egress_ifindex, 0); if (redirect_rc != TC_ACT_REDIRECT) { + if (service_reverse_installed && service_scratch) + bpf_map_delete_elem(&service_reverse_table, &service_scratch->reverse); count_claimed_drop(DROP_REASON_REDIRECT_FAILED, vrf); return TC_ACT_SHOT; } @@ -2390,6 +2746,474 @@ int usid_ingress(struct __sk_buff *skb) // once egress_route_table has a matching entry, this program is that packet's // only path off the node, so a failure past that point is a drop rather than a // pass-through. +#define SERVICE_FLOW_TIMEOUT_NS (300ULL * 1000000000ULL) +#define SERVICE_PARSE_NOT_L4 1 +#define SERVICE_PARSE_FRAGMENT 2 +#define SERVICE_PARSE_INVALID (-1) + +// parse_service_packet extracts a TCP/UDP tuple before any NPT or VIP rewrite. +// IPv6 extension headers are walked to a verifier-bounded depth; fragmented +// service traffic is rejected because non-first fragments do not carry ports. +static USID_ALWAYS_INLINE int parse_service_packet(struct __sk_buff *skb, __be16 h_proto, + struct service_fib_scratch_value *scratch) +{ + void *data = (void *) (long) skb->data; + void *data_end = (void *) (long) skb->data_end; + struct usid_ethhdr *eth = data; + __u32 l4_offset; + __u8 protocol; + + if ((void *) (eth + 1) > data_end) + return SERVICE_PARSE_INVALID; + + __builtin_memset(scratch->source_addr, 0, sizeof(scratch->source_addr)); + __builtin_memset(scratch->dest_addr, 0, sizeof(scratch->dest_addr)); + if (h_proto == __builtin_bswap16(USID_ETH_P_IPV6)) { + struct usid_ip6hdr *ip6 = (void *) (eth + 1); + + if ((void *) (ip6 + 1) > data_end) + return SERVICE_PARSE_INVALID; + scratch->meta_family = USID_EGRESS_ROUTE_FAMILY_INET6; + __builtin_memcpy(scratch->source_addr, ip6->saddr, 16); + __builtin_memcpy(scratch->dest_addr, ip6->daddr, 16); + protocol = ip6->nexthdr; + l4_offset = USID_L3_OFFSET + sizeof(*ip6); + +#pragma unroll + for (int i = 0; i < 6; i++) { + if (protocol == USID_IPPROTO_TCP || protocol == USID_IPPROTO_UDP) + goto found_l4; + if (protocol == USID_IPPROTO_FRAGMENT) + return SERVICE_PARSE_FRAGMENT; + __u8 ext[2]; + if (bpf_skb_load_bytes(skb, l4_offset, ext, sizeof(ext))) + return SERVICE_PARSE_INVALID; + if (protocol == USID_IPPROTO_HOPOPTS || protocol == USID_IPPROTO_ROUTING || + protocol == USID_IPPROTO_DSTOPTS) { + protocol = ext[0]; + l4_offset += ((__u32) ext[1] + 1) * 8; + continue; + } + if (protocol == USID_IPPROTO_AH) { + protocol = ext[0]; + l4_offset += ((__u32) ext[1] + 2) * 4; + continue; + } + return SERVICE_PARSE_NOT_L4; + } + return SERVICE_PARSE_INVALID; + } else { + struct usid_iphdr *ip4 = (void *) (eth + 1); + + if ((void *) (ip4 + 1) > data_end || (ip4->ver_ihl & 0x0F) < 5) + return SERVICE_PARSE_INVALID; + scratch->meta_family = USID_EGRESS_ROUTE_FAMILY_INET4; + __builtin_memcpy(scratch->source_addr, ip4->saddr, 4); + __builtin_memcpy(scratch->dest_addr, ip4->daddr, 4); + if (__builtin_bswap16(ip4->frag_off) & (USID_IPV4_FRAG_OFFSET_MASK | USID_IPV4_MORE_FRAGMENTS)) + return SERVICE_PARSE_FRAGMENT; + protocol = ip4->protocol; + if (protocol != USID_IPPROTO_TCP && protocol != USID_IPPROTO_UDP) + return SERVICE_PARSE_NOT_L4; + l4_offset = USID_L3_OFFSET + (__u32) (ip4->ver_ihl & 0x0F) * 4; + } + +found_l4: + ; + struct usid_l4ports ports; + if (bpf_skb_load_bytes(skb, l4_offset, &ports, sizeof(ports))) + return SERVICE_PARSE_INVALID; + scratch->protocol = protocol; + scratch->l4_offset = l4_offset; + scratch->source_port = ports.source; + scratch->dest_port = ports.dest; + return 0; +} + +static USID_ALWAYS_INLINE int service_decrement_hop(struct __sk_buff *skb, + const struct service_fib_scratch_value *scratch) +{ + if (scratch->meta_family == USID_EGRESS_ROUTE_FAMILY_INET6) { + __u32 offset = USID_L3_OFFSET + USID_OFFSETOF(struct usid_ip6hdr, hop_limit); + __u8 hop_limit; + + if (bpf_skb_load_bytes(skb, offset, &hop_limit, sizeof(hop_limit)) || hop_limit <= 1) + return -1; + hop_limit--; + return bpf_skb_store_bytes(skb, offset, &hop_limit, sizeof(hop_limit), 0) ? -1 : 0; + } + + __u32 ttl_offset = USID_L3_OFFSET + USID_OFFSETOF(struct usid_iphdr, ttl); + __u32 check_offset = USID_L3_OFFSET + USID_OFFSETOF(struct usid_iphdr, check); + __be16 old_word; + if (bpf_skb_load_bytes(skb, ttl_offset, &old_word, sizeof(old_word))) + return -1; + __be16 new_word = old_word; + __u8 *ttl = (__u8 *) &new_word; + if (*ttl <= 1) + return -1; + (*ttl)--; + if (bpf_l3_csum_replace(skb, check_offset, old_word, new_word, 2)) + return -1; + return bpf_skb_store_bytes(skb, ttl_offset, ttl, 1, 0) ? -1 : 0; +} + +static USID_ALWAYS_INLINE long service_redirect(struct __sk_buff *skb, + struct service_fib_scratch_value *scratch, + __u32 target_ifindex) +{ + if (service_decrement_hop(skb, scratch)) + return TC_ACT_SHOT; + __builtin_memset(&scratch->neigh, 0, sizeof(scratch->neigh)); + if (scratch->meta_family == USID_EGRESS_ROUTE_FAMILY_INET6) { + scratch->neigh.nh_family = USID_AF_INET6; + __builtin_memcpy(scratch->neigh.ipv6_nh, scratch->dest_addr, 16); + } else { + scratch->neigh.nh_family = USID_AF_INET; + __builtin_memcpy(&scratch->neigh.ipv4_nh, scratch->dest_addr, 4); + } + return bpf_redirect_neigh(target_ifindex, &scratch->neigh, sizeof(scratch->neigh), 0); +} + +static USID_NOINLINE long service_remote_encap(struct __sk_buff *skb, + struct service_fib_scratch_value *scratch) +{ + __u32 ifindex = skb->ifindex; + struct ifindex_vrf_value *iv = bpf_map_lookup_elem(&ifindex_vrf_table, &ifindex); + __u32 zero = 0; + __u8 *base = bpf_map_lookup_elem(&node_src_addr_table, &zero); + struct public_uplink_value *uplink = bpf_map_lookup_elem(&public_uplink_table, &zero); + if (!iv || !base || !uplink || !uplink->link_ifindex) + return TC_ACT_SHOT; + __u8 source_or = 0; +#pragma unroll + for (int i = 0; i < 16; i++) + source_or |= base[i]; + if (!source_or) + return TC_ACT_SHOT; + __u32 ip_version = scratch->meta_family == USID_EGRESS_ROUTE_FAMILY_INET6 ? 6 : 4; + if (clamp_tcp_mss(skb, ip_version, sizeof(struct service_tunnel_header))) + return TC_ACT_SHOT; + __u64 vrf_key = (iv->block << 12) | iv->argument; + int pmtu = send_too_big(skb, ip_version, vrf_key, sizeof(struct service_tunnel_header)); + if (pmtu == PMTU_SENT) + return TC_ACT_REDIRECT; + if (pmtu == PMTU_DROPPED || service_decrement_hop(skb, scratch)) + return TC_ACT_SHOT; + __builtin_memcpy(scratch->source_addr, base, 16); + scratch->source_addr[8] = (__u8) ((scratch->source_addr[8] & 0xF0) | ((iv->argument >> 8) & 0x0F)); + scratch->source_addr[9] = (__u8) iv->argument; + + __s32 grow = sizeof(struct usid_ip6hdr) + sizeof(struct service_tunnel_header); + if (bpf_skb_adjust_room(skb, grow, BPF_ADJ_ROOM_MAC, USID_BPF_F_ADJ_ROOM_ENCAP_L3_IPV6) && + bpf_skb_adjust_room(skb, grow, BPF_ADJ_ROOM_MAC, 0)) + return TC_ACT_SHOT; + void *data = (void *) (long) skb->data; + void *data_end = (void *) (long) skb->data_end; + struct usid_ethhdr *eth = data; + struct usid_ip6hdr *outer = (void *) (eth + 1); + struct service_tunnel_header *tunnel = (void *) (outer + 1); + if ((void *) (tunnel + 1) > data_end) + return TC_ACT_SHOT; + __builtin_memset(outer, 0, sizeof(*outer)); + outer->vtc_flow[0] = 0x60; + outer->payload_len = __builtin_bswap16((__u16) (skb->len - sizeof(*eth) - sizeof(*outer))); + outer->nexthdr = USID_IPPROTO_SERVICE; + outer->hop_limit = USID_EGRESS_HOP_LIMIT; + __builtin_memcpy(outer->saddr, scratch->source_addr, 16); + __builtin_memcpy(outer->daddr, scratch->tunnel_target_sid, 16); + __builtin_memset(tunnel, 0, sizeof(*tunnel)); + tunnel->version = SERVICE_TUNNEL_VERSION; + tunnel->inner_nexthdr = scratch->meta_family == USID_EGRESS_ROUTE_FAMILY_INET6 ? USID_IPPROTO_IPV6 : USID_IPPROTO_IPIP; + tunnel->direction = scratch->tunnel_direction; + __builtin_memcpy(tunnel->grant_id, scratch->tunnel_grant_id, 16); + eth->h_proto = __builtin_bswap16(USID_ETH_P_IPV6); + __builtin_memcpy(eth->h_dest, uplink->dmac, 6); + __builtin_memcpy(eth->h_source, uplink->smac, 6); + return bpf_redirect(uplink->link_ifindex, 0); +} + +static USID_NOINLINE int service_install_reverse(struct service_fib_scratch_value *s) +{ + __u64 now = bpf_ktime_get_ns(); + s->reverse_value.last_seen_ns = now; + if (!bpf_map_update_elem(&service_reverse_table, &s->reverse, &s->reverse_value, BPF_NOEXIST)) + return 0; + struct service_reverse_value *old = bpf_map_lookup_elem(&service_reverse_table, &s->reverse); + if (!old) + return -1; + if (now - old->last_seen_ns > SERVICE_FLOW_TIMEOUT_NS) { + bpf_map_delete_elem(&service_reverse_table, &s->reverse); + return bpf_map_update_elem(&service_reverse_table, &s->reverse, &s->reverse_value, BPF_NOEXIST); + } + int same_owner = old->mode == s->reverse_value.mode && + old->consumer_ifindex == s->reverse_value.consumer_ifindex && + old->consumer_token == s->reverse_value.consumer_token && + old->producer_token == s->reverse_value.producer_token && + service_id_equal(old->grant_id, s->reverse_value.grant_id) && + service_id_equal(old->return_sid, s->reverse_value.return_sid); + if (!same_owner && old->mode == SERVICE_ROUTE_MODE_LOCAL) { + __builtin_memset(&s->route, 0, sizeof(s->route)); + s->route.ingress_ifindex = old->consumer_ifindex; + s->route.family = s->reverse.family; + s->route.protocol = s->reverse.protocol; + s->route.port = s->reverse.source_port; + __builtin_memcpy(s->route.addr, s->reverse.source_addr, 16); + struct service_route_value *old_route = bpf_map_lookup_elem(&service_route_table, &s->route); + if (!old_route || old_route->mode != SERVICE_ROUTE_MODE_LOCAL || + old_route->target_ifindex != s->reverse.ingress_ifindex || + old_route->consumer_token != old->consumer_token || + old_route->producer_token != old->producer_token || + !service_identity_matches(old->consumer_ifindex, old->consumer_token) || + !service_identity_matches(s->reverse.ingress_ifindex, old->producer_token)) { + bpf_map_delete_elem(&service_reverse_table, &s->reverse); + return bpf_map_update_elem(&service_reverse_table, &s->reverse, &s->reverse_value, BPF_NOEXIST); + } + } + if (!same_owner && old->mode == SERVICE_ROUTE_MODE_REMOTE) { + __builtin_memset(&s->remote_grant, 0, sizeof(s->remote_grant)); + s->remote_grant.producer_ifindex = s->reverse.ingress_ifindex; + s->remote_grant.family = s->reverse.family; + s->remote_grant.protocol = s->reverse.protocol; + s->remote_grant.port = s->reverse.source_port; + __builtin_memcpy(s->remote_grant.grant_id, old->grant_id, 16); + __builtin_memcpy(s->remote_grant.addr, s->reverse.source_addr, 16); + struct service_remote_grant_value *old_grant = + bpf_map_lookup_elem(&service_remote_grant_table, &s->remote_grant); + if (!old_grant || old_grant->producer_token != old->producer_token || + !service_id_equal(old_grant->consumer_sid, old->return_sid) || + !service_identity_matches(s->reverse.ingress_ifindex, old_grant->producer_token)) { + bpf_map_delete_elem(&service_reverse_table, &s->reverse); + return bpf_map_update_elem(&service_reverse_table, &s->reverse, &s->reverse_value, BPF_NOEXIST); + } + } + if (!same_owner) + return -1; + return bpf_map_update_elem(&service_reverse_table, &s->reverse, &s->reverse_value, BPF_EXIST); +} + +static USID_ALWAYS_INLINE int service_identity_matches(__u32 ifindex, __u64 expected) +{ + if (!expected) + return 0; + __u64 *current = bpf_map_lookup_elem(&attachment_identity_table, &ifindex); + return current && *current == expected; +} + +static USID_ALWAYS_INLINE int service_policy_enabled(void) +{ + __u32 zero = 0; + struct service_policy_state_value *state = bpf_map_lookup_elem(&service_policy_state_table, &zero); + return state && state->enabled; +} + +// service_path is noinline so its transient tuple-building scalars do not +// inflate usid_egress's already tight stack on the unrelated encapsulation and +// Packet-Too-Big call chain. +static USID_NOINLINE long service_path(struct __sk_buff *skb, __be16 h_proto, __u32 ifindex) +{ + __u32 scratch_key = 0; + struct service_fib_scratch_value *s = bpf_map_lookup_elem(&service_fib_scratch, &scratch_key); + if (!s) + return TC_ACT_UNSPEC; + int parsed = parse_service_packet(skb, h_proto, s); + if (parsed != 0) { + // Neighbor discovery and other non-TCP/UDP control traffic must reach + // the legacy attachment path. Only fragments are denied by address + // markers because their missing transport tuple is intrinsically + // unauthorizable. + if (parsed != SERVICE_PARSE_FRAGMENT) + return TC_ACT_UNSPEC; + if (s->meta_family != USID_EGRESS_ROUTE_FAMILY_INET6 && + s->meta_family != USID_EGRESS_ROUTE_FAMILY_INET4) + return TC_ACT_UNSPEC; + __builtin_memset(&s->deny, 0, sizeof(s->deny)); + s->deny.ingress_ifindex = ifindex; + s->deny.family = s->meta_family; + __builtin_memcpy(s->deny.addr, s->dest_addr, 16); + struct service_deny_value *deny = bpf_map_lookup_elem(&service_deny_table, &s->deny); + if (deny && (deny->directions & SERVICE_MARKER_REQUEST)) { + count_drop(DROP_REASON_SERVICE_ROUTE_DENIED); + return TC_ACT_SHOT; + } + // A request marker is keyed by its service destination on the + // consumer interface. A reply marker is keyed by the same service + // address as its source on the producer interface. Checking both keeps + // unsupported service fragments fail closed without dropping unrelated + // fragmented tenant traffic, which must continue to the legacy path. + __builtin_memcpy(s->deny.addr, s->source_addr, 16); + deny = bpf_map_lookup_elem(&service_deny_table, &s->deny); + if (deny && (deny->directions & SERVICE_MARKER_REPLY)) { + count_drop(DROP_REASON_SERVICE_ROUTE_DENIED); + return TC_ACT_SHOT; + } + return TC_ACT_UNSPEC; + } + + __builtin_memset(&s->reverse, 0, sizeof(s->reverse)); + s->reverse.ingress_ifindex = ifindex; + s->reverse.family = s->meta_family; + s->reverse.protocol = s->protocol; + s->reverse.source_port = s->source_port; + s->reverse.dest_port = s->dest_port; + __builtin_memcpy(s->reverse.source_addr, s->source_addr, 16); + __builtin_memcpy(s->reverse.dest_addr, s->dest_addr, 16); + struct service_reverse_value *reverse = bpf_map_lookup_elem(&service_reverse_table, &s->reverse); + if (reverse) { + if (!service_policy_enabled()) { + count_drop(DROP_REASON_SERVICE_ROUTE_DENIED); + return TC_ACT_SHOT; + } + __u64 now = bpf_ktime_get_ns(); + if (now - reverse->last_seen_ns > SERVICE_FLOW_TIMEOUT_NS) { + bpf_map_delete_elem(&service_reverse_table, &s->reverse); + count_drop(DROP_REASON_SERVICE_ROUTE_DENIED); + return TC_ACT_SHOT; + } + if (!service_identity_matches(ifindex, reverse->producer_token) || + (reverse->mode == SERVICE_ROUTE_MODE_LOCAL && + !service_identity_matches(reverse->consumer_ifindex, reverse->consumer_token))) { + count_drop(DROP_REASON_SERVICE_ROUTE_DENIED); + return TC_ACT_SHOT; + } + if (reverse->mode == SERVICE_ROUTE_MODE_REMOTE) { + __builtin_memset(&s->remote_grant, 0, sizeof(s->remote_grant)); + s->remote_grant.producer_ifindex = ifindex; + s->remote_grant.family = s->meta_family; + s->remote_grant.protocol = s->protocol; + s->remote_grant.port = s->source_port; + __builtin_memcpy(s->remote_grant.grant_id, reverse->grant_id, 16); + __builtin_memcpy(s->remote_grant.addr, s->source_addr, 16); + struct service_remote_grant_value *grant = bpf_map_lookup_elem(&service_remote_grant_table, &s->remote_grant); + if (!grant || !service_id_equal(grant->consumer_sid, reverse->return_sid) || + !service_identity_matches(ifindex, grant->producer_token)) { + count_drop(DROP_REASON_SERVICE_ROUTE_DENIED); + return TC_ACT_SHOT; + } + __builtin_memcpy(s->tunnel_target_sid, reverse->return_sid, 16); + __builtin_memcpy(s->tunnel_grant_id, reverse->grant_id, 16); + s->tunnel_direction = SERVICE_TUNNEL_REPLY; + reverse->last_seen_ns = now; + long rc = service_remote_encap(skb, s); + if (rc != TC_ACT_REDIRECT) count_drop(DROP_REASON_SERVICE_ROUTE_REDIRECT_FAILED); + return rc == TC_ACT_REDIRECT ? rc : TC_ACT_SHOT; + } + __u32 consumer_ifindex = reverse->consumer_ifindex; + USID_BARRIER_VAR(consumer_ifindex); + __builtin_memset(&s->access, 0, sizeof(s->access)); + s->access.ingress_ifindex = consumer_ifindex; + s->access.family = s->meta_family; + s->access.protocol = s->protocol; + s->access.port = s->source_port; + __builtin_memcpy(s->access.addr, s->source_addr, 16); + struct service_access_value *access = bpf_map_lookup_elem(&service_access_table, &s->access); + __builtin_memset(&s->route, 0, sizeof(s->route)); + s->route.ingress_ifindex = consumer_ifindex; + s->route.family = s->meta_family; + s->route.protocol = s->protocol; + s->route.port = s->source_port; + __builtin_memcpy(s->route.addr, s->source_addr, 16); + struct service_route_value *reply_route = bpf_map_lookup_elem(&service_route_table, &s->route); + if (!access || access->attachment_token != reverse->consumer_token || !reply_route || + reply_route->mode != SERVICE_ROUTE_MODE_LOCAL || reply_route->target_ifindex != ifindex || + reply_route->consumer_token != reverse->consumer_token || + reply_route->producer_token != reverse->producer_token) { + count_drop(DROP_REASON_SERVICE_ROUTE_DENIED); + return TC_ACT_SHOT; + } + reverse->last_seen_ns = now; + long rc = service_redirect(skb, s, consumer_ifindex); + if (rc != TC_ACT_REDIRECT) { + count_drop(DROP_REASON_SERVICE_ROUTE_REDIRECT_FAILED); + return TC_ACT_SHOT; + } + return rc; + } + + __builtin_memset(&s->route, 0, sizeof(s->route)); + s->route.ingress_ifindex = ifindex; + s->route.family = s->meta_family; + s->route.protocol = s->protocol; + s->route.port = s->dest_port; + __builtin_memcpy(s->route.addr, s->dest_addr, 16); + struct service_route_value *route = bpf_map_lookup_elem(&service_route_table, &s->route); + if (!route) { + __builtin_memset(&s->deny, 0, sizeof(s->deny)); + s->deny.ingress_ifindex = ifindex; + s->deny.family = s->meta_family; + __builtin_memcpy(s->deny.addr, s->dest_addr, 16); + struct service_deny_value *deny = bpf_map_lookup_elem(&service_deny_table, &s->deny); + if (deny && (deny->directions & SERVICE_MARKER_REQUEST)) { + count_drop(DROP_REASON_SERVICE_ROUTE_DENIED); + return TC_ACT_SHOT; + } + __builtin_memcpy(s->deny.addr, s->source_addr, 16); + deny = bpf_map_lookup_elem(&service_deny_table, &s->deny); + if (deny && (deny->directions & SERVICE_MARKER_REPLY)) { + count_drop(DROP_REASON_SERVICE_ROUTE_DENIED); + return TC_ACT_SHOT; + } + return TC_ACT_UNSPEC; + } + if (!service_policy_enabled()) { + count_drop(DROP_REASON_SERVICE_ROUTE_DENIED); + return TC_ACT_SHOT; + } + if (!service_identity_matches(ifindex, route->consumer_token) || + (route->mode == SERVICE_ROUTE_MODE_LOCAL && + !service_identity_matches(route->target_ifindex, route->producer_token))) { + count_drop(DROP_REASON_SERVICE_ROUTE_DENIED); + return TC_ACT_SHOT; + } + + __builtin_memset(&s->access, 0, sizeof(s->access)); + s->access.ingress_ifindex = ifindex; + s->access.family = s->meta_family; + s->access.protocol = s->protocol; + s->access.port = s->dest_port; + __builtin_memcpy(s->access.addr, s->dest_addr, 16); + struct service_access_value *access = bpf_map_lookup_elem(&service_access_table, &s->access); + if (!access || access->attachment_token != route->consumer_token) { + count_drop(DROP_REASON_SERVICE_ROUTE_DENIED); + return TC_ACT_SHOT; + } + + if (route->mode == SERVICE_ROUTE_MODE_REMOTE) { + __builtin_memcpy(s->tunnel_target_sid, route->target_sid, 16); + __builtin_memcpy(s->tunnel_grant_id, route->grant_id, 16); + s->tunnel_direction = SERVICE_TUNNEL_REQUEST; + long rc = service_remote_encap(skb, s); + if (rc != TC_ACT_REDIRECT) count_drop(DROP_REASON_SERVICE_ROUTE_REDIRECT_FAILED); + return rc == TC_ACT_REDIRECT ? rc : TC_ACT_SHOT; + } + __u32 target_ifindex = route->target_ifindex; + USID_BARRIER_VAR(target_ifindex); + __builtin_memset(&s->reverse, 0, sizeof(s->reverse)); + s->reverse.ingress_ifindex = target_ifindex; + s->reverse.family = s->meta_family; + s->reverse.protocol = s->protocol; + s->reverse.source_port = s->dest_port; + s->reverse.dest_port = s->source_port; + __builtin_memcpy(s->reverse.source_addr, s->dest_addr, 16); + __builtin_memcpy(s->reverse.dest_addr, s->source_addr, 16); + __builtin_memset(&s->reverse_value, 0, sizeof(s->reverse_value)); + s->reverse_value.consumer_ifindex = ifindex; + s->reverse_value.mode = SERVICE_ROUTE_MODE_LOCAL; + s->reverse_value.consumer_token = route->consumer_token; + s->reverse_value.producer_token = route->producer_token; + if (service_install_reverse(s)) { + count_drop(DROP_REASON_SERVICE_ROUTE_REDIRECT_FAILED); + return TC_ACT_SHOT; + } + long rc = service_redirect(skb, s, target_ifindex); + if (rc != TC_ACT_REDIRECT) { + bpf_map_delete_elem(&service_reverse_table, &s->reverse); + count_drop(DROP_REASON_SERVICE_ROUTE_REDIRECT_FAILED); + return TC_ACT_SHOT; + } + return rc; +} + SEC("tc") int usid_egress(struct __sk_buff *skb) { @@ -2430,13 +3254,20 @@ int usid_egress(struct __sk_buff *skb) return TC_ACT_UNSPEC; // no attachment registered on this ifindex at all } count_drop(DROP_REASON_TRACE_IFINDEX_HIT); - __u64 vrf_key = (iv->block << 12) | iv->argument; + struct vrf_value *vrf = bpf_map_lookup_elem(&vrf_table, &vrf_key); + if (!vrf) { + count_drop(DROP_REASON_TRACE_MISS_VRF); + return TC_ACT_UNSPEC; + } + __u32 scratch_key = 0; + struct service_fib_scratch_value *egress_scratch = + bpf_map_lookup_elem(&service_fib_scratch, &scratch_key); + if (!egress_scratch) + return TC_ACT_UNSPEC; __u8 route_family; - __u8 dst_addr[16]; - - __builtin_memset(dst_addr, 0, sizeof(dst_addr)); + __builtin_memset(egress_scratch->dest_addr, 0, sizeof(egress_scratch->dest_addr)); if (h_proto == __builtin_bswap16(USID_ETH_P_IPV6)) { struct usid_ip6hdr *ip6 = (void *) (eth + 1); @@ -2510,7 +3341,7 @@ int usid_egress(struct __sk_buff *skb) // The fall-through below, for a node whose uplink // is not configured yet, reads ip6 again, so both // pointers are re-derived. - if (clamp_tcp_mss(skb, 6)) + if (clamp_tcp_mss(skb, 6, 0)) return TC_ACT_SHOT; data = (void *) (long) skb->data; data_end = (void *) (long) skb->data_end; @@ -2598,7 +3429,7 @@ int usid_egress(struct __sk_buff *skb) } route_family = USID_EGRESS_ROUTE_FAMILY_INET6; - __builtin_memcpy(dst_addr, ip6->daddr, 16); + __builtin_memcpy(egress_scratch->dest_addr, ip6->daddr, 16); } else { struct usid_iphdr *ip4 = (void *) (eth + 1); @@ -2606,29 +3437,20 @@ int usid_egress(struct __sk_buff *skb) return TC_ACT_UNSPEC; route_family = USID_EGRESS_ROUTE_FAMILY_INET4; - __builtin_memcpy(dst_addr, ip4->daddr, sizeof(ip4->daddr)); + __builtin_memcpy(egress_scratch->dest_addr, ip4->daddr, sizeof(ip4->daddr)); } - // Resolve this attachment's Linux VRF table ID through vrf_table, populated - // alongside ifindex_vrf_table at CNI ADD time. See struct egress_route_key - // for why that, and not (block, argument), is egress_route_table's key. - struct vrf_value *vrf = bpf_map_lookup_elem(&vrf_table, &vrf_key); + union egress_lookup_key *lookup_key = (void *) egress_scratch; - if (!vrf) { - count_drop(DROP_REASON_TRACE_MISS_VRF); - return TC_ACT_UNSPEC; // attachment registered but its vrf_table entry isn't -- shouldn't happen; fail open - } - - struct egress_route_key rkey; - - __builtin_memset(&rkey, 0, sizeof(rkey)); - rkey.table_id = vrf->vrf_table_id; - rkey.family = route_family; - __builtin_memcpy(rkey.addr, dst_addr, sizeof(rkey.addr)); - rkey.prefixlen = 8 * (sizeof(rkey.table_id) + sizeof(rkey.family)) + - (route_family == USID_EGRESS_ROUTE_FAMILY_INET6 ? 128 : 32); + __builtin_memset(lookup_key, 0, sizeof(*lookup_key)); + lookup_key->route.table_id = vrf->vrf_table_id; + lookup_key->route.family = route_family; + __builtin_memcpy(lookup_key->route.addr, egress_scratch->dest_addr, sizeof(lookup_key->route.addr)); + lookup_key->route.prefixlen = + 8 * (sizeof(lookup_key->route.table_id) + sizeof(lookup_key->route.family)) + + (route_family == USID_EGRESS_ROUTE_FAMILY_INET6 ? 128 : 32); - struct egress_route_value *rv = bpf_map_lookup_elem(&egress_route_table, &rkey); + struct egress_route_value *rv = bpf_map_lookup_elem(&egress_route_table, &lookup_key->route); if (!rv) { count_drop(DROP_REASON_TRACE_MISS_ROUTE); @@ -2674,7 +3496,7 @@ int usid_egress(struct __sk_buff *skb) // A failed rewrite after the checksum update leaves a packet whose checksum // is wrong, so it is dropped rather than sent, and counted with the encap // failure it is part of. mss_clamp_stats records which it was. - if (clamp_tcp_mss(skb, route_family == USID_EGRESS_ROUTE_FAMILY_INET6 ? 6 : 4)) { + if (clamp_tcp_mss(skb, route_family == USID_EGRESS_ROUTE_FAMILY_INET6 ? 6 : 4, 0)) { count_claimed_drop(DROP_REASON_EGRESS_ROUTE_ENCAP_FAILED, vrf); return TC_ACT_SHOT; } @@ -2684,7 +3506,7 @@ int usid_egress(struct __sk_buff *skb) // by the uplink. One that gets no error is counted as fib_frag_needed, the // drop the same packet would meet at a FIB lookup. pmtu_stats records // which outcome it was. - int pmtu = send_too_big(skb, route_family == USID_EGRESS_ROUTE_FAMILY_INET6 ? 6 : 4, vrf_key); + int pmtu = send_too_big(skb, route_family == USID_EGRESS_ROUTE_FAMILY_INET6 ? 6 : 4, vrf_key, 0); if (pmtu == PMTU_SENT) return TC_ACT_REDIRECT; @@ -2705,11 +3527,10 @@ int usid_egress(struct __sk_buff *skb) // the attachment this packet arrived on. Copied to the stack first -- // src points into a shared map value, and two CPUs completing two // tenants' SIDs in place would hand each other the wrong VRF. - __u8 src_sid[16]; - - __builtin_memcpy(src_sid, src, sizeof(src_sid)); - src_sid[8] = (__u8) ((src_sid[8] & 0xF0) | ((iv->argument >> 8) & 0x0F)); - src_sid[9] = (__u8) (iv->argument & 0xFF); + __builtin_memcpy(egress_scratch->source_addr, src, sizeof(egress_scratch->source_addr)); + egress_scratch->source_addr[8] = + (__u8) ((egress_scratch->source_addr[8] & 0xF0) | ((iv->argument >> 8) & 0x0F)); + egress_scratch->source_addr[9] = (__u8) (iv->argument & 0xFF); // Push room for a new outer IPv6 header. A positive length difference grows // room, the ingress strip being the same call with a negative one, and the @@ -2758,7 +3579,7 @@ int usid_egress(struct __sk_buff *skb) outer->payload_len = __builtin_bswap16(inner_len); outer->nexthdr = (route_family == USID_EGRESS_ROUTE_FAMILY_INET6) ? USID_IPPROTO_IPV6 : USID_IPPROTO_IPIP; outer->hop_limit = USID_EGRESS_HOP_LIMIT; - __builtin_memcpy(outer->saddr, src_sid, 16); + __builtin_memcpy(outer->saddr, egress_scratch->source_addr, 16); __builtin_memcpy(outer->daddr, rv->sid, 16); new_eth->h_proto = __builtin_bswap16(USID_ETH_P_IPV6); @@ -2793,4 +3614,29 @@ int usid_egress(struct __sk_buff *skb) return redirect_rc; } +SEC("tc") +int usid_service_egress(struct __sk_buff *skb) +{ + if (bpf_skb_pull_data(skb, 0)) + goto legacy; + + void *data = (void *) (long) skb->data; + void *data_end = (void *) (long) skb->data_end; + struct usid_ethhdr *eth = data; + if ((void *) (eth + 1) > data_end) + goto legacy; + + __be16 h_proto = eth->h_proto; + if (h_proto != __builtin_bswap16(USID_ETH_P_IPV6) && + h_proto != __builtin_bswap16(USID_ETH_P_IP)) + goto legacy; + + long service_rc = service_path(skb, h_proto, skb->ifindex); + if (service_rc != TC_ACT_UNSPEC) + return service_rc; + +legacy: + return TC_ACT_UNSPEC; +} + char __license[] SEC("license") = "GPL"; diff --git a/internal/plumbing/ebpf/prog/usid_caps_test.go b/internal/plumbing/ebpf/prog/usid_caps_test.go index 2f9d0e3c..5cabfd89 100644 --- a/internal/plumbing/ebpf/prog/usid_caps_test.go +++ b/internal/plumbing/ebpf/prog/usid_caps_test.go @@ -7,7 +7,6 @@ package prog import ( "errors" "path/filepath" - "slices" "testing" "github.com/cilium/ebpf" @@ -17,13 +16,9 @@ import ( // TestUsid_LoadsWithGalacticCNICapabilities loads the datapath with only the // capabilities galactic-cni's loader container holds. Every other test here -// loads as full root, which hides verifier rules that apply without -// CAP_PERFMON. -// -// It also loads without PERFMON even when the manifest grants it. The datapath -// must not depend on PERFMON-only verifier allowances such as variable packet -// pointer offsets, so that the grant stays optional and a regression fails CI -// instead of hiding behind it. +// loads as full root, which can hide a missing capability in the deployment +// manifest. PERFMON is part of that production capability contract: the +// service-routing extension takes the verifier's privileged path. func TestUsid_LoadsWithGalacticCNICapabilities(t *testing.T) { requireRoot(t) @@ -32,31 +27,18 @@ func TestUsid_LoadsWithGalacticCNICapabilities(t *testing.T) { if err != nil { t.Fatal(err) } - tests := []struct { - name string - caps []string - }{ - {"ManifestCapabilities", caps}, - {"ManifestCapabilitiesWithoutPerfmon", slices.DeleteFunc(slices.Clone(caps), func(c string) bool { - return c == "PERFMON" - })}, + err = loadcaps.Run(caps, func() error { + var objs UsidObjects + if err := LoadUsidObjects(&objs, nil); err != nil { + return err + } + return objs.Close() + }) + var ve *ebpf.VerifierError + if errors.As(err, &ve) { + t.Fatalf("verifier rejected the datapath with capabilities %v:\n%+v", caps, ve) } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := loadcaps.Run(tt.caps, func() error { - var objs UsidObjects - if err := LoadUsidObjects(&objs, nil); err != nil { - return err - } - return objs.Close() - }) - var ve *ebpf.VerifierError - if errors.As(err, &ve) { - t.Fatalf("verifier rejected the datapath with capabilities %v:\n%+v", tt.caps, ve) - } - if err != nil { - t.Fatalf("load datapath with capabilities %v: %v", tt.caps, err) - } - }) + if err != nil { + t.Fatalf("load datapath with capabilities %v: %v", caps, err) } } diff --git a/internal/plumbing/ebpf/prog/usid_test.go b/internal/plumbing/ebpf/prog/usid_test.go index f180cb8b..3c252e5d 100644 --- a/internal/plumbing/ebpf/prog/usid_test.go +++ b/internal/plumbing/ebpf/prog/usid_test.go @@ -5,8 +5,10 @@ package prog import ( + "bytes" "encoding/binary" "errors" + "math/bits" "net/netip" "os" "testing" @@ -1289,15 +1291,12 @@ func buildPlainIPv4Packet(t *testing.T, src, dst netip.Addr) []byte { // to the L4 header's start, and fail (silently, by this function's own // design -- see usid_egress's own comment at its call site) if that // offset falls outside the packet's bounds, which a 4-byte packet would. -// proto is currently always 6 (TCP) at this file's only two call sites, -// but is still a parameter, matching buildPlainIPv4Packet's own shape, -// rather than a hardcoded assumption baked into the function body. -func buildPlainV6PacketWithL4Ports(t *testing.T, src, dst netip.Addr, proto uint8, srcPort, dstPort uint16) []byte { +func buildPlainV6PacketWithL4Ports(t *testing.T, src, dst netip.Addr, srcPort, dstPort uint16) []byte { t.Helper() - if proto != 6 { - t.Fatalf("buildPlainV6PacketWithL4Ports: proto %d unsupported, only TCP(6)'s 20-byte header is built", proto) - } - const tcpHeaderLen = 20 + const ( + tcpProtocol = 6 + tcpHeaderLen = 20 + ) pkt := make([]byte, 0, ethHeaderLen+ip6HeaderLen+tcpHeaderLen) pkt = append(pkt, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA) @@ -1306,7 +1305,7 @@ func buildPlainV6PacketWithL4Ports(t *testing.T, src, dst netip.Addr, proto uint pkt = append(pkt, 0x60, 0x00, 0x00, 0x00) pkt = append(pkt, byte(tcpHeaderLen>>8), byte(tcpHeaderLen)) - pkt = append(pkt, proto) + pkt = append(pkt, tcpProtocol) pkt = append(pkt, 64) srcBytes := src.As16() pkt = append(pkt, srcBytes[:]...) @@ -1322,6 +1321,42 @@ func buildPlainV6PacketWithL4Ports(t *testing.T, src, dst netip.Addr, proto uint return pkt } +func buildPlainV4PacketWithL4Ports(t *testing.T, src, dst netip.Addr, srcPort, dstPort uint16) []byte { + t.Helper() + if !src.Is4() || !dst.Is4() { + t.Fatalf("buildPlainV4PacketWithL4Ports: src=%s dst=%s must both be IPv4", src, dst) + } + const tcpHeaderLen = 20 + pkt := buildPlainIPv4Packet(t, src, dst) + binary.BigEndian.PutUint16(pkt[ethHeaderLen+2:ethHeaderLen+4], ipv4HeaderLen+tcpHeaderLen) + pkt[ethHeaderLen+9] = 6 + tcp := make([]byte, tcpHeaderLen) + binary.BigEndian.PutUint16(tcp[0:2], srcPort) + binary.BigEndian.PutUint16(tcp[2:4], dstPort) + tcp[12] = 5 << 4 + return append(pkt, tcp...) +} + +func fragmentServicePacket(t *testing.T, family uint8, src, dst netip.Addr, srcPort, dstPort uint16) []byte { + t.Helper() + if family == egressRouteFamilyINET4 { + pkt := buildPlainV4PacketWithL4Ports(t, src, dst, srcPort, dstPort) + // MF is enough to make even a first fragment unsupported: later + // fragments cannot be tied to the transport authorization. + binary.BigEndian.PutUint16(pkt[ethHeaderLen+6:ethHeaderLen+8], 0x2000) + return pkt + } + pkt := buildPlainV6PacketWithL4Ports(t, src, dst, srcPort, dstPort) + const fragmentHeaderLen = 8 + fragment := [fragmentHeaderLen]byte{6, 0, 0, 1, 0, 0, 0, 1} + pkt[ethHeaderLen+6] = 44 + binary.BigEndian.PutUint16(pkt[ethHeaderLen+4:ethHeaderLen+6], 20+fragmentHeaderLen) + result := make([]byte, 0, len(pkt)+fragmentHeaderLen) + result = append(result, pkt[:ethHeaderLen+ip6HeaderLen]...) + result = append(result, fragment[:]...) + return append(result, pkt[ethHeaderLen+ip6HeaderLen:]...) +} + // egressRouteFamilyINET6/egressRouteFamilyINET4 mirror usid.c's // USID_EGRESS_ROUTE_FAMILY_INET6/USID_EGRESS_ROUTE_FAMILY_INET4 -- see // usidVIPXlatDirIngress/usidVIPXlatDirEgress's identical comment above for @@ -1364,6 +1399,36 @@ func egressRouteKey(tableID uint32, family uint8, addr netip.Addr, prefixBits in } } +func serviceRouteKey(addr netip.Addr) UsidServiceRouteKey { + return UsidServiceRouteKey{ + IngressIfindex: 1, Family: egressRouteFamilyINET6, Protocol: 6, + Port: bits.ReverseBytes16(8443), Addr: addr.As16(), + } +} + +func serviceAddressMarker(ifindex uint32, addr netip.Addr) UsidServiceAccessKey { + key := UsidServiceAccessKey{IngressIfindex: ifindex} + if addr.Is4() { + key.Family = egressRouteFamilyINET4 + v4 := addr.As4() + copy(key.Addr[:4], v4[:]) + } else { + key.Family = egressRouteFamilyINET6 + key.Addr = addr.As16() + } + return key +} + +func setServiceIdentity(t *testing.T, objs *UsidObjects, token uint64) { + t.Helper() + if err := objs.ServicePolicyStateTable.Put(uint32(0), UsidServicePolicyStateValue{Enabled: 1}); err != nil { + t.Fatalf("enable service policy: %v", err) + } + if err := objs.AttachmentIdentityTable.Put(uint32(1), token); err != nil { + t.Fatalf("populate attachment identity: %v", err) + } +} + // setUpEgressRouteAttachment registers the two per-attachment lookups // usid_egress's egress-routing extension needs before it can ever reach // egress_route_table at all: ifindex_vrf_table (this ifindex belongs to @@ -1447,6 +1512,451 @@ func TestUsidEgress_RouteMissPassesThroughUnmodified(t *testing.T) { } } +func TestUsidEgress_ServiceRouteAllowsAuthorizedPort(t *testing.T) { + requireRoot(t) + objs := loadObjects(t) + + const ( + tableID = uint32(7) + loopback = uint32(1) + servicePort = uint16(8443) + ) + setUpEgressRouteAttachment(t, objs, 0xABCDEF, 0x100, tableID) + setServiceIdentity(t, objs, 1) + + service := netip.MustParseAddr("fd20:70::100") + if err := objs.ServiceRouteTable.Put( + serviceRouteKey(service), + UsidServiceRouteValue{ + TargetIfindex: loopback, Mode: 1, ConsumerToken: 1, ProducerToken: 1, + }, + ); err != nil { + t.Fatalf("populate service_route_table: %v", err) + } + if err := objs.ServiceAccessTable.Put(UsidServiceAccessKey{ + IngressIfindex: loopback, + Family: egressRouteFamilyINET6, + Protocol: 6, + Port: bits.ReverseBytes16(servicePort), + Addr: service.As16(), + }, UsidServiceAccessValue{AttachmentToken: 1}); err != nil { + t.Fatalf("populate service_access_table: %v", err) + } + + pkt := buildPlainV6PacketWithL4Ports(t, + netip.MustParseAddr("fd20:70::2"), service, 49152, servicePort) + ret, out, err := objs.UsidServiceEgress.Test(pkt) + if err != nil { + t.Fatalf("program test-run: %v", err) + } + if ret != tcActRedirect { + t.Errorf("verdict = %d, want TC_ACT_REDIRECT (%d)", ret, tcActRedirect) + } + const ipv6HopLimitOffset = 14 + 7 + if got, want := out[ipv6HopLimitOffset], pkt[ipv6HopLimitOffset]-1; got != want { + t.Errorf("IPv6 hop limit = %d, want %d", got, want) + } + if got := netip.AddrFrom16([16]byte(out[22:38])); got != netip.MustParseAddr("fd20:70::2") { + t.Errorf("source = %s, want original consumer fd20:70::2", got) + } + if got := netip.AddrFrom16([16]byte(out[38:54])); got != service { + t.Errorf("destination = %s, want endpoint %s", got, service) + } + if got := sumPerCPU(t, objs.DropReasons, 40); got != 0 { + t.Errorf("drop_reasons[service_route_denied] = %d, want 0", got) + } + // Simulate deletion and immediate kernel ifindex reuse. The stale route and + // access keys still match ifindex 1, but their old incarnation token must + // not authorize the replacement interface. + setServiceIdentity(t, objs, 2) + if ret, _, err := objs.UsidServiceEgress.Test(pkt); err != nil || ret != tcActShot { + t.Fatalf("request after identity rotation verdict = %d, err = %v; want TC_ACT_SHOT", ret, err) + } +} + +func TestUsidEgress_ServiceRouteDropsUnauthorizedPort(t *testing.T) { + requireRoot(t) + objs := loadObjects(t) + + const tableID = uint32(7) + setUpEgressRouteAttachment(t, objs, 0xABCDEF, 0x100, tableID) + setServiceIdentity(t, objs, 1) + + service := netip.MustParseAddr("fd20:70::100") + if err := objs.ServiceRouteTable.Put( + serviceRouteKey(service), + UsidServiceRouteValue{TargetIfindex: 1, Mode: 1, ConsumerToken: 1, ProducerToken: 1}, + ); err != nil { + t.Fatalf("populate service_route_table: %v", err) + } + + pkt := buildPlainV6PacketWithL4Ports(t, + netip.MustParseAddr("fd20:70::2"), service, 49152, 8443) + ret, out, err := objs.UsidServiceEgress.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 denied service route:\n in: % x\nout: % x", pkt, out) + } + if got := sumPerCPU(t, objs.DropReasons, 40); got != 1 { + t.Errorf("drop_reasons[service_route_denied] = %d, want 1", got) + } +} + +func TestUsidServiceEgress_RemoteRequestPreservesInnerSource(t *testing.T) { + requireRoot(t) + objs := loadObjects(t) + const servicePort = uint16(8443) + setUpEgressRouteAttachment(t, objs, 0xABCDEF, 0x100, 7) + setServiceIdentity(t, objs, 1) + wantOuterSource := setUpNodeSIDBase(t, objs, netip.MustParseAddr("fd00:1:2:3::"), 0x100) + service := netip.MustParseAddr("fd20:70::100") + consumer := netip.MustParseAddr("fd20:70::2") + targetSID := netip.MustParseAddr("fd00:9:8:7:e000::") + grantID := [16]byte{1, 2, 3, 4} + if err := objs.PublicUplinkTable.Put(uint32(0), UsidPublicUplinkValue{LinkIfindex: 1}); err != nil { + t.Fatalf("populate public uplink: %v", err) + } + if err := objs.ServiceRouteTable.Put(serviceRouteKey(service), + UsidServiceRouteValue{Mode: 2, ConsumerToken: 1, GrantId: grantID, TargetSid: targetSID.As16()}); err != nil { + t.Fatalf("populate remote service route: %v", err) + } + if err := objs.ServiceAccessTable.Put(UsidServiceAccessKey{IngressIfindex: 1, Family: egressRouteFamilyINET6, + Protocol: 6, Port: bits.ReverseBytes16(servicePort), Addr: service.As16()}, + UsidServiceAccessValue{AttachmentToken: 1}); err != nil { + t.Fatalf("populate service access: %v", err) + } + pkt := buildPlainV6PacketWithL4Ports(t, consumer, service, 49152, servicePort) + ret, out, err := objs.UsidServiceEgress.Test(pkt) + if err != nil || ret != tcActRedirect { + t.Fatalf("remote request verdict = %d, err = %v; want TC_ACT_REDIRECT", ret, err) + } + if len(out) != len(pkt)+60 { + t.Fatalf("encapsulated length = %d, want %d", len(out), len(pkt)+60) + } + if got := netip.AddrFrom16([16]byte(out[22:38])); got != wantOuterSource { + t.Errorf("outer source = %s, want %s", got, wantOuterSource) + } + if got := netip.AddrFrom16([16]byte(out[38:54])); got != targetSID { + t.Errorf("outer destination = %s, want %s", got, targetSID) + } + if out[54] != 1 || out[55] != 41 || out[56] != 1 || !bytes.Equal(out[58:74], grantID[:]) { + t.Errorf("service shim = % x, want version=1 inner=41 request=1 grant=%x", out[54:74], grantID) + } + if got := netip.AddrFrom16([16]byte(out[82:98])); got != consumer { + t.Errorf("inner source = %s, want original consumer %s", got, consumer) + } + if got := netip.AddrFrom16([16]byte(out[98:114])); got != service { + t.Errorf("inner destination = %s, want endpoint %s", got, service) + } +} + +func TestUsidServiceEgress_ServiceFragmentsFailClosed(t *testing.T) { + requireRoot(t) + tests := []struct { + name string + family uint8 + consumer netip.Addr + service netip.Addr + requestSource bool + configured bool + wrongDirection bool + mismatchedToken bool + }{ + { + name: "IPv4 service request", family: egressRouteFamilyINET4, + consumer: netip.MustParseAddr("10.0.0.2"), service: netip.MustParseAddr("10.0.0.100"), + requestSource: true, configured: true, + }, + { + name: "IPv4 service reply", family: egressRouteFamilyINET4, + consumer: netip.MustParseAddr("10.0.0.2"), service: netip.MustParseAddr("10.0.0.100"), configured: true, + }, + { + name: "IPv4 unrelated", family: egressRouteFamilyINET4, + consumer: netip.MustParseAddr("10.0.0.2"), service: netip.MustParseAddr("10.0.0.200"), requestSource: true, + }, + { + name: "IPv4 request with reply-only marker", family: egressRouteFamilyINET4, + consumer: netip.MustParseAddr("10.0.0.2"), service: netip.MustParseAddr("10.0.0.100"), + requestSource: true, configured: true, wrongDirection: true, + }, + { + name: "IPv6 service request", family: egressRouteFamilyINET6, + consumer: netip.MustParseAddr("fd20:70::2"), service: netip.MustParseAddr("fd20:70::100"), + requestSource: true, configured: true, + }, + { + name: "IPv6 service reply", family: egressRouteFamilyINET6, + consumer: netip.MustParseAddr("fd20:70::2"), service: netip.MustParseAddr("fd20:70::100"), configured: true, + }, + { + name: "IPv6 unrelated", family: egressRouteFamilyINET6, + consumer: netip.MustParseAddr("fd20:70::2"), service: netip.MustParseAddr("fd20:70::200"), + requestSource: true, + }, + { + name: "IPv6 reply with request-only marker", family: egressRouteFamilyINET6, + consumer: netip.MustParseAddr("fd20:70::2"), service: netip.MustParseAddr("fd20:70::100"), + configured: true, wrongDirection: true, + }, + { + name: "IPv6 service request with stale token", family: egressRouteFamilyINET6, + consumer: netip.MustParseAddr("fd20:70::2"), service: netip.MustParseAddr("fd20:70::100"), + requestSource: true, configured: true, mismatchedToken: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + objs := loadObjects(t) + setServiceIdentity(t, objs, 1) + if tt.configured { + direction := uint8(2) + if tt.requestSource { + direction = 1 + } + if tt.wrongDirection { + direction ^= 3 + } + token := uint64(1) + if tt.mismatchedToken { + token = 2 + } + marker := serviceAddressMarker(1, tt.service) + if err := objs.ServiceDenyTable.Put(UsidServiceDenyKey{ + IngressIfindex: marker.IngressIfindex, Family: marker.Family, Addr: marker.Addr, + }, UsidServiceDenyValue{AttachmentToken: token, Directions: direction}); err != nil { + t.Fatalf("populate service address marker: %v", err) + } + } + src, dst := tt.service, tt.consumer + srcPort, dstPort := uint16(8443), uint16(49152) + if tt.requestSource { + src, dst = tt.consumer, tt.service + srcPort, dstPort = dstPort, srcPort + } + pkt := fragmentServicePacket(t, tt.family, src, dst, srcPort, dstPort) + ret, out, err := objs.UsidServiceEgress.Test(pkt) + if err != nil { + t.Fatalf("program test-run: %v", err) + } + wantVerdict := tcActUnspec + wantDrops := uint64(0) + if tt.configured && !tt.wrongDirection { + wantVerdict = tcActShot + wantDrops = 1 + } + if ret != wantVerdict { + t.Errorf("verdict = %d, want %d", ret, wantVerdict) + } + if !bytes.Equal(out, pkt) { + t.Errorf("fragment mutated before denial:\n in: % x\nout: % x", pkt, out) + } + if got := sumPerCPU(t, objs.DropReasons, 40); got != wantDrops { + t.Errorf("drop_reasons[service_route_denied] = %d, want %d", got, wantDrops) + } + }) + } +} + +func TestUsidServiceEgress_NeighborAdvertisementFromServicePasses(t *testing.T) { + requireRoot(t) + objs := loadObjects(t) + service := netip.MustParseAddr("fd20:70::100") + consumer := netip.MustParseAddr("fd20:70::2") + if err := objs.ServiceAccessTable.Put(serviceAddressMarker(1, service), + UsidServiceAccessValue{AttachmentToken: 1, MarkerDirections: 2}); err != nil { + t.Fatal(err) + } + pkt := buildPlainV6PacketWithICMPv6Type(t, service, consumer, 136) + ret, out, err := objs.UsidServiceEgress.Test(pkt) + if err != nil || ret != tcActUnspec { + t.Fatalf("neighbor advertisement verdict=%d err=%v, want TC_ACT_UNSPEC", ret, err) + } + if !bytes.Equal(out, pkt) { + t.Fatal("neighbor advertisement was mutated") + } +} + +func TestUsidServiceEgress_DisabledGateDeniesKnownServiceRouteMiss(t *testing.T) { + requireRoot(t) + objs := loadObjects(t) + service := netip.MustParseAddr("fd20:70::100") + if err := objs.ServiceDenyTable.Put(UsidServiceDenyKey{ + IngressIfindex: 1, Family: egressRouteFamilyINET6, Addr: service.As16(), + }, UsidServiceDenyValue{AttachmentToken: 1, Directions: 1}); err != nil { + t.Fatal(err) + } + if err := objs.ServicePolicyStateTable.Put(uint32(0), UsidServicePolicyStateValue{}); err != nil { + t.Fatal(err) + } + pkt := buildPlainV6PacketWithL4Ports(t, netip.MustParseAddr("fd20:70::2"), service, 49152, 8443) + if ret, _, err := objs.UsidServiceEgress.Test(pkt); err != nil || ret != tcActShot { + t.Fatalf("known route miss verdict=%d err=%v, want TC_ACT_SHOT", ret, err) + } +} + +func TestUsidServiceEgress_ReplyRequiresAuthorizedForwardFlow(t *testing.T) { + requireRoot(t) + objs := loadObjects(t) + setServiceIdentity(t, objs, 1) + + service := netip.MustParseAddr("fd20:70::100") + consumer := netip.MustParseAddr("fd20:70::2") + accessKey := UsidServiceAccessKey{ + IngressIfindex: 1, + Family: egressRouteFamilyINET6, + Protocol: 6, + Port: bits.ReverseBytes16(8443), + Addr: service.As16(), + } + if err := objs.ServiceRouteTable.Put( + serviceRouteKey(service), + UsidServiceRouteValue{TargetIfindex: 1, Mode: 1, ConsumerToken: 1, ProducerToken: 1}, + ); err != nil { + t.Fatalf("populate service_route_table: %v", err) + } + if err := objs.ServiceAccessTable.Put(accessKey, UsidServiceAccessValue{AttachmentToken: 1}); err != nil { + t.Fatalf("populate service_access_table: %v", err) + } + if err := objs.ServiceDenyTable.Put(UsidServiceDenyKey{ + IngressIfindex: 1, Family: egressRouteFamilyINET6, Addr: service.As16(), + }, UsidServiceDenyValue{AttachmentToken: 1, Directions: 2}); err != nil { + t.Fatalf("populate reply deny marker: %v", err) + } + + reply := buildPlainV6PacketWithL4Ports(t, service, consumer, 8443, 49152) + if ret, _, err := objs.UsidServiceEgress.Test(reply); err != nil || ret != tcActShot { + t.Fatalf("unsolicited reply verdict = %d, err = %v; want TC_ACT_SHOT", ret, err) + } + + request := buildPlainV6PacketWithL4Ports(t, consumer, service, 49152, 8443) + if ret, _, err := objs.UsidServiceEgress.Test(request); err != nil || ret != tcActRedirect { + t.Fatalf("authorized request verdict = %d, err = %v; want TC_ACT_REDIRECT", ret, err) + } + if ret, _, err := objs.UsidServiceEgress.Test(reply); err != nil || ret != tcActRedirect { + t.Fatalf("established reply verdict = %d, err = %v; want TC_ACT_REDIRECT", ret, err) + } + + if err := objs.ServiceRouteTable.Put( + serviceRouteKey(service), + UsidServiceRouteValue{TargetIfindex: 2, Mode: 1, ConsumerToken: 1, ProducerToken: 1}, + ); err != nil { + t.Fatalf("replace service route target: %v", err) + } + if ret, _, err := objs.UsidServiceEgress.Test(reply); err != nil || ret != tcActShot { + t.Fatalf("reply after route target change verdict = %d, err = %v; want TC_ACT_SHOT", ret, err) + } + if err := objs.ServiceRouteTable.Put( + serviceRouteKey(service), + UsidServiceRouteValue{TargetIfindex: 1, Mode: 1, ConsumerToken: 1, ProducerToken: 1}, + ); err != nil { + t.Fatalf("restore service route target: %v", err) + } + + if err := objs.ServiceAccessTable.Delete(accessKey); err != nil { + t.Fatalf("revoke service access: %v", err) + } + if ret, _, err := objs.UsidServiceEgress.Test(reply); err != nil || ret != tcActShot { + t.Fatalf("reply after revocation verdict = %d, err = %v; want TC_ACT_SHOT", ret, err) + } +} + +func TestUsidServiceEgress_ReclaimsCollisionFromStaleRemoteGrant(t *testing.T) { + requireRoot(t) + objs := loadObjects(t) + const currentToken = uint64(2) + setServiceIdentity(t, objs, currentToken) + service := netip.MustParseAddr("fd20:70::100") + consumer := netip.MustParseAddr("fd20:70::2") + grantID := [16]byte{9} + + if err := objs.ServiceRouteTable.Put(serviceRouteKey(service), UsidServiceRouteValue{ + TargetIfindex: 1, Mode: 1, ConsumerToken: currentToken, ProducerToken: currentToken, + }); err != nil { + t.Fatalf("populate service route: %v", err) + } + if err := objs.ServiceAccessTable.Put(UsidServiceAccessKey{ + IngressIfindex: 1, Family: egressRouteFamilyINET6, Protocol: 6, + Port: bits.ReverseBytes16(8443), Addr: service.As16(), + }, UsidServiceAccessValue{AttachmentToken: currentToken}); err != nil { + t.Fatalf("populate service access: %v", err) + } + reverseKey := UsidServiceReverseKey{ + IngressIfindex: 1, Family: egressRouteFamilyINET6, Protocol: 6, + SourcePort: bits.ReverseBytes16(8443), DestPort: bits.ReverseBytes16(49152), + SourceAddr: service.As16(), DestAddr: consumer.As16(), + } + if err := objs.ServiceReverseTable.Put(reverseKey, UsidServiceReverseValue{ + Mode: 2, ProducerToken: currentToken, GrantId: grantID, ReturnSid: [16]byte{1}, + }); err != nil { + t.Fatalf("seed stale remote reverse: %v", err) + } + if err := objs.ServiceRemoteGrantTable.Put(UsidServiceRemoteGrantKey{ + ProducerIfindex: 1, Family: egressRouteFamilyINET6, Protocol: 6, + Port: bits.ReverseBytes16(8443), GrantId: grantID, Addr: service.As16(), + }, UsidServiceRemoteGrantValue{ProducerToken: currentToken, ConsumerSid: [16]byte{2}}); err != nil { + t.Fatalf("seed stale remote grant: %v", err) + } + + request := buildPlainV6PacketWithL4Ports(t, consumer, service, 49152, 8443) + if ret, _, err := objs.UsidServiceEgress.Test(request); err != nil || ret != tcActRedirect { + t.Fatalf("replacement request verdict = %d, err = %v; want TC_ACT_REDIRECT", ret, err) + } + var reverse UsidServiceReverseValue + if err := objs.ServiceReverseTable.Lookup(reverseKey, &reverse); err != nil { + t.Fatalf("lookup replacement reverse: %v", err) + } + if reverse.Mode != 1 || reverse.ConsumerToken != currentToken || reverse.ProducerToken != currentToken { + t.Fatalf("replacement reverse = %#v, want current local owner", reverse) + } +} + +func TestUsidServiceEgress_ReclaimsCollisionFromStaleLocalRoute(t *testing.T) { + requireRoot(t) + objs := loadObjects(t) + const currentToken = uint64(2) + setServiceIdentity(t, objs, currentToken) + service := netip.MustParseAddr("fd20:70::100") + consumer := netip.MustParseAddr("fd20:70::2") + if err := objs.ServiceRouteTable.Put(serviceRouteKey(service), UsidServiceRouteValue{ + TargetIfindex: 1, Mode: 1, ConsumerToken: currentToken, ProducerToken: currentToken, + }); err != nil { + t.Fatal(err) + } + if err := objs.ServiceAccessTable.Put(UsidServiceAccessKey{ + IngressIfindex: 1, Family: egressRouteFamilyINET6, Protocol: 6, + Port: bits.ReverseBytes16(8443), Addr: service.As16(), + }, UsidServiceAccessValue{AttachmentToken: currentToken}); err != nil { + t.Fatal(err) + } + reverseKey := UsidServiceReverseKey{ + IngressIfindex: 1, Family: egressRouteFamilyINET6, Protocol: 6, + SourcePort: bits.ReverseBytes16(8443), DestPort: bits.ReverseBytes16(49152), + SourceAddr: service.As16(), DestAddr: consumer.As16(), + } + if err := objs.ServiceReverseTable.Put(reverseKey, UsidServiceReverseValue{ + ConsumerIfindex: 1, Mode: 1, ConsumerToken: 1, ProducerToken: 1, + }); err != nil { + t.Fatal(err) + } + request := buildPlainV6PacketWithL4Ports(t, consumer, service, 49152, 8443) + if ret, _, err := objs.UsidServiceEgress.Test(request); err != nil || ret != tcActRedirect { + t.Fatalf("replacement request verdict=%d err=%v, want redirect", ret, err) + } + var reverse UsidServiceReverseValue + if err := objs.ServiceReverseTable.Lookup(reverseKey, &reverse); err != nil { + t.Fatal(err) + } + if reverse.ConsumerToken != currentToken || reverse.ProducerToken != currentToken { + t.Fatalf("replacement reverse = %#v, want current tokens", reverse) + } +} + // TestUsidEgress_PassThroughEntryDefersToKernel covers the local // pass-through sentinel (egressroutemap.EgressRouteTable.RegisterPassThrough, // struct egress_route_value's own link_ifindex==0 doc comment): unlike @@ -1859,7 +2369,7 @@ func TestUsidEgress_VIPSourcedReplyRedirectsToPublicUplinkNotNAT66(t *testing.T) t.Fatalf("populate public_uplink_table: %v", err) } - pkt := buildPlainV6PacketWithL4Ports(t, backendReal, client, 6 /* TCP */, backendPort, clientPort) + pkt := buildPlainV6PacketWithL4Ports(t, backendReal, client, backendPort, clientPort) ret, out, err := objs.UsidEgress.Test(pkt) if err != nil { diff --git a/internal/plumbing/ebpf/prog/usid_verifier_cost_test.go b/internal/plumbing/ebpf/prog/usid_verifier_cost_test.go index 71c96a30..b2d95158 100644 --- a/internal/plumbing/ebpf/prog/usid_verifier_cost_test.go +++ b/internal/plumbing/ebpf/prog/usid_verifier_cost_test.go @@ -17,13 +17,14 @@ import ( // either program. The kernel's own limit is 1,000,000, but crossing it is a // cliff, not a slope: clamp_tcp_mss's option walk cost about 4,000 at 20 steps // and over 1,000,000 at 40, which failed every load on every node. This budget -// sits about four times above today's cost (roughly 4,000 per program), so a -// change that starts multiplying verifier states fails here, in a unit test, -// long before it reaches the cliff. +// leaves modest headroom above today's largest program: clang 18 reports about +// 17,800 instructions while newer compilers report about 13,600. It remains +// 50 times below the kernel limit, so a change that starts multiplying verifier +// states fails here, in a unit test, long before it reaches the cliff. // // Raising it is fine when a change genuinely needs the room. Measure first: // this test logs each program's cost. -const verifierCostBudget = 16000 +const verifierCostBudget = 20000 var processedInsns = regexp.MustCompile(`processed (\d+) insns`) diff --git a/internal/plumbing/ebpf/prog/usid_vipxlat_test.go b/internal/plumbing/ebpf/prog/usid_vipxlat_test.go index cc8492cb..899817bc 100644 --- a/internal/plumbing/ebpf/prog/usid_vipxlat_test.go +++ b/internal/plumbing/ebpf/prog/usid_vipxlat_test.go @@ -171,7 +171,7 @@ func putVIPEgressRow( func runEgressFrom(t *testing.T, objs *UsidObjects, src netip.Addr, port uint16) (uint32, netip.Addr) { t.Helper() client := netip.MustParseAddr("2001:db8:0:13::1") - pkt := buildPlainV6PacketWithL4Ports(t, src, client, 6 /* TCP */, port, 43210) + pkt := buildPlainV6PacketWithL4Ports(t, src, client, port, 43210) ret, out, err := objs.UsidEgress.Test(pkt) if err != nil { t.Fatalf("program test-run: %v", err) diff --git a/internal/plumbing/ebpf/serviceroutemap/table.go b/internal/plumbing/ebpf/serviceroutemap/table.go new file mode 100644 index 00000000..3178d801 --- /dev/null +++ b/internal/plumbing/ebpf/serviceroutemap/table.go @@ -0,0 +1,720 @@ +// Copyright 2026 Datum Cloud, Inc. +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +// Package serviceroutemap manages the pinned maps that drive node-local +// private-service forwarding in usid_egress. +package serviceroutemap + +import ( + "errors" + "fmt" + "math/bits" + "net" + "path/filepath" + + "github.com/cilium/ebpf" + + "go.datum.net/galactic/internal/plumbing/ebpf/usidmap" +) + +const ( + serviceRouteMapName = "service_route_table" + serviceAccessMapName = "service_access_table" + serviceReverseMapName = "service_reverse_table" + serviceRemoteGrantMapName = "service_remote_grant_table" + attachmentIdentityMapName = "attachment_identity_table" + servicePolicyStateMapName = "service_policy_state_table" + serviceDenyMapName = "service_deny_table" + + familyIPv6 = uint8(0) + familyIPv4 = uint8(1) +) + +// Protocol numbers used by ServiceRoutePolicy. +const ( + ProtocolTCP = uint8(6) + ProtocolUDP = uint8(17) +) + +type serviceRouteValue struct { + TargetIfindex uint32 + Mode uint8 + Pad [3]uint8 + ConsumerToken uint64 + ProducerToken uint64 + GrantID [16]uint8 + TargetSID [16]uint8 +} + +const ( + routeModeLocal = uint8(1) + routeModeRemote = uint8(2) +) + +type serviceRouteKey struct { + IngressIfindex uint32 + Family uint8 + Protocol uint8 + Port uint16 + Address [16]uint8 +} + +type serviceAccessKey struct { + IngressIfindex uint32 + Family uint8 + Protocol uint8 + Port uint16 + Address [16]uint8 +} + +type serviceAccessValue struct { + AttachmentToken uint64 + MarkerDirections uint8 + Pad [7]uint8 +} + +type serviceDenyKey struct { + IngressIfindex uint32 + Family uint8 + Pad [3]uint8 + Address [16]uint8 +} + +type serviceDenyValue struct { + AttachmentToken uint64 + Directions uint8 + Pad [7]uint8 +} + +const ( + MarkerRequest = uint8(1) + MarkerReply = uint8(2) +) + +type serviceReverseKey struct { + IngressIfindex uint32 + Family uint8 + Protocol uint8 + SourcePort uint16 + DestPort uint16 + Pad [2]uint8 + SourceAddress [16]uint8 + DestAddress [16]uint8 +} + +type serviceReverseValue struct { + ConsumerIfindex uint32 + Mode uint8 + Pad [3]uint8 + ConsumerToken uint64 + ProducerToken uint64 + GrantID [16]uint8 + ReturnSID [16]uint8 + LastSeenNS uint64 +} + +type serviceRemoteGrantKey struct { + ProducerIfindex uint32 + Family uint8 + Protocol uint8 + Port uint16 + GrantID [16]uint8 + Address [16]uint8 +} + +type serviceRemoteGrantValue struct { + ConsumerSID [16]uint8 + ProducerToken uint64 +} + +type servicePolicyStateValue struct{ Enabled uint8 } + +// RouteIdentity describes a forwarding key that must survive a complete +// desired-state sweep. +type RouteIdentity struct { + IngressIfindex uint32 + Address net.IP + Protocol uint8 + Port uint16 +} + +// AccessIdentity describes an access key that must survive a complete +// desired-state sweep. +type AccessIdentity struct { + IngressIfindex uint32 + Address net.IP + Protocol uint8 + Port uint16 +} + +type DenyIdentity struct { + IngressIfindex uint32 + Address net.IP +} + +// RemoteGrantIdentity describes a producer grant key that must survive a +// complete desired-state sweep. +type RemoteGrantIdentity struct { + ProducerIfindex uint32 + Address net.IP + Protocol uint8 + Port uint16 + GrantID [16]byte +} + +// PolicySnapshot contains the policy-map identities in a complete desired +// controller snapshot. Values are rewritten before the sweep. +type PolicySnapshot struct { + Routes []RouteIdentity + Access []AccessIdentity + RemoteGrants []RemoteGrantIdentity + Deny []DenyIdentity +} + +// Tables owns the route and access maps used by the service datapath. +type Tables struct { + routes usidmap.Table + access usidmap.Table + reverse usidmap.Table + remoteGrants usidmap.Table + identities usidmap.Table + policyState usidmap.Table + deny usidmap.Table +} + +// OpenPinned opens both service maps under pinDir. The caller owns the +// returned kernel maps and must close them. +func OpenPinned(pinDir string) (*Tables, []*ebpf.Map, error) { + routes, err := ebpf.LoadPinnedMap(filepath.Join(pinDir, serviceRouteMapName), nil) + if err != nil { + return nil, nil, fmt.Errorf("serviceroutemap: open %s: %w", serviceRouteMapName, err) + } + access, err := ebpf.LoadPinnedMap(filepath.Join(pinDir, serviceAccessMapName), nil) + if err != nil { + _ = routes.Close() + return nil, nil, fmt.Errorf("serviceroutemap: open %s: %w", serviceAccessMapName, err) + } + reverse, err := ebpf.LoadPinnedMap(filepath.Join(pinDir, serviceReverseMapName), nil) + if err != nil { + _ = routes.Close() + _ = access.Close() + return nil, nil, fmt.Errorf("serviceroutemap: open %s: %w", serviceReverseMapName, err) + } + remoteGrants, err := ebpf.LoadPinnedMap(filepath.Join(pinDir, serviceRemoteGrantMapName), nil) + if err != nil { + _ = routes.Close() + _ = access.Close() + _ = reverse.Close() + return nil, nil, fmt.Errorf("serviceroutemap: open %s: %w", serviceRemoteGrantMapName, err) + } + identities, err := ebpf.LoadPinnedMap(filepath.Join(pinDir, attachmentIdentityMapName), nil) + if err != nil { + _ = routes.Close() + _ = access.Close() + _ = reverse.Close() + _ = remoteGrants.Close() + return nil, nil, fmt.Errorf("serviceroutemap: open %s: %w", attachmentIdentityMapName, err) + } + policyState, err := ebpf.LoadPinnedMap(filepath.Join(pinDir, servicePolicyStateMapName), nil) + if err != nil { + _ = routes.Close() + _ = access.Close() + _ = reverse.Close() + _ = remoteGrants.Close() + _ = identities.Close() + return nil, nil, fmt.Errorf("serviceroutemap: open %s: %w", servicePolicyStateMapName, err) + } + deny, err := ebpf.LoadPinnedMap(filepath.Join(pinDir, serviceDenyMapName), nil) + if err != nil { + for _, opened := range []*ebpf.Map{routes, access, reverse, remoteGrants, identities, policyState} { + _ = opened.Close() + } + return nil, nil, fmt.Errorf("serviceroutemap: open %s: %w", serviceDenyMapName, err) + } + return New( + usidmap.KernelTable{Map: routes}, usidmap.KernelTable{Map: access}, + usidmap.KernelTable{Map: reverse}, usidmap.KernelTable{Map: remoteGrants}, usidmap.KernelTable{Map: identities}, + usidmap.KernelTable{Map: policyState}, usidmap.KernelTable{Map: deny}, + ), + []*ebpf.Map{routes, access, reverse, remoteGrants, identities, policyState, deny}, nil +} + +// New wraps map implementations. Production uses OpenPinned; tests use fakes. +func New(routes, access, reverse usidmap.Table, remoteGrants ...usidmap.Table) *Tables { + t := &Tables{routes: routes, access: access, reverse: reverse} + if len(remoteGrants) != 0 { + t.remoteGrants = remoteGrants[0] + } + if len(remoteGrants) > 1 { + t.identities = remoteGrants[1] + } + if len(remoteGrants) > 2 { + t.policyState = remoteGrants[2] + } + if len(remoteGrants) > 3 { + t.deny = remoteGrants[3] + } + return t +} + +// SetPolicyEnabled gates all service authorization while the controller is +// rebuilding a complete cache-synchronized generation. +func (t *Tables) SetPolicyEnabled(enabled bool) error { + if t.policyState == nil { + return errors.New("serviceroutemap: service policy state table unavailable") + } + value := servicePolicyStateValue{} + if enabled { + value.Enabled = 1 + } + if err := t.policyState.Put(uint32(0), value); err != nil { + return fmt.Errorf("serviceroutemap: set service policy enabled=%t: %w", enabled, err) + } + return nil +} + +// IdentityToken returns the current nonzero incarnation token for ifindex. +func (t *Tables) IdentityToken(ifindex uint32) (uint64, error) { + if t.identities == nil { + return 0, errors.New("serviceroutemap: attachment identity table unavailable") + } + var token uint64 + if err := t.identities.Lookup(ifindex, &token); err != nil { + return 0, fmt.Errorf("serviceroutemap: lookup attachment identity ifindex=%d: %w", ifindex, err) + } + if token == 0 { + return 0, fmt.Errorf("serviceroutemap: attachment identity ifindex=%d has zero token", ifindex) + } + return token, nil +} + +// Clear removes all service-owned state, including reverse flows. Normal +// controller restart reconciliation uses SweepPolicy instead so live desired +// entries and compatible reverse state remain available. +func (t *Tables) Clear() error { + var routeKeys []serviceRouteKey + routeIterator := t.routes.Iterate() + var routeKey serviceRouteKey + var routeValue serviceRouteValue + for routeIterator.Next(&routeKey, &routeValue) { + routeKeys = append(routeKeys, routeKey) + } + if err := routeIterator.Err(); err != nil { + return fmt.Errorf("serviceroutemap: iterate routes: %w", err) + } + + var accessKeys []serviceAccessKey + accessIterator := t.access.Iterate() + var grantKey serviceAccessKey + var grantValue serviceAccessValue + for accessIterator.Next(&grantKey, &grantValue) { + accessKeys = append(accessKeys, grantKey) + } + if err := accessIterator.Err(); err != nil { + return fmt.Errorf("serviceroutemap: iterate access grants: %w", err) + } + + var reverseKeys []serviceReverseKey + reverseIterator := t.reverse.Iterate() + var reverseKey serviceReverseKey + var reverseValue serviceReverseValue + for reverseIterator.Next(&reverseKey, &reverseValue) { + reverseKeys = append(reverseKeys, reverseKey) + } + if err := reverseIterator.Err(); err != nil { + return fmt.Errorf("serviceroutemap: iterate reverse flows: %w", err) + } + + var remoteGrantKeys []serviceRemoteGrantKey + if t.remoteGrants != nil { + iterator := t.remoteGrants.Iterate() + var key serviceRemoteGrantKey + var value serviceRemoteGrantValue + for iterator.Next(&key, &value) { + remoteGrantKeys = append(remoteGrantKeys, key) + } + if err := iterator.Err(); err != nil { + return fmt.Errorf("serviceroutemap: iterate remote grants: %w", err) + } + } + var denyKeys []serviceDenyKey + if t.deny != nil { + iterator := t.deny.Iterate() + var key serviceDenyKey + var value serviceDenyValue + for iterator.Next(&key, &value) { + denyKeys = append(denyKeys, key) + } + if err := iterator.Err(); err != nil { + return fmt.Errorf("serviceroutemap: iterate deny catalog: %w", err) + } + } + + for _, key := range routeKeys { + if err := t.routes.Delete(key); err != nil && !errors.Is(err, ebpf.ErrKeyNotExist) { + return fmt.Errorf("serviceroutemap: clear route: %w", err) + } + } + for _, key := range accessKeys { + if err := t.access.Delete(key); err != nil && !errors.Is(err, ebpf.ErrKeyNotExist) { + return fmt.Errorf("serviceroutemap: clear access grant: %w", err) + } + } + for _, key := range reverseKeys { + if err := t.reverse.Delete(key); err != nil && !errors.Is(err, ebpf.ErrKeyNotExist) { + return fmt.Errorf("serviceroutemap: clear reverse flow: %w", err) + } + } + for _, key := range remoteGrantKeys { + if err := t.remoteGrants.Delete(key); err != nil && !errors.Is(err, ebpf.ErrKeyNotExist) { + return fmt.Errorf("serviceroutemap: clear remote grant: %w", err) + } + } + for _, key := range denyKeys { + if err := t.deny.Delete(key); err != nil && !errors.Is(err, ebpf.ErrKeyNotExist) { + return fmt.Errorf("serviceroutemap: clear deny catalog: %w", err) + } + } + return nil +} + +// SweepPolicy removes route, access, and remote-grant entries absent from a +// complete cache-synchronized desired snapshot. Reverse-flow entries are +// deliberately preserved: reply processing revalidates the current route, +// access, and grant maps before delivery. +// +// All three maps are fully iterated before the first deletion. An iteration or +// desired-key error therefore leaves the live maps unchanged. Deletion is +// idempotent, so a partial deletion failure is safe to retry with the same +// complete snapshot. +// +//nolint:gocyclo // Atomic multi-map sweep is intentionally linear. +func (t *Tables) SweepPolicy(snapshot PolicySnapshot) error { + desiredRoutes := make(map[serviceRouteKey]struct{}, len(snapshot.Routes)) + for _, route := range snapshot.Routes { + key, err := routeKey(route.IngressIfindex, route.Address, route.Protocol, route.Port) + if err != nil { + return err + } + desiredRoutes[key] = struct{}{} + } + desiredAccess := make(map[serviceAccessKey]struct{}, len(snapshot.Access)) + for _, access := range snapshot.Access { + key, err := accessKey(access.IngressIfindex, access.Address, access.Protocol, access.Port) + if err != nil { + return err + } + desiredAccess[key] = struct{}{} + } + desiredGrants := make(map[serviceRemoteGrantKey]struct{}, len(snapshot.RemoteGrants)) + for _, grant := range snapshot.RemoteGrants { + key, err := remoteGrantKey(grant.ProducerIfindex, grant.Address, grant.Protocol, grant.Port, grant.GrantID) + if err != nil { + return err + } + desiredGrants[key] = struct{}{} + } + desiredDeny := make(map[serviceDenyKey]struct{}, len(snapshot.Deny)) + for _, deny := range snapshot.Deny { + key, err := denyKey(deny.IngressIfindex, deny.Address) + if err != nil { + return err + } + desiredDeny[key] = struct{}{} + } + + var staleRoutes []serviceRouteKey + routeIterator := t.routes.Iterate() + var route serviceRouteKey + var routeValue serviceRouteValue + for routeIterator.Next(&route, &routeValue) { + if _, desired := desiredRoutes[route]; !desired { + staleRoutes = append(staleRoutes, route) + } + } + if err := routeIterator.Err(); err != nil { + return fmt.Errorf("serviceroutemap: iterate routes for sweep: %w", err) + } + + var staleAccess []serviceAccessKey + accessIterator := t.access.Iterate() + var access serviceAccessKey + var accessValue serviceAccessValue + for accessIterator.Next(&access, &accessValue) { + if _, desired := desiredAccess[access]; !desired { + staleAccess = append(staleAccess, access) + } + } + if err := accessIterator.Err(); err != nil { + return fmt.Errorf("serviceroutemap: iterate access grants for sweep: %w", err) + } + + var staleGrants []serviceRemoteGrantKey + if t.remoteGrants != nil { + grantIterator := t.remoteGrants.Iterate() + var grant serviceRemoteGrantKey + var grantValue serviceRemoteGrantValue + for grantIterator.Next(&grant, &grantValue) { + if _, desired := desiredGrants[grant]; !desired { + staleGrants = append(staleGrants, grant) + } + } + if err := grantIterator.Err(); err != nil { + return fmt.Errorf("serviceroutemap: iterate remote grants for sweep: %w", err) + } + } + var staleDeny []serviceDenyKey + if t.deny != nil { + iterator := t.deny.Iterate() + var deny serviceDenyKey + var value serviceDenyValue + for iterator.Next(&deny, &value) { + if _, desired := desiredDeny[deny]; !desired { + staleDeny = append(staleDeny, deny) + } + } + if err := iterator.Err(); err != nil { + return fmt.Errorf("serviceroutemap: iterate deny catalog for sweep: %w", err) + } + } + + // Revoke authorization before forwarding. Continue independent deletions + // after an error so one broken key cannot leave unrelated stale grants live; + // every operation is idempotent and the joined error triggers a retry. + var deleteErrs []error + for _, key := range staleGrants { + if err := t.remoteGrants.Delete(key); err != nil && !errors.Is(err, ebpf.ErrKeyNotExist) { + deleteErrs = append(deleteErrs, fmt.Errorf("serviceroutemap: sweep remote grant: %w", err)) + } + } + for _, key := range staleAccess { + if err := t.access.Delete(key); err != nil && !errors.Is(err, ebpf.ErrKeyNotExist) { + deleteErrs = append(deleteErrs, fmt.Errorf("serviceroutemap: sweep access grant: %w", err)) + } + } + for _, key := range staleRoutes { + if err := t.routes.Delete(key); err != nil && !errors.Is(err, ebpf.ErrKeyNotExist) { + deleteErrs = append(deleteErrs, fmt.Errorf("serviceroutemap: sweep route: %w", err)) + } + } + for _, key := range staleDeny { + if err := t.deny.Delete(key); err != nil && !errors.Is(err, ebpf.ErrKeyNotExist) { + deleteErrs = append(deleteErrs, fmt.Errorf("serviceroutemap: sweep deny catalog: %w", err)) + } + } + return errors.Join(deleteErrs...) +} + +// RegisterRoute installs or replaces a service forwarding entry. +func (t *Tables) RegisterRoute( + ingressIfindex uint32, + address net.IP, + protocol uint8, + port uint16, + targetIfindex uint32, + consumerToken, producerToken uint64, +) error { + key, err := routeKey(ingressIfindex, address, protocol, port) + if err != nil { + return err + } + value := serviceRouteValue{ + TargetIfindex: targetIfindex, + Mode: routeModeLocal, + ConsumerToken: consumerToken, + ProducerToken: producerToken, + } + if err := t.routes.Put(key, value); err != nil { + return fmt.Errorf("serviceroutemap: register route ifindex=%d address=%s: %w", ingressIfindex, address, err) + } + return nil +} + +// RegisterRemoteRoute installs an authenticated service tunnel target while +// keeping the original inner source address intact. +func (t *Tables) RegisterRemoteRoute(ingressIfindex uint32, address net.IP, grantID [16]byte, + protocol uint8, port uint16, targetSID net.IP, consumerToken uint64) error { + key, err := routeKey(ingressIfindex, address, protocol, port) + if err != nil { + return err + } + sid := targetSID.To16() + if sid == nil || targetSID.To4() != nil { + return fmt.Errorf("serviceroutemap: target SID %q is not IPv6", targetSID) + } + value := serviceRouteValue{Mode: routeModeRemote, GrantID: grantID, ConsumerToken: consumerToken} + copy(value.TargetSID[:], sid) + if err := t.routes.Put(key, value); err != nil { + return fmt.Errorf("serviceroutemap: register remote route: %w", err) + } + return nil +} + +func (t *Tables) RegisterRemoteGrant(producerIfindex uint32, address net.IP, protocol uint8, port uint16, + grantID [16]byte, consumerSID net.IP, producerToken uint64) error { + if t.remoteGrants == nil { + return errors.New("serviceroutemap: remote grant table unavailable") + } + key, err := remoteGrantKey(producerIfindex, address, protocol, port, grantID) + if err != nil { + return err + } + sid := consumerSID.To16() + if sid == nil || consumerSID.To4() != nil { + return fmt.Errorf("serviceroutemap: consumer SID %q is not IPv6", consumerSID) + } + value := serviceRemoteGrantValue{ProducerToken: producerToken} + copy(value.ConsumerSID[:], sid) + if err := t.remoteGrants.Put(key, value); err != nil { + return fmt.Errorf("serviceroutemap: register remote grant: %w", err) + } + return nil +} + +func (t *Tables) UnregisterRemoteGrant( + producerIfindex uint32, address net.IP, protocol uint8, port uint16, grantID [16]byte, +) error { + key, err := remoteGrantKey(producerIfindex, address, protocol, port, grantID) + if err != nil { + return err + } + if err := t.remoteGrants.Delete(key); err != nil && !errors.Is(err, ebpf.ErrKeyNotExist) { + return fmt.Errorf("serviceroutemap: unregister remote grant: %w", err) + } + return nil +} + +// UnregisterRoute removes a service forwarding entry if it exists. +func (t *Tables) UnregisterRoute(ingressIfindex uint32, address net.IP, protocol uint8, port uint16) error { + key, err := routeKey(ingressIfindex, address, protocol, port) + if err != nil { + return err + } + if err := t.routes.Delete(key); err != nil && !errors.Is(err, ebpf.ErrKeyNotExist) { + return fmt.Errorf("serviceroutemap: unregister route ifindex=%d address=%s: %w", ingressIfindex, address, err) + } + return nil +} + +// RegisterAccess allows protocol and port traffic to one service address. +func (t *Tables) RegisterAccess(ingressIfindex uint32, address net.IP, protocol uint8, port uint16, + attachmentToken uint64, markerDirections uint8) error { + key, err := accessKey(ingressIfindex, address, protocol, port) + if err != nil { + return err + } + value := serviceAccessValue{AttachmentToken: attachmentToken, MarkerDirections: markerDirections} + if err := t.access.Put(key, value); err != nil { + return fmt.Errorf("serviceroutemap: register access ifindex=%d address=%s protocol=%d port=%d: %w", + ingressIfindex, address, protocol, port, err) + } + return nil +} + +func (t *Tables) RegisterDeny(ingressIfindex uint32, address net.IP, attachmentToken uint64, directions uint8) error { + if t.deny == nil { + return errors.New("serviceroutemap: service deny table unavailable") + } + key, err := denyKey(ingressIfindex, address) + if err != nil { + return err + } + if err := t.deny.Put(key, serviceDenyValue{AttachmentToken: attachmentToken, Directions: directions}); err != nil { + return fmt.Errorf("serviceroutemap: register deny ifindex=%d address=%s: %w", ingressIfindex, address, err) + } + return nil +} + +func (t *Tables) UnregisterDeny(ingressIfindex uint32, address net.IP) error { + key, err := denyKey(ingressIfindex, address) + if err != nil { + return err + } + if err := t.deny.Delete(key); err != nil && !errors.Is(err, ebpf.ErrKeyNotExist) { + return fmt.Errorf("serviceroutemap: unregister deny ifindex=%d address=%s: %w", ingressIfindex, address, err) + } + return nil +} + +// UnregisterAccess removes a service access grant if it exists. +func (t *Tables) UnregisterAccess(ingressIfindex uint32, address net.IP, protocol uint8, port uint16) error { + key, err := accessKey(ingressIfindex, address, protocol, port) + if err != nil { + return err + } + if err := t.access.Delete(key); err != nil && !errors.Is(err, ebpf.ErrKeyNotExist) { + return fmt.Errorf("serviceroutemap: unregister access ifindex=%d address=%s protocol=%d port=%d: %w", + ingressIfindex, address, protocol, port, err) + } + return nil +} + +func routeKey(ingressIfindex uint32, address net.IP, protocol uint8, port uint16) (serviceRouteKey, error) { + key := serviceRouteKey{IngressIfindex: ingressIfindex, Protocol: protocol, Port: bits.ReverseBytes16(port)} + if ipv4 := address.To4(); ipv4 != nil { + key.Family = familyIPv4 + copy(key.Address[:4], ipv4) + return key, nil + } + ipv6 := address.To16() + if ipv6 == nil { + return serviceRouteKey{}, fmt.Errorf("serviceroutemap: address %q is not an IP address", address) + } + key.Family = familyIPv6 + copy(key.Address[:], ipv6) + return key, nil +} + +func accessKey(ingressIfindex uint32, address net.IP, protocol uint8, port uint16) (serviceAccessKey, error) { + key := serviceAccessKey{IngressIfindex: ingressIfindex, Protocol: protocol, Port: bits.ReverseBytes16(port)} + if ipv4 := address.To4(); ipv4 != nil { + key.Family = familyIPv4 + copy(key.Address[:4], ipv4) + return key, nil + } + ipv6 := address.To16() + if ipv6 == nil { + return serviceAccessKey{}, fmt.Errorf("serviceroutemap: address %q is not an IP address", address) + } + key.Family = familyIPv6 + copy(key.Address[:], ipv6) + return key, nil +} + +func denyKey(ingressIfindex uint32, address net.IP) (serviceDenyKey, error) { + key := serviceDenyKey{IngressIfindex: ingressIfindex} + if ipv4 := address.To4(); ipv4 != nil { + key.Family = familyIPv4 + copy(key.Address[:4], ipv4) + return key, nil + } + ipv6 := address.To16() + if ipv6 == nil { + return serviceDenyKey{}, fmt.Errorf("serviceroutemap: address %q is not an IP address", address) + } + key.Family = familyIPv6 + copy(key.Address[:], ipv6) + return key, nil +} + +func remoteGrantKey( + producerIfindex uint32, address net.IP, protocol uint8, port uint16, grantID [16]byte, +) (serviceRemoteGrantKey, error) { + key := serviceRemoteGrantKey{ + ProducerIfindex: producerIfindex, Protocol: protocol, + Port: bits.ReverseBytes16(port), GrantID: grantID, + } + if ipv4 := address.To4(); ipv4 != nil { + key.Family = familyIPv4 + copy(key.Address[:4], ipv4) + return key, nil + } + ipv6 := address.To16() + if ipv6 == nil { + return serviceRemoteGrantKey{}, fmt.Errorf("serviceroutemap: address %q is not an IP address", address) + } + key.Family = familyIPv6 + copy(key.Address[:], ipv6) + return key, nil +} diff --git a/internal/plumbing/ebpf/serviceroutemap/table_test.go b/internal/plumbing/ebpf/serviceroutemap/table_test.go new file mode 100644 index 00000000..520c38ef --- /dev/null +++ b/internal/plumbing/ebpf/serviceroutemap/table_test.go @@ -0,0 +1,356 @@ +// Copyright 2026 Datum Cloud, Inc. +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package serviceroutemap + +import ( + "bytes" + "errors" + "math/bits" + "net" + "reflect" + "testing" + + "github.com/cilium/ebpf" + + "go.datum.net/galactic/internal/plumbing/ebpf/usidmap" +) + +type fakeTable struct { + putKey, putValue any + deleted any +} + +func (f *fakeTable) Put(key, value any) error { + f.putKey, f.putValue = key, value + return nil +} + +func (*fakeTable) Lookup(any, any) error { return ebpf.ErrKeyNotExist } + +func (f *fakeTable) Delete(key any) error { + f.deleted = key + return ebpf.ErrKeyNotExist +} + +func (*fakeTable) Iterate() usidmap.Iterator { + return emptyIterator{} +} + +type emptyIterator struct{} + +func (emptyIterator) Next(any, any) bool { return false } +func (emptyIterator) Err() error { return nil } + +type memoryTable struct { + entries map[any]any + iterateErr error + deleteErr error + deletes int + name string + deleteLog *[]string +} + +func (t *memoryTable) Put(key, value any) error { + if t.entries == nil { + t.entries = make(map[any]any) + } + t.entries[key] = value + return nil +} + +func (t *memoryTable) Lookup(key, value any) error { + stored, ok := t.entries[key] + if !ok { + return ebpf.ErrKeyNotExist + } + reflect.ValueOf(value).Elem().Set(reflect.ValueOf(stored)) + return nil +} + +func (t *memoryTable) Delete(key any) error { + t.deletes++ + if t.deleteLog != nil { + *t.deleteLog = append(*t.deleteLog, t.name) + } + if t.deleteErr != nil { + err := t.deleteErr + t.deleteErr = nil + return err + } + if _, ok := t.entries[key]; !ok { + return ebpf.ErrKeyNotExist + } + delete(t.entries, key) + return nil +} + +func (t *memoryTable) Iterate() usidmap.Iterator { + keys := make([]any, 0, len(t.entries)) + values := make([]any, 0, len(t.entries)) + for key, value := range t.entries { + keys = append(keys, key) + values = append(values, value) + } + return &memoryIterator{keys: keys, values: values, err: t.iterateErr} +} + +type memoryIterator struct { + keys, values []any + index int + err error +} + +func (i *memoryIterator) Next(key, value any) bool { + if i.index == len(i.keys) { + return false + } + reflect.ValueOf(key).Elem().Set(reflect.ValueOf(i.keys[i.index])) + reflect.ValueOf(value).Elem().Set(reflect.ValueOf(i.values[i.index])) + i.index++ + return true +} + +func (i *memoryIterator) Err() error { return i.err } + +func TestTables_RegisterIPv6Route(t *testing.T) { + routes, access := &fakeTable{}, &fakeTable{} + reverse := &fakeTable{} + tables := New(routes, access, reverse) + address := net.ParseIP("fd20:0:19::53") + if err := tables.RegisterRoute(42, address, ProtocolUDP, 53, 101, 420, 1010); err != nil { + t.Fatal(err) + } + key, ok := routes.putKey.(serviceRouteKey) + if !ok { + t.Fatalf("route key type = %T", routes.putKey) + } + if key.IngressIfindex != 42 || key.Family != familyIPv6 || key.Protocol != ProtocolUDP || + key.Port != bits.ReverseBytes16(53) || !bytes.Equal(key.Address[:], address.To16()) { + t.Fatalf("route key = %+v", key) + } + value, ok := routes.putValue.(serviceRouteValue) + if !ok || value.TargetIfindex != 101 || value.Mode != routeModeLocal || + value.ConsumerToken != 420 || value.ProducerToken != 1010 { + t.Fatalf("route value = %+v", routes.putValue) + } +} + +func TestTables_RegisterRemoteRoute(t *testing.T) { + routes := &fakeTable{} + tables := New(routes, &fakeTable{}, &fakeTable{}, &fakeTable{}) + grant := [16]byte{1, 2, 3} + if err := tables.RegisterRemoteRoute(42, net.ParseIP("10.0.0.53"), grant, + ProtocolTCP, 443, net.ParseIP("fd00::1234"), 420); err != nil { + t.Fatal(err) + } + value, ok := routes.putValue.(serviceRouteValue) + if !ok || value.Mode != routeModeRemote || value.GrantID != grant || value.ConsumerToken != 420 || + !bytes.Equal(value.TargetSID[:], net.ParseIP("fd00::1234").To16()) { + t.Fatalf("remote route value = %+v", routes.putValue) + } +} + +func TestTables_RegisterRemoteGrant(t *testing.T) { + grants := &fakeTable{} + tables := New(&fakeTable{}, &fakeTable{}, &fakeTable{}, grants) + grantID := [16]byte{9, 8, 7} + consumerSID := net.ParseIP("fd00::42") + if err := tables.RegisterRemoteGrant(101, net.ParseIP("fd20::53"), ProtocolTCP, 443, + grantID, consumerSID, 1010); err != nil { + t.Fatal(err) + } + key, ok := grants.putKey.(serviceRemoteGrantKey) + if !ok || key.ProducerIfindex != 101 || key.Protocol != ProtocolTCP || key.Port != bits.ReverseBytes16(443) || + key.GrantID != grantID || key.Family != familyIPv6 || !bytes.Equal(key.Address[:], net.ParseIP("fd20::53").To16()) { + t.Fatalf("remote grant key = %+v", grants.putKey) + } + value, ok := grants.putValue.(serviceRemoteGrantValue) + if !ok || !bytes.Equal(value.ConsumerSID[:], consumerSID.To16()) || value.ProducerToken != 1010 { + t.Fatalf("remote grant value = %+v", grants.putValue) + } + if err := tables.UnregisterRemoteGrant(101, net.ParseIP("fd20::53"), ProtocolTCP, 443, grantID); err != nil { + t.Fatal(err) + } + deleted, ok := grants.deleted.(serviceRemoteGrantKey) + if !ok || deleted != key { + t.Fatalf("deleted remote grant key = %+v, want %+v", grants.deleted, key) + } +} + +func TestTables_RegisterAccessUsesNetworkPortOrder(t *testing.T) { + routes, access := &fakeTable{}, &fakeTable{} + tables := New(routes, access, &fakeTable{}) + address := net.ParseIP("fd20::53") + if err := tables.RegisterAccess(42, address, ProtocolUDP, 53, 420, 0); err != nil { + t.Fatal(err) + } + key, ok := access.putKey.(serviceAccessKey) + if !ok { + t.Fatalf("access key type = %T", access.putKey) + } + if key.IngressIfindex != 42 || key.Family != familyIPv6 || key.Protocol != ProtocolUDP || key.Port != 0x3500 { + t.Fatalf("access key = %+v", key) + } + if !bytes.Equal(key.Address[:], address.To16()) { + t.Fatalf("address = %x, want %x", key.Address, address.To16()) + } + value, ok := access.putValue.(serviceAccessValue) + if !ok || value.AttachmentToken != 420 || value.MarkerDirections != 0 { + t.Fatalf("access value = %+v", access.putValue) + } +} + +func TestTables_RegisterServiceAddressMarker(t *testing.T) { + access := &fakeTable{} + tables := New(&fakeTable{}, access, &fakeTable{}) + address := net.ParseIP("192.0.2.53") + if err := tables.RegisterAccess(101, address, 0, 0, 1010, MarkerReply); err != nil { + t.Fatal(err) + } + key, ok := access.putKey.(serviceAccessKey) + if !ok || key.IngressIfindex != 101 || key.Family != familyIPv4 || key.Protocol != 0 || key.Port != 0 || + !bytes.Equal(key.Address[:4], address.To4()) { + t.Fatalf("service address marker = %+v", access.putKey) + } + value, ok := access.putValue.(serviceAccessValue) + if !ok || value.AttachmentToken != 1010 || value.MarkerDirections != MarkerReply { + t.Fatalf("service marker value = %+v", access.putValue) + } +} + +func TestTables_UnregisterAbsentIsIdempotent(t *testing.T) { + routes, access := &fakeTable{}, &fakeTable{} + reverse := &fakeTable{} + tables := New(routes, access, reverse) + if err := tables.UnregisterRoute(7, net.ParseIP("192.0.2.53"), ProtocolUDP, 53); err != nil { + t.Fatal(err) + } + if err := tables.UnregisterAccess(7, net.ParseIP("192.0.2.53"), ProtocolTCP, 853); err != nil { + t.Fatal(err) + } + if routes.deleted == nil || access.deleted == nil { + t.Fatal("expected both map keys to be deleted") + } +} + +func TestRouteKeyRejectsInvalidAddress(t *testing.T) { + _, err := routeKey(1, nil, ProtocolTCP, 443) + if err == nil { + t.Fatal("routeKey(nil) did not return an error") + } +} + +func TestTablesSweepPolicyRemovesOnlyStalePolicyState(t *testing.T) { + routes, access := &memoryTable{entries: make(map[any]any)}, &memoryTable{entries: make(map[any]any)} + reverse, grants := &memoryTable{entries: make(map[any]any)}, &memoryTable{entries: make(map[any]any)} + desiredRoute, _ := routeKey(10, net.ParseIP("10.0.0.53"), ProtocolTCP, 443) + staleRoute, _ := routeKey(11, net.ParseIP("10.0.0.54"), ProtocolUDP, 53) + desiredAccess, _ := accessKey(10, net.ParseIP("10.0.0.53"), ProtocolTCP, 443) + staleAccess, _ := accessKey(11, net.ParseIP("10.0.0.54"), ProtocolUDP, 53) + desiredGrant, _ := remoteGrantKey(20, net.ParseIP("10.0.0.53"), ProtocolTCP, 443, [16]byte{1}) + staleGrant, _ := remoteGrantKey(21, net.ParseIP("10.0.0.54"), ProtocolUDP, 53, [16]byte{2}) + reverseKey := serviceReverseKey{IngressIfindex: 10, Protocol: ProtocolTCP} + routes.entries[desiredRoute], routes.entries[staleRoute] = serviceRouteValue{}, serviceRouteValue{} + access.entries[desiredAccess], access.entries[staleAccess] = serviceAccessValue{}, serviceAccessValue{} + grants.entries[desiredGrant], grants.entries[staleGrant] = serviceRemoteGrantValue{}, serviceRemoteGrantValue{} + reverse.entries[reverseKey] = serviceReverseValue{} + tables := New(routes, access, reverse, grants) + + err := tables.SweepPolicy(PolicySnapshot{ + Routes: []RouteIdentity{{IngressIfindex: 10, Address: net.ParseIP("10.0.0.53"), Protocol: ProtocolTCP, Port: 443}}, + Access: []AccessIdentity{{IngressIfindex: 10, Address: net.ParseIP("10.0.0.53"), Protocol: ProtocolTCP, Port: 443}}, + RemoteGrants: []RemoteGrantIdentity{{ + ProducerIfindex: 20, Address: net.ParseIP("10.0.0.53"), Protocol: ProtocolTCP, Port: 443, + GrantID: [16]byte{1}, + }}, + }) + if err != nil { + t.Fatalf("SweepPolicy: %v", err) + } + if _, ok := routes.entries[desiredRoute]; !ok { + t.Fatal("desired route was removed") + } + if _, ok := routes.entries[staleRoute]; ok { + t.Fatal("stale route survived") + } + if _, ok := access.entries[desiredAccess]; !ok { + t.Fatal("desired access entry was removed") + } + if _, ok := access.entries[staleAccess]; ok { + t.Fatal("stale access entry survived") + } + if _, ok := grants.entries[desiredGrant]; !ok { + t.Fatal("desired remote grant was removed") + } + if _, ok := grants.entries[staleGrant]; ok { + t.Fatal("stale remote grant survived") + } + if _, ok := reverse.entries[reverseKey]; !ok || reverse.deletes != 0 { + t.Fatal("reverse state was not preserved") + } +} + +func TestTablesSweepPolicyIterationFailureDoesNotDeleteAnything(t *testing.T) { + iterationFailure := errors.New("access iteration failed") + route, _ := routeKey(10, net.ParseIP("10.0.0.53"), ProtocolTCP, 443) + accessKey, _ := accessKey(10, net.ParseIP("10.0.0.53"), ProtocolTCP, 443) + routes := &memoryTable{entries: map[any]any{route: serviceRouteValue{}}} + access := &memoryTable{entries: map[any]any{accessKey: serviceAccessValue{}}, iterateErr: iterationFailure} + reverse := &memoryTable{entries: make(map[any]any)} + grants := &memoryTable{entries: make(map[any]any)} + + if err := New(routes, access, reverse, grants).SweepPolicy(PolicySnapshot{}); !errors.Is(err, iterationFailure) { + t.Fatalf("SweepPolicy error = %v, want %v", err, iterationFailure) + } + if routes.deletes+access.deletes+grants.deletes != 0 { + t.Fatalf("sweep deleted entries before completing iteration: route/access/grant=%d/%d/%d", + routes.deletes, access.deletes, grants.deletes) + } +} + +func TestTablesSweepPolicyDeleteFailureIsRetryable(t *testing.T) { + deleteFailure := errors.New("grant deletion failed") + staleRoute, _ := routeKey(10, net.ParseIP("10.0.0.53"), ProtocolTCP, 443) + staleAccess, _ := accessKey(10, net.ParseIP("10.0.0.53"), ProtocolTCP, 443) + staleGrant, _ := remoteGrantKey(20, net.ParseIP("10.0.0.53"), ProtocolTCP, 443, [16]byte{1}) + var deleteLog []string + routes := &memoryTable{ + entries: map[any]any{staleRoute: serviceRouteValue{}}, name: "route", deleteLog: &deleteLog, + } + access := &memoryTable{ + entries: map[any]any{staleAccess: serviceAccessValue{}}, name: "access", deleteLog: &deleteLog, + } + reverseKey := serviceReverseKey{IngressIfindex: 10, Protocol: ProtocolTCP} + reverse := &memoryTable{entries: map[any]any{reverseKey: serviceReverseValue{}}} + grants := &memoryTable{ + entries: map[any]any{staleGrant: serviceRemoteGrantValue{}}, deleteErr: deleteFailure, + name: "grant", deleteLog: &deleteLog, + } + tables := New(routes, access, reverse, grants) + + if err := tables.SweepPolicy(PolicySnapshot{}); !errors.Is(err, deleteFailure) { + t.Fatalf("first SweepPolicy error = %v, want %v", err, deleteFailure) + } + if _, ok := grants.entries[staleGrant]; !ok { + t.Fatal("failed grant deletion unexpectedly removed entry") + } + if len(routes.entries) != 0 || len(access.entries) != 0 { + t.Fatalf("independent stale deletions did not continue: routes=%d access=%d", + len(routes.entries), len(access.entries)) + } + if got, want := deleteLog, []string{"grant", "access", "route"}; !reflect.DeepEqual(got, want) { + t.Fatalf("deletion order = %v, want %v", got, want) + } + if err := tables.SweepPolicy(PolicySnapshot{}); err != nil { + t.Fatalf("retry SweepPolicy: %v", err) + } + if len(routes.entries) != 0 || len(access.entries) != 0 || len(grants.entries) != 0 { + t.Fatalf("stale state survived retry: routes=%d access=%d grants=%d", + len(routes.entries), len(access.entries), len(grants.entries)) + } + if _, ok := reverse.entries[reverseKey]; !ok || reverse.deletes != 0 { + t.Fatal("reverse state was not preserved across retry") + } +} diff --git a/internal/serviceroute/cleanup_test.go b/internal/serviceroute/cleanup_test.go new file mode 100644 index 00000000..dc70b703 --- /dev/null +++ b/internal/serviceroute/cleanup_test.go @@ -0,0 +1,59 @@ +//go:build linux + +// Copyright 2026 Datum Cloud, Inc. +// SPDX-License-Identifier: AGPL-3.0-or-later + +package serviceroute + +import ( + "errors" + "fmt" + "testing" + + "github.com/cilium/ebpf" +) + +func TestCleanupDrainsFailedApplyRollbackBeforeFinalize(t *testing.T) { + applyFailure := errors.New("route write failed") + rollbackFailure := errors.New("access rollback failed") + routes := &recordingTable{putErr: applyFailure} + access := &recordingTable{deleteErr: rollbackFailure} + programmer := testRouteProgrammer(routes, access, &recordingTable{}) + programmer.mapIDs = [7]ebpf.MapID{1, 2, 3, 4, 5, 6, 7} + programmer.mapIDsValid = true + programmer.readMapIDsFn = func(string) ([7]ebpf.MapID, error) { return programmer.mapIDs, nil } + programmer.targetIndexFn = func(name string) (uint32, error) { + switch name { + case testConsumerDeviceName: + return 10, nil + case testProducerDeviceName: + return 20, nil + default: + return 0, fmt.Errorf("unknown interface %q", name) + } + } + intent := localTestIntent() + + err := programmer.Apply(intent) + if !errors.Is(err, applyFailure) || !errors.Is(err, rollbackFailure) { + t.Fatalf("Apply error = %v, want joined apply and rollback failures", err) + } + if len(programmer.pendingRollbacks[intentKey(intent)]) == 0 || programmer.syncFailures != 1 { + t.Fatalf("pending rollback/failure count = %d/%d, want nonzero/1", + len(programmer.pendingRollbacks[intentKey(intent)]), programmer.syncFailures) + } + if err := programmer.Finalize(); err == nil { + t.Fatal("Finalize enabled policy with an unresolved Apply rollback") + } + + if err := programmer.Cleanup(intent); err != nil { + t.Fatalf("Cleanup: %v", err) + } + if len(programmer.pendingRollbacks) != 0 || programmer.syncFailures != 0 { + t.Fatalf("pending rollbacks/failure count after Cleanup = %d/%d, want 0/0", + len(programmer.pendingRollbacks), programmer.syncFailures) + } + if err := programmer.Finalize(); err != nil { + t.Fatalf("Finalize after successful Cleanup: %v", err) + } +} diff --git a/internal/serviceroute/dataplane.go b/internal/serviceroute/dataplane.go new file mode 100644 index 00000000..f22acb04 --- /dev/null +++ b/internal/serviceroute/dataplane.go @@ -0,0 +1,976 @@ +//go:build linux + +// Copyright 2026 Datum Cloud, Inc. +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package serviceroute + +import ( + "errors" + "fmt" + "net" + "path/filepath" + "reflect" + "sort" + "strings" + "sync" + + "github.com/cilium/ebpf" + "github.com/vishvananda/netlink" + + "go.datum.net/galactic/internal/plumbing/ebpf/attach" + "go.datum.net/galactic/internal/plumbing/ebpf/serviceroutemap" + api "go.datum.net/network/api/v1alpha1" +) + +type routeRef struct { + ingressIfindex uint32 + address string + targetIfindex uint32 + mode RouteIntentKind + protocol uint8 + port uint16 + grantID ServiceGrantID + targetSID [16]byte + consumerToken uint64 + producerToken uint64 +} + +type accessRef struct { + ingressIfindex uint32 + address string + protocol uint8 + port uint16 + attachmentToken uint64 + markerDirections uint8 +} + +type accessKey struct { + ingressIfindex uint32 + address string + protocol uint8 + port uint16 +} + +type accessState struct { + token uint64 + regular, request, reply int +} + +type denyRef struct { + ingressIfindex uint32 + address string + attachmentToken uint64 + markerDirections uint8 +} + +type denyKey struct { + ingressIfindex uint32 + address string +} + +type denyState struct { + token uint64 + request, reply int +} + +type appliedEntry struct { + route *routeRef + access *accessRef + grant *grantRef + deny *denyRef +} + +type grantRef struct { + producerIfindex uint32 + address string + protocol uint8 + port uint16 + grantID ServiceGrantID + consumerSID [16]byte + producerToken uint64 +} + +type routeState struct { + count int + ref routeRef +} + +// EBPFRouteProgrammer writes the complete private-service path into pinned +// TC-eBPF maps. It creates no Linux route: consumers continue to use only their +// ordinary default gateway, and usid_egress redirects authorized packets. +type EBPFRouteProgrammer struct { + PinDir string + + mu sync.Mutex + tables *serviceroutemap.Tables + mapHandles []*ebpf.Map + mapIDs [7]ebpf.MapID + mapIDsValid bool + routeRefs map[string]routeState + accessRefs map[accessKey]accessState + denyRefs map[denyKey]denyState + grantRefs map[grantRef]int + appliedRefs map[string][][]appliedEntry + desiredIntents map[string][]RouteIntent + pendingRollbacks map[string][]appliedEntry + policyFinalized bool + syncFailures int + failedMutations map[string]struct{} + generationIncomplete bool + + // These hooks keep map replacement and interface recreation tests + // deterministic without requiring a privileged kernel eBPF setup. + openMapSetFn func(string) (*serviceroutemap.Tables, []*ebpf.Map, [7]ebpf.MapID, error) + readMapIDsFn func(string) ([7]ebpf.MapID, error) + targetIndexFn func(string) (uint32, error) +} + +// Initialize opens the pinned maps without deleting state left by the running +// datapath. It also repairs entries whose interface names now resolve to new +// ifindexes. Apply initializes lazily so startup ordering remains tolerant. +func (p *EBPFRouteProgrammer) Initialize() error { + p.mu.Lock() + defer p.mu.Unlock() + + if err := p.ensureOpen(); err != nil { + return err + } + if p.policyFinalized { + return nil + } + if err := p.tables.SetPolicyEnabled(false); err != nil { + return err + } + p.syncFailures = 0 + p.failedMutations = make(map[string]struct{}) + return nil +} + +func (p *EBPFRouteProgrammer) Apply(intent RouteIntent) error { + p.mu.Lock() + defer p.mu.Unlock() + + if err := p.ensureOpen(); err != nil { + return err + } + entries, err := p.entries(intent) + if err != nil { + return err + } + + key := intentKey(intent) + if err := p.retryRollback(key); err != nil { + return fmt.Errorf("retry prior service route rollback: %w", err) + } + p.clearSyncFailure(key) + applied := make([]appliedEntry, 0, len(entries)) + for _, entry := range entries { + if err := p.acquire(entry); err != nil { + var rollbackErrs []error + for index := len(applied) - 1; index >= 0; index-- { + if rollbackErr := p.release(applied[index]); rollbackErr != nil { + p.pendingRollbacks[key] = append(p.pendingRollbacks[key], applied[index]) + rollbackErrs = append(rollbackErrs, rollbackErr) + } + } + if len(rollbackErrs) != 0 { + p.markSyncFailure(key) + } + return errors.Join(err, errors.Join(rollbackErrs...)) + } + applied = append(applied, entry) + } + p.appliedRefs[key] = append(p.appliedRefs[key], applied) + p.desiredIntents[key] = append(p.desiredIntents[key], cloneRouteIntent(intent)) + return nil +} + +func (p *EBPFRouteProgrammer) Remove(intent RouteIntent) error { + p.mu.Lock() + defer p.mu.Unlock() + + if err := p.ensureOpen(); err != nil { + return err + } + key := intentKey(intent) + if err := p.retryRollback(key); err != nil { + p.markSyncFailure(key) + return fmt.Errorf("retry prior service route rollback: %w", err) + } + sets := p.appliedRefs[key] + if len(sets) == 0 { + return nil + } + entries := sets[len(sets)-1] + remaining := make([]appliedEntry, 0, len(entries)) + var errs []error + for index := len(entries) - 1; index >= 0; index-- { + if err := p.release(entries[index]); err != nil { + errs = append(errs, err) + remaining = append(remaining, entries[index]) + } + } + if len(remaining) != 0 { + p.markSyncFailure(key) + sets[len(sets)-1] = remaining + p.appliedRefs[key] = sets + return errors.Join(errs...) + } + if len(sets) == 1 { + delete(p.appliedRefs, key) + delete(p.desiredIntents, key) + } else { + p.appliedRefs[key] = sets[:len(sets)-1] + desired := p.desiredIntents[key] + p.desiredIntents[key] = desired[:len(desired)-1] + } + p.clearSyncFailure(key) + return errors.Join(errs...) +} + +func (p *EBPFRouteProgrammer) Cleanup(intent RouteIntent) error { + p.mu.Lock() + defer p.mu.Unlock() + if err := p.ensureOpen(); err != nil { + return err + } + if err := p.retryRollback(intentKey(intent)); err != nil { + return fmt.Errorf("retry prior service route rollback: %w", err) + } + p.clearSyncFailure(intentKey(intent)) + return nil +} + +// Finalize removes policy entries that were not adopted from a complete +// cache-synchronized desired snapshot. It never removes reverse-flow state. +func (p *EBPFRouteProgrammer) Finalize() error { + p.mu.Lock() + defer p.mu.Unlock() + + if err := p.ensureOpen(); err != nil { + return err + } + return p.finalizeLocked() +} + +func (p *EBPFRouteProgrammer) finalizeLocked() error { + if p.syncFailures != 0 || len(p.pendingRollbacks) != 0 { + return fmt.Errorf("service route generation has %d failed mutations and %d pending rollbacks", + p.syncFailures, len(p.pendingRollbacks)) + } + snapshot := serviceroutemap.PolicySnapshot{ + Routes: make([]serviceroutemap.RouteIdentity, 0, len(p.routeRefs)), + Access: make([]serviceroutemap.AccessIdentity, 0, len(p.accessRefs)), + RemoteGrants: make([]serviceroutemap.RemoteGrantIdentity, 0, len(p.grantRefs)), + Deny: make([]serviceroutemap.DenyIdentity, 0, len(p.denyRefs)), + } + for _, state := range p.routeRefs { + if state.count == 0 { + continue + } + snapshot.Routes = append(snapshot.Routes, serviceroutemap.RouteIdentity{ + IngressIfindex: state.ref.ingressIfindex, + Address: net.ParseIP(state.ref.address), + Protocol: state.ref.protocol, + Port: state.ref.port, + }) + } + for access, state := range p.accessRefs { + if accessStateCount(state) == 0 { + continue + } + snapshot.Access = append(snapshot.Access, serviceroutemap.AccessIdentity{ + IngressIfindex: access.ingressIfindex, + Address: net.ParseIP(access.address), + Protocol: access.protocol, + Port: access.port, + }) + } + for deny, state := range p.denyRefs { + if denyStateCount(state) == 0 { + continue + } + snapshot.Deny = append(snapshot.Deny, serviceroutemap.DenyIdentity{ + IngressIfindex: deny.ingressIfindex, Address: net.ParseIP(deny.address), + }) + } + for grant, count := range p.grantRefs { + if count == 0 { + continue + } + snapshot.RemoteGrants = append(snapshot.RemoteGrants, serviceroutemap.RemoteGrantIdentity{ + ProducerIfindex: grant.producerIfindex, + Address: net.ParseIP(grant.address), + Protocol: grant.protocol, + Port: grant.port, + GrantID: [16]byte(grant.grantID), + }) + } + if err := p.tables.SweepPolicy(snapshot); err != nil { + return err + } + if err := p.tables.SetPolicyEnabled(true); err != nil { + return err + } + p.policyFinalized = true + return nil +} + +func (p *EBPFRouteProgrammer) ensureOpen() error { + if p.tables != nil { + changed, err := p.mapSetChanged() + if err != nil { + return err + } + if !changed { + return p.repairDesired() + } + p.closeHandles() + } + tables, handles, ids, err := p.openMapSet(p.pinDir()) + if err != nil { + return err + } + p.tables = tables + p.mapHandles = handles + p.mapIDs = ids + p.mapIDsValid = true + p.routeRefs = make(map[string]routeState) + p.accessRefs = make(map[accessKey]accessState) + p.denyRefs = make(map[denyKey]denyState) + p.grantRefs = make(map[grantRef]int) + p.appliedRefs = make(map[string][][]appliedEntry) + p.pendingRollbacks = make(map[string][]appliedEntry) + p.failedMutations = make(map[string]struct{}) + p.syncFailures = 0 + if p.desiredIntents == nil { + p.desiredIntents = make(map[string][]RouteIntent) + return nil + } + if err := p.rebuildDesired(); err != nil { + p.closeHandles() + return err + } + if p.policyFinalized { + return p.finalizeLocked() + } + return nil +} + +var serviceRouteMapNames = [...]string{ + "service_route_table", + "service_access_table", + "service_reverse_table", + "service_remote_grant_table", + "attachment_identity_table", + "service_policy_state_table", + "service_deny_table", +} + +func (p *EBPFRouteProgrammer) mapSetChanged() (bool, error) { + if !p.mapIDsValid { + return true, nil + } + ids, err := p.readMapIDs(p.pinDir()) + if err != nil { + return false, err + } + return ids != p.mapIDs, nil +} + +func (p *EBPFRouteProgrammer) openMapSet(pinDir string) (*serviceroutemap.Tables, []*ebpf.Map, [7]ebpf.MapID, error) { + if p.openMapSetFn != nil { + return p.openMapSetFn(pinDir) + } + tables, handles, err := serviceroutemap.OpenPinned(pinDir) + if err != nil { + return nil, nil, [7]ebpf.MapID{}, err + } + var ids [7]ebpf.MapID + for index, handle := range handles { + ids[index], err = kernelMapID(handle) + if err != nil { + for _, opened := range handles { + _ = opened.Close() + } + return nil, nil, [7]ebpf.MapID{}, err + } + } + return tables, handles, ids, nil +} + +func (p *EBPFRouteProgrammer) readMapIDs(pinDir string) ([7]ebpf.MapID, error) { + if p.readMapIDsFn != nil { + return p.readMapIDsFn(pinDir) + } + var ids [7]ebpf.MapID + for index, name := range serviceRouteMapNames { + current, err := ebpf.LoadPinnedMap(filepath.Join(pinDir, name), nil) + if err != nil { + return ids, fmt.Errorf("open current %s map: %w", name, err) + } + ids[index], err = kernelMapID(current) + _ = current.Close() + if err != nil { + return ids, err + } + } + return ids, nil +} + +func kernelMapID(m *ebpf.Map) (ebpf.MapID, error) { + info, err := m.Info() + if err != nil { + return 0, fmt.Errorf("read service route map info: %w", err) + } + id, ok := info.ID() + if !ok { + return 0, errors.New("kernel did not report service route map ID") + } + return id, nil +} + +func (p *EBPFRouteProgrammer) closeHandles() { + for _, handle := range p.mapHandles { + _ = handle.Close() + } + p.mapHandles = nil + p.tables = nil + p.mapIDs = [7]ebpf.MapID{} + p.mapIDsValid = false +} + +func (p *EBPFRouteProgrammer) rebuildDesired() error { + for _, key := range sortedIntentKeys(p.desiredIntents) { + for _, intent := range p.desiredIntents[key] { + entries, err := p.entries(intent) + if err != nil { + return fmt.Errorf("re-derive service route state %s: %w", key, err) + } + applied, err := p.acquireSet(entries) + if err != nil { + return fmt.Errorf("rebuild service route state %s: %w", key, err) + } + p.appliedRefs[key] = append(p.appliedRefs[key], applied) + } + } + return nil +} + +func (p *EBPFRouteProgrammer) repairDesired() error { + needsRepair := p.generationIncomplete + for _, key := range sortedIntentKeys(p.desiredIntents) { + intents := p.desiredIntents[key] + sets := p.appliedRefs[key] + if len(sets) != len(intents) { + needsRepair = true + continue + } + for index, intent := range intents { + fresh, err := p.entries(intent) + if err != nil { + return fmt.Errorf("re-derive service route state %s: %w", key, err) + } + if reflect.DeepEqual(sets[index], fresh) { + continue + } + needsRepair = true + } + } + if !needsRepair { + return nil + } + // Value-bearing refs can share a kernel key. Transitioning them one intent + // at a time lets a late release delete a value already rewritten with the + // new interface token. Rebuild one complete generation behind the closed + // gate instead. + if err := p.tables.SetPolicyEnabled(false); err != nil { + return err + } + p.policyFinalized = false + p.generationIncomplete = true + if err := p.tables.SweepPolicy(serviceroutemap.PolicySnapshot{}); err != nil { + return fmt.Errorf("clear stale service policy generation: %w", err) + } + p.routeRefs = make(map[string]routeState) + p.accessRefs = make(map[accessKey]accessState) + p.denyRefs = make(map[denyKey]denyState) + p.grantRefs = make(map[grantRef]int) + p.appliedRefs = make(map[string][][]appliedEntry) + p.pendingRollbacks = make(map[string][]appliedEntry) + p.failedMutations = make(map[string]struct{}) + p.syncFailures = 0 + if err := p.rebuildDesired(); err != nil { + p.markSyncFailure("__generation__") + return err + } + if err := p.finalizeLocked(); err != nil { + p.markSyncFailure("__generation__") + return err + } + p.generationIncomplete = false + return nil +} + +func (p *EBPFRouteProgrammer) markSyncFailure(key string) { + if p.failedMutations == nil { + p.failedMutations = make(map[string]struct{}) + } + if _, exists := p.failedMutations[key]; exists { + return + } + p.failedMutations[key] = struct{}{} + p.syncFailures++ +} + +func (p *EBPFRouteProgrammer) clearSyncFailure(key string) { + if _, exists := p.failedMutations[key]; !exists { + return + } + delete(p.failedMutations, key) + if p.syncFailures > 0 { + p.syncFailures-- + } +} + +func (p *EBPFRouteProgrammer) acquireSet(entries []appliedEntry) ([]appliedEntry, error) { + applied := make([]appliedEntry, 0, len(entries)) + for _, entry := range entries { + if err := p.acquire(entry); err != nil { + var rollbackErrs []error + for index := len(applied) - 1; index >= 0; index-- { + rollbackErrs = append(rollbackErrs, p.release(applied[index])) + } + return nil, errors.Join(err, errors.Join(rollbackErrs...)) + } + applied = append(applied, entry) + } + return applied, nil +} + +func sortedIntentKeys(intents map[string][]RouteIntent) []string { + keys := make([]string, 0, len(intents)) + for key := range intents { + keys = append(keys, key) + } + sort.Strings(keys) + return keys +} + +func (p *EBPFRouteProgrammer) retryRollback(key string) error { + entries := p.pendingRollbacks[key] + if len(entries) == 0 { + return nil + } + remaining := make([]appliedEntry, 0, len(entries)) + var errs []error + for _, entry := range entries { + if err := p.release(entry); err != nil { + remaining = append(remaining, entry) + errs = append(errs, err) + } + } + if len(remaining) == 0 { + delete(p.pendingRollbacks, key) + } else { + p.pendingRollbacks[key] = remaining + } + return errors.Join(errs...) +} + +func (p *EBPFRouteProgrammer) entries(intent RouteIntent) ([]appliedEntry, error) { + serviceIP := intent.Service.IP + entries := make([]appliedEntry, 0, 2*len(intent.Ports)+2) + if intent.Kind == RouteIntentRemoteProducer { + serviceIfindex, err := p.target(intent.ServiceDevice) + if err != nil { + return nil, fmt.Errorf("resolve service interface %q: %w", intent.ServiceDevice, err) + } + consumerSID, err := ipv6Bytes(intent.ConsumerSID) + if err != nil { + return nil, err + } + producerToken, err := p.tables.IdentityToken(serviceIfindex) + if err != nil { + return nil, err + } + // Producer-side source marker for fragments, whose missing transport + // tuple cannot be matched against an individual grant. + entries = append(entries, appliedEntry{access: &accessRef{ + ingressIfindex: serviceIfindex, address: serviceIP.String(), attachmentToken: producerToken, + markerDirections: serviceroutemap.MarkerReply, + }}) + entries = append(entries, appliedEntry{deny: &denyRef{ + ingressIfindex: serviceIfindex, address: serviceIP.String(), attachmentToken: producerToken, + markerDirections: serviceroutemap.MarkerReply, + }}) + for _, port := range intent.Ports { + protocol, err := protocolNumber(port.Protocol) + if err != nil { + return nil, err + } + ref := grantRef{producerIfindex: serviceIfindex, address: serviceIP.String(), protocol: protocol, + port: uint16(port.Port), grantID: intent.GrantID, consumerSID: consumerSID, + producerToken: producerToken} + entries = append(entries, appliedEntry{grant: &ref}) + } + return entries, nil + } + consumerIfindex, err := p.target(intent.ConsumerDevice) + if err != nil { + return nil, fmt.Errorf("resolve consumer interface %q: %w", intent.ConsumerDevice, err) + } + consumerToken, err := p.tables.IdentityToken(consumerIfindex) + if err != nil { + return nil, err + } + // A protocol/port-zero marker makes malformed or non-TCP/UDP traffic to a + // configured service fail closed without conflating distinct real tuples. + entries = append(entries, appliedEntry{access: &accessRef{ + ingressIfindex: consumerIfindex, address: serviceIP.String(), attachmentToken: consumerToken, + markerDirections: serviceroutemap.MarkerRequest, + }}) + entries = append(entries, appliedEntry{deny: &denyRef{ + ingressIfindex: consumerIfindex, address: serviceIP.String(), attachmentToken: consumerToken, + markerDirections: serviceroutemap.MarkerRequest, + }}) + var targetIfindex uint32 + var producerToken uint64 + var targetSID [16]byte + if intent.Kind == RouteIntentRemoteConsumer { + targetSID, err = ipv6Bytes(intent.ServiceSID) + if err != nil { + return nil, err + } + } else { + targetIfindex, err = p.target(intent.ServiceDevice) + if err != nil { + return nil, fmt.Errorf("resolve service interface %q: %w", intent.ServiceDevice, err) + } + producerToken, err = p.tables.IdentityToken(targetIfindex) + if err != nil { + return nil, err + } + // Local replies need the same address-only producer marker. Its + // refcount is independent from the consumer marker because ifindex is + // part of the key. + entries = append(entries, appliedEntry{access: &accessRef{ + ingressIfindex: targetIfindex, address: serviceIP.String(), attachmentToken: producerToken, + markerDirections: serviceroutemap.MarkerReply, + }}) + entries = append(entries, appliedEntry{deny: &denyRef{ + ingressIfindex: targetIfindex, address: serviceIP.String(), attachmentToken: producerToken, + markerDirections: serviceroutemap.MarkerReply, + }}) + } + for _, port := range intent.Ports { + protocol, err := protocolNumber(port.Protocol) + if err != nil { + return nil, err + } + entries = append(entries, appliedEntry{access: &accessRef{ + ingressIfindex: consumerIfindex, address: serviceIP.String(), protocol: protocol, port: uint16(port.Port), + attachmentToken: consumerToken, + }}) + ref := routeRef{ingressIfindex: consumerIfindex, address: serviceIP.String(), mode: intent.Kind, + protocol: protocol, port: uint16(port.Port), grantID: intent.GrantID, + targetIfindex: targetIfindex, targetSID: targetSID, consumerToken: consumerToken, + producerToken: producerToken} + entries = append(entries, appliedEntry{route: &ref}) + } + return entries, nil +} + +func ipv6Bytes(ip net.IP) ([16]byte, error) { + var out [16]byte + if ip == nil || ip.To4() != nil || ip.To16() == nil { + return out, fmt.Errorf("service route SID %q is not IPv6", ip) + } + copy(out[:], ip.To16()) + return out, nil +} + +func (p *EBPFRouteProgrammer) target(name string) (uint32, error) { + if p.targetIndexFn != nil { + return p.targetIndexFn(name) + } + link, err := netlink.LinkByName(name) + if err != nil { + return 0, err + } + return uint32(link.Attrs().Index), nil +} + +func (p *EBPFRouteProgrammer) acquire(entry appliedEntry) error { + if entry.grant != nil { + if p.grantRefs[*entry.grant] == 0 { + g := entry.grant + if err := p.tables.RegisterRemoteGrant(g.producerIfindex, net.ParseIP(g.address), g.protocol, g.port, + [16]byte(g.grantID), net.IP(g.consumerSID[:]), g.producerToken); err != nil { + return err + } + } + p.grantRefs[*entry.grant]++ + return nil + } + if entry.access != nil { + key := accessRefKey(*entry.access) + state := p.accessRefs[key] + if accessStateCount(state) != 0 && state.token != entry.access.attachmentToken { + return fmt.Errorf("service access %v has conflicting attachment tokens", key) + } + state.token = entry.access.attachmentToken + incrementAccessState(&state, entry.access.markerDirections) + if err := p.tables.RegisterAccess(key.ingressIfindex, net.ParseIP(key.address), key.protocol, key.port, + state.token, accessStateDirections(state)); err != nil { + return err + } + p.accessRefs[key] = state + return nil + } + if entry.deny != nil { + key := denyRefKey(*entry.deny) + state := p.denyRefs[key] + if denyStateCount(state) != 0 && state.token != entry.deny.attachmentToken { + return fmt.Errorf("service deny %v has conflicting attachment tokens", key) + } + state.token = entry.deny.attachmentToken + incrementDenyState(&state, entry.deny.markerDirections) + if err := p.tables.RegisterDeny(key.ingressIfindex, net.ParseIP(key.address), state.token, + denyStateDirections(state)); err != nil { + return err + } + p.denyRefs[key] = state + return nil + } + + key := routeRefKey(*entry.route) + state, ok := p.routeRefs[key] + if ok { + if state.ref != *entry.route { + return fmt.Errorf("service route %s has conflicting redirect targets", key) + } + state.count++ + p.routeRefs[key] = state + return nil + } + var err error + if entry.route.mode == RouteIntentRemoteConsumer { + err = p.tables.RegisterRemoteRoute(entry.route.ingressIfindex, net.ParseIP(entry.route.address), + [16]byte(entry.route.grantID), entry.route.protocol, entry.route.port, net.IP(entry.route.targetSID[:]), + entry.route.consumerToken) + } else { + err = p.tables.RegisterRoute(entry.route.ingressIfindex, net.ParseIP(entry.route.address), + entry.route.protocol, entry.route.port, entry.route.targetIfindex, + entry.route.consumerToken, entry.route.producerToken) + } + if err != nil { + return err + } + p.routeRefs[key] = routeState{count: 1, ref: *entry.route} + return nil +} + +func (p *EBPFRouteProgrammer) release(entry appliedEntry) error { + if entry.grant != nil { + count := p.grantRefs[*entry.grant] + if count > 1 { + p.grantRefs[*entry.grant] = count - 1 + return nil + } + g := entry.grant + if err := p.tables.UnregisterRemoteGrant( + g.producerIfindex, net.ParseIP(g.address), g.protocol, g.port, [16]byte(g.grantID), + ); err != nil { + return err + } + delete(p.grantRefs, *entry.grant) + return nil + } + if entry.access != nil { + key := accessRefKey(*entry.access) + state, ok := p.accessRefs[key] + if !ok { + return nil + } + decrementAccessState(&state, entry.access.markerDirections) + if accessStateCount(state) == 0 { + if err := p.tables.UnregisterAccess( + key.ingressIfindex, net.ParseIP(key.address), key.protocol, key.port, + ); err != nil { + return err + } + delete(p.accessRefs, key) + return nil + } + if err := p.tables.RegisterAccess(key.ingressIfindex, net.ParseIP(key.address), key.protocol, key.port, + state.token, accessStateDirections(state)); err != nil { + return err + } + p.accessRefs[key] = state + return nil + } + if entry.deny != nil { + key := denyRefKey(*entry.deny) + state, ok := p.denyRefs[key] + if !ok { + return nil + } + decrementDenyState(&state, entry.deny.markerDirections) + if denyStateCount(state) == 0 { + if err := p.tables.UnregisterDeny(key.ingressIfindex, net.ParseIP(key.address)); err != nil { + return err + } + delete(p.denyRefs, key) + return nil + } + if err := p.tables.RegisterDeny(key.ingressIfindex, net.ParseIP(key.address), state.token, + denyStateDirections(state)); err != nil { + return err + } + p.denyRefs[key] = state + return nil + } + + key := routeRefKey(*entry.route) + state, ok := p.routeRefs[key] + if !ok { + return nil + } + if state.count > 1 { + state.count-- + p.routeRefs[key] = state + return nil + } + if err := p.tables.UnregisterRoute(entry.route.ingressIfindex, net.ParseIP(entry.route.address), + entry.route.protocol, entry.route.port); err != nil { + return err + } + delete(p.routeRefs, key) + return nil +} + +func protocolNumber(protocol api.NetworkRuleProtocol) (uint8, error) { + switch protocol { + case api.NetworkRuleProtocolTCP: + return serviceroutemap.ProtocolTCP, nil + case api.NetworkRuleProtocolUDP: + return serviceroutemap.ProtocolUDP, nil + default: + return 0, fmt.Errorf("unsupported service route protocol %q", protocol) + } +} + +func accessRefKey(ref accessRef) accessKey { + return accessKey{ingressIfindex: ref.ingressIfindex, address: ref.address, protocol: ref.protocol, port: ref.port} +} + +func accessStateCount(state accessState) int { return state.regular + state.request + state.reply } + +func accessStateDirections(state accessState) uint8 { + var directions uint8 + if state.request != 0 { + directions |= serviceroutemap.MarkerRequest + } + if state.reply != 0 { + directions |= serviceroutemap.MarkerReply + } + return directions +} + +func incrementAccessState(state *accessState, directions uint8) { + if directions == 0 { + state.regular++ + } + if directions&serviceroutemap.MarkerRequest != 0 { + state.request++ + } + if directions&serviceroutemap.MarkerReply != 0 { + state.reply++ + } +} + +func decrementAccessState(state *accessState, directions uint8) { + if directions == 0 && state.regular > 0 { + state.regular-- + } + if directions&serviceroutemap.MarkerRequest != 0 && state.request > 0 { + state.request-- + } + if directions&serviceroutemap.MarkerReply != 0 && state.reply > 0 { + state.reply-- + } +} + +func denyRefKey(ref denyRef) denyKey { + return denyKey{ingressIfindex: ref.ingressIfindex, address: ref.address} +} + +func denyStateCount(state denyState) int { return state.request + state.reply } + +func denyStateDirections(state denyState) uint8 { + var directions uint8 + if state.request != 0 { + directions |= serviceroutemap.MarkerRequest + } + if state.reply != 0 { + directions |= serviceroutemap.MarkerReply + } + return directions +} + +func incrementDenyState(state *denyState, directions uint8) { + if directions&serviceroutemap.MarkerRequest != 0 { + state.request++ + } + if directions&serviceroutemap.MarkerReply != 0 { + state.reply++ + } +} + +func decrementDenyState(state *denyState, directions uint8) { + if directions&serviceroutemap.MarkerRequest != 0 && state.request > 0 { + state.request-- + } + if directions&serviceroutemap.MarkerReply != 0 && state.reply > 0 { + state.reply-- + } +} + +func routeRefKey(ref routeRef) string { + return fmt.Sprintf("%d|%s|%d|%d", ref.ingressIfindex, ref.address, ref.protocol, ref.port) +} + +func intentKey(intent RouteIntent) string { + var builder strings.Builder + fmt.Fprintf(&builder, "%s|%s|%s|%s|%s|%s|%s|%s|%x", + intent.Attachment, intent.ProducerAttachment, intent.Kind, intent.Service, intent.ConsumerDevice, + intent.ServiceDevice, intent.ServiceSID, intent.ConsumerSID, intent.GrantID) + for _, port := range intent.Ports { + fmt.Fprintf(&builder, "|%s:%d", port.Protocol, port.Port) + } + return builder.String() +} + +func cloneRouteIntent(intent RouteIntent) RouteIntent { + cloned := intent + if intent.Service != nil { + cloned.Service = &net.IPNet{ + IP: append(net.IP(nil), intent.Service.IP...), + Mask: append(net.IPMask(nil), intent.Service.Mask...), + } + } + cloned.ServiceSID = append(net.IP(nil), intent.ServiceSID...) + cloned.ConsumerSID = append(net.IP(nil), intent.ConsumerSID...) + cloned.Ports = append([]api.ServiceRouteProtocolPort(nil), intent.Ports...) + return cloned +} + +func (p *EBPFRouteProgrammer) pinDir() string { + if p.PinDir != "" { + return p.PinDir + } + return attach.PinDir +} diff --git a/internal/serviceroute/dataplane_stub.go b/internal/serviceroute/dataplane_stub.go new file mode 100644 index 00000000..458386d9 --- /dev/null +++ b/internal/serviceroute/dataplane_stub.go @@ -0,0 +1,36 @@ +//go:build !linux + +// Copyright 2026 Datum Cloud, Inc. +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package serviceroute + +import "errors" + +// EBPFRouteProgrammer is unavailable outside Linux. The stub keeps API and +// planner packages testable on developer workstations; CI and production use +// the Linux implementation. +type EBPFRouteProgrammer struct { + PinDir string +} + +func (*EBPFRouteProgrammer) Initialize() error { + return errors.New("service route dataplane programming requires linux") +} + +func (*EBPFRouteProgrammer) Apply(RouteIntent) error { + return errors.New("service route dataplane programming requires linux") +} + +func (*EBPFRouteProgrammer) Remove(RouteIntent) error { + return errors.New("service route dataplane programming requires linux") +} + +func (*EBPFRouteProgrammer) Cleanup(RouteIntent) error { + return errors.New("service route dataplane programming requires linux") +} + +func (*EBPFRouteProgrammer) Finalize() error { + return errors.New("service route dataplane programming requires linux") +} diff --git a/internal/serviceroute/dataplane_test.go b/internal/serviceroute/dataplane_test.go new file mode 100644 index 00000000..5afc747b --- /dev/null +++ b/internal/serviceroute/dataplane_test.go @@ -0,0 +1,601 @@ +//go:build linux + +// Copyright 2026 Datum Cloud, Inc. +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +package serviceroute + +import ( + "errors" + "fmt" + "net" + "reflect" + "testing" + + "github.com/cilium/ebpf" + + "go.datum.net/galactic/internal/plumbing/ebpf/serviceroutemap" + "go.datum.net/galactic/internal/plumbing/ebpf/usidmap" + api "go.datum.net/network/api/v1alpha1" +) + +type recordingTable struct { + puts, deletes int + putErr error + deleteErr error + iterateCount int + identityTokens map[uint32]uint64 + lastValue any +} + +const ( + testRemoteServiceAddress = "10.0.0.53" + testConsumerDeviceName = "consumer0" + testProducerDeviceName = "producer0" +) + +func (t *recordingTable) Put(_ any, value any) error { + t.puts++ + t.lastValue = value + return t.putErr +} + +func TestAccessMarkersAggregateDirectionsByKernelKey(t *testing.T) { + access := &recordingTable{} + programmer := testRouteProgrammer(&recordingTable{}, access, &recordingTable{}) + request := appliedEntry{access: &accessRef{ingressIfindex: 10, address: "10.0.0.53", attachmentToken: 100, + markerDirections: serviceroutemap.MarkerRequest}} + reply := appliedEntry{access: &accessRef{ingressIfindex: 10, address: "10.0.0.53", attachmentToken: 100, + markerDirections: serviceroutemap.MarkerReply}} + if err := programmer.acquire(request); err != nil { + t.Fatal(err) + } + if err := programmer.acquire(reply); err != nil { + t.Fatal(err) + } + if got := reflect.ValueOf(access.lastValue).FieldByName("MarkerDirections").Uint(); got != 3 { + t.Fatalf("aggregated directions = %d, want request|reply", got) + } + if err := programmer.release(request); err != nil { + t.Fatal(err) + } + if access.deletes != 0 { + t.Fatal("shared marker deleted while reply owner remains") + } + if got := reflect.ValueOf(access.lastValue).FieldByName("MarkerDirections").Uint(); got != 2 { + t.Fatalf("directions after request release = %d, want reply", got) + } + if err := programmer.release(reply); err != nil { + t.Fatal(err) + } + if access.deletes != 1 { + t.Fatalf("final marker deletes = %d, want 1", access.deletes) + } +} + +func (t *recordingTable) Lookup(key, value any) error { + if t.identityTokens != nil { + ifindex, ok := key.(uint32) + out, valueOK := value.(*uint64) + if ok && valueOK { + token, found := t.identityTokens[ifindex] + if found { + *out = token + return nil + } + } + } + return ebpf.ErrKeyNotExist +} + +func (t *recordingTable) Delete(any) error { + t.deletes++ + if t.deleteErr != nil { + err := t.deleteErr + t.deleteErr = nil + return err + } + return nil +} + +func (t *recordingTable) Iterate() usidmap.Iterator { + return &recordingIterator{remaining: t.iterateCount} +} + +type recordingIterator struct{ remaining int } + +func (i *recordingIterator) Next(any, any) bool { + if i.remaining == 0 { + return false + } + i.remaining-- + return true +} +func (*recordingIterator) Err() error { return nil } + +func testRouteProgrammer(routes, access, grants *recordingTable) *EBPFRouteProgrammer { + identities := &recordingTable{identityTokens: map[uint32]uint64{10: 100, 11: 110, 20: 200, 42: 420, 101: 1010}} + policyState := &recordingTable{} + return &EBPFRouteProgrammer{ + tables: serviceroutemap.New( + routes, access, &recordingTable{}, grants, identities, policyState, &recordingTable{}, + ), + routeRefs: make(map[string]routeState), + accessRefs: make(map[accessKey]accessState), + denyRefs: make(map[denyKey]denyState), + grantRefs: make(map[grantRef]int), + appliedRefs: make(map[string][][]appliedEntry), + desiredIntents: make(map[string][]RouteIntent), + pendingRollbacks: make(map[string][]appliedEntry), + } +} + +func localTestIntent() RouteIntent { + _, service, _ := net.ParseCIDR("10.0.0.53/32") + return RouteIntent{ + Kind: RouteIntentLocal, + Service: service, + ConsumerDevice: testConsumerDeviceName, + ServiceDevice: testProducerDeviceName, + Ports: []api.ServiceRouteProtocolPort{{ + Protocol: api.NetworkRuleProtocolTCP, + Port: 443, + }}, + } +} + +func TestRemoteConsumerEntriesAcquireAndRevokeRouteAndAccess(t *testing.T) { + routes, access, grants := &recordingTable{}, &recordingTable{}, &recordingTable{} + programmer := testRouteProgrammer(routes, access, grants) + accessEntry := appliedEntry{access: &accessRef{ + ingressIfindex: 42, address: testRemoteServiceAddress, protocol: serviceroutemap.ProtocolTCP, port: 443, + }} + routeEntry := appliedEntry{route: &routeRef{ + ingressIfindex: 42, address: testRemoteServiceAddress, mode: RouteIntentRemoteConsumer, + protocol: serviceroutemap.ProtocolTCP, port: 443, grantID: ServiceGrantID{1}, + targetSID: [16]byte{0xfd, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2}, + }} + + if err := programmer.acquire(accessEntry); err != nil { + t.Fatalf("acquire access: %v", err) + } + if err := programmer.acquire(routeEntry); err != nil { + t.Fatalf("acquire route: %v", err) + } + if routes.puts != 1 || access.puts != 1 || grants.puts != 0 { + t.Fatalf("map puts routes/access/grants = %d/%d/%d, want 1/1/0", routes.puts, access.puts, grants.puts) + } + + if err := programmer.release(routeEntry); err != nil { + t.Fatalf("release route: %v", err) + } + if err := programmer.release(accessEntry); err != nil { + t.Fatalf("release access: %v", err) + } + if routes.deletes != 1 || access.deletes != 1 || len(programmer.routeRefs) != 0 || len(programmer.accessRefs) != 0 { + t.Fatalf("revocation incomplete: deletes=%d/%d refs=%d/%d", + routes.deletes, access.deletes, len(programmer.routeRefs), len(programmer.accessRefs)) + } +} + +func TestRemoteConsumerRollbackRevokesAcquiredAccess(t *testing.T) { + routeFailure := errors.New("route map write failed") + routes, access := &recordingTable{putErr: routeFailure}, &recordingTable{} + programmer := testRouteProgrammer(routes, access, &recordingTable{}) + accessEntry := appliedEntry{access: &accessRef{ + ingressIfindex: 42, address: testRemoteServiceAddress, protocol: serviceroutemap.ProtocolUDP, port: 53, + }} + routeEntry := appliedEntry{route: &routeRef{ + ingressIfindex: 42, address: testRemoteServiceAddress, mode: RouteIntentRemoteConsumer, + protocol: serviceroutemap.ProtocolUDP, port: 53, + targetSID: [16]byte{0xfd, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2}, + }} + + if err := programmer.acquire(accessEntry); err != nil { + t.Fatalf("acquire access: %v", err) + } + if err := programmer.acquire(routeEntry); !errors.Is(err, routeFailure) { + t.Fatalf("acquire route error = %v, want %v", err, routeFailure) + } + if err := programmer.release(accessEntry); err != nil { + t.Fatalf("rollback access: %v", err) + } + if access.deletes != 1 || len(programmer.accessRefs) != 0 || len(programmer.routeRefs) != 0 { + t.Fatalf("rollback incomplete: access deletes=%d access refs=%d route refs=%d", + access.deletes, len(programmer.accessRefs), len(programmer.routeRefs)) + } +} + +func TestRemoteProducerGrantRefcountAndRevocationRetry(t *testing.T) { + revokeFailure := errors.New("grant delete failed") + grants := &recordingTable{deleteErr: revokeFailure} + programmer := testRouteProgrammer(&recordingTable{}, &recordingTable{}, grants) + grant := grantRef{ + producerIfindex: 101, address: "fd20::53", protocol: serviceroutemap.ProtocolTCP, port: 443, + grantID: ServiceGrantID{9}, consumerSID: [16]byte{0xfd, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x42}, + } + entry := appliedEntry{grant: &grant} + + if err := programmer.acquire(entry); err != nil { + t.Fatalf("first acquire: %v", err) + } + if err := programmer.acquire(entry); err != nil { + t.Fatalf("second acquire: %v", err) + } + if grants.puts != 1 || programmer.grantRefs[grant] != 2 { + t.Fatalf("grant acquisition puts/count = %d/%d, want 1/2", grants.puts, programmer.grantRefs[grant]) + } + if err := programmer.release(entry); err != nil { + t.Fatalf("first release: %v", err) + } + if grants.deletes != 0 || programmer.grantRefs[grant] != 1 { + t.Fatalf("first release deletes/count = %d/%d, want 0/1", grants.deletes, programmer.grantRefs[grant]) + } + if err := programmer.release(entry); !errors.Is(err, revokeFailure) { + t.Fatalf("failed revocation error = %v, want %v", err, revokeFailure) + } + if programmer.grantRefs[grant] != 1 { + t.Fatalf("failed revocation dropped grant refcount: %d", programmer.grantRefs[grant]) + } + if err := programmer.release(entry); err != nil { + t.Fatalf("retry revocation: %v", err) + } + if grants.deletes != 2 { + t.Fatalf("grant delete attempts = %d, want 2", grants.deletes) + } + if _, ok := programmer.grantRefs[grant]; ok { + t.Fatal("grant remains tracked after successful revocation retry") + } +} + +func TestIPv6BytesRejectsIPv4SID(t *testing.T) { + if _, err := ipv6Bytes(net.ParseIP("192.0.2.1")); err == nil { + t.Fatal("ipv6Bytes accepted an IPv4 SID") + } +} + +func TestInitializePreservesPinnedState(t *testing.T) { + routes := &recordingTable{iterateCount: 1} + access := &recordingTable{iterateCount: 1} + reverse := &recordingTable{iterateCount: 1} + grants := &recordingTable{iterateCount: 1} + programmer := &EBPFRouteProgrammer{ + openMapSetFn: func(string) (*serviceroutemap.Tables, []*ebpf.Map, [7]ebpf.MapID, error) { + return serviceroutemap.New(routes, access, reverse, grants, + &recordingTable{identityTokens: map[uint32]uint64{}}, &recordingTable{}, &recordingTable{}), nil, + [7]ebpf.MapID{1, 2, 3, 4, 5, 6}, nil + }, + } + + if err := programmer.Initialize(); err != nil { + t.Fatalf("Initialize: %v", err) + } + if routes.deletes+access.deletes+reverse.deletes+grants.deletes != 0 { + t.Fatalf("Initialize deleted pinned state: routes/access/reverse/grants=%d/%d/%d/%d", + routes.deletes, access.deletes, reverse.deletes, grants.deletes) + } +} + +func TestInitializeDoesNotDisableFinalizedPolicy(t *testing.T) { + policyState := &recordingTable{} + ids := [7]ebpf.MapID{1, 2, 3, 4, 5, 6} + programmer := &EBPFRouteProgrammer{ + tables: serviceroutemap.New( + &recordingTable{}, &recordingTable{}, &recordingTable{}, &recordingTable{}, + &recordingTable{identityTokens: map[uint32]uint64{}}, policyState, &recordingTable{}, + ), + mapIDs: ids, + mapIDsValid: true, + routeRefs: make(map[string]routeState), + accessRefs: make(map[accessKey]accessState), + denyRefs: make(map[denyKey]denyState), + grantRefs: make(map[grantRef]int), + appliedRefs: make(map[string][][]appliedEntry), + desiredIntents: make(map[string][]RouteIntent), + pendingRollbacks: make(map[string][]appliedEntry), + readMapIDsFn: func(string) ([7]ebpf.MapID, error) { return ids, nil }, + } + if err := programmer.Initialize(); err != nil { + t.Fatalf("first Initialize: %v", err) + } + if err := programmer.Finalize(); err != nil { + t.Fatalf("Finalize: %v", err) + } + if err := programmer.Initialize(); err != nil { + t.Fatalf("periodic Initialize: %v", err) + } + if policyState.puts != 2 { + t.Fatalf("policy-state writes = %d, want disable+enable only", policyState.puts) + } +} + +func TestInitializeRepairsRecreatedInterface(t *testing.T) { + routes, access, grants := &recordingTable{}, &recordingTable{}, &recordingTable{} + programmer := testRouteProgrammer(routes, access, grants) + programmer.mapIDs = [7]ebpf.MapID{1, 2, 3, 4, 5, 6} + programmer.mapIDsValid = true + programmer.readMapIDsFn = func(string) ([7]ebpf.MapID, error) { return programmer.mapIDs, nil } + consumerIndex := uint32(10) + programmer.targetIndexFn = func(name string) (uint32, error) { + switch name { + case testConsumerDeviceName: + return consumerIndex, nil + case testProducerDeviceName: + return 20, nil + default: + return 0, fmt.Errorf("unknown interface %q", name) + } + } + intent := localTestIntent() + if err := programmer.Apply(intent); err != nil { + t.Fatalf("Apply: %v", err) + } + consumerIndex = 11 + if err := programmer.Initialize(); err != nil { + t.Fatalf("repair Initialize: %v", err) + } + + sets := programmer.appliedRefs[intentKey(intent)] + if len(sets) != 1 || sets[0][0].access == nil || sets[0][0].access.ingressIfindex != 11 { + t.Fatalf("repaired entries = %#v, want consumer ifindex 11", sets) + } + if routes.deletes != 0 || routes.puts != 2 { + t.Fatalf("route deletes/puts = %d/%d, want 0/2 (fake iterator has no persisted rows)", routes.deletes, routes.puts) + } + if access.deletes != 0 || access.puts != 6 { + t.Fatalf("access deletes/puts = %d/%d, want 0/6", access.deletes, access.puts) + } +} + +func TestInitializeRepairsSharedRefsAsCompleteGeneration(t *testing.T) { + routes, access, grants := &recordingTable{}, &recordingTable{}, &recordingTable{} + programmer := testRouteProgrammer(routes, access, grants) + programmer.mapIDs = [7]ebpf.MapID{1, 2, 3, 4, 5, 6} + programmer.mapIDsValid = true + programmer.readMapIDsFn = func(string) ([7]ebpf.MapID, error) { return programmer.mapIDs, nil } + consumerIndex := uint32(10) + programmer.targetIndexFn = func(name string) (uint32, error) { + switch name { + case testConsumerDeviceName: + return consumerIndex, nil + case testProducerDeviceName: + return 20, nil + default: + return 0, fmt.Errorf("unknown interface %q", name) + } + } + intent := localTestIntent() + if err := programmer.Apply(intent); err != nil { + t.Fatalf("first Apply: %v", err) + } + if err := programmer.Apply(intent); err != nil { + t.Fatalf("duplicate Apply: %v", err) + } + + consumerIndex = 11 + if err := programmer.Initialize(); err != nil { + t.Fatalf("repair Initialize: %v", err) + } + + sets := programmer.appliedRefs[intentKey(intent)] + if len(sets) != 2 { + t.Fatalf("repaired applied sets = %d, want 2", len(sets)) + } + if len(programmer.routeRefs) != 1 { + t.Fatalf("repaired route keys = %d, want 1", len(programmer.routeRefs)) + } + for _, state := range programmer.routeRefs { + if state.count != 2 || state.ref.ingressIfindex != 11 { + t.Fatalf("repaired shared route = %#v, want count 2 on ifindex 11", state) + } + } + for key, state := range programmer.accessRefs { + if key.ingressIfindex == 10 { + t.Fatalf("stale consumer access key survived generation repair: %#v", key) + } + if key.ingressIfindex == 11 && accessStateCount(state) != 2 { + t.Fatalf("shared consumer access refs = %d, want 2", accessStateCount(state)) + } + } +} + +func TestInitializeRetriesIncompleteGenerationRebuild(t *testing.T) { + routes, access, grants := &recordingTable{}, &recordingTable{}, &recordingTable{} + programmer := testRouteProgrammer(routes, access, grants) + programmer.mapIDs = [7]ebpf.MapID{1, 2, 3, 4, 5, 6} + programmer.mapIDsValid = true + programmer.readMapIDsFn = func(string) ([7]ebpf.MapID, error) { return programmer.mapIDs, nil } + consumerIndex := uint32(10) + programmer.targetIndexFn = func(name string) (uint32, error) { + if name == testConsumerDeviceName { + return consumerIndex, nil + } + return 20, nil + } + intent := localTestIntent() + if err := programmer.Apply(intent); err != nil { + t.Fatal(err) + } + consumerIndex = 11 + routes.putErr = errors.New("transient rebuild failure") + if err := programmer.Initialize(); err == nil { + t.Fatal("Initialize succeeded despite injected rebuild failure") + } + if !programmer.generationIncomplete { + t.Fatal("failed rebuild was not marked incomplete") + } + routes.putErr = nil + if err := programmer.Initialize(); err != nil { + t.Fatalf("retry Initialize: %v", err) + } + if programmer.generationIncomplete { + t.Fatal("successful retry left generation incomplete") + } + if got := len(programmer.appliedRefs[intentKey(intent)]); got != 1 { + t.Fatalf("rebuilt applied sets = %d, want 1", got) + } +} + +func TestRemoveRetryClearsSyncFailure(t *testing.T) { + routes := &recordingTable{deleteErr: errors.New("transient delete failure")} + programmer := testRouteProgrammer(routes, &recordingTable{}, &recordingTable{}) + programmer.mapIDs = [7]ebpf.MapID{1, 2, 3, 4, 5, 6} + programmer.mapIDsValid = true + programmer.readMapIDsFn = func(string) ([7]ebpf.MapID, error) { return programmer.mapIDs, nil } + programmer.targetIndexFn = func(name string) (uint32, error) { + if name == testConsumerDeviceName { + return 10, nil + } + return 20, nil + } + intent := localTestIntent() + if err := programmer.Apply(intent); err != nil { + t.Fatal(err) + } + if err := programmer.Remove(intent); err == nil { + t.Fatal("Remove succeeded despite injected delete failure") + } + if programmer.syncFailures != 1 { + t.Fatalf("sync failures = %d, want 1", programmer.syncFailures) + } + if err := programmer.Remove(intent); err != nil { + t.Fatalf("retry Remove: %v", err) + } + if programmer.syncFailures != 0 { + t.Fatalf("sync failures after retry = %d, want 0", programmer.syncFailures) + } +} + +func TestMapReplacementClearsStaleMutationFailure(t *testing.T) { + routes := &recordingTable{deleteErr: errors.New("transient delete failure")} + programmer := testRouteProgrammer(routes, &recordingTable{}, &recordingTable{}) + oldIDs := [7]ebpf.MapID{1, 2, 3, 4, 5, 6, 7} + newIDs := [7]ebpf.MapID{11, 12, 13, 14, 15, 16, 17} + currentIDs := oldIDs + programmer.mapIDs = oldIDs + programmer.mapIDsValid = true + programmer.readMapIDsFn = func(string) ([7]ebpf.MapID, error) { return currentIDs, nil } + programmer.targetIndexFn = func(name string) (uint32, error) { + if name == testConsumerDeviceName { + return 10, nil + } + return 20, nil + } + intent := localTestIntent() + if err := programmer.Apply(intent); err != nil { + t.Fatal(err) + } + if err := programmer.Finalize(); err != nil { + t.Fatal(err) + } + if err := programmer.Remove(intent); err == nil { + t.Fatal("Remove succeeded despite injected delete failure") + } + if programmer.syncFailures != 1 { + t.Fatalf("sync failures = %d, want 1", programmer.syncFailures) + } + + newRoutes := &recordingTable{} + newPolicyState := &recordingTable{} + currentIDs = newIDs + programmer.openMapSetFn = func(string) (*serviceroutemap.Tables, []*ebpf.Map, [7]ebpf.MapID, error) { + return serviceroutemap.New(newRoutes, &recordingTable{}, &recordingTable{}, &recordingTable{}, + &recordingTable{identityTokens: map[uint32]uint64{10: 100, 20: 200}}, newPolicyState, + &recordingTable{}), nil, newIDs, nil + } + if err := programmer.Initialize(); err != nil { + t.Fatalf("Initialize after map replacement: %v", err) + } + if programmer.syncFailures != 0 || len(programmer.failedMutations) != 0 { + t.Fatalf("stale mutation failures survived replacement: count=%d keys=%d", + programmer.syncFailures, len(programmer.failedMutations)) + } + if newRoutes.puts != 1 { + t.Fatalf("rebuilt route puts = %d, want 1", newRoutes.puts) + } + if newPolicyState.puts != 1 { + t.Fatalf("policy-state writes = %d, want finalized enable", newPolicyState.puts) + } +} + +func TestInitializeReopensAllMapsWhenGrantMapChanges(t *testing.T) { + oldRoutes, oldAccess, oldGrants := &recordingTable{}, &recordingTable{}, &recordingTable{} + programmer := testRouteProgrammer(oldRoutes, oldAccess, oldGrants) + programmer.mapIDs = [7]ebpf.MapID{1, 2, 3, 4, 5, 6} + programmer.mapIDsValid = true + currentIDs := programmer.mapIDs + programmer.readMapIDsFn = func(string) ([7]ebpf.MapID, error) { return currentIDs, nil } + programmer.targetIndexFn = func(name string) (uint32, error) { + if name == testConsumerDeviceName { + return 10, nil + } + return 20, nil + } + intent := localTestIntent() + if err := programmer.Apply(intent); err != nil { + t.Fatalf("Apply: %v", err) + } + + newRoutes, newAccess, newGrants := &recordingTable{}, &recordingTable{}, &recordingTable{} + openCalls := 0 + currentIDs[3] = 99 + programmer.openMapSetFn = func(string) (*serviceroutemap.Tables, []*ebpf.Map, [7]ebpf.MapID, error) { + openCalls++ + return serviceroutemap.New( + newRoutes, newAccess, &recordingTable{}, newGrants, + &recordingTable{identityTokens: map[uint32]uint64{10: 100, 20: 200}}, + &recordingTable{}, &recordingTable{}, + ), nil, currentIDs, nil + } + if err := programmer.Initialize(); err != nil { + t.Fatalf("Initialize after grant map replacement: %v", err) + } + if openCalls != 1 { + t.Fatalf("map-set open calls = %d, want 1", openCalls) + } + if newRoutes.puts != 1 || newAccess.puts != 3 { + t.Fatalf("rebuilt route/access puts = %d/%d, want 1/3", newRoutes.puts, newAccess.puts) + } +} + +func TestEntriesIncludeConsumerAndProducerFragmentMarkers(t *testing.T) { + programmer := testRouteProgrammer(&recordingTable{}, &recordingTable{}, &recordingTable{}) + programmer.targetIndexFn = func(name string) (uint32, error) { + switch name { + case testConsumerDeviceName: + return 10, nil + case testProducerDeviceName: + return 20, nil + default: + return 0, fmt.Errorf("unknown interface %q", name) + } + } + entries, err := programmer.entries(localTestIntent()) + if err != nil { + t.Fatalf("entries: %v", err) + } + var markers []accessRef + for _, entry := range entries { + if entry.access != nil && entry.access.protocol == 0 && entry.access.port == 0 { + markers = append(markers, *entry.access) + } + } + if len(markers) != 2 || markers[0].ingressIfindex != 10 || markers[1].ingressIfindex != 20 { + t.Fatalf("fragment markers = %#v, want consumer ifindex 10 and producer ifindex 20", markers) + } + + remoteProducer := localTestIntent() + remoteProducer.Kind = RouteIntentRemoteProducer + remoteProducer.ConsumerDevice = "" + remoteProducer.ConsumerSID = net.ParseIP("fd00::42") + remoteEntries, err := programmer.entries(remoteProducer) + if err != nil { + t.Fatalf("remote producer entries: %v", err) + } + if len(remoteEntries) != 3 || remoteEntries[0].access == nil || + remoteEntries[0].access.ingressIfindex != 20 || remoteEntries[0].access.protocol != 0 || + remoteEntries[0].access.port != 0 || remoteEntries[1].deny == nil || remoteEntries[2].grant == nil { + t.Fatalf("remote producer entries = %#v, want access marker, deny marker, then grant", remoteEntries) + } +} diff --git a/internal/serviceroute/planner.go b/internal/serviceroute/planner.go new file mode 100644 index 00000000..394f32f6 --- /dev/null +++ b/internal/serviceroute/planner.go @@ -0,0 +1,316 @@ +// Copyright 2026 Datum Cloud, Inc. +// SPDX-License-Identifier: AGPL-3.0-or-later + +// Package serviceroute compiles service policy into node-local dataplane intents. +package serviceroute + +import ( + "crypto/sha256" + "encoding/binary" + "errors" + "fmt" + "net" + "sort" + + apimeta "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/types" + + cloudv1alpha1 "go.datum.net/cloud/api/v1alpha1" + networkv1alpha1 "go.datum.net/network/api/v1alpha1" +) + +type RouteIntentKind string + +const ( + RouteIntentLocal RouteIntentKind = "Local" + RouteIntentRemoteConsumer RouteIntentKind = "RemoteConsumer" + RouteIntentRemoteProducer RouteIntentKind = "RemoteProducer" +) + +type ServiceGrantID [16]byte + +// RouteIntent is one node's half of an attachment-scoped service path. +type RouteIntent struct { + Attachment types.NamespacedName + ProducerAttachment types.NamespacedName + Kind RouteIntentKind + Service *net.IPNet + ConsumerDevice string + ServiceDevice string + ServiceSID net.IP + ConsumerSID net.IP + GrantID ServiceGrantID + Ports []networkv1alpha1.ServiceRouteProtocolPort +} + +type RouteProgrammer interface { + Initialize() error + Apply(RouteIntent) error + Cleanup(RouteIntent) error + Remove(RouteIntent) error + Finalize() error +} + +type SIDResolver func(*cloudv1alpha1.VPCAttachment) (net.IP, error) + +// DependencyNotReadyError reports a valid policy whose node-local routing +// dependencies are not ready yet. It is distinct from a policy validation +// failure because every Galactic node reconciles the same status object while +// only participating nodes need to resolve a given attachment SID. +type DependencyNotReadyError struct{ Err error } + +func (e *DependencyNotReadyError) Error() string { return e.Err.Error() } +func (e *DependencyNotReadyError) Unwrap() error { return e.Err } + +// Compile deterministically picks one ready producer for every selected +// consumer and emits only intent halves owned by localNode. +func Compile(policy *networkv1alpha1.ServiceRoutePolicy, endpoint *networkv1alpha1.ServiceEndpoint, + attachments []*cloudv1alpha1.VPCAttachment, localNode string, resolveSID SIDResolver, +) ([]RouteIntent, error) { + if policy == nil || endpoint == nil { + return nil, errors.New("service route policy and endpoint are required") + } + if policy.Spec.Region != "" && endpoint.Spec.Region != "" && policy.Spec.Region != endpoint.Spec.Region { + return nil, fmt.Errorf("policy region %q does not match endpoint region %q", policy.Spec.Region, endpoint.Spec.Region) + } + consumerSelector, err := metav1.LabelSelectorAsSelector(&policy.Spec.AttachmentSelector) + if err != nil { + return nil, fmt.Errorf("compile consumer attachment selector: %w", err) + } + service, err := hostPrefix(endpoint.Spec.Address) + if err != nil { + return nil, fmt.Errorf("parse service endpoint address: %w", err) + } + ports, err := servicePorts(policy, endpoint) + if err != nil { + return nil, err + } + producers, err := producerCandidates(endpoint, attachments) + if err != nil { + return nil, err + } + if err := validateProducerTopology(endpoint, producers); err != nil { + return nil, err + } + + consumers := readyAttachments(attachments) + intents := make([]RouteIntent, 0, len(consumers)) + for _, consumer := range consumers { + if consumer.Status.HostInterface == "" || !consumerSelector.Matches(labels.Set(consumer.Labels)) { + continue + } + producer := chooseProducer(endpoint.Spec.DeliveryMode, consumer.Status.Node, producers) + if producer == nil { + continue + } + consumerKey := types.NamespacedName{Namespace: consumer.Namespace, Name: consumer.Name} + producerKey := types.NamespacedName{Namespace: producer.Namespace, Name: producer.Name} + if producer.Status.Node == consumer.Status.Node { + if consumer.Status.Node == localNode { + intents = append(intents, RouteIntent{ + Attachment: consumerKey, ProducerAttachment: producerKey, Kind: RouteIntentLocal, + Service: cloneIPNet(service), ConsumerDevice: consumer.Status.HostInterface, + ServiceDevice: producer.Status.HostInterface, Ports: clonePorts(ports), + }) + } + continue + } + if resolveSID == nil { + return nil, errors.New("remote service routing requires a SID resolver") + } + grantID, err := serviceGrantID(policy, endpoint, consumer, producer, ports) + if err != nil { + return nil, err + } + if consumer.Status.Node == localNode { + serviceSID, err := resolveSID(producer) + if err != nil { + return nil, &DependencyNotReadyError{Err: fmt.Errorf( + "resolve producer attachment %s SID: %w", producerKey, err)} + } + intents = append(intents, RouteIntent{ + Attachment: consumerKey, ProducerAttachment: producerKey, Kind: RouteIntentRemoteConsumer, + Service: cloneIPNet(service), ConsumerDevice: consumer.Status.HostInterface, + ServiceSID: cloneIP(serviceSID), GrantID: grantID, Ports: clonePorts(ports), + }) + } + if producer.Status.Node == localNode { + consumerSID, err := resolveSID(consumer) + if err != nil { + return nil, &DependencyNotReadyError{Err: fmt.Errorf( + "resolve consumer attachment %s SID: %w", consumerKey, err)} + } + intents = append(intents, RouteIntent{ + Attachment: consumerKey, ProducerAttachment: producerKey, Kind: RouteIntentRemoteProducer, + Service: cloneIPNet(service), ServiceDevice: producer.Status.HostInterface, + ConsumerSID: cloneIP(consumerSID), GrantID: grantID, Ports: clonePorts(ports), + }) + } + } + sort.Slice(intents, func(i, j int) bool { + if intents[i].Attachment != intents[j].Attachment { + return intents[i].Attachment.String() < intents[j].Attachment.String() + } + return intents[i].Kind < intents[j].Kind + }) + return intents, nil +} + +func validateProducerTopology(endpoint *networkv1alpha1.ServiceEndpoint, + producers []*cloudv1alpha1.VPCAttachment, +) error { + type producerLocation struct { + node string + vpc string + } + seen := make(map[producerLocation]*cloudv1alpha1.VPCAttachment, len(producers)) + for _, producer := range producers { + location := producerLocation{node: producer.Status.Node, vpc: producer.Status.VPC} + if first, ok := seen[location]; ok { + firstKey := types.NamespacedName{Namespace: first.Namespace, Name: first.Name} + producerKey := types.NamespacedName{Namespace: producer.Namespace, Name: producer.Name} + endpointKey := types.NamespacedName{Namespace: endpoint.Namespace, Name: endpoint.Name} + return fmt.Errorf( + "service endpoint %s selects multiple ready producer attachments %s and %s on node %q in VPC %q; "+ + "the direct endpoint dataplane cannot distinguish them", + endpointKey, firstKey, producerKey, location.node, location.vpc, + ) + } + seen[location] = producer + } + return nil +} + +func producerCandidates(endpoint *networkv1alpha1.ServiceEndpoint, + attachments []*cloudv1alpha1.VPCAttachment, +) ([]*cloudv1alpha1.VPCAttachment, error) { + if (endpoint.Spec.AttachmentRef == nil) == (endpoint.Spec.AttachmentSelector == nil) { + return nil, fmt.Errorf( + "service endpoint %s must set exactly one of attachmentRef or attachmentSelector", endpoint.Name, + ) + } + ready := readyAttachments(attachments) + if endpoint.Spec.AttachmentRef != nil { + key := types.NamespacedName{Namespace: endpoint.Spec.AttachmentRef.Namespace, Name: endpoint.Spec.AttachmentRef.Name} + for _, attachment := range ready { + if (types.NamespacedName{Namespace: attachment.Namespace, Name: attachment.Name}) == key && + attachment.Status.HostInterface != "" { + return []*cloudv1alpha1.VPCAttachment{attachment}, nil + } + } + return nil, nil + } + selector, err := metav1.LabelSelectorAsSelector(endpoint.Spec.AttachmentSelector) + if err != nil { + return nil, fmt.Errorf("compile service attachment selector: %w", err) + } + producers := make([]*cloudv1alpha1.VPCAttachment, 0, len(ready)) + for _, attachment := range ready { + if attachment.Status.HostInterface != "" && selector.Matches(labels.Set(attachment.Labels)) { + producers = append(producers, attachment) + } + } + return producers, nil +} + +func readyAttachments(attachments []*cloudv1alpha1.VPCAttachment) []*cloudv1alpha1.VPCAttachment { + ready := make([]*cloudv1alpha1.VPCAttachment, 0, len(attachments)) + for _, attachment := range attachments { + if attachment == nil || attachment.Status.ObservedGeneration != attachment.Generation || + attachment.Status.Node == "" || attachment.Status.VPC == "" || attachment.Status.VPCAttachment == "" || + !apimeta.IsStatusConditionTrue(attachment.Status.Conditions, cloudv1alpha1.ConditionTypeReady) || + !apimeta.IsStatusConditionTrue(attachment.Status.Conditions, cloudv1alpha1.ConditionTypeProgrammed) { + continue + } + ready = append(ready, attachment) + } + sort.Slice(ready, func(i, j int) bool { + return types.NamespacedName{Namespace: ready[i].Namespace, Name: ready[i].Name}.String() < + types.NamespacedName{Namespace: ready[j].Namespace, Name: ready[j].Name}.String() + }) + return ready +} + +func chooseProducer(mode networkv1alpha1.ServiceEndpointDeliveryMode, consumerNode string, + producers []*cloudv1alpha1.VPCAttachment, +) *cloudv1alpha1.VPCAttachment { + for _, producer := range producers { + if producer.Status.Node == consumerNode { + return producer + } + } + if mode == networkv1alpha1.ServiceEndpointDeliveryModePreferNodeLocal && len(producers) != 0 { + return producers[0] + } + return nil +} + +func servicePorts(policy *networkv1alpha1.ServiceRoutePolicy, + endpoint *networkv1alpha1.ServiceEndpoint, +) ([]networkv1alpha1.ServiceRouteProtocolPort, error) { + ports := clonePorts(policy.Spec.ProtocolPorts) + if len(ports) == 0 { + ports = []networkv1alpha1.ServiceRouteProtocolPort{{Protocol: endpoint.Spec.Protocol, Port: endpoint.Spec.Port}} + } else { + for _, port := range ports { + if port.Protocol != endpoint.Spec.Protocol || port.Port != endpoint.Spec.Port { + return nil, fmt.Errorf("policy protocolPorts must be a subset of endpoint %s/%d", + endpoint.Spec.Protocol, endpoint.Spec.Port) + } + } + } + sort.Slice(ports, func(i, j int) bool { + if ports[i].Protocol != ports[j].Protocol { + return ports[i].Protocol < ports[j].Protocol + } + return ports[i].Port < ports[j].Port + }) + return ports, nil +} + +func serviceGrantID(policy *networkv1alpha1.ServiceRoutePolicy, endpoint *networkv1alpha1.ServiceEndpoint, + consumer, producer *cloudv1alpha1.VPCAttachment, ports []networkv1alpha1.ServiceRouteProtocolPort, +) (ServiceGrantID, error) { + if policy.UID == "" || endpoint.UID == "" || consumer.UID == "" || producer.UID == "" { + return ServiceGrantID{}, errors.New("remote service grant requires policy, endpoint, consumer, and producer UIDs") + } + h := sha256.New() + for _, uid := range []types.UID{policy.UID, consumer.UID, endpoint.UID, producer.UID} { + var length [4]byte + binary.BigEndian.PutUint32(length[:], uint32(len(uid))) + _, _ = h.Write(length[:]) + _, _ = h.Write([]byte(uid)) + } + for _, port := range ports { + _, _ = h.Write([]byte(port.Protocol)) + var encoded [4]byte + binary.BigEndian.PutUint32(encoded[:], uint32(port.Port)) + _, _ = h.Write(encoded[:]) + } + var id ServiceGrantID + copy(id[:], h.Sum(nil)) + return id, nil +} + +func hostPrefix(address string) (*net.IPNet, error) { + ip := net.ParseIP(address) + if ip == nil { + return nil, fmt.Errorf("%q is not an IP address", address) + } + if ip.To4() != nil { + return &net.IPNet{IP: ip.To4(), Mask: net.CIDRMask(32, 32)}, nil + } + return &net.IPNet{IP: ip.To16(), Mask: net.CIDRMask(128, 128)}, nil +} + +func cloneIPNet(in *net.IPNet) *net.IPNet { + return &net.IPNet{IP: cloneIP(in.IP), Mask: append(net.IPMask(nil), in.Mask...)} +} + +func cloneIP(in net.IP) net.IP { return append(net.IP(nil), in...) } + +func clonePorts(in []networkv1alpha1.ServiceRouteProtocolPort) []networkv1alpha1.ServiceRouteProtocolPort { + return append([]networkv1alpha1.ServiceRouteProtocolPort(nil), in...) +} diff --git a/internal/serviceroute/planner_test.go b/internal/serviceroute/planner_test.go new file mode 100644 index 00000000..6ca1e331 --- /dev/null +++ b/internal/serviceroute/planner_test.go @@ -0,0 +1,406 @@ +// Copyright 2026 Datum Cloud, Inc. +// SPDX-License-Identifier: AGPL-3.0-or-later + +package serviceroute + +import ( + "errors" + "net" + "strings" + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + + cloudv1alpha1 "go.datum.net/cloud/api/v1alpha1" + networkv1alpha1 "go.datum.net/network/api/v1alpha1" +) + +const ( + testNodeName = "node-a" + testRemoteNodeName = "node-b" + testAccessLabel = "access" + testProducerLabel = "producer" +) + +func testLabels(key, value string) map[string]string { return map[string]string{key: value} } + +func readyAttachment(namespace, name, uid, node, host string, labels map[string]string) *cloudv1alpha1.VPCAttachment { + return &cloudv1alpha1.VPCAttachment{ + ObjectMeta: metav1.ObjectMeta{Namespace: namespace, Name: name, UID: types.UID(uid), Generation: 3, Labels: labels}, + Status: cloudv1alpha1.VPCAttachmentStatus{ + ObservedGeneration: 3, Node: node, VPC: "vpc-" + node, VPCAttachment: "attachment-" + name, + HostInterface: host, + Conditions: []metav1.Condition{ + {Type: cloudv1alpha1.ConditionTypeReady, Status: metav1.ConditionTrue}, + {Type: cloudv1alpha1.ConditionTypeProgrammed, Status: metav1.ConditionTrue}, + }, + }, + } +} + +func testPolicyAndEndpoint(mode networkv1alpha1.ServiceEndpointDeliveryMode) ( + *networkv1alpha1.ServiceRoutePolicy, *networkv1alpha1.ServiceEndpoint, +) { + policy := &networkv1alpha1.ServiceRoutePolicy{ + ObjectMeta: metav1.ObjectMeta{Name: "policy", Namespace: "platform", UID: "policy-uid"}, + Spec: networkv1alpha1.ServiceRoutePolicySpec{ + ServiceRef: networkv1alpha1.ServiceEndpointReference{Name: "dns"}, + AttachmentSelector: metav1.LabelSelector{MatchLabels: testLabels(testAccessLabel, "yes")}, + }, + } + endpoint := &networkv1alpha1.ServiceEndpoint{ + ObjectMeta: metav1.ObjectMeta{Name: "dns", Namespace: "platform", UID: "endpoint-uid"}, + Spec: networkv1alpha1.ServiceEndpointSpec{ + Address: "fd00::53", Protocol: networkv1alpha1.NetworkRuleProtocolUDP, Port: 53, DeliveryMode: mode, + AttachmentSelector: &metav1.LabelSelector{MatchLabels: testLabels(testProducerLabel, "yes")}, + }, + } + return policy, endpoint +} + +func TestCompilePreferNodeLocalIgnoresInputOrder(t *testing.T) { + policy, endpoint := testPolicyAndEndpoint(networkv1alpha1.ServiceEndpointDeliveryModePreferNodeLocal) + consumer := readyAttachment( + "tenant", "consumer", "consumer-uid", testNodeName, "consumer0", testLabels(testAccessLabel, "yes"), + ) + remote := readyAttachment( + "service", "a-remote", "remote-uid", testRemoteNodeName, "remote0", testLabels(testProducerLabel, "yes"), + ) + local := readyAttachment( + "service", "z-local", "local-uid", testNodeName, "local0", testLabels(testProducerLabel, "yes"), + ) + + got, err := Compile(policy, endpoint, []*cloudv1alpha1.VPCAttachment{remote, consumer, local}, testNodeName, nil) + if err != nil { + t.Fatalf("Compile: %v", err) + } + if len(got) != 1 || got[0].Kind != RouteIntentLocal || got[0].ServiceDevice != "local0" { + t.Fatalf("intents = %+v, want one local intent through local0", got) + } +} + +func TestCompileDirectVIPSelectsOneExactLocalProducer(t *testing.T) { + policy, endpoint := testPolicyAndEndpoint(networkv1alpha1.ServiceEndpointDeliveryModePreferNodeLocal) + consumer := readyAttachment( + "tenant", "consumer", "consumer-uid", testNodeName, "consumer0", testLabels(testAccessLabel, "yes"), + ) + first := readyAttachment( + "service", "a-producer", "first-uid", testNodeName, "producer-a", testLabels(testProducerLabel, "yes"), + ) + last := readyAttachment( + "service", "z-producer", "last-uid", testNodeName, "producer-z", testLabels(testProducerLabel, "yes"), + ) + first.Status.VPC = "service-vpc-a" + last.Status.VPC = "service-vpc-z" + + got, err := Compile( + policy, endpoint, []*cloudv1alpha1.VPCAttachment{last, consumer, first}, testNodeName, nil, + ) + if err != nil { + t.Fatalf("Compile: %v", err) + } + wantProducer := types.NamespacedName{Namespace: first.Namespace, Name: first.Name} + if len(got) != 1 || got[0].Kind != RouteIntentLocal || got[0].ProducerAttachment != wantProducer || + got[0].ServiceDevice != first.Status.HostInterface { + t.Fatalf("intents = %+v, want one local intent through %s on %s", got, wantProducer, first.Status.HostInterface) + } + if got[0].Service.String() != endpoint.Spec.Address+"/128" { + t.Fatalf("service destination = %s, want declared endpoint %s/128 unchanged", got[0].Service, endpoint.Spec.Address) + } +} + +func TestCompileRejectsAmbiguousProducerTopology(t *testing.T) { + policy, endpoint := testPolicyAndEndpoint(networkv1alpha1.ServiceEndpointDeliveryModePreferNodeLocal) + consumer := readyAttachment( + "tenant", "consumer", "consumer-uid", testNodeName, "consumer0", testLabels(testAccessLabel, "yes"), + ) + first := readyAttachment( + "service", "a-producer", "first-uid", testNodeName, "producer-a", testLabels(testProducerLabel, "yes"), + ) + last := readyAttachment( + "service", "z-producer", "last-uid", testNodeName, "producer-z", testLabels(testProducerLabel, "yes"), + ) + + _, err := Compile( + policy, endpoint, []*cloudv1alpha1.VPCAttachment{last, consumer, first}, testNodeName, nil, + ) + if err == nil { + t.Fatal("Compile succeeded with two ready producers on the same node and VPC") + } + for _, want := range []string{ + "service/a-producer", "service/z-producer", `node "node-a"`, `VPC "vpc-node-a"`, + } { + if !strings.Contains(err.Error(), want) { + t.Fatalf("Compile error = %q, want it to contain %q", err, want) + } + } +} + +func TestCompileAllowsDistinguishableProducerTopologies(t *testing.T) { + tests := []struct { + name string + configure func(first, last *cloudv1alpha1.VPCAttachment) + }{ + { + name: "different nodes", + configure: func(_ *cloudv1alpha1.VPCAttachment, last *cloudv1alpha1.VPCAttachment) { + last.Status.Node = testRemoteNodeName + last.Status.VPC = "vpc-" + testRemoteNodeName + }, + }, + { + name: "different VPCs on one node", + configure: func(first, last *cloudv1alpha1.VPCAttachment) { + first.Status.VPC = "service-vpc-a" + last.Status.VPC = "service-vpc-z" + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + policy, endpoint := testPolicyAndEndpoint(networkv1alpha1.ServiceEndpointDeliveryModePreferNodeLocal) + consumer := readyAttachment( + "tenant", "consumer", "consumer-uid", testNodeName, "consumer0", testLabels(testAccessLabel, "yes"), + ) + first := readyAttachment( + "service", "a-producer", "first-uid", testNodeName, "producer-a", testLabels(testProducerLabel, "yes"), + ) + last := readyAttachment( + "service", "z-producer", "last-uid", testNodeName, "producer-z", testLabels(testProducerLabel, "yes"), + ) + tt.configure(first, last) + + got, err := Compile( + policy, endpoint, []*cloudv1alpha1.VPCAttachment{last, consumer, first}, testNodeName, + func(*cloudv1alpha1.VPCAttachment) (net.IP, error) { return net.ParseIP("fd00::b"), nil }, + ) + if err != nil { + t.Fatalf("Compile: %v", err) + } + wantProducer := types.NamespacedName{Namespace: first.Namespace, Name: first.Name} + if len(got) != 1 || got[0].ProducerAttachment != wantProducer { + t.Fatalf("intents = %+v, want selected producer %s", got, wantProducer) + } + }) + } +} + +func TestCompileUsesObservedNodeAndSelector(t *testing.T) { + policy, endpoint := testPolicyAndEndpoint(networkv1alpha1.ServiceEndpointDeliveryModeNodeLocal) + endpoint.Spec.AttachmentSelector = nil + endpoint.Spec.AttachmentRef = &networkv1alpha1.ServiceEndpointAttachmentReference{ + Namespace: "service", Name: "producer", + } + consumer := readyAttachment( + "tenant", "selected", "consumer-uid", testNodeName, "consumer0", testLabels(testAccessLabel, "yes"), + ) + producer := readyAttachment("service", "producer", "producer-uid", testNodeName, "producer0", nil) + otherNode := readyAttachment( + "tenant", "other-node", "other-uid", testRemoteNodeName, "other0", testLabels(testAccessLabel, "yes"), + ) + notSelected := readyAttachment( + "tenant", "not-selected", "not-selected-uid", testNodeName, "disabled0", testLabels(testAccessLabel, "no"), + ) + + got, err := Compile(policy, endpoint, + []*cloudv1alpha1.VPCAttachment{otherNode, notSelected, producer, consumer}, testNodeName, nil) + if err != nil { + t.Fatalf("Compile: %v", err) + } + if len(got) != 1 || got[0].Attachment != (types.NamespacedName{Namespace: "tenant", Name: "selected"}) { + t.Fatalf("intents = %+v, want selected attachment only", got) + } + if got[0].Service.String() != "fd00::53/128" { + t.Fatalf("service prefix = %s", got[0].Service) + } +} + +func TestCompileUsesEndpointPortByDefault(t *testing.T) { + policy, endpoint := testPolicyAndEndpoint(networkv1alpha1.ServiceEndpointDeliveryModeNodeLocal) + policy.Spec.ProtocolPorts = nil + endpoint.Spec.Address = "192.0.2.10" + endpoint.Spec.Protocol = networkv1alpha1.NetworkRuleProtocolTCP + consumer := readyAttachment( + "tenant", "consumer", "consumer-uid", testNodeName, "consumer0", testLabels(testAccessLabel, "yes"), + ) + producer := readyAttachment( + "service", "producer", "producer-uid", testNodeName, "producer0", testLabels(testProducerLabel, "yes"), + ) + + got, err := Compile(policy, endpoint, []*cloudv1alpha1.VPCAttachment{consumer, producer}, testNodeName, nil) + if err != nil { + t.Fatalf("Compile: %v", err) + } + if len(got) != 1 || len(got[0].Ports) != 1 || got[0].Ports[0].Port != 53 || + got[0].Ports[0].Protocol != networkv1alpha1.NetworkRuleProtocolTCP { + t.Fatalf("ports = %+v", got[0].Ports) + } +} + +func TestCompilePreferNodeLocalBuildsMatchingRemoteHalves(t *testing.T) { + policy, endpoint := testPolicyAndEndpoint(networkv1alpha1.ServiceEndpointDeliveryModePreferNodeLocal) + consumer := readyAttachment( + "tenant", "consumer", "consumer-uid", testNodeName, "consumer0", testLabels(testAccessLabel, "yes"), + ) + producer := readyAttachment( + "service", "producer", "producer-uid", testRemoteNodeName, "producer0", testLabels(testProducerLabel, "yes"), + ) + resolver := func(attachment *cloudv1alpha1.VPCAttachment) (net.IP, error) { + if attachment.Status.Node == testNodeName { + return net.ParseIP("fd00::a"), nil + } + return net.ParseIP("fd00::b"), nil + } + + consumerIntents, err := Compile( + policy, endpoint, []*cloudv1alpha1.VPCAttachment{producer, consumer}, testNodeName, resolver, + ) + if err != nil { + t.Fatalf("Compile consumer node: %v", err) + } + producerIntents, err := Compile( + policy, endpoint, []*cloudv1alpha1.VPCAttachment{consumer, producer}, testRemoteNodeName, resolver, + ) + if err != nil { + t.Fatalf("Compile producer node: %v", err) + } + if len(consumerIntents) != 1 || consumerIntents[0].Kind != RouteIntentRemoteConsumer || + !consumerIntents[0].ServiceSID.Equal(net.ParseIP("fd00::b")) { + t.Fatalf("consumer intents = %+v", consumerIntents) + } + if len(producerIntents) != 1 || producerIntents[0].Kind != RouteIntentRemoteProducer || + !producerIntents[0].ConsumerSID.Equal(net.ParseIP("fd00::a")) { + t.Fatalf("producer intents = %+v", producerIntents) + } + if consumerIntents[0].GrantID == (ServiceGrantID{}) || consumerIntents[0].GrantID != producerIntents[0].GrantID { + t.Fatalf("grant IDs differ or are zero: %x / %x", consumerIntents[0].GrantID, producerIntents[0].GrantID) + } +} + +func TestCompileNodeLocalDoesNotUseRemoteProducer(t *testing.T) { + policy, endpoint := testPolicyAndEndpoint(networkv1alpha1.ServiceEndpointDeliveryModeNodeLocal) + consumer := readyAttachment( + "tenant", "consumer", "consumer-uid", testNodeName, "consumer0", testLabels(testAccessLabel, "yes"), + ) + producer := readyAttachment( + "service", "producer", "producer-uid", testRemoteNodeName, "producer0", testLabels(testProducerLabel, "yes"), + ) + got, err := Compile(policy, endpoint, []*cloudv1alpha1.VPCAttachment{consumer, producer}, testNodeName, + func(*cloudv1alpha1.VPCAttachment) (net.IP, error) { return net.ParseIP("fd00::b"), nil }) + if err != nil { + t.Fatalf("Compile: %v", err) + } + if len(got) != 0 { + t.Fatalf("intents = %+v, want none", got) + } +} + +func TestCompileSkipsUnreadyLocalProducerAndFallsBackRemote(t *testing.T) { + policy, endpoint := testPolicyAndEndpoint(networkv1alpha1.ServiceEndpointDeliveryModePreferNodeLocal) + consumer := readyAttachment( + "tenant", "consumer", "consumer-uid", testNodeName, "consumer0", testLabels(testAccessLabel, "yes"), + ) + local := readyAttachment( + "service", "local", "local-uid", testNodeName, "local0", testLabels(testProducerLabel, "yes"), + ) + local.Status.Conditions[1].Status = metav1.ConditionFalse + remote := readyAttachment( + "service", "remote", "remote-uid", testRemoteNodeName, "remote0", testLabels(testProducerLabel, "yes"), + ) + got, err := Compile(policy, endpoint, []*cloudv1alpha1.VPCAttachment{local, remote, consumer}, testNodeName, + func(*cloudv1alpha1.VPCAttachment) (net.IP, error) { return net.ParseIP("fd00::b"), nil }) + if err != nil { + t.Fatalf("Compile: %v", err) + } + if len(got) != 1 || got[0].Kind != RouteIntentRemoteConsumer { + t.Fatalf("intents = %+v, want remote consumer fallback", got) + } +} + +func TestCompileClassifiesUnavailableRemoteSIDAsDependencyNotReady(t *testing.T) { + policy, endpoint := testPolicyAndEndpoint(networkv1alpha1.ServiceEndpointDeliveryModePreferNodeLocal) + consumer := readyAttachment( + "tenant", "consumer", "consumer-uid", testNodeName, "consumer0", testLabels(testAccessLabel, "yes"), + ) + producer := readyAttachment( + "service", "producer", "producer-uid", testRemoteNodeName, "producer0", testLabels(testProducerLabel, "yes"), + ) + want := errors.New("routing dependency unavailable") + _, err := Compile(policy, endpoint, []*cloudv1alpha1.VPCAttachment{consumer, producer}, testNodeName, + func(*cloudv1alpha1.VPCAttachment) (net.IP, error) { return nil, want }) + var dependencyErr *DependencyNotReadyError + if !errors.As(err, &dependencyErr) || !errors.Is(err, want) { + t.Fatalf("Compile error = %v, want DependencyNotReadyError wrapping %v", err, want) + } +} + +func TestCompilePreferNodeLocalSelectsStableRemoteFallback(t *testing.T) { + policy, endpoint := testPolicyAndEndpoint(networkv1alpha1.ServiceEndpointDeliveryModePreferNodeLocal) + consumer := readyAttachment( + "tenant", "consumer", "consumer-uid", testNodeName, "consumer0", testLabels(testAccessLabel, "yes"), + ) + first := readyAttachment( + "service", "a-remote", "first-uid", testRemoteNodeName, "first0", testLabels(testProducerLabel, "yes"), + ) + last := readyAttachment( + "service", "z-remote", "last-uid", "node-c", "last0", testLabels(testProducerLabel, "yes"), + ) + resolver := func(attachment *cloudv1alpha1.VPCAttachment) (net.IP, error) { + if attachment.Name == first.Name { + return net.ParseIP("fd00::b"), nil + } + return net.ParseIP("fd00::c"), nil + } + + one, err := Compile( + policy, endpoint, []*cloudv1alpha1.VPCAttachment{last, consumer, first}, testNodeName, resolver, + ) + if err != nil { + t.Fatalf("Compile first order: %v", err) + } + two, err := Compile( + policy, endpoint, []*cloudv1alpha1.VPCAttachment{first, last, consumer}, testNodeName, resolver, + ) + if err != nil { + t.Fatalf("Compile second order: %v", err) + } + wantProducer := types.NamespacedName{Namespace: first.Namespace, Name: first.Name} + for _, intents := range [][]RouteIntent{one, two} { + if len(intents) != 1 || intents[0].Kind != RouteIntentRemoteConsumer || + intents[0].ProducerAttachment != wantProducer || !intents[0].ServiceSID.Equal(net.ParseIP("fd00::b")) { + t.Fatalf("intents = %+v, want stable fallback through %s", intents, wantProducer) + } + } + if one[0].GrantID == (ServiceGrantID{}) || one[0].GrantID != two[0].GrantID { + t.Fatalf("grant ID changed with input order: %x / %x", one[0].GrantID, two[0].GrantID) + } +} + +func TestServiceGrantIDCanonicalizesPortOrder(t *testing.T) { + policy, endpoint := testPolicyAndEndpoint(networkv1alpha1.ServiceEndpointDeliveryModePreferNodeLocal) + consumer := readyAttachment("tenant", "consumer", "consumer-uid", testNodeName, "consumer0", nil) + producer := readyAttachment("service", "producer", "producer-uid", "node-b", "producer0", nil) + ports := []networkv1alpha1.ServiceRouteProtocolPort{{Protocol: "UDP", Port: 53}, {Protocol: "TCP", Port: 53}} + a := clonePorts(ports) + b := clonePorts(ports) + sortPorts := func(p []networkv1alpha1.ServiceRouteProtocolPort) { + if p[0].Protocol > p[1].Protocol { + p[0], p[1] = p[1], p[0] + } + } + sortPorts(a) + b[0], b[1] = b[1], b[0] + sortPorts(b) + idA, err := serviceGrantID(policy, endpoint, consumer, producer, a) + if err != nil { + t.Fatal(err) + } + idB, err := serviceGrantID(policy, endpoint, consumer, producer, b) + if err != nil { + t.Fatal(err) + } + if idA != idB { + t.Fatalf("canonical grant IDs differ: %x / %x", idA, idB) + } +}