release: v2.0.3 (#215)

* chore: add .github/CODEOWNERS for main branch policy

Signed-off-by: MrAlders0n <MrAlders0n@smal.ca>

* chore: add .github/workflows/main-source-guard.yml for main branch policy

Signed-off-by: MrAlders0n <MrAlders0n@smal.ca>

* feat(meshmapper): authenticate API imports (#211)

* feat(observers): add traffic-sorted directory (#212)

* fix(observers): rank available traffic over rolling seven days

* chore: bump version to 2.1.0

* chore: set release version to 2.0.2

* feat(api): add /info with mobile min app version (#214)

* chore: bump version to 2.0.3

---------

Signed-off-by: MrAlders0n <MrAlders0n@smal.ca>
This commit is contained in:
MrAlders0n
2026-10-06 21:53:30 -04:00
committed by GitHub
parent 041d9c1f45
commit 768889243c
15 changed files with 310 additions and 6 deletions
+5 -2
View File
@@ -18,7 +18,7 @@ import (
"time"
"github.com/MeshCore-Beacon/beacon-server/db"
_ "github.com/MeshCore-Beacon/beacon-server/docs"
"github.com/MeshCore-Beacon/beacon-server/docs"
"github.com/MeshCore-Beacon/beacon-server/internal/api"
"github.com/MeshCore-Beacon/beacon-server/internal/api/handlers"
"github.com/MeshCore-Beacon/beacon-server/internal/api/router"
@@ -45,7 +45,7 @@ import (
var version = "dev"
// @title MeshCore Beacon API
// @version 2.0.2
// @version 2.0.3
// @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.
@@ -84,6 +84,8 @@ var version = "dev"
// @tag.description MQTT broker connection status
// @tag.name Stats
// @tag.description Network statistics and time series
// @tag.name Info
// @tag.description Server info for the BEACON Mobile app
func main() {
_ = godotenv.Load()
addr := os.Getenv("LISTEN_ADDR")
@@ -432,6 +434,7 @@ func main() {
WSAllowedOrigins: cfg.WebSocket.AllowedOrigins,
CORS: cfg.CORS, Server: cfg.Server, Auth: cfg.Auth, RateLimit: resolved.RateLimit,
Scopes: scopes, RollupRetention: resolved.RollupRetention,
MinAppVersion: cfg.Mobile.MinAppVersion, ServerVersion: docs.SwaggerInfo.Version,
AdminRoutes: map[string]http.Handler{
"/accounts": handlers.AccountsRouter(store),
"/backup": handlers.BackupRouter(backupOpts, ctx),
+6
View File
@@ -319,3 +319,9 @@ cache:
# view_refresh: 1h # rebuilds radio presets; default: 1h
# reconfirm: 1h # default: 1h
# cleanup: 1h # default: 1h
# Minimum BEACON Mobile version, served at GET /api/v1/info. Older apps show
# "update required" for this server. Strict X.Y.Z (no v, no suffix); anything
# else prevents startup. Default: empty, no requirement.
#mobile:
# min_app_version: "0.1.1"
+51 -1
View File
@@ -897,6 +897,26 @@ const docTemplate = `{
}
}
},
"/info": {
"get": {
"description": "minAppVersion is null when the server sets no requirement.",
"produces": [
"application/json"
],
"tags": [
"Info"
],
"summary": "Server info and minimum mobile app version",
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.Info"
}
}
}
}
},
"/messages": {
"get": {
"produces": [
@@ -3639,6 +3659,9 @@ const docTemplate = `{
},
"ingest": {
"$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.AdminIngestConfig"
},
"mobile": {
"$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.AdminMobileConfig"
}
}
},
@@ -3651,6 +3674,15 @@ const docTemplate = `{
}
}
},
"github_com_MeshCore-Beacon_beacon-server_internal_api.AdminMobileConfig": {
"type": "object",
"properties": {
"min_app_version": {
"description": "empty means no requirement",
"type": "string"
}
}
},
"github_com_MeshCore-Beacon_beacon-server_internal_api.AdvertObservation": {
"type": "object",
"properties": {
@@ -3947,6 +3979,20 @@ const docTemplate = `{
}
}
},
"github_com_MeshCore-Beacon_beacon-server_internal_api.Info": {
"type": "object",
"properties": {
"minAppVersion": {
"description": "MinAppVersion is the oldest allowed app version (X.Y.Z); null means no requirement.",
"type": "string",
"example": "0.1.1"
},
"serverVersion": {
"type": "string",
"example": "2.0.2"
}
}
},
"github_com_MeshCore-Beacon_beacon-server_internal_api.KnownRoute": {
"type": "object",
"properties": {
@@ -6030,13 +6076,17 @@ const docTemplate = `{
{
"description": "Network statistics and time series",
"name": "Stats"
},
{
"description": "Server info for the BEACON Mobile app",
"name": "Info"
}
]
}`
// SwaggerInfo holds exported Swagger Info so clients can modify it
var SwaggerInfo = &swag.Spec{
Version: "2.0.2",
Version: "2.0.3",
Host: "localhost:8080",
BasePath: "/api/v1",
Schemes: []string{"http", "https"},
+51 -1
View File
@@ -15,7 +15,7 @@
"license": {
"name": "AGPL-3-or-later"
},
"version": "2.0.2"
"version": "2.0.3"
},
"host": "localhost:8080",
"basePath": "/api/v1",
@@ -895,6 +895,26 @@
}
}
},
"/info": {
"get": {
"description": "minAppVersion is null when the server sets no requirement.",
"produces": [
"application/json"
],
"tags": [
"Info"
],
"summary": "Server info and minimum mobile app version",
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.Info"
}
}
}
}
},
"/messages": {
"get": {
"produces": [
@@ -3637,6 +3657,9 @@
},
"ingest": {
"$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.AdminIngestConfig"
},
"mobile": {
"$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.AdminMobileConfig"
}
}
},
@@ -3649,6 +3672,15 @@
}
}
},
"github_com_MeshCore-Beacon_beacon-server_internal_api.AdminMobileConfig": {
"type": "object",
"properties": {
"min_app_version": {
"description": "empty means no requirement",
"type": "string"
}
}
},
"github_com_MeshCore-Beacon_beacon-server_internal_api.AdvertObservation": {
"type": "object",
"properties": {
@@ -3945,6 +3977,20 @@
}
}
},
"github_com_MeshCore-Beacon_beacon-server_internal_api.Info": {
"type": "object",
"properties": {
"minAppVersion": {
"description": "MinAppVersion is the oldest allowed app version (X.Y.Z); null means no requirement.",
"type": "string",
"example": "0.1.1"
},
"serverVersion": {
"type": "string",
"example": "2.0.2"
}
}
},
"github_com_MeshCore-Beacon_beacon-server_internal_api.KnownRoute": {
"type": "object",
"properties": {
@@ -6028,6 +6074,10 @@
{
"description": "Network statistics and time series",
"name": "Stats"
},
{
"description": "Server info for the BEACON Mobile app",
"name": "Info"
}
]
}
+35 -1
View File
@@ -52,6 +52,8 @@ definitions:
$ref: '#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.AdminCORSConfig'
ingest:
$ref: '#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.AdminIngestConfig'
mobile:
$ref: '#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.AdminMobileConfig'
type: object
github_com_MeshCore-Beacon_beacon-server_internal_api.AdminIngestConfig:
properties:
@@ -61,6 +63,12 @@ definitions:
or a configurable processing-worker pool.
type: integer
type: object
github_com_MeshCore-Beacon_beacon-server_internal_api.AdminMobileConfig:
properties:
min_app_version:
description: empty means no requirement
type: string
type: object
github_com_MeshCore-Beacon_beacon-server_internal_api.AdvertObservation:
properties:
heardAt:
@@ -264,6 +272,17 @@ definitions:
lon:
type: number
type: object
github_com_MeshCore-Beacon_beacon-server_internal_api.Info:
properties:
minAppVersion:
description: MinAppVersion is the oldest allowed app version (X.Y.Z); null
means no requirement.
example: 0.1.1
type: string
serverVersion:
example: 2.0.2
type: string
type: object
github_com_MeshCore-Beacon_beacon-server_internal_api.KnownRoute:
properties:
firstSeen:
@@ -1708,7 +1727,7 @@ info:
name: AGPL-3-or-later
termsOfService: https://github.com/MeshCore-Beacon/beacon-server
title: MeshCore Beacon API
version: 2.0.2
version: 2.0.3
paths:
/admin/accounts:
get:
@@ -2301,6 +2320,19 @@ paths:
summary: Get an IATA's GeoJSON border, if configured
tags:
- IATAs
/info:
get:
description: minAppVersion is null when the server sets no requirement.
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.Info'
summary: Server info and minimum mobile app version
tags:
- Info
/messages:
get:
parameters:
@@ -4186,3 +4218,5 @@ tags:
name: Brokers
- description: Network statistics and time series
name: Stats
- description: Server info for the BEACON Mobile app
name: Info
+5
View File
@@ -9,6 +9,11 @@ type AdminConfig struct {
Auth AdminAuthConfig `json:"auth"`
CORS AdminCORSConfig `json:"cors"`
Ingest AdminIngestConfig `json:"ingest"`
Mobile AdminMobileConfig `json:"mobile"`
}
type AdminMobileConfig struct {
MinAppVersion string `json:"min_app_version"` // empty means no requirement
}
type AdminAuthConfig struct {
+34
View File
@@ -0,0 +1,34 @@
// Copyright 2026 Beacon Contributors
// SPDX-License-Identifier: AGPL-3.0-or-later
package handlers
import (
"net/http"
"github.com/MeshCore-Beacon/beacon-server/internal/api"
"github.com/go-chi/chi/v5"
)
// InfoRouter mounts GET /info. The mobile app treats a 404 as "no requirement",
// so this route must stay mounted once shipped.
func InfoRouter(info api.Info) http.Handler {
r := chi.NewRouter()
r.Get("/", getInfo(info))
return r
}
// getInfo godoc
//
// @Summary Server info and minimum mobile app version
// @Description minAppVersion is null when the server sets no requirement.
// @Tags Info
// @Produce json
// @Success 200 {object} api.Info
// @Router /info [get]
func getInfo(info api.Info) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-cache")
respond(w, http.StatusOK, info)
}
}
+39
View File
@@ -0,0 +1,39 @@
// Copyright 2026 Beacon Contributors
// SPDX-License-Identifier: AGPL-3.0-or-later
package handlers
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/MeshCore-Beacon/beacon-server/internal/api"
)
func TestGetInfo(t *testing.T) {
version := "0.1.1"
for _, tc := range []struct {
name string
info api.Info
want string
}{
{"unset", api.Info{ServerVersion: "2.0.2"}, `{"minAppVersion":null,"serverVersion":"2.0.2"}`},
{"set", api.Info{MinAppVersion: &version, ServerVersion: "2.0.2"}, `{"minAppVersion":"0.1.1","serverVersion":"2.0.2"}`},
} {
t.Run(tc.name, func(t *testing.T) {
w := httptest.NewRecorder()
InfoRouter(tc.info).ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/", nil))
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
if got := w.Header().Get("Cache-Control"); got != "no-cache" {
t.Fatalf("Cache-Control = %q, want no-cache", got)
}
if got := strings.TrimSpace(w.Body.String()); got != tc.want {
t.Fatalf("body = %s, want %s", got, tc.want)
}
})
}
}
+11
View File
@@ -0,0 +1,11 @@
// Copyright 2026 Beacon Contributors
// SPDX-License-Identifier: AGPL-3.0-or-later
package api
// Info is the public server description the mobile app checks before using a server.
type Info struct {
// MinAppVersion is the oldest allowed app version (X.Y.Z); null means no requirement.
MinAppVersion *string `json:"minAppVersion" example:"0.1.1"`
ServerVersion string `json:"serverVersion" example:"2.0.2"`
}
+1 -1
View File
@@ -56,7 +56,7 @@ func TestAdminConfig(t *testing.T) {
if err := json.Unmarshal(response.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
expected := map[string]any{"cors": want, "auth": map[string]any{"configured": true}, "ingest": map[string]any{"broker_count": float64(2)}}
expected := map[string]any{"cors": want, "auth": map[string]any{"configured": true}, "ingest": map[string]any{"broker_count": float64(2)}, "mobile": map[string]any{"min_app_version": ""}}
if !reflect.DeepEqual(body, expected) {
t.Fatalf("unexpected config shape or values: %v", body)
}
+2
View File
@@ -23,6 +23,8 @@ type Options struct {
RateLimit config.ResolvedRateLimitConfig
Scopes handlers.ScopeMembership // region-filtered scope lists; nil lists none
RollupRetention time.Duration // longest historical stats window
MinAppVersion string // empty means no mobile app requirement
ServerVersion string
// AdminRoutes mounts operator-only subrouters at literal paths such as
// "/accounts". Each feature owns its dependencies, methods and handlers.
// The router applies authentication to every path and method in this group.
+7
View File
@@ -27,6 +27,7 @@ import (
//
// /ws → WebSocket (public in v1)
// /api/v1/ → public group
// /info → server info for the mobile app
// /packets → packets subrouter
// /nodes → nodes subrouter
// /brokers → brokers subrouter
@@ -69,6 +70,11 @@ func New(h *hub.Hub, reader api.Reader, workers []*ingest.Worker, opts Options)
MaxAge: maxAge,
},
Ingest: api.AdminIngestConfig{BrokerCount: len(workers)},
Mobile: api.AdminMobileConfig{MinAppVersion: opts.MinAppVersion},
}
info := api.Info{ServerVersion: opts.ServerVersion}
if opts.MinAppVersion != "" {
info.MinAppVersion = &opts.MinAppVersion
}
runtimeConfig := mw.NewRuntimeConfig(adminConfig, []string{"Retry-After"})
r.Use(runtimeConfig.CORS)
@@ -110,6 +116,7 @@ func New(h *hub.Hub, reader api.Reader, workers []*ingest.Worker, opts Options)
r.Mount("/scopes", handlers.ScopesRouter(reader, opts.Scopes))
r.Mount("/stats", handlers.StatsRouter(reader, handlers.StatsOptions{Scopes: opts.Scopes, SeriesWindow: opts.RollupRetention}))
r.Mount("/traces", handlers.TracesRouter(reader))
r.Mount("/info", handlers.InfoRouter(info))
})
// Protect the entire subtree, including its root and unknown paths.
+10
View File
@@ -90,3 +90,13 @@ func checkWebSocketLimit(t *testing.T, cfg config.ServerConfig, secondIP string,
t.Fatalf("established client stopped responding: %s %v", pong, err)
}
}
// Old servers 404 here and the app reads that as "no requirement", so the route must stay public.
func TestInfoRoute(t *testing.T) {
handler := New(nil, nil, nil, Options{MinAppVersion: "0.1.1", ServerVersion: "2.0.2"})
response := httptest.NewRecorder()
handler.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/api/v1/info", nil))
if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), `"minAppVersion":"0.1.1"`) {
t.Fatalf("info: %d %s", response.Code, response.Body.String())
}
}
+22
View File
@@ -11,6 +11,7 @@ import (
"net/url"
"os"
"path/filepath"
"regexp"
"slices"
"strings"
"time"
@@ -43,6 +44,13 @@ type Config struct {
Nodes NodesConfig `yaml:"nodes"`
Observers ObserversConfig `yaml:"observers"`
Analytics AnalyticsConfig `yaml:"analytics"`
Mobile MobileConfig `yaml:"mobile"`
}
// MobileConfig holds settings the BEACON Mobile app reads from /info.
type MobileConfig struct {
// MinAppVersion is the oldest app version (X.Y.Z) allowed to use this server; empty means any.
MinAppVersion string `yaml:"min_app_version"`
}
// BackupConfig enables protected downloads. Disabled by default.
@@ -450,6 +458,9 @@ func Load(path string) (*Config, error) {
if err := cfg.validateScopes(); err != nil {
return nil, err
}
if err := cfg.validateMobile(); err != nil {
return nil, err
}
if cfg.Nodes.MarkForeign && !cfg.MeshMapper.Zones.Enabled && !cfg.hasBorderFile() {
return nil, fmt.Errorf("nodes.mark_foreign requires an iatas.*.borderFile or meshmapper.zones")
}
@@ -479,6 +490,17 @@ func (c *Config) validateAPIKey() error {
return nil
}
var appVersionPattern = regexp.MustCompile(`^[0-9]+\.[0-9]+\.[0-9]+$`)
// validateMobile keeps min_app_version to plain X.Y.Z so every app can compare it.
func (c *Config) validateMobile() error {
c.Mobile.MinAppVersion = strings.TrimSpace(c.Mobile.MinAppVersion)
if v := c.Mobile.MinAppVersion; v != "" && !appVersionPattern.MatchString(v) {
return fmt.Errorf("mobile.min_app_version must be X.Y.Z (digits only), got %q", v)
}
return nil
}
// validateDurations rejects negative intervals, which would otherwise panic tickers or invert cutoffs.
// observers.delete_after is exempt: nonpositive disables it.
func (c *Config) validateDurations() error {
+31
View File
@@ -301,3 +301,34 @@ func TestLoad_NegativeDurationsRejected(t *testing.T) {
t.Errorf("observers.delete_after: %v", err)
}
}
func TestLoadMinAppVersion(t *testing.T) {
for _, tc := range []struct {
name, yaml, want string
wantError bool
}{
{"omitted", "{}", "", false},
{"empty", "mobile: {min_app_version: ''}", "", false},
{"valid", "mobile: {min_app_version: '0.1.1'}", "0.1.1", false},
{"multi-digit", "mobile: {min_app_version: ' 10.20.30 '}", "10.20.30", false},
{"v prefix", "mobile: {min_app_version: 'v0.1.1'}", "", true},
{"two parts", "mobile: {min_app_version: '0.1'}", "", true},
{"pre-release", "mobile: {min_app_version: '0.1.1-beta'}", "", true},
{"four parts", "mobile: {min_app_version: '0.1.1.2'}", "", true},
{"letters", "mobile: {min_app_version: 'a.b.c'}", "", true},
} {
t.Run(tc.name, func(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.yaml")
if err := os.WriteFile(path, []byte(tc.yaml), 0600); err != nil {
t.Fatal(err)
}
cfg, err := Load(path)
if (err != nil) != tc.wantError {
t.Fatalf("Load error = %v, want error = %v", err, tc.wantError)
}
if err == nil && cfg.Mobile.MinAppVersion != tc.want {
t.Fatalf("min_app_version = %q, want %q", cfg.Mobile.MinAppVersion, tc.want)
}
})
}
}