mirror of
https://github.com/MeshCore-Beacon/beacon-server.git
synced 2026-10-23 06:22:52 +00:00
feat(scopes): import cached regional MeshMapper catalogues
This commit is contained in:
1 parent
831148ff5f
commit
c18e059e0d
25 files changed
+1260
-49
No files matched your search
Vendored
+5
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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]
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user