From 71054b0ca9e4e2a6fc5090c6536607a644f23080 Mon Sep 17 00:00:00 2001 From: n30nex Date: Sun, 13 Sep 2026 08:38:51 -0400 Subject: [PATCH] feat(admin): expose selected startup configuration --- README.md | 10 +- cmd/beacon/main.go | 5 + docs/docs.go | 111 +++++++++++++++++++++++ docs/swagger.json | 111 +++++++++++++++++++++++ docs/swagger.yaml | 76 ++++++++++++++++ internal/api/admin.go | 32 +++++++ internal/api/handlers/admin.go | 36 ++++++++ internal/api/router/admin_config_test.go | 86 ++++++++++++++++++ internal/api/router/auth_test.go | 6 ++ internal/api/router/router.go | 18 +++- 10 files changed, 487 insertions(+), 4 deletions(-) create mode 100644 internal/api/admin.go create mode 100644 internal/api/handlers/admin.go create mode 100644 internal/api/router/admin_config_test.go diff --git a/README.md b/README.md index 0d3d7c7d..a6353030 100644 --- a/README.md +++ b/README.md @@ -127,7 +127,15 @@ With no key, admin requests return JSON 503 while public reads and WebSockets continue normally. With a key, missing, incorrect or duplicate Authorization headers return JSON 401 with `WWW-Authenticate: Bearer`. -Admin operations are not implemented yet: a valid key currently reaches a 404. +`GET /api/v1/admin/config` returns selected startup settings: CORS options with +Beacon defaults applied, `auth.configured`, and `ingest.broker_count` (configured +broker workers, not connection status or a tunable processing-worker pool). +The CORS lists are the options supplied to the middleware; its normal matching +normalization still applies. The response is a startup snapshot and excludes +credential fields, broker addresses, channel material, database settings and +other configuration. Changes require a restart. Configuration writes and account +operations are not implemented; unknown admin paths return 404 and unsupported +methods on the config endpoint return 405 after authentication. Global CORS preflights remain public. Use a long, randomly generated key, keep it out of source control and logs, and send it only in the Authorization header, never the URL or request body. Require HTTPS at the reverse proxy and restrict diff --git a/cmd/beacon/main.go b/cmd/beacon/main.go index 6f3f4606..be69f344 100644 --- a/cmd/beacon/main.go +++ b/cmd/beacon/main.go @@ -54,6 +54,11 @@ var version = "dev" // @schemes http https +// @securityDefinitions.apikey AdminKey +// @in header +// @name Authorization +// @description Enter Bearer followed by the configured operator key. Use HTTPS. + // @tag.name IATAs // @tag.description Airport/location codes that group observers and packets // @tag.name Regions diff --git a/docs/docs.go b/docs/docs.go index 451cd45e..68de0002 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -22,6 +22,49 @@ const docTemplate = `{ "host": "{{.Host}}", "basePath": "{{.BasePath}}", "paths": { + "/admin/config": { + "get": { + "security": [ + { + "AdminKey": [] + } + ], + "description": "Returns CORS startup options with Beacon defaults, auth configuration status and configured broker count. Credential fields and other configuration are excluded. Changes require restart; this endpoint is read-only.", + "produces": [ + "application/json" + ], + "tags": [ + "Admin" + ], + "summary": "Inspect selected startup configuration", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.AdminConfig" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/internal_api_handlers.APIError" + } + } + }, + "503": { + "description": "Service Unavailable", + "schema": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/internal_api_handlers.APIError" + } + } + } + } + } + }, "/brokers": { "get": { "produces": [ @@ -2423,6 +2466,66 @@ const docTemplate = `{ } }, "definitions": { + "github_com_MeshCore-Beacon_beacon-server_internal_api.AdminAuthConfig": { + "type": "object", + "properties": { + "configured": { + "type": "boolean" + } + } + }, + "github_com_MeshCore-Beacon_beacon-server_internal_api.AdminCORSConfig": { + "type": "object", + "properties": { + "allow_credentials": { + "type": "boolean" + }, + "allowed_headers": { + "type": "array", + "items": { + "type": "string" + } + }, + "allowed_methods": { + "type": "array", + "items": { + "type": "string" + } + }, + "allowed_origins": { + "type": "array", + "items": { + "type": "string" + } + }, + "max_age": { + "type": "integer" + } + } + }, + "github_com_MeshCore-Beacon_beacon-server_internal_api.AdminConfig": { + "type": "object", + "properties": { + "auth": { + "$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.AdminAuthConfig" + }, + "cors": { + "$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.AdminCORSConfig" + }, + "ingest": { + "$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.AdminIngestConfig" + } + } + }, + "github_com_MeshCore-Beacon_beacon-server_internal_api.AdminIngestConfig": { + "type": "object", + "properties": { + "broker_count": { + "description": "BrokerCount counts configured broker workers, not active MQTT connections\nor a configurable processing-worker pool.", + "type": "integer" + } + } + }, "github_com_MeshCore-Beacon_beacon-server_internal_api.AdvertObservation": { "type": "object", "properties": { @@ -4102,6 +4205,14 @@ const docTemplate = `{ } } }, + "securityDefinitions": { + "AdminKey": { + "description": "Enter Bearer followed by the configured operator key. Use HTTPS.", + "type": "apiKey", + "name": "Authorization", + "in": "header" + } + }, "tags": [ { "description": "Airport/location codes that group observers and packets", diff --git a/docs/swagger.json b/docs/swagger.json index 90acfb29..457c4cb7 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -20,6 +20,49 @@ "host": "localhost:8080", "basePath": "/api/v1", "paths": { + "/admin/config": { + "get": { + "security": [ + { + "AdminKey": [] + } + ], + "description": "Returns CORS startup options with Beacon defaults, auth configuration status and configured broker count. Credential fields and other configuration are excluded. Changes require restart; this endpoint is read-only.", + "produces": [ + "application/json" + ], + "tags": [ + "Admin" + ], + "summary": "Inspect selected startup configuration", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.AdminConfig" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/internal_api_handlers.APIError" + } + } + }, + "503": { + "description": "Service Unavailable", + "schema": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/internal_api_handlers.APIError" + } + } + } + } + } + }, "/brokers": { "get": { "produces": [ @@ -2421,6 +2464,66 @@ } }, "definitions": { + "github_com_MeshCore-Beacon_beacon-server_internal_api.AdminAuthConfig": { + "type": "object", + "properties": { + "configured": { + "type": "boolean" + } + } + }, + "github_com_MeshCore-Beacon_beacon-server_internal_api.AdminCORSConfig": { + "type": "object", + "properties": { + "allow_credentials": { + "type": "boolean" + }, + "allowed_headers": { + "type": "array", + "items": { + "type": "string" + } + }, + "allowed_methods": { + "type": "array", + "items": { + "type": "string" + } + }, + "allowed_origins": { + "type": "array", + "items": { + "type": "string" + } + }, + "max_age": { + "type": "integer" + } + } + }, + "github_com_MeshCore-Beacon_beacon-server_internal_api.AdminConfig": { + "type": "object", + "properties": { + "auth": { + "$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.AdminAuthConfig" + }, + "cors": { + "$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.AdminCORSConfig" + }, + "ingest": { + "$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.AdminIngestConfig" + } + } + }, + "github_com_MeshCore-Beacon_beacon-server_internal_api.AdminIngestConfig": { + "type": "object", + "properties": { + "broker_count": { + "description": "BrokerCount counts configured broker workers, not active MQTT connections\nor a configurable processing-worker pool.", + "type": "integer" + } + } + }, "github_com_MeshCore-Beacon_beacon-server_internal_api.AdvertObservation": { "type": "object", "properties": { @@ -4100,6 +4203,14 @@ } } }, + "securityDefinitions": { + "AdminKey": { + "description": "Enter Bearer followed by the configured operator key. Use HTTPS.", + "type": "apiKey", + "name": "Authorization", + "in": "header" + } + }, "tags": [ { "description": "Airport/location codes that group observers and packets", diff --git a/docs/swagger.yaml b/docs/swagger.yaml index cb0b716e..416384e7 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -1,5 +1,46 @@ basePath: /api/v1 definitions: + github_com_MeshCore-Beacon_beacon-server_internal_api.AdminAuthConfig: + properties: + configured: + type: boolean + type: object + github_com_MeshCore-Beacon_beacon-server_internal_api.AdminCORSConfig: + properties: + allow_credentials: + type: boolean + allowed_headers: + items: + type: string + type: array + allowed_methods: + items: + type: string + type: array + allowed_origins: + items: + type: string + type: array + max_age: + type: integer + type: object + github_com_MeshCore-Beacon_beacon-server_internal_api.AdminConfig: + properties: + auth: + $ref: '#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.AdminAuthConfig' + cors: + $ref: '#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.AdminCORSConfig' + ingest: + $ref: '#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.AdminIngestConfig' + type: object + github_com_MeshCore-Beacon_beacon-server_internal_api.AdminIngestConfig: + properties: + broker_count: + description: |- + BrokerCount counts configured broker workers, not active MQTT connections + or a configurable processing-worker pool. + type: integer + type: object github_com_MeshCore-Beacon_beacon-server_internal_api.AdvertObservation: properties: heardAt: @@ -1191,6 +1232,35 @@ info: title: MeshCore Beacon API version: 1.6.0 paths: + /admin/config: + get: + description: Returns CORS startup options with Beacon defaults, auth configuration + status and configured broker count. Credential fields and other configuration + are excluded. Changes require restart; this endpoint is read-only. + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.AdminConfig' + "401": + description: Unauthorized + schema: + additionalProperties: + $ref: '#/definitions/internal_api_handlers.APIError' + type: object + "503": + description: Service Unavailable + schema: + additionalProperties: + $ref: '#/definitions/internal_api_handlers.APIError' + type: object + security: + - AdminKey: [] + summary: Inspect selected startup configuration + tags: + - Admin /brokers: get: produces: @@ -2808,6 +2878,12 @@ paths: schemes: - http - https +securityDefinitions: + AdminKey: + description: Enter Bearer followed by the configured operator key. Use HTTPS. + in: header + name: Authorization + type: apiKey swagger: "2.0" tags: - description: Airport/location codes that group observers and packets diff --git a/internal/api/admin.go b/internal/api/admin.go new file mode 100644 index 00000000..70cfc3a7 --- /dev/null +++ b/internal/api/admin.go @@ -0,0 +1,32 @@ +// Copyright 2026 Beacon Contributors +// SPDX-License-Identifier: AGPL-3.0-or-later + +package api + +// AdminConfig is a whitelist of nonsecret settings captured when the router starts. +// It is not a serialization of the full file or environment configuration. +type AdminConfig struct { + Auth AdminAuthConfig `json:"auth"` + CORS AdminCORSConfig `json:"cors"` + Ingest AdminIngestConfig `json:"ingest"` +} + +type AdminAuthConfig struct { + Configured bool `json:"configured"` +} + +// AdminCORSConfig reports the startup options supplied to the CORS middleware, +// with Beacon defaults applied, before that library's matching normalization. +type AdminCORSConfig struct { + AllowedOrigins []string `json:"allowed_origins"` + AllowedMethods []string `json:"allowed_methods"` + AllowedHeaders []string `json:"allowed_headers"` + AllowCredentials bool `json:"allow_credentials"` + MaxAge int `json:"max_age"` +} + +type AdminIngestConfig struct { + // BrokerCount counts configured broker workers, not active MQTT connections + // or a configurable processing-worker pool. + BrokerCount int `json:"broker_count"` +} diff --git a/internal/api/handlers/admin.go b/internal/api/handlers/admin.go new file mode 100644 index 00000000..4ec31d33 --- /dev/null +++ b/internal/api/handlers/admin.go @@ -0,0 +1,36 @@ +// 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" +) + +// AdminRouter mounts read-only operator endpoints. Its caller must wrap the +// entire subrouter with BearerAuth, including unknown paths and methods. +func AdminRouter(snapshot api.AdminConfig) http.Handler { + r := chi.NewRouter() + r.Get("/config", getAdminConfig(snapshot)) + return r +} + +// getAdminConfig godoc +// +// @Summary Inspect selected startup configuration +// @Description Returns CORS startup options with Beacon defaults, auth configuration status and configured broker count. Credential fields and other configuration are excluded. Changes require restart; this endpoint is read-only. +// @Tags Admin +// @Produce json +// @Security AdminKey +// @Success 200 {object} api.AdminConfig +// @Failure 401 {object} map[string]APIError +// @Failure 503 {object} map[string]APIError +// @Router /admin/config [get] +func getAdminConfig(snapshot api.AdminConfig) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + respond(w, http.StatusOK, snapshot) + } +} diff --git a/internal/api/router/admin_config_test.go b/internal/api/router/admin_config_test.go new file mode 100644 index 00000000..544c8a61 --- /dev/null +++ b/internal/api/router/admin_config_test.go @@ -0,0 +1,86 @@ +// Copyright 2026 Beacon Contributors +// SPDX-License-Identifier: AGPL-3.0-or-later + +package router + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "reflect" + "strings" + "testing" + + "github.com/MeshCore-Beacon/beacon-server/internal/config" + "github.com/MeshCore-Beacon/beacon-server/internal/ingest" +) + +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"} { + t.Setenv(variable, "environment-secret-sentinel") + } + for _, custom := range []bool{false, true} { + cfg := config.CORSConfig{} + want := map[string]any{ + "allowed_origins": []any{"*"}, "allowed_methods": []any{"GET", "HEAD", "OPTIONS"}, + "allowed_headers": []any{"Accept", "Authorization", "Content-Type"}, "allow_credentials": false, "max_age": float64(300), + } + if custom { + cfg = config.CORSConfig{AllowedOrigins: []string{"https://example.test"}, AllowedMethods: []string{"GET"}, + AllowedHeaders: []string{"Authorization", "X-Operator"}, AllowCredentials: true, MaxAge: 600} + want = map[string]any{"allowed_origins": []any{"https://example.test"}, "allowed_methods": []any{"GET"}, + "allowed_headers": []any{"Authorization", "X-Operator"}, "allow_credentials": true, "max_age": float64(600)} + } + handler := New(nil, nil, []*ingest.Worker{{}, {}}, 5, cfg, config.ServerConfig{}, config.AuthConfig{APIKey: key}, config.ResolvedRateLimitConfig{}) + if custom { + cfg.AllowedOrigins[0] = "https://changed.test" + cfg.AllowedMethods[0] = "DELETE" + cfg.AllowedHeaders[0] = "X-Changed" + } + request := func(method, path string, headers map[string]string) *httptest.ResponseRecorder { + req := httptest.NewRequest(method, path, nil) + for name, value := range headers { + req.Header.Set(name, value) + } + response := httptest.NewRecorder() + handler.ServeHTTP(response, req) + return response + } + headers := map[string]string{"Authorization": "Bearer " + key} + response := request("GET", "/api/v1/admin/config", headers) + if response.Code != 200 || response.Header().Get("Content-Type") != "application/json" || response.Header().Get("Cache-Control") != "no-store" { + t.Fatalf("config read status/headers: %d %v", response.Code, response.Header()) + } + var body map[string]any + 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)}} + if !reflect.DeepEqual(body, expected) { + t.Fatalf("unexpected config shape or values: %v", body) + } + if strings.Contains(response.Body.String(), "sentinel") { + t.Fatal("credential setting was exposed") + } + for _, auth := range []string{"", "Bearer wrong"} { + denied := request("GET", "/api/v1/admin/config", map[string]string{"Authorization": auth}) + if denied.Code != 401 || strings.Contains(denied.Body.String(), "allowed_origins") { + t.Fatal("config escaped authentication") + } + } + if request("PUT", "/api/v1/admin/config", headers).Code != http.StatusMethodNotAllowed { + t.Fatal("config writes were accepted") + } + again := request("GET", "/api/v1/admin/config", headers) + if again.Body.String() != response.Body.String() { + t.Fatal("startup snapshot changed") + } + preflight := request("OPTIONS", "/api/v1/admin/config", map[string]string{ + "Origin": "https://example.test", "Access-Control-Request-Method": "GET", "Access-Control-Request-Headers": "Authorization", + }) + if preflight.Code != 200 || preflight.Header().Get("Access-Control-Allow-Origin") == "" || preflight.Header().Get("Access-Control-Max-Age") != map[bool]string{false: "300", true: "600"}[custom] { + t.Fatal("CORS behavior does not match reported options") + } + } +} diff --git a/internal/api/router/auth_test.go b/internal/api/router/auth_test.go index 25cef5ed..57bc5ac7 100644 --- a/internal/api/router/auth_test.go +++ b/internal/api/router/auth_test.go @@ -28,6 +28,12 @@ func TestAdminAuthBoundary(t *testing.T) { want = 401 if token == key { want = 404 + if path == "/api/v1/admin/config" || path == "/api/v1/admin//config" { + want = 405 + if method == "GET" { + want = 200 + } + } } } if w.Code != want { diff --git a/internal/api/router/router.go b/internal/api/router/router.go index 84584133..949ab41c 100644 --- a/internal/api/router/router.go +++ b/internal/api/router/router.go @@ -38,7 +38,7 @@ import ( // /regions → regions subrouter // /stats → stats subrouter // -// Admin handlers are added separately; the reserved subtree is protected now. +// Admin endpoints require a configured bearer key. func New(h *hub.Hub, reader api.Reader, workers []*ingest.Worker, maxConnsPerIP int, corsCfg config.CORSConfig, serverCfg config.ServerConfig, authCfg config.AuthConfig, rateLimitCfg config.ResolvedRateLimitConfig) http.Handler { r := chi.NewRouter() @@ -67,6 +67,19 @@ func New(h *hub.Hub, reader api.Reader, workers []*ingest.Worker, maxConnsPerIP AllowCredentials: corsCfg.AllowCredentials, MaxAge: maxAge, })) + // Capture only values used by this router. Do not retain Config, credentials + // or caller-owned slices in the admin response. + adminConfig := api.AdminConfig{ + Auth: api.AdminAuthConfig{Configured: authCfg.APIKey != ""}, + CORS: api.AdminCORSConfig{ + AllowedOrigins: append([]string{}, allowedOrigins...), + AllowedMethods: append([]string{}, allowedMethods...), + AllowedHeaders: append([]string{}, allowedHeaders...), + AllowCredentials: corsCfg.AllowCredentials, + MaxAge: maxAge, + }, + Ingest: api.AdminIngestConfig{BrokerCount: len(workers)}, + } // ── Global middleware ──────────────────────────────────────────────────── r.Use(middleware.RequestID) @@ -108,8 +121,7 @@ func New(h *hub.Hub, reader api.Reader, workers []*ingest.Worker, maxConnsPerIP }) // Protect the entire subtree, including its root and unknown paths. - // Replace the empty router with the admin handlers when they are added. - r.Mount("/admin", mw.BearerAuth(authCfg.APIKey, chi.NewRouter())) + r.Mount("/admin", mw.BearerAuth(authCfg.APIKey, handlers.AdminRouter(adminConfig))) }) return r