mirror of
https://github.com/MeshCore-Beacon/beacon-server.git
synced 2026-10-16 11:39:53 +00:00
295 lines
9.3 KiB
Go
295 lines
9.3 KiB
Go
// 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
|
|
}
|