Make the home page's AS name search work (closes #151)
check / check (push) Canceled after 0s
check / check (push) Canceled after 0s
The AS Name Search card on the home page said the search was coming soon.
It now asks the new GET /api/v1/search/as?q=<text> and lists the matches
as links to their AS pages. asinfo.Search ignores case, returns the first
matches up to a limit in AS number order, and counts all matches. An
empty q answers 400.
When the database has no record of an AS, its page and /api/v1/as/{asn}
take its handle and description from asinfo and still list its prefixes.
They answer 404 only for an AS with no record, no asinfo entry and no
prefix. AS 0 means no known origin: it always answers 404, and the search
never lists it.
Model: opus-5-5
This commit is contained in:
@@ -21,6 +21,7 @@ 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
|
||||
)
|
||||
@@ -1748,9 +1749,21 @@ 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
|
||||
@@ -1763,17 +1776,28 @@ func (d *Database) GetASDetailsContext(
|
||||
asn,
|
||||
).Scan(&asnInfo.ASN, &handle, &description, &asnInfo.FirstSeen,
|
||||
&asnInfo.LastSeen)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil, fmt.Errorf("%w: AS%d", ErrNoRoute, asn)
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
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
|
||||
@@ -1784,7 +1808,13 @@ func (d *Database) GetASDetailsContext(
|
||||
return &asnInfo, allPrefixes, err
|
||||
}
|
||||
|
||||
return &asnInfo, append(allPrefixes, prefixesV6...), nil
|
||||
allPrefixes = append(allPrefixes, prefixesV6...)
|
||||
|
||||
if !known && len(allPrefixes) == 0 {
|
||||
return nil, nil, fmt.Errorf("%w: AS%d", ErrNoRoute, asn)
|
||||
}
|
||||
|
||||
return &asnInfo, allPrefixes, nil
|
||||
}
|
||||
|
||||
// ASPeer represents a peering relationship with another AS including handle,
|
||||
|
||||
@@ -20,6 +20,7 @@ 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"
|
||||
)
|
||||
|
||||
@@ -1699,6 +1700,86 @@ 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 an AS with no record got no prefixes, although
|
||||
// its routes were stored.
|
||||
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
|
||||
|
||||
@@ -28,9 +28,10 @@ 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
|
||||
FirstSeen time.Time `json:"first_seen"`
|
||||
LastSeen time.Time `json:"last_seen"`
|
||||
// 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"`
|
||||
WHOISUpdatedAt *time.Time `json:"whois_updated_at,omitempty"`
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user