Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9308df2a3 |
@@ -123,14 +123,13 @@ 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. AS 0 always answers 404, because it means no
|
||||
known origin. The AS name search never lists it. 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.
|
||||
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.
|
||||
|
||||
Every answer carries `Strict-Transport-Security`, `Content-Security-Policy`,
|
||||
`X-Frame-Options`, `X-Content-Type-Options`, `Referrer-Policy` and
|
||||
|
||||
@@ -21,7 +21,6 @@ import (
|
||||
|
||||
"git.eeqj.de/sneak/routewatch/internal/config"
|
||||
"git.eeqj.de/sneak/routewatch/internal/logger"
|
||||
"git.eeqj.de/sneak/routewatch/internal/ristypes"
|
||||
"git.eeqj.de/sneak/routewatch/pkg/asinfo"
|
||||
_ "github.com/mattn/go-sqlite3" // CGO SQLite driver
|
||||
)
|
||||
@@ -1754,16 +1753,9 @@ func (d *Database) GetASDetails(asn int) (*ASN, []LiveRoute, error) {
|
||||
// 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.
|
||||
//
|
||||
// ristypes.NoOrigin means no known origin, not an AS, so it is ErrNoRoute
|
||||
// whatever is stored.
|
||||
func (d *Database) GetASDetailsContext(
|
||||
ctx context.Context, asn int,
|
||||
) (*ASN, []LiveRoute, error) {
|
||||
if asn == ristypes.NoOrigin {
|
||||
return nil, nil, fmt.Errorf("%w: AS%d", ErrNoRoute, asn)
|
||||
}
|
||||
|
||||
// Get AS information
|
||||
var (
|
||||
asnInfo ASN
|
||||
|
||||
@@ -39,16 +39,12 @@ type ASPath struct {
|
||||
Set []int
|
||||
}
|
||||
|
||||
// NoOrigin is the origin AS of a route whose origin is not known. AS 0 is
|
||||
// reserved, so no AS has this number.
|
||||
const NoOrigin = 0
|
||||
|
||||
// Origin returns the AS that originated the route: the last AS of the
|
||||
// sequence, or NoOrigin when the path ends in a set of several members or is
|
||||
// sequence, or 0 (none) when the path ends in a set of several members or is
|
||||
// empty.
|
||||
func (p *ASPath) Origin() int {
|
||||
if len(p.Set) > 0 || len(p.Sequence) == 0 {
|
||||
return NoOrigin
|
||||
return 0
|
||||
}
|
||||
|
||||
return p.Sequence[len(p.Sequence)-1]
|
||||
|
||||
@@ -16,7 +16,6 @@ import (
|
||||
|
||||
"git.eeqj.de/sneak/routewatch/internal/database"
|
||||
"git.eeqj.de/sneak/routewatch/internal/metrics"
|
||||
"git.eeqj.de/sneak/routewatch/internal/ristypes"
|
||||
"git.eeqj.de/sneak/routewatch/internal/streamer"
|
||||
"git.eeqj.de/sneak/routewatch/internal/templates"
|
||||
asinfo "git.eeqj.de/sneak/routewatch/pkg/asinfo"
|
||||
@@ -1136,27 +1135,17 @@ func (s *Server) handleASSearch() http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
// AS 0 means no known origin and has no AS page, so the answer leaves
|
||||
// it out. The matches come in AS number order, so AS 0 can only be the
|
||||
// first. The search asks for one match more than the answer holds, to
|
||||
// take its place.
|
||||
results, total := asinfo.Search(query, maxASSearchResults+1)
|
||||
if len(results) > 0 && results[0].ASN == ristypes.NoOrigin {
|
||||
results = results[1:]
|
||||
total--
|
||||
}
|
||||
|
||||
results = results[:min(len(results), maxASSearchResults)]
|
||||
matches := asinfo.Search(query)
|
||||
|
||||
// results is an empty list rather than nil when nothing matches, so
|
||||
// that the answer holds [] and not null.
|
||||
if results == nil {
|
||||
results = []asinfo.Info{}
|
||||
}
|
||||
shown := min(len(matches), maxASSearchResults)
|
||||
results := make([]asinfo.Info, 0, shown)
|
||||
results = append(results, matches[:shown]...)
|
||||
|
||||
err := writeJSONSuccess(w, ASSearchResponse{
|
||||
Query: query,
|
||||
Total: total,
|
||||
Total: len(matches),
|
||||
Results: results,
|
||||
})
|
||||
if err != nil {
|
||||
|
||||
@@ -19,7 +19,6 @@ import (
|
||||
"git.eeqj.de/sneak/routewatch/internal/database"
|
||||
"git.eeqj.de/sneak/routewatch/internal/logger"
|
||||
"git.eeqj.de/sneak/routewatch/internal/metrics"
|
||||
"git.eeqj.de/sneak/routewatch/internal/ristypes"
|
||||
"git.eeqj.de/sneak/routewatch/internal/streamer"
|
||||
"git.eeqj.de/sneak/routewatch/pkg/asinfo"
|
||||
)
|
||||
@@ -1269,7 +1268,7 @@ func TestASSearchShowsFirstMatchesAndTotal(t *testing.T) {
|
||||
|
||||
const query = "university"
|
||||
|
||||
all, _ := asinfo.Search(query, asinfo.Total())
|
||||
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)
|
||||
@@ -1489,65 +1488,6 @@ func TestASDetailPageShowsBuiltInAS(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestNoOriginHasNoASAnswers stores a route with no known origin, which has
|
||||
// origin AS 0 (https://git.eeqj.de/sneak/routewatch/issues/75). It expects
|
||||
// /as/0 and /api/v1/as/0 to answer 404. It expects a search for "iana" to leave
|
||||
// AS 0 out of its results and its total, although the AS list built into
|
||||
// asinfo holds AS 0. Before the fix both AS answers were 200 and listed the
|
||||
// route's prefix, and the search listed AS 0 first.
|
||||
func TestNoOriginHasNoASAnswers(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const (
|
||||
noOriginPrefix = "203.0.113.0/24"
|
||||
query = "iana"
|
||||
)
|
||||
|
||||
s := newServerWithTestRoutes(t)
|
||||
|
||||
err := s.db.UpsertLiveRouteBatch([]*database.LiveRoute{{
|
||||
Prefix: noOriginPrefix, MaskLength: 24, IPVersion: 4,
|
||||
OriginASN: ristypes.NoOrigin, PeerIP: testPeerV4,
|
||||
ASPath: []int{64500, 64501, 64502}, NextHop: testPeerV4,
|
||||
LastUpdated: time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC),
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatalf("UpsertLiveRouteBatch: %v", err)
|
||||
}
|
||||
|
||||
asn := strconv.Itoa(ristypes.NoOrigin)
|
||||
|
||||
for _, path := range []string{"/as/" + asn, "/api/v1/as/" + asn} {
|
||||
rec := httptest.NewRecorder()
|
||||
s.router.ServeHTTP(rec, httptest.NewRequestWithContext(
|
||||
t.Context(), http.MethodGet, path, nil))
|
||||
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Errorf("%s: status %d, want %d; body %s",
|
||||
path, rec.Code, http.StatusNotFound, rec.Body)
|
||||
}
|
||||
}
|
||||
|
||||
first, total := asinfo.Search(query, 1)
|
||||
if len(first) != 1 || first[0].ASN != ristypes.NoOrigin {
|
||||
t.Fatalf("AS%d is not the first match for %q in the AS info data",
|
||||
ristypes.NoOrigin, query)
|
||||
}
|
||||
|
||||
data := searchAS(t, s, "q="+query)
|
||||
|
||||
for _, info := range data.Results {
|
||||
if info.ASN == ristypes.NoOrigin {
|
||||
t.Errorf("the search for %q lists AS%d", query, info.ASN)
|
||||
}
|
||||
}
|
||||
|
||||
if data.Total != total-1 {
|
||||
t.Errorf("the search for %q has a total of %d, want %d",
|
||||
query, data.Total, total-1)
|
||||
}
|
||||
}
|
||||
|
||||
// settledGoroutineCount lets transient goroutines finish, then reports the
|
||||
// current count.
|
||||
func settledGoroutineCount() int {
|
||||
|
||||
+7
-15
@@ -155,14 +155,13 @@ func All() []Info {
|
||||
return result
|
||||
}
|
||||
|
||||
// Search finds the AS entries whose handle or description contains query,
|
||||
// ignoring case. It returns the first limit of them in AS number order, and the
|
||||
// number of entries that match. An empty query matches nothing.
|
||||
func Search(query string, limit int) ([]Info, int) {
|
||||
// Search returns the AS entries whose handle or description contains query,
|
||||
// ignoring case, in AS number order. An empty query matches nothing.
|
||||
func Search(query string) []Info {
|
||||
once.Do(initRegistry)
|
||||
|
||||
if query == "" {
|
||||
return nil, 0
|
||||
return nil
|
||||
}
|
||||
|
||||
query = strings.ToLower(query)
|
||||
@@ -170,21 +169,14 @@ func Search(query string, limit int) ([]Info, int) {
|
||||
defaultRegistry.mu.RLock()
|
||||
defer defaultRegistry.mu.RUnlock()
|
||||
|
||||
var (
|
||||
results []Info
|
||||
total int
|
||||
)
|
||||
var results []Info
|
||||
|
||||
for _, entry := range defaultRegistry.searchEntries {
|
||||
if strings.Contains(entry.lowerHandle, query) ||
|
||||
strings.Contains(entry.lowerDescription, query) {
|
||||
if total < limit {
|
||||
results = append(results, *entry.info)
|
||||
}
|
||||
|
||||
total++
|
||||
results = append(results, *entry.info)
|
||||
}
|
||||
}
|
||||
|
||||
return results, total
|
||||
return results
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package asinfo_test
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -153,7 +152,7 @@ func TestSearch(t *testing.T) {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
results, _ := asinfo.Search(tt.query, asinfo.Total())
|
||||
results := asinfo.Search(tt.query)
|
||||
if len(results) < tt.wantMin {
|
||||
t.Errorf("Search(%q) returned %d results, want at least %d",
|
||||
tt.query, len(results), tt.wantMin)
|
||||
@@ -177,10 +176,9 @@ func TestSearch(t *testing.T) {
|
||||
func TestSearchEmptyQueryMatchesNothing(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
results, total := asinfo.Search("", asinfo.Total())
|
||||
if len(results) != 0 || total != 0 {
|
||||
t.Errorf(`Search("") returned %d results and a total of %d, want none`,
|
||||
len(results), total)
|
||||
results := asinfo.Search("")
|
||||
if len(results) != 0 {
|
||||
t.Errorf(`Search("") returned %d results, want none`, len(results))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,8 +194,7 @@ func TestSearchIgnoresCase(t *testing.T) {
|
||||
}
|
||||
|
||||
for _, query := range []string{"cloudflarenet", "CLOUDFLARE, INC."} {
|
||||
results, _ := asinfo.Search(query, asinfo.Total())
|
||||
if !slices.ContainsFunc(results, isCloudflare) {
|
||||
if !slices.ContainsFunc(asinfo.Search(query), isCloudflare) {
|
||||
t.Errorf("Search(%q) did not find AS%d", query, cloudflareASN)
|
||||
}
|
||||
}
|
||||
@@ -210,7 +207,7 @@ func TestSearchOrdersByASN(t *testing.T) {
|
||||
|
||||
const query = "University"
|
||||
|
||||
results, _ := asinfo.Search(query, asinfo.Total())
|
||||
results := asinfo.Search(query)
|
||||
|
||||
for i := 1; i < len(results); i++ {
|
||||
if results[i-1].ASN >= results[i].ASN {
|
||||
@@ -220,48 +217,6 @@ func TestSearchOrdersByASN(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSearchStopsAtLimit searches for a word that matches more entries than
|
||||
// the limit. It expects exactly limit entries, the first matches in AS number
|
||||
// order, and the number of all matches, counted here from All.
|
||||
func TestSearchStopsAtLimit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const (
|
||||
query = "university"
|
||||
limit = 10
|
||||
)
|
||||
|
||||
var matches []asinfo.Info
|
||||
|
||||
for _, info := range asinfo.All() {
|
||||
if strings.Contains(strings.ToLower(info.Handle), query) ||
|
||||
strings.Contains(strings.ToLower(info.Description), query) {
|
||||
matches = append(matches, info)
|
||||
}
|
||||
}
|
||||
|
||||
if len(matches) <= limit {
|
||||
t.Fatalf("%q matches %d entries; the test needs more than %d",
|
||||
query, len(matches), limit)
|
||||
}
|
||||
|
||||
slices.SortFunc(matches, func(a, b asinfo.Info) int {
|
||||
return cmp.Compare(a.ASN, b.ASN)
|
||||
})
|
||||
|
||||
results, total := asinfo.Search(query, limit)
|
||||
|
||||
if total != len(matches) {
|
||||
t.Errorf("Search(%q, %d): total %d, want %d",
|
||||
query, limit, total, len(matches))
|
||||
}
|
||||
|
||||
if !slices.Equal(results, matches[:limit]) {
|
||||
t.Errorf("Search(%q, %d) = %v, want %v",
|
||||
query, limit, results, matches[:limit])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDataIntegrity(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -301,13 +256,11 @@ func BenchmarkGet(b *testing.B) {
|
||||
}
|
||||
|
||||
func BenchmarkSearch(b *testing.B) {
|
||||
const limit = 50
|
||||
|
||||
queries := []string{"Google", "Amazon", "Microsoft", "University"}
|
||||
|
||||
b.ResetTimer()
|
||||
|
||||
for i := range b.N {
|
||||
asinfo.Search(queries[i%len(queries)], limit)
|
||||
asinfo.Search(queries[i%len(queries)])
|
||||
}
|
||||
}
|
||||
|
||||
+3
-4
@@ -16,10 +16,9 @@ Basic usage:
|
||||
// Get just the description
|
||||
desc := asinfo.GetDescription(15169) // "Google LLC"
|
||||
|
||||
// Search the handles and descriptions, ignoring case, for the first 10
|
||||
// matches in AS number order and the number of all matches
|
||||
results, total := asinfo.Search("university", 10)
|
||||
fmt.Printf("%d matches\n", total)
|
||||
// Search the handles and descriptions, ignoring case; the matches come
|
||||
// in AS number order
|
||||
results := asinfo.Search("university")
|
||||
for _, as := range results {
|
||||
fmt.Printf("AS%d: %s\n", as.ASN, as.Description)
|
||||
}
|
||||
|
||||
@@ -21,16 +21,9 @@ func ExampleGetDescription() {
|
||||
}
|
||||
|
||||
func ExampleSearch() {
|
||||
const limit = 10
|
||||
|
||||
results, total := asinfo.Search("mit-gateway", limit)
|
||||
|
||||
fmt.Printf("%d of %d matches\n", len(results), total)
|
||||
|
||||
results := asinfo.Search("mit-gateway")
|
||||
for _, info := range results {
|
||||
fmt.Printf("AS%d: %s - %s\n", info.ASN, info.Handle, info.Description)
|
||||
}
|
||||
// Output:
|
||||
// 1 of 1 matches
|
||||
// AS3: MIT-GATEWAYS - Massachusetts Institute of Technology
|
||||
// Output: AS3: MIT-GATEWAYS - Massachusetts Institute of Technology
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user