diff --git a/README.md b/README.md
index 29fcb56..86226a3 100644
--- a/README.md
+++ b/README.md
@@ -70,6 +70,44 @@ one of its peers sends an update.
- `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
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.json` - JSON statistics: feed, memory, database and route
figures. The route figures come from the routing table. `oldest_route` and
@@ -248,6 +286,7 @@ routewatch/
│ └── streamdumper/ # Debug utility for raw stream output
├── internal/
│ ├── blocklist/ # Public IP blocklists: downloads and lookups
+│ ├── bogon/ # Reserved prefixes and AS numbers
│ ├── routewatch/ # Core application logic
│ ├── server/ # HTTP server and handlers
│ ├── database/ # SQLite storage layer
diff --git a/TODO.md b/TODO.md
index c095961..b63666d 100644
--- a/TODO.md
+++ b/TODO.md
@@ -35,6 +35,14 @@ tested.
# 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,
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
diff --git a/internal/bogon/bogon.go b/internal/bogon/bogon.go
new file mode 100644
index 0000000..f97ebe4
--- /dev/null
+++ b/internal/bogon/bogon.go
@@ -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"},
+}
diff --git a/internal/bogon/bogon_test.go b/internal/bogon/bogon_test.go
new file mode 100644
index 0000000..3de2d10
--- /dev/null
+++ b/internal/bogon/bogon_test.go
@@ -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)
+ }
+ })
+ }
+}
diff --git a/internal/database/database.go b/internal/database/database.go
index 4988e69..d0b8ad8 100644
--- a/internal/database/database.go
+++ b/internal/database/database.go
@@ -972,6 +972,34 @@ func (d *Database) GetAS(ctx context.Context, asn int) (*ASN, bool, error) {
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
// when the WHOIS details were fetched.
const asWHOISQuery = `
@@ -1379,6 +1407,29 @@ func (d *Database) Ping(ctx context.Context) error {
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
// walBytes, with the writing connection's busy timeout lowered to
// truncateBusyTimeoutMs for it, and logs how long it took. The caller holds the
diff --git a/internal/database/database_internal_test.go b/internal/database/database_internal_test.go
index 3195ead..33eabf1 100644
--- a/internal/database/database_internal_test.go
+++ b/internal/database/database_internal_test.go
@@ -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
// 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.
diff --git a/internal/database/interface.go b/internal/database/interface.go
index 895297f..0274ebb 100644
--- a/internal/database/interface.go
+++ b/internal/database/interface.go
@@ -62,6 +62,7 @@ type Store interface {
// AS and prefix detail operations
GetAS(ctx context.Context, asn int) (*ASN, bool, error)
+ GetLastSeenASes(ctx context.Context) (*ASN, *ASN, error)
GetASWHOIS(ctx context.Context, asn int) (*ASN, error)
GetPrefixFirstSeen(ctx context.Context, prefix netip.Prefix) (time.Time, error)
GetASPeers(asn int) ([]ASPeer, error)
diff --git a/internal/database/schema.sql b/internal/database/schema.sql
index 180c347..8abd176 100644
--- a/internal/database/schema.sql
+++ b/internal/database/schema.sql
@@ -83,6 +83,9 @@ CREATE INDEX IF NOT EXISTS idx_peerings_lookup ON peerings(as_a, as_b);
-- Indexes for asns table
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);
+-- 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
CREATE INDEX IF NOT EXISTS idx_bgp_peers_asn ON bgp_peers(peer_asn);
diff --git a/internal/routetable/overview.go b/internal/routetable/overview.go
new file mode 100644
index 0000000..b593c54
--- /dev/null
+++ b/internal/routetable/overview.go
@@ -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)
+}
diff --git a/internal/routetable/overview_internal_test.go b/internal/routetable/overview_internal_test.go
new file mode 100644
index 0000000..7ace291
--- /dev/null
+++ b/internal/routetable/overview_internal_test.go
@@ -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")
+ }
+}
diff --git a/internal/routetable/overview_test.go b/internal/routetable/overview_test.go
new file mode 100644
index 0000000..d0f6036
--- /dev/null
+++ b/internal/routetable/overview_test.go
@@ -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])
+ }
+}
diff --git a/internal/routetable/routetable_test.go b/internal/routetable/routetable_test.go
index d77f15f..c2aea91 100644
--- a/internal/routetable/routetable_test.go
+++ b/internal/routetable/routetable_test.go
@@ -625,6 +625,7 @@ func TestConcurrentUse(t *testing.T) {
func(int) { table.OriginatedPrefixes(asA) },
func(int) { table.SamplePrefixes(ipVersion, prefix.Bits(), sampleSize) },
func(int) { table.Counts() },
+ func(int) { table.Overview(sampleSize) },
}
var wg sync.WaitGroup
diff --git a/internal/routewatch/app_integration_internal_test.go b/internal/routewatch/app_integration_internal_test.go
index f736dc2..15b3b61 100644
--- a/internal/routewatch/app_integration_internal_test.go
+++ b/internal/routewatch/app_integration_internal_test.go
@@ -223,6 +223,35 @@ func (m *mockStore) GetAS(_ context.Context, asn int) (*database.ASN, bool, erro
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.
func (m *mockStore) GetASWHOIS(ctx context.Context, asn int) (*database.ASN, error) {
asnInfo, _, err := m.GetAS(ctx, asn)
@@ -605,12 +634,15 @@ func TestShutdownReturnsByItsDeadlineWhileAWriteIsBlocked(t *testing.T) {
cfg := &config.Config{Port: port}
s := streamer.New(log, metrics.New())
+ // A started server reads its routing table in the background, so it
+ // needs one.
rw := New(Dependencies{
DB: store,
Streamer: s,
- Server: server.New(store, nil, s, log, cfg),
- Logger: log,
- Config: cfg,
+ Server: server.New(store, routetable.New(routetable.Params{}), s, log,
+ cfg),
+ Logger: log,
+ Config: cfg,
})
err := rw.server.Start(t.Context())
diff --git a/internal/server/handlers.go b/internal/server/handlers.go
index 53906bd..a793b81 100644
--- a/internal/server/handlers.go
+++ b/internal/server/handlers.go
@@ -160,6 +160,7 @@ func (s *Server) handleIndex() http.HandlerFunc {
err := tmpl.Execute(w, indexPageData{
CSPNonce: cspNonce(r.Context()),
ClientIP: extractClientIP(r),
+ Cards: s.cards.Load(),
})
if err != nil {
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
// looks up: the address /ip/ would look up for the same request.
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
diff --git a/internal/server/home.go b/internal/server/home.go
new file mode 100644
index 0000000..9974097
--- /dev/null
+++ b/internal/server/home.go
@@ -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)
+ }
+ }
+}
diff --git a/internal/server/home_internal_test.go b/internal/server/home_internal_test.go
new file mode 100644
index 0000000..51253a7
--- /dev/null
+++ b/internal/server/home_internal_test.go
@@ -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, "
"+heading+"
")
+ if !found {
+ t.Fatalf("the home page has no card %q", heading)
+ }
+
+ card, _, _ = strings.Cut(card, "")
+
+ 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, "
2
") {
+ 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),
+ "
2
",
+ "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)
+ }
+}
diff --git a/internal/server/routes.go b/internal/server/routes.go
index 096e092..58371b7 100644
--- a/internal/server/routes.go
+++ b/internal/server/routes.go
@@ -55,6 +55,10 @@ func (s *Server) setupRoutes() {
r.Get("/prefixlength/{length}", s.handlePrefixLength())
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
// error body, as they answer everything else.
jsonNotFound := func(w http.ResponseWriter, _ *http.Request) {
diff --git a/internal/server/server.go b/internal/server/server.go
index 6d4aadc..ca9765f 100644
--- a/internal/server/server.go
+++ b/internal/server/server.go
@@ -1,5 +1,7 @@
// Package server serves RouteWatch's web pages and JSON API:
// - 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 IP lookups
// - the AS name search
@@ -17,6 +19,7 @@ import (
"net/http"
"net/netip"
"strconv"
+ "sync/atomic"
"time"
"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
// fake in tests
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
@@ -103,7 +110,8 @@ func New(
// 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
-// 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 {
const (
network = "tcp"
@@ -140,6 +148,10 @@ func (s *Server) Start(ctx context.Context) error {
}
}()
+ go func() {
+ s.keepHomeCardsFresh(ctx, homeCardsRefreshInterval)
+ }()
+
return nil
}
diff --git a/internal/templates/bogons.html b/internal/templates/bogons.html
new file mode 100644
index 0000000..23436a3
--- /dev/null
+++ b/internal/templates/bogons.html
@@ -0,0 +1,170 @@
+
+
+
+
+
+ Bogon Announcements - RouteWatch
+
+
+
+
+
+
+
Bogon Announcements
+ {{ with .Cards }}
+
+ 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.
+
{{ with .Newest }}{{ timeSince . }}{{ else }}no route{{ end }}
+
+ {{ end }}
+
+
+
+ {{ else }}
+
+
The figures of the routing table are being worked out. Reload the page in a minute.
+
+ {{ end }}
+
IP Information API
The API answers what RouteWatch knows about an IP address, such as its AS number and country. /ip/ with no address answers for the caller's own address.
diff --git a/internal/templates/multiple_origins.html b/internal/templates/multiple_origins.html
new file mode 100644
index 0000000..881146f
--- /dev/null
+++ b/internal/templates/multiple_origins.html
@@ -0,0 +1,182 @@
+
+
+
+
+
+ Prefixes with Several Origins - RouteWatch
+
+
+
+
+
+
+
Prefixes with Several Origin ASes
+ {{ with .Cards }}
+
+ 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.
+
{{ if .ASN }}AS{{ .ASN }}{{ else }}No known origin{{ end }} {{ .Peers }} {{ if eq .Peers 1 }}peer{{ else }}peers{{ end }}
+ {{ end }}
+
+
+
+ {{ end }}
+
+
+ {{ else }}
+
The list is being worked out. Reload the page in a minute.
+ {{ end }}
+
+
+
diff --git a/internal/templates/templates.go b/internal/templates/templates.go
index ad463fd..a1ea8b3 100644
--- a/internal/templates/templates.go
+++ b/internal/templates/templates.go
@@ -27,6 +27,12 @@ var prefixDetailHTML string
//go:embed prefix_length.html
var prefixLengthHTML string
+//go:embed bogons.html
+var bogonsHTML string
+
+//go:embed multiple_origins.html
+var multipleOriginsHTML string
+
// Templates contains all parsed templates
type Templates struct {
// Index is the template for the home page
@@ -39,6 +45,11 @@ type Templates struct {
PrefixDetail *template.Template
// PrefixLength is the template for displaying prefixes by length
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 (
@@ -116,6 +127,18 @@ func initTemplates() {
if err != nil {
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
@@ -149,3 +172,14 @@ func PrefixDetailTemplate() *template.Template {
func PrefixLengthTemplate() *template.Template {
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
+}
diff --git a/internal/templates/templates_test.go b/internal/templates/templates_test.go
index c59fe82..9be4132 100644
--- a/internal/templates/templates_test.go
+++ b/internal/templates/templates_test.go
@@ -12,13 +12,17 @@ import (
)
// 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 {
t.Helper()
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 {
t.Fatalf("rendering the page: %v", err)
}