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.
This commit is contained in:
MrAlders0n
2026-10-05 18:35:41 -04:00
committed by GitHub
parent 3828e689ed
commit 041d9c1f45
39 changed files with 2442 additions and 120 deletions
+45
View File
@@ -63,6 +63,51 @@ and the fully annotated `config.yaml` lives in beacon-docs as well; the copy her
starter. Migrations run on startup. The API listens on `LISTEN_ADDR` (default `:8080`) and
Swagger is at `http://localhost:8080/swagger/index.html`.
### MeshMapper authentication
MeshMapper APIs require either a regional API key or a grouped-region API key
covering multiple regions. Your local MeshMapper regional or grouped-region admin
can generate these keys.
Set `MESHMAPPER_API_KEY` in each deployment's private environment or
gitignored `.env`; it overrides `meshmapper.api_key` in `config.yaml`, including
when empty. The examples leave the key empty. Never put it in browser settings
or use the mobile app's App key. Restart Beacon after changing the key.
A regional deployment needs access to its IATA; a group deployment needs access
to every member IATA, including members added by `import_groups`. API keys do not
remove rate limits or change the `get_zones.php?country=...` request shape.
IP exemptions are not authentication.
Beacon sends `X-API-Key` to `get_zones.php`, `get_geojson.php`, `get_scopes.php`,
and `get_channels.php`. The shared client also supports that header for
`get_repeaters.php`; this repository does not currently fetch repeaters.
Requests are restricted to those HTTPS MeshMapper endpoints and never follow
redirects. Coverage consumers keep their separate keys, scopes, quotas, and
supported `?key=` authentication; this client does not call `coverage.php`.
Missing keys are reported as unconfigured without stopping Beacon or deleting
cached imports. Failed refreshes record `last_error`, retain the last successful
data and freshness timestamp, and report 401 as an authentication failure or
403 as a permission failure. Inspect the `meshmapper.zones`, `meshmapper.scopes`,
and `meshmapper.channels` logs for freshness and the next attempt. No anonymous
fallback occurs. Existing refresh limits still apply, including longer
`Retry-After` delays on 429/503.
Use the environment setting when backups are enabled. Backup exports preserve
saved YAML verbatim and refuse a nonempty `meshmapper.api_key` in that file so
the credential cannot enter a backup download. Clear that YAML value after
moving the key to the environment; environment secrets are excluded from backups.
Deployment coordination is required with MeshMapper Server before enforcement:
confirm `X-API-Key` support, obtain and verify the integration key's API and IATA
permissions, and confirm these requests do not consume Coverage quota. Header
support must not be assumed deployed. The first four endpoints can enforce
authentication before the app rollout; repeater enforcement waits for app 1.4.1
and its forced update. Key issuance, permissions, quotas, and enforcement switches
remain MeshMapper Server work, tracked in
[MeshMapper_Server#431](https://github.com/MeshMapper/MeshMapper_Server/issues/431).
To run the web frontend against it, see
[Running the full stack locally](https://github.com/MeshCore-Beacon/beacon-docs/blob/main/CONTRIBUTING.md#running-the-full-stack-locally).
+5 -2
View File
@@ -45,7 +45,7 @@ import (
var version = "dev"
// @title MeshCore Beacon API
// @version 2.0.1
// @version 2.0.2
// @description MeshCore network observation backend. Ingests LoRa packets from MQTT brokers, stores in PostgreSQL, and streams live events via WebSocket.
// @description REST requests share a configurable per-client rate limit (default 300/minute and a 300-request one-second burst cap). Exceeded limits return HTTP 429 with error.code=rate_limited and a Retry-After header in seconds. CORS preflights and WebSocket upgrades do not consume this API budget.
// @description WebSocket upgrade attempts at /ws have a configurable per-client limit (default 10/minute, including failed handshakes). Rate exhaustion returns HTTP 429 with Retry-After before upgrade. An accepted socket exceeding the concurrent cap closes with code 1013 before hello; established connections remain open.
@@ -197,8 +197,11 @@ func main() {
scopes.Load(scopeEntries)
scopes.SetManualMembers(cfg.ManualScopeMembers())
slog.Info(fmt.Sprintf("loaded %d transport scopes", len(scopeEntries)), "component", "startup")
directory := meshmapper.NewDirectory(store)
directory := meshmapper.NewDirectory(store, cfg.MeshMapper.APIKey)
if cfg.MeshMapper.Scopes.Enabled || cfg.MeshMapper.Zones.Enabled || cfg.MeshMapper.Channels.Enabled {
if cfg.MeshMapper.APIKey == "" {
slog.Warn("MeshMapper integration unconfigured: set MESHMAPPER_API_KEY or meshmapper.api_key; retaining cached data", "component", "meshmapper")
}
restoreCtx, cancelRestore := context.WithTimeout(ctx, 10*time.Second)
err = directory.Restore(restoreCtx)
cancelRestore()
+9 -1
View File
@@ -46,7 +46,14 @@ ratelimit:
# A 429 returns error.code=rate_limited and Retry-After (1 or 60 seconds).
burst: 300
# MeshMapper integrations (optional, no API key). Import transport scopes and
# MeshMapper integrations (optional, require a regional or grouped-region API key).
# Your local MeshMapper regional or grouped-region admin can generate the key.
# A grouped-region key covers multiple regions.
# MESHMAPPER_API_KEY overrides meshmapper.api_key, even when empty. Use the
# environment for secrets, especially with backups; never use the mobile App key.
# Missing keys leave imports unconfigured; cached data and the rest of Beacon remain.
# 401/403 failures retain cached data and report authentication/permission errors.
# Import transport scopes and
# IATA border outlines from MeshMapper instead of maintaining scopes: and
# iatas.*.borderFile by hand.
#
@@ -64,6 +71,7 @@ ratelimit:
# Region-filtered scope lists and scope stats show imported names only under the
# IATAs whose catalogue lists them; IATAs without a catalogue show manual scopes only.
#meshmapper:
# api_key: ""
# scopes:
# enabled: false
# refresh_interval: 24h # 1h-24h (MeshMapper allows one get_scopes.php per region per 55m); one source per 15s tick
+2 -2
View File
@@ -83,7 +83,7 @@ func TestMeshMapperCataloguePostgres(t *testing.T) {
t.Fatal(row, err)
}
scopes := scopestore.New()
_, err = meshmapper.New(ctx, config.MeshMapperScopesConfig{Enabled: true}, store, meshmapper.NewDirectory(store), scopes, manualRows)
_, err = meshmapper.New(ctx, config.MeshMapperScopesConfig{Enabled: true}, store, meshmapper.NewDirectory(store, ""), scopes, manualRows)
if err != nil || len(scopes.Entries()) != 2 {
t.Fatal("restart did not restore imported membership", err)
}
@@ -106,7 +106,7 @@ func TestMeshMapperCataloguePostgres(t *testing.T) {
if err := store.SaveScopeCatalogue(ctx, "YOW", url, update, nil); err != nil {
t.Fatal(err)
}
_, err = meshmapper.New(ctx, config.MeshMapperScopesConfig{Enabled: true}, store, meshmapper.NewDirectory(store), scopes, manualRows)
_, err = meshmapper.New(ctx, config.MeshMapperScopesConfig{Enabled: true}, store, meshmapper.NewDirectory(store, ""), scopes, manualRows)
if err != nil || len(scopes.Entries()) != 1 {
t.Fatal("removed imported membership remained active", err)
}
+31
View File
@@ -0,0 +1,31 @@
// Copyright 2026 Beacon Contributors
// SPDX-License-Identifier: AGPL-3.0-or-later
package db
import (
"context"
"encoding/json"
"time"
sqlc "github.com/MeshCore-Beacon/beacon-server/db/sqlc"
"github.com/MeshCore-Beacon/beacon-server/internal/api"
)
func (s *Store) ListObserverDirectory(ctx context.Context, query api.ObserverDirectoryQuery) (*api.ObserverDirectory, error) {
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
raw, err := s.q.ListObserverDirectory(ctx, sqlc.ListObserverDirectoryParams{
Column1: ts(time.UnixMilli(query.Since)), Column2: ts(time.UnixMilli(query.Until)), Column3: query.IATAs,
Column4: query.Broker, Column5: query.Name, Column6: query.Status, Column7: query.Scope, Column8: query.Type, Column9: query.Sort,
Column10: query.Cursor, Column11: query.Limit, Column12: query.MatchNone,
})
if err != nil {
return nil, err
}
var page api.ObserverDirectory
if err := json.Unmarshal([]byte(raw), &page); err != nil {
return nil, err
}
return &page, nil
}
+396
View File
@@ -0,0 +1,396 @@
// Copyright 2026 Beacon Contributors
// SPDX-License-Identifier: AGPL-3.0-or-later
package db
import (
"context"
"fmt"
"slices"
"testing"
"time"
sqlc "github.com/MeshCore-Beacon/beacon-server/db/sqlc"
"github.com/MeshCore-Beacon/beacon-server/internal/api"
"github.com/jackc/pgx/v5"
)
func TestObserverDirectoryPagesPostgres(t *testing.T) {
ctx, pool := schemaPool(t)
if err := RunMigrations(ctx, pool); err != nil {
t.Fatal(err)
}
_, err := pool.Exec(ctx, `INSERT INTO iata_codes(iata) VALUES ('YVR'),('YYJ');
INSERT INTO observers(id,public_key,display_name,observer_type,last_iata,last_seen) SELECT lpad(to_hex(i),32,'0')::uuid,int4send(i),'Observer '||lpad(i::text,3,'0'),CASE WHEN i%2=0 THEN 'type-a' ELSE 'type-b' END,'YVR',now() FROM generate_series(1,205)i;
INSERT INTO analytics_rollup_hours(hour,status) VALUES ('2026-01-01 00:00:00+00','complete');
INSERT INTO analytics_hourly_observer_identity(hour,iata,observer_id,observation_count) SELECT '2026-01-01 00:00:00+00','YVR',id,CASE WHEN display_name IN ('Observer 001','Observer 002') THEN 100 ELSE 1 END FROM observers WHERE display_name <> 'Observer 205';`)
if err != nil {
t.Fatal(err)
}
tx, err := pool.BeginTx(ctx, pgx.TxOptions{AccessMode: pgx.ReadOnly})
if err != nil {
t.Fatal(err)
}
defer tx.Rollback(ctx)
store := &Store{q: sqlc.New(tx)}
q := api.ObserverDirectoryQuery{IATAs: []string{"YVR"}, Sort: "traffic", Since: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC).UnixMilli(), Until: time.Date(2026, 1, 1, 1, 0, 0, 0, time.UTC).UnixMilli(), Limit: 2}
first, err := store.ListObserverDirectory(ctx, q)
if err != nil {
t.Fatal(err)
}
if len(first.Items) != 2 || *first.Items[0].DisplayName != "Observer 001" || *first.Items[1].DisplayName != "Observer 002" || !first.HasMore || *first.MaxObservationCount != 100 {
t.Fatalf("first %+v", first)
}
if len(first.ObserverTypes) != 2 {
t.Fatalf("facets %+v", first.ObserverTypes)
}
seen := map[string]bool{}
page := first
for {
for _, item := range page.Items {
if seen[item.ID.String()] {
t.Fatal("duplicate observer")
}
seen[item.ID.String()] = true
if item.IATA != "YVR" || *item.DisplayName == "Changed" {
t.Fatal("metadata moved between pages")
}
}
if !page.HasMore {
break
}
q.Cursor = *page.NextCursor
q.Limit = 37
page, err = store.ListObserverDirectory(ctx, q)
if err != nil {
t.Fatal(err)
}
if *page.MaxObservationCount != 100 || page.WindowEnd != first.WindowEnd {
t.Fatal("unchanged data produced different metrics")
}
}
if len(seen) != 205 {
t.Fatalf("got %d observers", len(seen))
}
if *page.Items[len(page.Items)-1].ObservationCount != 0 {
t.Fatal("missing explicit zero")
}
q.Cursor = 0
if _, err := pool.Exec(ctx, `UPDATE analytics_hourly_observer_identity SET observation_count=900; UPDATE observers SET display_name='Changed'`); err != nil {
t.Fatal(err)
}
refreshed, err := store.ListObserverDirectory(ctx, q)
if err != nil {
t.Fatal(err)
}
if *refreshed.MaxObservationCount != 900 || *refreshed.Items[0].DisplayName != "Changed" {
t.Fatal("directory did not reflect current data")
}
q.Cursor = 1000
empty, err := store.ListObserverDirectory(ctx, q)
if err != nil || len(empty.Items) != 0 || empty.HasMore || empty.NextCursor != nil {
t.Fatalf("terminal page %+v, %v", empty, err)
}
}
func TestObserverDirectoryFiltersAndCoveragePostgres(t *testing.T) {
ctx, pool := schemaPool(t)
if err := RunMigrations(ctx, pool); err != nil {
t.Fatal(err)
}
_, err := pool.Exec(ctx, `INSERT INTO iata_codes(iata) VALUES ('YVR'),('YYJ');
INSERT INTO observers(id,public_key,display_name,observer_type,last_iata,last_seen,last_status_at) VALUES
('00000000-0000-0000-0000-000000000001','\x01','Zulu','a','YVR',now(),now()),
('00000000-0000-0000-0000-000000000002','\x02','alpha','b','YVR',now()-interval '1 hour',now()-interval '1 hour'),
('00000000-0000-0000-0000-000000000003','\x03','alpha','a','YVR',now(),now()),
('00000000-0000-0000-0000-000000000004','\x04','Other','b','YYJ',now(),now());
INSERT INTO observer_brokers(observer_id,broker_name) VALUES ('00000000-0000-0000-0000-000000000001','mqtt1');
INSERT INTO transport_scopes(id,name,transport_key,key_fingerprint) VALUES (1,'#test','\x01','\x01');
INSERT INTO observer_scopes(observer_id,scope_id) VALUES ('00000000-0000-0000-0000-000000000001',1);
INSERT INTO analytics_rollup_hours(hour,status) VALUES ('2026-01-01 00:00:00+00','complete');
INSERT INTO analytics_hourly_observer_identity(hour,iata,observer_id,observation_count) VALUES
('2026-01-01 00:00:00+00','YVR','00000000-0000-0000-0000-000000000001',5),
('2026-01-01 00:00:00+00','YYJ','00000000-0000-0000-0000-000000000001',999),
('2026-01-01 00:00:00+00','YVR','00000000-0000-0000-0000-000000000002',8),
('2025-12-31 00:00:00+00','YVR','00000000-0000-0000-0000-000000000003',100);`)
if err != nil {
t.Fatal(err)
}
store := New(pool, 0, 0)
base := api.ObserverDirectoryQuery{IATAs: []string{"YVR"}, Sort: "traffic", Since: 1767225600000, Until: 1767229200000, Limit: 1}
cases := []struct {
name string
apply func(*api.ObserverDirectoryQuery)
ids []byte
}{
{"traffic", func(q *api.ObserverDirectoryQuery) {}, []byte{2, 1, 3}},
{"name ties", func(q *api.ObserverDirectoryQuery) { q.Sort = "name" }, []byte{2, 3, 1}},
{"name filter", func(q *api.ObserverDirectoryQuery) { q.Name = "zUL" }, []byte{1}},
{"type", func(q *api.ObserverDirectoryQuery) { q.Type = "a" }, []byte{1, 3}},
{"broker", func(q *api.ObserverDirectoryQuery) { q.Broker = "mqtt1" }, []byte{1}},
{"scope", func(q *api.ObserverDirectoryQuery) { q.Scope = "#test" }, []byte{1}},
{"status", func(q *api.ObserverDirectoryQuery) { q.Status = "offline" }, []byte{2}},
{"empty region", func(q *api.ObserverDirectoryQuery) { q.MatchNone = true }, []byte{}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
q := base
tc.apply(&q)
page, err := store.ListObserverDirectory(ctx, q)
if err != nil {
t.Fatal(err)
}
var ids []byte
for {
for _, o := range page.Items {
ids = append(ids, o.ID[15])
if o.ID[15] == 1 && *o.ObservationCount != 5 {
t.Fatal("count included other IATA")
}
if o.ID[15] == 3 && *o.ObservationCount != 0 {
t.Fatal("count included other hour")
}
}
if !page.HasMore {
break
}
q.Cursor = *page.NextCursor
page, err = New(pool, 0, 0).ListObserverDirectory(ctx, q)
if err != nil {
t.Fatal(err)
}
}
if !slices.Equal(ids, tc.ids) {
t.Fatalf("ids %v want %v", ids, tc.ids)
}
if tc.name == "type" && len(page.ObserverTypes) != 2 {
t.Fatal("type filter hid alternatives")
}
if tc.name == "name ties" && *page.MaxObservationCount != 8 {
t.Fatal("bar maximum followed page instead of full result")
}
})
}
for _, status := range []string{"partial", "missing"} {
if _, err := pool.Exec(ctx, `UPDATE analytics_rollup_hours SET status=$1`, status); err != nil {
t.Fatal(err)
}
page, err := store.ListObserverDirectory(ctx, base)
if err != nil {
t.Fatal(err)
}
want := "partial"
if status == "missing" {
want = "unavailable"
}
if page.Coverage.Status != want || page.EffectiveSort != "traffic" || page.MaxObservationCount == nil || *page.MaxObservationCount != 0 || page.Items[0].ObservationCount == nil || *page.Items[0].ObservationCount != 0 {
t.Fatalf("incomplete history suppressed numeric counts or traffic sorting: %+v", page)
}
}
}
func directoryFixture(t *testing.T) (context.Context, pgx.Tx, *Store) {
t.Helper()
ctx, tx := retentionTx(t)
applyBaseline(t, ctx, tx)
if _, err := tx.Exec(ctx, `INSERT INTO observers(id,public_key,display_name,observer_type,last_iata,last_seen,last_status_at) VALUES
('00000000-0000-0000-0000-000000000001','\x01','Zulu','a','YVR',now(),now()),
('00000000-0000-0000-0000-000000000002','\x02','alpha','b','YVR',now()-interval '1 hour',now()-interval '1 hour'),
('00000000-0000-0000-0000-000000000003','\x03','alpha','a','YVR',now(),now()),
('00000000-0000-0000-0000-000000000004','\x04','Other','b','YYJ',now(),now());
INSERT INTO observer_brokers(observer_id,broker_name) VALUES ('00000000-0000-0000-0000-000000000001','mqtt1');
INSERT INTO transport_scopes(id,name,transport_key,key_fingerprint) VALUES (1,'#test','\x01','\x01');
INSERT INTO observer_scopes(observer_id,scope_id) VALUES ('00000000-0000-0000-0000-000000000001',1);`); err != nil {
t.Fatal(err)
}
return ctx, tx, &Store{q: sqlc.New(tx)}
}
func directoryObservation(t *testing.T, ctx context.Context, tx pgx.Tx, observer int, iata string, at time.Time) {
t.Helper()
_, err := tx.Exec(ctx, `WITH packet AS (
INSERT INTO packets(packet_hash,payload_type,payload_version,route_type,raw_payload,raw_header,first_heard_at,last_heard_at)
VALUES (decode(md5(gen_random_uuid()::text),'hex'),4,0,1,'\x00','\x00',$1,$1) RETURNING packet_hash
) INSERT INTO packet_observations(packet_hash,observer_id,iata,heard_at,path_length_byte,hash_size,hop_count)
SELECT packet_hash,$2,$3,$1,0,1,0 FROM packet`, at, fmt.Sprintf("00000000-0000-0000-0000-%012d", observer), iata)
if err != nil {
t.Fatal(err)
}
}
func TestObserverDirectoryMixedHistoryPostgres(t *testing.T) {
ctx, tx, store := directoryFixture(t)
_, err := tx.Exec(ctx, `
INSERT INTO analytics_rollup_hours(hour,status) VALUES
('2026-01-01 00:00:00+00','complete'), ('2026-01-01 01:00:00+00','complete'),
('2026-01-01 02:00:00+00','partial'), ('2026-01-01 03:00:00+00','missing'),
('2026-01-01 04:00:00+00','complete');
INSERT INTO analytics_hourly_observer_identity(hour,iata,observer_id,observation_count) VALUES
('2026-01-01 00:00:00+00','YVR','00000000-0000-0000-0000-000000000001',900),
('2026-01-01 01:00:00+00','YVR','00000000-0000-0000-0000-000000000001',10),
('2026-01-01 01:00:00+00','YVR','00000000-0000-0000-0000-000000000002',12),
('2026-01-01 02:00:00+00','YVR','00000000-0000-0000-0000-000000000001',900),
('2026-01-01 03:00:00+00','YVR','00000000-0000-0000-0000-000000000001',900),
('2026-01-01 04:00:00+00','YVR','00000000-0000-0000-0000-000000000001',900);`)
if err != nil {
t.Fatal(err)
}
for _, row := range []struct {
id int
iata, at string
}{
{1, "YVR", "00:30:00.122"}, {1, "YVR", "00:30:00.123"}, {2, "YVR", "00:59:59.999"},
{1, "YVR", "01:30:00"}, {2, "YVR", "01:35:00"},
{1, "YVR", "02:15:00"}, {1, "YVR", "02:20:00"}, {1, "YYJ", "02:25:00"},
{2, "YVR", "03:15:00"}, {4, "YVR", "03:20:00"},
{1, "YVR", "04:20:00.455"}, {1, "YVR", "04:20:00.456"}, {1, "YVR", "05:00:00"},
} {
at, err := time.Parse(time.RFC3339Nano, "2026-01-01T"+row.at+"Z")
if err != nil {
t.Fatal(err)
}
directoryObservation(t, ctx, tx, row.id, row.iata, at)
}
since := time.Date(2026, 1, 1, 0, 30, 0, 123000000, time.UTC).UnixMilli()
until := time.Date(2026, 1, 1, 4, 20, 0, 456000000, time.UTC).UnixMilli()
base := api.ObserverDirectoryQuery{IATAs: []string{"YVR"}, Sort: "traffic", Since: since, Until: until, Limit: 1}
for _, tc := range []struct {
name string
apply func(*api.ObserverDirectoryQuery)
ids []byte
maximum int64
}{
{"traffic", func(*api.ObserverDirectoryQuery) {}, []byte{2, 1, 3}, 14},
{"name", func(q *api.ObserverDirectoryQuery) { q.Sort = "name" }, []byte{2, 3, 1}, 14},
{"type", func(q *api.ObserverDirectoryQuery) { q.Type = "a" }, []byte{1, 3}, 14},
{"broker", func(q *api.ObserverDirectoryQuery) { q.Broker = "mqtt1" }, []byte{1}, 14},
{"status", func(q *api.ObserverDirectoryQuery) { q.Status = "offline" }, []byte{2}, 14},
{"name filter", func(q *api.ObserverDirectoryQuery) { q.Name = "ALP" }, []byte{2, 3}, 14},
{"scope", func(q *api.ObserverDirectoryQuery) { q.Scope = "#test" }, []byte{1}, 14},
{"all IATAs", func(q *api.ObserverDirectoryQuery) { q.IATAs = nil }, []byte{1, 2, 4, 3}, 15},
{"multiple IATAs", func(q *api.ObserverDirectoryQuery) { q.IATAs = []string{"YVR", "YYJ"} }, []byte{1, 2, 4, 3}, 15},
{"empty region", func(q *api.ObserverDirectoryQuery) { q.MatchNone = true }, nil, 0},
} {
t.Run(tc.name, func(t *testing.T) {
q := base
tc.apply(&q)
var ids []byte
for {
page, err := store.ListObserverDirectory(ctx, q)
if err != nil {
t.Fatal(err)
}
if page.MaxObservationCount == nil || *page.MaxObservationCount != tc.maximum || page.EffectiveSort != q.Sort || page.WindowStart != since || page.WindowEnd != until {
t.Fatalf("page metrics %+v", page)
}
if page.Coverage.Status != "partial" || page.Coverage.ExpectedHours != 5 || page.Coverage.CompleteHours != 1 {
t.Fatalf("coverage %+v", page.Coverage)
}
for _, item := range page.Items {
ids = append(ids, item.ID[15])
want := int64(14)
if item.ID[15] == 3 {
want = 0
}
if item.ID[15] == 4 {
want = 1
}
if item.ID[15] == 1 && tc.maximum == 15 {
want = 15
}
if item.ObservationCount == nil || *item.ObservationCount != want {
t.Fatalf("observer %d count %v, want %d", item.ID[15], item.ObservationCount, want)
}
}
if !page.HasMore {
if page.NextCursor != nil {
t.Fatal("terminal page has cursor")
}
break
}
if page.NextCursor == nil || *page.NextCursor <= q.Cursor {
t.Fatal("cursor did not advance")
}
q.Cursor = *page.NextCursor
}
if !slices.Equal(ids, tc.ids) {
t.Fatalf("ids %v, want %v", ids, tc.ids)
}
})
}
// Raw retention removes both the first boundary slice and the rolled hour's raw rows.
if _, err := tx.Exec(ctx, `DELETE FROM packet_observations WHERE heard_at < '2026-01-01 02:00:00+00'; UPDATE analytics_raw_state SET raw_deleted_before='2026-01-01 01:30:00+00'`); err != nil {
t.Fatal(err)
}
q := base
q.Limit = 10
page, err := store.ListObserverDirectory(ctx, q)
if err != nil {
t.Fatal(err)
}
if len(page.Items) != 3 || page.Items[0].ObservationCount == nil || *page.Items[0].ObservationCount != 13 || page.Items[1].ObservationCount == nil || *page.Items[1].ObservationCount != 13 {
t.Fatalf("retention lost rolled history or included an out-of-window bucket: %+v", page)
}
}
func TestObserverDirectoryRawStartupAndZeroPostgres(t *testing.T) {
ctx, tx, store := directoryFixture(t)
now := time.Now().UTC().Truncate(time.Millisecond)
q := api.ObserverDirectoryQuery{IATAs: []string{"YVR"}, Sort: "traffic", Since: now.Add(-7 * 24 * time.Hour).UnixMilli(), Until: now.UnixMilli(), Limit: 10}
page, err := store.ListObserverDirectory(ctx, q)
if err != nil {
t.Fatal(err)
}
if page.Coverage.Status != "unavailable" || page.EffectiveSort != "traffic" || page.MaxObservationCount == nil || *page.MaxObservationCount != 0 || len(page.Items) != 3 {
t.Fatalf("empty history: %+v", page)
}
for _, item := range page.Items {
if item.ObservationCount == nil || *item.ObservationCount != 0 {
t.Fatal("no history must return numeric zero")
}
}
directoryObservation(t, ctx, tx, 1, "YVR", now.Add(-6*24*time.Hour))
directoryObservation(t, ctx, tx, 1, "YVR", now.Add(-time.Millisecond))
directoryObservation(t, ctx, tx, 1, "YVR", now.Add(-7*24*time.Hour-time.Millisecond))
directoryObservation(t, ctx, tx, 1, "YVR", now)
directoryObservation(t, ctx, tx, 1, "YVR", now.Add(time.Millisecond))
page, err = store.ListObserverDirectory(ctx, q)
if err != nil {
t.Fatal(err)
}
if page.Coverage.Status != "partial" || page.Coverage.CompleteHours != 0 || page.Coverage.PartialHours != 2 || page.Coverage.MissingHours == 0 || page.EffectiveSort != "traffic" || page.Items[0].ID[15] != 1 || page.MaxObservationCount == nil || *page.MaxObservationCount != 2 || page.Items[0].ObservationCount == nil || *page.Items[0].ObservationCount != 2 {
t.Fatalf("available raw history and live tail were not counted: %+v", page)
}
}
func TestObserverDirectoryExactSlicePostgres(t *testing.T) {
ctx, tx, store := directoryFixture(t)
at := time.Date(2026, 1, 1, 0, 30, 0, 123000000, time.UTC)
if _, err := tx.Exec(ctx, `SET LOCAL TIME ZONE 'America/Toronto';
INSERT INTO analytics_rollup_hours(hour,status) VALUES ('2026-01-01 00:00:00+00','complete');
INSERT INTO analytics_hourly_observer_identity(hour,iata,observer_id,observation_count)
VALUES ('2026-01-01 00:00:00+00','YVR','00000000-0000-0000-0000-000000000001',900)`); err != nil {
t.Fatal(err)
}
for _, delta := range []time.Duration{-time.Millisecond, 0, time.Millisecond} {
directoryObservation(t, ctx, tx, 1, "YVR", at.Add(delta))
}
q := api.ObserverDirectoryQuery{IATAs: []string{"YVR"}, Sort: "traffic", Since: at.UnixMilli(), Until: at.Add(time.Millisecond).UnixMilli(), Limit: 10}
page, err := store.ListObserverDirectory(ctx, q)
if err != nil {
t.Fatal(err)
}
if page.WindowStart != q.Since || page.WindowEnd != q.Until || page.MaxObservationCount == nil || *page.MaxObservationCount != 1 || page.Items[0].ObservationCount == nil || *page.Items[0].ObservationCount != 1 || page.Coverage.ExpectedHours != 1 || page.Coverage.PartialHours != 1 || page.Coverage.CompleteHours != 0 {
t.Fatalf("exact slice included a whole rollup or wrong boundary: %+v", page)
}
// A refresh advances the exclusive bound to include the next arrival.
q.Until++
page, err = store.ListObserverDirectory(ctx, q)
if err != nil {
t.Fatal(err)
}
if page.Items[0].ObservationCount == nil || *page.Items[0].ObservationCount != 2 || page.MaxObservationCount == nil || *page.MaxObservationCount != 2 {
t.Fatalf("refresh missed new traffic: %+v", page)
}
}
+91
View File
@@ -0,0 +1,91 @@
-- Copyright 2026 Beacon Contributors
-- SPDX-License-Identifier: AGPL-3.0-or-later
-- name: ListObserverDirectory :one
WITH hours AS MATERIALIZED (
SELECT g.h, coalesce(r.status, 'missing') AS status,
coalesce(r.status = 'complete', false) AND g.h >= $1::timestamptz
AND g.h + interval '1 hour' <= $2::timestamptz AS use_rollup
FROM generate_series(date_trunc('hour', $1::timestamptz, 'UTC'), $2::timestamptz, interval '1 hour') g(h)
LEFT JOIN analytics_rollup_hours r ON r.hour = g.h
WHERE g.h < $2::timestamptz
), candidates AS MATERIALIZED (
SELECT o.id, o.display_name, o.observer_type,
coalesce(o.last_iata, '')::text AS iata,
CASE WHEN greatest(coalesce(o.last_status_at, o.last_seen), o.last_seen) > now() - interval '5 minutes'
THEN 'online' ELSE 'offline' END AS status,
CASE WHEN o.radio_freq_mhz IS NOT NULL AND o.radio_bw_khz IS NOT NULL AND o.radio_sf IS NOT NULL
THEN concat(o.radio_freq_mhz, ',', o.radio_bw_khz, ',', o.radio_sf) END AS radio,
coalesce((SELECT jsonb_agg(ts.name ORDER BY ts.name) FROM observer_scopes os JOIN transport_scopes ts ON ts.id = os.scope_id WHERE os.observer_id = o.id), '[]'::jsonb) AS scopes
FROM observers o
WHERE NOT $12::boolean AND (coalesce(cardinality($3::bpchar[]), 0) = 0 OR o.last_iata = ANY($3::bpchar[]))
AND ($4::text = '' OR EXISTS (SELECT 1 FROM observer_brokers ob WHERE ob.observer_id = o.id AND ob.broker_name = $4))
AND ($5::text = '' OR o.display_name ILIKE '%' || $5 || '%')
AND ($6::text = '' OR CASE WHEN greatest(coalesce(o.last_status_at, o.last_seen), o.last_seen) > now() - interval '5 minutes' THEN 'online' ELSE 'offline' END = $6)
AND ($7::text = '' OR EXISTS (SELECT 1 FROM observer_scopes os JOIN transport_scopes ts ON ts.id = os.scope_id WHERE os.observer_id = o.id AND ts.name = $7))
), raw_traffic AS MATERIALIZED (
-- Disjoint hour slices keep raw scans bounded and never recount a completed whole hour.
SELECT h.h, r.observer_id, r.observations
FROM hours h
CROSS JOIN LATERAL (
SELECT o.observer_id, count(*)::bigint AS observations
FROM packet_observations o
WHERE o.heard_at >= greatest(h.h, $1::timestamptz)
AND o.heard_at < least(h.h + interval '1 hour', $2::timestamptz)
AND (coalesce(cardinality($3::bpchar[]), 0) = 0 OR o.iata = ANY($3::bpchar[]))
GROUP BY o.observer_id
) r
JOIN candidates c ON c.id = r.observer_id
WHERE NOT h.use_rollup
), coverage AS (
SELECT count(*)::integer AS expected,
count(*) FILTER (WHERE h.use_rollup)::integer AS complete,
count(*) FILTER (WHERE NOT h.use_rollup AND (h.status IN ('complete', 'partial') OR r.h IS NOT NULL))::integer AS partial,
count(*) FILTER (WHERE NOT h.use_rollup AND h.status = 'missing' AND r.h IS NULL)::integer AS missing
FROM hours h LEFT JOIN (SELECT DISTINCT h FROM raw_traffic) r ON r.h = h.h
), traffic AS (
SELECT observer_id, sum(observations)::bigint AS observations
FROM (
SELECT a.observer_id, a.observation_count AS observations
FROM analytics_hourly_observer_identity a
JOIN hours h ON h.h = a.hour AND h.use_rollup
JOIN candidates c ON c.id = a.observer_id
WHERE (coalesce(cardinality($3::bpchar[]), 0) = 0 OR a.iata = ANY($3::bpchar[]))
UNION ALL
SELECT observer_id, observations FROM raw_traffic
) counts
GROUP BY observer_id
), listed AS (
SELECT c.*, coalesce(t.observations, 0)::bigint AS observations
FROM candidates c LEFT JOIN traffic t ON t.observer_id = c.id
WHERE $8::text = '' OR c.observer_type = $8
), paged AS (
SELECT * FROM listed
ORDER BY CASE WHEN $9::text = 'traffic' THEN observations END DESC NULLS LAST,
lower(coalesce(display_name, '')) COLLATE "C", id
LIMIT $11::integer OFFSET $10::bigint
), packed AS (
SELECT coalesce(jsonb_agg(
jsonb_strip_nulls(jsonb_build_object('id', id, 'displayName', display_name, 'observerType', observer_type, 'iata', iata, 'status', status, 'radio', radio, 'scopes', scopes))
|| jsonb_build_object('observationCount', observations)
ORDER BY CASE WHEN $9::text = 'traffic' THEN observations END DESC NULLS LAST,
lower(coalesce(display_name, '')) COLLATE "C", id
), '[]'::jsonb) AS items
FROM paged
), totals AS (
SELECT count(*) AS n, max(observations) AS maximum FROM listed
)
SELECT jsonb_build_object(
'items', p.items,
'generatedAt', (extract(epoch FROM now())*1000)::bigint,
'hasMore', t.n > $10::bigint + $11::integer,
'nextCursor', CASE WHEN t.n > $10::bigint + $11::integer THEN $10::bigint + $11::integer END,
'windowStart', (extract(epoch FROM $1::timestamptz)*1000)::bigint,
'windowEnd', (extract(epoch FROM $2::timestamptz)*1000)::bigint,
'sort', $9::text, 'effectiveSort', $9::text,
'maxObservationCount', coalesce(t.maximum, 0),
'observerTypes', (SELECT coalesce(jsonb_agg(t.observer_type ORDER BY t.observer_type), '[]'::jsonb) FROM (SELECT DISTINCT observer_type FROM candidates WHERE observer_type IS NOT NULL AND observer_type <> '') t),
'coverage', jsonb_build_object('status', CASE WHEN v.complete = v.expected THEN 'complete' WHEN v.complete > 0 OR v.partial > 0 THEN 'partial' ELSE 'unavailable' END,
'expectedHours', v.expected, 'completeHours', v.complete, 'partialHours', v.partial, 'missingHours', v.missing)
)::text AS page
FROM packed p CROSS JOIN coverage v CROSS JOIN totals t;
+16 -1
View File
@@ -3,7 +3,7 @@
//
// Generated by this command:
//
// mockgen -source db/sqlc/querier.go -destination db/sqlc/mock/querier.go -package mockdb
// mockgen -source=db/sqlc/querier.go -destination=db/sqlc/mock/querier.go -package=mockdb
//
// Package mockdb is a generated GoMock package.
@@ -1466,6 +1466,21 @@ func (mr *MockQuerierMockRecorder) ListObserverAdverts(ctx, arg any) *gomock.Cal
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListObserverAdverts", reflect.TypeOf((*MockQuerier)(nil).ListObserverAdverts), ctx, arg)
}
// ListObserverDirectory mocks base method.
func (m *MockQuerier) ListObserverDirectory(ctx context.Context, arg db.ListObserverDirectoryParams) (string, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "ListObserverDirectory", ctx, arg)
ret0, _ := ret[0].(string)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// ListObserverDirectory indicates an expected call of ListObserverDirectory.
func (mr *MockQuerierMockRecorder) ListObserverDirectory(ctx, arg any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListObserverDirectory", reflect.TypeOf((*MockQuerier)(nil).ListObserverDirectory), ctx, arg)
}
// ListObservers mocks base method.
func (m *MockQuerier) ListObservers(ctx context.Context, arg db.ListObserversParams) ([]db.ListObserversRow, error) {
m.ctrl.T.Helper()
+140
View File
@@ -0,0 +1,140 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: observer_directory.sql
package db
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const listObserverDirectory = `-- name: ListObserverDirectory :one
WITH hours AS MATERIALIZED (
SELECT g.h, coalesce(r.status, 'missing') AS status,
coalesce(r.status = 'complete', false) AND g.h >= $1::timestamptz
AND g.h + interval '1 hour' <= $2::timestamptz AS use_rollup
FROM generate_series(date_trunc('hour', $1::timestamptz, 'UTC'), $2::timestamptz, interval '1 hour') g(h)
LEFT JOIN analytics_rollup_hours r ON r.hour = g.h
WHERE g.h < $2::timestamptz
), candidates AS MATERIALIZED (
SELECT o.id, o.display_name, o.observer_type,
coalesce(o.last_iata, '')::text AS iata,
CASE WHEN greatest(coalesce(o.last_status_at, o.last_seen), o.last_seen) > now() - interval '5 minutes'
THEN 'online' ELSE 'offline' END AS status,
CASE WHEN o.radio_freq_mhz IS NOT NULL AND o.radio_bw_khz IS NOT NULL AND o.radio_sf IS NOT NULL
THEN concat(o.radio_freq_mhz, ',', o.radio_bw_khz, ',', o.radio_sf) END AS radio,
coalesce((SELECT jsonb_agg(ts.name ORDER BY ts.name) FROM observer_scopes os JOIN transport_scopes ts ON ts.id = os.scope_id WHERE os.observer_id = o.id), '[]'::jsonb) AS scopes
FROM observers o
WHERE NOT $12::boolean AND (coalesce(cardinality($3::bpchar[]), 0) = 0 OR o.last_iata = ANY($3::bpchar[]))
AND ($4::text = '' OR EXISTS (SELECT 1 FROM observer_brokers ob WHERE ob.observer_id = o.id AND ob.broker_name = $4))
AND ($5::text = '' OR o.display_name ILIKE '%' || $5 || '%')
AND ($6::text = '' OR CASE WHEN greatest(coalesce(o.last_status_at, o.last_seen), o.last_seen) > now() - interval '5 minutes' THEN 'online' ELSE 'offline' END = $6)
AND ($7::text = '' OR EXISTS (SELECT 1 FROM observer_scopes os JOIN transport_scopes ts ON ts.id = os.scope_id WHERE os.observer_id = o.id AND ts.name = $7))
), raw_traffic AS MATERIALIZED (
-- Disjoint hour slices keep raw scans bounded and never recount a completed whole hour.
SELECT h.h, r.observer_id, r.observations
FROM hours h
CROSS JOIN LATERAL (
SELECT o.observer_id, count(*)::bigint AS observations
FROM packet_observations o
WHERE o.heard_at >= greatest(h.h, $1::timestamptz)
AND o.heard_at < least(h.h + interval '1 hour', $2::timestamptz)
AND (coalesce(cardinality($3::bpchar[]), 0) = 0 OR o.iata = ANY($3::bpchar[]))
GROUP BY o.observer_id
) r
JOIN candidates c ON c.id = r.observer_id
WHERE NOT h.use_rollup
), coverage AS (
SELECT count(*)::integer AS expected,
count(*) FILTER (WHERE h.use_rollup)::integer AS complete,
count(*) FILTER (WHERE NOT h.use_rollup AND (h.status IN ('complete', 'partial') OR r.h IS NOT NULL))::integer AS partial,
count(*) FILTER (WHERE NOT h.use_rollup AND h.status = 'missing' AND r.h IS NULL)::integer AS missing
FROM hours h LEFT JOIN (SELECT DISTINCT h FROM raw_traffic) r ON r.h = h.h
), traffic AS (
SELECT observer_id, sum(observations)::bigint AS observations
FROM (
SELECT a.observer_id, a.observation_count AS observations
FROM analytics_hourly_observer_identity a
JOIN hours h ON h.h = a.hour AND h.use_rollup
JOIN candidates c ON c.id = a.observer_id
WHERE (coalesce(cardinality($3::bpchar[]), 0) = 0 OR a.iata = ANY($3::bpchar[]))
UNION ALL
SELECT observer_id, observations FROM raw_traffic
) counts
GROUP BY observer_id
), listed AS (
SELECT c.id, c.display_name, c.observer_type, c.iata, c.status, c.radio, c.scopes, coalesce(t.observations, 0)::bigint AS observations
FROM candidates c LEFT JOIN traffic t ON t.observer_id = c.id
WHERE $8::text = '' OR c.observer_type = $8
), paged AS (
SELECT id, display_name, observer_type, iata, status, radio, scopes, observations FROM listed
ORDER BY CASE WHEN $9::text = 'traffic' THEN observations END DESC NULLS LAST,
lower(coalesce(display_name, '')) COLLATE "C", id
LIMIT $11::integer OFFSET $10::bigint
), packed AS (
SELECT coalesce(jsonb_agg(
jsonb_strip_nulls(jsonb_build_object('id', id, 'displayName', display_name, 'observerType', observer_type, 'iata', iata, 'status', status, 'radio', radio, 'scopes', scopes))
|| jsonb_build_object('observationCount', observations)
ORDER BY CASE WHEN $9::text = 'traffic' THEN observations END DESC NULLS LAST,
lower(coalesce(display_name, '')) COLLATE "C", id
), '[]'::jsonb) AS items
FROM paged
), totals AS (
SELECT count(*) AS n, max(observations) AS maximum FROM listed
)
SELECT jsonb_build_object(
'items', p.items,
'generatedAt', (extract(epoch FROM now())*1000)::bigint,
'hasMore', t.n > $10::bigint + $11::integer,
'nextCursor', CASE WHEN t.n > $10::bigint + $11::integer THEN $10::bigint + $11::integer END,
'windowStart', (extract(epoch FROM $1::timestamptz)*1000)::bigint,
'windowEnd', (extract(epoch FROM $2::timestamptz)*1000)::bigint,
'sort', $9::text, 'effectiveSort', $9::text,
'maxObservationCount', coalesce(t.maximum, 0),
'observerTypes', (SELECT coalesce(jsonb_agg(t.observer_type ORDER BY t.observer_type), '[]'::jsonb) FROM (SELECT DISTINCT observer_type FROM candidates WHERE observer_type IS NOT NULL AND observer_type <> '') t),
'coverage', jsonb_build_object('status', CASE WHEN v.complete = v.expected THEN 'complete' WHEN v.complete > 0 OR v.partial > 0 THEN 'partial' ELSE 'unavailable' END,
'expectedHours', v.expected, 'completeHours', v.complete, 'partialHours', v.partial, 'missingHours', v.missing)
)::text AS page
FROM packed p CROSS JOIN coverage v CROSS JOIN totals t
`
type ListObserverDirectoryParams struct {
Column1 pgtype.Timestamptz `json:"column_1"`
Column2 pgtype.Timestamptz `json:"column_2"`
Column3 []string `json:"column_3"`
Column4 string `json:"column_4"`
Column5 string `json:"column_5"`
Column6 string `json:"column_6"`
Column7 string `json:"column_7"`
Column8 string `json:"column_8"`
Column9 string `json:"column_9"`
Column10 int64 `json:"column_10"`
Column11 int32 `json:"column_11"`
Column12 bool `json:"column_12"`
}
// Copyright 2026 Beacon Contributors
// SPDX-License-Identifier: AGPL-3.0-or-later
func (q *Queries) ListObserverDirectory(ctx context.Context, arg ListObserverDirectoryParams) (string, error) {
row := q.db.QueryRow(ctx, listObserverDirectory,
arg.Column1,
arg.Column2,
arg.Column3,
arg.Column4,
arg.Column5,
arg.Column6,
arg.Column7,
arg.Column8,
arg.Column9,
arg.Column10,
arg.Column11,
arg.Column12,
)
var page string
err := row.Scan(&page)
return page, err
}
+3
View File
@@ -221,6 +221,9 @@ type Querier interface {
// Pass cursor=0 to start from the beginning, or the last seen id for pagination.
// Keep missing-origin adverts; the generated key field expects a string, not NULL.
ListObserverAdverts(ctx context.Context, arg ListObserverAdvertsParams) ([]ListObserverAdvertsRow, error)
// Copyright 2026 Beacon Contributors
// SPDX-License-Identifier: AGPL-3.0-or-later
ListObserverDirectory(ctx context.Context, arg ListObserverDirectoryParams) (string, error)
// Pass cursor=0 to start from the beginning, or the last seen observer's rownum for pagination.
// Note: observers use UUID PKs so we order by last_seen and use a keyset on last_seen+id.
ListObservers(ctx context.Context, arg ListObserversParams) ([]ListObserversRow, error)
+226 -1
View File
@@ -1430,6 +1430,127 @@ const docTemplate = `{
}
}
},
"/observers/directory": {
"get": {
"description": "Defaults to the trailing seven days through request time. Counts combine completed whole-hour analytics with retained observations for other hours and exact boundary slices. Incomplete history still returns numeric counts and the requested sort; coverage describes available history. Repeat filters and the returned windowStart/windowEnd as since/until with the cursor on later pages. Results may move as data changes; refresh without bounds for a new rolling window.",
"produces": [
"application/json"
],
"tags": [
"Observers"
],
"summary": "List observers with windowed traffic counts",
"parameters": [
{
"type": "string",
"description": "Single IATA",
"name": "iata",
"in": "query"
},
{
"type": "string",
"description": "Comma-separated IATAs",
"name": "iatas",
"in": "query"
},
{
"type": "integer",
"description": "Region ID",
"name": "regionId",
"in": "query"
},
{
"type": "string",
"description": "Region slug",
"name": "region",
"in": "query"
},
{
"type": "string",
"description": "Observer type",
"name": "type",
"in": "query"
},
{
"type": "string",
"description": "Broker membership",
"name": "broker",
"in": "query"
},
{
"type": "string",
"description": "online or offline",
"name": "status",
"in": "query"
},
{
"type": "string",
"description": "Case-insensitive partial name",
"name": "name",
"in": "query"
},
{
"type": "string",
"description": "Transport scope membership",
"name": "scope",
"in": "query"
},
{
"type": "string",
"description": "traffic (default) or name",
"name": "sort",
"in": "query"
},
{
"type": "integer",
"format": "int64",
"description": "Inclusive epoch ms, preserved exactly; default seven days before until; maximum window 31 days",
"name": "since",
"in": "query"
},
{
"type": "integer",
"format": "int64",
"description": "Exclusive epoch ms, preserved exactly; default request time",
"name": "until",
"in": "query"
},
{
"type": "integer",
"format": "int64",
"description": "nextCursor returned by preceding page",
"name": "cursor",
"in": "query"
},
{
"type": "integer",
"description": "Page size, default 50, maximum 200",
"name": "limit",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.ObserverDirectory"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/internal_api_handlers.APIError"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/internal_api_handlers.APIError"
}
}
}
}
},
"/observers/{observerId}": {
"get": {
"produces": [
@@ -4401,6 +4522,110 @@ const docTemplate = `{
}
}
},
"github_com_MeshCore-Beacon_beacon-server_internal_api.ObserverDirectory": {
"type": "object",
"properties": {
"coverage": {
"$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.ObserverDirectoryCoverage"
},
"effectiveSort": {
"type": "string"
},
"generatedAt": {
"type": "integer"
},
"hasMore": {
"type": "boolean"
},
"items": {
"type": "array",
"items": {
"$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.ObserverDirectoryItem"
}
},
"maxObservationCount": {
"description": "Maximum over all matching observers, not only this page; zero when none match.",
"type": "integer"
},
"nextCursor": {
"type": "integer"
},
"observerTypes": {
"type": "array",
"items": {
"type": "string"
}
},
"sort": {
"type": "string"
},
"windowEnd": {
"type": "integer"
},
"windowStart": {
"type": "integer"
}
}
},
"github_com_MeshCore-Beacon_beacon-server_internal_api.ObserverDirectoryCoverage": {
"type": "object",
"properties": {
"completeHours": {
"type": "integer"
},
"expectedHours": {
"type": "integer"
},
"missingHours": {
"type": "integer"
},
"partialHours": {
"type": "integer"
},
"status": {
"type": "string"
}
}
},
"github_com_MeshCore-Beacon_beacon-server_internal_api.ObserverDirectoryItem": {
"type": "object",
"properties": {
"displayName": {
"description": "friendly name from /status messages",
"type": "string"
},
"iata": {
"description": "most recently heard IATA",
"type": "string"
},
"id": {
"type": "string"
},
"observationCount": {
"description": "Available observations in [windowStart, windowEnd), including zero with missing history.",
"type": "integer"
},
"observerType": {
"description": "e.g. \"meshcoretomqtt\", \"meshcoreha\"",
"type": "string"
},
"radio": {
"description": "friendly radio param string: freqMhz,BwKhz,SF e.g. \"910.525,62.5,7\"",
"type": "string"
},
"scopes": {
"description": "list of observer forwarded scopes matched to config",
"type": "array",
"items": {
"type": "string"
}
},
"status": {
"description": "\"online\" or \"offline\" derived from last_status_at",
"type": "string"
}
}
},
"github_com_MeshCore-Beacon_beacon-server_internal_api.ObserverSummary": {
"type": "object",
"properties": {
@@ -5811,7 +6036,7 @@ const docTemplate = `{
// SwaggerInfo holds exported Swagger Info so clients can modify it
var SwaggerInfo = &swag.Spec{
Version: "2.0.1",
Version: "2.0.2",
Host: "localhost:8080",
BasePath: "/api/v1",
Schemes: []string{"http", "https"},
+40 -6
View File
@@ -2,10 +2,10 @@
## The rule
Historical stats never scan `packet_observations` per request or per refresh. They read
hourly rollup tables filled once per closed hour by one background task. Raw rows are read
only for per-entity drill-downs (a packet, a trace, a route), observer comparison and
sub-hour observer activity.
Historical stats read hourly rollup tables filled once per closed hour by one background
task. Raw rows are read for per-entity drill-downs (a packet, a trace, a route), observer
comparison, sub-hour observer activity, and bounded observer-directory slices that cannot
use a completed whole-hour rollup.
## Rollup contract
@@ -32,7 +32,8 @@ sub-hour observer activity.
newest deleted `last_heard_at`. Cleanup and the rollup never write the same row.
- **Partial hours.** A not-yet-complete hour starting at or before `raw_deleted_before + 30 min`
lost raw rows before it was rolled. It is marked `partial` and never rolled; readers
report it without values.
report it without values, except the observer directory, which counts any retained raw
observations in the requested slice.
- **Re-rolls.** Hours are not re-rolled periodically. A message stored by channel backfill (at
startup, and when keys are imported at runtime) queues every hour its packet was heard in
`analytics_dirty_hours`, in the same statement as the message insert. Each pass re-rolls a
@@ -52,10 +53,43 @@ sub-hour observer activity.
| Max path entries | Max |
| SNR / RSSI averages | Σ sum / Σ samples, never an average of averages |
Windows snap to UTC hours on the server, and responses report the effective window.
Historical stats windows snap to UTC hours on the server, and responses report the
effective window. The observer directory preserves exact millisecond bounds instead.
`/stats/overview` spans the 24 newest eligible hours; its `windowHours` counts the complete
ones its totals cover.
## Observer directory
`GET /api/v1/observers/directory` defaults to `[request time - 7 days, request time)`.
Explicit `since` and `until` are epoch milliseconds and remain exact; windows must be
positive and at most 31 days. If only `until` is supplied, `since` defaults to seven days
before it. If only `since` is supplied, `until` defaults to request time.
Counts combine complete rollups for whole UTC hours fully inside the window with retained
raw observations for every other hour and the exact boundary slices. These sources never
overlap. Completed whole-hour history survives raw retention; a sliced boundary whose raw
rows have expired cannot be reconstructed from its whole-hour total.
Every observer receives a numeric `observationCount`, including zero. `maxObservationCount`
is the maximum across all matching observers, independent of pagination, and is zero when
none match. Incomplete history never suppresses these counts or changes `effectiveSort`:
traffic sorting uses available counts, while explicit name sorting remains supported.
Both sorts break name ties by observer ID.
`coverage` is conservative and informational. `expectedHours` counts intersecting UTC
hours, including boundary slices. `completeHours` counts the whole hours read from complete
rollups. Other hours are `partialHours` when there is a partial/boundary rollup or matching
retained raw traffic; otherwise they are `missingHours`. Raw-only traffic does not establish
complete history. Status is `complete` only when all intersecting hours are complete,
`partial` when any complete or partial hour exists, and `unavailable` otherwise. Zero counts
with incomplete coverage mean no available observations, not proof that no traffic occurred.
The response uses `Cache-Control: no-store`. For continuation requests, send `nextCursor`,
the same filters and sort, and the exact returned `windowStart`/`windowEnd` as `since`/`until`.
These bounds fix the time window, not a database snapshot; late arrivals and metadata changes
can still move results. Refresh without bounds to obtain a new rolling window and include
new traffic.
## IATA sets
A packet heard in YVR and YYZ appears in both IATAs' per-IATA rows, so summing per-IATA
+226 -1
View File
@@ -15,7 +15,7 @@
"license": {
"name": "AGPL-3-or-later"
},
"version": "2.0.1"
"version": "2.0.2"
},
"host": "localhost:8080",
"basePath": "/api/v1",
@@ -1428,6 +1428,127 @@
}
}
},
"/observers/directory": {
"get": {
"description": "Defaults to the trailing seven days through request time. Counts combine completed whole-hour analytics with retained observations for other hours and exact boundary slices. Incomplete history still returns numeric counts and the requested sort; coverage describes available history. Repeat filters and the returned windowStart/windowEnd as since/until with the cursor on later pages. Results may move as data changes; refresh without bounds for a new rolling window.",
"produces": [
"application/json"
],
"tags": [
"Observers"
],
"summary": "List observers with windowed traffic counts",
"parameters": [
{
"type": "string",
"description": "Single IATA",
"name": "iata",
"in": "query"
},
{
"type": "string",
"description": "Comma-separated IATAs",
"name": "iatas",
"in": "query"
},
{
"type": "integer",
"description": "Region ID",
"name": "regionId",
"in": "query"
},
{
"type": "string",
"description": "Region slug",
"name": "region",
"in": "query"
},
{
"type": "string",
"description": "Observer type",
"name": "type",
"in": "query"
},
{
"type": "string",
"description": "Broker membership",
"name": "broker",
"in": "query"
},
{
"type": "string",
"description": "online or offline",
"name": "status",
"in": "query"
},
{
"type": "string",
"description": "Case-insensitive partial name",
"name": "name",
"in": "query"
},
{
"type": "string",
"description": "Transport scope membership",
"name": "scope",
"in": "query"
},
{
"type": "string",
"description": "traffic (default) or name",
"name": "sort",
"in": "query"
},
{
"type": "integer",
"format": "int64",
"description": "Inclusive epoch ms, preserved exactly; default seven days before until; maximum window 31 days",
"name": "since",
"in": "query"
},
{
"type": "integer",
"format": "int64",
"description": "Exclusive epoch ms, preserved exactly; default request time",
"name": "until",
"in": "query"
},
{
"type": "integer",
"format": "int64",
"description": "nextCursor returned by preceding page",
"name": "cursor",
"in": "query"
},
{
"type": "integer",
"description": "Page size, default 50, maximum 200",
"name": "limit",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.ObserverDirectory"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/internal_api_handlers.APIError"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/internal_api_handlers.APIError"
}
}
}
}
},
"/observers/{observerId}": {
"get": {
"produces": [
@@ -4399,6 +4520,110 @@
}
}
},
"github_com_MeshCore-Beacon_beacon-server_internal_api.ObserverDirectory": {
"type": "object",
"properties": {
"coverage": {
"$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.ObserverDirectoryCoverage"
},
"effectiveSort": {
"type": "string"
},
"generatedAt": {
"type": "integer"
},
"hasMore": {
"type": "boolean"
},
"items": {
"type": "array",
"items": {
"$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.ObserverDirectoryItem"
}
},
"maxObservationCount": {
"description": "Maximum over all matching observers, not only this page; zero when none match.",
"type": "integer"
},
"nextCursor": {
"type": "integer"
},
"observerTypes": {
"type": "array",
"items": {
"type": "string"
}
},
"sort": {
"type": "string"
},
"windowEnd": {
"type": "integer"
},
"windowStart": {
"type": "integer"
}
}
},
"github_com_MeshCore-Beacon_beacon-server_internal_api.ObserverDirectoryCoverage": {
"type": "object",
"properties": {
"completeHours": {
"type": "integer"
},
"expectedHours": {
"type": "integer"
},
"missingHours": {
"type": "integer"
},
"partialHours": {
"type": "integer"
},
"status": {
"type": "string"
}
}
},
"github_com_MeshCore-Beacon_beacon-server_internal_api.ObserverDirectoryItem": {
"type": "object",
"properties": {
"displayName": {
"description": "friendly name from /status messages",
"type": "string"
},
"iata": {
"description": "most recently heard IATA",
"type": "string"
},
"id": {
"type": "string"
},
"observationCount": {
"description": "Available observations in [windowStart, windowEnd), including zero with missing history.",
"type": "integer"
},
"observerType": {
"description": "e.g. \"meshcoretomqtt\", \"meshcoreha\"",
"type": "string"
},
"radio": {
"description": "friendly radio param string: freqMhz,BwKhz,SF e.g. \"910.525,62.5,7\"",
"type": "string"
},
"scopes": {
"description": "list of observer forwarded scopes matched to config",
"type": "array",
"items": {
"type": "string"
}
},
"status": {
"description": "\"online\" or \"offline\" derived from last_status_at",
"type": "string"
}
}
},
"github_com_MeshCore-Beacon_beacon-server_internal_api.ObserverSummary": {
"type": "object",
"properties": {
+162 -1
View File
@@ -687,6 +687,79 @@ definitions:
description: exclusive, epoch milliseconds
type: integer
type: object
github_com_MeshCore-Beacon_beacon-server_internal_api.ObserverDirectory:
properties:
coverage:
$ref: '#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.ObserverDirectoryCoverage'
effectiveSort:
type: string
generatedAt:
type: integer
hasMore:
type: boolean
items:
items:
$ref: '#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.ObserverDirectoryItem'
type: array
maxObservationCount:
description: Maximum over all matching observers, not only this page; zero
when none match.
type: integer
nextCursor:
type: integer
observerTypes:
items:
type: string
type: array
sort:
type: string
windowEnd:
type: integer
windowStart:
type: integer
type: object
github_com_MeshCore-Beacon_beacon-server_internal_api.ObserverDirectoryCoverage:
properties:
completeHours:
type: integer
expectedHours:
type: integer
missingHours:
type: integer
partialHours:
type: integer
status:
type: string
type: object
github_com_MeshCore-Beacon_beacon-server_internal_api.ObserverDirectoryItem:
properties:
displayName:
description: friendly name from /status messages
type: string
iata:
description: most recently heard IATA
type: string
id:
type: string
observationCount:
description: Available observations in [windowStart, windowEnd), including
zero with missing history.
type: integer
observerType:
description: e.g. "meshcoretomqtt", "meshcoreha"
type: string
radio:
description: 'friendly radio param string: freqMhz,BwKhz,SF e.g. "910.525,62.5,7"'
type: string
scopes:
description: list of observer forwarded scopes matched to config
items:
type: string
type: array
status:
description: '"online" or "offline" derived from last_status_at'
type: string
type: object
github_com_MeshCore-Beacon_beacon-server_internal_api.ObserverSummary:
properties:
displayName:
@@ -1635,7 +1708,7 @@ info:
name: AGPL-3-or-later
termsOfService: https://github.com/MeshCore-Beacon/beacon-server
title: MeshCore Beacon API
version: 2.0.1
version: 2.0.2
paths:
/admin/accounts:
get:
@@ -2739,6 +2812,94 @@ paths:
summary: Get observer telemetry history
tags:
- Observers
/observers/directory:
get:
description: Defaults to the trailing seven days through request time. Counts
combine completed whole-hour analytics with retained observations for other
hours and exact boundary slices. Incomplete history still returns numeric
counts and the requested sort; coverage describes available history. Repeat
filters and the returned windowStart/windowEnd as since/until with the cursor
on later pages. Results may move as data changes; refresh without bounds for
a new rolling window.
parameters:
- description: Single IATA
in: query
name: iata
type: string
- description: Comma-separated IATAs
in: query
name: iatas
type: string
- description: Region ID
in: query
name: regionId
type: integer
- description: Region slug
in: query
name: region
type: string
- description: Observer type
in: query
name: type
type: string
- description: Broker membership
in: query
name: broker
type: string
- description: online or offline
in: query
name: status
type: string
- description: Case-insensitive partial name
in: query
name: name
type: string
- description: Transport scope membership
in: query
name: scope
type: string
- description: traffic (default) or name
in: query
name: sort
type: string
- description: Inclusive epoch ms, preserved exactly; default seven days before
until; maximum window 31 days
format: int64
in: query
name: since
type: integer
- description: Exclusive epoch ms, preserved exactly; default request time
format: int64
in: query
name: until
type: integer
- description: nextCursor returned by preceding page
format: int64
in: query
name: cursor
type: integer
- description: Page size, default 50, maximum 200
in: query
name: limit
type: integer
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.ObserverDirectory'
"400":
description: Bad Request
schema:
$ref: '#/definitions/internal_api_handlers.APIError'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/internal_api_handlers.APIError'
summary: List observers with windowed traffic counts
tags:
- Observers
/packets:
get:
parameters:
+6
View File
@@ -10,6 +10,12 @@ LISTEN_ADDR=:8080
# If set, overrides auth.api_key (even when empty).
# BEACON_API_KEY=
# Regional or grouped-region API key generated by your local MeshMapper admin.
# A grouped-region key covers multiple regions. Never use the mobile App key.
# Overrides meshmapper.api_key, even when empty. Required for enabled imports.
# Keep this in the deployment environment when using saved-config backups.
# MESHMAPPER_API_KEY=
# Optional application logging overrides (defaults: info / text).
# LOG_LEVEL=info
# LOG_FORMAT=json
+140
View File
@@ -0,0 +1,140 @@
// Copyright 2026 Beacon Contributors
// SPDX-License-Identifier: AGPL-3.0-or-later
package handlers
import (
"net/http"
"slices"
"strconv"
"strings"
"time"
"github.com/MeshCore-Beacon/beacon-server/internal/api"
)
// listObserverDirectory godoc
//
// @Summary List observers with windowed traffic counts
// @Description Defaults to the trailing seven days through request time. Counts combine completed whole-hour analytics with retained observations for other hours and exact boundary slices. Incomplete history still returns numeric counts and the requested sort; coverage describes available history. Repeat filters and the returned windowStart/windowEnd as since/until with the cursor on later pages. Results may move as data changes; refresh without bounds for a new rolling window.
// @Tags Observers
// @Produce json
// @Param iata query string false "Single IATA"
// @Param iatas query string false "Comma-separated IATAs"
// @Param regionId query int false "Region ID"
// @Param region query string false "Region slug"
// @Param type query string false "Observer type"
// @Param broker query string false "Broker membership"
// @Param status query string false "online or offline"
// @Param name query string false "Case-insensitive partial name"
// @Param scope query string false "Transport scope membership"
// @Param sort query string false "traffic (default) or name"
// @Param since query int64 false "Inclusive epoch ms, preserved exactly; default seven days before until; maximum window 31 days"
// @Param until query int64 false "Exclusive epoch ms, preserved exactly; default request time"
// @Param cursor query int64 false "nextCursor returned by preceding page"
// @Param limit query int false "Page size, default 50, maximum 200"
// @Success 200 {object} api.ObserverDirectory
// @Failure 400 {object} handlers.APIError
// @Failure 500 {object} handlers.APIError
// @Router /observers/directory [get]
func listObserverDirectory(reader api.Reader) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
values := r.URL.Query()
allowed := map[string]bool{"iata": true, "iatas": true, "regionId": true, "region": true, "type": true, "broker": true, "status": true, "name": true, "scope": true, "sort": true, "since": true, "until": true, "cursor": true, "limit": true}
for k, v := range values {
if !allowed[k] || len(v) != 1 {
respondError(w, 400, "unknown or repeated directory parameter")
return
}
}
q := api.ObserverDirectoryQuery{}
limit, err := parseLimit(r, 50)
if err != nil {
respondError(w, 400, err.Error())
return
}
q.Limit = limit
if values.Has("cursor") {
q.Cursor, err = strconv.ParseInt(values.Get("cursor"), 10, 32)
if err != nil || q.Cursor < 0 || q.Cursor > 2147483447 {
respondError(w, 400, "invalid directory cursor")
return
}
}
if q.Cursor > 0 && (!values.Has("since") || !values.Has("until")) {
respondError(w, 400, "continuation requires since and until from the first page")
return
}
q.Sort = values.Get("sort")
if q.Sort == "" {
q.Sort = "traffic"
}
if q.Sort != "traffic" && q.Sort != "name" {
respondError(w, 400, "sort must be traffic or name")
return
}
q.Type = values.Get("type")
q.Broker = values.Get("broker")
q.Status = values.Get("status")
q.Name = strings.ToLower(values.Get("name"))
q.Scope = values.Get("scope")
if q.Status != "" && q.Status != "online" && q.Status != "offline" {
respondError(w, 400, "status must be online or offline")
return
}
for _, v := range []string{q.Type, q.Broker, q.Name, q.Scope} {
if len(v) > 200 {
respondError(w, 400, "directory filters must be at most 200 bytes")
return
}
}
now := time.Now().UTC().Truncate(time.Millisecond)
until := now
if values.Has("until") {
n, e := strconv.ParseInt(values.Get("until"), 10, 64)
if e != nil || n <= 0 || n > now.UnixMilli() {
respondError(w, 400, "until must be a past epoch millisecond timestamp")
return
}
until = time.UnixMilli(n).UTC()
}
since := until.Add(-7 * 24 * time.Hour)
if values.Has("since") {
n, e := strconv.ParseInt(values.Get("since"), 10, 64)
if e != nil || n <= 0 {
respondError(w, 400, "since must be positive epoch milliseconds")
return
}
since = time.UnixMilli(n).UTC()
}
if !since.Before(until) || until.Sub(since) > 31*24*time.Hour {
respondError(w, 400, "directory window must be positive and at most 31 days")
return
}
q.Since = since.UnixMilli()
q.Until = until.UnixMilli()
q.IATAs = parseIATAs(r)
if values.Get("regionId") != "" || values.Get("region") != "" {
iatas, err := resolveRegionIATAs(r.Context(), values.Get("regionId"), values.Get("region"), reader)
if err != nil {
respondRegionError(w, err)
return
}
q.IATAs = append(q.IATAs, iatas...)
q.MatchNone = len(q.IATAs) == 0
}
if len(q.IATAs) > 200 {
respondError(w, 400, "too many IATA filters")
return
}
slices.Sort(q.IATAs)
q.IATAs = slices.Compact(q.IATAs)
page, err := reader.ListObserverDirectory(r.Context(), q)
w.Header().Set("Cache-Control", "no-store")
if err != nil {
respondError(w, 500, "failed to get observer directory")
return
}
respond(w, 200, page)
}
}
@@ -0,0 +1,165 @@
// Copyright 2026 Beacon Contributors
// SPDX-License-Identifier: AGPL-3.0-or-later
package handlers
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http/httptest"
"slices"
"testing"
"time"
"github.com/MeshCore-Beacon/beacon-server/internal/api"
)
func TestDirectoryRejectsInvalidPagination(t *testing.T) {
for _, q := range []string{"sort=bogus", "cursor=1", "cursor=-1", "limit=0", "snapshot=bad", "until=no", "since=0", "unknown=1", "snapshot=00000000-0000-0000-0000-000000000001&sort=name", "status=maybe", "sort=name&sort=traffic", "cursor=2147483448", "since=1767229200000&until=1767225600000", "since=1764543600000&until=1767229200000", "until=9223372036854775807"} {
t.Run(q, func(t *testing.T) {
r := stubReader{listObserverDirectory: func(context.Context, api.ObserverDirectoryQuery) (*api.ObserverDirectory, error) {
t.Fatal("invalid query reached store")
return nil, nil
}}
w := httptest.NewRecorder()
ObserversRouter(r).ServeHTTP(w, httptest.NewRequest("GET", "/directory?"+q, nil))
if w.Code != 400 {
t.Fatalf("status %d: %s", w.Code, w.Body.String())
}
})
}
}
func TestDirectoryContinuationAndErrors(t *testing.T) {
for _, tc := range []struct {
err error
code int
}{{nil, 200}, {errors.New("database unavailable"), 500}} {
r := stubReader{listObserverDirectory: func(_ context.Context, q api.ObserverDirectoryQuery) (*api.ObserverDirectory, error) {
if q.Sort != "name" || q.Name != "alpha" || q.Since != 1767225600123 || q.Until != 1767229200789 || q.Cursor != 50 || q.Limit != 25 {
t.Fatalf("query %+v", q)
}
return &api.ObserverDirectory{}, tc.err
}}
w := httptest.NewRecorder()
ObserversRouter(r).ServeHTTP(w, httptest.NewRequest("GET", "/directory?sort=name&name=alpha&since=1767225600123&until=1767229200789&cursor=50&limit=25", nil))
if w.Code != tc.code {
t.Fatalf("status %d: %s", w.Code, w.Body.String())
}
}
}
func TestDirectoryNormalizesFirstPage(t *testing.T) {
r := stubReader{listObserverDirectory: func(_ context.Context, q api.ObserverDirectoryQuery) (*api.ObserverDirectory, error) {
if q.Sort != "traffic" || q.Limit != 200 || q.Name != "alpha" || q.Since != 1767225600999 || q.Until != 1767229200999 || len(q.IATAs) != 2 || q.IATAs[0] != "YVR" || q.IATAs[1] != "YYJ" {
t.Fatalf("query %+v", q)
}
return &api.ObserverDirectory{}, nil
}}
w := httptest.NewRecorder()
ObserversRouter(r).ServeHTTP(w, httptest.NewRequest("GET", "/directory?iatas=yyj,yvr,YVR&name=ALPHA&since=1767225600999&until=1767229200999&limit=201", nil))
if w.Code != 200 || w.Header().Get("Cache-Control") != "no-store" {
t.Fatalf("response %d %s", w.Code, w.Body.String())
}
}
func TestDirectoryRollingWeekAndContinuation(t *testing.T) {
var queries []api.ObserverDirectoryQuery
r := stubReader{listObserverDirectory: func(_ context.Context, q api.ObserverDirectoryQuery) (*api.ObserverDirectory, error) {
queries = append(queries, q)
return &api.ObserverDirectory{WindowStart: q.Since, WindowEnd: q.Until}, nil
}}
router := ObserversRouter(r)
before := time.Now().UnixMilli()
w := httptest.NewRecorder()
router.ServeHTTP(w, httptest.NewRequest("GET", "/directory?iatas=YVR,YYJ&limit=1", nil))
after := time.Now().UnixMilli()
if w.Code != 200 || len(queries) != 1 {
t.Fatalf("response %d: %s", w.Code, w.Body.String())
}
q := queries[0]
if q.Until < before || q.Until > after || q.Until-q.Since != (7*24*time.Hour).Milliseconds() {
t.Fatalf("window [%d,%d) does not span seven days through the request time", q.Since, q.Until)
}
var page api.ObserverDirectory
if err := json.Unmarshal(w.Body.Bytes(), &page); err != nil {
t.Fatal(err)
}
w = httptest.NewRecorder()
router.ServeHTTP(w, httptest.NewRequest("GET", fmt.Sprintf("/directory?iatas=YVR,YYJ&limit=1&cursor=1&since=%d&until=%d", page.WindowStart, page.WindowEnd), nil))
if w.Code != 200 || len(queries) != 2 {
t.Fatalf("continuation %d: %s", w.Code, w.Body.String())
}
if next := queries[1]; next.Since != q.Since || next.Until != q.Until || next.Sort != "traffic" || !slices.Equal(next.IATAs, q.IATAs) {
t.Fatalf("continuation changed the window or filters: %+v", next)
}
}
func TestDirectoryExactWindowValidation(t *testing.T) {
const until int64 = 1767229200123
for _, tc := range []struct {
name, query string
code int
since int64
}{
{"until only defaults to seven days", fmt.Sprintf("until=%d", until), 200, until - 604800000},
{"millisecond window", fmt.Sprintf("since=%d&until=%d", until-1, until), 200, until - 1},
{"31 days", fmt.Sprintf("since=%d&until=%d", until-2678400000, until), 200, until - 2678400000},
{"over 31 days", fmt.Sprintf("since=%d&until=%d", until-2678400001, until), 400, 0},
{"empty window", fmt.Sprintf("since=%d&until=%d", until, until), 400, 0},
{"repeated bound", fmt.Sprintf("since=1&since=2&until=%d", until), 400, 0},
} {
t.Run(tc.name, func(t *testing.T) {
r := stubReader{listObserverDirectory: func(_ context.Context, q api.ObserverDirectoryQuery) (*api.ObserverDirectory, error) {
if tc.code != 200 || q.Since != tc.since || q.Until != until {
t.Fatalf("unexpected query: %+v", q)
}
return &api.ObserverDirectory{}, nil
}}
w := httptest.NewRecorder()
ObserversRouter(r).ServeHTTP(w, httptest.NewRequest("GET", "/directory?"+tc.query, nil))
if w.Code != tc.code {
t.Fatalf("response %d: %s", w.Code, w.Body.String())
}
})
}
}
func TestDirectoryRegionFilters(t *testing.T) {
for _, tc := range []struct {
query string
iatas []string
none bool
}{
{"region=west", []string{"YVR", "YYJ"}, false},
{"regionId=7&iata=YOW", []string{"YOW", "YVR", "YYJ"}, false},
{"region=empty", nil, true},
} {
t.Run(tc.query, func(t *testing.T) {
r := stubReader{
getRegion: func(context.Context, int32) (*api.Region, error) {
return &api.Region{IATAs: []string{"YYJ", "YVR"}}, nil
},
getRegionBySlug: func(_ context.Context, slug string) (*api.Region, error) {
if slug == "empty" {
return &api.Region{}, nil
}
return &api.Region{IATAs: []string{"YYJ", "YVR"}}, nil
},
listObserverDirectory: func(_ context.Context, q api.ObserverDirectoryQuery) (*api.ObserverDirectory, error) {
if !slices.Equal(q.IATAs, tc.iatas) || q.MatchNone != tc.none {
t.Fatalf("query %+v", q)
}
return &api.ObserverDirectory{}, nil
},
}
w := httptest.NewRecorder()
ObserversRouter(r).ServeHTTP(w, httptest.NewRequest("GET", "/directory?"+tc.query, nil))
if w.Code != 200 {
t.Fatalf("response %d: %s", w.Code, w.Body.String())
}
})
}
}
+1
View File
@@ -25,6 +25,7 @@ import (
func ObserversRouter(reader api.Reader) http.Handler {
r := chi.NewRouter()
r.Get("/", listObservers(reader))
r.Get("/directory", listObserverDirectory(reader))
r.Route("/{observerId}", func(r chi.Router) {
r.Get("/", getObserver(reader))
r.Get("/adverts", listObserverAdverts(reader))
@@ -16,6 +16,7 @@ import (
// Unset fields return zero values. Use it for both validation tests
// (leave all fields nil) and happy path tests (set only what you need).
type stubReader struct {
listObserverDirectory func(context.Context, api.ObserverDirectoryQuery) (*api.ObserverDirectory, error)
getStatsSeries func(context.Context, time.Time, time.Time, []string) (*api.StatsSeries, error)
getRouteEvidence func(context.Context, string, string, api.RouteEvidenceQuery) (*api.RouteEvidence, error)
getObserverComparison func(context.Context, uuid.UUID, uuid.UUID, time.Time, time.Time, []string) (*api.ObserverComparison, error)
@@ -417,3 +418,10 @@ func (s stubReader) GetRouteEvidence(ctx context.Context, iata, key string, q ap
}
return nil, nil
}
func (s stubReader) ListObserverDirectory(ctx context.Context, q api.ObserverDirectoryQuery) (*api.ObserverDirectory, error) {
if s.listObserverDirectory != nil {
return s.listObserverDirectory(ctx, q)
}
return nil, nil
}
+48
View File
@@ -0,0 +1,48 @@
// Copyright 2026 Beacon Contributors
// SPDX-License-Identifier: AGPL-3.0-or-later
package api
type ObserverDirectoryQuery struct {
MatchNone bool
IATAs []string
Type string
Broker string
Status string
Name string
Scope string
Sort string
Since int64
Until int64
Cursor int64
Limit int32
}
type ObserverDirectoryItem struct {
ObserverSummary
// Available observations in [windowStart, windowEnd), including zero with missing history.
ObservationCount *int64 `json:"observationCount"`
}
// Coverage counts intersecting UTC hours. Complete hours use whole-hour rollups;
// partial hours have partial/boundary rollups or available raw observations.
type ObserverDirectoryCoverage struct {
Status string `json:"status"`
ExpectedHours int `json:"expectedHours"`
CompleteHours int `json:"completeHours"`
PartialHours int `json:"partialHours"`
MissingHours int `json:"missingHours"`
}
type ObserverDirectory struct {
Page[ObserverDirectoryItem]
GeneratedAt int64 `json:"generatedAt"`
WindowStart int64 `json:"windowStart"`
WindowEnd int64 `json:"windowEnd"`
Sort string `json:"sort"`
EffectiveSort string `json:"effectiveSort"`
Coverage ObserverDirectoryCoverage `json:"coverage"`
// Maximum over all matching observers, not only this page; zero when none match.
MaxObservationCount *int64 `json:"maxObservationCount"`
ObserverTypes []string `json:"observerTypes"`
}
+1
View File
@@ -23,6 +23,7 @@ type Page[T any] struct {
}
type Reader interface {
ListObserverDirectory(context.Context, ObserverDirectoryQuery) (*ObserverDirectory, error)
// GetRouteEvidence returns the full saved route and exact-prefix retained ordinary reports.
GetRouteEvidence(ctx context.Context, iata, pathKey string, query RouteEvidenceQuery) (*RouteEvidence, error)
// ListIATAs returns all known IATA codes with display name and coordinates.
+1 -1
View File
@@ -17,7 +17,7 @@ import (
func TestAdminConfig(t *testing.T) {
const key = "admin-key-sentinel"
for _, variable := range []string{"POSTGRES_DSN", "MQTT_BROKER_1_URL", "MQTT_BROKER_1_USERNAME", "MQTT_BROKER_1_PASSWORD", "REDIS_PASSWORD"} {
for _, variable := range []string{"POSTGRES_DSN", "MQTT_BROKER_1_URL", "MQTT_BROKER_1_USERNAME", "MQTT_BROKER_1_PASSWORD", "REDIS_PASSWORD", "MESHMAPPER_API_KEY"} {
t.Setenv(variable, "environment-secret-sentinel")
}
for _, custom := range []bool{false, true} {
+14
View File
@@ -19,6 +19,8 @@ import (
"path/filepath"
"strings"
"time"
"gopkg.in/yaml.v3"
)
const (
@@ -229,6 +231,18 @@ func readConfig(path string) ([]byte, error) {
if err != nil || len(data) > maxConfigBytes {
return nil, errors.New("cannot read saved config within the 1 MiB limit")
}
var credentials struct {
MeshMapper struct {
APIKey string `yaml:"api_key"`
} `yaml:"meshmapper"`
}
if err := yaml.Unmarshal(data, &credentials); err != nil {
return nil, errors.New("cannot inspect saved config for MeshMapper credentials")
}
// Backups preserve YAML verbatim, so require the deployment secret outside it.
if credentials.MeshMapper.APIKey != "" {
return nil, errors.New("backup unavailable: move meshmapper.api_key to MESHMAPPER_API_KEY and clear the saved YAML value")
}
return data, nil
}
+31
View File
@@ -24,6 +24,37 @@ import (
const testSQL = "CREATE TABLE fixture (id bigint PRIMARY KEY, name text);\nINSERT INTO fixture VALUES (1, 'café');\n"
func TestBackupDoesNotExportMeshMapperAPIKey(t *testing.T) {
for _, saved := range []string{
"meshmapper:\n api_key: backup-secret-canary\n",
"shared: &integration {api_key: backup-secret-canary}\nmeshmapper: *integration\n",
"meshmapper:\n <<: {api_key: backup-secret-canary}\n",
"meshmapper:\n api_key: [backup-secret-canary]\n",
} {
path := filepath.Join(t.TempDir(), "config.yaml")
if err := os.WriteFile(path, []byte(saved), 0600); err != nil {
t.Fatal(err)
}
data, err := readConfig(path)
if err == nil || data != nil {
t.Fatal("saved MeshMapper key could enter a backup response")
}
if strings.Contains(err.Error(), "backup-secret-canary") {
t.Fatal("backup error exposed the key")
}
}
t.Setenv("MESHMAPPER_API_KEY", "environment-secret-canary")
saved := []byte("# Saved config remains verbatim.\nmeshmapper:\n api_key: ''\n")
path := filepath.Join(t.TempDir(), "config.yaml")
if err := os.WriteFile(path, saved, 0600); err != nil {
t.Fatal(err)
}
data, err := readConfig(path)
if err != nil || !bytes.Equal(data, saved) {
t.Fatal("environment key changed the saved-config export")
}
}
// TestDumpProcess supplies an actual subprocess, including failure and cancellation.
func TestDumpProcess(t *testing.T) {
mode := os.Getenv("BEACON_BACKUP_TEST_PROCESS")
+4
View File
@@ -446,3 +446,7 @@ func TestCachedReader_InvalidateObserver(t *testing.T) {
func (s *stubReader) GetRouteEvidence(_ context.Context, _, _ string, _ api.RouteEvidenceQuery) (*api.RouteEvidence, error) {
return nil, nil
}
func (s *stubReader) ListObserverDirectory(context.Context, api.ObserverDirectoryQuery) (*api.ObserverDirectory, error) {
return nil, nil
}
+4
View File
@@ -378,3 +378,7 @@ func (cr *CachedReader) SearchCrossIATARoutes(ctx context.Context, fromHash, fro
func (cr *CachedReader) ListTraceTags(ctx context.Context, iatas []string, scope, traceType string, since, until time.Time, cursor time.Time, cursorTag string, limit int32) ([]api.TraceTagSummary, error) {
return cr.inner.ListTraceTags(ctx, iatas, scope, traceType, since, until, cursor, cursorTag, limit)
}
func (cr *CachedReader) ListObserverDirectory(ctx context.Context, query api.ObserverDirectoryQuery) (*api.ObserverDirectory, error) {
return cr.inner.ListObserverDirectory(ctx, query)
}
+5
View File
@@ -393,6 +393,7 @@ func Load(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
cfg.MeshMapper.APIKey = strings.TrimSpace(os.Getenv("MESHMAPPER_API_KEY"))
cfg.Auth.APIKey = os.Getenv("BEACON_API_KEY")
if err := cfg.validateAPIKey(); err != nil {
return nil, err
@@ -407,6 +408,10 @@ func Load(path string) (*Config, error) {
if value, set := os.LookupEnv("BEACON_API_KEY"); set {
cfg.Auth.APIKey = value
}
if value, set := os.LookupEnv("MESHMAPPER_API_KEY"); set {
cfg.MeshMapper.APIKey = value
}
cfg.MeshMapper.APIKey = strings.TrimSpace(cfg.MeshMapper.APIKey)
if err := cfg.validateAPIKey(); err != nil {
return nil, err
}
+1
View File
@@ -18,6 +18,7 @@ const (
)
type MeshMapperConfig struct {
APIKey string `yaml:"api_key" json:"-"`
Scopes MeshMapperScopesConfig `yaml:"scopes"`
Zones MeshMapperZonesConfig `yaml:"zones"`
Channels MeshMapperChannelsConfig `yaml:"channels"`
+44
View File
@@ -4,6 +4,7 @@
package config
import (
"encoding/json"
"os"
"path/filepath"
"strings"
@@ -11,6 +12,49 @@ import (
"time"
)
func TestLoadMeshMapperAPIKey(t *testing.T) {
for _, tc := range []struct {
name, env, want string
file, set bool
}{
{"missing", "", "", false, false},
{"YAML", "", "yaml-integration-test-key", true, false},
{"environment overrides", "env-integration-test-key", "env-integration-test-key", true, true},
{"empty environment overrides", "", "", true, true},
{"environment without file", "env-integration-test-key", "env-integration-test-key", false, true},
{"trim whitespace", " env-integration-test-key\n", "env-integration-test-key", true, true},
} {
t.Run(tc.name, func(t *testing.T) {
t.Setenv("MESHMAPPER_API_KEY", tc.env)
if !tc.set {
if err := os.Unsetenv("MESHMAPPER_API_KEY"); err != nil {
t.Fatal(err)
}
}
path := filepath.Join(t.TempDir(), "config.yaml")
if tc.file {
if err := os.WriteFile(path, []byte("meshmapper:\n api_key: yaml-integration-test-key\n scopes:\n enabled: true\n"), 0600); err != nil {
t.Fatal(err)
}
}
cfg, err := Load(path)
if err != nil {
t.Fatal(err)
}
if cfg.MeshMapper.APIKey != tc.want {
t.Fatal("MeshMapper key not loaded with expected precedence")
}
encoded, err := json.Marshal(cfg)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(encoded), "integration-test-key") || strings.Contains(Resolve(cfg).String(), "integration-test-key") {
t.Fatal("MeshMapper key exposed in JSON or startup summary")
}
})
}
}
func TestMeshMapperConfig(t *testing.T) {
valid := "meshmapper:\n scopes:\n enabled: true\n"
for _, tc := range []struct {
+427
View File
@@ -0,0 +1,427 @@
// 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")
}
})
}
}
+8 -9
View File
@@ -64,10 +64,7 @@ type Channels struct {
// Disabled, it clears MeshMapper's region membership so config scoping alone applies.
func NewChannels(ctx context.Context, cfg config.MeshMapperChannelsConfig, store ChannelStore, dir *Directory, keys ChannelKeys) (*Channels, error) {
c := &Channels{store: store, keys: keys, dir: dir, channelsURL: channelsEndpoint, interval: cfg.Interval(), seen: map[string]bool{},
client: &http.Client{
Timeout: requestTimeout,
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
}}
client: dir.client}
if !cfg.Enabled {
if err := store.ClearChannelMembers(ctx); err != nil {
return nil, fmt.Errorf("clear MeshMapper channel members: %w", err)
@@ -186,19 +183,21 @@ func (c *Channels) refresh(ctx context.Context, s *channelSource, now time.Time)
}
s.cache.AttemptedAt, s.cache.LastError = now, attempt.LastError
status, body, header, err := get(ctx, c.client, "Beacon-MeshMapper-Channels/1", s.url, etag, MaxChannelBody)
if err != nil {
return err
if ctx.Err() != nil {
return ctx.Err()
}
var entries []keystore.Entry
var retryAt time.Time
switch status {
case http.StatusOK:
switch {
case err != nil:
update.LastError = requestProblem(err)
case status == http.StatusOK:
if entries, err = decodeChannels(body, s.iata); err != nil {
update.LastError = "invalid response"
} else {
update.Payload = body
}
case http.StatusNotModified:
case status == http.StatusNotModified:
if len(s.cache.Payload) == 0 {
update.LastError = "304 without cached catalogue"
}
+2 -2
View File
@@ -115,7 +115,7 @@ type channelHarness struct {
func newChannelHarness(t *testing.T, f *channelSite, store *channelMemoryStore, lists *zoneListMemory, enabled bool) *channelHarness {
t.Helper()
dir := NewDirectory(lists)
dir := newTestDirectory(lists)
dir.listURL = f.URL + "/get_zones.php"
if err := dir.Restore(context.Background()); err != nil {
t.Fatal(err)
@@ -237,7 +237,7 @@ func TestChannelsRequestCutShortStillCounts(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { <-r.Context().Done() }))
defer server.Close()
store := newChannelMemoryStore("YOW")
c, err := NewChannels(context.Background(), config.MeshMapperChannelsConfig{Enabled: true}, store, NewDirectory(newZoneListMemory()), keystore.NewMapKeyStore(nil))
c, err := NewChannels(context.Background(), config.MeshMapperChannelsConfig{Enabled: true}, store, newTestDirectory(newZoneListMemory()), keystore.NewMapKeyStore(nil))
if err != nil {
t.Fatal(err)
}
+67
View File
@@ -0,0 +1,67 @@
// Copyright 2026 Beacon Contributors
// SPDX-License-Identifier: AGPL-3.0-or-later
package meshmapper
import (
"errors"
"net/http"
"net/url"
"strings"
)
var (
errMissingKey = errors.New("unconfigured: MeshMapper API key is missing")
errInvalidKey = errors.New("unconfigured: MeshMapper API key contains invalid characters")
errUntrustedEndpoint = errors.New("untrusted MeshMapper endpoint")
)
func newClient(apiKey string) *http.Client {
return &http.Client{
Timeout: requestTimeout,
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
Transport: &authenticatedTransport{key: apiKey, base: http.DefaultTransport},
}
}
type authenticatedTransport struct {
key string
base http.RoundTripper
}
func (t *authenticatedTransport) RoundTrip(req *http.Request) (*http.Response, error) {
if t.key == "" {
return nil, errMissingKey
}
if strings.ContainsFunc(t.key, func(r rune) bool { return r <= ' ' || r >= 127 }) {
return nil, errInvalidKey
}
if !trustedEndpoint(req.URL) || (req.Host != "" && req.Host != req.URL.Host) {
return nil, errUntrustedEndpoint
}
// Clone so credentials never enter a caller's request or redirect headers.
request := req.Clone(req.Context())
request.Header.Set("X-API-Key", t.key)
return t.base.RoundTrip(request)
}
func trustedEndpoint(u *url.URL) bool {
if u.Scheme != "https" || u.User != nil || u.Fragment != "" || u.Opaque != "" ||
(u.Host != "meshmapper.net" && !zoneSite.MatchString(u.Host)) || u.EscapedPath() != u.Path {
return false
}
switch u.Path {
case "/get_zones.php", "/get_geojson.php", "/get_scopes.php", "/get_channels.php", "/get_repeaters.php":
return true
}
return false
}
func requestProblem(err error) string {
for _, problem := range []error{errMissingKey, errInvalidKey, errUntrustedEndpoint} {
if errors.Is(err, problem) {
return problem.Error()
}
}
return "request failed" // Transport errors may contain URLs or credentials.
}
+9 -10
View File
@@ -63,11 +63,8 @@ type Directory struct {
version int // bumped whenever a list is replaced
}
func NewDirectory(store DirectoryStore) *Directory {
return &Directory{store: store, listURL: ZonesURL, lists: map[string]*zoneList{}, client: &http.Client{
Timeout: requestTimeout,
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
}}
func NewDirectory(store DirectoryStore, apiKey string) *Directory {
return &Directory{store: store, listURL: ZonesURL, lists: map[string]*zoneList{}, client: newClient(apiKey)}
}
// Restore loads saved lists so a restart doesn't spend each country's daily request.
@@ -130,23 +127,25 @@ func (d *Directory) fetch(ctx context.Context, list *zoneList, country string, c
}
endpoint := d.listURL + "?country=" + url.QueryEscape(country)
status, body, header, err := get(ctx, d.client, "Beacon-MeshMapper-Zones/1", endpoint, etag, MaxZoneList)
if err != nil {
return err
if ctx.Err() != nil {
return ctx.Err()
}
problem := ""
var payload json.RawMessage
var retryAt time.Time
var zones map[string]zoneEntry
var groups map[string]zoneGroup
switch status {
case http.StatusOK:
switch {
case err != nil:
problem = requestProblem(err)
case status == http.StatusOK:
var decodeErr error
if zones, groups, decodeErr = decodeZones(body, country); decodeErr != nil {
problem = "invalid response"
} else {
payload = body
}
case http.StatusNotModified:
case status == http.StatusNotModified:
if !cached {
problem = "304 without cached list"
}
+1 -1
View File
@@ -39,7 +39,7 @@ func newGroupHarness(t *testing.T, f *fakeMeshMapper, store *zoneMemoryStore, im
store.rows = map[string]Boundary{}
}
h := &groupHarness{f: f, store: store}
dir := NewDirectory(newZoneListMemory())
dir := newTestDirectory(newZoneListMemory())
dir.listURL = f.URL + "/get_zones.php"
h.z = NewZones(config.MeshMapperZonesConfig{Enabled: true, ImportGroups: importGroups}, store, dir)
h.z.boundsURL = func(site string) (string, bool) { return site + "get_geojson.php", true }
+33 -56
View File
@@ -8,11 +8,9 @@ import (
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"slices"
"strconv"
"strings"
"time"
"unicode"
@@ -78,10 +76,7 @@ func (i *Importer) SetCacheInvalidator(fn func(context.Context)) { i.onChange =
// New restores validated snapshots before ingestion, without making HTTP requests.
func New(ctx context.Context, cfg config.MeshMapperScopesConfig, store Store, dir *Directory, scopes *scopestore.ScopeStore, manual []scopestore.Entry) (*Importer, error) {
i := &Importer{store: store, scopes: scopes, manual: manual, dir: dir, scopesURL: scopesEndpoint, seen: map[string]bool{},
interval: cfg.Interval(), client: &http.Client{
Timeout: requestTimeout,
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
}}
interval: cfg.Interval(), client: dir.client}
if !cfg.Enabled {
return i, nil
}
@@ -194,14 +189,9 @@ func (i *Importer) Refresh(ctx context.Context) (err error) {
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)
etag := ""
if len(s.cache.Payload) > 0 {
etag = s.cache.ETag
}
// Recorded first: an abandoned request may still have used the region's call.
attempt := Cache{AttemptedAt: now, NextAttempt: now.Add(min(i.interval, failureRetry)), LastError: "no response"}
@@ -210,54 +200,41 @@ func (i *Importer) refresh(ctx context.Context, s *source, now time.Time) error
return fmt.Errorf("persist scope catalogue %s: %w", s.iata, err)
}
s.cache.AttemptedAt, s.cache.LastError = now, attempt.LastError
response, err := i.client.Do(request)
status, body, header, err := get(ctx, i.client, "Beacon-MeshMapper-Scopes/1", s.url, etag, MaxBody)
if ctx.Err() != nil {
return ctx.Err()
}
var entries []scopestore.Entry
var generated time.Time
var retryAfter time.Time
if err != nil {
if ctx.Err() != nil {
return ctx.Err()
switch {
case err != nil:
update.LastError = requestProblem(err)
case status == http.StatusOK:
if entries, generated, err = decode(body, s.iata); err != nil {
update.LastError = "invalid response"
} else {
update.Payload = body
}
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) {
retryAfter = now.Add(time.Duration(seconds) * time.Second)
}
}
case status == http.StatusNotModified:
if len(s.cache.Payload) == 0 || s.cache.ETag == "" {
update.LastError = "304 without cached catalogue"
}
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
default:
update.LastError = statusProblem(status, header, now, &retryAfter)
}
if update.LastError == "" {
etag := 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 status == http.StatusNotModified && etag == "" {
update.ETag = s.cache.ETag
}
update.CheckedAt = now
}
}
if update.LastError != "" {
+5 -5
View File
@@ -75,7 +75,7 @@ func (s *memoryStore) SaveScopeCatalogue(_ context.Context, iata, url string, ne
// newImporter restores an importer for YOW whose source points at url.
func newImporter(t *testing.T, cfg config.MeshMapperScopesConfig, store *memoryStore, url string, scopes *scopestore.ScopeStore, manual ...scopestore.Entry) *Importer {
t.Helper()
imp, err := New(context.Background(), cfg, store, NewDirectory(newZoneListMemory()), scopes, manual)
imp, err := New(context.Background(), cfg, store, newTestDirectory(newZoneListMemory()), scopes, manual)
if err != nil {
t.Fatal(err)
}
@@ -260,7 +260,7 @@ func TestImportedSourcesOverlapWithoutGlobalMembership(t *testing.T) {
store.rows[sourceKey{iata, iata}] = Cache{Payload: []byte(strings.ReplaceAll(string(catalogue("can")), "YOW", iata)), AttemptedAt: time.Now()}
}
scopes := scopestore.New()
_, err := New(ctx, config.MeshMapperScopesConfig{Enabled: true}, store, NewDirectory(newZoneListMemory()), scopes, nil)
_, err := New(ctx, config.MeshMapperScopesConfig{Enabled: true}, store, newTestDirectory(newZoneListMemory()), scopes, nil)
if err != nil || len(scopes.Entries()) != 1 || !reflect.DeepEqual(scopes.Entries()[0].IATAs, []string{"YOW", "YVR"}) {
t.Fatal("latest saved catalogue per IATA not restored", scopes.Entries(), err)
}
@@ -269,7 +269,7 @@ func TestImportedSourcesOverlapWithoutGlobalMembership(t *testing.T) {
}
// Disabled mode never calls the store.
disabled := scopestore.New()
if _, err = New(ctx, config.MeshMapperScopesConfig{}, nil, NewDirectory(newZoneListMemory()), disabled, nil); err != nil || len(disabled.Entries()) != 0 {
if _, err = New(ctx, config.MeshMapperScopesConfig{}, nil, newTestDirectory(newZoneListMemory()), disabled, nil); err != nil || len(disabled.Entries()) != 0 {
t.Fatal(err)
}
}
@@ -360,7 +360,7 @@ func newFakeSites(t *testing.T) *fakeSites {
func newDiscoveringImporter(t *testing.T, f *fakeSites, store *memoryStore, scopes *scopestore.ScopeStore) *Importer {
t.Helper()
dir := NewDirectory(newZoneListMemory())
dir := newTestDirectory(newZoneListMemory())
dir.listURL = f.URL + "/get_zones.php"
imp, err := New(context.Background(), config.MeshMapperScopesConfig{Enabled: true}, store, dir, scopes, nil)
if err != nil {
@@ -448,7 +448,7 @@ func newSweep(t *testing.T, store *memoryStore, fail ...string) (*Importer, *[]s
_, _ = w.Write([]byte(strings.ReplaceAll(string(catalogue("x")), "YOW", iata)))
}))
t.Cleanup(server.Close)
dir := NewDirectory(newZoneListMemory())
dir := newTestDirectory(newZoneListMemory())
dir.listURL = server.URL + "/get_zones.php"
imp, err := New(context.Background(), config.MeshMapperScopesConfig{Enabled: true}, store, dir, scopestore.New(), nil)
if err != nil {
+18 -14
View File
@@ -87,10 +87,7 @@ type Zones struct {
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: &http.Client{
Timeout: requestTimeout,
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
}}
boundsURL: boundaryEndpoint, client: dir.client}
}
// OnChange receives each IATA whose served border changed, for cache invalidation.
@@ -228,11 +225,13 @@ func (z *Zones) refresh(ctx context.Context, r *zoneRegion, zones map[string]zon
}
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 err != nil {
return err
if ctx.Err() != nil {
return ctx.Err()
}
switch status {
case http.StatusOK:
switch {
case err != nil:
update.LastError = requestProblem(err)
case status == http.StatusOK:
feature, decodeErr := decodeBoundary(body, r.b.IATA)
switch {
case decodeErr != nil:
@@ -242,7 +241,7 @@ func (z *Zones) refresh(ctx context.Context, r *zoneRegion, zones map[string]zon
default:
update.Feature = feature
}
case http.StatusNotModified:
case status == http.StatusNotModified:
if r.b.Feature == nil {
update.LastError = "304 without cached boundary"
}
@@ -287,7 +286,7 @@ func (z *Zones) refresh(ctx context.Context, r *zoneRegion, zones map[string]zon
return nil
}
// get returns transport errors only when ctx ended; other failures become status 0.
// 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 {
@@ -300,10 +299,7 @@ func get(ctx context.Context, client *http.Client, agent, endpoint, etag string,
}
response, err := client.Do(request)
if err != nil {
if ctx.Err() != nil {
return 0, nil, nil, ctx.Err()
}
return 0, nil, http.Header{}, nil
return 0, nil, nil, err
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
@@ -321,9 +317,17 @@ func statusProblem(status int, header http.Header, now time.Time, retryAt *time.
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)
+7 -7
View File
@@ -227,7 +227,7 @@ func newZoneHarness(t *testing.T, f *fakeMeshMapper, store *zoneMemoryStore, ena
if store.lists == nil {
store.lists = newZoneListMemory()
}
dir := NewDirectory(store.lists)
dir := newTestDirectory(store.lists)
dir.listURL = f.URL + "/get_zones.php"
if err := dir.Restore(context.Background()); err != nil {
t.Fatal(err)
@@ -488,17 +488,17 @@ func TestDecodeBoundaryRejectsUnusableOutlines(t *testing.T) {
func TestClientsAllowMeshMapperTimeout(t *testing.T) {
ctx := context.Background()
scopes, err := New(ctx, config.MeshMapperScopesConfig{}, nil, NewDirectory(nil), scopestore.New(), nil)
scopes, err := New(ctx, config.MeshMapperScopesConfig{}, nil, newTestDirectory(nil), scopestore.New(), nil)
if err != nil {
t.Fatal(err)
}
channels, err := NewChannels(ctx, config.MeshMapperChannelsConfig{}, newChannelMemoryStore(), NewDirectory(nil), keystore.NewMapKeyStore(nil))
channels, err := NewChannels(ctx, config.MeshMapperChannelsConfig{}, newChannelMemoryStore(), newTestDirectory(nil), keystore.NewMapKeyStore(nil))
if err != nil {
t.Fatal(err)
}
for name, c := range map[string]*http.Client{
"directory": NewDirectory(nil).client,
"zones": NewZones(config.MeshMapperZonesConfig{}, nil, nil).client,
"directory": newTestDirectory(nil).client,
"zones": NewZones(config.MeshMapperZonesConfig{}, nil, newTestDirectory(nil)).client,
"scopes": scopes.client,
"channels": channels.client,
} {
@@ -515,7 +515,7 @@ func TestZoneListCutShortStillCounts(t *testing.T) {
f := newFakeMeshMapper(t)
f.hang.Store(true)
lists := newZoneListMemory()
dir := NewDirectory(lists)
dir := newTestDirectory(lists)
dir.listURL = f.URL + "/get_zones.php"
now := time.Now().UTC()
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
@@ -542,7 +542,7 @@ func TestZoneListFetchDoesNotBlockOtherCallers(t *testing.T) {
}))
defer server.Close()
defer close(release)
dir := NewDirectory(newZoneListMemory())
dir := newTestDirectory(newZoneListMemory())
dir.listURL = server.URL
now := time.Now().UTC()
go func() { _, _, _ = dir.List(context.Background(), "CA", now) }()