diff --git a/README.md b/README.md index 1251cb0..10db988 100644 --- a/README.md +++ b/README.md @@ -127,14 +127,14 @@ 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`. -`GET /api/v1/admin/config` returns selected startup settings: CORS options with +`GET /api/v1/admin/config` returns selected running 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 +normalization still applies. The response 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 +other configuration. 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, @@ -142,6 +142,22 @@ never the URL or request body. Require HTTPS at the reverse proxy and restrict direct access to Beacon's HTTP listener to that proxy or a private connection. Changing the key requires a restart. No API key is issued automatically. +`PUT /api/v1/admin/config` accepts only +`{"cors":{"allowed_origins":["https://example.org"]}}`. It replaces the entire +origin list immediately and updates the reported configuration with the same +policy. Requests already in progress may use the previous policy. Concurrent +valid updates are serialized; updates take effect one at a time. The response +contains `config`, `persisted: false` and `requires_restart: false`. + +Updates are **runtime-only**: no file or database is written, and restarting +reloads the saved configuration. Keep 1–32 ASCII HTTP(S) origins, at most 512 bytes +each, with an optional single hostname wildcard; a sole `*` permits all origins. +Empty/null lists, URL paths/queries/credentials, control characters and unknown +fields are rejected. Requests must be JSON, at most 16 KiB. Other CORS options, +auth/credential fields and broker count cannot be changed here; there is no +configurable `ingest.worker_count`. Cross-origin admin clients need PUT allowed +in the saved CORS methods. CORS controls browser access, not authentication. + ### Environment variables (`.env`) | Variable | Default | Description | diff --git a/docs/docs.go b/docs/docs.go index 7e443c3..13e58b8 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -29,14 +29,14 @@ const docTemplate = `{ "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.", + "description": "Returns current CORS options, auth configuration status and configured broker count. Credential fields and other configuration are excluded. Runtime origin updates are lost on restart.", "produces": [ "application/json" ], "tags": [ "Admin" ], - "summary": "Inspect selected startup configuration", + "summary": "Inspect selected running configuration", "responses": { "200": { "description": "OK", @@ -63,6 +63,88 @@ const docTemplate = `{ } } } + }, + "put": { + "security": [ + { + "AdminKey": [] + } + ], + "description": "Replaces only cors.allowed_origins immediately. Updates are serialized; concurrent valid updates are applied one at a time. Already-running requests may finish with the previous policy. Nothing is persisted; restart reloads saved configuration.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Admin" + ], + "summary": "Update runtime CORS origins", + "parameters": [ + { + "description": "1-32 ASCII HTTP(S) origins, at most 512 bytes each; one hostname wildcard is supported, or a sole *. Empty/null lists and unsupported fields are rejected. Body at most 16 KiB.", + "name": "config", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.UpdateAdminConfigRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.UpdateAdminConfigResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/internal_api_handlers.APIError" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/internal_api_handlers.APIError" + } + } + }, + "413": { + "description": "Request Entity Too Large", + "schema": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/internal_api_handlers.APIError" + } + } + }, + "415": { + "description": "Unsupported Media Type", + "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": { @@ -4303,6 +4385,45 @@ const docTemplate = `{ } } }, + "github_com_MeshCore-Beacon_beacon-server_internal_api.UpdateAdminCORSRequest": { + "type": "object", + "required": [ + "allowed_origins" + ], + "properties": { + "allowed_origins": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "github_com_MeshCore-Beacon_beacon-server_internal_api.UpdateAdminConfigRequest": { + "type": "object", + "required": [ + "cors" + ], + "properties": { + "cors": { + "$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.UpdateAdminCORSRequest" + } + } + }, + "github_com_MeshCore-Beacon_beacon-server_internal_api.UpdateAdminConfigResponse": { + "type": "object", + "properties": { + "config": { + "$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.AdminConfig" + }, + "persisted": { + "type": "boolean" + }, + "requires_restart": { + "type": "boolean" + } + } + }, "internal_api_handlers.APIError": { "type": "object", "properties": { diff --git a/docs/swagger.json b/docs/swagger.json index aa2eba9..6a3db62 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -27,14 +27,14 @@ "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.", + "description": "Returns current CORS options, auth configuration status and configured broker count. Credential fields and other configuration are excluded. Runtime origin updates are lost on restart.", "produces": [ "application/json" ], "tags": [ "Admin" ], - "summary": "Inspect selected startup configuration", + "summary": "Inspect selected running configuration", "responses": { "200": { "description": "OK", @@ -61,6 +61,88 @@ } } } + }, + "put": { + "security": [ + { + "AdminKey": [] + } + ], + "description": "Replaces only cors.allowed_origins immediately. Updates are serialized; concurrent valid updates are applied one at a time. Already-running requests may finish with the previous policy. Nothing is persisted; restart reloads saved configuration.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Admin" + ], + "summary": "Update runtime CORS origins", + "parameters": [ + { + "description": "1-32 ASCII HTTP(S) origins, at most 512 bytes each; one hostname wildcard is supported, or a sole *. Empty/null lists and unsupported fields are rejected. Body at most 16 KiB.", + "name": "config", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.UpdateAdminConfigRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.UpdateAdminConfigResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/internal_api_handlers.APIError" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/internal_api_handlers.APIError" + } + } + }, + "413": { + "description": "Request Entity Too Large", + "schema": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/internal_api_handlers.APIError" + } + } + }, + "415": { + "description": "Unsupported Media Type", + "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": { @@ -4301,6 +4383,45 @@ } } }, + "github_com_MeshCore-Beacon_beacon-server_internal_api.UpdateAdminCORSRequest": { + "type": "object", + "required": [ + "allowed_origins" + ], + "properties": { + "allowed_origins": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "github_com_MeshCore-Beacon_beacon-server_internal_api.UpdateAdminConfigRequest": { + "type": "object", + "required": [ + "cors" + ], + "properties": { + "cors": { + "$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.UpdateAdminCORSRequest" + } + } + }, + "github_com_MeshCore-Beacon_beacon-server_internal_api.UpdateAdminConfigResponse": { + "type": "object", + "properties": { + "config": { + "$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.AdminConfig" + }, + "persisted": { + "type": "boolean" + }, + "requires_restart": { + "type": "boolean" + } + } + }, "internal_api_handlers.APIError": { "type": "object", "properties": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index d10fb48..9fd766f 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -1223,6 +1223,31 @@ definitions: description: TRACE or PING type: string type: object + github_com_MeshCore-Beacon_beacon-server_internal_api.UpdateAdminCORSRequest: + properties: + allowed_origins: + items: + type: string + type: array + required: + - allowed_origins + type: object + github_com_MeshCore-Beacon_beacon-server_internal_api.UpdateAdminConfigRequest: + properties: + cors: + $ref: '#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.UpdateAdminCORSRequest' + required: + - cors + type: object + github_com_MeshCore-Beacon_beacon-server_internal_api.UpdateAdminConfigResponse: + properties: + config: + $ref: '#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.AdminConfig' + persisted: + type: boolean + requires_restart: + type: boolean + type: object internal_api_handlers.APIError: properties: code: @@ -1255,9 +1280,9 @@ info: 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. + description: Returns current CORS options, auth configuration status and configured + broker count. Credential fields and other configuration are excluded. Runtime + origin updates are lost on restart. produces: - application/json responses: @@ -1279,7 +1304,65 @@ paths: type: object security: - AdminKey: [] - summary: Inspect selected startup configuration + summary: Inspect selected running configuration + tags: + - Admin + put: + consumes: + - application/json + description: Replaces only cors.allowed_origins immediately. Updates are serialized; + concurrent valid updates are applied one at a time. Already-running requests + may finish with the previous policy. Nothing is persisted; restart reloads + saved configuration. + parameters: + - description: 1-32 ASCII HTTP(S) origins, at most 512 bytes each; one hostname + wildcard is supported, or a sole *. Empty/null lists and unsupported fields + are rejected. Body at most 16 KiB. + in: body + name: config + required: true + schema: + $ref: '#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.UpdateAdminConfigRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.UpdateAdminConfigResponse' + "400": + description: Bad Request + schema: + additionalProperties: + $ref: '#/definitions/internal_api_handlers.APIError' + type: object + "401": + description: Unauthorized + schema: + additionalProperties: + $ref: '#/definitions/internal_api_handlers.APIError' + type: object + "413": + description: Request Entity Too Large + schema: + additionalProperties: + $ref: '#/definitions/internal_api_handlers.APIError' + type: object + "415": + description: Unsupported Media Type + 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: Update runtime CORS origins tags: - Admin /brokers: diff --git a/internal/api/admin.go b/internal/api/admin.go index 70cfc3a..e71b38c 100644 --- a/internal/api/admin.go +++ b/internal/api/admin.go @@ -3,7 +3,7 @@ package api -// AdminConfig is a whitelist of nonsecret settings captured when the router starts. +// AdminConfig is a whitelist of selected nonsecret running settings. // It is not a serialization of the full file or environment configuration. type AdminConfig struct { Auth AdminAuthConfig `json:"auth"` @@ -15,7 +15,7 @@ type AdminAuthConfig struct { Configured bool `json:"configured"` } -// AdminCORSConfig reports the startup options supplied to the CORS middleware, +// AdminCORSConfig reports the current options supplied to the CORS middleware, // with Beacon defaults applied, before that library's matching normalization. type AdminCORSConfig struct { AllowedOrigins []string `json:"allowed_origins"` @@ -30,3 +30,17 @@ type AdminIngestConfig struct { // or a configurable processing-worker pool. BrokerCount int `json:"broker_count"` } + +type UpdateAdminConfigRequest struct { + CORS *UpdateAdminCORSRequest `json:"cors" validate:"required"` +} + +type UpdateAdminCORSRequest struct { + AllowedOrigins *[]string `json:"allowed_origins" validate:"required"` +} + +type UpdateAdminConfigResponse struct { + Config AdminConfig `json:"config"` + Persisted bool `json:"persisted"` + RequiresRestart bool `json:"requires_restart"` +} diff --git a/internal/api/handlers/admin.go b/internal/api/handlers/admin.go index 4ec31d3..b775a51 100644 --- a/internal/api/handlers/admin.go +++ b/internal/api/handlers/admin.go @@ -4,24 +4,30 @@ package handlers import ( + "encoding/json" + "errors" + "io" + "mime" "net/http" "github.com/MeshCore-Beacon/beacon-server/internal/api" + mw "github.com/MeshCore-Beacon/beacon-server/internal/api/middleware" "github.com/go-chi/chi/v5" ) -// AdminRouter mounts read-only operator endpoints. Its caller must wrap the +// AdminRouter mounts operator endpoints. Its caller must wrap the // entire subrouter with BearerAuth, including unknown paths and methods. -func AdminRouter(snapshot api.AdminConfig) http.Handler { +func AdminRouter(runtime *mw.RuntimeConfig) http.Handler { r := chi.NewRouter() - r.Get("/config", getAdminConfig(snapshot)) + r.Get("/config", getAdminConfig(runtime)) + r.Put("/config", updateAdminConfig(runtime)) 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. +// @Summary Inspect selected running configuration +// @Description Returns current CORS options, auth configuration status and configured broker count. Credential fields and other configuration are excluded. Runtime origin updates are lost on restart. // @Tags Admin // @Produce json // @Security AdminKey @@ -29,8 +35,62 @@ func AdminRouter(snapshot api.AdminConfig) http.Handler { // @Failure 401 {object} map[string]APIError // @Failure 503 {object} map[string]APIError // @Router /admin/config [get] -func getAdminConfig(snapshot api.AdminConfig) http.HandlerFunc { +func getAdminConfig(runtime *mw.RuntimeConfig) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - respond(w, http.StatusOK, snapshot) + respond(w, http.StatusOK, runtime.Snapshot()) + } +} + +// updateAdminConfig godoc +// @Summary Update runtime CORS origins +// @Description Replaces only cors.allowed_origins immediately. Updates are serialized; concurrent valid updates are applied one at a time. Already-running requests may finish with the previous policy. Nothing is persisted; restart reloads saved configuration. +// @Tags Admin +// @Accept json +// @Produce json +// @Security AdminKey +// @Param config body api.UpdateAdminConfigRequest true "1-32 ASCII HTTP(S) origins, at most 512 bytes each; one hostname wildcard is supported, or a sole *. Empty/null lists and unsupported fields are rejected. Body at most 16 KiB." +// @Success 200 {object} api.UpdateAdminConfigResponse +// @Failure 400,401,413,415,503 {object} map[string]APIError +// @Router /admin/config [put] +func updateAdminConfig(runtime *mw.RuntimeConfig) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + media, _, err := mime.ParseMediaType(r.Header.Get("Content-Type")) + if err != nil || media != "application/json" { + respondError(w, 415, "Content-Type must be application/json") + return + } + r.Body = http.MaxBytesReader(w, r.Body, 16*1024) + decoder := json.NewDecoder(r.Body) + decoder.DisallowUnknownFields() + var input api.UpdateAdminConfigRequest + err = decoder.Decode(&input) + if err == nil { + var extra any + err = decoder.Decode(&extra) + if errors.Is(err, io.EOF) { + err = nil + } else if err == nil { + err = errors.New("multiple JSON values") + } + } + if err != nil { + var sizeError *http.MaxBytesError + if errors.As(err, &sizeError) { + respondError(w, 413, "request body exceeds 16 KiB") + } else { + respondError(w, 400, "invalid configuration JSON") + } + return + } + if input.CORS == nil || input.CORS.AllowedOrigins == nil { + respondError(w, 400, "cors.allowed_origins is required") + return + } + config, err := runtime.UpdateOrigins(*input.CORS.AllowedOrigins) + if err != nil { + respondError(w, 400, mw.ErrInvalidOrigins.Error()) + return + } + respond(w, 200, api.UpdateAdminConfigResponse{Config: config, Persisted: false, RequiresRestart: false}) } } diff --git a/internal/api/middleware/runtime_config.go b/internal/api/middleware/runtime_config.go new file mode 100644 index 0000000..cb6a9e6 --- /dev/null +++ b/internal/api/middleware/runtime_config.go @@ -0,0 +1,129 @@ +// Copyright 2026 Beacon Contributors +// SPDX-License-Identifier: AGPL-3.0-or-later + +package middleware + +import ( + "errors" + "net/http" + "net/url" + "slices" + "strconv" + "strings" + "sync" + "sync/atomic" + "unicode" + + "github.com/MeshCore-Beacon/beacon-server/internal/api" + "github.com/go-chi/cors" +) + +var ErrInvalidOrigins = errors.New("allowed_origins must contain 1-32 ASCII HTTP(S) origins (at most 512 bytes each), or a sole *; paths, credentials, queries and control characters are not allowed") + +type runtimePolicy struct { + config api.AdminConfig + handler http.Handler +} + +// RuntimeConfig owns one router's CORS handler and its nonsecret config view. +// Published policies are immutable. Updates are runtime-only, never persisted. +type RuntimeConfig struct { + mu sync.Mutex + current atomic.Pointer[runtimePolicy] + next http.Handler + exposedHeaders []string +} + +func NewRuntimeConfig(config api.AdminConfig, exposedHeaders []string) *RuntimeConfig { + s := &RuntimeConfig{exposedHeaders: slices.Clone(exposedHeaders)} + s.current.Store(&runtimePolicy{config: cloneAdminConfig(config)}) + return s +} + +// CORS is bound once as the outer middleware of its owning router. +func (s *RuntimeConfig) CORS(next http.Handler) http.Handler { + s.mu.Lock() + defer s.mu.Unlock() + if s.next != nil { + panic("runtime CORS already bound") + } + s.next = next + s.current.Store(s.policy(s.current.Load().config)) + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + s.current.Load().handler.ServeHTTP(w, r) + }) +} + +func (s *RuntimeConfig) Snapshot() api.AdminConfig { + return cloneAdminConfig(s.current.Load().config) +} + +func (s *RuntimeConfig) UpdateOrigins(origins []string) (api.AdminConfig, error) { + validated, err := validateOrigins(origins) + if err != nil { + return api.AdminConfig{}, err + } + s.mu.Lock() + defer s.mu.Unlock() + config := cloneAdminConfig(s.current.Load().config) + config.CORS.AllowedOrigins = validated + s.current.Store(s.policy(config)) + return cloneAdminConfig(config), nil +} + +func (s *RuntimeConfig) policy(config api.AdminConfig) *runtimePolicy { + p := &runtimePolicy{config: config} + if s.next != nil { + p.handler = cors.Handler(cors.Options{ + AllowedOrigins: slices.Clone(config.CORS.AllowedOrigins), + AllowedMethods: slices.Clone(config.CORS.AllowedMethods), + AllowedHeaders: slices.Clone(config.CORS.AllowedHeaders), + AllowCredentials: config.CORS.AllowCredentials, MaxAge: config.CORS.MaxAge, + ExposedHeaders: slices.Clone(s.exposedHeaders), + })(s.next) + } + return p +} + +func cloneAdminConfig(config api.AdminConfig) api.AdminConfig { + config.CORS.AllowedOrigins = slices.Clone(config.CORS.AllowedOrigins) + config.CORS.AllowedMethods = slices.Clone(config.CORS.AllowedMethods) + config.CORS.AllowedHeaders = slices.Clone(config.CORS.AllowedHeaders) + return config +} + +func validateOrigins(origins []string) ([]string, error) { + if len(origins) == 0 || len(origins) > 32 { + return nil, ErrInvalidOrigins + } + result := make([]string, len(origins)) + for i, origin := range origins { + if len(origin) > 512 || strings.IndexFunc(origin, unicode.IsControl) >= 0 { + return nil, ErrInvalidOrigins + } + origin = strings.TrimSpace(origin) + if strings.IndexFunc(origin, func(r rune) bool { return r > unicode.MaxASCII }) >= 0 { + return nil, ErrInvalidOrigins + } + origin = strings.ToLower(origin) + if origin == "*" { + if len(origins) != 1 { + return nil, ErrInvalidOrigins + } + result[i] = origin + continue + } + u, err := url.Parse(origin) + if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Hostname() == "" || u.User != nil || u.Path != "" || u.RawQuery != "" || u.ForceQuery || u.Fragment != "" || strings.Contains(origin, "#") || strings.Count(u.Host, "*") > 1 || strings.Contains(u.Host, "%") || strings.HasSuffix(u.Host, ":") { + return nil, ErrInvalidOrigins + } + if port := u.Port(); port != "" { + n, err := strconv.Atoi(port) + if err != nil || n < 1 || n > 65535 { + return nil, ErrInvalidOrigins + } + } + result[i] = origin + } + return result, nil +} diff --git a/internal/api/middleware/runtime_config_test.go b/internal/api/middleware/runtime_config_test.go new file mode 100644 index 0000000..402edd2 --- /dev/null +++ b/internal/api/middleware/runtime_config_test.go @@ -0,0 +1,110 @@ +// Copyright 2026 Beacon Contributors +// SPDX-License-Identifier: AGPL-3.0-or-later + +package middleware + +import ( + "net/http" + "net/http/httptest" + "reflect" + "strings" + "sync" + "testing" + + "github.com/MeshCore-Beacon/beacon-server/internal/api" +) + +func TestValidateOrigins(t *testing.T) { + for _, tc := range []struct { + input []string + valid bool + }{ + {[]string{"*"}, true}, {[]string{" HTTPS://Example.test ", "https://*.example.test", "http://127.0.0.1:8080", "http://[::1]:8080"}, true}, + {nil, false}, {[]string{}, false}, {make([]string, 33), false}, {[]string{""}, false}, {[]string{"null"}, false}, + {[]string{"*", "https://example.test"}, false}, {[]string{"ftp://example.test"}, false}, {[]string{"https://"}, false}, + {[]string{"https://example.test/"}, false}, {[]string{"https://example.test?"}, false}, {[]string{"https://example.test#"}, false}, + {[]string{"https://user:secret@example.test"}, false}, {[]string{"https://example.test:65536"}, false}, {[]string{"https://example.test:"}, false}, + {[]string{"https://*.*.test"}, false}, {[]string{"https://example.test\n"}, false}, {[]string{"https://é.test"}, false}, {[]string{"https://K.test"}, false}, {[]string{strings.Repeat("x", 513)}, false}, + } { + _, err := validateOrigins(tc.input) + if (err == nil) != tc.valid { + t.Fatalf("origins=%q valid=%v error=%v", tc.input, tc.valid, err) + } + } + input := []string{" HTTPS://Example.test "} + got, err := validateOrigins(input) + if err != nil || got[0] != "https://example.test" || input[0] != " HTTPS://Example.test " { + t.Fatal("normalization changed caller state") + } +} + +func TestRuntimePolicyAndConcurrency(t *testing.T) { + initial := api.AdminConfig{Auth: api.AdminAuthConfig{Configured: true}, Ingest: api.AdminIngestConfig{BrokerCount: 2}, + CORS: api.AdminCORSConfig{AllowedOrigins: []string{"https://before.test"}, AllowedMethods: []string{"GET", "PUT"}, AllowedHeaders: []string{"Authorization"}, AllowCredentials: true, MaxAge: 600}} + state := NewRuntimeConfig(initial, []string{"Retry-After"}) + initial.CORS.AllowedOrigins[0] = "https://caller-change.test" + handler := state.CORS(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(204) })) + preflight := func(origin string) *httptest.ResponseRecorder { + r := httptest.NewRequest("OPTIONS", "/api/v1/brokers", nil) + r.Header.Set("Origin", origin) + r.Header.Set("Access-Control-Request-Method", "PUT") + r.Header.Set("Access-Control-Request-Headers", "Authorization") + w := httptest.NewRecorder() + handler.ServeHTTP(w, r) + return w + } + if preflight("https://before.test").Header().Get("Access-Control-Allow-Origin") == "" { + t.Fatal("startup policy changed with caller slice") + } + origins := []string{"https://*.after.test"} + updated, err := state.UpdateOrigins(origins) + if err != nil { + t.Fatal(err) + } + origins[0] = "https://caller-change.test" + updated.CORS.AllowedOrigins[0] = "https://response-change.test" + if preflight("https://before.test").Header().Get("Access-Control-Allow-Origin") != "" { + t.Fatal("old policy remains active") + } + w := preflight("https://app.after.test") + if w.Code != 200 || w.Header().Get("Access-Control-Allow-Origin") != "https://app.after.test" || w.Header().Get("Access-Control-Max-Age") != "600" || w.Header().Get("Access-Control-Allow-Credentials") != "true" { + t.Fatal("new policy/options not enforced") + } + r := httptest.NewRequest("GET", "/", nil) + r.Header.Set("Origin", "https://app.after.test") + w = httptest.NewRecorder() + handler.ServeHTTP(w, r) + if w.Header().Get("Access-Control-Expose-Headers") != "Retry-After" { + t.Fatal("exposed headers lost") + } + before := state.Snapshot() + if _, err := state.UpdateOrigins(nil); err == nil || !reflect.DeepEqual(state.Snapshot(), before) { + t.Fatal("invalid update changed config") + } + var group sync.WaitGroup + for i := 0; i < 8; i++ { + group.Add(1) + go func(i int) { + defer group.Done() + for j := 0; j < 40; j++ { + origin := "https://a.test" + if i%2 != 0 { + origin = "https://b.test" + } + if _, err := state.UpdateOrigins([]string{origin}); err != nil { + t.Error(err) + } + snapshot := state.Snapshot() + if len(snapshot.CORS.AllowedOrigins) != 1 || !snapshot.Auth.Configured || snapshot.Ingest.BrokerCount != 2 { + t.Error("torn config") + } + preflight(origin) + } + }(i) + } + group.Wait() + final := state.Snapshot() + if preflight(final.CORS.AllowedOrigins[0]).Header().Get("Access-Control-Allow-Origin") == "" { + t.Fatal("reported/enforced final policy differs") + } +} diff --git a/internal/api/router/admin_config_test.go b/internal/api/router/admin_config_test.go index 544c8a6..0519d12 100644 --- a/internal/api/router/admin_config_test.go +++ b/internal/api/router/admin_config_test.go @@ -69,8 +69,8 @@ func TestAdminConfig(t *testing.T) { t.Fatal("config escaped authentication") } } - if request("PUT", "/api/v1/admin/config", headers).Code != http.StatusMethodNotAllowed { - t.Fatal("config writes were accepted") + if request("PUT", "/api/v1/admin/config", headers).Code != http.StatusUnsupportedMediaType { + t.Fatal("untyped config write was accepted") } again := request("GET", "/api/v1/admin/config", headers) if again.Body.String() != response.Body.String() { diff --git a/internal/api/router/auth_test.go b/internal/api/router/auth_test.go index 57bc5ac..69b46a0 100644 --- a/internal/api/router/auth_test.go +++ b/internal/api/router/auth_test.go @@ -33,6 +33,9 @@ func TestAdminAuthBoundary(t *testing.T) { if method == "GET" { want = 200 } + if method == "PUT" { + want = 415 + } } } } diff --git a/internal/api/router/router.go b/internal/api/router/router.go index 2e91ab8..f205ca8 100644 --- a/internal/api/router/router.go +++ b/internal/api/router/router.go @@ -11,7 +11,6 @@ import ( "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" - "github.com/go-chi/cors" "github.com/MeshCore-Beacon/beacon-server/internal/api" "github.com/MeshCore-Beacon/beacon-server/internal/api/handlers" @@ -59,14 +58,6 @@ func New(h *hub.Hub, reader api.Reader, workers []*ingest.Worker, maxConnsPerIP if maxAge == 0 { maxAge = 300 } - r.Use(cors.Handler(cors.Options{ - AllowedOrigins: allowedOrigins, - AllowedMethods: allowedMethods, - AllowedHeaders: allowedHeaders, - ExposedHeaders: []string{"Retry-After"}, - 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{ @@ -80,6 +71,8 @@ func New(h *hub.Hub, reader api.Reader, workers []*ingest.Worker, maxConnsPerIP }, Ingest: api.AdminIngestConfig{BrokerCount: len(workers)}, } + runtimeConfig := mw.NewRuntimeConfig(adminConfig, []string{"Retry-After"}) + r.Use(runtimeConfig.CORS) // ── Global middleware ──────────────────────────────────────────────────── r.Use(middleware.RequestID) @@ -121,7 +114,7 @@ func New(h *hub.Hub, reader api.Reader, workers []*ingest.Worker, maxConnsPerIP }) // Protect the entire subtree, including its root and unknown paths. - r.Mount("/admin", mw.BearerAuth(authCfg.APIKey, handlers.AdminRouter(adminConfig))) + r.Mount("/admin", mw.BearerAuth(authCfg.APIKey, handlers.AdminRouter(runtimeConfig))) }) return r diff --git a/internal/api/router/runtime_config_test.go b/internal/api/router/runtime_config_test.go new file mode 100644 index 0000000..cfdc16a --- /dev/null +++ b/internal/api/router/runtime_config_test.go @@ -0,0 +1,93 @@ +// Copyright 2026 Beacon Contributors +// SPDX-License-Identifier: AGPL-3.0-or-later + +package router + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/MeshCore-Beacon/beacon-server/internal/api" + "github.com/MeshCore-Beacon/beacon-server/internal/config" +) + +func TestRuntimeConfigUpdate(t *testing.T) { + newRouter := func() http.Handler { + return New(nil, nil, nil, 5, config.CORSConfig{AllowedOrigins: []string{"https://before.test"}}, config.ServerConfig{}, config.AuthConfig{APIKey: "test-key"}, config.ResolvedRateLimitConfig{}) + } + handler := newRouter() + request := func(method, path, body, token, media, origin string) *httptest.ResponseRecorder { + r := httptest.NewRequest(method, path, strings.NewReader(body)) + r.Header.Set("Content-Type", media) + if token != "" { + r.Header.Set("Authorization", "Bearer "+token) + } + if origin != "" { + r.Header.Set("Origin", origin) + } + if method == "OPTIONS" { + r.Header.Set("Access-Control-Request-Method", "GET") + r.Header.Set("Access-Control-Request-Headers", "Authorization") + } + w := httptest.NewRecorder() + handler.ServeHTTP(w, r) + return w + } + body := `{"cors":{"allowed_origins":["https://after.test"]}}` + for _, token := range []string{"", "wrong"} { + if request("PUT", "/api/v1/admin/config", body, token, "application/json", "").Code != 401 { + t.Fatal("unauthorized update") + } + } + w := request("PUT", "/api/v1/admin/config", body, "test-key", "application/json", "") + var result api.UpdateAdminConfigResponse + if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &result) != nil || result.Persisted || result.RequiresRestart || w.Header().Get("Cache-Control") != "no-store" { + t.Fatal("update contract") + } + if result.Config.CORS.AllowedOrigins[0] != "https://after.test" { + t.Fatal("wrong returned policy") + } + read := request("GET", "/api/v1/admin/config", "", "test-key", "", "") + var actual api.AdminConfig + if json.Unmarshal(read.Body.Bytes(), &actual) != nil || actual.CORS.AllowedOrigins[0] != "https://after.test" { + t.Fatal("GET still reports old config") + } + for _, tc := range []struct { + body, media string + status int + }{ + {`{}`, "application/json", 400}, {`null`, "application/json", 400}, {`{"cors":null}`, "application/json", 400}, + {`{"cors":{"allowed_origins":null}}`, "application/json", 400}, {`{"cors":{"allowed_origins":[]}}`, "application/json", 400}, + {`{"auth":{"api_key":"secret-sentinel"}}`, "application/json", 400}, {`{"ingest":{"worker_count":4}}`, "application/json", 400}, + {`{"cors":{"allowed_origins":["*"],"max_age":0}}`, "application/json", 400}, + {`{"cors":{"allowed_origins":["https://user:secret-sentinel@site.test"]}}`, "application/json", 400}, + {body + ` {}`, "application/json", 400}, {body, "text/plain", 415}, {body + strings.Repeat(" ", 16384), "application/json", 413}, + } { + bad := request("PUT", "/api/v1/admin/config", tc.body, "test-key", tc.media, "") + if bad.Code != tc.status || strings.Contains(bad.Body.String(), "secret-sentinel") { + t.Fatalf("invalid update status=%d want=%d", bad.Code, tc.status) + } + if request("GET", "/api/v1/admin/config", "", "test-key", "", "").Body.String() != read.Body.String() { + t.Fatal("invalid update changed state") + } + } + if request("OPTIONS", "/api/v1/brokers", "", "", "", "https://before.test").Header().Get("Access-Control-Allow-Origin") != "" { + t.Fatal("old origin allowed") + } + if request("OPTIONS", "/api/v1/brokers", "", "", "", "https://after.test").Header().Get("Access-Control-Allow-Origin") == "" { + t.Fatal("new origin blocked") + } + if exposed := request("GET", "/api/v1/brokers", "", "", "", "https://after.test").Header().Get("Access-Control-Expose-Headers"); !strings.EqualFold(exposed, "Retry-After") { + t.Fatalf("runtime update lost rate-limit backoff header: %q", exposed) + } + if request("GET", "/api/v1/brokers", "", "", "", "").Code != 200 { + t.Fatal("public reads blocked") + } + handler = newRouter() + if request("OPTIONS", "/api/v1/brokers", "", "", "", "https://before.test").Header().Get("Access-Control-Allow-Origin") == "" { + t.Fatal("new router did not reload startup policy") + } +}