feat(admin): expose selected startup configuration (#147)

This commit is contained in:
n30nex authored and GitHub committed 2026-09-19 10:36:30 -04:00
1 parent dab228e9bb
commit 6540b9253d
10 files changed
+487 -4

No files matched your search

+9 -1
View File
@@ -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
+5
View File
@@ -55,6 +55,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
+111
View File
@@ -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",
+111
View File
@@ -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",
+76
View File
@@ -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
+32
View File
@@ -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"`
}
+36
View File
@@ -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)
}
}
+86
View File
@@ -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")
}
}
}
+6
View File
@@ -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 {
+15 -3
View File
@@ -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