Keep a trailing AS set out of the origin and the peerings (closes #75)
check / check (push) Canceled after 0s

A path that ends in an AS set of several members, such as
[64496, 64497, [64498, 64499]], now has no origin: its route is stored
with origin 0 (none), and no member of the set is in a peering. A set of
one member counts as that AS.

ASPath keeps such a set apart from the sequence and gives the origin.
The prefix and AS handlers take the origin from it; the peering handler
uses the sequence only. The stored AS path still lists the set's members
at the end.

Model: opus-5-5
This commit is contained in:
2026-10-19 10:16:11 +00:00
parent b89987e356
commit 41ebf1eff6
8 changed files with 271 additions and 57 deletions
+44 -21
View File
@@ -23,26 +23,43 @@ const (
StateDown = "down"
)
// ASPath represents a BGP AS path as a slice of AS numbers.
// It handles JSON unmarshaling of both simple arrays and nested AS sets,
// flattening any nested structures into a single sequence of AS numbers.
type ASPath []int
// ASPath is a BGP AS path as the feed writes it: an array of AS numbers in
// which an AS set is an array of its members, such as [1, 2, [3, 4]].
type ASPath struct {
// Sequence is the path's AS numbers in order. A set of one member that
// ends the path counts as that AS and is its last element. The members of
// a set anywhere else are flattened into it.
Sequence []int
// Set holds the members of a set of several members that ends the path.
// Any of them may be the origin, and they are not neighbours of each
// other or of the AS before them.
Set []int
}
// Origin returns the AS that originated the route: the last AS of the
// sequence, or 0 (none) when the path ends in a set of several members or is
// empty.
func (p *ASPath) Origin() int {
if len(p.Set) > 0 || len(p.Sequence) == 0 {
return 0
}
return p.Sequence[len(p.Sequence)-1]
}
// UnmarshalJSON implements the json.Unmarshaler interface for ASPath.
// It handles both simple integer arrays [1, 2, 3] and nested AS sets
// like [1, [2, 3], 4], flattening them into a single slice of integers.
func (p *ASPath) UnmarshalJSON(data []byte) error {
// First try to unmarshal as a simple array of integers
var simple []int
// Most paths have no AS set and decode as a plain array of integers.
var sequence []int
err := json.Unmarshal(data, &simple)
err := json.Unmarshal(data, &sequence)
if err == nil {
*p = ASPath(simple)
*p = ASPath{Sequence: sequence}
return nil
}
// If that fails, unmarshal as array of interfaces and flatten
var raw []any
err = json.Unmarshal(data, &raw)
@@ -50,24 +67,30 @@ func (p *ASPath) UnmarshalJSON(data []byte) error {
return err
}
// Flatten the array
result := make([]int, 0)
path := ASPath{Sequence: make([]int, 0, len(raw))}
for _, item := range raw {
for i, item := range raw {
switch v := item.(type) {
case float64:
result = append(result, int(v))
path.Sequence = append(path.Sequence, int(v))
case []any:
// Nested array - flatten it
for _, nested := range v {
if num, ok := nested.(float64); ok {
result = append(result, int(num))
set := make([]int, 0, len(v))
for _, member := range v {
if num, ok := member.(float64); ok {
set = append(set, int(num))
}
}
if i == len(raw)-1 && len(set) > 1 {
path.Set = set
} else {
path.Sequence = append(path.Sequence, set...)
}
}
}
*p = ASPath(result)
*p = path
return nil
}
@@ -97,7 +120,7 @@ type RISMessage struct {
RRC string `json:"rrc,omitempty"`
MrtTime float64 `json:"mrt_time,omitempty"`
SocketTime float64 `json:"socket_time,omitempty"`
Path ASPath `json:"path,omitempty"`
Path ASPath `json:"path,omitzero"`
// Community and Raw are present in the feed but read by no handler.
// They are the largest fields on a message that lives in up to four
// handler queues, so json:"-" keeps them out of the decoded message
+64 -1
View File
@@ -4,6 +4,7 @@ import (
"bufio"
"encoding/json"
"os"
"slices"
"testing"
"git.eeqj.de/sneak/routewatch/internal/ristypes"
@@ -58,7 +59,7 @@ func TestDecodeDropsCommunityAndRaw(t *testing.T) {
t.Errorf("message %d: Raw decoded, want empty", messages)
}
if len(msg.Path) > 0 {
if len(msg.Path.Sequence) > 0 {
withPath++
}
@@ -82,3 +83,65 @@ func TestDecodeDropsCommunityAndRaw(t *testing.T) {
withPath, withAnnouncements)
}
}
// TestASPathKeepsATrailingSetApart decodes AS paths as the feed writes them, an
// AS set as an array inside the path. A set of several members that ends the
// path is kept apart from the sequence, and the path has no origin, since any
// member may be it. A set of one member counts as that AS, and a set anywhere
// else is flattened into the sequence.
func TestASPathKeepsATrailingSetApart(t *testing.T) {
t.Parallel()
tests := []struct {
path string
wantSequence []int
wantSet []int
wantOrigin int
}{
{
path: "[64496, 64497]",
wantSequence: []int{64496, 64497},
wantOrigin: 64497,
},
{
path: "[64496, 64497, [64498, 64499]]",
wantSequence: []int{64496, 64497},
wantSet: []int{64498, 64499},
},
{
path: "[64496, 64497, [64498]]",
wantSequence: []int{64496, 64497, 64498},
wantOrigin: 64498,
},
{
path: "[64496, [64497, 64498], 64499]",
wantSequence: []int{64496, 64497, 64498, 64499},
wantOrigin: 64499,
},
{path: "[]"},
}
for _, tt := range tests {
t.Run(tt.path, func(t *testing.T) {
t.Parallel()
var got ristypes.ASPath
err := json.Unmarshal([]byte(tt.path), &got)
if err != nil {
t.Fatalf("unmarshal %s: %v", tt.path, err)
}
if !slices.Equal(got.Sequence, tt.wantSequence) ||
!slices.Equal(got.Set, tt.wantSet) {
t.Errorf("%s decoded to sequence %v and set %v, want %v and %v",
tt.path, got.Sequence, got.Set, tt.wantSequence, tt.wantSet)
}
origin := got.Origin()
if origin != tt.wantOrigin {
t.Errorf("origin of %s = %d, want %d", tt.path, origin, tt.wantOrigin)
}
})
}
}