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