From ca68b8f605ca1499bc42bfc6146c73f4bf297c71 Mon Sep 17 00:00:00 2001 From: sneak Date: Mon, 26 Oct 2026 20:21:14 +0000 Subject: [PATCH] Make the home page's AS name search work (closes #151) 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=, 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. A match can be an AS that the database does not hold yet. Its AS page and /api/v1/as/{asn} now answer with its handle and description from asinfo and no prefixes, instead of 404. Empty prefix lists in that JSON answer are [] rather than null. Model: opus-5-5 --- README.md | 35 ++- TODO.md | 5 + internal/database/database.go | 13 +- internal/database/database_internal_test.go | 41 +++ internal/database/models.go | 7 +- internal/server/handlers.go | 61 +++- internal/server/handlers_internal_test.go | 297 ++++++++++++++++++++ internal/server/routes.go | 1 + internal/server/server.go | 6 +- internal/templates/as_detail.html | 2 +- internal/templates/index.html | 112 +++++++- pkg/asinfo/asinfo.go | 35 ++- pkg/asinfo/asinfo_test.go | 67 ++++- pkg/asinfo/doc.go | 5 +- pkg/asinfo/example_test.go | 2 +- 15 files changed, 622 insertions(+), 67 deletions(-) diff --git a/README.md b/README.md index 7c3629f..d5232ff 100644 --- a/README.md +++ b/README.md @@ -56,15 +56,16 @@ Every route answers `HEAD` as it answers `GET`, without the body. ### Web Interface -- `GET /` - Home page (HTML): headline figures, and lookups by AS number and by - IP address +- `GET /` - Home page (HTML): headline figures, lookups by AS number and by IP + address, and a search of AS names - `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 + its peers. An AS that only the AS list built into RouteWatch knows gets a page + with its handle and description and no prefixes. - `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 @@ -93,9 +94,22 @@ 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 + apart, each with when its newest route was updated. An AS that only the AS + list built into RouteWatch knows has its handle and description and no + prefixes. Its answer has no `first_seen` or `last_seen`. - `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 does not hold. 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 @@ -106,12 +120,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 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 +mask length out of range for its IP version. An AS number that is in neither the +database nor the AS list built into RouteWatch answers 404, as does a prefix +with no live route. 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`, diff --git a/TODO.md b/TODO.md index a4f5e9a..e18ccea 100644 --- a/TODO.md +++ b/TODO.md @@ -33,6 +33,11 @@ tested. # Completed Steps +- 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. An AS page answers for an AS + that only the AS list built into RouteWatch knows, with no 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) diff --git a/internal/database/database.go b/internal/database/database.go index caa7f69..e16fa22 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -1748,6 +1748,10 @@ 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. +// +// An AS that only the AS info lookup knows comes with its handle and +// description, and without prefixes or seen times. An AS that neither the +// database nor the lookup knows is ErrNoRoute. func (d *Database) GetASDetailsContext( ctx context.Context, asn int, ) (*ASN, []LiveRoute, error) { @@ -1765,7 +1769,14 @@ func (d *Database) GetASDetailsContext( &asnInfo.LastSeen) if err != nil { if errors.Is(err, sql.ErrNoRows) { - return nil, nil, fmt.Errorf("%w: AS%d", ErrNoRoute, asn) + info, ok := asinfo.Get(asn) + if !ok { + return nil, nil, fmt.Errorf("%w: AS%d", ErrNoRoute, asn) + } + + return &ASN{ + ASN: asn, Handle: info.Handle, Description: info.Description, + }, nil, nil } return nil, nil, fmt.Errorf("failed to query AS: %w", err) diff --git a/internal/database/database_internal_test.go b/internal/database/database_internal_test.go index 2f8f242..09214ce 100644 --- a/internal/database/database_internal_test.go +++ b/internal/database/database_internal_test.go @@ -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,46 @@ func TestGetASDetailsListsEachPrefixOnce(t *testing.T) { } } +// TestGetASDetailsOfASNotInDatabase asks an empty database for an AS that the +// AS info lookup knows, and gets its handle and description with no prefixes +// and no seen times. An AS that the lookup does not know is 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() }() + + const knownASN, unknownASN = 15169, 4200000000 + + info, ok := asinfo.Get(knownASN) + if !ok { + t.Fatalf("AS%d is not in the AS info data", knownASN) + } + + got, prefixes, err := db.GetASDetails(knownASN) + if err != nil { + t.Fatalf("GetASDetails(%d): %v", knownASN, err) + } + + want := ASN{ASN: knownASN, Handle: info.Handle, Description: info.Description} + if *got != want || len(prefixes) != 0 { + t.Errorf("GetASDetails(%d) = %+v with prefixes %+v, want %+v and none", + knownASN, *got, prefixes, want) + } + + if _, ok := asinfo.Get(unknownASN); ok { + t.Fatalf("AS%d is in the AS info data", unknownASN) + } + + _, _, err = db.GetASDetails(unknownASN) + if !errors.Is(err, ErrNoRoute) { + t.Errorf("GetASDetails(%d): error %v, want %v", unknownASN, 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 diff --git a/internal/database/models.go b/internal/database/models.go index 8797ae7..f09a1e6 100644 --- a/internal/database/models.go +++ b/internal/database/models.go @@ -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 the + // database does not hold. + FirstSeen time.Time `json:"first_seen,omitzero"` + LastSeen time.Time `json:"last_seen,omitzero"` WHOISUpdatedAt *time.Time `json:"whois_updated_at,omitempty"` } diff --git a/internal/server/handlers.go b/internal/server/handlers.go index 2132c0c..fb6b527 100644 --- a/internal/server/handlers.go +++ b/internal/server/handlers.go @@ -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) { diff --git a/internal/server/handlers_internal_test.go b/internal/server/handlers_internal_test.go index a734953..184b535 100644 --- a/internal/server/handlers_internal_test.go +++ b/internal/server/handlers_internal_test.go @@ -1166,6 +1166,303 @@ 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. builtInOnlyASN is in the AS list +// built into asinfo but not in the database. unknownASN is in neither. +const ( + storedASN = 64500 + builtInOnlyASN = 15169 + unknownASN = 4200000000 +) + +// zeroDate is how a page writes a time that was never set. +const zeroDate = "0001-01-01" + +// newServerWithStoredAS returns newServerWithTestRoutes's server with storedASN +// added to the database. It fails the test when the AS list built into asinfo +// lacks builtInOnlyASN or holds unknownASN. +func newServerWithStoredAS(t *testing.T) *Server { + t.Helper() + + if _, ok := asinfo.Get(builtInOnlyASN); !ok { + t.Fatalf("AS%d is not in the AS info data", builtInOnlyASN) + } + + 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 an AS in the database, +// one only the built-in AS list knows, and one in neither. The first two answer +// 200 with the handle and description from that list and their prefixes, as +// lists even when empty. Only the AS in the database has a first_seen time. The +// third answers 404. Before the fix the second answered 404, so a result of the +// AS name search could lead nowhere +// (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 int + wantFirstSeen bool + }{ + {storedASN, http.StatusOK, 1, true}, + {builtInOnlyASN, http.StatusOK, 0, false}, + {unknownASN, http.StatusNotFound, 0, 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) + } + + gotPrefixes := len(body.Data.IPv4Prefixes) + len(body.Data.IPv6Prefixes) + if gotPrefixes != tt.wantPrefixes { + t.Errorf("%s: %d prefixes, want %d", 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 an AS in the database, one only +// the built-in AS list knows, and one in neither. The first two answer 200 with +// the handle and description from that list. The first lists its prefix, and +// the second says the AS announces none. Neither shows a date that was never +// set. The third answers 404. Before the fix the second answered 404, so a +// result of the AS name search led nowhere +// (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 + }{ + {storedASN, http.StatusOK, testPrefixV4}, + {builtInOnlyASN, http.StatusOK, "No prefixes announced by this AS"}, + {unknownASN, http.StatusNotFound, ""}, + } + + 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) + } + } +} + // settledGoroutineCount lets transient goroutines finish, then reports the // current count. func settledGoroutineCount() int { diff --git a/internal/server/routes.go b/internal/server/routes.go index f995c22..096e092 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -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 diff --git a/internal/server/server.go b/internal/server/server.go index 0d97002..58e9ffb 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -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 ( diff --git a/internal/templates/as_detail.html b/internal/templates/as_detail.html index 3f16760..8f6c782 100644 --- a/internal/templates/as_detail.html +++ b/internal/templates/as_detail.html @@ -235,7 +235,7 @@
First Seen
-
{{.ASN.FirstSeen.Format "2006-01-02"}}
+
{{if .ASN.FirstSeen.IsZero}}Never{{else}}{{.ASN.FirstSeen.Format "2006-01-02"}}{{end}}
diff --git a/internal/templates/index.html b/internal/templates/index.html index d2bc5c2..771d65c 100644 --- a/internal/templates/index.html +++ b/internal/templates/index.html @@ -174,6 +174,38 @@ #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 { @@ -306,7 +338,7 @@ -

