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.
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
This commit is contained in:
@@ -56,15 +56,16 @@ Every route answers `HEAD` as it answers `GET`, without the body.
|
|||||||
|
|
||||||
### Web Interface
|
### Web Interface
|
||||||
|
|
||||||
- `GET /` - Home page (HTML): headline figures, and lookups by AS number and by
|
- `GET /` - Home page (HTML): headline figures, lookups by AS number and by IP
|
||||||
IP address
|
address, and a search of AS names
|
||||||
- `GET /status` - HTML status dashboard, which reads `/api/v1/stats`
|
- `GET /status` - HTML status dashboard, which reads `/api/v1/stats`
|
||||||
- `GET /status.json` - JSON statistics: feed, memory, database and route figures
|
- `GET /status.json` - JSON statistics: feed, memory, database and route figures
|
||||||
- `GET /.well-known/healthcheck.json` - Health check (JSON): 200 when the
|
- `GET /.well-known/healthcheck.json` - Health check (JSON): 200 when the
|
||||||
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
|
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
|
- `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
|
||||||
@@ -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
|
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
|
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
|
- `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
|
||||||
|
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
|
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
|
||||||
@@ -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,
|
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 the database does not hold,
|
mask length out of range for its IP version. An AS number that is in neither the
|
||||||
or a prefix with no live route, answers 404. The HTML pages give these errors as
|
database nor the AS list built into RouteWatch answers 404, as does a prefix
|
||||||
plain text. Under `/api/v1` and `/ip/`, a path no route matches answers 404 with
|
with no live route. The HTML pages give these errors as plain text. Under
|
||||||
the message `Not found`, and `POST`, `PUT`, `PATCH`, `DELETE`, `OPTIONS`,
|
`/api/v1` and `/ip/`, a path no route matches answers 404 with the message
|
||||||
`TRACE` or `CONNECT` answers 405 with `Method not allowed`. A 500 answer says
|
`Not found`, and `POST`, `PUT`, `PATCH`, `DELETE`, `OPTIONS`, `TRACE` or
|
||||||
only `Internal server error`, and a host name that does not resolve only
|
`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.
|
`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`,
|
||||||
|
|||||||
@@ -33,6 +33,11 @@ tested.
|
|||||||
|
|
||||||
# Completed Steps
|
# 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,
|
- 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)
|
||||||
|
|||||||
@@ -1748,6 +1748,10 @@ 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) {
|
||||||
@@ -1765,9 +1769,16 @@ 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)
|
||||||
|
if !ok {
|
||||||
return nil, nil, fmt.Errorf("%w: AS%d", ErrNoRoute, asn)
|
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,6 +20,7 @@ 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"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -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
|
// 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
|
||||||
|
|||||||
@@ -28,9 +28,10 @@ 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
|
// Timestamps. They are zero, and left out of JSON, for an AS that the
|
||||||
FirstSeen time.Time `json:"first_seen"`
|
// database does not hold.
|
||||||
LastSeen time.Time `json:"last_seen"`
|
FirstSeen time.Time `json:"first_seen,omitzero"`
|
||||||
|
LastSeen time.Time `json:"last_seen,omitzero"`
|
||||||
WHOISUpdatedAt *time.Time `json:"whois_updated_at,omitempty"`
|
WHOISUpdatedAt *time.Time `json:"whois_updated_at,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -60,6 +60,14 @@ const (
|
|||||||
hostNotResolvedMessage = "could not resolve host name"
|
hostNotResolvedMessage = "could not resolve host name"
|
||||||
hostNotPublicMessage = "host name has no public address"
|
hostNotPublicMessage = "host name has no public address"
|
||||||
tooManyTargetsMessage = "Too many addresses or host names"
|
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.
|
// 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(
|
func splitRoutesByIPVersion(
|
||||||
routes []database.LiveRoute,
|
routes []database.LiveRoute,
|
||||||
) ([]database.LiveRoute, []database.LiveRoute) {
|
) ([]database.LiveRoute, []database.LiveRoute) {
|
||||||
var ipv4Routes, ipv6Routes []database.LiveRoute
|
ipv4Routes, ipv6Routes := []database.LiveRoute{}, []database.LiveRoute{}
|
||||||
|
|
||||||
for _, route := range routes {
|
for _, route := range routes {
|
||||||
if route.IPVersion == ipVersionV4 {
|
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
|
// handleASDetail returns a handler that serves the AS detail HTML page
|
||||||
func (s *Server) handleASDetail() http.HandlerFunc {
|
func (s *Server) handleASDetail() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@@ -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
|
// settledGoroutineCount lets transient goroutines finish, then reports the
|
||||||
// current count.
|
// current count.
|
||||||
func settledGoroutineCount() int {
|
func settledGoroutineCount() int {
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ func (s *Server) setupRoutes() {
|
|||||||
r.Get("/ip/{ip}", s.handleIPLookup())
|
r.Get("/ip/{ip}", s.handleIPLookup())
|
||||||
r.Get("/as/{asn}", s.handleASDetailJSON())
|
r.Get("/as/{asn}", s.handleASDetailJSON())
|
||||||
r.Get("/prefix/{prefix}/{len}", s.handlePrefixDetailJSON())
|
r.Get("/prefix/{prefix}/{len}", s.handlePrefixDetailJSON())
|
||||||
|
r.Get("/search/as", s.handleASSearch())
|
||||||
})
|
})
|
||||||
|
|
||||||
s.router = r
|
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
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|||||||
@@ -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">{{.ASN.FirstSeen.Format "2006-01-02"}}</div>
|
<div class="info-value">{{if .ASN.FirstSeen.IsZero}}Never{{else}}{{.ASN.FirstSeen.Format "2006-01-02"}}{{end}}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
+101
-11
@@ -174,6 +174,38 @@
|
|||||||
#asname-results .search-message {
|
#asname-results .search-message {
|
||||||
font-size: 13px;
|
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 Lookup result */
|
||||||
.ip-result {
|
.ip-result {
|
||||||
@@ -306,7 +338,7 @@
|
|||||||
<input type="text" id="asname-input" placeholder="e.g., Google, Cloudflare" autocomplete="off">
|
<input type="text" id="asname-input" placeholder="e.g., Google, Cloudflare" autocomplete="off">
|
||||||
<button type="submit">Search</button>
|
<button type="submit">Search</button>
|
||||||
</form>
|
</form>
|
||||||
<p class="search-hint">Search for autonomous systems by organization name</p>
|
<p class="search-hint">Search for autonomous systems by organization name or handle</p>
|
||||||
<div id="asname-results"></div>
|
<div id="asname-results"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -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) {
|
document.getElementById('asname-form').addEventListener('submit', function(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const query = document.getElementById('asname-input').value.trim();
|
const query = document.getElementById('asname-input').value.trim();
|
||||||
if (!query) return;
|
if (!query) return;
|
||||||
|
|
||||||
const resultsDiv = document.getElementById('asname-results');
|
// A number, or AS and a number, opens that AS's page
|
||||||
resultsDiv.innerHTML = '<p class="loading">Searching...</p>';
|
const asn = query.replace(/^AS/i, '');
|
||||||
|
if (/^\d+$/.test(asn)) {
|
||||||
// Use a simple client-side search against the asinfo data
|
window.location.href = '/as/' + asn;
|
||||||
// For now, redirect to AS page if it looks like an ASN
|
|
||||||
if (/^\d+$/.test(query)) {
|
|
||||||
window.location.href = '/as/' + query;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show a message that server-side search is coming
|
showASNameMessage('Searching...', 'loading');
|
||||||
resultsDiv.innerHTML = '<p class="search-message">AS name search coming soon. For now, try an AS number.</p>';
|
|
||||||
|
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
|
// IP lookup
|
||||||
|
|||||||
+17
-18
@@ -2,10 +2,13 @@ package asinfo
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"cmp"
|
||||||
"compress/gzip"
|
"compress/gzip"
|
||||||
_ "embed"
|
_ "embed"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"io"
|
"io"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -129,36 +132,32 @@ func All() []Info {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// Search searches for AS entries where the handle or description contains the query
|
// Search returns the AS entries whose handle or description contains query,
|
||||||
// This is a simple case-sensitive substring search
|
// ignoring case, in AS number order. An empty query matches nothing.
|
||||||
func Search(query string) []Info {
|
func Search(query string) []Info {
|
||||||
once.Do(initRegistry)
|
once.Do(initRegistry)
|
||||||
|
|
||||||
|
if query == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
query = strings.ToLower(query)
|
||||||
|
|
||||||
defaultRegistry.mu.RLock()
|
defaultRegistry.mu.RLock()
|
||||||
defer defaultRegistry.mu.RUnlock()
|
defer defaultRegistry.mu.RUnlock()
|
||||||
|
|
||||||
var results []Info
|
var results []Info
|
||||||
|
|
||||||
for _, info := range defaultRegistry.byASN {
|
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)
|
results = append(results, *info)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
slices.SortFunc(results, func(a, b Info) int {
|
||||||
|
return cmp.Compare(a.ASN, b.ASN)
|
||||||
|
})
|
||||||
|
|
||||||
return results
|
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
|
|
||||||
}
|
|
||||||
|
|||||||
+51
-10
@@ -1,6 +1,7 @@
|
|||||||
package asinfo_test
|
package asinfo_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -140,11 +141,6 @@ 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",
|
||||||
@@ -162,20 +158,65 @@ func TestSearch(t *testing.T) {
|
|||||||
tt.query, len(results), tt.wantMin)
|
tt.query, len(results), tt.wantMin)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify all results contain the query
|
// Verify all results contain the query, ignoring case
|
||||||
if tt.query != "" {
|
query := strings.ToLower(tt.query)
|
||||||
for _, r := range results {
|
for _, r := range results {
|
||||||
if !strings.Contains(r.Handle, tt.query) &&
|
if !strings.Contains(strings.ToLower(r.Handle), query) &&
|
||||||
!strings.Contains(r.Description, tt.query) {
|
!strings.Contains(strings.ToLower(r.Description), query) {
|
||||||
t.Errorf("Search result ASN %d doesn't contain query %q",
|
t.Errorf("Search result ASN %d doesn't contain query %q",
|
||||||
r.ASN, tt.query)
|
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) {
|
func TestDataIntegrity(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
+3
-2
@@ -16,8 +16,9 @@ Basic usage:
|
|||||||
// Get just the description
|
// Get just the description
|
||||||
desc := asinfo.GetDescription(15169) // "Google LLC"
|
desc := asinfo.GetDescription(15169) // "Google LLC"
|
||||||
|
|
||||||
// Search for AS entries
|
// Search the handles and descriptions, ignoring case; the matches come
|
||||||
results := asinfo.Search("University")
|
// in AS number order
|
||||||
|
results := asinfo.Search("university")
|
||||||
for _, as := range results {
|
for _, as := range results {
|
||||||
fmt.Printf("AS%d: %s\n", as.ASN, as.Description)
|
fmt.Printf("AS%d: %s\n", as.ASN, as.Description)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ func ExampleGetDescription() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func ExampleSearch() {
|
func ExampleSearch() {
|
||||||
results := asinfo.Search("MIT-GATEWAY")
|
results := asinfo.Search("mit-gateway")
|
||||||
for _, info := range results {
|
for _, info := range results {
|
||||||
fmt.Printf("AS%d: %s - %s\n", info.ASN, info.Handle, info.Description)
|
fmt.Printf("AS%d: %s - %s\n", info.ASN, info.Handle, info.Description)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user