diff --git a/README.md b/README.md index 1251cb0..fead45d 100644 --- a/README.md +++ b/README.md @@ -133,8 +133,8 @@ 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 +other configuration. Changes require a restart. Configuration writes 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,16 @@ 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. +Operator accounts are available at `GET/POST /api/v1/admin/accounts` and +`GET/DELETE /api/v1/admin/accounts/{id}`. POST accepts a JSON `name` field in a +body up to 4 KiB; names are trimmed, case-sensitive and limited to 128 Unicode +characters without control characters. Active names are unique. DELETE soft +deactivates the record (204); missing IDs return 404 and an already inactive +record returns 409. A deactivated name may be reused by a new account. +Lists include active and inactive records, newest first, without pagination. +These are operator-defined records; no login, session or API token is created. +Cross-origin clients must have their methods allowed in the existing CORS config. + ### Environment variables (`.env`) | Variable | Default | Description | diff --git a/cmd/beacon/main.go b/cmd/beacon/main.go index 219b139..8e44c2b 100644 --- a/cmd/beacon/main.go +++ b/cmd/beacon/main.go @@ -293,7 +293,7 @@ func main() { go scheduler.Start(ctx) // ── HTTP server ────────────────────────────────────────────────────────── - r := router.New(h, reader, []*ingest.Worker{broker1, broker2}, resolved.MaxConnsPerIP, resolved.MaxConnectsPerMinute, cfg.CORS, cfg.Server, cfg.Auth, resolved.RateLimit) + r := router.New(h, reader, []*ingest.Worker{broker1, broker2}, resolved.MaxConnsPerIP, resolved.MaxConnectsPerMinute, cfg.CORS, cfg.Server, cfg.Auth, resolved.RateLimit, store) srv := &http.Server{ Addr: addr, diff --git a/docs/docs.go b/docs/docs.go index efc4bd1..4f31851 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -22,6 +22,322 @@ const docTemplate = `{ "host": "{{.Host}}", "basePath": "{{.BasePath}}", "paths": { + "/admin/accounts": { + "get": { + "security": [ + { + "AdminKey": [] + } + ], + "description": "Includes active and inactive accounts, newest first. These records are not login users.", + "produces": [ + "application/json" + ], + "tags": [ + "Admin" + ], + "summary": "List operator accounts", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.AccountList" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/internal_api_handlers.APIError" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/internal_api_handlers.APIError" + } + } + }, + "503": { + "description": "Service Unavailable", + "schema": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/internal_api_handlers.APIError" + } + } + } + } + }, + "post": { + "security": [ + { + "AdminKey": [] + } + ], + "description": "Trims the name; names are case-sensitive and unique among active accounts. No credential or login is created.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Admin" + ], + "summary": "Create an operator account", + "parameters": [ + { + "description": "Account name (1-128 Unicode characters, no control characters); body at most 4 KiB", + "name": "account", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.CreateAccountRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.Account" + } + }, + "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" + } + } + }, + "409": { + "description": "Conflict", + "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" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/internal_api_handlers.APIError" + } + } + }, + "503": { + "description": "Service Unavailable", + "schema": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/internal_api_handlers.APIError" + } + } + } + } + } + }, + "/admin/accounts/{id}": { + "get": { + "security": [ + { + "AdminKey": [] + } + ], + "description": "Returns active or inactive account records.", + "produces": [ + "application/json" + ], + "tags": [ + "Admin" + ], + "summary": "Get an operator account", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "Account UUID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.Account" + } + }, + "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" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/internal_api_handlers.APIError" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/internal_api_handlers.APIError" + } + } + }, + "503": { + "description": "Service Unavailable", + "schema": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/internal_api_handlers.APIError" + } + } + } + } + }, + "delete": { + "security": [ + { + "AdminKey": [] + } + ], + "description": "Soft deactivation preserves the record. Its name may be reused by a new account.", + "produces": [ + "application/json" + ], + "tags": [ + "Admin" + ], + "summary": "Deactivate an operator account", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "Account UUID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "Deactivated" + }, + "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" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/internal_api_handlers.APIError" + } + } + }, + "409": { + "description": "Conflict", + "schema": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/internal_api_handlers.APIError" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/internal_api_handlers.APIError" + } + } + }, + "503": { + "description": "Service Unavailable", + "schema": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/internal_api_handlers.APIError" + } + } + } + } + } + }, "/admin/config": { "get": { "security": [ @@ -2558,6 +2874,37 @@ const docTemplate = `{ } }, "definitions": { + "github_com_MeshCore-Beacon_beacon-server_internal_api.Account": { + "type": "object", + "properties": { + "active": { + "type": "boolean" + }, + "created_at": { + "type": "string" + }, + "deactivated_at": { + "type": "string" + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, + "github_com_MeshCore-Beacon_beacon-server_internal_api.AccountList": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.Account" + } + } + } + }, "github_com_MeshCore-Beacon_beacon-server_internal_api.AdminAuthConfig": { "type": "object", "properties": { @@ -2811,6 +3158,17 @@ const docTemplate = `{ } } }, + "github_com_MeshCore-Beacon_beacon-server_internal_api.CreateAccountRequest": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string" + } + } + }, "github_com_MeshCore-Beacon_beacon-server_internal_api.CrossIATAHop": { "type": "object", "properties": { diff --git a/docs/swagger.json b/docs/swagger.json index 018641c..593b757 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -20,6 +20,322 @@ "host": "localhost:8080", "basePath": "/api/v1", "paths": { + "/admin/accounts": { + "get": { + "security": [ + { + "AdminKey": [] + } + ], + "description": "Includes active and inactive accounts, newest first. These records are not login users.", + "produces": [ + "application/json" + ], + "tags": [ + "Admin" + ], + "summary": "List operator accounts", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.AccountList" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/internal_api_handlers.APIError" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/internal_api_handlers.APIError" + } + } + }, + "503": { + "description": "Service Unavailable", + "schema": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/internal_api_handlers.APIError" + } + } + } + } + }, + "post": { + "security": [ + { + "AdminKey": [] + } + ], + "description": "Trims the name; names are case-sensitive and unique among active accounts. No credential or login is created.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Admin" + ], + "summary": "Create an operator account", + "parameters": [ + { + "description": "Account name (1-128 Unicode characters, no control characters); body at most 4 KiB", + "name": "account", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.CreateAccountRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.Account" + } + }, + "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" + } + } + }, + "409": { + "description": "Conflict", + "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" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/internal_api_handlers.APIError" + } + } + }, + "503": { + "description": "Service Unavailable", + "schema": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/internal_api_handlers.APIError" + } + } + } + } + } + }, + "/admin/accounts/{id}": { + "get": { + "security": [ + { + "AdminKey": [] + } + ], + "description": "Returns active or inactive account records.", + "produces": [ + "application/json" + ], + "tags": [ + "Admin" + ], + "summary": "Get an operator account", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "Account UUID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.Account" + } + }, + "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" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/internal_api_handlers.APIError" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/internal_api_handlers.APIError" + } + } + }, + "503": { + "description": "Service Unavailable", + "schema": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/internal_api_handlers.APIError" + } + } + } + } + }, + "delete": { + "security": [ + { + "AdminKey": [] + } + ], + "description": "Soft deactivation preserves the record. Its name may be reused by a new account.", + "produces": [ + "application/json" + ], + "tags": [ + "Admin" + ], + "summary": "Deactivate an operator account", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "Account UUID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "Deactivated" + }, + "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" + } + } + }, + "404": { + "description": "Not Found", + "schema": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/internal_api_handlers.APIError" + } + } + }, + "409": { + "description": "Conflict", + "schema": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/internal_api_handlers.APIError" + } + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/internal_api_handlers.APIError" + } + } + }, + "503": { + "description": "Service Unavailable", + "schema": { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/internal_api_handlers.APIError" + } + } + } + } + } + }, "/admin/config": { "get": { "security": [ @@ -2556,6 +2872,37 @@ } }, "definitions": { + "github_com_MeshCore-Beacon_beacon-server_internal_api.Account": { + "type": "object", + "properties": { + "active": { + "type": "boolean" + }, + "created_at": { + "type": "string" + }, + "deactivated_at": { + "type": "string" + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, + "github_com_MeshCore-Beacon_beacon-server_internal_api.AccountList": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.Account" + } + } + } + }, "github_com_MeshCore-Beacon_beacon-server_internal_api.AdminAuthConfig": { "type": "object", "properties": { @@ -2809,6 +3156,17 @@ } } }, + "github_com_MeshCore-Beacon_beacon-server_internal_api.CreateAccountRequest": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string" + } + } + }, "github_com_MeshCore-Beacon_beacon-server_internal_api.CrossIATAHop": { "type": "object", "properties": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 6e7923e..8619ab7 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -1,5 +1,25 @@ basePath: /api/v1 definitions: + github_com_MeshCore-Beacon_beacon-server_internal_api.Account: + properties: + active: + type: boolean + created_at: + type: string + deactivated_at: + type: string + id: + type: string + name: + type: string + type: object + github_com_MeshCore-Beacon_beacon-server_internal_api.AccountList: + properties: + items: + items: + $ref: '#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.Account' + type: array + type: object github_com_MeshCore-Beacon_beacon-server_internal_api.AdminAuthConfig: properties: configured: @@ -177,6 +197,13 @@ definitions: nodeTypeName: type: string type: object + github_com_MeshCore-Beacon_beacon-server_internal_api.CreateAccountRequest: + properties: + name: + type: string + required: + - name + type: object github_com_MeshCore-Beacon_beacon-server_internal_api.CrossIATAHop: properties: fromIata: @@ -1254,6 +1281,215 @@ info: title: MeshCore Beacon API version: 1.6.0 paths: + /admin/accounts: + get: + description: Includes active and inactive accounts, newest first. These records + are not login users. + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.AccountList' + "401": + description: Unauthorized + schema: + additionalProperties: + $ref: '#/definitions/internal_api_handlers.APIError' + type: object + "500": + description: Internal Server Error + 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: List operator accounts + tags: + - Admin + post: + consumes: + - application/json + description: Trims the name; names are case-sensitive and unique among active + accounts. No credential or login is created. + parameters: + - description: Account name (1-128 Unicode characters, no control characters); + body at most 4 KiB + in: body + name: account + required: true + schema: + $ref: '#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.CreateAccountRequest' + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.Account' + "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 + "409": + description: Conflict + 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 + "500": + description: Internal Server Error + 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: Create an operator account + tags: + - Admin + /admin/accounts/{id}: + delete: + description: Soft deactivation preserves the record. Its name may be reused + by a new account. + parameters: + - description: Account UUID + format: uuid + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "204": + description: Deactivated + "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 + "404": + description: Not Found + schema: + additionalProperties: + $ref: '#/definitions/internal_api_handlers.APIError' + type: object + "409": + description: Conflict + schema: + additionalProperties: + $ref: '#/definitions/internal_api_handlers.APIError' + type: object + "500": + description: Internal Server Error + 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: Deactivate an operator account + tags: + - Admin + get: + description: Returns active or inactive account records. + parameters: + - description: Account UUID + format: uuid + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/github_com_MeshCore-Beacon_beacon-server_internal_api.Account' + "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 + "404": + description: Not Found + schema: + additionalProperties: + $ref: '#/definitions/internal_api_handlers.APIError' + type: object + "500": + description: Internal Server Error + 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: Get an operator account + tags: + - Admin /admin/config: get: description: Returns CORS startup options with Beacon defaults, auth configuration diff --git a/internal/api/account_requests.go b/internal/api/account_requests.go new file mode 100644 index 0000000..f040f98 --- /dev/null +++ b/internal/api/account_requests.go @@ -0,0 +1,8 @@ +// Copyright 2026 Beacon Contributors +// SPDX-License-Identifier: AGPL-3.0-or-later + +package api + +type CreateAccountRequest struct { + Name string `json:"name" validate:"required"` +} diff --git a/internal/api/handlers/accounts.go b/internal/api/handlers/accounts.go new file mode 100644 index 0000000..92c1fc2 --- /dev/null +++ b/internal/api/handlers/accounts.go @@ -0,0 +1,173 @@ +// Copyright 2026 Beacon Contributors +// SPDX-License-Identifier: AGPL-3.0-or-later + +package handlers + +import ( + "encoding/json" + "errors" + "io" + "mime" + "net/http" + + "github.com/MeshCore-Beacon/beacon-server/internal/api" + "github.com/go-chi/chi/v5" + "github.com/google/uuid" +) + +// AccountsRouter must be mounted inside the authenticated admin subtree. +func AccountsRouter(store api.AccountStore) http.Handler { + if store == nil { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { respondError(w, 503, "account storage is unavailable") }) + } + r := chi.NewRouter() + r.Get("/", listAccounts(store)) + r.Post("/", createAccount(store)) + r.Get("/{id}", getAccount(store)) + r.Delete("/{id}", deactivateAccount(store)) + return r +} + +// listAccounts godoc +// @Summary List operator accounts +// @Description Includes active and inactive accounts, newest first. These records are not login users. +// @Tags Admin +// @Produce json +// @Security AdminKey +// @Success 200 {object} api.AccountList +// @Failure 401,503,500 {object} map[string]APIError +// @Router /admin/accounts [get] +func listAccounts(store api.AccountStore) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + items, err := store.ListAccounts(r.Context()) + if err != nil { + accountError(w, err) + return + } + if items == nil { + items = []api.Account{} + } + respond(w, 200, api.AccountList{Items: items}) + } +} + +// createAccount godoc +// @Summary Create an operator account +// @Description Trims the name; names are case-sensitive and unique among active accounts. No credential or login is created. +// @Tags Admin +// @Accept json +// @Produce json +// @Security AdminKey +// @Param account body api.CreateAccountRequest true "Account name (1-128 Unicode characters, no control characters); body at most 4 KiB" +// @Success 201 {object} api.Account +// @Failure 400,401,409,413,415,503,500 {object} map[string]APIError +// @Router /admin/accounts [post] +func createAccount(store api.AccountStore) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + mediaType, _, err := mime.ParseMediaType(r.Header.Get("Content-Type")) + if err != nil || mediaType != "application/json" { + respondError(w, 415, "Content-Type must be application/json") + return + } + r.Body = http.MaxBytesReader(w, r.Body, 4096) + decoder := json.NewDecoder(r.Body) + decoder.DisallowUnknownFields() + var input api.CreateAccountRequest + 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 4 KiB") + } else { + respondError(w, 400, "invalid account JSON") + } + return + } + name, err := api.NormalizeAccountName(input.Name) + if err != nil { + accountError(w, err) + return + } + account, err := store.CreateAccount(r.Context(), name) + if err != nil { + accountError(w, err) + return + } + respond(w, 201, account) + } +} + +// getAccount godoc +// @Summary Get an operator account +// @Description Returns active or inactive account records. +// @Tags Admin +// @Produce json +// @Security AdminKey +// @Param id path string true "Account UUID" format(uuid) +// @Success 200 {object} api.Account +// @Failure 400,401,404,503,500 {object} map[string]APIError +// @Router /admin/accounts/{id} [get] +func getAccount(store api.AccountStore) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + id, err := uuid.Parse(chi.URLParam(r, "id")) + if err != nil { + respondError(w, 400, "invalid account ID") + return + } + account, err := store.GetAccount(r.Context(), id) + if err != nil { + accountError(w, err) + return + } + respond(w, 200, account) + } +} + +// deactivateAccount godoc +// @Summary Deactivate an operator account +// @Description Soft deactivation preserves the record. Its name may be reused by a new account. +// @Tags Admin +// @Produce json +// @Security AdminKey +// @Param id path string true "Account UUID" format(uuid) +// @Success 204 "Deactivated" +// @Failure 400,401,404,409,503,500 {object} map[string]APIError +// @Router /admin/accounts/{id} [delete] +func deactivateAccount(store api.AccountStore) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + id, err := uuid.Parse(chi.URLParam(r, "id")) + if err != nil { + respondError(w, 400, "invalid account ID") + return + } + if err := store.DeactivateAccount(r.Context(), id); err != nil { + accountError(w, err) + return + } + w.WriteHeader(204) + } +} + +func accountError(w http.ResponseWriter, err error) { + switch { + case errors.Is(err, api.ErrAccountNameInvalid): + respondError(w, 400, api.ErrAccountNameInvalid.Error()) + case errors.Is(err, api.ErrAccountNameConflict): + respondError(w, 409, api.ErrAccountNameConflict.Error()) + case errors.Is(err, api.ErrAccountNotFound): + respondError(w, 404, api.ErrAccountNotFound.Error()) + case errors.Is(err, api.ErrAccountInactive): + respondError(w, 409, api.ErrAccountInactive.Error()) + default: + respondError(w, 500, "internal server error") + } +} diff --git a/internal/api/handlers/accounts_test.go b/internal/api/handlers/accounts_test.go new file mode 100644 index 0000000..d6b9683 --- /dev/null +++ b/internal/api/handlers/accounts_test.go @@ -0,0 +1,147 @@ +// Copyright 2026 Beacon Contributors +// SPDX-License-Identifier: AGPL-3.0-or-later + +package handlers + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/MeshCore-Beacon/beacon-server/internal/api" + mw "github.com/MeshCore-Beacon/beacon-server/internal/api/middleware" + "github.com/google/uuid" +) + +type accountStub struct { + account api.Account + items []api.Account + err error + calls int + name string + id uuid.UUID +} + +func (s *accountStub) CreateAccount(_ context.Context, name string) (api.Account, error) { + s.calls++ + s.name = name + return s.account, s.err +} +func (s *accountStub) ListAccounts(context.Context) ([]api.Account, error) { + s.calls++ + return s.items, s.err +} +func (s *accountStub) GetAccount(_ context.Context, id uuid.UUID) (api.Account, error) { + s.calls++ + s.id = id + return s.account, s.err +} +func (s *accountStub) DeactivateAccount(_ context.Context, id uuid.UUID) error { + s.calls++ + s.id = id + return s.err +} + +func accountRequest(handler http.Handler, method, path, body, contentType, token string) *httptest.ResponseRecorder { + req := httptest.NewRequest(method, path, strings.NewReader(body)) + if contentType != "" { + req.Header.Set("Content-Type", contentType) + } + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + return w +} + +func TestAccountInputAndErrors(t *testing.T) { + id := uuid.New() + for _, tc := range []struct { + name, method, path, body, media string + err error + status, calls int + }{ + {"create", "POST", "/", `{"name":" Alice "}`, "application/json", nil, 201, 1}, + {"media parameters", "POST", "/", `{"name":"Alice"}`, "application/json; charset=utf-8", nil, 201, 1}, + {"empty", "POST", "/", `{}`, "application/json", nil, 400, 0}, + {"null", "POST", "/", `null`, "application/json", nil, 400, 0}, + {"wrong field", "POST", "/", `{"name":"Alice","admin":true}`, "application/json", nil, 400, 0}, + {"trailing JSON", "POST", "/", `{"name":"Alice"} {}`, "application/json", nil, 400, 0}, + {"malformed", "POST", "/", `{"name":`, "application/json", nil, 400, 0}, + {"wrong name type", "POST", "/", `{"name":3}`, "application/json", nil, 400, 0}, + {"control", "POST", "/", `{"name":"x\u0000y"}`, "application/json", nil, 400, 0}, + {"oversized", "POST", "/", `{"name":"` + strings.Repeat("a", 5000) + `"}`, "application/json", nil, 413, 0}, + {"oversized trailing space", "POST", "/", `{"name":"Alice"}` + strings.Repeat(" ", 5000), "application/json", nil, 413, 0}, + {"missing media", "POST", "/", `{"name":"Alice"}`, "", nil, 415, 0}, + {"wrong media", "POST", "/", `{"name":"Alice"}`, "text/plain", nil, 415, 0}, + {"conflict", "POST", "/", `{"name":"Alice"}`, "application/json", fmt.Errorf("private-error: %w", api.ErrAccountNameConflict), 409, 1}, + {"get", "GET", "/" + id.String(), "", "", nil, 200, 1}, + {"bad ID", "GET", "/invalid", "", "", nil, 400, 0}, + {"bad delete ID", "DELETE", "/invalid", "", "", nil, 400, 0}, + {"not found", "GET", "/" + id.String(), "", "", api.ErrAccountNotFound, 404, 1}, + {"deactivate", "DELETE", "/" + id.String(), "", "", nil, 204, 1}, + {"inactive", "DELETE", "/" + id.String(), "", "", api.ErrAccountInactive, 409, 1}, + {"delete absent", "DELETE", "/" + id.String(), "", "", api.ErrAccountNotFound, 404, 1}, + {"list database failure", "GET", "/", "", "", errors.New("private-error"), 500, 1}, + } { + t.Run(tc.name, func(t *testing.T) { + store := &accountStub{account: api.Account{ID: id, Name: "Alice", CreatedAt: time.Unix(1, 0).UTC(), Active: true}, err: tc.err} + handler := mw.BearerAuth("test-key", AccountsRouter(store)) + w := accountRequest(handler, tc.method, tc.path, tc.body, tc.media, "test-key") + if w.Code != tc.status || store.calls != tc.calls { + t.Fatalf("status=%d calls=%d", w.Code, store.calls) + } + if w.Header().Get("Cache-Control") != "no-store" || strings.Contains(w.Body.String(), "private-error") { + t.Fatal("cache policy or error disclosure") + } + if tc.status == 204 { + if w.Body.Len() != 0 { + t.Fatal("204 body") + } + return + } + if w.Header().Get("Content-Type") != "application/json" || !json.Valid(w.Body.Bytes()) { + t.Fatal("invalid JSON response") + } + if tc.status == 201 && store.name != "Alice" { + t.Fatal("untrimmed name reached storage") + } + if (tc.method == "DELETE" || tc.method == "GET") && tc.calls > 0 && tc.path != "/" && store.id != id { + t.Fatal("wrong account ID") + } + }) + } +} + +func TestAccountListAndAuthorization(t *testing.T) { + for _, items := range [][]api.Account{nil, {{ID: uuid.New(), Name: "inactive", Active: false}}} { + store := &accountStub{items: items} + handler := mw.BearerAuth("test-key", AccountsRouter(store)) + w := accountRequest(handler, "GET", "/", "", "", "test-key") + var body api.AccountList + if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &body) != nil || body.Items == nil || len(body.Items) != len(items) { + t.Fatal("account list shape") + } + } + for _, key := range []string{"", "test-key"} { + store := &accountStub{} + handler := mw.BearerAuth(key, AccountsRouter(store)) + for _, method := range []string{"GET", "POST", "DELETE"} { + w := accountRequest(handler, method, "/", "{}", "application/json", "") + want := 401 + if key == "" { + want = 503 + } + if w.Code != want || store.calls != 0 { + t.Fatal("unauthorized store access") + } + } + } +} diff --git a/internal/api/handlers/admin.go b/internal/api/handlers/admin.go index 4ec31d3..83a15e4 100644 --- a/internal/api/handlers/admin.go +++ b/internal/api/handlers/admin.go @@ -10,11 +10,12 @@ import ( "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(snapshot api.AdminConfig, accounts api.AccountStore) http.Handler { r := chi.NewRouter() r.Get("/config", getAdminConfig(snapshot)) + r.Mount("/accounts", AccountsRouter(accounts)) return r } diff --git a/internal/api/router/accounts_test.go b/internal/api/router/accounts_test.go new file mode 100644 index 0000000..1d3056a --- /dev/null +++ b/internal/api/router/accounts_test.go @@ -0,0 +1,51 @@ +// Copyright 2026 Beacon Contributors +// SPDX-License-Identifier: AGPL-3.0-or-later + +package router + +import ( + "context" + "net/http/httptest" + "strings" + "testing" + + "github.com/MeshCore-Beacon/beacon-server/internal/api" + "github.com/MeshCore-Beacon/beacon-server/internal/config" + "github.com/google/uuid" +) + +type routedAccountStore struct { + api.AccountStore + calls int +} + +func (s *routedAccountStore) CreateAccount(_ context.Context, name string) (api.Account, error) { + s.calls++ + return api.Account{ID: uuid.New(), Name: name, Active: true}, nil +} +func TestAccountRouterUsesProtectedStore(t *testing.T) { + store := &routedAccountStore{} + r := New(nil, nil, nil, 5, 1000, config.CORSConfig{}, config.ServerConfig{}, config.AuthConfig{APIKey: "test-key"}, config.ResolvedRateLimitConfig{}, store) + for _, token := range []string{"", "wrong", "test-key"} { + req := httptest.NewRequest("POST", "/api/v1/admin/accounts", strings.NewReader(`{"name":"test"}`)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+token) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + want := 401 + if token == "test-key" { + want = 201 + } + if w.Code != want { + t.Fatalf("route status=%d want=%d", w.Code, want) + } + } + if store.calls != 1 { + t.Fatal("auth boundary was bypassed") + } + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest("POST", "/api/v1/accounts", nil)) + if w.Code != 404 || store.calls != 1 { + t.Fatal("accounts mounted publicly") + } +} diff --git a/internal/api/router/admin_config_test.go b/internal/api/router/admin_config_test.go index fd24286..3839380 100644 --- a/internal/api/router/admin_config_test.go +++ b/internal/api/router/admin_config_test.go @@ -32,7 +32,7 @@ func TestAdminConfig(t *testing.T) { 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, 1000, cfg, config.ServerConfig{}, config.AuthConfig{APIKey: key}, config.ResolvedRateLimitConfig{}) + handler := New(nil, nil, []*ingest.Worker{{}, {}}, 5, 1000, cfg, config.ServerConfig{}, config.AuthConfig{APIKey: key}, config.ResolvedRateLimitConfig{}, nil) if custom { cfg.AllowedOrigins[0] = "https://changed.test" cfg.AllowedMethods[0] = "DELETE" diff --git a/internal/api/router/auth_test.go b/internal/api/router/auth_test.go index 6c7b196..9620cf1 100644 --- a/internal/api/router/auth_test.go +++ b/internal/api/router/auth_test.go @@ -13,7 +13,7 @@ import ( func TestAdminAuthBoundary(t *testing.T) { for _, key := range []string{"", "synthetic-test-key"} { - handler := New(nil, nil, nil, 5, 1000, config.CORSConfig{}, config.ServerConfig{}, config.AuthConfig{APIKey: key}, config.ResolvedRateLimitConfig{}) + handler := New(nil, nil, nil, 5, 1000, config.CORSConfig{}, config.ServerConfig{}, config.AuthConfig{APIKey: key}, config.ResolvedRateLimitConfig{}, nil) for _, path := range []string{"/api/v1/admin", "/api/v1/admin/", "/api/v1/admin/config", "/api/v1/admin//config"} { for _, method := range []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"} { for _, token := range []string{"", "wrong", "synthetic-test-key"} { diff --git a/internal/api/router/rate_limit_test.go b/internal/api/router/rate_limit_test.go index 95ab42f..db22746 100644 --- a/internal/api/router/rate_limit_test.go +++ b/internal/api/router/rate_limit_test.go @@ -22,7 +22,7 @@ import ( ) func TestDefaultAPIRateLimit(t *testing.T) { - handler := New(nil, nil, nil, 5, 1000, config.CORSConfig{}, config.ServerConfig{}, config.AuthConfig{}, config.Resolve(&config.Config{}).RateLimit) + handler := New(nil, nil, nil, 5, 1000, config.CORSConfig{}, config.ServerConfig{}, config.AuthConfig{}, config.Resolve(&config.Config{}).RateLimit, nil) for i := 0; i <= 300; i++ { request := httptest.NewRequest(http.MethodGet, "/api/v1/brokers", nil) request.RemoteAddr = "198.51.100.1:1234" @@ -56,7 +56,7 @@ func TestAPIRateLimitContractAndLogging(t *testing.T) { slog.SetDefault(slog.New(slog.NewJSONHandler(&logs, nil))) t.Cleanup(func() { slog.SetDefault(previous); log.SetOutput(writer); log.SetFlags(flags) }) proxy := config.ServerConfig{TrustedProxies: []netip.Prefix{netip.MustParsePrefix("127.0.0.1/32")}} - handler := New(nil, nil, nil, 5, 1000, config.CORSConfig{}, proxy, config.AuthConfig{}, config.ResolvedRateLimitConfig{Enabled: true, RequestsPerMinute: 2, Burst: 10}) + handler := New(nil, nil, nil, 5, 1000, config.CORSConfig{}, proxy, config.AuthConfig{}, config.ResolvedRateLimitConfig{Enabled: true, RequestsPerMinute: 2, Burst: 10}, nil) for _, path := range []string{"/api/v1/brokers", "/api/v1/packets?limit=0"} { response := rateRequest(handler, path, "127.0.0.1:1234", "198.51.100.25") if response.Code != http.StatusOK && response.Code != http.StatusBadRequest { @@ -111,7 +111,7 @@ func TestAPIRateLimitClientIdentity(t *testing.T) { if tc.trusted { proxy.TrustedProxies = []netip.Prefix{netip.MustParsePrefix("127.0.0.1/32")} } - handler := New(nil, nil, nil, 5, 1000, config.CORSConfig{}, proxy, config.AuthConfig{}, config.ResolvedRateLimitConfig{Enabled: true, RequestsPerMinute: 1, Burst: 1}) + handler := New(nil, nil, nil, 5, 1000, config.CORSConfig{}, proxy, config.AuthConfig{}, config.ResolvedRateLimitConfig{Enabled: true, RequestsPerMinute: 1, Burst: 1}, nil) if response := rateRequest(handler, "/api/v1/brokers", tc.firstPeer, tc.firstHeader); response.Code != http.StatusOK { t.Fatalf("first client: %d", response.Code) } @@ -128,7 +128,7 @@ func TestAPIRateLimitClientIdentity(t *testing.T) { func TestAPIRateLimitWindowsAndExclusions(t *testing.T) { synctest.Test(t, func(t *testing.T) { - handler := New(nil, nil, nil, 5, 1000, config.CORSConfig{}, config.ServerConfig{}, config.AuthConfig{}, config.ResolvedRateLimitConfig{Enabled: true, RequestsPerMinute: 4, Burst: 2}) + handler := New(nil, nil, nil, 5, 1000, config.CORSConfig{}, config.ServerConfig{}, config.AuthConfig{}, config.ResolvedRateLimitConfig{Enabled: true, RequestsPerMinute: 4, Burst: 2}, nil) request := func() *httptest.ResponseRecorder { return rateRequest(handler, "/api/v1/brokers", "198.51.100.1:1", "") } @@ -164,7 +164,7 @@ func TestAPIRateLimitWindowsAndExclusions(t *testing.T) { t.Fatal("minute budget did not recover") } }) - handler := New(nil, nil, nil, 5, 1000, config.CORSConfig{}, config.ServerConfig{}, config.AuthConfig{}, config.ResolvedRateLimitConfig{Enabled: false, RequestsPerMinute: 1, Burst: 1}) + handler := New(nil, nil, nil, 5, 1000, config.CORSConfig{}, config.ServerConfig{}, config.AuthConfig{}, config.ResolvedRateLimitConfig{Enabled: false, RequestsPerMinute: 1, Burst: 1}, nil) for range 5 { if response := rateRequest(handler, "/api/v1/brokers", "198.51.100.1:1", ""); response.Code != http.StatusOK || response.Header().Get("Retry-After") != "" { t.Fatal("disabled limiter still applied") @@ -173,7 +173,7 @@ func TestAPIRateLimitWindowsAndExclusions(t *testing.T) { } func TestAPIRateLimitConcurrentRequests(t *testing.T) { - handler := New(nil, nil, nil, 5, 1000, config.CORSConfig{}, config.ServerConfig{}, config.AuthConfig{}, config.ResolvedRateLimitConfig{Enabled: true, RequestsPerMinute: 5, Burst: 5}) + handler := New(nil, nil, nil, 5, 1000, config.CORSConfig{}, config.ServerConfig{}, config.AuthConfig{}, config.ResolvedRateLimitConfig{Enabled: true, RequestsPerMinute: 5, Burst: 5}, nil) var accepted, rejected atomic.Int32 var group sync.WaitGroup for range 20 { diff --git a/internal/api/router/router.go b/internal/api/router/router.go index 34db94a..c1c8844 100644 --- a/internal/api/router/router.go +++ b/internal/api/router/router.go @@ -39,7 +39,7 @@ import ( // /stats → stats subrouter // // Admin endpoints require a configured bearer key. -func New(h *hub.Hub, reader api.Reader, workers []*ingest.Worker, maxConnsPerIP, maxConnectsPerMinute int, corsCfg config.CORSConfig, serverCfg config.ServerConfig, authCfg config.AuthConfig, rateLimitCfg config.ResolvedRateLimitConfig) http.Handler { +func New(h *hub.Hub, reader api.Reader, workers []*ingest.Worker, maxConnsPerIP, maxConnectsPerMinute int, corsCfg config.CORSConfig, serverCfg config.ServerConfig, authCfg config.AuthConfig, rateLimitCfg config.ResolvedRateLimitConfig, accounts api.AccountStore) http.Handler { r := chi.NewRouter() // ── CORS ───────────────────────────────────────────────────────────────── @@ -121,7 +121,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(adminConfig, accounts))) }) return r diff --git a/internal/api/router/router_test.go b/internal/api/router/router_test.go index 88927e2..4079eaa 100644 --- a/internal/api/router/router_test.go +++ b/internal/api/router/router_test.go @@ -36,7 +36,7 @@ func TestWebSocketLimitUsesTrustedClientIP(t *testing.T) { func checkWebSocketLimit(t *testing.T, cfg config.ServerConfig, secondIP string, wantShed bool) { t.Helper() - server := httptest.NewServer(New(hub.New(), nil, nil, 1, 10, config.CORSConfig{}, cfg, config.AuthConfig{}, config.ResolvedRateLimitConfig{Enabled: true, RequestsPerMinute: 1, Burst: 1})) + server := httptest.NewServer(New(hub.New(), nil, nil, 1, 10, config.CORSConfig{}, cfg, config.AuthConfig{}, config.ResolvedRateLimitConfig{Enabled: true, RequestsPerMinute: 1, Burst: 1}, nil)) defer server.Close() // Exhaust this client's REST budget before checking its independent WS cap. for _, want := range []int{http.StatusOK, http.StatusTooManyRequests} { diff --git a/internal/api/router/ws_attempt_test.go b/internal/api/router/ws_attempt_test.go index b0f7398..1e4bf94 100644 --- a/internal/api/router/ws_attempt_test.go +++ b/internal/api/router/ws_attempt_test.go @@ -29,7 +29,7 @@ func TestWebSocketAttemptClientIdentity(t *testing.T) { if tc.trusted { cfg.TrustedProxies = []netip.Prefix{netip.MustParsePrefix("127.0.0.1/32")} } - handler := New(nil, nil, nil, 5, 1, config.CORSConfig{}, cfg, config.AuthConfig{}, config.ResolvedRateLimitConfig{}) + handler := New(nil, nil, nil, 5, 1, config.CORSConfig{}, cfg, config.AuthConfig{}, config.ResolvedRateLimitConfig{}, nil) attempt := func(peer, header string) int { request := httptest.NewRequest(http.MethodGet, "/ws", nil) request.RemoteAddr = peer