Skip to content

feat: HTTP/WebSocket REST API for external monitoring clients#3941

Draft
beats-dh wants to merge 10 commits into
opentibiabr:mainfrom
beats-dh:beats/api-pr146
Draft

feat: HTTP/WebSocket REST API for external monitoring clients#3941
beats-dh wants to merge 10 commits into
opentibiabr:mainfrom
beats-dh:beats/api-pr146

Conversation

@beats-dh

@beats-dh beats-dh commented May 5, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds an opt-in HTTP + WebSocket REST API embedded in canary, exposing server status, system metrics, online/banned player lists, and a small set of admin actions (kick, ban, broadcast, state). Intended for companion tooling — the Beats Monitor Flutter client is the reference consumer, but the API is generic.

Draft. Builds locally on Windows; haven't validated Linux/CI yet. Wanted feedback on the architecture (especially the dispatcher-marshalling pattern in src/api/utils/dispatcher_sync.hpp) before polishing.

Why

There's no first-class way for external dashboards or admin apps to read live server state without scraping the status protocol or querying the database directly. This adds a structured JSON API with proper auth, plus a WebSocket channel for real-time events (chat bridge, player join/leave, system metrics).

What's added

New module: src/api/

  • api.{cpp,hpp} — Crow application setup, route registration, lifecycle
  • endpoints/ — server, player, websocket handlers
  • middleware/ — auth (JWT), rate limit, validation, security headers, logging
  • utils/ — broadcast manager (1Hz status push), system info (CPU/mem via /proc or PDH), chat history, validators, dispatcher sync helper, version store (for client update catalog)

Dependencies (vcpkg.json): crow, simdjson, jwt-cpp.

Game integration (minimal touchpoints):

  • canary_server.{cpp,hpp} — start/stop the API gated on apiEnabled
  • game/game.{cpp,hpp} — broadcast hooks for chat / player events
  • game/scheduling/dispatcher.{cpp,hpp}asyncEvent gains a taskName parameter (used by the API helper to label posted events)
  • lua/functions/core/game/game_functions.{cpp,hpp}Game.banPlayer / Game.unbanPlayer Lua bindings (so the API and /ban talkaction can share a code path)

Configuration

Two surfaces:

config.lua.dist — runtime knobs (API is disabled by default):

```lua
apiEnabled = true
apiPort = 8081
apiJwtSecret = ""
apiMinAdminType = 5 -- minimum AccountType for destructive endpoints
apiCorsOrigin = "" -- exact origin string; empty disables CORS header
```

The server refuses to start the API if `apiJwtSecret` is empty (logs an error and continues without the API). Silent fallback to a default secret is a foot-gun, so we just refuse.

data/api_versions.json (optional) — catalog consumed by /api/v1/check-update. Operators publish their own client release URLs here; the engine ships a .dist template and defaults to "no updates available" when the file is missing. No third-party download URLs are baked into the binary.

Endpoints

```
POST /api/v1/login — argon2 auth → JWT
POST /api/v1/check-update — client version check (uses data/api_versions.json)
GET /api/v1/server/status — uptime, version, player counts, world state
GET /api/v1/server/resources — CPU/memory of the canary process
GET /api/v1/server/motd
POST /api/v1/server/state — admin: change game state
POST /api/v1/server/broadcast — admin: broadcast message
GET /api/v1/playersOnline — live list with details
GET /api/v1/players/ — player drill-down
POST /api/v1/players/kick — admin
POST /api/v1/players/ban — admin (action: ban|unban)
GET /api/v1/players/banned — list of bans
GET /api/v1/players/ban/history?name=
WS /ws — subscribe/unsubscribe + push events
```

WebSocket events: `server_status`, `system_resources`, `chat_global`, `chat_trade`, `chat_help`, `chat_history`, plus player join/leave.

Security

  • Authentication: `/api/v1/login` validates the username/password against the `accounts` table using the existing `Account::authenticate` (argon2 with SHA1 fallback) — no parallel password store. On success, signs a 1h JWT (HS256) with the configured secret containing `sub=accountId` and `acc_type=accountType`.
  • Authorization: any non-GET on `/api/v1/server/` or `/api/v1/players/` requires `acc_type >= apiMinAdminType`. Default 5 (CommunityManager) — God-only by setting it to 6.
  • Rate limiting: 5/min on `/login` (anti-bruteforce), 10/min on destructive admin endpoints, 30–60/min on read endpoints. Per-IP + per-route, sliding window.
  • CORS: opt-in, exact origin only — no wildcard.
  • Input filter: lightweight XSS-shape rejection on bodies and URL params (defense-in-depth for downstream HTML consumers; the API itself returns JSON).
  • No CSRF middleware — the API uses Bearer tokens which aren't subject to cookie-based CSRF.

