Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca68b8f605 |
@@ -64,9 +64,8 @@ Every route answers `HEAD` as it answers `GET`, without the body.
|
||||
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. When the database has no record of the AS, its handle and
|
||||
description come from the AS list built into RouteWatch. The page then shows
|
||||
no first-seen date.
|
||||
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
|
||||
@@ -95,9 +94,9 @@ 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. When the database has no
|
||||
record of the AS, its handle and description come from the AS list built into
|
||||
RouteWatch. The answer then has no `first_seen` or `last_seen`.
|
||||
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
|
||||
@@ -109,8 +108,8 @@ Every route answers `HEAD` as it answers `GET`, without the body.
|
||||
|
||||
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 has no record of. A `q` that
|
||||
is missing, empty or only spaces answers 400.
|
||||
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
|
||||
@@ -121,15 +120,14 @@ 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 answers 404 when it is not in
|
||||
the AS list built into RouteWatch and the database has neither a record of it
|
||||
nor a live route it originates. A prefix with no live route answers 404 too. 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.
|
||||
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`,
|
||||
`X-Frame-Options`, `X-Content-Type-Options`, `Referrer-Policy` and
|
||||
|
||||
@@ -35,9 +35,9 @@ tested.
|
||||
|
||||
- 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. When the database has no record
|
||||
of an AS, its page takes the handle and description from the AS list built
|
||||
into RouteWatch and still lists the AS's prefixes (closes #151)
|
||||
`/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)
|
||||
|
||||
@@ -1749,10 +1749,9 @@ func (d *Database) GetASDetails(asn int) (*ASN, []LiveRoute, error) {
|
||||
// ones first, each once with the time of its newest route. It makes two index
|
||||
// searches per prefix, however many peers carry the prefix.
|
||||
//
|
||||
// The routes of an AS can be stored before its record in the asns table. When
|
||||
// the AS has no record, its handle and description come from the AS info
|
||||
// lookup, and it has no seen times. An AS with no record, no lookup entry and
|
||||
// no prefix is ErrNoRoute.
|
||||
// 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) {
|
||||
@@ -1768,28 +1767,24 @@ func (d *Database) GetASDetailsContext(
|
||||
asn,
|
||||
).Scan(&asnInfo.ASN, &handle, &description, &asnInfo.FirstSeen,
|
||||
&asnInfo.LastSeen)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
info, ok := asinfo.Get(asn)
|
||||
if !ok {
|
||||
return nil, nil, fmt.Errorf("%w: AS%d", ErrNoRoute, asn)
|
||||
}
|
||||
|
||||
// known is whether the asns table or the AS info lookup holds the AS.
|
||||
known := true
|
||||
|
||||
switch {
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
asnInfo.ASN = asn
|
||||
|
||||
info, ok := asinfo.Get(asn)
|
||||
if ok {
|
||||
asnInfo.Handle = info.Handle
|
||||
asnInfo.Description = info.Description
|
||||
return &ASN{
|
||||
ASN: asn, Handle: info.Handle, Description: info.Description,
|
||||
}, nil, nil
|
||||
}
|
||||
|
||||
known = ok
|
||||
case err != nil:
|
||||
return nil, nil, fmt.Errorf("failed to query AS: %w", err)
|
||||
default:
|
||||
asnInfo.Handle = handle.String
|
||||
asnInfo.Description = description.String
|
||||
}
|
||||
|
||||
asnInfo.Handle = handle.String
|
||||
asnInfo.Description = description.String
|
||||
|
||||
allPrefixes, err := d.originPrefixes(ctx, asn, ipVersionV4)
|
||||
if err != nil {
|
||||
return &asnInfo, nil, err
|
||||
@@ -1800,13 +1795,7 @@ func (d *Database) GetASDetailsContext(
|
||||
return &asnInfo, allPrefixes, err
|
||||
}
|
||||
|
||||
allPrefixes = append(allPrefixes, prefixesV6...)
|
||||
|
||||
if !known && len(allPrefixes) == 0 {
|
||||
return nil, nil, fmt.Errorf("%w: AS%d", ErrNoRoute, asn)
|
||||
}
|
||||
|
||||
return &asnInfo, allPrefixes, nil
|
||||
return &asnInfo, append(allPrefixes, prefixesV6...), nil
|
||||
}
|
||||
|
||||
// ASPeer represents a peering relationship with another AS including handle,
|
||||
|
||||
@@ -1700,12 +1700,9 @@ func TestGetASDetailsListsEachPrefixOnce(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetASDetailsOfASNotInDatabase asks for ASes that the database has no
|
||||
// record of, some with stored routes. Each gets the prefixes of its routes and
|
||||
// no seen times. One that the AS info lookup knows gets its handle and
|
||||
// description from it. An AS with no record, no lookup entry and no route is
|
||||
// ErrNoRoute. Before the fix an AS with no record got no prefixes, although
|
||||
// its routes were stored.
|
||||
// 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()
|
||||
|
||||
@@ -1715,68 +1712,31 @@ func TestGetASDetailsOfASNotInDatabase(t *testing.T) {
|
||||
}
|
||||
defer func() { _ = db.Close() }()
|
||||
|
||||
ts := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
|
||||
const knownASN, unknownASN = 15169, 4200000000
|
||||
|
||||
tests := []struct {
|
||||
asn int
|
||||
listed bool // in the AS info lookup
|
||||
prefixes []string // of the routes stored for the AS
|
||||
}{
|
||||
{15169, true, []string{"192.0.2.0/24"}},
|
||||
{13335, true, nil},
|
||||
{4200000000, false, []string{"198.51.100.0/24"}},
|
||||
info, ok := asinfo.Get(knownASN)
|
||||
if !ok {
|
||||
t.Fatalf("AS%d is not in the AS info data", knownASN)
|
||||
}
|
||||
|
||||
var routes []*LiveRoute
|
||||
|
||||
for _, tt := range tests {
|
||||
if _, ok := asinfo.Get(tt.asn); ok != tt.listed {
|
||||
t.Fatalf("AS%d in the AS info data: %t, want %t", tt.asn, ok, tt.listed)
|
||||
}
|
||||
|
||||
for _, prefix := range tt.prefixes {
|
||||
routes = append(routes, mkV4Route(t, prefix, tt.asn, ts))
|
||||
}
|
||||
}
|
||||
|
||||
err = db.UpsertLiveRouteBatch(routes)
|
||||
got, prefixes, err := db.GetASDetails(knownASN)
|
||||
if err != nil {
|
||||
t.Fatalf("UpsertLiveRouteBatch: %v", err)
|
||||
t.Fatalf("GetASDetails(%d): %v", knownASN, err)
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
got, prefixes, err := db.GetASDetails(tt.asn)
|
||||
if err != nil {
|
||||
t.Errorf("GetASDetails(%d): %v", tt.asn, err)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
want := ASN{ASN: tt.asn}
|
||||
if info, ok := asinfo.Get(tt.asn); ok {
|
||||
want.Handle, want.Description = info.Handle, info.Description
|
||||
}
|
||||
|
||||
var gotPrefixes []string
|
||||
for _, route := range prefixes {
|
||||
gotPrefixes = append(gotPrefixes, route.Prefix)
|
||||
}
|
||||
|
||||
if *got != want || !slices.Equal(gotPrefixes, tt.prefixes) {
|
||||
t.Errorf("GetASDetails(%d) = %+v with prefixes %v, want %+v with %v",
|
||||
tt.asn, *got, gotPrefixes, want, tt.prefixes)
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
const absentASN = 4200000001 // no record, no lookup entry and no route
|
||||
|
||||
if _, ok := asinfo.Get(absentASN); ok {
|
||||
t.Fatalf("AS%d is in the AS info data", absentASN)
|
||||
if _, ok := asinfo.Get(unknownASN); ok {
|
||||
t.Fatalf("AS%d is in the AS info data", unknownASN)
|
||||
}
|
||||
|
||||
_, _, err = db.GetASDetails(absentASN)
|
||||
_, _, err = db.GetASDetails(unknownASN)
|
||||
if !errors.Is(err, ErrNoRoute) {
|
||||
t.Errorf("GetASDetails(%d): error %v, want %v", absentASN, err, ErrNoRoute)
|
||||
t.Errorf("GetASDetails(%d): error %v, want %v", unknownASN, err, ErrNoRoute)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -28,8 +28,8 @@ 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. They are zero, and left out of JSON, for an AS that has no
|
||||
// record in the asns table.
|
||||
// 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"`
|
||||
|
||||
@@ -26,7 +26,6 @@ import (
|
||||
// The prefix and peer addresses the prefix page tests store routes for.
|
||||
const (
|
||||
testPrefixV4 = "192.0.2.0/24"
|
||||
testPrefixV6 = "2001:db8::/32"
|
||||
testPeerV4 = "198.51.100.1"
|
||||
testPeerV6 = "2001:db8::1"
|
||||
)
|
||||
@@ -195,7 +194,7 @@ func TestStatsHandlersAnswerFromMemory(t *testing.T) {
|
||||
}
|
||||
|
||||
// newServerWithTestRoutes returns a server on a new database that holds a
|
||||
// route for testPrefixV4 and one for testPrefixV6.
|
||||
// route for testPrefixV4 and one for 2001:db8::/32.
|
||||
func newServerWithTestRoutes(t *testing.T) *Server {
|
||||
t.Helper()
|
||||
|
||||
@@ -215,7 +214,7 @@ func newServerWithTestRoutes(t *testing.T) *Server {
|
||||
NextHop: testPeerV4, LastUpdated: ts,
|
||||
},
|
||||
{
|
||||
Prefix: testPrefixV6, MaskLength: 32, IPVersion: 6,
|
||||
Prefix: "2001:db8::/32", MaskLength: 32, IPVersion: 6,
|
||||
OriginASN: 64501, PeerIP: testPeerV6, ASPath: []int{64501},
|
||||
NextHop: testPeerV6, LastUpdated: ts,
|
||||
},
|
||||
@@ -1287,13 +1286,10 @@ 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. routeOnlyASN originates the route
|
||||
// for testPrefixV6, but the database has no record of the AS itself, as when
|
||||
// a route is stored before its AS. builtInOnlyASN is in the AS list built into
|
||||
// asinfo but not in the database. unknownASN is in neither.
|
||||
// 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
|
||||
routeOnlyASN = 64501
|
||||
builtInOnlyASN = 15169
|
||||
unknownASN = 4200000000
|
||||
)
|
||||
@@ -1301,19 +1297,14 @@ const (
|
||||
// zeroDate is how a page writes a time that was never set.
|
||||
const zeroDate = "0001-01-01"
|
||||
|
||||
// firstSeenCard is the label of the AS page's first-seen date.
|
||||
const firstSeenCard = `<div class="info-label">First Seen</div>`
|
||||
|
||||
// newServerWithStoredAS returns newServerWithTestRoutes's server with storedASN
|
||||
// added to the database. It fails the test when the AS list built into asinfo
|
||||
// lacks routeOnlyASN or builtInOnlyASN, or holds unknownASN.
|
||||
// lacks builtInOnlyASN or holds unknownASN.
|
||||
func newServerWithStoredAS(t *testing.T) *Server {
|
||||
t.Helper()
|
||||
|
||||
for _, asn := range []int{routeOnlyASN, builtInOnlyASN} {
|
||||
if _, ok := asinfo.Get(asn); !ok {
|
||||
t.Fatalf("AS%d is not in the AS info data", asn)
|
||||
}
|
||||
if _, ok := asinfo.Get(builtInOnlyASN); !ok {
|
||||
t.Fatalf("AS%d is not in the AS info data", builtInOnlyASN)
|
||||
}
|
||||
|
||||
if _, ok := asinfo.Get(unknownASN); ok {
|
||||
@@ -1330,13 +1321,13 @@ func newServerWithStoredAS(t *testing.T) *Server {
|
||||
return s
|
||||
}
|
||||
|
||||
// TestASDetailJSONAnswersBuiltInAS asks /api/v1/as/ for each of the ASes above.
|
||||
// All but unknownASN answer 200 with the handle and description from the
|
||||
// built-in AS list and their prefixes, as lists even when empty. Only storedASN
|
||||
// has a first_seen time. unknownASN answers 404. Before the fix builtInOnlyASN
|
||||
// answered 404, so a result of the AS name search could lead nowhere
|
||||
// (https://git.eeqj.de/sneak/routewatch/issues/151), and routeOnlyASN listed
|
||||
// no prefixes.
|
||||
// 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()
|
||||
|
||||
@@ -1345,13 +1336,12 @@ func TestASDetailJSONAnswersBuiltInAS(t *testing.T) {
|
||||
tests := []struct {
|
||||
asn int
|
||||
wantCode int
|
||||
wantPrefixes []string
|
||||
wantPrefixes int
|
||||
wantFirstSeen bool
|
||||
}{
|
||||
{storedASN, http.StatusOK, []string{testPrefixV4}, true},
|
||||
{routeOnlyASN, http.StatusOK, []string{testPrefixV6}, false},
|
||||
{builtInOnlyASN, http.StatusOK, nil, false},
|
||||
{unknownASN, http.StatusNotFound, nil, false},
|
||||
{storedASN, http.StatusOK, 1, true},
|
||||
{builtInOnlyASN, http.StatusOK, 0, false},
|
||||
{unknownASN, http.StatusNotFound, 0, false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -1402,15 +1392,9 @@ func TestASDetailJSONAnswersBuiltInAS(t *testing.T) {
|
||||
t.Errorf("%s: a prefix list is null; body %s", path, rec.Body)
|
||||
}
|
||||
|
||||
routes := slices.Concat(body.Data.IPv4Prefixes, body.Data.IPv6Prefixes)
|
||||
|
||||
gotPrefixes := make([]string, 0, len(routes))
|
||||
for _, route := range routes {
|
||||
gotPrefixes = append(gotPrefixes, route.Prefix)
|
||||
}
|
||||
|
||||
if !slices.Equal(gotPrefixes, tt.wantPrefixes) {
|
||||
t.Errorf("%s: prefixes %v, want %v", path, gotPrefixes, tt.wantPrefixes)
|
||||
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"`)
|
||||
@@ -1421,29 +1405,26 @@ func TestASDetailJSONAnswersBuiltInAS(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestASDetailPageShowsBuiltInAS asks /as/ for each of the ASes above. All but
|
||||
// unknownASN answer 200 with the handle and description from the built-in AS
|
||||
// list. storedASN and routeOnlyASN list their prefixes, and builtInOnlyASN says
|
||||
// the AS announces none. Only storedASN shows a first-seen date, and no page
|
||||
// shows a date that was never set. unknownASN answers 404. Before the fix
|
||||
// builtInOnlyASN answered 404, so a result of the AS name search led nowhere
|
||||
// (https://git.eeqj.de/sneak/routewatch/issues/151), and routeOnlyASN listed no
|
||||
// prefixes.
|
||||
// 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
|
||||
wantFirstSeen bool
|
||||
asn int
|
||||
wantCode int
|
||||
wantText string
|
||||
}{
|
||||
{storedASN, http.StatusOK, testPrefixV4, true},
|
||||
{routeOnlyASN, http.StatusOK, testPrefixV6, false},
|
||||
{builtInOnlyASN, http.StatusOK, "No prefixes announced by this AS", false},
|
||||
{unknownASN, http.StatusNotFound, "", false},
|
||||
{storedASN, http.StatusOK, testPrefixV4},
|
||||
{builtInOnlyASN, http.StatusOK, "No prefixes announced by this AS"},
|
||||
{unknownASN, http.StatusNotFound, ""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -1479,12 +1460,6 @@ func TestASDetailPageShowsBuiltInAS(t *testing.T) {
|
||||
if strings.Contains(page, zeroDate) {
|
||||
t.Errorf("%s: page shows the date %s", path, zeroDate)
|
||||
}
|
||||
|
||||
gotFirstSeen := strings.Contains(page, firstSeenCard)
|
||||
if gotFirstSeen != tt.wantFirstSeen {
|
||||
t.Errorf("%s: shows a first-seen date %t, want %t",
|
||||
path, gotFirstSeen, tt.wantFirstSeen)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -233,12 +233,10 @@
|
||||
<div class="info-label">Peer ASNs</div>
|
||||
<div class="info-value">{{.PeerCount}}</div>
|
||||
</div>
|
||||
{{if not .ASN.FirstSeen.IsZero}}
|
||||
<div class="info-card">
|
||||
<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>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
{{if .IPv4Prefixes}}
|
||||
|
||||
+8
-26
@@ -26,16 +26,6 @@ type Info struct {
|
||||
type Registry struct {
|
||||
mu sync.RWMutex
|
||||
byASN map[int]*Info
|
||||
// searchEntries holds every entry in AS number order, for Search.
|
||||
searchEntries []searchEntry
|
||||
}
|
||||
|
||||
// searchEntry is an entry with its handle and description in lower case. They
|
||||
// are lowered once, when the data loads, so that Search only compares.
|
||||
type searchEntry struct {
|
||||
info *Info
|
||||
lowerHandle string
|
||||
lowerDescription string
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -72,21 +62,9 @@ func initRegistry() {
|
||||
panic("failed to unmarshal embedded AS data: " + err.Error())
|
||||
}
|
||||
|
||||
slices.SortFunc(asInfos, func(a, b Info) int {
|
||||
return cmp.Compare(a.ASN, b.ASN)
|
||||
})
|
||||
|
||||
defaultRegistry.searchEntries = make([]searchEntry, 0, len(asInfos))
|
||||
|
||||
for i := range asInfos {
|
||||
info := &asInfos[i]
|
||||
defaultRegistry.byASN[info.ASN] = info
|
||||
defaultRegistry.searchEntries = append(defaultRegistry.searchEntries,
|
||||
searchEntry{
|
||||
info: info,
|
||||
lowerHandle: strings.ToLower(info.Handle),
|
||||
lowerDescription: strings.ToLower(info.Description),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,12 +148,16 @@ func Search(query string) []Info {
|
||||
|
||||
var results []Info
|
||||
|
||||
for _, entry := range defaultRegistry.searchEntries {
|
||||
if strings.Contains(entry.lowerHandle, query) ||
|
||||
strings.Contains(entry.lowerDescription, query) {
|
||||
results = append(results, *entry.info)
|
||||
for _, info := range defaultRegistry.byASN {
|
||||
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user