Keep a trailing AS set out of the origin and the peerings (closes #75)
check / check (push) Waiting to run
check / check (push) Waiting to run
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. An announcement with an empty path is still not stored. 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. The prefix and prefix length pages show origin 0 as no known origin, the IP lookup asks no WHOIS lookup for it, and the README says what it means. Model: opus-5-5
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/as/{asn}` - Get prefixes announced by an AS
|
||||||
- `GET /api/v1/prefix/{prefix}` - Get routes for a specific prefix
|
- `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
|
## Code Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -2141,11 +2141,12 @@ func (d *Database) GetIPInfoContext(
|
|||||||
info.FirstSeen = prefixFirstSeen.Time
|
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
|
const staleThreshold = 30 * 24 * time.Hour
|
||||||
|
|
||||||
info.NeedsWHOISRefresh = !whoisUpdatedAt.Valid ||
|
info.NeedsWHOISRefresh = info.ASN != 0 && (!whoisUpdatedAt.Valid ||
|
||||||
time.Since(whoisUpdatedAt.Time) > staleThreshold
|
time.Since(whoisUpdatedAt.Time) > staleThreshold)
|
||||||
|
|
||||||
return info, nil
|
return info, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -213,6 +213,33 @@ func TestGetIPInfoIgnoresShortRoutes(t *testing.T) {
|
|||||||
lookup("2001:db8::1", "2001:db8::/32", 64502)
|
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
|
// TestLiveRouteTablesHoldOneRoutePerPrefixAndPeer checks that each live route
|
||||||
// table refuses a second route from a peer for a prefix, even with another
|
// 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
|
// origin AS. An upsert updates the peer's route before it would insert one, so
|
||||||
|
|||||||
+44
-21
@@ -23,26 +23,43 @@ const (
|
|||||||
StateDown = "down"
|
StateDown = "down"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ASPath represents a BGP AS path as a slice of AS numbers.
|
// ASPath is a BGP AS path as the feed writes it: an array of AS numbers in
|
||||||
// It handles JSON unmarshaling of both simple arrays and nested AS sets,
|
// which an AS set is an array of its members, such as [1, 2, [3, 4]].
|
||||||
// flattening any nested structures into a single sequence of AS numbers.
|
type ASPath struct {
|
||||||
type ASPath []int
|
// 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.
|
// 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 {
|
func (p *ASPath) UnmarshalJSON(data []byte) error {
|
||||||
// First try to unmarshal as a simple array of integers
|
// Most paths have no AS set and decode as a plain array of integers.
|
||||||
var simple []int
|
var sequence []int
|
||||||
|
|
||||||
err := json.Unmarshal(data, &simple)
|
err := json.Unmarshal(data, &sequence)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
*p = ASPath(simple)
|
*p = ASPath{Sequence: sequence}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// If that fails, unmarshal as array of interfaces and flatten
|
|
||||||
var raw []any
|
var raw []any
|
||||||
|
|
||||||
err = json.Unmarshal(data, &raw)
|
err = json.Unmarshal(data, &raw)
|
||||||
@@ -50,24 +67,30 @@ func (p *ASPath) UnmarshalJSON(data []byte) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Flatten the array
|
path := ASPath{Sequence: make([]int, 0, len(raw))}
|
||||||
result := make([]int, 0)
|
|
||||||
|
|
||||||
for _, item := range raw {
|
for i, item := range raw {
|
||||||
switch v := item.(type) {
|
switch v := item.(type) {
|
||||||
case float64:
|
case float64:
|
||||||
result = append(result, int(v))
|
path.Sequence = append(path.Sequence, int(v))
|
||||||
case []any:
|
case []any:
|
||||||
// Nested array - flatten it
|
set := make([]int, 0, len(v))
|
||||||
for _, nested := range v {
|
|
||||||
if num, ok := nested.(float64); ok {
|
for _, member := range v {
|
||||||
result = append(result, int(num))
|
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
|
return nil
|
||||||
}
|
}
|
||||||
@@ -97,7 +120,7 @@ type RISMessage struct {
|
|||||||
RRC string `json:"rrc,omitempty"`
|
RRC string `json:"rrc,omitempty"`
|
||||||
MrtTime float64 `json:"mrt_time,omitempty"`
|
MrtTime float64 `json:"mrt_time,omitempty"`
|
||||||
SocketTime float64 `json:"socket_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.
|
// 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
|
// 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
|
// handler queues, so json:"-" keeps them out of the decoded message
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"bufio"
|
"bufio"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"os"
|
"os"
|
||||||
|
"slices"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/routewatch/internal/ristypes"
|
"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)
|
t.Errorf("message %d: Raw decoded, want empty", messages)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(msg.Path) > 0 {
|
if len(msg.Path.Sequence) > 0 {
|
||||||
withPath++
|
withPath++
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,3 +83,65 @@ func TestDecodeDropsCommunityAndRaw(t *testing.T) {
|
|||||||
withPath, withAnnouncements)
|
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
|
package routewatch
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"slices"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -98,18 +99,14 @@ func (h *ASHandler) QueueCapacity() int {
|
|||||||
|
|
||||||
// HandleMessage processes a RIS Live BGP message by extracting all ASNs from
|
// 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
|
// 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
|
// origin ASN and all transit ASNs are recorded with their associated
|
||||||
// with their associated timestamps. When the batch reaches the configured size
|
// timestamps. When the batch reaches the configured size threshold, the flush
|
||||||
// threshold, the flush goroutine is woken to write it.
|
// goroutine is woken to write it.
|
||||||
func (h *ASHandler) HandleMessage(msg *ristypes.RISMessage) {
|
func (h *ASHandler) HandleMessage(msg *ristypes.RISMessage) {
|
||||||
// Use the pre-parsed timestamp
|
// Use the pre-parsed timestamp
|
||||||
timestamp := msg.ParsedTimestamp
|
timestamp := msg.ParsedTimestamp
|
||||||
|
|
||||||
// Get origin ASN from path (last element)
|
originASN := msg.Path.Origin()
|
||||||
var originASN int
|
|
||||||
if len(msg.Path) > 0 {
|
|
||||||
originASN = msg.Path[len(msg.Path)-1]
|
|
||||||
}
|
|
||||||
|
|
||||||
h.mu.Lock()
|
h.mu.Lock()
|
||||||
defer h.mu.Unlock()
|
defer h.mu.Unlock()
|
||||||
@@ -129,8 +126,8 @@ func (h *ASHandler) HandleMessage(msg *ristypes.RISMessage) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Also track all ASNs in the path
|
// Also track all ASNs in the path, the members of an AS set included
|
||||||
for _, asn := range msg.Path {
|
for _, asn := range slices.Concat(msg.Path.Sequence, msg.Path.Set) {
|
||||||
if asn > 0 {
|
if asn > 0 {
|
||||||
h.batch = append(h.batch, asnOp{
|
h.batch = append(h.batch, asnOp{
|
||||||
number: asn,
|
number: asn,
|
||||||
|
|||||||
@@ -222,7 +222,7 @@ func handleAtMaxBatchSize(
|
|||||||
func prefixAnnouncement(prefixes []string) *ristypes.RISMessage {
|
func prefixAnnouncement(prefixes []string) *ristypes.RISMessage {
|
||||||
return &ristypes.RISMessage{
|
return &ristypes.RISMessage{
|
||||||
Peer: testPeerIP,
|
Peer: testPeerIP,
|
||||||
Path: ristypes.ASPath{testASNA},
|
Path: ristypes.ASPath{Sequence: []int{testASNA}},
|
||||||
Announcements: []ristypes.RISAnnouncement{
|
Announcements: []ristypes.RISAnnouncement{
|
||||||
{NextHop: testPeerIP, Prefixes: prefixes},
|
{NextHop: testPeerIP, Prefixes: prefixes},
|
||||||
},
|
},
|
||||||
@@ -255,10 +255,10 @@ func TestASHandlerHandlesMessagesWhileAWriteIsBlocked(t *testing.T) {
|
|||||||
|
|
||||||
store := newBlockingStore()
|
store := newBlockingStore()
|
||||||
h := NewASHandler(store, logger.New())
|
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,
|
handleWhileAWriteIsBlocked(t, store, h,
|
||||||
[]*ristypes.RISMessage{{Path: fill}},
|
[]*ristypes.RISMessage{{Path: fill}},
|
||||||
&ristypes.RISMessage{Path: ristypes.ASPath{testASNB}})
|
&ristypes.RISMessage{Path: ristypes.ASPath{Sequence: []int{testASNB}}})
|
||||||
|
|
||||||
store.mu.Lock()
|
store.mu.Lock()
|
||||||
defer store.mu.Unlock()
|
defer store.mu.Unlock()
|
||||||
@@ -317,12 +317,12 @@ func TestASHandlerWaitsAtTheMaximumBatchSize(t *testing.T) {
|
|||||||
|
|
||||||
store := newBlockingStore()
|
store := newBlockingStore()
|
||||||
h := NewASHandler(store, logger.New())
|
h := NewASHandler(store, logger.New())
|
||||||
fill := slices.Repeat(ristypes.ASPath{testASNA}, asnBatchSize)
|
fill := ristypes.ASPath{Sequence: slices.Repeat([]int{testASNA}, asnBatchSize)}
|
||||||
toMax := slices.Repeat(ristypes.ASPath{testASNA}, asnBatchMaxSize)
|
toMax := ristypes.ASPath{Sequence: slices.Repeat([]int{testASNA}, asnBatchMaxSize)}
|
||||||
handleAtMaxBatchSize(t, store, h,
|
handleAtMaxBatchSize(t, store, h,
|
||||||
[]*ristypes.RISMessage{{Path: fill}},
|
[]*ristypes.RISMessage{{Path: fill}},
|
||||||
[]*ristypes.RISMessage{{Path: toMax}},
|
[]*ristypes.RISMessage{{Path: toMax}},
|
||||||
&ristypes.RISMessage{Path: ristypes.ASPath{testASNB}})
|
&ristypes.RISMessage{Path: ristypes.ASPath{Sequence: []int{testASNB}}})
|
||||||
|
|
||||||
store.mu.Lock()
|
store.mu.Lock()
|
||||||
defer store.mu.Unlock()
|
defer store.mu.Unlock()
|
||||||
@@ -386,7 +386,7 @@ func TestHandlersWakeTheFlushGoroutineWhenTheBatchIsFull(t *testing.T) {
|
|||||||
h := &ASHandler{batchFullCh: make(chan struct{}, 1)}
|
h := &ASHandler{batchFullCh: make(chan struct{}, 1)}
|
||||||
// The origin AS and each AS in the path are one update each.
|
// The origin AS and each AS in the path are one update each.
|
||||||
h.HandleMessage(&ristypes.RISMessage{
|
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
|
return h.batchFullCh
|
||||||
|
|||||||
@@ -87,15 +87,20 @@ func (h *PeeringHandler) QueueCapacity() int {
|
|||||||
// than minPathLengthForPeering are ignored as they cannot contain valid
|
// than minPathLengthForPeering are ignored as they cannot contain valid
|
||||||
// peering information.
|
// peering information.
|
||||||
func (h *PeeringHandler) HandleMessage(msg *ristypes.RISMessage) {
|
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
|
// Skip if no AS path or only one AS
|
||||||
if len(msg.Path) < minPathLengthForPeering {
|
if len(sequence) < minPathLengthForPeering {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
timestamp := msg.ParsedTimestamp
|
timestamp := msg.ParsedTimestamp
|
||||||
|
|
||||||
// Encode AS path as JSON for use as map key
|
// Encode AS path as JSON for use as map key
|
||||||
pathJSON, err := json.Marshal(msg.Path)
|
pathJSON, err := json.Marshal(sequence)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.logger.Error("Failed to encode AS path", "error", err)
|
h.logger.Error("Failed to encode AS path", "error", err)
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ package routewatch
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"maps"
|
||||||
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -16,6 +19,7 @@ const (
|
|||||||
testASNA = 64500
|
testASNA = 64500
|
||||||
testASNB = 64501
|
testASNB = 64501
|
||||||
testASNC = 64502
|
testASNC = 64502
|
||||||
|
testASND = 64503
|
||||||
)
|
)
|
||||||
|
|
||||||
// recordingStore wraps mockStore to count every RecordPeering call, so a
|
// 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 {
|
func pathKey(t *testing.T, asns ...int) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
b, err := json.Marshal(ristypes.ASPath(asns))
|
b, err := json.Marshal(asns)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to marshal path: %v", err)
|
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) {
|
func handle(h *PeeringHandler, ts time.Time, asns ...int) {
|
||||||
h.HandleMessage(&ristypes.RISMessage{
|
h.HandleMessage(&ristypes.RISMessage{
|
||||||
Path: ristypes.ASPath(asns),
|
Path: ristypes.ASPath{Sequence: asns},
|
||||||
ParsedTimestamp: ts,
|
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
|
// 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.
|
||||||
|
|||||||
@@ -73,6 +73,9 @@ type prefixUpdate struct {
|
|||||||
messageType string
|
messageType string
|
||||||
timestamp time.Time
|
timestamp time.Time
|
||||||
path []int
|
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
|
// NewPrefixHandler creates a new batched prefix tracking handler
|
||||||
@@ -127,11 +130,11 @@ func (h *PrefixHandler) HandleMessage(msg *ristypes.RISMessage) {
|
|||||||
// Use the pre-parsed timestamp
|
// Use the pre-parsed timestamp
|
||||||
timestamp := msg.ParsedTimestamp
|
timestamp := msg.ParsedTimestamp
|
||||||
|
|
||||||
// Get origin ASN from path (last element)
|
originASN := msg.Path.Origin()
|
||||||
var originASN int
|
|
||||||
if len(msg.Path) > 0 {
|
// The stored and shown AS path lists the members of an AS set that ends
|
||||||
originASN = msg.Path[len(msg.Path)-1]
|
// the path after the rest of it.
|
||||||
}
|
path := slices.Concat(msg.Path.Sequence, msg.Path.Set)
|
||||||
|
|
||||||
h.mu.Lock()
|
h.mu.Lock()
|
||||||
defer h.mu.Unlock()
|
defer h.mu.Unlock()
|
||||||
@@ -169,7 +172,8 @@ func (h *PrefixHandler) HandleMessage(msg *ristypes.RISMessage) {
|
|||||||
peer: msg.Peer,
|
peer: msg.Peer,
|
||||||
messageType: "announcement",
|
messageType: "announcement",
|
||||||
timestamp: timestamp,
|
timestamp: timestamp,
|
||||||
path: msg.Path,
|
path: path,
|
||||||
|
endsInSet: len(msg.Path.Set) > 0,
|
||||||
})
|
})
|
||||||
// Record announcement in metrics
|
// Record announcement in metrics
|
||||||
if h.metrics != nil {
|
if h.metrics != nil {
|
||||||
@@ -297,7 +301,11 @@ func (h *PrefixHandler) flushBatch() {
|
|||||||
prefixesToUpdate[update.prefix] = update.timestamp
|
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
|
// Create live route for batch upsert
|
||||||
route := h.createLiveRoute(update)
|
route := h.createLiveRoute(update)
|
||||||
if route != nil {
|
if route != nil {
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ func TestPrefixHandlerStoresPrefixesTheIPLookupFinds(t *testing.T) {
|
|||||||
ts := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
|
ts := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
|
||||||
h.HandleMessage(&ristypes.RISMessage{
|
h.HandleMessage(&ristypes.RISMessage{
|
||||||
Peer: testPeerIP,
|
Peer: testPeerIP,
|
||||||
Path: ristypes.ASPath{testASNA, testASNB},
|
Path: ristypes.ASPath{Sequence: []int{testASNA, testASNB}},
|
||||||
ParsedTimestamp: ts,
|
ParsedTimestamp: ts,
|
||||||
Announcements: []ristypes.RISAnnouncement{{
|
Announcements: []ristypes.RISAnnouncement{{
|
||||||
NextHop: testPeerIP,
|
NextHop: testPeerIP,
|
||||||
@@ -99,7 +99,7 @@ func TestPrefixHandlerAppliesEachPeersLastUpdateInABatch(t *testing.T) {
|
|||||||
announce := func(peer string) *ristypes.RISMessage {
|
announce := func(peer string) *ristypes.RISMessage {
|
||||||
return &ristypes.RISMessage{
|
return &ristypes.RISMessage{
|
||||||
Peer: peer,
|
Peer: peer,
|
||||||
Path: ristypes.ASPath{testASNA, testASNB},
|
Path: ristypes.ASPath{Sequence: []int{testASNA, testASNB}},
|
||||||
ParsedTimestamp: ts,
|
ParsedTimestamp: ts,
|
||||||
Announcements: []ristypes.RISAnnouncement{
|
Announcements: []ristypes.RISAnnouncement{
|
||||||
{NextHop: peer, Prefixes: []string{prefix}},
|
{NextHop: peer, Prefixes: []string{prefix}},
|
||||||
@@ -216,7 +216,7 @@ func TestPrefixHandlerKeepsOneRoutePerPrefixAndPeer(t *testing.T) {
|
|||||||
// One message that announces otherPrefix with origin AS testASNB and
|
// One message that announces otherPrefix with origin AS testASNB and
|
||||||
// withdraws prefix.
|
// withdraws prefix.
|
||||||
announceOtherAndWithdraw := announce(
|
announceOtherAndWithdraw := announce(
|
||||||
ristypes.ASPath{testASNA, testASNB}, otherPrefix)
|
ristypes.ASPath{Sequence: []int{testASNA, testASNB}}, otherPrefix)
|
||||||
announceOtherAndWithdraw.Withdrawals = []string{prefix}
|
announceOtherAndWithdraw.Withdrawals = []string{prefix}
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
@@ -230,8 +230,8 @@ func TestPrefixHandlerKeepsOneRoutePerPrefixAndPeer(t *testing.T) {
|
|||||||
{
|
{
|
||||||
name: "the peer announces the prefix with a new origin AS",
|
name: "the peer announces the prefix with a new origin AS",
|
||||||
batches: [][]*ristypes.RISMessage{
|
batches: [][]*ristypes.RISMessage{
|
||||||
{announce(ristypes.ASPath{testASNA}, prefix)},
|
{announce(ristypes.ASPath{Sequence: []int{testASNA}}, prefix)},
|
||||||
{announce(ristypes.ASPath{testASNA, testASNB}, prefix)},
|
{announce(ristypes.ASPath{Sequence: []int{testASNA, testASNB}}, prefix)},
|
||||||
},
|
},
|
||||||
wantOrigins: map[string]int{prefix: testASNB},
|
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 " +
|
name: "one message announces a prefix with another origin AS " +
|
||||||
"and withdraws the prefix",
|
"and withdraws the prefix",
|
||||||
batches: [][]*ristypes.RISMessage{
|
batches: [][]*ristypes.RISMessage{
|
||||||
{announce(ristypes.ASPath{testASNA}, prefix)},
|
{announce(ristypes.ASPath{Sequence: []int{testASNA}}, prefix)},
|
||||||
{announceOtherAndWithdraw},
|
{announceOtherAndWithdraw},
|
||||||
},
|
},
|
||||||
wantOrigins: map[string]int{otherPrefix: testASNB},
|
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
|
// TestStateMessagesGoToThePrefixHandlerNotThePeerHandler checks that STATE
|
||||||
// messages go to the prefix handler, which acts on a down, and not to the peer
|
// 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.
|
// 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{
|
return &ristypes.RISMessage{
|
||||||
Type: ristypes.MessageTypeUpdate,
|
Type: ristypes.MessageTypeUpdate,
|
||||||
Peer: peer,
|
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),
|
ParsedTimestamp: time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC),
|
||||||
Announcements: []ristypes.RISAnnouncement{
|
Announcements: []ristypes.RISAnnouncement{
|
||||||
{NextHop: peer, Prefixes: prefixes},
|
{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
|
// 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,
|
// server's 30-second limit and the stats handlers' 4-second limit inherit it,
|
||||||
// so the tests do not wait for either.
|
// so the tests do not wait for either.
|
||||||
|
|||||||
@@ -281,9 +281,13 @@
|
|||||||
<div class="origin-list">
|
<div class="origin-list">
|
||||||
{{range .Origins}}
|
{{range .Origins}}
|
||||||
<div class="origin-item">
|
<div class="origin-item">
|
||||||
|
{{if .ASN}}
|
||||||
<a href="/as/{{.ASN}}" class="as-link">AS{{.ASN}}</a>
|
<a href="/as/{{.ASN}}" class="as-link">AS{{.ASN}}</a>
|
||||||
{{if .Handle}} ({{.Handle}}){{end}}
|
{{if .Handle}} ({{.Handle}}){{end}}
|
||||||
{{if .Description}} {{.Description}}{{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>
|
<span style="color: #7f8c8d; margin-left: 10px;">{{.PeerCount}} peer{{if ne .PeerCount 1}}s{{end}}</span>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
@@ -312,7 +316,11 @@
|
|||||||
{{range .Routes}}
|
{{range .Routes}}
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
|
{{if .OriginASN}}
|
||||||
<a href="/as/{{.OriginASN}}" class="as-link">AS{{.OriginASN}}</a>
|
<a href="/as/{{.OriginASN}}" class="as-link">AS{{.OriginASN}}</a>
|
||||||
|
{{else}}
|
||||||
|
No known origin
|
||||||
|
{{end}}
|
||||||
</td>
|
</td>
|
||||||
<td class="peer-ip">{{.PeerIP}}</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>
|
<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><a href="{{ .Prefix | prefixURL }}" class="prefix-link">{{ .Prefix }}</a></td>
|
||||||
<td class="age">{{ .Age }}</td>
|
<td class="age">{{ .Age }}</td>
|
||||||
<td>
|
<td>
|
||||||
|
{{ if .OriginASN }}
|
||||||
<a href="/as/{{ .OriginASN }}" class="as-link">
|
<a href="/as/{{ .OriginASN }}" class="as-link">
|
||||||
AS{{ .OriginASN }}{{ if .OriginASDescription }} ({{ .OriginASDescription }}){{ end }}
|
AS{{ .OriginASN }}{{ if .OriginASDescription }} ({{ .OriginASDescription }}){{ end }}
|
||||||
</a>
|
</a>
|
||||||
|
{{ else }}
|
||||||
|
No known origin
|
||||||
|
{{ end }}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|||||||
Reference in New Issue
Block a user