diff --git a/README.md b/README.md index 7c3629f..b235621 100644 --- a/README.md +++ b/README.md @@ -56,8 +56,8 @@ 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 @@ -96,6 +96,18 @@ Every route answers `HEAD` as it answers `GET`, without the body. 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, taken from + [ipverse/asn-info](https://github.com/ipverse/asn-info), not the database. A + match can therefore be an AS that the database does not hold. Its + `/as/{asn}` and `/api/v1/as/{asn}` then answer 404. 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 diff --git a/TODO.md b/TODO.md index a4f5e9a..3385ead 100644 --- a/TODO.md +++ b/TODO.md @@ -33,6 +33,9 @@ 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 (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/server/handlers.go b/internal/server/handlers.go index 2132c0c..b647f9e 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. @@ -1105,6 +1113,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..c022869 100644 --- a/internal/server/handlers_internal_test.go +++ b/internal/server/handlers_internal_test.go @@ -1166,6 +1166,125 @@ 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]) + } +} + // 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/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..2fc22b0 100644 --- a/pkg/asinfo/asinfo_test.go +++ b/pkg/asinfo/asinfo_test.go @@ -1,6 +1,7 @@ package asinfo_test import ( + "slices" "strings" "testing" @@ -162,20 +163,54 @@ 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) } } }) } } +// 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) }