Search for autonomous systems by organization name

+

Search for autonomous systems by organization name or handle

@@ -387,24 +419,82 @@ } }); - // AS name search + // 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); + } + } + document.getElementById('asname-form').addEventListener('submit', function(e) { e.preventDefault(); const query = document.getElementById('asname-input').value.trim(); if (!query) return; - const resultsDiv = document.getElementById('asname-results'); - resultsDiv.innerHTML = '

Searching...

'; - - // 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; + // 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; return; } - // Show a message that server-side search is coming - resultsDiv.innerHTML = '

AS name search coming soon. For now, try an AS number.

'; + 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'); + }); }); // IP lookup diff --git a/pkg/asinfo/asinfo.go b/pkg/asinfo/asinfo.go index 372f2e1..5283527 100644 --- a/pkg/asinfo/asinfo.go +++ b/pkg/asinfo/asinfo.go @@ -2,10 +2,13 @@ package asinfo import ( "bytes" + "cmp" "compress/gzip" _ "embed" "encoding/json" "io" + "slices" + "strings" "sync" ) @@ -129,36 +132,32 @@ func All() []Info { return result } -// Search searches for AS entries where the handle or description contains the query -// This is a simple case-sensitive substring search +// Search returns the AS entries whose handle or description contains query, +// ignoring case, in AS number order. An empty query matches nothing. func Search(query string) []Info { once.Do(initRegistry) + if query == "" { + return nil + } + + query = strings.ToLower(query) + defaultRegistry.mu.RLock() defer defaultRegistry.mu.RUnlock() var results []Info for _, info := range defaultRegistry.byASN { - if contains(info.Handle, query) || contains(info.Description, query) { + if strings.Contains(strings.ToLower(info.Handle), query) || + strings.Contains(strings.ToLower(info.Description), query) { results = append(results, *info) } } + slices.SortFunc(results, func(a, b Info) int { + return cmp.Compare(a.ASN, b.ASN) + }) + 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 -} diff --git a/pkg/asinfo/asinfo_test.go b/pkg/asinfo/asinfo_test.go index 3dabfa6..793429f 100644 --- a/pkg/asinfo/asinfo_test.go +++ b/pkg/asinfo/asinfo_test.go @@ -1,6 +1,7 @@ package asinfo_test import ( + "slices" "strings" "testing" @@ -140,11 +141,6 @@ func TestSearch(t *testing.T) { query: "University", wantMin: 10, }, - { - name: "Empty search", - query: "", - wantMin: 0, - }, { name: "Non-existent org", query: "XYZABC123456", @@ -162,20 +158,65 @@ func TestSearch(t *testing.T) { tt.query, len(results), tt.wantMin) } - // 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) - } + // 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) } } }) } } +// TestSearchEmptyQueryMatchesNothing expects an empty query to match no AS, +// although every handle contains the empty string. +func TestSearchEmptyQueryMatchesNothing(t *testing.T) { + t.Parallel() + + results := asinfo.Search("") + if len(results) != 0 { + t.Errorf(`Search("") returned %d results, want none`, len(results)) + } +} + +// 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."} { + if !slices.ContainsFunc(asinfo.Search(query), 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) + + 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) + } + } +} + func TestDataIntegrity(t *testing.T) { t.Parallel() diff --git a/pkg/asinfo/doc.go b/pkg/asinfo/doc.go index c2b4074..814f7ef 100644 --- a/pkg/asinfo/doc.go +++ b/pkg/asinfo/doc.go @@ -16,8 +16,9 @@ Basic usage: // Get just the description desc := asinfo.GetDescription(15169) // "Google LLC" - // Search for AS entries - results := asinfo.Search("University") + // Search the handles and descriptions, ignoring case; the matches come + // in AS number order + results := asinfo.Search("university") for _, as := range results { fmt.Printf("AS%d: %s\n", as.ASN, as.Description) } diff --git a/pkg/asinfo/example_test.go b/pkg/asinfo/example_test.go index 9b6f831..e7557d3 100644 --- a/pkg/asinfo/example_test.go +++ b/pkg/asinfo/example_test.go @@ -21,7 +21,7 @@ func ExampleGetDescription() { } func ExampleSearch() { - results := asinfo.Search("MIT-GATEWAY") + results := asinfo.Search("mit-gateway") for _, info := range results { fmt.Printf("AS%d: %s - %s\n", info.ASN, info.Handle, info.Description) }