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
|
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
|
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
|
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
|
nor a live route it originates. A prefix with no live route answers 404 too. The
|
||||||
known origin. The AS name search never lists it. A prefix with no live route
|
HTML pages give these errors as plain text. Under `/api/v1` and `/ip/`, a path
|
||||||
answers 404 too. The HTML pages give these errors as plain text. Under `/api/v1`
|
no route matches answers 404 with the message `Not found`, and `POST`, `PUT`,
|
||||||
and `/ip/`, a path no route matches answers 404 with the message `Not found`,
|
`PATCH`, `DELETE`, `OPTIONS`, `TRACE` or `CONNECT` answers 405 with
|
||||||
and `POST`, `PUT`, `PATCH`, `DELETE`, `OPTIONS`, `TRACE` or `CONNECT` answers
|
`Method not allowed`. A 500 answer says only `Internal server error`, and a host
|
||||||
405 with `Method not allowed`. A 500 answer says only `Internal server error`,
|
name that does not resolve only `could not resolve host name`; the detail goes
|
||||||
and a host name that does not resolve only `could not resolve host name`; the
|
to the log.
|
||||||
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
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ 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/internal/ristypes"
|
|
||||||
"git.eeqj.de/sneak/routewatch/pkg/asinfo"
|
"git.eeqj.de/sneak/routewatch/pkg/asinfo"
|
||||||
_ "github.com/mattn/go-sqlite3" // CGO SQLite driver
|
_ "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
|
// 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
|
// lookup, and it has no seen times. An AS with no record, no lookup entry and
|
||||||
// no prefix is ErrNoRoute.
|
// no prefix is ErrNoRoute.
|
||||||
//
|
|
||||||
// ristypes.NoOrigin means no known origin, not an AS, so it is ErrNoRoute
|
|
||||||
// whatever is stored.
|
|
||||||
func (d *Database) GetASDetailsContext(
|
func (d *Database) GetASDetailsContext(
|
||||||
ctx context.Context, asn int,
|
ctx context.Context, asn int,
|
||||||
) (*ASN, []LiveRoute, error) {
|
) (*ASN, []LiveRoute, error) {
|
||||||
if asn == ristypes.NoOrigin {
|
|
||||||
return nil, nil, fmt.Errorf("%w: AS%d", ErrNoRoute, asn)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get AS information
|
// Get AS information
|
||||||
var (
|
var (
|
||||||
asnInfo ASN
|
asnInfo ASN
|
||||||
|
|||||||
@@ -39,16 +39,12 @@ type ASPath struct {
|
|||||||
Set []int
|
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
|
// 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.
|
// empty.
|
||||||
func (p *ASPath) Origin() int {
|
func (p *ASPath) Origin() int {
|
||||||
if len(p.Set) > 0 || len(p.Sequence) == 0 {
|
if len(p.Set) > 0 || len(p.Sequence) == 0 {
|
||||||
return NoOrigin
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
return p.Sequence[len(p.Sequence)-1]
|
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/database"
|
||||||
"git.eeqj.de/sneak/routewatch/internal/metrics"
|
"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/streamer"
|
||||||
"git.eeqj.de/sneak/routewatch/internal/templates"
|
"git.eeqj.de/sneak/routewatch/internal/templates"
|
||||||
asinfo "git.eeqj.de/sneak/routewatch/pkg/asinfo"
|
asinfo "git.eeqj.de/sneak/routewatch/pkg/asinfo"
|
||||||
@@ -1136,27 +1135,17 @@ func (s *Server) handleASSearch() http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// AS 0 means no known origin and has no AS page, so the answer leaves
|
matches := asinfo.Search(query)
|
||||||
// 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)]
|
|
||||||
|
|
||||||
// results is an empty list rather than nil when nothing matches, so
|
// results is an empty list rather than nil when nothing matches, so
|
||||||
// that the answer holds [] and not null.
|
// that the answer holds [] and not null.
|
||||||
if results == nil {
|
shown := min(len(matches), maxASSearchResults)
|
||||||
results = []asinfo.Info{}
|
results := make([]asinfo.Info, 0, shown)
|
||||||
}
|
results = append(results, matches[:shown]...)
|
||||||
|
|
||||||
err := writeJSONSuccess(w, ASSearchResponse{
|
err := writeJSONSuccess(w, ASSearchResponse{
|
||||||
Query: query,
|
Query: query,
|
||||||
Total: total,
|
Total: len(matches),
|
||||||
Results: results,
|
Results: results,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ import (
|
|||||||
"git.eeqj.de/sneak/routewatch/internal/database"
|
"git.eeqj.de/sneak/routewatch/internal/database"
|
||||||
"git.eeqj.de/sneak/routewatch/internal/logger"
|
"git.eeqj.de/sneak/routewatch/internal/logger"
|
||||||
"git.eeqj.de/sneak/routewatch/internal/metrics"
|
"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/streamer"
|
||||||
"git.eeqj.de/sneak/routewatch/pkg/asinfo"
|
"git.eeqj.de/sneak/routewatch/pkg/asinfo"
|
||||||
)
|
)
|
||||||
@@ -1269,7 +1268,7 @@ func TestASSearchShowsFirstMatchesAndTotal(t *testing.T) {
|
|||||||
|
|
||||||
const query = "university"
|
const query = "university"
|
||||||
|
|
||||||
all, _ := asinfo.Search(query, asinfo.Total())
|
all := asinfo.Search(query)
|
||||||
if len(all) <= maxASSearchResults {
|
if len(all) <= maxASSearchResults {
|
||||||
t.Fatalf("Search(%q) has %d matches; the test needs more than %d",
|
t.Fatalf("Search(%q) has %d matches; the test needs more than %d",
|
||||||
query, len(all), maxASSearchResults)
|
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
|
// settledGoroutineCount lets transient goroutines finish, then reports the
|
||||||
// current count.
|
// current count.
|
||||||
func settledGoroutineCount() int {
|
func settledGoroutineCount() int {
|
||||||
|
|||||||
+7
-15
@@ -155,14 +155,13 @@ func All() []Info {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// Search finds the AS entries whose handle or description contains query,
|
// Search returns the AS entries whose handle or description contains query,
|
||||||
// ignoring case. It returns the first limit of them in AS number order, and the
|
// ignoring case, in AS number order. An empty query matches nothing.
|
||||||
// number of entries that match. An empty query matches nothing.
|
func Search(query string) []Info {
|
||||||
func Search(query string, limit int) ([]Info, int) {
|
|
||||||
once.Do(initRegistry)
|
once.Do(initRegistry)
|
||||||
|
|
||||||
if query == "" {
|
if query == "" {
|
||||||
return nil, 0
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
query = strings.ToLower(query)
|
query = strings.ToLower(query)
|
||||||
@@ -170,21 +169,14 @@ func Search(query string, limit int) ([]Info, int) {
|
|||||||
defaultRegistry.mu.RLock()
|
defaultRegistry.mu.RLock()
|
||||||
defer defaultRegistry.mu.RUnlock()
|
defer defaultRegistry.mu.RUnlock()
|
||||||
|
|
||||||
var (
|
var results []Info
|
||||||
results []Info
|
|
||||||
total int
|
|
||||||
)
|
|
||||||
|
|
||||||
for _, entry := range defaultRegistry.searchEntries {
|
for _, entry := range defaultRegistry.searchEntries {
|
||||||
if strings.Contains(entry.lowerHandle, query) ||
|
if strings.Contains(entry.lowerHandle, query) ||
|
||||||
strings.Contains(entry.lowerDescription, query) {
|
strings.Contains(entry.lowerDescription, query) {
|
||||||
if total < limit {
|
results = append(results, *entry.info)
|
||||||
results = append(results, *entry.info)
|
|
||||||
}
|
|
||||||
|
|
||||||
total++
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return results, total
|
return results
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package asinfo_test
|
package asinfo_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"cmp"
|
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -153,7 +152,7 @@ func TestSearch(t *testing.T) {
|
|||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
results, _ := asinfo.Search(tt.query, asinfo.Total())
|
results := asinfo.Search(tt.query)
|
||||||
if len(results) < tt.wantMin {
|
if len(results) < tt.wantMin {
|
||||||
t.Errorf("Search(%q) returned %d results, want at least %d",
|
t.Errorf("Search(%q) returned %d results, want at least %d",
|
||||||
tt.query, len(results), tt.wantMin)
|
tt.query, len(results), tt.wantMin)
|
||||||
@@ -177,10 +176,9 @@ func TestSearch(t *testing.T) {
|
|||||||
func TestSearchEmptyQueryMatchesNothing(t *testing.T) {
|
func TestSearchEmptyQueryMatchesNothing(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
results, total := asinfo.Search("", asinfo.Total())
|
results := asinfo.Search("")
|
||||||
if len(results) != 0 || total != 0 {
|
if len(results) != 0 {
|
||||||
t.Errorf(`Search("") returned %d results and a total of %d, want none`,
|
t.Errorf(`Search("") returned %d results, want none`, len(results))
|
||||||
len(results), total)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,8 +194,7 @@ func TestSearchIgnoresCase(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, query := range []string{"cloudflarenet", "CLOUDFLARE, INC."} {
|
for _, query := range []string{"cloudflarenet", "CLOUDFLARE, INC."} {
|
||||||
results, _ := asinfo.Search(query, asinfo.Total())
|
if !slices.ContainsFunc(asinfo.Search(query), isCloudflare) {
|
||||||
if !slices.ContainsFunc(results, isCloudflare) {
|
|
||||||
t.Errorf("Search(%q) did not find AS%d", query, cloudflareASN)
|
t.Errorf("Search(%q) did not find AS%d", query, cloudflareASN)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -210,7 +207,7 @@ func TestSearchOrdersByASN(t *testing.T) {
|
|||||||
|
|
||||||
const query = "University"
|
const query = "University"
|
||||||
|
|
||||||
results, _ := asinfo.Search(query, asinfo.Total())
|
results := asinfo.Search(query)
|
||||||
|
|
||||||
for i := 1; i < len(results); i++ {
|
for i := 1; i < len(results); i++ {
|
||||||
if results[i-1].ASN >= results[i].ASN {
|
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) {
|
func TestDataIntegrity(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -301,13 +256,11 @@ func BenchmarkGet(b *testing.B) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func BenchmarkSearch(b *testing.B) {
|
func BenchmarkSearch(b *testing.B) {
|
||||||
const limit = 50
|
|
||||||
|
|
||||||
queries := []string{"Google", "Amazon", "Microsoft", "University"}
|
queries := []string{"Google", "Amazon", "Microsoft", "University"}
|
||||||
|
|
||||||
b.ResetTimer()
|
b.ResetTimer()
|
||||||
|
|
||||||
for i := range b.N {
|
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
|
// Get just the description
|
||||||
desc := asinfo.GetDescription(15169) // "Google LLC"
|
desc := asinfo.GetDescription(15169) // "Google LLC"
|
||||||
|
|
||||||
// Search the handles and descriptions, ignoring case, for the first 10
|
// Search the handles and descriptions, ignoring case; the matches come
|
||||||
// matches in AS number order and the number of all matches
|
// in AS number order
|
||||||
results, total := asinfo.Search("university", 10)
|
results := asinfo.Search("university")
|
||||||
fmt.Printf("%d matches\n", total)
|
|
||||||
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,16 +21,9 @@ func ExampleGetDescription() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func ExampleSearch() {
|
func ExampleSearch() {
|
||||||
const limit = 10
|
results := asinfo.Search("mit-gateway")
|
||||||
|
|
||||||
results, total := asinfo.Search("mit-gateway", limit)
|
|
||||||
|
|
||||||
fmt.Printf("%d of %d matches\n", len(results), total)
|
|
||||||
|
|
||||||
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)
|
||||||
}
|
}
|
||||||
// Output:
|
// Output: AS3: MIT-GATEWAYS - Massachusetts Institute of Technology
|
||||||
// 1 of 1 matches
|
|
||||||
// AS3: MIT-GATEWAYS - Massachusetts Institute of Technology
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user