feat(security): add internal secret handling and Redis integration for bot events

This commit is contained in:
2026-04-28 09:29:05 +02:00
parent c10a4d7e64
commit 1ab6532b0a
50 changed files with 951 additions and 214 deletions
+1
View File
@@ -19,6 +19,7 @@
<option value="$PROJECT_DIR$/modules/json" /> <option value="$PROJECT_DIR$/modules/json" />
<option value="$PROJECT_DIR$/modules/official-bots" /> <option value="$PROJECT_DIR$/modules/official-bots" />
<option value="$PROJECT_DIR$/modules/rule" /> <option value="$PROJECT_DIR$/modules/rule" />
<option value="$PROJECT_DIR$/modules/security" />
<option value="$PROJECT_DIR$/modules/store" /> <option value="$PROJECT_DIR$/modules/store" />
<option value="$PROJECT_DIR$/modules/ws" /> <option value="$PROJECT_DIR$/modules/ws" />
</set> </set>
+1 -1
View File
@@ -5,7 +5,7 @@
<option name="deprecationWarnings" value="true" /> <option name="deprecationWarnings" value="true" />
<option name="uncheckedWarnings" value="true" /> <option name="uncheckedWarnings" value="true" />
</profile> </profile>
<profile name="Gradle 2" modules="NowChessSystems.modules.account.integrationTest,NowChessSystems.modules.account.main,NowChessSystems.modules.account.native-test,NowChessSystems.modules.account.quarkus-generated-sources,NowChessSystems.modules.account.quarkus-test-generated-sources,NowChessSystems.modules.account.scoverage,NowChessSystems.modules.account.test,NowChessSystems.modules.bot-platform.integrationTest,NowChessSystems.modules.bot-platform.main,NowChessSystems.modules.bot-platform.native-test,NowChessSystems.modules.bot-platform.quarkus-generated-sources,NowChessSystems.modules.bot-platform.quarkus-test-generated-sources,NowChessSystems.modules.bot-platform.scoverage,NowChessSystems.modules.bot-platform.test,NowChessSystems.modules.bot.main,NowChessSystems.modules.bot.scoverage,NowChessSystems.modules.bot.test,NowChessSystems.modules.coordinator.integrationTest,NowChessSystems.modules.coordinator.main,NowChessSystems.modules.coordinator.native-test,NowChessSystems.modules.coordinator.quarkus-generated-sources,NowChessSystems.modules.coordinator.quarkus-test-generated-sources,NowChessSystems.modules.coordinator.scoverage,NowChessSystems.modules.coordinator.test,NowChessSystems.modules.core.integrationTest,NowChessSystems.modules.core.main,NowChessSystems.modules.core.native-test,NowChessSystems.modules.core.quarkus-generated-sources,NowChessSystems.modules.core.quarkus-test-generated-sources,NowChessSystems.modules.core.scoverage,NowChessSystems.modules.core.test,NowChessSystems.modules.io.integrationTest,NowChessSystems.modules.io.main,NowChessSystems.modules.io.native-test,NowChessSystems.modules.io.quarkus-generated-sources,NowChessSystems.modules.io.quarkus-test-generated-sources,NowChessSystems.modules.io.scoverage,NowChessSystems.modules.io.test,NowChessSystems.modules.json.main,NowChessSystems.modules.json.scoverage,NowChessSystems.modules.json.test,NowChessSystems.modules.official-bots.integrationTest,NowChessSystems.modules.official-bots.main,NowChessSystems.modules.official-bots.native-test,NowChessSystems.modules.official-bots.quarkus-generated-sources,NowChessSystems.modules.official-bots.quarkus-test-generated-sources,NowChessSystems.modules.official-bots.scoverage,NowChessSystems.modules.official-bots.test,NowChessSystems.modules.rule.integrationTest,NowChessSystems.modules.rule.main,NowChessSystems.modules.rule.native-test,NowChessSystems.modules.rule.quarkus-generated-sources,NowChessSystems.modules.rule.quarkus-test-generated-sources,NowChessSystems.modules.rule.scoverage,NowChessSystems.modules.rule.test,NowChessSystems.modules.store.integrationTest,NowChessSystems.modules.store.main,NowChessSystems.modules.store.native-test,NowChessSystems.modules.store.quarkus-generated-sources,NowChessSystems.modules.store.quarkus-test-generated-sources,NowChessSystems.modules.store.scoverage,NowChessSystems.modules.store.test,NowChessSystems.modules.ui.main,NowChessSystems.modules.ui.scoverage,NowChessSystems.modules.ui.test,NowChessSystems.modules.ws.integrationTest,NowChessSystems.modules.ws.main,NowChessSystems.modules.ws.native-test,NowChessSystems.modules.ws.quarkus-generated-sources,NowChessSystems.modules.ws.quarkus-test-generated-sources,NowChessSystems.modules.ws.scoverage,NowChessSystems.modules.ws.test"> <profile name="Gradle 2" modules="NowChessSystems.modules.account.integrationTest,NowChessSystems.modules.account.main,NowChessSystems.modules.account.native-test,NowChessSystems.modules.account.quarkus-generated-sources,NowChessSystems.modules.account.quarkus-test-generated-sources,NowChessSystems.modules.account.scoverage,NowChessSystems.modules.account.test,NowChessSystems.modules.bot-platform.integrationTest,NowChessSystems.modules.bot-platform.main,NowChessSystems.modules.bot-platform.native-test,NowChessSystems.modules.bot-platform.quarkus-generated-sources,NowChessSystems.modules.bot-platform.quarkus-test-generated-sources,NowChessSystems.modules.bot-platform.scoverage,NowChessSystems.modules.bot-platform.test,NowChessSystems.modules.bot.main,NowChessSystems.modules.bot.scoverage,NowChessSystems.modules.bot.test,NowChessSystems.modules.coordinator.integrationTest,NowChessSystems.modules.coordinator.main,NowChessSystems.modules.coordinator.native-test,NowChessSystems.modules.coordinator.quarkus-generated-sources,NowChessSystems.modules.coordinator.quarkus-test-generated-sources,NowChessSystems.modules.coordinator.scoverage,NowChessSystems.modules.coordinator.test,NowChessSystems.modules.core.integrationTest,NowChessSystems.modules.core.main,NowChessSystems.modules.core.native-test,NowChessSystems.modules.core.quarkus-generated-sources,NowChessSystems.modules.core.quarkus-test-generated-sources,NowChessSystems.modules.core.scoverage,NowChessSystems.modules.core.test,NowChessSystems.modules.io.integrationTest,NowChessSystems.modules.io.main,NowChessSystems.modules.io.native-test,NowChessSystems.modules.io.quarkus-generated-sources,NowChessSystems.modules.io.quarkus-test-generated-sources,NowChessSystems.modules.io.scoverage,NowChessSystems.modules.io.test,NowChessSystems.modules.json.main,NowChessSystems.modules.json.scoverage,NowChessSystems.modules.json.test,NowChessSystems.modules.official-bots.integrationTest,NowChessSystems.modules.official-bots.main,NowChessSystems.modules.official-bots.native-test,NowChessSystems.modules.official-bots.quarkus-generated-sources,NowChessSystems.modules.official-bots.quarkus-test-generated-sources,NowChessSystems.modules.official-bots.scoverage,NowChessSystems.modules.official-bots.test,NowChessSystems.modules.rule.integrationTest,NowChessSystems.modules.rule.main,NowChessSystems.modules.rule.native-test,NowChessSystems.modules.rule.quarkus-generated-sources,NowChessSystems.modules.rule.quarkus-test-generated-sources,NowChessSystems.modules.rule.scoverage,NowChessSystems.modules.rule.test,NowChessSystems.modules.security.main,NowChessSystems.modules.security.scoverage,NowChessSystems.modules.security.test,NowChessSystems.modules.store.integrationTest,NowChessSystems.modules.store.main,NowChessSystems.modules.store.native-test,NowChessSystems.modules.store.quarkus-generated-sources,NowChessSystems.modules.store.quarkus-test-generated-sources,NowChessSystems.modules.store.scoverage,NowChessSystems.modules.store.test,NowChessSystems.modules.ui.main,NowChessSystems.modules.ui.scoverage,NowChessSystems.modules.ui.test,NowChessSystems.modules.ws.integrationTest,NowChessSystems.modules.ws.main,NowChessSystems.modules.ws.native-test,NowChessSystems.modules.ws.quarkus-generated-sources,NowChessSystems.modules.ws.quarkus-test-generated-sources,NowChessSystems.modules.ws.scoverage,NowChessSystems.modules.ws.test">
<option name="deprecationWarnings" value="true" /> <option name="deprecationWarnings" value="true" />
<option name="uncheckedWarnings" value="true" /> <option name="uncheckedWarnings" value="true" />
<parameters> <parameters>
@@ -0,0 +1,334 @@
# Plan: Add Coordinator Microservice
## Context
NowChess scales `core` horizontally via shared Redis but lacks:
- **Instance visibility**: no way to list running cores or their load
- **Load balancing**: games land randomly on cores; no rebalancing
- **Failover**: dead cores orphan subscriptions; bullet chess requires <1s recovery
- **Auto-scaling**: manual ops to add/remove cores
- **Cache management**: no eviction of stale games from core memory
Bullet chess games run on move timings of <3s. 30s failover = game lost on clock. Target: **<300ms failover**.
---
## Architecture: Sub-1s Failover
### Why Not Polling/TTL
- TTL expiry: minimum 10-30s detection
- HTTP polling 3x failure: 30s minimum
- **gRPC streaming TCP drop: 50-200ms** — use this as primary
### Primary: gRPC Bidirectional Streaming
- Core opens a **persistent bidirectional stream** (`CoreHeartbeatStream`) to coordinator on startup
- Core sends heartbeat frames every **200ms**
- Core crash = TCP RST/FIN → coordinator stream error in **~50-200ms**
- Stream also carries metadata updates (subscription count changes) in real-time
### Fallback: Redis Heartbeat + K8s Watch
- Redis heartbeat key `{prefix}:instances:{instanceId}` with **5s TTL**, refreshed every **2s**
- K8s pod watch via Kubernetes Java client (event-driven; handles pod eviction/OOMKill)
- Fallback covers: network partition (TCP stays up but core is zombie), coordinator restart gap
---
## Design
### 1. Module: `modules/coordinator`
**Language**: Scala 3.5.1, Quarkus REST + gRPC
**Ports**: HTTP 8086, gRPC 9086
**Dependencies**: Redisson, Kubernetes Java client, Quarkus gRPC
**Persistence**: None (all state in Redis)
---
### 2. Instance Registry
**Redis schema**:
```
{prefix}:instances:{instanceId}
- TTL: 5s (refreshed by core every 2s via background task)
- Value: JSON
{
"instanceId": "core-abc123",
"hostname": "core-pod-3",
"httpPort": 8080,
"grpcPort": 9080,
"subscriptionCount": 147,
"localCacheSize": 147,
"lastHeartbeat": "2026-04-26T10:15:30.123Z"
}
{prefix}:instance:{instanceId}:games
- Type: Redis Set (no TTL — managed explicitly)
- Members: all gameIds currently subscribed on this instance
```
**Core changes** (new `InstanceHeartbeatService` bean in `modules/core`):
- `@PostConstruct`: generate stable `instanceId` (hostname + random suffix); open gRPC stream to coordinator; publish Redis heartbeat; register in `{prefix}:instances:{instanceId}`
- Every 200ms: send heartbeat frame on gRPC stream (carries `subscriptionCount`)
- Every 2s: refresh Redis heartbeat bucket TTL
- `subscribeGame(gameId)`: `SADD {prefix}:instance:{instanceId}:games gameId`
- `unsubscribeGame(gameId)` / `evictGame(gameId)`: `SREM {prefix}:instance:{instanceId}:games gameId`
- `@PreDestroy`: delete Redis key + games set; close gRPC stream (clean shutdown)
---
### 3. Health Monitoring (3 signals, primary fast)
| Signal | Mechanism | Detection time | Role |
|--------|-----------|---------------|------|
| **gRPC stream drop** | TCP RST/FIN on bidirectional stream | 50200ms | Primary |
| **Redis heartbeat expiry** | `{prefix}:instances:{instanceId}` TTL=5s | 57s | Fallback |
| **K8s pod watch** | `CoreV1Api.listNamespacedPod` watch stream | ~instant (pod events) | Fallback |
**Dead decision**:
- gRPC stream drops → **immediate failover** (no confirmation needed; games must recover fast)
- Redis heartbeat expires (gRPC still up) → verify with single HTTP `/q/health` call → if fail: failover
- K8s pod NotReady (gRPC still up) → failover
---
### 4. Failover Protocol (<300ms target)
```
T+0ms Core JVM crashes / network drops
T+50ms Coordinator: gRPC stream error received
T+52ms SMEMBERS {prefix}:instance:{instanceId}:games → list of orphaned gameIds
T+55ms Distribute gameIds across healthy cores (least-loaded first)
T+60ms BatchResubscribeGames gRPC call(s) fire to healthy core(s)
T+150ms Healthy cores resubscribed; Redis s2c topics live again
T+200ms WebSocket clients reconnect; receive GameFullEventDto on CONNECTED
```
**Failover steps** (coordinator `FailoverService`):
1. On stream drop for `instanceId`:
a. Mark instance DEAD in local map
b. `SMEMBERS {prefix}:instance:{instanceId}:games`
c. Group gameIds into batches per target core (round-robin by load)
d. For each target core: call `BatchResubscribeGames(gameIds)`
e. Each target core: calls `subscribeGame(gameId)` for each (loads from Redis if not in local cache)
f. `DEL {prefix}:instance:{instanceId}:games` (cleanup)
2. Log failover event with count of games migrated + latency
---
### 5. Load Rebalancing
**Thresholds** (both must be evaluated):
1. **Absolute**: any core > 500 games → rebalance
2. **Relative**: max load > mean × 1.2 AND max - min > 50 games → rebalance
**Algorithm** (runs every 30s, min 60s between actual rebalances):
1. Read all `{prefix}:instances:*` keys → load map
2. Identify overloaded cores (exceed either threshold)
3. For each overloaded core: pick `excess = load - targetLoad` games
4. Assign excess games to underloaded cores
5. Call `UnsubscribeGames(gameIds)` on overloaded core
6. Call `BatchResubscribeGames(gameIds)` on target core
7. Overloaded core: `SREM` each game from its set
8. Target core: `SADD` each game to its set on subscribe
---
### 6. Auto-Scaling
**Metric**: avg `subscriptionCount` across all cores
**Actions**:
- avg > `scale-up-threshold` (80% of max): patch `nowchess-core` Argo Rollout `spec.replicas += 1`
- avg < `scale-down-threshold` (30% of max) AND `replicas > min-replicas`: drain one core then scale down
- Backoff: min 2-minute interval between scale events
**Argo Rollouts API**:
- CRD: `argoproj.io/v1alpha1`, Kind: `Rollout`, resource: `rollouts`
- Scale via Fabric8 `GenericKubernetesResource` patch on `spec.replicas`
- No StatefulSet — Argo Rollout owns pod lifecycle (canary/blue-green strategies respected)
- Pod watch filter: label selector `app=nowchess-core` (Rollout sets this; `rollouts-pod-template-hash` is Argo's equivalent of `pod-template-hash`)
**Drain before scale-down**:
1. Pick least-loaded core
2. Migrate all its games to other cores via `BatchResubscribeGames`
3. Call `DrainInstance(instanceId)` on that core (sets it to reject new subscriptions)
4. After drain confirmed: patch Rollout `spec.replicas -= 1`
---
### 7. Cache Eviction
**Trigger**: coordinator scans `{prefix}:game:entry:*` every 10 minutes
**Policy**: if `now - lastUpdated > 45min` AND `gameId` in any instance's games set → call `EvictGame`
**Effect**: core removes game from `localEngines` and `unsubscribeGame`, `SREM` from instance set
---
### 8. Proto: `coordinator_service.proto`
```proto
syntax = "proto3";
package de.nowchess.coordinator;
service CoordinatorService {
// Core → Coordinator: bidirectional stream for liveness
rpc HeartbeatStream(stream HeartbeatFrame) returns (stream CoordinatorCommand);
// Coordinator → Core: batch resubscribe after failover or rebalance
rpc BatchResubscribeGames(BatchResubscribeRequest) returns (BatchResubscribeResponse);
// Coordinator → Core: unsubscribe games (rebalance source)
rpc UnsubscribeGames(UnsubscribeGamesRequest) returns (UnsubscribeGamesResponse);
// Coordinator → Core: evict idle games from local cache
rpc EvictGames(EvictGamesRequest) returns (EvictGamesResponse);
// Coordinator → Core: drain instance before scale-down
rpc DrainInstance(DrainInstanceRequest) returns (DrainInstanceResponse);
}
message HeartbeatFrame {
string instanceId = 1;
string hostname = 2;
int32 httpPort = 3;
int32 grpcPort = 4;
int32 subscriptionCount = 5;
int32 localCacheSize = 6;
int64 timestampMillis = 7;
}
message CoordinatorCommand {
// Future: coordinator can push commands back (e.g., "start draining")
string type = 1;
string payload = 2;
}
message BatchResubscribeRequest {
repeated string gameIds = 1;
}
message BatchResubscribeResponse {
int32 subscribedCount = 1;
repeated string failedGameIds = 2;
}
message UnsubscribeGamesRequest {
repeated string gameIds = 1;
}
message UnsubscribeGamesResponse {
int32 unsubscribedCount = 1;
}
message EvictGamesRequest {
repeated string gameIds = 1;
}
message EvictGamesResponse {
int32 evictedCount = 1;
}
message DrainInstanceRequest {}
message DrainInstanceResponse {
int32 gamesMigrated = 0;
}
```
---
### 9. Coordinator REST API (internal)
- `GET /api/coordinator/instances` — all cores with load, health state
- `GET /api/coordinator/metrics` — load distribution, rebalance history
- `POST /api/coordinator/rebalance` — manual rebalance trigger
- `POST /api/coordinator/failover/{instanceId}` — manual failover
- `POST /api/coordinator/scale-up` / `scale-down` — manual scaling
---
### 10. Configuration
**`modules/coordinator/src/main/resources/application.yml`**:
```yaml
quarkus.application.name: nowchess-coordinator
quarkus.http.port: 8086
quarkus.grpc.server.port: 9086
nowchess.coordinator.max-games-per-core: 500
nowchess.coordinator.max-deviation-percent: 20
nowchess.coordinator.rebalance-interval: 30s
nowchess.coordinator.rebalance-min-interval: 60s
nowchess.coordinator.heartbeat-ttl: 5s
nowchess.coordinator.stream-heartbeat-interval: 200ms
nowchess.coordinator.cache-eviction-interval: 10m
nowchess.coordinator.game-idle-threshold: 45m
nowchess.coordinator.auto-scale-enabled: false
nowchess.coordinator.scale-up-threshold: 0.8
nowchess.coordinator.scale-down-threshold: 0.3
nowchess.coordinator.scale-min-replicas: 2
nowchess.coordinator.scale-max-replicas: 10
nowchess.coordinator.k8s-namespace: default
nowchess.coordinator.k8s-rollout-name: nowchess-core
nowchess.coordinator.k8s-rollout-label-selector: app=nowchess-core
quarkus.kubernetes-client.trust-certs: true
```
**Core `application.yml` additions**:
```yaml
nowchess.coordinator.host: localhost
nowchess.coordinator.grpc-port: 9086
nowchess.coordinator.stream-heartbeat-interval: 200ms
nowchess.coordinator.redis-heartbeat-interval: 2s
nowchess.coordinator.instance-id: ${HOSTNAME:local}-${quarkus.uuid}
```
---
### 11. Files to Create / Modify
**New — `modules/coordinator/`**:
```
build.gradle.kts
src/main/proto/coordinator_service.proto
src/main/resources/application.yml
src/main/scala/de/nowchess/coordinator/
resource/CoordinatorResource.scala # REST endpoints
service/InstanceRegistry.scala # Redis instance list + in-memory map
service/HealthMonitor.scala # gRPC stream watcher + Redis TTL + k8s watch
service/FailoverService.scala # dead core → BatchResubscribe
service/LoadBalancer.scala # rebalance logic
service/AutoScaler.scala # k8s StatefulSet scaling
service/CacheEvictionManager.scala # idle game eviction
grpc/CoordinatorGrpcServer.scala # CoordinatorService gRPC impl (for HeartbeatStream)
```
**Modify — `modules/core/`**:
- `build.gradle.kts` — add `coordinator_service.proto` stub, keep grpc dep
- `src/main/proto/coordinator_service.proto` — copy (or symlink) proto for stub generation
- `src/main/scala/de/nowchess/chess/redis/GameRedisSubscriberManager.scala``SADD`/`SREM` on subscribe/unsubscribe + implement `BatchResubscribeGames`, `UnsubscribeGames`, `EvictGames`, `DrainInstance` gRPC handlers
- `src/main/scala/de/nowchess/chess/` — new `InstanceHeartbeatService.scala` (startup, gRPC stream, Redis TTL refresh)
- `src/main/resources/application.yml` — coordinator connection config
**Modify — root**:
- `settings.gradle.kts` — add `include("modules/coordinator")`
---
## Verification
1. `./compile` — coordinator and core compile cleanly
2. **Stream detection**: start core + coordinator; kill core JVM (`kill -9`); coordinator logs failover within 300ms
3. **Game continuity**: active game on killed core; WebSocket client reconnects and receives game state
4. **Rebalance**: create 600 games on core-1 (2-core setup); coordinator rebalances ~100 to core-2
5. **Fallback**: disconnect gRPC stream manually but keep core alive; Redis TTL fallback triggers within 7s
6. **Cache eviction**: create idle game; coordinator calls `EvictGames` after 45min idle
7. **REST metrics**: `curl localhost:8086/api/coordinator/metrics` returns per-core load + health
8. **Restart recovery**: restart coordinator; gRPC streams re-establish from cores; state rebuilt from Redis
---
## Dependencies (new)
- `io.fabric8:kubernetes-client:6.13.0` (Fabric8 k8s client — handles Argo `Rollout` CRD via `GenericKubernetesResource`; no Argo Java SDK needed)
- Redisson — already in core, reuse via shared config
- Quarkus gRPC — already in core, reuse
+3
View File
@@ -45,6 +45,8 @@ dependencies {
} }
} }
implementation(project(":modules:security"))
implementation(platform("${quarkusPlatformGroupId}:${quarkusPlatformArtifactId}:${quarkusPlatformVersion}")) implementation(platform("${quarkusPlatformGroupId}:${quarkusPlatformArtifactId}:${quarkusPlatformVersion}"))
implementation("io.quarkus:quarkus-rest") implementation("io.quarkus:quarkus-rest")
implementation("io.quarkus:quarkus-rest-jackson") implementation("io.quarkus:quarkus-rest-jackson")
@@ -60,6 +62,7 @@ dependencies {
implementation("io.quarkus:quarkus-micrometer") implementation("io.quarkus:quarkus-micrometer")
implementation("io.quarkus:quarkus-smallrye-openapi") implementation("io.quarkus:quarkus-smallrye-openapi")
implementation("com.fasterxml.jackson.module:jackson-module-scala_3:${versions["JACKSON_SCALA"]!!}") implementation("com.fasterxml.jackson.module:jackson-module-scala_3:${versions["JACKSON_SCALA"]!!}")
implementation("org.redisson:redisson:${versions["REDISSON"]!!}")
testImplementation(platform("org.junit:junit-bom:5.13.4")) testImplementation(platform("org.junit:junit-bom:5.13.4"))
testImplementation("org.junit.jupiter:junit-jupiter") testImplementation("org.junit.jupiter:junit-jupiter")
@@ -6,8 +6,6 @@ quarkus:
rest-client: rest-client:
core-service: core-service:
url: http://localhost:8080 url: http://localhost:8080
bot-platform-service:
url: http://localhost:8087
smallrye-openapi: smallrye-openapi:
info-title: NowChess Account Service info-title: NowChess Account Service
path: /openapi path: /openapi
@@ -24,13 +22,24 @@ quarkus:
schema-management: schema-management:
strategy: drop-and-create strategy: drop-and-create
nowchess:
redis:
host: localhost
port: 6379
prefix: nowchess
internal:
secret: ${INTERNAL_SECRET}
"%deployed": "%deployed":
quarkus: quarkus:
rest-client: rest-client:
core-service: core-service:
url: ${CORE_SERVICE_URL} url: ${CORE_SERVICE_URL}
bot-platform-service: nowchess:
url: ${BOT_PLATFORM_SERVICE_URL} redis:
host: ${REDIS_HOST:localhost}
port: ${REDIS_PORT:6379}
prefix: ${REDIS_PREFIX:nowchess}
datasource: datasource:
db-kind: postgresql db-kind: postgresql
username: ${DB_USER} username: ${DB_USER}
@@ -1,20 +0,0 @@
package de.nowchess.account.client
import jakarta.ws.rs.*
import jakarta.ws.rs.core.MediaType
import org.eclipse.microprofile.rest.client.inject.RegisterRestClient
@Path("/api/bot")
@RegisterRestClient(configKey = "bot-platform-service")
trait BotPlatformClient:
@POST
@Path("/game/{gameId}/assign")
@Produces(Array(MediaType.APPLICATION_JSON))
def assignBot(
@PathParam("gameId") gameId: String,
@QueryParam("botId") botId: String,
@QueryParam("difficulty") difficulty: Int,
@QueryParam("playingAs") playingAs: String,
@QueryParam("botAccountId") botAccountId: String,
): Unit
@@ -1,7 +1,9 @@
package de.nowchess.account.client package de.nowchess.account.client
import de.nowchess.security.InternalSecretClientFilter
import jakarta.ws.rs.* import jakarta.ws.rs.*
import jakarta.ws.rs.core.MediaType import jakarta.ws.rs.core.MediaType
import org.eclipse.microprofile.rest.client.annotation.RegisterProvider
import org.eclipse.microprofile.rest.client.inject.RegisterRestClient import org.eclipse.microprofile.rest.client.inject.RegisterRestClient
case class CorePlayerInfo(id: String, displayName: String) case class CorePlayerInfo(id: String, displayName: String)
@@ -16,6 +18,7 @@ case class CoreGameResponse(gameId: String)
@Path("/api/board/game") @Path("/api/board/game")
@RegisterRestClient(configKey = "core-service") @RegisterRestClient(configKey = "core-service")
@RegisterProvider(classOf[InternalSecretClientFilter])
trait CoreGameClient: trait CoreGameClient:
@POST @POST
@@ -0,0 +1,18 @@
package de.nowchess.account.config
import jakarta.enterprise.context.ApplicationScoped
import org.eclipse.microprofile.config.inject.ConfigProperty
import scala.compiletime.uninitialized
@ApplicationScoped
class RedisConfig:
// scalafix:off DisableSyntax.var
@ConfigProperty(name = "nowchess.redis.host", defaultValue = "localhost")
var host: String = uninitialized
@ConfigProperty(name = "nowchess.redis.port", defaultValue = "6379")
var port: Int = uninitialized
@ConfigProperty(name = "nowchess.redis.prefix", defaultValue = "nowchess")
var prefix: String = uninitialized
// scalafix:on DisableSyntax.var
@@ -0,0 +1,33 @@
package de.nowchess.account.config
import jakarta.annotation.PreDestroy
import jakarta.enterprise.context.ApplicationScoped
import jakarta.enterprise.inject.Produces
import jakarta.inject.Inject
import org.redisson.Redisson
import org.redisson.api.RedissonClient
import org.redisson.config.Config
import scala.compiletime.uninitialized
@ApplicationScoped
class RedissonProducer:
// scalafix:off DisableSyntax.var
@Inject var redisConfig: RedisConfig = uninitialized
private var clientOpt: Option[RedissonClient] = None
// scalafix:on DisableSyntax.var
@Produces
@ApplicationScoped
def produceRedissonClient(): RedissonClient =
val config = new Config()
config.useSingleServer().setAddress(s"redis://${redisConfig.host}:${redisConfig.port}")
config.useSingleServer().setConnectionMinimumIdleSize(1)
config.useSingleServer().setConnectTimeout(500)
val client = Redisson.create(config)
clientOpt = Some(client)
client
@PreDestroy
def shutdown(): Unit =
clientOpt.foreach(_.shutdown())
@@ -47,6 +47,14 @@ class ChallengeResource:
val userId = UUID.fromString(jwt.getSubject) val userId = UUID.fromString(jwt.getSubject)
Response.ok(challengeService.listForUser(userId)).build() Response.ok(challengeService.listForUser(userId)).build()
@GET
@Path("/{id}")
def get(@PathParam("id") id: UUID): Response =
val userId = UUID.fromString(jwt.getSubject)
challengeService.findById(id, userId) match
case Right(challenge) => Response.ok(challengeService.toDto(challenge)).build()
case Left(error) => errorResponse(error)
@POST @POST
@Path("/{id}/accept") @Path("/{id}/accept")
def accept(@PathParam("id") id: UUID): Response = def accept(@PathParam("id") id: UUID): Response =
@@ -1,8 +1,8 @@
package de.nowchess.account.resource package de.nowchess.account.resource
import de.nowchess.account.client.{BotPlatformClient, CoreCreateGameRequest, CoreGameClient, CorePlayerInfo} import de.nowchess.account.client.{CoreCreateGameRequest, CoreGameClient, CorePlayerInfo}
import de.nowchess.account.dto.{ErrorDto, OfficialChallengeResponse} import de.nowchess.account.dto.{ErrorDto, OfficialChallengeResponse}
import de.nowchess.account.service.AccountService import de.nowchess.account.service.{AccountService, BotEventPublisher}
import jakarta.annotation.security.RolesAllowed import jakarta.annotation.security.RolesAllowed
import jakarta.enterprise.context.ApplicationScoped import jakarta.enterprise.context.ApplicationScoped
import jakarta.inject.Inject import jakarta.inject.Inject
@@ -23,19 +23,13 @@ import java.util.UUID
class OfficialChallengeResource: class OfficialChallengeResource:
// scalafix:off DisableSyntax.var // scalafix:off DisableSyntax.var
@Inject @Inject var accountService: AccountService = uninitialized
var accountService: AccountService = uninitialized @Inject var jwt: JsonWebToken = uninitialized
@Inject var botEventPublisher: BotEventPublisher = uninitialized
@Inject
var jwt: JsonWebToken = uninitialized
@Inject @Inject
@RestClient @RestClient
var coreGameClient: CoreGameClient = uninitialized var coreGameClient: CoreGameClient = uninitialized
@Inject
@RestClient
var botPlatformClient: BotPlatformClient = uninitialized
// scalafix:on // scalafix:on
private val log = Logger.getLogger(classOf[OfficialChallengeResource]) private val log = Logger.getLogger(classOf[OfficialChallengeResource])
@@ -56,10 +50,12 @@ class OfficialChallengeResource:
val playerColor = Option(color).map(_.toLowerCase).getOrElse("random") match val playerColor = Option(color).map(_.toLowerCase).getOrElse("random") match
case "white" | "black" | "random" => Option(color).map(_.toLowerCase).getOrElse("random") case "white" | "black" | "random" => Option(color).map(_.toLowerCase).getOrElse("random")
case other => case other =>
return Response.status(Response.Status.BAD_REQUEST).entity(ErrorDto(s"Invalid color: $other. Must be white, black or random")).build() return Response
.status(Response.Status.BAD_REQUEST)
.entity(ErrorDto(s"Invalid color: $other. Must be white, black or random"))
.build()
val userId = UUID.fromString(jwt.getSubject) val userId = UUID.fromString(jwt.getSubject)
val botOpt = accountService.getOfficialBotAccounts().find(_.name == botName) val botOpt = accountService.getOfficialBotAccounts().find(_.name == botName)
val userOpt = accountService.findById(userId) val userOpt = accountService.findById(userId)
@@ -86,9 +82,6 @@ class OfficialChallengeResource:
case Left(err) => case Left(err) =>
Response.status(Response.Status.INTERNAL_SERVER_ERROR).entity(ErrorDto(err)).build() Response.status(Response.Status.INTERNAL_SERVER_ERROR).entity(ErrorDto(err)).build()
case Right(id) => case Right(id) =>
try botPlatformClient.assignBot(id, botName, difficulty, botColor, bot.id.toString) try botEventPublisher.publishGameStart(bot.name, id, botColor, difficulty, bot.id.toString)
catch case ex: Exception => log.warnf(ex, "Failed to notify bot-platform for game %s", id) catch case ex: Exception => log.warnf(ex, "Failed to notify bot for game %s", id)
Response Response.status(Response.Status.CREATED).entity(OfficialChallengeResponse(id, botName, difficulty)).build()
.status(Response.Status.CREATED)
.entity(OfficialChallengeResponse(id, botName, difficulty))
.build()
@@ -0,0 +1,30 @@
package de.nowchess.account.service
import de.nowchess.account.config.RedisConfig
import jakarta.enterprise.context.ApplicationScoped
import jakarta.inject.Inject
import org.redisson.api.RedissonClient
import scala.compiletime.uninitialized
@ApplicationScoped
class BotEventPublisher:
// scalafix:off DisableSyntax.var
@Inject var redisson: RedissonClient = uninitialized
@Inject var redisConfig: RedisConfig = uninitialized
// scalafix:on DisableSyntax.var
def publishGameStart(botId: String, gameId: String, playingAs: String, difficulty: Int, botAccountId: String): Unit =
val event = s"""{"type":"gameStart","gameId":"$gameId","playingAs":"$playingAs","difficulty":$difficulty,"botAccountId":"$botAccountId"}"""
redisson.getTopic(s"${redisConfig.prefix}:bot:$botId:events").publish(event)
()
def publishChallengeCreated(destUserId: String, challengeId: String, challengerName: String): Unit =
val event = s"""{"type":"challengeCreated","challengeId":"$challengeId","challengerName":"$challengerName"}"""
redisson.getTopic(s"${redisConfig.prefix}:user:$destUserId:events").publish(event)
()
def publishChallengeAccepted(challengerId: String, challengeId: String, gameId: String): Unit =
val event = s"""{"type":"challengeAccepted","challengeId":"$challengeId","gameId":"$gameId"}"""
redisson.getTopic(s"${redisConfig.prefix}:user:$challengerId:events").publish(event)
()
@@ -1,7 +1,6 @@
package de.nowchess.account.service package de.nowchess.account.service
import de.nowchess.account.client.{ import de.nowchess.account.client.{
BotPlatformClient,
CoreCreateGameRequest, CoreCreateGameRequest,
CoreGameClient, CoreGameClient,
CoreGameResponse, CoreGameResponse,
@@ -26,6 +25,7 @@ import org.eclipse.microprofile.rest.client.inject.RestClient
import org.jboss.logging.Logger import org.jboss.logging.Logger
import scala.compiletime.uninitialized import scala.compiletime.uninitialized
import java.time.Instant import java.time.Instant
import java.time.temporal.ChronoUnit import java.time.temporal.ChronoUnit
import java.util.UUID import java.util.UUID
@@ -47,8 +47,7 @@ class ChallengeService:
var coreGameClient: CoreGameClient = uninitialized var coreGameClient: CoreGameClient = uninitialized
@Inject @Inject
@RestClient var botEventPublisher: BotEventPublisher = uninitialized
var botPlatformClient: BotPlatformClient = uninitialized
// scalafix:on // scalafix:on
@Transactional @Transactional
@@ -75,6 +74,8 @@ class ChallengeService:
challenge.createdAt = Instant.now() challenge.createdAt = Instant.now()
challenge.expiresAt = Instant.now().plus(24, ChronoUnit.HOURS) challenge.expiresAt = Instant.now().plus(24, ChronoUnit.HOURS)
challengeRepository.persist(challenge) challengeRepository.persist(challenge)
try botEventPublisher.publishChallengeCreated(destUser.id.toString, challenge.id.toString, challenger.username)
catch case ex: Exception => log.warnf(ex, "Failed to notify dest user for challenge %s", challenge.id)
challenge challenge
@Transactional @Transactional
@@ -89,6 +90,8 @@ class ChallengeService:
challenge.gameId = gameId challenge.gameId = gameId
challengeRepository.merge(challenge) challengeRepository.merge(challenge)
notifyBotIfNeeded(challenge, gameId) notifyBotIfNeeded(challenge, gameId)
try botEventPublisher.publishChallengeAccepted(challenge.challenger.id.toString, challenge.id.toString, gameId)
catch case ex: Exception => log.warnf(ex, "Failed to notify challenger for game %s", gameId)
challenge challenge
@Transactional @Transactional
@@ -115,6 +118,16 @@ class ChallengeService:
challengeRepository.merge(challenge) challengeRepository.merge(challenge)
challenge challenge
def findById(challengeId: UUID, userId: UUID): Either[ChallengeError, Challenge] =
for
challenge <- challengeRepository.findById(challengeId).toRight(ChallengeError.ChallengeNotFound)
_ <- Either.cond(
challenge.challenger.id == userId || challenge.destUser.id == userId,
(),
ChallengeError.NotAuthorized,
)
yield challenge
def listForUser(userId: UUID): ChallengeListDto = def listForUser(userId: UUID): ChallengeListDto =
val incoming = challengeRepository.findActiveByDestUserId(userId).map(toDto) val incoming = challengeRepository.findActiveByDestUserId(userId).map(toDto)
val outgoing = challengeRepository.findActiveByChallengerId(userId).map(toDto) val outgoing = challengeRepository.findActiveByChallengerId(userId).map(toDto)
@@ -125,8 +138,8 @@ class ChallengeService:
List(challenge.challenger, challenge.destUser).foreach { user => List(challenge.challenger, challenge.destUser).foreach { user =>
user.getBotAccounts.headOption.foreach { bot => user.getBotAccounts.headOption.foreach { bot =>
val playingAs = if white.id == user.id.toString then "white" else "black" val playingAs = if white.id == user.id.toString then "white" else "black"
try botPlatformClient.assignBot(gameId, bot.name, 1400, playingAs, bot.id.toString) try botEventPublisher.publishGameStart(bot.id.toString, gameId, playingAs, 1400, bot.id.toString)
catch case ex: Exception => log.warnf(ex, "Failed to notify bot-platform for game %s", gameId) catch case ex: Exception => log.warnf(ex, "Failed to notify bot for game %s", gameId)
} }
} }
@@ -1,3 +1,5 @@
package de.nowchess.api.dto package de.nowchess.api.dto
final case class PlayerInfoDto(id: String, displayName: String) import de.nowchess.api.player.PlayerType
final case class PlayerInfoDto(id: String, displayName: String, playerType: PlayerType)
@@ -23,4 +23,10 @@ object PlayerId:
final case class PlayerInfo( final case class PlayerInfo(
id: PlayerId, id: PlayerId,
displayName: String, displayName: String,
playerType: PlayerType = PlayerType.Human,
) )
enum PlayerType:
case Human
case OfficialBot
case Bot
@@ -1,8 +0,0 @@
package de.nowchess.botplatform.registry
case class BotGameInfo(
botId: String,
difficulty: Int,
playingAs: String,
botAccountId: String,
)
@@ -1,29 +1,44 @@
package de.nowchess.botplatform.registry package de.nowchess.botplatform.registry
import de.nowchess.botplatform.config.RedisConfig
import io.smallrye.mutiny.subscription.MultiEmitter import io.smallrye.mutiny.subscription.MultiEmitter
import jakarta.enterprise.context.ApplicationScoped import jakarta.enterprise.context.ApplicationScoped
import jakarta.inject.Inject
import org.redisson.api.RedissonClient
import org.redisson.api.listener.MessageListener
import scala.compiletime.uninitialized
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
@ApplicationScoped @ApplicationScoped
class BotRegistry: class BotRegistry:
private val connections = ConcurrentHashMap[String, MultiEmitter[?]]() // scalafix:off DisableSyntax.var
@Inject var redisson: RedissonClient = uninitialized
@Inject var redisConfig: RedisConfig = uninitialized
// scalafix:on DisableSyntax.var
private val connections = ConcurrentHashMap[String, (MultiEmitter[?], Int)]()
def register(botId: String, emitter: MultiEmitter[? >: String]): Unit = def register(botId: String, emitter: MultiEmitter[? >: String]): Unit =
connections.put(botId, emitter) val topic = redisson.getTopic(s"${redisConfig.prefix}:bot:$botId:events")
val listenerId = topic.addListener(
classOf[String],
new MessageListener[String]:
def onMessage(channel: CharSequence, msg: String): Unit =
emitter.asInstanceOf[MultiEmitter[String]].emit(msg),
)
connections.put(botId, (emitter, listenerId))
() ()
def unregister(botId: String): Unit = def unregister(botId: String): Unit =
connections.remove(botId) Option(connections.remove(botId)).foreach { (_, listenerId) =>
() redisson.getTopic(s"${redisConfig.prefix}:bot:$botId:events").removeListener(listenerId)
}
def dispatch(botId: String, event: String): Boolean = def dispatch(botId: String, event: String): Unit =
Option(connections.get(botId)) match redisson.getTopic(s"${redisConfig.prefix}:bot:$botId:events").publish(event)
case Some(emitter) => ()
emitter.asInstanceOf[MultiEmitter[String]].emit(event)
true
case None => false
def registeredBots: List[String] = def registeredBots: List[String] =
import scala.jdk.CollectionConverters.* import scala.jdk.CollectionConverters.*
@@ -1,7 +1,7 @@
package de.nowchess.botplatform.resource package de.nowchess.botplatform.resource
import de.nowchess.botplatform.registry.{BotGameInfo, BotRegistry} import de.nowchess.botplatform.config.RedisConfig
import de.nowchess.botplatform.service.GameBotMonitor import de.nowchess.botplatform.registry.BotRegistry
import io.smallrye.mutiny.Multi import io.smallrye.mutiny.Multi
import jakarta.annotation.security.RolesAllowed import jakarta.annotation.security.RolesAllowed
import jakarta.enterprise.context.ApplicationScoped import jakarta.enterprise.context.ApplicationScoped
@@ -9,6 +9,8 @@ import jakarta.inject.Inject
import jakarta.ws.rs.* import jakarta.ws.rs.*
import jakarta.ws.rs.core.{MediaType, Response} import jakarta.ws.rs.core.{MediaType, Response}
import org.eclipse.microprofile.jwt.JsonWebToken import org.eclipse.microprofile.jwt.JsonWebToken
import org.redisson.api.RedissonClient
import org.redisson.api.listener.MessageListener
import scala.compiletime.uninitialized import scala.compiletime.uninitialized
@Path("/api/bot") @Path("/api/bot")
@@ -17,14 +19,10 @@ import scala.compiletime.uninitialized
class BotEventResource: class BotEventResource:
// scalafix:off DisableSyntax.var // scalafix:off DisableSyntax.var
@Inject @Inject var registry: BotRegistry = uninitialized
var registry: BotRegistry = uninitialized @Inject var jwt: JsonWebToken = uninitialized
@Inject var redisson: RedissonClient = uninitialized
@Inject @Inject var redisConfig: RedisConfig = uninitialized
var jwt: JsonWebToken = uninitialized
@Inject
var gameMonitor: GameBotMonitor = uninitialized
// scalafix:on DisableSyntax.var // scalafix:on DisableSyntax.var
@GET @GET
@@ -34,7 +32,7 @@ class BotEventResource:
val tokenType = Option(jwt.getClaim[AnyRef]("type")).map(_.toString).getOrElse("") val tokenType = Option(jwt.getClaim[AnyRef]("type")).map(_.toString).getOrElse("")
val subject = Option(jwt.getSubject).getOrElse("") val subject = Option(jwt.getSubject).getOrElse("")
if tokenType != "bot" || subject != botId then if tokenType != "bot" || subject != botId then
Multi.createFrom().failure(new jakarta.ws.rs.ForbiddenException("Not authorized for this bot")) Multi.createFrom().failure(new ForbiddenException("Not authorized for this bot"))
else else
Multi.createFrom().emitter[String] { emitter => Multi.createFrom().emitter[String] { emitter =>
registry.register(botId, emitter) registry.register(botId, emitter)
@@ -46,22 +44,24 @@ class BotEventResource:
@Produces(Array(MediaType.SERVER_SENT_EVENTS)) @Produces(Array(MediaType.SERVER_SENT_EVENTS))
def streamGame(@PathParam("gameId") gameId: String): Multi[String] = def streamGame(@PathParam("gameId") gameId: String): Multi[String] =
Multi.createFrom().emitter[String] { emitter => Multi.createFrom().emitter[String] { emitter =>
registry.register(s"game-$gameId", emitter) val topicName = s"${redisConfig.prefix}:game:$gameId:s2c"
emitter.onTermination(() => registry.unregister(s"game-$gameId")) val topic = redisson.getTopic(topicName)
val listenerId = topic.addListener(
classOf[String],
new MessageListener[String]:
def onMessage(channel: CharSequence, msg: String): Unit = emitter.emit(msg),
)
emitter.onTermination(() => topic.removeListener(listenerId))
} }
@POST @POST
@Path("/game/{gameId}/assign") @Path("/game/{gameId}/move/{uci}")
@Produces(Array(MediaType.APPLICATION_JSON)) @Produces(Array(MediaType.APPLICATION_JSON))
def assignBot( def makeMove(
@PathParam("gameId") gameId: String, @PathParam("gameId") gameId: String,
@QueryParam("botId") botId: String, @PathParam("uci") uci: String,
@QueryParam("difficulty") difficulty: Int,
@QueryParam("playingAs") playingAs: String,
@QueryParam("botAccountId") botAccountId: String,
): Response = ): Response =
val info = BotGameInfo(botId, difficulty, playingAs, botAccountId) val playerId = Option(jwt.getSubject).getOrElse("")
gameMonitor.watchGame(gameId, info) val moveMsg = s"""{"type":"MOVE","uci":"$uci","playerId":"$playerId"}"""
val event = s"""{"type":"gameStart","gameId":"$gameId","botId":"$botId"}""" redisson.getTopic(s"${redisConfig.prefix}:game:$gameId:c2s").publish(moveMsg)
registry.dispatch(botId, event)
Response.ok().build() Response.ok().build()
@@ -1,56 +0,0 @@
package de.nowchess.botplatform.service
import com.fasterxml.jackson.databind.ObjectMapper
import de.nowchess.botplatform.config.RedisConfig
import de.nowchess.botplatform.registry.BotGameInfo
import jakarta.enterprise.context.ApplicationScoped
import jakarta.inject.Inject
import org.redisson.api.{RedissonClient, RBlockingQueue}
import org.redisson.api.listener.MessageListener
import scala.compiletime.uninitialized
import java.util.concurrent.ConcurrentHashMap
@ApplicationScoped
class GameBotMonitor:
// scalafix:off DisableSyntax.var
@Inject var redisson: RedissonClient = uninitialized
@Inject var redisConfig: RedisConfig = uninitialized
@Inject var objectMapper: ObjectMapper = uninitialized
// scalafix:on DisableSyntax.var
private val listeners = ConcurrentHashMap[String, Int]()
def watchGame(gameId: String, info: BotGameInfo): Unit =
val topicName = s"${redisConfig.prefix}:game:$gameId:s2c"
val topic = redisson.getTopic(topicName)
val listenerId = topic.addListener(
classOf[String],
new MessageListener[String]:
def onMessage(channel: CharSequence, msg: String): Unit =
handleS2cEvent(gameId, msg, info),
)
listeners.put(gameId, listenerId)
def unwatchGame(gameId: String): Unit =
Option(listeners.remove(gameId)).foreach { listenerId =>
val topicName = s"${redisConfig.prefix}:game:$gameId:s2c"
redisson.getTopic(topicName).removeListener(listenerId)
}
private val terminalStatuses = Set("checkmate", "resign", "timeout", "stalemate", "insufficientMaterial", "draw")
private def handleS2cEvent(gameId: String, msg: String, info: BotGameInfo): Unit =
try
val node = objectMapper.readTree(msg)
val status = Option(node.path("state").path("status").asText()).getOrElse("")
if terminalStatuses.contains(status) then
unwatchGame(gameId)
else
val turn = Option(node.path("state").path("turn").asText()).getOrElse("")
if turn == info.playingAs then
val fen = node.path("state").path("fen").asText()
val req = s"""{"gameId":"$gameId","fen":"${fen.replace("\"", "\\\"")}","turn":"$turn","playingAs":"${info.playingAs}","difficulty":${info.difficulty},"botAccountId":"${info.botAccountId}"}"""
val queue: RBlockingQueue[String] = redisson.getBlockingQueue("nowchess:bot:move-queue")
queue.put(req)
catch case _: Exception => ()
+1
View File
@@ -52,6 +52,7 @@ dependencies {
implementation(project(":modules:rule")) implementation(project(":modules:rule"))
implementation(project(":modules:io")) implementation(project(":modules:io"))
implementation(project(":modules:official-bots")) implementation(project(":modules:official-bots"))
implementation(project(":modules:security"))
implementation(enforcedPlatform("${quarkusPlatformGroupId}:${quarkusPlatformArtifactId}:${quarkusPlatformVersion}")) implementation(enforcedPlatform("${quarkusPlatformGroupId}:${quarkusPlatformArtifactId}:${quarkusPlatformVersion}"))
@@ -23,6 +23,9 @@ nowchess:
port: 6379 port: 6379
prefix: nowchess prefix: nowchess
internal:
secret: ${INTERNAL_SECRET}
coordinator: coordinator:
enabled: ${NOWCHESS_COORDINATOR_ENABLED:false} enabled: ${NOWCHESS_COORDINATOR_ENABLED:false}
host: localhost host: localhost
@@ -2,14 +2,17 @@ package de.nowchess.chess.client
import de.nowchess.api.dto.{ImportFenRequest, ImportPgnRequest} import de.nowchess.api.dto.{ImportFenRequest, ImportPgnRequest}
import de.nowchess.api.game.GameContext import de.nowchess.api.game.GameContext
import de.nowchess.security.InternalSecretClientFilter
import jakarta.ws.rs.* import jakarta.ws.rs.*
import jakarta.ws.rs.core.MediaType import jakarta.ws.rs.core.MediaType
import org.eclipse.microprofile.rest.client.annotation.RegisterProvider
import org.eclipse.microprofile.rest.client.inject.RegisterRestClient import org.eclipse.microprofile.rest.client.inject.RegisterRestClient
case class CombinedExportResponse(fen: String, pgn: String) case class CombinedExportResponse(fen: String, pgn: String)
@Path("/io") @Path("/io")
@RegisterRestClient(configKey = "io-service") @RegisterRestClient(configKey = "io-service")
@RegisterProvider(classOf[InternalSecretClientFilter])
trait IoServiceClient: trait IoServiceClient:
@POST @POST
@@ -3,8 +3,10 @@ package de.nowchess.chess.client
import de.nowchess.api.game.GameContext import de.nowchess.api.game.GameContext
import de.nowchess.api.move.Move import de.nowchess.api.move.Move
import de.nowchess.api.rules.PostMoveStatus import de.nowchess.api.rules.PostMoveStatus
import de.nowchess.security.InternalSecretClientFilter
import jakarta.ws.rs.* import jakarta.ws.rs.*
import jakarta.ws.rs.core.MediaType import jakarta.ws.rs.core.MediaType
import org.eclipse.microprofile.rest.client.annotation.RegisterProvider
import org.eclipse.microprofile.rest.client.inject.RegisterRestClient import org.eclipse.microprofile.rest.client.inject.RegisterRestClient
case class RuleSquareRequest(context: GameContext, square: String) case class RuleSquareRequest(context: GameContext, square: String)
@@ -12,6 +14,7 @@ case class RuleMoveRequest(context: GameContext, move: Move)
@Path("/api/rules") @Path("/api/rules")
@RegisterRestClient(configKey = "rule-service") @RegisterRestClient(configKey = "rule-service")
@RegisterProvider(classOf[InternalSecretClientFilter])
trait RuleServiceClient: trait RuleServiceClient:
@POST @POST
@@ -1,10 +1,13 @@
package de.nowchess.chess.client package de.nowchess.chess.client
import de.nowchess.security.InternalSecretClientFilter
import jakarta.ws.rs.* import jakarta.ws.rs.*
import jakarta.ws.rs.core.MediaType import jakarta.ws.rs.core.MediaType
import org.eclipse.microprofile.rest.client.annotation.RegisterProvider
import org.eclipse.microprofile.rest.client.inject.RegisterRestClient import org.eclipse.microprofile.rest.client.inject.RegisterRestClient
@RegisterRestClient(configKey = "store-service") @RegisterRestClient(configKey = "store-service")
@RegisterProvider(classOf[InternalSecretClientFilter])
@Path("/game") @Path("/game")
trait StoreServiceClient: trait StoreServiceClient:
@GET @GET
@@ -4,6 +4,8 @@ import com.fasterxml.jackson.databind.ObjectMapper
import de.nowchess.api.dto.GameStateEventDto import de.nowchess.api.dto.GameStateEventDto
import de.nowchess.api.game.{CorrespondenceClockState, LiveClockState} import de.nowchess.api.game.{CorrespondenceClockState, LiveClockState}
import de.nowchess.chess.grpc.IoGrpcClientWrapper import de.nowchess.chess.grpc.IoGrpcClientWrapper
import de.nowchess.api.game.{DrawReason, GameResult, WinReason}
import de.nowchess.api.board.Color
import de.nowchess.chess.observer.{GameEvent, Observer} import de.nowchess.chess.observer.{GameEvent, Observer}
import de.nowchess.chess.registry.GameRegistry import de.nowchess.chess.registry.GameRegistry
import de.nowchess.chess.resource.GameDtoMapper import de.nowchess.chess.resource.GameDtoMapper
@@ -54,6 +56,21 @@ class GameRedisPublisher(
clockMoveDeadline = clock.collect { case c: CorrespondenceClockState => c.moveDeadline.toEpochMilli }, clockMoveDeadline = clock.collect { case c: CorrespondenceClockState => c.moveDeadline.toEpochMilli },
clockActiveColor = clock.map(_.activeColor.label.toLowerCase), clockActiveColor = clock.map(_.activeColor.label.toLowerCase),
pendingDrawOffer = entry.engine.pendingDrawOfferBy.map(_.label.toLowerCase), pendingDrawOffer = entry.engine.pendingDrawOfferBy.map(_.label.toLowerCase),
result = entry.engine.context.result.map {
case GameResult.Win(Color.White, _) => "white"
case GameResult.Win(Color.Black, _) => "black"
case GameResult.Draw(_) => "draw"
},
terminationReason = entry.engine.context.result.map {
case GameResult.Win(_, WinReason.Checkmate) => "checkmate"
case GameResult.Win(_, WinReason.Resignation) => "resignation"
case GameResult.Win(_, WinReason.TimeControl) => "timeout"
case GameResult.Draw(DrawReason.Stalemate) => "stalemate"
case GameResult.Draw(DrawReason.InsufficientMaterial) => "insufficient_material"
case GameResult.Draw(DrawReason.FiftyMoveRule) => "fifty_move"
case GameResult.Draw(DrawReason.ThreefoldRepetition) => "repetition"
case GameResult.Draw(DrawReason.Agreement) => "agreement"
},
redoStack = entry.engine.redoStackMoves.map(GameDtoMapper.moveToUci), redoStack = entry.engine.redoStackMoves.map(GameDtoMapper.moveToUci),
pendingTakebackRequest = entry.engine.pendingTakebackRequestBy.map(_.label.toLowerCase), pendingTakebackRequest = entry.engine.pendingTakebackRequestBy.map(_.label.toLowerCase),
) )
@@ -21,6 +21,8 @@ case class GameWritebackEventDto(
clockMoveDeadline: Option[Long], clockMoveDeadline: Option[Long],
clockActiveColor: Option[String], clockActiveColor: Option[String],
pendingDrawOffer: Option[String], pendingDrawOffer: Option[String],
result: Option[String] = None,
terminationReason: Option[String] = None,
redoStack: List[String] = Nil, redoStack: List[String] = Nil,
pendingTakebackRequest: Option[String] = None, pendingTakebackRequest: Option[String] = None,
) )
@@ -38,7 +38,7 @@ object GameDtoMapper:
case _ => base case _ => base
def toPlayerDto(info: PlayerInfo): PlayerInfoDto = def toPlayerDto(info: PlayerInfo): PlayerInfoDto =
PlayerInfoDto(info.id.value, info.displayName) PlayerInfoDto(info.id.value, info.displayName, info.playerType)
def toClockDto(entry: GameEntry): Option[ClockDto] = def toClockDto(entry: GameEntry): Option[ClockDto] =
val now = Instant.now() val now = Instant.now()
@@ -24,6 +24,7 @@ import de.nowchess.chess.grpc.{IoGrpcClientWrapper, RuleSetGrpcAdapter}
import de.nowchess.chess.observer.* import de.nowchess.chess.observer.*
import de.nowchess.chess.redis.GameRedisSubscriberManager import de.nowchess.chess.redis.GameRedisSubscriberManager
import de.nowchess.chess.registry.{GameEntry, GameRegistry} import de.nowchess.chess.registry.{GameEntry, GameRegistry}
import de.nowchess.security.InternalOnly
import jakarta.enterprise.context.ApplicationScoped import jakarta.enterprise.context.ApplicationScoped
import jakarta.inject.Inject import jakarta.inject.Inject
import jakarta.ws.rs.* import jakarta.ws.rs.*
@@ -79,9 +80,9 @@ class GameResource:
val color = colorOf(entry) val color = colorOf(entry)
if color != entry.engine.context.turn then throw ForbiddenException("Not your turn") if color != entry.engine.context.turn then throw ForbiddenException("Not your turn")
private def assertIsBot(): Unit = private def assertIsNotBot(): Unit =
val botType = Option(jwt.getClaim[AnyRef]("type")).map(_.toString).getOrElse("") val botType = Option(jwt.getClaim[AnyRef]("type")).map(_.toString).getOrElse("")
if !Set("bot", "official-bot").contains(botType) then if Set("bot", "official-bot").contains(botType) then
throw ForbiddenException("Only bots can make moves") throw ForbiddenException("Only bots can make moves")
// scalafix:on DisableSyntax.throw // scalafix:on DisableSyntax.throw
@@ -153,6 +154,7 @@ class GameResource:
// scalafix:off DisableSyntax.throw // scalafix:off DisableSyntax.throw
@POST @POST
@InternalOnly
@Consumes(Array(MediaType.APPLICATION_JSON)) @Consumes(Array(MediaType.APPLICATION_JSON))
@Produces(Array(MediaType.APPLICATION_JSON)) @Produces(Array(MediaType.APPLICATION_JSON))
def createGame(body: CreateGameRequestDto): Response = def createGame(body: CreateGameRequestDto): Response =
@@ -189,7 +191,7 @@ class GameResource:
@Path("/{gameId}/move/{uci}") @Path("/{gameId}/move/{uci}")
@Produces(Array(MediaType.APPLICATION_JSON)) @Produces(Array(MediaType.APPLICATION_JSON))
def makeMove(@PathParam("gameId") gameId: String, @PathParam("uci") uci: String): Response = def makeMove(@PathParam("gameId") gameId: String, @PathParam("uci") uci: String): Response =
assertIsBot() assertIsNotBot()
val entry = registry.get(gameId).getOrElse(throw GameNotFoundException(gameId)) val entry = registry.get(gameId).getOrElse(throw GameNotFoundException(gameId))
assertGameNotOver(entry) assertGameNotOver(entry)
assertIsCurrentPlayer(entry) assertIsCurrentPlayer(entry)
+1
View File
@@ -52,6 +52,7 @@ dependencies {
implementation(project(":modules:api")) implementation(project(":modules:api"))
implementation(project(":modules:json")) implementation(project(":modules:json"))
implementation(project(":modules:rule")) implementation(project(":modules:rule"))
implementation(project(":modules:security"))
// Jackson for JSON serialization/deserialization // Jackson for JSON serialization/deserialization
implementation("com.fasterxml.jackson.core:jackson-databind:${versions["JACKSON"]!!}") implementation("com.fasterxml.jackson.core:jackson-databind:${versions["JACKSON"]!!}")
@@ -6,6 +6,10 @@ quarkus:
use-separate-server: false use-separate-server: false
application: application:
name: nowchess-io name: nowchess-io
nowchess:
internal:
secret: ${INTERNAL_SECRET}
smallrye-openapi: smallrye-openapi:
info-title: NowChess IO Service info-title: NowChess IO Service
info-version: 1.0.0 info-version: 1.0.0
@@ -1,6 +1,7 @@
package de.nowchess.io.service.resource package de.nowchess.io.service.resource
import de.nowchess.api.game.GameContext import de.nowchess.api.game.GameContext
import de.nowchess.security.InternalOnly
import de.nowchess.io.fen.{FenExporter, FenParser} import de.nowchess.io.fen.{FenExporter, FenParser}
import de.nowchess.io.pgn.{PgnExporter, PgnParser} import de.nowchess.io.pgn.{PgnExporter, PgnParser}
import de.nowchess.io.service.dto.{CombinedExportResponse, ImportFenRequest, ImportPgnRequest, IoErrorDto} import de.nowchess.io.service.dto.{CombinedExportResponse, ImportFenRequest, ImportPgnRequest, IoErrorDto}
@@ -15,6 +16,7 @@ import org.eclipse.microprofile.openapi.annotations.tags.Tag
@Path("/io") @Path("/io")
@ApplicationScoped @ApplicationScoped
@InternalOnly
@Tag(name = "IO", description = "Chess notation import and export") @Tag(name = "IO", description = "Chess notation import and export")
class IoResource: class IoResource:
@@ -1,26 +0,0 @@
package de.nowchess.bot.service
import com.fasterxml.jackson.databind.ObjectMapper
case class MoveRequest(
gameId: String,
fen: String,
turn: String,
playingAs: String,
difficulty: Int,
botAccountId: String,
)
object MoveRequestParser:
def parse(json: String, mapper: ObjectMapper): Option[MoveRequest] =
scala.util.Try {
val node = mapper.readTree(json)
MoveRequest(
gameId = node.get("gameId").asText(),
fen = node.get("fen").asText(),
turn = node.get("turn").asText(),
playingAs = node.get("playingAs").asText(),
difficulty = node.get("difficulty").asInt(1400),
botAccountId = node.get("botAccountId").asText(),
)
}.toOption
@@ -2,7 +2,6 @@ package de.nowchess.bot.service
import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.databind.ObjectMapper
import de.nowchess.api.move.{Move, MoveType, PromotionPiece} import de.nowchess.api.move.{Move, MoveType, PromotionPiece}
import de.nowchess.bot.Bot
import de.nowchess.bot.BotController import de.nowchess.bot.BotController
import de.nowchess.bot.BotDifficulty import de.nowchess.bot.BotDifficulty
import de.nowchess.bot.config.RedisConfig import de.nowchess.bot.config.RedisConfig
@@ -12,6 +11,7 @@ import jakarta.enterprise.context.ApplicationScoped
import jakarta.enterprise.event.Observes import jakarta.enterprise.event.Observes
import jakarta.inject.Inject import jakarta.inject.Inject
import org.redisson.api.RedissonClient import org.redisson.api.RedissonClient
import org.redisson.api.listener.MessageListener
import scala.compiletime.uninitialized import scala.compiletime.uninitialized
@ApplicationScoped @ApplicationScoped
@@ -24,37 +24,74 @@ class OfficialBotService:
@Inject var botController: BotController = uninitialized @Inject var botController: BotController = uninitialized
// scalafix:on DisableSyntax.var // scalafix:on DisableSyntax.var
private val terminalStatuses =
Set("checkmate", "resign", "timeout", "stalemate", "insufficientMaterial", "draw")
def onStart(@Observes event: StartupEvent): Unit = def onStart(@Observes event: StartupEvent): Unit =
Thread.ofVirtual().start(() => runWorker()) BotController.listBots.foreach(subscribeToEventChannel)
private def subscribeToEventChannel(botName: String): Unit =
val topic = redisson.getTopic(s"${redisConfig.prefix}:bot:$botName:events")
topic.addListener(
classOf[String],
new MessageListener[String]:
def onMessage(channel: CharSequence, msg: String): Unit =
handleBotEvent(botName, msg),
)
() ()
private def runWorker(): Unit = private def handleBotEvent(botName: String, msg: String): Unit =
val queue = redisson.getBlockingQueue[String]("nowchess:bot:move-queue")
while true do
try try
val json = queue.take() val node = objectMapper.readTree(msg)
MoveRequestParser.parse(json, objectMapper).foreach(processRequest) if node.path("type").asText() == "gameStart" then
catch case _: InterruptedException => Thread.currentThread().interrupt() val gameId = node.path("gameId").asText()
val playingAs = node.path("playingAs").asText()
val difficulty = node.path("difficulty").asInt(1400)
val botAccountId = node.path("botAccountId").asText()
watchGame(botName, gameId, playingAs, difficulty, botAccountId)
catch case _: Exception => ()
private def processRequest(req: MoveRequest): Unit = private def watchGame(botName: String, gameId: String, playingAs: String, difficulty: Int, botAccountId: String): Unit =
val difficulty = DifficultyMapper.fromElo(req.difficulty).getOrElse(BotDifficulty.Medium) val topic = redisson.getTopic(s"${redisConfig.prefix}:game:$gameId:s2c")
val botName = difficulty match topic.addListener(
case BotDifficulty.Easy => "easy" classOf[String],
case BotDifficulty.Medium => "medium" new MessageListener[String]:
case BotDifficulty.Hard => "hard" def onMessage(channel: CharSequence, msg: String): Unit =
case BotDifficulty.Expert => "expert" handleGameEvent(botName, gameId, playingAs, difficulty, botAccountId, msg),
botController.getBot(botName).foreach(bot => parseAndMove(req, bot)) )
()
private def parseAndMove(req: MoveRequest, bot: Bot): Unit = private def handleGameEvent(
FenParser.parseFen(req.fen).toOption.foreach { context => botName: String,
gameId: String,
playingAs: String,
difficulty: Int,
botAccountId: String,
msg: String,
): Unit =
try
val node = objectMapper.readTree(msg)
val status = node.path("state").path("status").asText("")
if !terminalStatuses.contains(status) then
val turn = node.path("state").path("turn").asText("")
if turn == playingAs then
val fen = node.path("state").path("fen").asText()
computeAndSendMove(botName, gameId, fen, difficulty, botAccountId)
catch case _: Exception => ()
private def computeAndSendMove(botName: String, gameId: String, fen: String, difficulty: Int, botAccountId: String): Unit =
val level = DifficultyMapper.fromElo(difficulty).getOrElse(BotDifficulty.Medium)
botController.getBot(botName).orElse(botController.getBot(level.toString.toLowerCase)).foreach { bot =>
FenParser.parseFen(fen).toOption.foreach { context =>
bot(context).foreach { move => bot(context).foreach { move =>
val uci = toUci(move) val uci = toUci(move)
val c2sTopic = s"${redisConfig.prefix}:game:${req.gameId}:c2s" val c2sTopic = s"${redisConfig.prefix}:game:$gameId:c2s"
val moveMsg = s"""{"type":"MOVE","uci":"$uci","playerId":"${req.botAccountId}"}""" val moveMsg = s"""{"type":"MOVE","uci":"$uci","playerId":"$botAccountId"}"""
redisson.getTopic(c2sTopic).publish(moveMsg) redisson.getTopic(c2sTopic).publish(moveMsg)
() ()
} }
} }
}
private def toUci(move: Move): String = private def toUci(move: Move): String =
val base = s"${move.from}${move.to}" val base = s"${move.from}${move.to}"
+1
View File
@@ -52,6 +52,7 @@ dependencies {
implementation(project(":modules:api")) implementation(project(":modules:api"))
implementation(project(":modules:json")) implementation(project(":modules:json"))
implementation(project(":modules:security"))
implementation(platform("${quarkusPlatformGroupId}:${quarkusPlatformArtifactId}:${quarkusPlatformVersion}")) implementation(platform("${quarkusPlatformGroupId}:${quarkusPlatformArtifactId}:${quarkusPlatformVersion}"))
implementation("io.quarkus:quarkus-rest") implementation("io.quarkus:quarkus-rest")
@@ -6,3 +6,7 @@ quarkus:
use-separate-server: false use-separate-server: false
application: application:
name: rule-service name: rule-service
nowchess:
internal:
secret: ${INTERNAL_SECRET}
@@ -5,6 +5,7 @@ import de.nowchess.api.game.GameContext
import de.nowchess.api.move.Move import de.nowchess.api.move.Move
import de.nowchess.rules.dto.* import de.nowchess.rules.dto.*
import de.nowchess.api.rules.PostMoveStatus import de.nowchess.api.rules.PostMoveStatus
import de.nowchess.security.InternalOnly
import de.nowchess.rules.sets.DefaultRules import de.nowchess.rules.sets.DefaultRules
import jakarta.enterprise.context.ApplicationScoped import jakarta.enterprise.context.ApplicationScoped
import jakarta.ws.rs.* import jakarta.ws.rs.*
@@ -12,6 +13,7 @@ import jakarta.ws.rs.core.MediaType
@Path("/api/rules") @Path("/api/rules")
@ApplicationScoped @ApplicationScoped
@InternalOnly
class RuleSetResource: class RuleSetResource:
private val rules = DefaultRules private val rules = DefaultRules
+65
View File
@@ -0,0 +1,65 @@
plugins {
id("scala")
id("org.scoverage") version "8.1"
}
group = "de.nowchess"
version = "1.0-SNAPSHOT"
@Suppress("UNCHECKED_CAST")
val versions = rootProject.extra["VERSIONS"] as Map<String, String>
repositories {
mavenCentral()
}
scala {
scalaVersion = versions["SCALA3"]!!
}
scoverage {
scoverageVersion.set(versions["SCOVERAGE"]!!)
}
tasks.withType<ScalaCompile> {
scalaCompileOptions.additionalParameters = listOf("-encoding", "UTF-8")
}
val quarkusPlatformGroupId: String by project
val quarkusPlatformArtifactId: String by project
val quarkusPlatformVersion: String by project
dependencies {
compileOnly("org.scala-lang:scala3-compiler_3") {
version { strictly(versions["SCALA3"]!!) }
}
implementation("org.scala-lang:scala3-library_3") {
version { strictly(versions["SCALA3"]!!) }
}
compileOnly(platform("${quarkusPlatformGroupId}:${quarkusPlatformArtifactId}:${quarkusPlatformVersion}"))
compileOnly("io.quarkus:quarkus-rest")
compileOnly("io.quarkus:quarkus-rest-client")
compileOnly("io.quarkus:quarkus-grpc")
compileOnly("io.quarkus:quarkus-arc")
}
configurations.matching { !it.name.startsWith("scoverage") }.configureEach {
resolutionStrategy.force("org.scala-lang:scala-library:${versions["SCALA_LIBRARY"]!!}")
}
configurations.scoverage {
resolutionStrategy.eachDependency {
if (requested.group == "org.scoverage" && requested.name.startsWith("scalac-scoverage-plugin_")) {
useTarget("${requested.group}:scalac-scoverage-plugin_2.13.16:2.3.0")
}
}
}
tasks.withType<JavaCompile> {
options.encoding = "UTF-8"
options.compilerArgs.add("-parameters")
}
tasks.withType<Jar>().configureEach {
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
}
@@ -0,0 +1,12 @@
package de.nowchess.security;
import jakarta.ws.rs.NameBinding;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@NameBinding
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE, ElementType.METHOD})
public @interface InternalOnly {}
@@ -0,0 +1,23 @@
package de.nowchess.security
import jakarta.enterprise.context.ApplicationScoped
import jakarta.ws.rs.container.{ContainerRequestContext, ContainerRequestFilter}
import jakarta.ws.rs.core.Response
import jakarta.ws.rs.ext.Provider
import org.eclipse.microprofile.config.inject.ConfigProperty
import scala.compiletime.uninitialized
@Provider
@InternalOnly
@ApplicationScoped
class InternalAuthFilter extends ContainerRequestFilter:
@ConfigProperty(name = "nowchess.internal.secret")
// scalafix:off DisableSyntax.var
var secret: String = uninitialized
// scalafix:on DisableSyntax.var
override def filter(ctx: ContainerRequestContext): Unit =
val header = ctx.getHeaderString("X-Internal-Secret")
if header == null || header != secret then
ctx.abortWith(Response.status(Response.Status.UNAUTHORIZED).build())
@@ -0,0 +1,30 @@
package de.nowchess.security
import io.grpc.{Metadata, ServerCall, ServerCallHandler, ServerInterceptor, Status}
import io.quarkus.grpc.GlobalInterceptor
import jakarta.enterprise.context.ApplicationScoped
import org.eclipse.microprofile.config.inject.ConfigProperty
import scala.compiletime.uninitialized
@GlobalInterceptor
@ApplicationScoped
class InternalGrpcAuthInterceptor extends ServerInterceptor:
private val secretKey = Metadata.Key.of("x-internal-secret", Metadata.ASCII_STRING_MARSHALLER)
@ConfigProperty(name = "nowchess.internal.secret")
// scalafix:off DisableSyntax.var
var secret: String = uninitialized
// scalafix:on DisableSyntax.var
override def interceptCall[Req, Resp](
call: ServerCall[Req, Resp],
headers: Metadata,
next: ServerCallHandler[Req, Resp],
): ServerCall.Listener[Req] =
val token = Option(headers.get(secretKey)).getOrElse("")
if token != secret then
call.close(Status.UNAUTHENTICATED.withDescription("Missing or invalid internal secret"), new Metadata())
new ServerCall.Listener[Req] {}
else
next.startCall(call, headers)
@@ -0,0 +1,28 @@
package de.nowchess.security
import io.grpc.{CallOptions, Channel, ClientCall, ClientInterceptor, ForwardingClientCall, Metadata, MethodDescriptor}
import io.quarkus.grpc.GlobalInterceptor
import jakarta.enterprise.context.ApplicationScoped
import org.eclipse.microprofile.config.inject.ConfigProperty
import scala.compiletime.uninitialized
@GlobalInterceptor
@ApplicationScoped
class InternalGrpcSecretClientInterceptor extends ClientInterceptor:
private val secretKey = Metadata.Key.of("x-internal-secret", Metadata.ASCII_STRING_MARSHALLER)
@ConfigProperty(name = "nowchess.internal.secret")
// scalafix:off DisableSyntax.var
var secret: String = uninitialized
// scalafix:on DisableSyntax.var
override def interceptCall[Req, Resp](
method: MethodDescriptor[Req, Resp],
callOptions: CallOptions,
next: Channel,
): ClientCall[Req, Resp] =
new ForwardingClientCall.SimpleForwardingClientCall[Req, Resp](next.newCall(method, callOptions)):
override def start(responseListener: ClientCall.Listener[Resp], headers: Metadata): Unit =
headers.put(secretKey, secret)
super.start(responseListener, headers)
@@ -0,0 +1,17 @@
package de.nowchess.security
import jakarta.enterprise.context.ApplicationScoped
import jakarta.ws.rs.client.{ClientRequestContext, ClientRequestFilter}
import org.eclipse.microprofile.config.inject.ConfigProperty
import scala.compiletime.uninitialized
@ApplicationScoped
class InternalSecretClientFilter extends ClientRequestFilter:
@ConfigProperty(name = "nowchess.internal.secret")
// scalafix:off DisableSyntax.var
var secret: String = uninitialized
// scalafix:on DisableSyntax.var
override def filter(ctx: ClientRequestContext): Unit =
ctx.getHeaders.putSingle("X-Internal-Secret", secret)
@@ -6,7 +6,13 @@ import scala.compiletime.uninitialized
import java.time.Instant import java.time.Instant
@Entity @Entity
@Table(name = "game_records") @Table(
name = "game_records",
indexes = Array(
new Index(name = "idx_game_records_white_id", columnList = "whiteId"),
new Index(name = "idx_game_records_black_id", columnList = "blackId"),
),
)
class GameRecord extends PanacheEntityBase: class GameRecord extends PanacheEntityBase:
// scalafix:off DisableSyntax.var // scalafix:off DisableSyntax.var
@Id @Id
@@ -79,4 +85,11 @@ class GameRecord extends PanacheEntityBase:
@Column @Column
var pendingDrawOffer: String = uninitialized var pendingDrawOffer: String = uninitialized
// Game result
@Column
var result: String = uninitialized
@Column
var terminationReason: String = uninitialized
// scalafix:on // scalafix:on
@@ -21,4 +21,6 @@ case class GameWritebackEventDto(
clockMoveDeadline: Option[Long], clockMoveDeadline: Option[Long],
clockActiveColor: Option[String], clockActiveColor: Option[String],
pendingDrawOffer: Option[String], pendingDrawOffer: Option[String],
result: Option[String] = None,
terminationReason: Option[String] = None,
) )
@@ -24,8 +24,6 @@ class GameWritebackStreamListener:
val topic = redisson.getTopic("game-writeback") val topic = redisson.getTopic("game-writeback")
topic.addListener( topic.addListener(
classOf[String], classOf[String],
new MessageListener[String]: (channel: CharSequence, json: String) => Try(objectMapper.readValue(json, classOf[GameWritebackEventDto])).toOption
def onMessage(channel: CharSequence, json: String): Unit = .foreach(writebackService.writeBack)
Try(objectMapper.readValue(json, classOf[GameWritebackEventDto])).toOption
.foreach(writebackService.writeBack),
) )
@@ -5,6 +5,7 @@ import jakarta.enterprise.context.ApplicationScoped
import jakarta.inject.Inject import jakarta.inject.Inject
import jakarta.persistence.EntityManager import jakarta.persistence.EntityManager
import scala.compiletime.uninitialized import scala.compiletime.uninitialized
import scala.jdk.CollectionConverters.*
@ApplicationScoped @ApplicationScoped
class GameRecordRepository: class GameRecordRepository:
@@ -21,3 +22,25 @@ class GameRecordRepository:
def merge(record: GameRecord): Unit = def merge(record: GameRecord): Unit =
em.merge(record) em.merge(record)
def findByPlayerId(playerId: String, offset: Int, limit: Int): List[GameRecord] =
em.createQuery(
"SELECT g FROM GameRecord g WHERE g.whiteId = :id OR g.blackId = :id ORDER BY g.updatedAt DESC",
classOf[GameRecord],
).setParameter("id", playerId)
.setFirstResult(offset)
.setMaxResults(limit)
.getResultList
.asScala
.toList
def findByPlayerIdRunning(playerId: String, offset: Int, limit: Int): List[GameRecord] =
em.createQuery(
"SELECT g FROM GameRecord g WHERE g.whiteId = :id OR g.blackId = :id AND g.result = null ORDER BY g.updatedAt DESC",
classOf[GameRecord],
).setParameter("id", playerId)
.setFirstResult(offset)
.setMaxResults(limit)
.getResultList
.asScala
.toList
@@ -5,6 +5,7 @@ import jakarta.enterprise.context.ApplicationScoped
import jakarta.inject.Inject import jakarta.inject.Inject
import jakarta.ws.rs.* import jakarta.ws.rs.*
import jakarta.ws.rs.core.{MediaType, Response} import jakarta.ws.rs.core.{MediaType, Response}
import jakarta.ws.rs.DefaultValue
import scala.compiletime.uninitialized import scala.compiletime.uninitialized
@Path("/game") @Path("/game")
@@ -22,3 +23,23 @@ class StoreGameResource:
repository repository
.findByGameId(gameId) .findByGameId(gameId)
.fold(Response.status(404).build())(r => Response.ok(r).build()) .fold(Response.status(404).build())(r => Response.ok(r).build())
@GET
@Path("/running/{playerId}")
@Produces(Array(MediaType.APPLICATION_JSON))
def getRunning(
@PathParam("playerId") playerId: String,
@QueryParam("offset") @DefaultValue("0") offset: Int,
@QueryParam("limit") @DefaultValue("20") limit: Int,
): Response =
Response.ok(repository.findByPlayerIdRunning(playerId, offset, limit)).build()
@GET
@Path("/history/{playerId}")
@Produces(Array(MediaType.APPLICATION_JSON))
def getHistory(
@PathParam("playerId") playerId: String,
@QueryParam("offset") @DefaultValue("0") offset: Int,
@QueryParam("limit") @DefaultValue("20") limit: Int,
): Response =
Response.ok(repository.findByPlayerId(playerId, offset, limit)).build()
@@ -41,6 +41,8 @@ class GameWritebackService:
record.clockMoveDeadline = event.clockMoveDeadline.map(java.lang.Long.valueOf).orNull record.clockMoveDeadline = event.clockMoveDeadline.map(java.lang.Long.valueOf).orNull
record.clockActiveColor = event.clockActiveColor.orNull record.clockActiveColor = event.clockActiveColor.orNull
record.pendingDrawOffer = event.pendingDrawOffer.orNull record.pendingDrawOffer = event.pendingDrawOffer.orNull
record.result = event.result.orNull
record.terminationReason = event.terminationReason.orNull
record.createdAt = Instant.now() record.createdAt = Instant.now()
record.updatedAt = Instant.now() record.updatedAt = Instant.now()
repository.persist(record) repository.persist(record)
@@ -64,6 +66,8 @@ class GameWritebackService:
r.clockMoveDeadline = event.clockMoveDeadline.map(java.lang.Long.valueOf).orNull r.clockMoveDeadline = event.clockMoveDeadline.map(java.lang.Long.valueOf).orNull
r.clockActiveColor = event.clockActiveColor.orNull r.clockActiveColor = event.clockActiveColor.orNull
r.pendingDrawOffer = event.pendingDrawOffer.orNull r.pendingDrawOffer = event.pendingDrawOffer.orNull
r.result = event.result.orNull
r.terminationReason = event.terminationReason.orNull
r.updatedAt = Instant.now() r.updatedAt = Instant.now()
repository.merge(r) repository.merge(r)
case _ => () case _ => ()
@@ -0,0 +1,57 @@
package de.nowchess.ws.resource
import de.nowchess.ws.config.RedisConfig
import io.quarkus.websockets.next.*
import io.smallrye.jwt.auth.principal.JWTParser
import jakarta.inject.Inject
import org.redisson.api.listener.MessageListener
import org.redisson.api.RedissonClient
import scala.compiletime.uninitialized
import scala.util.Try
import java.util.concurrent.ConcurrentHashMap
@WebSocket(path = "/api/user/ws")
class UserWebSocketResource:
// scalafix:off DisableSyntax.var
@Inject
var redisson: RedissonClient = uninitialized
@Inject
var redisConfig: RedisConfig = uninitialized
@Inject
var jwtParser: JWTParser = uninitialized
// scalafix:on DisableSyntax.var
private val connections = new ConcurrentHashMap[String, (String, Int)]()
private def userTopic(userId: String): String =
s"${redisConfig.prefix}:user:$userId:events"
@OnOpen
def onOpen(connection: WebSocketConnection, handshake: HandshakeRequest): Unit =
val userIdOpt = Option(handshake.header("Authorization"))
.filter(_.nonEmpty)
.flatMap(token => Try(jwtParser.parse(token)).toOption)
.map(_.getSubject)
userIdOpt match
case None => connection.close().subscribe().`with`(_ => (), _ => ())
case Some(userId) =>
val listenerId = redisson.getTopic(userTopic(userId)).addListener(
classOf[String],
new MessageListener[String]:
def onMessage(channel: CharSequence, msg: String): Unit =
connection.sendText(msg).subscribe().`with`(_ => (), _ => ()),
)
connections.put(connection.id(), (userId, listenerId))
val connectedMsg = s"""{"type":"CONNECTED","userId":"$userId"}"""
connection.sendText(connectedMsg).subscribe().`with`(_ => (), _ => ())
@OnClose
def onClose(connection: WebSocketConnection): Unit =
Option(connections.remove(connection.id())).foreach { (userId, listenerId) =>
redisson.getTopic(userTopic(userId)).removeListener(listenerId)
}
+1
View File
@@ -19,6 +19,7 @@ include(
"modules:json", "modules:json",
"modules:io", "modules:io",
"modules:rule", "modules:rule",
"modules:security",
"modules:bot-platform", "modules:bot-platform",
"modules:official-bots", "modules:official-bots",
"modules:account", "modules:account",