1 Commits
Author SHA1 Message Date
sneak 20a29e389d Merge pull request 'next into main' (#87) from next into main
check / check (push) Canceled after 0s
Reviewed-on: #87
2026-10-22 18:25:14 +02:00
24 changed files with 148 additions and 1407 deletions
+12 -32
View File
@@ -56,17 +56,15 @@ Every route answers `HEAD` as it answers `GET`, without the body.
### Web Interface
- `GET /` - Home page (HTML): headline figures, lookups by AS number and by IP
address, and a search of AS names
- `GET /` - Home page (HTML): headline figures, and lookups by AS number and by
IP address
- `GET /status` - HTML status dashboard, which reads `/api/v1/stats`
- `GET /status.json` - JSON statistics: feed, memory, database and route figures
- `GET /.well-known/healthcheck.json` - Health check (JSON): 200 when the
database answers and the RIS Live feed is connected, otherwise 503;
`data.status` is `ok` or `error` and `data.checks` says which check failed
- `GET /as/{asn}` - AS detail page (HTML): the prefixes the AS originates and
its peers. When the database has no record of the AS, its handle and
description come from the AS list built into RouteWatch. The page then shows
no first-seen date.
its peers
- `GET /prefix/{prefix}/{length}` - Prefix detail page (HTML), such as
`/prefix/192.0.2.0/24`: every live route for the prefix, one per peer, and its
origin ASes
@@ -81,9 +79,7 @@ Every route answers `HEAD` as it answers `GET`, without the body.
### API v1
- `GET /api/v1/stats` - The `/status.json` figures plus connection, garbage
collector, stream and handler queue statistics. `stream.route_churn_per_min`
is the number of prefix announcements and withdrawals, from all peers, in the
last full clock minute.
collector, stream and handler queue statistics
- `GET /api/v1/ip/{ip}` - Looks up each address: its most specific live route
(`netblock`), that route's origin AS with its WHOIS details, and the address's
PTR names. `{ip}` is an IP address, a host name, or a comma-separated list of
@@ -97,22 +93,9 @@ Every route answers `HEAD` as it answers `GET`, without the body.
and 404 when none of the addresses has a live route. A PTR lookup gets 2
seconds; an address whose lookup takes longer is answered without PTR names.
- `GET /api/v1/as/{asn}` - The AS and the prefixes it originates, IPv4 and IPv6
apart, each with when its newest route was updated. When the database has no
record of the AS, its handle and description come from the AS list built into
RouteWatch. The answer then has no `first_seen` or `last_seen`.
apart, each with when its newest route was updated
- `GET /api/v1/prefix/{prefix}/{length}` - Every live route for the prefix, one
per peer, and the same routes grouped by origin AS in `origins`
- `GET /api/v1/search/as?q={text}` - Finds the ASes whose handle or description
contains `{text}`, ignoring case. `data` holds:
- `query`: `{text}` without its leading and trailing spaces
- `total`: the number of ASes that match
- `results`: the first 50 of them in AS number order, each with `asn`,
`handle` and `description`
The search reads the AS list built into RouteWatch, not the database. The
list comes from [ipverse/asn-info](https://github.com/ipverse/asn-info). A
match can therefore be an AS that the database has no record of. A `q` that
is missing, empty or only spaces answers 400.
A JSON answer is `{"status": "ok", "data": ...}`, or for an error
`{"status": "error", "error": {"msg": ..., "code": ...}}`, and carries an
@@ -123,16 +106,13 @@ took. The health check is the exception: its 503 answer is also
A request that runs past its time limit answers 504: the limit is 30 seconds,
and 4 seconds for the database reads behind `/status.json` and `/api/v1/stats`.
An AS number, prefix or mask length that does not parse answers 400, as does a
mask length out of range for its IP version. An AS answers 404 when it is not in
the AS list built into RouteWatch and the database has neither a record of it
nor a live route it originates. AS 0 always answers 404, because it means no
known origin. The AS name search never lists it. A prefix with no live route
answers 404 too. The HTML pages give these errors as plain text. Under `/api/v1`
and `/ip/`, a path no route matches answers 404 with the message `Not found`,
and `POST`, `PUT`, `PATCH`, `DELETE`, `OPTIONS`, `TRACE` or `CONNECT` answers
405 with `Method not allowed`. A 500 answer says only `Internal server error`,
and a host name that does not resolve only `could not resolve host name`; the
detail goes to the log.
mask length out of range for its IP version. An AS the database does not hold,
or a prefix with no live route, answers 404. The HTML pages give these errors as
plain text. Under `/api/v1` and `/ip/`, a path no route matches answers 404 with
the message `Not found`, and `POST`, `PUT`, `PATCH`, `DELETE`, `OPTIONS`,
`TRACE` or `CONNECT` answers 405 with `Method not allowed`. A 500 answer says
only `Internal server error`, and a host name that does not resolve only
`could not resolve host name`; the detail goes to the log.
Every answer carries `Strict-Transport-Security`, `Content-Security-Policy`,
`X-Frame-Options`, `X-Content-Type-Options`, `Referrer-Policy` and
-15
View File
@@ -33,21 +33,6 @@ tested.
# Completed Steps
- 2026-10-26: the status page shows "Route Churn/min", the number of prefix
announcements and withdrawals, from all peers, in the last full clock minute.
`/api/v1/stats` reports it as `route_churn_per_min` in `stream`, which
replaces the moving average `route_churn_per_sec`. The messages and updates
per second on `/` and `/status` are whole numbers (closes #160)
- 2026-10-26: the WHOIS fetcher backs off more slowly. A failure makes its wait
1.5 times as long, up to one minute instead of five. Its queries to RIPE,
APNIC and AFRINIC leave out person and role objects, which RIPE counts against
a daily limit per address. A refused query is logged with the RIR's error line
(closes #159)
- 2026-10-26: the home page's AS name search lists the ASes whose handle or
description holds the text, as links to their pages, from the new
`/api/v1/search/as`; the search ignores case. When the database has no record
of an AS, its page takes the handle and description from the AS list built
into RouteWatch and still lists the AS's prefixes (closes #151)
- 2026-10-19: README "HTTP Endpoints" lists every route with what it answers,
"Database Schema" matches `schema.sql`, and the rest of the README matches the
code; this file brought up to date (closes #72)
+7 -37
View File
@@ -21,7 +21,6 @@ import (
"git.eeqj.de/sneak/routewatch/internal/config"
"git.eeqj.de/sneak/routewatch/internal/logger"
"git.eeqj.de/sneak/routewatch/internal/ristypes"
"git.eeqj.de/sneak/routewatch/pkg/asinfo"
_ "github.com/mattn/go-sqlite3" // CGO SQLite driver
)
@@ -1749,21 +1748,9 @@ func (d *Database) GetASDetails(asn int) (*ASN, []LiveRoute, error) {
// GetASDetailsContext returns the AS and the prefixes it originates, the IPv4
// ones first, each once with the time of its newest route. It makes two index
// searches per prefix, however many peers carry the prefix.
//
// The routes of an AS can be stored before its record in the asns table. When
// the AS has no record, its handle and description come from the AS info
// lookup, and it has no seen times. An AS with no record, no lookup entry and
// no prefix is ErrNoRoute.
//
// ristypes.NoOrigin means no known origin, not an AS, so it is ErrNoRoute
// whatever is stored.
func (d *Database) GetASDetailsContext(
ctx context.Context, asn int,
) (*ASN, []LiveRoute, error) {
if asn == ristypes.NoOrigin {
return nil, nil, fmt.Errorf("%w: AS%d", ErrNoRoute, asn)
}
// Get AS information
var (
asnInfo ASN
@@ -1776,28 +1763,17 @@ func (d *Database) GetASDetailsContext(
asn,
).Scan(&asnInfo.ASN, &handle, &description, &asnInfo.FirstSeen,
&asnInfo.LastSeen)
// known is whether the asns table or the AS info lookup holds the AS.
known := true
switch {
case errors.Is(err, sql.ErrNoRows):
asnInfo.ASN = asn
info, ok := asinfo.Get(asn)
if ok {
asnInfo.Handle = info.Handle
asnInfo.Description = info.Description
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil, fmt.Errorf("%w: AS%d", ErrNoRoute, asn)
}
known = ok
case err != nil:
return nil, nil, fmt.Errorf("failed to query AS: %w", err)
default:
asnInfo.Handle = handle.String
asnInfo.Description = description.String
}
asnInfo.Handle = handle.String
asnInfo.Description = description.String
allPrefixes, err := d.originPrefixes(ctx, asn, ipVersionV4)
if err != nil {
return &asnInfo, nil, err
@@ -1808,13 +1784,7 @@ func (d *Database) GetASDetailsContext(
return &asnInfo, allPrefixes, err
}
allPrefixes = append(allPrefixes, prefixesV6...)
if !known && len(allPrefixes) == 0 {
return nil, nil, fmt.Errorf("%w: AS%d", ErrNoRoute, asn)
}
return &asnInfo, allPrefixes, nil
return &asnInfo, append(allPrefixes, prefixesV6...), nil
}
// ASPeer represents a peering relationship with another AS including handle,
@@ -20,7 +20,6 @@ import (
"git.eeqj.de/sneak/routewatch/internal/config"
"git.eeqj.de/sneak/routewatch/internal/logger"
"git.eeqj.de/sneak/routewatch/pkg/asinfo"
"github.com/mattn/go-sqlite3"
)
@@ -1700,85 +1699,6 @@ func TestGetASDetailsListsEachPrefixOnce(t *testing.T) {
}
}
// TestGetASDetailsOfASNotInDatabase asks for ASes that the database has no
// record of, some with stored routes. Each gets the prefixes of its routes and
// no seen times. One that the AS info lookup knows gets its handle and
// description from it. An AS with no record, no lookup entry and no route is
// ErrNoRoute. Before the fix every AS with no record was ErrNoRoute.
func TestGetASDetailsOfASNotInDatabase(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() }()
ts := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
tests := []struct {
asn int
listed bool // in the AS info lookup
prefixes []string // of the routes stored for the AS
}{
{15169, true, []string{"192.0.2.0/24"}},
{13335, true, nil},
{4200000000, false, []string{"198.51.100.0/24"}},
}
var routes []*LiveRoute
for _, tt := range tests {
if _, ok := asinfo.Get(tt.asn); ok != tt.listed {
t.Fatalf("AS%d in the AS info data: %t, want %t", tt.asn, ok, tt.listed)
}
for _, prefix := range tt.prefixes {
routes = append(routes, mkV4Route(t, prefix, tt.asn, ts))
}
}
err = db.UpsertLiveRouteBatch(routes)
if err != nil {
t.Fatalf("UpsertLiveRouteBatch: %v", err)
}
for _, tt := range tests {
got, prefixes, err := db.GetASDetails(tt.asn)
if err != nil {
t.Errorf("GetASDetails(%d): %v", tt.asn, err)
continue
}
want := ASN{ASN: tt.asn}
if info, ok := asinfo.Get(tt.asn); ok {
want.Handle, want.Description = info.Handle, info.Description
}
var gotPrefixes []string
for _, route := range prefixes {
gotPrefixes = append(gotPrefixes, route.Prefix)
}
if *got != want || !slices.Equal(gotPrefixes, tt.prefixes) {
t.Errorf("GetASDetails(%d) = %+v with prefixes %v, want %+v with %v",
tt.asn, *got, gotPrefixes, want, tt.prefixes)
}
}
const absentASN = 4200000001 // no record, no lookup entry and no route
if _, ok := asinfo.Get(absentASN); ok {
t.Fatalf("AS%d is in the AS info data", absentASN)
}
_, _, err = db.GetASDetails(absentASN)
if !errors.Is(err, ErrNoRoute) {
t.Errorf("GetASDetails(%d): error %v, want %v", absentASN, err, ErrNoRoute)
}
}
// TestASPrefixQueriesUseCoveringIndexes checks the plan of each query that
// lists the prefixes an AS originates: a search of a covering index and no
// temporary B-tree, so the list reads one index entry per prefix, not one
+3 -4
View File
@@ -28,10 +28,9 @@ type ASN struct {
RIRRegDate *time.Time `json:"rir_registration_date,omitempty"`
RIRLastMod *time.Time `json:"rir_last_modified,omitempty"`
WHOISRaw string `json:"whois_raw,omitempty"`
// Timestamps. They are zero, and left out of JSON, for an AS that has no
// record in the asns table.
FirstSeen time.Time `json:"first_seen,omitzero"`
LastSeen time.Time `json:"last_seen,omitzero"`
// Timestamps
FirstSeen time.Time `json:"first_seen"`
LastSeen time.Time `json:"last_seen"`
WHOISUpdatedAt *time.Time `json:"whois_updated_at,omitempty"`
}
+7 -8
View File
@@ -34,7 +34,7 @@ type Tracker struct {
// Announcement/withdrawal metrics
announcementCounter metrics.Counter
withdrawalCounter metrics.Counter
churnPerMinute minuteCounter // prefix announcements and withdrawals
churnRate metrics.Meter // combined announcements + withdrawals per second
// BGP peer tracking
bgpPeerCount atomic.Int32
@@ -56,7 +56,7 @@ func New() *Tracker {
ipv6UpdateRate: metrics.NewMeter(),
announcementCounter: metrics.NewCounter(),
withdrawalCounter: metrics.NewCounter(),
churnPerMinute: minuteCounter{now: time.Now},
churnRate: metrics.NewMeter(),
}
}
@@ -156,13 +156,13 @@ func (t *Tracker) RecordIPv6Update() {
// RecordAnnouncement records a route announcement
func (t *Tracker) RecordAnnouncement() {
t.announcementCounter.Inc(1)
t.churnPerMinute.add()
t.churnRate.Mark(1)
}
// RecordWithdrawal records a route withdrawal
func (t *Tracker) RecordWithdrawal() {
t.withdrawalCounter.Inc(1)
t.churnPerMinute.add()
t.churnRate.Mark(1)
}
// SetBGPPeerCount updates the current BGP peer count
@@ -198,10 +198,9 @@ func (t *Tracker) GetWithdrawalCount() uint64 {
return uint64(count)
}
// GetChurnPerMinute returns the number of prefix announcements and
// withdrawals, from all peers, in the last full clock minute.
func (t *Tracker) GetChurnPerMinute() int {
return t.churnPerMinute.lastFullMinute()
// GetChurnRate returns the route churn rate per second
func (t *Tracker) GetChurnRate() float64 {
return t.churnRate.Rate1()
}
// GetRouteMetrics returns current route update metrics
-59
View File
@@ -1,59 +0,0 @@
package metrics
import (
"sync"
"time"
)
// minuteCounter counts events in each clock minute and reports the count of
// the last full minute. It is safe for concurrent use.
type minuteCounter struct {
mu sync.Mutex
now func() time.Time
// minute is the start of the minute that count belongs to.
minute time.Time
count int
// lastMinuteCount is the count of the minute just before minute.
lastMinuteCount int
}
// add counts one event in the current minute.
func (c *minuteCounter) add() {
c.mu.Lock()
defer c.mu.Unlock()
c.moveToCurrentMinute()
c.count++
}
// lastFullMinute returns the number of events in the minute that ended most
// recently.
func (c *minuteCounter) lastFullMinute() int {
c.mu.Lock()
defer c.mu.Unlock()
c.moveToCurrentMinute()
return c.lastMinuteCount
}
// moveToCurrentMinute starts a new count when the clock has moved into
// another minute. The count it ends becomes the last full minute's count when
// its minute is the one just before the current minute. Otherwise the last
// full minute had no events. The caller holds mu.
func (c *minuteCounter) moveToCurrentMinute() {
minute := c.now().Truncate(time.Minute)
if minute.Equal(c.minute) {
return
}
if minute.Sub(c.minute) == time.Minute {
c.lastMinuteCount = c.count
} else {
c.lastMinuteCount = 0
}
c.minute = minute
c.count = 0
}
@@ -1,86 +0,0 @@
package metrics
import (
"sync"
"testing"
"time"
)
// TestChurnPerMinuteCountsLastFullMinute announces and withdraws prefixes on
// an injected clock and expects each minute's whole count once that minute
// has ended, and 0 for a minute with none.
func TestChurnPerMinuteCountsLastFullMinute(t *testing.T) {
t.Parallel()
now := time.Date(2026, 10, 26, 12, 0, 30, 0, time.UTC)
tracker := New()
tracker.churnPerMinute.now = func() time.Time { return now }
expectChurn := func(want int, why string) {
t.Helper()
got := tracker.GetChurnPerMinute()
if got != want {
t.Fatalf("at %s churn = %d, want %d: %s",
now.Format(time.TimeOnly), got, want, why)
}
}
const firstMinuteChurn = 2
tracker.RecordAnnouncement()
tracker.RecordWithdrawal()
expectChurn(0, "12:00 has not ended")
now = time.Date(2026, 10, 26, 12, 1, 59, 0, time.UTC)
tracker.RecordAnnouncement()
expectChurn(firstMinuteChurn, "12:00 had one announcement and one withdrawal")
now = time.Date(2026, 10, 26, 12, 2, 0, 0, time.UTC)
expectChurn(1, "12:01 had one announcement")
now = time.Date(2026, 10, 26, 12, 4, 0, 0, time.UTC)
expectChurn(0, "12:03 had none")
tracker.RecordWithdrawal()
now = time.Date(2026, 10, 26, 12, 6, 0, 0, time.UTC)
expectChurn(0, "12:05 had none, though 12:04 had one withdrawal")
}
// TestChurnPerMinuteCountsConcurrentRecords announces prefixes from several
// goroutines at once and expects every one counted.
func TestChurnPerMinuteCountsConcurrentRecords(t *testing.T) {
t.Parallel()
const (
goroutines = 8
recordsPerGoroutine = 1000
)
now := time.Date(2026, 10, 26, 12, 0, 0, 0, time.UTC)
tracker := New()
tracker.churnPerMinute.now = func() time.Time { return now }
var wg sync.WaitGroup
for range goroutines {
wg.Go(func() {
for range recordsPerGoroutine {
tracker.RecordAnnouncement()
}
})
}
wg.Wait()
now = now.Add(time.Minute)
want := goroutines * recordsPerGoroutine
if got := tracker.GetChurnPerMinute(); got != want {
t.Fatalf("churn = %d, want %d", got, want)
}
}
+2 -6
View File
@@ -39,16 +39,12 @@ type ASPath struct {
Set []int
}
// NoOrigin is the origin AS of a route whose origin is not known. AS 0 is
// reserved, so no AS has this number.
const NoOrigin = 0
// Origin returns the AS that originated the route: the last AS of the
// sequence, or NoOrigin when the path ends in a set of several members or is
// sequence, or 0 (none) when the path ends in a set of several members or is
// empty.
func (p *ASPath) Origin() int {
if len(p.Set) > 0 || len(p.Sequence) == 0 {
return NoOrigin
return 0
}
return p.Sequence[len(p.Sequence)-1]
+6 -13
View File
@@ -21,16 +21,10 @@ const (
minInterval = 1 * time.Second
// maxInterval is the maximum interval after failures (backoff cap).
maxInterval = time.Minute
maxInterval = 5 * time.Minute
// A failure multiplies the interval by backoffNumerator divided by
// backoffDenominator, 1.5, up to maxInterval.
backoffNumerator = 3
backoffDenominator = 2
// A success divides the interval by successDivisor, down to
// minInterval.
successDivisor = 2
// backoffMultiplier is how much to multiply interval on failure.
backoffMultiplier = 2
// whoisStaleThreshold is how old WHOIS data can be before refresh.
whoisStaleThreshold = 30 * 24 * time.Hour // 30 days
@@ -166,7 +160,7 @@ func (f *ASNFetcher) recordSuccess() {
f.consecutiveFails = 0
// Decrease interval by half, but not below minimum
newInterval := max(f.currentInterval/successDivisor, minInterval)
newInterval := max(f.currentInterval/backoffMultiplier, minInterval)
if newInterval != f.currentInterval {
f.logger.Debug("Decreased fetch interval",
@@ -190,9 +184,8 @@ func (f *ASNFetcher) recordFailure() {
f.intervalMu.Lock()
f.consecutiveFails++
// Exponential backoff: multiply by 1.5, capped at max
newInterval := min(
f.currentInterval*backoffNumerator/backoffDenominator, maxInterval)
// Exponential backoff: multiply by 2, capped at max
newInterval := min(f.currentInterval*backoffMultiplier, maxInterval)
if newInterval != f.currentInterval {
f.logger.Debug("Increased fetch interval due to failure",
@@ -120,58 +120,17 @@ func TestFetchAndUpdateKeepsTheRowWhenTheLookupFails(t *testing.T) {
"was stored", stats.NeverFetched)
}
want := baseInterval * backoffNumerator / backoffDenominator
got := f.GetStats()
if got.CurrentInterval != want || got.ConsecutiveFails != 1 {
if got.CurrentInterval != baseInterval*backoffMultiplier ||
got.ConsecutiveFails != 1 {
t.Errorf("interval %v after %d failures, want %v after 1",
got.CurrentInterval, got.ConsecutiveFails, want)
got.CurrentInterval, got.ConsecutiveFails,
baseInterval*backoffMultiplier)
}
})
}
}
// TestRecordStepsTheInterval has the fetcher fail from its base interval of
// 15 seconds, then succeed. Each failure makes the interval 1.5 times as
// long, up to one minute, and each success halves it, down to one second.
func TestRecordStepsTheInterval(t *testing.T) {
t.Parallel()
f := NewASNFetcher(newMockStore(), logger.New().Logger)
for i, want := range []time.Duration{
22500 * time.Millisecond,
33750 * time.Millisecond,
50625 * time.Millisecond,
time.Minute,
time.Minute,
} {
f.recordFailure()
if got := f.getInterval(); got != want {
t.Errorf("interval after %d failures = %v, want %v",
i+1, got, want)
}
}
for i, want := range []time.Duration{
30 * time.Second,
15 * time.Second,
7500 * time.Millisecond,
3750 * time.Millisecond,
1875 * time.Millisecond,
time.Second,
time.Second,
} {
f.recordSuccess()
if got := f.getInterval(); got != want {
t.Errorf("interval after %d successes = %v, want %v",
i+1, got, want)
}
}
}
// TestFetchAndUpdateMarksAnASWithoutReferral looks up a private-use AS, which
// IANA refers to no RIR. The AS must not be looked up again until it is
// stale, and the lookup counts as neither a success nor a failure.
+11 -71
View File
@@ -16,7 +16,6 @@ import (
"git.eeqj.de/sneak/routewatch/internal/database"
"git.eeqj.de/sneak/routewatch/internal/metrics"
"git.eeqj.de/sneak/routewatch/internal/ristypes"
"git.eeqj.de/sneak/routewatch/internal/streamer"
"git.eeqj.de/sneak/routewatch/internal/templates"
asinfo "git.eeqj.de/sneak/routewatch/pkg/asinfo"
@@ -57,18 +56,10 @@ const (
// Error messages an answer sends, in place of the error behind it where there
// is one.
const (
invalidPrefixMessage = "Invalid prefix"
hostNotResolvedMessage = "could not resolve host name"
hostNotPublicMessage = "host name has no public address"
tooManyTargetsMessage = "Too many addresses or host names"
searchQueryMissingMessage = "Missing search query"
)
// The AS name search reads the text it looks for from the URL query parameter
// asSearchQueryParam, and answers with at most maxASSearchResults matches.
const (
asSearchQueryParam = "q"
maxASSearchResults = 50
invalidPrefixMessage = "Invalid prefix"
hostNotResolvedMessage = "could not resolve host name"
hostNotPublicMessage = "host name has no public address"
tooManyTargetsMessage = "Too many addresses or host names"
)
// IP versions as database.LiveRoute.IPVersion records them.
@@ -441,10 +432,10 @@ type GCStats struct {
//
//nolint:tagliatelle // field names are the existing JSON API
type StreamStats struct {
Announcements uint64 `json:"announcements"`
Withdrawals uint64 `json:"withdrawals"`
RouteChurnPerMin int `json:"route_churn_per_min"`
BGPPeerCount int `json:"bgp_peer_count"`
Announcements uint64 `json:"announcements"`
Withdrawals uint64 `json:"withdrawals"`
RouteChurnPerSec float64 `json:"route_churn_per_sec"`
BGPPeerCount int `json:"bgp_peer_count"`
}
// StatsResponse represents the API statistics response
@@ -545,7 +536,7 @@ func (s *Server) streamStats() StreamStats {
return StreamStats{
Announcements: metricsTracker.GetAnnouncementCount(),
Withdrawals: metricsTracker.GetWithdrawalCount(),
RouteChurnPerMin: metricsTracker.GetChurnPerMinute(),
RouteChurnPerSec: metricsTracker.GetChurnRate(),
BGPPeerCount: metricsTracker.GetBGPPeerCount(),
}
}
@@ -1010,12 +1001,11 @@ func (s *Server) handleASDetailJSON() http.HandlerFunc {
}
}
// splitRoutesByIPVersion groups routes into the IPv4 and the IPv6 ones. Neither
// group is nil, so that a JSON answer holds [] for an empty one and not null.
// splitRoutesByIPVersion groups routes into the IPv4 and the IPv6 ones.
func splitRoutesByIPVersion(
routes []database.LiveRoute,
) ([]database.LiveRoute, []database.LiveRoute) {
ipv4Routes, ipv6Routes := []database.LiveRoute{}, []database.LiveRoute{}
var ipv4Routes, ipv6Routes []database.LiveRoute
for _, route := range routes {
if route.IPVersion == ipVersionV4 {
@@ -1115,56 +1105,6 @@ func (s *Server) handlePrefixDetailJSON() http.HandlerFunc {
}
}
// ASSearchResponse is the answer to an AS name search: the text searched for,
// the number of ASes that match it, and the first maxASSearchResults of them in
// AS number order.
type ASSearchResponse struct {
Query string `json:"query"`
Total int `json:"total"`
Results []asinfo.Info `json:"results"`
}
// handleASSearch returns a handler that searches the AS list built into
// asinfo for the text in the q parameter, without its leading and trailing
// spaces. It answers 400 when no text is left.
func (s *Server) handleASSearch() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
query := strings.TrimSpace(r.URL.Query().Get(asSearchQueryParam))
if query == "" {
s.writeJSONError(w, http.StatusBadRequest, searchQueryMissingMessage)
return
}
// AS 0 means no known origin and has no AS page, so the answer leaves
// it out. The matches come in AS number order, so AS 0 can only be the
// first. The search asks for one match more than the answer holds, to
// take its place.
results, total := asinfo.Search(query, maxASSearchResults+1)
if len(results) > 0 && results[0].ASN == ristypes.NoOrigin {
results = results[1:]
total--
}
results = results[:min(len(results), maxASSearchResults)]
// results is an empty list rather than nil when nothing matches, so
// that the answer holds [] and not null.
if results == nil {
results = []asinfo.Info{}
}
err := writeJSONSuccess(w, ASSearchResponse{
Query: query,
Total: total,
Results: results,
})
if err != nil {
s.logger.Error("Failed to encode AS search results", "error", err)
}
}
}
// handleASDetail returns a handler that serves the AS detail HTML page
func (s *Server) handleASDetail() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
+2 -432
View File
@@ -19,7 +19,6 @@ import (
"git.eeqj.de/sneak/routewatch/internal/database"
"git.eeqj.de/sneak/routewatch/internal/logger"
"git.eeqj.de/sneak/routewatch/internal/metrics"
"git.eeqj.de/sneak/routewatch/internal/ristypes"
"git.eeqj.de/sneak/routewatch/internal/streamer"
"git.eeqj.de/sneak/routewatch/pkg/asinfo"
)
@@ -27,7 +26,6 @@ import (
// The prefix and peer addresses the prefix page tests store routes for.
const (
testPrefixV4 = "192.0.2.0/24"
testPrefixV6 = "2001:db8::/32"
testPeerV4 = "198.51.100.1"
testPeerV6 = "2001:db8::1"
)
@@ -195,53 +193,8 @@ func TestStatsHandlersAnswerFromMemory(t *testing.T) {
}
}
// TestStatsAnswerCarriesChurnPerMinute checks that the stream object of the
// /api/v1/stats answer holds route_churn_per_min as a whole number, and no
// longer route_churn_per_sec, the moving average it replaces
// (https://git.eeqj.de/sneak/routewatch/issues/160).
func TestStatsAnswerCarriesChurnPerMinute(t *testing.T) {
t.Parallel()
s := newServerWithTestRoutes(t)
rec := httptest.NewRecorder()
s.handleStats()(rec,
httptest.NewRequestWithContext(t.Context(), http.MethodGet, statsPath, nil))
if rec.Code != http.StatusOK {
t.Fatalf("status %d, want %d; body %s", rec.Code, http.StatusOK, rec.Body)
}
var body struct {
Data struct {
Stream map[string]json.RawMessage `json:"stream"`
} `json:"data"`
}
err := json.Unmarshal(rec.Body.Bytes(), &body)
if err != nil {
t.Fatalf("decoding the answer: %v", err)
}
churn, ok := body.Data.Stream["route_churn_per_min"]
if !ok {
t.Fatalf("stream has no route_churn_per_min: %s", rec.Body)
}
var count int
err = json.Unmarshal(churn, &count)
if err != nil {
t.Errorf("route_churn_per_min = %s, want a whole number: %v", churn, err)
}
if _, ok := body.Data.Stream["route_churn_per_sec"]; ok {
t.Errorf("stream still has route_churn_per_sec: %s", rec.Body)
}
}
// newServerWithTestRoutes returns a server on a new database that holds a
// route for testPrefixV4 and one for testPrefixV6.
// route for testPrefixV4 and one for 2001:db8::/32.
func newServerWithTestRoutes(t *testing.T) *Server {
t.Helper()
@@ -261,7 +214,7 @@ func newServerWithTestRoutes(t *testing.T) *Server {
NextHop: testPeerV4, LastUpdated: ts,
},
{
Prefix: testPrefixV6, MaskLength: 32, IPVersion: 6,
Prefix: "2001:db8::/32", MaskLength: 32, IPVersion: 6,
OriginASN: 64501, PeerIP: testPeerV6, ASPath: []int{64501},
NextHop: testPeerV6, LastUpdated: ts,
},
@@ -1213,389 +1166,6 @@ func TestIPLookupHidesPrivateAddresses(t *testing.T) {
}
}
// asSearchPath is the path of the AS name search.
const asSearchPath = "/api/v1/search/as"
// searchAS sends an AS name search with the URL query rawQuery through s's
// router, expects a 200 answer, and returns its data.
func searchAS(t *testing.T, s *Server, rawQuery string) ASSearchResponse {
t.Helper()
rec := httptest.NewRecorder()
s.router.ServeHTTP(rec, httptest.NewRequestWithContext(
t.Context(), http.MethodGet, asSearchPath+"?"+rawQuery, nil))
if rec.Code != http.StatusOK {
t.Fatalf("%s: status %d, want %d; body %s",
rawQuery, rec.Code, http.StatusOK, rec.Body)
}
var body struct {
Data ASSearchResponse `json:"data"`
}
err := json.Unmarshal(rec.Body.Bytes(), &body)
if err != nil {
t.Fatalf("%s: decoding the answer %q: %v", rawQuery, rec.Body, err)
}
return body.Data
}
// TestASSearchAnswersMatches searches for Cloudflare's handle in lower case,
// with spaces around it, and expects AS13335 among the matches. Before the fix
// there was no AS name search, and the home page's search card said it was
// coming soon (https://git.eeqj.de/sneak/routewatch/issues/151).
func TestASSearchAnswersMatches(t *testing.T) {
t.Parallel()
const query = "cloudflarenet"
data := searchAS(t, newServerWithTestRoutes(t), "q=%20"+query+"%20")
if data.Query != query {
t.Errorf("query %q, want %q", data.Query, query)
}
if data.Total != len(data.Results) {
t.Errorf("total %d, but %d results", data.Total, len(data.Results))
}
cloudflare := asinfo.Info{
ASN: 13335, Handle: "CLOUDFLARENET", Description: "Cloudflare, Inc.",
}
if !slices.Contains(data.Results, cloudflare) {
t.Errorf("results %v do not hold %v", data.Results, cloudflare)
}
}
// TestASSearchAnswersEmptyListWhenNothingMatches expects a search that matches
// nothing to answer 200 with a total of 0 and an empty list, not null.
func TestASSearchAnswersEmptyListWhenNothingMatches(t *testing.T) {
t.Parallel()
data := searchAS(t, newServerWithTestRoutes(t), "q=XYZABC123456")
if data.Total != 0 || data.Results == nil || len(data.Results) != 0 {
t.Errorf("total %d and results %#v, want 0 and an empty list",
data.Total, data.Results)
}
}
// TestASSearchAnswers400ForEmptyQuery expects a search with no q, an empty q
// or a q of only spaces to answer 400 with the JSON error body.
func TestASSearchAnswers400ForEmptyQuery(t *testing.T) {
t.Parallel()
s := newServerWithTestRoutes(t)
for _, path := range []string{
asSearchPath, asSearchPath + "?q=", asSearchPath + "?q=%20%20",
} {
rec := httptest.NewRecorder()
s.router.ServeHTTP(rec, httptest.NewRequestWithContext(
t.Context(), http.MethodGet, path, nil))
if rec.Code != http.StatusBadRequest {
t.Errorf("%s: status %d, want %d; body %s",
path, rec.Code, http.StatusBadRequest, rec.Body)
}
checkJSONErrorCode(t, path, rec, http.StatusBadRequest)
checkJSONErrorMessage(t, path, rec, searchQueryMissingMessage)
}
}
// TestASSearchShowsFirstMatchesAndTotal searches for a word with more matches
// than an answer holds, and expects the first maxASSearchResults of them, in
// AS number order, and the number of all of them.
func TestASSearchShowsFirstMatchesAndTotal(t *testing.T) {
t.Parallel()
const query = "university"
all, _ := asinfo.Search(query, asinfo.Total())
if len(all) <= maxASSearchResults {
t.Fatalf("Search(%q) has %d matches; the test needs more than %d",
query, len(all), maxASSearchResults)
}
data := searchAS(t, newServerWithTestRoutes(t), "q="+query)
if data.Total != len(all) {
t.Errorf("total %d, want %d", data.Total, len(all))
}
if !slices.Equal(data.Results, all[:maxASSearchResults]) {
t.Errorf("results %v, want the first %d matches %v",
data.Results, maxASSearchResults, all[:maxASSearchResults])
}
}
// The AS numbers the AS detail tests ask for. storedASN originates the route
// for testPrefixV4 and is in the database. routeOnlyASN originates the route
// for testPrefixV6, but the database has no record of the AS itself, as when
// a route is stored before its AS. builtInOnlyASN is in the AS list built into
// asinfo but not in the database. unknownASN is in neither.
const (
storedASN = 64500
routeOnlyASN = 64501
builtInOnlyASN = 15169
unknownASN = 4200000000
)
// zeroDate is how a page writes a time that was never set.
const zeroDate = "0001-01-01"
// firstSeenCard is the label of the AS page's first-seen date.
const firstSeenCard = `<div class="info-label">First Seen</div>`
// newServerWithStoredAS returns newServerWithTestRoutes's server with storedASN
// added to the database. It fails the test when the AS list built into asinfo
// lacks routeOnlyASN or builtInOnlyASN, or holds unknownASN.
func newServerWithStoredAS(t *testing.T) *Server {
t.Helper()
for _, asn := range []int{routeOnlyASN, builtInOnlyASN} {
if _, ok := asinfo.Get(asn); !ok {
t.Fatalf("AS%d is not in the AS info data", asn)
}
}
if _, ok := asinfo.Get(unknownASN); ok {
t.Fatalf("AS%d is in the AS info data", unknownASN)
}
s := newServerWithTestRoutes(t)
_, err := s.db.GetOrCreateASN(storedASN, time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC))
if err != nil {
t.Fatalf("GetOrCreateASN: %v", err)
}
return s
}
// TestASDetailJSONAnswersBuiltInAS asks /api/v1/as/ for each of the ASes above.
// All but unknownASN answer 200 with the handle and description from the
// built-in AS list and their prefixes, as lists even when empty. Only storedASN
// has a first_seen time. unknownASN answers 404. Before the fix builtInOnlyASN
// and routeOnlyASN answered 404 too
// (https://git.eeqj.de/sneak/routewatch/issues/151).
func TestASDetailJSONAnswersBuiltInAS(t *testing.T) {
t.Parallel()
s := newServerWithStoredAS(t)
tests := []struct {
asn int
wantCode int
wantPrefixes []string
wantFirstSeen bool
}{
{storedASN, http.StatusOK, []string{testPrefixV4}, true},
{routeOnlyASN, http.StatusOK, []string{testPrefixV6}, false},
{builtInOnlyASN, http.StatusOK, nil, false},
{unknownASN, http.StatusNotFound, nil, false},
}
for _, tt := range tests {
path := "/api/v1/as/" + strconv.Itoa(tt.asn)
rec := httptest.NewRecorder()
s.router.ServeHTTP(rec, httptest.NewRequestWithContext(
t.Context(), http.MethodGet, path, nil))
if rec.Code != tt.wantCode {
t.Errorf("%s: status %d, want %d; body %s",
path, rec.Code, tt.wantCode, rec.Body)
continue
}
if tt.wantCode != http.StatusOK {
checkJSONErrorCode(t, path, rec, tt.wantCode)
continue
}
//nolint:tagliatelle // field names are the existing JSON API
var body struct {
Data struct {
ASN database.ASN `json:"asn"`
IPv4Prefixes []database.LiveRoute `json:"ipv4_prefixes"`
IPv6Prefixes []database.LiveRoute `json:"ipv6_prefixes"`
} `json:"data"`
}
err := json.Unmarshal(rec.Body.Bytes(), &body)
if err != nil {
t.Errorf("%s: decoding the answer %q: %v", path, rec.Body, err)
continue
}
info, _ := asinfo.Get(tt.asn)
got := body.Data.ASN
if got.ASN != tt.asn || got.Handle != info.Handle ||
got.Description != info.Description {
t.Errorf("%s: AS %d %q %q, want %d %q %q", path, got.ASN,
got.Handle, got.Description, tt.asn, info.Handle, info.Description)
}
if body.Data.IPv4Prefixes == nil || body.Data.IPv6Prefixes == nil {
t.Errorf("%s: a prefix list is null; body %s", path, rec.Body)
}
routes := slices.Concat(body.Data.IPv4Prefixes, body.Data.IPv6Prefixes)
gotPrefixes := make([]string, 0, len(routes))
for _, route := range routes {
gotPrefixes = append(gotPrefixes, route.Prefix)
}
if !slices.Equal(gotPrefixes, tt.wantPrefixes) {
t.Errorf("%s: prefixes %v, want %v", path, gotPrefixes, tt.wantPrefixes)
}
gotFirstSeen := strings.Contains(rec.Body.String(), `"first_seen"`)
if gotFirstSeen != tt.wantFirstSeen {
t.Errorf("%s: has first_seen %t, want %t; body %s",
path, gotFirstSeen, tt.wantFirstSeen, rec.Body)
}
}
}
// TestASDetailPageShowsBuiltInAS asks /as/ for each of the ASes above. All but
// unknownASN answer 200 with the handle and description from the built-in AS
// list. storedASN and routeOnlyASN list their prefixes, and builtInOnlyASN says
// the AS announces none. Only storedASN shows a first-seen date, and no page
// shows a date that was never set. unknownASN answers 404. Before the fix
// builtInOnlyASN and routeOnlyASN answered 404 too
// (https://git.eeqj.de/sneak/routewatch/issues/151).
func TestASDetailPageShowsBuiltInAS(t *testing.T) {
t.Parallel()
s := newServerWithStoredAS(t)
tests := []struct {
asn int
wantCode int
wantText string
wantFirstSeen bool
}{
{storedASN, http.StatusOK, testPrefixV4, true},
{routeOnlyASN, http.StatusOK, testPrefixV6, false},
{builtInOnlyASN, http.StatusOK, "No prefixes announced by this AS", false},
{unknownASN, http.StatusNotFound, "", false},
}
for _, tt := range tests {
path := "/as/" + strconv.Itoa(tt.asn)
rec := httptest.NewRecorder()
s.router.ServeHTTP(rec, httptest.NewRequestWithContext(
t.Context(), http.MethodGet, path, nil))
if rec.Code != tt.wantCode {
t.Errorf("%s: status %d, want %d; body %s",
path, rec.Code, tt.wantCode, rec.Body)
continue
}
if tt.wantCode != http.StatusOK {
continue
}
info, _ := asinfo.Get(tt.asn)
page := rec.Body.String()
for _, want := range []string{
html.EscapeString(info.Handle),
html.EscapeString(info.Description),
tt.wantText,
} {
if !strings.Contains(page, want) {
t.Errorf("%s: page does not show %q", path, want)
}
}
if strings.Contains(page, zeroDate) {
t.Errorf("%s: page shows the date %s", path, zeroDate)
}
gotFirstSeen := strings.Contains(page, firstSeenCard)
if gotFirstSeen != tt.wantFirstSeen {
t.Errorf("%s: shows a first-seen date %t, want %t",
path, gotFirstSeen, tt.wantFirstSeen)
}
}
}
// TestNoOriginHasNoASAnswers stores a route with no known origin, which has
// origin AS 0 (https://git.eeqj.de/sneak/routewatch/issues/75). It expects
// /as/0 and /api/v1/as/0 to answer 404. It expects a search for "iana" to leave
// AS 0 out of its results and its total, although the AS list built into
// asinfo holds AS 0. That search has more matches than an answer holds, so it
// still lists maxASSearchResults of them.
func TestNoOriginHasNoASAnswers(t *testing.T) {
t.Parallel()
const (
noOriginPrefix = "203.0.113.0/24"
query = "iana"
)
s := newServerWithTestRoutes(t)
err := s.db.UpsertLiveRouteBatch([]*database.LiveRoute{{
Prefix: noOriginPrefix, MaskLength: 24, IPVersion: 4,
OriginASN: ristypes.NoOrigin, PeerIP: testPeerV4,
ASPath: []int{64500, 64501, 64502}, NextHop: testPeerV4,
LastUpdated: time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC),
}})
if err != nil {
t.Fatalf("UpsertLiveRouteBatch: %v", err)
}
asn := strconv.Itoa(ristypes.NoOrigin)
for _, path := range []string{"/as/" + asn, "/api/v1/as/" + asn} {
rec := httptest.NewRecorder()
s.router.ServeHTTP(rec, httptest.NewRequestWithContext(
t.Context(), http.MethodGet, path, nil))
if rec.Code != http.StatusNotFound {
t.Errorf("%s: status %d, want %d; body %s",
path, rec.Code, http.StatusNotFound, rec.Body)
}
}
first, total := asinfo.Search(query, 1)
if len(first) != 1 || first[0].ASN != ristypes.NoOrigin {
t.Fatalf("AS%d is not the first match for %q in the AS info data",
ristypes.NoOrigin, query)
}
data := searchAS(t, s, "q="+query)
for _, info := range data.Results {
if info.ASN == ristypes.NoOrigin {
t.Errorf("the search for %q lists AS%d", query, info.ASN)
}
}
if data.Total != total-1 {
t.Errorf("the search for %q has a total of %d, want %d",
query, data.Total, total-1)
}
if len(data.Results) != maxASSearchResults {
t.Errorf("the search for %q lists %d matches, want %d",
query, len(data.Results), maxASSearchResults)
}
}
// settledGoroutineCount lets transient goroutines finish, then reports the
// current count.
func settledGoroutineCount() int {
-1
View File
@@ -87,7 +87,6 @@ func (s *Server) setupRoutes() {
r.Get("/ip/{ip}", s.handleIPLookup())
r.Get("/as/{asn}", s.handleASDetailJSON())
r.Get("/prefix/{prefix}/{len}", s.handlePrefixDetailJSON())
r.Get("/search/as", s.handleASSearch())
})
s.router = r
+1 -5
View File
@@ -1,8 +1,4 @@
// Package server serves RouteWatch's web pages and JSON API:
// - the home page, the status figures and the health check
// - the AS, prefix and prefix length pages, and the JSON answers behind them
// - the IP lookups
// - the AS name search
// Package server provides HTTP endpoints for status monitoring and statistics
package server
import (
-2
View File
@@ -233,12 +233,10 @@
<div class="info-label">Peer ASNs</div>
<div class="info-value">{{.PeerCount}}</div>
</div>
{{if not .ASN.FirstSeen.IsZero}}
<div class="info-card">
<div class="info-label">First Seen</div>
<div class="info-value">{{.ASN.FirstSeen.Format "2006-01-02"}}</div>
</div>
{{end}}
</div>
{{if .IPv4Prefixes}}
+12 -102
View File
@@ -174,38 +174,6 @@
#asname-results .search-message {
font-size: 13px;
}
#asname-results .error {
color: #c00;
}
.asname-list {
list-style: none;
margin: 12px 0 0 0;
padding: 0;
max-height: 400px;
overflow-y: auto;
}
.asname-list li {
border-bottom: 1px solid #eee;
}
.asname-list a {
display: block;
padding: 8px 4px;
color: #2c3e50;
text-decoration: none;
font-size: 14px;
}
.asname-list a:hover {
background: #f5f5f5;
}
.asname-list .asn {
font-weight: 600;
color: #3498db;
}
.asname-list .description {
display: block;
color: #7f8c8d;
font-size: 12px;
}
/* IP Lookup result */
.ip-result {
@@ -338,7 +306,7 @@
<input type="text" id="asname-input" placeholder="e.g., Google, Cloudflare" autocomplete="off">
<button type="submit">Search</button>
</form>
<p class="search-hint">Search for autonomous systems by organization name or handle</p>
<p class="search-hint">Search for autonomous systems by organization name</p>
<div id="asname-results"></div>
</div>
@@ -400,7 +368,7 @@
}
const totalUpdates = data.ipv4_updates_per_sec + data.ipv6_updates_per_sec;
document.getElementById('stat-updates').textContent = Math.round(totalUpdates).toLocaleString();
document.getElementById('stat-updates').textContent = totalUpdates.toFixed(1);
})
.catch(() => {
document.getElementById('stat-status').textContent = 'Error';
@@ -419,82 +387,24 @@
}
});
// AS name search. The handles and descriptions come from outside data,
// so the results are built with textContent, never innerHTML.
const asNameResults = document.getElementById('asname-results');
// Replace the search results with one line of text.
function showASNameMessage(text, className) {
const line = document.createElement('p');
line.className = className;
line.textContent = text;
asNameResults.replaceChildren(line);
}
// List the matches of a search answer, each as a link to its AS page,
// and say how many more matched than the answer holds.
function showASNameMatches(data) {
if (data.total === 0) {
showASNameMessage('No AS matches "' + data.query + '".', 'search-message');
return;
}
const list = document.createElement('ul');
list.className = 'asname-list';
for (const match of data.results) {
const asn = document.createElement('span');
asn.className = 'asn';
asn.textContent = 'AS' + match.asn;
const description = document.createElement('span');
description.className = 'description';
description.textContent = match.description;
const link = document.createElement('a');
link.href = '/as/' + match.asn;
link.append(asn, ' ' + match.handle, description);
const item = document.createElement('li');
item.append(link);
list.append(item);
}
asNameResults.replaceChildren(list);
const more = data.total - data.results.length;
if (more > 0) {
const line = document.createElement('p');
line.className = 'search-message';
line.textContent = more.toLocaleString() + ' more matched. Narrow the search to see them.';
asNameResults.append(line);
}
}
// AS name search
document.getElementById('asname-form').addEventListener('submit', function(e) {
e.preventDefault();
const query = document.getElementById('asname-input').value.trim();
if (!query) return;
// A number, or AS and a number, opens that AS's page
const asn = query.replace(/^AS/i, '');
if (/^\d+$/.test(asn)) {
window.location.href = '/as/' + asn;
const resultsDiv = document.getElementById('asname-results');
resultsDiv.innerHTML = '<p class="loading">Searching...</p>';
// Use a simple client-side search against the asinfo data
// For now, redirect to AS page if it looks like an ASN
if (/^\d+$/.test(query)) {
window.location.href = '/as/' + query;
return;
}
showASNameMessage('Searching...', 'loading');
fetch('/api/v1/search/as?q=' + encodeURIComponent(query))
.then(response => response.json())
.then(response => {
if (response.status === 'error') {
showASNameMessage('Error: ' + response.error.msg, 'error');
} else {
showASNameMatches(response.data);
}
})
.catch(error => {
showASNameMessage('Error: ' + error.message, 'error');
});
// Show a message that server-side search is coming
resultsDiv.innerHTML = '<p class="search-message">AS name search coming soon. For now, try an AS number.</p>';
});
// IP lookup
+7 -7
View File
@@ -231,8 +231,8 @@
<span class="metric-value" id="withdrawals">-</span>
</div>
<div class="metric">
<span class="metric-label">Route Churn/min</span>
<span class="metric-value" id="route_churn_per_min">-</span>
<span class="metric-label">Route Churn/sec</span>
<span class="metric-value" id="route_churn_per_sec">-</span>
</div>
<div class="metric">
<span class="metric-label">Total Data</span>
@@ -511,7 +511,7 @@
document.getElementById('messages_per_sec').textContent = '-';
document.getElementById('announcements').textContent = '-';
document.getElementById('withdrawals').textContent = '-';
document.getElementById('route_churn_per_min').textContent = '-';
document.getElementById('route_churn_per_sec').textContent = '-';
document.getElementById('total_wire_bytes').textContent = '-';
document.getElementById('wire_mbits_per_sec').textContent = '-';
document.getElementById('gc_num').textContent = '-';
@@ -578,7 +578,7 @@
document.getElementById('connection_duration').textContent = data.connection_duration;
document.getElementById('reconnect_count').textContent = formatNumber(data.reconnect_count);
document.getElementById('total_messages').textContent = formatNumber(data.total_messages);
document.getElementById('messages_per_sec').textContent = formatNumber(Math.round(data.messages_per_sec));
document.getElementById('messages_per_sec').textContent = data.messages_per_sec.toFixed(1);
document.getElementById('total_wire_bytes').textContent = formatBytes(data.total_wire_bytes);
document.getElementById('wire_mbits_per_sec').textContent = data.wire_mbits_per_sec.toFixed(2) + ' Mbps';
document.getElementById('asns').textContent = formatNumber(data.asns);
@@ -591,8 +591,8 @@
document.getElementById('live_routes').textContent = formatNumber(data.live_routes);
document.getElementById('ipv4_routes').textContent = formatNumber(data.ipv4_routes);
document.getElementById('ipv6_routes').textContent = formatNumber(data.ipv6_routes);
document.getElementById('ipv4_updates_per_sec').textContent = formatNumber(Math.round(data.ipv4_updates_per_sec));
document.getElementById('ipv6_updates_per_sec').textContent = formatNumber(Math.round(data.ipv6_updates_per_sec));
document.getElementById('ipv4_updates_per_sec').textContent = data.ipv4_updates_per_sec.toFixed(1);
document.getElementById('ipv6_updates_per_sec').textContent = data.ipv6_updates_per_sec.toFixed(1);
document.getElementById('oldest_route').textContent = formatRelativeTime(data.oldest_route);
document.getElementById('newest_route').textContent = formatRelativeTime(data.newest_route);
@@ -601,7 +601,7 @@
document.getElementById('bgp_peer_count').textContent = formatNumber(data.stream.bgp_peer_count);
document.getElementById('announcements').textContent = formatNumber(data.stream.announcements);
document.getElementById('withdrawals').textContent = formatNumber(data.stream.withdrawals);
document.getElementById('route_churn_per_min').textContent = formatNumber(data.stream.route_churn_per_min);
document.getElementById('route_churn_per_sec').textContent = data.stream.route_churn_per_sec.toFixed(1);
}
// Update GC stats
+5 -64
View File
@@ -49,31 +49,6 @@ const (
RIRAFRNIC = "AFRINIC"
)
// noPersonalFlag keeps person and role objects out of an answer. The
// organisation object stays in it. RIPE counts the person and role objects
// it returns against a daily limit per IP address. Role objects for abuse
// contacts do not count. RIPE refuses an address for good that passes the
// limit too often:
// https://docs.db.ripe.net/Types-of-Queries/Access-Control-for-Queries/
//
// RIPE documents the flag here:
// https://docs.db.ripe.net/Types-of-Queries/Referenced-Objects-in-Query-Response/
// APNIC and AFRINIC list it in their answers to the query "help". AFRINIC
// sends readers to that answer for its full list of flags:
// https://afrinic.net/whois-getting-started
const noPersonalFlag = "--no-personal"
// The error lines with which RIPE's database software refuses a query:
// access denied to the client's IP address, and that address's limit
// reached. APNIC and AFRINIC run the same software. The lines are listed
// under "Access Errors" on this page, whose URL is split over two lines:
// https://docs.db.ripe.net/Appendices/
// Appendix-C--RIPE-Database-Query-Server-Response-Codes-and-Messages/
const (
accessDeniedError = "%ERROR:201:"
accessLimitError = "%ERROR:202:"
)
// Errors LookupASN returns when a WHOIS answer has nothing to store.
var (
// ErrNoReferral is returned when IANA refers the AS to no RIR, as it
@@ -83,7 +58,6 @@ var (
errNoASBlock = errors.New("IANA answered with neither a referral " +
"nor an AS block")
errNoFields = errors.New("RIR answered with no WHOIS fields")
errRefused = errors.New("RIR refused the query")
errUnknownServer = errors.New("IANA refers the AS to a server no RIR runs")
errAnswerTooLarge = errors.New("WHOIS answer reached the size cap")
)
@@ -123,10 +97,10 @@ func NewClient() *Client {
// LookupASN asks IANA which RIR holds the given ASN, asks that RIR, and
// returns what the RIR's answer says. It returns ErrNoReferral when IANA
// refers the AS to no RIR, and another error when a query fails, when the
// RIR refuses it, when IANA's answer has neither a referral nor the AS's
// block, when IANA refers the AS to a server none of the five RIRs run, or
// when the RIR's answer carries none of ASNInfo's fields.
// refers the AS to no RIR, and another error when a query fails, when IANA's
// answer has neither a referral nor the AS's block, when IANA refers the AS
// to a server none of the five RIRs run, or when the RIR's answer carries
// none of ASNInfo's fields, as a refusal of service does.
func (c *Client) LookupASN(ctx context.Context, asn int) (*ASNInfo, error) {
// Query IANA first to find the authoritative RIR
query := fmt.Sprintf("AS%d", asn)
@@ -153,7 +127,7 @@ func (c *Client) LookupASN(ctx context.Context, asn int) (*ASNInfo, error) {
}
// Query the authoritative RIR
rirResp, err := c.queryRIR(ctx, whoisServer, rirQuery(rir, query))
rirResp, err := c.query(ctx, whoisServer, query)
if err != nil {
return nil, fmt.Errorf("RIR query failed: %w", err)
}
@@ -180,39 +154,6 @@ func (info *ASNInfo) isEmpty() bool {
info.RegDate == nil && info.LastMod == nil
}
// rirQuery returns the query sent to rir's server for asQuery, an AS
// number such as "AS3333". RIPE, APNIC and AFRINIC are asked to keep person
// and role objects out of the answer.
func rirQuery(rir, asQuery string) string {
switch rir {
case RIRRIPE, RIRAPNIC, RIRAFRNIC:
return noPersonalFlag + " " + asQuery
default:
return asQuery
}
}
// queryRIR sends query to an RIR's server and returns the answer. An answer
// holding an error line with which the server refuses the query is an
// error, and the error holds that line.
func (c *Client) queryRIR(
ctx context.Context, server, query string,
) (string, error) {
answer, err := c.query(ctx, server, query)
if err != nil {
return "", err
}
for line := range strings.SplitSeq(answer, "\n") {
if strings.HasPrefix(line, accessDeniedError) ||
strings.HasPrefix(line, accessLimitError) {
return "", fmt.Errorf("%w: %s", errRefused, line)
}
}
return answer, nil
}
// query performs a raw WHOIS query to the specified server. An answer that
// reaches maxAnswerSize is an error.
func (c *Client) query(ctx context.Context, server, query string) (string, error) {
+27 -173
View File
@@ -6,7 +6,6 @@ import (
"errors"
"io"
"net"
"slices"
"strings"
"sync"
"syscall"
@@ -22,20 +21,8 @@ const (
ripeServer = "whois.ripe.net:43"
)
// testASN is the AS the tests look up, and asQuery the query for it.
const (
testASN = 3333
asQuery = "AS3333"
)
// The fake servers' answers are keyed by a server's address, a space and a
// query. These are the keys of the queries LookupASN sends for testASN:
// IANA's, then RIPE's, which asks RIPE to leave out person and role
// objects.
const (
ianaQuery = ianaServer + " " + asQuery
ripeQuery = ripeServer + " --no-personal " + asQuery
)
// testASN is the AS the tests look up.
const testASN = 3333
// ianaReferral is IANA's answer for an AS in a block it gave to RIPE.
const ianaReferral = `% IANA WHOIS server
@@ -49,75 +36,27 @@ changed: 2002-09-01
source: IANA
`
// ripeRecord is RIPE's answer for the AS when asked to leave out person and
// role objects: the abuse address as a comment, the AS's object and the
// organisation object it refers to.
// ripeRecord is RIPE's answer for the AS.
const ripeRecord = `% This is the RIPE Database query service.
% The objects are in RPSL format.
% Note: --no-personal means ALL personal data has been filtered from this output.
% Information related to 'AS3333'
% Abuse contact for 'AS3333' is 'abuse@ripe.net'
aut-num: AS3333
as-name: RIPE-NCC-AS
org: ORG-RIEN1-RIPE
admin-c: BRD-RIPE
tech-c: OPS4-RIPE
abuse-c: OPS4-RIPE
mnt-by: RIPE-NCC-MNT
created: 2002-09-17T13:08:32Z
last-modified: 2024-01-09T08:55:41Z
source: RIPE
organisation: ORG-RIEN1-RIPE
org-name: Reseaux IP Europeens Network Coordination Centre (RIPE NCC)
country: NL
org-type: RIR
address: P.O. Box 10096
address: 1001EB Amsterdam
abuse-c: OPS4-RIPE
created: 2012-03-09T13:08:29Z
last-modified: 2024-03-26T10:55:38Z
source: RIPE
`
// The error lines with which RIPE refuses a query: access denied to the
// host, and the host's limit on returned objects reached.
const (
ripeAccessDenied = "%ERROR:201: access denied for 192.0.2.1"
ripeLimitReached = "%ERROR:202: access control limit reached"
)
// ripeRefusal is RIPE's answer to a host it no longer serves.
const ripeRefusal = `% This is the RIPE Database query service.
% The objects are in RPSL format.
%
` + ripeAccessDenied + `
%ERROR:201: access denied for 192.0.2.1
%
% Sorry, access from your host has been permanently
% denied because of a repeated excessive querying.
`
// apnicAfrinicRecord is the answer of APNIC or AFRINIC for the AS when asked
// to leave out person and role objects. Like RIPE's, it holds the abuse
// address as a comment, the AS's object and the organisation object.
const apnicAfrinicRecord = `% Abuse contact for 'AS3333' is 'abuse@example.net'
aut-num: AS3333
as-name: EXAMPLE-AS
org: ORG-EXAMPLE1
admin-c: EX1-TEST
last-modified: 2026-08-26T00:40:00Z
organisation: ORG-EXAMPLE1
org-name: Example Networks
country: ZA
address: 1 Example Road
`
// ianaPrivateUse is IANA's answer for a private-use AS: its block and no
// referral.
const ianaPrivateUse = `% IANA WHOIS server
@@ -141,29 +80,20 @@ const fillerLine = "remarks: and more\n"
const endlessAnswerSize = 4 << 20
// fakeServers returns a DialContext for a whois.Client that connects to a
// fake server at each address answers names. Each key of answers is a
// server's address, a space and a query, and its value is that server's
// answer to that query. A server reads one query and sends its answer, or
// nothing for a query it has no answer for. Connecting to any other address
// is refused.
// fake server for each address in answers, which reads the query and sends
// answers[address]. Connecting to any other address is refused.
func fakeServers(
t *testing.T, answers map[string]string,
) func(context.Context, string, string) (net.Conn, error) {
t.Helper()
addresses := make(map[string]bool)
for key := range answers {
address, _, _ := strings.Cut(key, " ")
addresses[address] = true
}
var servers sync.WaitGroup
t.Cleanup(servers.Wait)
return func(_ context.Context, _, address string) (net.Conn, error) {
if !addresses[address] {
answer, ok := answers[address]
if !ok {
return nil, syscall.ECONNREFUSED
}
@@ -172,8 +102,7 @@ func fakeServers(
servers.Go(func() {
defer func() { _ = server.Close() }()
query, _ := bufio.NewReader(server).ReadString('\n')
answer := answers[address+" "+strings.TrimSpace(query)]
_, _ = bufio.NewReader(server).ReadString('\n')
_, _ = io.WriteString(server, answer)
})
@@ -193,41 +122,25 @@ func lookUp(
return client.LookupASN(t.Context(), testASN)
}
// TestLookupASNReadsTheRIRAnswer looks up an AS IANA refers to RIPE. RIPE
// answers only a query that leaves out person and role objects, and its
// answer to that still fills the AS's fields: the abuse address from the
// comment line, and the organisation's name and country from its object.
// The dates are the AS's: IANA's "changed:" line dates its block.
// TestLookupASNReadsTheRIRAnswer looks up an AS IANA refers to RIPE. The
// fields come from RIPE's answer, the last-modified date included: IANA's
// "changed:" line dates its block, not the AS.
func TestLookupASNReadsTheRIRAnswer(t *testing.T) {
t.Parallel()
info, err := lookUp(t, map[string]string{
ianaQuery: ianaReferral,
ripeQuery: ripeRecord,
ianaServer: ianaReferral,
ripeServer: ripeRecord,
})
if err != nil {
t.Fatalf("LookupASN: %v", err)
}
got := []string{
info.ASName, info.OrgID, info.OrgName, info.CountryCode,
info.AbuseEmail, info.RIR,
}
want := []string{
"RIPE-NCC-AS", "ORG-RIEN1-RIPE",
"Reseaux IP Europeens Network Coordination Centre (RIPE NCC)", "NL",
"abuse@ripe.net", whois.RIRRIPE,
}
if !slices.Equal(got, want) {
t.Errorf("LookupASN = AS name, org ID, org name, country, abuse "+
"address, RIR %q; want %q", got, want)
}
wantRegDate := time.Date(2002, 9, 17, 13, 8, 32, 0, time.UTC)
if info.RegDate == nil || !info.RegDate.Equal(wantRegDate) {
t.Errorf("LookupASN registered = %v, want %v",
info.RegDate, wantRegDate)
if info.ASName != "RIPE-NCC-AS" || info.OrgID != "ORG-RIEN1-RIPE" ||
info.RIR != whois.RIRRIPE {
t.Errorf("LookupASN = AS name %q, org %q, RIR %q; "+
"want RIPE-NCC-AS, ORG-RIEN1-RIPE, RIPE",
info.ASName, info.OrgID, info.RIR)
}
wantLastMod := time.Date(2024, 1, 9, 8, 55, 41, 0, time.UTC)
@@ -237,65 +150,6 @@ func TestLookupASNReadsTheRIRAnswer(t *testing.T) {
}
}
// TestLookupASNAsksAPNICAndAFRINICToLeaveOutPersonalData looks up an AS at
// APNIC and at AFRINIC. Each answers only a query that leaves out person
// and role objects, as RIPE does. That one answer still holds the
// organisation object, so the organisation's name and country are filled.
func TestLookupASNAsksAPNICAndAFRINICToLeaveOutPersonalData(t *testing.T) {
t.Parallel()
for _, server := range []string{"whois.apnic.net", "whois.afrinic.net"} {
t.Run(server, func(t *testing.T) {
t.Parallel()
address := server + ":43"
info, err := lookUp(t, map[string]string{
ianaQuery: "refer: " + server + "\n",
address + " --no-personal " + asQuery: apnicAfrinicRecord,
})
if err != nil {
t.Fatalf("LookupASN: %v", err)
}
got := []string{
info.ASName, info.OrgName, info.CountryCode, info.AbuseEmail,
}
want := []string{
"EXAMPLE-AS", "Example Networks", "ZA", "abuse@example.net",
}
if !slices.Equal(got, want) {
t.Errorf("LookupASN = AS name, org name, country, abuse "+
"address %q; want %q", got, want)
}
})
}
}
// TestLookupASNReportsARefusal looks up an AS IANA refers to RIPE when RIPE
// refuses the query, with each of the error lines RIPE refuses with. The
// error holds RIPE's line, so the log says why the lookup failed.
func TestLookupASNReportsARefusal(t *testing.T) {
t.Parallel()
for _, line := range []string{ripeAccessDenied, ripeLimitReached} {
t.Run(line, func(t *testing.T) {
t.Parallel()
_, err := lookUp(t, map[string]string{
ianaQuery: ianaReferral,
ripeQuery: "% This is the RIPE Database query service.\n" +
line + "\n",
})
if err == nil || !strings.Contains(err.Error(), line) {
t.Errorf("LookupASN error = %v, want one holding %q",
err, line)
}
})
}
}
// TestLookupASNFailsWhenTheRIRGivesNoAnswer looks up an AS IANA refers to
// RIPE when RIPE's server refuses the connection, and when it answers only
// with an error. Neither is a lookup that found the AS.
@@ -303,8 +157,8 @@ func TestLookupASNFailsWhenTheRIRGivesNoAnswer(t *testing.T) {
t.Parallel()
for name, answers := range map[string]map[string]string{
"connection refused": {ianaQuery: ianaReferral},
"only an error": {ianaQuery: ianaReferral, ripeQuery: ripeRefusal},
"connection refused": {ianaServer: ianaReferral},
"only an error": {ianaServer: ianaReferral, ripeServer: ripeRefusal},
} {
t.Run(name, func(t *testing.T) {
t.Parallel()
@@ -323,7 +177,7 @@ func TestLookupASNFailsWhenTheRIRGivesNoAnswer(t *testing.T) {
func TestLookupASNReportsNoReferral(t *testing.T) {
t.Parallel()
_, err := lookUp(t, map[string]string{ianaQuery: ianaPrivateUse})
_, err := lookUp(t, map[string]string{ianaServer: ianaPrivateUse})
if !errors.Is(err, whois.ErrNoReferral) {
t.Errorf("LookupASN error = %v, want ErrNoReferral", err)
}
@@ -335,7 +189,7 @@ func TestLookupASNReportsNoReferral(t *testing.T) {
func TestLookupASNFailsWhenIANAAnswersNoBlock(t *testing.T) {
t.Parallel()
_, err := lookUp(t, map[string]string{ianaQuery: ianaNoObject})
_, err := lookUp(t, map[string]string{ianaServer: ianaNoObject})
if err == nil || errors.Is(err, whois.ErrNoReferral) {
t.Errorf("LookupASN error = %v, want one that is not ErrNoReferral",
err)
@@ -352,8 +206,8 @@ func TestLookupASNFailsWhenTheRIRAnswerDoesNotEnd(t *testing.T) {
strings.Repeat(fillerLine, endlessAnswerSize/len(fillerLine))
_, err := lookUp(t, map[string]string{
ianaQuery: ianaReferral,
ripeQuery: endless,
ianaServer: ianaReferral,
ripeServer: endless,
})
if err == nil || errors.Is(err, whois.ErrNoReferral) {
t.Errorf("LookupASN error = %v, want one that is not ErrNoReferral",
@@ -379,9 +233,9 @@ func TestLookupASNFailsOnAReferralToAnotherServer(t *testing.T) {
t.Parallel()
_, err := lookUp(t, map[string]string{
ianaQuery: "refer: " + refer + "\n\n" +
ianaServer: "refer: " + refer + "\n\n" +
"as-block: 3154-3353\n",
address + " " + asQuery: ripeRecord,
address: ripeRecord,
})
if err == nil || errors.Is(err, whois.ErrNoReferral) {
t.Errorf("LookupASN error = %v, want one that is not "+
+23 -49
View File
@@ -2,13 +2,10 @@ package asinfo
import (
"bytes"
"cmp"
"compress/gzip"
_ "embed"
"encoding/json"
"io"
"slices"
"strings"
"sync"
)
@@ -26,17 +23,6 @@ type Info struct {
type Registry struct {
mu sync.RWMutex
byASN map[int]*Info
// searchEntries holds every entry in AS number order, for Search.
searchEntries []searchEntry
}
// searchEntry is an entry with its handle and description in lower case. They
// are put in lower case once, when the data loads, so that Search only
// compares.
type searchEntry struct {
info *Info
lowerHandle string
lowerDescription string
}
var (
@@ -73,21 +59,9 @@ func initRegistry() {
panic("failed to unmarshal embedded AS data: " + err.Error())
}
slices.SortFunc(asInfos, func(a, b Info) int {
return cmp.Compare(a.ASN, b.ASN)
})
defaultRegistry.searchEntries = make([]searchEntry, 0, len(asInfos))
for i := range asInfos {
info := &asInfos[i]
defaultRegistry.byASN[info.ASN] = info
defaultRegistry.searchEntries = append(defaultRegistry.searchEntries,
searchEntry{
info: info,
lowerHandle: strings.ToLower(info.Handle),
lowerDescription: strings.ToLower(info.Description),
})
}
}
@@ -155,36 +129,36 @@ func All() []Info {
return result
}
// Search finds the AS entries whose handle or description contains query,
// ignoring case. It returns the first limit of them in AS number order, and the
// number of entries that match. An empty query matches nothing.
func Search(query string, limit int) ([]Info, int) {
// Search searches for AS entries where the handle or description contains the query
// This is a simple case-sensitive substring search
func Search(query string) []Info {
once.Do(initRegistry)
if query == "" {
return nil, 0
}
query = strings.ToLower(query)
defaultRegistry.mu.RLock()
defer defaultRegistry.mu.RUnlock()
var (
results []Info
total int
)
var results []Info
for _, entry := range defaultRegistry.searchEntries {
if strings.Contains(entry.lowerHandle, query) ||
strings.Contains(entry.lowerDescription, query) {
if total < limit {
results = append(results, *entry.info)
}
total++
for _, info := range defaultRegistry.byASN {
if contains(info.Handle, query) || contains(info.Description, query) {
results = append(results, *info)
}
}
return results, total
return results
}
// contains is a simple substring search
func contains(s, substr string) bool {
return len(substr) > 0 && len(s) >= len(substr) && containsImpl(s, substr)
}
func containsImpl(s, substr string) bool {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}
+15 -103
View File
@@ -1,8 +1,6 @@
package asinfo_test
import (
"cmp"
"slices"
"strings"
"testing"
@@ -142,6 +140,11 @@ func TestSearch(t *testing.T) {
query: "University",
wantMin: 10,
},
{
name: "Empty search",
query: "",
wantMin: 0,
},
{
name: "Non-existent org",
query: "XYZABC123456",
@@ -153,115 +156,26 @@ func TestSearch(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
results, _ := asinfo.Search(tt.query, asinfo.Total())
results := asinfo.Search(tt.query)
if len(results) < tt.wantMin {
t.Errorf("Search(%q) returned %d results, want at least %d",
tt.query, len(results), tt.wantMin)
}
// Verify all results contain the query, ignoring case
query := strings.ToLower(tt.query)
for _, r := range results {
if !strings.Contains(strings.ToLower(r.Handle), query) &&
!strings.Contains(strings.ToLower(r.Description), query) {
t.Errorf("Search result ASN %d doesn't contain query %q",
r.ASN, tt.query)
// Verify all results contain the query
if tt.query != "" {
for _, r := range results {
if !strings.Contains(r.Handle, tt.query) &&
!strings.Contains(r.Description, tt.query) {
t.Errorf("Search result ASN %d doesn't contain query %q",
r.ASN, tt.query)
}
}
}
})
}
}
// TestSearchEmptyQueryMatchesNothing expects an empty query to match no AS,
// although every handle contains the empty string.
func TestSearchEmptyQueryMatchesNothing(t *testing.T) {
t.Parallel()
results, total := asinfo.Search("", asinfo.Total())
if len(results) != 0 || total != 0 {
t.Errorf(`Search("") returned %d results and a total of %d, want none`,
len(results), total)
}
}
// TestSearchIgnoresCase searches for Cloudflare's handle in lower case and its
// description in upper case, and expects both searches to find AS13335.
func TestSearchIgnoresCase(t *testing.T) {
t.Parallel()
const cloudflareASN = 13335 // handle CLOUDFLARENET, "Cloudflare, Inc."
isCloudflare := func(info asinfo.Info) bool {
return info.ASN == cloudflareASN
}
for _, query := range []string{"cloudflarenet", "CLOUDFLARE, INC."} {
results, _ := asinfo.Search(query, asinfo.Total())
if !slices.ContainsFunc(results, isCloudflare) {
t.Errorf("Search(%q) did not find AS%d", query, cloudflareASN)
}
}
}
// TestSearchOrdersByASN expects the matches of a search with many of them to
// come in AS number order.
func TestSearchOrdersByASN(t *testing.T) {
t.Parallel()
const query = "University"
results, _ := asinfo.Search(query, asinfo.Total())
for i := 1; i < len(results); i++ {
if results[i-1].ASN >= results[i].ASN {
t.Fatalf("Search(%q): AS%d comes after AS%d",
query, results[i].ASN, results[i-1].ASN)
}
}
}
// TestSearchStopsAtLimit searches for a word that matches more entries than
// the limit. It expects exactly limit entries, the first matches in AS number
// order, and the number of all matches, counted here from All.
func TestSearchStopsAtLimit(t *testing.T) {
t.Parallel()
const (
query = "university"
limit = 10
)
var matches []asinfo.Info
for _, info := range asinfo.All() {
if strings.Contains(strings.ToLower(info.Handle), query) ||
strings.Contains(strings.ToLower(info.Description), query) {
matches = append(matches, info)
}
}
if len(matches) <= limit {
t.Fatalf("%q matches %d entries; the test needs more than %d",
query, len(matches), limit)
}
slices.SortFunc(matches, func(a, b asinfo.Info) int {
return cmp.Compare(a.ASN, b.ASN)
})
results, total := asinfo.Search(query, limit)
if total != len(matches) {
t.Errorf("Search(%q, %d): total %d, want %d",
query, limit, total, len(matches))
}
if !slices.Equal(results, matches[:limit]) {
t.Errorf("Search(%q, %d) = %v, want %v",
query, limit, results, matches[:limit])
}
}
func TestDataIntegrity(t *testing.T) {
t.Parallel()
@@ -301,13 +215,11 @@ func BenchmarkGet(b *testing.B) {
}
func BenchmarkSearch(b *testing.B) {
const limit = 50
queries := []string{"Google", "Amazon", "Microsoft", "University"}
b.ResetTimer()
for i := range b.N {
asinfo.Search(queries[i%len(queries)], limit)
asinfo.Search(queries[i%len(queries)])
}
}
+2 -4
View File
@@ -16,10 +16,8 @@ Basic usage:
// Get just the description
desc := asinfo.GetDescription(15169) // "Google LLC"
// Search the handles and descriptions, ignoring case, for the first 10
// matches in AS number order and the number of all matches
results, total := asinfo.Search("university", 10)
fmt.Printf("%d matches\n", total)
// Search for AS entries
results := asinfo.Search("University")
for _, as := range results {
fmt.Printf("AS%d: %s\n", as.ASN, as.Description)
}
+2 -9
View File
@@ -21,16 +21,9 @@ func ExampleGetDescription() {
}
func ExampleSearch() {
const limit = 10
results, total := asinfo.Search("mit-gateway", limit)
fmt.Printf("%d of %d matches\n", len(results), total)
results := asinfo.Search("MIT-GATEWAY")
for _, info := range results {
fmt.Printf("AS%d: %s - %s\n", info.ASN, info.Handle, info.Description)
}
// Output:
// 1 of 1 matches
// AS3: MIT-GATEWAYS - Massachusetts Institute of Technology
// Output: AS3: MIT-GATEWAYS - Massachusetts Institute of Technology
}