Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cmd/beacon/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ import (
var version = "dev"

// @title MeshCore Beacon API
// @version 2.0.0
// @version 2.0.1
// @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.
Expand Down
2 changes: 1 addition & 1 deletion config.yaml.example
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ ratelimit:
#meshmapper:
# scopes:
# enabled: false
# refresh_interval: 1h # 1h-24h (MeshMapper allows one get_scopes.php per region per 55m); one source per 15s tick
# refresh_interval: 24h # 1h-24h (MeshMapper allows one get_scopes.php per region per 55m); one source per 15s tick
#
# Region boundaries (Zones API). Every known IATA, including ones auto-created
# from traffic, is looked up in its country's zone list, then its get_geojson.php
Expand Down
2 changes: 1 addition & 1 deletion docs/docs.go
Original file line number Diff line number Diff line change
Expand Up @@ -5811,7 +5811,7 @@ const docTemplate = `{

// SwaggerInfo holds exported Swagger Info so clients can modify it
var SwaggerInfo = &swag.Spec{
Version: "2.0.0",
Version: "2.0.1",
Host: "localhost:8080",
BasePath: "/api/v1",
Schemes: []string{"http", "https"},
Expand Down
2 changes: 1 addition & 1 deletion docs/swagger.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
"license": {
"name": "AGPL-3-or-later"
},
"version": "2.0.0"
"version": "2.0.1"
},
"host": "localhost:8080",
"basePath": "/api/v1",
Expand Down
2 changes: 1 addition & 1 deletion docs/swagger.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1635,7 +1635,7 @@ info:
name: AGPL-3-or-later
termsOfService: https://github.com/MeshCore-Beacon/beacon-server
title: MeshCore Beacon API
version: 2.0.0
version: 2.0.1
paths:
/admin/accounts:
get:
Expand Down
2 changes: 1 addition & 1 deletion internal/config/meshmapper.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ type MeshMapperScopesConfig struct {

func (c MeshMapperScopesConfig) Interval() time.Duration {
if c.RefreshInterval.Duration == 0 {
return time.Hour
return 24 * time.Hour
}
return c.RefreshInterval.Duration
}
Expand Down
2 changes: 1 addition & 1 deletion internal/config/meshmapper_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ func TestMeshMapperConfig(t *testing.T) {
}
})
}
if (MeshMapperScopesConfig{}).Interval() != time.Hour {
if (MeshMapperScopesConfig{}).Interval() != 24*time.Hour {
t.Fatal("wrong default")
}
}
Expand Down
10 changes: 9 additions & 1 deletion internal/meshmapper/scopes.go
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,15 @@ func (i *Importer) Refresh(ctx context.Context) (err error) {
return fmt.Errorf("list IATAs for MeshMapper scopes: %w", err)
}
i.track(iatas, nil)
for n := range i.sources {
// Longest-waiting first, so a pass longer than the interval can't starve the tail.
order := make([]int, len(i.sources))
for n := range order {
order[n] = n
}
slices.SortStableFunc(order, func(a, b int) int {
return i.sources[a].cache.NextAttempt.Compare(i.sources[b].cache.NextAttempt)
})
for _, n := range order {
s := &i.sources[n]
if now.Before(s.cache.NextAttempt) {
continue
Expand Down
59 changes: 59 additions & 0 deletions internal/meshmapper/scopes_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -425,3 +425,62 @@ func TestScopesRequestCutShortStillCounts(t *testing.T) {
t.Fatal("abandoned request not persisted", row)
}
}

// newSweep serves a CA zone list whose IATAs each have a catalogue; fail lists IATAs answering 500.
func newSweep(t *testing.T, store *memoryStore, fail ...string) (*Importer, *[]string) {
t.Helper()
var hits []string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/get_zones.php" {
var zones []string
for _, iata := range store.iatas {
zones = append(zones, fmt.Sprintf(`{"code":%q,"url":"%s/%s/","has_boundary":true,"group":null}`, iata, "http://"+r.Host, iata))
}
_, _ = fmt.Fprintf(w, `{"country":"CA","zones":[%s]}`, strings.Join(zones, ","))
return
}
iata := strings.Split(r.URL.Path, "/")[1]
hits = append(hits, iata)
if slices.Contains(fail, iata) {
w.WriteHeader(500)
return
}
_, _ = w.Write([]byte(strings.ReplaceAll(string(catalogue("x")), "YOW", iata)))
}))
t.Cleanup(server.Close)
dir := NewDirectory(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 {
t.Fatal(err)
}
imp.scopesURL = func(site string) (string, bool) { return site + "get_scopes.php", true }
refreshN(t, imp, 1) // zone list fetch
return imp, &hits
}

func TestScopesLongestWaitingGoesFirst(t *testing.T) {
store := newMemoryStore("YOW", "YUL", "YVR", "YYZ")
now := time.Now()
// Restored mid-pass: YOW/YUL done, YVR never reached; YOW is already due again.
store.rows[sourceKey{"YOW", "old"}] = Cache{AttemptedAt: now.Add(-time.Hour), NextAttempt: now.Add(-time.Minute)}
store.rows[sourceKey{"YUL", "old"}] = Cache{AttemptedAt: now, NextAttempt: now.Add(time.Hour)}
store.rows[sourceKey{"YVR", "old"}] = Cache{AttemptedAt: now.Add(-48 * time.Hour), NextAttempt: now.Add(-47 * time.Hour)}
imp, hits := newSweep(t, store)
refreshN(t, imp, 3)
if want := []string{"YYZ", "YVR", "YOW"}; !reflect.DeepEqual(*hits, want) {
t.Fatalf("refresh order %v, want %v", *hits, want)
}
}

func TestScopesFailureDoesNotStallThePass(t *testing.T) {
store := newMemoryStore("YOW", "YUL", "YVR")
imp, hits := newSweep(t, store, "YOW")
refreshN(t, imp, 4)
if want := []string{"YOW", "YUL", "YVR"}; !reflect.DeepEqual(*hits, want) {
t.Fatalf("refresh order %v, want %v", *hits, want)
}
if s := imp.sources[0]; s.iata != "YOW" || time.Until(s.cache.NextAttempt) > failureRetry {
t.Fatalf("failed source not retried early: %+v", s.cache)
}
}
Loading