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>, which
searches the AS list built into asinfo, and lists the matches as links
to their AS pages. An empty q answers 400. asinfo.Search now ignores
case and returns its matches in AS number order. asinfo puts each
handle and description in lower case once, when the list loads.
A match can be an AS that the database has no record of. Its AS page
and /api/v1/as/{asn} then 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. Empty prefix lists in that JSON
answer are [] rather than null.
Model: opus-5-5
This commit is contained in:
@@ -56,10 +56,18 @@ 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"
|
||||
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
|
||||
)
|
||||
|
||||
// IP versions as database.LiveRoute.IPVersion records them.
|
||||
@@ -1001,11 +1009,12 @@ func (s *Server) handleASDetailJSON() http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// splitRoutesByIPVersion groups routes into the IPv4 and the IPv6 ones.
|
||||
// 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.
|
||||
func splitRoutesByIPVersion(
|
||||
routes []database.LiveRoute,
|
||||
) ([]database.LiveRoute, []database.LiveRoute) {
|
||||
var ipv4Routes, ipv6Routes []database.LiveRoute
|
||||
ipv4Routes, ipv6Routes := []database.LiveRoute{}, []database.LiveRoute{}
|
||||
|
||||
for _, route := range routes {
|
||||
if route.IPVersion == ipVersionV4 {
|
||||
@@ -1105,6 +1114,46 @@ 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
|
||||
}
|
||||
|
||||
matches := asinfo.Search(query)
|
||||
|
||||
// results is an empty list rather than nil when nothing matches, so
|
||||
// that the answer holds [] and not null.
|
||||
shown := min(len(matches), maxASSearchResults)
|
||||
results := make([]asinfo.Info, 0, shown)
|
||||
results = append(results, matches[:shown]...)
|
||||
|
||||
err := writeJSONSuccess(w, ASSearchResponse{
|
||||
Query: query,
|
||||
Total: len(matches),
|
||||
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) {
|
||||
|
||||
@@ -26,6 +26,7 @@ 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"
|
||||
)
|
||||
@@ -194,7 +195,7 @@ func TestStatsHandlersAnswerFromMemory(t *testing.T) {
|
||||
}
|
||||
|
||||
// newServerWithTestRoutes returns a server on a new database that holds a
|
||||
// route for testPrefixV4 and one for 2001:db8::/32.
|
||||
// route for testPrefixV4 and one for testPrefixV6.
|
||||
func newServerWithTestRoutes(t *testing.T) *Server {
|
||||
t.Helper()
|
||||
|
||||
@@ -214,7 +215,7 @@ func newServerWithTestRoutes(t *testing.T) *Server {
|
||||
NextHop: testPeerV4, LastUpdated: ts,
|
||||
},
|
||||
{
|
||||
Prefix: "2001:db8::/32", MaskLength: 32, IPVersion: 6,
|
||||
Prefix: testPrefixV6, MaskLength: 32, IPVersion: 6,
|
||||
OriginASN: 64501, PeerIP: testPeerV6, ASPath: []int{64501},
|
||||
NextHop: testPeerV6, LastUpdated: ts,
|
||||
},
|
||||
@@ -1166,6 +1167,327 @@ 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)
|
||||
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
|
||||
// answered 404, so a result of the AS name search could lead nowhere
|
||||
// (https://git.eeqj.de/sneak/routewatch/issues/151), and routeOnlyASN listed
|
||||
// no prefixes.
|
||||
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 answered 404, so a result of the AS name search led nowhere
|
||||
// (https://git.eeqj.de/sneak/routewatch/issues/151), and routeOnlyASN listed no
|
||||
// prefixes.
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// settledGoroutineCount lets transient goroutines finish, then reports the
|
||||
// current count.
|
||||
func settledGoroutineCount() int {
|
||||
|
||||
@@ -87,6 +87,7 @@ 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,4 +1,8 @@
|
||||
// Package server provides HTTP endpoints for status monitoring and statistics
|
||||
// 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
|
||||
|
||||
import (
|
||||
|
||||
Reference in New Issue
Block a user