Files
MrAlders0n 041d9c1f45 release: v2.0.2
Release the changes on `dev` as v2.0.2: authenticated MeshMapper imports and the traffic-sorted observer directory, including rolling seven-day counts from available history. Bump the API version and regenerate Swagger.

MeshMapper integration requires an API key. Regional admins’ existing per-region or grouped-region coverage keys also work for these APIs. Deployments should set `MESHMAPPER_API_KEY` and restart Beacon.

Validation: `go build ./...`, `gofmt -l .` (empty), `go vet ./...`, `go test ./...`, `govulncheck ./...`, and race tests for MeshMapper and scopestore passed in a clean checkout. PostgreSQL integration tests require CI because Docker is unavailable locally.
2026-10-05 18:35:41 -04:00

413 lines
13 KiB
Go

// Copyright 2026 Beacon Contributors
// SPDX-License-Identifier: AGPL-3.0-or-later
package meshmapper
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"regexp"
"strconv"
"strings"
"time"
"github.com/MeshCore-Beacon/beacon-server/internal/config"
"github.com/MeshCore-Beacon/beacon-server/internal/iatadb"
)
const (
ZonesURL = "https://meshmapper.net/get_zones.php"
MaxZoneList = 1 << 20
MaxBoundaryBody = 8 << 20 // outlines are never simplified
// MeshMapper asks clients to allow 60-120s; a request given up on may still use the day's call.
requestTimeout = 60 * time.Second
refreshTimeout = requestTimeout + 30*time.Second // room for the DB writes around a slow request
zoneListFresh = config.MinZonesRefresh // get_zones.php allows one request per country per 23.5h
zoneFailureRetry = config.MinZonesRefresh // failed requests count too
)
var zoneSite = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*\.meshmapper\.net$`)
// Boundary is one IATA's imported MeshMapper outline and its fetch state.
type Boundary struct {
IATA, URL string
Feature json.RawMessage
ETag string
CheckedAt, AttemptedAt, NextAttempt time.Time
LastError string
}
type ZoneStore interface {
ListKnownIATAs(ctx context.Context) ([]string, error)
// ListHeardIATAs returns IATAs observers report from; group imports create IATAs too.
ListHeardIATAs(ctx context.Context) ([]string, error)
PruneZoneBoundaries(ctx context.Context, keep []string) ([]string, error)
ListZoneBoundaries(ctx context.Context) ([]Boundary, error)
SaveZoneBoundary(ctx context.Context, b Boundary) error
ListRegionState(ctx context.Context) ([]RegionState, error)
ListIATADetails(ctx context.Context) ([]IATADetails, error)
UpsertIATADetails(ctx context.Context, iata, name string, lat, lng *float64) error
SaveImportedRegion(ctx context.Context, r RegionState) (bool, error)
PruneImportedRegions(ctx context.Context, keep []string) ([]string, error)
}
type zoneRegion struct {
country string
b Boundary
}
// Zones is owned by one background task; listeners are wired before Restore.
type Zones struct {
store ZoneStore
enabled bool
seen map[string]bool
interval time.Duration
client *http.Client
dir *Directory
boundsURL func(site string) (string, bool)
regions []*zoneRegion
onChange func(ctx context.Context, iata string)
onUpdate func(imported map[string]json.RawMessage)
importGroups bool
groupsSynced bool
synced syncInputs
onRegions func(ctx context.Context)
configured map[string]bool
onIATAs func(ctx context.Context)
}
func NewZones(cfg config.MeshMapperZonesConfig, store ZoneStore, dir *Directory) *Zones {
return &Zones{store: store, enabled: cfg.Enabled, importGroups: cfg.Enabled && cfg.ImportGroups, seen: map[string]bool{}, interval: cfg.Interval(), dir: dir,
boundsURL: boundaryEndpoint, client: dir.client}
}
// OnChange receives each IATA whose served border changed, for cache invalidation.
func (z *Zones) OnChange(fn func(ctx context.Context, iata string)) { z.onChange = fn }
// OnUpdate receives every imported boundary whenever the set changes.
func (z *Zones) OnUpdate(fn func(imported map[string]json.RawMessage)) { z.onUpdate = fn }
// Restore prunes imports for unknown IATAs (all of them when disabled), and
// imported regions when group import is off, then loads saved boundaries,
// without making HTTP requests.
func (z *Zones) Restore(ctx context.Context) error {
if !z.importGroups {
if err := z.pruneGroups(ctx); err != nil {
return err
}
}
iatas, err := z.store.ListKnownIATAs(ctx)
if err != nil {
return fmt.Errorf("list IATAs for MeshMapper boundaries: %w", err)
}
keep := []string{}
if z.enabled {
keep = iatas
}
pruned, err := z.store.PruneZoneBoundaries(ctx, keep)
if err != nil {
return fmt.Errorf("prune MeshMapper boundaries: %w", err)
}
for _, iata := range pruned {
slog.Info("MeshMapper boundary removed", "component", "meshmapper.zones", "iata", iata)
z.changed(ctx, iata)
}
if !z.enabled {
return nil
}
saved, err := z.store.ListZoneBoundaries(ctx)
if err != nil {
return fmt.Errorf("restore MeshMapper boundaries: %w", err)
}
byIATA := map[string]Boundary{}
for _, b := range saved {
byIATA[b.IATA] = b
}
z.track(iatas, byIATA)
z.publish()
return nil
}
// track adds a region for each IATA not seen before.
func (z *Zones) track(iatas []string, saved map[string]Boundary) {
for _, iata := range iatas {
if z.seen[iata] {
continue
}
z.seen[iata] = true
country := iatadb.CountryFor(iata)
if country == "" {
slog.Warn("MeshMapper boundary skipped: IATA has no known country", "component", "meshmapper.zones", "iata", iata)
continue
}
b, ok := saved[iata]
if !ok {
b = Boundary{IATA: iata}
}
z.regions = append(z.regions, &zoneRegion{country: country, b: b})
if ok {
z.log(b, "restored")
}
}
}
// Refresh makes at most one request. The scheduler serializes calls every 15s.
func (z *Zones) Refresh(ctx context.Context) (err error) {
parent := ctx
defer func() {
if parent.Err() != nil {
err = nil
}
}()
ctx, cancel := context.WithTimeout(ctx, refreshTimeout)
defer cancel()
now := time.Now().UTC()
// IATAs are created from traffic, so pick up new ones every tick.
iatas, err := z.store.ListKnownIATAs(ctx)
if err != nil {
return fmt.Errorf("list IATAs for MeshMapper boundaries: %w", err)
}
z.track(iatas, nil)
if err := z.syncDirectory(ctx, iatas); err != nil {
return err
}
for _, r := range z.regions {
if now.Before(r.b.NextAttempt) {
continue
}
zones, fetched, err := z.dir.List(ctx, r.country, now)
if err != nil || fetched {
return err
}
if zones == nil {
continue
}
return z.refresh(ctx, r, zones, now)
}
return nil
}
func (z *Zones) refresh(ctx context.Context, r *zoneRegion, zones map[string]zoneEntry, now time.Time) error {
update := Boundary{IATA: r.b.IATA, URL: r.b.URL, AttemptedAt: now, NextAttempt: now.Add(z.interval)}
var retryAt time.Time
entry, listed := zones[r.b.IATA]
endpoint, valid := "", false
if listed {
update.URL = entry.url
endpoint, valid = z.boundsURL(entry.url)
}
switch {
case !listed:
update.LastError = "not listed"
case !entry.hasBoundary:
update.LastError = "no boundary"
case !valid:
update.LastError = "invalid site URL"
default:
etag := ""
if r.b.Feature != nil {
etag = r.b.ETag
}
// Recorded first: an abandoned request may still have used the region's call.
attempt := Boundary{IATA: r.b.IATA, URL: update.URL, AttemptedAt: now, NextAttempt: now.Add(min(z.interval, zoneFailureRetry)), LastError: "no response"}
r.b.NextAttempt = attempt.NextAttempt
if err := z.store.SaveZoneBoundary(ctx, attempt); err != nil {
return fmt.Errorf("persist MeshMapper boundary %s: %w", r.b.IATA, err)
}
r.b.URL, r.b.AttemptedAt, r.b.LastError = attempt.URL, now, attempt.LastError
status, body, header, err := get(ctx, z.client, "Beacon-MeshMapper-Zones/1", endpoint, etag, MaxBoundaryBody)
if ctx.Err() != nil {
return ctx.Err()
}
switch {
case err != nil:
update.LastError = requestProblem(err)
case status == http.StatusOK:
feature, decodeErr := decodeBoundary(body, r.b.IATA)
switch {
case decodeErr != nil:
update.LastError = "invalid response" // includes truncated bodies; retry
case feature == nil:
update.LastError = "no boundary"
default:
update.Feature = feature
}
case status == http.StatusNotModified:
if r.b.Feature == nil {
update.LastError = "304 without cached boundary"
}
default:
update.LastError = statusProblem(status, header, now, &retryAt)
}
if update.LastError == "" {
etag := header.Get("ETag")
if len(etag) > 256 || strings.ContainsAny(etag, "\r\n") {
update.LastError, update.Feature = "invalid ETag", nil
} else {
if status == http.StatusNotModified && etag == "" {
etag = r.b.ETag
}
update.ETag, update.CheckedAt = etag, now
}
}
if update.LastError != "" && update.LastError != "no boundary" {
update.NextAttempt = now.Add(min(z.interval, zoneFailureRetry))
if retryAt.After(update.NextAttempt) {
update.NextAttempt = retryAt
}
}
}
if err := z.store.SaveZoneBoundary(ctx, update); err != nil {
r.b.NextAttempt = update.NextAttempt // avoid retrying every tick during a DB outage
return fmt.Errorf("persist MeshMapper boundary %s: %w", r.b.IATA, err)
}
changed := update.Feature != nil && !bytes.Equal(update.Feature, r.b.Feature)
if update.Feature != nil {
r.b.Feature = update.Feature
}
if !update.CheckedAt.IsZero() {
r.b.CheckedAt, r.b.ETag = update.CheckedAt, update.ETag
}
r.b.URL, r.b.AttemptedAt, r.b.NextAttempt, r.b.LastError = update.URL, now, update.NextAttempt, update.LastError
if changed {
z.changed(ctx, r.b.IATA)
z.publish()
}
z.log(r.b, "checked")
return nil
}
// get never returns response bodies for failed requests.
func get(ctx context.Context, client *http.Client, agent, endpoint, etag string, limit int64) (int, []byte, http.Header, error) {
request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return 0, nil, nil, err
}
request.Header.Set("Accept", "application/json")
request.Header.Set("User-Agent", agent)
if etag != "" {
request.Header.Set("If-None-Match", etag)
}
response, err := client.Do(request)
if err != nil {
return 0, nil, nil, err
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return response.StatusCode, nil, response.Header, nil
}
body, err := io.ReadAll(io.LimitReader(response.Body, limit+1))
if err != nil || int64(len(body)) > limit {
return response.StatusCode, nil, response.Header, nil
}
return response.StatusCode, body, response.Header, nil
}
// statusProblem describes a failed response and reads Retry-After on 429/503.
func statusProblem(status int, header http.Header, now time.Time, retryAt *time.Time) string {
if status == 0 {
return "request failed" // don't persist untrusted error text
}
if status == http.StatusUnauthorized {
return "authentication failed (HTTP 401)"
}
if status == http.StatusForbidden {
return "permission denied (HTTP 403)"
}
if status == http.StatusTooManyRequests || status == http.StatusServiceUnavailable {
if seconds, err := strconv.ParseInt(header.Get("Retry-After"), 10, 64); err == nil && seconds > 0 && seconds <= int64((1<<63-1)/time.Second) {
*retryAt = now.Add(time.Duration(seconds) * time.Second)
} else if deadline, err := http.ParseTime(header.Get("Retry-After")); err == nil && deadline.After(now) {
*retryAt = deadline
}
}
return fmt.Sprintf("HTTP %d", status)
}
func (z *Zones) changed(ctx context.Context, iata string) {
if z.onChange != nil {
z.onChange(ctx, iata)
}
}
func (z *Zones) publish() {
if z.onUpdate == nil {
return
}
imported := map[string]json.RawMessage{}
for _, r := range z.regions {
if r.b.Feature != nil {
imported[r.b.IATA] = r.b.Feature
}
}
z.onUpdate(imported)
}
func (z *Zones) log(b Boundary, action string) {
level := slog.LevelInfo
if b.LastError != "" && b.LastError != "no boundary" && b.LastError != "not listed" {
level = slog.LevelWarn
}
slog.Log(context.Background(), level, "MeshMapper boundary "+action, "component", "meshmapper.zones", "iata", b.IATA,
"source", b.URL, "imported", b.Feature != nil, "checked_at", b.CheckedAt, "next_attempt", b.NextAttempt, "last_error", b.LastError)
}
func boundaryEndpoint(site string) (string, bool) { return siteEndpoint(site, "get_geojson.php") }
// siteEndpoint accepts only a published region site root.
func siteEndpoint(site, file string) (string, bool) {
u, err := url.Parse(site)
if err != nil || u.Scheme != "https" || u.User != nil || !zoneSite.MatchString(u.Host) ||
(u.Path != "" && u.Path != "/") || u.RawQuery != "" || u.ForceQuery || u.Fragment != "" {
return "", false
}
return "https://" + u.Host + "/" + file, true
}
// decodeBoundary returns nil when MeshMapper has no usable outline for the region.
func decodeBoundary(body []byte, iata string) (json.RawMessage, error) {
var collection struct {
Type string `json:"type"`
Features []json.RawMessage `json:"features"`
}
if err := json.Unmarshal(body, &collection); err != nil {
return nil, err
}
if collection.Type != "FeatureCollection" || len(collection.Features) != 1 {
return nil, fmt.Errorf("expected one region feature")
}
var feature struct {
Geometry json.RawMessage `json:"geometry"`
Properties struct {
Code string `json:"code"`
} `json:"properties"`
}
if err := json.Unmarshal(collection.Features[0], &feature); err != nil {
return nil, err
}
if !strings.EqualFold(feature.Properties.Code, iata) {
return nil, fmt.Errorf("feature is for another region")
}
if len(feature.Geometry) == 0 || string(feature.Geometry) == "null" {
return nil, nil
}
validated, err := config.ValidateBorder(collection.Features[0])
if err != nil {
return nil, err
}
// Foreign marking must accept anything the map serves.
if _, err := config.BuildLocalBorders(nil, map[string]json.RawMessage{iata: validated}); err != nil {
return nil, err
}
return validated, nil
}