From dea118b5efa3b8766fc1b7c27890833a81c818aa Mon Sep 17 00:00:00 2001 From: ryskn Date: Mon, 6 Jul 2026 20:09:14 +0900 Subject: [PATCH] feat(srv6): RFC 9256 candidate-path selection, dynamic BSID, SID verification, drop-upon-invalid MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port the RFC 9256/9830 receiver features from the safi73-vpp PoC into the agent's SR Policy path (bgp_watcher -> SRv6Provider). Selection (§2.9): getPolicyNode picked the highest Priority, which is wrong on two axes -- selection is Preference's job (higher wins, default 100, was not even parsed), and Priority orders revalidation with LOWER value first (§2.12, default 128, was defaulting to 0 = most urgent). Selection is now preference-based with §2.9 tie-breaks (lower originator from the BGP path source, then higher discriminator); Priority orders the RescanState revalidation sweep instead. Dynamic BSID (§6.2.1): advertisements without a BSID used to install an unusable all-zero-BSID policy. The provider now binds a BSID from the policy pool via Calico IPAM (handle-scoped per , stable across candidate switches, released with the last candidate, reclaimed across agent restarts). S-Flag (Specified-BSID-only, §6.2.3) still rejects. SID verification (§5.1): the first SID of every segment list, plus any segment with the V-Flag, must resolve in the FIB to a non-drop path or the list is invalid; lookup failures fail open. Adds vpplink.RouteLookup (LPM) and IsDrop mapping in FromFibPath. Drop-upon-invalid (§8.2, I-Flag): when a policy still has candidates but none is valid, orphaned steerings get a fail-closed drop route (FibTable preserved) instead of falling back to routing; lifted on recovery or when the policy's last candidate is withdrawn. Watcher parsing also gains: SRv6 Binding SID sub-TLV (type 20, arrives from gobgp as Unknown; preferred over type 13 per RFC 9830 §2.4.2), S/I flags, V-Flag masks, weight-0 rejection (§5.1), first-instance-wins sub-TLVs. --- calico-vpp-agent/common/common.go | 21 + calico-vpp-agent/connectivity/srv6.go | 464 +++++++++++++++--- calico-vpp-agent/connectivity/srv6_test.go | 394 ++++++++++++++- calico-vpp-agent/routing/bgp_watcher.go | 116 ++++- .../routing/srpolicy_parse_test.go | 190 +++++++ vpplink/routes.go | 25 + vpplink/types/route.go | 1 + 7 files changed, 1116 insertions(+), 95 deletions(-) create mode 100644 calico-vpp-agent/routing/srpolicy_parse_test.go diff --git a/calico-vpp-agent/common/common.go b/calico-vpp-agent/common/common.go index b79b83176..1117698bc 100644 --- a/calico-vpp-agent/common/common.go +++ b/calico-vpp-agent/common/common.go @@ -534,6 +534,27 @@ type SRv6Tunnel struct { Priority uint32 Color uint32 Distinguisher uint32 + + // Preference selects the active candidate path among candidates of the same + // SR Policy : higher wins, default 100 (RFC 9256 §2.7/§2.9). + // Priority above is unrelated: it orders revalidation work (§2.12, lower + // value first, default 128). + Preference uint32 + // OriginatorASN/OriginatorNode break preference ties (§2.9: lower originator + // wins). Filled from the BGP path source (peer ASN / router-id). + OriginatorASN uint32 + OriginatorNode string + // SpecifiedBSIDOnly is the BSID sub-TLV S-Flag (§6.2.3): when set, a + // candidate without a usable specified BSID is invalid (no dynamic allocation). + SpecifiedBSIDOnly bool + // DropUponInvalid is the BSID sub-TLV I-Flag (§8.2): when the policy becomes + // invalid, steered traffic is dropped (fail-closed) instead of falling back + // to routing. + DropUponInvalid bool + // VerifyMasks holds, per Policy.SidLists entry, a bitmask of segments that + // requested SID verification (V-Flag, bit i = Sids[i]). The first SID is + // always verified regardless (RFC 9256 §5.1). + VerifyMasks []uint32 } func GetBGPSpecAddresses(nodeBGPSpec *LocalNodeSpec) (ip4 *net.IP, ip6 *net.IP) { diff --git a/calico-vpp-agent/connectivity/srv6.go b/calico-vpp-agent/connectivity/srv6.go index 2dd0f067e..793851632 100644 --- a/calico-vpp-agent/connectivity/srv6.go +++ b/calico-vpp-agent/connectivity/srv6.go @@ -3,7 +3,10 @@ package connectivity import ( "context" "fmt" + "math" "net" + "sort" + "strings" "github.com/pkg/errors" "github.com/projectcalico/calico/libcalico-go/lib/ipam" @@ -59,6 +62,7 @@ type srv6VppAPI interface { SetEncapSource(net.IP) error RouteAdd(*types.Route) error RouteDel(*types.Route) error + RouteLookup(dst *net.IPNet, tableID uint32) (*types.Route, error) } // SRv6Provider is node connectivity provider that uses segment routing over IPv6 (SRv6) to connect the nodes @@ -88,6 +92,28 @@ type SRv6Provider struct { // pendingBsidCleanup holds prior BSIDs not yet freeable (a steering still // resolves through them); drained on later SR-policy events to avoid leaks. pendingBsidCleanup []ip_types.IP6Address + + // dynBsids maps "|" to the BSID dynamically bound to that + // SR Policy (RFC 9256 §6.2.1) when candidates arrive without one. The + // binding is policy-scoped: it survives candidate-path changes and is + // released only when the policy's last candidate is withdrawn. + dynBsids map[string]ip_types.IP6Address + // allocBsid/releaseBsid provision dynamic BSIDs. Production wires Calico + // IPAM on the policy pool (handle-scoped for restart attribution); tests + // inject fakes. + allocBsid func(handle string) (net.IP, error) + releaseBsid func(handle string) error + // droppedPrefixes tracks drop routes installed for drop-upon-invalid + // (RFC 9256 §8.2, I-Flag): prefix@table -> the endpoint whose invalid + // policy is holding it, plus the route to delete on release. + droppedPrefixes map[string]dropState +} + +// dropState is one fail-closed drop route installed while an SR Policy with the +// I-Flag is invalid (RFC 9256 §8.2). +type dropState struct { + nodeip string + route *types.Route } func NewSRv6Provider(d *ConnectivityProviderData) *SRv6Provider { @@ -98,16 +124,54 @@ func NewSRv6Provider(d *ConnectivityProviderData) *SRv6Provider { nodePolices: make(map[string]*NodeToPolicies), dsrServices: make(map[string]*dsrServiceState), dsrDesired: make(map[string]*common.DSRService), + dynBsids: make(map[string]ip_types.IP6Address), + droppedPrefixes: make(map[string]dropState), } if *config.GetCalicoVppFeatureGates().SRv6Enabled { p.localSidIPPool = cnet.MustParseNetwork(config.GetCalicoVppSrv6().LocalsidPool).IPNet p.policyIPPool = cnet.MustParseNetwork(config.GetCalicoVppSrv6().PolicyPool).IPNet } + p.allocBsid = p.ipamAllocBsid + p.releaseBsid = p.ipamReleaseBsid p.log.Infof("SRv6Provider NewSRv6Provider") return p } +// dynBsidKey identifies the SR Policy a dynamic BSID is bound to. RFC 9256 +// §6.2.1 scopes the binding to the policy , not the candidate. +func dynBsidKey(nodeip string, color uint32) string { + return nodeip + "|" + fmt.Sprint(color) +} + +// ipamAllocBsid allocates a dynamic BSID from the policy pool through Calico +// IPAM, attributed to the handle. Any stale allocation under the same handle +// (left over from a previous agent run) is released first, so restarts do not +// leak pool addresses. +func (p *SRv6Provider) ipamAllocBsid(handle string) (net.IP, error) { + ctx := context.Background() + if err := p.Clientv3().IPAM().ReleaseByHandle(ctx, handle); err != nil { + p.log.Debugf("SRv6Provider ipamAllocBsid: no stale allocation for %s: %v", handle, err) + } + _, v6Assignments, err := p.Clientv3().IPAM().AutoAssign(ctx, ipam.AutoAssignArgs{ + Num6: 1, + IPv6Pools: []cnet.IPNet{{IPNet: p.policyIPPool}}, + HandleID: &handle, + IntendedUse: "Tunnel", + }) + if err != nil { + return nil, errors.Wrapf(err, "SRv6Provider dynamic BSID allocation (handle %s)", handle) + } + if v6Assignments == nil || len(v6Assignments.IPs) == 0 { + return nil, fmt.Errorf("SRv6Provider dynamic BSID pool %s exhausted", p.policyIPPool.String()) + } + return v6Assignments.IPs[0].IP, nil +} + +func (p *SRv6Provider) ipamReleaseBsid(handle string) error { + return p.Clientv3().IPAM().ReleaseByHandle(context.Background(), handle) +} + func (p *SRv6Provider) GetSwifindexes() []uint32 { return []uint32{} } @@ -143,6 +207,9 @@ func (p *SRv6Provider) RescanState() { p.log.Errorf("SRv6Provider Error creating SRv6Localsid: %v", err) } + // Re-run candidate selection in priority order (RFC 9256 §2.12): picks up + // FIB changes affecting SID reachability and retries failed installs. + p.revalidatePolicies() } func (p *SRv6Provider) CreateSRv6Tunnel(dst net.IP, prefixDst ip_types.Prefix, policyTunnel *types.SrPolicy) (err error) { @@ -163,12 +230,76 @@ func (p *SRv6Provider) CreateSRv6Tunnel(dst net.IP, prefixDst ip_types.Prefix, p if vpplink.IsIP6(srSteer.Prefix.Address.ToIP()) { srSteer.TrafficType = types.SrSteerIPv6 } + // A valid candidate is taking over: lift any drop-upon-invalid route + // (RFC 9256 §8.2) held on this prefix before steering through it. + p.clearDropRoute(srSteer.Prefix, srSteer.FibTable) if err := p.vpp.AddSRv6Steering(srSteer); err != nil { return errors.Wrapf(err, "SRv6Provider CreateSRv6Tunnel AddSRv6Steering") } return nil } +// dropKey identifies one fail-closed drop route (RFC 9256 §8.2). +func dropKey(prefix ip_types.Prefix, table uint32) string { + return prefix.String() + "@" + fmt.Sprint(table) +} + +// clearDropRoute removes the drop-upon-invalid route held on prefix, if any. +// Called before (re)steering the prefix through a valid policy. +func (p *SRv6Provider) clearDropRoute(prefix ip_types.Prefix, table uint32) { + key := dropKey(prefix, table) + ds, ok := p.droppedPrefixes[key] + if !ok { + return + } + if err := p.vpp.RouteDel(ds.route); err != nil && !isAlreadyGoneOnDelete(err) { + // Keep it tracked so a later pass retries; the drop route would + // otherwise shadow the fresh steering. + p.log.Warnf("SRv6Provider clearDropRoute %s: %v; will retry", key, err) + return + } + p.log.Infof("SRv6Provider drop-upon-invalid released for %s", key) + delete(p.droppedPrefixes, key) +} + +// engageDropRoute installs the fail-closed drop for a prefix whose SR Policy +// became invalid with the I-Flag set (RFC 9256 §8.2): the traffic is dropped +// rather than escaping to ordinary routing. +func (p *SRv6Provider) engageDropRoute(nodeip string, orphan *types.SrSteer) { + key := dropKey(orphan.Prefix, orphan.FibTable) + if _, ok := p.droppedPrefixes[key]; ok { + return + } + route := &types.Route{ + Dst: orphan.Prefix.ToIPNet(), + Table: orphan.FibTable, + Paths: []types.RoutePath{{IsDrop: true}}, + } + if err := p.vpp.RouteAdd(route); err != nil { + p.log.Warnf("SRv6Provider engageDropRoute %s: %v; falling back to fail-open", key, err) + return + } + p.droppedPrefixes[key] = dropState{nodeip: nodeip, route: route} + p.log.Infof("SRv6Provider drop-upon-invalid engaged for %s (RFC 9256 §8.2)", key) +} + +// releaseDropsForNode lifts every drop held for nodeip's policies. Called when +// the policy ceases to exist (all candidates withdrawn): with no policy left +// there is no drop-upon-invalid state to honor, traffic reverts to routing. +func (p *SRv6Provider) releaseDropsForNode(nodeip string) { + for key, ds := range p.droppedPrefixes { + if ds.nodeip != nodeip { + continue + } + if err := p.vpp.RouteDel(ds.route); err != nil && !isAlreadyGoneOnDelete(err) { + p.log.Warnf("SRv6Provider releaseDropsForNode %s: %v; will retry", key, err) + continue + } + p.log.Infof("SRv6Provider drop-upon-invalid released for %s (policy gone)", key) + delete(p.droppedPrefixes, key) + } +} + // steerNodeIPViaSID steers pod traffic to a remote node's own IP onto that node's // End.DT6 SID (in PodVRFIndex) so host-network backed ClusterIPs work under SRv6. func (p *SRv6Provider) steerNodeIPViaSID(nodeip string) { @@ -193,6 +324,7 @@ func (p *SRv6Provider) steerNodeIPViaSID(nodeip string) { Prefix: prefix, Bsid: policy.Bsid, } + p.clearDropRoute(srSteer.Prefix, srSteer.FibTable) if err := p.vpp.AddSRv6Steering(srSteer); err != nil { p.log.Errorf("SRv6Provider steerNodeIPViaSID AddSRv6Steering node=%s prefix=%s: %v", nodeip, prefix.String(), err) } @@ -326,28 +458,7 @@ func (p *SRv6Provider) AddConnectivity(cn *common.NodeConnectivity) error { // We got all needed data (normal common.NodeConnectivity and SRv6 tunnel info from tunnel-end node transported by BGP) // we can create dynamic parts of SRv6 tunnel (SR steering and SR policy) - if p.nodePrefixes[nodeip] != nil { - p.log.Debugf("SRv6Provider check new tunnel for node %s, prefixes %d", nodeip, len(p.nodePrefixes[nodeip].Prefixes)) - - for _, prefix := range p.nodePrefixes[nodeip].Prefixes { - prefixBehavior := types.SrBehaviorDT4 - if vpplink.IsIP6(prefix.Address.ToIP()) { - prefixBehavior = types.SrBehaviorDT6 - } - - policy, err := p.getPolicyNode(nodeip, prefixBehavior) - if err == nil && policy != nil { - if err := p.CreateSRv6Tunnel(p.nodePrefixes[nodeip].Node, prefix, policy); err != nil { - p.log.Error(err) - } - } - - } - - // Bring the host plane onto SRv6 too: steer pod traffic to this node's - // IP via its End.DT6 SID so host-network backed ClusterIPs work. - p.steerNodeIPViaSID(nodeip) - } + p.installNode(nodeip) p.drainPendingBsidCleanup(orphanedBsid, orphanedBsidValid) @@ -358,6 +469,60 @@ func (p *SRv6Provider) AddConnectivity(cn *common.NodeConnectivity) error { return nil } +// installNode creates the dynamic parts of the SRv6 tunnel (SR policy and +// steering) for one endpoint node, once both its prefixes and policy +// candidates are known. Selection runs per prefix behavior (RFC 9256 §2.9). +func (p *SRv6Provider) installNode(nodeip string) { + if p.nodePrefixes[nodeip] == nil { + return + } + p.log.Debugf("SRv6Provider check new tunnel for node %s, prefixes %d", nodeip, len(p.nodePrefixes[nodeip].Prefixes)) + + for _, prefix := range p.nodePrefixes[nodeip].Prefixes { + prefixBehavior := types.SrBehaviorDT4 + if vpplink.IsIP6(prefix.Address.ToIP()) { + prefixBehavior = types.SrBehaviorDT6 + } + + policy, err := p.getPolicyNode(nodeip, prefixBehavior) + if err == nil && policy != nil { + if err := p.CreateSRv6Tunnel(p.nodePrefixes[nodeip].Node, prefix, policy); err != nil { + p.log.Error(err) + } + } + } + + // Bring the host plane onto SRv6 too: steer pod traffic to this node's + // IP via its End.DT6 SID so host-network backed ClusterIPs work. + p.steerNodeIPViaSID(nodeip) +} + +// revalidatePolicies re-runs candidate selection and installation for every +// endpoint, ordered by SR Policy priority (RFC 9256 §2.12: lower value first; +// the policy takes the lowest priority among its candidates). Invoked from +// RescanState so FIB changes (SID reachability) and previously failed installs +// are picked up, most important policies first. +func (p *SRv6Provider) revalidatePolicies() { + type item struct { + nodeip string + prio uint32 + } + items := make([]item, 0, len(p.nodePolices)) + for nodeip, entry := range p.nodePolices { + prio := uint32(math.MaxUint32) + for i := range entry.SRv6Tunnel { + if entry.SRv6Tunnel[i].Priority < prio { + prio = entry.SRv6Tunnel[i].Priority + } + } + items = append(items, item{nodeip, prio}) + } + sort.Slice(items, func(i, j int) bool { return items[i].prio < items[j].prio }) + for _, it := range items { + p.installNode(it.nodeip) + } +} + // drainPendingBsidCleanup deletes queued BSIDs no steering resolves through // (one ListSRv6Steering classifies all); still-referenced ones stay queued. func (p *SRv6Provider) drainPendingBsidCleanup(orphanedBsid ip_types.IP6Address, orphanedBsidValid bool) { @@ -427,6 +592,9 @@ func (p *SRv6Provider) delSRPolicy(cn *common.NodeConnectivity) error { // Match cached tunnels by NLRI key. // Withdraws carry only the NLRI key (no BSID); the cached tunnel preserves // the BSID we installed, which is what VPP needs to delete. + // Withdraws also carry no flags, so drop-upon-invalid (I-Flag) intent is + // read from the cached tunnels and the superseding advertisement alike. + dropRequested := policyData.DropUponInvalid var matched []ip_types.IP6Address remaining := entry.SRv6Tunnel[:0] for _, tun := range entry.SRv6Tunnel { @@ -434,6 +602,7 @@ func (p *SRv6Provider) delSRPolicy(cn *common.NodeConnectivity) error { if b, ok := tunnelBsid(&tun); ok { matched = append(matched, b) } + dropRequested = dropRequested || tun.DropUponInvalid continue } remaining = append(remaining, tun) @@ -443,6 +612,12 @@ func (p *SRv6Provider) delSRPolicy(cn *common.NodeConnectivity) error { nodeip, policyData.Color, policyData.Distinguisher) return nil } + for _, tun := range remaining { + dropRequested = dropRequested || tun.DropUponInvalid + } + // RFC 9256 §8.2 applies while the policy exists but is invalid. With no + // candidate left at all the policy is gone, so fail-open is correct. + dropRequested = dropRequested && len(remaining) > 0 steering, listErr := p.vpp.ListSRv6Steering() if listErr != nil { @@ -483,31 +658,58 @@ func (p *SRv6Provider) delSRPolicy(cn *common.NodeConnectivity) error { if len(remaining) == 0 { delete(p.nodePolices, nodeip) + // Policy gone entirely: no drop-upon-invalid state left to honor. + p.releaseDropsForNode(nodeip) } else { entry.SRv6Tunnel = remaining } - // AddConnectivity only installs the highest-priority candidate per behavior; - // lower-priority survivors are cached but absent from VPP. Track which we + // Release the dynamic BSID binding (RFC 9256 §6.2.1) once the last + // candidate of its SR Policy is gone. + p.releaseUnusedDynBsids(nodeip, policyData.Color, remaining) + + // AddConnectivity only installs the selected candidate per behavior; + // other survivors are cached but absent from VPP. Track which we // install on demand here so multiple orphaned prefixes targeting the same // surviving BSID don't churn the install. installed := make(map[ip_types.IP6Address]struct{}) for _, st := range orphaned { - p.resteerOrphan(nodeip, st, installed) + p.resteerOrphan(nodeip, st, installed, dropRequested) } return nil } +// releaseUnusedDynBsids frees the dynamic BSID bound to when +// no candidate of that SR Policy survives (RFC 9256 §6.2.1: the binding lives +// as long as the policy does). +func (p *SRv6Provider) releaseUnusedDynBsids(nodeip string, color uint32, remaining []common.SRv6Tunnel) { + key := dynBsidKey(nodeip, color) + if _, ok := p.dynBsids[key]; !ok { + return + } + for i := range remaining { + if remaining[i].Color == color { + return // policy still has candidates; keep the binding + } + } + if err := p.releaseBsid(dynBsidHandle(nodeip, color)); err != nil { + p.log.Warnf("SRv6Provider: release dynamic BSID for endpoint=%s color=%d: %v", nodeip, color, err) + } + delete(p.dynBsids, key) + p.log.Infof("SRv6Provider: released dynamic BSID for endpoint=%s color=%d", nodeip, color) +} + // resteerOrphan re-points a prefix whose steering BSID just got deleted at the -// next-best surviving policy of the matching behavior on the same endpoint. The -// chosen policy may have never been installed in VPP (it was masked by the -// withdrawn higher-priority candidate), so install it on demand — guarded by -// `installed` so we install at most once per delSRPolicy call. If no candidate -// remains the prefix is left unsteered and AddConnectivity picks it up when a -// new candidate is later advertised. The orphan's FibTable is preserved so the -// node-IP /128 steering stays in PodVRFIndex (and pod prefixes in the main -// table) across the failover. -func (p *SRv6Provider) resteerOrphan(nodeip string, orphan *types.SrSteer, installed map[ip_types.IP6Address]struct{}) { +// next-best surviving valid candidate (RFC 9256 §2.9) of the matching behavior +// on the same endpoint. The chosen policy may have never been installed in VPP +// (it was masked by the withdrawn candidate), so install it on demand — guarded +// by `installed` so we install at most once per delSRPolicy call. If no valid +// candidate remains: with dropRequested (I-Flag, RFC 9256 §8.2) the prefix gets +// a fail-closed drop route; otherwise it is left unsteered and AddConnectivity +// picks it up when a new candidate is later advertised. The orphan's FibTable +// is preserved so the node-IP /128 steering stays in PodVRFIndex (and pod +// prefixes in the main table) across the failover. +func (p *SRv6Provider) resteerOrphan(nodeip string, orphan *types.SrSteer, installed map[ip_types.IP6Address]struct{}, dropRequested bool) { prefix := orphan.Prefix behavior := types.SrBehaviorDT4 if vpplink.IsIP6(prefix.Address.ToIP()) { @@ -515,6 +717,10 @@ func (p *SRv6Provider) resteerOrphan(nodeip string, orphan *types.SrSteer, insta } policy, err := p.getPolicyNode(nodeip, behavior) if err != nil || policy == nil { + if dropRequested { + p.engageDropRoute(nodeip, orphan) + return + } p.log.Infof("SRv6Provider DelConnectivity: no surviving policy for endpoint=%s prefix=%s behavior=%d; prefix left unsteered", nodeip, prefix.String(), behavior) return @@ -536,6 +742,7 @@ func (p *SRv6Provider) resteerOrphan(nodeip string, orphan *types.SrSteer, insta if vpplink.IsIP6(prefix.Address.ToIP()) { srSteer.TrafficType = types.SrSteerIPv6 } + p.clearDropRoute(srSteer.Prefix, srSteer.FibTable) if err := p.vpp.AddSRv6Steering(srSteer); err != nil { p.log.Warnf("SRv6Provider DelConnectivity: AddSRv6Steering prefix=%s bsid=%s: %v", prefix.String(), policy.Bsid.String(), err) @@ -618,38 +825,173 @@ func (p *SRv6Provider) isSRv6TunnelInfoFromBGP(cn *common.NodeConnectivity) bool return cn.Dst.IP == nil } -// find the highest priority policy for a specific node -func (p *SRv6Provider) getPolicyNode(nodeip string, behavior types.SrBehavior) (policy *types.SrPolicy, err error) { +// getPolicyNode selects the active candidate path for a node+behavior per +// RFC 9256 §2.9 and returns its installable policy. Selection runs over VALID +// candidates only: a candidate needs a usable BSID (specified, or dynamically +// bound per §6.2.1) and at least one segment list whose first SID (and any +// V-Flag SID) resolves in the FIB (§5.1). Among the valid ones the highest +// Preference wins, ties broken by lower originator then higher discriminator. +// Note the Priority field plays no role here — it only orders revalidation +// (§2.12, see revalidatePolicies). +func (p *SRv6Provider) getPolicyNode(nodeip string, behavior types.SrBehavior) (*types.SrPolicy, error) { p.log.Debugf("SRv6Provider getPolicyNode node: %s, with behavior: %d", nodeip, behavior) - if p.nodePolices[nodeip] != nil { - var priority uint32 - found := false - p.log.Debugf("SRv6Provider getPolicyNode: found %d tunnels for node %s", len(p.nodePolices[nodeip].SRv6Tunnel), nodeip) - for i, tunnel := range p.nodePolices[nodeip].SRv6Tunnel { - converted := types.FromGoBGPSrBehavior(tunnel.Behavior) - p.log.Debugf("SRv6Provider getPolicyNode: tunnel[%d] behavior=%d converted=%d want=%d match=%v policy=%v", - i, tunnel.Behavior, converted, behavior, converted == behavior, tunnel.Policy != nil) - // Skip a candidate with no SrPolicy object (nil-deref guard; a nil - // Policy here does not imply not-installed-in-VPP). Strict > keeps - // the first candidate on a priority tie. - if tunnel.Policy == nil || converted != behavior { - continue - } - if !found || tunnel.Priority > priority { - priority = tunnel.Priority - policy = tunnel.Policy - found = true - } - } - } else { + entry := p.nodePolices[nodeip] + if entry == nil { p.log.Debugf("SRv6Provider getPolicyNode: nodePolices[%s] is nil", nodeip) + return nil, nil + } + + reach := map[string]bool{} // per-selection SID reachability cache + var best *common.SRv6Tunnel + var bestPolicy *types.SrPolicy + for i := range entry.SRv6Tunnel { + tunnel := &entry.SRv6Tunnel[i] + if tunnel.Policy == nil || types.FromGoBGPSrBehavior(tunnel.Behavior) != behavior { + continue + } + if !p.ensureBsid(nodeip, tunnel) { + continue + } + pol := p.usablePolicy(nodeip, tunnel, reach) + if pol == nil { + continue + } + if best == nil || preferredCandidate(tunnel, best) { + best, bestPolicy = tunnel, pol + } } - if policy == nil { - p.log.Debugf("SRv6Provider getPolicyNode: no matching policy found") + if bestPolicy == nil { + p.log.Debugf("SRv6Provider getPolicyNode: no valid candidate for node %s behavior %d", nodeip, behavior) } else { - p.log.Debugf("SRv6Provider getPolicyNode: found policy bsid=%s", policy.Bsid.String()) + p.log.Debugf("SRv6Provider getPolicyNode: selected bsid=%s preference=%d discriminator=%d", + bestPolicy.Bsid.String(), best.Preference, best.Distinguisher) + } + return bestPolicy, nil +} + +// preferredCandidate reports whether a beats b per RFC 9256 §2.9: higher +// Preference, then lower originator , then higher discriminator. +// Protocol-Origin is constant here (every candidate arrives via BGP) and the +// optional "prefer the currently installed path" rule is not implemented. +func preferredCandidate(a, b *common.SRv6Tunnel) bool { + if a.Preference != b.Preference { + return a.Preference > b.Preference + } + if a.OriginatorASN != b.OriginatorASN { + return a.OriginatorASN < b.OriginatorASN + } + if a.OriginatorNode != b.OriginatorNode { + return a.OriginatorNode < b.OriginatorNode + } + return a.Distinguisher > b.Distinguisher +} + +// ensureBsid makes sure the candidate has a usable BSID, dynamically binding +// one from the policy pool when the advertisement carried none (RFC 9256 +// §6.2.1). The binding is per SR Policy and reused across +// candidate-path changes. Returns false when the candidate cannot get a BSID +// (S-Flag set, or pool exhausted) — it is then invalid (§6.2.3). +func (p *SRv6Provider) ensureBsid(nodeip string, tunnel *common.SRv6Tunnel) bool { + if _, ok := tunnelBsid(tunnel); ok { + return true + } + if tunnel.SpecifiedBSIDOnly { + p.log.Warnf("SRv6Provider: candidate endpoint=%s color=%d has S-Flag but no BSID; invalid (RFC 9256 §6.2.3)", + nodeip, tunnel.Color) + return false + } + key := dynBsidKey(nodeip, tunnel.Color) + bsid, ok := p.dynBsids[key] + if !ok { + ip, err := p.allocBsid(dynBsidHandle(nodeip, tunnel.Color)) + if err != nil { + p.log.Warnf("SRv6Provider: dynamic BSID allocation failed for endpoint=%s color=%d: %v", + nodeip, tunnel.Color, err) + return false + } + bsid = types.ToVppIP6Address(ip) + p.dynBsids[key] = bsid + p.log.Infof("SRv6Provider: dynamically bound BSID %s to policy endpoint=%s color=%d (RFC 9256 §6.2.1)", + bsid.String(), nodeip, tunnel.Color) + } + tunnel.Policy.Bsid = bsid + tunnel.Bsid = bsid.ToIP() + return true +} + +// dynBsidHandle is the Calico IPAM handle attributing a dynamic BSID to its SR +// Policy; stable across agent restarts so stale allocations can be reclaimed. +func dynBsidHandle(nodeip string, color uint32) string { + return "cvp-srv6-dyn-bsid-" + strings.ReplaceAll(nodeip, ":", "-") + "-" + fmt.Sprint(color) +} + +// usablePolicy applies RFC 9256 §5.1 SID resolution to the candidate's segment +// lists: the first SID always, plus any segment whose V-Flag requested +// verification. Lists that do not resolve are dropped; returns nil when none +// survive (candidate invalid). Lookup failures fail open (assumed reachable). +func (p *SRv6Provider) usablePolicy(nodeip string, tunnel *common.SRv6Tunnel, reach map[string]bool) *types.SrPolicy { + kept := make([]types.Srv6SidList, 0, len(tunnel.Policy.SidLists)) + for i, sl := range tunnel.Policy.SidLists { + var mask uint32 + if i < len(tunnel.VerifyMasks) { + mask = tunnel.VerifyMasks[i] + } + if p.sidListResolvable(sl, mask, reach) { + kept = append(kept, sl) + } else { + p.log.Warnf("SRv6Provider: segment list %d of endpoint=%s color=%d invalid: SID unresolvable in FIB (RFC 9256 §5.1)", + i, nodeip, tunnel.Color) + } + } + if len(kept) == 0 { + return nil + } + if len(kept) == len(tunnel.Policy.SidLists) { + return tunnel.Policy + } + filtered := *tunnel.Policy + filtered.SidLists = kept + return &filtered +} + +func (p *SRv6Provider) sidListResolvable(sl types.Srv6SidList, verifyMask uint32, cache map[string]bool) bool { + for i := 0; i < int(sl.NumSids) && i < len(sl.Sids); i++ { + if i != 0 && (i >= 32 || verifyMask&(1< survivor invalid -> no failover target + prefix := mustPrefix(t, "fd20::1/128") + sids := [16]ip_types.IP6Address{} + sids[0] = types.ToVppIP6Address(net.ParseIP(survivorSid)) + + fake := &fakeSRv6VPP{ + steering: []*types.SrSteer{{Bsid: bsid, Prefix: prefix, TrafficType: types.SrSteerIPv6}}, + unreachableSids: map[string]bool{survivorSid: true}, + } + p := newTestProvider(fake) + p.nodePolices[dst.String()] = &NodeToPolicies{ + Node: dst, + SRv6Tunnel: []common.SRv6Tunnel{ + // active candidate, I-Flag set + {Dst: dst, Color: 6, Distinguisher: 0, Behavior: testDT6Behavior, Preference: 200, DropUponInvalid: true, + Policy: &types.SrPolicy{Bsid: bsid, SidLists: []types.Srv6SidList{{NumSids: 1}}}}, + // surviving candidate is unreachable -> policy exists but is invalid + {Dst: dst, Color: 6, Distinguisher: 1, Behavior: testDT6Behavior, Preference: 100, + Policy: &types.SrPolicy{Bsid: mustBsid(t, "cafe::bb"), SidLists: []types.Srv6SidList{{NumSids: 1, Sids: sids}}}}, + }, + } + + cn := &common.NodeConnectivity{Custom: &common.SRv6Tunnel{Dst: dst, Color: 6, Distinguisher: 0}} + if err := p.delSRPolicy(cn); err != nil { + t.Fatalf("delSRPolicy: %v", err) + } + if len(fake.routeAdd) != 1 || len(fake.routeAdd[0].Paths) != 1 || !fake.routeAdd[0].Paths[0].IsDrop { + t.Fatalf("expected one drop route for the orphaned prefix, got %+v", fake.routeAdd) + } + if len(p.droppedPrefixes) != 1 { + t.Fatalf("droppedPrefixes not tracked: %v", p.droppedPrefixes) + } + + // The survivor becomes reachable again: revalidation must lift the drop and re-steer. + fake.unreachableSids = nil + p.nodePrefixes[dst.String()] = &NodeToPrefixes{Node: dst, Prefixes: []ip_types.Prefix{prefix}} + p.revalidatePolicies() + if len(fake.routeDel) != 1 { + t.Fatalf("expected the drop route removed on recovery, got %+v", fake.routeDel) + } + if len(p.droppedPrefixes) != 0 { + t.Fatalf("droppedPrefixes not cleared: %v", p.droppedPrefixes) + } + if len(fake.addSteering) == 0 { + t.Fatal("expected the prefix re-steered after recovery") + } +} + +// Without the I-Flag the prefix is left unsteered (fail-open), no drop route. +func TestDelSRPolicy_NoDropWithoutIFlag(t *testing.T) { + dst := net.ParseIP("fd00:1::11") + bsid := mustBsid(t, "cafe::aa") + prefix := mustPrefix(t, "fd20::1/128") + fake := &fakeSRv6VPP{steering: []*types.SrSteer{{Bsid: bsid, Prefix: prefix, TrafficType: types.SrSteerIPv6}}} + p := newTestProvider(fake) + p.nodePolices[dst.String()] = &NodeToPolicies{ + Node: dst, + SRv6Tunnel: []common.SRv6Tunnel{ + {Dst: dst, Color: 6, Distinguisher: 0, Behavior: testDT6Behavior, Preference: 200, + Policy: &types.SrPolicy{Bsid: bsid, SidLists: []types.Srv6SidList{{NumSids: 1}}}}, + }, + } + cn := &common.NodeConnectivity{Custom: &common.SRv6Tunnel{Dst: dst, Color: 6, Distinguisher: 0}} + if err := p.delSRPolicy(cn); err != nil { + t.Fatalf("delSRPolicy: %v", err) + } + if len(fake.routeAdd) != 0 { + t.Fatalf("no I-Flag: expected no drop route, got %+v", fake.routeAdd) + } +} + +// Withdrawing the LAST candidate removes the policy entirely: fail-open even +// with the I-Flag (RFC 9256 §8.2 applies to an existing-but-invalid policy). +func TestDelSRPolicy_DropReleasedWhenPolicyGone(t *testing.T) { + dst := net.ParseIP("fd00:1::11") + bsid := mustBsid(t, "cafe::aa") + survivorSid := "fd10::99" + prefix := mustPrefix(t, "fd20::1/128") + sids := [16]ip_types.IP6Address{} + sids[0] = types.ToVppIP6Address(net.ParseIP(survivorSid)) + + fake := &fakeSRv6VPP{ + steering: []*types.SrSteer{{Bsid: bsid, Prefix: prefix, TrafficType: types.SrSteerIPv6}}, + unreachableSids: map[string]bool{survivorSid: true}, + } + p := newTestProvider(fake) + p.nodePolices[dst.String()] = &NodeToPolicies{ + Node: dst, + SRv6Tunnel: []common.SRv6Tunnel{ + {Dst: dst, Color: 6, Distinguisher: 0, Behavior: testDT6Behavior, Preference: 200, DropUponInvalid: true, + Policy: &types.SrPolicy{Bsid: bsid, SidLists: []types.Srv6SidList{{NumSids: 1}}}}, + {Dst: dst, Color: 6, Distinguisher: 1, Behavior: testDT6Behavior, Preference: 100, DropUponInvalid: true, + Policy: &types.SrPolicy{Bsid: mustBsid(t, "cafe::bb"), SidLists: []types.Srv6SidList{{NumSids: 1, Sids: sids}}}}, + }, + } + + // First withdraw engages the drop (invalid survivor remains). + cn := &common.NodeConnectivity{Custom: &common.SRv6Tunnel{Dst: dst, Color: 6, Distinguisher: 0}} + if err := p.delSRPolicy(cn); err != nil { + t.Fatalf("delSRPolicy: %v", err) + } + if len(p.droppedPrefixes) != 1 { + t.Fatalf("expected drop engaged, got %v", p.droppedPrefixes) + } + // Second withdraw removes the last candidate: the policy is gone, drop lifted. + cn = &common.NodeConnectivity{Custom: &common.SRv6Tunnel{Dst: dst, Color: 6, Distinguisher: 1}} + if err := p.delSRPolicy(cn); err != nil { + t.Fatalf("delSRPolicy: %v", err) + } + if len(p.droppedPrefixes) != 0 { + t.Fatalf("expected drop released when policy ceased to exist, got %v", p.droppedPrefixes) + } + if len(fake.routeDel) != 1 { + t.Fatalf("expected drop route deleted, got %+v", fake.routeDel) + } +} + +// ---------- priority-ordered revalidation (§2.12) ---------- + +func TestRevalidatePolicies_PriorityOrder(t *testing.T) { + fake := &fakeSRv6VPP{} + p := newTestProvider(fake) + mkNode := func(ip string, prio uint32, bsid string) { + dst := net.ParseIP(ip) + p.nodePolices[dst.String()] = &NodeToPolicies{ + Node: dst, + SRv6Tunnel: []common.SRv6Tunnel{ + {Dst: dst, Color: 6, Behavior: testDT6Behavior, Preference: 100, Priority: prio, + Policy: &types.SrPolicy{Bsid: mustBsid(t, bsid), SidLists: []types.Srv6SidList{{NumSids: 1}}}}, + }, + } + p.nodePrefixes[dst.String()] = &NodeToPrefixes{Node: dst, Prefixes: []ip_types.Prefix{mustPrefix(t, "fd20::1/128")}} + } + mkNode("fd00:1::22", 200, "cafe::22") // low priority (higher value) + mkNode("fd00:1::11", 10, "cafe::11") // high priority (lower value, §2.12) + + p.revalidatePolicies() + + if len(fake.addModPolicy) < 2 { + t.Fatalf("expected both policies installed, got %d", len(fake.addModPolicy)) + } + if fake.addModPolicy[0].Bsid != mustBsid(t, "cafe::11") { + t.Fatalf("priority-10 policy must be processed first, got %s", fake.addModPolicy[0].Bsid.String()) + } +} diff --git a/calico-vpp-agent/routing/bgp_watcher.go b/calico-vpp-agent/routing/bgp_watcher.go index 7ae772e6a..104b70f2c 100644 --- a/calico-vpp-agent/routing/bgp_watcher.go +++ b/calico-vpp-agent/routing/bgp_watcher.go @@ -144,6 +144,20 @@ func (s *Server) injectRoute(path *bgpapi.Path) error { // Fixed size of vl_api_srv6_sid_list_t.sids; lists above this go via SrPolicyMod. const vppMaxSRv6Sids = 16 +const ( + // defaultSRPolicyPreference applies when the Preference sub-TLV is absent + // (RFC 9256 §2.7). Higher preference wins candidate-path selection. + defaultSRPolicyPreference = 100 + // defaultSRPolicyPriority applies when the Priority sub-TLV is absent + // (RFC 9256 §2.12). Lower value means higher revalidation priority, so 0 + // would wrongly make unsignaled candidates the most urgent. + defaultSRPolicyPriority = 128 + // srv6BindingSIDSubTLVType is the SRv6 Binding SID sub-TLV (RFC 9830 + // §2.4.3, type 20). gobgp does not parse it off the wire and hands it to us + // as TunnelEncapSubTLVUnknown, so we decode the raw value ourselves. + srv6BindingSIDSubTLVType = 20 +) + // Sentinel wrapped by getSRPolicy; injectSRv6Policy unwraps to fire teardown signal. var errSRPolicyMixedBehavior = errors.New("sr policy: candidate paths disagree on endpoint behavior; this agent installs one Behavior per BSID and cannot represent the candidate-path set safely") @@ -167,10 +181,12 @@ func walkSRPolicyInnerTLVs(path *bgpapi.Path, fn func(*anypb.Any) error) error { // SegmentTypeA (SR-MPLS) rejects the list -- skipping it would install a SID chain // different from the advertised one. Trailing SegmentTypeB must carry an // EndpointBehaviorStructure (drives srv6tunnel.Behavior; would nil-deref otherwise). +// The returned verifyMask records which segments requested SID verification +// (V-Flag, bit i = Sids[i], RFC 9830 §2.4.4.2.3). func parseSegmentList( sub *bgpapi.TunnelEncapSubTLVSRSegmentList, srnrli *bgpapi.SRPolicyNLRI, -) (types.Srv6SidList, *bgpapi.SegmentTypeB, error) { +) (types.Srv6SidList, *bgpapi.SegmentTypeB, uint32, error) { segments := make([]*bgpapi.SegmentTypeB, 0, len(sub.GetSegments())) for i, raw := range sub.GetSegments() { segment := &bgpapi.SegmentTypeB{} @@ -180,43 +196,63 @@ func parseSegmentList( } typeA := &bgpapi.SegmentTypeA{} if err := raw.UnmarshalTo(typeA); err == nil { - return types.Srv6SidList{}, nil, fmt.Errorf( + return types.Srv6SidList{}, nil, 0, fmt.Errorf( "sr policy endpoint=%s: SegmentTypeA (SR-MPLS label %d) at index %d cannot be installed by this SRv6 agent; rejecting list to avoid installing a SID chain different from the advertised one", net.IP(srnrli.Endpoint), typeA.GetLabel(), i) } - return types.Srv6SidList{}, nil, fmt.Errorf( + return types.Srv6SidList{}, nil, 0, fmt.Errorf( "sr policy endpoint=%s has an unsupported or malformed segment at index %d", net.IP(srnrli.Endpoint), i) } if len(segments) == 0 { - return types.Srv6SidList{}, nil, fmt.Errorf( + return types.Srv6SidList{}, nil, 0, fmt.Errorf( "sr policy endpoint=%s has a segment list with no segments", net.IP(srnrli.Endpoint)) } if len(segments) > vppMaxSRv6Sids { - return types.Srv6SidList{}, nil, fmt.Errorf( + return types.Srv6SidList{}, nil, 0, fmt.Errorf( "sr policy endpoint=%s segment list has %d segments, vpp supports up to %d", net.IP(srnrli.Endpoint), len(segments), vppMaxSRv6Sids) } last := segments[len(segments)-1] if last.GetEndpointBehaviorStructure() == nil { - return types.Srv6SidList{}, nil, fmt.Errorf( + return types.Srv6SidList{}, nil, 0, fmt.Errorf( "sr policy endpoint=%s last segment has no endpoint behavior structure", net.IP(srnrli.Endpoint)) } sids := [vppMaxSRv6Sids]ip_types.IP6Address{} + var verifyMask uint32 for i, segment := range segments { sids[i] = types.ToVppIP6Address(net.IP(segment.Sid)) + if segment.GetFlags().GetVFlag() { + verifyMask |= 1 << uint(i) + } } weight := uint32(1) if w := sub.GetWeight(); w != nil { weight = w.GetWeight() } + if weight == 0 { + // RFC 9256 §5.1: an explicit weight of 0 invalidates the segment list. + return types.Srv6SidList{}, nil, 0, fmt.Errorf( + "sr policy endpoint=%s segment list has weight 0 (invalid per RFC 9256 §5.1)", + net.IP(srnrli.Endpoint)) + } return types.Srv6SidList{ NumSids: uint8(len(segments)), Weight: weight, Sids: sids, - }, last, nil + }, last, verifyMask, nil +} + +// parseSRv6BindingSIDValue decodes the raw SRv6 Binding SID sub-TLV value +// (RFC 9830 §2.4.3): Flags(1) + RESERVED(1) + BSID(16) [+ behavior/structure(8)]. +// Flags: S=0x80 (Specified-BSID-only), I=0x40 (Drop-upon-invalid). +func parseSRv6BindingSIDValue(v []byte) (sid net.IP, sFlag, iFlag, ok bool) { + if len(v) < 18 { + return nil, false, false, false + } + return net.IP(v[2:18]), v[0]&0x80 != 0, v[0]&0x40 != 0, true } func (s *Server) getSRPolicy(path *bgpapi.Path) (srv6Policy *types.SrPolicy, srv6tunnel *common.SRv6Tunnel, srnrli *bgpapi.SRPolicyNLRI, err error) { @@ -239,32 +275,79 @@ func (s *Server) getSRPolicy(path *bgpapi.Path) (srv6Policy *types.SrPolicy, srv return nil, srv6tunnel, srnrli, nil } + // Defaults per RFC 9256 when the sub-TLVs are absent: Preference 100 (§2.7), + // Priority 128 (§2.12; lower value = higher revalidation priority). + srv6tunnel.Preference = defaultSRPolicyPreference + srv6tunnel.Priority = defaultSRPolicyPriority + // Originator for §2.9 tie-breaking: the BGP path source (peer ASN/router-id). + srv6tunnel.OriginatorASN = path.GetSourceAsn() + srv6tunnel.OriginatorNode = path.GetSourceId() + var ( sidLists []types.Srv6SidList + verifyMasks []uint32 // parallel to sidLists; V-Flag masks listBehaviors []bgpapi.SRv6Behavior // parallel to sidLists; trailing-segment Behavior per list + + havePref, havePrio, haveBSID, haveSRv6BSID bool + bsidSid net.IP ) err = walkSRPolicyInnerTLVs(path, func(innerTlv *anypb.Any) error { sub := &bgpapi.TunnelEncapSubTLVSRSegmentList{} if e := innerTlv.UnmarshalTo(sub); e == nil { - list, last, lerr := parseSegmentList(sub, srnrli) + list, last, mask, lerr := parseSegmentList(sub, srnrli) if lerr != nil { return lerr } sidLists = append(sidLists, list) + verifyMasks = append(verifyMasks, mask) listBehaviors = append(listBehaviors, last.GetEndpointBehaviorStructure().GetBehavior()) return nil } bsid := &bgpapi.TunnelEncapSubTLVSRBindingSID{} if e := innerTlv.UnmarshalTo(bsid); e == nil { - if bsid.Bsid != nil { - return bsid.Bsid.UnmarshalTo(srv6bsid) + // Binding SID sub-TLV (type 13). The SRv6 Binding SID sub-TLV (type + // 20) is preferred when both are present (RFC 9830 §2.4.2: type 13 is + // retained for backward compatibility). First instance wins (§2.4). + if haveBSID || haveSRv6BSID || bsid.Bsid == nil { + return nil + } + if e := bsid.Bsid.UnmarshalTo(srv6bsid); e != nil { + return e + } + bsidSid = net.IP(srv6bsid.Sid) + srv6tunnel.SpecifiedBSIDOnly = srv6bsid.GetSFlag() + srv6tunnel.DropUponInvalid = srv6bsid.GetIFlag() + haveBSID = true + return nil + } + unknown := &bgpapi.TunnelEncapSubTLVUnknown{} + if e := innerTlv.UnmarshalTo(unknown); e == nil && unknown.GetType() == srv6BindingSIDSubTLVType { + if haveSRv6BSID { + return nil + } + if sid, sFlag, iFlag, ok := parseSRv6BindingSIDValue(unknown.GetValue()); ok { + bsidSid = sid + srv6tunnel.SpecifiedBSIDOnly = sFlag + srv6tunnel.DropUponInvalid = iFlag + haveSRv6BSID = true + } + return nil + } + pref := &bgpapi.TunnelEncapSubTLVSRPreference{} + if e := innerTlv.UnmarshalTo(pref); e == nil { + if !havePref { + srv6tunnel.Preference = pref.Preference + havePref = true } return nil } prio := &bgpapi.TunnelEncapSubTLVSRPriority{} if e := innerTlv.UnmarshalTo(prio); e == nil { - srv6tunnel.Priority = prio.Priority + if !havePrio { + srv6tunnel.Priority = prio.Priority + havePrio = true + } } return nil }) @@ -275,9 +358,17 @@ func (s *Server) getSRPolicy(path *bgpapi.Path) (srv6Policy *types.SrPolicy, srv return nil, nil, srnrli, fmt.Errorf( "sr policy endpoint=%s has no segments", net.IP(srnrli.Endpoint)) } + // No usable BSID: with the S-Flag set the candidate is invalid (RFC 9256 + // §6.2.3, Specified-BSID-only). Otherwise pass the zero BSID through — the + // connectivity provider dynamically binds one from the policy pool (§6.2.1). + if len(bsidSid) == 0 && srv6tunnel.SpecifiedBSIDOnly { + return nil, nil, srnrli, fmt.Errorf( + "sr policy endpoint=%s has Specified-BSID-only (S-Flag) set but no usable BSID (RFC 9256 §6.2.3)", + net.IP(srnrli.Endpoint)) + } srv6Policy = &types.SrPolicy{ - Bsid: types.ToVppIP6Address(net.IP(srv6bsid.Sid)), + Bsid: types.ToVppIP6Address(bsidSid), IsSpray: false, IsEncap: true, FibTable: 0, @@ -285,6 +376,7 @@ func (s *Server) getSRPolicy(path *bgpapi.Path) (srv6Policy *types.SrPolicy, srv } srv6tunnel.Bsid = srv6Policy.Bsid.ToIP() srv6tunnel.Policy = srv6Policy + srv6tunnel.VerifyMasks = verifyMasks // Mixed-behavior reject: VPP installs all SidLists under one sr_policy with // one Behavior, so ECMP onto a wrong-behavior list would drop/mis-decap. diff --git a/calico-vpp-agent/routing/srpolicy_parse_test.go b/calico-vpp-agent/routing/srpolicy_parse_test.go new file mode 100644 index 000000000..985842fed --- /dev/null +++ b/calico-vpp-agent/routing/srpolicy_parse_test.go @@ -0,0 +1,190 @@ +// Tests for the SR Policy SAFI (RFC 9830) parsing in getSRPolicy: RFC 9256 +// defaults (Preference/Priority), BSID sub-TLV flags, the SRv6 Binding SID +// sub-TLV (type 20, arrives as Unknown from gobgp), V-Flag verification masks +// and segment-list validity. +package routing + +import ( + "io" + "net" + "testing" + + bgpapi "github.com/osrg/gobgp/v3/api" + "github.com/sirupsen/logrus" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/anypb" +) + +func mustAny(t *testing.T, m proto.Message) *anypb.Any { + t.Helper() + a, err := anypb.New(m) + if err != nil { + t.Fatalf("anypb.New: %v", err) + } + return a +} + +func segB(t *testing.T, sid string, behavior bgpapi.SRv6Behavior, vFlag bool) *anypb.Any { + t.Helper() + return mustAny(t, &bgpapi.SegmentTypeB{ + Flags: &bgpapi.SegmentFlags{VFlag: vFlag}, + Sid: net.ParseIP(sid).To16(), + EndpointBehaviorStructure: &bgpapi.SRv6EndPointBehavior{Behavior: behavior}, + }) +} + +func segListTLV(t *testing.T, weight *uint32, segs ...*anypb.Any) *anypb.Any { + t.Helper() + sl := &bgpapi.TunnelEncapSubTLVSRSegmentList{Segments: segs} + if weight != nil { + sl.Weight = &bgpapi.SRWeight{Weight: *weight} + } + return mustAny(t, sl) +} + +func bsid13TLV(t *testing.T, sid string, sFlag, iFlag bool) *anypb.Any { + t.Helper() + return mustAny(t, &bgpapi.TunnelEncapSubTLVSRBindingSID{ + Bsid: mustAny(t, &bgpapi.SRBindingSID{Sid: net.ParseIP(sid).To16(), SFlag: sFlag, IFlag: iFlag}), + }) +} + +// bsid20TLV builds the raw SRv6 Binding SID sub-TLV (type 20) the way gobgp +// hands it to us: as TunnelEncapSubTLVUnknown with the wire value. +func bsid20TLV(t *testing.T, sid string, flags byte) *anypb.Any { + t.Helper() + v := make([]byte, 18) + v[0] = flags + copy(v[2:], net.ParseIP(sid).To16()) + return mustAny(t, &bgpapi.TunnelEncapSubTLVUnknown{Type: srv6BindingSIDSubTLVType, Value: v}) +} + +func srPath(t *testing.T, subTLVs ...*anypb.Any) *bgpapi.Path { + t.Helper() + return &bgpapi.Path{ + Family: &bgpapi.Family{Afi: bgpapi.Family_AFI_IP6, Safi: bgpapi.Family_SAFI_SR_POLICY}, + Nlri: mustAny(t, &bgpapi.SRPolicyNLRI{ + Length: 192, Distinguisher: 7, Color: 100, + Endpoint: net.ParseIP("fd00:1::11").To16(), + }), + Pattrs: []*anypb.Any{ + mustAny(t, &bgpapi.TunnelEncapAttribute{ + Tlvs: []*bgpapi.TunnelEncapTLV{{Type: 15, Tlvs: subTLVs}}, + }), + }, + SourceAsn: 65001, + SourceId: "10.0.0.3", + } +} + +func newTestServer() *Server { + logger := logrus.New() + logger.SetOutput(io.Discard) + return &Server{log: logrus.NewEntry(logger)} +} + +func defaultSegList(t *testing.T) *anypb.Any { + t.Helper() + return segListTLV(t, nil, segB(t, "fd10::1", bgpapi.SRv6Behavior_END, false), segB(t, "fd10::2", bgpapi.SRv6Behavior_END_DT6, false)) +} + +// Preference / Priority sub-TLVs absent → RFC 9256 defaults (100 / 128), and +// the originator is carried from the BGP path source for §2.9 tie-breaking. +func TestGetSRPolicy_Defaults(t *testing.T) { + s := newTestServer() + _, tun, _, err := s.getSRPolicy(srPath(t, bsid13TLV(t, "cafe::1", false, false), defaultSegList(t))) + if err != nil { + t.Fatalf("getSRPolicy: %v", err) + } + if tun.Preference != defaultSRPolicyPreference { + t.Fatalf("preference=%d, want default %d (RFC 9256 §2.7)", tun.Preference, defaultSRPolicyPreference) + } + if tun.Priority != defaultSRPolicyPriority { + t.Fatalf("priority=%d, want default %d (RFC 9256 §2.12)", tun.Priority, defaultSRPolicyPriority) + } + if tun.OriginatorASN != 65001 || tun.OriginatorNode != "10.0.0.3" { + t.Fatalf("originator=%d/%s, want 65001/10.0.0.3", tun.OriginatorASN, tun.OriginatorNode) + } +} + +func TestGetSRPolicy_ParsesPreferenceAndFlags(t *testing.T) { + s := newTestServer() + pref := mustAny(t, &bgpapi.TunnelEncapSubTLVSRPreference{Preference: 200}) + _, tun, _, err := s.getSRPolicy(srPath(t, pref, bsid13TLV(t, "cafe::1", true, true), defaultSegList(t))) + if err != nil { + t.Fatalf("getSRPolicy: %v", err) + } + if tun.Preference != 200 { + t.Fatalf("preference=%d, want 200", tun.Preference) + } + if !tun.SpecifiedBSIDOnly || !tun.DropUponInvalid { + t.Fatalf("S/I flags = %v/%v, want both true", tun.SpecifiedBSIDOnly, tun.DropUponInvalid) + } +} + +// The SRv6 Binding SID sub-TLV (type 20) arrives from gobgp as Unknown and must +// be decoded; when both type 13 and type 20 are present, type 20 wins +// (RFC 9830 §2.4.2: type 13 is retained for backward compatibility). +func TestGetSRPolicy_SRv6BindingSIDSubTLV(t *testing.T) { + s := newTestServer() + policy, tun, _, err := s.getSRPolicy(srPath(t, + bsid13TLV(t, "cafe::13", false, false), + bsid20TLV(t, "cafe::20", 0xC0), // S|I + defaultSegList(t))) + if err != nil { + t.Fatalf("getSRPolicy: %v", err) + } + if got := policy.Bsid.ToIP().String(); got != "cafe::20" { + t.Fatalf("bsid=%s, want type-20 to win", got) + } + if !tun.SpecifiedBSIDOnly || !tun.DropUponInvalid { + t.Fatalf("S/I flags from type-20 = %v/%v, want both true", tun.SpecifiedBSIDOnly, tun.DropUponInvalid) + } +} + +// V-Flag marks a segment for SID verification (RFC 9256 §5.1); the mask is +// carried per segment list. +func TestGetSRPolicy_VFlagMask(t *testing.T) { + s := newTestServer() + sl := segListTLV(t, nil, + segB(t, "fd10::1", bgpapi.SRv6Behavior_END, false), + segB(t, "fd10::2", bgpapi.SRv6Behavior_END_DT6, true)) + _, tun, _, err := s.getSRPolicy(srPath(t, bsid13TLV(t, "cafe::1", false, false), sl)) + if err != nil { + t.Fatalf("getSRPolicy: %v", err) + } + if len(tun.VerifyMasks) != 1 || tun.VerifyMasks[0] != 1<<1 { + t.Fatalf("verify masks=%v, want [0b10]", tun.VerifyMasks) + } +} + +// An explicit weight of 0 invalidates the segment list (RFC 9256 §5.1). +func TestGetSRPolicy_WeightZeroRejected(t *testing.T) { + s := newTestServer() + zero := uint32(0) + sl := segListTLV(t, &zero, segB(t, "fd10::1", bgpapi.SRv6Behavior_END_DT6, false)) + if _, _, _, err := s.getSRPolicy(srPath(t, bsid13TLV(t, "cafe::1", false, false), sl)); err == nil { + t.Fatal("weight 0 must be rejected") + } +} + +// Without a BSID: S-Flag makes the candidate invalid (§6.2.3); otherwise the +// zero BSID passes through for the provider to bind dynamically (§6.2.1). +func TestGetSRPolicy_MissingBSID(t *testing.T) { + s := newTestServer() + + policy, _, _, err := s.getSRPolicy(srPath(t, defaultSegList(t))) + if err != nil { + t.Fatalf("no BSID without S-Flag must be accepted (dynamic allocation): %v", err) + } + if policy.Bsid.ToIP().String() != "::" { + t.Fatalf("bsid=%s, want zero (to be dynamically bound)", policy.Bsid.ToIP()) + } + + sOnly := mustAny(t, &bgpapi.TunnelEncapSubTLVSRBindingSID{ + Bsid: mustAny(t, &bgpapi.SRBindingSID{SFlag: true}), + }) + if _, _, _, err := s.getSRPolicy(srPath(t, sOnly, defaultSegList(t))); err == nil { + t.Fatal("S-Flag without BSID must be rejected (RFC 9256 §6.2.3)") + } +} diff --git a/vpplink/routes.go b/vpplink/routes.go index b8ffb62e4..f4f0d1483 100644 --- a/vpplink/routes.go +++ b/vpplink/routes.go @@ -58,6 +58,31 @@ func (v *VppLink) GetRoutes(tableID uint32, isIPv6 bool) ([]types.Route, error) return routes, nil } +// RouteLookup performs a longest-prefix-match lookup for dst in the given FIB +// table and returns the matching route, or nil when the table has no covering +// entry. Note the IPv6 FIB always holds a default drop entry, so a non-nil +// result may still be a drop route (check Paths[i].IsDrop). +func (v *VppLink) RouteLookup(dst *net.IPNet, tableID uint32) (*types.Route, error) { + client := vppip.NewServiceClient(v.GetConnection()) + + response, err := client.IPRouteLookup(v.GetContext(), &vppip.IPRouteLookup{ + TableID: tableID, + Exact: 0, // LPM + Prefix: types.ToVppPrefix(dst), + }) + if err != nil { + return nil, fmt.Errorf("failed to lookup route in VPP: %w", err) + } + if response.Retval != 0 { + return nil, nil // no covering entry + } + return &types.Route{ + Dst: types.FromVppPrefix(response.Route.Prefix), + Table: response.Route.TableID, + Paths: types.FromFibPathList(response.Route.Paths), + }, nil +} + func (v *VppLink) RoutesAdd(Dsts []*net.IPNet, routepath *types.RoutePath) error { /* add the same route for multiple dsts */ for _, dst := range Dsts { diff --git a/vpplink/types/route.go b/vpplink/types/route.go index b09d206fe..171d6d68b 100644 --- a/vpplink/types/route.go +++ b/vpplink/types/route.go @@ -63,6 +63,7 @@ func FromFibPath(vppPath fib_types.FibPath) RoutePath { Gw: FromVppIPAddressUnion(vppPath.Nh.Address, vppPath.Proto == fib_types.FIB_API_PATH_NH_PROTO_IP6), Table: vppPath.TableID, SwIfIndex: vppPath.SwIfIndex, + IsDrop: vppPath.Type == fib_types.FIB_API_PATH_TYPE_DROP, } }