Treat a path with AS 0 anywhere as a withdrawal (closes #141)
check / check (push) Canceled after 0s

An AS path that holds AS 0 anywhere, a member of an AS set included, now
decodes as an empty path. RFC 7607 makes such a path malformed and its
announcements withdrawals. An empty path already gets that handling:
the prefix handler deletes the peer's route for the prefix, and the AS
and peering handlers record nothing from it. Before, a path such as
[64496, 0, 64497] stored the route with origin AS64497, recorded both
ASes, and recorded the peerings between the path's other ASes.

Model: opus-5-5
This commit is contained in:
2026-10-20 01:57:56 +00:00
parent 100689a4fb
commit ccd5203735
7 changed files with 141 additions and 25 deletions
+5 -5
View File
@@ -195,11 +195,11 @@ routewatch/
- **ASHandler**: Tracks all ASNs seen in AS paths - **ASHandler**: Tracks all ASNs seen in AS paths
- **PeerHandler**: Records BGP peer information - **PeerHandler**: Records BGP peer information
- **PrefixHandler**: Maintains live routing table (upserts on announcement, - **PrefixHandler**: Maintains live routing table (upserts on announcement,
deletes on withdrawal or on an announcement whose AS path is empty or ends deletes on withdrawal or on an announcement whose AS path is empty or
in AS 0, deletes all of a peer's routes when its BGP session goes down). holds AS 0 anywhere, deletes all of a peer's routes when its BGP session
Routes are kept per peer IP, so when one peer IP has sessions on two goes down). Routes are kept per peer IP, so when one peer IP has sessions
collectors, a session going down on one also removes the routes the other on two collectors, a session going down on one also removes the routes the
still carries, until they are announced again. other still carries, until they are announced again.
- **PeeringHandler**: Extracts AS peering relationships from AS paths - **PeeringHandler**: Extracts AS peering relationships from AS paths
5. **HTTP Request Flow** 5. **HTTP Request Flow**
+27 -9
View File
@@ -4,6 +4,7 @@ package ristypes
import ( import (
"encoding/json" "encoding/json"
"slices"
"time" "time"
) )
@@ -24,7 +25,8 @@ const (
) )
// ASPath is a BGP AS path as the feed writes it: an array of AS numbers in // 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]]. // which an AS set is an array of its members, such as [1, 2, [3, 4]]. A path
// that holds AS 0 decodes as an empty one (UnmarshalJSON).
type ASPath struct { type ASPath struct {
// Sequence is the path's AS numbers in order. A set of one member that // 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 // ends the path counts as that AS and is its last element. The members of
@@ -48,23 +50,41 @@ func (p *ASPath) Origin() int {
return p.Sequence[len(p.Sequence)-1] return p.Sequence[len(p.Sequence)-1]
} }
// UnmarshalJSON implements the json.Unmarshaler interface for ASPath. // UnmarshalJSON implements the json.Unmarshaler interface for ASPath. A path
// that holds AS 0 anywhere, a member of an AS set included, decodes as an empty
// path: RFC 7607 makes such a path malformed and its announcements
// withdrawals. On an announcement with an empty path the prefix handler
// deletes the peer's route, and the AS and peering handlers record nothing.
func (p *ASPath) UnmarshalJSON(data []byte) error { func (p *ASPath) UnmarshalJSON(data []byte) error {
path, err := decodeASPath(data)
if err != nil {
return err
}
if slices.Contains(path.Sequence, 0) || slices.Contains(path.Set, 0) {
path = ASPath{}
}
*p = path
return nil
}
// decodeASPath decodes an AS path as the feed writes it.
func decodeASPath(data []byte) (ASPath, error) {
// Most paths have no AS set and decode as a plain array of integers. // Most paths have no AS set and decode as a plain array of integers.
var sequence []int var sequence []int
err := json.Unmarshal(data, &sequence) err := json.Unmarshal(data, &sequence)
if err == nil { if err == nil {
*p = ASPath{Sequence: sequence} return ASPath{Sequence: sequence}, nil
return nil
} }
var raw []any var raw []any
err = json.Unmarshal(data, &raw) err = json.Unmarshal(data, &raw)
if err != nil { if err != nil {
return err return ASPath{}, err
} }
path := ASPath{Sequence: make([]int, 0, len(raw))} path := ASPath{Sequence: make([]int, 0, len(raw))}
@@ -90,9 +110,7 @@ func (p *ASPath) UnmarshalJSON(data []byte) error {
} }
} }
*p = path return path, nil
return nil
} }
// RISLiveMessage represents the outer wrapper message from the RIPE RIS Live stream. // RISLiveMessage represents the outer wrapper message from the RIPE RIS Live stream.
+30
View File
@@ -145,3 +145,33 @@ func TestASPathKeepsATrailingSetApart(t *testing.T) {
}) })
} }
} }
// TestASPathWithASZeroDecodesAsAnEmptyPath decodes AS paths that hold AS 0
// before their end, at their end, or in an AS set. RFC 7607 makes such a path
// malformed, so each decodes as an empty path.
func TestASPathWithASZeroDecodesAsAnEmptyPath(t *testing.T) {
t.Parallel()
for _, path := range []string{
"[64496, 0, 64497]",
"[64496, 0]",
"[64496, [0, 64497], 64498]",
"[64496, [64497, 0]]",
} {
t.Run(path, func(t *testing.T) {
t.Parallel()
var got ristypes.ASPath
err := json.Unmarshal([]byte(path), &got)
if err != nil {
t.Fatalf("unmarshal %s: %v", path, err)
}
if len(got.Sequence) > 0 || len(got.Set) > 0 {
t.Errorf("%s decoded to sequence %v and set %v, want an empty path",
path, got.Sequence, got.Set)
}
})
}
}
@@ -1,6 +1,8 @@
package routewatch package routewatch
import ( import (
"maps"
"slices"
"testing" "testing"
"git.eeqj.de/sneak/routewatch/internal/logger" "git.eeqj.de/sneak/routewatch/internal/logger"
@@ -42,3 +44,25 @@ func TestASHandlerStoresTheEarliestAndLatestTimesOfANewAS(t *testing.T) {
asn.LastSeen, second.ParsedTimestamp) asn.LastSeen, second.ParsedTimestamp)
} }
} }
// TestASHandlerRecordsNothingFromAPathWithASZero handles an update whose AS
// path holds AS 0 between two other ASes. RFC 7607 makes such a path
// malformed, so neither AS is recorded.
func TestASHandlerRecordsNothingFromAPathWithASZero(t *testing.T) {
t.Parallel()
store := newMockStore()
// Built without NewASHandler's flush goroutine, so the test flushes itself.
h := &ASHandler{db: store, logger: logger.New()}
msg := peerAnnouncement(testPeerIP, testV4Prefix)
msg.Path = decodePath(t, testASNA, 0, testASNB)
h.HandleMessage(msg)
h.flushBatch()
if len(store.ASNs) > 0 {
t.Errorf("ASes recorded = %v, want none",
slices.Sorted(maps.Keys(store.ASNs)))
}
}
@@ -153,6 +153,43 @@ func TestPeeringHandlerLeavesATrailingASSetOutOfThePeerings(t *testing.T) {
} }
} }
// TestPeeringHandlerRecordsNothingFromAPathWithASZero handles AS paths that
// hold AS 0. RFC 7607 makes such a path malformed, so no peering is recorded
// from it, not even one between two other ASes of the path.
func TestPeeringHandlerRecordsNothingFromAPathWithASZero(t *testing.T) {
t.Parallel()
tests := []struct {
name string
path []any
}{
{name: "AS 0 between two ASes", path: []any{testASNA, 0, testASNB}},
{
name: "AS 0 after two ASes",
path: []any{testASNA, testASNB, 0, testASNC},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
store := newMockStore()
h := newTestHandler(store)
h.HandleMessage(&ristypes.RISMessage{
Path: decodePath(t, tt.path...),
ParsedTimestamp: time.Now().UTC(),
})
h.ProcessPeeringsNow()
if len(store.Peerings) > 0 {
t.Errorf("peerings recorded = %v, want none",
slices.Sorted(maps.Keys(store.Peerings)))
}
})
}
}
// TestPeeringHandlerProcessesEachRunAndEmpties verifies that a processing run // TestPeeringHandlerProcessesEachRunAndEmpties verifies that a processing run
// empties the path map (the swap) and that a path seen again after a run is // empties the path map (the swap) and that a path seen again after a run is
// recorded in the next run too. // recorded in the next run too.
+6 -6
View File
@@ -45,8 +45,8 @@ const (
// PrefixHandler tracks BGP prefixes and maintains a live routing table in the // PrefixHandler tracks BGP prefixes and maintains a live routing table in the
// database. Routes are added on announcement and deleted on withdrawal or on an // database. Routes are added on announcement and deleted on withdrawal or on an
// announcement whose AS path is empty or ends in AS 0, and all routes of a peer // announcement whose AS path is empty or holds AS 0 anywhere, and all routes of
// are deleted when its BGP session goes down. // a peer are deleted when its BGP session goes down.
type PrefixHandler struct { type PrefixHandler struct {
db database.Store db database.Store
logger *logger.Logger logger *logger.Logger
@@ -373,10 +373,10 @@ func (h *PrefixHandler) flushBatch() {
// An announcement is stored when its path gives an origin, or when it // An announcement is stored when its path gives an origin, or when it
// ends in an AS set of several members: that route is stored with // ends in an AS set of several members: that route is stored with
// origin 0 (none). Any other update deletes the peer's route for the // origin 0 (none). Any other update deletes the peer's route for the
// prefix: a withdrawal, or an announcement whose path is empty or ends // prefix: a withdrawal, or an announcement whose path is empty or
// in AS 0. Such an announcement replaces the peer's route, as any // holds AS 0 anywhere (the decoder empties such a path). Such an
// announcement does, with none (RFC 7607 treats AS 0 in a path as a // announcement replaces the peer's route, as any announcement does,
// withdrawal). // with none (RFC 7607 treats AS 0 in a path as a withdrawal).
if update.messageType == "announcement" && if update.messageType == "announcement" &&
(update.originASN > 0 || update.endsInSet) { (update.originASN > 0 || update.endsInSet) {
// Create live route for batch upsert // Create live route for batch upsert
@@ -381,11 +381,12 @@ func TestPrefixHandlerDoesNotStoreAnAnnouncementWithAnEmptyPath(t *testing.T) {
// TestPrefixHandlerRemovesTheRouteOnAnAnnouncementWithoutAnOrigin runs, through // TestPrefixHandlerRemovesTheRouteOnAnAnnouncementWithoutAnOrigin runs, through
// the prefix handler into a real database, a peer's announcement of a prefix // the prefix handler into a real database, a peer's announcement of a prefix
// with origin AS testASNB and then the peer's announcement of the prefix with // with origin AS testASNB and then the peer's announcement of the prefix with
// an AS path that gives no origin: one whose last AS is 0, or an empty one. As // an AS path that gives no origin: one whose last AS is 0, an empty one, or one
// any later announcement from the peer does, the second replaces the peer's // that holds AS 0 before its end, which decodes as an empty path. As any later
// route, here with none, whether it comes in a later batch or follows a // announcement from the peer does, the second replaces the peer's route, here
// withdrawal in one batch. The in-memory route count and prefix distribution // with none, whether it comes in a later batch or follows a withdrawal in one
// must match the route table. // batch. The in-memory route count and prefix distribution must match the
// route table.
func TestPrefixHandlerRemovesTheRouteOnAnAnnouncementWithoutAnOrigin( func TestPrefixHandlerRemovesTheRouteOnAnAnnouncementWithoutAnOrigin(
t *testing.T, t *testing.T,
) { ) {
@@ -421,6 +422,12 @@ func TestPrefixHandlerRemovesTheRouteOnAnAnnouncementWithoutAnOrigin(
name: "an empty path", name: "an empty path",
batch: []*ristypes.RISMessage{noOrigin(ristypes.ASPath{})}, batch: []*ristypes.RISMessage{noOrigin(ristypes.ASPath{})},
}, },
{
name: "a path with AS 0 before its end",
batch: []*ristypes.RISMessage{
noOrigin(decodePath(t, testASNA, 0, testASNB)),
},
},
{ {
name: "a withdrawal and then a path whose last AS is 0", name: "a withdrawal and then a path whose last AS is 0",
batch: []*ristypes.RISMessage{withdrawal, noOrigin(endsInZero)}, batch: []*ristypes.RISMessage{withdrawal, noOrigin(endsInZero)},