Threading

API request handlers run on Crow worker threads, which would race the game loop if they touched `g_game()` or `Database` directly. All such access is marshalled via `runOnDispatcher()` (see `src/api/utils/dispatcher_sync.hpp`), which posts to the dispatcher and waits on a `std::future` with a timeout. The 1Hz broadcast loop does the same for status snapshots; `SystemInfo` reads `/proc` (Linux) or PDH counters (Windows) and stays out of the dispatcher.

This keeps the game's invariants intact and means an unresponsive API request can't deadlock — it times out (default 2s, 5s for DB queries) and returns 500 to the client while the dispatcher keeps running.

Test plan

  • Build on Windows (`windows-debug` and `windows-release` presets) with API disabled — verify zero overhead path
  • Build on Linux (`linux-release`)
  • Enable API, start server, confirm boot log shows "REST API initialized on port 8081"
  • `POST /api/v1/login` with wrong password → 401; with correct → JWT
  • `GET /api/v1/server/status` without token → 401; with token → 200
  • `POST /api/v1/players/kick` with non-admin token → 403; with admin token → 200
  • Hammer `/api/v1/login` 6× in 60s → 6th gets 429
  • WebSocket `/ws` subscribe to `chat_global`, send message in-game, observe push
  • `POST /api/v1/check-update` with no `data/api_versions.json` → `{"hasUpdate": false}`; with file populated → 200 and update info
  • Stop server, confirm clean shutdown (no hanging threads, no crash on the 1Hz broadcast loop)
  • Empty `apiJwtSecret` with `apiEnabled=true` → API doesn't start, error logged, server continues normally

Notes

  • This started as a private branch (canary-premium) and I'm cherry-picking the relevant commits onto current canary main. Skipped one commit that conflicted with canary's later OpenSSL/RSA refactor (the original fix is now obsolete). 11 cherry-picked commits + integration tweaks.
  • The Beats Monitor client is licensed PolyForm Noncommercial 1.0.0 — independent of this PR.
  • Open to renaming things (`apiMinAdminType` is awkward), restructuring the auth claims, or splitting this into smaller PRs if reviewers prefer.

beats-dh and others added 9 commits May 5, 2026 00:11
Replace placeholder login (beats:1234) with real authentication against the
accounts table using the existing argon2/SHA1 path. JWT tokens now embed the
account id (sub) and account type (acc_type claim) and are signed with a
configurable secret. The verify path no longer does the tautological
with_subject(decoded.get_subject()) check; non-GET endpoints additionally
require accountType >= apiMinAdminType.

CSRF middleware was removed — Bearer-token auth is not subject to
cookie-based CSRF and the previous check accepted any non-empty header.

Rate limits are now real values (5/min on /login for anti-bruteforce, 10/min
on destructive admin endpoints, 30-60/min on read-only) instead of the 100M
placeholder.

All g_game() and Database access from Crow worker threads now goes through
the dispatcher via runOnDispatcher (api/utils/dispatcher_sync.hpp), so the
game loop's invariants are not racing with API handlers.

API server is opt-in via config:
- apiEnabled (default false)
- apiPort (default 8081)
- apiJwtSecret (refused to start if empty)
- apiMinAdminType (default 5 = CommunityManager)
- apiCorsOrigin (default empty = no CORS header)

Removed the test endpoint POST /api/v1/players, the obsolete
/api/v1/check-update with hardcoded GitHub URLs, and the leftover
VersionInfo/availableVersions/isNewerVersion machinery.
…middleware

The XSS sanitizer was rebuilding std::regex objects on every request — once per
pattern, per body and per URL param. Out of the 5 patterns, 4 were plain
substrings that don't need a regex at all.

Compile the one real pattern (<script>...</script>) once into a static
std::regex with regex::optimize, and match the rest with a case-insensitive
substring search. Behavior unchanged; per-request cost drops from O(patterns ×
inputs) regex constructions to one cheap pass.
@coderabbitai

