mirror of
https://github.com/MeshCore-Beacon/beacon-server.git
synced 2026-10-20 08:42:01 +00:00
feat(scopes): expose cached regional catalogue context
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user