Show figures of the routing table on the home page (closes #154) #196
@@ -70,6 +70,44 @@ one of its peers sends an update.
|
|||||||
- `GET /` - Home page (HTML): headline figures, lookups by AS number and by IP
|
- `GET /` - Home page (HTML): headline figures, lookups by AS number and by IP
|
||||||
address, and a search of AS names. A card shows the `curl` command that looks
|
address, and a search of AS names. A card shows the `curl` command that looks
|
||||||
up the visitor's own address at `/ip/{addr}`, and that command's live answer.
|
up the visitor's own address at `/ip/{addr}`, and that command's live answer.
|
||||||
|
Other cards show figures of the routing table, with links to the AS and prefix
|
||||||
|
pages:
|
||||||
|
- the newest and the oldest kept route, with its prefix, origin AS and time
|
||||||
|
- the AS last seen in an AS path most recently, and the one last seen
|
||||||
|
longest ago
|
||||||
|
- the AS that originates the most prefixes
|
||||||
|
- how many ASes originate a single prefix, and the most recently updated of
|
||||||
|
them
|
||||||
|
- the number of bogon announcements, linked to `/bogons`
|
||||||
|
- the number of prefixes with more than one origin AS, linked to
|
||||||
|
`/multiple-origins`
|
||||||
|
- well-known networks, such as Apple, Google, Cogent and MIT, each with its
|
||||||
|
number of prefixes and the time of its newest kept route, newest first
|
||||||
|
|
||||||
|
RouteWatch works these figures out once a minute, so they can be a minute
|
||||||
|
old.
|
||||||
|
|
||||||
|
- `GET /bogons` - The bogon announcements (HTML): the routes whose prefix or
|
||||||
|
origin AS is reserved. A prefix is reserved when it lies inside one of these:
|
||||||
|
- a block that the IANA special-purpose address registries mark as not
|
||||||
|
globally reachable
|
||||||
|
- the multicast space, `224.0.0.0/4` and `ff00::/8`, which the IANA IPv4 and
|
||||||
|
IPv6 address space registries list as Multicast
|
||||||
|
|
||||||
|
A reserved AS number is one of these:
|
||||||
|
- an AS number for private use or for documentation
|
||||||
|
- `AS23456`
|
||||||
|
- the last AS number of the 16-bit or the 32-bit range
|
||||||
|
|
||||||
|
Address space and AS numbers that the IANA has not handed out yet are not
|
||||||
|
counted. Each row shows the prefix, the origin AS, the number of peers and
|
||||||
|
why it is a bogon. The page shows at most 1,000 rows, in prefix order, and
|
||||||
|
says how many there are in all.
|
||||||
|
|
||||||
|
- `GET /multiple-origins` - The prefixes announced by more than one origin AS
|
||||||
|
(HTML). Each row shows the origin ASes and how many peers carry the prefix
|
||||||
|
with each. Like `/bogons`, it shows at most 1,000 rows, in prefix order, and
|
||||||
|
says how many there are in all.
|
||||||
- `GET /status` - HTML status dashboard, which reads `/api/v1/stats`
|
- `GET /status` - HTML status dashboard, which reads `/api/v1/stats`
|
||||||
- `GET /status.json` - JSON statistics: feed, memory, database and route
|
- `GET /status.json` - JSON statistics: feed, memory, database and route
|
||||||
figures. The route figures come from the routing table. `oldest_route` and
|
figures. The route figures come from the routing table. `oldest_route` and
|
||||||
@@ -248,6 +286,7 @@ routewatch/
|
|||||||
│ └── streamdumper/ # Debug utility for raw stream output
|
│ └── streamdumper/ # Debug utility for raw stream output
|
||||||
├── internal/
|
├── internal/
|
||||||
│ ├── blocklist/ # Public IP blocklists: downloads and lookups
|
│ ├── blocklist/ # Public IP blocklists: downloads and lookups
|
||||||
|
│ ├── bogon/ # Reserved prefixes and AS numbers
|
||||||
│ ├── routewatch/ # Core application logic
|
│ ├── routewatch/ # Core application logic
|
||||||
│ ├── server/ # HTTP server and handlers
|
│ ├── server/ # HTTP server and handlers
|
||||||
│ ├── database/ # SQLite storage layer
|
│ ├── database/ # SQLite storage layer
|
||||||
|
|||||||
@@ -35,6 +35,14 @@ tested.
|
|||||||
|
|
||||||
# Completed Steps
|
# Completed Steps
|
||||||
|
|
||||||
|
- 2026-10-27: the home page has cards with figures of the routing table: the
|
||||||
|
newest and oldest kept route, the newest and oldest updated AS, the AS with
|
||||||
|
the most prefixes, the ASes with a single prefix, and fifteen well-known
|
||||||
|
networks. Two cards count the bogon announcements and the prefixes with more
|
||||||
|
than one origin AS, and link to the new pages `/bogons` and
|
||||||
|
`/multiple-origins` that list them. A new package, `internal/bogon`, tells
|
||||||
|
which prefixes and AS numbers are reserved. The server works the figures out
|
||||||
|
once a minute (closes #154)
|
||||||
- 2026-10-27: the per-peer route tables are gone, with the code that wrote,
|
- 2026-10-27: the per-peer route tables are gone, with the code that wrote,
|
||||||
counted and read them. The prefix handler applies each update to the routing
|
counted and read them. The prefix handler applies each update to the routing
|
||||||
table in memory and writes the table's changes every second. It also stores
|
table in memory and writes the table's changes every second. It also stores
|
||||||
|
|||||||
@@ -0,0 +1,202 @@
|
|||||||
|
// Package bogon tells whether a prefix or an AS number is reserved: set aside
|
||||||
|
// for a special purpose, so that no network should announce it on the
|
||||||
|
// internet. A route is a bogon announcement when its prefix is reserved or its
|
||||||
|
// origin AS is.
|
||||||
|
//
|
||||||
|
// Most reserved prefixes come from the IANA special-purpose address registries
|
||||||
|
// for IPv4 and IPv6. The two multicast blocks, 224.0.0.0/4 and ff00::/8, come
|
||||||
|
// from the IANA IPv4 and IPv6 address space registries. The reserved AS
|
||||||
|
// numbers come from the IANA special-purpose AS numbers registry:
|
||||||
|
// - the AS numbers for private use
|
||||||
|
// - the AS numbers for documentation
|
||||||
|
// - AS 23456, which stands in for a 32-bit AS number
|
||||||
|
// - the last AS number of the 16-bit and of the 32-bit range
|
||||||
|
//
|
||||||
|
// Address space and AS numbers that the IANA has not handed out yet are not
|
||||||
|
// counted.
|
||||||
|
package bogon
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/netip"
|
||||||
|
"strconv"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Prefix returns why prefix is reserved, and true, when it lies inside a
|
||||||
|
// reserved block. The most specific registry block that holds prefix decides,
|
||||||
|
// because some blocks that may be announced lie inside reserved ones. It
|
||||||
|
// returns "" and false when prefix is not reserved.
|
||||||
|
func Prefix(prefix netip.Prefix) (string, bool) {
|
||||||
|
var holder *block
|
||||||
|
|
||||||
|
for i, b := range registryBlocks {
|
||||||
|
inside := b.prefix.Bits() <= prefix.Bits() && b.prefix.Contains(prefix.Addr())
|
||||||
|
if inside && (holder == nil || b.prefix.Bits() > holder.prefix.Bits()) {
|
||||||
|
holder = ®istryBlocks[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if holder == nil || !holder.reserved {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
return holder.name + " (" + holder.prefix.String() + ")", true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ASN returns why asn is reserved, and true, when it is. It returns "" and
|
||||||
|
// false when asn is not reserved.
|
||||||
|
func ASN(asn int) (string, bool) {
|
||||||
|
for _, r := range reservedASRanges {
|
||||||
|
if asn < r.first || asn > r.last {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.first == r.last {
|
||||||
|
return r.name + " (AS" + strconv.Itoa(asn) + ")", true
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.name + " (AS" + strconv.Itoa(r.first) + "-AS" +
|
||||||
|
strconv.Itoa(r.last) + ")", true
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// The names that the registries give to more than one row.
|
||||||
|
const (
|
||||||
|
privateUse = "Private-Use"
|
||||||
|
documentation = "Documentation"
|
||||||
|
benchmarking = "Benchmarking"
|
||||||
|
ietfProtocolAssignments = "IETF Protocol Assignments"
|
||||||
|
portControlAnycast = "Port Control Protocol Anycast"
|
||||||
|
natTraversalAnycast = "Traversal Using Relays around NAT Anycast"
|
||||||
|
nat64Discovery = "NAT64/DNS64 Discovery"
|
||||||
|
amt = "AMT"
|
||||||
|
as112DirectDelegation = "Direct Delegation AS112 Service"
|
||||||
|
ipv4IPv6Translation = "IPv4-IPv6 Translat."
|
||||||
|
multicast = "Multicast"
|
||||||
|
)
|
||||||
|
|
||||||
|
// block is one row of an IANA address registry.
|
||||||
|
type block struct {
|
||||||
|
prefix netip.Prefix
|
||||||
|
name string
|
||||||
|
// reserved says that no network should announce the row's addresses on
|
||||||
|
// the internet.
|
||||||
|
reserved bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// reservedBlock returns a reserved row: a multicast block, or a special-purpose
|
||||||
|
// row whose Globally Reachable column is False.
|
||||||
|
func reservedBlock(prefix, name string) block {
|
||||||
|
return block{prefix: netip.MustParsePrefix(prefix), name: name, reserved: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
// reachableBlock returns a special-purpose row whose Globally Reachable column
|
||||||
|
// is True or N/A. Such a row matters only inside a reserved block, whose
|
||||||
|
// addresses it lets be announced.
|
||||||
|
func reachableBlock(prefix, name string) block {
|
||||||
|
return block{prefix: netip.MustParsePrefix(prefix), name: name}
|
||||||
|
}
|
||||||
|
|
||||||
|
// registryBlocks are the rows of the IANA special-purpose address registries,
|
||||||
|
// without the deprecated ones:
|
||||||
|
// - https://www.iana.org/assignments/iana-ipv4-special-registry/
|
||||||
|
// - https://www.iana.org/assignments/iana-ipv6-special-registry/
|
||||||
|
//
|
||||||
|
// They also hold the multicast blocks, which the IANA address space registries
|
||||||
|
// list as Multicast:
|
||||||
|
// - https://www.iana.org/assignments/ipv4-address-space/
|
||||||
|
// - https://www.iana.org/assignments/ipv6-address-space/
|
||||||
|
//
|
||||||
|
//nolint:gochecknoglobals // a fixed list, parsed once
|
||||||
|
var registryBlocks = []block{
|
||||||
|
reservedBlock("0.0.0.0/8", "This network"),
|
||||||
|
reservedBlock("0.0.0.0/32", "This host on this network"),
|
||||||
|
reservedBlock("10.0.0.0/8", privateUse),
|
||||||
|
reservedBlock("100.64.0.0/10", "Shared Address Space"),
|
||||||
|
reservedBlock("127.0.0.0/8", "Loopback"),
|
||||||
|
reservedBlock("169.254.0.0/16", "Link Local"),
|
||||||
|
reservedBlock("172.16.0.0/12", privateUse),
|
||||||
|
reservedBlock("192.0.0.0/24", ietfProtocolAssignments),
|
||||||
|
reservedBlock("192.0.0.0/29", "IPv4 Service Continuity Prefix"),
|
||||||
|
reservedBlock("192.0.0.8/32", "IPv4 dummy address"),
|
||||||
|
reachableBlock("192.0.0.9/32", portControlAnycast),
|
||||||
|
reachableBlock("192.0.0.10/32", natTraversalAnycast),
|
||||||
|
reservedBlock("192.0.0.170/32", nat64Discovery),
|
||||||
|
reservedBlock("192.0.0.171/32", nat64Discovery),
|
||||||
|
reservedBlock("192.0.2.0/24", "Documentation (TEST-NET-1)"),
|
||||||
|
reachableBlock("192.31.196.0/24", "AS112-v4"),
|
||||||
|
reachableBlock("192.52.193.0/24", amt),
|
||||||
|
reservedBlock("192.88.99.2/32", "6a44-relay anycast address"),
|
||||||
|
reservedBlock("192.168.0.0/16", privateUse),
|
||||||
|
reachableBlock("192.175.48.0/24", as112DirectDelegation),
|
||||||
|
reservedBlock("198.18.0.0/15", benchmarking),
|
||||||
|
reservedBlock("198.51.100.0/24", "Documentation (TEST-NET-2)"),
|
||||||
|
reservedBlock("203.0.113.0/24", "Documentation (TEST-NET-3)"),
|
||||||
|
reservedBlock("224.0.0.0/4", multicast),
|
||||||
|
reservedBlock("240.0.0.0/4", "Reserved"),
|
||||||
|
reservedBlock("255.255.255.255/32", "Limited Broadcast"),
|
||||||
|
|
||||||
|
reservedBlock("::1/128", "Loopback Address"),
|
||||||
|
reservedBlock("::/128", "Unspecified Address"),
|
||||||
|
reservedBlock("::ffff:0:0/96", "IPv4-mapped Address"),
|
||||||
|
reachableBlock("64:ff9b::/96", ipv4IPv6Translation),
|
||||||
|
reservedBlock("64:ff9b:1::/48", ipv4IPv6Translation),
|
||||||
|
reservedBlock("100::/64", "Discard-Only Address Block"),
|
||||||
|
reservedBlock("100:0:0:1::/64", "Dummy IPv6 Prefix"),
|
||||||
|
reservedBlock("2001::/23", ietfProtocolAssignments),
|
||||||
|
reachableBlock("2001::/32", "TEREDO"),
|
||||||
|
reachableBlock("2001:1::1/128", portControlAnycast),
|
||||||
|
reachableBlock("2001:1::2/128", natTraversalAnycast),
|
||||||
|
reachableBlock("2001:1::3/128", "DNS-SD Service Registration Protocol Anycast"),
|
||||||
|
reservedBlock("2001:2::/48", benchmarking),
|
||||||
|
reachableBlock("2001:3::/32", amt),
|
||||||
|
reachableBlock("2001:4:112::/48", "AS112-v6"),
|
||||||
|
reachableBlock("2001:20::/28", "ORCHIDv2"),
|
||||||
|
reachableBlock("2001:30::/28", "Drone Remote ID Protocol Entity Tags (DETs) Prefix"),
|
||||||
|
reservedBlock("2001:db8::/32", documentation),
|
||||||
|
reachableBlock("2002::/16", "6to4"),
|
||||||
|
reachableBlock("2620:4f:8000::/48", as112DirectDelegation),
|
||||||
|
reservedBlock("3fff::/20", documentation),
|
||||||
|
reservedBlock("5f00::/16", "Segment Routing (SRv6) SIDs"),
|
||||||
|
reservedBlock("fc00::/7", "Unique-Local"),
|
||||||
|
reservedBlock("fe80::/10", "Link-Local Unicast"),
|
||||||
|
reservedBlock("ff00::/8", multicast),
|
||||||
|
}
|
||||||
|
|
||||||
|
// asRange is a range of reserved AS numbers, first to last.
|
||||||
|
type asRange struct {
|
||||||
|
first, last int
|
||||||
|
name string
|
||||||
|
}
|
||||||
|
|
||||||
|
// The reserved AS numbers of the IANA special-purpose AS numbers registry.
|
||||||
|
const (
|
||||||
|
asTrans = 23456
|
||||||
|
firstDocumentationAS16 = 64496
|
||||||
|
lastDocumentationAS16 = 64511
|
||||||
|
firstPrivateUseAS16 = 64512
|
||||||
|
lastPrivateUseAS16 = 65534
|
||||||
|
lastAS16 = 65535
|
||||||
|
firstDocumentationAS32 = 65536
|
||||||
|
lastDocumentationAS32 = 65551
|
||||||
|
firstPrivateUseAS32 = 4200000000
|
||||||
|
lastPrivateUseAS32 = 4294967294
|
||||||
|
lastAS32 = 4294967295
|
||||||
|
)
|
||||||
|
|
||||||
|
// reservedASRanges are the reserved rows of the IANA special-purpose AS numbers
|
||||||
|
// registry, https://www.iana.org/assignments/iana-as-numbers-special-registry/.
|
||||||
|
// AS 0 is left out: RouteWatch uses it for a route with no known origin. AS 112
|
||||||
|
// is left out because the AS112 project announces routes with it.
|
||||||
|
//
|
||||||
|
//nolint:gochecknoglobals // a fixed list
|
||||||
|
var reservedASRanges = []asRange{
|
||||||
|
{first: asTrans, last: asTrans, name: "AS_TRANS"},
|
||||||
|
{first: firstDocumentationAS16, last: lastDocumentationAS16, name: documentation},
|
||||||
|
{first: firstPrivateUseAS16, last: lastPrivateUseAS16, name: privateUse},
|
||||||
|
{first: lastAS16, last: lastAS16, name: "Last 16-bit AS number"},
|
||||||
|
{first: firstDocumentationAS32, last: lastDocumentationAS32, name: documentation},
|
||||||
|
{first: firstPrivateUseAS32, last: lastPrivateUseAS32, name: privateUse},
|
||||||
|
{first: lastAS32, last: lastAS32, name: "Last 32-bit AS number"},
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package bogon_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/netip"
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/routewatch/internal/bogon"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPrefix(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
prefix string
|
||||||
|
// want is why the prefix is reserved, or "" when it is not.
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"10.1.2.0/24", "Private-Use (10.0.0.0/8)"},
|
||||||
|
{"10.0.0.0/8", "Private-Use (10.0.0.0/8)"},
|
||||||
|
{"192.0.2.0/24", "Documentation (TEST-NET-1) (192.0.2.0/24)"},
|
||||||
|
{"2001:db8:1::/48", "Documentation (2001:db8::/32)"},
|
||||||
|
{"fd00::/8", "Unique-Local (fc00::/7)"},
|
||||||
|
// Multicast space, from the IANA address space registries.
|
||||||
|
{"224.0.0.0/4", "Multicast (224.0.0.0/4)"},
|
||||||
|
{"239.255.0.0/16", "Multicast (224.0.0.0/4)"},
|
||||||
|
{"ff00::/8", "Multicast (ff00::/8)"},
|
||||||
|
{"ff02::/16", "Multicast (ff00::/8)"},
|
||||||
|
// The most specific block names the reason.
|
||||||
|
{"0.0.0.0/32", "This host on this network (0.0.0.0/32)"},
|
||||||
|
{"2001:2::/48", "Benchmarking (2001:2::/48)"},
|
||||||
|
// Blocks that may be announced, inside reserved blocks.
|
||||||
|
{"192.0.0.9/32", ""},
|
||||||
|
{"2001:4:112::/48", ""},
|
||||||
|
// Blocks that may be announced, inside no reserved block.
|
||||||
|
{"192.175.48.0/24", ""},
|
||||||
|
{"2002::/16", ""},
|
||||||
|
// A prefix that holds a reserved block is not inside it.
|
||||||
|
{"192.0.0.0/16", ""},
|
||||||
|
{"0.0.0.0/0", ""},
|
||||||
|
// Prefixes anyone may announce.
|
||||||
|
{"8.8.8.0/24", ""},
|
||||||
|
{"2001:4860::/32", ""},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.prefix, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
reason, reserved := bogon.Prefix(netip.MustParsePrefix(tc.prefix))
|
||||||
|
if reason != tc.want || reserved != (tc.want != "") {
|
||||||
|
t.Errorf("Prefix(%s) = %q, %v; want %q", tc.prefix, reason,
|
||||||
|
reserved, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestASN(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
asn int
|
||||||
|
// want is why the AS number is reserved, or "" when it is not.
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{23456, "AS_TRANS (AS23456)"},
|
||||||
|
{64496, "Documentation (AS64496-AS64511)"},
|
||||||
|
{64512, "Private-Use (AS64512-AS65534)"},
|
||||||
|
{65534, "Private-Use (AS64512-AS65534)"},
|
||||||
|
{65535, "Last 16-bit AS number (AS65535)"},
|
||||||
|
{65551, "Documentation (AS65536-AS65551)"},
|
||||||
|
{4200000000, "Private-Use (AS4200000000-AS4294967294)"},
|
||||||
|
{4294967295, "Last 32-bit AS number (AS4294967295)"},
|
||||||
|
// AS 0 means no known origin, and the AS112 project uses AS 112.
|
||||||
|
{0, ""},
|
||||||
|
{112, ""},
|
||||||
|
// Not handed out yet, which is not counted.
|
||||||
|
{65552, ""},
|
||||||
|
{15169, ""},
|
||||||
|
{64495, ""},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(strconv.Itoa(tc.asn), func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
reason, reserved := bogon.ASN(tc.asn)
|
||||||
|
if reason != tc.want || reserved != (tc.want != "") {
|
||||||
|
t.Errorf("ASN(%d) = %q, %v; want %q", tc.asn, reason, reserved,
|
||||||
|
tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -972,6 +972,34 @@ func (d *Database) GetAS(ctx context.Context, asn int) (*ASN, bool, error) {
|
|||||||
return &asnInfo, true, nil
|
return &asnInfo, true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The queries of GetLastSeenASes. The index on last_seen answers each without
|
||||||
|
// reading the whole table.
|
||||||
|
const (
|
||||||
|
newestLastSeenASQuery = `
|
||||||
|
SELECT asn, handle, description, first_seen, last_seen
|
||||||
|
FROM asns ORDER BY last_seen DESC LIMIT 1`
|
||||||
|
oldestLastSeenASQuery = `
|
||||||
|
SELECT asn, handle, description, first_seen, last_seen
|
||||||
|
FROM asns ORDER BY last_seen ASC LIMIT 1`
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetLastSeenASes returns two ASes of the asns table, with their handles,
|
||||||
|
// descriptions and seen times: the one last seen most recently, and the one
|
||||||
|
// last seen longest ago. Both are nil when the table holds no AS.
|
||||||
|
func (d *Database) GetLastSeenASes(ctx context.Context) (*ASN, *ASN, error) {
|
||||||
|
newest, found, err := d.readOneAS(ctx, newestLastSeenASQuery)
|
||||||
|
if err != nil || !found {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
oldest, _, err := d.readOneAS(ctx, oldestLastSeenASQuery)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return newest, oldest, nil
|
||||||
|
}
|
||||||
|
|
||||||
// asWHOISQuery reads the handle, description and WHOIS details of one AS, and
|
// asWHOISQuery reads the handle, description and WHOIS details of one AS, and
|
||||||
// when the WHOIS details were fetched.
|
// when the WHOIS details were fetched.
|
||||||
const asWHOISQuery = `
|
const asWHOISQuery = `
|
||||||
@@ -1379,6 +1407,29 @@ func (d *Database) Ping(ctx context.Context) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// readOneAS returns the AS that query reads, and whether it read one.
|
||||||
|
func (d *Database) readOneAS(ctx context.Context, query string) (*ASN, bool, error) {
|
||||||
|
var (
|
||||||
|
asnInfo ASN
|
||||||
|
handle, description sql.NullString
|
||||||
|
)
|
||||||
|
|
||||||
|
err := d.db.QueryRowContext(ctx, query).Scan(&asnInfo.ASN, &handle,
|
||||||
|
&description, &asnInfo.FirstSeen, &asnInfo.LastSeen)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, fmt.Errorf("failed to query AS by last seen: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
asnInfo.Handle = handle.String
|
||||||
|
asnInfo.Description = description.String
|
||||||
|
|
||||||
|
return &asnInfo, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
// truncateWAL runs the TRUNCATE checkpoint of Checkpoint on a WAL file of
|
// truncateWAL runs the TRUNCATE checkpoint of Checkpoint on a WAL file of
|
||||||
// walBytes, with the writing connection's busy timeout lowered to
|
// walBytes, with the writing connection's busy timeout lowered to
|
||||||
// truncateBusyTimeoutMs for it, and logs how long it took. The caller holds the
|
// truncateBusyTimeoutMs for it, and logs how long it took. The caller holds the
|
||||||
|
|||||||
@@ -1003,6 +1003,62 @@ func TestGetASReadsTheRecordOrTheBuiltInList(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestGetLastSeenASes stores three ASes, each last seen at another time, and
|
||||||
|
// expects the one last seen most recently and the one last seen longest ago.
|
||||||
|
// An empty asns table gives neither.
|
||||||
|
func TestGetLastSeenASes(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() }()
|
||||||
|
|
||||||
|
newest, oldest, err := db.GetLastSeenASes(t.Context())
|
||||||
|
if err != nil || newest != nil || oldest != nil {
|
||||||
|
t.Fatalf("GetLastSeenASes() on an empty table = %+v, %+v, %v; want "+
|
||||||
|
"neither", newest, oldest, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
newestASN = 13335
|
||||||
|
middleASN = 15169
|
||||||
|
oldestASN = 3
|
||||||
|
)
|
||||||
|
|
||||||
|
start := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
|
||||||
|
|
||||||
|
err = db.GetOrCreateASNBatch(map[int]SeenTimes{
|
||||||
|
middleASN: {First: start, Last: start.Add(time.Hour)},
|
||||||
|
newestASN: {First: start, Last: start.Add(2 * time.Hour)},
|
||||||
|
oldestASN: {First: start, Last: start.Add(time.Minute)},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetOrCreateASNBatch: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
newest, oldest, err = db.GetLastSeenASes(t.Context())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetLastSeenASes: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
listed, _ := asinfo.Get(newestASN)
|
||||||
|
|
||||||
|
if newest == nil || newest.ASN != newestASN ||
|
||||||
|
!newest.LastSeen.Equal(start.Add(2*time.Hour)) ||
|
||||||
|
newest.Description != listed.Description {
|
||||||
|
t.Errorf("newest = %+v, want AS%d (%s) last seen %v", newest, newestASN,
|
||||||
|
listed.Description, start.Add(2*time.Hour))
|
||||||
|
}
|
||||||
|
|
||||||
|
if oldest == nil || oldest.ASN != oldestASN ||
|
||||||
|
!oldest.LastSeen.Equal(start.Add(time.Minute)) {
|
||||||
|
t.Errorf("oldest = %+v, want AS%d last seen %v", oldest, oldestASN,
|
||||||
|
start.Add(time.Minute))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestGetASWHOISReadsTheStoredDetails stores an AS with WHOIS details and asks
|
// TestGetASWHOISReadsTheStoredDetails stores an AS with WHOIS details and asks
|
||||||
// GetASWHOIS for it and for an AS the asns table does not hold. The first gets
|
// GetASWHOIS for it and for an AS the asns table does not hold. The first gets
|
||||||
// its stored handle, description and details, and the time they were fetched.
|
// its stored handle, description and details, and the time they were fetched.
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ type Store interface {
|
|||||||
|
|
||||||
// AS and prefix detail operations
|
// AS and prefix detail operations
|
||||||
GetAS(ctx context.Context, asn int) (*ASN, bool, error)
|
GetAS(ctx context.Context, asn int) (*ASN, bool, error)
|
||||||
|
GetLastSeenASes(ctx context.Context) (*ASN, *ASN, error)
|
||||||
GetASWHOIS(ctx context.Context, asn int) (*ASN, error)
|
GetASWHOIS(ctx context.Context, asn int) (*ASN, error)
|
||||||
GetPrefixFirstSeen(ctx context.Context, prefix netip.Prefix) (time.Time, error)
|
GetPrefixFirstSeen(ctx context.Context, prefix netip.Prefix) (time.Time, error)
|
||||||
GetASPeers(asn int) ([]ASPeer, error)
|
GetASPeers(asn int) ([]ASPeer, error)
|
||||||
|
|||||||
@@ -83,6 +83,9 @@ CREATE INDEX IF NOT EXISTS idx_peerings_lookup ON peerings(as_a, as_b);
|
|||||||
-- Indexes for asns table
|
-- Indexes for asns table
|
||||||
CREATE INDEX IF NOT EXISTS idx_asns_asn ON asns(asn);
|
CREATE INDEX IF NOT EXISTS idx_asns_asn ON asns(asn);
|
||||||
CREATE INDEX IF NOT EXISTS idx_asns_whois_updated_at ON asns(whois_updated_at);
|
CREATE INDEX IF NOT EXISTS idx_asns_whois_updated_at ON asns(whois_updated_at);
|
||||||
|
-- Finds the AS last seen most recently and the one last seen longest ago, for
|
||||||
|
-- the home page.
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_asns_last_seen ON asns(last_seen);
|
||||||
|
|
||||||
-- Indexes for bgp_peers table
|
-- Indexes for bgp_peers table
|
||||||
CREATE INDEX IF NOT EXISTS idx_bgp_peers_asn ON bgp_peers(peer_asn);
|
CREATE INDEX IF NOT EXISTS idx_bgp_peers_asn ON bgp_peers(peer_asn);
|
||||||
|
|||||||
@@ -0,0 +1,259 @@
|
|||||||
|
package routetable
|
||||||
|
|
||||||
|
import (
|
||||||
|
"cmp"
|
||||||
|
"net/netip"
|
||||||
|
"slices"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/routewatch/internal/bogon"
|
||||||
|
"git.eeqj.de/sneak/routewatch/internal/ristypes"
|
||||||
|
)
|
||||||
|
|
||||||
|
// walkBatch is how many prefixes the walk over the whole table reads in one
|
||||||
|
// hold of the read lock. The updates waiting for the lock get it in between.
|
||||||
|
const walkBatch = 10000
|
||||||
|
|
||||||
|
// Overview holds figures about the whole table. Working them out takes a walk
|
||||||
|
// over every prefix, so a caller asks for them now and then and keeps them.
|
||||||
|
type Overview struct {
|
||||||
|
// NewestRoute and OldestRoute are the newest and the oldest kept routes,
|
||||||
|
// or nil when the table keeps none. Of several with the same time, it is
|
||||||
|
// any one of them.
|
||||||
|
NewestRoute *PrefixRoute
|
||||||
|
OldestRoute *PrefixRoute
|
||||||
|
|
||||||
|
// MostPrefixesASN is the AS that originates the most prefixes, and
|
||||||
|
// MostPrefixes is how many it originates. Of several with as many, it is
|
||||||
|
// the lowest AS number. AS 0, no known origin, counts neither here nor
|
||||||
|
// in the single-prefix figures.
|
||||||
|
MostPrefixesASN int
|
||||||
|
MostPrefixes int
|
||||||
|
|
||||||
|
// SinglePrefixASes counts the ASes that originate one prefix.
|
||||||
|
SinglePrefixASes int
|
||||||
|
// NewestSinglePrefix is the newest kept route of those ASes, or nil when
|
||||||
|
// they keep none.
|
||||||
|
NewestSinglePrefix *PrefixRoute
|
||||||
|
|
||||||
|
// Bogons are the bogon announcements, in prefix order: at most the
|
||||||
|
// number Overview is asked for. BogonCount counts them all.
|
||||||
|
Bogons []Bogon
|
||||||
|
BogonCount int
|
||||||
|
|
||||||
|
// MultipleOrigins are the prefixes that peers carry with more than one
|
||||||
|
// origin AS, in prefix order: at most the number Overview is asked for.
|
||||||
|
// MultipleOriginCount counts them all.
|
||||||
|
MultipleOrigins []MultipleOriginPrefix
|
||||||
|
MultipleOriginCount int
|
||||||
|
}
|
||||||
|
|
||||||
|
// PrefixRoute is the time of a kept route, with its prefix and origin AS.
|
||||||
|
type PrefixRoute struct {
|
||||||
|
Prefix netip.Prefix
|
||||||
|
OriginASN int
|
||||||
|
Time time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bogon is a bogon announcement: a prefix and origin AS pair whose prefix or
|
||||||
|
// origin AS is reserved, as package bogon tells.
|
||||||
|
type Bogon struct {
|
||||||
|
Prefix netip.Prefix
|
||||||
|
OriginASN int
|
||||||
|
// Peers is how many peers carry the prefix with this origin AS.
|
||||||
|
Peers int
|
||||||
|
// Reasons say why the prefix, the origin AS or both are reserved.
|
||||||
|
Reasons []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// MultipleOriginPrefix is a prefix that peers carry with more than one origin
|
||||||
|
// AS.
|
||||||
|
type MultipleOriginPrefix struct {
|
||||||
|
Prefix netip.Prefix
|
||||||
|
// Origins are its origin ASes, in AS number order.
|
||||||
|
Origins []OriginPeers
|
||||||
|
}
|
||||||
|
|
||||||
|
// OriginPeers is one origin AS of a prefix, and how many peers carry the prefix
|
||||||
|
// with it.
|
||||||
|
type OriginPeers struct {
|
||||||
|
ASN int
|
||||||
|
Peers int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Overview works out the table's overview. It lists at most maxListed bogon
|
||||||
|
// announcements and at most maxListed prefixes with more than one origin AS:
|
||||||
|
// the first in prefix order. It walks over every prefix, but holds the read
|
||||||
|
// lock for walkBatch prefixes at a time.
|
||||||
|
func (t *Table) Overview(maxListed int) Overview {
|
||||||
|
var overview Overview
|
||||||
|
|
||||||
|
t.walkPrefixes(func(prefix netip.Prefix, origins []*origin) {
|
||||||
|
overview.noteKeptRoutes(prefix, origins)
|
||||||
|
overview.noteBogons(prefix, origins)
|
||||||
|
overview.noteMultipleOrigins(prefix, origins)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.countPrefixesPerOrigin(&overview)
|
||||||
|
|
||||||
|
slices.SortFunc(overview.Bogons, func(a, b Bogon) int {
|
||||||
|
return cmp.Or(a.Prefix.Compare(b.Prefix),
|
||||||
|
cmp.Compare(a.OriginASN, b.OriginASN))
|
||||||
|
})
|
||||||
|
overview.BogonCount = len(overview.Bogons)
|
||||||
|
overview.Bogons = overview.Bogons[:min(maxListed, overview.BogonCount)]
|
||||||
|
|
||||||
|
slices.SortFunc(overview.MultipleOrigins, func(a, b MultipleOriginPrefix) int {
|
||||||
|
return a.Prefix.Compare(b.Prefix)
|
||||||
|
})
|
||||||
|
overview.MultipleOriginCount = len(overview.MultipleOrigins)
|
||||||
|
overview.MultipleOrigins =
|
||||||
|
overview.MultipleOrigins[:min(maxListed, overview.MultipleOriginCount)]
|
||||||
|
|
||||||
|
return overview
|
||||||
|
}
|
||||||
|
|
||||||
|
// walkPrefixes calls visit with every prefix of the table and its origins. It
|
||||||
|
// holds the read lock, but lets it go after every walkBatch prefixes, so the
|
||||||
|
// updates waiting for it get in. A prefix that is added or removed in such a
|
||||||
|
// gap may or may not be visited, as Go allows for a map that changes during a
|
||||||
|
// range over it. visit must not keep origins once it returns.
|
||||||
|
func (t *Table) walkPrefixes(visit func(prefix netip.Prefix, origins []*origin)) {
|
||||||
|
t.mu.RLock()
|
||||||
|
defer t.mu.RUnlock()
|
||||||
|
|
||||||
|
visited := 0
|
||||||
|
|
||||||
|
for _, f := range []*family{&t.ipv4, &t.ipv6} {
|
||||||
|
for _, prefixes := range f.byLength {
|
||||||
|
for prefix, origins := range prefixes {
|
||||||
|
visit(prefix, origins)
|
||||||
|
|
||||||
|
visited++
|
||||||
|
if visited%walkBatch == 0 {
|
||||||
|
t.mu.RUnlock()
|
||||||
|
t.mu.RLock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// countPrefixesPerOrigin works out the overview's figures of how many prefixes
|
||||||
|
// each AS originates. It holds the read lock once, for one look at each origin
|
||||||
|
// AS, of which there are far fewer than prefixes.
|
||||||
|
func (t *Table) countPrefixesPerOrigin(overview *Overview) {
|
||||||
|
t.mu.RLock()
|
||||||
|
defer t.mu.RUnlock()
|
||||||
|
|
||||||
|
for asn, prefixes := range t.byOrigin {
|
||||||
|
if asn == ristypes.NoOrigin {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
count := len(prefixes)
|
||||||
|
if count > overview.MostPrefixes ||
|
||||||
|
(count == overview.MostPrefixes && asn < overview.MostPrefixesASN) {
|
||||||
|
overview.MostPrefixesASN = asn
|
||||||
|
overview.MostPrefixes = count
|
||||||
|
}
|
||||||
|
|
||||||
|
if count == 1 {
|
||||||
|
overview.SinglePrefixASes++
|
||||||
|
overview.noteSinglePrefix(asn, prefixes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// noteSinglePrefix makes the newest kept route of asn, whose one prefix is in
|
||||||
|
// prefixes, the overview's newest single-prefix route when it is newer.
|
||||||
|
func (overview *Overview) noteSinglePrefix(
|
||||||
|
asn int, prefixes map[netip.Prefix]*origin,
|
||||||
|
) {
|
||||||
|
for prefix, o := range prefixes {
|
||||||
|
newest := o.newestTime()
|
||||||
|
if newest == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if overview.NewestSinglePrefix == nil ||
|
||||||
|
newest.After(overview.NewestSinglePrefix.Time) {
|
||||||
|
overview.NewestSinglePrefix = &PrefixRoute{
|
||||||
|
Prefix: prefix, OriginASN: asn, Time: *newest,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// noteKeptRoutes makes the newest and the oldest kept routes of prefix's
|
||||||
|
// origins the overview's newest and oldest routes when they are newer or older.
|
||||||
|
func (overview *Overview) noteKeptRoutes(prefix netip.Prefix, origins []*origin) {
|
||||||
|
for _, o := range origins {
|
||||||
|
if len(o.routes) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
newest := o.routes[0].time
|
||||||
|
if overview.NewestRoute == nil || newest.After(overview.NewestRoute.Time) {
|
||||||
|
overview.NewestRoute = &PrefixRoute{
|
||||||
|
Prefix: prefix, OriginASN: o.asn, Time: newest,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
oldest := o.routes[len(o.routes)-1].time
|
||||||
|
if overview.OldestRoute == nil || oldest.Before(overview.OldestRoute.Time) {
|
||||||
|
overview.OldestRoute = &PrefixRoute{
|
||||||
|
Prefix: prefix, OriginASN: o.asn, Time: oldest,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// noteBogons adds each origin of prefix to the overview's bogon announcements
|
||||||
|
// when the prefix or the origin AS is reserved.
|
||||||
|
func (overview *Overview) noteBogons(prefix netip.Prefix, origins []*origin) {
|
||||||
|
prefixReason, prefixReserved := bogon.Prefix(prefix)
|
||||||
|
|
||||||
|
for _, o := range origins {
|
||||||
|
asnReason, asnReserved := bogon.ASN(o.asn)
|
||||||
|
if !prefixReserved && !asnReserved {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
announcement := Bogon{
|
||||||
|
Prefix: prefix, OriginASN: o.asn, Peers: int(o.peers.Count()),
|
||||||
|
}
|
||||||
|
|
||||||
|
if prefixReserved {
|
||||||
|
announcement.Reasons = append(announcement.Reasons, prefixReason)
|
||||||
|
}
|
||||||
|
|
||||||
|
if asnReserved {
|
||||||
|
announcement.Reasons = append(announcement.Reasons, asnReason)
|
||||||
|
}
|
||||||
|
|
||||||
|
overview.Bogons = append(overview.Bogons, announcement)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// noteMultipleOrigins adds prefix to the overview's prefixes with more than one
|
||||||
|
// origin AS when it has more than one.
|
||||||
|
func (overview *Overview) noteMultipleOrigins(
|
||||||
|
prefix netip.Prefix, origins []*origin,
|
||||||
|
) {
|
||||||
|
if len(origins) <= 1 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
entry := MultipleOriginPrefix{
|
||||||
|
Prefix: prefix,
|
||||||
|
Origins: make([]OriginPeers, 0, len(origins)),
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, o := range origins {
|
||||||
|
entry.Origins = append(entry.Origins,
|
||||||
|
OriginPeers{ASN: o.asn, Peers: int(o.peers.Count())})
|
||||||
|
}
|
||||||
|
|
||||||
|
overview.MultipleOrigins = append(overview.MultipleOrigins, entry)
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
package routetable
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/netip"
|
||||||
|
"runtime"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestWalkPrefixesVisitsEveryPrefix walks a table with more prefixes than two
|
||||||
|
// of the walk's batches, while another goroutine keeps adding and removing
|
||||||
|
// other prefixes of the same mask length. The walk lets the lock go between
|
||||||
|
// batches, so those updates get in while it runs. Every prefix that was in
|
||||||
|
// the table all along must be visited once.
|
||||||
|
func TestWalkPrefixesVisitsEveryPrefix(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const (
|
||||||
|
asn = 1000
|
||||||
|
peer = "192.0.2.1"
|
||||||
|
// prefixCount is more prefixes than two of the walk's batches.
|
||||||
|
prefixCount = 2*walkBatch + 1
|
||||||
|
)
|
||||||
|
|
||||||
|
routeTime := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
|
||||||
|
table := New(Params{})
|
||||||
|
prefixes := make([]netip.Prefix, 0, prefixCount)
|
||||||
|
addr := netip.MustParseAddr("1.0.0.0")
|
||||||
|
|
||||||
|
for range prefixCount {
|
||||||
|
prefix := netip.PrefixFrom(addr, addr.BitLen())
|
||||||
|
table.Announce(prefix, asn, Route{Peer: peer, Time: routeTime})
|
||||||
|
prefixes = append(prefixes, prefix)
|
||||||
|
addr = addr.Next()
|
||||||
|
}
|
||||||
|
|
||||||
|
stop := make(chan struct{})
|
||||||
|
updated := make(chan struct{})
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer close(updated)
|
||||||
|
|
||||||
|
other := netip.MustParseAddr("2.0.0.0")
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-stop:
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
prefix := netip.PrefixFrom(other, other.BitLen())
|
||||||
|
table.Announce(prefix, asn, Route{Peer: peer, Time: routeTime})
|
||||||
|
table.Withdraw(prefix, peer)
|
||||||
|
|
||||||
|
other = other.Next()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
visits := make(map[netip.Prefix]int)
|
||||||
|
|
||||||
|
table.walkPrefixes(func(prefix netip.Prefix, _ []*origin) {
|
||||||
|
visits[prefix]++
|
||||||
|
})
|
||||||
|
|
||||||
|
close(stop)
|
||||||
|
<-updated
|
||||||
|
|
||||||
|
for _, prefix := range prefixes {
|
||||||
|
if visits[prefix] != 1 {
|
||||||
|
t.Fatalf("the walk visited %s %d times, want once", prefix,
|
||||||
|
visits[prefix])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWalkPrefixesLetsUpdatesIn starts an update during the walk's first
|
||||||
|
// batch. The update must have been made by the time the walk visits the first
|
||||||
|
// prefix of its second batch, because the walk lets the lock go in between.
|
||||||
|
func TestWalkPrefixesLetsUpdatesIn(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const (
|
||||||
|
asn = 1000
|
||||||
|
peer = "192.0.2.1"
|
||||||
|
)
|
||||||
|
|
||||||
|
routeTime := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
|
||||||
|
table := New(Params{})
|
||||||
|
addr := netip.MustParseAddr("1.0.0.0")
|
||||||
|
|
||||||
|
for range walkBatch + 1 {
|
||||||
|
prefix := netip.PrefixFrom(addr, addr.BitLen())
|
||||||
|
table.Announce(prefix, asn, Route{Peer: peer, Time: routeTime})
|
||||||
|
|
||||||
|
addr = addr.Next()
|
||||||
|
}
|
||||||
|
|
||||||
|
updatePrefix := netip.MustParsePrefix("2.0.0.0/8")
|
||||||
|
updateDone := make(chan struct{})
|
||||||
|
visited := 0
|
||||||
|
madeBeforeSecondBatch := false
|
||||||
|
|
||||||
|
table.walkPrefixes(func(netip.Prefix, []*origin) {
|
||||||
|
visited++
|
||||||
|
|
||||||
|
switch visited {
|
||||||
|
case 1:
|
||||||
|
go func() {
|
||||||
|
defer close(updateDone)
|
||||||
|
|
||||||
|
table.Announce(updatePrefix, asn, Route{Peer: peer, Time: routeTime})
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Wait until the update waits for the lock. TryRLock fails
|
||||||
|
// once a writer waits.
|
||||||
|
for table.mu.TryRLock() {
|
||||||
|
table.mu.RUnlock()
|
||||||
|
runtime.Gosched()
|
||||||
|
}
|
||||||
|
case walkBatch + 1:
|
||||||
|
// The walk holds the read lock, so the table can be read.
|
||||||
|
madeBeforeSecondBatch = table.origins(updatePrefix) != nil
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
<-updateDone
|
||||||
|
|
||||||
|
if !madeBeforeSecondBatch {
|
||||||
|
t.Fatal("an update started during the walk's first batch was not made " +
|
||||||
|
"before its second batch")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
package routetable_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/netip"
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/routewatch/internal/routetable"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Origin ASes that are not reserved, unlike asA, asB and asC, which are
|
||||||
|
// documentation AS numbers and so make bogon announcements.
|
||||||
|
const (
|
||||||
|
asOne = 1000
|
||||||
|
asTwo = 2000
|
||||||
|
asThree = 3000
|
||||||
|
asFour = 4000
|
||||||
|
asFive = 5000
|
||||||
|
|
||||||
|
// noKnownOrigin is the origin AS of a route whose path ends in an AS
|
||||||
|
// set.
|
||||||
|
noKnownOrigin = 0
|
||||||
|
// privateUseAS and asTrans are reserved AS numbers.
|
||||||
|
privateUseAS = 64512
|
||||||
|
asTrans = 23456
|
||||||
|
|
||||||
|
// maxListed is how many bogon announcements and prefixes with more than
|
||||||
|
// one origin AS the tests ask Overview for.
|
||||||
|
maxListed = 10
|
||||||
|
)
|
||||||
|
|
||||||
|
// prefixRoute returns the PrefixRoute of prefix with origin AS asn, second
|
||||||
|
// seconds after the start.
|
||||||
|
func prefixRoute(prefix string, asn, second int) *routetable.PrefixRoute {
|
||||||
|
return &routetable.PrefixRoute{
|
||||||
|
Prefix: netip.MustParsePrefix(prefix), OriginASN: asn, Time: at(second),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOverviewOfAnEmptyTable(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
got := routetable.New(routetable.Params{}).Overview(maxListed)
|
||||||
|
if !reflect.DeepEqual(got, routetable.Overview{}) {
|
||||||
|
t.Errorf("Overview() = %+v, want no figures", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestOverviewNewestAndOldestRoutes checks that the newest and the oldest kept
|
||||||
|
// routes are found among every origin's kept routes, and that withdrawn routes
|
||||||
|
// no longer count.
|
||||||
|
func TestOverviewNewestAndOldestRoutes(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
table := routetable.New(routetable.Params{})
|
||||||
|
announceAll(table, "1.0.0.0/24", asOne, 20, peerA, peerB)
|
||||||
|
// The oldest is the older of this origin's two kept routes.
|
||||||
|
announceAll(table, "2.0.0.0/24", asTwo, 10, peerA, peerB)
|
||||||
|
announceAll(table, "2001:4860::/32", asThree, 40, peerC)
|
||||||
|
// Withdrawn routes, newer and older than every other.
|
||||||
|
announceAll(table, "3.0.0.0/24", asOne, 50, peerA)
|
||||||
|
announceAll(table, "4.0.0.0/24", asOne, 5, peerB)
|
||||||
|
table.Withdraw(netip.MustParsePrefix("3.0.0.0/24"), peerA)
|
||||||
|
table.Withdraw(netip.MustParsePrefix("4.0.0.0/24"), peerB)
|
||||||
|
|
||||||
|
got := table.Overview(maxListed)
|
||||||
|
|
||||||
|
want := prefixRoute("2001:4860::/32", asThree, 40)
|
||||||
|
if !reflect.DeepEqual(got.NewestRoute, want) {
|
||||||
|
t.Errorf("NewestRoute = %+v, want %+v", got.NewestRoute, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
want = prefixRoute("2.0.0.0/24", asTwo, 10)
|
||||||
|
if !reflect.DeepEqual(got.OldestRoute, want) {
|
||||||
|
t.Errorf("OldestRoute = %+v, want %+v", got.OldestRoute, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestOverviewPrefixesPerOrigin checks the AS that originates the most
|
||||||
|
// prefixes and the ASes that originate one.
|
||||||
|
func TestOverviewPrefixesPerOrigin(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
table := routetable.New(routetable.Params{})
|
||||||
|
// asOne and asTwo originate two prefixes each; the lower AS number counts.
|
||||||
|
announceAll(table, "1.0.0.0/24", asTwo, 10, peerA)
|
||||||
|
announceAll(table, "1.0.1.0/24", asTwo, 10, peerA)
|
||||||
|
announceAll(table, "2.0.0.0/24", asOne, 10, peerA)
|
||||||
|
announceAll(table, "2.0.1.0/24", asOne, 10, peerB)
|
||||||
|
// Three ASes originate one prefix. asThree's route is the newest, and
|
||||||
|
// asFive's origin keeps no route: peers B and C withdrew theirs.
|
||||||
|
announceAll(table, "3.0.0.0/24", asThree, 30, peerA)
|
||||||
|
announceAll(table, "4.0.0.0/24", asFour, 20, peerA)
|
||||||
|
announceAll(table, "5.0.0.0/24", asFive, 40, peerA, peerB, peerC)
|
||||||
|
table.Withdraw(netip.MustParsePrefix("5.0.0.0/24"), peerB)
|
||||||
|
table.Withdraw(netip.MustParsePrefix("5.0.0.0/24"), peerC)
|
||||||
|
|
||||||
|
got := table.Overview(maxListed)
|
||||||
|
|
||||||
|
if got.MostPrefixesASN != asOne || got.MostPrefixes != 2 {
|
||||||
|
t.Errorf("most prefixes: AS%d with %d, want AS%d with 2",
|
||||||
|
got.MostPrefixesASN, got.MostPrefixes, asOne)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got.SinglePrefixASes != 3 {
|
||||||
|
t.Errorf("SinglePrefixASes = %d, want 3", got.SinglePrefixASes)
|
||||||
|
}
|
||||||
|
|
||||||
|
want := prefixRoute("3.0.0.0/24", asThree, 30)
|
||||||
|
if !reflect.DeepEqual(got.NewestSinglePrefix, want) {
|
||||||
|
t.Errorf("NewestSinglePrefix = %+v, want %+v", got.NewestSinglePrefix, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestOverviewLeavesOutNoKnownOrigin checks that AS 0, which stands for no
|
||||||
|
// known origin, counts in neither the most nor the single-prefix figures. It
|
||||||
|
// originates one prefix, with a route newer than asOne's, and asOne would lose
|
||||||
|
// the tie for the most prefixes to it.
|
||||||
|
func TestOverviewLeavesOutNoKnownOrigin(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
table := routetable.New(routetable.Params{})
|
||||||
|
announceAll(table, "1.0.0.0/24", asOne, 10, peerA)
|
||||||
|
announceAll(table, "2.0.0.0/24", noKnownOrigin, 20, peerA)
|
||||||
|
|
||||||
|
got := table.Overview(maxListed)
|
||||||
|
|
||||||
|
if got.MostPrefixesASN != asOne || got.SinglePrefixASes != 1 {
|
||||||
|
t.Errorf("most prefixes AS%d, %d single-prefix ASes; want AS%d, 1",
|
||||||
|
got.MostPrefixesASN, got.SinglePrefixASes, asOne)
|
||||||
|
}
|
||||||
|
|
||||||
|
want := prefixRoute("1.0.0.0/24", asOne, 10)
|
||||||
|
if !reflect.DeepEqual(got.NewestSinglePrefix, want) {
|
||||||
|
t.Errorf("NewestSinglePrefix = %+v, want %+v", got.NewestSinglePrefix, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestOverviewBogons checks which routes are bogon announcements and why, in
|
||||||
|
// prefix order, and that a smaller maxListed lists fewer but counts them all.
|
||||||
|
func TestOverviewBogons(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
table := routetable.New(routetable.Params{})
|
||||||
|
// A reserved prefix, carried by two peers.
|
||||||
|
announceAll(table, "192.168.0.0/16", asOne, 10, peerA, peerB)
|
||||||
|
// A reserved origin AS.
|
||||||
|
announceAll(table, "1.0.0.0/24", privateUseAS, 10, peerA)
|
||||||
|
// Both.
|
||||||
|
announceAll(table, "10.0.0.0/8", asTrans, 10, peerA)
|
||||||
|
// Neither: a normal route, and a block that may be announced although it
|
||||||
|
// lies inside a reserved one.
|
||||||
|
announceAll(table, "1.0.1.0/24", asOne, 10, peerA)
|
||||||
|
announceAll(table, "2001:4:112::/48", asTwo, 10, peerA)
|
||||||
|
|
||||||
|
want := []routetable.Bogon{
|
||||||
|
{
|
||||||
|
Prefix: netip.MustParsePrefix("1.0.0.0/24"), OriginASN: privateUseAS,
|
||||||
|
Peers: 1, Reasons: []string{"Private-Use (AS64512-AS65534)"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Prefix: netip.MustParsePrefix("10.0.0.0/8"), OriginASN: asTrans,
|
||||||
|
Peers: 1,
|
||||||
|
Reasons: []string{
|
||||||
|
"Private-Use (10.0.0.0/8)", "AS_TRANS (AS23456)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Prefix: netip.MustParsePrefix("192.168.0.0/16"), OriginASN: asOne,
|
||||||
|
Peers: 2, Reasons: []string{"Private-Use (192.168.0.0/16)"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
got := table.Overview(maxListed)
|
||||||
|
if !reflect.DeepEqual(got.Bogons, want) || got.BogonCount != len(want) {
|
||||||
|
t.Errorf("Overview(%d): %d bogons %+v, want %d %+v", maxListed,
|
||||||
|
got.BogonCount, got.Bogons, len(want), want)
|
||||||
|
}
|
||||||
|
|
||||||
|
const fewer = 2
|
||||||
|
|
||||||
|
got = table.Overview(fewer)
|
||||||
|
if !reflect.DeepEqual(got.Bogons, want[:fewer]) || got.BogonCount != len(want) {
|
||||||
|
t.Errorf("Overview(%d): %d bogons %+v, want %d %+v", fewer,
|
||||||
|
got.BogonCount, got.Bogons, len(want), want[:fewer])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestOverviewMultipleOrigins checks the prefixes with more than one origin
|
||||||
|
// AS, in prefix order, and that a smaller maxListed lists fewer but counts them
|
||||||
|
// all.
|
||||||
|
func TestOverviewMultipleOrigins(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
table := routetable.New(routetable.Params{})
|
||||||
|
announceAll(table, "2001:4860::/32", asThree, 10, peerA)
|
||||||
|
announceAll(table, "2001:4860::/32", asTwo, 10, peerB)
|
||||||
|
announceAll(table, "1.0.0.0/24", asOne, 10, peerA, peerB)
|
||||||
|
announceAll(table, "1.0.0.0/24", asTwo, 30, peerC)
|
||||||
|
// One origin only.
|
||||||
|
announceAll(table, "2.0.0.0/24", asOne, 10, peerA, peerB)
|
||||||
|
|
||||||
|
want := []routetable.MultipleOriginPrefix{
|
||||||
|
{
|
||||||
|
Prefix: netip.MustParsePrefix("1.0.0.0/24"),
|
||||||
|
Origins: []routetable.OriginPeers{
|
||||||
|
{ASN: asOne, Peers: 2}, {ASN: asTwo, Peers: 1},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Prefix: netip.MustParsePrefix("2001:4860::/32"),
|
||||||
|
Origins: []routetable.OriginPeers{
|
||||||
|
{ASN: asTwo, Peers: 1}, {ASN: asThree, Peers: 1},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
got := table.Overview(maxListed)
|
||||||
|
if !reflect.DeepEqual(got.MultipleOrigins, want) ||
|
||||||
|
got.MultipleOriginCount != len(want) {
|
||||||
|
t.Errorf("Overview(%d): %d prefixes %+v, want %d %+v", maxListed,
|
||||||
|
got.MultipleOriginCount, got.MultipleOrigins, len(want), want)
|
||||||
|
}
|
||||||
|
|
||||||
|
got = table.Overview(1)
|
||||||
|
if !reflect.DeepEqual(got.MultipleOrigins, want[:1]) ||
|
||||||
|
got.MultipleOriginCount != len(want) {
|
||||||
|
t.Errorf("Overview(1): %d prefixes %+v, want %d %+v",
|
||||||
|
got.MultipleOriginCount, got.MultipleOrigins, len(want), want[:1])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -625,6 +625,7 @@ func TestConcurrentUse(t *testing.T) {
|
|||||||
func(int) { table.OriginatedPrefixes(asA) },
|
func(int) { table.OriginatedPrefixes(asA) },
|
||||||
func(int) { table.SamplePrefixes(ipVersion, prefix.Bits(), sampleSize) },
|
func(int) { table.SamplePrefixes(ipVersion, prefix.Bits(), sampleSize) },
|
||||||
func(int) { table.Counts() },
|
func(int) { table.Counts() },
|
||||||
|
func(int) { table.Overview(sampleSize) },
|
||||||
}
|
}
|
||||||
|
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
|
|||||||
@@ -223,6 +223,35 @@ func (m *mockStore) GetAS(_ context.Context, asn int) (*database.ASN, bool, erro
|
|||||||
return &database.ASN{ASN: asn}, false, nil
|
return &database.ASN{ASN: asn}, false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetLastSeenASes returns copies of the stored ASes last seen most recently
|
||||||
|
// and longest ago, or neither when none is stored.
|
||||||
|
func (m *mockStore) GetLastSeenASes(
|
||||||
|
_ context.Context,
|
||||||
|
) (*database.ASN, *database.ASN, error) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
|
var newest, oldest *database.ASN
|
||||||
|
|
||||||
|
for _, asn := range m.ASNs {
|
||||||
|
if newest == nil || asn.LastSeen.After(newest.LastSeen) {
|
||||||
|
newest = asn
|
||||||
|
}
|
||||||
|
|
||||||
|
if oldest == nil || asn.LastSeen.Before(oldest.LastSeen) {
|
||||||
|
oldest = asn
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if newest == nil {
|
||||||
|
return nil, nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
newestCopy, oldestCopy := *newest, *oldest
|
||||||
|
|
||||||
|
return &newestCopy, &oldestCopy, nil
|
||||||
|
}
|
||||||
|
|
||||||
// GetASWHOIS returns the stored AS, or only its number when none is stored.
|
// GetASWHOIS returns the stored AS, or only its number when none is stored.
|
||||||
func (m *mockStore) GetASWHOIS(ctx context.Context, asn int) (*database.ASN, error) {
|
func (m *mockStore) GetASWHOIS(ctx context.Context, asn int) (*database.ASN, error) {
|
||||||
asnInfo, _, err := m.GetAS(ctx, asn)
|
asnInfo, _, err := m.GetAS(ctx, asn)
|
||||||
@@ -605,12 +634,15 @@ func TestShutdownReturnsByItsDeadlineWhileAWriteIsBlocked(t *testing.T) {
|
|||||||
cfg := &config.Config{Port: port}
|
cfg := &config.Config{Port: port}
|
||||||
s := streamer.New(log, metrics.New())
|
s := streamer.New(log, metrics.New())
|
||||||
|
|
||||||
|
// A started server reads its routing table in the background, so it
|
||||||
|
// needs one.
|
||||||
rw := New(Dependencies{
|
rw := New(Dependencies{
|
||||||
DB: store,
|
DB: store,
|
||||||
Streamer: s,
|
Streamer: s,
|
||||||
Server: server.New(store, nil, s, log, cfg),
|
Server: server.New(store, routetable.New(routetable.Params{}), s, log,
|
||||||
Logger: log,
|
cfg),
|
||||||
Config: cfg,
|
Logger: log,
|
||||||
|
Config: cfg,
|
||||||
})
|
})
|
||||||
|
|
||||||
err := rw.server.Start(t.Context())
|
err := rw.server.Start(t.Context())
|
||||||
|
|||||||
@@ -160,6 +160,7 @@ func (s *Server) handleIndex() http.HandlerFunc {
|
|||||||
err := tmpl.Execute(w, indexPageData{
|
err := tmpl.Execute(w, indexPageData{
|
||||||
CSPNonce: cspNonce(r.Context()),
|
CSPNonce: cspNonce(r.Context()),
|
||||||
ClientIP: extractClientIP(r),
|
ClientIP: extractClientIP(r),
|
||||||
|
Cards: s.cards.Load(),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.logger.Error("Failed to render index template", "error", err)
|
s.logger.Error("Failed to render index template", "error", err)
|
||||||
@@ -181,6 +182,9 @@ type indexPageData struct {
|
|||||||
// ClientIP is the visitor's address, which the IP information API card
|
// ClientIP is the visitor's address, which the IP information API card
|
||||||
// looks up: the address /ip/ would look up for the same request.
|
// looks up: the address /ip/ would look up for the same request.
|
||||||
ClientIP string
|
ClientIP string
|
||||||
|
// Cards is the copy of the cards that show figures of the routing table,
|
||||||
|
// or nil before the server has first worked them out.
|
||||||
|
Cards *homeCards
|
||||||
}
|
}
|
||||||
|
|
||||||
// writeJSONError writes a standardized JSON error response with the given
|
// writeJSONError writes a standardized JSON error response with the given
|
||||||
|
|||||||
@@ -0,0 +1,206 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"html/template"
|
||||||
|
"net/http"
|
||||||
|
"slices"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/routewatch/internal/database"
|
||||||
|
"git.eeqj.de/sneak/routewatch/internal/routetable"
|
||||||
|
"git.eeqj.de/sneak/routewatch/internal/templates"
|
||||||
|
asinfo "git.eeqj.de/sneak/routewatch/pkg/asinfo"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// homeCardsRefreshInterval is how often the server works out the home
|
||||||
|
// page's cards again. Some of their figures take a walk over the whole
|
||||||
|
// routing table, so no request works them out.
|
||||||
|
homeCardsRefreshInterval = time.Minute
|
||||||
|
|
||||||
|
// homeCardsDatabaseTimeout is how long the database read behind the
|
||||||
|
// cards of the newest and the oldest AS may take.
|
||||||
|
homeCardsDatabaseTimeout = 4 * time.Second
|
||||||
|
|
||||||
|
// maxListedEntries is how many entries the bogon and multiple-origin
|
||||||
|
// pages list at most.
|
||||||
|
maxListedEntries = 1000
|
||||||
|
)
|
||||||
|
|
||||||
|
// homeCards is what the home page's cards show. The bogon and multiple-origin
|
||||||
|
// pages list the entries their cards count. The server works it out every
|
||||||
|
// homeCardsRefreshInterval and serves this copy in between.
|
||||||
|
type homeCards struct {
|
||||||
|
routetable.Overview
|
||||||
|
|
||||||
|
// NewestAS and OldestAS are the ASes the asns table last saw most
|
||||||
|
// recently and longest ago. They are nil when the database did not
|
||||||
|
// answer or holds no AS.
|
||||||
|
NewestAS *database.ASN
|
||||||
|
OldestAS *database.ASN
|
||||||
|
|
||||||
|
// WellKnownNetworks are the networks of wellKnownASNs, newest update
|
||||||
|
// first.
|
||||||
|
WellKnownNetworks []wellKnownNetwork
|
||||||
|
}
|
||||||
|
|
||||||
|
// wellKnownNetwork is one network on the home page's card of well-known
|
||||||
|
// networks.
|
||||||
|
type wellKnownNetwork struct {
|
||||||
|
ASN int
|
||||||
|
// Name is its description in the AS list built into RouteWatch.
|
||||||
|
Name string
|
||||||
|
// Prefixes is how many prefixes it originates.
|
||||||
|
Prefixes int
|
||||||
|
// Newest is the time of its newest kept route, or nil when it keeps none.
|
||||||
|
Newest *time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// listPageData is what the bogon and multiple-origin pages are rendered with.
|
||||||
|
type listPageData struct {
|
||||||
|
// CSPNonce is the nonce the page's style block carries.
|
||||||
|
CSPNonce string
|
||||||
|
// Cards is the copy of the home page's cards, or nil before the server
|
||||||
|
// has first worked them out.
|
||||||
|
Cards *homeCards
|
||||||
|
}
|
||||||
|
|
||||||
|
// The AS numbers of the networks the home page lists as well known.
|
||||||
|
const (
|
||||||
|
appleASN = 714
|
||||||
|
googleASN = 15169
|
||||||
|
amazonASN = 16509
|
||||||
|
microsoftASN = 8075
|
||||||
|
metaASN = 32934
|
||||||
|
cloudflareASN = 13335
|
||||||
|
akamaiASN = 20940
|
||||||
|
netflixASN = 2906
|
||||||
|
dodNetworkInfoCtrASN = 721
|
||||||
|
cogentASN = 174
|
||||||
|
hurricaneElectricASN = 6939
|
||||||
|
level3ASN = 3356
|
||||||
|
arelionASN = 1299
|
||||||
|
nttASN = 2914
|
||||||
|
mitASN = 3
|
||||||
|
)
|
||||||
|
|
||||||
|
// wellKnownASNs returns the AS numbers of the networks the home page lists as
|
||||||
|
// well known: large companies, large transit networks, the US Department of
|
||||||
|
// Defense's Network Information Center and a university.
|
||||||
|
func wellKnownASNs() []int {
|
||||||
|
return []int{
|
||||||
|
appleASN, googleASN, amazonASN, microsoftASN, metaASN, cloudflareASN,
|
||||||
|
akamaiASN, netflixASN, dodNetworkInfoCtrASN, cogentASN,
|
||||||
|
hurricaneElectricASN, level3ASN, arelionASN, nttASN, mitASN,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// keepHomeCardsFresh works out the home page's cards now, and again every
|
||||||
|
// interval until ctx ends.
|
||||||
|
func (s *Server) keepHomeCardsFresh(ctx context.Context, interval time.Duration) {
|
||||||
|
ticker := time.NewTicker(interval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
s.refreshHomeCards(ctx)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// refreshHomeCards works out the home page's cards and keeps them for the
|
||||||
|
// pages to serve. When the database does not answer, the failure is logged
|
||||||
|
// and the cards of the newest and the oldest AS stay empty.
|
||||||
|
func (s *Server) refreshHomeCards(ctx context.Context) {
|
||||||
|
cards := &homeCards{
|
||||||
|
Overview: s.table.Overview(maxListedEntries),
|
||||||
|
WellKnownNetworks: s.wellKnownNetworks(),
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, homeCardsDatabaseTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
newest, oldest, err := s.db.GetLastSeenASes(ctx)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Warn("Failed to read the newest and the oldest AS", "error", err)
|
||||||
|
} else {
|
||||||
|
cards.NewestAS, cards.OldestAS = newest, oldest
|
||||||
|
}
|
||||||
|
|
||||||
|
s.cards.Store(cards)
|
||||||
|
}
|
||||||
|
|
||||||
|
// wellKnownNetworks returns the networks of wellKnownASNs, each with how many
|
||||||
|
// prefixes it originates and the time of its newest kept route. They come
|
||||||
|
// newest first, and those that keep no route last.
|
||||||
|
func (s *Server) wellKnownNetworks() []wellKnownNetwork {
|
||||||
|
asns := wellKnownASNs()
|
||||||
|
networks := make([]wellKnownNetwork, 0, len(asns))
|
||||||
|
|
||||||
|
for _, asn := range asns {
|
||||||
|
originated := s.table.OriginatedPrefixes(asn)
|
||||||
|
network := wellKnownNetwork{ASN: asn, Prefixes: len(originated)}
|
||||||
|
|
||||||
|
if info, ok := asinfo.Get(asn); ok {
|
||||||
|
network.Name = info.Description
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, p := range originated {
|
||||||
|
if p.Newest != nil &&
|
||||||
|
(network.Newest == nil || p.Newest.After(*network.Newest)) {
|
||||||
|
network.Newest = p.Newest
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
networks = append(networks, network)
|
||||||
|
}
|
||||||
|
|
||||||
|
slices.SortStableFunc(networks, func(a, b wellKnownNetwork) int {
|
||||||
|
switch {
|
||||||
|
case a.Newest == nil && b.Newest == nil:
|
||||||
|
return 0
|
||||||
|
case a.Newest == nil:
|
||||||
|
return 1
|
||||||
|
case b.Newest == nil:
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
return b.Newest.Compare(*a.Newest)
|
||||||
|
})
|
||||||
|
|
||||||
|
return networks
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleBogons returns a handler that serves the page listing the bogon
|
||||||
|
// announcements.
|
||||||
|
func (s *Server) handleBogons() http.HandlerFunc {
|
||||||
|
return s.listPageHandler(templates.BogonsTemplate())
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleMultipleOrigins returns a handler that serves the page listing the
|
||||||
|
// prefixes announced by more than one origin AS.
|
||||||
|
func (s *Server) handleMultipleOrigins() http.HandlerFunc {
|
||||||
|
return s.listPageHandler(templates.MultipleOriginsTemplate())
|
||||||
|
}
|
||||||
|
|
||||||
|
// listPageHandler returns a handler that renders tmpl, a page that lists
|
||||||
|
// entries the home page's cards count, from the copy of the cards.
|
||||||
|
func (s *Server) listPageHandler(tmpl *template.Template) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
|
||||||
|
err := tmpl.Execute(w, listPageData{
|
||||||
|
CSPNonce: cspNonce(r.Context()),
|
||||||
|
Cards: s.cards.Load(),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Error("Failed to render a list page", "error", err)
|
||||||
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,358 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/netip"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/routewatch/internal/database"
|
||||||
|
"git.eeqj.de/sneak/routewatch/internal/routetable"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The ASes of the routing table that newCardsTable fills. Each card test
|
||||||
|
// finds one of them on its card.
|
||||||
|
const (
|
||||||
|
twoPrefixASN = 13335 // originates two prefixes, the most
|
||||||
|
newestRouteASN = 15169 // the newest route
|
||||||
|
oldestRouteASN = 3 // the oldest route
|
||||||
|
secondOrigin = 16509 // a second origin AS of twoPrefixASN's prefix
|
||||||
|
privateUseASN = 64512 // reserved, so its route is a bogon announcement
|
||||||
|
)
|
||||||
|
|
||||||
|
// Paths of the pages that list what two of the home page's cards count.
|
||||||
|
const (
|
||||||
|
bogonsPath = "/bogons"
|
||||||
|
multipleOriginsPath = "/multiple-origins"
|
||||||
|
)
|
||||||
|
|
||||||
|
// cardsNotReadyText is what the pages say before the cards are first worked
|
||||||
|
// out.
|
||||||
|
const cardsNotReadyText = "being worked out"
|
||||||
|
|
||||||
|
// newCardsTable returns a routing table in which:
|
||||||
|
// - twoPrefixASN originates 1.0.0.0/24 and 1.0.4.0/22;
|
||||||
|
// - secondOrigin originates 1.0.0.0/24 too, which so has two origin ASes;
|
||||||
|
// - newestRouteASN's 8.8.8.0/24 has the newest route;
|
||||||
|
// - oldestRouteASN's 18.0.0.0/8 has the oldest;
|
||||||
|
// - privateUseASN originates 10.0.0.0/8, carried by two peers: a bogon
|
||||||
|
// announcement for both its prefix and its AS.
|
||||||
|
//
|
||||||
|
// The last four ASes originate one prefix each.
|
||||||
|
func newCardsTable() *routetable.Table {
|
||||||
|
table := emptyTable()
|
||||||
|
announce(table, "1.0.0.0/24", twoPrefixASN, testPeer(1), testRouteTime(10))
|
||||||
|
announce(table, "1.0.4.0/22", twoPrefixASN, testPeer(1), testRouteTime(5))
|
||||||
|
announce(table, "1.0.0.0/24", secondOrigin, testPeer(3), testRouteTime(12))
|
||||||
|
announce(table, "8.8.8.0/24", newestRouteASN, testPeer(2), testRouteTime(20))
|
||||||
|
announce(table, "18.0.0.0/8", oldestRouteASN, testPeer(2), testRouteTime(1))
|
||||||
|
announce(table, "10.0.0.0/8", privateUseASN, testPeer(1), testRouteTime(15))
|
||||||
|
announce(table, "10.0.0.0/8", privateUseASN, testPeer(2), testRouteTime(15))
|
||||||
|
|
||||||
|
return table
|
||||||
|
}
|
||||||
|
|
||||||
|
// homeCard returns the HTML of the home page's card with heading, up to the
|
||||||
|
// end of the card.
|
||||||
|
func homeCard(t *testing.T, page, heading string) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
_, card, found := strings.Cut(page, "<h2>"+heading+"</h2>")
|
||||||
|
if !found {
|
||||||
|
t.Fatalf("the home page has no card %q", heading)
|
||||||
|
}
|
||||||
|
|
||||||
|
card, _, _ = strings.Cut(card, "</div>")
|
||||||
|
|
||||||
|
return card
|
||||||
|
}
|
||||||
|
|
||||||
|
// asLink returns the HTML attribute of a link to the AS page of asn.
|
||||||
|
func asLink(asn int) string {
|
||||||
|
return `href="/as/` + strconv.Itoa(asn) + `"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHomePageCardsShowTheTableFigures fills a routing table and the asns
|
||||||
|
// table, works out the cards, and checks that each card shows its figure.
|
||||||
|
func TestHomePageCardsShowTheTableFigures(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const (
|
||||||
|
newestSeenASN = 6939
|
||||||
|
oldestSeenASN = 174
|
||||||
|
)
|
||||||
|
|
||||||
|
s := newServerWithTable(t, newCardsTable())
|
||||||
|
|
||||||
|
err := s.db.GetOrCreateASNBatch(map[int]database.SeenTimes{
|
||||||
|
newestSeenASN: {First: testRouteTime(0), Last: testRouteTime(30)},
|
||||||
|
oldestSeenASN: {First: testRouteTime(-30), Last: testRouteTime(-30)},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetOrCreateASNBatch: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.refreshHomeCards(t.Context())
|
||||||
|
|
||||||
|
page := get(t, s, "/").Body.String()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
heading string
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{"Newest Updated Route", []string{
|
||||||
|
"8.8.8.0/24", asLink(newestRouteASN),
|
||||||
|
testRouteTime(20).Format(routeTimeFormat),
|
||||||
|
}},
|
||||||
|
{"Oldest Updated Route", []string{
|
||||||
|
"18.0.0.0/8", asLink(oldestRouteASN),
|
||||||
|
testRouteTime(1).Format(routeTimeFormat),
|
||||||
|
}},
|
||||||
|
{"Newest Updated AS", []string{
|
||||||
|
asLink(newestSeenASN), testRouteTime(30).Format(routeTimeFormat),
|
||||||
|
}},
|
||||||
|
{"Oldest Updated AS", []string{
|
||||||
|
asLink(oldestSeenASN), testRouteTime(-30).Format(routeTimeFormat),
|
||||||
|
}},
|
||||||
|
{"AS with the Most Prefixes", []string{
|
||||||
|
asLink(twoPrefixASN), "Prefixes it originates: 2",
|
||||||
|
}},
|
||||||
|
{"ASes with the Fewest Prefixes", []string{
|
||||||
|
"ASes that originate a single prefix: 4", asLink(newestRouteASN),
|
||||||
|
"8.8.8.0/24",
|
||||||
|
}},
|
||||||
|
{"Bogon Announcements", []string{`href="/bogons">1<`}},
|
||||||
|
{"Prefixes with Several Origins", []string{`href="/multiple-origins">1<`}},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
card := homeCard(t, page, tc.heading)
|
||||||
|
for _, want := range tc.want {
|
||||||
|
if !strings.Contains(card, want) {
|
||||||
|
t.Errorf("card %q does not hold %s: %s", tc.heading, want, card)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The well-known networks come newest update first, and those without
|
||||||
|
// a route after them.
|
||||||
|
networks := homeCard(t, page, "Well-Known Networks")
|
||||||
|
order := []int{newestRouteASN, secondOrigin, twoPrefixASN, oldestRouteASN, 714}
|
||||||
|
last := -1
|
||||||
|
|
||||||
|
for _, asn := range order {
|
||||||
|
i := strings.Index(networks, asLink(asn))
|
||||||
|
if i <= last {
|
||||||
|
t.Fatalf("well-known networks: AS%d is not after the ASes before "+
|
||||||
|
"it in %v: %s", asn, order, networks)
|
||||||
|
}
|
||||||
|
|
||||||
|
last = i
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(networks, "<td>2</td>") {
|
||||||
|
t.Errorf("well-known networks: no network with 2 prefixes: %s", networks)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestListPagesListTheirEntries checks the rows of the bogon and multiple-origin
|
||||||
|
// pages for the table of newCardsTable.
|
||||||
|
func TestListPagesListTheirEntries(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s := newServerWithTable(t, newCardsTable())
|
||||||
|
s.refreshHomeCards(t.Context())
|
||||||
|
|
||||||
|
pages := map[string][]string{
|
||||||
|
bogonsPath: {
|
||||||
|
"Bogon announcements: 1.", "10.0.0.0/8", asLink(privateUseASN),
|
||||||
|
"<td>2</td>",
|
||||||
|
"Private-Use (10.0.0.0/8); Private-Use (AS64512-AS65534)",
|
||||||
|
},
|
||||||
|
multipleOriginsPath: {
|
||||||
|
"Prefixes announced by more than one origin AS: 1.", "1.0.0.0/24",
|
||||||
|
asLink(twoPrefixASN), asLink(secondOrigin), ">1 peer<",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for path, wants := range pages {
|
||||||
|
page := get(t, s, path).Body.String()
|
||||||
|
for _, want := range wants {
|
||||||
|
if !strings.Contains(page, want) {
|
||||||
|
t.Errorf("%s does not hold %s", path, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestListPagesListAtMostMaxListedEntries fills a table with more prefixes than
|
||||||
|
// the list pages list, each announced by two origin ASes in reserved address
|
||||||
|
// space. Each page lists maxListedEntries of them and gives how many there are
|
||||||
|
// in all.
|
||||||
|
func TestListPagesListAtMostMaxListedEntries(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const prefixCount = maxListedEntries + 1
|
||||||
|
|
||||||
|
table := emptyTable()
|
||||||
|
addr := netip.MustParseAddr("10.0.0.0")
|
||||||
|
|
||||||
|
for range prefixCount {
|
||||||
|
prefix := netip.PrefixFrom(addr, addr.BitLen()).String()
|
||||||
|
announce(table, prefix, twoPrefixASN, testPeer(1), testRouteTime(0))
|
||||||
|
announce(table, prefix, secondOrigin, testPeer(2), testRouteTime(0))
|
||||||
|
|
||||||
|
addr = addr.Next()
|
||||||
|
}
|
||||||
|
|
||||||
|
s := newServerWithTable(t, table)
|
||||||
|
s.refreshHomeCards(t.Context())
|
||||||
|
|
||||||
|
listed := "The first " + strconv.Itoa(maxListedEntries) + " are listed"
|
||||||
|
tests := []struct {
|
||||||
|
path string
|
||||||
|
total string
|
||||||
|
}{
|
||||||
|
{bogonsPath, "Bogon announcements: " + strconv.Itoa(2*prefixCount) + "."},
|
||||||
|
{multipleOriginsPath, "Prefixes announced by more than one origin AS: " +
|
||||||
|
strconv.Itoa(prefixCount) + "."},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
page := get(t, s, tc.path).Body.String()
|
||||||
|
|
||||||
|
rows := strings.Count(page, `class="prefix-link"`)
|
||||||
|
if rows != maxListedEntries {
|
||||||
|
t.Errorf("%s lists %d entries, want %d", tc.path, rows,
|
||||||
|
maxListedEntries)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(page, tc.total) || !strings.Contains(page, listed) {
|
||||||
|
t.Errorf("%s does not say %q and %q", tc.path, tc.total, listed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPagesServeTheCopyOfTheCards checks that the pages show the cards as they
|
||||||
|
// were last worked out, not as the routing table is now, and that they say so
|
||||||
|
// before the cards are first worked out.
|
||||||
|
func TestPagesServeTheCopyOfTheCards(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
table := emptyTable()
|
||||||
|
announce(table, "1.0.0.0/24", twoPrefixASN, testPeer(1), testRouteTime(0))
|
||||||
|
|
||||||
|
s := newServerWithTable(t, table)
|
||||||
|
|
||||||
|
for _, path := range []string{"/", bogonsPath, multipleOriginsPath} {
|
||||||
|
if !strings.Contains(get(t, s, path).Body.String(), cardsNotReadyText) {
|
||||||
|
t.Errorf("%s does not say the cards are %s", path, cardsNotReadyText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
s.refreshHomeCards(t.Context())
|
||||||
|
announce(table, "8.8.8.0/24", newestRouteASN, testPeer(2), testRouteTime(1))
|
||||||
|
|
||||||
|
card := homeCard(t, get(t, s, "/").Body.String(), "Newest Updated Route")
|
||||||
|
if !strings.Contains(card, "1.0.0.0/24") {
|
||||||
|
t.Errorf("before the cards are worked out again, the newest route "+
|
||||||
|
"is not the one they were worked out with: %s", card)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.refreshHomeCards(t.Context())
|
||||||
|
|
||||||
|
card = homeCard(t, get(t, s, "/").Body.String(), "Newest Updated Route")
|
||||||
|
if !strings.Contains(card, "8.8.8.0/24") {
|
||||||
|
t.Errorf("after the cards are worked out again, the newest route is "+
|
||||||
|
"not the table's: %s", card)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestKeepHomeCardsFreshWorksThemOutEveryInterval runs keepHomeCardsFresh with
|
||||||
|
// a short interval. It works the cards out at once, again after the table
|
||||||
|
// changes, and stops when its context ends.
|
||||||
|
func TestKeepHomeCardsFreshWorksThemOutEveryInterval(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const (
|
||||||
|
interval = 10 * time.Millisecond
|
||||||
|
maxWait = 5 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
table := emptyTable()
|
||||||
|
announce(table, "1.0.0.0/24", twoPrefixASN, testPeer(1), testRouteTime(0))
|
||||||
|
|
||||||
|
s := newServerWithTable(t, table)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(t.Context())
|
||||||
|
stopped := make(chan struct{})
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
s.keepHomeCardsFresh(ctx, interval)
|
||||||
|
close(stopped)
|
||||||
|
}()
|
||||||
|
|
||||||
|
newestRouteASNIs := func(asn int) bool {
|
||||||
|
cards := s.cards.Load()
|
||||||
|
|
||||||
|
return cards != nil && cards.NewestRoute != nil &&
|
||||||
|
cards.NewestRoute.OriginASN == asn
|
||||||
|
}
|
||||||
|
|
||||||
|
waitUntil(t, maxWait, func() bool { return newestRouteASNIs(twoPrefixASN) })
|
||||||
|
|
||||||
|
announce(table, "8.8.8.0/24", newestRouteASN, testPeer(2), testRouteTime(1))
|
||||||
|
waitUntil(t, maxWait, func() bool { return newestRouteASNIs(newestRouteASN) })
|
||||||
|
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-stopped:
|
||||||
|
case <-time.After(maxWait):
|
||||||
|
t.Fatal("keepHomeCardsFresh did not stop when its context ended")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestStartedServerWorksOutTheCards starts a server and waits until its home
|
||||||
|
// page shows the cards.
|
||||||
|
func TestStartedServerWorksOutTheCards(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const maxWait = 5 * time.Second
|
||||||
|
|
||||||
|
s := newServerWithTable(t, newCardsTable())
|
||||||
|
|
||||||
|
err := s.Start(t.Context())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Start: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = s.Stop(context.Background()) }()
|
||||||
|
|
||||||
|
waitUntil(t, maxWait, func() bool {
|
||||||
|
return !strings.Contains(get(t, s, "/").Body.String(), cardsNotReadyText)
|
||||||
|
})
|
||||||
|
|
||||||
|
card := homeCard(t, get(t, s, "/").Body.String(), "Newest Updated Route")
|
||||||
|
if !strings.Contains(card, "8.8.8.0/24") {
|
||||||
|
t.Errorf("the newest route is not the table's: %s", card)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// waitUntil fails the test unless done reports true within maxWait.
|
||||||
|
func waitUntil(t *testing.T, maxWait time.Duration, done func() bool) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
const pollInterval = 5 * time.Millisecond
|
||||||
|
|
||||||
|
deadline := time.Now().Add(maxWait)
|
||||||
|
for !done() {
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
t.Fatalf("the cards did not change within %s", maxWait)
|
||||||
|
}
|
||||||
|
|
||||||
|
time.Sleep(pollInterval)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -55,6 +55,10 @@ func (s *Server) setupRoutes() {
|
|||||||
r.Get("/prefixlength/{length}", s.handlePrefixLength())
|
r.Get("/prefixlength/{length}", s.handlePrefixLength())
|
||||||
r.Get("/prefixlength6/{length}", s.handlePrefixLength6())
|
r.Get("/prefixlength6/{length}", s.handlePrefixLength6())
|
||||||
|
|
||||||
|
// The pages that list what two of the home page's cards count
|
||||||
|
r.Get("/bogons", s.handleBogons())
|
||||||
|
r.Get("/multiple-origins", s.handleMultipleOrigins())
|
||||||
|
|
||||||
// The JSON routes answer a path or method they do not take with the JSON
|
// The JSON routes answer a path or method they do not take with the JSON
|
||||||
// error body, as they answer everything else.
|
// error body, as they answer everything else.
|
||||||
jsonNotFound := func(w http.ResponseWriter, _ *http.Request) {
|
jsonNotFound := func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
// Package server serves RouteWatch's web pages and JSON API:
|
// Package server serves RouteWatch's web pages and JSON API:
|
||||||
// - the home page, the status figures and the health check
|
// - the home page, the status figures and the health check
|
||||||
|
// - the pages that list the bogon announcements and the prefixes announced
|
||||||
|
// by more than one origin AS
|
||||||
// - the AS, prefix and prefix length pages, and the JSON answers behind them
|
// - the AS, prefix and prefix length pages, and the JSON answers behind them
|
||||||
// - the IP lookups
|
// - the IP lookups
|
||||||
// - the AS name search
|
// - the AS name search
|
||||||
@@ -17,6 +19,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.eeqj.de/sneak/routewatch/internal/blocklist"
|
"git.eeqj.de/sneak/routewatch/internal/blocklist"
|
||||||
@@ -75,6 +78,10 @@ type Server struct {
|
|||||||
// resolver makes the IP lookup's DNS lookups: net.DefaultResolver, or a
|
// resolver makes the IP lookup's DNS lookups: net.DefaultResolver, or a
|
||||||
// fake in tests
|
// fake in tests
|
||||||
resolver dnsResolver
|
resolver dnsResolver
|
||||||
|
|
||||||
|
// cards is the copy of the home page's cards that the pages serve, nil
|
||||||
|
// until the server first works them out
|
||||||
|
cards atomic.Pointer[homeCards]
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a new HTTP server that reads routes from table, the routing
|
// New creates a new HTTP server that reads routes from table, the routing
|
||||||
@@ -103,7 +110,8 @@ func New(
|
|||||||
|
|
||||||
// Start listens on the configured port and serves HTTP in the background.
|
// Start listens on the configured port and serves HTTP in the background.
|
||||||
// It returns an error if it cannot listen, for example because the port is
|
// It returns an error if it cannot listen, for example because the port is
|
||||||
// already in use. ctx bounds only opening the port, not serving.
|
// already in use. ctx bounds opening the port, not serving. Once listening,
|
||||||
|
// the server works out the home page's cards every minute until ctx ends.
|
||||||
func (s *Server) Start(ctx context.Context) error {
|
func (s *Server) Start(ctx context.Context) error {
|
||||||
const (
|
const (
|
||||||
network = "tcp"
|
network = "tcp"
|
||||||
@@ -140,6 +148,10 @@ func (s *Server) Start(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
s.keepHomeCardsFresh(ctx, homeCardsRefreshInterval)
|
||||||
|
}()
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Bogon Announcements - RouteWatch</title>
|
||||||
|
<style nonce="{{.CSPNonce}}">
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Navbar styles */
|
||||||
|
.navbar {
|
||||||
|
background: #2c3e50;
|
||||||
|
padding: 0 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
height: 56px;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
.navbar-brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.navbar-brand a {
|
||||||
|
color: white;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.navbar-brand a:hover {
|
||||||
|
color: #ecf0f1;
|
||||||
|
}
|
||||||
|
.navbar-brand .by {
|
||||||
|
font-weight: normal;
|
||||||
|
color: #95a5a6;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.navbar-brand .author {
|
||||||
|
color: #3498db;
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
.navbar-brand .author:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
.navbar-links {
|
||||||
|
display: flex;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
.navbar-links a {
|
||||||
|
color: #ecf0f1;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 14px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
.navbar-links a:hover {
|
||||||
|
background: rgba(255,255,255,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Main content */
|
||||||
|
.main-content {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.subtitle {
|
||||||
|
color: #666;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
background: white;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
th {
|
||||||
|
background: #f8f9fa;
|
||||||
|
padding: 12px;
|
||||||
|
text-align: left;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
border-bottom: 2px solid #dee2e6;
|
||||||
|
}
|
||||||
|
td {
|
||||||
|
padding: 12px;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
}
|
||||||
|
tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
tr:hover {
|
||||||
|
background: #f8f9fa;
|
||||||
|
}
|
||||||
|
a {
|
||||||
|
color: #0066cc;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
.prefix-link {
|
||||||
|
font-family: 'SF Mono', Monaco, 'Cascadia Mono', 'Roboto Mono', Consolas, 'Courier New', monospace;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<nav class="navbar">
|
||||||
|
<div class="navbar-brand">
|
||||||
|
<a href="/">routewatch</a>
|
||||||
|
<span class="by">by</span>
|
||||||
|
<a href="https://sneak.berlin" class="author">@sneak</a>
|
||||||
|
</div>
|
||||||
|
<div class="navbar-links">
|
||||||
|
<a href="/">Home</a>
|
||||||
|
<a href="/status">Status</a>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main class="main-content">
|
||||||
|
<h1>Bogon Announcements</h1>
|
||||||
|
{{ with .Cards }}
|
||||||
|
<p class="subtitle">
|
||||||
|
Bogon announcements: {{ .BogonCount }}.
|
||||||
|
A bogon announcement is a route that announces a reserved prefix, or that comes from a reserved AS number.
|
||||||
|
{{ if lt (len .Bogons) .BogonCount }}The first {{ len .Bogons }} are listed, in prefix order.{{ end }}
|
||||||
|
The list is worked out once a minute.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Prefix</th>
|
||||||
|
<th>Origin AS</th>
|
||||||
|
<th>Peers</th>
|
||||||
|
<th>Why it is a bogon</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{ range .Bogons }}
|
||||||
|
<tr>
|
||||||
|
<td><a href="{{ prefixURL .Prefix.String }}" class="prefix-link">{{ .Prefix }}</a></td>
|
||||||
|
<td>{{ if .OriginASN }}<a href="/as/{{ .OriginASN }}">AS{{ .OriginASN }}</a>{{ else }}No known origin{{ end }}</td>
|
||||||
|
<td>{{ .Peers }}</td>
|
||||||
|
<td>{{ range $i, $reason := .Reasons }}{{ if $i }}; {{ end }}{{ $reason }}{{ end }}</td>
|
||||||
|
</tr>
|
||||||
|
{{ end }}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{{ else }}
|
||||||
|
<p class="subtitle">The list is being worked out. Reload the page in a minute.</p>
|
||||||
|
{{ end }}
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -276,6 +276,52 @@
|
|||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Cards with figures of the routing table, worked out once a minute */
|
||||||
|
#table-cards p,
|
||||||
|
#well-known p {
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
#table-cards a,
|
||||||
|
#well-known a {
|
||||||
|
color: #3498db;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
#table-cards a:hover,
|
||||||
|
#well-known a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
#table-cards .card-figure {
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
#table-cards .card-detail,
|
||||||
|
#table-cards .card-time {
|
||||||
|
color: #7f8c8d;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
#table-cards .prefix {
|
||||||
|
font-family: 'SF Mono', Monaco, 'Cascadia Mono', 'Roboto Mono', Consolas, 'Courier New', monospace;
|
||||||
|
}
|
||||||
|
#well-known {
|
||||||
|
margin-bottom: 40px;
|
||||||
|
}
|
||||||
|
#well-known table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
#well-known th,
|
||||||
|
#well-known td {
|
||||||
|
text-align: left;
|
||||||
|
padding: 8px 4px;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
}
|
||||||
|
#well-known th {
|
||||||
|
color: #7f8c8d;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
/* Footer */
|
/* Footer */
|
||||||
.footer {
|
.footer {
|
||||||
margin-top: 40px;
|
margin-top: 40px;
|
||||||
@@ -381,6 +427,109 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{{ with .Cards }}
|
||||||
|
<div class="search-section" id="table-cards">
|
||||||
|
<div class="search-card">
|
||||||
|
<h2>Newest Updated Route</h2>
|
||||||
|
{{ with .NewestRoute }}
|
||||||
|
<p><a href="{{ prefixURL .Prefix.String }}" class="prefix">{{ .Prefix }}</a> from {{ if .OriginASN }}<a href="/as/{{ .OriginASN }}">AS{{ .OriginASN }}</a>{{ else }}no known origin{{ end }}</p>
|
||||||
|
<p class="card-time">{{ .Time.UTC.Format "2006-01-02 15:04:05" }} UTC, {{ timeSince .Time }}</p>
|
||||||
|
{{ else }}
|
||||||
|
<p>The routing table keeps no route yet.</p>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="search-card">
|
||||||
|
<h2>Oldest Updated Route</h2>
|
||||||
|
{{ with .OldestRoute }}
|
||||||
|
<p><a href="{{ prefixURL .Prefix.String }}" class="prefix">{{ .Prefix }}</a> from {{ if .OriginASN }}<a href="/as/{{ .OriginASN }}">AS{{ .OriginASN }}</a>{{ else }}no known origin{{ end }}</p>
|
||||||
|
<p class="card-time">{{ .Time.UTC.Format "2006-01-02 15:04:05" }} UTC, {{ timeSince .Time }}</p>
|
||||||
|
{{ else }}
|
||||||
|
<p>The routing table keeps no route yet.</p>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="search-card">
|
||||||
|
<h2>Newest Updated AS</h2>
|
||||||
|
{{ with .NewestAS }}
|
||||||
|
<p><a href="/as/{{ .ASN }}">AS{{ .ASN }}</a> {{ .Handle }}</p>
|
||||||
|
<p class="card-detail">{{ .Description }}</p>
|
||||||
|
<p class="card-time">Last seen {{ .LastSeen.UTC.Format "2006-01-02 15:04:05" }} UTC, {{ timeSince .LastSeen }}</p>
|
||||||
|
{{ else }}
|
||||||
|
<p>No AS has been seen yet.</p>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="search-card">
|
||||||
|
<h2>Oldest Updated AS</h2>
|
||||||
|
{{ with .OldestAS }}
|
||||||
|
<p><a href="/as/{{ .ASN }}">AS{{ .ASN }}</a> {{ .Handle }}</p>
|
||||||
|
<p class="card-detail">{{ .Description }}</p>
|
||||||
|
<p class="card-time">Last seen {{ .LastSeen.UTC.Format "2006-01-02 15:04:05" }} UTC, {{ timeSince .LastSeen }}</p>
|
||||||
|
{{ else }}
|
||||||
|
<p>No AS has been seen yet.</p>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="search-card">
|
||||||
|
<h2>AS with the Most Prefixes</h2>
|
||||||
|
{{ if .MostPrefixes }}
|
||||||
|
<p><a href="/as/{{ .MostPrefixesASN }}">AS{{ .MostPrefixesASN }}</a></p>
|
||||||
|
<p>Prefixes it originates: {{ .MostPrefixes }}</p>
|
||||||
|
{{ else }}
|
||||||
|
<p>No AS originates a prefix yet.</p>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="search-card">
|
||||||
|
<h2>ASes with the Fewest Prefixes</h2>
|
||||||
|
<p>ASes that originate a single prefix: {{ .SinglePrefixASes }}</p>
|
||||||
|
{{ with .NewestSinglePrefix }}
|
||||||
|
<p>The most recently updated of them: <a href="/as/{{ .OriginASN }}">AS{{ .OriginASN }}</a>, with <a href="{{ prefixURL .Prefix.String }}" class="prefix">{{ .Prefix }}</a></p>
|
||||||
|
<p class="card-time">{{ .Time.UTC.Format "2006-01-02 15:04:05" }} UTC, {{ timeSince .Time }}</p>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="search-card">
|
||||||
|
<h2>Bogon Announcements</h2>
|
||||||
|
<p class="card-figure"><a href="/bogons">{{ .BogonCount }}</a></p>
|
||||||
|
<p>Routes that announce a reserved prefix, or that come from a reserved AS number</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="search-card">
|
||||||
|
<h2>Prefixes with Several Origins</h2>
|
||||||
|
<p class="card-figure"><a href="/multiple-origins">{{ .MultipleOriginCount }}</a></p>
|
||||||
|
<p>Prefixes announced by more than one origin AS</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="search-card" id="well-known">
|
||||||
|
<h2>Well-Known Networks</h2>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Network</th>
|
||||||
|
<th>Prefixes</th>
|
||||||
|
<th>Last Update</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{ range .WellKnownNetworks }}
|
||||||
|
<tr>
|
||||||
|
<td><a href="/as/{{ .ASN }}">AS{{ .ASN }}</a> {{ .Name }}</td>
|
||||||
|
<td>{{ .Prefixes }}</td>
|
||||||
|
<td>{{ with .Newest }}{{ timeSince . }}{{ else }}no route{{ end }}</td>
|
||||||
|
</tr>
|
||||||
|
{{ end }}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{{ else }}
|
||||||
|
<div class="search-card" id="well-known">
|
||||||
|
<p>The figures of the routing table are being worked out. Reload the page in a minute.</p>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
<div class="search-card" id="ip-api">
|
<div class="search-card" id="ip-api">
|
||||||
<h2>IP Information API</h2>
|
<h2>IP Information API</h2>
|
||||||
<p>The API answers what RouteWatch knows about an IP address, such as its AS number and country. <code>/ip/</code> with no address answers for the caller's own address.</p>
|
<p>The API answers what RouteWatch knows about an IP address, such as its AS number and country. <code>/ip/</code> with no address answers for the caller's own address.</p>
|
||||||
|
|||||||
@@ -0,0 +1,182 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Prefixes with Several Origins - RouteWatch</title>
|
||||||
|
<style nonce="{{.CSPNonce}}">
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Navbar styles */
|
||||||
|
.navbar {
|
||||||
|
background: #2c3e50;
|
||||||
|
padding: 0 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
height: 56px;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
.navbar-brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.navbar-brand a {
|
||||||
|
color: white;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.navbar-brand a:hover {
|
||||||
|
color: #ecf0f1;
|
||||||
|
}
|
||||||
|
.navbar-brand .by {
|
||||||
|
font-weight: normal;
|
||||||
|
color: #95a5a6;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.navbar-brand .author {
|
||||||
|
color: #3498db;
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
.navbar-brand .author:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
.navbar-links {
|
||||||
|
display: flex;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
.navbar-links a {
|
||||||
|
color: #ecf0f1;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 14px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
.navbar-links a:hover {
|
||||||
|
background: rgba(255,255,255,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Main content */
|
||||||
|
.main-content {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.subtitle {
|
||||||
|
color: #666;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
background: white;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
th {
|
||||||
|
background: #f8f9fa;
|
||||||
|
padding: 12px;
|
||||||
|
text-align: left;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
border-bottom: 2px solid #dee2e6;
|
||||||
|
}
|
||||||
|
td {
|
||||||
|
padding: 12px;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
tr:hover {
|
||||||
|
background: #f8f9fa;
|
||||||
|
}
|
||||||
|
a {
|
||||||
|
color: #0066cc;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
.prefix-link {
|
||||||
|
font-family: 'SF Mono', Monaco, 'Cascadia Mono', 'Roboto Mono', Consolas, 'Courier New', monospace;
|
||||||
|
}
|
||||||
|
.origins {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.peers {
|
||||||
|
color: #666;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<nav class="navbar">
|
||||||
|
<div class="navbar-brand">
|
||||||
|
<a href="/">routewatch</a>
|
||||||
|
<span class="by">by</span>
|
||||||
|
<a href="https://sneak.berlin" class="author">@sneak</a>
|
||||||
|
</div>
|
||||||
|
<div class="navbar-links">
|
||||||
|
<a href="/">Home</a>
|
||||||
|
<a href="/status">Status</a>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main class="main-content">
|
||||||
|
<h1>Prefixes with Several Origin ASes</h1>
|
||||||
|
{{ with .Cards }}
|
||||||
|
<p class="subtitle">
|
||||||
|
Prefixes announced by more than one origin AS: {{ .MultipleOriginCount }}.
|
||||||
|
Such a prefix can be a hijack.
|
||||||
|
{{ if lt (len .MultipleOrigins) .MultipleOriginCount }}The first {{ len .MultipleOrigins }} are listed, in prefix order.{{ end }}
|
||||||
|
The list is worked out once a minute.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Prefix</th>
|
||||||
|
<th>Origin ASes</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{ range .MultipleOrigins }}
|
||||||
|
<tr>
|
||||||
|
<td><a href="{{ prefixURL .Prefix.String }}" class="prefix-link">{{ .Prefix }}</a></td>
|
||||||
|
<td>
|
||||||
|
<ul class="origins">
|
||||||
|
{{ range .Origins }}
|
||||||
|
<li>{{ if .ASN }}<a href="/as/{{ .ASN }}">AS{{ .ASN }}</a>{{ else }}No known origin{{ end }} <span class="peers">{{ .Peers }} {{ if eq .Peers 1 }}peer{{ else }}peers{{ end }}</span></li>
|
||||||
|
{{ end }}
|
||||||
|
</ul>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{ end }}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{{ else }}
|
||||||
|
<p class="subtitle">The list is being worked out. Reload the page in a minute.</p>
|
||||||
|
{{ end }}
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -27,6 +27,12 @@ var prefixDetailHTML string
|
|||||||
//go:embed prefix_length.html
|
//go:embed prefix_length.html
|
||||||
var prefixLengthHTML string
|
var prefixLengthHTML string
|
||||||
|
|
||||||
|
//go:embed bogons.html
|
||||||
|
var bogonsHTML string
|
||||||
|
|
||||||
|
//go:embed multiple_origins.html
|
||||||
|
var multipleOriginsHTML string
|
||||||
|
|
||||||
// Templates contains all parsed templates
|
// Templates contains all parsed templates
|
||||||
type Templates struct {
|
type Templates struct {
|
||||||
// Index is the template for the home page
|
// Index is the template for the home page
|
||||||
@@ -39,6 +45,11 @@ type Templates struct {
|
|||||||
PrefixDetail *template.Template
|
PrefixDetail *template.Template
|
||||||
// PrefixLength is the template for displaying prefixes by length
|
// PrefixLength is the template for displaying prefixes by length
|
||||||
PrefixLength *template.Template
|
PrefixLength *template.Template
|
||||||
|
// Bogons is the template of the page that lists the bogon announcements
|
||||||
|
Bogons *template.Template
|
||||||
|
// MultipleOrigins is the template of the page that lists the prefixes
|
||||||
|
// announced by more than one origin AS
|
||||||
|
MultipleOrigins *template.Template
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -116,6 +127,18 @@ func initTemplates() {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
panic("failed to parse prefix length template: " + err.Error())
|
panic("failed to parse prefix length template: " + err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
defaultTemplates.Bogons, err = template.New("bogons").
|
||||||
|
Funcs(funcs).Parse(bogonsHTML)
|
||||||
|
if err != nil {
|
||||||
|
panic("failed to parse bogons template: " + err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultTemplates.MultipleOrigins, err = template.New("multipleOrigins").
|
||||||
|
Funcs(funcs).Parse(multipleOriginsHTML)
|
||||||
|
if err != nil {
|
||||||
|
panic("failed to parse multiple origins template: " + err.Error())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get returns the singleton Templates instance
|
// Get returns the singleton Templates instance
|
||||||
@@ -149,3 +172,14 @@ func PrefixDetailTemplate() *template.Template {
|
|||||||
func PrefixLengthTemplate() *template.Template {
|
func PrefixLengthTemplate() *template.Template {
|
||||||
return Get().PrefixLength
|
return Get().PrefixLength
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BogonsTemplate returns the parsed template of the bogon announcements page
|
||||||
|
func BogonsTemplate() *template.Template {
|
||||||
|
return Get().Bogons
|
||||||
|
}
|
||||||
|
|
||||||
|
// MultipleOriginsTemplate returns the parsed template of the page of prefixes
|
||||||
|
// announced by more than one origin AS
|
||||||
|
func MultipleOriginsTemplate() *template.Template {
|
||||||
|
return Get().MultipleOrigins
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,13 +12,17 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// renderPage renders a page with the data every page needs, and the visitor's
|
// renderPage renders a page with the data every page needs, and the visitor's
|
||||||
// address, which the home page also needs.
|
// address and the routing table's cards, which the home page also needs. The
|
||||||
|
// cards are left out, as before the server first works them out.
|
||||||
func renderPage(t *testing.T, tmpl *template.Template) string {
|
func renderPage(t *testing.T, tmpl *template.Template) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
var page bytes.Buffer
|
var page bytes.Buffer
|
||||||
|
|
||||||
err := tmpl.Execute(&page, struct{ CSPNonce, ClientIP string }{})
|
err := tmpl.Execute(&page, struct {
|
||||||
|
CSPNonce, ClientIP string
|
||||||
|
Cards any
|
||||||
|
}{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("rendering the page: %v", err)
|
t.Fatalf("rendering the page: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user