1 Commits
Author SHA1 Message Date
sneak 50283d9e9e Make the home page's AS name search work (closes #151)
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. The answer holds the query, the number of matches
and the first 50 of them; an empty q answers 400. asinfo.Search now
ignores case, uses strings.Contains instead of its own substring loop,
and returns its matches in AS number order. A number, or AS and a
number, typed into the card still opens that AS's page. The card builds
the list with textContent, since the names come from outside data.

Model: opus-5-5
2026-10-26 20:21:14 +00:00
10 changed files with 27 additions and 274 deletions
+13 -16
View File
@@ -64,8 +64,7 @@ Every route answers `HEAD` as it answers `GET`, without the body.
database answers and the RIS Live feed is connected, otherwise 503; database answers and the RIS Live feed is connected, otherwise 503;
`data.status` is `ok` or `error` and `data.checks` says which check failed `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 - `GET /as/{asn}` - AS detail page (HTML): the prefixes the AS originates and
its peers. An AS that only the AS list built into RouteWatch knows gets a page its peers
with its handle and description and no prefixes.
- `GET /prefix/{prefix}/{length}` - Prefix detail page (HTML), such as - `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 `/prefix/192.0.2.0/24`: every live route for the prefix, one per peer, and its
origin ASes origin ASes
@@ -94,9 +93,7 @@ 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 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. 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 - `GET /api/v1/as/{asn}` - The AS and the prefixes it originates, IPv4 and IPv6
apart, each with when its newest route was updated. An AS that only the AS apart, each with when its newest route was updated
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 - `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` 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 - `GET /api/v1/search/as?q={text}` - Finds the ASes whose handle or description
@@ -106,10 +103,11 @@ Every route answers `HEAD` as it answers `GET`, without the body.
- `results`: the first 50 of them in AS number order, each with `asn`, - `results`: the first 50 of them in AS number order, each with `asn`,
`handle` and `description` `handle` and `description`
The search reads the AS list built into RouteWatch, not the database. The The search reads the AS list built into RouteWatch, taken from
list comes from [ipverse/asn-info](https://github.com/ipverse/asn-info). A [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. A `q` that is match can therefore be an AS that the database does not hold. Its
missing, empty or only spaces answers 400. `/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 A JSON answer is `{"status": "ok", "data": ...}`, or for an error
`{"status": "error", "error": {"msg": ..., "code": ...}}`, and carries an `{"status": "error", "error": {"msg": ..., "code": ...}}`, and carries an
@@ -120,13 +118,12 @@ 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, 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`. 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 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 number that is in neither the mask length out of range for its IP version. An AS the database does not hold,
database nor the AS list built into RouteWatch answers 404, as does a prefix or a prefix with no live route, answers 404. The HTML pages give these errors as
with no live route. The HTML pages give these errors as plain text. Under plain text. Under `/api/v1` and `/ip/`, a path no route matches answers 404 with
`/api/v1` and `/ip/`, a path no route matches answers 404 with the message the message `Not found`, and `POST`, `PUT`, `PATCH`, `DELETE`, `OPTIONS`,
`Not found`, and `POST`, `PUT`, `PATCH`, `DELETE`, `OPTIONS`, `TRACE` or `TRACE` or `CONNECT` answers 405 with `Method not allowed`. A 500 answer says
`CONNECT` answers 405 with `Method not allowed`. A 500 answer says only only `Internal server error`, and a host name that does not resolve only
`Internal server error`, and a host name that does not resolve only
`could not resolve host name`; the detail goes to the log. `could not resolve host name`; the detail goes to the log.
Every answer carries `Strict-Transport-Security`, `Content-Security-Policy`, Every answer carries `Strict-Transport-Security`, `Content-Security-Policy`,
+1 -3
View File
@@ -35,9 +35,7 @@ tested.
- 2026-10-26: the home page's AS name search lists the ASes whose handle or - 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 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 `/api/v1/search/as`; the search ignores case (closes #151)
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, - 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 "Database Schema" matches `schema.sql`, and the rest of the README matches the
code; this file brought up to date (closes #72) code; this file brought up to date (closes #72)
+1 -12
View File
@@ -1748,10 +1748,6 @@ func (d *Database) GetASDetails(asn int) (*ASN, []LiveRoute, error) {
// GetASDetailsContext returns the AS and the prefixes it originates, the IPv4 // 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 // ones first, each once with the time of its newest route. It makes two index
// searches per prefix, however many peers carry the prefix. // 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( func (d *Database) GetASDetailsContext(
ctx context.Context, asn int, ctx context.Context, asn int,
) (*ASN, []LiveRoute, error) { ) (*ASN, []LiveRoute, error) {
@@ -1769,14 +1765,7 @@ func (d *Database) GetASDetailsContext(
&asnInfo.LastSeen) &asnInfo.LastSeen)
if err != nil { if err != nil {
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
info, ok := asinfo.Get(asn) return nil, nil, fmt.Errorf("%w: AS%d", ErrNoRoute, 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) return nil, nil, fmt.Errorf("failed to query AS: %w", err)
@@ -20,7 +20,6 @@ import (
"git.eeqj.de/sneak/routewatch/internal/config" "git.eeqj.de/sneak/routewatch/internal/config"
"git.eeqj.de/sneak/routewatch/internal/logger" "git.eeqj.de/sneak/routewatch/internal/logger"
"git.eeqj.de/sneak/routewatch/pkg/asinfo"
"github.com/mattn/go-sqlite3" "github.com/mattn/go-sqlite3"
) )
@@ -1700,46 +1699,6 @@ 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 // TestASPrefixQueriesUseCoveringIndexes checks the plan of each query that
// lists the prefixes an AS originates: a search of a covering index and no // 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 // 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"` RIRRegDate *time.Time `json:"rir_registration_date,omitempty"`
RIRLastMod *time.Time `json:"rir_last_modified,omitempty"` RIRLastMod *time.Time `json:"rir_last_modified,omitempty"`
WHOISRaw string `json:"whois_raw,omitempty"` WHOISRaw string `json:"whois_raw,omitempty"`
// Timestamps. They are zero, and left out of JSON, for an AS that the // Timestamps
// database does not hold. FirstSeen time.Time `json:"first_seen"`
FirstSeen time.Time `json:"first_seen,omitzero"` LastSeen time.Time `json:"last_seen"`
LastSeen time.Time `json:"last_seen,omitzero"`
WHOISUpdatedAt *time.Time `json:"whois_updated_at,omitempty"` WHOISUpdatedAt *time.Time `json:"whois_updated_at,omitempty"`
} }
+2 -3
View File
@@ -1009,12 +1009,11 @@ func (s *Server) handleASDetailJSON() http.HandlerFunc {
} }
} }
// splitRoutesByIPVersion groups routes into the IPv4 and the IPv6 ones. Neither // splitRoutesByIPVersion groups routes into the IPv4 and the IPv6 ones.
// group is nil, so that a JSON answer holds [] for an empty one and not null.
func splitRoutesByIPVersion( func splitRoutesByIPVersion(
routes []database.LiveRoute, routes []database.LiveRoute,
) ([]database.LiveRoute, []database.LiveRoute) { ) ([]database.LiveRoute, []database.LiveRoute) {
ipv4Routes, ipv6Routes := []database.LiveRoute{}, []database.LiveRoute{} var ipv4Routes, ipv6Routes []database.LiveRoute
for _, route := range routes { for _, route := range routes {
if route.IPVersion == ipVersionV4 { if route.IPVersion == ipVersionV4 {
-178
View File
@@ -1285,184 +1285,6 @@ func TestASSearchShowsFirstMatchesAndTotal(t *testing.T) {
} }
} }
// 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 // settledGoroutineCount lets transient goroutines finish, then reports the
// current count. // current count.
func settledGoroutineCount() int { func settledGoroutineCount() int {
+1 -5
View File
@@ -1,8 +1,4 @@
// Package server serves RouteWatch's web pages and JSON API: // Package server provides HTTP endpoints for status monitoring and statistics
// - 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 package server
import ( import (
+1 -1
View File
@@ -235,7 +235,7 @@
</div> </div>
<div class="info-card"> <div class="info-card">
<div class="info-label">First Seen</div> <div class="info-label">First Seen</div>
<div class="info-value">{{if .ASN.FirstSeen.IsZero}}Never{{else}}{{.ASN.FirstSeen.Format "2006-01-02"}}{{end}}</div> <div class="info-value">{{.ASN.FirstSeen.Format "2006-01-02"}}</div>
</div> </div>
</div> </div>
+5 -11
View File
@@ -141,6 +141,11 @@ func TestSearch(t *testing.T) {
query: "University", query: "University",
wantMin: 10, wantMin: 10,
}, },
{
name: "Empty search",
query: "",
wantMin: 0,
},
{ {
name: "Non-existent org", name: "Non-existent org",
query: "XYZABC123456", query: "XYZABC123456",
@@ -171,17 +176,6 @@ func TestSearch(t *testing.T) {
} }
} }
// 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 // TestSearchIgnoresCase searches for Cloudflare's handle in lower case and its
// description in upper case, and expects both searches to find AS13335. // description in upper case, and expects both searches to find AS13335.
func TestSearchIgnoresCase(t *testing.T) { func TestSearchIgnoresCase(t *testing.T) {