feat(scopes): expose cached regional catalogue context

This commit is contained in:
n30nex
2026-09-30 21:07:42 -04:00
parent af2604a610
commit a5c8d2839b
11 changed files with 436 additions and 0 deletions
+1
View File
@@ -367,6 +367,7 @@ func main() {
// CachedReader. Only response projections receive the geographic annotation.
reader = api.WithLocalBorders(reader, localBorders)
r := router.New(h, reader, []*ingest.Worker{broker1, broker2}, router.Options{
ScopeCatalogues: scopeImporter,
MaxConnsPerIP: resolved.MaxConnsPerIP,
MaxConnectsPerMinute: resolved.MaxConnectsPerMinute,
WSAllowedOrigins: cfg.WebSocket.AllowedOrigins,
+78
View File
@@ -2231,6 +2231,29 @@ const docTemplate = `{
}
}
},
"/scope-catalogues": {
"get": {
"description": "Configured sources only. Counts describe the source region, not individual nodes or links. No upstream request is made. An empty list means no configured importer; checkedAt=0 means no successful check. lastError or a past freshUntil marks stale metadata. Manual Beacon scopes and packet evidence are separate.",
"produces": [
"application/json"
],
"tags": [
"Scopes"
],
"summary": "Cached MeshMapper regional scope catalogues",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_meshmapper.Catalogue"
}
}
}
}
}
},
"/scopes": {
"get": {
"description": "The unfiltered list includes stored imported names after an importer is disabled or a source is removed. Those historical identities are retained indefinitely; listing them does not establish observed traffic or current source membership.",
@@ -5442,6 +5465,61 @@ const docTemplate = `{
}
}
},
"github_com_MeshCore-Beacon_beacon-server_internal_meshmapper.Catalogue": {
"type": "object",
"properties": {
"checkedAt": {
"type": "integer"
},
"freshUntil": {
"type": "integer"
},
"generatedAt": {
"type": "integer"
},
"iata": {
"type": "string"
},
"lastError": {
"type": "string"
},
"repeaters": {
"type": "integer"
},
"scoped": {
"type": "integer"
},
"scopes": {
"type": "array",
"items": {
"$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_meshmapper.CatalogueScope"
}
},
"url": {
"type": "string"
}
}
},
"github_com_MeshCore-Beacon_beacon-server_internal_meshmapper.CatalogueScope": {
"type": "object",
"properties": {
"default": {
"type": "integer"
},
"monitored": {
"type": "boolean"
},
"name": {
"type": "string"
},
"repeaters": {
"type": "integer"
},
"wardriving": {
"type": "boolean"
}
}
},
"internal_api_handlers.APIError": {
"type": "object",
"properties": {
+78
View File
@@ -2229,6 +2229,29 @@
}
}
},
"/scope-catalogues": {
"get": {
"description": "Configured sources only. Counts describe the source region, not individual nodes or links. No upstream request is made. An empty list means no configured importer; checkedAt=0 means no successful check. lastError or a past freshUntil marks stale metadata. Manual Beacon scopes and packet evidence are separate.",
"produces": [
"application/json"
],
"tags": [
"Scopes"
],
"summary": "Cached MeshMapper regional scope catalogues",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_meshmapper.Catalogue"
}
}
}
}
}
},
"/scopes": {
"get": {
"description": "The unfiltered list includes stored imported names after an importer is disabled or a source is removed. Those historical identities are retained indefinitely; listing them does not establish observed traffic or current source membership.",
@@ -5440,6 +5463,61 @@
}
}
},
"github_com_MeshCore-Beacon_beacon-server_internal_meshmapper.Catalogue": {
"type": "object",
"properties": {
"checkedAt": {
"type": "integer"
},
"freshUntil": {
"type": "integer"
},
"generatedAt": {
"type": "integer"
},
"iata": {
"type": "string"
},
"lastError": {
"type": "string"
},
"repeaters": {
"type": "integer"
},
"scoped": {
"type": "integer"
},
"scopes": {
"type": "array",
"items": {
"$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_meshmapper.CatalogueScope"
}
},
"url": {
"type": "string"
}
}
},
"github_com_MeshCore-Beacon_beacon-server_internal_meshmapper.CatalogueScope": {
"type": "object",
"properties": {
"default": {
"type": "integer"
},
"monitored": {
"type": "boolean"
},
"name": {
"type": "string"
},
"repeaters": {
"type": "integer"
},
"wardriving": {
"type": "boolean"
}
}
},
"internal_api_handlers.APIError": {
"type": "object",
"properties": {
+55
View File
@@ -1492,6 +1492,42 @@ definitions:
requires_restart:
type: boolean
type: object
github_com_MeshCore-Beacon_beacon-server_internal_meshmapper.Catalogue:
properties:
checkedAt:
type: integer
freshUntil:
type: integer
generatedAt:
type: integer
iata:
type: string
lastError:
type: string
repeaters:
type: integer
scoped:
type: integer
scopes:
items:
$ref: '#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_meshmapper.CatalogueScope'
type: array
url:
type: string
type: object
github_com_MeshCore-Beacon_beacon-server_internal_meshmapper.CatalogueScope:
properties:
default:
type: integer
monitored:
type: boolean
name:
type: string
repeaters:
type: integer
wardriving:
type: boolean
type: object
internal_api_handlers.APIError:
properties:
code:
@@ -3026,6 +3062,25 @@ paths:
summary: Search known routes by source and destination hash
tags:
- Routes
/scope-catalogues:
get:
description: Configured sources only. Counts describe the source region, not
individual nodes or links. No upstream request is made. An empty list means
no configured importer; checkedAt=0 means no successful check. lastError or
a past freshUntil marks stale metadata. Manual Beacon scopes and packet evidence
are separate.
produces:
- application/json
responses:
"200":
description: OK
schema:
items:
$ref: '#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_meshmapper.Catalogue'
type: array
summary: Cached MeshMapper regional scope catalogues
tags:
- Scopes
/scopes:
get:
description: The unfiltered list includes stored imported names after an importer
+33
View File
@@ -0,0 +1,33 @@
// Copyright 2026 Beacon Contributors
// SPDX-License-Identifier: AGPL-3.0-or-later
package handlers
import (
"net/http"
"github.com/MeshCore-Beacon/beacon-server/internal/meshmapper"
)
// ScopeCatalogueSource supplies immutable, already-cached public metadata.
type ScopeCatalogueSource interface {
Catalogues() []meshmapper.Catalogue
}
// ScopeCatalogues godoc
// @Summary Cached MeshMapper regional scope catalogues
// @Description Configured sources only. Counts describe the source region, not individual nodes or links. No upstream request is made. An empty list means no configured importer; checkedAt=0 means no successful check. lastError or a past freshUntil marks stale metadata. Manual Beacon scopes and packet evidence are separate.
// @Tags Scopes
// @Produce json
// @Success 200 {array} meshmapper.Catalogue
// @Router /scope-catalogues [get]
func ScopeCatalogues(source ScopeCatalogueSource) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
items := []meshmapper.Catalogue{}
if source != nil {
items = source.Catalogues()
}
w.Header().Set("Cache-Control", "public, max-age=60")
respond(w, http.StatusOK, items)
}
}
+3
View File
@@ -6,12 +6,15 @@ package router
import (
"net/http"
"github.com/MeshCore-Beacon/beacon-server/internal/api/handlers"
"github.com/MeshCore-Beacon/beacon-server/internal/config"
)
// Options names the startup settings used by the router. Adding an optional
// route does not change New's signature or require unrelated callers to change.
type Options struct {
ScopeCatalogues handlers.ScopeCatalogueSource
MaxConnsPerIP int
MaxConnectsPerMinute int
WSAllowedOrigins []string
+1
View File
@@ -108,6 +108,7 @@ func New(h *hub.Hub, reader api.Reader, workers []*ingest.Worker, opts Options)
r.Mount("/regions", handlers.RegionsRouter(reader))
r.Mount("/routes", handlers.RoutesRouter(reader))
r.Mount("/scopes", handlers.ScopesRouter(reader))
r.Get("/scope-catalogues", handlers.ScopeCatalogues(opts.ScopeCatalogues))
r.Mount("/stats", handlers.StatsRouter(reader))
r.Mount("/traces", handlers.TracesRouter(reader))
})
@@ -0,0 +1,28 @@
// Copyright 2026 Beacon Contributors
// SPDX-License-Identifier: AGPL-3.0-or-later
package router
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/MeshCore-Beacon/beacon-server/internal/meshmapper"
)
func TestScopeCataloguesDisabledAndReadOnly(t *testing.T) {
var importer *meshmapper.Importer
r := New(nil, nil, nil, Options{ScopeCatalogues: importer})
for _, method := range []string{http.MethodGet, http.MethodPost} {
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest(method, "/api/v1/scope-catalogues", nil))
if method == http.MethodGet && (w.Code != 200 || strings.TrimSpace(w.Body.String()) != "[]" || w.Header().Get("Cache-Control") != "public, max-age=60") {
t.Fatal(w.Code, w.Body.String())
}
if method == http.MethodPost && w.Code != 405 {
t.Fatal(w.Code)
}
}
}
+75
View File
@@ -0,0 +1,75 @@
// Copyright 2026 Beacon Contributors
// SPDX-License-Identifier: AGPL-3.0-or-later
package meshmapper
import (
"encoding/json"
"time"
"github.com/MeshCore-Beacon/beacon-server/internal/scopestore"
)
// CatalogueScope is regional source metadata, not per-node forwarding evidence.
type CatalogueScope struct {
Name string `json:"name"`
Repeaters int `json:"repeaters"`
Default int `json:"default"`
Monitored bool `json:"monitored"`
Wardriving bool `json:"wardriving"`
}
type Catalogue struct {
IATA string `json:"iata"`
URL string `json:"url"`
GeneratedAt int64 `json:"generatedAt"`
CheckedAt int64 `json:"checkedAt"`
FreshUntil int64 `json:"freshUntil"`
LastError string `json:"lastError,omitempty"`
Repeaters int `json:"repeaters"`
Scoped int `json:"scoped"`
Scopes []CatalogueScope `json:"scopes"`
}
// Catalogues returns an immutable snapshot of configured sources. No request
// triggers an upstream fetch or reads ingestion tables. Callers must not mutate it.
func (i *Importer) Catalogues() []Catalogue {
if i != nil {
if snapshot := i.catalogues.Load(); snapshot != nil {
return snapshot.([]Catalogue)
}
}
return []Catalogue{}
}
func (i *Importer) publishCatalogues() {
result := make([]Catalogue, 0, len(i.sources))
for _, s := range i.sources {
c := Catalogue{IATA: s.iata, URL: s.url, LastError: s.cache.LastError, Scopes: []CatalogueScope{}}
if !s.cache.CheckedAt.IsZero() {
c.CheckedAt = s.cache.CheckedAt.UnixMilli()
c.FreshUntil = s.cache.CheckedAt.Add(i.interval + 5*time.Minute).UnixMilli()
}
if len(s.cache.Payload) > 0 {
// Reuse the importer validation before exposing saved metadata. Invalid
// saved catalogues are omitted just as they are from the scope matcher.
if _, generated, err := decode(s.cache.Payload, s.iata); err == nil {
var payload struct {
Repeaters int
Scoped int
Scopes []CatalogueScope
}
if json.Unmarshal(s.cache.Payload, &payload) == nil {
c.GeneratedAt = generated.UnixMilli()
c.Repeaters, c.Scoped = payload.Repeaters, payload.Scoped
for _, scope := range payload.Scopes {
scope.Name = scopestore.FromName(scope.Name).Name
c.Scopes = append(c.Scopes, scope)
}
}
}
}
result = append(result, c)
}
i.catalogues.Store(result)
}
+80
View File
@@ -0,0 +1,80 @@
// Copyright 2026 Beacon Contributors
// SPDX-License-Identifier: AGPL-3.0-or-later
package meshmapper
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
"github.com/MeshCore-Beacon/beacon-server/internal/config"
"github.com/MeshCore-Beacon/beacon-server/internal/scopestore"
)
func TestPublicCataloguesFreshnessAndIsolation(t *testing.T) {
status := http.StatusOK
calls := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
w.WriteHeader(status)
if status == http.StatusOK {
_, _ = w.Write(catalogue("yow", "YOW", "monitored-zero"))
}
}))
defer server.Close()
cfg := config.MeshMapperScopesConfig{Enabled: true, Sources: map[string]string{"YOW": server.URL}}
store := &memoryStore{rows: map[string]Cache{}}
imp, err := New(context.Background(), cfg, store, scopestore.New(), nil)
if err != nil {
t.Fatal(err)
}
if got := imp.Catalogues(); len(got) != 1 || got[0].CheckedAt != 0 || len(got[0].Scopes) != 0 {
t.Fatal(got)
}
now := time.Now()
if err := imp.refresh(context.Background(), &imp.sources[0], now); err != nil {
t.Fatal(err)
}
old := imp.Catalogues()
if old[0].Scopes[0].Name != "#yow" || old[0].Scopes[1].Name != "#YOW" || len(old[0].Scopes) != 3 || old[0].Repeaters != 5 || old[0].Scoped != 3 || old[0].FreshUntil <= old[0].CheckedAt {
t.Fatal(old)
}
status = http.StatusServiceUnavailable
if err := imp.refresh(context.Background(), &imp.sources[0], now.Add(time.Minute)); err != nil {
t.Fatal(err)
}
current := imp.Catalogues()[0]
if current.LastError != "HTTP 503" || current.CheckedAt != old[0].CheckedAt || current.GeneratedAt != old[0].GeneratedAt || len(current.Scopes) != 3 || old[0].LastError != "" {
t.Fatal(current, old)
}
// Readers only access published immutable data; the race suite exercises concurrent refreshes.
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
for range 100 {
_, _ = json.Marshal(imp.Catalogues())
}
}()
for range 100 {
imp.publishCatalogues()
}
wg.Wait()
if calls != 2 {
t.Fatal("reading public metadata performed upstream requests", calls)
}
disabled, err := New(context.Background(), config.MeshMapperScopesConfig{}, store, scopestore.New(), nil)
if err != nil || len(disabled.Catalogues()) != 0 {
t.Fatal("disabled importer exposed old membership")
}
store.rows["YOW"+server.URL] = Cache{Payload: []byte(`{"scopes":"invalid"}`)}
restored, err := New(context.Background(), cfg, store, scopestore.New(), nil)
if err != nil || len(restored.Catalogues()[0].Scopes) != 0 || restored.Catalogues()[0].LastError == "" {
t.Fatal("invalid saved metadata leaked")
}
}
+4
View File
@@ -14,6 +14,7 @@ import (
"slices"
"strconv"
"strings"
"sync/atomic"
"time"
"unicode"
"unicode/utf8"
@@ -52,6 +53,7 @@ type source struct {
// Importer is owned by one background task; ScopeStore synchronizes its consumers.
type Importer struct {
catalogues atomic.Value // immutable []Catalogue, refreshed off the ingest path
store Store
scopes *scopestore.ScopeStore
manual []scopestore.Entry
@@ -103,6 +105,7 @@ func New(ctx context.Context, cfg config.MeshMapperScopesConfig, store Store, sc
i.log(s, "restored")
}
i.publish()
i.publishCatalogues()
return i, nil
}
@@ -224,6 +227,7 @@ func (i *Importer) refresh(ctx context.Context, s *source, now time.Time) error
i.onChange(ctx)
}
}
i.publishCatalogues()
i.log(*s, "checked")
return nil
}