coderabbitai Bot commented May 5, 2026

Copy link
Copy Markdown

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 892a1a8e-0001-46c2-9127-865bcc8a9c1b

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

The previous hardening commit removed the endpoint along with the hardcoded
GitHub release URL it used. The Beats Monitor app actually consumes it
(lib/services/update_service.dart:64), so removing it broke that flow.

Restore the endpoint with the same request/response contract, but read the
catalog from data/api_versions.json (loaded once at API startup) instead of
baking URLs into the binary. Operators ship their own JSON; the engine
defaults to "no updates available" when the file is missing.

A data/api_versions.json.dist template is included documenting the schema.
@sonarqubecloud

sonarqubecloud Bot commented May 5, 2026

Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
C Reliability Rating on New Code (required ≥ A)

See analysis details on SonarQube Cloud

Catch issues before they fail your Quality Gate with our IDE extension SonarQube for IDE

@InnerCircleTFS

Copy link
Copy Markdown

To fix the compilation errors in VS Solution, I had to do this

diff --git a/src/api/api.cpp b/src/api/api.cpp
index bf0f5572a..0d2dcf901 100644
--- a/src/api/api.cpp
+++ b/src/api/api.cpp
@@ -56,7 +56,7 @@ void APIServer::initialize(uint16_t port) {
 	VersionStore::getInstance().loadFromFile("data/api_versions.json");
 
 	app.loglevel(crow::LogLevel::Warning);
-	app.port(port).use_compression(crow::compression::DEFLATE).multithreaded();
+	app.port(port).multithreaded();
 
 	setupRoutes();
 	setupWebSocket();
diff --git a/src/api/middleware/auth.hpp b/src/api/middleware/auth.hpp
index d8fd222c1..0ba42f3b6 100644
--- a/src/api/middleware/auth.hpp
+++ b/src/api/middleware/auth.hpp
@@ -36,7 +36,7 @@ public:
 
 			ctx.accountId = static_cast<uint32_t>(std::stoul(decoded.get_subject()));
 			if (decoded.has_payload_claim("acc_type")) {
-				ctx.accountType = static_cast<uint8_t>(decoded.get_payload_claim("acc_type").as_int());
+				ctx.accountType = static_cast<uint8_t>(decoded.get_payload_claim("acc_type").as_integer());
 			}
 		} catch (const std::exception &) {
 			res = APIResponse::unauthorized("Token inválido ou expirado");
diff --git a/src/api/utils/system_info.cpp b/src/api/utils/system_info.cpp
index 4c3162f6f..4f839ea51 100644
--- a/src/api/utils/system_info.cpp
+++ b/src/api/utils/system_info.cpp
@@ -10,6 +10,7 @@ bool SystemInfo::firstCall = true;
 	#include <psapi.h>
 	#include <winreg.h>
 	#pragma comment(lib, "pdh.lib")
+	#pragma comment(lib, "advapi32.lib")
 
 ULARGE_INTEGER SystemInfo::lastSystemKernel { 0 };
 ULARGE_INTEGER SystemInfo::lastSystemUser { 0 };

diff --git a/vcproj/canary.vcxproj b/vcproj/canary.vcxproj
index bc8e08e47..e89f9df01 100644
--- a/vcproj/canary.vcxproj
+++ b/vcproj/canary.vcxproj
@@ -236,6 +236,23 @@
     <ClInclude Include="..\src\utils\vectorset.hpp" />
     <ClInclude Include="..\src\utils\vectorsort.hpp" />
     <ClInclude Include="..\src\utils\wildcardtree.hpp" />
+    <ClInclude Include="..\src\api\api.hpp" />
+    <ClInclude Include="..\src\api\endpoints\player.hpp" />
+    <ClInclude Include="..\src\api\endpoints\server.hpp" />
+    <ClInclude Include="..\src\api\endpoints\websocket.hpp" />
+    <ClInclude Include="..\src\api\middleware\auth.hpp" />
+    <ClInclude Include="..\src\api\middleware\logging.hpp" />
+    <ClInclude Include="..\src\api\middleware\rate_limit.hpp" />
+    <ClInclude Include="..\src\api\middleware\security.hpp" />
+    <ClInclude Include="..\src\api\middleware\validation.hpp" />
+    <ClInclude Include="..\src\api\models\responses.hpp" />
+    <ClInclude Include="..\src\api\utils\broadcast_manager.hpp" />
+    <ClInclude Include="..\src\api\utils\chat_history.hpp" />
+    <ClInclude Include="..\src\api\utils\dispatcher_sync.hpp" />
+    <ClInclude Include="..\src\api\utils\system_info.hpp" />
+    <ClInclude Include="..\src\api\utils\validators.hpp" />
+    <ClInclude Include="..\src\api\utils\version_store.hpp" />
+    <ClInclude Include="..\src\api\utils\websocket_events.hpp" />
   </ItemGroup>
   <ItemGroup>
     <ClCompile Include="..\src\account\account_repository.cpp" />
@@ -426,6 +443,17 @@
     <ClCompile Include="..\src\utils\pugicast.cpp" />
     <ClCompile Include="..\src\utils\tools.cpp" />
     <ClCompile Include="..\src\utils\wildcardtree.cpp" />
+    <ClCompile Include="..\src\api\api.cpp" />
+    <ClCompile Include="..\src\api\endpoints\player.cpp">
+      <ObjectFileName>$(IntDir)api_player.obj</ObjectFileName>
+    </ClCompile>
+    <ClCompile Include="..\src\api\endpoints\server.cpp">
+      <ObjectFileName>$(IntDir)api_server.obj</ObjectFileName>
+    </ClCompile>
+    <ClCompile Include="..\src\api\utils\broadcast_manager.cpp" />
+    <ClCompile Include="..\src\api\utils\chat_history.cpp" />
+    <ClCompile Include="..\src\api\utils\system_info.cpp" />
+    <ClCompile Include="..\src\api\utils\version_store.cpp" />
   </ItemGroup>
 

As for the APK, I compiled it using this
9m 50s

name: Build Android APK

on:
  push:
    branches:
      - main

jobs:
  build:
    name: Build Release APK
    runs-on: ubuntu-latest

    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      - name: Set up Java
        uses: actions/setup-java@v4
        with:
          distribution: temurin
          java-version: '21'

      - name: Set up Flutter
        uses: subosito/flutter-action@v2
        with:
          flutter-version: '3.29.2'
          channel: stable
          cache: true

      - name: Install dependencies
        run: flutter pub get

      - name: Build release APK
        run: flutter build apk --release

      - name: Upload APK artifact
        uses: actions/upload-artifact@v4
        with:
          name: beats-monitor-release-apk
          path: build/app/outputs/flutter-apk/app-release.apk
          retention-days: 30

@orafal-dev

This comment has been minimized.

@beats-dh

Copy link
Copy Markdown
Contributor Author

To fix the compilation errors in VS Solution, I had to do this

diff --git a/src/api/api.cpp b/src/api/api.cpp
index bf0f5572a..0d2dcf901 100644
--- a/src/api/api.cpp
+++ b/src/api/api.cpp
@@ -56,7 +56,7 @@ void APIServer::initialize(uint16_t port) {
 	VersionStore::getInstance().loadFromFile("data/api_versions.json");
 
 	app.loglevel(crow::LogLevel::Warning);
-	app.port(port).use_compression(crow::compression::DEFLATE).multithreaded();
+	app.port(port).multithreaded();
 
 	setupRoutes();
 	setupWebSocket();
diff --git a/src/api/middleware/auth.hpp b/src/api/middleware/auth.hpp
index d8fd222c1..0ba42f3b6 100644
--- a/src/api/middleware/auth.hpp
+++ b/src/api/middleware/auth.hpp
@@ -36,7 +36,7 @@ public:
 
 			ctx.accountId = static_cast<uint32_t>(std::stoul(decoded.get_subject()));
 			if (decoded.has_payload_claim("acc_type")) {
-				ctx.accountType = static_cast<uint8_t>(decoded.get_payload_claim("acc_type").as_int());
+				ctx.accountType = static_cast<uint8_t>(decoded.get_payload_claim("acc_type").as_integer());
 			}
 		} catch (const std::exception &) {
 			res = APIResponse::unauthorized("Token inválido ou expirado");
diff --git a/src/api/utils/system_info.cpp b/src/api/utils/system_info.cpp
index 4c3162f6f..4f839ea51 100644
--- a/src/api/utils/system_info.cpp
+++ b/src/api/utils/system_info.cpp
@@ -10,6 +10,7 @@ bool SystemInfo::firstCall = true;
 	#include <psapi.h>
 	#include <winreg.h>
 	#pragma comment(lib, "pdh.lib")
+	#pragma comment(lib, "advapi32.lib")
 
 ULARGE_INTEGER SystemInfo::lastSystemKernel { 0 };
 ULARGE_INTEGER SystemInfo::lastSystemUser { 0 };

diff --git a/vcproj/canary.vcxproj b/vcproj/canary.vcxproj
index bc8e08e47..e89f9df01 100644
--- a/vcproj/canary.vcxproj
+++ b/vcproj/canary.vcxproj
@@ -236,6 +236,23 @@
     <ClInclude Include="..\src\utils\vectorset.hpp" />
     <ClInclude Include="..\src\utils\vectorsort.hpp" />
     <ClInclude Include="..\src\utils\wildcardtree.hpp" />
+    <ClInclude Include="..\src\api\api.hpp" />
+    <ClInclude Include="..\src\api\endpoints\player.hpp" />
+    <ClInclude Include="..\src\api\endpoints\server.hpp" />
+    <ClInclude Include="..\src\api\endpoints\websocket.hpp" />
+    <ClInclude Include="..\src\api\middleware\auth.hpp" />
+    <ClInclude Include="..\src\api\middleware\logging.hpp" />
+    <ClInclude Include="..\src\api\middleware\rate_limit.hpp" />
+    <ClInclude Include="..\src\api\middleware\security.hpp" />
+    <ClInclude Include="..\src\api\middleware\validation.hpp" />
+    <ClInclude Include="..\src\api\models\responses.hpp" />
+    <ClInclude Include="..\src\api\utils\broadcast_manager.hpp" />
+    <ClInclude Include="..\src\api\utils\chat_history.hpp" />
+    <ClInclude Include="..\src\api\utils\dispatcher_sync.hpp" />
+    <ClInclude Include="..\src\api\utils\system_info.hpp" />
+    <ClInclude Include="..\src\api\utils\validators.hpp" />
+    <ClInclude Include="..\src\api\utils\version_store.hpp" />
+    <ClInclude Include="..\src\api\utils\websocket_events.hpp" />
   </ItemGroup>
   <ItemGroup>
     <ClCompile Include="..\src\account\account_repository.cpp" />
@@ -426,6 +443,17 @@
     <ClCompile Include="..\src\utils\pugicast.cpp" />
     <ClCompile Include="..\src\utils\tools.cpp" />
     <ClCompile Include="..\src\utils\wildcardtree.cpp" />
+    <ClCompile Include="..\src\api\api.cpp" />
+    <ClCompile Include="..\src\api\endpoints\player.cpp">
+      <ObjectFileName>$(IntDir)api_player.obj</ObjectFileName>
+    </ClCompile>
+    <ClCompile Include="..\src\api\endpoints\server.cpp">
+      <ObjectFileName>$(IntDir)api_server.obj</ObjectFileName>
+    </ClCompile>
+    <ClCompile Include="..\src\api\utils\broadcast_manager.cpp" />
+    <ClCompile Include="..\src\api\utils\chat_history.cpp" />
+    <ClCompile Include="..\src\api\utils\system_info.cpp" />
+    <ClCompile Include="..\src\api\utils\version_store.cpp" />
   </ItemGroup>
 

As for the APK, I compiled it using this 9m 50s

name: Build Android APK

on:
  push:
    branches:
      - main

jobs:
  build:
    name: Build Release APK
    runs-on: ubuntu-latest

    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      - name: Set up Java
        uses: actions/setup-java@v4
        with:
          distribution: temurin
          java-version: '21'

      - name: Set up Flutter
        uses: subosito/flutter-action@v2
        with:
          flutter-version: '3.29.2'
          channel: stable
          cache: true

      - name: Install dependencies
        run: flutter pub get

      - name: Build release APK
        run: flutter build apk --release

      - name: Upload APK artifact
        uses: actions/upload-artifact@v4
        with:
          name: beats-monitor-release-apk
          path: build/app/outputs/flutter-apk/app-release.apk
          retention-days: 30

Can you open a PR adding the workflow to the app repository's GHA?

@github-actions

Copy link
Copy Markdown
Contributor

This PR is stale because it has been open 45 days with no activity.

@github-actions github-actions Bot added the Stale No activity label Jun 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Stale No activity

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants