feat(scopes): import cached regional MeshMapper catalogues

This commit is contained in:
n30nex authored and MrAlders0n committed 2026-09-30 07:19:03 -04:00
1 parent 831148ff5f
commit c18e059e0d
25 files changed
+1260 -49

No files matched your search

+5
View File
@@ -80,6 +80,11 @@ func NewCachedReader(inner api.Reader, c *Client, ttl CacheTTLs) api.Reader {
}
}
// InvalidateScopeNames makes newly committed catalogue names available to filters.
func (cr *CachedReader) InvalidateScopeNames(ctx context.Context) {
cr.c.del(ctx, keyScopeNames)
}
// InvalidateNode removes the cached entries for a node by UUID.
// Should be called from the ingest path after a node upsert.
func (cr *CachedReader) InvalidateNode(ctx context.Context, nodeID uuid.UUID) {
+4
View File
@@ -33,6 +33,7 @@ type Config struct {
Routes RoutesConfig `yaml:"routes"`
Ingest IngestFilterConfig `yaml:"ingest"`
Scopes []ScopeConfig `yaml:"scopes"`
MeshMapper MeshMapperConfig `yaml:"meshmapper"`
Cache CacheConfig `yaml:"cache"`
CORS CORSConfig `yaml:"cors"`
RateLimit RateLimitConfig `yaml:"ratelimit"`
@@ -408,6 +409,9 @@ func Load(path string) (*Config, error) {
}
}
configDir := filepath.Dir(path)
if err := cfg.validateMeshMapper(); err != nil {
return nil, err
}
for iata, details := range cfg.IATAs {
if details.BorderFile != "" && !filepath.IsAbs(details.BorderFile) {
details.BorderFile = filepath.Join(configDir, details.BorderFile)
+64
View File
@@ -0,0 +1,64 @@
// Copyright 2026 Beacon Contributors
// SPDX-License-Identifier: AGPL-3.0-or-later
package config
import (
"fmt"
"net/url"
"regexp"
"time"
)
// MaxScopeSources bounds both network refresh work and imported matching keys.
const MaxScopeSources = 16
type MeshMapperConfig struct {
Scopes MeshMapperScopesConfig `yaml:"scopes"`
}
// MeshMapperScopesConfig augments, but never replaces, the manual scopes list.
type MeshMapperScopesConfig struct {
Enabled bool `yaml:"enabled"`
Sources map[string]string `yaml:"sources"` // configured IATA -> published regional endpoint
RefreshInterval duration `yaml:"refresh_interval"`
}
func (c MeshMapperScopesConfig) Interval() time.Duration {
if c.RefreshInterval.Duration == 0 {
return time.Hour
}
return c.RefreshInterval.Duration
}
var meshMapperHost = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*\.meshmapper\.net$`)
func (c *Config) validateMeshMapper() error {
s := c.MeshMapper.Scopes
if !s.Enabled {
return nil
}
if len(s.Sources) == 0 || len(s.Sources) > MaxScopeSources {
return fmt.Errorf("meshmapper.scopes.sources must contain 1-%d regional endpoints", MaxScopeSources)
}
if s.Interval() < 5*time.Minute || s.Interval() > 24*time.Hour {
return fmt.Errorf("meshmapper.scopes.refresh_interval must be between 5m and 24h")
}
configured := map[string]bool{}
for _, region := range c.Regions {
for _, iata := range region.IATAs {
configured[iata] = true
}
}
for iata, endpoint := range s.Sources {
if !configured[iata] {
return fmt.Errorf("meshmapper.scopes source %q must belong to a configured region", iata)
}
u, err := url.Parse(endpoint)
if err != nil || u.Scheme != "https" || u.User != nil || !meshMapperHost.MatchString(u.Host) ||
u.Path != "/get_scopes.php" || u.RawPath != "" || u.RawQuery != "" || u.ForceQuery || u.Fragment != "" {
return fmt.Errorf("meshmapper.scopes source %q must be a published https://<region>.meshmapper.net/get_scopes.php endpoint without credentials or parameters", iata)
}
}
return nil
}
+49
View File
@@ -0,0 +1,49 @@
// Copyright 2026 Beacon Contributors
// SPDX-License-Identifier: AGPL-3.0-or-later
package config
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestMeshMapperConfig(t *testing.T) {
valid := "regions:\n - slug: ottawa\n iatas: [YOW]\nmeshmapper:\n scopes:\n enabled: true\n sources:\n YOW: https://yow.meshmapper.net/get_scopes.php\n"
for _, tc := range []struct {
name, text string
bad bool
}{
{"valid", valid, false},
{"unknown region", strings.Replace(valid, "iatas: [YOW]", "iatas: [YVR]", 1), true},
{"http", strings.Replace(valid, "https:", "http:", 1), true},
{"private host", strings.Replace(valid, "yow.meshmapper.net", "127.0.0.1", 1), true},
{"credentials", strings.Replace(valid, "https://", "https://secret@", 1), true},
{"query", strings.Replace(valid, "get_scopes.php", "get_scopes.php?key=secret", 1), true},
{"non API", strings.Replace(valid, "get_scopes.php", "index.php", 1), true},
{"fast", valid + " refresh_interval: 1s\n", true},
{"negative", valid + " refresh_interval: -1h\n", true},
{"disabled", strings.Replace(valid, "enabled: true", "enabled: false", 1), false},
} {
t.Run(tc.name, func(t *testing.T) {
p := filepath.Join(t.TempDir(), "config.yaml")
if err := os.WriteFile(p, []byte(tc.text), 0600); err != nil {
t.Fatal(err)
}
cfg, err := Load(p)
if (err != nil) != tc.bad {
t.Fatalf("error=%v", err)
}
if err == nil && cfg.MeshMapper.Scopes.Interval() != time.Hour {
t.Fatal("wrong default")
}
})
}
cfg := Config{MeshMapper: MeshMapperConfig{Scopes: MeshMapperScopesConfig{Enabled: true}}}
if err := cfg.validateMeshMapper(); err == nil {
t.Fatal("empty sources accepted")
}
}
+4 -23
View File
@@ -5,12 +5,12 @@ package config
import (
"context"
"crypto/sha256"
"encoding/json"
"fmt"
"log/slog"
"os"
"strings"
"github.com/MeshCore-Beacon/beacon-server/internal/scopestore"
)
// Seeder is the database interface required to seed config data on startup.
@@ -63,29 +63,10 @@ func Seed(ctx context.Context, cfg *Config, db Seeder) error {
}
// Transport Codes
for _, s := range cfg.Scopes {
name := normalizeScopeName(s.Name)
key := deriveScopeKey(name)
h := sha256.Sum256(key)
fingerprint := h[:8]
if err := db.UpsertTransportScope(ctx, name, "", key, fingerprint); err != nil {
entry := scopestore.FromName(s.Name)
if err := db.UpsertTransportScope(ctx, entry.Name, "", entry.TransportKey, entry.KeyFingerprint); err != nil {
return err
}
}
return nil
}
// normalizeScopeName ensures the scope name has a # or $ prefix.
// Plain names get # prepended: "bc" → "#bc".
func normalizeScopeName(name string) string {
if strings.HasPrefix(name, "#") || strings.HasPrefix(name, "$") {
return name
}
return "#" + name
}
// deriveScopeKey derives the 16-byte transport key from a normalized scope name.
// key = SHA256(name)[:16]
func deriveScopeKey(name string) []byte {
h := sha256.Sum256([]byte(name))
return h[:16]
}
+11 -10
View File
@@ -8,6 +8,7 @@ import (
"encoding/hex"
"encoding/json"
"errors"
"github.com/MeshCore-Beacon/beacon-server/internal/scopestore"
"os"
"testing"
)
@@ -198,39 +199,39 @@ func TestSeed_DBError(t *testing.T) {
}
func TestNormalizeScopeName_WithHash(t *testing.T) {
if normalizeScopeName("#bc") != "#bc" {
if scopestore.FromName("#bc").Name != "#bc" {
t.Error("expected #bc unchanged")
}
}
func TestNormalizeScopeName_WithDollar(t *testing.T) {
if normalizeScopeName("$bc") != "$bc" {
if scopestore.FromName("$bc").Name != "$bc" {
t.Error("expected $bc unchanged")
}
}
func TestNormalizeScopeName_WithoutPrefix(t *testing.T) {
if normalizeScopeName("bc") != "#bc" {
if scopestore.FromName("bc").Name != "#bc" {
t.Error("expected bc to become #bc")
}
}
func TestNormalizeScopeName_Empty(t *testing.T) {
if normalizeScopeName("") != "#" {
if scopestore.FromName("").Name != "#" {
t.Error("expected empty string to become #")
}
}
func TestDeriveScopeKey_Length(t *testing.T) {
key := deriveScopeKey("#bc")
key := scopestore.FromName("#bc").TransportKey
if len(key) != 16 {
t.Errorf("expected 16 bytes, got %d", len(key))
}
}
func TestDeriveScopeKey_Deterministic(t *testing.T) {
a := deriveScopeKey("#bc")
b := deriveScopeKey("#bc")
a := scopestore.FromName("#bc").TransportKey
b := scopestore.FromName("#bc").TransportKey
if hex.EncodeToString(a) != hex.EncodeToString(b) {
t.Error("expected same key for same input")
}
@@ -238,7 +239,7 @@ func TestDeriveScopeKey_Deterministic(t *testing.T) {
func TestDeriveScopeKey_KnownValue(t *testing.T) {
// SHA256("#bc")[:16] — pin the exact derivation so changes are caught
key := deriveScopeKey("#bc")
key := scopestore.FromName("#bc").TransportKey
got := hex.EncodeToString(key)
// generate this once: echo -n "#bc" | sha256sum | cut -c1-32
const want = "84509cfe73d94f7f6a8299e6bcdb8a3c"
@@ -248,8 +249,8 @@ func TestDeriveScopeKey_KnownValue(t *testing.T) {
}
func TestDeriveScopeKey_DifferentInputs(t *testing.T) {
a := deriveScopeKey("#bc")
b := deriveScopeKey("#other")
a := scopestore.FromName("#bc").TransportKey
b := scopestore.FromName("#other").TransportKey
if hex.EncodeToString(a) == hex.EncodeToString(b) {
t.Error("expected different keys for different inputs")
}
+23 -8
View File
@@ -11,11 +11,13 @@ import (
"encoding/hex"
"encoding/json"
"fmt"
"slices"
"strings"
"time"
"github.com/MeshCore-Beacon/beacon-server/internal/api"
"github.com/MeshCore-Beacon/beacon-server/internal/lora"
"github.com/MeshCore-Beacon/beacon-server/internal/scopestore"
"github.com/google/uuid"
"github.com/meshcore-go/meshcore-go"
)
@@ -694,14 +696,7 @@ func (w *Worker) handlePacket(ctx context.Context, iata, pubkeyHex string, raw [
var matchedScope *string
if packet.RouteType() == meshcore.RouteTypeTransportFlood || packet.RouteType() == meshcore.RouteTypeTransportDirect {
for _, entry := range w.scopes.Entries() {
code := computeTransportCode(entry.TransportKey, packet.PayloadType(), packet.Payload)
if code == packet.TransportCode1 {
s := entry.Name
matchedScope = &s
break
}
}
matchedScope = matchTransportScope(w.scopes.Entries(), iata, packet.PayloadType(), packet.Payload, packet.TransportCode1)
}
var scopeID *int32
if matchedScope != nil {
@@ -930,6 +925,26 @@ func (w *Worker) handlePacket(ctx context.Context, iata, pubkeyHex string, raw [
}
}
// matchTransportScope never treats catalogue membership as forwarding evidence.
// Multiple candidate names matching the short code are ambiguous, not first-wins.
func matchTransportScope(entries []scopestore.Entry, iata string, payloadType uint8, payload []byte, code uint16) *string {
var matched *string
for _, entry := range entries {
if entry.IATAs != nil && !slices.Contains(entry.IATAs, iata) {
continue
}
if computeTransportCode(entry.TransportKey, payloadType, payload) != code {
continue
}
if matched != nil && *matched != entry.Name {
return nil
}
name := entry.Name
matched = &name
}
return matched
}
// computeTransportCode derives transport_code_1 from a transport key and packet payload.
// code = HMAC-SHA256(key, payload_type_byte || payload)[0:2] as little-endian uint16.
// Coerces reserved values 0x0000 → 0x0001 and 0xFFFF → 0xFFFE per §2.4.
+95
View File
@@ -0,0 +1,95 @@
// Copyright 2026 Beacon Contributors
// SPDX-License-Identifier: AGPL-3.0-or-later
package ingest
import (
"context"
"fmt"
"testing"
"github.com/MeshCore-Beacon/beacon-server/internal/scopestore"
"github.com/meshcore-go/meshcore-go"
)
func TestTransportScopeRegionalAndAmbiguousMatches(t *testing.T) {
entry := scopestore.FromName("yow")
entry.IATAs = []string{"YOW"}
payload := []byte{0xde, 0xad, 0xbe, 0xef}
// Fixed HMAC-SHA256 fixture: SHA256("#yow")[:16], payload type 4.
if got := matchTransportScope([]scopestore.Entry{entry}, "YOW", 4, payload, 21698); got == nil || *got != "#yow" {
t.Fatal(got)
}
if got := matchTransportScope([]scopestore.Entry{entry}, "YVR", 4, payload, 21698); got != nil {
t.Fatal("imported key escaped source region")
}
entry.IATAs = nil
if got := matchTransportScope([]scopestore.Entry{entry}, "YVR", 4, payload, 21698); got == nil {
t.Fatal("manual key was region-restricted")
}
// Distinct keys with the same short code for this payload must remain unknown.
a, b := scopestore.FromName("collision-320"), scopestore.FromName("collision-322")
for _, entries := range [][]scopestore.Entry{{a, b}, {b, a}} {
if computeTransportCode(entries[0].TransportKey, 4, payload) != 37018 {
t.Fatal("collision fixture changed")
}
if got := matchTransportScope(entries, "YOW", 4, payload, 37018); got != nil {
t.Fatal("ambiguous short code resolved", *got)
}
}
}
type importCaptureDB struct{ *frameCaptureDB }
func (s *importCaptureDB) GetTransportScopeByName(context.Context, string) (int32, error) {
return 123, nil
}
func TestImportedScopeFlowsThroughPacketIngest(t *testing.T) {
for _, iata := range []string{"YOW", "YVR"} {
t.Run(iata, func(t *testing.T) {
w, base := newTestWorker()
capture := &importCaptureDB{&frameCaptureDB{stubDB: base}}
w.db = capture
key := scopestore.FromName("yow")
key.IATAs = []string{"YOW"}
store := scopestore.New()
store.Load([]scopestore.Entry{key})
w.scopes = store
packet := buildGrpTxtPacket(t, 0x1a, make([]byte, 16))
packet.Header = meshcore.MakeHeader(meshcore.RouteTypeTransportFlood, meshcore.PayloadTypeGrpTxt, 0)
packet.TransportCode1 = computeTransportCode(key.TransportKey, packet.PayloadType(), packet.Payload)
w.handlePacket(context.Background(), iata, "0102", packetEnvelope(t, packet))
if len(capture.packets) != 1 {
t.Fatal("packet not stored")
}
got := capture.packets[0].ScopeID
if iata == "YOW" && (got == nil || *got != 123) {
t.Fatal("scope not persisted", got)
}
if iata == "YVR" && got != nil {
t.Fatal("scope assigned outside source region", got)
}
})
}
}
func BenchmarkTransportScopeCatalogue(b *testing.B) {
for _, count := range []int{0, 64, 1024} {
b.Run(fmt.Sprint(count), func(b *testing.B) {
entries := []scopestore.Entry{scopestore.FromName("manual")}
for n := 0; n < count; n++ {
entry := scopestore.FromName(fmt.Sprintf("scope-%d", n))
entry.IATAs = []string{fmt.Sprintf("R%02d", n/64)}
entries = append(entries, entry)
}
store := scopestore.New()
store.Load(entries)
payload := make([]byte, 128)
b.ReportAllocs()
for b.Loop() {
matchTransportScope(store.Entries(), "R00", 4, payload, 1234)
}
})
}
}
+294
View File
@@ -0,0 +1,294 @@
// Copyright 2026 Beacon Contributors
// SPDX-License-Identifier: AGPL-3.0-or-later
// Package meshmapper imports the published regional scope catalogue, off the ingest path.
package meshmapper
import (
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"slices"
"strconv"
"strings"
"time"
"unicode"
"unicode/utf8"
"github.com/MeshCore-Beacon/beacon-server/internal/config"
"github.com/MeshCore-Beacon/beacon-server/internal/scopestore"
)
const (
MaxScopes = 64 // per source; at most 16 configured sources (1024 imported identities)
MaxBody = 64 << 10
PollInterval = 15 * time.Second // one source per tick, at most four requests/minute
)
// Cache stores source provenance and freshness separately from packet evidence.
type Cache struct {
Payload json.RawMessage
ETag string
CheckedAt, AttemptedAt, NextAttempt time.Time
LastError string
}
type Store interface {
GetScopeCatalogue(context.Context, string, string) (*Cache, error)
SaveScopeCatalogue(context.Context, string, string, Cache, []scopestore.Entry) error
}
type source struct {
iata, url string
cache Cache
entries []scopestore.Entry
generated time.Time
}
// Importer is owned by one background task; ScopeStore synchronizes its consumers.
type Importer struct {
store Store
scopes *scopestore.ScopeStore
manual []scopestore.Entry
sources []source
interval time.Duration
client *http.Client
retryAfter time.Time
onChange func(context.Context)
}
// SetCacheInvalidator is wired once at startup, before the background task starts.
func (i *Importer) SetCacheInvalidator(fn func(context.Context)) { i.onChange = fn }
// New restores validated snapshots before ingestion, without making HTTP requests.
func New(ctx context.Context, cfg config.MeshMapperScopesConfig, store Store, scopes *scopestore.ScopeStore, manual []scopestore.Entry) (*Importer, error) {
i := &Importer{store: store, scopes: scopes, manual: manual, interval: cfg.Interval(), client: &http.Client{
Timeout: 10 * time.Second,
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
}}
if !cfg.Enabled {
return i, nil
}
keys := make([]string, 0, len(cfg.Sources))
for key := range cfg.Sources {
keys = append(keys, key)
}
slices.Sort(keys)
for _, key := range keys {
s := source{iata: key, url: cfg.Sources[key]}
cached, err := store.GetScopeCatalogue(ctx, s.iata, s.url)
if err != nil {
return nil, fmt.Errorf("restore scope catalogue %s: %w", key, err)
}
if cached != nil {
s.cache = *cached
if len(cached.Payload) > 0 {
s.entries, s.generated, err = decode(cached.Payload, key)
if err != nil {
return nil, fmt.Errorf("invalid saved scope catalogue %s: %w", key, err)
}
}
if cached.LastError == "HTTP 429" && cached.NextAttempt.After(i.retryAfter) {
i.retryAfter = cached.NextAttempt
}
}
i.sources = append(i.sources, s)
i.log(s, "restored")
}
i.publish()
return i, nil
}
// Refresh checks only one due source. The scheduler serializes calls every 15s.
func (i *Importer) Refresh(ctx context.Context) error {
ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
now := time.Now().UTC()
if now.Before(i.retryAfter) {
return nil
}
for n := range i.sources {
s := &i.sources[n]
if now.Before(s.cache.NextAttempt) {
continue
}
return i.refresh(ctx, s, now)
}
return nil
}
func (i *Importer) refresh(ctx context.Context, s *source, now time.Time) error {
update := Cache{AttemptedAt: now, NextAttempt: now.Add(i.interval)}
request, err := http.NewRequestWithContext(ctx, http.MethodGet, s.url, nil)
if err != nil {
return err
}
request.Header.Set("Accept", "application/json")
request.Header.Set("User-Agent", "Beacon-MeshMapper-Scopes/1")
if len(s.cache.Payload) > 0 && s.cache.ETag != "" {
request.Header.Set("If-None-Match", s.cache.ETag)
}
response, err := i.client.Do(request)
var entries []scopestore.Entry
var generated time.Time
if err != nil {
if ctx.Err() != nil {
return ctx.Err()
}
update.LastError = "request failed" // do not persist untrusted response bodies or URLs from errors
} else {
defer response.Body.Close()
switch response.StatusCode {
case http.StatusOK:
body, readErr := io.ReadAll(io.LimitReader(response.Body, MaxBody+1))
if readErr == nil {
entries, generated, readErr = decode(body, s.iata)
}
if readErr != nil {
update.LastError = "invalid response"
} else {
update.Payload = body
}
case http.StatusNotModified:
if len(s.cache.Payload) == 0 || s.cache.ETag == "" {
update.LastError = "304 without cached catalogue"
}
default:
update.LastError = fmt.Sprintf("HTTP %d", response.StatusCode)
if response.StatusCode == http.StatusTooManyRequests || response.StatusCode == http.StatusServiceUnavailable {
// The published contract uses seconds. Ignore invalid/overflowing headers.
if seconds, parseErr := strconv.ParseInt(response.Header.Get("Retry-After"), 10, 64); parseErr == nil && seconds > 0 && seconds <= int64((1<<63-1)/time.Second) {
until := now.Add(time.Duration(seconds) * time.Second)
if until.After(update.NextAttempt) {
update.NextAttempt = until
}
}
}
}
if update.LastError == "" {
etag := response.Header.Get("ETag")
if len(etag) > 256 || strings.ContainsAny(etag, "\r\n") {
update.LastError = "invalid ETag"
update.Payload = nil
entries = nil
} else {
update.ETag = etag
if response.StatusCode == http.StatusNotModified && etag == "" {
update.ETag = s.cache.ETag
}
update.CheckedAt = now
}
}
}
if update.LastError == "HTTP 429" {
i.retryAfter = update.NextAttempt
}
if err := i.store.SaveScopeCatalogue(ctx, s.iata, s.url, update, entries); err != nil {
// Avoid a retry every scheduler tick during a DB outage; never publish uncommitted keys.
s.cache.NextAttempt = update.NextAttempt
return fmt.Errorf("persist scope catalogue %s: %w", s.iata, err)
}
if len(update.Payload) > 0 {
s.cache.Payload = update.Payload
s.entries = entries
s.generated = generated
}
if !update.CheckedAt.IsZero() {
s.cache.CheckedAt = update.CheckedAt
s.cache.ETag = update.ETag
}
s.cache.AttemptedAt = now
s.cache.NextAttempt = update.NextAttempt
s.cache.LastError = update.LastError
if len(update.Payload) > 0 {
i.publish()
if i.onChange != nil {
i.onChange(ctx)
}
}
i.log(*s, "checked")
return nil
}
func (i *Importer) publish() {
byName := make(map[string]scopestore.Entry, len(i.manual))
for _, e := range i.manual {
byName[e.Name] = e
}
for _, s := range i.sources {
for _, candidate := range s.entries {
entry, exists := byName[candidate.Name]
if exists && entry.IATAs == nil {
continue
} // manual metadata/keys win
if !exists {
entry = candidate
}
entry.IATAs = append(entry.IATAs, s.iata)
byName[entry.Name] = entry
}
}
names := make([]string, 0, len(byName))
for name := range byName {
names = append(names, name)
}
slices.Sort(names)
entries := make([]scopestore.Entry, 0, len(names))
for _, name := range names {
entries = append(entries, byName[name])
}
i.scopes.Load(entries)
}
func (i *Importer) log(s source, action string) {
level := slog.LevelInfo
if s.cache.LastError != "" {
level = slog.LevelWarn
}
slog.Log(context.Background(), level, "MeshMapper scopes "+action, "component", "meshmapper.scopes", "iata", s.iata, "source", s.url,
"names", len(s.entries), "generated_at", s.generated, "checked_at", s.cache.CheckedAt, "next_attempt", s.cache.NextAttempt, "last_error", s.cache.LastError)
}
func decode(body []byte, iata string) ([]scopestore.Entry, time.Time, error) {
var document struct {
GeneratedAt time.Time `json:"generated_at"`
Region string `json:"region"`
Zones []string `json:"zones"`
Repeaters, Scoped *int
Scopes *[]struct {
Name string
Repeaters, Default *int
Monitored, Wardriving *bool
}
}
if len(body) > MaxBody || !utf8.Valid(body) {
return nil, time.Time{}, fmt.Errorf("invalid catalogue size/encoding")
}
if err := json.Unmarshal(body, &document); err != nil {
return nil, time.Time{}, err
}
if document.GeneratedAt.IsZero() || document.Region != iata || len(document.Zones) != 1 || document.Zones[0] != iata ||
document.Repeaters == nil || document.Scoped == nil || *document.Scoped < 0 || *document.Repeaters < *document.Scoped ||
document.Scopes == nil || len(*document.Scopes) > MaxScopes {
return nil, time.Time{}, fmt.Errorf("invalid regional catalogue")
}
entries := make([]scopestore.Entry, 0, len(*document.Scopes))
seen := map[string]bool{}
for _, s := range *document.Scopes {
if s.Name == "" || len(s.Name) > 128 || strings.TrimSpace(s.Name) != s.Name || strings.ContainsFunc(s.Name, unicode.IsControl) ||
s.Repeaters == nil || s.Default == nil || s.Monitored == nil || s.Wardriving == nil ||
*s.Default < 0 || *s.Repeaters < *s.Default || *s.Repeaters > *document.Scoped {
return nil, time.Time{}, fmt.Errorf("invalid scope entry")
}
entry := scopestore.FromName(s.Name)
if len(entry.Name) < 2 || seen[entry.Name] {
return nil, time.Time{}, fmt.Errorf("empty or duplicate scope name")
}
seen[entry.Name] = true
entries = append(entries, entry)
}
return entries, document.GeneratedAt, nil
}
+253
View File
@@ -0,0 +1,253 @@
// Copyright 2026 Beacon Contributors
// SPDX-License-Identifier: AGPL-3.0-or-later
package meshmapper
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"reflect"
"strings"
"testing"
"time"
"github.com/MeshCore-Beacon/beacon-server/internal/config"
"github.com/MeshCore-Beacon/beacon-server/internal/scopestore"
)
func catalogue(names ...string) []byte {
rows := make([]map[string]any, 0, len(names))
for _, name := range names {
rows = append(rows, map[string]any{"name": name, "repeaters": 0, "default": 0, "monitored": true, "wardriving": false})
}
b, _ := json.Marshal(map[string]any{"generated_at": "2026-09-27T16:58:49Z", "region": "YOW", "zones": []string{"YOW"}, "repeaters": 5, "scoped": 3, "scopes": rows})
return b
}
type memoryStore struct {
rows map[string]Cache
fail bool
saved []scopestore.Entry
}
func (s *memoryStore) GetScopeCatalogue(_ context.Context, iata, url string) (*Cache, error) {
row, ok := s.rows[iata+url]
if !ok {
return nil, nil
}
return &row, nil
}
func (s *memoryStore) SaveScopeCatalogue(_ context.Context, iata, url string, next Cache, entries []scopestore.Entry) error {
if s.fail {
return errors.New("offline")
}
old := s.rows[iata+url]
if next.Payload == nil {
next.Payload = old.Payload
}
if next.CheckedAt.IsZero() {
next.CheckedAt = old.CheckedAt
next.ETag = old.ETag
}
s.rows[iata+url] = next
s.saved = append(s.saved, entries...)
return nil
}
func names(store *scopestore.ScopeStore) []string {
out := []string{}
for _, e := range store.Entries() {
out = append(out, e.Name)
}
return out
}
func TestCatalogueRefreshFallbackAndRestart(t *testing.T) {
ctx := context.Background()
status, body, etag, after := 200, catalogue("yow", "manual"), `"v1"`, ""
calls := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
if calls > 1 && r.Header.Get("If-None-Match") != `"v1"` {
t.Errorf("missing conditional refresh: %v", r.Header)
}
w.Header().Set("ETag", etag)
w.Header().Set("Retry-After", after)
w.WriteHeader(status)
if status != 304 {
_, _ = w.Write(body)
}
}))
defer server.Close()
cfg := config.MeshMapperScopesConfig{Enabled: true, Sources: map[string]string{"YOW": server.URL}}
store := &memoryStore{rows: map[string]Cache{}}
scopes := scopestore.New()
manual := scopestore.FromName("manual")
manual.TransportKey = []byte("manual wins")
imp, err := New(ctx, cfg, store, scopes, []scopestore.Entry{manual})
if err != nil {
t.Fatal(err)
}
now := time.Now().UTC()
check := func() {
t.Helper()
now = now.Add(time.Minute)
if err := imp.refresh(ctx, &imp.sources[0], now); err != nil {
t.Fatal(err)
}
}
check()
if got := names(scopes); !reflect.DeepEqual(got, []string{"#manual", "#yow"}) {
t.Fatal(got)
}
if string(scopes.Entries()[0].TransportKey) != "manual wins" || scopes.Entries()[0].IATAs != nil {
t.Fatal("manual key replaced/restricted")
}
if !reflect.DeepEqual(scopes.Entries()[1].IATAs, []string{"YOW"}) {
t.Fatal("import not bound to region")
}
generated := imp.sources[0].generated
status = 304
check()
checked := imp.sources[0].cache.CheckedAt
if checked != now || imp.sources[0].generated != generated {
t.Fatal("304 lost freshness or changed generation time")
}
for _, failure := range []int{200, 404, 503, 302} {
status, body = failure, []byte(`{"scopes":null}`)
check()
if imp.sources[0].cache.CheckedAt != checked || len(scopes.Entries()) != 2 {
t.Fatal("failed refresh replaced known catalogue")
}
}
status, after = 429, "7200"
check()
if !imp.retryAfter.Equal(now.Add(2 * time.Hour)) {
t.Fatal("Retry-After ignored")
}
restored := scopestore.New()
restart, err := New(ctx, cfg, store, restored, []scopestore.Entry{manual})
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(names(restored), names(scopes)) {
t.Fatal("outage restart lost cached names")
}
before := calls
if err := restart.Refresh(ctx); err != nil {
t.Fatal(err)
}
if calls != before {
t.Fatal("restart ignored shared rate-limit cooldown")
}
status, after, body = 200, "", catalogue()
check()
if got := names(scopes); !reflect.DeepEqual(got, []string{"#manual"}) {
t.Fatalf("empty snapshot: %v", got)
}
store.fail = true
body = catalogue("new")
now = now.Add(time.Hour)
if err := imp.refresh(ctx, &imp.sources[0], now); err == nil || len(scopes.Entries()) != 1 {
t.Fatal("uncommitted names became active")
}
}
func TestDecodeCatalogueBoundaries(t *testing.T) {
valid := string(catalogue("yow", "YOW", "$private"))
bad := []string{
`null`, `{}`, `[]`, valid + `{}`,
strings.Replace(valid, `"region":"YOW"`, `"region":"PNW"`, 1),
strings.Replace(valid, `"zones":["YOW"]`, `"zones":["YOW","YVR"]`, 1),
strings.Replace(valid, `"scoped":3`, `"scoped":6`, 1),
strings.Replace(valid, `"repeaters":0`, `"repeaters":-1`, 1),
strings.Replace(valid, `"monitored":true,`, ``, 1),
strings.Replace(valid, `"generated_at":"2026-09-27T16:58:49Z"`, `"generated_at":null`, 1),
string(catalogue("yow", "#yow")), string(catalogue("#")), string(catalogue(" yow")), string(catalogue("a\nb")),
strings.Repeat(" ", MaxBody+1),
}
tooMany := []string{}
for n := 0; n <= MaxScopes; n++ {
tooMany = append(tooMany, fmt.Sprint(n))
}
bad = append(bad, string(catalogue(tooMany...)))
for n, raw := range bad {
if _, _, err := decode([]byte(raw), "YOW"); err == nil {
t.Errorf("invalid case %d accepted", n)
}
}
entries, _, err := decode([]byte(valid), "YOW")
if err != nil || len(entries) != 3 {
t.Fatal(entries, err)
}
if entries[0].Name != "#yow" || entries[1].Name != "#YOW" || entries[2].Name != "$private" {
t.Fatal("case/prefix changed", entries)
}
}
func TestNoRedirectAndResponseBounds(t *testing.T) {
targetCalls := 0
target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { targetCalls++ }))
defer target.Close()
mode := "redirect"
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch mode {
case "redirect":
http.Redirect(w, r, target.URL, 302)
case "oversize":
_, _ = w.Write([]byte(strings.Repeat("x", MaxBody+1)))
case "timeout":
select {
case <-r.Context().Done():
case <-time.After(time.Second):
}
case "304":
w.WriteHeader(304)
}
}))
defer server.Close()
store := &memoryStore{rows: map[string]Cache{}}
imp, err := New(context.Background(), config.MeshMapperScopesConfig{Enabled: true, Sources: map[string]string{"YOW": server.URL}}, store, scopestore.New(), nil)
if err != nil {
t.Fatal(err)
}
imp.client.Timeout = 20 * time.Millisecond
for _, value := range []string{"redirect", "oversize", "timeout", "304"} {
mode = value
if err := imp.refresh(context.Background(), &imp.sources[0], time.Now()); err != nil {
t.Fatal(err)
}
if imp.sources[0].cache.LastError == "" || len(imp.sources[0].entries) != 0 {
t.Fatal("invalid response accepted", value)
}
}
if targetCalls != 0 {
t.Fatal("followed redirect")
}
}
func TestImportedSourcesOverlapWithoutGlobalMembership(t *testing.T) {
ctx := context.Background()
store := &memoryStore{rows: map[string]Cache{}}
for _, iata := range []string{"YOW", "YVR"} {
store.rows[iata+iata] = Cache{Payload: []byte(strings.ReplaceAll(string(catalogue("can")), "YOW", iata))}
}
scopes := scopestore.New()
_, err := New(ctx, config.MeshMapperScopesConfig{Enabled: true, Sources: map[string]string{"YOW": "YOW", "YVR": "YVR"}}, store, scopes, nil)
if err != nil || len(scopes.Entries()) != 1 || !reflect.DeepEqual(scopes.Entries()[0].IATAs, []string{"YOW", "YVR"}) {
t.Fatal(scopes.Entries(), err)
}
_, err = New(ctx, config.MeshMapperScopesConfig{Enabled: true, Sources: map[string]string{"YOW": "changed-source"}}, store, scopes, nil)
if err != nil || len(scopes.Entries()) != 0 {
t.Fatal("removed/changed source remained active", err)
}
// Disabled mode never calls the store, even if URLs remain configured.
_, err = New(ctx, config.MeshMapperScopesConfig{Sources: map[string]string{"YOW": "YOW"}}, nil, scopes, nil)
if err != nil {
t.Fatal(err)
}
}
+19 -3
View File
@@ -5,13 +5,29 @@
// loaded from the database at startup.
package scopestore
import "sync"
import (
"crypto/sha256"
"strings"
"sync"
)
// Entry holds a single transport scope key and its metadata.
type Entry struct {
Name string
TransportKey []byte // 16 bytes
KeyFingerprint []byte // 8 bytes
TransportKey []byte // 16 bytes
KeyFingerprint []byte // 8 bytes
IATAs []string // nil for manual keys; imported candidates are regional
}
// FromName uses the same case-sensitive derivation for manual and imported scopes.
func FromName(name string) Entry {
if !strings.HasPrefix(name, "#") && !strings.HasPrefix(name, "$") {
name = "#" + name
}
h := sha256.Sum256([]byte(name))
key := h[:16]
fingerprint := sha256.Sum256(key)
return Entry{Name: name, TransportKey: key, KeyFingerprint: fingerprint[:8]}
}
// ScopeStore holds all known transport scope keys in memory.