diff --git a/cmd/beacon/main.go b/cmd/beacon/main.go index 62172bb..640a882 100644 --- a/cmd/beacon/main.go +++ b/cmd/beacon/main.go @@ -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), diff --git a/config.yaml.example b/config.yaml.example index 0a61765..a09604a 100644 --- a/config.yaml.example +++ b/config.yaml.example @@ -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" diff --git a/docs/docs.go b/docs/docs.go index 227df2a..4260553 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -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"}, diff --git a/docs/swagger.json b/docs/swagger.json index 29cd6b8..fbd00b5 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -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" } ] } \ No newline at end of file diff --git a/docs/swagger.yaml b/docs/swagger.yaml index c7b70d6..8477b1a 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -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 diff --git a/internal/api/admin.go b/internal/api/admin.go index e71b38c..7d81654 100644 --- a/internal/api/admin.go +++ b/internal/api/admin.go @@ -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 { diff --git a/internal/api/handlers/info.go b/internal/api/handlers/info.go new file mode 100644 index 0000000..f0a1985 --- /dev/null +++ b/internal/api/handlers/info.go @@ -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) + } +} diff --git a/internal/api/handlers/info_test.go b/internal/api/handlers/info_test.go new file mode 100644 index 0000000..a074e42 --- /dev/null +++ b/internal/api/handlers/info_test.go @@ -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) + } + }) + } +} diff --git a/internal/api/info.go b/internal/api/info.go new file mode 100644 index 0000000..2a6b65d --- /dev/null +++ b/internal/api/info.go @@ -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"` +} diff --git a/internal/api/router/admin_config_test.go b/internal/api/router/admin_config_test.go index 4ae50d8..9b57b51 100644 --- a/internal/api/router/admin_config_test.go +++ b/internal/api/router/admin_config_test.go @@ -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) } diff --git a/internal/api/router/options.go b/internal/api/router/options.go index e838834..9145091 100644 --- a/internal/api/router/options.go +++ b/internal/api/router/options.go @@ -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. diff --git a/internal/api/router/router.go b/internal/api/router/router.go index d6bbbc9..45d7a88 100644 --- a/internal/api/router/router.go +++ b/internal/api/router/router.go @@ -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. diff --git a/internal/api/router/router_test.go b/internal/api/router/router_test.go index bbb434d..bcc1b70 100644 --- a/internal/api/router/router_test.go +++ b/internal/api/router/router_test.go @@ -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()) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 0b29211..d51d0cc 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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 { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index f7716de..c5d7098 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -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) + } + }) + } +}