Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca68b8f605 |
@@ -64,7 +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
|
||||
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
|
||||
@@ -93,7 +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
|
||||
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
|
||||
@@ -103,11 +106,10 @@ Every route answers `HEAD` as it answers `GET`, without the body.
|
||||
- `results`: the first 50 of them in AS number order, each with `asn`,
|
||||
`handle` and `description`
|
||||
|
||||
The search reads the AS list built into RouteWatch, taken from
|
||||
[ipverse/asn-info](https://github.com/ipverse/asn-info), not the database. A
|
||||
match can therefore be an AS that the database does not hold. Its
|
||||
`/as/{asn}` and `/api/v1/as/{asn}` then answer 404. A `q` that is missing,
|
||||
empty or only spaces answers 400.
|
||||
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
|
||||
`{"status": "error", "error": {"msg": ..., "code": ...}}`, and carries an
|
||||
@@ -118,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,
|
||||
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 the database does not hold,
|
||||
or a prefix with no live route, answers 404. 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
|
||||
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`,
|
||||
|
||||
@@ -35,7 +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 (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)
|
||||
|
||||
@@ -1748,6 +1748,10 @@ func (d *Database) GetASDetails(asn int) (*ASN, []LiveRoute, error) {
|
||||
// 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
|
||||
// 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(
|
||||
ctx context.Context, asn int,
|
||||
) (*ASN, []LiveRoute, error) {
|
||||
@@ -1765,7 +1769,14 @@ func (d *Database) GetASDetailsContext(
|
||||
&asnInfo.LastSeen)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil, fmt.Errorf("%w: AS%d", ErrNoRoute, asn)
|
||||
info, ok := asinfo.Get(asn)
|
||||
if !ok {
|
||||
return nil, nil, fmt.Errorf("%w: AS%d", ErrNoRoute, asn)
|
||||
}
|
||||
|
||||
return &ASN{
|
||||
ASN: asn, Handle: info.Handle, Description: info.Description,
|
||||
}, nil, nil
|
||||
}
|
||||
|
||||
return nil, nil, fmt.Errorf("failed to query AS: %w", err)
|
||||
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
|
||||
"git.eeqj.de/sneak/routewatch/internal/config"
|
||||
"git.eeqj.de/sneak/routewatch/internal/logger"
|
||||
"git.eeqj.de/sneak/routewatch/pkg/asinfo"
|
||||
"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
|
||||
// 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
|
||||
|
||||
@@ -28,9 +28,10 @@ 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
|
||||
FirstSeen time.Time `json:"first_seen"`
|
||||
LastSeen time.Time `json:"last_seen"`
|
||||
// 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"`
|
||||
}
|
||||
|
||||
|
||||
@@ -1009,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(
|
||||
routes []database.LiveRoute,
|
||||
) ([]database.LiveRoute, []database.LiveRoute) {
|
||||
var ipv4Routes, ipv6Routes []database.LiveRoute
|
||||
ipv4Routes, ipv6Routes := []database.LiveRoute{}, []database.LiveRoute{}
|
||||
|
||||
for _, route := range routes {
|
||||
if route.IPVersion == ipVersionV4 {
|
||||
|
||||
@@ -1285,6 +1285,184 @@ func TestASSearchShowsFirstMatchesAndTotal(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The AS numbers the AS detail tests ask for. storedASN originates the route
|
||||
// for testPrefixV4 and is in the database. builtInOnlyASN is in the AS list
|
||||
// built into asinfo but not in the database. unknownASN is in neither.
|
||||
const (
|
||||
storedASN = 64500
|
||||
builtInOnlyASN = 15169
|
||||
unknownASN = 4200000000
|
||||
)
|
||||
|
||||
// zeroDate is how a page writes a time that was never set.
|
||||
const zeroDate = "0001-01-01"
|
||||
|
||||
// newServerWithStoredAS returns newServerWithTestRoutes's server with storedASN
|
||||
// added to the database. It fails the test when the AS list built into asinfo
|
||||
// lacks builtInOnlyASN or holds unknownASN.
|
||||
func newServerWithStoredAS(t *testing.T) *Server {
|
||||
t.Helper()
|
||||
|
||||
if _, ok := asinfo.Get(builtInOnlyASN); !ok {
|
||||
t.Fatalf("AS%d is not in the AS info data", builtInOnlyASN)
|
||||
}
|
||||
|
||||
if _, ok := asinfo.Get(unknownASN); ok {
|
||||
t.Fatalf("AS%d is in the AS info data", unknownASN)
|
||||
}
|
||||
|
||||
s := newServerWithTestRoutes(t)
|
||||
|
||||
_, err := s.db.GetOrCreateASN(storedASN, time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC))
|
||||
if err != nil {
|
||||
t.Fatalf("GetOrCreateASN: %v", err)
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
// TestASDetailJSONAnswersBuiltInAS asks /api/v1/as/ for an AS in the database,
|
||||
// one only the built-in AS list knows, and one in neither. The first two answer
|
||||
// 200 with the handle and description from that list and their prefixes, as
|
||||
// lists even when empty. Only the AS in the database has a first_seen time. The
|
||||
// third answers 404. Before the fix the second answered 404, so a result of the
|
||||
// AS name search could lead nowhere
|
||||
// (https://git.eeqj.de/sneak/routewatch/issues/151).
|
||||
func TestASDetailJSONAnswersBuiltInAS(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := newServerWithStoredAS(t)
|
||||
|
||||
tests := []struct {
|
||||
asn int
|
||||
wantCode int
|
||||
wantPrefixes int
|
||||
wantFirstSeen bool
|
||||
}{
|
||||
{storedASN, http.StatusOK, 1, true},
|
||||
{builtInOnlyASN, http.StatusOK, 0, false},
|
||||
{unknownASN, http.StatusNotFound, 0, false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
path := "/api/v1/as/" + strconv.Itoa(tt.asn)
|
||||
rec := httptest.NewRecorder()
|
||||
s.router.ServeHTTP(rec, httptest.NewRequestWithContext(
|
||||
t.Context(), http.MethodGet, path, nil))
|
||||
|
||||
if rec.Code != tt.wantCode {
|
||||
t.Errorf("%s: status %d, want %d; body %s",
|
||||
path, rec.Code, tt.wantCode, rec.Body)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if tt.wantCode != http.StatusOK {
|
||||
checkJSONErrorCode(t, path, rec, tt.wantCode)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
//nolint:tagliatelle // field names are the existing JSON API
|
||||
var body struct {
|
||||
Data struct {
|
||||
ASN database.ASN `json:"asn"`
|
||||
IPv4Prefixes []database.LiveRoute `json:"ipv4_prefixes"`
|
||||
IPv6Prefixes []database.LiveRoute `json:"ipv6_prefixes"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
err := json.Unmarshal(rec.Body.Bytes(), &body)
|
||||
if err != nil {
|
||||
t.Errorf("%s: decoding the answer %q: %v", path, rec.Body, err)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
info, _ := asinfo.Get(tt.asn)
|
||||
got := body.Data.ASN
|
||||
|
||||
if got.ASN != tt.asn || got.Handle != info.Handle ||
|
||||
got.Description != info.Description {
|
||||
t.Errorf("%s: AS %d %q %q, want %d %q %q", path, got.ASN,
|
||||
got.Handle, got.Description, tt.asn, info.Handle, info.Description)
|
||||
}
|
||||
|
||||
if body.Data.IPv4Prefixes == nil || body.Data.IPv6Prefixes == nil {
|
||||
t.Errorf("%s: a prefix list is null; body %s", path, rec.Body)
|
||||
}
|
||||
|
||||
gotPrefixes := len(body.Data.IPv4Prefixes) + len(body.Data.IPv6Prefixes)
|
||||
if gotPrefixes != tt.wantPrefixes {
|
||||
t.Errorf("%s: %d prefixes, want %d", path, gotPrefixes, tt.wantPrefixes)
|
||||
}
|
||||
|
||||
gotFirstSeen := strings.Contains(rec.Body.String(), `"first_seen"`)
|
||||
if gotFirstSeen != tt.wantFirstSeen {
|
||||
t.Errorf("%s: has first_seen %t, want %t; body %s",
|
||||
path, gotFirstSeen, tt.wantFirstSeen, rec.Body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestASDetailPageShowsBuiltInAS asks /as/ for an AS in the database, one only
|
||||
// the built-in AS list knows, and one in neither. The first two answer 200 with
|
||||
// the handle and description from that list. The first lists its prefix, and
|
||||
// the second says the AS announces none. Neither shows a date that was never
|
||||
// set. The third answers 404. Before the fix the second answered 404, so a
|
||||
// result of the AS name search led nowhere
|
||||
// (https://git.eeqj.de/sneak/routewatch/issues/151).
|
||||
func TestASDetailPageShowsBuiltInAS(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := newServerWithStoredAS(t)
|
||||
|
||||
tests := []struct {
|
||||
asn int
|
||||
wantCode int
|
||||
wantText string
|
||||
}{
|
||||
{storedASN, http.StatusOK, testPrefixV4},
|
||||
{builtInOnlyASN, http.StatusOK, "No prefixes announced by this AS"},
|
||||
{unknownASN, http.StatusNotFound, ""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
path := "/as/" + strconv.Itoa(tt.asn)
|
||||
rec := httptest.NewRecorder()
|
||||
s.router.ServeHTTP(rec, httptest.NewRequestWithContext(
|
||||
t.Context(), http.MethodGet, path, nil))
|
||||
|
||||
if rec.Code != tt.wantCode {
|
||||
t.Errorf("%s: status %d, want %d; body %s",
|
||||
path, rec.Code, tt.wantCode, rec.Body)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if tt.wantCode != http.StatusOK {
|
||||
continue
|
||||
}
|
||||
|
||||
info, _ := asinfo.Get(tt.asn)
|
||||
page := rec.Body.String()
|
||||
|
||||
for _, want := range []string{
|
||||
html.EscapeString(info.Handle),
|
||||
html.EscapeString(info.Description),
|
||||
tt.wantText,
|
||||
} {
|
||||
if !strings.Contains(page, want) {
|
||||
t.Errorf("%s: page does not show %q", path, want)
|
||||
}
|
||||
}
|
||||
|
||||
if strings.Contains(page, zeroDate) {
|
||||
t.Errorf("%s: page shows the date %s", path, zeroDate)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// settledGoroutineCount lets transient goroutines finish, then reports the
|
||||
// current count.
|
||||
func settledGoroutineCount() int {
|
||||
|
||||
@@ -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
|
||||
|
||||
import (
|
||||
|
||||
@@ -235,7 +235,7 @@
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -141,11 +141,6 @@ func TestSearch(t *testing.T) {
|
||||
query: "University",
|
||||
wantMin: 10,
|
||||
},
|
||||
{
|
||||
name: "Empty search",
|
||||
query: "",
|
||||
wantMin: 0,
|
||||
},
|
||||
{
|
||||
name: "Non-existent org",
|
||||
query: "XYZABC123456",
|
||||
@@ -176,6 +171,17 @@ func TestSearch(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSearchEmptyQueryMatchesNothing expects an empty query to match no AS,
|
||||
// although every handle contains the empty string.
|
||||
func TestSearchEmptyQueryMatchesNothing(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
results := asinfo.Search("")
|
||||
if len(results) != 0 {
|
||||
t.Errorf(`Search("") returned %d results, want none`, len(results))
|
||||
}
|
||||
}
|
||||
|
||||
// TestSearchIgnoresCase searches for Cloudflare's handle in lower case and its
|
||||
// description in upper case, and expects both searches to find AS13335.
|
||||
func TestSearchIgnoresCase(t *testing.T) {
|
||||
|
||||
Reference in New Issue
Block a user