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

428 lines
15 KiB
Go

// Copyright 2026 Beacon Contributors
// SPDX-License-Identifier: AGPL-3.0-or-later
package meshmapper
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
"slices"
"strings"
"testing"
"time"
"github.com/MeshCore-Beacon/beacon-server/internal/config"
"github.com/MeshCore-Beacon/beacon-server/internal/keystore"
"github.com/MeshCore-Beacon/beacon-server/internal/scopestore"
)
func TestDirectoryMissingKeyDoesNotRequest(t *testing.T) {
calls := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
_, _ = w.Write([]byte(`{"country":"CA","zones":[]}`))
}))
defer server.Close()
store := newZoneListMemory()
dir := NewDirectory(store, "")
dir.listURL = server.URL + "/get_zones.php"
now := time.Now().UTC()
if _, _, err := dir.List(context.Background(), "CA", now); err != nil {
t.Fatal(err)
}
if calls != 0 {
t.Fatal("missing key sent an anonymous request")
}
if !strings.Contains(store.rows["CA"].LastError, "unconfigured") {
t.Fatal("missing key not reported as unconfigured")
}
if _, fetched, err := dir.List(context.Background(), "CA", now.Add(PollInterval)); fetched || err != nil {
t.Fatal("missing key retried immediately")
}
}
func TestAuthenticationStatusAndRetryAfter(t *testing.T) {
now := time.Date(2026, 10, 4, 12, 0, 0, 0, time.UTC)
for _, tc := range []struct {
status int
want string
}{
{401, "authentication"}, {403, "permission"},
} {
var retry time.Time
if got := statusProblem(tc.status, http.Header{}, now, &retry); !strings.Contains(got, tc.want) {
t.Errorf("status %d not identified: %s", tc.status, got)
}
}
for _, status := range []int{429, 503} {
var retry time.Time
deadline := now.Add(48 * time.Hour)
h := http.Header{"Retry-After": []string{deadline.Format(http.TimeFormat)}}
statusProblem(status, h, now, &retry)
if !retry.Equal(deadline) {
t.Errorf("status %d ignored HTTP-date Retry-After", status)
}
}
}
// Parser and cache fixtures use localhost; authenticated requests are tested separately.
func newTestDirectory(store DirectoryStore) *Directory {
d := NewDirectory(store, "")
d.client.Transport = http.DefaultTransport
return d
}
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
// Only the socket destination changes; the production credential checks see the real URL.
func routeClient(t *testing.T, client *http.Client, server string) {
t.Helper()
u, err := url.Parse(server)
if err != nil {
t.Fatal(err)
}
client.Transport.(*authenticatedTransport).base = roundTripFunc(func(r *http.Request) (*http.Response, error) {
out := r.Clone(r.Context())
out.URL.Scheme, out.URL.Host = u.Scheme, u.Host
return http.DefaultTransport.RoundTrip(out)
})
}
func TestAuthenticatedClientEndpointsAndRedirects(t *testing.T) {
const key = "operator-integration-test-secret"
var paths []string
redirect := ""
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
paths = append(paths, r.URL.Path)
if r.Header.Get("X-API-Key") != key {
t.Error("request missing configured key")
}
if r.URL.Query().Get("key") != "" {
t.Error("key sent in query")
}
if r.Header.Get("If-None-Match") != `"saved"` {
t.Error("conditional header lost")
}
if r.URL.Path == "/get_zones.php" && r.URL.RawQuery != "country=CA" {
t.Error("country parameter changed")
}
if redirect != "" {
http.Redirect(w, r, redirect, http.StatusFound)
return
}
w.WriteHeader(http.StatusNotModified)
}))
defer server.Close()
client := newClient(key)
routeClient(t, client, server.URL)
for _, path := range []string{"get_zones.php?country=CA", "get_geojson.php", "get_scopes.php", "get_channels.php", "get_repeaters.php"} {
host := "yow.meshmapper.net"
if strings.HasPrefix(path, "get_zones") {
host = "meshmapper.net"
}
status, _, _, err := get(context.Background(), client, "Beacon-Test", "https://"+host+"/"+path, `"saved"`, MaxBody)
if err != nil || status != 304 {
t.Fatalf("%s: status=%d, error=%v", path, status, err)
}
}
if len(paths) != 5 {
t.Fatal("not all five endpoints requested")
}
for _, destination := range []string{"https://unrelated.example/get_scopes.php", "https://other.meshmapper.net/get_scopes.php", "http://yow.meshmapper.net/get_scopes.php", "/get_scopes.php"} {
redirect = destination
before := len(paths)
status, _, _, err := get(context.Background(), client, "Beacon-Test", "https://yow.meshmapper.net/get_scopes.php", `"saved"`, MaxBody)
if err != nil || status != 302 || len(paths) != before+1 {
t.Fatal("redirect followed")
}
}
}
func TestAuthenticatedClientRejectsUntrustedTargets(t *testing.T) {
client := newClient("operator-integration-test-secret")
calls := 0
client.Transport.(*authenticatedTransport).base = roundTripFunc(func(*http.Request) (*http.Response, error) {
calls++
return nil, errors.New("unexpected network call")
})
for _, endpoint := range []string{
"https://unrelated.example/get_scopes.php", "http://yow.meshmapper.net/get_scopes.php",
"https://yow.meshmapper.net.evil.example/get_scopes.php", "https://evilmeshmapper.net/get_scopes.php",
"https://user@yow.meshmapper.net/get_scopes.php", "https://yow.meshmapper.net:8443/get_scopes.php",
"https://yow.meshmapper.net/coverage.php?key=coverage-test-key", "https://yow.meshmapper.net/other.php",
"https://yow.meshmapper.net/%67et_scopes.php", "https://yow.meshmapper.net/get_scopes.php#fragment",
} {
_, _, _, err := get(context.Background(), client, "Beacon-Test", endpoint, "", MaxBody)
if !errors.Is(err, errUntrustedEndpoint) {
t.Errorf("untrusted endpoint accepted: %s", endpoint)
}
}
if calls != 0 {
t.Fatal("credentials could reach an untrusted target or Coverage API")
}
}
func TestAuthenticatedClientInvalidKeyAndTransportErrors(t *testing.T) {
for _, key := range []string{"", "bad\r\nkey", "bad\x00key", "bad key"} {
client := newClient(key)
client.Transport.(*authenticatedTransport).base = roundTripFunc(func(*http.Request) (*http.Response, error) {
t.Error("unconfigured client made a network request")
return nil, errors.New("unexpected request")
})
_, _, _, err := get(context.Background(), client, "Beacon-Test", ZonesURL+"?country=CA", "", MaxBody)
if !strings.Contains(requestProblem(err), "unconfigured") {
t.Fatal("unusable key not identified")
}
}
client := newClient("reflected-test-secret")
client.Transport.(*authenticatedTransport).base = roundTripFunc(func(*http.Request) (*http.Response, error) {
return nil, errors.New("reflected-test-secret")
})
_, _, _, err := get(context.Background(), client, "Beacon-Test", ZonesURL+"?country=CA", "", MaxBody)
if requestProblem(err) != "request failed" {
t.Fatal("transport diagnostics not sanitized")
}
}
type authSite struct {
*httptest.Server
key string
status map[string]int
after string
calls map[string]int
}
func newAuthSite(t *testing.T, key string, iatas []string) *authSite {
t.Helper()
f := &authSite{key: key, status: map[string]int{}, calls: map[string]int{}}
f.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
f.calls[r.URL.Path]++
if r.Header.Get("X-API-Key") != f.key {
t.Error("importer omitted configured key")
}
if r.URL.Query().Has("key") {
t.Error("credential in URL")
}
w.Header().Set("ETag", `"auth-v1"`)
w.Header().Set("Retry-After", f.after)
status := f.status[r.URL.Path]
if status != 0 {
w.WriteHeader(status)
_, _ = w.Write([]byte("upstream reflected " + f.key))
return
}
iata := strings.ToUpper(strings.Split(r.Host, ".")[0])
switch r.URL.Path {
case "/get_zones.php":
country := r.URL.Query().Get("country")
if country != "CA" && country != "US" {
t.Error("missing or changed country")
}
var zones []map[string]any
for _, code := range iatas {
if (code == "JFK") != (country == "US") {
continue
}
zones = append(zones, map[string]any{"code": code, "url": "https://" + strings.ToLower(code) + ".meshmapper.net/", "has_boundary": true})
}
_ = json.NewEncoder(w).Encode(map[string]any{"country": country, "zones": zones})
case "/get_geojson.php":
_, _ = w.Write([]byte(boundaryBody(iata, square)))
case "/get_scopes.php":
_, _ = w.Write(bytes.ReplaceAll(catalogue(strings.ToLower(iata)), []byte("YOW"), []byte(iata)))
case "/get_channels.php":
_, _ = w.Write(channelList(iata, "#"+strings.ToLower(iata)))
default:
t.Error("unexpected endpoint")
}
}))
t.Cleanup(f.Close)
return f
}
type authHarness struct {
dir *Directory
zones *Zones
scopes *Importer
channels *Channels
lists *zoneListMemory
bounds *zoneMemoryStore
catalogues *memoryStore
channelLists *channelMemoryStore
}
func newAuthHarness(t *testing.T, site *authSite, key string, iatas []string, saved *authHarness) *authHarness {
t.Helper()
h := &authHarness{}
if saved != nil {
h.lists, h.bounds, h.catalogues, h.channelLists = saved.lists, saved.bounds, saved.catalogues, saved.channelLists
} else {
h.lists = newZoneListMemory()
h.bounds = &zoneMemoryStore{iatas: iatas, rows: map[string]Boundary{}}
h.catalogues, h.channelLists = newMemoryStore(iatas...), newChannelMemoryStore(iatas...)
}
h.dir = NewDirectory(h.lists, key)
routeClient(t, h.dir.client, site.URL)
ctx := context.Background()
if err := h.dir.Restore(ctx); err != nil {
t.Fatal(err)
}
h.zones = NewZones(config.MeshMapperZonesConfig{Enabled: true}, h.bounds, h.dir)
if err := h.zones.Restore(ctx); err != nil {
t.Fatal(err)
}
var err error
h.scopes, err = New(ctx, config.MeshMapperScopesConfig{Enabled: true}, h.catalogues, h.dir, scopestore.New(), nil)
if err != nil {
t.Fatal(err)
}
h.channels, err = NewChannels(ctx, config.MeshMapperChannelsConfig{Enabled: true}, h.channelLists, h.dir, keystore.NewMapKeyStore(nil))
if err != nil {
t.Fatal(err)
}
return h
}
func (h *authHarness) tick(t *testing.T, n int) {
t.Helper()
for range n {
for _, refresh := range []func(context.Context) error{h.zones.Refresh, h.scopes.Refresh, h.channels.Refresh} {
if err := refresh(context.Background()); err != nil {
t.Fatal(err)
}
}
}
}
func TestAuthenticatedImportersDeploymentScopes(t *testing.T) {
for _, tc := range []struct {
name string
iatas []string
}{
{"regional", []string{"YOW"}}, {"group", []string{"YOW", "YUL"}}, {"global", []string{"YOW", "JFK"}},
} {
t.Run(tc.name, func(t *testing.T) {
key := tc.name + "-operator-test-key"
f := newAuthSite(t, key, tc.iatas)
h := newAuthHarness(t, f, key, tc.iatas, nil)
h.tick(t, 5)
for _, iata := range tc.iatas {
if h.bounds.rows[iata].Feature == nil || len(h.channelLists.members[iata]) != 1 || !slices.Contains(names(h.scopes.scopes), "#"+strings.ToLower(iata)) {
t.Fatalf("%s data not imported", iata)
}
}
countries := 1
if tc.name == "global" {
countries = 2
}
if f.calls["/get_zones.php"] != countries {
t.Fatal("country lists not shared between importers")
}
for _, endpoint := range []string{"/get_geojson.php", "/get_scopes.php", "/get_channels.php"} {
if f.calls[endpoint] != len(tc.iatas) {
t.Fatalf("%s unexpected request count", endpoint)
}
}
})
}
}
func TestAuthenticatedRefreshFailuresKeepCachedData(t *testing.T) {
for _, tc := range []struct {
name string
status int
after, want string
wait time.Duration
}{
{"invalid key", 401, "", "authentication", 0},
{"insufficient regional permissions", 403, "", "permission", 0},
{"rate limit", 429, "172800", "HTTP 429", 48 * time.Hour},
{"unavailable", 503, "172800", "HTTP 503", 48 * time.Hour},
{"missing key", 0, "", "unconfigured", 0},
} {
t.Run(tc.name, func(t *testing.T) {
const key = "failure-test-secret-canary"
var logs bytes.Buffer
oldLogger := slog.Default()
slog.SetDefault(slog.New(slog.NewTextHandler(&logs, nil)))
t.Cleanup(func() { slog.SetDefault(oldLogger) })
f := newAuthSite(t, key, []string{"YOW"})
h := newAuthHarness(t, f, key, []string{"YOW"}, nil)
h.tick(t, 2)
goodBoundary := h.bounds.rows["YOW"]
goodScope, goodChannel := h.scopes.sources[0].cache, h.channels.sources[0].cache
goodList := h.lists.rows["CA"]
if tc.name == "missing key" {
h = newAuthHarness(t, f, "", []string{"YOW"}, h)
}
for _, path := range []string{"/get_geojson.php", "/get_scopes.php", "/get_channels.php", "/get_zones.php"} {
f.status[path] = tc.status
}
f.after = tc.after
h.zones.regions[0].b.NextAttempt = time.Time{}
h.scopes.sources[0].cache.NextAttempt = time.Time{}
h.channels.sources[0].cache.NextAttempt = time.Time{}
h.tick(t, 1)
list := h.dir.lists["CA"]
list.fetchedAt, list.nextAttempt = time.Time{}, time.Time{}
if _, _, err := h.dir.List(context.Background(), "CA", time.Now().UTC()); err != nil {
t.Fatal(err)
}
boundary, scope, channel, savedList := h.bounds.rows["YOW"], h.scopes.sources[0].cache, h.channels.sources[0].cache, h.lists.rows["CA"]
for _, cache := range []Cache{scope, channel,
{LastError: boundary.LastError, AttemptedAt: boundary.AttemptedAt, NextAttempt: boundary.NextAttempt},
{LastError: savedList.LastError, AttemptedAt: savedList.AttemptedAt, NextAttempt: savedList.NextAttempt},
} {
if !strings.Contains(cache.LastError, tc.want) {
t.Fatalf("failure not recorded: %s", cache.LastError)
}
if tc.wait != 0 && cache.NextAttempt.Sub(cache.AttemptedAt) != tc.wait {
t.Fatal("Retry-After not retained")
}
}
if !bytes.Equal(boundary.Feature, goodBoundary.Feature) || !boundary.CheckedAt.Equal(goodBoundary.CheckedAt) || boundary.ETag != goodBoundary.ETag ||
!bytes.Equal(scope.Payload, goodScope.Payload) || !scope.CheckedAt.Equal(goodScope.CheckedAt) || scope.ETag != goodScope.ETag ||
!bytes.Equal(channel.Payload, goodChannel.Payload) || !channel.CheckedAt.Equal(goodChannel.CheckedAt) || channel.ETag != goodChannel.ETag ||
!bytes.Equal(savedList.Payload, goodList.Payload) || !savedList.FetchedAt.Equal(goodList.FetchedAt) || savedList.ETag != goodList.ETag {
t.Fatal("failed refresh replaced data or advanced freshness")
}
for path, calls := range f.calls {
want := 2
if tc.name == "missing key" {
want = 1
}
if calls != want {
t.Fatalf("%s: unexpected retries or missing-key requests: %d", path, calls)
}
}
before := fmt.Sprint(f.calls)
restarted := newAuthHarness(t, f, key, []string{"YOW"}, h)
restarted.tick(t, 5)
if fmt.Sprint(f.calls) != before {
t.Fatal("restart retried before backoff elapsed")
}
if !restarted.lists.rows["CA"].AttemptedAt.Equal(savedList.AttemptedAt) ||
!restarted.scopes.sources[0].cache.AttemptedAt.Equal(scope.AttemptedAt) ||
!restarted.channels.sources[0].cache.AttemptedAt.Equal(channel.AttemptedAt) ||
!restarted.zones.regions[0].b.AttemptedAt.Equal(boundary.AttemptedAt) {
t.Fatal("restart attempted a refresh before persisted backoff elapsed")
}
if restarted.zones.regions[0].b.Feature == nil || len(restarted.channels.sources[0].entries) != 1 || len(names(restarted.scopes.scopes)) != 1 || len(restarted.dir.lists["CA"].zones) != 1 {
t.Fatal("restart lost last successful cache")
}
if strings.Contains(logs.String(), key) {
t.Fatal("key leaked to logs")
}
})
}
}