Keep a trailing AS set out of the origin and the peerings (closes #75)
check / check (push) Canceled after 0s
check / check (push) Canceled after 0s
A path ending in an AS set, such as [64496, 64497, [64498, 64499]], was flattened into the sequence, so the route was stored as originated by the last set member and the peering handler recorded peerings the path does not show. ASPath now keeps the sequence and a trailing set of several members apart; Origin() gives the last AS of the sequence, or 0 (no known origin) for such a set. The handlers use it; peerings come from the sequence only. The pages show origin 0 as no known origin, and the IP lookup asks no WHOIS lookup for it. Empty paths are unchanged. Model: opus-5-5
This commit was merged in pull request #91.
This commit is contained in:
@@ -54,6 +54,11 @@ DEBUG=routewatch ./routewatch
|
||||
- `GET /api/v1/as/{asn}` - Get prefixes announced by an AS
|
||||
- `GET /api/v1/prefix/{prefix}` - Get routes for a specific prefix
|
||||
|
||||
An origin AS of 0 (`origin_asn` in a route, `asn` in an IP lookup, the key `0`
|
||||
in a prefix's `origins`) means the route has no known origin: its AS path ends
|
||||
in an AS set of several members, any of which may have originated it. The pages
|
||||
show such a route as having no known origin.
|
||||
|
||||
## Code Structure
|
||||
|
||||
```
|
||||
|
||||
@@ -2141,11 +2141,12 @@ func (d *Database) GetIPInfoContext(
|
||||
info.FirstSeen = prefixFirstSeen.Time
|
||||
}
|
||||
|
||||
// Check if WHOIS data needs refresh (never fetched or older than 30 days)
|
||||
// Check if WHOIS data needs refresh (never fetched or older than 30 days).
|
||||
// A route with origin 0 has no known origin, so no AS to look up.
|
||||
const staleThreshold = 30 * 24 * time.Hour
|
||||
|
||||
info.NeedsWHOISRefresh = !whoisUpdatedAt.Valid ||
|
||||
time.Since(whoisUpdatedAt.Time) > staleThreshold
|
||||
info.NeedsWHOISRefresh = info.ASN != 0 && (!whoisUpdatedAt.Valid ||
|
||||
time.Since(whoisUpdatedAt.Time) > staleThreshold)
|
||||
|
||||
return info, nil
|
||||
}
|
||||
|
||||
@@ -213,6 +213,33 @@ func TestGetIPInfoIgnoresShortRoutes(t *testing.T) {
|
||||
lookup("2001:db8::1", "2001:db8::/32", 64502)
|
||||
}
|
||||
|
||||
// TestGetIPInfoNeedsNoWHOISRefreshForOriginZero checks that the IP lookup asks
|
||||
// for no WHOIS lookup when the address's route has origin 0, no known origin,
|
||||
// and still asks for one when its origin AS has no WHOIS data.
|
||||
func TestGetIPInfoNeedsNoWHOISRefreshForOriginZero(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db, err := New(&config.Config{StateDir: t.TempDir()}, logger.New())
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create database: %v", err)
|
||||
}
|
||||
defer func() { _ = db.Close() }()
|
||||
|
||||
writeOriginRoutes(t, db, map[string]int{"10.0.0.0/8": 0, "11.0.0.0/8": 64500})
|
||||
|
||||
for ip, want := range map[string]bool{"10.0.0.1": false, "11.0.0.1": true} {
|
||||
info, err := db.GetIPInfo(ip)
|
||||
if err != nil {
|
||||
t.Fatalf("GetIPInfo(%s): %v", ip, err)
|
||||
}
|
||||
|
||||
if info.NeedsWHOISRefresh != want {
|
||||
t.Errorf("GetIPInfo(%s) asks for a WHOIS lookup of AS%d: %t, want %t",
|
||||
ip, info.ASN, info.NeedsWHOISRefresh, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestLiveRouteTablesHoldOneRoutePerPrefixAndPeer checks that each live route
|
||||
// table refuses a second route from a peer for a prefix, even with another
|
||||
// origin AS. An upsert updates the peer's route before it would insert one, so
|
||||
|
||||
+44
-21
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package routewatch
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -98,18 +99,14 @@ func (h *ASHandler) QueueCapacity() int {
|
||||
|
||||
// HandleMessage processes a RIS Live BGP message by extracting all ASNs from
|
||||
// the AS path and queuing them for batch insertion into the database. The
|
||||
// origin ASN (last element in the path) and all transit ASNs are recorded
|
||||
// with their associated timestamps. When the batch reaches the configured size
|
||||
// threshold, the flush goroutine is woken to write it.
|
||||
// origin ASN and all transit ASNs are recorded with their associated
|
||||
// timestamps. When the batch reaches the configured size threshold, the flush
|
||||
// goroutine is woken to write it.
|
||||
func (h *ASHandler) HandleMessage(msg *ristypes.RISMessage) {
|
||||
// Use the pre-parsed timestamp
|
||||
timestamp := msg.ParsedTimestamp
|
||||
|
||||
// Get origin ASN from path (last element)
|
||||
var originASN int
|
||||
if len(msg.Path) > 0 {
|
||||
originASN = msg.Path[len(msg.Path)-1]
|
||||
}
|
||||
originASN := msg.Path.Origin()
|
||||
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
@@ -129,8 +126,8 @@ func (h *ASHandler) HandleMessage(msg *ristypes.RISMessage) {
|
||||
})
|
||||
}
|
||||
|
||||
// Also track all ASNs in the path
|
||||
for _, asn := range msg.Path {
|
||||
// Also track all ASNs in the path, the members of an AS set included
|
||||
for _, asn := range slices.Concat(msg.Path.Sequence, msg.Path.Set) {
|
||||
if asn > 0 {
|
||||
h.batch = append(h.batch, asnOp{
|
||||
number: asn,
|
||||
|
||||
@@ -222,7 +222,7 @@ func handleAtMaxBatchSize(
|
||||
func prefixAnnouncement(prefixes []string) *ristypes.RISMessage {
|
||||
return &ristypes.RISMessage{
|
||||
Peer: testPeerIP,
|
||||
Path: ristypes.ASPath{testASNA},
|
||||
Path: ristypes.ASPath{Sequence: []int{testASNA}},
|
||||
Announcements: []ristypes.RISAnnouncement{
|
||||
{NextHop: testPeerIP, Prefixes: prefixes},
|
||||
},
|
||||
@@ -255,10 +255,10 @@ func TestASHandlerHandlesMessagesWhileAWriteIsBlocked(t *testing.T) {
|
||||
|
||||
store := newBlockingStore()
|
||||
h := NewASHandler(store, logger.New())
|
||||
fill := slices.Repeat(ristypes.ASPath{testASNA}, asnBatchSize)
|
||||
fill := ristypes.ASPath{Sequence: slices.Repeat([]int{testASNA}, asnBatchSize)}
|
||||
handleWhileAWriteIsBlocked(t, store, h,
|
||||
[]*ristypes.RISMessage{{Path: fill}},
|
||||
&ristypes.RISMessage{Path: ristypes.ASPath{testASNB}})
|
||||
&ristypes.RISMessage{Path: ristypes.ASPath{Sequence: []int{testASNB}}})
|
||||
|
||||
store.mu.Lock()
|
||||
defer store.mu.Unlock()
|
||||
@@ -317,12 +317,12 @@ func TestASHandlerWaitsAtTheMaximumBatchSize(t *testing.T) {
|
||||
|
||||
store := newBlockingStore()
|
||||
h := NewASHandler(store, logger.New())
|
||||
fill := slices.Repeat(ristypes.ASPath{testASNA}, asnBatchSize)
|
||||
toMax := slices.Repeat(ristypes.ASPath{testASNA}, asnBatchMaxSize)
|
||||
fill := ristypes.ASPath{Sequence: slices.Repeat([]int{testASNA}, asnBatchSize)}
|
||||
toMax := ristypes.ASPath{Sequence: slices.Repeat([]int{testASNA}, asnBatchMaxSize)}
|
||||
handleAtMaxBatchSize(t, store, h,
|
||||
[]*ristypes.RISMessage{{Path: fill}},
|
||||
[]*ristypes.RISMessage{{Path: toMax}},
|
||||
&ristypes.RISMessage{Path: ristypes.ASPath{testASNB}})
|
||||
&ristypes.RISMessage{Path: ristypes.ASPath{Sequence: []int{testASNB}}})
|
||||
|
||||
store.mu.Lock()
|
||||
defer store.mu.Unlock()
|
||||
@@ -386,7 +386,7 @@ func TestHandlersWakeTheFlushGoroutineWhenTheBatchIsFull(t *testing.T) {
|
||||
h := &ASHandler{batchFullCh: make(chan struct{}, 1)}
|
||||
// The origin AS and each AS in the path are one update each.
|
||||
h.HandleMessage(&ristypes.RISMessage{
|
||||
Path: slices.Repeat(ristypes.ASPath{testASNA}, n-1),
|
||||
Path: ristypes.ASPath{Sequence: slices.Repeat([]int{testASNA}, n-1)},
|
||||
})
|
||||
|
||||
return h.batchFullCh
|
||||
|
||||
@@ -87,15 +87,20 @@ func (h *PeeringHandler) QueueCapacity() int {
|
||||
// than minPathLengthForPeering are ignored as they cannot contain valid
|
||||
// peering information.
|
||||
func (h *PeeringHandler) HandleMessage(msg *ristypes.RISMessage) {
|
||||
// Peerings are between neighbours in the path's sequence only: the
|
||||
// members of an AS set that ends the path are not neighbours of each
|
||||
// other or of the AS before them.
|
||||
sequence := msg.Path.Sequence
|
||||
|
||||
// Skip if no AS path or only one AS
|
||||
if len(msg.Path) < minPathLengthForPeering {
|
||||
if len(sequence) < minPathLengthForPeering {
|
||||
return
|
||||
}
|
||||
|
||||
timestamp := msg.ParsedTimestamp
|
||||
|
||||
// Encode AS path as JSON for use as map key
|
||||
pathJSON, err := json.Marshal(msg.Path)
|
||||
pathJSON, err := json.Marshal(sequence)
|
||||
if err != nil {
|
||||
h.logger.Error("Failed to encode AS path", "error", err)
|
||||
|
||||
|
||||
@@ -2,6 +2,9 @@ package routewatch
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"maps"
|
||||
"slices"
|
||||
"strconv"
|
||||
"sync"
|
||||
"testing"
|
||||
@@ -16,6 +19,7 @@ const (
|
||||
testASNA = 64500
|
||||
testASNB = 64501
|
||||
testASNC = 64502
|
||||
testASND = 64503
|
||||
)
|
||||
|
||||
// recordingStore wraps mockStore to count every RecordPeering call, so a
|
||||
@@ -64,7 +68,7 @@ func snapshot(h *PeeringHandler) (int, int) {
|
||||
func pathKey(t *testing.T, asns ...int) string {
|
||||
t.Helper()
|
||||
|
||||
b, err := json.Marshal(ristypes.ASPath(asns))
|
||||
b, err := json.Marshal(asns)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to marshal path: %v", err)
|
||||
}
|
||||
@@ -74,11 +78,80 @@ func pathKey(t *testing.T, asns ...int) string {
|
||||
|
||||
func handle(h *PeeringHandler, ts time.Time, asns ...int) {
|
||||
h.HandleMessage(&ristypes.RISMessage{
|
||||
Path: ristypes.ASPath(asns),
|
||||
Path: ristypes.ASPath{Sequence: asns},
|
||||
ParsedTimestamp: ts,
|
||||
})
|
||||
}
|
||||
|
||||
// decodePath decodes an AS path from the JSON the feed writes for it, in which
|
||||
// an AS set is an array inside the path: decodePath(t, 1, 2, []int{3, 4})
|
||||
// decodes [1, 2, [3, 4]].
|
||||
func decodePath(t *testing.T, elements ...any) ristypes.ASPath {
|
||||
t.Helper()
|
||||
|
||||
data, err := json.Marshal(elements)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to marshal path: %v", err)
|
||||
}
|
||||
|
||||
var path ristypes.ASPath
|
||||
|
||||
err = json.Unmarshal(data, &path)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to decode path %s: %v", data, err)
|
||||
}
|
||||
|
||||
return path
|
||||
}
|
||||
|
||||
// TestPeeringHandlerLeavesATrailingASSetOutOfThePeerings handles AS paths that
|
||||
// end in an AS set. The members of a set are not neighbours of each other or of
|
||||
// the AS before the set, so a set of several members is in no peering; a set of
|
||||
// one member counts as that AS.
|
||||
func TestPeeringHandlerLeavesATrailingASSetOutOfThePeerings(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
path []any
|
||||
// The peerings recorded, keyed as the mock store keys them.
|
||||
wantPeerings []string
|
||||
}{
|
||||
{
|
||||
name: "a set of several members",
|
||||
path: []any{testASNA, testASNB, []int{testASNC, testASND}},
|
||||
wantPeerings: []string{fmt.Sprintf("%d_%d", testASNA, testASNB)},
|
||||
},
|
||||
{
|
||||
name: "a set of one member",
|
||||
path: []any{testASNA, testASNB, []int{testASNC}},
|
||||
wantPeerings: []string{
|
||||
fmt.Sprintf("%d_%d", testASNA, testASNB),
|
||||
fmt.Sprintf("%d_%d", testASNB, 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()
|
||||
|
||||
got := slices.Sorted(maps.Keys(store.Peerings))
|
||||
if !slices.Equal(got, tt.wantPeerings) {
|
||||
t.Errorf("peerings recorded = %v, want %v", got, tt.wantPeerings)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestPeeringHandlerProcessesEachRunAndEmpties verifies that a processing run
|
||||
// empties the path map (the swap) and that a path seen again after a run is
|
||||
// recorded in the next run too.
|
||||
|
||||
@@ -73,6 +73,9 @@ type prefixUpdate struct {
|
||||
messageType string
|
||||
timestamp time.Time
|
||||
path []int
|
||||
// endsInSet says the path ends in an AS set of several members, so it
|
||||
// gives no origin.
|
||||
endsInSet bool
|
||||
}
|
||||
|
||||
// NewPrefixHandler creates a new batched prefix tracking handler
|
||||
@@ -127,11 +130,11 @@ func (h *PrefixHandler) HandleMessage(msg *ristypes.RISMessage) {
|
||||
// Use the pre-parsed timestamp
|
||||
timestamp := msg.ParsedTimestamp
|
||||
|
||||
// Get origin ASN from path (last element)
|
||||
var originASN int
|
||||
if len(msg.Path) > 0 {
|
||||
originASN = msg.Path[len(msg.Path)-1]
|
||||
}
|
||||
originASN := msg.Path.Origin()
|
||||
|
||||
// The stored and shown AS path lists the members of an AS set that ends
|
||||
// the path after the rest of it.
|
||||
path := slices.Concat(msg.Path.Sequence, msg.Path.Set)
|
||||
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
@@ -169,7 +172,8 @@ func (h *PrefixHandler) HandleMessage(msg *ristypes.RISMessage) {
|
||||
peer: msg.Peer,
|
||||
messageType: "announcement",
|
||||
timestamp: timestamp,
|
||||
path: msg.Path,
|
||||
path: path,
|
||||
endsInSet: len(msg.Path.Set) > 0,
|
||||
})
|
||||
// Record announcement in metrics
|
||||
if h.metrics != nil {
|
||||
@@ -297,7 +301,11 @@ func (h *PrefixHandler) flushBatch() {
|
||||
prefixesToUpdate[update.prefix] = update.timestamp
|
||||
}
|
||||
|
||||
if update.messageType == "announcement" && update.originASN > 0 {
|
||||
// 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
|
||||
// origin 0 (none). An announcement with an empty path is not stored.
|
||||
if update.messageType == "announcement" &&
|
||||
(update.originASN > 0 || update.endsInSet) {
|
||||
// Create live route for batch upsert
|
||||
route := h.createLiveRoute(update)
|
||||
if route != nil {
|
||||
|
||||
@@ -40,7 +40,7 @@ func TestPrefixHandlerStoresPrefixesTheIPLookupFinds(t *testing.T) {
|
||||
ts := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
|
||||
h.HandleMessage(&ristypes.RISMessage{
|
||||
Peer: testPeerIP,
|
||||
Path: ristypes.ASPath{testASNA, testASNB},
|
||||
Path: ristypes.ASPath{Sequence: []int{testASNA, testASNB}},
|
||||
ParsedTimestamp: ts,
|
||||
Announcements: []ristypes.RISAnnouncement{{
|
||||
NextHop: testPeerIP,
|
||||
@@ -99,7 +99,7 @@ func TestPrefixHandlerAppliesEachPeersLastUpdateInABatch(t *testing.T) {
|
||||
announce := func(peer string) *ristypes.RISMessage {
|
||||
return &ristypes.RISMessage{
|
||||
Peer: peer,
|
||||
Path: ristypes.ASPath{testASNA, testASNB},
|
||||
Path: ristypes.ASPath{Sequence: []int{testASNA, testASNB}},
|
||||
ParsedTimestamp: ts,
|
||||
Announcements: []ristypes.RISAnnouncement{
|
||||
{NextHop: peer, Prefixes: []string{prefix}},
|
||||
@@ -216,7 +216,7 @@ func TestPrefixHandlerKeepsOneRoutePerPrefixAndPeer(t *testing.T) {
|
||||
// One message that announces otherPrefix with origin AS testASNB and
|
||||
// withdraws prefix.
|
||||
announceOtherAndWithdraw := announce(
|
||||
ristypes.ASPath{testASNA, testASNB}, otherPrefix)
|
||||
ristypes.ASPath{Sequence: []int{testASNA, testASNB}}, otherPrefix)
|
||||
announceOtherAndWithdraw.Withdrawals = []string{prefix}
|
||||
|
||||
tests := []struct {
|
||||
@@ -230,8 +230,8 @@ func TestPrefixHandlerKeepsOneRoutePerPrefixAndPeer(t *testing.T) {
|
||||
{
|
||||
name: "the peer announces the prefix with a new origin AS",
|
||||
batches: [][]*ristypes.RISMessage{
|
||||
{announce(ristypes.ASPath{testASNA}, prefix)},
|
||||
{announce(ristypes.ASPath{testASNA, testASNB}, prefix)},
|
||||
{announce(ristypes.ASPath{Sequence: []int{testASNA}}, prefix)},
|
||||
{announce(ristypes.ASPath{Sequence: []int{testASNA, testASNB}}, prefix)},
|
||||
},
|
||||
wantOrigins: map[string]int{prefix: testASNB},
|
||||
},
|
||||
@@ -239,7 +239,7 @@ func TestPrefixHandlerKeepsOneRoutePerPrefixAndPeer(t *testing.T) {
|
||||
name: "one message announces a prefix with another origin AS " +
|
||||
"and withdraws the prefix",
|
||||
batches: [][]*ristypes.RISMessage{
|
||||
{announce(ristypes.ASPath{testASNA}, prefix)},
|
||||
{announce(ristypes.ASPath{Sequence: []int{testASNA}}, prefix)},
|
||||
{announceOtherAndWithdraw},
|
||||
},
|
||||
wantOrigins: map[string]int{otherPrefix: testASNB},
|
||||
@@ -264,6 +264,70 @@ func TestPrefixHandlerKeepsOneRoutePerPrefixAndPeer(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrefixHandlerStoresNoOriginForAPathEndingInASet announces a prefix with
|
||||
// AS paths that end in an AS set through the prefix handler into a real
|
||||
// database. Any member of a set of several may be the origin, so the route is
|
||||
// stored with origin 0 (none); a set of one member counts as that AS. The
|
||||
// stored AS path lists the set's members after the rest of the path.
|
||||
func TestPrefixHandlerStoresNoOriginForAPathEndingInASet(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
path []any
|
||||
wantOrigin int
|
||||
wantPath []int
|
||||
}{
|
||||
{
|
||||
name: "a set of several members",
|
||||
path: []any{testASNA, testASNB, []int{testASNC, testASND}},
|
||||
wantPath: []int{testASNA, testASNB, testASNC, testASND},
|
||||
},
|
||||
{
|
||||
name: "a set of one member",
|
||||
path: []any{testASNA, testASNB, []int{testASNC}},
|
||||
wantOrigin: testASNC,
|
||||
wantPath: []int{testASNA, testASNB, testASNC},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
msg := peerAnnouncement(testPeerIP, testV4Prefix)
|
||||
msg.Path = decodePath(t, tt.path...)
|
||||
db := handleBatches(t, [][]*ristypes.RISMessage{{msg}})
|
||||
|
||||
routes, _, err := db.GetPrefixDetailsContext(
|
||||
context.Background(), testV4Prefix)
|
||||
if err != nil {
|
||||
t.Fatalf("GetPrefixDetailsContext(%s): %v", testV4Prefix, err)
|
||||
}
|
||||
|
||||
if len(routes) != 1 || routes[0].OriginASN != tt.wantOrigin ||
|
||||
!slices.Equal(routes[0].ASPath, tt.wantPath) {
|
||||
t.Errorf("live routes for %s = %+v, "+
|
||||
"want one with origin AS%d and AS path %v",
|
||||
testV4Prefix, routes, tt.wantOrigin, tt.wantPath)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrefixHandlerDoesNotStoreAnAnnouncementWithAnEmptyPath announces a
|
||||
// prefix with an empty AS path, which gives no origin and does not end in an
|
||||
// AS set, so no live route is stored.
|
||||
func TestPrefixHandlerDoesNotStoreAnAnnouncementWithAnEmptyPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
msg := peerAnnouncement(testPeerIP, testV4Prefix)
|
||||
msg.Path = ristypes.ASPath{}
|
||||
db := handleBatches(t, [][]*ristypes.RISMessage{{msg}})
|
||||
|
||||
assertPeers(t, db, map[string][]string{testV4Prefix: nil})
|
||||
}
|
||||
|
||||
// TestStateMessagesGoToThePrefixHandlerNotThePeerHandler checks that STATE
|
||||
// messages go to the prefix handler, which acts on a down, and not to the peer
|
||||
// handler, which would record a peer whose session never comes up as active.
|
||||
@@ -540,7 +604,7 @@ func peerAnnouncement(peer string, prefixes ...string) *ristypes.RISMessage {
|
||||
return &ristypes.RISMessage{
|
||||
Type: ristypes.MessageTypeUpdate,
|
||||
Peer: peer,
|
||||
Path: ristypes.ASPath{testASNA, testASNB},
|
||||
Path: ristypes.ASPath{Sequence: []int{testASNA, testASNB}},
|
||||
ParsedTimestamp: time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC),
|
||||
Announcements: []ristypes.RISAnnouncement{
|
||||
{NextHop: peer, Prefixes: prefixes},
|
||||
|
||||
@@ -331,6 +331,63 @@ func TestPrefixDetailShowsOriginNames(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestPagesSayOriginZeroIsNoKnownOrigin stores a route with origin 0, as the
|
||||
// prefix handler stores a route whose AS path ends in an AS set of several
|
||||
// members, and checks that the prefix page and the prefix length page say it
|
||||
// has no known origin, without a link to AS0 or the description of the
|
||||
// reserved AS 0 (https://git.eeqj.de/sneak/routewatch/issues/75).
|
||||
func TestPagesSayOriginZeroIsNoKnownOrigin(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db, err := database.New(&config.Config{StateDir: t.TempDir()}, logger.New())
|
||||
if err != nil {
|
||||
t.Fatalf("database.New: %v", err)
|
||||
}
|
||||
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
|
||||
err = db.UpsertLiveRouteBatch([]*database.LiveRoute{{
|
||||
ID: uuid.New(), Prefix: testPrefixV4, MaskLength: 24, IPVersion: 4,
|
||||
OriginASN: 0, PeerIP: testPeerV4, ASPath: []int{64500, 64501, 64502},
|
||||
NextHop: testPeerV4, LastUpdated: time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC),
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatalf("UpsertLiveRouteBatch: %v", err)
|
||||
}
|
||||
|
||||
reserved, ok := asinfo.Get(0)
|
||||
if !ok {
|
||||
t.Fatal("AS0 is not in the AS info data")
|
||||
}
|
||||
|
||||
s := New(db, streamer.New(logger.New(), metrics.New()), logger.New(), &config.Config{})
|
||||
|
||||
for _, path := range []string{"/prefix/" + testPrefixV4, "/prefixlength/24"} {
|
||||
rec := httptest.NewRecorder()
|
||||
s.router.ServeHTTP(rec, httptest.NewRequestWithContext(
|
||||
t.Context(), http.MethodGet, path, nil))
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("%s: status %d, want %d; body %s",
|
||||
path, rec.Code, http.StatusOK, rec.Body)
|
||||
}
|
||||
|
||||
page := rec.Body.String()
|
||||
|
||||
if !strings.Contains(page, "No known origin") {
|
||||
t.Errorf("%s does not say the route has no known origin", path)
|
||||
}
|
||||
|
||||
if strings.Contains(page, `href="/as/0"`) {
|
||||
t.Errorf("%s links to AS0", path)
|
||||
}
|
||||
|
||||
if strings.Contains(page, html.EscapeString(reserved.Description)) {
|
||||
t.Errorf("%s shows AS0's description %q", path, reserved.Description)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// shortRequestDeadline is the deadline the tests below give a request. The
|
||||
// server's 30-second limit and the stats handlers' 4-second limit inherit it,
|
||||
// so the tests do not wait for either.
|
||||
|
||||
@@ -281,9 +281,13 @@
|
||||
<div class="origin-list">
|
||||
{{range .Origins}}
|
||||
<div class="origin-item">
|
||||
{{if .ASN}}
|
||||
<a href="/as/{{.ASN}}" class="as-link">AS{{.ASN}}</a>
|
||||
{{if .Handle}} ({{.Handle}}){{end}}
|
||||
{{if .Description}} {{.Description}}{{end}}
|
||||
{{else}}
|
||||
No known origin
|
||||
{{end}}
|
||||
<span style="color: #7f8c8d; margin-left: 10px;">{{.PeerCount}} peer{{if ne .PeerCount 1}}s{{end}}</span>
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -312,7 +316,11 @@
|
||||
{{range .Routes}}
|
||||
<tr>
|
||||
<td>
|
||||
{{if .OriginASN}}
|
||||
<a href="/as/{{.OriginASN}}" class="as-link">AS{{.OriginASN}}</a>
|
||||
{{else}}
|
||||
No known origin
|
||||
{{end}}
|
||||
</td>
|
||||
<td class="peer-ip">{{.PeerIP}}</td>
|
||||
<td class="as-path">{{range $i, $as := .ASPathWithHandle}}{{if $i}} → {{end}}<a href="/as/{{$as.ASN}}" class="as-link">{{if $as.Handle}}{{$as.Handle}}{{else}}AS{{$as.ASN}}{{end}}</a>{{end}}</td>
|
||||
|
||||
@@ -172,9 +172,13 @@
|
||||
<td><a href="{{ .Prefix | prefixURL }}" class="prefix-link">{{ .Prefix }}</a></td>
|
||||
<td class="age">{{ .Age }}</td>
|
||||
<td>
|
||||
{{ if .OriginASN }}
|
||||
<a href="/as/{{ .OriginASN }}" class="as-link">
|
||||
AS{{ .OriginASN }}{{ if .OriginASDescription }} ({{ .OriginASDescription }}){{ end }}
|
||||
</a>
|
||||
{{ else }}
|
||||
No known origin
|
||||
{{ end }}
|
||||
</td>
|
||||
</tr>
|
||||
{{ end }}
|
||||
|
||||
Reference in New Issue
Block a user