From a5c8d2839b984f43afd5506a1fbcf64a02b7d1ac Mon Sep 17 00:00:00 2001 From: n30nex Date: Wed, 30 Sep 2026 21:07:42 -0400 Subject: [PATCH] feat(scopes): expose cached regional catalogue context --- cmd/beacon/main.go | 1 + docs/docs.go | 78 +++++++++++++++++++ docs/swagger.json | 78 +++++++++++++++++++ docs/swagger.yaml | 55 ++++++++++++++ internal/api/handlers/scope_catalogues.go | 33 ++++++++ internal/api/router/options.go | 3 + internal/api/router/router.go | 1 + internal/api/router/scope_catalogues_test.go | 28 +++++++ internal/meshmapper/catalogues.go | 75 ++++++++++++++++++ internal/meshmapper/catalogues_test.go | 80 ++++++++++++++++++++ internal/meshmapper/scopes.go | 4 + 11 files changed, 436 insertions(+) create mode 100644 internal/api/handlers/scope_catalogues.go create mode 100644 internal/api/router/scope_catalogues_test.go create mode 100644 internal/meshmapper/catalogues.go create mode 100644 internal/meshmapper/catalogues_test.go diff --git a/cmd/beacon/main.go b/cmd/beacon/main.go index 5017dff..daafc06 100644 --- a/cmd/beacon/main.go +++ b/cmd/beacon/main.go @@ -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, diff --git a/docs/docs.go b/docs/docs.go index 2c79a82..81b8afc 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -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": { diff --git a/docs/swagger.json b/docs/swagger.json index f16f216..ca0e504 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -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": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 9b02983..796cc77 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -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 diff --git a/internal/api/handlers/scope_catalogues.go b/internal/api/handlers/scope_catalogues.go new file mode 100644 index 0000000..86ec772 --- /dev/null +++ b/internal/api/handlers/scope_catalogues.go @@ -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) + } +} diff --git a/internal/api/router/options.go b/internal/api/router/options.go index 757a659..f033bd9 100644 --- a/internal/api/router/options.go +++ b/internal/api/router/options.go @@ -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 diff --git a/internal/api/router/router.go b/internal/api/router/router.go index 58bbfac..95ba3cc 100644 --- a/internal/api/router/router.go +++ b/internal/api/router/router.go @@ -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)) }) diff --git a/internal/api/router/scope_catalogues_test.go b/internal/api/router/scope_catalogues_test.go new file mode 100644 index 0000000..5092e4c --- /dev/null +++ b/internal/api/router/scope_catalogues_test.go @@ -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) + } + } +} diff --git a/internal/meshmapper/catalogues.go b/internal/meshmapper/catalogues.go new file mode 100644 index 0000000..530aafc --- /dev/null +++ b/internal/meshmapper/catalogues.go @@ -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) +} diff --git a/internal/meshmapper/catalogues_test.go b/internal/meshmapper/catalogues_test.go new file mode 100644 index 0000000..01f6cb8 --- /dev/null +++ b/internal/meshmapper/catalogues_test.go @@ -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") + } +} diff --git a/internal/meshmapper/scopes.go b/internal/meshmapper/scopes.go index ac389a1..3f210be 100644 --- a/internal/meshmapper/scopes.go +++ b/internal/meshmapper/scopes.go @@ -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 }