mirror of
https://github.com/layue13/MoonBridge.git
synced 2026-10-25 18:46:18 +00:00
Initial StrataProxy implementation
This commit is contained in:
commit
648fd76012
166 files changed
+22826
No files matched your search
@@ -0,0 +1,529 @@
|
||||
# StrataProxy
|
||||
|
||||
StrataProxy is a new Java 25 Minecraft proxy project for large modded networks. It is not a BungeeCord or HexaCord compatibility rewrite.
|
||||
|
||||
The core model intentionally does not contain `modpackId`. Routing decisions are based on capabilities, tags, protocol range, load, health, drain state, and metadata. Pack identity can be stored in `metadata` when operators need it.
|
||||
|
||||
## Technical Baseline
|
||||
|
||||
- Gradle Kotlin DSL multi-module build
|
||||
- Java Toolchains with Java 25 baseline
|
||||
- Version Catalog
|
||||
- Convention plugins in `proxy-build-logic`
|
||||
- Configuration cache enabled
|
||||
|
||||
The local machine currently has JDK 25 installed at `C:\Program Files\Zulu\zulu-25`, while the default `java` on PATH may still point to Java 8. Use `JAVA_HOME=C:\Program Files\Zulu\zulu-25` or update PATH before building.
|
||||
|
||||
## Modules
|
||||
|
||||
- `proxy-api`: stable public model and contracts
|
||||
- `proxy-network`: Netty acceptor, lifecycle, pipeline, backpressure boundary
|
||||
- `proxy-protocol`: protocol state, packet metadata, classifier
|
||||
- `proxy-codec-minecraft`: Minecraft packet definitions and codec entry points
|
||||
- `proxy-registry`: dynamic server registration, health, load, drain
|
||||
- `proxy-routing`: tag/capacity/health-aware selection
|
||||
- `proxy-compression`: adaptive compression decisions
|
||||
- `proxy-observability`: metrics/event abstractions
|
||||
- `proxy-packet-analysis`: anomaly rules and diagnostic findings
|
||||
- `proxy-admin-api`: HTTP Admin API, Prometheus endpoint, registry operations, and incident reports
|
||||
- `proxy-admin-cli`: scriptable command-line client for the Admin API
|
||||
- `proxy-query`: Minecraft status and lightweight idle-connection probe CLI
|
||||
- `proxy-app`: runnable distribution entry point
|
||||
|
||||
## Run
|
||||
|
||||
Use JDK 25:
|
||||
|
||||
```powershell
|
||||
$env:JAVA_HOME='C:\Program Files\Zulu\zulu-25'
|
||||
.\gradlew.bat --no-daemon :proxy-app:installDist
|
||||
.\proxy-app\build\install\strataproxy\bin\strataproxy.bat
|
||||
```
|
||||
|
||||
The process runs until it receives a shutdown signal such as Ctrl+C or service stop. Shutdown is centralized and closes the listener, Admin API, health checker, load reporter, and Netty event loops in reverse startup order.
|
||||
|
||||
By default the proxy reads `config/strataproxy.yml`. You can pass a config path as the first argument:
|
||||
|
||||
```powershell
|
||||
.\proxy-app\build\install\strataproxy\bin\strataproxy.bat C:\path\to\strataproxy.yml
|
||||
```
|
||||
|
||||
Validate a config without starting the proxy:
|
||||
|
||||
```powershell
|
||||
.\proxy-app\build\install\strataproxy\bin\strataproxy.bat --validate-config C:\path\to\strataproxy.yml
|
||||
```
|
||||
|
||||
Show startup help or version information:
|
||||
|
||||
```powershell
|
||||
.\proxy-app\build\install\strataproxy\bin\strataproxy.bat --help
|
||||
.\proxy-app\build\install\strataproxy\bin\strataproxy.bat --version
|
||||
```
|
||||
|
||||
The packaged example config is available in `proxy-app/src/main/resources/config/strataproxy.yml`.
|
||||
The packaged production-oriented config is available in `proxy-app/src/main/resources/config/strataproxy-production.yml` and is included in the installed distribution as `config/strataproxy-production.yml`.
|
||||
String config values support environment placeholders in the form `${NAME}` and `${NAME:default}`, which is useful for secrets such as `STRATAPROXY_ADMIN_TOKEN`.
|
||||
|
||||
Build a complete release bundle with checksums:
|
||||
|
||||
```powershell
|
||||
.\gradlew.bat --no-daemon release
|
||||
Get-ChildItem .\build\release
|
||||
Get-Content .\build\release\strataproxy-0.1.0-SNAPSHOT.zip.sha256
|
||||
```
|
||||
|
||||
The release bundle contains app, Admin CLI, query CLI distribution archives, deployment profiles, sample configs, observability assets, README, `RELEASE-MANIFEST.txt`, `strataproxy-<version>.sbom.cdx.json`, and `strataproxy-<version>.metadata.json`. The checksum file covers the outer release bundle, the three inner distribution archives, the SBOM, and release metadata.
|
||||
|
||||
Release metadata is unsigned by default for local builds. Set `STRATAPROXY_RELEASE_SIGNING_KEY` or Gradle property `strataproxy.releaseSigningKey` to emit an HMAC-SHA256 signature over the release metadata payload:
|
||||
|
||||
```powershell
|
||||
$env:STRATAPROXY_RELEASE_SIGNING_KEY='replace-with-ci-secret'
|
||||
.\gradlew.bat --no-daemon release
|
||||
```
|
||||
|
||||
## Deployment
|
||||
|
||||
Production launch profiles live under `deployment/` and are packaged into `proxy-app` distributions:
|
||||
|
||||
- `deployment/systemd/strataproxy.service`: Linux service unit with `LimitNOFILE=1048576`, restart policy, restricted filesystem access, and dedicated `strataproxy` user.
|
||||
- `deployment/systemd/strataproxy.env`: Java 25 runtime defaults using ZGC, fixed heap sizing, explicit Netty direct memory, and fail-fast OOM behavior.
|
||||
- `deployment/container/Containerfile`: multi-stage Java 25 image build with a non-root runtime user and `/opt/strataproxy/data` volume.
|
||||
- `deployment/observability/prometheus/strataproxy-alerts.yml`: Prometheus alert rules for availability, event-loop delay, memory pressure, route/backend failures, rejected connections, packet anomalies, and relay backpressure.
|
||||
- `deployment/observability/grafana/strataproxy-overview.json`: Grafana dashboard for connection lifecycle, server capacity, bandwidth, compression, anomalies, and backpressure.
|
||||
- `deployment/performance/profiles/*.json`: repeatable performance profiles for smoke, Linux native Java 25 acceptance, and compression rewrite validation.
|
||||
- `deployment/performance/profile-result-template.json`: structured result record for load-test evidence.
|
||||
- `deployment/README.md`: install commands, container run command, and JVM sizing guidance.
|
||||
|
||||
Start from `JAVA_OPTS="-Xms2g -Xmx2g -XX:MaxDirectMemorySize=2g -XX:+UseZGC -XX:+AlwaysPreTouch -XX:+ExitOnOutOfMemoryError"` and resize heap/direct memory from observed `/metrics` values.
|
||||
|
||||
Build the admin CLI distribution separately when you want local operator tooling:
|
||||
|
||||
```powershell
|
||||
.\gradlew.bat --no-daemon :proxy-admin-cli:installDist
|
||||
.\proxy-admin-cli\build\install\strataproxy-admin\bin\strataproxy-admin.bat --base-url http://127.0.0.1:8080 health
|
||||
.\proxy-admin-cli\build\install\strataproxy-admin\bin\strataproxy-admin.bat --base-url http://127.0.0.1:8080 ready
|
||||
.\proxy-admin-cli\build\install\strataproxy-admin\bin\strataproxy-admin.bat --base-url http://127.0.0.1:8080 overview
|
||||
.\proxy-admin-cli\build\install\strataproxy-admin\bin\strataproxy-admin.bat --base-url http://127.0.0.1:8080 slo --require-ready --max-event-loop-delay-ms 5 --max-rejected 0 --max-anomalies 0
|
||||
.\proxy-admin-cli\build\install\strataproxy-admin\bin\strataproxy-admin.bat --base-url http://127.0.0.1:8080 compression
|
||||
.\proxy-admin-cli\build\install\strataproxy-admin\bin\strataproxy-admin.bat --base-url http://127.0.0.1:8080 packets
|
||||
.\proxy-admin-cli\build\install\strataproxy-admin\bin\strataproxy-admin.bat --base-url http://127.0.0.1:8080 players
|
||||
.\proxy-admin-cli\build\install\strataproxy-admin\bin\strataproxy-admin.bat --base-url http://127.0.0.1:8080 routes preview --route survival.example.net --protocol-version 763 --remote-address 127.0.0.1:50000 --tag survival --capability large-payload
|
||||
.\proxy-admin-cli\build\install\strataproxy-admin\bin\strataproxy-admin.bat --base-url http://127.0.0.1:8080 anomalies
|
||||
.\proxy-admin-cli\build\install\strataproxy-admin\bin\strataproxy-admin.bat --base-url http://127.0.0.1:8080 anomalies --samples
|
||||
.\proxy-admin-cli\build\install\strataproxy-admin\bin\strataproxy-admin.bat --base-url http://127.0.0.1:8080 backpressure
|
||||
.\proxy-admin-cli\build\install\strataproxy-admin\bin\strataproxy-admin.bat --base-url http://127.0.0.1:8080 captures
|
||||
.\proxy-admin-cli\build\install\strataproxy-admin\bin\strataproxy-admin.bat --base-url http://127.0.0.1:8080 captures start --id survival-capture --server survival-1 --direction frontend_to_backend --max-samples 64 --max-bytes 256 --duration-ms 30000
|
||||
.\proxy-admin-cli\build\install\strataproxy-admin\bin\strataproxy-admin.bat --base-url http://127.0.0.1:8080 captures get survival-capture
|
||||
.\proxy-admin-cli\build\install\strataproxy-admin\bin\strataproxy-admin.bat --base-url http://127.0.0.1:8080 servers update survival-1 --weight 20 --soft-capacity 120 --hard-capacity 160 --metadata group=survival-canary
|
||||
.\proxy-admin-cli\build\install\strataproxy-admin\bin\strataproxy-admin.bat --base-url http://127.0.0.1:8080 diagnostics
|
||||
```
|
||||
|
||||
Build the query/load probe CLI when you want to verify the proxy data plane:
|
||||
|
||||
```powershell
|
||||
.\gradlew.bat --no-daemon :proxy-query:installDist
|
||||
.\proxy-query\build\install\strataproxy-query\bin\strataproxy-query.bat --host 127.0.0.1 --port 25577 status --virtual-host play.example.net
|
||||
.\proxy-query\build\install\strataproxy-query\bin\strataproxy-query.bat --host 127.0.0.1 --port 25577 idle-load --connections 1000 --hold-ms 10000 --parallelism 256 --settle-ms 100 --probe-timeout-ms 50 --fail-on-closed --min-connected 1000 --min-alive 1000 --max-failed 0
|
||||
.\proxy-query\build\install\strataproxy-query\bin\strataproxy-query.bat --host 127.0.0.1 --port 25577 handshake-load --connections 2000 --virtual-host play.example.net --hold-ms 10000 --parallelism 256 --settle-ms 100 --probe-timeout-ms 50 --fail-on-closed --min-handshaken 2000 --min-alive 2000 --max-failed 0
|
||||
.\proxy-query\build\install\strataproxy-query\bin\strataproxy-query.bat --host 127.0.0.1 --port 25577 route-storm --connections 2000 --routes 250 --virtual-host-template route-%d.example.net --parallelism 256 --settle-ms 100 --probe-timeout-ms 50 --min-handshaken 2000 --min-routes-handshaken 250 --max-failed 0
|
||||
.\proxy-query\build\install\strataproxy-query\bin\strataproxy-query.bat --host 127.0.0.1 --port 25577 traffic-load --connections 2000 --virtual-host play.example.net --login-start --player-template load%05d --packets-per-connection 200 --payload-bytes 64 --parallelism 256 --min-handshaken 2000 --min-packets-sent 400000 --max-failed 0
|
||||
.\proxy-query\build\install\strataproxy-query\bin\strataproxy-query.bat --host 127.0.0.1 --port 25577 traffic-load --connections 100 --virtual-host play.example.net --packets-per-connection 100 --payload-bytes 64 --parallelism 100 --measure-echo-latency --max-p99-latency-ms 5
|
||||
.\proxy-query\build\install\strataproxy-query\bin\strataproxy-query.bat --host 127.0.0.1 --port 25577 traffic-load --connections 2000 --virtual-host play.example.net --packets-per-connection 200 --payload-bytes 64 --parallelism 256 --min-handshaken 2000 --min-packets-sent 400000 --max-failed 0 --json
|
||||
.\proxy-query\build\install\strataproxy-query\bin\strataproxy-query.bat --host 127.0.0.1 --port 25577 compression-rewrite-load --connections 100 --virtual-host play.example.net --packets-per-connection 200 --payload-bytes 1024 --threshold 256 --split-frames --parallelism 100 --min-handshaken 100 --min-negotiated 100 --min-packets-sent 20000 --max-failed 0
|
||||
.\proxy-query\build\install\strataproxy-query\bin\strataproxy-query.bat --host 127.0.0.1 --port 25577 load-suite --profile acceptance --virtual-host play.example.net --parallelism 512 --json
|
||||
.\proxy-query\build\install\strataproxy-query\bin\strataproxy-query.bat slow-sink --bind-host 127.0.0.1 --port 25565 --duration-ms 30000 --read-chunk-bytes 1 --read-delay-ms 100
|
||||
```
|
||||
|
||||
`idle-load` reports `connected`, `alive`, `closed`, and `failed` counts. `closed` means the TCP connect succeeded but the proxy closed the socket during the settle/probe window, which is useful for validating connection admission and storm protection.
|
||||
`handshake-load` sends Minecraft login handshakes before probing sockets and reports `handshaken`, `alive`, `closed`, `failed`, and `handshakeRatePerSecond`, which is useful for validating route storms and backend-connect survivability without completing full player login.
|
||||
`route-storm` sends Minecraft login handshakes across many generated virtual hosts and reports `routesAttempted`, `routesHandshaken`, `handshakeRatePerSecond`, and `routeRatePerSecond`, which is useful for validating routing decision throughput across many host/metadata route keys.
|
||||
`traffic-load` sends generated Minecraft packet frames after the login handshake and reports `packetsSent`, `bytesSent`, `packetRatePerSecond`, and `byteRatePerSecond`, which is useful for validating transparent relay throughput and backend write pressure. With `--login-start`, it sends generated Login Start player names before traffic frames and reports `loginStartsSent` plus `loginStartBytesSent`, which exercises active-player session attribution in the proxy. With `--measure-echo-latency`, the backend must echo each received Minecraft frame; the command then reports `latencySamples`, `p50LatencyMillis`, `p95LatencyMillis`, `p99LatencyMillis`, and `maxLatencyMillis`.
|
||||
`compression-rewrite-load` sends Login Start, waits for backend/proxy `Set Compression`, then sends generated Minecraft compression-mode frames. Use `--split-frames` to split each compressed frame across two writes so release checks exercise partial-frame buffering, recompression throughput, and rewrite guard behavior.
|
||||
`load-suite` runs repeatable scenarios by orchestrating the built-in probes. The default `smoke` profile uses small counts for CI. The `acceptance` profile defaults to `10000` idle connections, `2000` active traffic connections, and `200` packets per active connection; use overrides such as `--idle-connections`, `--active-connections`, and `--traffic-packets-per-connection` to scale for the host. Add `--include-compression-rewrite` when the backend/proxy path negotiates compression and should be included in the suite.
|
||||
All load commands support scriptable acceptance gates and `--json` machine-readable output for CI or load-test pipelines. `idle-load` supports `--min-connected`, `--min-alive`, `--max-closed`, `--max-failed`, and `--min-connect-rate`. `handshake-load` supports `--min-handshaken`, `--min-alive`, `--max-closed`, `--max-failed`, and `--min-handshake-rate`. `route-storm` supports `--min-handshaken`, `--min-alive`, `--min-routes-handshaken`, `--max-closed`, `--max-failed`, `--min-handshake-rate`, and `--min-route-rate`. `traffic-load` supports `--min-handshaken`, `--min-packets-sent`, `--min-bytes-sent`, `--max-failed`, `--min-packet-rate`, `--min-byte-rate`, and `--max-p99-latency-ms`. `compression-rewrite-load` supports `--min-handshaken`, `--min-negotiated`, `--min-packets-sent`, `--min-bytes-sent`, `--max-failed`, `--min-packet-rate`, and `--min-byte-rate`. `load-suite` exits non-zero if any child probe fails. The command exits non-zero when any configured gate is not met.
|
||||
Release performance profiles live in `deployment/performance`. They define the commands, host prerequisites, JVM options, and gates for repeatable smoke, acceptance, and compression rewrite validation. Use `profile-result-template.json` to record actual host evidence; do not treat a profile as passed without captured command output and gate results.
|
||||
`slow-sink` starts a local backend that accepts TCP connections and reads slowly, or holds sockets without reading when `--read-chunk-bytes 0` is used. Point a StrataProxy backend at it, run `traffic-load` against the proxy, then inspect `strataproxy-admin backpressure` to validate frontend-to-backend write pressure attribution.
|
||||
|
||||
Compression rewrite is opt-in:
|
||||
|
||||
```yaml
|
||||
compression:
|
||||
mode: adaptive
|
||||
minThreshold: 256
|
||||
maxThreshold: 8192
|
||||
cpuGuard: 0.75
|
||||
rewriteEnabled: false
|
||||
rewriteMaxEventLoopDelayMillis: 25
|
||||
```
|
||||
|
||||
Leave `rewriteEnabled: false` for maximum transparent fast-path compatibility. Set it to `true` only when you want the proxy to apply the configured compression strategy to safe bounded compressed relay frames after backend compression negotiation. Partial frames are buffered per connection until a complete frame can be rewritten or safely forwarded by policy. `rewriteMaxEventLoopDelayMillis` disables live rewrite while observed event-loop delay is above the configured threshold; `0` disables this guard.
|
||||
|
||||
Dynamic registry persistence is enabled by default:
|
||||
|
||||
```yaml
|
||||
registry:
|
||||
staticServers: true
|
||||
persistenceEnabled: true
|
||||
persistencePath: "data/registry.json"
|
||||
healthCheckEnabled: true
|
||||
healthCheckInterval: "5s"
|
||||
healthCheckTimeout: "2s"
|
||||
```
|
||||
|
||||
Static config is loaded first. Persisted dynamic entries are then replayed when their names are not already present in static config.
|
||||
Relative `registry.persistencePath` values are resolved from the directory containing the active config file, so the packaged default `data/registry.json` lives next to `config/strataproxy.yml` instead of depending on the process working directory.
|
||||
If the persisted registry file is corrupt or contains invalid entries, startup quarantines it with a `.invalid-<timestamp>` suffix, logs a warning, and continues from static config or an empty dynamic registry.
|
||||
Set `registry.staticServers: false` for dynamic-only deployments. In that mode `servers:` entries in YAML are ignored, the proxy may start with zero registered backends, and operators can add servers through the Admin API or persisted registry state. If the Admin API is disabled, at least one backend server must be configured.
|
||||
|
||||
Custom payload anomaly thresholds are configurable and accept byte units:
|
||||
|
||||
```yaml
|
||||
packetAnalysis:
|
||||
largePayloadWarnBytes: "1mb"
|
||||
unknownChannelThrottleBytes: "256kb"
|
||||
moddedHandshakeWarnBytes: "2mb"
|
||||
customPayloadFloodMaxCount: 200
|
||||
customPayloadFloodWindow: "10s"
|
||||
```
|
||||
|
||||
Packet anomaly reporting is controlled separately from rule evaluation:
|
||||
|
||||
```yaml
|
||||
observability:
|
||||
prometheus: true
|
||||
packetTopN: 50
|
||||
anomalySampling: true
|
||||
flushIntervalSeconds: 5
|
||||
```
|
||||
|
||||
`prometheus: false` disables the Prometheus text endpoint at `/metrics` while leaving structured Admin API reports such as `/overview`, `/compression-report`, `/packet-traffic`, and `/packet-anomalies` available.
|
||||
`packetTopN` limits the sorted rule list returned by `/packet-anomalies`. `anomalySampling: false` keeps counters and Prometheus metrics active, but stops retaining recent anomaly samples.
|
||||
`flushIntervalSeconds` controls how often proxy-observed bytes, packet counts, and event loop delay are converted into per-server `ServerLoad` rates for routing and admin metrics.
|
||||
|
||||
Use `/healthz` for process liveness and `/readyz` for load balancer readiness. `/readyz` returns `200 READY` only when at least one registered backend can receive new connections; drained, down, maintenance, and hard-full backends make the proxy not ready when no other backend is available.
|
||||
|
||||
## Current Runtime Capability
|
||||
|
||||
- Binds a Minecraft proxy listener.
|
||||
- Supports `--validate-config` to catch unsafe network, compression, packet analysis, registry, admin, and backend server settings before startup.
|
||||
- Supports `--help` and `--version` in the installed `strataproxy` command.
|
||||
- Reports startup failures such as invalid config, occupied ports, and bind failures with a short operator-facing error.
|
||||
- Runs as a long-lived service until shutdown, with one centralized shutdown hook and idempotent reverse-order resource cleanup.
|
||||
- Reports the actual bound proxy address after startup, including when the configured port is `0`.
|
||||
- Honors `network.nativeTransport`: uses Netty epoll/kqueue when available and falls back to NIO otherwise; startup logs the selected transport.
|
||||
- Parses the first Minecraft handshake frame using bounded VarInt/frame checks.
|
||||
- Rejects excessive bytes pipelined after the initial handshake before backend connect; pre-route pending data is capped by `network.maxFrameBytes`.
|
||||
- Provides Minecraft compression frame codec and Netty encoder/decoder handlers with bounded VarInt parsing, threshold checks, maximum uncompressed-size guard, partial-frame handling, and malformed zlib rejection.
|
||||
- Provides a bounded custom payload classifier for inspection paths. It identifies brand, Forge/FML handshake, Fabric handshake, registry/config sync, unknown channels, and large payloads without consuming the original `ByteBuf`.
|
||||
- Provides configurable custom payload anomaly policies for large payloads, large unknown channels, oversized Forge/Fabric handshake payloads, and per-connection custom payload flood windows.
|
||||
- Runs custom payload classification and anomaly policies on uncompressed client-to-backend configuration payload frames in the live relay. After compression negotiation, it also inspects a bounded early compressed-frame window for configuration custom payloads. WARN findings record anomaly counters and recent samples without changing forwarded bytes; THROTTLE findings close the relay before forwarding the triggering payload.
|
||||
- Records classified custom payload counters by server, direction, kind, and bounded channel for Forge/Fabric/registry diagnostics. Unknown channels are grouped as `unknown` to avoid unbounded metric cardinality.
|
||||
- Provides configured compression strategies for `off`, `fixed`, and `adaptive` modes. Adaptive decisions use packet size, RTT, historical compression ratio, threshold bounds, and the configured CPU guard.
|
||||
- Provides a standalone compressed-frame rewriter that can decode one Minecraft compression-mode frame, re-encode it with a target threshold, and return before/after frame size attribution.
|
||||
- Supports opt-in live compressed-frame rewrite after backend compression negotiation through `compression.rewriteEnabled`. The default remains `false`; when enabled, the relay rewrites safe bounded compressed-frame batches selected by the configured compression strategy, including frames split across reads, and records low-cardinality rewrite outcomes plus CPU time.
|
||||
- Routes to a backend by requested host, server name, tag, metadata `host`, metadata `route`, protocol range, health, drain state, capacity, and effective weight.
|
||||
- Distributes matching requests with deterministic weighted selection, so gray/canary backends receive traffic according to weight while repeated requests from the same source remain stable.
|
||||
- Exposes route preview with candidate explanations through the Admin API and CLI so operators can verify host/tag/capability/protocol decisions, effective weights, and rejection reasons before changing DNS, weights, or drain mode.
|
||||
- Falls back to a generic healthy backend when no host-specific backend matches, which makes a one-backend setup work out of the box.
|
||||
- Relays client/backend traffic transparently after routing.
|
||||
- Uses Netty with manual read backpressure: both frontend and backend channels keep `AUTO_READ=false` during relay and only read after writes complete.
|
||||
- Applies configured backend connect timeout and write-buffer watermarks to frontend/backend channels.
|
||||
- Enforces global and per-address connection admission limits before handshake routing to reduce connection storm impact.
|
||||
- Attributes admission rejections by low-cardinality reason (`global_limit` or `per_address_limit`) so operators can distinguish total saturation from one-address storms without high-cardinality client labels.
|
||||
- Closes connections that do not send the initial Minecraft handshake within `network.initialHandshakeTimeoutMillis`.
|
||||
- Uses Netty pooled `ByteBuf` allocation for listener and backend connections.
|
||||
- Tracks accepted connections, active connections, rejected connections by reason, routed connections, route failures, backend connect failures, and bidirectional bytes with low-contention `LongAdder` counters.
|
||||
- Tracks proxy-observed active and total routed connections per backend server.
|
||||
- Tracks active player sessions by bounded Login Start username parsing and attributes each player to the selected backend server without decoding mod payloads.
|
||||
- Attributes real forwarded bytes to each selected backend server for bandwidth auditing.
|
||||
- Samples uncompressed Minecraft frame packet IDs on the transparent relay path without deep payload decoding, tracking packet count and byte totals by server, direction, state, and packet ID.
|
||||
- Periodically converts proxy-observed traffic deltas into per-server load rates while preserving externally supplied player counts and capacity values.
|
||||
- Detects backend login `Set Compression` negotiation on the transparent fast path and records the selected threshold without changing forwarded bytes.
|
||||
- Samples backend-to-client and client-to-backend compressed-frame wire size versus declared uncompressed packet size after negotiation for compression bandwidth audit.
|
||||
- Enforces a lightweight compressed-frame safety gate after compression negotiation: malformed compressed frames and frames whose declared uncompressed size exceeds `network.maxFrameBytes` are recorded as anomalies and the relay is closed instead of forwarding a possible compression bomb.
|
||||
- Records compression audit samples globally, per backend server, and per relay direction: raw bytes, compressed bytes, saved bytes, ratio, and CPU time.
|
||||
- Runs the configured compression strategy against live compression audit samples and exports decision counts by server, direction, action, and threshold. This observes the live policy outcome without rewriting forwarded bytes yet.
|
||||
- Tracks malformed initial frame, malformed initial handshake, and no-route handshake anomalies by rule name.
|
||||
- Samples event loop delay, pooled direct memory, and JVM heap metrics for runtime performance diagnosis.
|
||||
- Exposes per-server health, load, capacity, bandwidth, and packet-rate metrics.
|
||||
- Exposes per-server proxy-observed active connections and routed connection totals.
|
||||
- Exposes per-server cumulative frontend-to-backend and backend-to-frontend forwarded bytes.
|
||||
- Exposes global compression negotiation count and per-server negotiated compression threshold.
|
||||
- Exposes global, per-server, and per-direction compression audit metrics for compression strategy tuning.
|
||||
- Exposes compression strategy decision counters as `strataproxy_compression_decisions_total`.
|
||||
- Exposes opt-in compression rewrite outcome counters as `strataproxy_compression_rewrites_total` and CPU cost as `strataproxy_compression_rewrite_cpu_seconds_total`.
|
||||
- Exposes packet traffic counters as `strataproxy_packet_traffic_packets_total`, `strataproxy_packet_traffic_raw_bytes_total`, and `strataproxy_packet_traffic_compressed_bytes_total`.
|
||||
- Exposes relay backpressure counters and gauges as `strataproxy_relay_backpressure_events_total`, `strataproxy_relay_backpressure_last_bytes_before_writable`, and `strataproxy_relay_backpressure_max_bytes_before_writable`.
|
||||
- Exposes active player sessions as `strataproxy_player_session_active`, `GET /player-sessions`, `strataproxy-admin players`, and diagnostic reports.
|
||||
- Exposes JVM GC collection and thread-count metrics.
|
||||
- Exposes unauthenticated liveness and readiness probes: `/healthz` reports process health, while `/readyz` returns 503 until at least one backend is routable.
|
||||
- Exposes a structured packet anomaly report sorted by rule count and limited by `observability.packetTopN`, plus an optional fixed-size recent sample ring buffer for operators and CLI tooling.
|
||||
- Exposes a structured packet traffic report sorted by raw bytes and limited by `observability.packetTopN`.
|
||||
- Exposes a structured diagnostic report combining overview, admission rejection reason breakdown, server state, compression audit, packet traffic, classified custom payload traffic, packet anomaly samples, relay backpressure rows, and active payload capture state for incident export.
|
||||
- Ships packaged Prometheus alert rules and a Grafana overview dashboard under `deployment/observability`.
|
||||
- Supports operator-toggled short-lived payload prefix captures with fixed-size ring buffers. Captures are disabled by default, expire automatically, store only the first configured bytes per matching relay buffer, and include player plus remote-address attribution when the connection identity is known.
|
||||
- Persists dynamic registry changes to `registry.persistencePath` with temp-file write plus atomic move when supported.
|
||||
- Resolves relative registry persistence paths from the active config directory, making installed distributions independent of the shell working directory.
|
||||
- Quarantines unreadable or invalid persisted registry files during startup and continues from static config or dynamic-only Admin API recovery.
|
||||
- Resolves relative Admin TLS keystore and truststore paths from the active config directory during both validation and startup.
|
||||
- Supports `${NAME}` and `${NAME:default}` environment placeholders in YAML config values before strong config mapping.
|
||||
- Rejects unauthenticated Admin API exposure on non-loopback bind addresses unless mTLS client authentication is enabled.
|
||||
- Persists drain mode changes so maintenance state survives restart.
|
||||
- Replays persisted registry entries on startup after static config is loaded.
|
||||
- Validates Admin API JSON request bodies and returns 400 for malformed or unsafe registration, health, and load updates.
|
||||
- Treats `POST /servers` as an upsert: new servers return 201, existing names return 200 and replace descriptor fields while preserving current health/load samples.
|
||||
- Returns 404 for health/load updates to unknown servers and rolls back in-memory registration when registry persistence fails.
|
||||
- Runs optional TCP backend health checks and marks unreachable servers `DOWN`, causing routing to avoid them.
|
||||
- Exposes:
|
||||
- `GET /healthz`
|
||||
- `GET /readyz`
|
||||
- `GET /overview`
|
||||
- `GET /compression-report`
|
||||
- `GET /diagnostic-report`
|
||||
- `GET /metrics` in Prometheus text format when `observability.prometheus` is enabled
|
||||
- `GET /packet-anomalies`
|
||||
- `GET /packet-traffic`
|
||||
- `GET /custom-payloads`
|
||||
- `GET /player-sessions`
|
||||
- `GET /routes/preview`
|
||||
- `GET /payload-captures`
|
||||
- `POST /payload-captures`
|
||||
- `GET /payload-captures/{id}`
|
||||
- `DELETE /payload-captures/{id}`
|
||||
- `GET /servers`
|
||||
- `GET /servers/{name}`
|
||||
- `POST /servers`
|
||||
- `PATCH /servers/{name}`
|
||||
- `DELETE /servers/{name}`
|
||||
- `POST /servers/{name}/drain`
|
||||
- `POST /servers/{name}/undrain`
|
||||
- `POST /servers/{name}/health`
|
||||
- `POST /servers/{name}/load`
|
||||
- Provides `strataproxy-admin` CLI commands for health, readiness, structured operational overview with admission rejection reasons independent of Prometheus, scriptable SLO gates for readiness, event-loop delay, active connections, rejected connections, anomaly count, and native transport, metrics, diagnostic report export, direction-aware compression audit, strategy decision, and live rewrite outcome summaries independent of Prometheus, packet traffic summaries, classified custom payload summaries, active player sessions, route preview, structured packet anomaly summary with optional recent samples, relay backpressure summaries, payload capture list/start/get/stop, server list/get/register/update/remove/drain/undrain, and health/load updates.
|
||||
- Supports optional Admin API HTTPS and mTLS using Java keystore/truststore configuration.
|
||||
- Provides `strataproxy-query` CLI commands for Minecraft status checks, idle TCP connection load probes, Minecraft handshake route-load probes, multi-virtual-host route storm probes, generated packet traffic-load probes, compression rewrite load probes with partial-frame writes, repeatable smoke/acceptance load-suite orchestration, JSON load-test result output for automation, and a slow-reading backend sink for backpressure validation.
|
||||
|
||||
If `admin.bearerToken` is set, all admin endpoints except `/healthz` and `/readyz` require:
|
||||
|
||||
```text
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
`--validate-config` rejects an Admin API bound to a non-loopback address with a blank `admin.bearerToken` unless Admin TLS `clientAuth` is enabled. The packaged default binds Admin API to `127.0.0.1`, where a blank token is allowed but still reported as a warning.
|
||||
|
||||
Admin HTTPS and optional mTLS can be enabled with a Java keystore. `clientAuth: true` requires clients to present a certificate trusted by the configured truststore:
|
||||
|
||||
```yaml
|
||||
admin:
|
||||
enabled: true
|
||||
bind: "127.0.0.1:8443"
|
||||
bearerToken: "replace-with-a-strong-token"
|
||||
tls:
|
||||
enabled: true
|
||||
keyStorePath: "conf/admin-server.p12"
|
||||
keyStorePassword: "changeit"
|
||||
keyStoreType: "PKCS12"
|
||||
trustStorePath: "conf/admin-clients.p12"
|
||||
trustStorePassword: "changeit"
|
||||
trustStoreType: "PKCS12"
|
||||
clientAuth: true
|
||||
```
|
||||
|
||||
Relative Admin TLS `keyStorePath` and `trustStorePath` values are resolved from the active config directory during validation and startup.
|
||||
|
||||
When TLS is enabled, point the admin CLI at an `https://` base URL:
|
||||
|
||||
```powershell
|
||||
.\proxy-admin-cli\build\install\strataproxy-admin\bin\strataproxy-admin.bat `
|
||||
--base-url https://127.0.0.1:8443 `
|
||||
--token replace-with-a-strong-token `
|
||||
--trust-store-path conf/admin-server-ca.p12 `
|
||||
--trust-store-password changeit `
|
||||
--trust-store-type PKCS12 `
|
||||
health
|
||||
```
|
||||
|
||||
When Admin API mTLS `clientAuth` is enabled, also provide the client keystore:
|
||||
|
||||
```powershell
|
||||
.\proxy-admin-cli\build\install\strataproxy-admin\bin\strataproxy-admin.bat `
|
||||
--base-url https://127.0.0.1:8443 `
|
||||
--token replace-with-a-strong-token `
|
||||
--trust-store-path conf/admin-server-ca.p12 `
|
||||
--trust-store-password changeit `
|
||||
--key-store-path conf/admin-client.p12 `
|
||||
--key-store-password changeit `
|
||||
health
|
||||
```
|
||||
|
||||
Example short diagnostic payload prefix capture:
|
||||
|
||||
```powershell
|
||||
.\proxy-admin-cli\build\install\strataproxy-admin\bin\strataproxy-admin.bat `
|
||||
--base-url http://127.0.0.1:8080 `
|
||||
--token replace-with-a-strong-token `
|
||||
captures start `
|
||||
--id survival-capture `
|
||||
--server survival-1 `
|
||||
--direction frontend_to_backend `
|
||||
--max-samples 64 `
|
||||
--max-bytes 256 `
|
||||
--duration-ms 30000
|
||||
|
||||
.\proxy-admin-cli\build\install\strataproxy-admin\bin\strataproxy-admin.bat `
|
||||
--base-url http://127.0.0.1:8080 `
|
||||
--token replace-with-a-strong-token `
|
||||
captures get survival-capture
|
||||
|
||||
Invoke-RestMethod `
|
||||
-Method POST `
|
||||
-Uri http://127.0.0.1:8080/payload-captures `
|
||||
-Headers @{Authorization = "Bearer replace-with-a-strong-token"} `
|
||||
-ContentType "application/json" `
|
||||
-Body '{"id":"survival-capture","server":"survival-1","direction":"frontend_to_backend","maxSamples":64,"maxBytesPerSample":256,"durationMillis":30000}'
|
||||
|
||||
Invoke-RestMethod `
|
||||
-Uri http://127.0.0.1:8080/payload-captures/survival-capture `
|
||||
-Headers @{Authorization = "Bearer replace-with-a-strong-token"}
|
||||
```
|
||||
|
||||
Example dynamic registration:
|
||||
|
||||
```powershell
|
||||
.\proxy-admin-cli\build\install\strataproxy-admin\bin\strataproxy-admin.bat `
|
||||
--base-url http://127.0.0.1:8080 `
|
||||
--token replace-with-a-strong-token `
|
||||
servers register `
|
||||
--name survival-1 `
|
||||
--address 10.0.0.12:25565 `
|
||||
--tag survival,forge `
|
||||
--capability large-payload,modern-forwarding `
|
||||
--protocol-range 763 `
|
||||
--soft-capacity 180 `
|
||||
--hard-capacity 220 `
|
||||
--metadata host=survival.example.net,group=survival
|
||||
```
|
||||
|
||||
Example gray rollout adjustment without re-registering the whole server descriptor:
|
||||
|
||||
```powershell
|
||||
.\proxy-admin-cli\build\install\strataproxy-admin\bin\strataproxy-admin.bat `
|
||||
--base-url http://127.0.0.1:8080 `
|
||||
--token replace-with-a-strong-token `
|
||||
servers update survival-1 `
|
||||
--weight 20 `
|
||||
--soft-capacity 120 `
|
||||
--hard-capacity 160 `
|
||||
--tag survival,forge,canary `
|
||||
--metadata host=survival.example.net,group=survival-canary
|
||||
```
|
||||
|
||||
## Performance Design
|
||||
|
||||
The first usable runtime favors a high-throughput transparent fast path:
|
||||
|
||||
- Only the first handshake packet is decoded for routing.
|
||||
- Bytes pipelined with the first handshake are bounded before the backend connection is opened, preventing pre-route memory amplification under connection storms.
|
||||
- Initial handshake anomaly detection is bounded to the first frame and does not add deep inspection to the relay fast path.
|
||||
- Post-route packets are forwarded as Netty `ByteBuf` objects without full packet decode.
|
||||
- Relay uses event-loop-local backend connects and does not run blocking work on Netty event loops.
|
||||
- Native Netty transports are selected once at startup and reused for listener and backend connections.
|
||||
- Routing uses deterministic weighted sampling instead of global highest-score selection, avoiding a hot backend under equal pools while still reducing effective weight for high capacity pressure, recent failures, backend latency, and event-loop delay.
|
||||
- Backpressure is connection-local and write-driven, avoiding unbounded pending reads under slow backend/client conditions.
|
||||
- Relay handlers record per-server, per-direction backpressure only when the target Netty channel is not writable, giving operators slow-backend evidence without adding work to the normal writable path.
|
||||
- Per-server bandwidth attribution is recorded in the relay path with `LongAdder` counters and does not decode forwarded packets.
|
||||
- Compression negotiation detection only parses bounded pre-compression login frames and disables itself after a threshold is found or malformed input is observed.
|
||||
- Compression frame audit parses only frame length and declared data length after negotiation in both relay directions; it does not inflate payloads or alter forwarded buffers.
|
||||
- Compression safety checks use only VarInt/frame metadata and close unsafe relays before forwarding malformed or oversized compressed frames.
|
||||
- Compression audit counters and strategy decision counters use `LongAdder`; configured compression strategy selection is created at startup, and runtime policy observation only runs on already-sampled compressed frames.
|
||||
- Compression rewrite is isolated behind a single-frame codec primitive and an opt-in Netty relay path, so live threshold changes are guarded by exact frame boundaries, max-uncompressed-size checks, and explicit ByteBuf ownership.
|
||||
- Live compression rewrite is conservative: it only runs after negotiated compression, buffers partial compressed frames with bounded per-connection state, rewrites only audited complete compressed-mode frame batches, and falls back to forwarding the original `ByteBuf` for bypass decisions, mixed per-frame policy targets, high event-loop delay, unsafe frame shapes, or rewrite failures.
|
||||
- Packet anomaly recent samples use a fixed-size ring buffer and record bounded metadata only; full payload bytes are not retained.
|
||||
- Payload prefix captures are opt-in, bounded by sample count and bytes per sample, and expire automatically. Relay handlers copy bytes only when a matching capture is active.
|
||||
- Custom payload classification performs bounded channel-name parsing only; full mod payload semantics are not decoded on the fast path. Live inspection covers uncompressed client-to-backend configuration payload frames and a bounded early compressed-frame window after compression negotiation, then closes the deep inspection sampler so normal play traffic returns to metadata-only auditing.
|
||||
- Custom payload anomaly policies operate on classifier metadata only and do not retain full payloads. THROTTLE enforcement for large unknown channels and per-connection custom payload floods happens before backend forwarding, protecting modded login/configuration phases from oversized or high-rate unknown payload abuse.
|
||||
- Minecraft compression codec and Netty handler instances reuse zlib state and scratch buffers. Compressed custom-payload inspection inflates only bounded early frames and immediately releases decoded buffers.
|
||||
- `network.writeBufferLow` and `network.writeBufferHigh` are applied as Netty watermarks to protect pending write queues.
|
||||
- `network.connectTimeoutMillis` is applied to backend connection attempts so failed routes do not hang.
|
||||
- `network.maxConnections` and `network.maxConnectionsPerAddress` reject excess connections before route/backend work starts.
|
||||
- Admission rejects are exported as `/overview` and `/diagnostic-report` JSON reason maps, CLI overview reason rows, a compatible total counter, and `strataproxy_connections_rejected_total{reason="global_limit|per_address_limit"}` for storm attribution.
|
||||
- `network.initialHandshakeTimeoutMillis` prevents idle pre-handshake sockets from holding connection slots indefinitely.
|
||||
- Metrics use `LongAdder` and stay off the hot path beyond simple increments.
|
||||
- Global connection lifecycle is accounted at admission; per-server lifecycle starts only after backend route/connect succeeds.
|
||||
- Runtime monitoring samples once per second by scheduling tiny tasks onto worker event loops; player identity attribution only parses the bounded Login Start username before compression and does not decode mod payloads.
|
||||
- Server and JVM metrics are collected only when `/metrics` is scraped.
|
||||
- Admin HTTP uses Java 25 virtual threads and is isolated from Netty event loops.
|
||||
- Startup failure rolls back already-started admin, health-check, load-reporting, and Netty resources.
|
||||
- Runtime close paths are idempotent; Netty shutdown waits for graceful completion from non-event-loop callers and avoids blocking inside Netty event loops.
|
||||
- Admin bearer-token checks are simple constant-time comparisons outside the packet forwarding path.
|
||||
- Admin TLS and client-certificate checks are isolated to the Admin API listener and never run on Netty proxy event loops.
|
||||
- Registry persistence only runs on admin mutations and startup, never on the packet forwarding path.
|
||||
- Health checks run on a dedicated daemon scheduler and update registry health out of band.
|
||||
|
||||
## Verification
|
||||
|
||||
Known-good checks:
|
||||
|
||||
```powershell
|
||||
$env:JAVA_HOME='C:\Program Files\Zulu\zulu-25'
|
||||
.\gradlew.bat --no-daemon check installDist :proxy-admin-cli:installDist :proxy-query:installDist
|
||||
.\gradlew.bat --no-daemon release
|
||||
.\gradlew.bat --no-daemon :proxy-app:installedDistSmokeTest
|
||||
.\gradlew.bat --no-daemon :proxy-app:installedDistProductionConfigSmokeTest
|
||||
.\gradlew.bat --no-daemon :proxy-app:deploymentAssetsSmokeTest
|
||||
.\gradlew.bat --no-daemon performanceProfilesSmokeTest
|
||||
.\gradlew.bat --no-daemon :proxy-app:installedDistHelpSmokeTest
|
||||
.\gradlew.bat --no-daemon :proxy-app:installedDistVersionSmokeTest
|
||||
.\gradlew.bat --no-daemon :proxy-admin-cli:installedDistSmokeTest
|
||||
.\gradlew.bat --no-daemon :proxy-query:installedDistSmokeTest
|
||||
.\proxy-app\build\install\strataproxy\bin\strataproxy.bat --validate-config .\proxy-app\build\install\strataproxy\config\strataproxy.yml
|
||||
```
|
||||
|
||||
Smoke-tested runtime:
|
||||
|
||||
- started installed distribution with a temporary config
|
||||
- verified `/healthz`
|
||||
- verified `/readyz` and `strataproxy-admin ready`
|
||||
- verified `/metrics`
|
||||
- confirmed listener/admin ports were released after shutdown
|
||||
- covered app-level YAML startup smoke: temporary config -> running proxy -> Minecraft status request -> backend -> proxy -> client, with runtime resources closed inside the test
|
||||
- covered config-relative registry persistence path resolution at runtime
|
||||
- covered corrupt persisted registry quarantine plus successful runtime startup
|
||||
- covered environment placeholder expansion in YAML config values
|
||||
- covered installed distribution validation of both default and production-oriented packaged configs
|
||||
- covered deployment observability asset validation, including Grafana JSON parsing and required Prometheus alert/dashboard metric references
|
||||
- covered release packaging with app/Admin CLI/query CLI archives, deployment profiles, configs, observability assets, manifest, CycloneDX-style SBOM, release metadata, optional HMAC-SHA256 metadata signing, and SHA-256 checksums
|
||||
- covered performance profile asset validation, including Java 25 target checks, command/gate presence, and result template structure
|
||||
- covered config-relative Admin TLS keystore path validation
|
||||
- covered rejection of unauthenticated non-loopback Admin API binds while allowing mTLS client-authenticated Admin API
|
||||
- covered deterministic weighted routing for gray rollout distribution, stable same-source selection, and capacity-pressure load shifting
|
||||
- covered Admin API and Admin CLI route preview for selected and rejected routing decisions, including candidate eligibility, effective weight, and rejection reason output
|
||||
- covered `/readyz` reporting 200 only when a backend can receive new connections and 503 for empty, draining, down, or hard-full pools
|
||||
- covered idempotent runtime close, shutdown waiter release, and listener port release
|
||||
- covered active player session tracking from split Login Start frames plus Prometheus, direct Admin API, Admin CLI, and diagnostic report export
|
||||
- covered a real TCP proxy smoke test in `proxy-network`: client handshake -> proxy -> backend -> proxy -> client, including routed connection count and per-server byte attribution
|
||||
- covered a real TCP per-address connection storm test in `proxy-network`: concurrent virtual-thread clients exceed the per-address admission limit and rejected connections are counted
|
||||
- covered connection admission rejection reasons from control logic through Netty handler metrics, Prometheus output, Admin API diagnostic JSON, and CLI overview output
|
||||
- covered `proxy-query` status protocol encode/decode and a real local status-query exchange
|
||||
- covered `proxy-query` Minecraft login handshake load probe against a real local socket server
|
||||
- covered `proxy-query` route-storm probe across multiple generated virtual hosts, JSON output, and route threshold failure
|
||||
- covered `proxy-query` generated packet traffic-load probe against a real local socket server, including optional Login Start player-name frames
|
||||
- covered `proxy-query` compression rewrite load probe against a real local socket server, including Set Compression negotiation, generated compressed packet frames, split-frame writes, and JSON output
|
||||
- covered `proxy-query` load-suite orchestration against a real local socket server, including idle and active traffic child probes plus JSON suite summaries
|
||||
- covered `proxy-query` idle, handshake, and traffic load acceptance gates returning non-zero when configured thresholds are not met
|
||||
- covered `proxy-query` idle, handshake, and traffic load JSON output for machine-readable performance gates
|
||||
- covered `proxy-query` traffic-load echo latency sampling and p99 latency gate output
|
||||
- covered `proxy-query` slow-reading backend sink startup, connection acceptance, byte accounting, and bounded shutdown
|
||||
- covered Admin API HTTPS server construction plus TLS config parsing and validation
|
||||
- covered Admin CLI HTTPS/mTLS with Java `keytool`-generated PKCS12 server/client stores and a real client-certificate handshake
|
||||
- covered compressed client-to-backend custom payload inspection, classified custom payload Admin API/CLI/Prometheus reporting, and THROTTLE enforcement before forwarding
|
||||
- covered payload prefix capture ring buffers, Admin API capture lifecycle, relay prefix capture, and frontend/backend capture sample player identity attribution
|
||||
- covered `strataproxy-admin slo` SLO gates returning non-zero when event-loop delay or rejected-connection thresholds are exceeded
|
||||
- covered `strataproxy-admin slo --require-ready` returning non-zero when no backend can receive new connections
|
||||
|
||||
## Next Milestones
|
||||
|
||||
1. Broaden optional deep payload diagnostics for Forge/Fabric login and configuration phases beyond bounded channel/kind/byte counters.
|
||||
2. Run and publish actual acceptance profile results on a dedicated Linux host with native transport enabled.
|
||||
3. Add native-image or jlink runtime experiments only if profiling shows startup/runtime packaging pressure is worth the added release complexity.
|
||||
@@ -0,0 +1,509 @@
|
||||
import org.gradle.api.DefaultTask
|
||||
import org.gradle.api.Project
|
||||
import org.gradle.api.file.ConfigurableFileCollection
|
||||
import org.gradle.api.file.RegularFileProperty
|
||||
import org.gradle.api.provider.Property
|
||||
import org.gradle.api.provider.ListProperty
|
||||
import org.gradle.api.tasks.Input
|
||||
import org.gradle.api.tasks.InputFile
|
||||
import org.gradle.api.tasks.InputFiles
|
||||
import org.gradle.api.tasks.OutputFile
|
||||
import org.gradle.api.tasks.Sync
|
||||
import org.gradle.api.tasks.TaskAction
|
||||
import org.gradle.api.tasks.bundling.Zip
|
||||
import groovy.json.JsonSlurper
|
||||
import java.security.MessageDigest
|
||||
import javax.crypto.Mac
|
||||
import javax.crypto.spec.SecretKeySpec
|
||||
|
||||
plugins {
|
||||
id("strataproxy.java-library-conventions") apply false
|
||||
id("strataproxy.application-conventions") apply false
|
||||
}
|
||||
|
||||
allprojects {
|
||||
group = "dev.strataproxy"
|
||||
version = "0.1.0-SNAPSHOT"
|
||||
}
|
||||
|
||||
tasks.register("check") {
|
||||
group = "verification"
|
||||
description = "Runs root-level StrataProxy verification tasks."
|
||||
}
|
||||
|
||||
gradle.projectsEvaluated {
|
||||
val releaseName = "strataproxy-${project.version}"
|
||||
val releaseStaging = layout.buildDirectory.dir("release/staging/$releaseName")
|
||||
val releaseZip = layout.buildDirectory.file("release/$releaseName.zip")
|
||||
val releaseChecksum = layout.buildDirectory.file("release/$releaseName.zip.sha256")
|
||||
val releaseSbom = layout.buildDirectory.file("release/$releaseName.sbom.cdx.json")
|
||||
val releaseMetadata = layout.buildDirectory.file("release/$releaseName.metadata.json")
|
||||
val app = project(":proxy-app")
|
||||
val admin = project(":proxy-admin-cli")
|
||||
val query = project(":proxy-query")
|
||||
val appDistZip = app.layout.buildDirectory.file("distributions/strataproxy-${project.version}.zip")
|
||||
val adminDistZip = admin.layout.buildDirectory.file("distributions/strataproxy-admin-${project.version}.zip")
|
||||
val queryDistZip = query.layout.buildDirectory.file("distributions/strataproxy-query-${project.version}.zip")
|
||||
|
||||
val writeReleaseManifest = tasks.register<ReleaseManifestTask>("writeReleaseManifest") {
|
||||
group = "distribution"
|
||||
description = "Writes the StrataProxy release manifest."
|
||||
dependsOn(
|
||||
app.tasks.named("distZip"),
|
||||
admin.tasks.named("distZip"),
|
||||
query.tasks.named("distZip")
|
||||
)
|
||||
outputFile.set(layout.buildDirectory.file("release/RELEASE-MANIFEST.txt"))
|
||||
nameValue.set(releaseName)
|
||||
versionValue.set(project.version.toString())
|
||||
appArchive.set(appDistZip.map { it.asFile.name })
|
||||
adminArchive.set(adminDistZip.map { it.asFile.name })
|
||||
queryArchive.set(queryDistZip.map { it.asFile.name })
|
||||
}
|
||||
|
||||
val generateReleaseSbom = tasks.register<GenerateSbomTask>("generateReleaseSbom") {
|
||||
group = "distribution"
|
||||
description = "Generates a CycloneDX-style SBOM for StrataProxy runtime artifacts."
|
||||
outputFile.set(releaseSbom)
|
||||
bomVersion.set(project.version.toString())
|
||||
components.set(runtimeComponents(app, admin, query))
|
||||
}
|
||||
|
||||
val writeReleaseMetadata = tasks.register<ReleaseMetadataTask>("writeReleaseMetadata") {
|
||||
group = "distribution"
|
||||
description = "Writes release metadata with checksums and optional HMAC signature."
|
||||
dependsOn(
|
||||
app.tasks.named("distZip"),
|
||||
admin.tasks.named("distZip"),
|
||||
query.tasks.named("distZip"),
|
||||
generateReleaseSbom
|
||||
)
|
||||
outputFile.set(releaseMetadata)
|
||||
releaseNameValue.set(releaseName)
|
||||
versionValue.set(project.version.toString())
|
||||
signingKey.set(providers.gradleProperty("strataproxy.releaseSigningKey")
|
||||
.orElse(providers.environmentVariable("STRATAPROXY_RELEASE_SIGNING_KEY"))
|
||||
.orElse(""))
|
||||
artifactFiles.from(appDistZip, adminDistZip, queryDistZip, releaseSbom)
|
||||
}
|
||||
|
||||
val stageRelease = tasks.register<Sync>("stageRelease") {
|
||||
group = "distribution"
|
||||
description = "Stages all StrataProxy release artifacts."
|
||||
dependsOn(
|
||||
app.tasks.named("distZip"),
|
||||
admin.tasks.named("distZip"),
|
||||
query.tasks.named("distZip"),
|
||||
writeReleaseManifest,
|
||||
generateReleaseSbom,
|
||||
writeReleaseMetadata
|
||||
)
|
||||
into(releaseStaging)
|
||||
from("README.md")
|
||||
from("deployment") {
|
||||
into("deployment")
|
||||
}
|
||||
from("proxy-app/src/main/resources/config") {
|
||||
into("config")
|
||||
}
|
||||
from(layout.buildDirectory.file("release/RELEASE-MANIFEST.txt"))
|
||||
from(releaseSbom)
|
||||
from(releaseMetadata)
|
||||
from(appDistZip) {
|
||||
into("archives")
|
||||
}
|
||||
from(adminDistZip) {
|
||||
into("archives")
|
||||
}
|
||||
from(queryDistZip) {
|
||||
into("archives")
|
||||
}
|
||||
}
|
||||
|
||||
val releaseBundle = tasks.register<Zip>("releaseBundle") {
|
||||
group = "distribution"
|
||||
description = "Builds a distributable StrataProxy release bundle."
|
||||
dependsOn(stageRelease)
|
||||
archiveFileName.set("$releaseName.zip")
|
||||
destinationDirectory.set(layout.buildDirectory.dir("release"))
|
||||
from(releaseStaging)
|
||||
}
|
||||
|
||||
val releaseChecksums = tasks.register<Sha256FilesTask>("releaseChecksums") {
|
||||
group = "distribution"
|
||||
description = "Writes SHA-256 checksums for StrataProxy release artifacts."
|
||||
dependsOn(releaseBundle)
|
||||
inputFiles.from(releaseZip, appDistZip, adminDistZip, queryDistZip, releaseSbom, releaseMetadata)
|
||||
outputFile.set(releaseChecksum)
|
||||
}
|
||||
|
||||
val releaseAuditSmokeTest = tasks.register<ReleaseAuditTask>("releaseAuditSmokeTest") {
|
||||
group = "verification"
|
||||
description = "Validates release metadata, SBOM, and checksum artifacts."
|
||||
dependsOn(releaseChecksums)
|
||||
metadataFile.set(releaseMetadata)
|
||||
sbomFile.set(releaseSbom)
|
||||
checksumFile.set(releaseChecksum)
|
||||
}
|
||||
|
||||
val performanceProfilesSmokeTest = tasks.register<PerformanceProfilesAuditTask>("performanceProfilesSmokeTest") {
|
||||
group = "verification"
|
||||
description = "Validates packaged StrataProxy performance profile definitions."
|
||||
profileFiles.from(fileTree("deployment/performance") {
|
||||
include("profiles/*.json")
|
||||
include("profile-result-template.json")
|
||||
})
|
||||
}
|
||||
|
||||
tasks.register("release") {
|
||||
group = "distribution"
|
||||
description = "Builds the release bundle and SHA-256 checksum."
|
||||
dependsOn(releaseAuditSmokeTest)
|
||||
}
|
||||
|
||||
tasks.named("check") {
|
||||
dependsOn(releaseAuditSmokeTest)
|
||||
dependsOn(performanceProfilesSmokeTest)
|
||||
}
|
||||
}
|
||||
|
||||
fun runtimeComponents(vararg projects: Project): List<String> {
|
||||
return projects
|
||||
.flatMap { project ->
|
||||
project.configurations.getByName("runtimeClasspath")
|
||||
.resolvedConfiguration
|
||||
.resolvedArtifacts
|
||||
.map { artifact ->
|
||||
val id = artifact.moduleVersion.id
|
||||
"${id.group}:${id.name}:${id.version}:${artifact.type}:${artifact.file.name}"
|
||||
}
|
||||
}
|
||||
.distinct()
|
||||
.sorted()
|
||||
}
|
||||
|
||||
abstract class ReleaseManifestTask : DefaultTask() {
|
||||
@get:OutputFile
|
||||
abstract val outputFile: RegularFileProperty
|
||||
|
||||
@get:Input
|
||||
abstract val nameValue: Property<String>
|
||||
|
||||
@get:Input
|
||||
abstract val versionValue: Property<String>
|
||||
|
||||
@get:Input
|
||||
abstract val appArchive: Property<String>
|
||||
|
||||
@get:Input
|
||||
abstract val adminArchive: Property<String>
|
||||
|
||||
@get:Input
|
||||
abstract val queryArchive: Property<String>
|
||||
|
||||
@TaskAction
|
||||
fun writeManifest() {
|
||||
val file = outputFile.get().asFile
|
||||
file.parentFile.mkdirs()
|
||||
file.writeText(
|
||||
"""
|
||||
name=${nameValue.get()}
|
||||
version=${versionValue.get()}
|
||||
java=25
|
||||
appArchive=${appArchive.get()}
|
||||
adminArchive=${adminArchive.get()}
|
||||
queryArchive=${queryArchive.get()}
|
||||
includes=deployment,configs,observability,checksums
|
||||
""".trimIndent() + System.lineSeparator()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
abstract class Sha256FilesTask : DefaultTask() {
|
||||
@get:InputFiles
|
||||
abstract val inputFiles: ConfigurableFileCollection
|
||||
|
||||
@get:OutputFile
|
||||
abstract val outputFile: RegularFileProperty
|
||||
|
||||
@TaskAction
|
||||
fun writeChecksums() {
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
val lines = inputFiles.files
|
||||
.sortedBy { it.name }
|
||||
.map { archive ->
|
||||
digest.reset()
|
||||
archive.inputStream().use { input ->
|
||||
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
|
||||
while (true) {
|
||||
val read = input.read(buffer)
|
||||
if (read < 0) {
|
||||
break
|
||||
}
|
||||
digest.update(buffer, 0, read)
|
||||
}
|
||||
}
|
||||
val checksum = digest.digest().joinToString("") { "%02x".format(it) }
|
||||
"$checksum ${archivePathLabel(archive, outputFile.get().asFile.parentFile)}"
|
||||
}
|
||||
val checksumFile = outputFile.get().asFile
|
||||
checksumFile.parentFile.mkdirs()
|
||||
checksumFile.writeText(lines.joinToString(System.lineSeparator(), postfix = System.lineSeparator()))
|
||||
}
|
||||
|
||||
private fun archivePathLabel(archive: File, checksumDirectory: File): String {
|
||||
return if (archive.parentFile == checksumDirectory) {
|
||||
archive.name
|
||||
} else {
|
||||
"archives/${archive.name}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
abstract class GenerateSbomTask : DefaultTask() {
|
||||
@get:OutputFile
|
||||
abstract val outputFile: RegularFileProperty
|
||||
|
||||
@get:Input
|
||||
abstract val bomVersion: Property<String>
|
||||
|
||||
@get:Input
|
||||
abstract val components: ListProperty<String>
|
||||
|
||||
@TaskAction
|
||||
fun generate() {
|
||||
val file = outputFile.get().asFile
|
||||
file.parentFile.mkdirs()
|
||||
val componentJson = components.get().joinToString(",\n") { component ->
|
||||
val parts = component.split(':', limit = 5)
|
||||
val group = parts.getOrElse(0) { "" }
|
||||
val name = parts.getOrElse(1) { "" }
|
||||
val version = parts.getOrElse(2) { "" }
|
||||
val type = parts.getOrElse(3) { "jar" }
|
||||
val fileName = parts.getOrElse(4) { "$name-$version.$type" }
|
||||
"""
|
||||
{
|
||||
"type": "library",
|
||||
"group": "${json(group)}",
|
||||
"name": "${json(name)}",
|
||||
"version": "${json(version)}",
|
||||
"purl": "pkg:maven/${json(group)}/${json(name)}@${json(version)}",
|
||||
"properties": [
|
||||
{"name": "strataproxy:artifactType", "value": "${json(type)}"},
|
||||
{"name": "strataproxy:fileName", "value": "${json(fileName)}"}
|
||||
]
|
||||
}
|
||||
""".trimIndent()
|
||||
}
|
||||
file.writeText(
|
||||
"""
|
||||
{
|
||||
"bomFormat": "CycloneDX",
|
||||
"specVersion": "1.5",
|
||||
"serialNumber": "urn:uuid:${stableUuid("strataproxy-${bomVersion.get()}-${components.get().joinToString("|")}")}",
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"component": {
|
||||
"type": "application",
|
||||
"group": "dev.strataproxy",
|
||||
"name": "strataproxy",
|
||||
"version": "${json(bomVersion.get())}"
|
||||
}
|
||||
},
|
||||
"components": [
|
||||
$componentJson
|
||||
]
|
||||
}
|
||||
""".trimIndent() + System.lineSeparator()
|
||||
)
|
||||
}
|
||||
|
||||
private fun stableUuid(input: String): String {
|
||||
val hash = MessageDigest.getInstance("SHA-256").digest(input.toByteArray(Charsets.UTF_8))
|
||||
hash[6] = ((hash[6].toInt() and 0x0f) or 0x50).toByte()
|
||||
hash[8] = ((hash[8].toInt() and 0x3f) or 0x80).toByte()
|
||||
val hex = hash.take(16).joinToString("") { "%02x".format(it) }
|
||||
return "${hex.substring(0, 8)}-${hex.substring(8, 12)}-${hex.substring(12, 16)}-${hex.substring(16, 20)}-${hex.substring(20, 32)}"
|
||||
}
|
||||
|
||||
private fun json(value: String): String {
|
||||
return value
|
||||
.replace("\\", "\\\\")
|
||||
.replace("\"", "\\\"")
|
||||
.replace("\n", "\\n")
|
||||
.replace("\r", "\\r")
|
||||
}
|
||||
}
|
||||
|
||||
abstract class ReleaseMetadataTask : DefaultTask() {
|
||||
@get:OutputFile
|
||||
abstract val outputFile: RegularFileProperty
|
||||
|
||||
@get:Input
|
||||
abstract val releaseNameValue: Property<String>
|
||||
|
||||
@get:Input
|
||||
abstract val versionValue: Property<String>
|
||||
|
||||
@get:Input
|
||||
abstract val signingKey: Property<String>
|
||||
|
||||
@get:InputFiles
|
||||
abstract val artifactFiles: ConfigurableFileCollection
|
||||
|
||||
@TaskAction
|
||||
fun writeMetadata() {
|
||||
val artifacts = artifactFiles.files
|
||||
.sortedBy { it.name }
|
||||
.map { file ->
|
||||
ReleaseArtifact(artifactPathLabel(file, outputFile.get().asFile.parentFile), file.length(), sha256(file))
|
||||
}
|
||||
val payload = artifacts.joinToString("|") { "${it.name}:${it.size}:${it.sha256}" }
|
||||
val key = signingKey.get()
|
||||
val signature = if (key.isBlank()) "" else hmacSha256(key, payload)
|
||||
val signatureMode = if (key.isBlank()) "unsigned" else "hmac-sha256"
|
||||
val artifactJson = artifacts.joinToString(",\n") { artifact ->
|
||||
"""
|
||||
{
|
||||
"name": "${json(artifact.name)}",
|
||||
"size": ${artifact.size},
|
||||
"sha256": "${artifact.sha256}"
|
||||
}
|
||||
""".trimIndent()
|
||||
}
|
||||
val file = outputFile.get().asFile
|
||||
file.parentFile.mkdirs()
|
||||
file.writeText(
|
||||
"""
|
||||
{
|
||||
"name": "${json(releaseNameValue.get())}",
|
||||
"version": "${json(versionValue.get())}",
|
||||
"java": "25",
|
||||
"artifacts": [
|
||||
$artifactJson
|
||||
],
|
||||
"signature": {
|
||||
"mode": "$signatureMode",
|
||||
"payload": "${json(payload)}",
|
||||
"value": "$signature"
|
||||
}
|
||||
}
|
||||
""".trimIndent() + System.lineSeparator()
|
||||
)
|
||||
}
|
||||
|
||||
private data class ReleaseArtifact(val name: String, val size: Long, val sha256: String)
|
||||
|
||||
private fun artifactPathLabel(artifact: File, metadataDirectory: File): String {
|
||||
return if (artifact.parentFile == metadataDirectory) {
|
||||
artifact.name
|
||||
} else {
|
||||
"archives/${artifact.name}"
|
||||
}
|
||||
}
|
||||
|
||||
private fun sha256(file: File): String {
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
file.inputStream().use { input ->
|
||||
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
|
||||
while (true) {
|
||||
val read = input.read(buffer)
|
||||
if (read < 0) {
|
||||
break
|
||||
}
|
||||
digest.update(buffer, 0, read)
|
||||
}
|
||||
}
|
||||
return digest.digest().joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
|
||||
private fun hmacSha256(key: String, payload: String): String {
|
||||
val mac = Mac.getInstance("HmacSHA256")
|
||||
mac.init(SecretKeySpec(key.toByteArray(Charsets.UTF_8), "HmacSHA256"))
|
||||
return mac.doFinal(payload.toByteArray(Charsets.UTF_8)).joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
|
||||
private fun json(value: String): String {
|
||||
return value
|
||||
.replace("\\", "\\\\")
|
||||
.replace("\"", "\\\"")
|
||||
.replace("\n", "\\n")
|
||||
.replace("\r", "\\r")
|
||||
}
|
||||
}
|
||||
|
||||
abstract class ReleaseAuditTask : DefaultTask() {
|
||||
@get:InputFile
|
||||
abstract val metadataFile: RegularFileProperty
|
||||
|
||||
@get:InputFile
|
||||
abstract val sbomFile: RegularFileProperty
|
||||
|
||||
@get:InputFile
|
||||
abstract val checksumFile: RegularFileProperty
|
||||
|
||||
@TaskAction
|
||||
fun audit() {
|
||||
val metadata = metadataFile.get().asFile.readText()
|
||||
val sbom = sbomFile.get().asFile.readText()
|
||||
val checksums = checksumFile.get().asFile.readText()
|
||||
listOf(
|
||||
"\"name\": \"strataproxy-",
|
||||
"\"java\": \"25\"",
|
||||
"\"signature\":",
|
||||
"archives/strataproxy-admin-",
|
||||
"archives/strataproxy-query-"
|
||||
).forEach { token ->
|
||||
require(metadata.contains(token)) { "release metadata missing $token" }
|
||||
}
|
||||
listOf(
|
||||
"\"bomFormat\": \"CycloneDX\"",
|
||||
"\"specVersion\": \"1.5\"",
|
||||
"\"components\": [",
|
||||
"pkg:maven/io.netty/netty-transport",
|
||||
"pkg:maven/com.fasterxml.jackson.core/jackson-databind",
|
||||
"pkg:maven/info.picocli/picocli"
|
||||
).forEach { token ->
|
||||
require(sbom.contains(token)) { "release SBOM missing $token" }
|
||||
}
|
||||
listOf(
|
||||
"strataproxy-0.1.0-SNAPSHOT.zip",
|
||||
"strataproxy-0.1.0-SNAPSHOT.sbom.cdx.json",
|
||||
"strataproxy-0.1.0-SNAPSHOT.metadata.json",
|
||||
"archives/strataproxy-admin-0.1.0-SNAPSHOT.zip",
|
||||
"archives/strataproxy-query-0.1.0-SNAPSHOT.zip"
|
||||
).forEach { token ->
|
||||
require(checksums.contains(token)) { "release checksums missing $token" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
abstract class PerformanceProfilesAuditTask : DefaultTask() {
|
||||
@get:InputFiles
|
||||
abstract val profileFiles: ConfigurableFileCollection
|
||||
|
||||
@TaskAction
|
||||
fun audit() {
|
||||
val files = profileFiles.files.sortedBy { it.name }
|
||||
require(files.size >= 4) { "expected performance profiles and result template" }
|
||||
val parser = JsonSlurper()
|
||||
val profileIds = mutableSetOf<String>()
|
||||
for (file in files) {
|
||||
val parsed = parser.parse(file) as Map<*, *>
|
||||
if (file.name == "profile-result-template.json") {
|
||||
require(parsed.containsKey("gateResults")) { "profile result template missing gateResults" }
|
||||
require(parsed.containsKey("observations")) { "profile result template missing observations" }
|
||||
continue
|
||||
}
|
||||
val id = parsed["id"] as? String
|
||||
require(!id.isNullOrBlank()) { "profile ${file.name} missing id" }
|
||||
require(profileIds.add(id)) { "duplicate performance profile id: $id" }
|
||||
require(parsed["java"] == "25") { "profile $id must target Java 25" }
|
||||
require(parsed.containsKey("commands")) { "profile $id missing commands" }
|
||||
require(parsed.containsKey("gates")) { "profile $id missing gates" }
|
||||
}
|
||||
require(profileIds.contains("smoke")) { "missing smoke performance profile" }
|
||||
require(profileIds.contains("acceptance-linux-native-java25")) { "missing Linux native Java 25 acceptance profile" }
|
||||
require(profileIds.contains("compression-rewrite-linux-native-java25")) { "missing compression rewrite performance profile" }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
# StrataProxy Deployment Profiles
|
||||
|
||||
This directory contains production-oriented launch profiles for Linux hosts and containers.
|
||||
|
||||
The defaults target large modded networks:
|
||||
|
||||
- Java 25 runtime
|
||||
- ZGC
|
||||
- fixed heap sizing
|
||||
- explicit direct-memory budget for Netty
|
||||
- high file-descriptor limit
|
||||
- config-relative runtime data under `data/`
|
||||
- non-root container runtime
|
||||
|
||||
## systemd
|
||||
|
||||
Install an `installDist` build under `/opt/strataproxy`:
|
||||
|
||||
```bash
|
||||
./gradlew --no-daemon :proxy-app:installDist
|
||||
sudo install -d -o strataproxy -g strataproxy /opt/strataproxy
|
||||
sudo cp -a proxy-app/build/install/strataproxy/. /opt/strataproxy/
|
||||
sudo install -d -o strataproxy -g strataproxy /etc/strataproxy /var/lib/strataproxy /var/log/strataproxy
|
||||
sudo install -m 0640 -o root -g strataproxy deployment/systemd/strataproxy.env /etc/strataproxy/strataproxy.env
|
||||
sudo install -m 0644 deployment/systemd/strataproxy.service /etc/systemd/system/strataproxy.service
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now strataproxy
|
||||
```
|
||||
|
||||
Before exposing the Admin API beyond loopback, set `STRATAPROXY_ADMIN_TOKEN` and configure TLS or mTLS. The systemd profile stores dynamic registry state under `/var/lib/strataproxy` through `STRATAPROXY_DATA_DIR`; the container profile defaults to `/opt/strataproxy/data`.
|
||||
|
||||
## Container
|
||||
|
||||
Build the image from the repository root:
|
||||
|
||||
```bash
|
||||
docker build -f deployment/container/Containerfile -t strataproxy:local .
|
||||
docker run --rm \
|
||||
--name strataproxy \
|
||||
--ulimit nofile=1048576:1048576 \
|
||||
-p 25577:25577 \
|
||||
-p 127.0.0.1:8080:8080 \
|
||||
-e STRATAPROXY_ADMIN_TOKEN=change-me \
|
||||
-v strataproxy-data:/opt/strataproxy/data \
|
||||
strataproxy:local
|
||||
```
|
||||
|
||||
For host-network deployments, prefer Linux native transport and keep the Admin API bound to loopback or protected by mTLS.
|
||||
|
||||
## Observability
|
||||
|
||||
Packaged observability assets live under `deployment/observability`:
|
||||
|
||||
- `prometheus/strataproxy-alerts.yml` contains alert rules for target availability, backend readiness, route/backend failures, event-loop delay, heap/direct-memory pressure, rejected connection spikes, packet anomalies, custom payload floods, and relay backpressure.
|
||||
- `grafana/strataproxy-overview.json` contains a Prometheus-backed dashboard for the main proxy health and performance surface.
|
||||
|
||||
Prometheus scrape example:
|
||||
|
||||
```yaml
|
||||
scrape_configs:
|
||||
- job_name: strataproxy
|
||||
static_configs:
|
||||
- targets: ["127.0.0.1:8080"]
|
||||
```
|
||||
|
||||
Keep the Admin API on loopback or protect it with TLS/mTLS and a bearer token before exposing `/metrics` to a network scraper.
|
||||
|
||||
## JVM Sizing
|
||||
|
||||
Start with:
|
||||
|
||||
```text
|
||||
JAVA_OPTS=-Xms2g -Xmx2g -XX:MaxDirectMemorySize=2g -XX:+UseZGC -XX:+AlwaysPreTouch -XX:+ExitOnOutOfMemoryError
|
||||
```
|
||||
|
||||
Increase heap when Admin API reports high retained JVM heap. Increase direct memory when Prometheus reports pooled direct memory close to the configured ceiling or when large compressed payload bursts are expected. Keep direct memory and heap below the cgroup or host memory limit with operating-system headroom.
|
||||
@@ -0,0 +1,22 @@
|
||||
ARG JAVA_IMAGE=eclipse-temurin:25-jdk
|
||||
ARG RUNTIME_IMAGE=eclipse-temurin:25-jre
|
||||
|
||||
FROM ${JAVA_IMAGE} AS build
|
||||
WORKDIR /workspace
|
||||
COPY . .
|
||||
RUN ./gradlew --no-daemon :proxy-app:installDist
|
||||
|
||||
FROM ${RUNTIME_IMAGE}
|
||||
RUN useradd --system --home-dir /opt/strataproxy --shell /usr/sbin/nologin strataproxy
|
||||
WORKDIR /opt/strataproxy
|
||||
COPY --from=build /workspace/proxy-app/build/install/strataproxy/ /opt/strataproxy/
|
||||
RUN mkdir -p /opt/strataproxy/data \
|
||||
&& cp /opt/strataproxy/config/strataproxy-production.yml /opt/strataproxy/config/strataproxy.yml \
|
||||
&& chown -R strataproxy:strataproxy /opt/strataproxy
|
||||
USER strataproxy
|
||||
EXPOSE 25577 8080
|
||||
VOLUME ["/opt/strataproxy/data"]
|
||||
ENV JAVA_OPTS="-Xms2g -Xmx2g -XX:MaxDirectMemorySize=2g -XX:+UseZGC -XX:+AlwaysPreTouch -XX:+ExitOnOutOfMemoryError -Dio.netty.leakDetection.level=disabled"
|
||||
ENV STRATAPROXY_ADMIN_TOKEN="change-me"
|
||||
ENTRYPOINT ["/opt/strataproxy/bin/strataproxy"]
|
||||
CMD ["/opt/strataproxy/config/strataproxy.yml"]
|
||||
@@ -0,0 +1,16 @@
|
||||
# Grafana Dashboard
|
||||
|
||||
Import `strataproxy-overview.json` into Grafana and select your Prometheus datasource.
|
||||
|
||||
The dashboard covers:
|
||||
|
||||
- active connections and registered servers
|
||||
- event-loop delay and direct memory
|
||||
- per-server capacity
|
||||
- global and per-server bandwidth
|
||||
- compression savings and CPU cost
|
||||
- packet anomaly rate
|
||||
- relay backpressure
|
||||
- route/backend failure rate
|
||||
|
||||
Keep dashboard variables low-cardinality. The StrataProxy metric surface intentionally bounds packet IDs, custom payload channels, and anomaly metadata to avoid expensive Prometheus series growth.
|
||||
@@ -0,0 +1,188 @@
|
||||
{
|
||||
"uid": "strataproxy-overview",
|
||||
"title": "StrataProxy Overview",
|
||||
"schemaVersion": 39,
|
||||
"version": 1,
|
||||
"refresh": "10s",
|
||||
"timezone": "browser",
|
||||
"tags": ["strataproxy", "minecraft", "proxy"],
|
||||
"time": {
|
||||
"from": "now-1h",
|
||||
"to": "now"
|
||||
},
|
||||
"templating": {
|
||||
"list": [
|
||||
{
|
||||
"name": "datasource",
|
||||
"type": "datasource",
|
||||
"query": "prometheus",
|
||||
"current": {
|
||||
"text": "Prometheus",
|
||||
"value": "Prometheus"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "server",
|
||||
"type": "query",
|
||||
"datasource": "${datasource}",
|
||||
"query": "label_values(strataproxy_server_connections_active, server)",
|
||||
"includeAll": true,
|
||||
"multi": true,
|
||||
"current": {
|
||||
"text": "All",
|
||||
"value": "$__all"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"panels": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "stat",
|
||||
"title": "Active Connections",
|
||||
"gridPos": {"x": 0, "y": 0, "w": 6, "h": 4},
|
||||
"datasource": "${datasource}",
|
||||
"targets": [
|
||||
{"expr": "strataproxy_connections_active", "legendFormat": "active"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "stat",
|
||||
"title": "Registered Servers",
|
||||
"gridPos": {"x": 6, "y": 0, "w": 6, "h": 4},
|
||||
"datasource": "${datasource}",
|
||||
"targets": [
|
||||
{"expr": "strataproxy_servers_registered", "legendFormat": "servers"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "stat",
|
||||
"title": "Event Loop Delay",
|
||||
"gridPos": {"x": 12, "y": 0, "w": 6, "h": 4},
|
||||
"datasource": "${datasource}",
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"unit": "s",
|
||||
"thresholds": {
|
||||
"steps": [
|
||||
{"color": "green", "value": null},
|
||||
{"color": "orange", "value": 0.025},
|
||||
{"color": "red", "value": 0.1}
|
||||
]
|
||||
}
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"targets": [
|
||||
{"expr": "strataproxy_event_loop_delay_seconds", "legendFormat": "delay"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "stat",
|
||||
"title": "Direct Memory",
|
||||
"gridPos": {"x": 18, "y": 0, "w": 6, "h": 4},
|
||||
"datasource": "${datasource}",
|
||||
"fieldConfig": {
|
||||
"defaults": {"unit": "bytes"},
|
||||
"overrides": []
|
||||
},
|
||||
"targets": [
|
||||
{"expr": "strataproxy_pooled_direct_memory_bytes", "legendFormat": "pooled"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"type": "timeseries",
|
||||
"title": "Connection Lifecycle",
|
||||
"gridPos": {"x": 0, "y": 4, "w": 12, "h": 8},
|
||||
"datasource": "${datasource}",
|
||||
"targets": [
|
||||
{"expr": "strataproxy_connections_active", "legendFormat": "active"},
|
||||
{"expr": "rate(strataproxy_connections_accepted_total[1m])", "legendFormat": "accepted/s"},
|
||||
{"expr": "rate(strataproxy_connections_rejected_total[1m])", "legendFormat": "rejected/s"},
|
||||
{"expr": "rate(strataproxy_connections_routed_total[1m])", "legendFormat": "routed/s"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"type": "timeseries",
|
||||
"title": "Server Capacity",
|
||||
"gridPos": {"x": 12, "y": 4, "w": 12, "h": 8},
|
||||
"datasource": "${datasource}",
|
||||
"targets": [
|
||||
{"expr": "strataproxy_server_connections_active{server=~\"$server\"}", "legendFormat": "{{server}} active"},
|
||||
{"expr": "strataproxy_server_players{server=~\"$server\"}", "legendFormat": "{{server}} players"},
|
||||
{"expr": "strataproxy_server_soft_capacity{server=~\"$server\"}", "legendFormat": "{{server}} soft"},
|
||||
{"expr": "strataproxy_server_hard_capacity{server=~\"$server\"}", "legendFormat": "{{server}} hard"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"type": "timeseries",
|
||||
"title": "Bandwidth",
|
||||
"gridPos": {"x": 0, "y": 12, "w": 12, "h": 8},
|
||||
"datasource": "${datasource}",
|
||||
"fieldConfig": {
|
||||
"defaults": {"unit": "Bps"},
|
||||
"overrides": []
|
||||
},
|
||||
"targets": [
|
||||
{"expr": "rate(strataproxy_frontend_to_backend_bytes_total[1m])", "legendFormat": "frontend -> backend"},
|
||||
{"expr": "rate(strataproxy_backend_to_frontend_bytes_total[1m])", "legendFormat": "backend -> frontend"},
|
||||
{"expr": "sum by (server) (rate(strataproxy_server_frontend_to_backend_bytes_total{server=~\"$server\"}[1m]))", "legendFormat": "{{server}} f2b"},
|
||||
{"expr": "sum by (server) (rate(strataproxy_server_backend_to_frontend_bytes_total{server=~\"$server\"}[1m]))", "legendFormat": "{{server}} b2f"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"type": "timeseries",
|
||||
"title": "Compression Savings",
|
||||
"gridPos": {"x": 12, "y": 12, "w": 12, "h": 8},
|
||||
"datasource": "${datasource}",
|
||||
"fieldConfig": {
|
||||
"defaults": {"unit": "Bps"},
|
||||
"overrides": []
|
||||
},
|
||||
"targets": [
|
||||
{"expr": "rate(strataproxy_compression_saved_bytes_total[1m])", "legendFormat": "global saved/s"},
|
||||
{"expr": "sum by (server) (rate(strataproxy_compression_saved_bytes_total{server=~\"$server\"}[1m]))", "legendFormat": "{{server}} saved/s"},
|
||||
{"expr": "rate(strataproxy_compression_cpu_seconds_total[1m])", "legendFormat": "compression cpu/s"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"type": "timeseries",
|
||||
"title": "Packet Anomalies",
|
||||
"gridPos": {"x": 0, "y": 20, "w": 8, "h": 8},
|
||||
"datasource": "${datasource}",
|
||||
"targets": [
|
||||
{"expr": "sum by (rule) (rate(strataproxy_packet_anomalies_total[5m]))", "legendFormat": "{{rule}}"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"type": "timeseries",
|
||||
"title": "Relay Backpressure",
|
||||
"gridPos": {"x": 8, "y": 20, "w": 8, "h": 8},
|
||||
"datasource": "${datasource}",
|
||||
"targets": [
|
||||
{"expr": "sum by (server, direction) (rate(strataproxy_relay_backpressure_events_total{server=~\"$server\"}[5m]))", "legendFormat": "{{server}} {{direction}}"}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"type": "timeseries",
|
||||
"title": "Route and Backend Failures",
|
||||
"gridPos": {"x": 16, "y": 20, "w": 8, "h": 8},
|
||||
"datasource": "${datasource}",
|
||||
"targets": [
|
||||
{"expr": "rate(strataproxy_routes_failed_total[5m])", "legendFormat": "route failures"},
|
||||
{"expr": "rate(strataproxy_backend_connect_failures_total[5m])", "legendFormat": "backend connect failures"},
|
||||
{"expr": "rate(strataproxy_handshake_timeouts_total[5m])", "legendFormat": "handshake timeouts"}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
# Prometheus Rules
|
||||
|
||||
Load `strataproxy-alerts.yml` into Prometheus or a compatible rule manager.
|
||||
|
||||
The rules assume the StrataProxy Admin API `/metrics` endpoint is scraped with `job="strataproxy"`:
|
||||
|
||||
```yaml
|
||||
scrape_configs:
|
||||
- job_name: strataproxy
|
||||
static_configs:
|
||||
- targets: ["127.0.0.1:8080"]
|
||||
```
|
||||
|
||||
Tune thresholds to your host and expected modpack behavior. The default rules intentionally focus on availability, event-loop delay, memory pressure, route/backend failures, relay backpressure, rejected connection spikes, and packet anomaly rule hits.
|
||||
@@ -0,0 +1,114 @@
|
||||
groups:
|
||||
- name: strataproxy.availability
|
||||
rules:
|
||||
- alert: StrataProxyTargetDown
|
||||
expr: up{job="strataproxy"} == 0
|
||||
for: 1m
|
||||
labels:
|
||||
severity: critical
|
||||
annotations:
|
||||
summary: "StrataProxy scrape target is down"
|
||||
description: "Prometheus cannot scrape the StrataProxy Admin API metrics endpoint."
|
||||
|
||||
- alert: StrataProxyNoReadyBackends
|
||||
expr: strataproxy_servers_registered == 0 or sum(strataproxy_server_health{status="UP"}) == 0
|
||||
for: 2m
|
||||
labels:
|
||||
severity: critical
|
||||
annotations:
|
||||
summary: "StrataProxy has no ready backend"
|
||||
description: "No registered backend is currently healthy enough to receive players."
|
||||
|
||||
- alert: StrataProxyRouteFailures
|
||||
expr: increase(strataproxy_routes_failed_total[5m]) > 0
|
||||
for: 2m
|
||||
labels:
|
||||
severity: warning
|
||||
annotations:
|
||||
summary: "StrataProxy route failures detected"
|
||||
description: "The proxy failed to route at least one player in the last five minutes."
|
||||
|
||||
- alert: StrataProxyBackendConnectFailures
|
||||
expr: increase(strataproxy_backend_connect_failures_total[5m]) > 5
|
||||
for: 2m
|
||||
labels:
|
||||
severity: warning
|
||||
annotations:
|
||||
summary: "StrataProxy backend connection failures"
|
||||
description: "Backend connection failures are above the expected baseline."
|
||||
|
||||
- name: strataproxy.performance
|
||||
rules:
|
||||
- alert: StrataProxyEventLoopDelayHigh
|
||||
expr: strataproxy_event_loop_delay_seconds > 0.025
|
||||
for: 2m
|
||||
labels:
|
||||
severity: warning
|
||||
annotations:
|
||||
summary: "StrataProxy event loop delay is high"
|
||||
description: "Observed event loop delay is above 25ms. Check CPU saturation, blocking work, and compression rewrite policy."
|
||||
|
||||
- alert: StrataProxyEventLoopDelayCritical
|
||||
expr: strataproxy_event_loop_delay_seconds > 0.100
|
||||
for: 1m
|
||||
labels:
|
||||
severity: critical
|
||||
annotations:
|
||||
summary: "StrataProxy event loop delay is critical"
|
||||
description: "Observed event loop delay is above 100ms and player forwarding latency is likely affected."
|
||||
|
||||
- alert: StrataProxyHeapPressure
|
||||
expr: strataproxy_jvm_heap_used_bytes / strataproxy_jvm_heap_max_bytes > 0.85
|
||||
for: 5m
|
||||
labels:
|
||||
severity: warning
|
||||
annotations:
|
||||
summary: "StrataProxy JVM heap pressure"
|
||||
description: "Heap usage is above 85% of max heap for five minutes."
|
||||
|
||||
- alert: StrataProxyDirectMemoryPressure
|
||||
expr: strataproxy_pooled_direct_memory_bytes > 1610612736
|
||||
for: 5m
|
||||
labels:
|
||||
severity: warning
|
||||
annotations:
|
||||
summary: "StrataProxy pooled direct memory is high"
|
||||
description: "Pooled direct memory is above 1.5GiB. Compare with -XX:MaxDirectMemorySize and active connection load."
|
||||
|
||||
- alert: StrataProxyRelayBackpressure
|
||||
expr: increase(strataproxy_relay_backpressure_events_total[5m]) > 0
|
||||
for: 5m
|
||||
labels:
|
||||
severity: warning
|
||||
annotations:
|
||||
summary: "StrataProxy relay backpressure"
|
||||
description: "One or more relay directions observed non-writable target channels."
|
||||
|
||||
- name: strataproxy.security
|
||||
rules:
|
||||
- alert: StrataProxyRejectedConnectionSpike
|
||||
expr: increase(strataproxy_connections_rejected_total[5m]) > 100
|
||||
for: 2m
|
||||
labels:
|
||||
severity: warning
|
||||
annotations:
|
||||
summary: "StrataProxy rejected connection spike"
|
||||
description: "Connection admission is rejecting a high number of clients; check storms and per-address limits."
|
||||
|
||||
- alert: StrataProxyPacketAnomalies
|
||||
expr: increase(strataproxy_packet_anomalies_total[5m]) > 0
|
||||
for: 2m
|
||||
labels:
|
||||
severity: warning
|
||||
annotations:
|
||||
summary: "StrataProxy packet anomalies detected"
|
||||
description: "Packet anomaly rules are firing. Inspect /packet-anomalies or strataproxy-admin anomalies."
|
||||
|
||||
- alert: StrataProxyCustomPayloadFlood
|
||||
expr: increase(strataproxy_packet_anomalies_total{rule="custom-payload-flood"}[5m]) > 0
|
||||
for: 1m
|
||||
labels:
|
||||
severity: warning
|
||||
annotations:
|
||||
summary: "StrataProxy custom payload flood"
|
||||
description: "A player connection triggered the custom payload flood rule."
|
||||
@@ -0,0 +1,40 @@
|
||||
# Performance Profiles
|
||||
|
||||
This directory defines repeatable performance profiles for StrataProxy release validation.
|
||||
|
||||
Profiles are intentionally versioned as JSON so CI, load-test runners, and operators can record comparable results without copying ad hoc commands from documentation.
|
||||
|
||||
## Profiles
|
||||
|
||||
- `profiles/smoke.json`: small local sanity profile for CI and developer machines.
|
||||
- `profiles/acceptance-linux-native-java25.json`: production-scale profile for Linux native Netty transport, Java 25, 10k idle connections, and 2k active traffic connections.
|
||||
- `profiles/compression-rewrite-linux-native-java25.json`: focused profile for live compression rewrite throughput and split compressed-frame buffering.
|
||||
|
||||
## Result Recording
|
||||
|
||||
Use `profile-result-template.json` to capture evidence after a run. A complete result should include:
|
||||
|
||||
- machine shape, OS, Java version, kernel limits, and JVM options
|
||||
- exact StrataProxy version and git revision when available
|
||||
- proxy config hash
|
||||
- `strataproxy-query` command output JSON
|
||||
- selected `/metrics`, `/overview`, and `strataproxy-admin slo` outputs
|
||||
- pass/fail conclusion against the profile gates
|
||||
|
||||
Do not mark a profile as passed from partial command output. The result must include the configured gates and the observed values used to evaluate them.
|
||||
|
||||
## Example
|
||||
|
||||
```powershell
|
||||
.\gradlew.bat --no-daemon :proxy-app:installDist :proxy-query:installDist :proxy-admin-cli:installDist
|
||||
.\proxy-query\build\install\strataproxy-query\bin\strataproxy-query.bat --host 127.0.0.1 --port 25577 load-suite --profile acceptance --virtual-host play.example.net --parallelism 512 --json
|
||||
.\proxy-admin-cli\build\install\strataproxy-admin\bin\strataproxy-admin.bat --base-url http://127.0.0.1:8080 slo --require-ready --max-event-loop-delay-ms 5 --max-rejected 0 --max-anomalies 0
|
||||
```
|
||||
|
||||
For Linux hosts, raise file descriptors before running the acceptance profile:
|
||||
|
||||
```bash
|
||||
ulimit -n 1048576
|
||||
```
|
||||
|
||||
The systemd profile already sets `LimitNOFILE=1048576`.
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"profileId": "",
|
||||
"strataproxyVersion": "",
|
||||
"gitRevision": "",
|
||||
"timestamp": "",
|
||||
"host": {
|
||||
"os": "",
|
||||
"kernel": "",
|
||||
"cpuModel": "",
|
||||
"logicalCores": 0,
|
||||
"memoryGiB": 0,
|
||||
"ulimitNofile": 0
|
||||
},
|
||||
"runtime": {
|
||||
"javaVersion": "",
|
||||
"javaVendor": "",
|
||||
"jvmOptions": [],
|
||||
"nativeTransport": false,
|
||||
"configSha256": ""
|
||||
},
|
||||
"commands": [
|
||||
{
|
||||
"name": "",
|
||||
"exitCode": 0,
|
||||
"stdoutJson": {},
|
||||
"stderr": ""
|
||||
}
|
||||
],
|
||||
"observations": {
|
||||
"metricsSnapshot": "",
|
||||
"overviewJson": {},
|
||||
"sloOutput": ""
|
||||
},
|
||||
"gateResults": [
|
||||
{
|
||||
"name": "",
|
||||
"expected": "",
|
||||
"observed": "",
|
||||
"passed": false
|
||||
}
|
||||
],
|
||||
"passed": false,
|
||||
"notes": ""
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"id": "acceptance-linux-native-java25",
|
||||
"description": "Production-scale acceptance profile for Linux native transport and Java 25.",
|
||||
"java": "25",
|
||||
"transport": "linux-native-epoll",
|
||||
"requiresNativeTransport": true,
|
||||
"hostPrerequisites": {
|
||||
"ulimitNofile": 1048576,
|
||||
"recommendedCores": 8,
|
||||
"recommendedMemoryGiB": 8
|
||||
},
|
||||
"jvmOptions": [
|
||||
"-Xms2g",
|
||||
"-Xmx2g",
|
||||
"-XX:MaxDirectMemorySize=2g",
|
||||
"-XX:+UseZGC",
|
||||
"-XX:+AlwaysPreTouch",
|
||||
"-XX:+ExitOnOutOfMemoryError"
|
||||
],
|
||||
"commands": [
|
||||
{
|
||||
"name": "load-suite-acceptance",
|
||||
"tool": "strataproxy-query",
|
||||
"args": [
|
||||
"--host", "127.0.0.1",
|
||||
"--port", "25577",
|
||||
"load-suite",
|
||||
"--profile", "acceptance",
|
||||
"--virtual-host", "play.example.net",
|
||||
"--parallelism", "512",
|
||||
"--json"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "slo-gate",
|
||||
"tool": "strataproxy-admin",
|
||||
"args": [
|
||||
"--base-url", "http://127.0.0.1:8080",
|
||||
"slo",
|
||||
"--require-ready",
|
||||
"--max-event-loop-delay-ms", "5",
|
||||
"--max-rejected", "0",
|
||||
"--max-anomalies", "0"
|
||||
]
|
||||
}
|
||||
],
|
||||
"gates": {
|
||||
"minIdleConnections": 10000,
|
||||
"minActiveConnections": 2000,
|
||||
"minTrafficPackets": 400000,
|
||||
"maxFailed": 0,
|
||||
"maxP99ProxyForwardingLatencyMillis": 5,
|
||||
"maxEventLoopDelayMillis": 5,
|
||||
"maxRejectedConnections": 0,
|
||||
"maxPacketAnomalies": 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"id": "compression-rewrite-linux-native-java25",
|
||||
"description": "Focused profile for opt-in live compression rewrite throughput and split compressed-frame handling.",
|
||||
"java": "25",
|
||||
"transport": "linux-native-epoll",
|
||||
"requiresNativeTransport": true,
|
||||
"configOverrides": {
|
||||
"compression.rewriteEnabled": true,
|
||||
"compression.rewriteMaxEventLoopDelayMillis": 25
|
||||
},
|
||||
"jvmOptions": [
|
||||
"-Xms2g",
|
||||
"-Xmx2g",
|
||||
"-XX:MaxDirectMemorySize=2g",
|
||||
"-XX:+UseZGC",
|
||||
"-XX:+AlwaysPreTouch",
|
||||
"-XX:+ExitOnOutOfMemoryError"
|
||||
],
|
||||
"commands": [
|
||||
{
|
||||
"name": "compression-rewrite-load",
|
||||
"tool": "strataproxy-query",
|
||||
"args": [
|
||||
"--host", "127.0.0.1",
|
||||
"--port", "25577",
|
||||
"compression-rewrite-load",
|
||||
"--connections", "1000",
|
||||
"--parallelism", "256",
|
||||
"--virtual-host", "play.example.net",
|
||||
"--packets-per-connection", "200",
|
||||
"--payload-bytes", "1024",
|
||||
"--threshold", "256",
|
||||
"--split-frames",
|
||||
"--min-handshaken", "1000",
|
||||
"--min-negotiated", "1000",
|
||||
"--min-packets-sent", "200000",
|
||||
"--max-failed", "0",
|
||||
"--json"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "slo-gate",
|
||||
"tool": "strataproxy-admin",
|
||||
"args": [
|
||||
"--base-url", "http://127.0.0.1:8080",
|
||||
"slo",
|
||||
"--require-ready",
|
||||
"--max-event-loop-delay-ms", "25",
|
||||
"--max-rejected", "0",
|
||||
"--max-anomalies", "0"
|
||||
]
|
||||
}
|
||||
],
|
||||
"gates": {
|
||||
"minActiveConnections": 1000,
|
||||
"minNegotiatedCompressionConnections": 1000,
|
||||
"minCompressedPackets": 200000,
|
||||
"maxFailed": 0,
|
||||
"maxEventLoopDelayMillis": 25,
|
||||
"maxRejectedConnections": 0,
|
||||
"maxPacketAnomalies": 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"id": "smoke",
|
||||
"description": "Small local sanity profile for CI and developer machines.",
|
||||
"java": "25",
|
||||
"transport": "any",
|
||||
"requiresNativeTransport": false,
|
||||
"jvmOptions": [
|
||||
"-Xms1g",
|
||||
"-Xmx1g",
|
||||
"-XX:MaxDirectMemorySize=1g",
|
||||
"-XX:+UseZGC"
|
||||
],
|
||||
"commands": [
|
||||
{
|
||||
"name": "load-suite-smoke",
|
||||
"tool": "strataproxy-query",
|
||||
"args": [
|
||||
"--host", "127.0.0.1",
|
||||
"--port", "25577",
|
||||
"load-suite",
|
||||
"--profile", "smoke",
|
||||
"--idle-connections", "100",
|
||||
"--active-connections", "10",
|
||||
"--traffic-packets-per-connection", "10",
|
||||
"--parallelism", "64",
|
||||
"--json"
|
||||
]
|
||||
}
|
||||
],
|
||||
"gates": {
|
||||
"minIdleConnections": 100,
|
||||
"minActiveConnections": 10,
|
||||
"minTrafficPackets": 100,
|
||||
"maxFailed": 0,
|
||||
"maxEventLoopDelayMillis": 25,
|
||||
"maxRejectedConnections": 0,
|
||||
"maxPacketAnomalies": 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
# JVM options read by the Gradle application start script.
|
||||
JAVA_OPTS="-Xms2g -Xmx2g -XX:MaxDirectMemorySize=2g -XX:+UseZGC -XX:+AlwaysPreTouch -XX:+ExitOnOutOfMemoryError -Dio.netty.leakDetection.level=disabled"
|
||||
|
||||
# Used by /opt/strataproxy/config/strataproxy-production.yml.
|
||||
STRATAPROXY_ADMIN_TOKEN="change-me"
|
||||
STRATAPROXY_DATA_DIR="/var/lib/strataproxy"
|
||||
@@ -0,0 +1,28 @@
|
||||
[Unit]
|
||||
Description=StrataProxy Minecraft Proxy
|
||||
Documentation=file:/opt/strataproxy/deployment/README.md
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=strataproxy
|
||||
Group=strataproxy
|
||||
WorkingDirectory=/opt/strataproxy
|
||||
EnvironmentFile=/etc/strataproxy/strataproxy.env
|
||||
ExecStart=/opt/strataproxy/bin/strataproxy /opt/strataproxy/config/strataproxy-production.yml
|
||||
Restart=on-failure
|
||||
RestartSec=5s
|
||||
TimeoutStopSec=30s
|
||||
LimitNOFILE=1048576
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ReadWritePaths=/var/lib/strataproxy /var/log/strataproxy
|
||||
AmbientCapabilities=CAP_NET_BIND_SERVICE
|
||||
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
|
||||
SuccessExitStatus=130 143
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,5 @@
|
||||
org.gradle.configuration-cache=true
|
||||
org.gradle.configuration-cache.problems=warn
|
||||
org.gradle.caching=true
|
||||
org.gradle.parallel=true
|
||||
org.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=512m -Dfile.encoding=UTF-8
|
||||
@@ -0,0 +1,17 @@
|
||||
[versions]
|
||||
junit = "5.14.1"
|
||||
netty = "4.2.2.Final"
|
||||
jackson = "2.20.0"
|
||||
picocli = "4.7.7"
|
||||
|
||||
[libraries]
|
||||
junit-bom = { module = "org.junit:junit-bom", version.ref = "junit" }
|
||||
junit-jupiter = { module = "org.junit.jupiter:junit-jupiter" }
|
||||
netty-transport = { module = "io.netty:netty-transport", version.ref = "netty" }
|
||||
netty-transport-classes-epoll = { module = "io.netty:netty-transport-classes-epoll", version.ref = "netty" }
|
||||
netty-transport-classes-kqueue = { module = "io.netty:netty-transport-classes-kqueue", version.ref = "netty" }
|
||||
netty-codec = { module = "io.netty:netty-codec", version.ref = "netty" }
|
||||
netty-handler = { module = "io.netty:netty-handler", version.ref = "netty" }
|
||||
jackson-databind = { module = "com.fasterxml.jackson.core:jackson-databind", version.ref = "jackson" }
|
||||
jackson-yaml = { module = "com.fasterxml.jackson.dataformat:jackson-dataformat-yaml", version.ref = "jackson" }
|
||||
cli-picocli = { module = "info.picocli:picocli", version.ref = "picocli" }
|
||||
Vendored
BIN
Binary file not shown.
+9
@@ -0,0 +1,9 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip
|
||||
networkTimeout=10000
|
||||
retries=0
|
||||
retryBackOffMs=500
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
@@ -0,0 +1,248 @@
|
||||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015 the original authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# gradlew start up script for POSIX generated by Gradle.
|
||||
#
|
||||
# Important for running:
|
||||
#
|
||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||
# noncompliant, but you have some other compliant shell such as ksh or
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh gradlew
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX shell features:
|
||||
# * functions;
|
||||
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||
# * compound commands having a testable exit status, especially «case»;
|
||||
# * various built-in commands including «command», «set», and «ulimit».
|
||||
#
|
||||
# Important for patching:
|
||||
#
|
||||
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||
#
|
||||
# The "traditional" practice of packing multiple parameters into a
|
||||
# space-separated string is a well documented source of bugs and security
|
||||
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||
# options in "$@", and eventually passing that to Java.
|
||||
#
|
||||
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||
# see the in-line comments for details.
|
||||
#
|
||||
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
|
||||
# Resolve links: $0 may be a link
|
||||
app_path=$0
|
||||
|
||||
# Need this for daisy-chained symlinks.
|
||||
while
|
||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||
[ -h "$app_path" ]
|
||||
do
|
||||
ls=$( ls -ld "$app_path" )
|
||||
link=${ls#*' -> '}
|
||||
case $link in #(
|
||||
/*) app_path=$link ;; #(
|
||||
*) app_path=$APP_HOME$link ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# This is normally unused
|
||||
# shellcheck disable=SC2034
|
||||
APP_BASE_NAME=${0##*/}
|
||||
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD=maximum
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
} >&2
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
} >&2
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "$( uname )" in #(
|
||||
CYGWIN* ) cygwin=true ;; #(
|
||||
Darwin* ) darwin=true ;; #(
|
||||
MSYS* | MINGW* ) msys=true ;; #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||
else
|
||||
JAVACMD=$JAVA_HOME/bin/java
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD=java
|
||||
if ! command -v java >/dev/null 2>&1
|
||||
then
|
||||
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||
case $MAX_FD in #(
|
||||
max*)
|
||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
MAX_FD=$( ulimit -H -n ) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
esac
|
||||
case $MAX_FD in #(
|
||||
'' | soft) :;; #(
|
||||
*)
|
||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC2039,SC3045
|
||||
ulimit -n "$MAX_FD" ||
|
||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||
esac
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command, stacking in reverse order:
|
||||
# * args from the command line
|
||||
# * the main class name
|
||||
# * -classpath
|
||||
# * -D...appname settings
|
||||
# * --module-path (only if needed)
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if "$cygwin" || "$msys" ; then
|
||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||
|
||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
for arg do
|
||||
if
|
||||
case $arg in #(
|
||||
-*) false ;; # don't mess with options #(
|
||||
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||
[ -e "$t" ] ;; #(
|
||||
*) false ;;
|
||||
esac
|
||||
then
|
||||
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||
fi
|
||||
# Roll the args list around exactly as many times as the number of
|
||||
# args, so each arg winds up back in the position where it started, but
|
||||
# possibly modified.
|
||||
#
|
||||
# NB: a `for` loop captures its iteration list before it begins, so
|
||||
# changing the positional parameters here affects neither the number of
|
||||
# iterations, nor the values presented in `arg`.
|
||||
shift # remove old arg
|
||||
set -- "$@" "$arg" # push replacement arg
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Collect all arguments for the java command:
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||
# and any embedded shellness will be escaped.
|
||||
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||
# treated as '${Hostname}' itself on the command line.
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
if ! command -v xargs >/dev/null 2>&1
|
||||
then
|
||||
die "xargs is not available"
|
||||
fi
|
||||
|
||||
# Use "xargs" to parse quoted args.
|
||||
#
|
||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||
#
|
||||
# In Bash we could simply go:
|
||||
#
|
||||
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||
# set -- "${ARGS[@]}" "$@"
|
||||
#
|
||||
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||
# character that might be a shell metacharacter, then use eval to reverse
|
||||
# that process (while maintaining the separation between arguments), and wrap
|
||||
# the whole thing up as a single "set" statement.
|
||||
#
|
||||
# This will of course break if any of these variables contains a newline or
|
||||
# an unmatched quote.
|
||||
#
|
||||
|
||||
eval "set -- $(
|
||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||
xargs -n1 |
|
||||
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||
tr '\n' ' '
|
||||
)" '"$@"'
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
Vendored
+82
@@ -0,0 +1,82 @@
|
||||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
@rem SPDX-License-Identifier: Apache-2.0
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem gradlew startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables, and ensure extensions are enabled
|
||||
setlocal EnableExtensions
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
"%COMSPEC%" /c exit 1
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
"%COMSPEC%" /c exit 1
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
|
||||
|
||||
@rem Execute gradlew
|
||||
@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
|
||||
@rem which allows us to clear the local environment before executing the java command
|
||||
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
|
||||
|
||||
:exitWithErrorLevel
|
||||
@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
|
||||
"%COMSPEC%" /c exit %ERRORLEVEL%
|
||||
@@ -0,0 +1,13 @@
|
||||
plugins {
|
||||
id("strataproxy.java-library-conventions")
|
||||
}
|
||||
|
||||
dependencies {
|
||||
api(project(":proxy-api"))
|
||||
api(project(":proxy-observability"))
|
||||
implementation(project(":proxy-observability"))
|
||||
implementation(project(":proxy-routing"))
|
||||
implementation(libs.findLibrary("jackson-databind").get())
|
||||
testImplementation(project(":proxy-bootstrap"))
|
||||
testImplementation(project(":proxy-registry"))
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,75 @@
|
||||
package dev.strataproxy.admin;
|
||||
|
||||
import dev.strataproxy.api.server.DrainPolicy;
|
||||
import dev.strataproxy.api.server.RegisteredServer;
|
||||
import dev.strataproxy.api.server.ServerDescriptor;
|
||||
import dev.strataproxy.api.server.ServerHealth;
|
||||
import dev.strataproxy.api.server.ServerLoad;
|
||||
import dev.strataproxy.api.server.ServerRegistry;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Collection;
|
||||
import java.util.Optional;
|
||||
|
||||
public final class AdminRegistryService {
|
||||
private final ServerRegistry registry;
|
||||
private final RegistryStore store;
|
||||
|
||||
public AdminRegistryService(ServerRegistry registry, RegistryStore store) {
|
||||
this.registry = registry;
|
||||
this.store = store == null ? NoopRegistryStore.INSTANCE : store;
|
||||
}
|
||||
|
||||
public RegisteredServer register(ServerDescriptor descriptor) throws IOException {
|
||||
var previous = registry.get(descriptor.name()).map(RegisteredServer::descriptor);
|
||||
var server = registry.registerOrReplace(descriptor);
|
||||
try {
|
||||
persist();
|
||||
} catch (IOException exception) {
|
||||
if (previous.isPresent()) {
|
||||
registry.registerOrReplace(previous.get());
|
||||
} else {
|
||||
registry.unregister(descriptor.name(), DrainPolicy.rejectNew());
|
||||
}
|
||||
throw exception;
|
||||
}
|
||||
return server;
|
||||
}
|
||||
|
||||
public boolean unregister(String name, DrainPolicy policy) throws IOException {
|
||||
var changed = registry.unregister(name, policy);
|
||||
if (changed) {
|
||||
persist();
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
public void updateHealth(String name, ServerHealth health) {
|
||||
registry.updateHealth(name, health);
|
||||
}
|
||||
|
||||
public void updateLoad(String name, ServerLoad load) {
|
||||
registry.updateLoad(name, load);
|
||||
}
|
||||
|
||||
public boolean updateDrainMode(String name, boolean drainMode) throws IOException {
|
||||
if (registry.get(name).isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
registry.updateDrainMode(name, drainMode);
|
||||
persist();
|
||||
return true;
|
||||
}
|
||||
|
||||
public Optional<RegisteredServer> get(String name) {
|
||||
return registry.get(name);
|
||||
}
|
||||
|
||||
public Collection<RegisteredServer> snapshot() {
|
||||
return registry.snapshot();
|
||||
}
|
||||
|
||||
public void persist() throws IOException {
|
||||
store.save(registry.snapshot());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package dev.strataproxy.admin;
|
||||
|
||||
import dev.strataproxy.api.server.RegisteredServer;
|
||||
import dev.strataproxy.observability.MetricEvent;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
public record AdminSnapshot(
|
||||
int onlinePlayers,
|
||||
int registeredServers,
|
||||
List<RegisteredServer> servers,
|
||||
List<MetricEvent> recentEvents,
|
||||
Instant createdAt) {
|
||||
public AdminSnapshot {
|
||||
servers = List.copyOf(servers == null ? List.of() : servers);
|
||||
recentEvents = List.copyOf(recentEvents == null ? List.of() : recentEvents);
|
||||
createdAt = createdAt == null ? Instant.now() : createdAt;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package dev.strataproxy.admin;
|
||||
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import dev.strataproxy.api.server.ProtocolRange;
|
||||
import dev.strataproxy.api.server.RegisteredServer;
|
||||
import dev.strataproxy.api.server.ServerCapability;
|
||||
import dev.strataproxy.api.server.ServerDescriptor;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
public final class JsonRegistryStore implements RegistryStore {
|
||||
private final ObjectMapper mapper = new ObjectMapper()
|
||||
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
|
||||
private final Path path;
|
||||
|
||||
public JsonRegistryStore(Path path) {
|
||||
this.path = path;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ServerDescriptor> load() throws IOException {
|
||||
if (Files.notExists(path)) {
|
||||
return List.of();
|
||||
}
|
||||
var entries = mapper.readValue(path.toFile(), RegistryEntry[].class);
|
||||
return java.util.Arrays.stream(entries).map(RegistryEntry::toDescriptor).toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void save(java.util.Collection<RegisteredServer> servers) throws IOException {
|
||||
var parent = path.toAbsolutePath().getParent();
|
||||
if (parent != null) {
|
||||
Files.createDirectories(parent);
|
||||
}
|
||||
var entries = servers.stream()
|
||||
.map(server -> RegistryEntry.from(server.descriptor()))
|
||||
.toList();
|
||||
var temp = path.resolveSibling(path.getFileName() + ".tmp");
|
||||
mapper.writerWithDefaultPrettyPrinter().writeValue(temp.toFile(), entries);
|
||||
try {
|
||||
Files.move(temp, path, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
|
||||
} catch (IOException atomicMoveFailed) {
|
||||
Files.move(temp, path, StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
}
|
||||
|
||||
public static final class RegistryEntry {
|
||||
public String name;
|
||||
public String address;
|
||||
public Set<String> tags = Set.of();
|
||||
public Set<String> capabilities = Set.of();
|
||||
public int minProtocol;
|
||||
public int maxProtocol;
|
||||
public String protocolName = "any";
|
||||
public int weight = 100;
|
||||
public int softCapacity = 500;
|
||||
public int hardCapacity = 600;
|
||||
public boolean drainMode = false;
|
||||
public Map<String, String> metadata = Map.of();
|
||||
|
||||
static RegistryEntry from(ServerDescriptor descriptor) {
|
||||
var entry = new RegistryEntry();
|
||||
entry.name = descriptor.name();
|
||||
entry.address = descriptor.address().getHostString() + ":" + descriptor.address().getPort();
|
||||
entry.tags = descriptor.tags();
|
||||
entry.capabilities = descriptor.capabilities().stream().map(Enum::name).collect(Collectors.toUnmodifiableSet());
|
||||
entry.minProtocol = descriptor.protocolRange().minProtocol();
|
||||
entry.maxProtocol = descriptor.protocolRange().maxProtocol();
|
||||
entry.protocolName = descriptor.protocolRange().displayName();
|
||||
entry.weight = descriptor.weight();
|
||||
entry.softCapacity = descriptor.softCapacity();
|
||||
entry.hardCapacity = descriptor.hardCapacity();
|
||||
entry.drainMode = descriptor.drainMode();
|
||||
entry.metadata = descriptor.metadata();
|
||||
return entry;
|
||||
}
|
||||
|
||||
ServerDescriptor toDescriptor() {
|
||||
return new ServerDescriptor(
|
||||
name,
|
||||
parseAddress(address, 25565),
|
||||
tags,
|
||||
capabilities.stream().map(RegistryEntry::parseCapability).collect(Collectors.toUnmodifiableSet()),
|
||||
new ProtocolRange(minProtocol, maxProtocol, protocolName == null || protocolName.isBlank() ? "any" : protocolName),
|
||||
weight,
|
||||
softCapacity,
|
||||
hardCapacity,
|
||||
drainMode,
|
||||
metadata);
|
||||
}
|
||||
|
||||
private static ServerCapability parseCapability(String value) {
|
||||
return ServerCapability.valueOf(value.trim().replace('-', '_').toUpperCase(Locale.ROOT));
|
||||
}
|
||||
}
|
||||
|
||||
private static InetSocketAddress parseAddress(String value, int defaultPort) {
|
||||
if (value == null || value.isBlank()) {
|
||||
throw new IllegalArgumentException("address must not be blank");
|
||||
}
|
||||
var trimmed = value.trim();
|
||||
var splitAt = trimmed.lastIndexOf(':');
|
||||
if (splitAt <= 0) {
|
||||
return new InetSocketAddress(trimmed, defaultPort);
|
||||
}
|
||||
return new InetSocketAddress(trimmed.substring(0, splitAt), Integer.parseInt(trimmed.substring(splitAt + 1)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package dev.strataproxy.admin;
|
||||
|
||||
import dev.strataproxy.api.server.RegisteredServer;
|
||||
import dev.strataproxy.api.server.ServerDescriptor;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
|
||||
public enum NoopRegistryStore implements RegistryStore {
|
||||
INSTANCE;
|
||||
|
||||
@Override
|
||||
public List<ServerDescriptor> load() {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void save(Collection<RegisteredServer> servers) throws IOException {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package dev.strataproxy.admin;
|
||||
|
||||
import dev.strataproxy.api.server.RegisteredServer;
|
||||
import dev.strataproxy.api.server.ServerDescriptor;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
|
||||
public interface RegistryStore {
|
||||
List<ServerDescriptor> load() throws IOException;
|
||||
|
||||
void save(Collection<RegisteredServer> servers) throws IOException;
|
||||
}
|
||||
@@ -0,0 +1,974 @@
|
||||
package dev.strataproxy.admin;
|
||||
|
||||
import dev.strataproxy.observability.ProxyMetrics;
|
||||
import dev.strataproxy.registry.InMemoryServerRegistry;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import javax.net.ssl.SSLContext;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
final class AdminHttpServerTest {
|
||||
@Test
|
||||
void closeIsIdempotent() throws Exception {
|
||||
var registry = new InMemoryServerRegistry();
|
||||
var admin = new AdminHttpServer(new InetSocketAddress("127.0.0.1", 0), registry, new ProxyMetrics());
|
||||
admin.start();
|
||||
|
||||
admin.close();
|
||||
admin.close();
|
||||
}
|
||||
|
||||
@Test
|
||||
void startsWithHttpsContext() throws Exception {
|
||||
var registry = new InMemoryServerRegistry();
|
||||
try (var admin = new AdminHttpServer(
|
||||
new InetSocketAddress("127.0.0.1", 0),
|
||||
new AdminRegistryService(registry, NoopRegistryStore.INSTANCE),
|
||||
new ProxyMetrics(),
|
||||
"",
|
||||
50,
|
||||
true,
|
||||
SSLContext.getDefault(),
|
||||
false)) {
|
||||
admin.start();
|
||||
|
||||
assertTrue(admin.bindAddress().getPort() > 0);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void supportsDynamicServerLifecycle() throws Exception {
|
||||
var registry = new InMemoryServerRegistry();
|
||||
try (var admin = new AdminHttpServer(new InetSocketAddress("127.0.0.1", 0), registry, new ProxyMetrics())) {
|
||||
admin.start();
|
||||
var base = "http://" + admin.bindAddress().getHostString() + ":" + admin.bindAddress().getPort();
|
||||
var client = HttpClient.newHttpClient();
|
||||
|
||||
var register = post(client, base + "/servers", """
|
||||
{
|
||||
"name": "survival-1",
|
||||
"address": "127.0.0.1:25566",
|
||||
"tags": ["survival"],
|
||||
"capabilities": ["large-payload"],
|
||||
"protocolRange": "763",
|
||||
"metadata": {"host": "survival.local"}
|
||||
}
|
||||
""");
|
||||
assertEquals(201, register.statusCode());
|
||||
assertTrue(register.body().contains("survival-1"));
|
||||
|
||||
var health = post(client, base + "/servers/survival-1/health", """
|
||||
{"status": "DEGRADED", "backendPingMillis": 42, "recentFailureRate": 0.1, "reason": "test"}
|
||||
""");
|
||||
assertEquals(200, health.statusCode());
|
||||
assertTrue(health.body().contains("DEGRADED"));
|
||||
|
||||
var load = post(client, base + "/servers/survival-1/load", """
|
||||
{"players": 12, "softCapacity": 100, "hardCapacity": 120, "packetsPerSecond": 300}
|
||||
""");
|
||||
assertEquals(200, load.statusCode());
|
||||
assertTrue(load.body().contains("\"players\":12"));
|
||||
|
||||
var drain = post(client, base + "/servers/survival-1/drain", "{}");
|
||||
assertEquals(200, drain.statusCode());
|
||||
assertTrue(drain.body().contains("true"));
|
||||
|
||||
var drained = client.send(HttpRequest.newBuilder(URI.create(base + "/servers/survival-1")).GET().build(), HttpResponse.BodyHandlers.ofString());
|
||||
assertEquals(200, drained.statusCode());
|
||||
assertTrue(drained.body().contains("\"draining\":true"));
|
||||
|
||||
var undrain = post(client, base + "/servers/survival-1/undrain", "{}");
|
||||
assertEquals(200, undrain.statusCode());
|
||||
assertTrue(undrain.body().contains("\"draining\":false"));
|
||||
|
||||
var remove = delete(client, base + "/servers/survival-1");
|
||||
assertEquals(200, remove.statusCode());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void protectsAdminEndpointsWhenBearerTokenIsConfigured() throws Exception {
|
||||
var registry = new InMemoryServerRegistry();
|
||||
try (var admin = new AdminHttpServer(new InetSocketAddress("127.0.0.1", 0), registry, new ProxyMetrics(), "secret-token")) {
|
||||
admin.start();
|
||||
var base = "http://" + admin.bindAddress().getHostString() + ":" + admin.bindAddress().getPort();
|
||||
var client = HttpClient.newHttpClient();
|
||||
|
||||
var health = client.send(HttpRequest.newBuilder(URI.create(base + "/healthz")).GET().build(), HttpResponse.BodyHandlers.ofString());
|
||||
assertEquals(200, health.statusCode());
|
||||
var readiness = client.send(HttpRequest.newBuilder(URI.create(base + "/readyz")).GET().build(), HttpResponse.BodyHandlers.ofString());
|
||||
assertEquals(503, readiness.statusCode());
|
||||
assertTrue(readiness.body().contains("\"status\":\"NOT_READY\""));
|
||||
|
||||
var unauthorized = post(client, base + "/servers", """
|
||||
{"name":"blocked","address":"127.0.0.1:25565"}
|
||||
""");
|
||||
assertEquals(401, unauthorized.statusCode());
|
||||
|
||||
var authorized = post(client, base + "/servers", """
|
||||
{"name":"allowed","address":"127.0.0.1:25565"}
|
||||
""", "secret-token");
|
||||
assertEquals(201, authorized.statusCode());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void readinessReflectsRoutableBackendAvailability() throws Exception {
|
||||
var registry = new InMemoryServerRegistry();
|
||||
try (var admin = new AdminHttpServer(new InetSocketAddress("127.0.0.1", 0), registry, new ProxyMetrics())) {
|
||||
admin.start();
|
||||
var base = "http://" + admin.bindAddress().getHostString() + ":" + admin.bindAddress().getPort();
|
||||
var client = HttpClient.newHttpClient();
|
||||
|
||||
var empty = client.send(HttpRequest.newBuilder(URI.create(base + "/readyz")).GET().build(), HttpResponse.BodyHandlers.ofString());
|
||||
assertEquals(503, empty.statusCode());
|
||||
assertTrue(empty.body().contains("\"status\":\"NOT_READY\""));
|
||||
assertTrue(empty.body().contains("\"readyServers\":0"));
|
||||
assertTrue(empty.body().contains("\"registeredServers\":0"));
|
||||
|
||||
registry.register(server("survival-1", false, 100, 120));
|
||||
var ready = client.send(HttpRequest.newBuilder(URI.create(base + "/readyz")).GET().build(), HttpResponse.BodyHandlers.ofString());
|
||||
assertEquals(200, ready.statusCode());
|
||||
assertTrue(ready.body().contains("\"status\":\"READY\""));
|
||||
assertTrue(ready.body().contains("\"readyServers\":1"));
|
||||
assertTrue(ready.body().contains("\"registeredServers\":1"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void readinessRejectsDrainingDownAndHardFullBackends() throws Exception {
|
||||
var registry = new InMemoryServerRegistry();
|
||||
registry.register(server("drain-1", true, 100, 120));
|
||||
registry.register(server("down-1", false, 100, 120));
|
||||
registry.register(server("full-1", false, 1, 1));
|
||||
registry.updateHealth("down-1", new dev.strataproxy.api.server.ServerHealth(
|
||||
dev.strataproxy.api.server.ServerHealthStatus.DOWN,
|
||||
-1,
|
||||
1.0d,
|
||||
"test",
|
||||
java.time.Instant.now()));
|
||||
registry.updateLoad("full-1", new dev.strataproxy.api.server.ServerLoad(1, 1, 1, 0, 0, 0, 0));
|
||||
try (var admin = new AdminHttpServer(new InetSocketAddress("127.0.0.1", 0), registry, new ProxyMetrics())) {
|
||||
admin.start();
|
||||
var base = "http://" + admin.bindAddress().getHostString() + ":" + admin.bindAddress().getPort();
|
||||
var client = HttpClient.newHttpClient();
|
||||
|
||||
var response = client.send(HttpRequest.newBuilder(URI.create(base + "/readyz")).GET().build(), HttpResponse.BodyHandlers.ofString());
|
||||
|
||||
assertEquals(503, response.statusCode());
|
||||
assertTrue(response.body().contains("\"status\":\"NOT_READY\""));
|
||||
assertTrue(response.body().contains("\"readyServers\":0"));
|
||||
assertTrue(response.body().contains("\"registeredServers\":3"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void previewsRoutingDecisionWithoutMutatingRegistry() throws Exception {
|
||||
var registry = new InMemoryServerRegistry();
|
||||
registry.register(new dev.strataproxy.api.server.ServerDescriptor(
|
||||
"survival-1",
|
||||
new InetSocketAddress("127.0.0.1", 25565),
|
||||
java.util.Set.of("survival", "forge"),
|
||||
java.util.Set.of(dev.strataproxy.api.server.ServerCapability.LARGE_PAYLOAD),
|
||||
new dev.strataproxy.api.server.ProtocolRange(763, 763, "1.20.1"),
|
||||
100,
|
||||
100,
|
||||
120,
|
||||
false,
|
||||
java.util.Map.of("host", "survival.example.net")));
|
||||
try (var admin = new AdminHttpServer(new InetSocketAddress("127.0.0.1", 0), registry, new ProxyMetrics())) {
|
||||
admin.start();
|
||||
var base = "http://" + admin.bindAddress().getHostString() + ":" + admin.bindAddress().getPort();
|
||||
var client = HttpClient.newHttpClient();
|
||||
|
||||
var response = client.send(HttpRequest.newBuilder(URI.create(base
|
||||
+ "/routes/preview?route=survival.example.net&protocolVersion=763&remoteAddress=127.0.0.1:50000&tag=survival&capability=large-payload"))
|
||||
.GET()
|
||||
.build(), HttpResponse.BodyHandlers.ofString());
|
||||
|
||||
assertEquals(200, response.statusCode());
|
||||
assertTrue(response.body().contains("\"selected\":true"));
|
||||
assertTrue(response.body().contains("\"server\":\"survival-1\""));
|
||||
assertTrue(response.body().contains("\"protocolVersion\":763"));
|
||||
assertTrue(response.body().contains("\"candidates\":["));
|
||||
assertTrue(response.body().contains("\"eligible\":true"));
|
||||
assertTrue(response.body().contains("\"effectiveWeight\""));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void previewsRejectedRoutingDecision() throws Exception {
|
||||
var registry = new InMemoryServerRegistry();
|
||||
registry.register(server("survival-1", true, 100, 120));
|
||||
try (var admin = new AdminHttpServer(new InetSocketAddress("127.0.0.1", 0), registry, new ProxyMetrics())) {
|
||||
admin.start();
|
||||
var base = "http://" + admin.bindAddress().getHostString() + ":" + admin.bindAddress().getPort();
|
||||
var client = HttpClient.newHttpClient();
|
||||
|
||||
var response = client.send(HttpRequest.newBuilder(URI.create(base + "/routes/preview?route=survival-1&protocolVersion=763"))
|
||||
.GET()
|
||||
.build(), HttpResponse.BodyHandlers.ofString());
|
||||
|
||||
assertEquals(200, response.statusCode());
|
||||
assertTrue(response.body().contains("\"selected\":false"));
|
||||
assertTrue(response.body().contains("no healthy backend matched route constraints"));
|
||||
assertTrue(response.body().contains("\"reason\":\"draining\""));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void routePreviewRequiresProtocolVersion() throws Exception {
|
||||
var registry = new InMemoryServerRegistry();
|
||||
try (var admin = new AdminHttpServer(new InetSocketAddress("127.0.0.1", 0), registry, new ProxyMetrics())) {
|
||||
admin.start();
|
||||
var base = "http://" + admin.bindAddress().getHostString() + ":" + admin.bindAddress().getPort();
|
||||
var client = HttpClient.newHttpClient();
|
||||
|
||||
var response = client.send(HttpRequest.newBuilder(URI.create(base + "/routes/preview?route=survival"))
|
||||
.GET()
|
||||
.build(), HttpResponse.BodyHandlers.ofString());
|
||||
|
||||
assertEquals(400, response.statusCode());
|
||||
assertTrue(response.body().contains("protocolVersion query parameter is required"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsMalformedJsonAsBadRequest() throws Exception {
|
||||
var registry = new InMemoryServerRegistry();
|
||||
try (var admin = new AdminHttpServer(new InetSocketAddress("127.0.0.1", 0), registry, new ProxyMetrics())) {
|
||||
admin.start();
|
||||
var base = "http://" + admin.bindAddress().getHostString() + ":" + admin.bindAddress().getPort();
|
||||
var client = HttpClient.newHttpClient();
|
||||
|
||||
var response = post(client, base + "/servers", "{");
|
||||
|
||||
assertEquals(400, response.statusCode());
|
||||
assertTrue(response.body().contains("invalid json request"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsInvalidServerRegistrationRequest() throws Exception {
|
||||
var registry = new InMemoryServerRegistry();
|
||||
try (var admin = new AdminHttpServer(new InetSocketAddress("127.0.0.1", 0), registry, new ProxyMetrics())) {
|
||||
admin.start();
|
||||
var base = "http://" + admin.bindAddress().getHostString() + ":" + admin.bindAddress().getPort();
|
||||
var client = HttpClient.newHttpClient();
|
||||
|
||||
var blankName = post(client, base + "/servers", """
|
||||
{"name":" ","address":"127.0.0.1:25565"}
|
||||
""");
|
||||
var badCapacity = post(client, base + "/servers", """
|
||||
{"name":"bad-capacity","address":"127.0.0.1:25565","softCapacity":20,"hardCapacity":10}
|
||||
""");
|
||||
|
||||
assertEquals(400, blankName.statusCode());
|
||||
assertTrue(blankName.body().contains("server name must not be blank"));
|
||||
assertEquals(400, badCapacity.statusCode());
|
||||
assertTrue(badCapacity.body().contains("softCapacity"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void healthAndLoadUpdatesReturnNotFoundForMissingServer() throws Exception {
|
||||
var registry = new InMemoryServerRegistry();
|
||||
try (var admin = new AdminHttpServer(new InetSocketAddress("127.0.0.1", 0), registry, new ProxyMetrics())) {
|
||||
admin.start();
|
||||
var base = "http://" + admin.bindAddress().getHostString() + ":" + admin.bindAddress().getPort();
|
||||
var client = HttpClient.newHttpClient();
|
||||
|
||||
var health = post(client, base + "/servers/missing/health", """
|
||||
{"status":"UP"}
|
||||
""");
|
||||
var load = post(client, base + "/servers/missing/load", """
|
||||
{"players":1}
|
||||
""");
|
||||
|
||||
assertEquals(404, health.statusCode());
|
||||
assertTrue(health.body().contains("server not found"));
|
||||
assertEquals(404, load.statusCode());
|
||||
assertTrue(load.body().contains("server not found"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsInvalidLoadValues() throws Exception {
|
||||
var registry = new InMemoryServerRegistry();
|
||||
registry.register(new dev.strataproxy.api.server.ServerDescriptor(
|
||||
"load-1",
|
||||
new InetSocketAddress("127.0.0.1", 25565),
|
||||
java.util.Set.of(),
|
||||
java.util.Set.of(),
|
||||
new dev.strataproxy.api.server.ProtocolRange(0, Integer.MAX_VALUE, "any"),
|
||||
100,
|
||||
100,
|
||||
120,
|
||||
false,
|
||||
java.util.Map.of()));
|
||||
try (var admin = new AdminHttpServer(new InetSocketAddress("127.0.0.1", 0), registry, new ProxyMetrics())) {
|
||||
admin.start();
|
||||
var base = "http://" + admin.bindAddress().getHostString() + ":" + admin.bindAddress().getPort();
|
||||
var client = HttpClient.newHttpClient();
|
||||
|
||||
var response = post(client, base + "/servers/load-1/load", """
|
||||
{"players":1,"inboundBytesPerSecond":-1}
|
||||
""");
|
||||
|
||||
assertEquals(400, response.statusCode());
|
||||
assertTrue(response.body().contains("traffic values"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void exportsActivePlayerSessions() throws Exception {
|
||||
var registry = new InMemoryServerRegistry();
|
||||
var metrics = new ProxyMetrics();
|
||||
metrics.playerSessionStarted("Steve", "survival-1", "127.0.0.1:50000");
|
||||
try (var admin = new AdminHttpServer(new InetSocketAddress("127.0.0.1", 0), registry, metrics)) {
|
||||
admin.start();
|
||||
var base = "http://" + admin.bindAddress().getHostString() + ":" + admin.bindAddress().getPort();
|
||||
var client = HttpClient.newHttpClient();
|
||||
|
||||
var response = client.send(HttpRequest.newBuilder(URI.create(base + "/player-sessions")).GET().build(), HttpResponse.BodyHandlers.ofString());
|
||||
|
||||
assertEquals(200, response.statusCode());
|
||||
assertTrue(response.body().contains("\"active\":1"));
|
||||
assertTrue(response.body().contains("\"player\":\"Steve\""));
|
||||
assertTrue(response.body().contains("\"server\":\"survival-1\""));
|
||||
assertTrue(response.body().contains("\"remoteAddress\":\"127.0.0.1:50000\""));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void rollsBackRegisterWhenRegistryPersistenceFails() throws Exception {
|
||||
var registry = new InMemoryServerRegistry();
|
||||
try (var admin = new AdminHttpServer(
|
||||
new InetSocketAddress("127.0.0.1", 0),
|
||||
new AdminRegistryService(registry, new FailingRegistryStore()),
|
||||
new ProxyMetrics(),
|
||||
"")) {
|
||||
admin.start();
|
||||
var base = "http://" + admin.bindAddress().getHostString() + ":" + admin.bindAddress().getPort();
|
||||
var client = HttpClient.newHttpClient();
|
||||
|
||||
var response = post(client, base + "/servers", """
|
||||
{"name":"rollback-1","address":"127.0.0.1:25565"}
|
||||
""");
|
||||
|
||||
assertEquals(500, response.statusCode());
|
||||
assertTrue(response.body().contains("persist failed"));
|
||||
assertTrue(registry.snapshot().isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void postServersReplacesExistingDescriptorAndKeepsRuntimeState() throws Exception {
|
||||
var registry = new InMemoryServerRegistry();
|
||||
try (var admin = new AdminHttpServer(new InetSocketAddress("127.0.0.1", 0), registry, new ProxyMetrics())) {
|
||||
admin.start();
|
||||
var base = "http://" + admin.bindAddress().getHostString() + ":" + admin.bindAddress().getPort();
|
||||
var client = HttpClient.newHttpClient();
|
||||
|
||||
var create = post(client, base + "/servers", """
|
||||
{
|
||||
"name": "survival-1",
|
||||
"address": "127.0.0.1:25565",
|
||||
"tags": ["blue"],
|
||||
"softCapacity": 100,
|
||||
"hardCapacity": 120,
|
||||
"metadata": {"host": "blue.local"}
|
||||
}
|
||||
""");
|
||||
assertEquals(201, create.statusCode());
|
||||
var health = post(client, base + "/servers/survival-1/health", """
|
||||
{"status": "DEGRADED", "backendPingMillis": 42, "recentFailureRate": 0.2, "reason": "canary"}
|
||||
""");
|
||||
var load = post(client, base + "/servers/survival-1/load", """
|
||||
{"players": 12, "softCapacity": 100, "hardCapacity": 120, "inboundBytesPerSecond": 1024}
|
||||
""");
|
||||
assertEquals(200, health.statusCode());
|
||||
assertEquals(200, load.statusCode());
|
||||
|
||||
var replace = post(client, base + "/servers", """
|
||||
{
|
||||
"name": "survival-1",
|
||||
"address": "127.0.0.1:25566",
|
||||
"tags": ["green", "survival"],
|
||||
"softCapacity": 200,
|
||||
"hardCapacity": 240,
|
||||
"drainMode": true,
|
||||
"metadata": {"host": "green.local"}
|
||||
}
|
||||
""");
|
||||
assertEquals(200, replace.statusCode());
|
||||
assertTrue(replace.body().contains("\"address\":\"127.0.0.1:25566\""));
|
||||
assertTrue(replace.body().contains("\"green\""));
|
||||
assertTrue(replace.body().contains("\"draining\":true"));
|
||||
assertTrue(replace.body().contains("\"players\":12"));
|
||||
assertTrue(replace.body().contains("\"softCapacity\":200"));
|
||||
assertTrue(replace.body().contains("\"hardCapacity\":240"));
|
||||
assertTrue(replace.body().contains("\"status\":\"DEGRADED\""));
|
||||
|
||||
var fetched = client.send(HttpRequest.newBuilder(URI.create(base + "/servers/survival-1")).GET().build(), HttpResponse.BodyHandlers.ofString());
|
||||
assertEquals(200, fetched.statusCode());
|
||||
assertTrue(fetched.body().contains("\"address\":\"127.0.0.1:25566\""));
|
||||
assertTrue(fetched.body().contains("\"host\":\"green.local\""));
|
||||
assertTrue(fetched.body().contains("\"players\":12"));
|
||||
assertTrue(fetched.body().contains("\"status\":\"DEGRADED\""));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void patchServerPartiallyUpdatesDescriptorAndKeepsRuntimeState() throws Exception {
|
||||
var registry = new InMemoryServerRegistry();
|
||||
try (var admin = new AdminHttpServer(new InetSocketAddress("127.0.0.1", 0), registry, new ProxyMetrics())) {
|
||||
admin.start();
|
||||
var base = "http://" + admin.bindAddress().getHostString() + ":" + admin.bindAddress().getPort();
|
||||
var client = HttpClient.newHttpClient();
|
||||
|
||||
var create = post(client, base + "/servers", """
|
||||
{
|
||||
"name": "survival-1",
|
||||
"address": "127.0.0.1:25565",
|
||||
"tags": ["blue"],
|
||||
"weight": 100,
|
||||
"softCapacity": 100,
|
||||
"hardCapacity": 120,
|
||||
"metadata": {"host": "blue.local"}
|
||||
}
|
||||
""");
|
||||
assertEquals(201, create.statusCode());
|
||||
assertEquals(200, post(client, base + "/servers/survival-1/health", """
|
||||
{"status": "DEGRADED", "backendPingMillis": 42, "recentFailureRate": 0.2, "reason": "canary"}
|
||||
""").statusCode());
|
||||
assertEquals(200, post(client, base + "/servers/survival-1/load", """
|
||||
{"players": 12, "softCapacity": 100, "hardCapacity": 120, "packetsPerSecond": 300}
|
||||
""").statusCode());
|
||||
|
||||
var update = patch(client, base + "/servers/survival-1", """
|
||||
{
|
||||
"address": "127.0.0.1:25566",
|
||||
"tags": ["green", "survival"],
|
||||
"capabilities": ["large-payload"],
|
||||
"weight": 25,
|
||||
"softCapacity": 40,
|
||||
"hardCapacity": 50,
|
||||
"drainMode": true,
|
||||
"metadata": {"host": "green.local", "route": "survival"}
|
||||
}
|
||||
""");
|
||||
|
||||
assertEquals(200, update.statusCode());
|
||||
assertTrue(update.body().contains("\"address\":\"127.0.0.1:25566\""));
|
||||
assertTrue(update.body().contains("\"green\""));
|
||||
assertTrue(update.body().contains("\"LARGE_PAYLOAD\""));
|
||||
assertTrue(update.body().contains("\"weight\":25"));
|
||||
assertTrue(update.body().contains("\"draining\":true"));
|
||||
assertTrue(update.body().contains("\"players\":12"));
|
||||
assertTrue(update.body().contains("\"status\":\"DEGRADED\""));
|
||||
assertTrue(update.body().contains("\"host\":\"green.local\""));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void patchServerReturnsNotFoundForMissingServer() throws Exception {
|
||||
var registry = new InMemoryServerRegistry();
|
||||
try (var admin = new AdminHttpServer(new InetSocketAddress("127.0.0.1", 0), registry, new ProxyMetrics())) {
|
||||
admin.start();
|
||||
var base = "http://" + admin.bindAddress().getHostString() + ":" + admin.bindAddress().getPort();
|
||||
var client = HttpClient.newHttpClient();
|
||||
|
||||
var response = patch(client, base + "/servers/missing", "{\"weight\":10}");
|
||||
|
||||
assertEquals(404, response.statusCode());
|
||||
assertTrue(response.body().contains("server not found"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void exportsStructuredDiagnosticReport() throws Exception {
|
||||
var registry = new InMemoryServerRegistry();
|
||||
registry.register(new dev.strataproxy.api.server.ServerDescriptor(
|
||||
"survival-1",
|
||||
new InetSocketAddress("127.0.0.1", 25565),
|
||||
java.util.Set.of("survival"),
|
||||
java.util.Set.of(),
|
||||
new dev.strataproxy.api.server.ProtocolRange(0, Integer.MAX_VALUE, "any"),
|
||||
100,
|
||||
100,
|
||||
120,
|
||||
false,
|
||||
java.util.Map.of("host", "survival.local")));
|
||||
var metrics = new ProxyMetrics();
|
||||
metrics.networkTransport("nio", false);
|
||||
metrics.routedConnection();
|
||||
metrics.rejectedConnection("global_limit");
|
||||
metrics.rejectedConnection("per_address_limit");
|
||||
metrics.frontendToBackendBytes("survival-1", 1024);
|
||||
metrics.backendToFrontendBytes("survival-1", 2048);
|
||||
metrics.compressionNegotiated("survival-1", 256);
|
||||
metrics.compressionSample("survival-1", 1000, 400, 0);
|
||||
metrics.compressionDecision("survival-1", ProxyMetrics.CompressionDirection.FRONTEND_TO_BACKEND, "threshold", 1024);
|
||||
metrics.packetTraffic("survival-1", ProxyMetrics.CompressionDirection.FRONTEND_TO_BACKEND, "UNCOMPRESSED", 1, 128, 0);
|
||||
metrics.customPayload("survival-1", ProxyMetrics.CompressionDirection.FRONTEND_TO_BACKEND, "FORGE_HANDSHAKE", "fml:handshake", 256, 0);
|
||||
metrics.relayBackpressure("survival-1", ProxyMetrics.CompressionDirection.FRONTEND_TO_BACKEND, 512);
|
||||
metrics.startPayloadCapture("cap-1", "survival-1", ProxyMetrics.CompressionDirection.FRONTEND_TO_BACKEND, 4, 8, java.time.Instant.now().plusSeconds(30));
|
||||
metrics.packetAnomaly("initial-handshake-malformed-frame", "127.0.0.1:50000", "survival-1", "frontend_to_backend", "HANDSHAKE", 0, 48, -1, "play.example.net");
|
||||
metrics.playerSessionStarted("Steve", "survival-1", "127.0.0.1:50000");
|
||||
try (var admin = new AdminHttpServer(new InetSocketAddress("127.0.0.1", 0), registry, metrics, "", 10, false)) {
|
||||
admin.start();
|
||||
var base = "http://" + admin.bindAddress().getHostString() + ":" + admin.bindAddress().getPort();
|
||||
var client = HttpClient.newHttpClient();
|
||||
|
||||
var response = client.send(HttpRequest.newBuilder(URI.create(base + "/diagnostic-report")).GET().build(), HttpResponse.BodyHandlers.ofString());
|
||||
|
||||
assertEquals(200, response.statusCode());
|
||||
assertTrue(response.body().contains("\"generatedAt\""));
|
||||
assertTrue(response.body().contains("\"overview\""));
|
||||
assertTrue(response.body().contains("\"rejectedConnectionsByReason\":{\"global_limit\":1,\"per_address_limit\":1}"));
|
||||
assertTrue(response.body().contains("\"admission\":{\"rejectedConnections\":2,\"rejectedConnectionsByReason\":{\"global_limit\":1,\"per_address_limit\":1}}"));
|
||||
assertTrue(response.body().contains("\"servers\":[{\"name\":\"survival-1\""));
|
||||
assertTrue(response.body().contains("\"compression\""));
|
||||
assertTrue(response.body().contains("\"scope\":\"survival-1\",\"direction\":\"all\",\"rawBytes\":1000,\"compressedBytes\":400,\"savedBytes\":600"));
|
||||
assertTrue(response.body().contains("\"action\":\"threshold\",\"threshold\":1024,\"count\":1"));
|
||||
assertTrue(response.body().contains("\"packetTraffic\""));
|
||||
assertTrue(response.body().contains("\"packetId\":1,\"packets\":1,\"rawBytes\":128"));
|
||||
assertTrue(response.body().contains("\"customPayloads\""));
|
||||
assertTrue(response.body().contains("\"kind\":\"FORGE_HANDSHAKE\",\"channel\":\"fml:handshake\",\"packets\":1,\"payloadBytes\":256"));
|
||||
assertTrue(response.body().contains("\"packetAnomalies\""));
|
||||
assertTrue(response.body().contains("\"rule\":\"initial-handshake-malformed-frame\",\"count\":1"));
|
||||
assertTrue(response.body().contains("\"relayBackpressure\""));
|
||||
assertTrue(response.body().contains("\"events\":1,\"lastBytesBeforeWritable\":512,\"maxBytesBeforeWritable\":512"));
|
||||
assertTrue(response.body().contains("\"payloadCaptures\""));
|
||||
assertTrue(response.body().contains("\"id\":\"cap-1\",\"server\":\"survival-1\",\"direction\":\"frontend_to_backend\""));
|
||||
assertTrue(response.body().contains("\"playerSessions\""));
|
||||
assertTrue(response.body().contains("\"player\":\"Steve\",\"server\":\"survival-1\""));
|
||||
assertTrue(response.body().contains("\"remoteAddress\":\"127.0.0.1:50000\""));
|
||||
assertTrue(response.body().contains("\"detail\":\"play.example.net\""));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void managesPayloadCaptures() throws Exception {
|
||||
var registry = new InMemoryServerRegistry();
|
||||
var metrics = new ProxyMetrics();
|
||||
try (var admin = new AdminHttpServer(new InetSocketAddress("127.0.0.1", 0), registry, metrics)) {
|
||||
admin.start();
|
||||
var base = "http://" + admin.bindAddress().getHostString() + ":" + admin.bindAddress().getPort();
|
||||
var client = HttpClient.newHttpClient();
|
||||
|
||||
var create = post(client, base + "/payload-captures", """
|
||||
{
|
||||
"id": "cap-1",
|
||||
"server": "survival-1",
|
||||
"direction": "frontend_to_backend",
|
||||
"maxSamples": 2,
|
||||
"maxBytesPerSample": 3,
|
||||
"durationMillis": 30000
|
||||
}
|
||||
""");
|
||||
assertEquals(201, create.statusCode());
|
||||
assertTrue(create.body().contains("\"id\":\"cap-1\""));
|
||||
|
||||
metrics.payloadCaptured(
|
||||
"survival-1",
|
||||
ProxyMetrics.CompressionDirection.FRONTEND_TO_BACKEND,
|
||||
4,
|
||||
-1,
|
||||
new byte[] {1, 2, 3, 4},
|
||||
"Steve",
|
||||
"127.0.0.1:50000");
|
||||
var export = client.send(HttpRequest.newBuilder(URI.create(base + "/payload-captures/cap-1")).GET().build(), HttpResponse.BodyHandlers.ofString());
|
||||
assertEquals(200, export.statusCode());
|
||||
assertTrue(export.body().contains("\"sampleCount\":1"));
|
||||
assertTrue(export.body().contains("\"prefixBase64\":\"AQID\""));
|
||||
assertTrue(export.body().contains("\"player\":\"Steve\""));
|
||||
assertTrue(export.body().contains("\"remoteAddress\":\"127.0.0.1:50000\""));
|
||||
|
||||
var list = client.send(HttpRequest.newBuilder(URI.create(base + "/payload-captures")).GET().build(), HttpResponse.BodyHandlers.ofString());
|
||||
assertEquals(200, list.statusCode());
|
||||
assertTrue(list.body().contains("\"captures\""));
|
||||
assertTrue(list.body().contains("\"sampleCount\":1"));
|
||||
|
||||
var delete = delete(client, base + "/payload-captures/cap-1");
|
||||
assertEquals(200, delete.statusCode());
|
||||
assertTrue(delete.body().contains("\"removed\":true"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void exposesRuntimeMetrics() throws Exception {
|
||||
var registry = new InMemoryServerRegistry();
|
||||
var metrics = new ProxyMetrics();
|
||||
metrics.eventLoopDelayNanos(1_000_000);
|
||||
metrics.pooledDirectMemoryBytes(2048);
|
||||
metrics.networkTransport("nio", false);
|
||||
metrics.rejectedConnection("global_limit");
|
||||
metrics.rejectedConnection("per_address_limit");
|
||||
metrics.packetAnomaly("initial-handshake-malformed-frame");
|
||||
metrics.packetTraffic("metrics-1", ProxyMetrics.CompressionDirection.FRONTEND_TO_BACKEND, "UNCOMPRESSED", 1, 64, 0);
|
||||
metrics.customPayload("metrics-1", ProxyMetrics.CompressionDirection.FRONTEND_TO_BACKEND, "FABRIC_HANDSHAKE", "fabric:registry/sync", 128, 64);
|
||||
metrics.relayBackpressure("metrics-1", ProxyMetrics.CompressionDirection.FRONTEND_TO_BACKEND, 1024);
|
||||
metrics.compressionSample(1000, 500, 2_000_000_000L);
|
||||
metrics.compressionNegotiated("metrics-1", 256);
|
||||
metrics.compressionDecision("metrics-1", ProxyMetrics.CompressionDirection.BACKEND_TO_FRONTEND, "threshold", 1024);
|
||||
metrics.compressionRewrite("metrics-1", ProxyMetrics.CompressionDirection.BACKEND_TO_FRONTEND, "rewritten", 2_000_000);
|
||||
try (var admin = new AdminHttpServer(new InetSocketAddress("127.0.0.1", 0), registry, metrics)) {
|
||||
admin.start();
|
||||
var base = "http://" + admin.bindAddress().getHostString() + ":" + admin.bindAddress().getPort();
|
||||
var client = HttpClient.newHttpClient();
|
||||
|
||||
var response = client.send(HttpRequest.newBuilder(URI.create(base + "/metrics")).GET().build(), HttpResponse.BodyHandlers.ofString());
|
||||
|
||||
assertEquals(200, response.statusCode());
|
||||
assertTrue(response.body().contains("strataproxy_event_loop_delay_seconds"));
|
||||
assertTrue(response.body().contains("strataproxy_pooled_direct_memory_bytes 2048"));
|
||||
assertTrue(response.body().contains("strataproxy_network_transport_info{transport=\"nio\",native=\"false\"} 1"));
|
||||
assertTrue(response.body().contains("strataproxy_connections_rejected_total 2"));
|
||||
assertTrue(response.body().contains("strataproxy_connections_rejected_total{reason=\"global_limit\"} 1"));
|
||||
assertTrue(response.body().contains("strataproxy_connections_rejected_total{reason=\"per_address_limit\"} 1"));
|
||||
assertTrue(response.body().contains("strataproxy_jvm_heap_used_bytes"));
|
||||
assertTrue(response.body().contains("strataproxy_jvm_threads_live"));
|
||||
assertTrue(response.body().contains("strataproxy_jvm_gc_collections_total"));
|
||||
assertTrue(response.body().contains("strataproxy_packet_anomalies_total{rule=\"initial-handshake-malformed-frame\"} 1"));
|
||||
assertTrue(response.body().contains("strataproxy_packet_traffic_packets_total{server=\"metrics-1\",direction=\"frontend_to_backend\",state=\"UNCOMPRESSED\",packet_id=\"1\"} 1"));
|
||||
assertTrue(response.body().contains("strataproxy_packet_traffic_raw_bytes_total{server=\"metrics-1\",direction=\"frontend_to_backend\",state=\"UNCOMPRESSED\",packet_id=\"1\"} 64"));
|
||||
assertTrue(response.body().contains("strataproxy_custom_payload_packets_total{server=\"metrics-1\",direction=\"frontend_to_backend\",kind=\"FABRIC_HANDSHAKE\",channel=\"fabric:registry/sync\"} 1"));
|
||||
assertTrue(response.body().contains("strataproxy_custom_payload_bytes_total{server=\"metrics-1\",direction=\"frontend_to_backend\",kind=\"FABRIC_HANDSHAKE\",channel=\"fabric:registry/sync\"} 128"));
|
||||
assertTrue(response.body().contains("strataproxy_custom_payload_compressed_bytes_total{server=\"metrics-1\",direction=\"frontend_to_backend\",kind=\"FABRIC_HANDSHAKE\",channel=\"fabric:registry/sync\"} 64"));
|
||||
assertTrue(response.body().contains("strataproxy_custom_payload_max_bytes{server=\"metrics-1\",direction=\"frontend_to_backend\",kind=\"FABRIC_HANDSHAKE\",channel=\"fabric:registry/sync\"} 128"));
|
||||
assertTrue(response.body().contains("strataproxy_custom_payload_max_compressed_bytes{server=\"metrics-1\",direction=\"frontend_to_backend\",kind=\"FABRIC_HANDSHAKE\",channel=\"fabric:registry/sync\"} 64"));
|
||||
assertTrue(response.body().contains("strataproxy_relay_backpressure_events_total{server=\"metrics-1\",direction=\"frontend_to_backend\"} 1"));
|
||||
assertTrue(response.body().contains("strataproxy_relay_backpressure_last_bytes_before_writable{server=\"metrics-1\",direction=\"frontend_to_backend\"} 1024"));
|
||||
assertTrue(response.body().contains("strataproxy_compression_negotiations_total 1"));
|
||||
assertTrue(response.body().contains("strataproxy_compression_raw_bytes_total 1000"));
|
||||
assertTrue(response.body().contains("strataproxy_compression_compressed_bytes_total 500"));
|
||||
assertTrue(response.body().contains("strataproxy_compression_saved_bytes_total 500"));
|
||||
assertTrue(response.body().contains("strataproxy_compression_cpu_seconds_total 2.0"));
|
||||
assertTrue(response.body().contains("strataproxy_compression_ratio 0.5"));
|
||||
assertTrue(response.body().contains("strataproxy_compression_decisions_total{server=\"metrics-1\",direction=\"backend_to_frontend\",action=\"threshold\",threshold=\"1024\"} 1"));
|
||||
assertTrue(response.body().contains("strataproxy_compression_rewrites_total{server=\"metrics-1\",direction=\"backend_to_frontend\",outcome=\"rewritten\"} 1"));
|
||||
assertTrue(response.body().contains("strataproxy_compression_rewrite_cpu_seconds_total{server=\"metrics-1\",direction=\"backend_to_frontend\",outcome=\"rewritten\"} 0.002"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void canDisablePrometheusMetricsWithoutDisablingStructuredReports() throws Exception {
|
||||
var registry = new InMemoryServerRegistry();
|
||||
var metrics = new ProxyMetrics();
|
||||
metrics.packetTraffic("survival-1", ProxyMetrics.CompressionDirection.FRONTEND_TO_BACKEND, "UNCOMPRESSED", 1, 64, 0);
|
||||
metrics.routedConnection();
|
||||
try (var admin = new AdminHttpServer(new InetSocketAddress("127.0.0.1", 0), registry, metrics, "", 50, false)) {
|
||||
admin.start();
|
||||
var base = "http://" + admin.bindAddress().getHostString() + ":" + admin.bindAddress().getPort();
|
||||
var client = HttpClient.newHttpClient();
|
||||
|
||||
var prometheus = client.send(HttpRequest.newBuilder(URI.create(base + "/metrics")).GET().build(), HttpResponse.BodyHandlers.ofString());
|
||||
var packetTraffic = client.send(HttpRequest.newBuilder(URI.create(base + "/packet-traffic")).GET().build(), HttpResponse.BodyHandlers.ofString());
|
||||
var overview = client.send(HttpRequest.newBuilder(URI.create(base + "/overview")).GET().build(), HttpResponse.BodyHandlers.ofString());
|
||||
|
||||
assertEquals(404, prometheus.statusCode());
|
||||
assertTrue(prometheus.body().contains("prometheus metrics disabled"));
|
||||
assertEquals(200, packetTraffic.statusCode());
|
||||
assertTrue(packetTraffic.body().contains("\"totalPackets\":1"));
|
||||
assertEquals(200, overview.statusCode());
|
||||
assertTrue(overview.body().contains("\"status\":\"UP\""));
|
||||
assertTrue(overview.body().contains("\"routedConnections\":1"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void exposesPacketAnomalyReport() throws Exception {
|
||||
var registry = new InMemoryServerRegistry();
|
||||
var metrics = new ProxyMetrics();
|
||||
metrics.packetAnomaly("custom-payload-flood");
|
||||
metrics.packetAnomaly("initial-handshake-malformed-frame", "127.0.0.1:50000", "", "frontend_to_backend", "HANDSHAKE", 0, 48, -1, "play.example.net");
|
||||
metrics.packetAnomaly("initial-handshake-malformed-frame");
|
||||
try (var admin = new AdminHttpServer(new InetSocketAddress("127.0.0.1", 0), registry, metrics)) {
|
||||
admin.start();
|
||||
var base = "http://" + admin.bindAddress().getHostString() + ":" + admin.bindAddress().getPort();
|
||||
var client = HttpClient.newHttpClient();
|
||||
|
||||
var response = client.send(HttpRequest.newBuilder(URI.create(base + "/packet-anomalies")).GET().build(), HttpResponse.BodyHandlers.ofString());
|
||||
|
||||
assertEquals(200, response.statusCode());
|
||||
assertTrue(response.body().contains("\"total\":3"));
|
||||
assertTrue(response.body().contains("\"rule\":\"initial-handshake-malformed-frame\",\"count\":2"));
|
||||
assertTrue(response.body().contains("\"rule\":\"custom-payload-flood\",\"count\":1"));
|
||||
assertTrue(response.body().contains("\"recentSamples\""));
|
||||
assertTrue(response.body().contains("\"remoteAddress\":\"127.0.0.1:50000\""));
|
||||
assertTrue(response.body().contains("\"direction\":\"frontend_to_backend\""));
|
||||
assertTrue(response.body().contains("\"protocolState\":\"HANDSHAKE\""));
|
||||
assertTrue(response.body().contains("\"rawSize\":48"));
|
||||
assertTrue(response.body().contains("\"detail\":\"play.example.net\""));
|
||||
assertTrue(response.body().indexOf("initial-handshake-malformed-frame")
|
||||
< response.body().indexOf("custom-payload-flood"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void limitsPacketAnomalyRulesWithoutChangingTotalOrSamples() throws Exception {
|
||||
var registry = new InMemoryServerRegistry();
|
||||
var metrics = new ProxyMetrics();
|
||||
metrics.packetAnomaly("third-rule");
|
||||
metrics.packetAnomaly("second-rule");
|
||||
metrics.packetAnomaly("second-rule");
|
||||
metrics.packetAnomaly("top-rule", "127.0.0.1:50000", "", "frontend_to_backend", "HANDSHAKE", 0, 48, -1, "play.example.net");
|
||||
metrics.packetAnomaly("top-rule");
|
||||
metrics.packetAnomaly("top-rule");
|
||||
try (var admin = new AdminHttpServer(new InetSocketAddress("127.0.0.1", 0), registry, metrics, "", 2)) {
|
||||
admin.start();
|
||||
var base = "http://" + admin.bindAddress().getHostString() + ":" + admin.bindAddress().getPort();
|
||||
var client = HttpClient.newHttpClient();
|
||||
|
||||
var response = client.send(HttpRequest.newBuilder(URI.create(base + "/packet-anomalies")).GET().build(), HttpResponse.BodyHandlers.ofString());
|
||||
|
||||
assertEquals(200, response.statusCode());
|
||||
assertTrue(response.body().contains("\"total\":6"));
|
||||
assertTrue(response.body().contains("\"rule\":\"top-rule\",\"count\":3"));
|
||||
assertTrue(response.body().contains("\"rule\":\"second-rule\",\"count\":2"));
|
||||
assertTrue(!response.body().contains("\"rule\":\"third-rule\",\"count\":1"));
|
||||
assertTrue(response.body().contains("\"recentSamples\""));
|
||||
assertTrue(response.body().contains("\"remoteAddress\":\"127.0.0.1:50000\""));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void protectsPacketAnomalyReportWhenBearerTokenIsConfigured() throws Exception {
|
||||
var registry = new InMemoryServerRegistry();
|
||||
var metrics = new ProxyMetrics();
|
||||
metrics.packetAnomaly("protected-rule");
|
||||
try (var admin = new AdminHttpServer(new InetSocketAddress("127.0.0.1", 0), registry, metrics, "secret-token")) {
|
||||
admin.start();
|
||||
var base = "http://" + admin.bindAddress().getHostString() + ":" + admin.bindAddress().getPort();
|
||||
var client = HttpClient.newHttpClient();
|
||||
|
||||
var unauthorized = client.send(HttpRequest.newBuilder(URI.create(base + "/packet-anomalies")).GET().build(), HttpResponse.BodyHandlers.ofString());
|
||||
var authorized = client.send(HttpRequest.newBuilder(URI.create(base + "/packet-anomalies"))
|
||||
.header("Authorization", "Bearer secret-token")
|
||||
.GET()
|
||||
.build(), HttpResponse.BodyHandlers.ofString());
|
||||
|
||||
assertEquals(401, unauthorized.statusCode());
|
||||
assertEquals(200, authorized.statusCode());
|
||||
assertTrue(authorized.body().contains("protected-rule"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void exposesPacketTrafficReport() throws Exception {
|
||||
var registry = new InMemoryServerRegistry();
|
||||
var metrics = new ProxyMetrics();
|
||||
metrics.packetTraffic("survival-1", ProxyMetrics.CompressionDirection.FRONTEND_TO_BACKEND, "UNCOMPRESSED", 1, 100, 0);
|
||||
metrics.packetTraffic("survival-1", ProxyMetrics.CompressionDirection.FRONTEND_TO_BACKEND, "UNCOMPRESSED", 1, 50, 0);
|
||||
metrics.packetTraffic("survival-1", ProxyMetrics.CompressionDirection.BACKEND_TO_FRONTEND, "UNCOMPRESSED", 2, 300, 0);
|
||||
metrics.packetTraffic("lobby-1", ProxyMetrics.CompressionDirection.FRONTEND_TO_BACKEND, "UNCOMPRESSED", 3, 25, 0);
|
||||
try (var admin = new AdminHttpServer(new InetSocketAddress("127.0.0.1", 0), registry, metrics, "", 2)) {
|
||||
admin.start();
|
||||
var base = "http://" + admin.bindAddress().getHostString() + ":" + admin.bindAddress().getPort();
|
||||
var client = HttpClient.newHttpClient();
|
||||
|
||||
var response = client.send(HttpRequest.newBuilder(URI.create(base + "/packet-traffic")).GET().build(), HttpResponse.BodyHandlers.ofString());
|
||||
|
||||
assertEquals(200, response.statusCode());
|
||||
assertTrue(response.body().contains("\"totalPackets\":4"));
|
||||
assertTrue(response.body().contains("\"totalRawBytes\":475"));
|
||||
assertTrue(response.body().contains("\"server\":\"survival-1\",\"direction\":\"backend_to_frontend\",\"protocolState\":\"UNCOMPRESSED\",\"packetId\":2,\"packets\":1,\"rawBytes\":300"));
|
||||
assertTrue(response.body().contains("\"server\":\"survival-1\",\"direction\":\"frontend_to_backend\",\"protocolState\":\"UNCOMPRESSED\",\"packetId\":1,\"packets\":2,\"rawBytes\":150"));
|
||||
assertTrue(!response.body().contains("\"server\":\"lobby-1\""));
|
||||
assertTrue(response.body().indexOf("\"packetId\":2") < response.body().indexOf("\"packetId\":1"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void exposesCustomPayloadReport() throws Exception {
|
||||
var registry = new InMemoryServerRegistry();
|
||||
var metrics = new ProxyMetrics();
|
||||
metrics.customPayload("survival-1", ProxyMetrics.CompressionDirection.FRONTEND_TO_BACKEND, "FORGE_HANDSHAKE", "fml:handshake", 100, 0);
|
||||
metrics.customPayload("survival-1", ProxyMetrics.CompressionDirection.FRONTEND_TO_BACKEND, "FORGE_HANDSHAKE", "fml:handshake", 50, 0);
|
||||
metrics.customPayload("survival-1", ProxyMetrics.CompressionDirection.FRONTEND_TO_BACKEND, "FABRIC_HANDSHAKE", "fabric:registry/sync", 300, 64);
|
||||
metrics.customPayload("lobby-1", ProxyMetrics.CompressionDirection.FRONTEND_TO_BACKEND, "UNKNOWN", "attacker:random", 25, 0);
|
||||
try (var admin = new AdminHttpServer(new InetSocketAddress("127.0.0.1", 0), registry, metrics, "", 2)) {
|
||||
admin.start();
|
||||
var base = "http://" + admin.bindAddress().getHostString() + ":" + admin.bindAddress().getPort();
|
||||
var client = HttpClient.newHttpClient();
|
||||
|
||||
var response = client.send(HttpRequest.newBuilder(URI.create(base + "/custom-payloads")).GET().build(), HttpResponse.BodyHandlers.ofString());
|
||||
|
||||
assertEquals(200, response.statusCode());
|
||||
assertTrue(response.body().contains("\"totalPackets\":4"));
|
||||
assertTrue(response.body().contains("\"totalPayloadBytes\":475"));
|
||||
assertTrue(response.body().contains("\"totalCompressedBytes\":64"));
|
||||
assertTrue(response.body().contains("\"server\":\"survival-1\",\"direction\":\"frontend_to_backend\",\"kind\":\"FABRIC_HANDSHAKE\",\"channel\":\"fabric:registry/sync\",\"packets\":1,\"payloadBytes\":300,\"compressedBytes\":64,\"maxPayloadBytes\":300,\"maxCompressedBytes\":64"));
|
||||
assertTrue(response.body().contains("\"server\":\"survival-1\",\"direction\":\"frontend_to_backend\",\"kind\":\"FORGE_HANDSHAKE\",\"channel\":\"fml:handshake\",\"packets\":2,\"payloadBytes\":150,\"compressedBytes\":0,\"maxPayloadBytes\":100,\"maxCompressedBytes\":0"));
|
||||
assertTrue(response.body().contains("\"firstSeen\":"));
|
||||
assertTrue(response.body().contains("\"lastSeen\":"));
|
||||
assertTrue(!response.body().contains("\"server\":\"lobby-1\""));
|
||||
assertTrue(response.body().indexOf("FABRIC_HANDSHAKE") < response.body().indexOf("FORGE_HANDSHAKE"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void exposesCompressionReportWhenPrometheusIsDisabled() throws Exception {
|
||||
var registry = new InMemoryServerRegistry();
|
||||
registry.register(new dev.strataproxy.api.server.ServerDescriptor(
|
||||
"survival-1",
|
||||
new InetSocketAddress("127.0.0.1", 25565),
|
||||
java.util.Set.of("survival"),
|
||||
java.util.Set.of(),
|
||||
new dev.strataproxy.api.server.ProtocolRange(0, Integer.MAX_VALUE, "any"),
|
||||
100,
|
||||
100,
|
||||
120,
|
||||
false,
|
||||
java.util.Map.of()));
|
||||
var metrics = new ProxyMetrics();
|
||||
metrics.compressionNegotiated("survival-1", 256);
|
||||
metrics.compressionSample("survival-1", 1000, 400, 0);
|
||||
metrics.compressionSample("survival-1", ProxyMetrics.CompressionDirection.FRONTEND_TO_BACKEND, 500, 250, 0);
|
||||
metrics.compressionDecision("survival-1", ProxyMetrics.CompressionDirection.FRONTEND_TO_BACKEND, "threshold", 1024);
|
||||
try (var admin = new AdminHttpServer(new InetSocketAddress("127.0.0.1", 0), registry, metrics, "", 50, false)) {
|
||||
admin.start();
|
||||
var base = "http://" + admin.bindAddress().getHostString() + ":" + admin.bindAddress().getPort();
|
||||
var client = HttpClient.newHttpClient();
|
||||
|
||||
var metricsResponse = client.send(HttpRequest.newBuilder(URI.create(base + "/metrics")).GET().build(), HttpResponse.BodyHandlers.ofString());
|
||||
var report = client.send(HttpRequest.newBuilder(URI.create(base + "/compression-report")).GET().build(), HttpResponse.BodyHandlers.ofString());
|
||||
|
||||
assertEquals(404, metricsResponse.statusCode());
|
||||
assertEquals(200, report.statusCode());
|
||||
assertTrue(report.body().contains("\"scope\":\"global\",\"direction\":\"all\",\"rawBytes\":1500,\"compressedBytes\":650,\"savedBytes\":850"));
|
||||
assertTrue(report.body().contains("\"scope\":\"survival-1\",\"direction\":\"all\",\"rawBytes\":1500,\"compressedBytes\":650,\"savedBytes\":850"));
|
||||
assertTrue(report.body().contains("\"scope\":\"survival-1\",\"direction\":\"frontend_to_backend\",\"rawBytes\":500,\"compressedBytes\":250,\"savedBytes\":250"));
|
||||
assertTrue(report.body().contains("\"scope\":\"survival-1\",\"direction\":\"frontend_to_backend\",\"action\":\"threshold\",\"threshold\":1024,\"count\":1"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void exposesServerMetrics() throws Exception {
|
||||
var registry = new InMemoryServerRegistry();
|
||||
registry.register(new dev.strataproxy.api.server.ServerDescriptor(
|
||||
"metrics-1",
|
||||
new InetSocketAddress("127.0.0.1", 25565),
|
||||
java.util.Set.of("metrics"),
|
||||
java.util.Set.of(),
|
||||
new dev.strataproxy.api.server.ProtocolRange(0, Integer.MAX_VALUE, "any"),
|
||||
100,
|
||||
50,
|
||||
60,
|
||||
false,
|
||||
java.util.Map.of()));
|
||||
registry.updateHealth("metrics-1", new dev.strataproxy.api.server.ServerHealth(
|
||||
dev.strataproxy.api.server.ServerHealthStatus.DEGRADED,
|
||||
42,
|
||||
0.25d,
|
||||
"test",
|
||||
java.time.Instant.now()));
|
||||
registry.updateLoad("metrics-1", new dev.strataproxy.api.server.ServerLoad(
|
||||
12,
|
||||
50,
|
||||
60,
|
||||
1024,
|
||||
2048,
|
||||
300,
|
||||
1.5d));
|
||||
var metrics = new ProxyMetrics();
|
||||
metrics.frontendToBackendBytes("metrics-1", 1024);
|
||||
metrics.backendToFrontendBytes("metrics-1", 2048);
|
||||
metrics.compressionSample("metrics-1", 4096, 1024, 500_000_000L);
|
||||
metrics.compressionSample("metrics-1", ProxyMetrics.CompressionDirection.FRONTEND_TO_BACKEND, 2048, 512, 0);
|
||||
metrics.compressionNegotiated("metrics-1", 512);
|
||||
metrics.serverConnectionOpened("metrics-1");
|
||||
metrics.playerSessionStarted("Alex", "metrics-1", "127.0.0.1:50001");
|
||||
try (var admin = new AdminHttpServer(new InetSocketAddress("127.0.0.1", 0), registry, metrics)) {
|
||||
admin.start();
|
||||
var base = "http://" + admin.bindAddress().getHostString() + ":" + admin.bindAddress().getPort();
|
||||
var client = HttpClient.newHttpClient();
|
||||
|
||||
var response = client.send(HttpRequest.newBuilder(URI.create(base + "/metrics")).GET().build(), HttpResponse.BodyHandlers.ofString());
|
||||
|
||||
assertEquals(200, response.statusCode());
|
||||
assertTrue(response.body().contains("strataproxy_server_health{server=\"metrics-1\",status=\"DEGRADED\"} 1"));
|
||||
assertTrue(response.body().contains("strataproxy_server_players{server=\"metrics-1\"} 12"));
|
||||
assertTrue(response.body().contains("strataproxy_server_packets_per_second{server=\"metrics-1\"} 300"));
|
||||
assertTrue(response.body().contains("strataproxy_server_connections_active{server=\"metrics-1\"} 1"));
|
||||
assertTrue(response.body().contains("strataproxy_server_connections_routed_total{server=\"metrics-1\"} 1"));
|
||||
assertTrue(response.body().contains("strataproxy_server_frontend_to_backend_bytes_total{server=\"metrics-1\"} 1024"));
|
||||
assertTrue(response.body().contains("strataproxy_server_backend_to_frontend_bytes_total{server=\"metrics-1\"} 2048"));
|
||||
assertTrue(response.body().contains("strataproxy_server_compression_threshold_bytes{server=\"metrics-1\"} 512"));
|
||||
assertTrue(response.body().contains("strataproxy_compression_saved_bytes_total{server=\"metrics-1\"} 4608"));
|
||||
assertTrue(response.body().contains("strataproxy_compression_ratio{server=\"metrics-1\"} 0.25"));
|
||||
assertTrue(response.body().contains("strataproxy_compression_saved_bytes_total{server=\"metrics-1\",direction=\"frontend_to_backend\"} 1536"));
|
||||
assertTrue(response.body().contains("strataproxy_compression_saved_bytes_total{server=\"metrics-1\",direction=\"backend_to_frontend\"} 0"));
|
||||
assertTrue(response.body().contains("strataproxy_player_session_active{player=\"Alex\",server=\"metrics-1\",remote=\"127.0.0.1:50001\"} 1"));
|
||||
}
|
||||
}
|
||||
|
||||
private static HttpResponse<String> post(HttpClient client, String uri, String body) throws IOException, InterruptedException {
|
||||
return post(client, uri, body, null);
|
||||
}
|
||||
|
||||
private static HttpResponse<String> post(HttpClient client, String uri, String body, String bearerToken) throws IOException, InterruptedException {
|
||||
var request = HttpRequest.newBuilder(URI.create(uri))
|
||||
.header("Content-Type", "application/json")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(body));
|
||||
if (bearerToken != null) {
|
||||
request.header("Authorization", "Bearer " + bearerToken);
|
||||
}
|
||||
return client.send(request.build(), HttpResponse.BodyHandlers.ofString());
|
||||
}
|
||||
|
||||
private static HttpResponse<String> patch(HttpClient client, String uri, String body) throws IOException, InterruptedException {
|
||||
var request = HttpRequest.newBuilder(URI.create(uri))
|
||||
.header("Content-Type", "application/json")
|
||||
.method("PATCH", HttpRequest.BodyPublishers.ofString(body))
|
||||
.build();
|
||||
return client.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
}
|
||||
|
||||
private static HttpResponse<String> delete(HttpClient client, String uri) throws IOException, InterruptedException {
|
||||
var request = HttpRequest.newBuilder(URI.create(uri)).DELETE().build();
|
||||
return client.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
}
|
||||
|
||||
private static dev.strataproxy.api.server.ServerDescriptor server(
|
||||
String name,
|
||||
boolean drainMode,
|
||||
int softCapacity,
|
||||
int hardCapacity) {
|
||||
return new dev.strataproxy.api.server.ServerDescriptor(
|
||||
name,
|
||||
new InetSocketAddress("127.0.0.1", 25565),
|
||||
java.util.Set.of(),
|
||||
java.util.Set.of(),
|
||||
new dev.strataproxy.api.server.ProtocolRange(0, Integer.MAX_VALUE, "any"),
|
||||
100,
|
||||
softCapacity,
|
||||
hardCapacity,
|
||||
drainMode,
|
||||
java.util.Map.of());
|
||||
}
|
||||
|
||||
private static final class FailingRegistryStore implements RegistryStore {
|
||||
@Override
|
||||
public List<dev.strataproxy.api.server.ServerDescriptor> load() {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void save(Collection<dev.strataproxy.api.server.RegisteredServer> servers) throws IOException {
|
||||
throw new IOException("persist failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package dev.strataproxy.admin;
|
||||
|
||||
import dev.strataproxy.api.server.ProtocolRange;
|
||||
import dev.strataproxy.api.server.RegisteredServer;
|
||||
import dev.strataproxy.api.server.ServerCapability;
|
||||
import dev.strataproxy.api.server.ServerDescriptor;
|
||||
import dev.strataproxy.registry.InMemoryServerRegistry;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
final class JsonRegistryStoreTest {
|
||||
@Test
|
||||
void savesAndLoadsServerDescriptors(@TempDir Path tempDir) throws Exception {
|
||||
var store = new JsonRegistryStore(tempDir.resolve("registry.json"));
|
||||
var registry = new InMemoryServerRegistry();
|
||||
registry.register(new ServerDescriptor(
|
||||
"survival-1",
|
||||
new InetSocketAddress("127.0.0.1", 25566),
|
||||
Set.of("survival"),
|
||||
Set.of(ServerCapability.LARGE_PAYLOAD),
|
||||
new ProtocolRange(763, 763, "1.20.1"),
|
||||
100,
|
||||
180,
|
||||
220,
|
||||
false,
|
||||
Map.of("host", "survival.local")));
|
||||
|
||||
store.save(registry.snapshot());
|
||||
var loaded = store.load();
|
||||
|
||||
assertEquals(1, loaded.size());
|
||||
assertEquals("survival-1", loaded.getFirst().name());
|
||||
assertEquals(Set.of("survival"), loaded.getFirst().tags());
|
||||
assertEquals("survival.local", loaded.getFirst().metadata().get("host"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void adminServicePersistsRegisterAndUnregister(@TempDir Path tempDir) throws Exception {
|
||||
var path = tempDir.resolve("registry.json");
|
||||
var service = new AdminRegistryService(new InMemoryServerRegistry(), new JsonRegistryStore(path));
|
||||
|
||||
service.register(new ServerDescriptor(
|
||||
"creative-1",
|
||||
new InetSocketAddress("127.0.0.1", 25567),
|
||||
Set.of("creative"),
|
||||
Set.of(ServerCapability.MODERN_FORWARDING),
|
||||
new ProtocolRange(0, Integer.MAX_VALUE, "any"),
|
||||
100,
|
||||
50,
|
||||
60,
|
||||
false,
|
||||
Map.of()));
|
||||
|
||||
assertTrue(Files.readString(path).contains("creative-1"));
|
||||
service.unregister("creative-1", dev.strataproxy.api.server.DrainPolicy.rejectNew());
|
||||
assertTrue(new JsonRegistryStore(path).load().isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void persistsDrainMode(@TempDir Path tempDir) throws Exception {
|
||||
var store = new JsonRegistryStore(tempDir.resolve("registry.json"));
|
||||
var registry = new InMemoryServerRegistry();
|
||||
registry.register(new ServerDescriptor(
|
||||
"maintenance-1",
|
||||
new InetSocketAddress("127.0.0.1", 25568),
|
||||
Set.of("maintenance"),
|
||||
Set.of(ServerCapability.MODERN_FORWARDING),
|
||||
new ProtocolRange(0, Integer.MAX_VALUE, "any"),
|
||||
100,
|
||||
50,
|
||||
60,
|
||||
false,
|
||||
Map.of()));
|
||||
registry.updateDrainMode("maintenance-1", true);
|
||||
|
||||
store.save(registry.snapshot());
|
||||
var loaded = store.load();
|
||||
|
||||
assertEquals(1, loaded.size());
|
||||
assertTrue(loaded.getFirst().drainMode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void adminServiceRollsBackReplacementWhenPersistenceFails() {
|
||||
var registry = new InMemoryServerRegistry();
|
||||
registry.register(new ServerDescriptor(
|
||||
"survival-1",
|
||||
new InetSocketAddress("127.0.0.1", 25565),
|
||||
Set.of("blue"),
|
||||
Set.of(ServerCapability.LARGE_PAYLOAD),
|
||||
new ProtocolRange(0, Integer.MAX_VALUE, "any"),
|
||||
100,
|
||||
100,
|
||||
120,
|
||||
false,
|
||||
Map.of("host", "blue.local")));
|
||||
var service = new AdminRegistryService(registry, new FailingRegistryStore());
|
||||
|
||||
assertThrows(IOException.class, () -> service.register(new ServerDescriptor(
|
||||
"survival-1",
|
||||
new InetSocketAddress("127.0.0.1", 25566),
|
||||
Set.of("green"),
|
||||
Set.of(ServerCapability.MODERN_FORWARDING),
|
||||
new ProtocolRange(0, Integer.MAX_VALUE, "any"),
|
||||
50,
|
||||
200,
|
||||
240,
|
||||
true,
|
||||
Map.of("host", "green.local"))));
|
||||
|
||||
var restored = registry.get("survival-1").orElseThrow();
|
||||
assertEquals(25565, restored.descriptor().address().getPort());
|
||||
assertEquals(Set.of("blue"), restored.descriptor().tags());
|
||||
assertEquals(100, restored.load().softCapacity());
|
||||
assertEquals(120, restored.load().hardCapacity());
|
||||
assertTrue(!restored.draining());
|
||||
}
|
||||
|
||||
private static final class FailingRegistryStore implements RegistryStore {
|
||||
@Override
|
||||
public List<ServerDescriptor> load() {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void save(Collection<RegisteredServer> servers) throws IOException {
|
||||
throw new IOException("persist failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import org.gradle.api.tasks.Exec
|
||||
|
||||
plugins {
|
||||
id("strataproxy.application-conventions")
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":proxy-admin-api"))
|
||||
implementation(libs.findLibrary("jackson-databind").get())
|
||||
implementation(libs.findLibrary("cli-picocli").get())
|
||||
testImplementation(project(":proxy-registry"))
|
||||
}
|
||||
|
||||
application {
|
||||
mainClass.set("dev.strataproxy.admin.cli.StrataProxyAdminCli")
|
||||
applicationName = "strataproxy-admin"
|
||||
}
|
||||
|
||||
val installedDistSmokeTest = tasks.register<Exec>("installedDistSmokeTest") {
|
||||
group = "verification"
|
||||
description = "Runs the installed strataproxy-admin distribution help command."
|
||||
dependsOn(tasks.named("installDist"))
|
||||
|
||||
val installDir = layout.buildDirectory.dir("install/strataproxy-admin")
|
||||
inputs.dir(installDir)
|
||||
|
||||
doFirst {
|
||||
val windows = System.getProperty("os.name").lowercase().contains("windows")
|
||||
val script = installDir.get().file("bin/" + if (windows) "strataproxy-admin.bat" else "strataproxy-admin").asFile
|
||||
commandLine(script.absolutePath, "--help")
|
||||
}
|
||||
}
|
||||
|
||||
tasks.named("check") {
|
||||
dependsOn(installedDistSmokeTest)
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,787 @@
|
||||
package dev.strataproxy.admin.cli;
|
||||
|
||||
import dev.strataproxy.admin.AdminHttpServer;
|
||||
import dev.strataproxy.admin.AdminRegistryService;
|
||||
import dev.strataproxy.admin.NoopRegistryStore;
|
||||
import dev.strataproxy.api.server.ProtocolRange;
|
||||
import dev.strataproxy.api.server.ServerDescriptor;
|
||||
import dev.strataproxy.observability.ProxyMetrics;
|
||||
import dev.strataproxy.registry.InMemoryServerRegistry;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import picocli.CommandLine;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.PrintStream;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.security.KeyStore;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import javax.net.ssl.KeyManagerFactory;
|
||||
import javax.net.ssl.SSLContext;
|
||||
import javax.net.ssl.TrustManagerFactory;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
final class StrataProxyAdminCliTest {
|
||||
@Test
|
||||
void fetchesHealthAndManagesServers() throws Exception {
|
||||
var registry = new InMemoryServerRegistry();
|
||||
try (var admin = new AdminHttpServer(new InetSocketAddress("127.0.0.1", 0), registry, new ProxyMetrics())) {
|
||||
admin.start();
|
||||
var baseUrl = "http://" + admin.bindAddress().getHostString() + ":" + admin.bindAddress().getPort();
|
||||
|
||||
var health = execute("--base-url", baseUrl, "health");
|
||||
assertEquals(0, health.exitCode());
|
||||
assertTrue(health.output().contains("\"status\":\"UP\""));
|
||||
|
||||
var notReady = execute("--base-url", baseUrl, "ready");
|
||||
assertEquals(1, notReady.exitCode());
|
||||
assertTrue(notReady.output().contains("\"status\":\"NOT_READY\""));
|
||||
|
||||
var register = execute(
|
||||
"--base-url", baseUrl,
|
||||
"servers", "register",
|
||||
"--name", "survival-1",
|
||||
"--address", "127.0.0.1:25565",
|
||||
"--tag", "survival,forge",
|
||||
"--capability", "large-payload",
|
||||
"--protocol-range", "763",
|
||||
"--metadata", "host=survival.local,group=survival");
|
||||
assertEquals(0, register.exitCode());
|
||||
assertTrue(register.output().contains("\"name\":\"survival-1\""));
|
||||
|
||||
var ready = execute("--base-url", baseUrl, "ready");
|
||||
assertEquals(0, ready.exitCode());
|
||||
assertTrue(ready.output().contains("\"status\":\"READY\""));
|
||||
|
||||
var list = execute("--base-url", baseUrl, "servers", "list");
|
||||
assertEquals(0, list.exitCode());
|
||||
assertTrue(list.output().contains("survival-1"));
|
||||
|
||||
var drain = execute("--base-url", baseUrl, "servers", "drain", "survival-1");
|
||||
assertEquals(0, drain.exitCode());
|
||||
assertTrue(drain.output().contains("\"draining\":true"));
|
||||
|
||||
var undrain = execute("--base-url", baseUrl, "servers", "undrain", "survival-1");
|
||||
assertEquals(0, undrain.exitCode());
|
||||
assertTrue(undrain.output().contains("\"draining\":false"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void updatesServerDescriptorPartially() throws Exception {
|
||||
var registry = new InMemoryServerRegistry();
|
||||
try (var admin = new AdminHttpServer(new InetSocketAddress("127.0.0.1", 0), registry, new ProxyMetrics())) {
|
||||
admin.start();
|
||||
var baseUrl = "http://" + admin.bindAddress().getHostString() + ":" + admin.bindAddress().getPort();
|
||||
|
||||
var register = execute(
|
||||
"--base-url", baseUrl,
|
||||
"servers", "register",
|
||||
"--name", "survival-1",
|
||||
"--address", "127.0.0.1:25565",
|
||||
"--tag", "survival",
|
||||
"--metadata", "host=blue.local");
|
||||
assertEquals(0, register.exitCode());
|
||||
|
||||
var update = execute(
|
||||
"--base-url", baseUrl,
|
||||
"servers", "update", "survival-1",
|
||||
"--address", "127.0.0.1:25566",
|
||||
"--tag", "survival,canary",
|
||||
"--capability", "large-payload",
|
||||
"--weight", "20",
|
||||
"--soft-capacity", "80",
|
||||
"--hard-capacity", "100",
|
||||
"--drain-mode", "true",
|
||||
"--metadata", "host=green.local,group=survival-canary");
|
||||
|
||||
assertEquals(0, update.exitCode());
|
||||
assertTrue(update.output().contains("\"address\":\"127.0.0.1:25566\""));
|
||||
assertTrue(update.output().contains("\"canary\""));
|
||||
assertTrue(update.output().contains("\"LARGE_PAYLOAD\""));
|
||||
assertTrue(update.output().contains("\"weight\":20"));
|
||||
assertTrue(update.output().contains("\"draining\":true"));
|
||||
assertTrue(update.output().contains("\"softCapacity\":80"));
|
||||
assertTrue(update.output().contains("\"hardCapacity\":100"));
|
||||
assertTrue(update.output().contains("\"group\":\"survival-canary\""));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void sendsBearerTokenWhenConfigured() throws Exception {
|
||||
var registry = new InMemoryServerRegistry();
|
||||
try (var admin = new AdminHttpServer(new InetSocketAddress("127.0.0.1", 0), registry, new ProxyMetrics(), "secret")) {
|
||||
admin.start();
|
||||
var baseUrl = "http://" + admin.bindAddress().getHostString() + ":" + admin.bindAddress().getPort();
|
||||
|
||||
var unauthorized = execute("--base-url", baseUrl, "servers", "list");
|
||||
assertEquals(1, unauthorized.exitCode());
|
||||
assertTrue(unauthorized.output().contains("unauthorized"));
|
||||
|
||||
var authorized = execute("--base-url", baseUrl, "--token", "secret", "servers", "list");
|
||||
assertEquals(0, authorized.exitCode());
|
||||
assertEquals("[]\n", authorized.output());
|
||||
|
||||
var overviewUnauthorized = execute("--base-url", baseUrl, "overview");
|
||||
assertEquals(1, overviewUnauthorized.exitCode());
|
||||
assertTrue(overviewUnauthorized.output().contains("unauthorized"));
|
||||
|
||||
var readiness = execute("--base-url", baseUrl, "ready");
|
||||
assertEquals(1, readiness.exitCode());
|
||||
assertTrue(readiness.output().contains("\"status\":\"NOT_READY\""));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void acceptsTlsStoreOptionsForAdminHttpsAndMtls() throws Exception {
|
||||
var directory = Files.createTempDirectory("strataproxy-admin-cli-tls");
|
||||
var serverKeyStore = directory.resolve("server-key.p12");
|
||||
var serverTrustStore = directory.resolve("server-trust.p12");
|
||||
var serverCertificate = directory.resolve("server.crt");
|
||||
var clientCertificate = directory.resolve("client.crt");
|
||||
var clientTrustStore = directory.resolve("client-trust.p12");
|
||||
var clientKeyStore = directory.resolve("client-key.p12");
|
||||
|
||||
generateKeyPair(serverKeyStore, "serverpass", "server", "CN=localhost", "SAN=dns:localhost");
|
||||
generateKeyPair(clientKeyStore, "keypass", "client", "CN=strataproxy-admin-client");
|
||||
exportCertificate(serverKeyStore, "serverpass", "server", serverCertificate);
|
||||
exportCertificate(clientKeyStore, "keypass", "client", clientCertificate);
|
||||
importCertificate(clientTrustStore, "trustpass", "server", serverCertificate);
|
||||
importCertificate(serverTrustStore, "servertrustpass", "client", clientCertificate);
|
||||
|
||||
var serverSslContext = sslContext(
|
||||
loadStore(serverKeyStore, "serverpass"),
|
||||
"serverpass",
|
||||
loadStore(serverTrustStore, "servertrustpass"));
|
||||
|
||||
var registry = new InMemoryServerRegistry();
|
||||
try (var admin = new AdminHttpServer(
|
||||
new InetSocketAddress("localhost", 0),
|
||||
new AdminRegistryService(registry, NoopRegistryStore.INSTANCE),
|
||||
new ProxyMetrics(),
|
||||
"",
|
||||
50,
|
||||
true,
|
||||
serverSslContext,
|
||||
true)) {
|
||||
admin.start();
|
||||
var baseUrl = "https://localhost:" + admin.bindAddress().getPort();
|
||||
|
||||
var health = execute(
|
||||
"--base-url", baseUrl,
|
||||
"--trust-store-path", clientTrustStore.toString(),
|
||||
"--trust-store-password", "trustpass",
|
||||
"--trust-store-type", "PKCS12",
|
||||
"--key-store-path", clientKeyStore.toString(),
|
||||
"--key-store-password", "keypass",
|
||||
"--key-store-type", "PKCS12",
|
||||
"health");
|
||||
|
||||
assertEquals(0, health.exitCode());
|
||||
assertTrue(health.output().contains("\"status\":\"UP\""));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void printsOperationalOverview() throws Exception {
|
||||
var registry = new InMemoryServerRegistry();
|
||||
registry.register(new ServerDescriptor(
|
||||
"lobby-1",
|
||||
new InetSocketAddress("127.0.0.1", 25565),
|
||||
Set.of("lobby"),
|
||||
Set.of(),
|
||||
new ProtocolRange(0, Integer.MAX_VALUE, "any"),
|
||||
100,
|
||||
100,
|
||||
120,
|
||||
false,
|
||||
Map.of()));
|
||||
var metrics = new ProxyMetrics();
|
||||
metrics.networkTransport("nio", false);
|
||||
metrics.acceptedConnection();
|
||||
metrics.routedConnection();
|
||||
metrics.rejectedConnection("global_limit");
|
||||
metrics.frontendToBackendBytes("lobby-1", 1024);
|
||||
metrics.backendToFrontendBytes("lobby-1", 2048);
|
||||
metrics.compressionNegotiated("lobby-1", 256);
|
||||
metrics.compressionSample("lobby-1", 1000, 400, 0);
|
||||
metrics.packetAnomaly("initial-handshake-malformed-frame");
|
||||
metrics.eventLoopDelayNanos(1_000_000);
|
||||
metrics.pooledDirectMemoryBytes(4096);
|
||||
try (var admin = new AdminHttpServer(new InetSocketAddress("127.0.0.1", 0), registry, metrics)) {
|
||||
admin.start();
|
||||
var baseUrl = "http://" + admin.bindAddress().getHostString() + ":" + admin.bindAddress().getPort();
|
||||
|
||||
var overview = execute("--base-url", baseUrl, "overview");
|
||||
|
||||
assertEquals(0, overview.exitCode());
|
||||
assertTrue(overview.output().contains("status UP"));
|
||||
assertTrue(overview.output().contains("servers 1"));
|
||||
assertTrue(overview.output().contains("connections_active 1"));
|
||||
assertTrue(overview.output().contains("connections_routed_total 1"));
|
||||
assertTrue(overview.output().contains("connections_rejected_total 1"));
|
||||
assertTrue(overview.output().contains("connections_rejected_total{reason=\"global_limit\"} 1"));
|
||||
assertTrue(overview.output().contains("frontend_to_backend_bytes_total 1024"));
|
||||
assertTrue(overview.output().contains("backend_to_frontend_bytes_total 2048"));
|
||||
assertTrue(overview.output().contains("compression_negotiations_total 1"));
|
||||
assertTrue(overview.output().contains("compression_saved_bytes_total 600"));
|
||||
assertTrue(overview.output().contains("compression_ratio 0.4000"));
|
||||
assertTrue(overview.output().contains("packet_anomalies_total 1"));
|
||||
assertTrue(overview.output().contains("event_loop_delay_seconds 0.001000"));
|
||||
assertTrue(overview.output().contains("pooled_direct_memory_bytes 4096"));
|
||||
assertTrue(overview.output().contains("transport nio"));
|
||||
assertTrue(overview.output().contains("native_transport false"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void checksOperationalSloGates() throws Exception {
|
||||
var registry = new InMemoryServerRegistry();
|
||||
registry.register(new ServerDescriptor(
|
||||
"lobby-1",
|
||||
new InetSocketAddress("127.0.0.1", 25565),
|
||||
Set.of("lobby"),
|
||||
Set.of(),
|
||||
new ProtocolRange(0, Integer.MAX_VALUE, "any"),
|
||||
100,
|
||||
100,
|
||||
120,
|
||||
false,
|
||||
Map.of()));
|
||||
var metrics = new ProxyMetrics();
|
||||
metrics.networkTransport("nio", false);
|
||||
metrics.acceptedConnection();
|
||||
metrics.eventLoopDelayNanos(1_000_000);
|
||||
try (var admin = new AdminHttpServer(new InetSocketAddress("127.0.0.1", 0), registry, metrics)) {
|
||||
admin.start();
|
||||
var baseUrl = "http://" + admin.bindAddress().getHostString() + ":" + admin.bindAddress().getPort();
|
||||
|
||||
var slo = execute(
|
||||
"--base-url", baseUrl,
|
||||
"slo",
|
||||
"--require-ready",
|
||||
"--max-event-loop-delay-ms", "2",
|
||||
"--max-active-connections", "1",
|
||||
"--max-rejected", "0",
|
||||
"--max-anomalies", "0");
|
||||
|
||||
assertEquals(0, slo.exitCode());
|
||||
assertTrue(slo.output().contains("gate actual expected status\n"));
|
||||
assertTrue(slo.output().contains("health_status UP UP PASS"));
|
||||
assertTrue(slo.output().contains("readiness_status READY/1/1/200 READY/>=1/registered/200 PASS"));
|
||||
assertTrue(slo.output().contains("event_loop_delay_ms 1.000 <=2.000 PASS"));
|
||||
assertTrue(slo.output().contains("active_connections 1 <=1 PASS"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void failsOperationalSloWhenGateIsExceeded() throws Exception {
|
||||
var registry = new InMemoryServerRegistry();
|
||||
var metrics = new ProxyMetrics();
|
||||
metrics.eventLoopDelayNanos(6_000_000);
|
||||
metrics.rejectedConnection();
|
||||
try (var admin = new AdminHttpServer(new InetSocketAddress("127.0.0.1", 0), registry, metrics)) {
|
||||
admin.start();
|
||||
var baseUrl = "http://" + admin.bindAddress().getHostString() + ":" + admin.bindAddress().getPort();
|
||||
|
||||
var slo = execute(
|
||||
"--base-url", baseUrl,
|
||||
"slo",
|
||||
"--max-event-loop-delay-ms", "5",
|
||||
"--max-rejected", "0");
|
||||
|
||||
assertEquals(1, slo.exitCode());
|
||||
assertTrue(slo.output().contains("event_loop_delay_ms 6.000 <=5.000 FAIL"));
|
||||
assertTrue(slo.output().contains("rejected_connections 1 <=0 FAIL"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void failsOperationalSloWhenReadinessFails() throws Exception {
|
||||
var registry = new InMemoryServerRegistry();
|
||||
var metrics = new ProxyMetrics();
|
||||
metrics.networkTransport("nio", false);
|
||||
try (var admin = new AdminHttpServer(new InetSocketAddress("127.0.0.1", 0), registry, metrics)) {
|
||||
admin.start();
|
||||
var baseUrl = "http://" + admin.bindAddress().getHostString() + ":" + admin.bindAddress().getPort();
|
||||
|
||||
var slo = execute("--base-url", baseUrl, "slo", "--require-ready");
|
||||
|
||||
assertEquals(1, slo.exitCode());
|
||||
assertTrue(slo.output().contains("health_status UP UP PASS"));
|
||||
assertTrue(slo.output().contains("readiness_status NOT_READY/0/0/503 READY/>=1/registered/200 FAIL"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void printsOperationalOverviewWhenPrometheusIsDisabled() throws Exception {
|
||||
var registry = new InMemoryServerRegistry();
|
||||
var metrics = new ProxyMetrics();
|
||||
metrics.networkTransport("nio", false);
|
||||
metrics.routedConnection();
|
||||
metrics.rejectedConnection("per_address_limit");
|
||||
metrics.frontendToBackendBytes(512);
|
||||
metrics.backendToFrontendBytes(1024);
|
||||
metrics.compressionSample(1000, 500, 0);
|
||||
metrics.packetAnomaly("initial-handshake-malformed-frame");
|
||||
metrics.eventLoopDelayNanos(2_000_000);
|
||||
metrics.pooledDirectMemoryBytes(8192);
|
||||
try (var admin = new AdminHttpServer(new InetSocketAddress("127.0.0.1", 0), registry, metrics, "", 50, false)) {
|
||||
admin.start();
|
||||
var baseUrl = "http://" + admin.bindAddress().getHostString() + ":" + admin.bindAddress().getPort();
|
||||
|
||||
var overview = execute("--base-url", baseUrl, "overview");
|
||||
|
||||
assertEquals(0, overview.exitCode());
|
||||
assertTrue(overview.output().contains("status UP"));
|
||||
assertTrue(overview.output().contains("connections_routed_total 1"));
|
||||
assertTrue(overview.output().contains("connections_rejected_total 1"));
|
||||
assertTrue(overview.output().contains("connections_rejected_total{reason=\"per_address_limit\"} 1"));
|
||||
assertTrue(overview.output().contains("frontend_to_backend_bytes_total 512"));
|
||||
assertTrue(overview.output().contains("backend_to_frontend_bytes_total 1024"));
|
||||
assertTrue(overview.output().contains("compression_saved_bytes_total 500"));
|
||||
assertTrue(overview.output().contains("packet_anomalies_total 1"));
|
||||
assertTrue(overview.output().contains("event_loop_delay_seconds 0.002000"));
|
||||
assertTrue(overview.output().contains("pooled_direct_memory_bytes 8192"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void printsActivePlayerSessions() throws Exception {
|
||||
var registry = new InMemoryServerRegistry();
|
||||
var metrics = new ProxyMetrics();
|
||||
metrics.playerSessionStarted("Steve", "survival-1", "127.0.0.1:50000");
|
||||
try (var admin = new AdminHttpServer(new InetSocketAddress("127.0.0.1", 0), registry, metrics)) {
|
||||
admin.start();
|
||||
var baseUrl = "http://" + admin.bindAddress().getHostString() + ":" + admin.bindAddress().getPort();
|
||||
|
||||
var players = execute("--base-url", baseUrl, "players");
|
||||
|
||||
assertEquals(0, players.exitCode());
|
||||
assertTrue(players.output().contains("player server remote_address connected_at\n"));
|
||||
assertTrue(players.output().contains("Steve survival-1 127.0.0.1:50000 "));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void previewsRoutingDecisions() throws Exception {
|
||||
var registry = new InMemoryServerRegistry();
|
||||
registry.register(new ServerDescriptor(
|
||||
"survival-1",
|
||||
new InetSocketAddress("127.0.0.1", 25565),
|
||||
Set.of("survival", "forge"),
|
||||
Set.of(dev.strataproxy.api.server.ServerCapability.LARGE_PAYLOAD),
|
||||
new ProtocolRange(763, 763, "1.20.1"),
|
||||
100,
|
||||
100,
|
||||
120,
|
||||
false,
|
||||
Map.of("host", "survival.example.net")));
|
||||
try (var admin = new AdminHttpServer(new InetSocketAddress("127.0.0.1", 0), registry, new ProxyMetrics())) {
|
||||
admin.start();
|
||||
var baseUrl = "http://" + admin.bindAddress().getHostString() + ":" + admin.bindAddress().getPort();
|
||||
|
||||
var selected = execute(
|
||||
"--base-url", baseUrl,
|
||||
"routes", "preview",
|
||||
"--route", "survival.example.net",
|
||||
"--protocol-version", "763",
|
||||
"--remote-address", "127.0.0.1:50000",
|
||||
"--tag", "survival",
|
||||
"--capability", "large-payload");
|
||||
|
||||
assertEquals(0, selected.exitCode());
|
||||
assertTrue(selected.output().contains("selected server score reason route protocol_version remote_address\n"));
|
||||
assertTrue(selected.output().contains("true survival-1 "));
|
||||
assertTrue(selected.output().contains("survival.example.net 763 /127.0.0.1:50000"));
|
||||
assertTrue(selected.output().contains("candidate eligible reason effective_weight selection_key\n"));
|
||||
assertTrue(selected.output().contains("survival-1 true - "));
|
||||
|
||||
registry.updateDrainMode("survival-1", true);
|
||||
var rejected = execute(
|
||||
"--base-url", baseUrl,
|
||||
"routes", "preview",
|
||||
"--route", "survival.example.net",
|
||||
"--protocol-version", "763");
|
||||
|
||||
assertEquals(1, rejected.exitCode());
|
||||
assertTrue(rejected.output().contains("false - 0.0000 no_healthy_backend_matched_route_constraints"));
|
||||
assertTrue(rejected.output().contains("survival-1 false draining 0.0000 inf"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void printsCompressionAuditSummary() throws Exception {
|
||||
var registry = new InMemoryServerRegistry();
|
||||
registry.register(new ServerDescriptor(
|
||||
"survival-1",
|
||||
new InetSocketAddress("127.0.0.1", 25565),
|
||||
Set.of("survival"),
|
||||
Set.of(),
|
||||
new ProtocolRange(0, Integer.MAX_VALUE, "any"),
|
||||
100,
|
||||
100,
|
||||
120,
|
||||
false,
|
||||
Map.of()));
|
||||
var metrics = new ProxyMetrics();
|
||||
metrics.compressionNegotiated("survival-1", 256);
|
||||
metrics.compressionSample("survival-1", 1000, 400, 0);
|
||||
metrics.compressionSample("survival-1", ProxyMetrics.CompressionDirection.FRONTEND_TO_BACKEND, 500, 250, 0);
|
||||
metrics.compressionDecision("survival-1", ProxyMetrics.CompressionDirection.FRONTEND_TO_BACKEND, "threshold", 1024);
|
||||
metrics.compressionDecision("survival-1", ProxyMetrics.CompressionDirection.FRONTEND_TO_BACKEND, "threshold", 1024);
|
||||
metrics.compressionDecision("survival-1", ProxyMetrics.CompressionDirection.BACKEND_TO_FRONTEND, "bypass", -1);
|
||||
metrics.compressionRewrite("survival-1", ProxyMetrics.CompressionDirection.FRONTEND_TO_BACKEND, "rewritten", 2_000_000);
|
||||
try (var admin = new AdminHttpServer(new InetSocketAddress("127.0.0.1", 0), registry, metrics)) {
|
||||
admin.start();
|
||||
var baseUrl = "http://" + admin.bindAddress().getHostString() + ":" + admin.bindAddress().getPort();
|
||||
|
||||
var compression = execute("--base-url", baseUrl, "compression");
|
||||
|
||||
assertEquals(0, compression.exitCode());
|
||||
assertTrue(compression.output().contains("scope direction raw_bytes compressed_bytes saved_bytes ratio samples threshold negotiations"));
|
||||
assertTrue(compression.output().contains("global all 1500 650 850 0.4333 2 -1 1"));
|
||||
assertTrue(compression.output().contains("survival-1 all 1500 650 850 0.4333 2 256 0"));
|
||||
assertTrue(compression.output().contains("survival-1 frontend_to_backend 500 250 250 0.5000 1 -1 0"));
|
||||
assertTrue(compression.output().contains("survival-1 backend_to_frontend 0 0 0 1.0000 0 -1 0"));
|
||||
assertTrue(compression.output().contains("decision_scope direction action threshold count\n"));
|
||||
assertTrue(compression.output().contains("survival-1 frontend_to_backend threshold 1024 2"));
|
||||
assertTrue(compression.output().contains("survival-1 backend_to_frontend bypass -1 1"));
|
||||
assertTrue(compression.output().contains("rewrite_scope direction outcome count cpu_millis\n"));
|
||||
assertTrue(compression.output().contains("survival-1 frontend_to_backend rewritten 1 2.000"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void printsCompressionAuditSummaryWhenPrometheusIsDisabled() throws Exception {
|
||||
var registry = new InMemoryServerRegistry();
|
||||
registry.register(new ServerDescriptor(
|
||||
"survival-1",
|
||||
new InetSocketAddress("127.0.0.1", 25565),
|
||||
Set.of("survival"),
|
||||
Set.of(),
|
||||
new ProtocolRange(0, Integer.MAX_VALUE, "any"),
|
||||
100,
|
||||
100,
|
||||
120,
|
||||
false,
|
||||
Map.of()));
|
||||
var metrics = new ProxyMetrics();
|
||||
metrics.compressionNegotiated("survival-1", 256);
|
||||
metrics.compressionSample("survival-1", 1000, 400, 0);
|
||||
metrics.compressionSample("survival-1", ProxyMetrics.CompressionDirection.FRONTEND_TO_BACKEND, 500, 250, 0);
|
||||
metrics.compressionDecision("survival-1", ProxyMetrics.CompressionDirection.FRONTEND_TO_BACKEND, "threshold", 1024);
|
||||
metrics.compressionRewrite("survival-1", ProxyMetrics.CompressionDirection.FRONTEND_TO_BACKEND, "rewritten", 3_000_000);
|
||||
try (var admin = new AdminHttpServer(new InetSocketAddress("127.0.0.1", 0), registry, metrics, "", 50, false)) {
|
||||
admin.start();
|
||||
var baseUrl = "http://" + admin.bindAddress().getHostString() + ":" + admin.bindAddress().getPort();
|
||||
|
||||
var compression = execute("--base-url", baseUrl, "compression");
|
||||
|
||||
assertEquals(0, compression.exitCode());
|
||||
assertTrue(compression.output().contains("global all 1500 650 850 0.4333 2 -1 1"));
|
||||
assertTrue(compression.output().contains("survival-1 all 1500 650 850 0.4333 2 256 0"));
|
||||
assertTrue(compression.output().contains("survival-1 frontend_to_backend 500 250 250 0.5000 1 -1 0"));
|
||||
assertTrue(compression.output().contains("survival-1 frontend_to_backend threshold 1024 1"));
|
||||
assertTrue(compression.output().contains("survival-1 frontend_to_backend rewritten 1 3.000"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void printsPacketAnomalySummary() throws Exception {
|
||||
var registry = new InMemoryServerRegistry();
|
||||
var metrics = new ProxyMetrics();
|
||||
metrics.packetAnomaly("compression-audit-malformed");
|
||||
metrics.packetAnomaly("initial-handshake-malformed-frame", "127.0.0.1:50000", "", "frontend_to_backend", "HANDSHAKE", 0, 48, -1, "play.example.net");
|
||||
metrics.packetAnomaly("initial-handshake-malformed-frame");
|
||||
try (var admin = new AdminHttpServer(new InetSocketAddress("127.0.0.1", 0), registry, metrics)) {
|
||||
admin.start();
|
||||
var baseUrl = "http://" + admin.bindAddress().getHostString() + ":" + admin.bindAddress().getPort();
|
||||
|
||||
var anomalies = execute("--base-url", baseUrl, "anomalies");
|
||||
|
||||
assertEquals(0, anomalies.exitCode());
|
||||
assertTrue(anomalies.output().contains("rule count\n"));
|
||||
assertTrue(anomalies.output().contains("initial-handshake-malformed-frame 2"));
|
||||
assertTrue(anomalies.output().contains("compression-audit-malformed 1"));
|
||||
assertTrue(anomalies.output().indexOf("initial-handshake-malformed-frame 2")
|
||||
< anomalies.output().indexOf("compression-audit-malformed 1"));
|
||||
|
||||
var samples = execute("--base-url", baseUrl, "anomalies", "--samples");
|
||||
assertEquals(0, samples.exitCode());
|
||||
assertTrue(samples.output().contains("sequence timestamp rule server direction state packet_id raw_size compressed_size remote detail\n"));
|
||||
assertTrue(samples.output().contains("initial-handshake-malformed-frame"));
|
||||
assertTrue(samples.output().contains("frontend_to_backend"));
|
||||
assertTrue(samples.output().contains("HANDSHAKE"));
|
||||
assertTrue(samples.output().contains(" 0 48 -1 "));
|
||||
assertTrue(samples.output().contains("127.0.0.1:50000"));
|
||||
assertTrue(samples.output().contains("play.example.net"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void printsPacketTrafficSummary() throws Exception {
|
||||
var registry = new InMemoryServerRegistry();
|
||||
var metrics = new ProxyMetrics();
|
||||
metrics.packetTraffic("survival-1", ProxyMetrics.CompressionDirection.FRONTEND_TO_BACKEND, "UNCOMPRESSED", 1, 100, 0);
|
||||
metrics.packetTraffic("survival-1", ProxyMetrics.CompressionDirection.FRONTEND_TO_BACKEND, "UNCOMPRESSED", 1, 50, 0);
|
||||
metrics.packetTraffic("survival-1", ProxyMetrics.CompressionDirection.BACKEND_TO_FRONTEND, "UNCOMPRESSED", 2, 300, 0);
|
||||
try (var admin = new AdminHttpServer(new InetSocketAddress("127.0.0.1", 0), registry, metrics)) {
|
||||
admin.start();
|
||||
var baseUrl = "http://" + admin.bindAddress().getHostString() + ":" + admin.bindAddress().getPort();
|
||||
|
||||
var packets = execute("--base-url", baseUrl, "packets");
|
||||
|
||||
assertEquals(0, packets.exitCode());
|
||||
assertTrue(packets.output().contains("server direction state packet_id packets raw_bytes compressed_bytes\n"));
|
||||
assertTrue(packets.output().contains("survival-1 backend_to_frontend UNCOMPRESSED 2 1 300 0"));
|
||||
assertTrue(packets.output().contains("survival-1 frontend_to_backend UNCOMPRESSED 1 2 150 0"));
|
||||
assertTrue(packets.output().indexOf("backend_to_frontend") < packets.output().indexOf("frontend_to_backend"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void printsCustomPayloadSummary() throws Exception {
|
||||
var registry = new InMemoryServerRegistry();
|
||||
var metrics = new ProxyMetrics();
|
||||
metrics.customPayload("survival-1", ProxyMetrics.CompressionDirection.FRONTEND_TO_BACKEND, "FORGE_HANDSHAKE", "fml:handshake", 128, 0);
|
||||
metrics.customPayload("survival-1", ProxyMetrics.CompressionDirection.FRONTEND_TO_BACKEND, "FABRIC_HANDSHAKE", "fabric:registry/sync", 256, 64);
|
||||
try (var admin = new AdminHttpServer(new InetSocketAddress("127.0.0.1", 0), registry, metrics)) {
|
||||
admin.start();
|
||||
var baseUrl = "http://" + admin.bindAddress().getHostString() + ":" + admin.bindAddress().getPort();
|
||||
|
||||
var payloads = execute("--base-url", baseUrl, "mod-payloads");
|
||||
|
||||
assertEquals(0, payloads.exitCode());
|
||||
assertTrue(payloads.output().contains("server direction kind channel packets payload_bytes compressed_bytes max_payload_bytes max_compressed_bytes first_seen last_seen\n"));
|
||||
assertTrue(payloads.output().contains("survival-1 frontend_to_backend FABRIC_HANDSHAKE fabric:registry/sync 1 256 64 256 64"));
|
||||
assertTrue(payloads.output().contains("survival-1 frontend_to_backend FORGE_HANDSHAKE fml:handshake 1 128 0 128 0"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void printsRelayBackpressureSummary() throws Exception {
|
||||
var registry = new InMemoryServerRegistry();
|
||||
var metrics = new ProxyMetrics();
|
||||
metrics.relayBackpressure("survival-1", ProxyMetrics.CompressionDirection.FRONTEND_TO_BACKEND, 1024);
|
||||
metrics.relayBackpressure("survival-1", ProxyMetrics.CompressionDirection.FRONTEND_TO_BACKEND, 2048);
|
||||
metrics.relayBackpressure("lobby-1", ProxyMetrics.CompressionDirection.BACKEND_TO_FRONTEND, 512);
|
||||
try (var admin = new AdminHttpServer(new InetSocketAddress("127.0.0.1", 0), registry, metrics)) {
|
||||
admin.start();
|
||||
var baseUrl = "http://" + admin.bindAddress().getHostString() + ":" + admin.bindAddress().getPort();
|
||||
|
||||
var backpressure = execute("--base-url", baseUrl, "backpressure");
|
||||
|
||||
assertEquals(0, backpressure.exitCode());
|
||||
assertTrue(backpressure.output().contains("server direction events last_bytes_before_writable max_bytes_before_writable\n"));
|
||||
assertTrue(backpressure.output().contains("survival-1 frontend_to_backend 2 2048 2048"));
|
||||
assertTrue(backpressure.output().contains("lobby-1 backend_to_frontend 1 512 512"));
|
||||
assertTrue(backpressure.output().indexOf("survival-1") < backpressure.output().indexOf("lobby-1"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void managesPayloadCaptures() throws Exception {
|
||||
var registry = new InMemoryServerRegistry();
|
||||
var metrics = new ProxyMetrics();
|
||||
try (var admin = new AdminHttpServer(new InetSocketAddress("127.0.0.1", 0), registry, metrics)) {
|
||||
admin.start();
|
||||
var baseUrl = "http://" + admin.bindAddress().getHostString() + ":" + admin.bindAddress().getPort();
|
||||
|
||||
var start = execute(
|
||||
"--base-url", baseUrl,
|
||||
"captures", "start",
|
||||
"--id", "cap-1",
|
||||
"--server", "survival-1",
|
||||
"--direction", "frontend_to_backend",
|
||||
"--max-samples", "2",
|
||||
"--max-bytes", "3",
|
||||
"--duration-ms", "30000");
|
||||
|
||||
assertEquals(0, start.exitCode());
|
||||
assertTrue(start.output().contains("id server direction max_samples max_bytes_per_sample expires_at sample_count\n"));
|
||||
assertTrue(start.output().contains("cap-1 survival-1 frontend_to_backend 2 3 "));
|
||||
|
||||
metrics.payloadCaptured(
|
||||
"survival-1",
|
||||
ProxyMetrics.CompressionDirection.FRONTEND_TO_BACKEND,
|
||||
4,
|
||||
-1,
|
||||
new byte[] {1, 2, 3, 4},
|
||||
"Steve",
|
||||
"127.0.0.1:50000");
|
||||
|
||||
var get = execute("--base-url", baseUrl, "captures", "get", "cap-1");
|
||||
|
||||
assertEquals(0, get.exitCode());
|
||||
assertTrue(
|
||||
get.output().contains("sequence capture_id server direction raw_bytes compressed_bytes player remote_address prefix_base64 timestamp\n"),
|
||||
get.output());
|
||||
assertTrue(get.output().contains("1 cap-1 survival-1 frontend_to_backend 4 0 Steve 127.0.0.1:50000 AQID "), get.output());
|
||||
|
||||
var list = execute("--base-url", baseUrl, "captures");
|
||||
|
||||
assertEquals(0, list.exitCode());
|
||||
assertTrue(list.output().contains("sample_count"));
|
||||
assertTrue(list.output().contains("cap-1 survival-1 frontend_to_backend 2 3 "));
|
||||
|
||||
var stop = execute("--base-url", baseUrl, "captures", "stop", "cap-1");
|
||||
|
||||
assertEquals(0, stop.exitCode());
|
||||
assertTrue(stop.output().contains("\"removed\":true"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void exportsDiagnosticReportJson() throws Exception {
|
||||
var registry = new InMemoryServerRegistry();
|
||||
registry.register(new ServerDescriptor(
|
||||
"survival-1",
|
||||
new InetSocketAddress("127.0.0.1", 25565),
|
||||
Set.of("survival"),
|
||||
Set.of(),
|
||||
new ProtocolRange(0, Integer.MAX_VALUE, "any"),
|
||||
100,
|
||||
100,
|
||||
120,
|
||||
false,
|
||||
Map.of()));
|
||||
var metrics = new ProxyMetrics();
|
||||
metrics.packetAnomaly("custom-payload-flood");
|
||||
metrics.packetTraffic("survival-1", ProxyMetrics.CompressionDirection.FRONTEND_TO_BACKEND, "UNCOMPRESSED", 1, 100, 0);
|
||||
metrics.customPayload("survival-1", ProxyMetrics.CompressionDirection.FRONTEND_TO_BACKEND, "FORGE_HANDSHAKE", "fml:handshake", 128, 0);
|
||||
metrics.relayBackpressure("survival-1", ProxyMetrics.CompressionDirection.FRONTEND_TO_BACKEND, 1024);
|
||||
try (var admin = new AdminHttpServer(new InetSocketAddress("127.0.0.1", 0), registry, metrics)) {
|
||||
admin.start();
|
||||
var baseUrl = "http://" + admin.bindAddress().getHostString() + ":" + admin.bindAddress().getPort();
|
||||
|
||||
var diagnostics = execute("--base-url", baseUrl, "diagnostics");
|
||||
|
||||
assertEquals(0, diagnostics.exitCode());
|
||||
assertTrue(diagnostics.output().contains("\"generatedAt\""));
|
||||
assertTrue(diagnostics.output().contains("\"overview\""));
|
||||
assertTrue(diagnostics.output().contains("\"servers\":[{\"name\":\"survival-1\""));
|
||||
assertTrue(diagnostics.output().contains("\"packetTraffic\""));
|
||||
assertTrue(diagnostics.output().contains("\"customPayloads\""));
|
||||
assertTrue(diagnostics.output().contains("\"packetAnomalies\""));
|
||||
assertTrue(diagnostics.output().contains("\"relayBackpressure\""));
|
||||
assertTrue(diagnostics.output().contains("\"kind\":\"FORGE_HANDSHAKE\",\"channel\":\"fml:handshake\",\"packets\":1"));
|
||||
assertTrue(diagnostics.output().contains("\"rule\":\"custom-payload-flood\",\"count\":1"));
|
||||
assertTrue(diagnostics.output().contains("\"server\":\"survival-1\",\"direction\":\"frontend_to_backend\",\"events\":1"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void printsEmptyPacketAnomalySummary() throws Exception {
|
||||
var registry = new InMemoryServerRegistry();
|
||||
try (var admin = new AdminHttpServer(new InetSocketAddress("127.0.0.1", 0), registry, new ProxyMetrics())) {
|
||||
admin.start();
|
||||
var baseUrl = "http://" + admin.bindAddress().getHostString() + ":" + admin.bindAddress().getPort();
|
||||
|
||||
var anomalies = execute("--base-url", baseUrl, "anomalies");
|
||||
|
||||
assertEquals(0, anomalies.exitCode());
|
||||
assertEquals("rule count\n", anomalies.output());
|
||||
}
|
||||
}
|
||||
|
||||
private static Result execute(String... args) {
|
||||
var originalOut = System.out;
|
||||
var output = new ByteArrayOutputStream();
|
||||
try {
|
||||
System.setOut(new PrintStream(output, true, StandardCharsets.UTF_8));
|
||||
var exitCode = new CommandLine(new StrataProxyAdminCli()).execute(args);
|
||||
return new Result(exitCode, output.toString(StandardCharsets.UTF_8));
|
||||
} finally {
|
||||
System.setOut(originalOut);
|
||||
}
|
||||
}
|
||||
|
||||
private static void generateKeyPair(Path store, String password, String alias, String distinguishedName, String... extensions) throws Exception {
|
||||
var args = new ArrayList<>(List.of(
|
||||
"-genkeypair",
|
||||
"-alias", alias,
|
||||
"-keyalg", "RSA",
|
||||
"-keysize", "2048",
|
||||
"-validity", "1",
|
||||
"-keystore", store.toString(),
|
||||
"-storetype", "PKCS12",
|
||||
"-storepass", password,
|
||||
"-keypass", password,
|
||||
"-dname", distinguishedName));
|
||||
for (var extension : extensions) {
|
||||
args.add("-ext");
|
||||
args.add(extension);
|
||||
}
|
||||
keytool(args);
|
||||
}
|
||||
|
||||
private static void exportCertificate(Path store, String password, String alias, Path certificate) throws Exception {
|
||||
keytool(List.of(
|
||||
"-exportcert",
|
||||
"-rfc",
|
||||
"-alias", alias,
|
||||
"-keystore", store.toString(),
|
||||
"-storepass", password,
|
||||
"-file", certificate.toString()));
|
||||
}
|
||||
|
||||
private static void importCertificate(Path store, String password, String alias, Path certificate) throws Exception {
|
||||
keytool(List.of(
|
||||
"-importcert",
|
||||
"-noprompt",
|
||||
"-alias", alias,
|
||||
"-file", certificate.toString(),
|
||||
"-keystore", store.toString(),
|
||||
"-storetype", "PKCS12",
|
||||
"-storepass", password));
|
||||
}
|
||||
|
||||
private static KeyStore loadStore(Path path, String password) throws Exception {
|
||||
var store = KeyStore.getInstance("PKCS12");
|
||||
try (var input = Files.newInputStream(path)) {
|
||||
store.load(input, password.toCharArray());
|
||||
}
|
||||
return store;
|
||||
}
|
||||
|
||||
private static void keytool(List<String> args) throws Exception {
|
||||
var executable = Path.of(System.getProperty("java.home"), "bin", windows() ? "keytool.exe" : "keytool").toString();
|
||||
var command = new ArrayList<String>();
|
||||
command.add(executable);
|
||||
command.addAll(args);
|
||||
var process = new ProcessBuilder(command)
|
||||
.redirectErrorStream(true)
|
||||
.start();
|
||||
var output = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
|
||||
var exitCode = process.waitFor();
|
||||
if (exitCode != 0) {
|
||||
throw new IOException("keytool failed with exit code " + exitCode + ": " + output);
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean windows() {
|
||||
return System.getProperty("os.name").toLowerCase(java.util.Locale.ROOT).contains("windows");
|
||||
}
|
||||
|
||||
private static SSLContext sslContext(KeyStore keyStore, String keyPassword, KeyStore trustStore) throws Exception {
|
||||
var keyManagerFactory = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
|
||||
keyManagerFactory.init(keyStore, keyPassword.toCharArray());
|
||||
var trustManagerFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
|
||||
trustManagerFactory.init(trustStore);
|
||||
var context = SSLContext.getInstance("TLS");
|
||||
context.init(keyManagerFactory.getKeyManagers(), trustManagerFactory.getTrustManagers(), null);
|
||||
return context;
|
||||
}
|
||||
|
||||
private record Result(int exitCode, String output) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
plugins {
|
||||
id("strataproxy.java-library-conventions")
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package dev.strataproxy.api.server;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
public record DrainPolicy(boolean rejectNewConnections, boolean migrateExistingPlayers, Duration gracePeriod) {
|
||||
public DrainPolicy {
|
||||
gracePeriod = gracePeriod == null ? Duration.ZERO : gracePeriod;
|
||||
}
|
||||
|
||||
public static DrainPolicy rejectNew() {
|
||||
return new DrainPolicy(true, false, Duration.ZERO);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package dev.strataproxy.api.server;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
public record ProtocolRange(int minProtocol, int maxProtocol, String displayName) {
|
||||
public ProtocolRange {
|
||||
if (minProtocol < 0 || maxProtocol < 0) {
|
||||
throw new IllegalArgumentException("protocol versions must be non-negative");
|
||||
}
|
||||
if (minProtocol > maxProtocol) {
|
||||
throw new IllegalArgumentException("minProtocol must be <= maxProtocol");
|
||||
}
|
||||
displayName = Objects.requireNonNull(displayName, "displayName");
|
||||
}
|
||||
|
||||
public boolean accepts(int protocolVersion) {
|
||||
return protocolVersion >= minProtocol && protocolVersion <= maxProtocol;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package dev.strataproxy.api.server;
|
||||
|
||||
public interface RegisteredServer {
|
||||
ServerDescriptor descriptor();
|
||||
|
||||
ServerHealth health();
|
||||
|
||||
ServerLoad load();
|
||||
|
||||
boolean draining();
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package dev.strataproxy.api.server;
|
||||
|
||||
public enum ServerCapability {
|
||||
FORGE_HANDSHAKE,
|
||||
FABRIC_HANDSHAKE,
|
||||
LARGE_PAYLOAD,
|
||||
MODERN_FORWARDING,
|
||||
LEGACY_FORWARDING,
|
||||
TRANSFER_PACKET,
|
||||
HEALTH_PROBE
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package dev.strataproxy.api.server;
|
||||
|
||||
import java.net.InetSocketAddress;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
public record ServerDescriptor(
|
||||
String name,
|
||||
InetSocketAddress address,
|
||||
Set<String> tags,
|
||||
Set<ServerCapability> capabilities,
|
||||
ProtocolRange protocolRange,
|
||||
int weight,
|
||||
int softCapacity,
|
||||
int hardCapacity,
|
||||
boolean drainMode,
|
||||
Map<String, String> metadata) {
|
||||
public ServerDescriptor {
|
||||
name = requireName(name);
|
||||
address = Objects.requireNonNull(address, "address");
|
||||
tags = Set.copyOf(tags == null ? Set.of() : tags);
|
||||
capabilities = Set.copyOf(capabilities == null ? Set.of() : capabilities);
|
||||
protocolRange = Objects.requireNonNull(protocolRange, "protocolRange");
|
||||
metadata = Map.copyOf(metadata == null ? Map.of() : metadata);
|
||||
if (weight <= 0) {
|
||||
throw new IllegalArgumentException("weight must be positive");
|
||||
}
|
||||
if (softCapacity < 0 || hardCapacity < 0) {
|
||||
throw new IllegalArgumentException("capacity values must be non-negative");
|
||||
}
|
||||
}
|
||||
|
||||
private static String requireName(String name) {
|
||||
var value = Objects.requireNonNull(name, "name").trim();
|
||||
if (value.isEmpty()) {
|
||||
throw new IllegalArgumentException("name must not be blank");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
public boolean supports(ServerCapability capability) {
|
||||
return capabilities.contains(capability);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package dev.strataproxy.api.server;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Objects;
|
||||
|
||||
public record ServerHealth(
|
||||
ServerHealthStatus status,
|
||||
long backendPingMillis,
|
||||
double recentFailureRate,
|
||||
String reason,
|
||||
Instant updatedAt) {
|
||||
public ServerHealth {
|
||||
status = Objects.requireNonNull(status, "status");
|
||||
reason = reason == null ? "" : reason;
|
||||
updatedAt = Objects.requireNonNull(updatedAt, "updatedAt");
|
||||
if (backendPingMillis < -1) {
|
||||
throw new IllegalArgumentException("backendPingMillis must be -1 or non-negative");
|
||||
}
|
||||
if (recentFailureRate < 0.0d || recentFailureRate > 1.0d) {
|
||||
throw new IllegalArgumentException("recentFailureRate must be between 0 and 1");
|
||||
}
|
||||
}
|
||||
|
||||
public static ServerHealth up(long pingMillis) {
|
||||
return new ServerHealth(ServerHealthStatus.UP, pingMillis, 0.0d, "", Instant.now());
|
||||
}
|
||||
|
||||
public boolean canReceiveNewConnections() {
|
||||
return status == ServerHealthStatus.UP || status == ServerHealthStatus.DEGRADED;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package dev.strataproxy.api.server;
|
||||
|
||||
public enum ServerHealthStatus {
|
||||
UP,
|
||||
DEGRADED,
|
||||
MAINTENANCE,
|
||||
DOWN
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package dev.strataproxy.api.server;
|
||||
|
||||
public record ServerLoad(
|
||||
int players,
|
||||
int softCapacity,
|
||||
int hardCapacity,
|
||||
long inboundBytesPerSecond,
|
||||
long outboundBytesPerSecond,
|
||||
long packetsPerSecond,
|
||||
double eventLoopDelayMillis) {
|
||||
public ServerLoad {
|
||||
if (players < 0 || softCapacity < 0 || hardCapacity < 0) {
|
||||
throw new IllegalArgumentException("capacity values must be non-negative");
|
||||
}
|
||||
if (softCapacity > hardCapacity && hardCapacity != 0) {
|
||||
throw new IllegalArgumentException("softCapacity must be <= hardCapacity");
|
||||
}
|
||||
}
|
||||
|
||||
public double capacityPressure() {
|
||||
if (softCapacity == 0) {
|
||||
return 0.0d;
|
||||
}
|
||||
return Math.min(2.0d, (double) players / softCapacity);
|
||||
}
|
||||
|
||||
public boolean isHardFull() {
|
||||
return hardCapacity > 0 && players >= hardCapacity;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package dev.strataproxy.api.server;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface ServerRegistry {
|
||||
RegisteredServer register(ServerDescriptor descriptor);
|
||||
|
||||
RegisteredServer registerOrReplace(ServerDescriptor descriptor);
|
||||
|
||||
boolean unregister(String name, DrainPolicy policy);
|
||||
|
||||
Optional<RegisteredServer> get(String name);
|
||||
|
||||
Collection<RegisteredServer> snapshot();
|
||||
|
||||
void updateHealth(String name, ServerHealth health);
|
||||
|
||||
void updateLoad(String name, ServerLoad load);
|
||||
|
||||
void updateDrainMode(String name, boolean drainMode);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import groovy.json.JsonSlurper
|
||||
import org.gradle.api.tasks.Exec
|
||||
|
||||
plugins {
|
||||
id("strataproxy.application-conventions")
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":proxy-bootstrap"))
|
||||
implementation(project(":proxy-network"))
|
||||
implementation(project(":proxy-registry"))
|
||||
implementation(project(":proxy-routing"))
|
||||
implementation(project(":proxy-compression"))
|
||||
implementation(project(":proxy-observability"))
|
||||
implementation(project(":proxy-packet-analysis"))
|
||||
implementation(project(":proxy-admin-api"))
|
||||
implementation(project(":proxy-codec-minecraft"))
|
||||
}
|
||||
|
||||
val installedDistSmokeTest = tasks.register<Exec>("installedDistSmokeTest") {
|
||||
group = "verification"
|
||||
description = "Runs the installed StrataProxy distribution in --validate-config mode."
|
||||
dependsOn(tasks.named("installDist"))
|
||||
|
||||
val installDir = layout.buildDirectory.dir("install/strataproxy")
|
||||
inputs.dir(installDir)
|
||||
|
||||
doFirst {
|
||||
val windows = System.getProperty("os.name").lowercase().contains("windows")
|
||||
val script = installDir.get().file("bin/" + if (windows) "strataproxy.bat" else "strataproxy").asFile
|
||||
val config = installDir.get().file("config/strataproxy.yml").asFile
|
||||
commandLine(script.absolutePath, "--validate-config", config.absolutePath)
|
||||
}
|
||||
}
|
||||
|
||||
val installedDistProductionConfigSmokeTest = tasks.register<Exec>("installedDistProductionConfigSmokeTest") {
|
||||
group = "verification"
|
||||
description = "Validates the installed production StrataProxy config."
|
||||
dependsOn(tasks.named("installDist"))
|
||||
|
||||
val installDir = layout.buildDirectory.dir("install/strataproxy")
|
||||
inputs.dir(installDir)
|
||||
|
||||
doFirst {
|
||||
val windows = System.getProperty("os.name").lowercase().contains("windows")
|
||||
val script = installDir.get().file("bin/" + if (windows) "strataproxy.bat" else "strataproxy").asFile
|
||||
val config = installDir.get().file("config/strataproxy-production.yml").asFile
|
||||
environment("STRATAPROXY_ADMIN_TOKEN", "smoke-token")
|
||||
commandLine(script.absolutePath, "--validate-config", config.absolutePath)
|
||||
}
|
||||
}
|
||||
|
||||
val deploymentAssetsSmokeTest = tasks.register("deploymentAssetsSmokeTest") {
|
||||
group = "verification"
|
||||
description = "Validates packaged deployment observability assets."
|
||||
|
||||
val dashboard = layout.projectDirectory.file("../deployment/observability/grafana/strataproxy-overview.json")
|
||||
val alerts = layout.projectDirectory.file("../deployment/observability/prometheus/strataproxy-alerts.yml")
|
||||
inputs.file(dashboard)
|
||||
inputs.file(alerts)
|
||||
|
||||
doLast {
|
||||
val parsed = JsonSlurper().parse(dashboard.asFile) as Map<*, *>
|
||||
require(parsed["uid"] == "strataproxy-overview") { "Grafana dashboard uid mismatch" }
|
||||
val panels = parsed["panels"] as List<*>
|
||||
require(panels.size >= 8) { "Grafana dashboard must contain operational panels" }
|
||||
val dashboardText = dashboard.asFile.readText()
|
||||
listOf(
|
||||
"strataproxy_connections_active",
|
||||
"strataproxy_event_loop_delay_seconds",
|
||||
"strataproxy_pooled_direct_memory_bytes",
|
||||
"strataproxy_compression_saved_bytes_total",
|
||||
"strataproxy_packet_anomalies_total",
|
||||
"strataproxy_relay_backpressure_events_total"
|
||||
).forEach { metric ->
|
||||
require(dashboardText.contains(metric)) { "Grafana dashboard missing $metric" }
|
||||
}
|
||||
|
||||
val alertText = alerts.asFile.readText()
|
||||
listOf(
|
||||
"StrataProxyTargetDown",
|
||||
"StrataProxyNoReadyBackends",
|
||||
"StrataProxyEventLoopDelayHigh",
|
||||
"StrataProxyHeapPressure",
|
||||
"StrataProxyPacketAnomalies",
|
||||
"strataproxy_routes_failed_total",
|
||||
"strataproxy_backend_connect_failures_total"
|
||||
).forEach { token ->
|
||||
require(alertText.contains(token)) { "Prometheus alerts missing $token" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val installedDistHelpSmokeTest = tasks.register<Exec>("installedDistHelpSmokeTest") {
|
||||
group = "verification"
|
||||
description = "Runs the installed StrataProxy distribution help command."
|
||||
dependsOn(tasks.named("installDist"))
|
||||
|
||||
val installDir = layout.buildDirectory.dir("install/strataproxy")
|
||||
inputs.dir(installDir)
|
||||
|
||||
doFirst {
|
||||
val windows = System.getProperty("os.name").lowercase().contains("windows")
|
||||
val script = installDir.get().file("bin/" + if (windows) "strataproxy.bat" else "strataproxy").asFile
|
||||
commandLine(script.absolutePath, "--help")
|
||||
}
|
||||
}
|
||||
|
||||
val installedDistVersionSmokeTest = tasks.register<Exec>("installedDistVersionSmokeTest") {
|
||||
group = "verification"
|
||||
description = "Runs the installed StrataProxy distribution version command."
|
||||
dependsOn(tasks.named("installDist"))
|
||||
|
||||
val installDir = layout.buildDirectory.dir("install/strataproxy")
|
||||
inputs.dir(installDir)
|
||||
|
||||
doFirst {
|
||||
val windows = System.getProperty("os.name").lowercase().contains("windows")
|
||||
val script = installDir.get().file("bin/" + if (windows) "strataproxy.bat" else "strataproxy").asFile
|
||||
commandLine(script.absolutePath, "--version")
|
||||
}
|
||||
}
|
||||
|
||||
tasks.named("check") {
|
||||
dependsOn(installedDistSmokeTest)
|
||||
dependsOn(installedDistProductionConfigSmokeTest)
|
||||
dependsOn(deploymentAssetsSmokeTest)
|
||||
dependsOn(installedDistHelpSmokeTest)
|
||||
dependsOn(installedDistVersionSmokeTest)
|
||||
}
|
||||
|
||||
application {
|
||||
mainClass.set("dev.strataproxy.app.StrataProxyLauncher")
|
||||
applicationName = "strataproxy"
|
||||
}
|
||||
|
||||
distributions {
|
||||
main {
|
||||
contents {
|
||||
from("src/main/resources/config") {
|
||||
into("config")
|
||||
}
|
||||
from("../deployment") {
|
||||
into("deployment")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package dev.strataproxy.app;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Map;
|
||||
|
||||
final class ConfigPathResolver {
|
||||
private static final Path DEFAULT_CONFIG = Path.of("config", "strataproxy.yml");
|
||||
|
||||
private ConfigPathResolver() {
|
||||
}
|
||||
|
||||
static Path resolve(String[] args) {
|
||||
return resolve(args, System.getenv(), Path.of("").toAbsolutePath(), detectedAppHome());
|
||||
}
|
||||
|
||||
static Path resolve(String[] args, Map<String, String> environment, Path workingDirectory) {
|
||||
return resolve(args, environment, workingDirectory, null);
|
||||
}
|
||||
|
||||
static Path resolve(String[] args, Map<String, String> environment, Path workingDirectory, Path detectedAppHome) {
|
||||
if (args != null && args.length > 0 && args[0] != null && !args[0].isBlank()) {
|
||||
return Path.of(args[0]);
|
||||
}
|
||||
|
||||
var workingConfig = workingDirectory.resolve(DEFAULT_CONFIG).normalize();
|
||||
if (Files.exists(workingConfig)) {
|
||||
return workingConfig;
|
||||
}
|
||||
|
||||
var appHome = environment.get("APP_HOME");
|
||||
if (appHome != null && !appHome.isBlank()) {
|
||||
var appHomeConfig = Path.of(appHome).resolve(DEFAULT_CONFIG).normalize();
|
||||
if (Files.exists(appHomeConfig)) {
|
||||
return appHomeConfig;
|
||||
}
|
||||
}
|
||||
|
||||
if (detectedAppHome != null) {
|
||||
var appHomeConfig = detectedAppHome.resolve(DEFAULT_CONFIG).normalize();
|
||||
if (Files.exists(appHomeConfig)) {
|
||||
return appHomeConfig;
|
||||
}
|
||||
}
|
||||
|
||||
return DEFAULT_CONFIG;
|
||||
}
|
||||
|
||||
private static Path detectedAppHome() {
|
||||
try {
|
||||
var location = ConfigPathResolver.class.getProtectionDomain().getCodeSource().getLocation().toURI();
|
||||
var path = Path.of(location).toAbsolutePath();
|
||||
var parent = Files.isRegularFile(path) ? path.getParent() : path;
|
||||
if (parent != null && parent.getFileName() != null && parent.getFileName().toString().equals("lib")) {
|
||||
return parent.getParent();
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package dev.strataproxy.app;
|
||||
|
||||
import dev.strataproxy.api.server.ServerLoad;
|
||||
import dev.strataproxy.api.server.ServerRegistry;
|
||||
import dev.strataproxy.observability.ProxyMetrics;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
final class ProxyObservedLoadReporter implements AutoCloseable {
|
||||
private final ServerRegistry registry;
|
||||
private final ProxyMetrics metrics;
|
||||
private final Duration interval;
|
||||
private final ScheduledExecutorService executor;
|
||||
private Map<String, PreviousServerCounters> previous = Map.of();
|
||||
|
||||
ProxyObservedLoadReporter(ServerRegistry registry, ProxyMetrics metrics, Duration interval) {
|
||||
this.registry = Objects.requireNonNull(registry, "registry");
|
||||
this.metrics = Objects.requireNonNull(metrics, "metrics");
|
||||
this.interval = Objects.requireNonNull(interval, "interval");
|
||||
if (interval.isZero() || interval.isNegative()) {
|
||||
throw new IllegalArgumentException("interval must be positive");
|
||||
}
|
||||
this.executor = Executors.newSingleThreadScheduledExecutor(Thread.ofVirtual()
|
||||
.name("strataproxy-load-reporter-", 0)
|
||||
.factory());
|
||||
}
|
||||
|
||||
void start() {
|
||||
executor.scheduleAtFixedRate(this::flushSafely, interval.toMillis(), interval.toMillis(), TimeUnit.MILLISECONDS);
|
||||
}
|
||||
|
||||
void flushOnce() {
|
||||
var snapshot = metrics.snapshot();
|
||||
var next = new HashMap<String, PreviousServerCounters>();
|
||||
var seconds = Math.max(0.001d, interval.toNanos() / 1_000_000_000.0d);
|
||||
for (var server : registry.snapshot()) {
|
||||
var name = server.descriptor().name();
|
||||
var traffic = snapshot.serverTraffic().getOrDefault(name, new ProxyMetrics.ServerTraffic(0, 0));
|
||||
var packetCount = packetCount(snapshot, name);
|
||||
var current = new PreviousServerCounters(
|
||||
traffic.frontendToBackendBytes(),
|
||||
traffic.backendToFrontendBytes(),
|
||||
packetCount);
|
||||
var prior = previous.getOrDefault(name, current);
|
||||
var load = server.load();
|
||||
registry.updateLoad(name, new ServerLoad(
|
||||
load.players(),
|
||||
load.softCapacity(),
|
||||
load.hardCapacity(),
|
||||
rate(current.frontendToBackendBytes() - prior.frontendToBackendBytes(), seconds),
|
||||
rate(current.backendToFrontendBytes() - prior.backendToFrontendBytes(), seconds),
|
||||
rate(current.packets() - prior.packets(), seconds),
|
||||
snapshot.eventLoopDelayNanos() / 1_000_000.0d));
|
||||
next.put(name, current);
|
||||
}
|
||||
previous = Map.copyOf(next);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
executor.shutdownNow();
|
||||
}
|
||||
|
||||
private void flushSafely() {
|
||||
try {
|
||||
flushOnce();
|
||||
} catch (RuntimeException ignored) {
|
||||
// Load reporting must not take down the proxy runtime.
|
||||
}
|
||||
}
|
||||
|
||||
private static long packetCount(ProxyMetrics.Snapshot snapshot, String server) {
|
||||
return snapshot.packetTraffic().entrySet().stream()
|
||||
.filter(entry -> entry.getKey().server().equals(server))
|
||||
.mapToLong(entry -> entry.getValue().packets())
|
||||
.sum();
|
||||
}
|
||||
|
||||
private static long rate(long delta, double seconds) {
|
||||
return Math.max(0L, Math.round(delta / seconds));
|
||||
}
|
||||
|
||||
private record PreviousServerCounters(
|
||||
long frontendToBackendBytes,
|
||||
long backendToFrontendBytes,
|
||||
long packets) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,454 @@
|
||||
package dev.strataproxy.app;
|
||||
|
||||
import dev.strataproxy.admin.AdminHttpServer;
|
||||
import dev.strataproxy.admin.AdminRegistryService;
|
||||
import dev.strataproxy.admin.JsonRegistryStore;
|
||||
import dev.strataproxy.admin.NoopRegistryStore;
|
||||
import dev.strataproxy.admin.RegistryStore;
|
||||
import dev.strataproxy.analysis.CustomPayloadAnomalyPolicy;
|
||||
import dev.strataproxy.bootstrap.ConfigLoader;
|
||||
import dev.strataproxy.bootstrap.ConfigValidationResult;
|
||||
import dev.strataproxy.bootstrap.ConfigValidator;
|
||||
import dev.strataproxy.bootstrap.ProxyConfig;
|
||||
import dev.strataproxy.compression.CompressionStrategies;
|
||||
import dev.strataproxy.network.NettyProxyNetworkServer;
|
||||
import dev.strataproxy.network.NetworkTuning;
|
||||
import dev.strataproxy.network.RoutingBackendResolver;
|
||||
import dev.strataproxy.observability.ProxyMetrics;
|
||||
import dev.strataproxy.registry.InMemoryServerRegistry;
|
||||
import dev.strataproxy.registry.TcpServerHealthChecker;
|
||||
import dev.strataproxy.routing.WeightedHealthAwareRouter;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.security.KeyStore;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.time.Instant;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import javax.net.ssl.KeyManagerFactory;
|
||||
import javax.net.ssl.SSLContext;
|
||||
import javax.net.ssl.TrustManagerFactory;
|
||||
|
||||
public final class StrataProxyLauncher {
|
||||
private static final String VERSION = "0.1.0-SNAPSHOT";
|
||||
|
||||
private StrataProxyLauncher() {
|
||||
}
|
||||
|
||||
public static void main(String[] args) throws Exception {
|
||||
var code = run(args);
|
||||
if (LauncherArguments.parse(args).terminalMode() || code != 0) {
|
||||
System.exit(code);
|
||||
}
|
||||
}
|
||||
|
||||
static int run(String[] args) throws Exception {
|
||||
var arguments = LauncherArguments.parse(args);
|
||||
if (arguments.error() != null) {
|
||||
System.err.println(arguments.error());
|
||||
printUsage(System.err);
|
||||
return 2;
|
||||
}
|
||||
if (arguments.help()) {
|
||||
printUsage(System.out);
|
||||
return 0;
|
||||
}
|
||||
if (arguments.version()) {
|
||||
System.out.println("StrataProxy " + VERSION);
|
||||
return 0;
|
||||
}
|
||||
var configPath = ConfigPathResolver.resolve(arguments.configArgs());
|
||||
if ((arguments.validateConfig() || arguments.explicitConfig()) && Files.notExists(configPath)) {
|
||||
System.err.println("StrataProxy config missing: " + configPath.toAbsolutePath());
|
||||
return 1;
|
||||
}
|
||||
ConfigLoader.LoadedProxyConfig loaded;
|
||||
ConfigValidationResult validation;
|
||||
try {
|
||||
loaded = new ConfigLoader().load(configPath);
|
||||
loaded = resolveConfigRelativePaths(configPath, loaded);
|
||||
validation = new ConfigValidator().validate(loaded);
|
||||
} catch (Exception exception) {
|
||||
if (arguments.validateConfig()) {
|
||||
System.out.println("ERROR failed to parse config: " + exception.getMessage());
|
||||
return 1;
|
||||
}
|
||||
System.err.println("StrataProxy config error: " + exception.getMessage());
|
||||
return 1;
|
||||
}
|
||||
if (arguments.validateConfig()) {
|
||||
for (var warning : validation.warnings()) {
|
||||
System.out.println("WARN " + warning);
|
||||
}
|
||||
for (var error : validation.errors()) {
|
||||
System.out.println("ERROR " + error);
|
||||
}
|
||||
if (validation.valid()) {
|
||||
System.out.println("StrataProxy config OK: " + configPath.toAbsolutePath());
|
||||
System.out.println("StrataProxy configured servers: " + loaded.servers().size());
|
||||
return 0;
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
if (!validation.valid()) {
|
||||
System.err.println("StrataProxy config invalid: " + String.join("; ", validation.errors()));
|
||||
return 1;
|
||||
}
|
||||
try {
|
||||
try (var runtime = startRuntime(loaded, configPath, System.out, true)) {
|
||||
runtime.awaitShutdown();
|
||||
}
|
||||
return 0;
|
||||
} catch (Exception exception) {
|
||||
System.err.println("StrataProxy startup failed: " + rootMessage(exception));
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
static RunningProxy startRuntime(
|
||||
ConfigLoader.LoadedProxyConfig loaded,
|
||||
Path configPath,
|
||||
java.io.PrintStream out,
|
||||
boolean registerShutdownHooks) throws Exception {
|
||||
loaded = resolveConfigRelativePaths(configPath, loaded);
|
||||
var config = loaded.proxy();
|
||||
var started = new ArrayList<AutoCloseable>();
|
||||
try {
|
||||
var registry = new InMemoryServerRegistry();
|
||||
loaded.servers().forEach(registry::register);
|
||||
var registryPersistencePath = config.registry().persistenceEnabled()
|
||||
? Path.of(config.registry().persistencePath())
|
||||
: null;
|
||||
RegistryStore registryStore = config.registry().persistenceEnabled()
|
||||
? new JsonRegistryStore(registryPersistencePath)
|
||||
: NoopRegistryStore.INSTANCE;
|
||||
for (var descriptor : loadPersistedRegistry(registryStore, registryPersistencePath, out)) {
|
||||
if (registry.get(descriptor.name()).isEmpty()) {
|
||||
registry.register(descriptor);
|
||||
}
|
||||
}
|
||||
var adminRegistry = new AdminRegistryService(registry, registryStore);
|
||||
adminRegistry.persist();
|
||||
|
||||
var router = new WeightedHealthAwareRouter(registry);
|
||||
var resolver = new RoutingBackendResolver(router);
|
||||
var metrics = new ProxyMetrics(config.observability().anomalySampling());
|
||||
var compressionStrategy = CompressionStrategies.from(config.compression().mode());
|
||||
var customPayloadPolicy = new CustomPayloadAnomalyPolicy(
|
||||
config.packetAnalysis().largePayloadWarnBytes(),
|
||||
config.packetAnalysis().unknownChannelThrottleBytes(),
|
||||
config.packetAnalysis().moddedHandshakeWarnBytes(),
|
||||
config.packetAnalysis().customPayloadFloodMaxCount(),
|
||||
config.packetAnalysis().customPayloadFloodWindow());
|
||||
|
||||
out.println("StrataProxy config: " + configPath.toAbsolutePath());
|
||||
out.println("StrataProxy servers: " + registry.snapshot().size());
|
||||
out.println("StrataProxy compression: " + config.compression().mode()
|
||||
+ " strategy=" + compressionStrategy.getClass().getSimpleName()
|
||||
+ " thresholds=" + config.compression().minThreshold() + ".." + config.compression().maxThreshold()
|
||||
+ " cpuGuard=" + config.compression().cpuGuard()
|
||||
+ " rewriteEnabled=" + config.compression().rewriteEnabled()
|
||||
+ " rewriteMaxEventLoopDelayMillis=" + config.compression().rewriteMaxEventLoopDelayMillis());
|
||||
out.println("StrataProxy starting on " + config.bindAddress() + " with " + config.resolvedWorkerThreads() + " worker threads");
|
||||
|
||||
if (config.registry().healthCheckEnabled()) {
|
||||
var healthChecker = new TcpServerHealthChecker(
|
||||
registry,
|
||||
config.registry().healthCheckInterval(),
|
||||
config.registry().healthCheckTimeout());
|
||||
healthChecker.start();
|
||||
started.add(healthChecker);
|
||||
out.println("StrataProxy health checks every " + config.registry().healthCheckInterval());
|
||||
}
|
||||
|
||||
if (config.admin().enabled()) {
|
||||
var adminSslContext = adminSslContext(config.admin().tls());
|
||||
var admin = new AdminHttpServer(
|
||||
config.admin().bindAddress(),
|
||||
adminRegistry,
|
||||
metrics,
|
||||
config.admin().bearerToken(),
|
||||
config.observability().packetTopN(),
|
||||
config.observability().prometheus(),
|
||||
adminSslContext,
|
||||
config.admin().tls().clientAuth());
|
||||
admin.start();
|
||||
started.add(admin);
|
||||
out.println("StrataProxy admin listening on "
|
||||
+ (adminSslContext == null ? "http://" : "https://")
|
||||
+ config.admin().bindAddress()
|
||||
+ (config.admin().tls().clientAuth() ? " with client certificate authentication" : ""));
|
||||
}
|
||||
|
||||
var loadReporter = new ProxyObservedLoadReporter(registry, metrics, config.observability().flushInterval());
|
||||
loadReporter.start();
|
||||
started.add(loadReporter);
|
||||
out.println("StrataProxy observed load flush interval " + config.observability().flushInterval());
|
||||
|
||||
var server = new NettyProxyNetworkServer(
|
||||
config.resolvedWorkerThreads(),
|
||||
resolver,
|
||||
metrics,
|
||||
new NetworkTuning(
|
||||
config.network().maxFrameBytes(),
|
||||
config.network().connectTimeoutMillis(),
|
||||
config.network().writeBufferLowBytes(),
|
||||
config.network().writeBufferHighBytes(),
|
||||
config.network().maxConnections(),
|
||||
config.network().maxConnectionsPerAddress(),
|
||||
config.network().initialHandshakeTimeoutMillis()),
|
||||
config.nativeTransport(),
|
||||
compressionStrategy,
|
||||
config.compression().minThreshold(),
|
||||
config.compression().maxThreshold(),
|
||||
config.compression().cpuGuard(),
|
||||
customPayloadPolicy,
|
||||
config.compression().rewriteEnabled(),
|
||||
config.compression().rewriteMaxEventLoopDelayMillis());
|
||||
started.add(server);
|
||||
server.bind(config.bindAddress()).toCompletableFuture().join();
|
||||
out.println("StrataProxy bound on " + server.bindAddress() + " using " + server.transportName() + " transport");
|
||||
var running = new RunningProxy(server, List.copyOf(started), metrics);
|
||||
if (registerShutdownHooks) {
|
||||
Runtime.getRuntime().addShutdownHook(new Thread(running::close, "strataproxy-shutdown"));
|
||||
}
|
||||
return running;
|
||||
} catch (Exception exception) {
|
||||
closeStarted(started);
|
||||
throw exception;
|
||||
}
|
||||
}
|
||||
|
||||
private static void closeStarted(List<AutoCloseable> started) {
|
||||
var reverse = new ArrayList<>(started);
|
||||
Collections.reverse(reverse);
|
||||
for (var resource : reverse) {
|
||||
try {
|
||||
resource.close();
|
||||
} catch (Exception ignored) {
|
||||
// Startup failure cleanup is best effort; the original startup error is more useful.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static List<dev.strataproxy.api.server.ServerDescriptor> loadPersistedRegistry(
|
||||
RegistryStore registryStore,
|
||||
Path registryPersistencePath,
|
||||
java.io.PrintStream out) throws Exception {
|
||||
try {
|
||||
return registryStore.load();
|
||||
} catch (Exception exception) {
|
||||
var quarantined = quarantineRegistry(registryPersistencePath);
|
||||
out.println("WARN failed to load persisted registry"
|
||||
+ (quarantined == null ? "" : "; moved bad file to " + quarantined)
|
||||
+ ": " + rootMessage(exception));
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
|
||||
private static Path quarantineRegistry(Path path) {
|
||||
if (path == null || Files.notExists(path)) {
|
||||
return null;
|
||||
}
|
||||
var timestamp = DateTimeFormatter.ISO_INSTANT.format(Instant.now()).replace(':', '-');
|
||||
var target = path.resolveSibling(path.getFileName() + ".invalid-" + timestamp);
|
||||
try {
|
||||
Files.move(path, target);
|
||||
return target;
|
||||
} catch (Exception ignored) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static String rootMessage(Throwable throwable) {
|
||||
var current = throwable;
|
||||
while (current.getCause() != null) {
|
||||
current = current.getCause();
|
||||
}
|
||||
var message = current.getMessage();
|
||||
return current.getClass().getSimpleName() + (message == null || message.isBlank() ? "" : ": " + message);
|
||||
}
|
||||
|
||||
private static SSLContext adminSslContext(ProxyConfig.AdminTlsConfig tls) throws Exception {
|
||||
if (tls == null || !tls.enabled()) {
|
||||
return null;
|
||||
}
|
||||
var keyStore = loadKeyStore(tls.keyStoreType(), tls.keyStorePath(), tls.keyStorePassword());
|
||||
var keyManagerFactory = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
|
||||
keyManagerFactory.init(keyStore, tls.keyStorePassword().toCharArray());
|
||||
|
||||
TrustManagerFactory trustManagerFactory = null;
|
||||
if (tls.clientAuth()) {
|
||||
var trustStore = loadKeyStore(tls.trustStoreType(), tls.trustStorePath(), tls.trustStorePassword());
|
||||
trustManagerFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
|
||||
trustManagerFactory.init(trustStore);
|
||||
}
|
||||
|
||||
var context = SSLContext.getInstance("TLS");
|
||||
context.init(
|
||||
keyManagerFactory.getKeyManagers(),
|
||||
trustManagerFactory == null ? null : trustManagerFactory.getTrustManagers(),
|
||||
null);
|
||||
return context;
|
||||
}
|
||||
|
||||
private static KeyStore loadKeyStore(String type, String path, String password) throws Exception {
|
||||
var store = KeyStore.getInstance(type);
|
||||
try (var input = Files.newInputStream(Path.of(path))) {
|
||||
store.load(input, password.toCharArray());
|
||||
}
|
||||
return store;
|
||||
}
|
||||
|
||||
private static Path resolveConfigRelativePath(Path configPath, String configuredPath) {
|
||||
var path = Path.of(configuredPath);
|
||||
if (path.isAbsolute()) {
|
||||
return path.normalize();
|
||||
}
|
||||
var parent = configPath.toAbsolutePath().normalize().getParent();
|
||||
return (parent == null ? path.toAbsolutePath() : parent.resolve(path)).normalize();
|
||||
}
|
||||
|
||||
private static ConfigLoader.LoadedProxyConfig resolveConfigRelativePaths(
|
||||
Path configPath,
|
||||
ConfigLoader.LoadedProxyConfig loaded) {
|
||||
var config = loaded.proxy();
|
||||
var registry = config.registry();
|
||||
var resolvedRegistry = new ProxyConfig.RegistryConfig(
|
||||
registry.staticServers(),
|
||||
registry.persistenceEnabled(),
|
||||
registry.persistencePath().isBlank()
|
||||
? registry.persistencePath()
|
||||
: resolveConfigRelativePath(configPath, registry.persistencePath()).toString(),
|
||||
registry.healthCheckEnabled(),
|
||||
registry.healthCheckInterval(),
|
||||
registry.healthCheckTimeout());
|
||||
|
||||
var admin = config.admin();
|
||||
var tls = admin.tls();
|
||||
var resolvedTls = new ProxyConfig.AdminTlsConfig(
|
||||
tls.enabled(),
|
||||
tls.keyStorePath().isBlank()
|
||||
? tls.keyStorePath()
|
||||
: resolveConfigRelativePath(configPath, tls.keyStorePath()).toString(),
|
||||
tls.keyStorePassword(),
|
||||
tls.keyStoreType(),
|
||||
tls.trustStorePath().isBlank()
|
||||
? tls.trustStorePath()
|
||||
: resolveConfigRelativePath(configPath, tls.trustStorePath()).toString(),
|
||||
tls.trustStorePassword(),
|
||||
tls.trustStoreType(),
|
||||
tls.clientAuth());
|
||||
var resolvedAdmin = new ProxyConfig.AdminConfig(
|
||||
admin.enabled(),
|
||||
admin.bindAddress(),
|
||||
admin.bearerToken(),
|
||||
resolvedTls);
|
||||
var resolvedConfig = new ProxyConfig(
|
||||
config.bindAddress(),
|
||||
config.workerThreads(),
|
||||
config.nativeTransport(),
|
||||
config.network(),
|
||||
resolvedRegistry,
|
||||
config.compression(),
|
||||
config.packetAnalysis(),
|
||||
config.observability(),
|
||||
resolvedAdmin);
|
||||
return new ConfigLoader.LoadedProxyConfig(resolvedConfig, loaded.servers());
|
||||
}
|
||||
|
||||
private static void printUsage(java.io.PrintStream out) {
|
||||
out.println("Usage: strataproxy [--help] [--version] [--validate-config] [config.yml]");
|
||||
out.println();
|
||||
out.println("Runs the StrataProxy Minecraft proxy.");
|
||||
out.println();
|
||||
out.println("Options:");
|
||||
out.println(" -h, --help Show this help message and exit.");
|
||||
out.println(" -V, --version Print version information and exit.");
|
||||
out.println(" --validate-config Validate config and exit without opening sockets.");
|
||||
out.println();
|
||||
out.println("Config lookup without an explicit path:");
|
||||
out.println(" 1. ./config/strataproxy.yml");
|
||||
out.println(" 2. $APP_HOME/config/strataproxy.yml");
|
||||
out.println(" 3. packaged application home config/strataproxy.yml");
|
||||
out.println(" 4. built-in defaults");
|
||||
}
|
||||
|
||||
record RunningProxy(
|
||||
NettyProxyNetworkServer server,
|
||||
List<AutoCloseable> started,
|
||||
ProxyMetrics metrics,
|
||||
AtomicBoolean closed,
|
||||
CountDownLatch stopped) implements AutoCloseable {
|
||||
RunningProxy(NettyProxyNetworkServer server, List<AutoCloseable> started, ProxyMetrics metrics) {
|
||||
this(server, started, metrics, new AtomicBoolean(), new CountDownLatch(1));
|
||||
}
|
||||
|
||||
java.net.InetSocketAddress bindAddress() {
|
||||
return server.bindAddress();
|
||||
}
|
||||
|
||||
void awaitShutdown() throws InterruptedException {
|
||||
stopped.await();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
if (!closed.compareAndSet(false, true)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
closeStarted(started);
|
||||
} finally {
|
||||
stopped.countDown();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private record LauncherArguments(
|
||||
boolean validateConfig,
|
||||
boolean help,
|
||||
boolean version,
|
||||
String[] configArgs,
|
||||
String error) {
|
||||
boolean explicitConfig() {
|
||||
return configArgs.length > 0;
|
||||
}
|
||||
|
||||
boolean terminalMode() {
|
||||
return validateConfig || help || version;
|
||||
}
|
||||
|
||||
private static LauncherArguments parse(String[] args) {
|
||||
if (args == null || args.length == 0) {
|
||||
return new LauncherArguments(false, false, false, new String[0], null);
|
||||
}
|
||||
var validate = false;
|
||||
var help = false;
|
||||
var version = false;
|
||||
var configArgs = new ArrayList<String>();
|
||||
for (var argument : Arrays.asList(args)) {
|
||||
switch (argument) {
|
||||
case "--validate-config" -> validate = true;
|
||||
case "-h", "--help" -> help = true;
|
||||
case "-V", "--version" -> version = true;
|
||||
default -> {
|
||||
if (argument.startsWith("-")) {
|
||||
return new LauncherArguments(validate, help, version, new String[0], "Unknown option: " + argument);
|
||||
}
|
||||
configArgs.add(argument);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (configArgs.size() > 1) {
|
||||
return new LauncherArguments(validate, help, version, configArgs.toArray(String[]::new), "Only one config path may be provided");
|
||||
}
|
||||
return new LauncherArguments(validate, help, version, configArgs.toArray(String[]::new), null);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
network:
|
||||
bind: "0.0.0.0:25577"
|
||||
workerThreads: 0
|
||||
nativeTransport: true
|
||||
maxFrameBytes: "16mb"
|
||||
connectTimeoutMillis: 3000
|
||||
writeBufferLow: "4mb"
|
||||
writeBufferHigh: "16mb"
|
||||
maxConnections: 20000
|
||||
maxConnectionsPerAddress: 300
|
||||
initialHandshakeTimeoutMillis: 5000
|
||||
|
||||
registry:
|
||||
staticServers: false
|
||||
persistenceEnabled: true
|
||||
persistencePath: "${STRATAPROXY_DATA_DIR:../data}/registry.json"
|
||||
healthCheckEnabled: true
|
||||
healthCheckInterval: "5s"
|
||||
healthCheckTimeout: "2s"
|
||||
|
||||
compression:
|
||||
mode: adaptive
|
||||
minThreshold: 256
|
||||
maxThreshold: 8192
|
||||
cpuGuard: 0.75
|
||||
rewriteEnabled: false
|
||||
rewriteMaxEventLoopDelayMillis: 25
|
||||
|
||||
packetAnalysis:
|
||||
largePayloadWarnBytes: "1mb"
|
||||
unknownChannelThrottleBytes: "256kb"
|
||||
moddedHandshakeWarnBytes: "2mb"
|
||||
customPayloadFloodMaxCount: 200
|
||||
customPayloadFloodWindow: "10s"
|
||||
|
||||
observability:
|
||||
prometheus: true
|
||||
packetTopN: 50
|
||||
anomalySampling: true
|
||||
flushIntervalSeconds: 5
|
||||
|
||||
admin:
|
||||
enabled: true
|
||||
bind: "127.0.0.1:8080"
|
||||
bearerToken: "${STRATAPROXY_ADMIN_TOKEN:}"
|
||||
tls:
|
||||
enabled: false
|
||||
keyStorePath: ""
|
||||
keyStorePassword: ""
|
||||
keyStoreType: "PKCS12"
|
||||
trustStorePath: ""
|
||||
trustStorePassword: ""
|
||||
trustStoreType: "PKCS12"
|
||||
clientAuth: false
|
||||
|
||||
servers: []
|
||||
@@ -0,0 +1,69 @@
|
||||
network:
|
||||
bind: "0.0.0.0:25577"
|
||||
workerThreads: 0
|
||||
nativeTransport: true
|
||||
maxFrameBytes: "8mb"
|
||||
connectTimeoutMillis: 5000
|
||||
writeBufferLow: "4mb"
|
||||
writeBufferHigh: "16mb"
|
||||
maxConnections: 10000
|
||||
maxConnectionsPerAddress: 200
|
||||
initialHandshakeTimeoutMillis: 5000
|
||||
|
||||
registry:
|
||||
staticServers: true
|
||||
persistenceEnabled: true
|
||||
persistencePath: "data/registry.json"
|
||||
healthCheckEnabled: true
|
||||
healthCheckInterval: "5s"
|
||||
healthCheckTimeout: "2s"
|
||||
|
||||
compression:
|
||||
mode: adaptive
|
||||
minThreshold: 256
|
||||
maxThreshold: 8192
|
||||
cpuGuard: 0.75
|
||||
rewriteEnabled: false
|
||||
rewriteMaxEventLoopDelayMillis: 25
|
||||
|
||||
packetAnalysis:
|
||||
largePayloadWarnBytes: "1mb"
|
||||
unknownChannelThrottleBytes: "256kb"
|
||||
moddedHandshakeWarnBytes: "2mb"
|
||||
customPayloadFloodMaxCount: 200
|
||||
customPayloadFloodWindow: "10s"
|
||||
|
||||
observability:
|
||||
prometheus: true
|
||||
packetTopN: 50
|
||||
anomalySampling: true
|
||||
flushIntervalSeconds: 5
|
||||
|
||||
admin:
|
||||
enabled: true
|
||||
bind: "127.0.0.1:8080"
|
||||
# Empty means unauthenticated admin API. Set a strong token before exposing beyond localhost.
|
||||
bearerToken: ""
|
||||
tls:
|
||||
enabled: false
|
||||
keyStorePath: ""
|
||||
keyStorePassword: ""
|
||||
keyStoreType: "PKCS12"
|
||||
trustStorePath: ""
|
||||
trustStorePassword: ""
|
||||
trustStoreType: "PKCS12"
|
||||
clientAuth: false
|
||||
|
||||
servers:
|
||||
- name: "lobby-1"
|
||||
address: "127.0.0.1:25565"
|
||||
tags: ["lobby"]
|
||||
capabilities: ["modern-forwarding"]
|
||||
protocolRange: "any"
|
||||
weight: 100
|
||||
softCapacity: 500
|
||||
hardCapacity: 600
|
||||
drainMode: false
|
||||
metadata:
|
||||
group: "lobby"
|
||||
host: "localhost"
|
||||
@@ -0,0 +1,56 @@
|
||||
package dev.strataproxy.app;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
final class ConfigPathResolverTest {
|
||||
@Test
|
||||
void explicitArgumentWins(@TempDir Path tempDir) {
|
||||
var explicit = tempDir.resolve("custom.yml");
|
||||
|
||||
var resolved = ConfigPathResolver.resolve(new String[] {explicit.toString()}, Map.of(), tempDir);
|
||||
|
||||
assertEquals(explicit, resolved);
|
||||
}
|
||||
|
||||
@Test
|
||||
void usesWorkingDirectoryConfigWhenPresent(@TempDir Path tempDir) throws Exception {
|
||||
var config = tempDir.resolve("config").resolve("strataproxy.yml");
|
||||
Files.createDirectories(config.getParent());
|
||||
Files.writeString(config, "network: {}\n");
|
||||
|
||||
var resolved = ConfigPathResolver.resolve(new String[0], Map.of(), tempDir);
|
||||
|
||||
assertEquals(config.normalize(), resolved);
|
||||
}
|
||||
|
||||
@Test
|
||||
void usesAppHomeConfigWhenWorkingDirectoryConfigIsMissing(@TempDir Path tempDir) throws Exception {
|
||||
var appHome = tempDir.resolve("app");
|
||||
var config = appHome.resolve("config").resolve("strataproxy.yml");
|
||||
Files.createDirectories(config.getParent());
|
||||
Files.writeString(config, "network: {}\n");
|
||||
|
||||
var resolved = ConfigPathResolver.resolve(new String[0], Map.of("APP_HOME", appHome.toString()), tempDir);
|
||||
|
||||
assertEquals(config.normalize(), resolved);
|
||||
}
|
||||
|
||||
@Test
|
||||
void usesDetectedAppHomeWhenEnvironmentIsMissing(@TempDir Path tempDir) throws Exception {
|
||||
var appHome = tempDir.resolve("installed");
|
||||
var config = appHome.resolve("config").resolve("strataproxy.yml");
|
||||
Files.createDirectories(config.getParent());
|
||||
Files.writeString(config, "network: {}\n");
|
||||
|
||||
var resolved = ConfigPathResolver.resolve(new String[0], Map.of(), tempDir, appHome);
|
||||
|
||||
assertEquals(config.normalize(), resolved);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package dev.strataproxy.app;
|
||||
|
||||
import dev.strataproxy.api.server.ProtocolRange;
|
||||
import dev.strataproxy.api.server.ServerCapability;
|
||||
import dev.strataproxy.api.server.ServerDescriptor;
|
||||
import dev.strataproxy.api.server.ServerLoad;
|
||||
import dev.strataproxy.observability.ProxyMetrics;
|
||||
import dev.strataproxy.registry.InMemoryServerRegistry;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.net.InetSocketAddress;
|
||||
import java.time.Duration;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
final class ProxyObservedLoadReporterTest {
|
||||
@Test
|
||||
void flushesObservedTrafficRatesWithoutOverwritingCapacityState() {
|
||||
var registry = new InMemoryServerRegistry();
|
||||
registry.register(new ServerDescriptor(
|
||||
"survival-1",
|
||||
new InetSocketAddress("127.0.0.1", 25565),
|
||||
Set.of("survival"),
|
||||
Set.of(ServerCapability.LARGE_PAYLOAD),
|
||||
new ProtocolRange(0, Integer.MAX_VALUE, "any"),
|
||||
100,
|
||||
200,
|
||||
240,
|
||||
false,
|
||||
Map.of()));
|
||||
registry.updateLoad("survival-1", new ServerLoad(42, 200, 240, 0, 0, 0, 0.0d));
|
||||
var metrics = new ProxyMetrics();
|
||||
|
||||
try (var reporter = new ProxyObservedLoadReporter(registry, metrics, Duration.ofSeconds(2))) {
|
||||
reporter.flushOnce();
|
||||
|
||||
metrics.frontendToBackendBytes("survival-1", 512);
|
||||
metrics.backendToFrontendBytes("survival-1", 1024);
|
||||
metrics.packetTraffic("survival-1", ProxyMetrics.CompressionDirection.FRONTEND_TO_BACKEND, "UNCOMPRESSED", 1, 256, 0);
|
||||
metrics.packetTraffic("survival-1", ProxyMetrics.CompressionDirection.BACKEND_TO_FRONTEND, "UNCOMPRESSED", 2, 512, 0);
|
||||
metrics.eventLoopDelayNanos(2_500_000);
|
||||
reporter.flushOnce();
|
||||
}
|
||||
|
||||
var load = registry.get("survival-1").orElseThrow().load();
|
||||
assertEquals(42, load.players());
|
||||
assertEquals(200, load.softCapacity());
|
||||
assertEquals(240, load.hardCapacity());
|
||||
assertEquals(256, load.inboundBytesPerSecond());
|
||||
assertEquals(512, load.outboundBytesPerSecond());
|
||||
assertEquals(1, load.packetsPerSecond());
|
||||
assertEquals(2.5d, load.eventLoopDelayMillis());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,516 @@
|
||||
package dev.strataproxy.app;
|
||||
|
||||
import dev.strataproxy.bootstrap.ConfigLoader;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.DataInputStream;
|
||||
import java.io.PrintStream;
|
||||
import java.net.ServerSocket;
|
||||
import java.net.Socket;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
final class StrataProxyLauncherValidationTest {
|
||||
@Test
|
||||
void validatesExistingConfigWithoutStartingProxy(@TempDir Path tempDir) throws Exception {
|
||||
var config = tempDir.resolve("strataproxy.yml");
|
||||
Files.writeString(config, """
|
||||
network:
|
||||
bind: "127.0.0.1:0"
|
||||
admin:
|
||||
enabled: false
|
||||
servers:
|
||||
- name: "lobby-1"
|
||||
address: "127.0.0.1:25565"
|
||||
tags: ["lobby"]
|
||||
capabilities: ["modern-forwarding"]
|
||||
protocolRange: "any"
|
||||
softCapacity: 100
|
||||
hardCapacity: 120
|
||||
""");
|
||||
|
||||
var result = capture(() -> StrataProxyLauncher.run(new String[] {"--validate-config", config.toString()}));
|
||||
|
||||
assertEquals(0, result.exitCode());
|
||||
assertTrue(result.output().contains("StrataProxy config OK"));
|
||||
assertTrue(result.output().contains("configured servers: 1"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void validatesDynamicOnlyConfigWithoutStaticServers(@TempDir Path tempDir) throws Exception {
|
||||
var config = tempDir.resolve("dynamic-only.yml");
|
||||
Files.writeString(config, """
|
||||
network:
|
||||
bind: "127.0.0.1:0"
|
||||
registry:
|
||||
staticServers: false
|
||||
persistenceEnabled: true
|
||||
admin:
|
||||
enabled: true
|
||||
servers:
|
||||
- name: "ignored-static"
|
||||
address: "127.0.0.1:25565"
|
||||
""");
|
||||
|
||||
var result = capture(() -> StrataProxyLauncher.run(new String[] {"--validate-config", config.toString()}));
|
||||
|
||||
assertEquals(0, result.exitCode());
|
||||
assertTrue(result.output().contains("WARN no static servers configured"));
|
||||
assertTrue(result.output().contains("configured servers: 0"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void validatesTlsRelativePathsFromConfigDirectory(@TempDir Path tempDir) throws Exception {
|
||||
var configDirectory = tempDir.resolve("conf");
|
||||
var tlsDirectory = configDirectory.resolve("tls");
|
||||
Files.createDirectories(tlsDirectory);
|
||||
Files.write(tlsDirectory.resolve("admin-server.p12"), new byte[] {1});
|
||||
var config = configDirectory.resolve("strataproxy.yml");
|
||||
Files.writeString(config, """
|
||||
network:
|
||||
bind: "127.0.0.1:0"
|
||||
registry:
|
||||
persistenceEnabled: false
|
||||
admin:
|
||||
enabled: true
|
||||
bind: "127.0.0.1:0"
|
||||
tls:
|
||||
enabled: true
|
||||
keyStorePath: "tls/admin-server.p12"
|
||||
keyStorePassword: "changeit"
|
||||
keyStoreType: "PKCS12"
|
||||
servers:
|
||||
- name: "lobby-1"
|
||||
address: "127.0.0.1:25565"
|
||||
""");
|
||||
|
||||
var result = capture(() -> StrataProxyLauncher.run(new String[] {"--validate-config", config.toString()}));
|
||||
|
||||
assertEquals(0, result.exitCode());
|
||||
assertTrue(result.output().contains("StrataProxy config OK"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void reportsInvalidConfigDuringValidation(@TempDir Path tempDir) throws Exception {
|
||||
var config = tempDir.resolve("bad.yml");
|
||||
Files.writeString(config, """
|
||||
network:
|
||||
bind: "127.0.0.1:0"
|
||||
maxFrameBytes: "64b"
|
||||
admin:
|
||||
enabled: false
|
||||
servers:
|
||||
- name: "bad"
|
||||
address: "127.0.0.1:25565"
|
||||
softCapacity: 200
|
||||
hardCapacity: 100
|
||||
""");
|
||||
|
||||
var result = capture(() -> StrataProxyLauncher.run(new String[] {"--validate-config", config.toString()}));
|
||||
|
||||
assertEquals(1, result.exitCode());
|
||||
assertTrue(result.output().contains("ERROR network.maxFrameBytes"));
|
||||
assertTrue(result.output().contains("ERROR server.bad softCapacity"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void reportsMissingConfigDuringValidation(@TempDir Path tempDir) throws Exception {
|
||||
var missing = tempDir.resolve("missing.yml");
|
||||
|
||||
var result = capture(() -> StrataProxyLauncher.run(new String[] {"--validate-config", missing.toString()}));
|
||||
|
||||
assertEquals(1, result.exitCode());
|
||||
assertTrue(result.error().contains("config missing"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void printsHelpWithoutLoadingConfig() throws Exception {
|
||||
var result = capture(() -> StrataProxyLauncher.run(new String[] {"--help"}));
|
||||
|
||||
assertEquals(0, result.exitCode());
|
||||
assertTrue(result.output().contains("Usage: strataproxy"));
|
||||
assertTrue(result.output().contains("--validate-config"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void printsVersionWithoutLoadingConfig() throws Exception {
|
||||
var result = capture(() -> StrataProxyLauncher.run(new String[] {"--version"}));
|
||||
|
||||
assertEquals(0, result.exitCode());
|
||||
assertTrue(result.output().contains("StrataProxy 0.1.0-SNAPSHOT"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsUnknownOptions() throws Exception {
|
||||
var result = capture(() -> StrataProxyLauncher.run(new String[] {"--bogus"}));
|
||||
|
||||
assertEquals(2, result.exitCode());
|
||||
assertTrue(result.error().contains("Unknown option: --bogus"));
|
||||
assertTrue(result.error().contains("Usage: strataproxy"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsMultipleConfigPaths(@TempDir Path tempDir) throws Exception {
|
||||
var first = tempDir.resolve("one.yml");
|
||||
var second = tempDir.resolve("two.yml");
|
||||
|
||||
var result = capture(() -> StrataProxyLauncher.run(new String[] {first.toString(), second.toString()}));
|
||||
|
||||
assertEquals(2, result.exitCode());
|
||||
assertTrue(result.error().contains("Only one config path"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void reportsMissingExplicitConfigDuringStartup(@TempDir Path tempDir) throws Exception {
|
||||
var missing = tempDir.resolve("missing.yml");
|
||||
|
||||
var result = capture(() -> StrataProxyLauncher.run(new String[] {missing.toString()}));
|
||||
|
||||
assertEquals(1, result.exitCode());
|
||||
assertTrue(result.error().contains("config missing"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void reportsConfigParseErrorsDuringValidation(@TempDir Path tempDir) throws Exception {
|
||||
var config = tempDir.resolve("bad-unit.yml");
|
||||
Files.writeString(config, """
|
||||
network:
|
||||
bind: "127.0.0.1:0"
|
||||
maxFrameBytes: "64parsecs"
|
||||
admin:
|
||||
enabled: false
|
||||
servers:
|
||||
- name: "lobby-1"
|
||||
address: "127.0.0.1:25565"
|
||||
""");
|
||||
|
||||
var result = capture(() -> StrataProxyLauncher.run(new String[] {"--validate-config", config.toString()}));
|
||||
|
||||
assertEquals(1, result.exitCode());
|
||||
assertTrue(result.output().contains("ERROR failed to parse config"));
|
||||
assertTrue(result.output().contains("unsupported byte unit"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void reportsStartupBindFailureWithoutThrowing(@TempDir Path tempDir) throws Exception {
|
||||
try (var occupied = new ServerSocket(0)) {
|
||||
var config = tempDir.resolve("strataproxy.yml");
|
||||
Files.writeString(config, """
|
||||
network:
|
||||
bind: "127.0.0.1:%d"
|
||||
nativeTransport: false
|
||||
registry:
|
||||
persistenceEnabled: false
|
||||
healthCheckEnabled: false
|
||||
admin:
|
||||
enabled: false
|
||||
servers:
|
||||
- name: "lobby-1"
|
||||
address: "127.0.0.1:25565"
|
||||
""".formatted(occupied.getLocalPort()));
|
||||
|
||||
var result = capture(() -> StrataProxyLauncher.run(new String[] {config.toString()}));
|
||||
|
||||
assertEquals(1, result.exitCode());
|
||||
assertTrue(result.error().contains("StrataProxy startup failed"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void startsRuntimeFromYamlAndProxiesMinecraftStatus(@TempDir Path tempDir) throws Exception {
|
||||
var status = "{\"version\":{\"name\":\"StrataProxy smoke\",\"protocol\":763},\"players\":{\"online\":0,\"max\":100}}";
|
||||
var backendExecutor = Executors.newSingleThreadExecutor();
|
||||
try (var backend = new ServerSocket(0)) {
|
||||
backend.setSoTimeout(5_000);
|
||||
var backendFuture = backendExecutor.submit(() -> {
|
||||
try (var socket = backend.accept()) {
|
||||
socket.setSoTimeout(5_000);
|
||||
var input = new DataInputStream(socket.getInputStream());
|
||||
input.readNBytes(readVarInt(input));
|
||||
input.readNBytes(readVarInt(input));
|
||||
socket.getOutputStream().write(statusResponse(status));
|
||||
socket.getOutputStream().flush();
|
||||
} catch (Exception exception) {
|
||||
throw new RuntimeException(exception);
|
||||
}
|
||||
});
|
||||
|
||||
var proxyPort = freePort();
|
||||
var config = tempDir.resolve("strataproxy.yml");
|
||||
Files.writeString(config, """
|
||||
network:
|
||||
bind: "127.0.0.1:%d"
|
||||
workerThreads: 1
|
||||
nativeTransport: false
|
||||
registry:
|
||||
persistenceEnabled: false
|
||||
healthCheckEnabled: false
|
||||
admin:
|
||||
enabled: false
|
||||
observability:
|
||||
flushIntervalSeconds: 1
|
||||
servers:
|
||||
- name: "smoke-backend"
|
||||
address: "127.0.0.1:%d"
|
||||
metadata:
|
||||
host: "play.example.net"
|
||||
""".formatted(proxyPort, backend.getLocalPort()));
|
||||
var loaded = new ConfigLoader().load(config);
|
||||
|
||||
try (var runtime = StrataProxyLauncher.startRuntime(
|
||||
loaded,
|
||||
config,
|
||||
new PrintStream(ByteArrayOutputStream.nullOutputStream(), true, StandardCharsets.UTF_8),
|
||||
false)) {
|
||||
assertEquals(proxyPort, runtime.bindAddress().getPort());
|
||||
|
||||
try (var client = new Socket()) {
|
||||
client.connect(runtime.bindAddress(), 5_000);
|
||||
client.setSoTimeout(5_000);
|
||||
client.getOutputStream().write(statusHandshake(763, "play.example.net", proxyPort));
|
||||
client.getOutputStream().write(statusRequest());
|
||||
client.getOutputStream().flush();
|
||||
|
||||
assertEquals(status, readStatusResponse(client.getInputStream()));
|
||||
}
|
||||
}
|
||||
|
||||
backendFuture.get(5, TimeUnit.SECONDS);
|
||||
} finally {
|
||||
backendExecutor.shutdownNow();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void resolvesRelativeRegistryPersistencePathFromConfigDirectory(@TempDir Path tempDir) throws Exception {
|
||||
var configDirectory = tempDir.resolve("conf");
|
||||
Files.createDirectories(configDirectory);
|
||||
var config = configDirectory.resolve("strataproxy.yml");
|
||||
Files.writeString(config, """
|
||||
network:
|
||||
bind: "127.0.0.1:0"
|
||||
workerThreads: 1
|
||||
nativeTransport: false
|
||||
registry:
|
||||
persistenceEnabled: true
|
||||
persistencePath: "data/registry.json"
|
||||
healthCheckEnabled: false
|
||||
admin:
|
||||
enabled: false
|
||||
observability:
|
||||
flushIntervalSeconds: 1
|
||||
servers:
|
||||
- name: "lobby-1"
|
||||
address: "127.0.0.1:25565"
|
||||
""");
|
||||
var loaded = new ConfigLoader().load(config);
|
||||
|
||||
try (var runtime = StrataProxyLauncher.startRuntime(
|
||||
loaded,
|
||||
config,
|
||||
new PrintStream(ByteArrayOutputStream.nullOutputStream(), true, StandardCharsets.UTF_8),
|
||||
false)) {
|
||||
assertTrue(runtime.bindAddress().getPort() > 0);
|
||||
}
|
||||
|
||||
assertTrue(Files.exists(configDirectory.resolve("data").resolve("registry.json")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void runningRuntimeCloseIsIdempotentAndReleasesListener(@TempDir Path tempDir) throws Exception {
|
||||
var config = tempDir.resolve("strataproxy.yml");
|
||||
Files.writeString(config, """
|
||||
network:
|
||||
bind: "127.0.0.1:0"
|
||||
workerThreads: 1
|
||||
nativeTransport: false
|
||||
registry:
|
||||
persistenceEnabled: false
|
||||
healthCheckEnabled: false
|
||||
admin:
|
||||
enabled: false
|
||||
observability:
|
||||
flushIntervalSeconds: 1
|
||||
servers:
|
||||
- name: "lobby-1"
|
||||
address: "127.0.0.1:25565"
|
||||
""");
|
||||
var loaded = new ConfigLoader().load(config);
|
||||
var runtime = StrataProxyLauncher.startRuntime(
|
||||
loaded,
|
||||
config,
|
||||
new PrintStream(ByteArrayOutputStream.nullOutputStream(), true, StandardCharsets.UTF_8),
|
||||
false);
|
||||
var address = runtime.bindAddress();
|
||||
|
||||
try (var executor = Executors.newSingleThreadExecutor()) {
|
||||
var stopped = executor.submit(() -> {
|
||||
runtime.awaitShutdown();
|
||||
return true;
|
||||
});
|
||||
|
||||
runtime.close();
|
||||
runtime.close();
|
||||
|
||||
assertTrue(stopped.get(5, TimeUnit.SECONDS));
|
||||
}
|
||||
|
||||
try (var rebound = new ServerSocket()) {
|
||||
rebound.bind(address);
|
||||
assertEquals(address.getPort(), rebound.getLocalPort());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void quarantinesCorruptPersistedRegistryAndStarts(@TempDir Path tempDir) throws Exception {
|
||||
var configDirectory = tempDir.resolve("conf");
|
||||
var dataDirectory = configDirectory.resolve("data");
|
||||
Files.createDirectories(dataDirectory);
|
||||
Files.writeString(dataDirectory.resolve("registry.json"), "{ not-json");
|
||||
var config = configDirectory.resolve("strataproxy.yml");
|
||||
Files.writeString(config, """
|
||||
network:
|
||||
bind: "127.0.0.1:0"
|
||||
workerThreads: 1
|
||||
nativeTransport: false
|
||||
registry:
|
||||
persistenceEnabled: true
|
||||
persistencePath: "data/registry.json"
|
||||
healthCheckEnabled: false
|
||||
admin:
|
||||
enabled: false
|
||||
observability:
|
||||
flushIntervalSeconds: 1
|
||||
servers:
|
||||
- name: "lobby-1"
|
||||
address: "127.0.0.1:25565"
|
||||
""");
|
||||
var loaded = new ConfigLoader().load(config);
|
||||
var output = new ByteArrayOutputStream();
|
||||
|
||||
try (var runtime = StrataProxyLauncher.startRuntime(
|
||||
loaded,
|
||||
config,
|
||||
new PrintStream(output, true, StandardCharsets.UTF_8),
|
||||
false)) {
|
||||
assertTrue(runtime.bindAddress().getPort() > 0);
|
||||
}
|
||||
|
||||
assertTrue(output.toString(StandardCharsets.UTF_8).contains("WARN failed to load persisted registry"));
|
||||
assertTrue(Files.readString(dataDirectory.resolve("registry.json")).contains("lobby-1"));
|
||||
try (var files = Files.list(dataDirectory)) {
|
||||
assertTrue(files.anyMatch(path -> path.getFileName().toString().startsWith("registry.json.invalid-")));
|
||||
}
|
||||
}
|
||||
|
||||
private static Result capture(ThrowingIntSupplier supplier) throws Exception {
|
||||
var originalOut = System.out;
|
||||
var originalErr = System.err;
|
||||
var out = new ByteArrayOutputStream();
|
||||
var err = new ByteArrayOutputStream();
|
||||
try {
|
||||
System.setOut(new PrintStream(out, true, StandardCharsets.UTF_8));
|
||||
System.setErr(new PrintStream(err, true, StandardCharsets.UTF_8));
|
||||
return new Result(
|
||||
supplier.getAsInt(),
|
||||
out.toString(StandardCharsets.UTF_8),
|
||||
err.toString(StandardCharsets.UTF_8));
|
||||
} finally {
|
||||
System.setOut(originalOut);
|
||||
System.setErr(originalErr);
|
||||
}
|
||||
}
|
||||
|
||||
private static int freePort() throws Exception {
|
||||
try (var socket = new ServerSocket(0)) {
|
||||
return socket.getLocalPort();
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] statusHandshake(int protocolVersion, String host, int port) {
|
||||
var payload = new ByteArrayOutputStream();
|
||||
writeVarInt(payload, 0);
|
||||
writeVarInt(payload, protocolVersion);
|
||||
writeString(payload, host);
|
||||
payload.write((port >>> 8) & 0xFF);
|
||||
payload.write(port & 0xFF);
|
||||
writeVarInt(payload, 1);
|
||||
return frame(payload.toByteArray());
|
||||
}
|
||||
|
||||
private static byte[] statusRequest() {
|
||||
var payload = new ByteArrayOutputStream();
|
||||
writeVarInt(payload, 0);
|
||||
return frame(payload.toByteArray());
|
||||
}
|
||||
|
||||
private static byte[] statusResponse(String json) {
|
||||
var payload = new ByteArrayOutputStream();
|
||||
writeVarInt(payload, 0);
|
||||
writeString(payload, json);
|
||||
return frame(payload.toByteArray());
|
||||
}
|
||||
|
||||
private static String readStatusResponse(java.io.InputStream input) throws Exception {
|
||||
var data = new DataInputStream(input);
|
||||
var frame = data.readNBytes(readVarInt(data));
|
||||
var packet = new DataInputStream(new java.io.ByteArrayInputStream(frame));
|
||||
assertEquals(0, readVarInt(packet));
|
||||
var jsonBytes = packet.readNBytes(readVarInt(packet));
|
||||
return new String(jsonBytes, StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
private static byte[] frame(byte[] payload) {
|
||||
var frame = new ByteArrayOutputStream();
|
||||
writeVarInt(frame, payload.length);
|
||||
frame.writeBytes(payload);
|
||||
return frame.toByteArray();
|
||||
}
|
||||
|
||||
private static void writeString(ByteArrayOutputStream output, String value) {
|
||||
var bytes = value.getBytes(StandardCharsets.UTF_8);
|
||||
writeVarInt(output, bytes.length);
|
||||
output.writeBytes(bytes);
|
||||
}
|
||||
|
||||
private static void writeVarInt(ByteArrayOutputStream output, int value) {
|
||||
var current = value;
|
||||
do {
|
||||
var temp = current & 0x7F;
|
||||
current >>>= 7;
|
||||
if (current != 0) {
|
||||
temp |= 0x80;
|
||||
}
|
||||
output.write(temp);
|
||||
} while (current != 0);
|
||||
}
|
||||
|
||||
private static int readVarInt(DataInputStream input) throws Exception {
|
||||
var value = 0;
|
||||
var position = 0;
|
||||
while (position < 5) {
|
||||
var current = input.readUnsignedByte();
|
||||
value |= (current & 0x7F) << (position * 7);
|
||||
position++;
|
||||
if ((current & 0x80) == 0) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
throw new IllegalArgumentException("malformed VarInt");
|
||||
}
|
||||
|
||||
private interface ThrowingIntSupplier {
|
||||
int getAsInt() throws Exception;
|
||||
}
|
||||
|
||||
private record Result(int exitCode, String output, String error) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
plugins {
|
||||
id("strataproxy.java-library-conventions")
|
||||
}
|
||||
|
||||
dependencies {
|
||||
api(project(":proxy-api"))
|
||||
implementation(project(":proxy-common"))
|
||||
implementation(libs.findLibrary("jackson-databind").get())
|
||||
implementation(libs.findLibrary("jackson-yaml").get())
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
package dev.strataproxy.bootstrap;
|
||||
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
|
||||
import dev.strataproxy.api.server.ProtocolRange;
|
||||
import dev.strataproxy.api.server.ServerCapability;
|
||||
import dev.strataproxy.api.server.ServerDescriptor;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
public final class ConfigLoader {
|
||||
private final ObjectMapper mapper = new ObjectMapper(new YAMLFactory())
|
||||
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
|
||||
|
||||
public LoadedProxyConfig load(Path path) throws IOException {
|
||||
return load(path, System.getenv());
|
||||
}
|
||||
|
||||
public LoadedProxyConfig load(Path path, Map<String, String> environment) throws IOException {
|
||||
if (Files.notExists(path)) {
|
||||
return LoadedProxyConfig.defaults();
|
||||
}
|
||||
var yaml = expandEnvironment(Files.readString(path), environment);
|
||||
var file = mapper.readValue(yaml, ConfigFile.class);
|
||||
return file.toLoadedConfig();
|
||||
}
|
||||
|
||||
public record LoadedProxyConfig(ProxyConfig proxy, List<ServerDescriptor> servers) {
|
||||
public static LoadedProxyConfig defaults() {
|
||||
return new LoadedProxyConfig(ProxyConfig.defaults(), List.of(defaultServer()));
|
||||
}
|
||||
|
||||
private static ServerDescriptor defaultServer() {
|
||||
return new ServerDescriptor(
|
||||
"lobby-1",
|
||||
new InetSocketAddress("127.0.0.1", 25565),
|
||||
Set.of("lobby"),
|
||||
Set.of(ServerCapability.MODERN_FORWARDING),
|
||||
new ProtocolRange(0, Integer.MAX_VALUE, "any"),
|
||||
100,
|
||||
500,
|
||||
600,
|
||||
false,
|
||||
Map.of("group", "lobby", "host", "localhost"));
|
||||
}
|
||||
}
|
||||
|
||||
public static final class ConfigFile {
|
||||
public NetworkFile network = new NetworkFile();
|
||||
public RegistryFile registry = new RegistryFile();
|
||||
public CompressionFile compression = new CompressionFile();
|
||||
public PacketAnalysisFile packetAnalysis = new PacketAnalysisFile();
|
||||
public ObservabilityFile observability = new ObservabilityFile();
|
||||
public AdminFile admin = new AdminFile();
|
||||
public List<ServerFile> servers = List.of();
|
||||
|
||||
LoadedProxyConfig toLoadedConfig() {
|
||||
var bind = parseAddress(network.bind, 25577);
|
||||
var proxy = new ProxyConfig(
|
||||
bind,
|
||||
network.workerThreads,
|
||||
network.nativeTransport,
|
||||
new ProxyConfig.NetworkConfig(
|
||||
parseBytes(network.maxFrameBytes, 8 * 1024 * 1024),
|
||||
network.connectTimeoutMillis,
|
||||
parseBytes(network.writeBufferLow, 4 * 1024 * 1024),
|
||||
parseBytes(network.writeBufferHigh, 16 * 1024 * 1024),
|
||||
network.maxConnections,
|
||||
network.maxConnectionsPerAddress,
|
||||
network.initialHandshakeTimeoutMillis),
|
||||
new ProxyConfig.RegistryConfig(
|
||||
registry.staticServers,
|
||||
registry.persistenceEnabled,
|
||||
registry.persistencePath,
|
||||
registry.healthCheckEnabled,
|
||||
java.time.Duration.ofMillis(parseDurationMillis(registry.healthCheckInterval, 5_000)),
|
||||
java.time.Duration.ofMillis(parseDurationMillis(registry.healthCheckTimeout, 2_000))),
|
||||
new ProxyConfig.CompressionConfig(
|
||||
compression.mode,
|
||||
compression.minThreshold,
|
||||
compression.maxThreshold,
|
||||
compression.cpuGuard,
|
||||
compression.rewriteEnabled,
|
||||
compression.rewriteMaxEventLoopDelayMillis),
|
||||
new ProxyConfig.PacketAnalysisConfig(
|
||||
parseBytes(packetAnalysis.largePayloadWarnBytes, 1024 * 1024),
|
||||
parseBytes(packetAnalysis.unknownChannelThrottleBytes, 256 * 1024),
|
||||
parseBytes(packetAnalysis.moddedHandshakeWarnBytes, 2 * 1024 * 1024),
|
||||
packetAnalysis.customPayloadFloodMaxCount,
|
||||
java.time.Duration.ofMillis(parseDurationMillis(packetAnalysis.customPayloadFloodWindow, 10_000))),
|
||||
new ProxyConfig.ObservabilityConfig(observability.prometheus, observability.packetTopN, observability.anomalySampling, java.time.Duration.ofSeconds(observability.flushIntervalSeconds)),
|
||||
new ProxyConfig.AdminConfig(
|
||||
admin.enabled,
|
||||
parseAddress(admin.bind, 8080),
|
||||
admin.bearerToken,
|
||||
new ProxyConfig.AdminTlsConfig(
|
||||
admin.tls.enabled,
|
||||
admin.tls.keyStorePath,
|
||||
admin.tls.keyStorePassword,
|
||||
admin.tls.keyStoreType,
|
||||
admin.tls.trustStorePath,
|
||||
admin.tls.trustStorePassword,
|
||||
admin.tls.trustStoreType,
|
||||
admin.tls.clientAuth)));
|
||||
var descriptors = registry.staticServers
|
||||
? servers.stream().map(ServerFile::toDescriptor).toList()
|
||||
: List.<ServerDescriptor>of();
|
||||
return new LoadedProxyConfig(proxy, descriptors);
|
||||
}
|
||||
}
|
||||
|
||||
public static final class NetworkFile {
|
||||
public String bind = "0.0.0.0:25577";
|
||||
public int workerThreads = 0;
|
||||
public boolean nativeTransport = true;
|
||||
public String maxFrameBytes = "8mb";
|
||||
public int connectTimeoutMillis = 5_000;
|
||||
public String writeBufferLow = "4mb";
|
||||
public String writeBufferHigh = "16mb";
|
||||
public int maxConnections = 10_000;
|
||||
public int maxConnectionsPerAddress = 200;
|
||||
public int initialHandshakeTimeoutMillis = 5_000;
|
||||
}
|
||||
|
||||
public static final class RegistryFile {
|
||||
public boolean staticServers = true;
|
||||
public boolean persistenceEnabled = true;
|
||||
public String persistencePath = "data/registry.json";
|
||||
public boolean healthCheckEnabled = true;
|
||||
public String healthCheckInterval = "5s";
|
||||
public String healthCheckTimeout = "2s";
|
||||
}
|
||||
|
||||
public static final class CompressionFile {
|
||||
public String mode = "adaptive";
|
||||
public int minThreshold = 256;
|
||||
public int maxThreshold = 8192;
|
||||
public double cpuGuard = 0.75d;
|
||||
public boolean rewriteEnabled = false;
|
||||
public int rewriteMaxEventLoopDelayMillis = 25;
|
||||
}
|
||||
|
||||
public static final class PacketAnalysisFile {
|
||||
public String largePayloadWarnBytes = "1mb";
|
||||
public String unknownChannelThrottleBytes = "256kb";
|
||||
public String moddedHandshakeWarnBytes = "2mb";
|
||||
public int customPayloadFloodMaxCount = 200;
|
||||
public String customPayloadFloodWindow = "10s";
|
||||
}
|
||||
|
||||
public static final class ObservabilityFile {
|
||||
public boolean prometheus = true;
|
||||
public int packetTopN = 50;
|
||||
public boolean anomalySampling = true;
|
||||
public long flushIntervalSeconds = 5;
|
||||
}
|
||||
|
||||
public static final class AdminFile {
|
||||
public boolean enabled = true;
|
||||
public String bind = "127.0.0.1:8080";
|
||||
public String bearerToken = "";
|
||||
public AdminTlsFile tls = new AdminTlsFile();
|
||||
}
|
||||
|
||||
public static final class AdminTlsFile {
|
||||
public boolean enabled = false;
|
||||
public String keyStorePath = "";
|
||||
public String keyStorePassword = "";
|
||||
public String keyStoreType = "PKCS12";
|
||||
public String trustStorePath = "";
|
||||
public String trustStorePassword = "";
|
||||
public String trustStoreType = "PKCS12";
|
||||
public boolean clientAuth = false;
|
||||
}
|
||||
|
||||
public static final class ServerFile {
|
||||
public String name;
|
||||
public String address;
|
||||
public Set<String> tags = Set.of();
|
||||
public Set<String> capabilities = Set.of();
|
||||
public String protocolRange = "any";
|
||||
public int weight = 100;
|
||||
public int softCapacity = 500;
|
||||
public int hardCapacity = 600;
|
||||
public boolean drainMode = false;
|
||||
public Map<String, String> metadata = Map.of();
|
||||
|
||||
ServerDescriptor toDescriptor() {
|
||||
return new ServerDescriptor(
|
||||
name,
|
||||
parseAddress(address, 25565),
|
||||
tags,
|
||||
capabilities.stream().map(ServerFile::parseCapability).collect(Collectors.toUnmodifiableSet()),
|
||||
parseProtocolRange(protocolRange),
|
||||
weight,
|
||||
softCapacity,
|
||||
hardCapacity,
|
||||
drainMode,
|
||||
metadata);
|
||||
}
|
||||
|
||||
private static ServerCapability parseCapability(String value) {
|
||||
return ServerCapability.valueOf(value.trim().replace('-', '_').toUpperCase(Locale.ROOT));
|
||||
}
|
||||
}
|
||||
|
||||
static InetSocketAddress parseAddress(String value, int defaultPort) {
|
||||
if (value == null || value.isBlank()) {
|
||||
throw new IllegalArgumentException("address must not be blank");
|
||||
}
|
||||
var trimmed = value.trim();
|
||||
var splitAt = trimmed.lastIndexOf(':');
|
||||
if (splitAt <= 0) {
|
||||
return new InetSocketAddress(trimmed, defaultPort);
|
||||
}
|
||||
return new InetSocketAddress(trimmed.substring(0, splitAt), Integer.parseInt(trimmed.substring(splitAt + 1)));
|
||||
}
|
||||
|
||||
static ProtocolRange parseProtocolRange(String value) {
|
||||
if (value == null || value.isBlank() || value.equalsIgnoreCase("any")) {
|
||||
return new ProtocolRange(0, Integer.MAX_VALUE, "any");
|
||||
}
|
||||
var trimmed = value.trim();
|
||||
if (trimmed.matches("\\d+")) {
|
||||
var protocol = Integer.parseInt(trimmed);
|
||||
return new ProtocolRange(protocol, protocol, trimmed);
|
||||
}
|
||||
var bounds = trimmed.split("\\.\\.", 2);
|
||||
if (bounds.length == 2) {
|
||||
return new ProtocolRange(Integer.parseInt(bounds[0]), Integer.parseInt(bounds[1]), trimmed);
|
||||
}
|
||||
throw new IllegalArgumentException("unsupported protocolRange: " + value);
|
||||
}
|
||||
|
||||
static int parseBytes(String value, int defaultValue) {
|
||||
if (value == null || value.isBlank()) {
|
||||
return defaultValue;
|
||||
}
|
||||
var trimmed = value.trim().toLowerCase(Locale.ROOT);
|
||||
var multiplier = switch (trimmed.replaceAll("[0-9_ ]", "")) {
|
||||
case "kb", "k" -> 1024;
|
||||
case "mb", "m" -> 1024 * 1024;
|
||||
case "b", "" -> 1;
|
||||
default -> throw new IllegalArgumentException("unsupported byte unit: " + value);
|
||||
};
|
||||
var number = trimmed.replaceAll("[^0-9]", "");
|
||||
return Math.toIntExact(Long.parseLong(number) * multiplier);
|
||||
}
|
||||
|
||||
static long parseDurationMillis(String value, long defaultValue) {
|
||||
if (value == null || value.isBlank()) {
|
||||
return defaultValue;
|
||||
}
|
||||
var trimmed = value.trim().toLowerCase(Locale.ROOT);
|
||||
var unit = trimmed.replaceAll("[0-9_ .]", "");
|
||||
var number = trimmed.replaceAll("[^0-9.]", "");
|
||||
var parsed = Double.parseDouble(number);
|
||||
return switch (unit) {
|
||||
case "ms", "" -> Math.round(parsed);
|
||||
case "s" -> Math.round(parsed * 1_000);
|
||||
case "m" -> Math.round(parsed * 60_000);
|
||||
default -> throw new IllegalArgumentException("unsupported duration unit: " + value);
|
||||
};
|
||||
}
|
||||
|
||||
static String expandEnvironment(String value, Map<String, String> environment) {
|
||||
if (value == null || value.indexOf('$') < 0) {
|
||||
return value;
|
||||
}
|
||||
var expanded = new StringBuilder(value.length());
|
||||
var index = 0;
|
||||
while (index < value.length()) {
|
||||
var current = value.charAt(index);
|
||||
if (current != '$' || index + 1 >= value.length() || value.charAt(index + 1) != '{') {
|
||||
expanded.append(current);
|
||||
index++;
|
||||
continue;
|
||||
}
|
||||
var end = value.indexOf('}', index + 2);
|
||||
if (end < 0) {
|
||||
expanded.append(current);
|
||||
index++;
|
||||
continue;
|
||||
}
|
||||
var expression = value.substring(index + 2, end);
|
||||
var defaultSeparator = expression.indexOf(':');
|
||||
var name = defaultSeparator < 0 ? expression : expression.substring(0, defaultSeparator);
|
||||
var defaultValue = defaultSeparator < 0 ? "" : expression.substring(defaultSeparator + 1);
|
||||
if (!isEnvironmentName(name)) {
|
||||
expanded.append(value, index, end + 1);
|
||||
} else {
|
||||
expanded.append(environment.getOrDefault(name, defaultValue));
|
||||
}
|
||||
index = end + 1;
|
||||
}
|
||||
return expanded.toString();
|
||||
}
|
||||
|
||||
private static boolean isEnvironmentName(String value) {
|
||||
if (value == null || value.isBlank()) {
|
||||
return false;
|
||||
}
|
||||
for (var i = 0; i < value.length(); i++) {
|
||||
var current = value.charAt(i);
|
||||
var valid = current == '_' || Character.isDigit(current) && i > 0 || current >= 'A' && current <= 'Z';
|
||||
if (!valid) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package dev.strataproxy.bootstrap;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public record ConfigValidationResult(List<String> errors, List<String> warnings) {
|
||||
public ConfigValidationResult {
|
||||
errors = List.copyOf(errors == null ? List.of() : errors);
|
||||
warnings = List.copyOf(warnings == null ? List.of() : warnings);
|
||||
}
|
||||
|
||||
public boolean valid() {
|
||||
return errors.isEmpty();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
package dev.strataproxy.bootstrap;
|
||||
|
||||
import dev.strataproxy.api.server.ServerDescriptor;
|
||||
|
||||
import java.net.InetSocketAddress;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.Locale;
|
||||
|
||||
public final class ConfigValidator {
|
||||
private static final int MIN_SAFE_MAX_FRAME_BYTES = 1_024;
|
||||
|
||||
public ConfigValidationResult validate(ConfigLoader.LoadedProxyConfig loaded) {
|
||||
var errors = new ArrayList<String>();
|
||||
var warnings = new ArrayList<String>();
|
||||
if (loaded == null) {
|
||||
errors.add("config is null");
|
||||
return new ConfigValidationResult(errors, warnings);
|
||||
}
|
||||
validateProxy(loaded.proxy(), errors, warnings);
|
||||
validateServers(loaded.proxy(), loaded.servers(), errors, warnings);
|
||||
return new ConfigValidationResult(errors, warnings);
|
||||
}
|
||||
|
||||
private static void validateProxy(ProxyConfig config, ArrayList<String> errors, ArrayList<String> warnings) {
|
||||
if (config == null) {
|
||||
errors.add("proxy config is missing");
|
||||
return;
|
||||
}
|
||||
validateAddress("network.bind", config.bindAddress(), errors);
|
||||
if (config.workerThreads() < 0) {
|
||||
errors.add("network.workerThreads must be >= 0");
|
||||
}
|
||||
|
||||
var network = config.network();
|
||||
if (network.maxFrameBytes() < MIN_SAFE_MAX_FRAME_BYTES) {
|
||||
errors.add("network.maxFrameBytes must be at least " + MIN_SAFE_MAX_FRAME_BYTES + " bytes");
|
||||
}
|
||||
if (network.connectTimeoutMillis() <= 0) {
|
||||
errors.add("network.connectTimeoutMillis must be positive");
|
||||
}
|
||||
if (network.writeBufferLowBytes() < 0 || network.writeBufferHighBytes() <= 0 || network.writeBufferLowBytes() >= network.writeBufferHighBytes()) {
|
||||
errors.add("network.writeBufferWatermark must satisfy 0 <= low < high");
|
||||
}
|
||||
if (network.maxConnections() <= 0) {
|
||||
errors.add("network.maxConnections must be positive");
|
||||
}
|
||||
if (network.maxConnectionsPerAddress() <= 0) {
|
||||
errors.add("network.maxConnectionsPerAddress must be positive");
|
||||
}
|
||||
if (network.maxConnectionsPerAddress() > network.maxConnections()) {
|
||||
errors.add("network.maxConnectionsPerAddress must be <= network.maxConnections");
|
||||
}
|
||||
if (network.initialHandshakeTimeoutMillis() <= 0) {
|
||||
errors.add("network.initialHandshakeTimeoutMillis must be positive");
|
||||
}
|
||||
|
||||
var registry = config.registry();
|
||||
if (registry.persistenceEnabled() && registry.persistencePath().isBlank()) {
|
||||
errors.add("registry.persistencePath must not be blank when persistence is enabled");
|
||||
}
|
||||
validatePositive("registry.healthCheckInterval", registry.healthCheckInterval(), errors);
|
||||
validatePositive("registry.healthCheckTimeout", registry.healthCheckTimeout(), errors);
|
||||
if (registry.healthCheckTimeout().compareTo(registry.healthCheckInterval()) > 0) {
|
||||
warnings.add("registry.healthCheckTimeout is greater than healthCheckInterval");
|
||||
}
|
||||
|
||||
var compression = config.compression();
|
||||
var mode = compression.mode() == null ? "" : compression.mode().trim().toLowerCase(Locale.ROOT);
|
||||
if (!mode.equals("adaptive") && !mode.equals("off") && !mode.equals("fixed")) {
|
||||
errors.add("compression.mode must be one of: adaptive, fixed, off");
|
||||
}
|
||||
if (compression.minThreshold() < 0 || compression.maxThreshold() < compression.minThreshold()) {
|
||||
errors.add("compression thresholds must satisfy 0 <= minThreshold <= maxThreshold");
|
||||
}
|
||||
if (compression.cpuGuard() < 0.0d || compression.cpuGuard() > 1.0d) {
|
||||
errors.add("compression.cpuGuard must be between 0.0 and 1.0");
|
||||
}
|
||||
if (compression.rewriteMaxEventLoopDelayMillis() < 0) {
|
||||
errors.add("compression.rewriteMaxEventLoopDelayMillis must be >= 0");
|
||||
}
|
||||
|
||||
var packetAnalysis = config.packetAnalysis();
|
||||
if (packetAnalysis.largePayloadWarnBytes() < 0) {
|
||||
errors.add("packetAnalysis.largePayloadWarnBytes must be >= 0");
|
||||
}
|
||||
if (packetAnalysis.unknownChannelThrottleBytes() < 0) {
|
||||
errors.add("packetAnalysis.unknownChannelThrottleBytes must be >= 0");
|
||||
}
|
||||
if (packetAnalysis.moddedHandshakeWarnBytes() < 0) {
|
||||
errors.add("packetAnalysis.moddedHandshakeWarnBytes must be >= 0");
|
||||
}
|
||||
if (packetAnalysis.customPayloadFloodMaxCount() < 0) {
|
||||
errors.add("packetAnalysis.customPayloadFloodMaxCount must be >= 0");
|
||||
}
|
||||
if (packetAnalysis.customPayloadFloodMaxCount() > 0) {
|
||||
validatePositive("packetAnalysis.customPayloadFloodWindow", packetAnalysis.customPayloadFloodWindow(), errors);
|
||||
}
|
||||
|
||||
var observability = config.observability();
|
||||
if (observability.packetTopN() < 0) {
|
||||
errors.add("observability.packetTopN must be >= 0");
|
||||
}
|
||||
validatePositive("observability.flushInterval", observability.flushInterval(), errors);
|
||||
|
||||
var admin = config.admin();
|
||||
if (admin.enabled()) {
|
||||
validateAddress("admin.bind", admin.bindAddress(), errors);
|
||||
if (admin.bearerToken().isBlank()) {
|
||||
if (admin.tls().clientAuth() || isLoopbackBind(admin.bindAddress())) {
|
||||
warnings.add("admin.bearerToken is blank; admin endpoints except /healthz and /readyz are unauthenticated");
|
||||
} else {
|
||||
errors.add("admin.bearerToken must not be blank when admin.bind is not loopback unless admin.tls.clientAuth is enabled");
|
||||
}
|
||||
}
|
||||
validateAdminTls(admin.tls(), errors, warnings);
|
||||
}
|
||||
}
|
||||
|
||||
private static void validateAdminTls(ProxyConfig.AdminTlsConfig tls, ArrayList<String> errors, ArrayList<String> warnings) {
|
||||
if (tls == null || !tls.enabled()) {
|
||||
return;
|
||||
}
|
||||
if (tls.keyStorePath().isBlank()) {
|
||||
errors.add("admin.tls.keyStorePath must not be blank when admin TLS is enabled");
|
||||
} else if (Files.notExists(Path.of(tls.keyStorePath()))) {
|
||||
errors.add("admin.tls.keyStorePath does not exist: " + tls.keyStorePath());
|
||||
}
|
||||
if (tls.keyStoreType().isBlank()) {
|
||||
errors.add("admin.tls.keyStoreType must not be blank when admin TLS is enabled");
|
||||
}
|
||||
if (tls.clientAuth()) {
|
||||
if (tls.trustStorePath().isBlank()) {
|
||||
errors.add("admin.tls.trustStorePath must not be blank when admin TLS clientAuth is enabled");
|
||||
} else if (Files.notExists(Path.of(tls.trustStorePath()))) {
|
||||
errors.add("admin.tls.trustStorePath does not exist: " + tls.trustStorePath());
|
||||
}
|
||||
if (tls.trustStoreType().isBlank()) {
|
||||
errors.add("admin.tls.trustStoreType must not be blank when admin TLS clientAuth is enabled");
|
||||
}
|
||||
} else if (!tls.trustStorePath().isBlank()) {
|
||||
warnings.add("admin.tls.trustStorePath is configured but clientAuth is disabled");
|
||||
}
|
||||
}
|
||||
|
||||
private static void validateServers(ProxyConfig config, Iterable<ServerDescriptor> servers, ArrayList<String> errors, ArrayList<String> warnings) {
|
||||
var names = new HashSet<String>();
|
||||
var count = 0;
|
||||
for (var server : servers) {
|
||||
count++;
|
||||
if (!names.add(server.name())) {
|
||||
errors.add("duplicate server name: " + server.name());
|
||||
}
|
||||
validateAddress("server." + server.name() + ".address", server.address(), errors);
|
||||
if (server.softCapacity() < 0 || server.hardCapacity() < 0) {
|
||||
errors.add("server." + server.name() + " capacity values must be non-negative");
|
||||
}
|
||||
if (server.hardCapacity() > 0 && server.softCapacity() > server.hardCapacity()) {
|
||||
errors.add("server." + server.name() + " softCapacity must be <= hardCapacity");
|
||||
}
|
||||
if (server.drainMode()) {
|
||||
warnings.add("server." + server.name() + " starts in drain mode");
|
||||
}
|
||||
}
|
||||
if (count == 0) {
|
||||
if (config.admin().enabled()) {
|
||||
warnings.add("no static servers configured; use Admin API or persisted registry entries to add backends");
|
||||
if (!config.registry().persistenceEnabled()) {
|
||||
warnings.add("registry persistence is disabled; dynamically registered servers will not survive restart");
|
||||
}
|
||||
} else {
|
||||
errors.add("at least one server must be configured when admin API is disabled");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void validateAddress(String field, InetSocketAddress address, ArrayList<String> errors) {
|
||||
if (address == null) {
|
||||
errors.add(field + " is missing");
|
||||
return;
|
||||
}
|
||||
if (address.getHostString() == null || address.getHostString().isBlank()) {
|
||||
errors.add(field + " host must not be blank");
|
||||
}
|
||||
if (address.getPort() < 0 || address.getPort() > 65535) {
|
||||
errors.add(field + " port must be between 0 and 65535");
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isLoopbackBind(InetSocketAddress address) {
|
||||
if (address == null) {
|
||||
return false;
|
||||
}
|
||||
var resolved = address.getAddress();
|
||||
if (resolved != null) {
|
||||
return resolved.isLoopbackAddress();
|
||||
}
|
||||
var host = address.getHostString();
|
||||
return host.equalsIgnoreCase("localhost")
|
||||
|| host.equals("127.0.0.1")
|
||||
|| host.equals("::1")
|
||||
|| host.equals("[::1]");
|
||||
}
|
||||
|
||||
private static void validatePositive(String field, Duration duration, ArrayList<String> errors) {
|
||||
if (duration == null || duration.isZero() || duration.isNegative()) {
|
||||
errors.add(field + " must be positive");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package dev.strataproxy.bootstrap;
|
||||
|
||||
import java.net.InetSocketAddress;
|
||||
import java.time.Duration;
|
||||
|
||||
public record ProxyConfig(
|
||||
InetSocketAddress bindAddress,
|
||||
int workerThreads,
|
||||
boolean nativeTransport,
|
||||
NetworkConfig network,
|
||||
RegistryConfig registry,
|
||||
CompressionConfig compression,
|
||||
PacketAnalysisConfig packetAnalysis,
|
||||
ObservabilityConfig observability,
|
||||
AdminConfig admin) {
|
||||
public ProxyConfig {
|
||||
if (workerThreads < 0) {
|
||||
throw new IllegalArgumentException("workerThreads must be >= 0");
|
||||
}
|
||||
network = network == null ? NetworkConfig.defaults() : network;
|
||||
registry = registry == null ? RegistryConfig.defaults() : registry;
|
||||
compression = compression == null ? CompressionConfig.defaults() : compression;
|
||||
packetAnalysis = packetAnalysis == null ? PacketAnalysisConfig.defaults() : packetAnalysis;
|
||||
observability = observability == null ? ObservabilityConfig.defaults() : observability;
|
||||
admin = admin == null ? AdminConfig.defaults() : admin;
|
||||
}
|
||||
|
||||
public int resolvedWorkerThreads() {
|
||||
return workerThreads == 0 ? Math.max(4, Runtime.getRuntime().availableProcessors()) : workerThreads;
|
||||
}
|
||||
|
||||
public static ProxyConfig defaults() {
|
||||
return new ProxyConfig(
|
||||
new InetSocketAddress("0.0.0.0", 25577),
|
||||
0,
|
||||
true,
|
||||
NetworkConfig.defaults(),
|
||||
RegistryConfig.defaults(),
|
||||
CompressionConfig.defaults(),
|
||||
PacketAnalysisConfig.defaults(),
|
||||
ObservabilityConfig.defaults(),
|
||||
AdminConfig.defaults());
|
||||
}
|
||||
|
||||
public record NetworkConfig(
|
||||
int maxFrameBytes,
|
||||
int connectTimeoutMillis,
|
||||
int writeBufferLowBytes,
|
||||
int writeBufferHighBytes,
|
||||
int maxConnections,
|
||||
int maxConnectionsPerAddress,
|
||||
int initialHandshakeTimeoutMillis) {
|
||||
public static NetworkConfig defaults() {
|
||||
return new NetworkConfig(8 * 1024 * 1024, 5_000, 4 * 1024 * 1024, 16 * 1024 * 1024, 10_000, 200, 5_000);
|
||||
}
|
||||
}
|
||||
|
||||
public record RegistryConfig(
|
||||
boolean staticServers,
|
||||
boolean persistenceEnabled,
|
||||
String persistencePath,
|
||||
boolean healthCheckEnabled,
|
||||
Duration healthCheckInterval,
|
||||
Duration healthCheckTimeout) {
|
||||
public RegistryConfig {
|
||||
persistencePath = persistencePath == null ? "" : persistencePath;
|
||||
healthCheckInterval = healthCheckInterval == null ? Duration.ofSeconds(5) : healthCheckInterval;
|
||||
healthCheckTimeout = healthCheckTimeout == null ? Duration.ofSeconds(2) : healthCheckTimeout;
|
||||
}
|
||||
|
||||
public static RegistryConfig defaults() {
|
||||
return new RegistryConfig(true, true, "data/registry.json", true, Duration.ofSeconds(5), Duration.ofSeconds(2));
|
||||
}
|
||||
}
|
||||
|
||||
public record CompressionConfig(
|
||||
String mode,
|
||||
int minThreshold,
|
||||
int maxThreshold,
|
||||
double cpuGuard,
|
||||
boolean rewriteEnabled,
|
||||
int rewriteMaxEventLoopDelayMillis) {
|
||||
public CompressionConfig(String mode, int minThreshold, int maxThreshold, double cpuGuard) {
|
||||
this(mode, minThreshold, maxThreshold, cpuGuard, false, 25);
|
||||
}
|
||||
|
||||
public CompressionConfig(String mode, int minThreshold, int maxThreshold, double cpuGuard, boolean rewriteEnabled) {
|
||||
this(mode, minThreshold, maxThreshold, cpuGuard, rewriteEnabled, 25);
|
||||
}
|
||||
|
||||
public static CompressionConfig defaults() {
|
||||
return new CompressionConfig("adaptive", 256, 8192, 0.75d, false, 25);
|
||||
}
|
||||
}
|
||||
|
||||
public record PacketAnalysisConfig(
|
||||
int largePayloadWarnBytes,
|
||||
int unknownChannelThrottleBytes,
|
||||
int moddedHandshakeWarnBytes,
|
||||
int customPayloadFloodMaxCount,
|
||||
Duration customPayloadFloodWindow) {
|
||||
public PacketAnalysisConfig {
|
||||
customPayloadFloodWindow = customPayloadFloodWindow == null ? Duration.ofSeconds(10) : customPayloadFloodWindow;
|
||||
}
|
||||
|
||||
public static PacketAnalysisConfig defaults() {
|
||||
return new PacketAnalysisConfig(1024 * 1024, 256 * 1024, 2 * 1024 * 1024, 200, Duration.ofSeconds(10));
|
||||
}
|
||||
}
|
||||
|
||||
public record ObservabilityConfig(boolean prometheus, int packetTopN, boolean anomalySampling, Duration flushInterval) {
|
||||
public static ObservabilityConfig defaults() {
|
||||
return new ObservabilityConfig(true, 50, true, Duration.ofSeconds(5));
|
||||
}
|
||||
}
|
||||
|
||||
public record AdminConfig(boolean enabled, InetSocketAddress bindAddress, String bearerToken, AdminTlsConfig tls) {
|
||||
public AdminConfig {
|
||||
bearerToken = bearerToken == null ? "" : bearerToken;
|
||||
tls = tls == null ? AdminTlsConfig.defaults() : tls;
|
||||
}
|
||||
|
||||
public AdminConfig(boolean enabled, InetSocketAddress bindAddress, String bearerToken) {
|
||||
this(enabled, bindAddress, bearerToken, AdminTlsConfig.defaults());
|
||||
}
|
||||
|
||||
public static AdminConfig defaults() {
|
||||
return new AdminConfig(true, new InetSocketAddress("127.0.0.1", 8080), "", AdminTlsConfig.defaults());
|
||||
}
|
||||
}
|
||||
|
||||
public record AdminTlsConfig(
|
||||
boolean enabled,
|
||||
String keyStorePath,
|
||||
String keyStorePassword,
|
||||
String keyStoreType,
|
||||
String trustStorePath,
|
||||
String trustStorePassword,
|
||||
String trustStoreType,
|
||||
boolean clientAuth) {
|
||||
public AdminTlsConfig {
|
||||
keyStorePath = keyStorePath == null ? "" : keyStorePath;
|
||||
keyStorePassword = keyStorePassword == null ? "" : keyStorePassword;
|
||||
keyStoreType = keyStoreType == null || keyStoreType.isBlank() ? "PKCS12" : keyStoreType;
|
||||
trustStorePath = trustStorePath == null ? "" : trustStorePath;
|
||||
trustStorePassword = trustStorePassword == null ? "" : trustStorePassword;
|
||||
trustStoreType = trustStoreType == null || trustStoreType.isBlank() ? "PKCS12" : trustStoreType;
|
||||
}
|
||||
|
||||
public static AdminTlsConfig defaults() {
|
||||
return new AdminTlsConfig(false, "", "", "PKCS12", "", "", "PKCS12", false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package dev.strataproxy.bootstrap;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
final class ConfigLoaderTest {
|
||||
@TempDir
|
||||
Path tempDir;
|
||||
|
||||
@Test
|
||||
void parsesByteUnits() {
|
||||
assertEquals(8 * 1024 * 1024, ConfigLoader.parseBytes("8mb", 0));
|
||||
assertEquals(4096, ConfigLoader.parseBytes("4kb", 0));
|
||||
assertEquals(512, ConfigLoader.parseBytes("512", 0));
|
||||
}
|
||||
|
||||
@Test
|
||||
void parsesAddressWithDefaultPort() {
|
||||
var address = ConfigLoader.parseAddress("127.0.0.1", 25565);
|
||||
|
||||
assertEquals("127.0.0.1", address.getHostString());
|
||||
assertEquals(25565, address.getPort());
|
||||
}
|
||||
|
||||
@Test
|
||||
void parsesDurationUnits() {
|
||||
assertEquals(250, ConfigLoader.parseDurationMillis("250ms", 0));
|
||||
assertEquals(5_000, ConfigLoader.parseDurationMillis("5s", 0));
|
||||
assertEquals(60_000, ConfigLoader.parseDurationMillis("1m", 0));
|
||||
}
|
||||
|
||||
@Test
|
||||
void parsesPacketAnalysisThresholds() throws Exception {
|
||||
var config = tempDir.resolve("strataproxy.yml");
|
||||
Files.writeString(config, """
|
||||
packetAnalysis:
|
||||
largePayloadWarnBytes: "3mb"
|
||||
unknownChannelThrottleBytes: "384kb"
|
||||
moddedHandshakeWarnBytes: "4mb"
|
||||
customPayloadFloodMaxCount: 123
|
||||
customPayloadFloodWindow: "7s"
|
||||
servers:
|
||||
- name: "lobby-1"
|
||||
address: "127.0.0.1:25565"
|
||||
tags: ["lobby"]
|
||||
capabilities: ["modern-forwarding"]
|
||||
""");
|
||||
|
||||
var loaded = new ConfigLoader().load(config);
|
||||
|
||||
assertEquals(3 * 1024 * 1024, loaded.proxy().packetAnalysis().largePayloadWarnBytes());
|
||||
assertEquals(384 * 1024, loaded.proxy().packetAnalysis().unknownChannelThrottleBytes());
|
||||
assertEquals(4 * 1024 * 1024, loaded.proxy().packetAnalysis().moddedHandshakeWarnBytes());
|
||||
assertEquals(123, loaded.proxy().packetAnalysis().customPayloadFloodMaxCount());
|
||||
assertEquals(java.time.Duration.ofSeconds(7), loaded.proxy().packetAnalysis().customPayloadFloodWindow());
|
||||
}
|
||||
|
||||
@Test
|
||||
void parsesCompressionRewriteFlag() throws Exception {
|
||||
var config = tempDir.resolve("strataproxy.yml");
|
||||
Files.writeString(config, """
|
||||
compression:
|
||||
mode: fixed
|
||||
minThreshold: 1024
|
||||
maxThreshold: 1024
|
||||
cpuGuard: 0.5
|
||||
rewriteEnabled: true
|
||||
rewriteMaxEventLoopDelayMillis: 7
|
||||
servers:
|
||||
- name: "lobby-1"
|
||||
address: "127.0.0.1:25565"
|
||||
""");
|
||||
|
||||
var loaded = new ConfigLoader().load(config);
|
||||
|
||||
assertEquals("fixed", loaded.proxy().compression().mode());
|
||||
assertEquals(1024, loaded.proxy().compression().minThreshold());
|
||||
assertEquals(1024, loaded.proxy().compression().maxThreshold());
|
||||
assertTrue(loaded.proxy().compression().rewriteEnabled());
|
||||
assertEquals(7, loaded.proxy().compression().rewriteMaxEventLoopDelayMillis());
|
||||
}
|
||||
|
||||
@Test
|
||||
void parsesAdminTlsConfig() throws Exception {
|
||||
var config = tempDir.resolve("strataproxy.yml");
|
||||
Files.writeString(config, """
|
||||
admin:
|
||||
tls:
|
||||
enabled: true
|
||||
keyStorePath: "conf/admin.p12"
|
||||
keyStorePassword: "changeit"
|
||||
keyStoreType: "PKCS12"
|
||||
trustStorePath: "conf/clients.p12"
|
||||
trustStorePassword: "clientpass"
|
||||
trustStoreType: "JKS"
|
||||
clientAuth: true
|
||||
servers:
|
||||
- name: "lobby-1"
|
||||
address: "127.0.0.1:25565"
|
||||
""");
|
||||
|
||||
var loaded = new ConfigLoader().load(config);
|
||||
|
||||
var tls = loaded.proxy().admin().tls();
|
||||
assertTrue(tls.enabled());
|
||||
assertEquals("conf/admin.p12", tls.keyStorePath());
|
||||
assertEquals("changeit", tls.keyStorePassword());
|
||||
assertEquals("PKCS12", tls.keyStoreType());
|
||||
assertEquals("conf/clients.p12", tls.trustStorePath());
|
||||
assertEquals("clientpass", tls.trustStorePassword());
|
||||
assertEquals("JKS", tls.trustStoreType());
|
||||
assertTrue(tls.clientAuth());
|
||||
}
|
||||
|
||||
@Test
|
||||
void explicitEmptyServersStayEmpty() throws Exception {
|
||||
var config = tempDir.resolve("strataproxy.yml");
|
||||
Files.writeString(config, """
|
||||
admin:
|
||||
enabled: true
|
||||
servers: []
|
||||
""");
|
||||
|
||||
var loaded = new ConfigLoader().load(config);
|
||||
|
||||
assertTrue(loaded.servers().isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void staticServersFalseIgnoresConfiguredServerList() throws Exception {
|
||||
var config = tempDir.resolve("strataproxy.yml");
|
||||
Files.writeString(config, """
|
||||
registry:
|
||||
staticServers: false
|
||||
servers:
|
||||
- name: "ignored"
|
||||
address: "127.0.0.1:25565"
|
||||
""");
|
||||
|
||||
var loaded = new ConfigLoader().load(config);
|
||||
|
||||
assertTrue(loaded.servers().isEmpty());
|
||||
assertEquals(false, loaded.proxy().registry().staticServers());
|
||||
}
|
||||
|
||||
@Test
|
||||
void expandsEnvironmentPlaceholdersBeforeParsingYaml() throws Exception {
|
||||
var config = tempDir.resolve("strataproxy.yml");
|
||||
Files.writeString(config, """
|
||||
admin:
|
||||
bearerToken: "${STRATAPROXY_ADMIN_TOKEN:fallback-token}"
|
||||
servers:
|
||||
- name: "lobby-1"
|
||||
address: "${STRATAPROXY_BACKEND_HOST}:25565"
|
||||
metadata:
|
||||
unset: "${STRATAPROXY_UNSET:default-value}"
|
||||
""");
|
||||
|
||||
var loaded = new ConfigLoader().load(config, Map.of(
|
||||
"STRATAPROXY_ADMIN_TOKEN", "secret-token",
|
||||
"STRATAPROXY_BACKEND_HOST", "10.0.0.12"));
|
||||
|
||||
assertEquals("secret-token", loaded.proxy().admin().bearerToken());
|
||||
assertEquals("10.0.0.12", loaded.servers().getFirst().address().getHostString());
|
||||
assertEquals("default-value", loaded.servers().getFirst().metadata().get("unset"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingEnvironmentPlaceholderExpandsToEmptyString() {
|
||||
assertEquals("token=", ConfigLoader.expandEnvironment("token=${MISSING}", Map.of()));
|
||||
assertEquals("${bad-name}", ConfigLoader.expandEnvironment("${bad-name}", Map.of()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
package dev.strataproxy.bootstrap;
|
||||
|
||||
import dev.strataproxy.api.server.ProtocolRange;
|
||||
import dev.strataproxy.api.server.ServerCapability;
|
||||
import dev.strataproxy.api.server.ServerDescriptor;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.net.InetSocketAddress;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
final class ConfigValidatorTest {
|
||||
@TempDir
|
||||
Path tempDir;
|
||||
|
||||
@Test
|
||||
void acceptsDefaultConfig() {
|
||||
var result = new ConfigValidator().validate(ConfigLoader.LoadedProxyConfig.defaults());
|
||||
|
||||
assertTrue(result.valid());
|
||||
assertTrue(result.warnings().stream().anyMatch(warning -> warning.contains("bearerToken")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsUnsafeNetworkAndCompressionValues() {
|
||||
var config = new ProxyConfig(
|
||||
new InetSocketAddress("127.0.0.1", 25577),
|
||||
0,
|
||||
true,
|
||||
new ProxyConfig.NetworkConfig(64, 0, 1024, 1024, 10, 20, 0),
|
||||
ProxyConfig.RegistryConfig.defaults(),
|
||||
new ProxyConfig.CompressionConfig("unknown", 8192, 256, 1.5d),
|
||||
new ProxyConfig.PacketAnalysisConfig(-1, -1, -1, -1, Duration.ZERO),
|
||||
ProxyConfig.ObservabilityConfig.defaults(),
|
||||
new ProxyConfig.AdminConfig(false, new InetSocketAddress("127.0.0.1", 8080), ""));
|
||||
var loaded = new ConfigLoader.LoadedProxyConfig(config, List.of(server("one")));
|
||||
|
||||
var result = new ConfigValidator().validate(loaded);
|
||||
|
||||
assertFalse(result.valid());
|
||||
assertTrue(result.errors().stream().anyMatch(error -> error.contains("maxFrameBytes")));
|
||||
assertTrue(result.errors().stream().anyMatch(error -> error.contains("writeBufferWatermark")));
|
||||
assertTrue(result.errors().stream().anyMatch(error -> error.contains("maxConnectionsPerAddress")));
|
||||
assertTrue(result.errors().stream().anyMatch(error -> error.contains("compression.mode")));
|
||||
assertTrue(result.errors().stream().anyMatch(error -> error.contains("cpuGuard")));
|
||||
assertTrue(result.errors().stream().anyMatch(error -> error.contains("packetAnalysis.largePayloadWarnBytes")));
|
||||
assertTrue(result.errors().stream().anyMatch(error -> error.contains("packetAnalysis.unknownChannelThrottleBytes")));
|
||||
assertTrue(result.errors().stream().anyMatch(error -> error.contains("packetAnalysis.moddedHandshakeWarnBytes")));
|
||||
assertTrue(result.errors().stream().anyMatch(error -> error.contains("packetAnalysis.customPayloadFloodMaxCount")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsDuplicateServersAndBadCapacity() {
|
||||
var loaded = new ConfigLoader.LoadedProxyConfig(
|
||||
ProxyConfig.defaults(),
|
||||
List.of(server("duplicate"), server("duplicate"), badCapacityServer()));
|
||||
|
||||
var result = new ConfigValidator().validate(loaded);
|
||||
|
||||
assertFalse(result.valid());
|
||||
assertTrue(result.errors().stream().anyMatch(error -> error.contains("duplicate server name")));
|
||||
assertTrue(result.errors().stream().anyMatch(error -> error.contains("softCapacity")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void acceptsDynamicOnlyRegistryWhenAdminApiIsEnabled() {
|
||||
var loaded = new ConfigLoader.LoadedProxyConfig(ProxyConfig.defaults(), List.of());
|
||||
|
||||
var result = new ConfigValidator().validate(loaded);
|
||||
|
||||
assertTrue(result.valid());
|
||||
assertTrue(result.warnings().stream().anyMatch(warning -> warning.contains("no static servers")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsUnauthenticatedAdminApiOnNonLoopbackBind() {
|
||||
var config = new ProxyConfig(
|
||||
new InetSocketAddress("0.0.0.0", 8080),
|
||||
0,
|
||||
true,
|
||||
ProxyConfig.NetworkConfig.defaults(),
|
||||
ProxyConfig.RegistryConfig.defaults(),
|
||||
ProxyConfig.CompressionConfig.defaults(),
|
||||
ProxyConfig.PacketAnalysisConfig.defaults(),
|
||||
ProxyConfig.ObservabilityConfig.defaults(),
|
||||
new ProxyConfig.AdminConfig(true, new InetSocketAddress("0.0.0.0", 8080), ""));
|
||||
var loaded = new ConfigLoader.LoadedProxyConfig(config, List.of(server("one")));
|
||||
|
||||
var result = new ConfigValidator().validate(loaded);
|
||||
|
||||
assertFalse(result.valid());
|
||||
assertTrue(result.errors().stream().anyMatch(error -> error.contains("admin.bearerToken must not be blank")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsEmptyRegistryWhenAdminApiIsDisabled() {
|
||||
var config = new ProxyConfig(
|
||||
new InetSocketAddress("127.0.0.1", 25577),
|
||||
0,
|
||||
true,
|
||||
ProxyConfig.NetworkConfig.defaults(),
|
||||
ProxyConfig.RegistryConfig.defaults(),
|
||||
ProxyConfig.CompressionConfig.defaults(),
|
||||
ProxyConfig.PacketAnalysisConfig.defaults(),
|
||||
ProxyConfig.ObservabilityConfig.defaults(),
|
||||
new ProxyConfig.AdminConfig(false, new InetSocketAddress("127.0.0.1", 8080), ""));
|
||||
var loaded = new ConfigLoader.LoadedProxyConfig(config, List.of());
|
||||
|
||||
var result = new ConfigValidator().validate(loaded);
|
||||
|
||||
assertFalse(result.valid());
|
||||
assertTrue(result.errors().stream().anyMatch(error -> error.contains("admin API is disabled")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void validatesAdminTlsFiles() throws Exception {
|
||||
var keyStore = tempDir.resolve("admin.p12");
|
||||
var trustStore = tempDir.resolve("clients.p12");
|
||||
Files.writeString(keyStore, "placeholder");
|
||||
Files.writeString(trustStore, "placeholder");
|
||||
|
||||
var missingKeyStore = validateWithTls(new ProxyConfig.AdminTlsConfig(
|
||||
true,
|
||||
tempDir.resolve("missing.p12").toString(),
|
||||
"changeit",
|
||||
"PKCS12",
|
||||
"",
|
||||
"",
|
||||
"PKCS12",
|
||||
false));
|
||||
assertFalse(missingKeyStore.valid());
|
||||
assertTrue(missingKeyStore.errors().stream().anyMatch(error -> error.contains("keyStorePath does not exist")));
|
||||
|
||||
var missingTrustStore = validateWithTls(new ProxyConfig.AdminTlsConfig(
|
||||
true,
|
||||
keyStore.toString(),
|
||||
"changeit",
|
||||
"PKCS12",
|
||||
"",
|
||||
"",
|
||||
"PKCS12",
|
||||
true));
|
||||
assertFalse(missingTrustStore.valid());
|
||||
assertTrue(missingTrustStore.errors().stream().anyMatch(error -> error.contains("trustStorePath must not be blank")));
|
||||
|
||||
var trustStoreWithoutClientAuth = validateWithTls(new ProxyConfig.AdminTlsConfig(
|
||||
true,
|
||||
keyStore.toString(),
|
||||
"changeit",
|
||||
"PKCS12",
|
||||
trustStore.toString(),
|
||||
"changeit",
|
||||
"PKCS12",
|
||||
false));
|
||||
assertTrue(trustStoreWithoutClientAuth.valid());
|
||||
assertTrue(trustStoreWithoutClientAuth.warnings().stream().anyMatch(warning -> warning.contains("clientAuth is disabled")));
|
||||
|
||||
var mtlsWithoutBearerToken = new ProxyConfig(
|
||||
new InetSocketAddress("127.0.0.1", 25577),
|
||||
0,
|
||||
true,
|
||||
ProxyConfig.NetworkConfig.defaults(),
|
||||
ProxyConfig.RegistryConfig.defaults(),
|
||||
ProxyConfig.CompressionConfig.defaults(),
|
||||
ProxyConfig.PacketAnalysisConfig.defaults(),
|
||||
ProxyConfig.ObservabilityConfig.defaults(),
|
||||
new ProxyConfig.AdminConfig(
|
||||
true,
|
||||
new InetSocketAddress("0.0.0.0", 8080),
|
||||
"",
|
||||
new ProxyConfig.AdminTlsConfig(
|
||||
true,
|
||||
keyStore.toString(),
|
||||
"changeit",
|
||||
"PKCS12",
|
||||
trustStore.toString(),
|
||||
"changeit",
|
||||
"PKCS12",
|
||||
true)));
|
||||
assertTrue(new ConfigValidator()
|
||||
.validate(new ConfigLoader.LoadedProxyConfig(mtlsWithoutBearerToken, List.of(server("one"))))
|
||||
.valid());
|
||||
}
|
||||
|
||||
private static ServerDescriptor server(String name) {
|
||||
return new ServerDescriptor(
|
||||
name,
|
||||
new InetSocketAddress("127.0.0.1", 25565),
|
||||
Set.of("lobby"),
|
||||
Set.of(ServerCapability.MODERN_FORWARDING),
|
||||
new ProtocolRange(0, Integer.MAX_VALUE, "any"),
|
||||
100,
|
||||
100,
|
||||
120,
|
||||
false,
|
||||
Map.of());
|
||||
}
|
||||
|
||||
private ConfigValidationResult validateWithTls(ProxyConfig.AdminTlsConfig tls) {
|
||||
var config = new ProxyConfig(
|
||||
new InetSocketAddress("127.0.0.1", 25577),
|
||||
0,
|
||||
true,
|
||||
ProxyConfig.NetworkConfig.defaults(),
|
||||
ProxyConfig.RegistryConfig.defaults(),
|
||||
ProxyConfig.CompressionConfig.defaults(),
|
||||
ProxyConfig.PacketAnalysisConfig.defaults(),
|
||||
ProxyConfig.ObservabilityConfig.defaults(),
|
||||
new ProxyConfig.AdminConfig(true, new InetSocketAddress("127.0.0.1", 8080), "secret", tls));
|
||||
return new ConfigValidator().validate(new ConfigLoader.LoadedProxyConfig(config, List.of(server("one"))));
|
||||
}
|
||||
|
||||
private static ServerDescriptor badCapacityServer() {
|
||||
return new ServerDescriptor(
|
||||
"bad-capacity",
|
||||
new InetSocketAddress("127.0.0.1", 25566),
|
||||
Set.of("lobby"),
|
||||
Set.of(ServerCapability.MODERN_FORWARDING),
|
||||
new ProtocolRange(0, Integer.MAX_VALUE, "any"),
|
||||
100,
|
||||
200,
|
||||
100,
|
||||
false,
|
||||
Map.of());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
plugins {
|
||||
`kotlin-dsl`
|
||||
}
|
||||
|
||||
gradlePlugin {
|
||||
plugins {
|
||||
register("javaLibraryConventions") {
|
||||
id = "strataproxy.java-library-conventions"
|
||||
implementationClass = "StrataProxyJavaLibraryConventionsPlugin"
|
||||
}
|
||||
register("applicationConventions") {
|
||||
id = "strataproxy.application-conventions"
|
||||
implementationClass = "StrataProxyApplicationConventionsPlugin"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
dependencyResolutionManagement {
|
||||
repositories {
|
||||
gradlePluginPortal()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.name = "proxy-build-logic"
|
||||
@@ -0,0 +1,10 @@
|
||||
import org.gradle.api.Plugin
|
||||
import org.gradle.api.Project
|
||||
import org.gradle.api.plugins.ApplicationPlugin
|
||||
|
||||
class StrataProxyApplicationConventionsPlugin : Plugin<Project> {
|
||||
override fun apply(project: Project) {
|
||||
project.plugins.apply(StrataProxyJavaLibraryConventionsPlugin::class.java)
|
||||
project.plugins.apply(ApplicationPlugin::class.java)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import org.gradle.api.Plugin
|
||||
import org.gradle.api.Project
|
||||
import org.gradle.api.plugins.JavaLibraryPlugin
|
||||
import org.gradle.api.tasks.testing.Test
|
||||
import org.gradle.jvm.toolchain.JavaLanguageVersion
|
||||
import org.gradle.kotlin.dsl.configure
|
||||
import org.gradle.kotlin.dsl.dependencies
|
||||
import org.gradle.kotlin.dsl.withType
|
||||
import org.gradle.api.plugins.JavaPluginExtension
|
||||
|
||||
class StrataProxyJavaLibraryConventionsPlugin : Plugin<Project> {
|
||||
override fun apply(project: Project) {
|
||||
project.plugins.apply(JavaLibraryPlugin::class.java)
|
||||
|
||||
project.extensions.configure<JavaPluginExtension> {
|
||||
toolchain {
|
||||
languageVersion.set(JavaLanguageVersion.of(25))
|
||||
}
|
||||
}
|
||||
|
||||
project.tasks.withType<Test>().configureEach {
|
||||
useJUnitPlatform()
|
||||
}
|
||||
|
||||
project.dependencies {
|
||||
"testImplementation"(project.dependencies.platform(project.libs.findLibrary("junit-bom").get()))
|
||||
"testImplementation"(project.libs.findLibrary("junit-jupiter").get())
|
||||
"testRuntimeOnly"("org.junit.platform:junit-platform-launcher")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import org.gradle.api.Project
|
||||
import org.gradle.api.artifacts.VersionCatalogsExtension
|
||||
import org.gradle.kotlin.dsl.getByType
|
||||
|
||||
val Project.libs
|
||||
get() = extensions.getByType<VersionCatalogsExtension>().named("libs")
|
||||
@@ -0,0 +1,8 @@
|
||||
plugins {
|
||||
id("strataproxy.java-library-conventions")
|
||||
}
|
||||
|
||||
dependencies {
|
||||
api(project(":proxy-protocol"))
|
||||
implementation(libs.findLibrary("netty-codec").get())
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package dev.strataproxy.codec.minecraft;
|
||||
|
||||
public final class MinecraftCodecException extends RuntimeException {
|
||||
public MinecraftCodecException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public MinecraftCodecException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
+227
@@ -0,0 +1,227 @@
|
||||
package dev.strataproxy.codec.minecraft;
|
||||
|
||||
import io.netty.buffer.ByteBuf;
|
||||
import io.netty.buffer.ByteBufAllocator;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Objects;
|
||||
|
||||
public final class MinecraftCompressedFrameRewriter implements AutoCloseable {
|
||||
private final MinecraftCompressionCodec codec;
|
||||
|
||||
public MinecraftCompressedFrameRewriter() {
|
||||
this(new MinecraftCompressionCodec());
|
||||
}
|
||||
|
||||
public MinecraftCompressedFrameRewriter(MinecraftCompressionCodec codec) {
|
||||
this.codec = Objects.requireNonNull(codec, "codec");
|
||||
}
|
||||
|
||||
public RewriteResult rewrite(
|
||||
ByteBufAllocator allocator,
|
||||
ByteBuf frame,
|
||||
int sourceThreshold,
|
||||
int targetThreshold,
|
||||
int maxUncompressedBytes) {
|
||||
Objects.requireNonNull(allocator, "allocator");
|
||||
Objects.requireNonNull(frame, "frame");
|
||||
if (sourceThreshold < 0 || targetThreshold < 0) {
|
||||
throw new IllegalArgumentException("thresholds must be non-negative");
|
||||
}
|
||||
if (maxUncompressedBytes <= 0) {
|
||||
throw new IllegalArgumentException("maxUncompressedBytes must be positive");
|
||||
}
|
||||
|
||||
var original = inspectSingleFrame(frame);
|
||||
ByteBuf decoded = null;
|
||||
ByteBuf rewritten = null;
|
||||
try {
|
||||
decoded = codec.decodeFrame(allocator, frame.slice(), sourceThreshold, maxUncompressedBytes);
|
||||
rewritten = codec.encodeFrame(allocator, decoded, targetThreshold);
|
||||
var rewrittenStats = inspectSingleFrame(rewritten);
|
||||
return new RewriteResult(
|
||||
rewritten,
|
||||
sourceThreshold,
|
||||
targetThreshold,
|
||||
original.frameBytes(),
|
||||
original.uncompressedBytes(),
|
||||
original.compressedPayloadBytes(),
|
||||
rewrittenStats.frameBytes(),
|
||||
rewrittenStats.uncompressedBytes(),
|
||||
rewrittenStats.compressedPayloadBytes());
|
||||
} catch (RuntimeException exception) {
|
||||
if (rewritten != null) {
|
||||
rewritten.release();
|
||||
}
|
||||
throw exception;
|
||||
} finally {
|
||||
if (decoded != null) {
|
||||
decoded.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public BatchRewriteResult rewriteBatch(
|
||||
ByteBufAllocator allocator,
|
||||
ByteBuf frames,
|
||||
int sourceThreshold,
|
||||
int targetThreshold,
|
||||
int maxUncompressedBytes,
|
||||
int maxFrames) {
|
||||
Objects.requireNonNull(allocator, "allocator");
|
||||
Objects.requireNonNull(frames, "frames");
|
||||
if (sourceThreshold < 0 || targetThreshold < 0) {
|
||||
throw new IllegalArgumentException("thresholds must be non-negative");
|
||||
}
|
||||
if (maxUncompressedBytes <= 0) {
|
||||
throw new IllegalArgumentException("maxUncompressedBytes must be positive");
|
||||
}
|
||||
if (maxFrames <= 0) {
|
||||
throw new IllegalArgumentException("maxFrames must be positive");
|
||||
}
|
||||
|
||||
var view = frames.slice();
|
||||
var rewrittenFrames = new ArrayList<ByteBuf>();
|
||||
var frameCount = 0;
|
||||
var originalFrameBytes = 0;
|
||||
var originalUncompressedBytes = 0;
|
||||
var originalCompressedPayloadBytes = 0;
|
||||
var rewrittenFrameBytes = 0;
|
||||
var rewrittenUncompressedBytes = 0;
|
||||
var rewrittenCompressedPayloadBytes = 0;
|
||||
try {
|
||||
while (view.isReadable()) {
|
||||
if (++frameCount > maxFrames) {
|
||||
throw new MinecraftCodecException("compressed frame rewrite batch exceeds maximum frame count");
|
||||
}
|
||||
var probe = MinecraftVarInts.probe(view);
|
||||
if (!probe.complete()) {
|
||||
throw new MinecraftCodecException("compressed frame rewrite batch contains partial frame length");
|
||||
}
|
||||
var totalBytes = probe.bytes() + probe.value();
|
||||
if (probe.value() < 0 || totalBytes > view.readableBytes()) {
|
||||
throw new MinecraftCodecException("compressed frame rewrite batch contains partial frame");
|
||||
}
|
||||
var frame = view.readRetainedSlice(totalBytes);
|
||||
try (var result = rewrite(allocator, frame, sourceThreshold, targetThreshold, maxUncompressedBytes)) {
|
||||
var rewritten = result.frame().retain();
|
||||
rewrittenFrames.add(rewritten);
|
||||
originalFrameBytes += result.originalFrameBytes();
|
||||
originalUncompressedBytes += result.originalUncompressedBytes();
|
||||
originalCompressedPayloadBytes += result.originalCompressedPayloadBytes();
|
||||
rewrittenFrameBytes += result.rewrittenFrameBytes();
|
||||
rewrittenUncompressedBytes += result.rewrittenUncompressedBytes();
|
||||
rewrittenCompressedPayloadBytes += result.rewrittenCompressedPayloadBytes();
|
||||
} finally {
|
||||
frame.release();
|
||||
}
|
||||
}
|
||||
var output = allocator.compositeBuffer(rewrittenFrames.size()).addComponents(true, rewrittenFrames);
|
||||
rewrittenFrames = new ArrayList<>();
|
||||
return new BatchRewriteResult(
|
||||
output,
|
||||
sourceThreshold,
|
||||
targetThreshold,
|
||||
frameCount,
|
||||
originalFrameBytes,
|
||||
originalUncompressedBytes,
|
||||
originalCompressedPayloadBytes,
|
||||
rewrittenFrameBytes,
|
||||
rewrittenUncompressedBytes,
|
||||
rewrittenCompressedPayloadBytes);
|
||||
} catch (RuntimeException exception) {
|
||||
for (var rewritten : rewrittenFrames) {
|
||||
rewritten.release();
|
||||
}
|
||||
throw exception;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
codec.close();
|
||||
}
|
||||
|
||||
private static FrameStats inspectSingleFrame(ByteBuf frame) {
|
||||
var view = frame.slice();
|
||||
var frameBytes = view.readableBytes();
|
||||
var packetLength = MinecraftVarInts.read(view);
|
||||
if (packetLength < 0 || packetLength != view.readableBytes()) {
|
||||
throw new MinecraftCodecException("compressed frame rewriter requires exactly one complete frame");
|
||||
}
|
||||
var packetFrame = view.readSlice(packetLength);
|
||||
var dataLength = MinecraftVarInts.read(packetFrame);
|
||||
if (dataLength == 0) {
|
||||
return new FrameStats(frameBytes, packetFrame.readableBytes(), 0);
|
||||
}
|
||||
return new FrameStats(frameBytes, dataLength, packetFrame.readableBytes());
|
||||
}
|
||||
|
||||
private record FrameStats(int frameBytes, int uncompressedBytes, int compressedPayloadBytes) {
|
||||
}
|
||||
|
||||
public record RewriteResult(
|
||||
ByteBuf frame,
|
||||
int sourceThreshold,
|
||||
int targetThreshold,
|
||||
int originalFrameBytes,
|
||||
int originalUncompressedBytes,
|
||||
int originalCompressedPayloadBytes,
|
||||
int rewrittenFrameBytes,
|
||||
int rewrittenUncompressedBytes,
|
||||
int rewrittenCompressedPayloadBytes) implements AutoCloseable {
|
||||
public RewriteResult {
|
||||
Objects.requireNonNull(frame, "frame");
|
||||
}
|
||||
|
||||
public boolean wasCompressed() {
|
||||
return originalCompressedPayloadBytes > 0;
|
||||
}
|
||||
|
||||
public boolean isCompressed() {
|
||||
return rewrittenCompressedPayloadBytes > 0;
|
||||
}
|
||||
|
||||
public int savedBytes() {
|
||||
return originalFrameBytes - rewrittenFrameBytes;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
frame.release();
|
||||
}
|
||||
}
|
||||
|
||||
public record BatchRewriteResult(
|
||||
ByteBuf frames,
|
||||
int sourceThreshold,
|
||||
int targetThreshold,
|
||||
int frameCount,
|
||||
int originalFrameBytes,
|
||||
int originalUncompressedBytes,
|
||||
int originalCompressedPayloadBytes,
|
||||
int rewrittenFrameBytes,
|
||||
int rewrittenUncompressedBytes,
|
||||
int rewrittenCompressedPayloadBytes) implements AutoCloseable {
|
||||
public BatchRewriteResult {
|
||||
Objects.requireNonNull(frames, "frames");
|
||||
}
|
||||
|
||||
public boolean wasCompressed() {
|
||||
return originalCompressedPayloadBytes > 0;
|
||||
}
|
||||
|
||||
public boolean isCompressed() {
|
||||
return rewrittenCompressedPayloadBytes > 0;
|
||||
}
|
||||
|
||||
public int savedBytes() {
|
||||
return originalFrameBytes - rewrittenFrameBytes;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
frames.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
+154
@@ -0,0 +1,154 @@
|
||||
package dev.strataproxy.codec.minecraft;
|
||||
|
||||
import io.netty.buffer.ByteBuf;
|
||||
import io.netty.buffer.ByteBufAllocator;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.zip.DataFormatException;
|
||||
import java.util.zip.Deflater;
|
||||
import java.util.zip.Inflater;
|
||||
|
||||
public final class MinecraftCompressionCodec implements AutoCloseable {
|
||||
private static final int DEFAULT_BUFFER_SIZE = 8192;
|
||||
|
||||
private final Deflater deflater;
|
||||
private final Inflater inflater;
|
||||
private final byte[] buffer;
|
||||
|
||||
public MinecraftCompressionCodec() {
|
||||
this(Deflater.DEFAULT_COMPRESSION, DEFAULT_BUFFER_SIZE);
|
||||
}
|
||||
|
||||
public MinecraftCompressionCodec(int compressionLevel, int scratchBufferBytes) {
|
||||
if (scratchBufferBytes <= 0) {
|
||||
throw new IllegalArgumentException("scratchBufferBytes must be positive");
|
||||
}
|
||||
this.deflater = new Deflater(compressionLevel);
|
||||
this.inflater = new Inflater();
|
||||
this.buffer = new byte[scratchBufferBytes];
|
||||
}
|
||||
|
||||
public ByteBuf encodeFrame(ByteBufAllocator allocator, ByteBuf packet, int threshold) {
|
||||
Objects.requireNonNull(allocator, "allocator");
|
||||
Objects.requireNonNull(packet, "packet");
|
||||
if (threshold < 0) {
|
||||
throw new IllegalArgumentException("threshold must be non-negative");
|
||||
}
|
||||
var packetSize = packet.readableBytes();
|
||||
if (packetSize < threshold) {
|
||||
return encodeUncompressedFrame(allocator, packet);
|
||||
}
|
||||
return encodeCompressedFrame(allocator, packet, packetSize);
|
||||
}
|
||||
|
||||
public ByteBuf decodeFrame(ByteBufAllocator allocator, ByteBuf frame, int threshold, int maxUncompressedBytes) {
|
||||
Objects.requireNonNull(allocator, "allocator");
|
||||
Objects.requireNonNull(frame, "frame");
|
||||
if (threshold < 0) {
|
||||
throw new IllegalArgumentException("threshold must be non-negative");
|
||||
}
|
||||
if (maxUncompressedBytes <= 0) {
|
||||
throw new IllegalArgumentException("maxUncompressedBytes must be positive");
|
||||
}
|
||||
var packetLength = MinecraftVarInts.read(frame);
|
||||
if (packetLength < 0 || packetLength > frame.readableBytes()) {
|
||||
throw new MinecraftCodecException("invalid compressed packet frame length");
|
||||
}
|
||||
var packetFrame = frame.readRetainedSlice(packetLength);
|
||||
try {
|
||||
var dataLength = MinecraftVarInts.read(packetFrame);
|
||||
if (dataLength == 0) {
|
||||
if (threshold > 0 && packetFrame.readableBytes() >= threshold) {
|
||||
throw new MinecraftCodecException("uncompressed packet exceeds compression threshold");
|
||||
}
|
||||
return packetFrame.readRetainedSlice(packetFrame.readableBytes());
|
||||
}
|
||||
if (dataLength < threshold) {
|
||||
throw new MinecraftCodecException("compressed packet is smaller than compression threshold");
|
||||
}
|
||||
if (dataLength > maxUncompressedBytes) {
|
||||
throw new MinecraftCodecException("compressed packet exceeds maximum uncompressed size");
|
||||
}
|
||||
return inflate(allocator, packetFrame, dataLength);
|
||||
} finally {
|
||||
packetFrame.release();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
deflater.end();
|
||||
inflater.end();
|
||||
}
|
||||
|
||||
private ByteBuf encodeUncompressedFrame(ByteBufAllocator allocator, ByteBuf packet) {
|
||||
var packetSize = packet.readableBytes();
|
||||
var length = MinecraftVarInts.encodedSize(0) + packetSize;
|
||||
var output = allocator.buffer(MinecraftVarInts.encodedSize(length) + length);
|
||||
MinecraftVarInts.write(output, length);
|
||||
MinecraftVarInts.write(output, 0);
|
||||
output.writeBytes(packet, packet.readerIndex(), packetSize);
|
||||
return output;
|
||||
}
|
||||
|
||||
private ByteBuf encodeCompressedFrame(ByteBufAllocator allocator, ByteBuf packet, int packetSize) {
|
||||
var compressed = allocator.buffer(Math.max(64, packetSize / 2));
|
||||
try {
|
||||
deflater.reset();
|
||||
deflater.setInput(toBytes(packet));
|
||||
deflater.finish();
|
||||
while (!deflater.finished()) {
|
||||
var bytes = deflater.deflate(buffer);
|
||||
compressed.writeBytes(buffer, 0, bytes);
|
||||
}
|
||||
var length = MinecraftVarInts.encodedSize(packetSize) + compressed.readableBytes();
|
||||
var output = allocator.buffer(MinecraftVarInts.encodedSize(length) + length);
|
||||
MinecraftVarInts.write(output, length);
|
||||
MinecraftVarInts.write(output, packetSize);
|
||||
output.writeBytes(compressed, compressed.readerIndex(), compressed.readableBytes());
|
||||
return output;
|
||||
} finally {
|
||||
compressed.release();
|
||||
}
|
||||
}
|
||||
|
||||
private ByteBuf inflate(ByteBufAllocator allocator, ByteBuf compressedFrame, int dataLength) {
|
||||
var output = allocator.buffer(dataLength, dataLength);
|
||||
try {
|
||||
inflater.reset();
|
||||
inflater.setInput(toBytes(compressedFrame));
|
||||
while (!inflater.finished()) {
|
||||
var bytes = inflater.inflate(buffer);
|
||||
if (bytes == 0) {
|
||||
if (inflater.needsInput()) {
|
||||
throw new MinecraftCodecException("truncated compressed packet");
|
||||
}
|
||||
if (inflater.needsDictionary()) {
|
||||
throw new MinecraftCodecException("compressed packet requires a dictionary");
|
||||
}
|
||||
throw new MinecraftCodecException("compressed packet made no progress");
|
||||
}
|
||||
if (output.readableBytes() + bytes > dataLength) {
|
||||
throw new MinecraftCodecException("compressed packet inflated beyond declared size");
|
||||
}
|
||||
output.writeBytes(buffer, 0, bytes);
|
||||
}
|
||||
if (output.readableBytes() != dataLength) {
|
||||
throw new MinecraftCodecException("compressed packet size does not match declaration");
|
||||
}
|
||||
return output;
|
||||
} catch (DataFormatException exception) {
|
||||
output.release();
|
||||
throw new MinecraftCodecException("malformed compressed packet", exception);
|
||||
} catch (RuntimeException exception) {
|
||||
output.release();
|
||||
throw exception;
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] toBytes(ByteBuf input) {
|
||||
var bytes = new byte[input.readableBytes()];
|
||||
input.getBytes(input.readerIndex(), bytes);
|
||||
return bytes;
|
||||
}
|
||||
}
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
package dev.strataproxy.codec.minecraft;
|
||||
|
||||
import io.netty.buffer.ByteBuf;
|
||||
import io.netty.channel.ChannelHandlerContext;
|
||||
import io.netty.handler.codec.ByteToMessageDecoder;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public final class MinecraftCompressionFrameDecoder extends ByteToMessageDecoder {
|
||||
private final int threshold;
|
||||
private final int maxFrameBytes;
|
||||
private final int maxUncompressedBytes;
|
||||
private final MinecraftCompressionCodec codec;
|
||||
|
||||
public MinecraftCompressionFrameDecoder(int threshold, int maxFrameBytes, int maxUncompressedBytes) {
|
||||
if (threshold < 0) {
|
||||
throw new IllegalArgumentException("threshold must be non-negative");
|
||||
}
|
||||
if (maxFrameBytes <= 0 || maxUncompressedBytes <= 0) {
|
||||
throw new IllegalArgumentException("frame limits must be positive");
|
||||
}
|
||||
this.threshold = threshold;
|
||||
this.maxFrameBytes = maxFrameBytes;
|
||||
this.maxUncompressedBytes = maxUncompressedBytes;
|
||||
this.codec = new MinecraftCompressionCodec();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void decode(ChannelHandlerContext context, ByteBuf input, List<Object> output) {
|
||||
var probe = MinecraftVarInts.probe(input);
|
||||
if (!probe.complete()) {
|
||||
return;
|
||||
}
|
||||
var packetLength = probe.value();
|
||||
if (packetLength < 0 || packetLength > maxFrameBytes) {
|
||||
throw new MinecraftCodecException("compressed frame length exceeds maximum");
|
||||
}
|
||||
var totalBytes = probe.bytes() + packetLength;
|
||||
if (input.readableBytes() < totalBytes) {
|
||||
return;
|
||||
}
|
||||
var frame = input.readRetainedSlice(totalBytes);
|
||||
try {
|
||||
output.add(codec.decodeFrame(context.alloc(), frame, threshold, maxUncompressedBytes));
|
||||
} finally {
|
||||
frame.release();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void handlerRemoved0(ChannelHandlerContext context) {
|
||||
codec.close();
|
||||
}
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
package dev.strataproxy.codec.minecraft;
|
||||
|
||||
import io.netty.buffer.ByteBuf;
|
||||
import io.netty.channel.ChannelHandlerContext;
|
||||
import io.netty.handler.codec.MessageToMessageEncoder;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public final class MinecraftCompressionFrameEncoder extends MessageToMessageEncoder<ByteBuf> {
|
||||
private final int threshold;
|
||||
private final MinecraftCompressionCodec codec;
|
||||
|
||||
public MinecraftCompressionFrameEncoder(int threshold) {
|
||||
if (threshold < 0) {
|
||||
throw new IllegalArgumentException("threshold must be non-negative");
|
||||
}
|
||||
this.threshold = threshold;
|
||||
this.codec = new MinecraftCompressionCodec();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void encode(ChannelHandlerContext context, ByteBuf message, List<Object> output) {
|
||||
output.add(codec.encodeFrame(context.alloc(), message, threshold));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handlerRemoved(ChannelHandlerContext context) {
|
||||
codec.close();
|
||||
}
|
||||
}
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
package dev.strataproxy.codec.minecraft;
|
||||
|
||||
import io.netty.buffer.ByteBuf;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Locale;
|
||||
|
||||
public final class MinecraftCustomPayloadClassifier {
|
||||
private static final int MAX_CHANNEL_BYTES = 128;
|
||||
|
||||
private MinecraftCustomPayloadClassifier() {
|
||||
}
|
||||
|
||||
public static CustomPayloadClassification classify(ByteBuf packetFrame, int largePayloadBytes) {
|
||||
if (packetFrame == null) {
|
||||
throw new IllegalArgumentException("packetFrame must not be null");
|
||||
}
|
||||
if (largePayloadBytes < 0) {
|
||||
throw new IllegalArgumentException("largePayloadBytes must be non-negative");
|
||||
}
|
||||
var view = packetFrame.retainedDuplicate();
|
||||
try {
|
||||
var packetId = MinecraftVarInts.read(view);
|
||||
var channel = readString(view, MAX_CHANNEL_BYTES);
|
||||
var payloadBytes = view.readableBytes();
|
||||
return new CustomPayloadClassification(
|
||||
packetId,
|
||||
channel,
|
||||
payloadBytes,
|
||||
classifyChannel(channel),
|
||||
payloadBytes >= largePayloadBytes && largePayloadBytes > 0);
|
||||
} finally {
|
||||
view.release();
|
||||
}
|
||||
}
|
||||
|
||||
private static String readString(ByteBuf input, int maxBytes) {
|
||||
var length = MinecraftVarInts.read(input);
|
||||
if (length < 0 || length > maxBytes) {
|
||||
throw new MinecraftCodecException("custom payload channel length exceeds maximum");
|
||||
}
|
||||
if (input.readableBytes() < length) {
|
||||
throw new MinecraftCodecException("truncated custom payload channel");
|
||||
}
|
||||
var bytes = new byte[length];
|
||||
input.readBytes(bytes);
|
||||
return new String(bytes, StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
private static CustomPayloadKind classifyChannel(String channel) {
|
||||
var normalized = channel.toLowerCase(Locale.ROOT);
|
||||
if (normalized.equals("minecraft:brand") || normalized.equals("brand")) {
|
||||
return CustomPayloadKind.BRAND;
|
||||
}
|
||||
if (normalized.startsWith("fml:")
|
||||
|| normalized.startsWith("forge:")
|
||||
|| normalized.equals("fml|hs")
|
||||
|| normalized.equals("forge")) {
|
||||
return CustomPayloadKind.FORGE_HANDSHAKE;
|
||||
}
|
||||
if (normalized.startsWith("fabric:")
|
||||
|| normalized.equals("fabric:registry/sync")
|
||||
|| normalized.equals("fabric:modlist")) {
|
||||
return CustomPayloadKind.FABRIC_HANDSHAKE;
|
||||
}
|
||||
if (normalized.contains("registry") || normalized.contains("config")) {
|
||||
return CustomPayloadKind.REGISTRY_OR_CONFIG_SYNC;
|
||||
}
|
||||
return CustomPayloadKind.UNKNOWN;
|
||||
}
|
||||
|
||||
public enum CustomPayloadKind {
|
||||
BRAND,
|
||||
FORGE_HANDSHAKE,
|
||||
FABRIC_HANDSHAKE,
|
||||
REGISTRY_OR_CONFIG_SYNC,
|
||||
UNKNOWN
|
||||
}
|
||||
|
||||
public record CustomPayloadClassification(
|
||||
int packetId,
|
||||
String channel,
|
||||
int payloadBytes,
|
||||
CustomPayloadKind kind,
|
||||
boolean largePayload) {
|
||||
}
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
package dev.strataproxy.codec.minecraft;
|
||||
|
||||
import dev.strataproxy.protocol.InMemoryPacketRegistry;
|
||||
import dev.strataproxy.protocol.PacketDefinition;
|
||||
import dev.strataproxy.protocol.PacketDirection;
|
||||
import dev.strataproxy.protocol.PacketFlag;
|
||||
import dev.strataproxy.protocol.PacketRegistry;
|
||||
import dev.strataproxy.protocol.ProtocolState;
|
||||
|
||||
import java.util.EnumSet;
|
||||
import java.util.List;
|
||||
|
||||
public final class MinecraftPacketDefinitions {
|
||||
private MinecraftPacketDefinitions() {
|
||||
}
|
||||
|
||||
public static PacketRegistry modernDefaults() {
|
||||
return new InMemoryPacketRegistry(List.of(
|
||||
new PacketDefinition(
|
||||
0x00,
|
||||
ProtocolState.HANDSHAKE,
|
||||
PacketDirection.SERVERBOUND,
|
||||
0,
|
||||
Integer.MAX_VALUE,
|
||||
EnumSet.of(PacketFlag.REQUIRES_INSPECTION, PacketFlag.MUST_PRESERVE_ORDER),
|
||||
"handshake"),
|
||||
new PacketDefinition(
|
||||
0x01,
|
||||
ProtocolState.CONFIGURATION,
|
||||
PacketDirection.SERVERBOUND,
|
||||
0,
|
||||
Integer.MAX_VALUE,
|
||||
EnumSet.of(
|
||||
PacketFlag.REQUIRES_INSPECTION,
|
||||
PacketFlag.SUSPICIOUS_WHEN_LARGE,
|
||||
PacketFlag.MUST_PRESERVE_ORDER,
|
||||
PacketFlag.MODDED_PAYLOAD),
|
||||
"custom_payload"),
|
||||
new PacketDefinition(
|
||||
0x00,
|
||||
ProtocolState.PLAY,
|
||||
PacketDirection.CLIENTBOUND,
|
||||
0,
|
||||
Integer.MAX_VALUE,
|
||||
EnumSet.of(PacketFlag.CAN_FAST_FORWARD, PacketFlag.CAN_COMPRESS, PacketFlag.HIGH_FREQUENCY),
|
||||
"bundle_or_keepalive_fast_path_sample")));
|
||||
}
|
||||
}
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
package dev.strataproxy.codec.minecraft;
|
||||
|
||||
import io.netty.buffer.ByteBuf;
|
||||
|
||||
public final class MinecraftVarInts {
|
||||
private static final int SEGMENT_BITS = 0x7F;
|
||||
private static final int CONTINUE_BIT = 0x80;
|
||||
private static final int MAX_VARINT_BYTES = 5;
|
||||
|
||||
private MinecraftVarInts() {
|
||||
}
|
||||
|
||||
public static int read(ByteBuf input) {
|
||||
var value = 0;
|
||||
var position = 0;
|
||||
while (position < MAX_VARINT_BYTES) {
|
||||
if (!input.isReadable()) {
|
||||
throw new MinecraftCodecException("truncated VarInt");
|
||||
}
|
||||
var current = input.readUnsignedByte();
|
||||
value |= (current & SEGMENT_BITS) << (position * 7);
|
||||
if ((current & CONTINUE_BIT) == 0) {
|
||||
return value;
|
||||
}
|
||||
position++;
|
||||
}
|
||||
throw new MinecraftCodecException("VarInt is too long");
|
||||
}
|
||||
|
||||
public static VarIntProbe probe(ByteBuf input) {
|
||||
var value = 0;
|
||||
var position = 0;
|
||||
var readerIndex = input.readerIndex();
|
||||
var readable = input.readableBytes();
|
||||
while (position < MAX_VARINT_BYTES) {
|
||||
if (position >= readable) {
|
||||
return VarIntProbe.incomplete();
|
||||
}
|
||||
var current = input.getUnsignedByte(readerIndex + position);
|
||||
value |= (current & SEGMENT_BITS) << (position * 7);
|
||||
if ((current & CONTINUE_BIT) == 0) {
|
||||
return new VarIntProbe(true, value, position + 1);
|
||||
}
|
||||
position++;
|
||||
}
|
||||
throw new MinecraftCodecException("VarInt is too long");
|
||||
}
|
||||
|
||||
public static void write(ByteBuf output, int value) {
|
||||
while ((value & ~SEGMENT_BITS) != 0) {
|
||||
output.writeByte((value & SEGMENT_BITS) | CONTINUE_BIT);
|
||||
value >>>= 7;
|
||||
}
|
||||
output.writeByte(value);
|
||||
}
|
||||
|
||||
public static int encodedSize(int value) {
|
||||
var bytes = 1;
|
||||
while ((value & ~SEGMENT_BITS) != 0) {
|
||||
bytes++;
|
||||
value >>>= 7;
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
public record VarIntProbe(boolean complete, int value, int bytes) {
|
||||
private static VarIntProbe incomplete() {
|
||||
return new VarIntProbe(false, 0, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
+172
@@ -0,0 +1,172 @@
|
||||
package dev.strataproxy.codec.minecraft;
|
||||
|
||||
import io.netty.buffer.ByteBuf;
|
||||
import io.netty.buffer.Unpooled;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
final class MinecraftCompressedFrameRewriterTest {
|
||||
@Test
|
||||
void rewritesCompressedFrameToUncompressedWhenTargetThresholdIsHigher() {
|
||||
try (var codec = new MinecraftCompressionCodec();
|
||||
var rewriter = new MinecraftCompressedFrameRewriter(new MinecraftCompressionCodec())) {
|
||||
var packet = copiedBuffer("login-registry-payload-".repeat(64));
|
||||
ByteBuf source = null;
|
||||
ByteBuf decoded = null;
|
||||
try {
|
||||
source = codec.encodeFrame(ALLOCATOR, packet, 32);
|
||||
try (var result = rewriter.rewrite(ALLOCATOR, source, 32, packet.readableBytes() + 1, 64 * 1024)) {
|
||||
assertTrue(result.wasCompressed());
|
||||
assertFalse(result.isCompressed());
|
||||
assertEquals(packet.readableBytes(), result.originalUncompressedBytes());
|
||||
assertEquals(packet.readableBytes(), result.rewrittenUncompressedBytes());
|
||||
|
||||
decoded = codec.decodeFrame(ALLOCATOR, result.frame(), packet.readableBytes() + 1, 64 * 1024);
|
||||
assertArrayEquals(toBytes(packet), toBytes(decoded));
|
||||
}
|
||||
} finally {
|
||||
release(packet);
|
||||
release(source);
|
||||
release(decoded);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void rewritesUncompressedFrameToCompressedWhenTargetThresholdIsLower() {
|
||||
try (var codec = new MinecraftCompressionCodec();
|
||||
var rewriter = new MinecraftCompressedFrameRewriter(new MinecraftCompressionCodec())) {
|
||||
var packet = copiedBuffer("mod-sync-smallish-payload-".repeat(32));
|
||||
ByteBuf source = null;
|
||||
ByteBuf decoded = null;
|
||||
try {
|
||||
source = codec.encodeFrame(ALLOCATOR, packet, packet.readableBytes() + 1);
|
||||
try (var result = rewriter.rewrite(ALLOCATOR, source, packet.readableBytes() + 1, 1, 64 * 1024)) {
|
||||
assertFalse(result.wasCompressed());
|
||||
assertTrue(result.isCompressed());
|
||||
assertTrue(result.rewrittenCompressedPayloadBytes() < packet.readableBytes());
|
||||
|
||||
decoded = codec.decodeFrame(ALLOCATOR, result.frame(), 1, 64 * 1024);
|
||||
assertArrayEquals(toBytes(packet), toBytes(decoded));
|
||||
}
|
||||
} finally {
|
||||
release(packet);
|
||||
release(source);
|
||||
release(decoded);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void doesNotMoveSourceReaderIndex() {
|
||||
try (var codec = new MinecraftCompressionCodec();
|
||||
var rewriter = new MinecraftCompressedFrameRewriter(new MinecraftCompressionCodec())) {
|
||||
var packet = copiedBuffer("payload-".repeat(16));
|
||||
ByteBuf source = null;
|
||||
try {
|
||||
source = codec.encodeFrame(ALLOCATOR, packet, 16);
|
||||
var readerIndex = source.readerIndex();
|
||||
try (var ignored = rewriter.rewrite(ALLOCATOR, source, 16, 128, 64 * 1024)) {
|
||||
assertEquals(readerIndex, source.readerIndex());
|
||||
}
|
||||
} finally {
|
||||
release(packet);
|
||||
release(source);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsBuffersWithMoreThanOneFrame() {
|
||||
try (var codec = new MinecraftCompressionCodec();
|
||||
var rewriter = new MinecraftCompressedFrameRewriter(new MinecraftCompressionCodec())) {
|
||||
var packet = copiedBuffer("payload-".repeat(16));
|
||||
ByteBuf first = null;
|
||||
ByteBuf second = null;
|
||||
ByteBuf combined = null;
|
||||
try {
|
||||
first = codec.encodeFrame(ALLOCATOR, packet, 16);
|
||||
second = codec.encodeFrame(ALLOCATOR, packet, 16);
|
||||
combined = Unpooled.buffer(first.readableBytes() + second.readableBytes());
|
||||
combined.writeBytes(first, first.readerIndex(), first.readableBytes());
|
||||
combined.writeBytes(second, second.readerIndex(), second.readableBytes());
|
||||
|
||||
var input = combined;
|
||||
assertThrows(MinecraftCodecException.class,
|
||||
() -> rewriter.rewrite(ALLOCATOR, input, 16, 128, 64 * 1024));
|
||||
} finally {
|
||||
release(packet);
|
||||
release(first);
|
||||
release(second);
|
||||
release(combined);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void rewritesBatchWithMoreThanOneFrame() {
|
||||
try (var codec = new MinecraftCompressionCodec();
|
||||
var rewriter = new MinecraftCompressedFrameRewriter(new MinecraftCompressionCodec())) {
|
||||
var firstPacket = copiedBuffer("first-payload-".repeat(32));
|
||||
var secondPacket = copiedBuffer("second-payload-".repeat(32));
|
||||
ByteBuf first = null;
|
||||
ByteBuf second = null;
|
||||
ByteBuf combined = null;
|
||||
ByteBuf firstDecoded = null;
|
||||
ByteBuf secondDecoded = null;
|
||||
try {
|
||||
first = codec.encodeFrame(ALLOCATOR, firstPacket, 16);
|
||||
second = codec.encodeFrame(ALLOCATOR, secondPacket, 16);
|
||||
combined = Unpooled.buffer(first.readableBytes() + second.readableBytes());
|
||||
combined.writeBytes(first, first.readerIndex(), first.readableBytes());
|
||||
combined.writeBytes(second, second.readerIndex(), second.readableBytes());
|
||||
|
||||
try (var result = rewriter.rewriteBatch(ALLOCATOR, combined, 16, 4096, 64 * 1024, 8)) {
|
||||
assertEquals(2, result.frameCount());
|
||||
assertTrue(result.wasCompressed());
|
||||
assertFalse(result.isCompressed());
|
||||
|
||||
var view = result.frames().slice();
|
||||
firstDecoded = codec.decodeFrame(ALLOCATOR, view, 4096, 64 * 1024);
|
||||
secondDecoded = codec.decodeFrame(ALLOCATOR, view, 4096, 64 * 1024);
|
||||
assertEquals(0, view.readableBytes());
|
||||
assertArrayEquals(toBytes(firstPacket), toBytes(firstDecoded));
|
||||
assertArrayEquals(toBytes(secondPacket), toBytes(secondDecoded));
|
||||
}
|
||||
} finally {
|
||||
release(firstPacket);
|
||||
release(secondPacket);
|
||||
release(first);
|
||||
release(second);
|
||||
release(combined);
|
||||
release(firstDecoded);
|
||||
release(secondDecoded);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static ByteBuf copiedBuffer(String value) {
|
||||
return Unpooled.copiedBuffer(value, StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
private static byte[] toBytes(ByteBuf buffer) {
|
||||
var bytes = new byte[buffer.readableBytes()];
|
||||
buffer.getBytes(buffer.readerIndex(), bytes);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
private static void release(ByteBuf buffer) {
|
||||
if (buffer != null) {
|
||||
buffer.release();
|
||||
}
|
||||
}
|
||||
|
||||
private static final io.netty.buffer.ByteBufAllocator ALLOCATOR = io.netty.buffer.UnpooledByteBufAllocator.DEFAULT;
|
||||
}
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
package dev.strataproxy.codec.minecraft;
|
||||
|
||||
import io.netty.buffer.ByteBuf;
|
||||
import io.netty.buffer.Unpooled;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
final class MinecraftCompressionCodecTest {
|
||||
@Test
|
||||
void encodesAndDecodesUncompressedFrameBelowThreshold() {
|
||||
try (var codec = new MinecraftCompressionCodec()) {
|
||||
var packet = copiedBuffer("small-packet");
|
||||
ByteBuf frame = null;
|
||||
ByteBuf decoded = null;
|
||||
try {
|
||||
frame = codec.encodeFrame(UnpooledByteBufAllocatorHolder.ALLOCATOR, packet, 128);
|
||||
var packetLength = MinecraftVarInts.read(frame.slice());
|
||||
assertEquals(packet.readableBytes() + 1, packetLength);
|
||||
|
||||
decoded = codec.decodeFrame(UnpooledByteBufAllocatorHolder.ALLOCATOR, frame, 128, 1024);
|
||||
assertArrayEquals(toBytes(packet), toBytes(decoded));
|
||||
} finally {
|
||||
release(packet);
|
||||
release(frame);
|
||||
release(decoded);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void encodesAndDecodesCompressedFrameAtThreshold() {
|
||||
try (var codec = new MinecraftCompressionCodec()) {
|
||||
var packet = copiedBuffer("payload-".repeat(512));
|
||||
ByteBuf frame = null;
|
||||
ByteBuf decoded = null;
|
||||
try {
|
||||
frame = codec.encodeFrame(UnpooledByteBufAllocatorHolder.ALLOCATOR, packet, 64);
|
||||
var frameView = frame.slice();
|
||||
MinecraftVarInts.read(frameView);
|
||||
assertEquals(packet.readableBytes(), MinecraftVarInts.read(frameView));
|
||||
assertTrue(frameView.readableBytes() < packet.readableBytes());
|
||||
|
||||
decoded = codec.decodeFrame(UnpooledByteBufAllocatorHolder.ALLOCATOR, frame, 64, 16 * 1024);
|
||||
assertArrayEquals(toBytes(packet), toBytes(decoded));
|
||||
} finally {
|
||||
release(packet);
|
||||
release(frame);
|
||||
release(decoded);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsUncompressedPacketThatExceedsThreshold() {
|
||||
try (var codec = new MinecraftCompressionCodec()) {
|
||||
var oversized = copiedBuffer("0123456789");
|
||||
var frame = Unpooled.buffer();
|
||||
try {
|
||||
MinecraftVarInts.write(frame, oversized.readableBytes() + 1);
|
||||
MinecraftVarInts.write(frame, 0);
|
||||
frame.writeBytes(oversized, oversized.readerIndex(), oversized.readableBytes());
|
||||
|
||||
assertThrows(MinecraftCodecException.class,
|
||||
() -> codec.decodeFrame(UnpooledByteBufAllocatorHolder.ALLOCATOR, frame, 4, 1024));
|
||||
} finally {
|
||||
release(oversized);
|
||||
release(frame);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsCompressedPacketUnderThreshold() {
|
||||
try (var codec = new MinecraftCompressionCodec()) {
|
||||
var frame = Unpooled.buffer();
|
||||
try {
|
||||
MinecraftVarInts.write(frame, 2);
|
||||
MinecraftVarInts.write(frame, 3);
|
||||
frame.writeByte(0);
|
||||
|
||||
assertThrows(MinecraftCodecException.class,
|
||||
() -> codec.decodeFrame(UnpooledByteBufAllocatorHolder.ALLOCATOR, frame, 4, 1024));
|
||||
} finally {
|
||||
release(frame);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsCompressedPacketAboveMaximumUncompressedSize() {
|
||||
try (var codec = new MinecraftCompressionCodec()) {
|
||||
var packet = copiedBuffer("payload-".repeat(128));
|
||||
ByteBuf frame = null;
|
||||
try {
|
||||
frame = codec.encodeFrame(UnpooledByteBufAllocatorHolder.ALLOCATOR, packet, 1);
|
||||
var compressedFrame = frame;
|
||||
|
||||
assertThrows(MinecraftCodecException.class,
|
||||
() -> codec.decodeFrame(UnpooledByteBufAllocatorHolder.ALLOCATOR, compressedFrame, 1, 32));
|
||||
} finally {
|
||||
release(packet);
|
||||
release(frame);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsMalformedCompressedPayload() {
|
||||
try (var codec = new MinecraftCompressionCodec()) {
|
||||
var frame = Unpooled.buffer();
|
||||
try {
|
||||
MinecraftVarInts.write(frame, 5);
|
||||
MinecraftVarInts.write(frame, 128);
|
||||
frame.writeBytes(new byte[] {1, 2, 3});
|
||||
|
||||
assertThrows(MinecraftCodecException.class,
|
||||
() -> codec.decodeFrame(UnpooledByteBufAllocatorHolder.ALLOCATOR, frame, 64, 1024));
|
||||
} finally {
|
||||
release(frame);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static ByteBuf copiedBuffer(String value) {
|
||||
return Unpooled.copiedBuffer(value, StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
private static byte[] toBytes(ByteBuf buffer) {
|
||||
var bytes = new byte[buffer.readableBytes()];
|
||||
buffer.getBytes(buffer.readerIndex(), bytes);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
private static void release(ByteBuf buffer) {
|
||||
if (buffer != null) {
|
||||
buffer.release();
|
||||
}
|
||||
}
|
||||
|
||||
private static final class UnpooledByteBufAllocatorHolder {
|
||||
private static final io.netty.buffer.ByteBufAllocator ALLOCATOR = io.netty.buffer.UnpooledByteBufAllocator.DEFAULT;
|
||||
}
|
||||
}
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
package dev.strataproxy.codec.minecraft;
|
||||
|
||||
import io.netty.buffer.ByteBuf;
|
||||
import io.netty.buffer.Unpooled;
|
||||
import io.netty.channel.embedded.EmbeddedChannel;
|
||||
import io.netty.handler.codec.DecoderException;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
final class MinecraftCompressionFrameHandlersTest {
|
||||
@Test
|
||||
void encoderAndDecoderRoundTripPacketPayloads() {
|
||||
var packet = Unpooled.copiedBuffer("packet-payload-".repeat(256), StandardCharsets.UTF_8);
|
||||
var encoder = new EmbeddedChannel(new MinecraftCompressionFrameEncoder(64));
|
||||
var decoder = new EmbeddedChannel(new MinecraftCompressionFrameDecoder(64, 16 * 1024, 16 * 1024));
|
||||
try {
|
||||
assertTrue(encoder.writeOutbound(packet.retainedDuplicate()));
|
||||
var frame = (ByteBuf) encoder.readOutbound();
|
||||
|
||||
assertTrue(decoder.writeInbound(frame));
|
||||
var decoded = (ByteBuf) decoder.readInbound();
|
||||
try {
|
||||
assertArrayEquals(bytes(packet), bytes(decoded));
|
||||
} finally {
|
||||
decoded.release();
|
||||
}
|
||||
} finally {
|
||||
packet.release();
|
||||
assertFalse(encoder.finishAndReleaseAll());
|
||||
assertFalse(decoder.finishAndReleaseAll());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void decoderWaitsForCompleteFrame() {
|
||||
var packet = Unpooled.copiedBuffer("packet-payload-".repeat(32), StandardCharsets.UTF_8);
|
||||
var encoder = new EmbeddedChannel(new MinecraftCompressionFrameEncoder(16));
|
||||
var decoder = new EmbeddedChannel(new MinecraftCompressionFrameDecoder(16, 4096, 4096));
|
||||
try {
|
||||
assertTrue(encoder.writeOutbound(packet.retainedDuplicate()));
|
||||
var frame = (ByteBuf) encoder.readOutbound();
|
||||
var splitAt = Math.max(1, frame.readableBytes() / 2);
|
||||
var firstHalf = frame.readRetainedSlice(splitAt);
|
||||
var secondHalf = frame.readRetainedSlice(frame.readableBytes());
|
||||
frame.release();
|
||||
|
||||
assertFalse(decoder.writeInbound(firstHalf));
|
||||
assertTrue(decoder.writeInbound(secondHalf));
|
||||
|
||||
var decoded = (ByteBuf) decoder.readInbound();
|
||||
try {
|
||||
assertArrayEquals(bytes(packet), bytes(decoded));
|
||||
} finally {
|
||||
decoded.release();
|
||||
}
|
||||
} finally {
|
||||
packet.release();
|
||||
assertFalse(encoder.finishAndReleaseAll());
|
||||
assertFalse(decoder.finishAndReleaseAll());
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void decoderRejectsFrameAboveMaximumLength() {
|
||||
var decoder = new EmbeddedChannel(new MinecraftCompressionFrameDecoder(16, 4, 4096));
|
||||
var frame = Unpooled.buffer();
|
||||
try {
|
||||
MinecraftVarInts.write(frame, 5);
|
||||
frame.writeZero(5);
|
||||
var exception = assertThrows(DecoderException.class, () -> {
|
||||
try {
|
||||
decoder.writeInbound(frame);
|
||||
} finally {
|
||||
decoder.finishAndReleaseAll();
|
||||
}
|
||||
});
|
||||
assertCodecCause(exception);
|
||||
} finally {
|
||||
// Ownership moves to EmbeddedChannel even when decode fails.
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void decoderRejectsMalformedCompressedPayload() {
|
||||
var decoder = new EmbeddedChannel(new MinecraftCompressionFrameDecoder(16, 4096, 4096));
|
||||
var frame = Unpooled.buffer();
|
||||
try {
|
||||
MinecraftVarInts.write(frame, 5);
|
||||
MinecraftVarInts.write(frame, 128);
|
||||
frame.writeBytes(new byte[] {1, 2, 3});
|
||||
var exception = assertThrows(DecoderException.class, () -> {
|
||||
try {
|
||||
decoder.writeInbound(frame);
|
||||
} finally {
|
||||
decoder.finishAndReleaseAll();
|
||||
}
|
||||
});
|
||||
assertCodecCause(exception);
|
||||
} finally {
|
||||
// Ownership moves to EmbeddedChannel even when decode fails.
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] bytes(ByteBuf buffer) {
|
||||
var bytes = new byte[buffer.readableBytes()];
|
||||
buffer.getBytes(buffer.readerIndex(), bytes);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
private static void assertCodecCause(Throwable exception) {
|
||||
var current = exception;
|
||||
while (current != null) {
|
||||
if (current instanceof MinecraftCodecException) {
|
||||
return;
|
||||
}
|
||||
current = current.getCause();
|
||||
}
|
||||
throw new AssertionError("expected MinecraftCodecException in cause chain", exception);
|
||||
}
|
||||
|
||||
}
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
package dev.strataproxy.codec.minecraft;
|
||||
|
||||
import io.netty.buffer.Unpooled;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
final class MinecraftCustomPayloadClassifierTest {
|
||||
@Test
|
||||
void classifiesBrandPayloadWithoutConsumingInput() {
|
||||
var frame = customPayload(0x01, "minecraft:brand", 12);
|
||||
var readerIndex = frame.readerIndex();
|
||||
try {
|
||||
var classification = MinecraftCustomPayloadClassifier.classify(frame, 1024);
|
||||
|
||||
assertEquals(readerIndex, frame.readerIndex());
|
||||
assertEquals(0x01, classification.packetId());
|
||||
assertEquals("minecraft:brand", classification.channel());
|
||||
assertEquals(12, classification.payloadBytes());
|
||||
assertEquals(MinecraftCustomPayloadClassifier.CustomPayloadKind.BRAND, classification.kind());
|
||||
} finally {
|
||||
frame.release();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void classifiesForgeAndFabricPayloads() {
|
||||
var forge = customPayload(0x01, "fml:handshake", 64);
|
||||
var fabric = customPayload(0x01, "fabric:registry/sync", 64);
|
||||
try {
|
||||
assertEquals(
|
||||
MinecraftCustomPayloadClassifier.CustomPayloadKind.FORGE_HANDSHAKE,
|
||||
MinecraftCustomPayloadClassifier.classify(forge, 1024).kind());
|
||||
assertEquals(
|
||||
MinecraftCustomPayloadClassifier.CustomPayloadKind.FABRIC_HANDSHAKE,
|
||||
MinecraftCustomPayloadClassifier.classify(fabric, 1024).kind());
|
||||
} finally {
|
||||
forge.release();
|
||||
fabric.release();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void classifiesRegistryOrConfigSyncPayloads() {
|
||||
var frame = customPayload(0x01, "modded:registry_sync", 64);
|
||||
try {
|
||||
assertEquals(
|
||||
MinecraftCustomPayloadClassifier.CustomPayloadKind.REGISTRY_OR_CONFIG_SYNC,
|
||||
MinecraftCustomPayloadClassifier.classify(frame, 1024).kind());
|
||||
} finally {
|
||||
frame.release();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void flagsLargePayloads() {
|
||||
var frame = customPayload(0x01, "unknown:large", 2048);
|
||||
try {
|
||||
var classification = MinecraftCustomPayloadClassifier.classify(frame, 1024);
|
||||
|
||||
assertEquals(MinecraftCustomPayloadClassifier.CustomPayloadKind.UNKNOWN, classification.kind());
|
||||
assertTrue(classification.largePayload());
|
||||
} finally {
|
||||
frame.release();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsOversizedChannelName() {
|
||||
var frame = customPayload(0x01, "x".repeat(129), 1);
|
||||
try {
|
||||
assertThrows(MinecraftCodecException.class, () -> MinecraftCustomPayloadClassifier.classify(frame, 1024));
|
||||
} finally {
|
||||
frame.release();
|
||||
}
|
||||
}
|
||||
|
||||
private static io.netty.buffer.ByteBuf customPayload(int packetId, String channel, int payloadBytes) {
|
||||
var frame = Unpooled.buffer();
|
||||
MinecraftVarInts.write(frame, packetId);
|
||||
var channelBytes = channel.getBytes(StandardCharsets.UTF_8);
|
||||
MinecraftVarInts.write(frame, channelBytes.length);
|
||||
frame.writeBytes(channelBytes);
|
||||
frame.writeZero(payloadBytes);
|
||||
return frame;
|
||||
}
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
package dev.strataproxy.codec.minecraft;
|
||||
|
||||
import dev.strataproxy.protocol.PacketDirection;
|
||||
import dev.strataproxy.protocol.PacketFlag;
|
||||
import dev.strataproxy.protocol.PacketView;
|
||||
import dev.strataproxy.protocol.ProtocolState;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
final class MinecraftPacketDefinitionsTest {
|
||||
@Test
|
||||
void marksConfigurationCustomPayloadAsModdedInspectionPacket() {
|
||||
var registry = MinecraftPacketDefinitions.modernDefaults();
|
||||
var packet = new PacketView(PacketDirection.SERVERBOUND, ProtocolState.CONFIGURATION, 763, 0x01, 4096, 0, false);
|
||||
|
||||
var definition = registry.find(packet).orElseThrow();
|
||||
|
||||
assertTrue(definition.has(PacketFlag.REQUIRES_INSPECTION));
|
||||
assertTrue(definition.has(PacketFlag.SUSPICIOUS_WHEN_LARGE));
|
||||
assertTrue(definition.has(PacketFlag.MUST_PRESERVE_ORDER));
|
||||
assertTrue(definition.has(PacketFlag.MODDED_PAYLOAD));
|
||||
}
|
||||
}
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
package dev.strataproxy.codec.minecraft;
|
||||
|
||||
import io.netty.buffer.Unpooled;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
final class MinecraftVarIntsTest {
|
||||
@Test
|
||||
void roundTripsRepresentativeValues() {
|
||||
var values = new int[] {0, 1, 127, 128, 255, 2_097_151, Integer.MAX_VALUE};
|
||||
for (var value : values) {
|
||||
var buffer = Unpooled.buffer();
|
||||
try {
|
||||
MinecraftVarInts.write(buffer, value);
|
||||
|
||||
assertEquals(MinecraftVarInts.encodedSize(value), buffer.readableBytes());
|
||||
assertEquals(value, MinecraftVarInts.read(buffer));
|
||||
} finally {
|
||||
buffer.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsTooLongVarInt() {
|
||||
var buffer = Unpooled.wrappedBuffer(new byte[] {
|
||||
(byte) 0x80,
|
||||
(byte) 0x80,
|
||||
(byte) 0x80,
|
||||
(byte) 0x80,
|
||||
(byte) 0x80,
|
||||
0x00
|
||||
});
|
||||
try {
|
||||
assertThrows(MinecraftCodecException.class, () -> MinecraftVarInts.read(buffer));
|
||||
} finally {
|
||||
buffer.release();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void probesWithoutConsumingReaderIndex() {
|
||||
var buffer = Unpooled.buffer();
|
||||
try {
|
||||
MinecraftVarInts.write(buffer, 300);
|
||||
var readerIndex = buffer.readerIndex();
|
||||
|
||||
var probe = MinecraftVarInts.probe(buffer);
|
||||
|
||||
assertEquals(true, probe.complete());
|
||||
assertEquals(300, probe.value());
|
||||
assertEquals(2, probe.bytes());
|
||||
assertEquals(readerIndex, buffer.readerIndex());
|
||||
} finally {
|
||||
buffer.release();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void probeReportsIncompleteVarInt() {
|
||||
var buffer = Unpooled.wrappedBuffer(new byte[] {(byte) 0x80});
|
||||
try {
|
||||
var probe = MinecraftVarInts.probe(buffer);
|
||||
|
||||
assertEquals(false, probe.complete());
|
||||
assertEquals(0, probe.bytes());
|
||||
} finally {
|
||||
buffer.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
plugins {
|
||||
id("strataproxy.java-library-conventions")
|
||||
}
|
||||
|
||||
dependencies {
|
||||
api(project(":proxy-api"))
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
plugins {
|
||||
id("strataproxy.java-library-conventions")
|
||||
}
|
||||
|
||||
dependencies {
|
||||
api(project(":proxy-api"))
|
||||
implementation(project(":proxy-protocol"))
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
package dev.strataproxy.compression;
|
||||
|
||||
public final class AdaptiveCompressionStrategy implements CompressionStrategy {
|
||||
private static final int SMALL_HIGH_FREQUENCY_CUTOFF = 384;
|
||||
private static final int LARGE_PAYLOAD_CUTOFF = 1_048_576;
|
||||
|
||||
@Override
|
||||
public CompressionAction choose(CompressionContext context) {
|
||||
var packet = context.packet();
|
||||
if (packet.rawSize() < SMALL_HIGH_FREQUENCY_CUTOFF) {
|
||||
return new CompressionAction.Bypass("small packets are cheaper to forward uncompressed");
|
||||
}
|
||||
if (context.cpuLoad() >= context.cpuGuard() && packet.rawSize() < LARGE_PAYLOAD_CUTOFF) {
|
||||
return new CompressionAction.Bypass("cpu guard active");
|
||||
}
|
||||
if (packet.rawSize() >= LARGE_PAYLOAD_CUTOFF && context.historicalCompressionRatio() < 0.9d) {
|
||||
return new CompressionAction.Force(context.minThreshold(), "large payload with useful compression history");
|
||||
}
|
||||
var latencyBias = context.playerRttMillis() + context.backendRttMillis() > 250 ? -256 : 0;
|
||||
var cpuBias = context.cpuLoad() > 0.70d ? 1024 : 0;
|
||||
var threshold = clamp(1024 + latencyBias + cpuBias, context.minThreshold(), context.maxThreshold());
|
||||
return new CompressionAction.Threshold(threshold);
|
||||
}
|
||||
|
||||
private static int clamp(int value, int min, int max) {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package dev.strataproxy.compression;
|
||||
|
||||
public sealed interface CompressionAction permits CompressionAction.Bypass, CompressionAction.Threshold, CompressionAction.Force {
|
||||
record Bypass(String reason) implements CompressionAction {
|
||||
}
|
||||
|
||||
record Threshold(int bytes) implements CompressionAction {
|
||||
}
|
||||
|
||||
record Force(int thresholdBytes, String reason) implements CompressionAction {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package dev.strataproxy.compression;
|
||||
|
||||
import dev.strataproxy.protocol.PacketView;
|
||||
|
||||
public record CompressionContext(
|
||||
PacketView packet,
|
||||
long playerRttMillis,
|
||||
long backendRttMillis,
|
||||
double cpuLoad,
|
||||
double cpuGuard,
|
||||
double historicalCompressionRatio,
|
||||
int minThreshold,
|
||||
int maxThreshold) {
|
||||
public CompressionContext {
|
||||
if (cpuLoad < 0.0d || cpuLoad > 1.0d) {
|
||||
throw new IllegalArgumentException("cpuLoad must be between 0 and 1");
|
||||
}
|
||||
if (cpuGuard < 0.0d || cpuGuard > 1.0d) {
|
||||
throw new IllegalArgumentException("cpuGuard must be between 0 and 1");
|
||||
}
|
||||
if (minThreshold < 0 || maxThreshold < minThreshold) {
|
||||
throw new IllegalArgumentException("invalid threshold bounds");
|
||||
}
|
||||
}
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
package dev.strataproxy.compression;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
public final class CompressionStrategies {
|
||||
private CompressionStrategies() {
|
||||
}
|
||||
|
||||
public static CompressionStrategy from(String mode) {
|
||||
return switch (normalize(mode)) {
|
||||
case "off" -> new DisabledCompressionStrategy();
|
||||
case "fixed" -> new FixedCompressionStrategy();
|
||||
case "adaptive" -> new AdaptiveCompressionStrategy();
|
||||
default -> throw new IllegalArgumentException("unsupported compression mode: " + mode);
|
||||
};
|
||||
}
|
||||
|
||||
private static String normalize(String mode) {
|
||||
return mode == null ? "" : mode.trim().toLowerCase(Locale.ROOT);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package dev.strataproxy.compression;
|
||||
|
||||
public interface CompressionStrategy {
|
||||
CompressionAction choose(CompressionContext context);
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
package dev.strataproxy.compression;
|
||||
|
||||
public final class DisabledCompressionStrategy implements CompressionStrategy {
|
||||
@Override
|
||||
public CompressionAction choose(CompressionContext context) {
|
||||
return new CompressionAction.Bypass("compression disabled");
|
||||
}
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
package dev.strataproxy.compression;
|
||||
|
||||
public final class FixedCompressionStrategy implements CompressionStrategy {
|
||||
@Override
|
||||
public CompressionAction choose(CompressionContext context) {
|
||||
return new CompressionAction.Threshold(context.minThreshold());
|
||||
}
|
||||
}
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
package dev.strataproxy.compression;
|
||||
|
||||
import dev.strataproxy.protocol.PacketDirection;
|
||||
import dev.strataproxy.protocol.PacketView;
|
||||
import dev.strataproxy.protocol.ProtocolState;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
final class CompressionStrategiesTest {
|
||||
@Test
|
||||
void createsDisabledStrategy() {
|
||||
var action = CompressionStrategies.from("off").choose(context(4096, 0.1d, 0.75d, 0.5d, 256, 8192));
|
||||
|
||||
var bypass = assertInstanceOf(CompressionAction.Bypass.class, action);
|
||||
assertEquals("compression disabled", bypass.reason());
|
||||
}
|
||||
|
||||
@Test
|
||||
void createsFixedThresholdStrategy() {
|
||||
var action = CompressionStrategies.from("fixed").choose(context(4096, 0.1d, 0.75d, 0.5d, 512, 8192));
|
||||
|
||||
var threshold = assertInstanceOf(CompressionAction.Threshold.class, action);
|
||||
assertEquals(512, threshold.bytes());
|
||||
}
|
||||
|
||||
@Test
|
||||
void adaptiveBypassesSmallPackets() {
|
||||
var action = CompressionStrategies.from("adaptive").choose(context(128, 0.1d, 0.75d, 0.5d, 256, 8192));
|
||||
|
||||
assertInstanceOf(CompressionAction.Bypass.class, action);
|
||||
}
|
||||
|
||||
@Test
|
||||
void adaptiveUsesConfiguredCpuGuard() {
|
||||
var action = CompressionStrategies.from("adaptive").choose(context(4096, 0.76d, 0.75d, 0.5d, 256, 8192));
|
||||
|
||||
var bypass = assertInstanceOf(CompressionAction.Bypass.class, action);
|
||||
assertEquals("cpu guard active", bypass.reason());
|
||||
}
|
||||
|
||||
@Test
|
||||
void adaptiveForcesLargePayloadsWhenHistoryShowsSavings() {
|
||||
var action = CompressionStrategies.from("adaptive").choose(context(2 * 1024 * 1024, 0.95d, 0.75d, 0.4d, 256, 8192));
|
||||
|
||||
var force = assertInstanceOf(CompressionAction.Force.class, action);
|
||||
assertEquals(256, force.thresholdBytes());
|
||||
}
|
||||
|
||||
@Test
|
||||
void adaptiveClampsThresholdToBounds() {
|
||||
var action = CompressionStrategies.from("adaptive").choose(context(4096, 0.2d, 0.75d, 0.9d, 2048, 4096));
|
||||
|
||||
var threshold = assertInstanceOf(CompressionAction.Threshold.class, action);
|
||||
assertEquals(2048, threshold.bytes());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsUnknownMode() {
|
||||
assertThrows(IllegalArgumentException.class, () -> CompressionStrategies.from("unknown"));
|
||||
}
|
||||
|
||||
private static CompressionContext context(
|
||||
int rawSize,
|
||||
double cpuLoad,
|
||||
double cpuGuard,
|
||||
double historicalCompressionRatio,
|
||||
int minThreshold,
|
||||
int maxThreshold) {
|
||||
return new CompressionContext(
|
||||
new PacketView(PacketDirection.CLIENTBOUND, ProtocolState.PLAY, 763, 0x01, rawSize, 0, false),
|
||||
40,
|
||||
40,
|
||||
cpuLoad,
|
||||
cpuGuard,
|
||||
historicalCompressionRatio,
|
||||
minThreshold,
|
||||
maxThreshold);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
plugins {
|
||||
id("strataproxy.java-library-conventions")
|
||||
}
|
||||
|
||||
dependencies {
|
||||
api(project(":proxy-network"))
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
plugins {
|
||||
id("strataproxy.java-library-conventions")
|
||||
}
|
||||
|
||||
val nettyVersion = libs.findVersion("netty").get().requiredVersion
|
||||
|
||||
dependencies {
|
||||
api(project(":proxy-api"))
|
||||
api(project(":proxy-observability"))
|
||||
implementation(project(":proxy-codec-minecraft"))
|
||||
implementation(project(":proxy-compression"))
|
||||
implementation(project(":proxy-packet-analysis"))
|
||||
implementation(project(":proxy-protocol"))
|
||||
implementation(project(":proxy-routing"))
|
||||
implementation(libs.findLibrary("netty-transport").get())
|
||||
implementation(libs.findLibrary("netty-transport-classes-epoll").get())
|
||||
implementation(libs.findLibrary("netty-transport-classes-kqueue").get())
|
||||
implementation(libs.findLibrary("netty-handler").get())
|
||||
runtimeOnly("io.netty:netty-transport-native-epoll:$nettyVersion:linux-x86_64")
|
||||
runtimeOnly("io.netty:netty-transport-native-epoll:$nettyVersion:linux-aarch_64")
|
||||
runtimeOnly("io.netty:netty-transport-native-kqueue:$nettyVersion:osx-x86_64")
|
||||
runtimeOnly("io.netty:netty-transport-native-kqueue:$nettyVersion:osx-aarch_64")
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
package dev.strataproxy.network;
|
||||
|
||||
import dev.strataproxy.compression.CompressionAction;
|
||||
import dev.strataproxy.observability.ProxyMetrics;
|
||||
import dev.strataproxy.observability.ProxyMetrics.CompressionDirection;
|
||||
import io.netty.buffer.ByteBuf;
|
||||
import io.netty.channel.Channel;
|
||||
import io.netty.channel.ChannelFutureListener;
|
||||
import io.netty.channel.ChannelHandlerContext;
|
||||
import io.netty.channel.ChannelInboundHandlerAdapter;
|
||||
import io.netty.util.ReferenceCountUtil;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
final class BackendRelayHandler extends ChannelInboundHandlerAdapter {
|
||||
private static final String RULE_COMPRESSION_NEGOTIATION_MALFORMED = "compression-negotiation-malformed";
|
||||
private static final String RULE_COMPRESSION_AUDIT_MALFORMED = "compression-audit-malformed";
|
||||
|
||||
private final Channel frontend;
|
||||
private final ProxyMetrics metrics;
|
||||
private final String serverName;
|
||||
private final MinecraftCompressionAuditState compressionAudit;
|
||||
private final MinecraftCompressionNegotiationDetector compressionDetector;
|
||||
private final CompressionRuntime compressionRuntime;
|
||||
private final MinecraftPacketTrafficSampler packetTrafficSampler;
|
||||
private final RelaySessionIdentity identity;
|
||||
private final CompressionRewriteRuntime compressionRewriteRuntime;
|
||||
private final int maxFrameBytes;
|
||||
private boolean closed;
|
||||
|
||||
BackendRelayHandler(Channel frontend, ProxyMetrics metrics, String serverName) {
|
||||
this(frontend, metrics, serverName, NetworkTuning.defaults().maxFrameBytes());
|
||||
}
|
||||
|
||||
BackendRelayHandler(Channel frontend, ProxyMetrics metrics, String serverName, int maxFrameBytes) {
|
||||
this(frontend, metrics, serverName, maxFrameBytes, new MinecraftCompressionAuditState(maxFrameBytes));
|
||||
}
|
||||
|
||||
BackendRelayHandler(
|
||||
Channel frontend,
|
||||
ProxyMetrics metrics,
|
||||
String serverName,
|
||||
int maxFrameBytes,
|
||||
MinecraftCompressionAuditState compressionAudit) {
|
||||
this(frontend, metrics, serverName, maxFrameBytes, compressionAudit, CompressionRuntime.defaults());
|
||||
}
|
||||
|
||||
BackendRelayHandler(
|
||||
Channel frontend,
|
||||
ProxyMetrics metrics,
|
||||
String serverName,
|
||||
int maxFrameBytes,
|
||||
MinecraftCompressionAuditState compressionAudit,
|
||||
CompressionRuntime compressionRuntime) {
|
||||
this(frontend, metrics, serverName, maxFrameBytes, compressionAudit, compressionRuntime, new RelaySessionIdentity(""));
|
||||
}
|
||||
|
||||
BackendRelayHandler(
|
||||
Channel frontend,
|
||||
ProxyMetrics metrics,
|
||||
String serverName,
|
||||
int maxFrameBytes,
|
||||
MinecraftCompressionAuditState compressionAudit,
|
||||
CompressionRuntime compressionRuntime,
|
||||
RelaySessionIdentity identity) {
|
||||
this(frontend, metrics, serverName, maxFrameBytes, compressionAudit, compressionRuntime, identity, false);
|
||||
}
|
||||
|
||||
BackendRelayHandler(
|
||||
Channel frontend,
|
||||
ProxyMetrics metrics,
|
||||
String serverName,
|
||||
int maxFrameBytes,
|
||||
MinecraftCompressionAuditState compressionAudit,
|
||||
CompressionRuntime compressionRuntime,
|
||||
RelaySessionIdentity identity,
|
||||
boolean compressionRewriteEnabled) {
|
||||
this(frontend, metrics, serverName, maxFrameBytes, compressionAudit, compressionRuntime, identity, compressionRewriteEnabled, 25);
|
||||
}
|
||||
|
||||
BackendRelayHandler(
|
||||
Channel frontend,
|
||||
ProxyMetrics metrics,
|
||||
String serverName,
|
||||
int maxFrameBytes,
|
||||
MinecraftCompressionAuditState compressionAudit,
|
||||
CompressionRuntime compressionRuntime,
|
||||
RelaySessionIdentity identity,
|
||||
boolean compressionRewriteEnabled,
|
||||
int compressionRewriteMaxEventLoopDelayMillis) {
|
||||
this.frontend = frontend;
|
||||
this.metrics = metrics;
|
||||
this.serverName = serverName;
|
||||
this.compressionAudit = compressionAudit;
|
||||
this.compressionDetector = new MinecraftCompressionNegotiationDetector(maxFrameBytes);
|
||||
this.compressionRuntime = compressionRuntime;
|
||||
this.packetTrafficSampler = new MinecraftPacketTrafficSampler(maxFrameBytes);
|
||||
this.identity = identity;
|
||||
this.compressionRewriteRuntime = new CompressionRewriteRuntime(compressionRewriteEnabled, compressionRewriteMaxEventLoopDelayMillis);
|
||||
this.maxFrameBytes = maxFrameBytes;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void channelRead(ChannelHandlerContext context, Object message) {
|
||||
if (!frontend.isActive()) {
|
||||
ReferenceCountUtil.release(message);
|
||||
context.close();
|
||||
return;
|
||||
}
|
||||
var outbound = message;
|
||||
var forward = true;
|
||||
List<CompressionAction> compressionActions = List.of();
|
||||
if (message instanceof ByteBuf buffer) {
|
||||
metrics.backendToFrontendBytes(serverName, buffer.readableBytes());
|
||||
capturePayloadPrefix(buffer, CompressionDirection.BACKEND_TO_FRONTEND);
|
||||
observePacketTraffic(buffer);
|
||||
var compression = observeCompression(buffer);
|
||||
forward = compression.shouldForward();
|
||||
compressionActions = compression.actions();
|
||||
if (forward && compression.rewriteEligible()) {
|
||||
var rewrite = compressionRewriteRuntime.rewrite(
|
||||
context.alloc(),
|
||||
metrics,
|
||||
serverName,
|
||||
CompressionDirection.BACKEND_TO_FRONTEND,
|
||||
buffer,
|
||||
compressionAudit.threshold(),
|
||||
compressionActions,
|
||||
maxFrameBytes);
|
||||
if (rewrite.suppressed()) {
|
||||
ReferenceCountUtil.release(message);
|
||||
context.channel().read();
|
||||
return;
|
||||
}
|
||||
if (rewrite.replaced()) {
|
||||
ReferenceCountUtil.release(message);
|
||||
outbound = rewrite.frame();
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!forward) {
|
||||
ReferenceCountUtil.release(outbound);
|
||||
context.close();
|
||||
if (frontend.isOpen()) {
|
||||
frontend.close();
|
||||
}
|
||||
return;
|
||||
}
|
||||
recordBackpressureIfNeeded(frontend);
|
||||
frontend.writeAndFlush(outbound).addListener((ChannelFutureListener) future -> {
|
||||
if (future.isSuccess()) {
|
||||
context.channel().read();
|
||||
} else {
|
||||
future.channel().close();
|
||||
context.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void channelInactive(ChannelHandlerContext context) {
|
||||
closeBackendSide();
|
||||
if (frontend.isOpen()) {
|
||||
frontend.close();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void exceptionCaught(ChannelHandlerContext context, Throwable cause) {
|
||||
context.close();
|
||||
closeBackendSide();
|
||||
if (frontend.isOpen()) {
|
||||
frontend.close();
|
||||
}
|
||||
}
|
||||
|
||||
private void closeBackendSide() {
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
closed = true;
|
||||
compressionDetector.close();
|
||||
compressionAudit.closeBackendSampler();
|
||||
packetTrafficSampler.close();
|
||||
compressionRewriteRuntime.close();
|
||||
}
|
||||
|
||||
private void observePacketTraffic(ByteBuf buffer) {
|
||||
if (compressionAudit.negotiated()) {
|
||||
packetTrafficSampler.close();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
for (var sample : packetTrafficSampler.observe(buffer)) {
|
||||
metrics.packetTraffic(
|
||||
serverName,
|
||||
CompressionDirection.BACKEND_TO_FRONTEND,
|
||||
"UNCOMPRESSED",
|
||||
sample.packetId(),
|
||||
sample.frameBytes(),
|
||||
0);
|
||||
}
|
||||
} catch (RuntimeException exception) {
|
||||
packetTrafficSampler.close();
|
||||
}
|
||||
}
|
||||
|
||||
private CompressionObservation observeCompression(ByteBuf buffer) {
|
||||
if (compressionAudit.negotiated()) {
|
||||
return observeCompressionFrames(buffer);
|
||||
}
|
||||
try {
|
||||
var threshold = compressionDetector.observe(buffer);
|
||||
if (threshold.isPresent()) {
|
||||
metrics.compressionNegotiated(serverName, threshold.getAsInt());
|
||||
compressionAudit.negotiate(threshold.getAsInt());
|
||||
}
|
||||
return CompressionObservation.allow();
|
||||
} catch (RuntimeException exception) {
|
||||
metrics.packetAnomaly(
|
||||
RULE_COMPRESSION_NEGOTIATION_MALFORMED,
|
||||
"",
|
||||
serverName,
|
||||
CompressionDirection.BACKEND_TO_FRONTEND.label(),
|
||||
"LOGIN",
|
||||
-1,
|
||||
buffer.readableBytes(),
|
||||
-1,
|
||||
exception.getMessage());
|
||||
return CompressionObservation.block();
|
||||
}
|
||||
}
|
||||
|
||||
private CompressionObservation observeCompressionFrames(ByteBuf buffer) {
|
||||
try {
|
||||
var actions = new ArrayList<CompressionAction>();
|
||||
for (var sample : compressionAudit.observeBackend(buffer)) {
|
||||
metrics.compressionSample(
|
||||
serverName,
|
||||
CompressionDirection.BACKEND_TO_FRONTEND,
|
||||
sample.rawBytes(),
|
||||
sample.compressedBytes(),
|
||||
0);
|
||||
actions.add(compressionRuntime.recordDecision(
|
||||
metrics,
|
||||
serverName,
|
||||
CompressionDirection.BACKEND_TO_FRONTEND,
|
||||
sample.rawBytes(),
|
||||
ratio(sample),
|
||||
0));
|
||||
}
|
||||
return new CompressionObservation(true, true, List.copyOf(actions));
|
||||
} catch (RuntimeException exception) {
|
||||
metrics.packetAnomaly(
|
||||
RULE_COMPRESSION_AUDIT_MALFORMED,
|
||||
"",
|
||||
serverName,
|
||||
CompressionDirection.BACKEND_TO_FRONTEND.label(),
|
||||
"PLAY",
|
||||
-1,
|
||||
buffer.readableBytes(),
|
||||
-1,
|
||||
exception.getMessage());
|
||||
compressionAudit.closeBackendSampler();
|
||||
return CompressionObservation.block();
|
||||
}
|
||||
}
|
||||
|
||||
private static double ratio(MinecraftCompressedFrameAuditSampler.CompressionFrameSample sample) {
|
||||
return sample.rawBytes() == 0 ? 1.0d : (double) sample.compressedBytes() / sample.rawBytes();
|
||||
}
|
||||
|
||||
private void recordBackpressureIfNeeded(Channel target) {
|
||||
if (!target.isWritable()) {
|
||||
metrics.relayBackpressure(serverName, CompressionDirection.BACKEND_TO_FRONTEND, target.bytesBeforeWritable());
|
||||
}
|
||||
}
|
||||
|
||||
private void capturePayloadPrefix(ByteBuf buffer, CompressionDirection direction) {
|
||||
var request = metrics.payloadCaptureRequest(serverName, direction);
|
||||
if (!request.enabled()) {
|
||||
return;
|
||||
}
|
||||
var bytes = new byte[Math.min(buffer.readableBytes(), request.maxBytesPerSample())];
|
||||
buffer.getBytes(buffer.readerIndex(), bytes);
|
||||
metrics.payloadCaptured(
|
||||
serverName,
|
||||
direction,
|
||||
buffer.readableBytes(),
|
||||
compressionAudit.negotiated() ? buffer.readableBytes() : -1,
|
||||
bytes,
|
||||
identity.playerName(),
|
||||
identity.remoteAddress());
|
||||
}
|
||||
|
||||
private record CompressionObservation(boolean shouldForward, boolean rewriteEligible, List<CompressionAction> actions) {
|
||||
private static CompressionObservation allow() {
|
||||
return new CompressionObservation(true, false, List.of());
|
||||
}
|
||||
|
||||
private static CompressionObservation block() {
|
||||
return new CompressionObservation(false, false, List.of());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package dev.strataproxy.network;
|
||||
|
||||
import dev.strataproxy.api.server.RegisteredServer;
|
||||
|
||||
import java.net.SocketAddress;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface BackendResolver {
|
||||
Optional<RegisteredServer> resolve(MinecraftHandshake handshake, SocketAddress remoteAddress);
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
package dev.strataproxy.network;
|
||||
|
||||
import dev.strataproxy.codec.minecraft.MinecraftCodecException;
|
||||
import dev.strataproxy.codec.minecraft.MinecraftCompressedFrameRewriter;
|
||||
import dev.strataproxy.codec.minecraft.MinecraftVarInts;
|
||||
import dev.strataproxy.compression.CompressionAction;
|
||||
import dev.strataproxy.observability.ProxyMetrics;
|
||||
import io.netty.buffer.ByteBuf;
|
||||
import io.netty.buffer.ByteBufAllocator;
|
||||
import io.netty.buffer.Unpooled;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
final class CompressionRewriteRuntime implements AutoCloseable {
|
||||
private static final String OUTCOME_BYPASS = "bypass";
|
||||
private static final String OUTCOME_REWRITTEN = "rewritten";
|
||||
private static final String OUTCOME_UNCHANGED = "unchanged";
|
||||
private static final String OUTCOME_MIXED_POLICY = "mixed_policy";
|
||||
private static final String OUTCOME_EVENT_LOOP_GUARD = "event_loop_guard";
|
||||
private static final String OUTCOME_UNSAFE_FRAME = "unsafe_frame";
|
||||
private static final String OUTCOME_FAILED = "failed";
|
||||
private static final int MAX_REWRITE_BATCH_FRAMES = 64;
|
||||
|
||||
private final boolean enabled;
|
||||
private final MinecraftCompressedFrameRewriter rewriter;
|
||||
private final long maxEventLoopDelayNanos;
|
||||
private ByteBuf pending = Unpooled.EMPTY_BUFFER;
|
||||
|
||||
CompressionRewriteRuntime(boolean enabled) {
|
||||
this(enabled, 25, new MinecraftCompressedFrameRewriter());
|
||||
}
|
||||
|
||||
CompressionRewriteRuntime(boolean enabled, int maxEventLoopDelayMillis) {
|
||||
this(enabled, maxEventLoopDelayMillis, new MinecraftCompressedFrameRewriter());
|
||||
}
|
||||
|
||||
CompressionRewriteRuntime(boolean enabled, int maxEventLoopDelayMillis, MinecraftCompressedFrameRewriter rewriter) {
|
||||
this.enabled = enabled;
|
||||
this.rewriter = rewriter;
|
||||
this.maxEventLoopDelayNanos = Math.max(0L, maxEventLoopDelayMillis) * 1_000_000L;
|
||||
}
|
||||
|
||||
RewriteAttempt rewrite(
|
||||
ByteBufAllocator allocator,
|
||||
ProxyMetrics metrics,
|
||||
String serverName,
|
||||
ProxyMetrics.CompressionDirection direction,
|
||||
ByteBuf frame,
|
||||
int sourceThreshold,
|
||||
List<CompressionAction> actions,
|
||||
int maxUncompressedBytes) {
|
||||
if (!enabled) {
|
||||
return RewriteAttempt.unchanged(frame);
|
||||
}
|
||||
if (maxEventLoopDelayNanos > 0 && metrics.currentEventLoopDelayNanos() > maxEventLoopDelayNanos) {
|
||||
metrics.compressionRewrite(serverName, direction, OUTCOME_EVENT_LOOP_GUARD);
|
||||
return RewriteAttempt.unchanged(frame);
|
||||
}
|
||||
if ((actions == null || actions.isEmpty()) && !hasCompleteFrame(frame, maxUncompressedBytes)) {
|
||||
appendPending(frame, maxUncompressedBytes);
|
||||
return RewriteAttempt.hold();
|
||||
}
|
||||
if (actions == null || actions.isEmpty()) {
|
||||
metrics.compressionRewrite(serverName, direction, OUTCOME_BYPASS);
|
||||
return RewriteAttempt.unchanged(frame);
|
||||
}
|
||||
var targetThreshold = commonTargetThreshold(actions);
|
||||
if (targetThreshold < 0) {
|
||||
metrics.compressionRewrite(serverName, direction, OUTCOME_BYPASS);
|
||||
return RewriteAttempt.unchanged(frame);
|
||||
}
|
||||
if (targetThreshold == Integer.MIN_VALUE) {
|
||||
metrics.compressionRewrite(serverName, direction, OUTCOME_MIXED_POLICY);
|
||||
return RewriteAttempt.unchanged(frame);
|
||||
}
|
||||
try {
|
||||
var rewriteInput = rewriteInput(allocator, frame, actions.size(), maxUncompressedBytes);
|
||||
try {
|
||||
var startedAt = System.nanoTime();
|
||||
var rewritten = rewriter.rewriteBatch(
|
||||
allocator,
|
||||
rewriteInput.frames(),
|
||||
sourceThreshold,
|
||||
targetThreshold,
|
||||
maxUncompressedBytes,
|
||||
MAX_REWRITE_BATCH_FRAMES);
|
||||
var output = rewritten.frames();
|
||||
var outcome = rewritten.rewrittenFrameBytes() == rewritten.originalFrameBytes()
|
||||
&& rewritten.isCompressed() == rewritten.wasCompressed()
|
||||
? OUTCOME_UNCHANGED
|
||||
: OUTCOME_REWRITTEN;
|
||||
metrics.compressionRewrite(serverName, direction, outcome, System.nanoTime() - startedAt);
|
||||
return new RewriteAttempt(output, output != frame, false);
|
||||
} finally {
|
||||
rewriteInput.close();
|
||||
}
|
||||
} catch (MinecraftCodecException exception) {
|
||||
metrics.compressionRewrite(serverName, direction, OUTCOME_UNSAFE_FRAME);
|
||||
return RewriteAttempt.unchanged(frame);
|
||||
} catch (RuntimeException exception) {
|
||||
metrics.compressionRewrite(serverName, direction, OUTCOME_FAILED);
|
||||
return RewriteAttempt.unchanged(frame);
|
||||
}
|
||||
}
|
||||
|
||||
private RewriteInput rewriteInput(ByteBufAllocator allocator, ByteBuf input, int expectedFrames, int maxFrameBytes) {
|
||||
if (expectedFrames <= 0) {
|
||||
return new RewriteInput(input, false);
|
||||
}
|
||||
ByteBuf source;
|
||||
var ownedSource = false;
|
||||
if (pending.isReadable()) {
|
||||
if (pending.readableBytes() + input.readableBytes() > maxFrameBytes + 5) {
|
||||
closePending();
|
||||
throw new MinecraftCodecException("compressed rewrite pending frame exceeds maximum size");
|
||||
}
|
||||
source = allocator.buffer(pending.readableBytes() + input.readableBytes());
|
||||
ownedSource = true;
|
||||
source.writeBytes(pending, pending.readerIndex(), pending.readableBytes());
|
||||
source.writeBytes(input, input.readerIndex(), input.readableBytes());
|
||||
pending.clear();
|
||||
} else {
|
||||
source = input;
|
||||
}
|
||||
|
||||
var view = source.slice();
|
||||
var completeBytes = 0;
|
||||
var frames = 0;
|
||||
while (view.isReadable() && frames < expectedFrames) {
|
||||
var probe = MinecraftVarInts.probe(view);
|
||||
if (!probe.complete()) {
|
||||
break;
|
||||
}
|
||||
if (probe.value() < 0 || probe.value() > maxFrameBytes) {
|
||||
releaseIfOwned(source, ownedSource);
|
||||
throw new MinecraftCodecException("compressed rewrite frame exceeds maximum size");
|
||||
}
|
||||
var totalBytes = probe.bytes() + probe.value();
|
||||
if (view.readableBytes() < totalBytes) {
|
||||
break;
|
||||
}
|
||||
view.skipBytes(totalBytes);
|
||||
completeBytes += totalBytes;
|
||||
frames++;
|
||||
}
|
||||
if (frames != expectedFrames) {
|
||||
releaseIfOwned(source, ownedSource);
|
||||
throw new MinecraftCodecException("compressed rewrite observed frame count does not match audit");
|
||||
}
|
||||
|
||||
var trailingBytes = view.readableBytes();
|
||||
if (trailingBytes > 0) {
|
||||
if (trailingBytes > maxFrameBytes + 5) {
|
||||
releaseIfOwned(source, ownedSource);
|
||||
closePending();
|
||||
throw new MinecraftCodecException("compressed rewrite pending frame exceeds maximum size");
|
||||
}
|
||||
appendPending(view, maxFrameBytes);
|
||||
}
|
||||
|
||||
if (completeBytes == source.readableBytes()) {
|
||||
return new RewriteInput(source, ownedSource);
|
||||
}
|
||||
var complete = source.retainedSlice(source.readerIndex(), completeBytes);
|
||||
releaseIfOwned(source, ownedSource);
|
||||
return new RewriteInput(complete, true);
|
||||
}
|
||||
|
||||
private boolean hasCompleteFrame(ByteBuf input, int maxFrameBytes) {
|
||||
var view = input.slice();
|
||||
var probe = MinecraftVarInts.probe(view);
|
||||
if (!probe.complete()) {
|
||||
return false;
|
||||
}
|
||||
if (probe.value() < 0 || probe.value() > maxFrameBytes) {
|
||||
throw new MinecraftCodecException("compressed rewrite frame exceeds maximum size");
|
||||
}
|
||||
return view.readableBytes() >= probe.bytes() + probe.value();
|
||||
}
|
||||
|
||||
private void appendPending(ByteBuf input, int maxFrameBytes) {
|
||||
if (!input.isReadable()) {
|
||||
return;
|
||||
}
|
||||
if (pending.readableBytes() + input.readableBytes() > maxFrameBytes + 5) {
|
||||
closePending();
|
||||
throw new MinecraftCodecException("compressed rewrite pending frame exceeds maximum size");
|
||||
}
|
||||
if (pending == Unpooled.EMPTY_BUFFER) {
|
||||
pending = Unpooled.buffer(Math.min(maxFrameBytes + 5, Math.max(64, input.readableBytes())));
|
||||
}
|
||||
pending.writeBytes(input, input.readerIndex(), input.readableBytes());
|
||||
}
|
||||
|
||||
private static void releaseIfOwned(ByteBuf source, boolean owned) {
|
||||
if (owned && source.refCnt() > 0) {
|
||||
source.release();
|
||||
}
|
||||
}
|
||||
|
||||
private static int commonTargetThreshold(List<CompressionAction> actions) {
|
||||
var target = -1;
|
||||
for (var action : actions) {
|
||||
var threshold = switch (action) {
|
||||
case CompressionAction.Threshold value -> value.bytes();
|
||||
case CompressionAction.Force value -> value.thresholdBytes();
|
||||
case CompressionAction.Bypass ignored -> -1;
|
||||
};
|
||||
if (threshold < 0) {
|
||||
return -1;
|
||||
}
|
||||
if (target < 0) {
|
||||
target = threshold;
|
||||
} else if (target != threshold) {
|
||||
return Integer.MIN_VALUE;
|
||||
}
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
closePending();
|
||||
rewriter.close();
|
||||
}
|
||||
|
||||
private void closePending() {
|
||||
if (pending.refCnt() > 0) {
|
||||
pending.release();
|
||||
}
|
||||
pending = Unpooled.EMPTY_BUFFER;
|
||||
}
|
||||
|
||||
private record RewriteInput(ByteBuf frames, boolean owned) implements AutoCloseable {
|
||||
@Override
|
||||
public void close() {
|
||||
if (owned && frames.refCnt() > 0) {
|
||||
frames.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
record RewriteAttempt(ByteBuf frame, boolean replaced, boolean suppressed) {
|
||||
private static RewriteAttempt unchanged(ByteBuf frame) {
|
||||
return new RewriteAttempt(frame, false, false);
|
||||
}
|
||||
|
||||
private static RewriteAttempt hold() {
|
||||
return new RewriteAttempt(Unpooled.EMPTY_BUFFER, false, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package dev.strataproxy.network;
|
||||
|
||||
import dev.strataproxy.compression.CompressionAction;
|
||||
import dev.strataproxy.compression.CompressionContext;
|
||||
import dev.strataproxy.compression.CompressionStrategies;
|
||||
import dev.strataproxy.compression.CompressionStrategy;
|
||||
import dev.strataproxy.observability.ProxyMetrics;
|
||||
import dev.strataproxy.protocol.PacketDirection;
|
||||
import dev.strataproxy.protocol.PacketView;
|
||||
import dev.strataproxy.protocol.ProtocolState;
|
||||
|
||||
final class CompressionRuntime {
|
||||
private final CompressionStrategy strategy;
|
||||
private final int minThreshold;
|
||||
private final int maxThreshold;
|
||||
private final double cpuGuard;
|
||||
|
||||
CompressionRuntime(CompressionStrategy strategy, int minThreshold, int maxThreshold, double cpuGuard) {
|
||||
if (strategy == null) {
|
||||
throw new IllegalArgumentException("strategy must not be null");
|
||||
}
|
||||
if (minThreshold < 0 || maxThreshold < minThreshold) {
|
||||
throw new IllegalArgumentException("invalid compression threshold bounds");
|
||||
}
|
||||
if (cpuGuard < 0.0d || cpuGuard > 1.0d) {
|
||||
throw new IllegalArgumentException("cpuGuard must be between 0 and 1");
|
||||
}
|
||||
this.strategy = strategy;
|
||||
this.minThreshold = minThreshold;
|
||||
this.maxThreshold = maxThreshold;
|
||||
this.cpuGuard = cpuGuard;
|
||||
}
|
||||
|
||||
static CompressionRuntime defaults() {
|
||||
return new CompressionRuntime(CompressionStrategies.from("adaptive"), 256, 8192, 0.75d);
|
||||
}
|
||||
|
||||
CompressionAction recordDecision(
|
||||
ProxyMetrics metrics,
|
||||
String serverName,
|
||||
ProxyMetrics.CompressionDirection direction,
|
||||
long rawBytes,
|
||||
double historicalCompressionRatio,
|
||||
long eventLoopDelayNanos) {
|
||||
if (rawBytes > Integer.MAX_VALUE) {
|
||||
var bypass = new CompressionAction.Bypass("packet too large for compression context");
|
||||
metrics.compressionDecision(serverName, direction, actionName(bypass), actionThreshold(bypass));
|
||||
return bypass;
|
||||
}
|
||||
var action = choose(direction, rawBytes, historicalCompressionRatio, eventLoopDelayNanos);
|
||||
metrics.compressionDecision(serverName, direction, actionName(action), actionThreshold(action));
|
||||
return action;
|
||||
}
|
||||
|
||||
CompressionAction choose(
|
||||
ProxyMetrics.CompressionDirection direction,
|
||||
long rawBytes,
|
||||
double historicalCompressionRatio,
|
||||
long eventLoopDelayNanos) {
|
||||
if (rawBytes > Integer.MAX_VALUE) {
|
||||
return new CompressionAction.Bypass("packet too large for compression context");
|
||||
}
|
||||
var packetDirection = direction == ProxyMetrics.CompressionDirection.BACKEND_TO_FRONTEND
|
||||
? PacketDirection.CLIENTBOUND
|
||||
: PacketDirection.SERVERBOUND;
|
||||
var eventLoopDelayMillis = Math.max(0L, eventLoopDelayNanos / 1_000_000L);
|
||||
var context = new CompressionContext(
|
||||
new PacketView(packetDirection, ProtocolState.PLAY, -1, -1, (int) rawBytes, 0, false),
|
||||
0,
|
||||
eventLoopDelayMillis,
|
||||
Math.min(1.0d, eventLoopDelayMillis / 1000.0d),
|
||||
cpuGuard,
|
||||
historicalCompressionRatio,
|
||||
minThreshold,
|
||||
maxThreshold);
|
||||
return strategy.choose(context);
|
||||
}
|
||||
|
||||
int targetThreshold(CompressionAction action) {
|
||||
return actionThreshold(action);
|
||||
}
|
||||
|
||||
private static String actionName(CompressionAction action) {
|
||||
return switch (action) {
|
||||
case CompressionAction.Bypass ignored -> "bypass";
|
||||
case CompressionAction.Threshold ignored -> "threshold";
|
||||
case CompressionAction.Force ignored -> "force";
|
||||
};
|
||||
}
|
||||
|
||||
private static int actionThreshold(CompressionAction action) {
|
||||
return switch (action) {
|
||||
case CompressionAction.Bypass ignored -> -1;
|
||||
case CompressionAction.Threshold threshold -> threshold.bytes();
|
||||
case CompressionAction.Force force -> force.thresholdBytes();
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package dev.strataproxy.network;
|
||||
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.SocketAddress;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
public final class ConnectionAdmissionControl {
|
||||
private final int maxConnections;
|
||||
private final int maxConnectionsPerAddress;
|
||||
private final AtomicInteger activeConnections = new AtomicInteger();
|
||||
private final ConcurrentHashMap<String, AtomicInteger> activeByAddress = new ConcurrentHashMap<>();
|
||||
|
||||
public ConnectionAdmissionControl(int maxConnections, int maxConnectionsPerAddress) {
|
||||
if (maxConnections <= 0 || maxConnectionsPerAddress <= 0) {
|
||||
throw new IllegalArgumentException("connection limits must be positive");
|
||||
}
|
||||
this.maxConnections = maxConnections;
|
||||
this.maxConnectionsPerAddress = maxConnectionsPerAddress;
|
||||
}
|
||||
|
||||
public Admission acquire(SocketAddress remoteAddress) {
|
||||
var key = key(remoteAddress);
|
||||
if (!tryIncrement(activeConnections, maxConnections)) {
|
||||
return Admission.rejected(key, "global_limit");
|
||||
}
|
||||
var addressCounter = activeByAddress.computeIfAbsent(key, ignored -> new AtomicInteger());
|
||||
if (!tryIncrement(addressCounter, maxConnectionsPerAddress)) {
|
||||
activeConnections.decrementAndGet();
|
||||
return Admission.rejected(key, "per_address_limit");
|
||||
}
|
||||
return Admission.accepted(key);
|
||||
}
|
||||
|
||||
public void release(Admission admission) {
|
||||
if (admission == null || !admission.accepted()) {
|
||||
return;
|
||||
}
|
||||
activeConnections.updateAndGet(value -> Math.max(0, value - 1));
|
||||
var counter = activeByAddress.get(admission.addressKey());
|
||||
if (counter != null) {
|
||||
var remaining = counter.updateAndGet(value -> Math.max(0, value - 1));
|
||||
if (remaining <= 0) {
|
||||
activeByAddress.remove(admission.addressKey(), counter);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public long activeConnections() {
|
||||
return activeConnections.get();
|
||||
}
|
||||
|
||||
public long activeConnectionsFor(String addressKey) {
|
||||
var counter = activeByAddress.get(addressKey);
|
||||
return counter == null ? 0 : counter.get();
|
||||
}
|
||||
|
||||
private static boolean tryIncrement(AtomicInteger counter, int limit) {
|
||||
while (true) {
|
||||
var current = counter.get();
|
||||
if (current >= limit) {
|
||||
return false;
|
||||
}
|
||||
if (counter.compareAndSet(current, current + 1)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static String key(SocketAddress remoteAddress) {
|
||||
if (remoteAddress instanceof InetSocketAddress inet) {
|
||||
var address = inet.getAddress();
|
||||
return address == null ? inet.getHostString() : address.getHostAddress();
|
||||
}
|
||||
return String.valueOf(remoteAddress);
|
||||
}
|
||||
|
||||
public record Admission(boolean accepted, String addressKey, String rejectionReason) {
|
||||
static Admission accepted(String addressKey) {
|
||||
return new Admission(true, addressKey, "");
|
||||
}
|
||||
|
||||
static Admission rejected(String addressKey, String reason) {
|
||||
return new Admission(false, addressKey, reason);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package dev.strataproxy.network;
|
||||
|
||||
import dev.strataproxy.observability.ProxyMetrics;
|
||||
import io.netty.channel.ChannelHandlerContext;
|
||||
import io.netty.channel.ChannelInboundHandlerAdapter;
|
||||
|
||||
final class ConnectionAdmissionHandler extends ChannelInboundHandlerAdapter {
|
||||
private final ConnectionAdmissionControl admissionControl;
|
||||
private final ProxyMetrics metrics;
|
||||
private ConnectionAdmissionControl.Admission admission;
|
||||
private boolean released;
|
||||
|
||||
ConnectionAdmissionHandler(ConnectionAdmissionControl admissionControl, ProxyMetrics metrics) {
|
||||
this.admissionControl = admissionControl;
|
||||
this.metrics = metrics;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void channelActive(ChannelHandlerContext context) throws Exception {
|
||||
admission = admissionControl.acquire(context.channel().remoteAddress());
|
||||
if (!admission.accepted()) {
|
||||
metrics.rejectedConnection(admission.rejectionReason());
|
||||
context.close();
|
||||
return;
|
||||
}
|
||||
metrics.acceptedConnection();
|
||||
super.channelActive(context);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void channelInactive(ChannelHandlerContext context) throws Exception {
|
||||
if (!released) {
|
||||
released = true;
|
||||
admissionControl.release(admission);
|
||||
if (admission != null && admission.accepted()) {
|
||||
metrics.closedConnection();
|
||||
}
|
||||
}
|
||||
super.channelInactive(context);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,454 @@
|
||||
package dev.strataproxy.network;
|
||||
|
||||
import dev.strataproxy.analysis.CustomPayloadAnomalyPolicy;
|
||||
import dev.strataproxy.analysis.AnomalyAction;
|
||||
import dev.strataproxy.compression.CompressionAction;
|
||||
import dev.strataproxy.observability.ProxyMetrics;
|
||||
import dev.strataproxy.observability.ProxyMetrics.CompressionDirection;
|
||||
import dev.strataproxy.analysis.PacketAnomaly;
|
||||
import io.netty.buffer.ByteBuf;
|
||||
import io.netty.channel.Channel;
|
||||
import io.netty.channel.ChannelFutureListener;
|
||||
import io.netty.channel.ChannelHandlerContext;
|
||||
import io.netty.channel.ChannelInboundHandlerAdapter;
|
||||
import io.netty.util.ReferenceCountUtil;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
final class FrontendRelayHandler extends ChannelInboundHandlerAdapter {
|
||||
private static final String RULE_COMPRESSION_AUDIT_MALFORMED = "compression-audit-malformed";
|
||||
|
||||
private final Channel backend;
|
||||
private final ProxyMetrics metrics;
|
||||
private final String serverName;
|
||||
private final MinecraftCompressionAuditState compressionAudit;
|
||||
private final CompressionRuntime compressionRuntime;
|
||||
private final MinecraftCustomPayloadInspectionSampler customPayloadInspection;
|
||||
private final MinecraftPacketTrafficSampler packetTrafficSampler;
|
||||
private final MinecraftLoginStartSampler loginStartSampler;
|
||||
private final int maxFrameBytes;
|
||||
private final CustomPayloadAnomalyPolicy customPayloadPolicy;
|
||||
private final RelaySessionIdentity identity;
|
||||
private final CompressionRewriteRuntime compressionRewriteRuntime;
|
||||
private MinecraftCompressedCustomPayloadInspectionSampler compressedCustomPayloadInspection;
|
||||
private String playerName;
|
||||
private boolean closed;
|
||||
|
||||
FrontendRelayHandler(Channel backend, ProxyMetrics metrics, String serverName) {
|
||||
this(backend, metrics, serverName, new MinecraftCompressionAuditState(NetworkTuning.defaults().maxFrameBytes()));
|
||||
}
|
||||
|
||||
FrontendRelayHandler(Channel backend, ProxyMetrics metrics, String serverName, MinecraftCompressionAuditState compressionAudit) {
|
||||
this(backend, metrics, serverName, compressionAudit, CompressionRuntime.defaults());
|
||||
}
|
||||
|
||||
FrontendRelayHandler(
|
||||
Channel backend,
|
||||
ProxyMetrics metrics,
|
||||
String serverName,
|
||||
MinecraftCompressionAuditState compressionAudit,
|
||||
CompressionRuntime compressionRuntime) {
|
||||
this(backend, metrics, serverName, compressionAudit, compressionRuntime, NetworkTuning.defaults().maxFrameBytes());
|
||||
}
|
||||
|
||||
FrontendRelayHandler(
|
||||
Channel backend,
|
||||
ProxyMetrics metrics,
|
||||
String serverName,
|
||||
MinecraftCompressionAuditState compressionAudit,
|
||||
CompressionRuntime compressionRuntime,
|
||||
int maxFrameBytes) {
|
||||
this(backend, metrics, serverName, compressionAudit, compressionRuntime, maxFrameBytes, CustomPayloadAnomalyPolicy.defaults());
|
||||
}
|
||||
|
||||
FrontendRelayHandler(
|
||||
Channel backend,
|
||||
ProxyMetrics metrics,
|
||||
String serverName,
|
||||
MinecraftCompressionAuditState compressionAudit,
|
||||
CompressionRuntime compressionRuntime,
|
||||
int maxFrameBytes,
|
||||
CustomPayloadAnomalyPolicy customPayloadPolicy) {
|
||||
this(backend, metrics, serverName, compressionAudit, compressionRuntime, maxFrameBytes, customPayloadPolicy, null);
|
||||
}
|
||||
|
||||
FrontendRelayHandler(
|
||||
Channel backend,
|
||||
ProxyMetrics metrics,
|
||||
String serverName,
|
||||
MinecraftCompressionAuditState compressionAudit,
|
||||
CompressionRuntime compressionRuntime,
|
||||
int maxFrameBytes,
|
||||
CustomPayloadAnomalyPolicy customPayloadPolicy,
|
||||
String initialPlayerName) {
|
||||
this(backend, metrics, serverName, compressionAudit, compressionRuntime, maxFrameBytes, customPayloadPolicy, initialPlayerName, new RelaySessionIdentity(""));
|
||||
}
|
||||
|
||||
FrontendRelayHandler(
|
||||
Channel backend,
|
||||
ProxyMetrics metrics,
|
||||
String serverName,
|
||||
MinecraftCompressionAuditState compressionAudit,
|
||||
CompressionRuntime compressionRuntime,
|
||||
int maxFrameBytes,
|
||||
CustomPayloadAnomalyPolicy customPayloadPolicy,
|
||||
String initialPlayerName,
|
||||
RelaySessionIdentity identity) {
|
||||
this(backend, metrics, serverName, compressionAudit, compressionRuntime, maxFrameBytes, customPayloadPolicy, initialPlayerName, identity, false);
|
||||
}
|
||||
|
||||
FrontendRelayHandler(
|
||||
Channel backend,
|
||||
ProxyMetrics metrics,
|
||||
String serverName,
|
||||
MinecraftCompressionAuditState compressionAudit,
|
||||
CompressionRuntime compressionRuntime,
|
||||
int maxFrameBytes,
|
||||
CustomPayloadAnomalyPolicy customPayloadPolicy,
|
||||
String initialPlayerName,
|
||||
RelaySessionIdentity identity,
|
||||
boolean compressionRewriteEnabled) {
|
||||
this(backend, metrics, serverName, compressionAudit, compressionRuntime, maxFrameBytes, customPayloadPolicy, initialPlayerName, identity, compressionRewriteEnabled, 25);
|
||||
}
|
||||
|
||||
FrontendRelayHandler(
|
||||
Channel backend,
|
||||
ProxyMetrics metrics,
|
||||
String serverName,
|
||||
MinecraftCompressionAuditState compressionAudit,
|
||||
CompressionRuntime compressionRuntime,
|
||||
int maxFrameBytes,
|
||||
CustomPayloadAnomalyPolicy customPayloadPolicy,
|
||||
String initialPlayerName,
|
||||
RelaySessionIdentity identity,
|
||||
boolean compressionRewriteEnabled,
|
||||
int compressionRewriteMaxEventLoopDelayMillis) {
|
||||
this.backend = backend;
|
||||
this.metrics = metrics;
|
||||
this.serverName = serverName;
|
||||
this.compressionAudit = compressionAudit;
|
||||
this.compressionRuntime = compressionRuntime;
|
||||
this.maxFrameBytes = maxFrameBytes;
|
||||
this.customPayloadPolicy = customPayloadPolicy;
|
||||
this.customPayloadInspection = new MinecraftCustomPayloadInspectionSampler(
|
||||
maxFrameBytes,
|
||||
Math.min(maxFrameBytes, customPayloadPolicy.largePayloadWarnBytes()),
|
||||
customPayloadPolicy);
|
||||
this.packetTrafficSampler = new MinecraftPacketTrafficSampler(maxFrameBytes);
|
||||
this.loginStartSampler = new MinecraftLoginStartSampler(maxFrameBytes);
|
||||
this.identity = identity;
|
||||
this.compressionRewriteRuntime = new CompressionRewriteRuntime(compressionRewriteEnabled, compressionRewriteMaxEventLoopDelayMillis);
|
||||
this.playerName = initialPlayerName;
|
||||
if (initialPlayerName != null && !initialPlayerName.isBlank()) {
|
||||
this.identity.playerName(initialPlayerName);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void channelRead(ChannelHandlerContext context, Object message) {
|
||||
if (!backend.isActive()) {
|
||||
ReferenceCountUtil.release(message);
|
||||
context.close();
|
||||
return;
|
||||
}
|
||||
var outbound = message;
|
||||
var forward = true;
|
||||
List<CompressionAction> compressionActions = List.of();
|
||||
if (message instanceof ByteBuf buffer) {
|
||||
metrics.frontendToBackendBytes(serverName, buffer.readableBytes());
|
||||
capturePayloadPrefix(buffer, CompressionDirection.FRONTEND_TO_BACKEND);
|
||||
observeLoginStart(context, buffer);
|
||||
observePacketTraffic(buffer);
|
||||
var compression = observeCompressionFrames(buffer);
|
||||
forward = observeCustomPayload(context, buffer) && compression.shouldForward();
|
||||
compressionActions = compression.actions();
|
||||
if (forward && compression.rewriteEligible()) {
|
||||
var rewrite = compressionRewriteRuntime.rewrite(
|
||||
context.alloc(),
|
||||
metrics,
|
||||
serverName,
|
||||
CompressionDirection.FRONTEND_TO_BACKEND,
|
||||
buffer,
|
||||
compressionAudit.threshold(),
|
||||
compressionActions,
|
||||
maxFrameBytes);
|
||||
if (rewrite.suppressed()) {
|
||||
ReferenceCountUtil.release(message);
|
||||
context.channel().read();
|
||||
return;
|
||||
}
|
||||
if (rewrite.replaced()) {
|
||||
ReferenceCountUtil.release(message);
|
||||
outbound = rewrite.frame();
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!forward) {
|
||||
ReferenceCountUtil.release(outbound);
|
||||
context.close();
|
||||
closeBackend();
|
||||
return;
|
||||
}
|
||||
recordBackpressureIfNeeded(backend, CompressionDirection.FRONTEND_TO_BACKEND);
|
||||
backend.writeAndFlush(outbound).addListener((ChannelFutureListener) future -> {
|
||||
if (future.isSuccess()) {
|
||||
context.channel().read();
|
||||
} else {
|
||||
future.channel().close();
|
||||
context.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void channelInactive(ChannelHandlerContext context) {
|
||||
closeFrontendSide();
|
||||
closeBackend();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void exceptionCaught(ChannelHandlerContext context, Throwable cause) {
|
||||
context.close();
|
||||
closeFrontendSide();
|
||||
closeBackend();
|
||||
}
|
||||
|
||||
private void closeFrontendSide() {
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
closed = true;
|
||||
compressionAudit.closeFrontendSampler();
|
||||
customPayloadInspection.close();
|
||||
loginStartSampler.close();
|
||||
if (compressedCustomPayloadInspection != null) {
|
||||
compressedCustomPayloadInspection.close();
|
||||
}
|
||||
packetTrafficSampler.close();
|
||||
compressionRewriteRuntime.close();
|
||||
metrics.playerSessionClosed(playerName);
|
||||
metrics.serverConnectionClosed(serverName);
|
||||
}
|
||||
|
||||
private void closeBackend() {
|
||||
if (backend.isOpen()) {
|
||||
backend.close();
|
||||
}
|
||||
}
|
||||
|
||||
private CompressionObservation observeCompressionFrames(ByteBuf buffer) {
|
||||
if (!compressionAudit.negotiated()) {
|
||||
return CompressionObservation.allow();
|
||||
}
|
||||
try {
|
||||
var actions = new ArrayList<CompressionAction>();
|
||||
for (var sample : compressionAudit.observeFrontend(buffer)) {
|
||||
metrics.compressionSample(
|
||||
serverName,
|
||||
CompressionDirection.FRONTEND_TO_BACKEND,
|
||||
sample.rawBytes(),
|
||||
sample.compressedBytes(),
|
||||
0);
|
||||
actions.add(compressionRuntime.recordDecision(
|
||||
metrics,
|
||||
serverName,
|
||||
CompressionDirection.FRONTEND_TO_BACKEND,
|
||||
sample.rawBytes(),
|
||||
ratio(sample),
|
||||
0));
|
||||
}
|
||||
return new CompressionObservation(true, true, List.copyOf(actions));
|
||||
} catch (RuntimeException exception) {
|
||||
metrics.packetAnomaly(
|
||||
RULE_COMPRESSION_AUDIT_MALFORMED,
|
||||
"",
|
||||
serverName,
|
||||
CompressionDirection.FRONTEND_TO_BACKEND.label(),
|
||||
"PLAY",
|
||||
-1,
|
||||
buffer.readableBytes(),
|
||||
-1,
|
||||
exception.getMessage());
|
||||
compressionAudit.closeFrontendSampler();
|
||||
return CompressionObservation.block();
|
||||
}
|
||||
}
|
||||
|
||||
private boolean observeCustomPayload(ChannelHandlerContext context, ByteBuf buffer) {
|
||||
if (compressionAudit.negotiated()) {
|
||||
customPayloadInspection.close();
|
||||
return observeCompressedCustomPayload(context, buffer);
|
||||
}
|
||||
try {
|
||||
for (var result : customPayloadInspection.observe(buffer)) {
|
||||
recordCustomPayload(result.classification(), -1);
|
||||
for (var anomaly : result.anomalies()) {
|
||||
recordCustomPayloadAnomaly(context, result.classification(), anomaly, -1);
|
||||
if (anomaly.action() == AnomalyAction.THROTTLE) {
|
||||
customPayloadInspection.close();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
} catch (RuntimeException exception) {
|
||||
metrics.packetAnomaly(
|
||||
"custom-payload-inspection-malformed",
|
||||
remoteAddress(context),
|
||||
serverName,
|
||||
CompressionDirection.FRONTEND_TO_BACKEND.label(),
|
||||
"CONFIGURATION",
|
||||
-1,
|
||||
buffer.readableBytes(),
|
||||
-1,
|
||||
exception.getMessage());
|
||||
customPayloadInspection.close();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private boolean observeCompressedCustomPayload(ChannelHandlerContext context, ByteBuf buffer) {
|
||||
if (compressedCustomPayloadInspection == null) {
|
||||
compressedCustomPayloadInspection = new MinecraftCompressedCustomPayloadInspectionSampler(
|
||||
compressionAudit.threshold(),
|
||||
maxFrameBytes,
|
||||
Math.min(maxFrameBytes, customPayloadPolicy.largePayloadWarnBytes()),
|
||||
customPayloadPolicy);
|
||||
}
|
||||
try {
|
||||
for (var result : compressedCustomPayloadInspection.observe(buffer)) {
|
||||
recordCustomPayload(result.classification(), result.compressedBytes());
|
||||
for (var anomaly : result.anomalies()) {
|
||||
recordCustomPayloadAnomaly(context, result.classification(), anomaly, result.compressedBytes());
|
||||
if (anomaly.action() == AnomalyAction.THROTTLE) {
|
||||
compressedCustomPayloadInspection.close();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
} catch (RuntimeException exception) {
|
||||
metrics.packetAnomaly(
|
||||
"compressed-custom-payload-inspection-malformed",
|
||||
remoteAddress(context),
|
||||
serverName,
|
||||
CompressionDirection.FRONTEND_TO_BACKEND.label(),
|
||||
"CONFIGURATION",
|
||||
-1,
|
||||
buffer.readableBytes(),
|
||||
-1,
|
||||
exception.getMessage());
|
||||
compressedCustomPayloadInspection.close();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private void observePacketTraffic(ByteBuf buffer) {
|
||||
if (compressionAudit.negotiated()) {
|
||||
packetTrafficSampler.close();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
for (var sample : packetTrafficSampler.observe(buffer)) {
|
||||
metrics.packetTraffic(
|
||||
serverName,
|
||||
CompressionDirection.FRONTEND_TO_BACKEND,
|
||||
"UNCOMPRESSED",
|
||||
sample.packetId(),
|
||||
sample.frameBytes(),
|
||||
0);
|
||||
}
|
||||
} catch (RuntimeException exception) {
|
||||
packetTrafficSampler.close();
|
||||
}
|
||||
}
|
||||
|
||||
private void observeLoginStart(ChannelHandlerContext context, ByteBuf buffer) {
|
||||
if (playerName != null || compressionAudit.negotiated()) {
|
||||
loginStartSampler.close();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
var username = loginStartSampler.observe(buffer);
|
||||
if (username.isPresent()) {
|
||||
playerName = username.get();
|
||||
identity.playerName(playerName);
|
||||
metrics.playerSessionStarted(playerName, serverName, remoteAddress(context));
|
||||
}
|
||||
} catch (RuntimeException exception) {
|
||||
loginStartSampler.close();
|
||||
}
|
||||
}
|
||||
|
||||
private void recordCustomPayloadAnomaly(
|
||||
ChannelHandlerContext context,
|
||||
dev.strataproxy.codec.minecraft.MinecraftCustomPayloadClassifier.CustomPayloadClassification classification,
|
||||
PacketAnomaly anomaly,
|
||||
long compressedSize) {
|
||||
metrics.packetAnomaly(
|
||||
anomaly.ruleId(),
|
||||
remoteAddress(context),
|
||||
serverName,
|
||||
CompressionDirection.FRONTEND_TO_BACKEND.label(),
|
||||
"CONFIGURATION",
|
||||
classification.packetId(),
|
||||
anomaly.packet().rawSize(),
|
||||
compressedSize,
|
||||
classification.channel() + " kind=" + classification.kind() + " action=" + anomaly.action());
|
||||
}
|
||||
|
||||
private void recordCustomPayload(
|
||||
dev.strataproxy.codec.minecraft.MinecraftCustomPayloadClassifier.CustomPayloadClassification classification,
|
||||
long compressedSize) {
|
||||
metrics.customPayload(
|
||||
serverName,
|
||||
CompressionDirection.FRONTEND_TO_BACKEND,
|
||||
classification.kind().name(),
|
||||
classification.channel(),
|
||||
classification.payloadBytes(),
|
||||
Math.max(0, compressedSize));
|
||||
}
|
||||
|
||||
private void recordBackpressureIfNeeded(Channel target, CompressionDirection direction) {
|
||||
if (!target.isWritable()) {
|
||||
metrics.relayBackpressure(serverName, direction, target.bytesBeforeWritable());
|
||||
}
|
||||
}
|
||||
|
||||
private void capturePayloadPrefix(ByteBuf buffer, CompressionDirection direction) {
|
||||
var request = metrics.payloadCaptureRequest(serverName, direction);
|
||||
if (!request.enabled()) {
|
||||
return;
|
||||
}
|
||||
var bytes = new byte[Math.min(buffer.readableBytes(), request.maxBytesPerSample())];
|
||||
buffer.getBytes(buffer.readerIndex(), bytes);
|
||||
metrics.payloadCaptured(
|
||||
serverName,
|
||||
direction,
|
||||
buffer.readableBytes(),
|
||||
compressionAudit.negotiated() ? buffer.readableBytes() : -1,
|
||||
bytes,
|
||||
identity.playerName(),
|
||||
identity.remoteAddress());
|
||||
}
|
||||
|
||||
private static String remoteAddress(ChannelHandlerContext context) {
|
||||
var address = context.channel().remoteAddress();
|
||||
return address == null ? "" : address.toString();
|
||||
}
|
||||
|
||||
private static double ratio(MinecraftCompressedFrameAuditSampler.CompressionFrameSample sample) {
|
||||
return sample.rawBytes() == 0 ? 1.0d : (double) sample.compressedBytes() / sample.rawBytes();
|
||||
}
|
||||
|
||||
private record CompressionObservation(boolean shouldForward, boolean rewriteEligible, List<CompressionAction> actions) {
|
||||
private static CompressionObservation allow() {
|
||||
return new CompressionObservation(true, false, List.of());
|
||||
}
|
||||
|
||||
private static CompressionObservation block() {
|
||||
return new CompressionObservation(false, false, List.of());
|
||||
}
|
||||
}
|
||||
}
|
||||
+320
@@ -0,0 +1,320 @@
|
||||
package dev.strataproxy.network;
|
||||
|
||||
import dev.strataproxy.api.server.RegisteredServer;
|
||||
import dev.strataproxy.analysis.CustomPayloadAnomalyPolicy;
|
||||
import dev.strataproxy.observability.ProxyMetrics;
|
||||
import dev.strataproxy.observability.ProxyMetrics.CompressionDirection;
|
||||
import io.netty.bootstrap.Bootstrap;
|
||||
import io.netty.buffer.ByteBuf;
|
||||
import io.netty.buffer.PooledByteBufAllocator;
|
||||
import io.netty.channel.ChannelFutureListener;
|
||||
import io.netty.channel.ChannelHandlerContext;
|
||||
import io.netty.channel.ChannelInitializer;
|
||||
import io.netty.channel.ChannelOption;
|
||||
import io.netty.channel.WriteBufferWaterMark;
|
||||
import io.netty.handler.codec.ByteToMessageDecoder;
|
||||
|
||||
import java.net.SocketAddress;
|
||||
import java.util.List;
|
||||
|
||||
final class InitialHandshakeRouteHandler extends ByteToMessageDecoder {
|
||||
private static final String RULE_MALFORMED_FRAME = "initial-handshake-malformed-frame";
|
||||
private static final String RULE_MALFORMED_HANDSHAKE = "initial-handshake-malformed-packet";
|
||||
private static final String RULE_NO_ROUTE = "initial-handshake-no-route";
|
||||
private static final String RULE_PENDING_TOO_LARGE = "initial-handshake-pending-too-large";
|
||||
|
||||
private final BackendResolver backendResolver;
|
||||
private final ProxyMetrics metrics;
|
||||
private final NetworkTuning tuning;
|
||||
private final Class<? extends io.netty.channel.Channel> backendChannel;
|
||||
private final CompressionRuntime compressionRuntime;
|
||||
private final CustomPayloadAnomalyPolicy customPayloadPolicy;
|
||||
private final boolean compressionRewriteEnabled;
|
||||
private final int compressionRewriteMaxEventLoopDelayMillis;
|
||||
private boolean terminal;
|
||||
|
||||
InitialHandshakeRouteHandler(BackendResolver backendResolver, ProxyMetrics metrics, NetworkTuning tuning) {
|
||||
this(backendResolver, metrics, tuning, io.netty.channel.socket.nio.NioSocketChannel.class, CompressionRuntime.defaults(), CustomPayloadAnomalyPolicy.defaults());
|
||||
}
|
||||
|
||||
InitialHandshakeRouteHandler(
|
||||
BackendResolver backendResolver,
|
||||
ProxyMetrics metrics,
|
||||
NetworkTuning tuning,
|
||||
Class<? extends io.netty.channel.Channel> backendChannel,
|
||||
CompressionRuntime compressionRuntime,
|
||||
CustomPayloadAnomalyPolicy customPayloadPolicy) {
|
||||
this(backendResolver, metrics, tuning, backendChannel, compressionRuntime, customPayloadPolicy, false);
|
||||
}
|
||||
|
||||
InitialHandshakeRouteHandler(
|
||||
BackendResolver backendResolver,
|
||||
ProxyMetrics metrics,
|
||||
NetworkTuning tuning,
|
||||
Class<? extends io.netty.channel.Channel> backendChannel,
|
||||
CompressionRuntime compressionRuntime,
|
||||
CustomPayloadAnomalyPolicy customPayloadPolicy,
|
||||
boolean compressionRewriteEnabled) {
|
||||
this(backendResolver, metrics, tuning, backendChannel, compressionRuntime, customPayloadPolicy, compressionRewriteEnabled, 25);
|
||||
}
|
||||
|
||||
InitialHandshakeRouteHandler(
|
||||
BackendResolver backendResolver,
|
||||
ProxyMetrics metrics,
|
||||
NetworkTuning tuning,
|
||||
Class<? extends io.netty.channel.Channel> backendChannel,
|
||||
CompressionRuntime compressionRuntime,
|
||||
CustomPayloadAnomalyPolicy customPayloadPolicy,
|
||||
boolean compressionRewriteEnabled,
|
||||
int compressionRewriteMaxEventLoopDelayMillis) {
|
||||
this.backendResolver = backendResolver;
|
||||
this.metrics = metrics;
|
||||
this.tuning = tuning;
|
||||
this.backendChannel = backendChannel;
|
||||
this.compressionRuntime = compressionRuntime;
|
||||
this.customPayloadPolicy = customPayloadPolicy;
|
||||
this.compressionRewriteEnabled = compressionRewriteEnabled;
|
||||
this.compressionRewriteMaxEventLoopDelayMillis = compressionRewriteMaxEventLoopDelayMillis;
|
||||
}
|
||||
|
||||
InitialHandshakeRouteHandler(
|
||||
BackendResolver backendResolver,
|
||||
ProxyMetrics metrics,
|
||||
NetworkTuning tuning,
|
||||
Class<? extends io.netty.channel.Channel> backendChannel,
|
||||
CompressionRuntime compressionRuntime) {
|
||||
this(backendResolver, metrics, tuning, backendChannel, compressionRuntime, CustomPayloadAnomalyPolicy.defaults());
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void decode(ChannelHandlerContext context, ByteBuf input, List<Object> output) {
|
||||
if (terminal) {
|
||||
return;
|
||||
}
|
||||
MinecraftProtocolCodec.FrameProbe probe;
|
||||
try {
|
||||
probe = MinecraftProtocolCodec.probeFrame(input, tuning.maxFrameBytes());
|
||||
} catch (RuntimeException exception) {
|
||||
metrics.packetAnomaly(
|
||||
RULE_MALFORMED_FRAME,
|
||||
remoteAddress(context.channel().remoteAddress()),
|
||||
"",
|
||||
"frontend_to_backend",
|
||||
"HANDSHAKE",
|
||||
-1,
|
||||
input.readableBytes(),
|
||||
-1,
|
||||
exception.getMessage());
|
||||
metrics.failedRoute();
|
||||
terminal = true;
|
||||
context.close();
|
||||
return;
|
||||
}
|
||||
if (!probe.complete()) {
|
||||
return;
|
||||
}
|
||||
|
||||
var firstFrame = input.readRetainedSlice(probe.totalBytes());
|
||||
var frontend = context.channel();
|
||||
frontend.config().setAutoRead(false);
|
||||
|
||||
MinecraftHandshake handshake;
|
||||
RegisteredServer selected;
|
||||
try {
|
||||
handshake = MinecraftProtocolCodec.readHandshake(firstFrame, probe);
|
||||
selected = backendResolver.resolve(handshake, frontend.remoteAddress()).orElse(null);
|
||||
} catch (RuntimeException exception) {
|
||||
metrics.packetAnomaly(
|
||||
RULE_MALFORMED_HANDSHAKE,
|
||||
remoteAddress(frontend.remoteAddress()),
|
||||
"",
|
||||
"frontend_to_backend",
|
||||
"HANDSHAKE",
|
||||
0,
|
||||
firstFrame.readableBytes(),
|
||||
-1,
|
||||
exception.getMessage());
|
||||
metrics.failedRoute();
|
||||
terminal = true;
|
||||
firstFrame.release();
|
||||
context.close();
|
||||
return;
|
||||
}
|
||||
|
||||
if (selected == null) {
|
||||
metrics.packetAnomaly(
|
||||
RULE_NO_ROUTE,
|
||||
remoteAddress(frontend.remoteAddress()),
|
||||
"",
|
||||
"frontend_to_backend",
|
||||
"HANDSHAKE",
|
||||
0,
|
||||
firstFrame.readableBytes(),
|
||||
-1,
|
||||
handshake.requestedHost());
|
||||
metrics.failedRoute();
|
||||
terminal = true;
|
||||
firstFrame.release();
|
||||
context.close();
|
||||
return;
|
||||
}
|
||||
|
||||
if (input.readableBytes() > tuning.maxFrameBytes()) {
|
||||
metrics.packetAnomaly(
|
||||
RULE_PENDING_TOO_LARGE,
|
||||
remoteAddress(frontend.remoteAddress()),
|
||||
selected.descriptor().name(),
|
||||
"frontend_to_backend",
|
||||
"HANDSHAKE",
|
||||
0,
|
||||
input.readableBytes(),
|
||||
-1,
|
||||
"pending bytes exceeded maximum before backend route");
|
||||
metrics.failedRoute();
|
||||
terminal = true;
|
||||
firstFrame.release();
|
||||
context.close();
|
||||
return;
|
||||
}
|
||||
|
||||
var pendingBytes = input.isReadable() ? input.readRetainedSlice(input.readableBytes()) : null;
|
||||
connectBackend(context, firstFrame, pendingBytes, selected);
|
||||
}
|
||||
|
||||
private void connectBackend(
|
||||
ChannelHandlerContext frontendContext,
|
||||
ByteBuf firstFrame,
|
||||
ByteBuf pendingBytes,
|
||||
RegisteredServer selected) {
|
||||
var frontend = frontendContext.channel();
|
||||
var serverName = selected.descriptor().name();
|
||||
var compressionAudit = new MinecraftCompressionAuditState(tuning.maxFrameBytes());
|
||||
var identity = new RelaySessionIdentity(remoteAddress(frontend.remoteAddress()));
|
||||
var bootstrap = new Bootstrap()
|
||||
.group(frontend.eventLoop())
|
||||
.channel(backendChannel)
|
||||
.option(ChannelOption.ALLOCATOR, PooledByteBufAllocator.DEFAULT)
|
||||
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, tuning.connectTimeoutMillis())
|
||||
.option(ChannelOption.TCP_NODELAY, true)
|
||||
.option(ChannelOption.SO_KEEPALIVE, true)
|
||||
.option(ChannelOption.AUTO_READ, false)
|
||||
.option(ChannelOption.WRITE_BUFFER_WATER_MARK, new WriteBufferWaterMark(
|
||||
tuning.writeBufferLowBytes(),
|
||||
tuning.writeBufferHighBytes()))
|
||||
.handler(new ChannelInitializer<io.netty.channel.Channel>() {
|
||||
@Override
|
||||
protected void initChannel(io.netty.channel.Channel backend) {
|
||||
backend.pipeline().addLast("backend-relay", new BackendRelayHandler(
|
||||
frontend,
|
||||
metrics,
|
||||
serverName,
|
||||
tuning.maxFrameBytes(),
|
||||
compressionAudit,
|
||||
compressionRuntime,
|
||||
identity,
|
||||
compressionRewriteEnabled,
|
||||
compressionRewriteMaxEventLoopDelayMillis));
|
||||
}
|
||||
});
|
||||
|
||||
bootstrap.connect(selected.descriptor().address()).addListener((ChannelFutureListener) future -> {
|
||||
if (!future.isSuccess()) {
|
||||
metrics.backendConnectFailure();
|
||||
release(firstFrame);
|
||||
release(pendingBytes);
|
||||
frontend.close();
|
||||
return;
|
||||
}
|
||||
|
||||
var backend = future.channel();
|
||||
metrics.routedConnection();
|
||||
metrics.serverConnectionOpened(serverName);
|
||||
metrics.frontendToBackendBytes(serverName, firstFrame.readableBytes());
|
||||
if (pendingBytes != null) {
|
||||
metrics.frontendToBackendBytes(serverName, pendingBytes.readableBytes());
|
||||
}
|
||||
recordInitialPacketTraffic(serverName, firstFrame, pendingBytes);
|
||||
var initialPlayerName = pendingBytes == null ? null : observeInitialLoginStart(frontend, serverName, pendingBytes);
|
||||
if (initialPlayerName != null && !initialPlayerName.isBlank()) {
|
||||
identity.playerName(initialPlayerName);
|
||||
}
|
||||
frontend.pipeline().remove("initial-handshake-timeout");
|
||||
frontend.pipeline().replace(this, "frontend-relay", new FrontendRelayHandler(
|
||||
backend,
|
||||
metrics,
|
||||
serverName,
|
||||
compressionAudit,
|
||||
compressionRuntime,
|
||||
tuning.maxFrameBytes(),
|
||||
customPayloadPolicy,
|
||||
initialPlayerName,
|
||||
identity,
|
||||
compressionRewriteEnabled,
|
||||
compressionRewriteMaxEventLoopDelayMillis));
|
||||
backend.write(firstFrame);
|
||||
if (pendingBytes != null) {
|
||||
backend.write(pendingBytes);
|
||||
}
|
||||
backend.flush();
|
||||
frontend.config().setAutoRead(false);
|
||||
backend.config().setAutoRead(false);
|
||||
frontend.read();
|
||||
backend.read();
|
||||
});
|
||||
}
|
||||
|
||||
private void recordInitialPacketTraffic(String serverName, ByteBuf firstFrame, ByteBuf pendingBytes) {
|
||||
metrics.packetTraffic(
|
||||
serverName,
|
||||
CompressionDirection.FRONTEND_TO_BACKEND,
|
||||
"HANDSHAKE",
|
||||
0,
|
||||
firstFrame.readableBytes(),
|
||||
0);
|
||||
if (pendingBytes == null || !pendingBytes.isReadable()) {
|
||||
return;
|
||||
}
|
||||
var sampler = new MinecraftPacketTrafficSampler(tuning.maxFrameBytes());
|
||||
try {
|
||||
for (var sample : sampler.observe(pendingBytes)) {
|
||||
metrics.packetTraffic(
|
||||
serverName,
|
||||
CompressionDirection.FRONTEND_TO_BACKEND,
|
||||
"UNCOMPRESSED",
|
||||
sample.packetId(),
|
||||
sample.frameBytes(),
|
||||
0);
|
||||
}
|
||||
} catch (RuntimeException ignored) {
|
||||
// Traffic attribution must never block the initial fast-forward path.
|
||||
} finally {
|
||||
sampler.close();
|
||||
}
|
||||
}
|
||||
|
||||
private String observeInitialLoginStart(io.netty.channel.Channel frontend, String serverName, ByteBuf pendingBytes) {
|
||||
var sampler = new MinecraftLoginStartSampler(tuning.maxFrameBytes());
|
||||
try {
|
||||
var username = sampler.observe(pendingBytes);
|
||||
if (username.isPresent()) {
|
||||
metrics.playerSessionStarted(username.get(), serverName, remoteAddress(frontend.remoteAddress()));
|
||||
return username.get();
|
||||
}
|
||||
} catch (RuntimeException ignored) {
|
||||
// Player attribution must never block the initial fast-forward path.
|
||||
} finally {
|
||||
sampler.close();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static void release(ByteBuf buffer) {
|
||||
if (buffer != null) {
|
||||
buffer.release();
|
||||
}
|
||||
}
|
||||
|
||||
private static String remoteAddress(SocketAddress address) {
|
||||
return address == null ? "" : address.toString();
|
||||
}
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
package dev.strataproxy.network;
|
||||
|
||||
import dev.strataproxy.observability.ProxyMetrics;
|
||||
import io.netty.channel.ChannelHandlerContext;
|
||||
import io.netty.handler.timeout.ReadTimeoutHandler;
|
||||
import io.netty.handler.timeout.ReadTimeoutException;
|
||||
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
final class InitialHandshakeTimeoutHandler extends ReadTimeoutHandler {
|
||||
private final ProxyMetrics metrics;
|
||||
|
||||
InitialHandshakeTimeoutHandler(int timeoutMillis, ProxyMetrics metrics) {
|
||||
super(timeoutMillis, TimeUnit.MILLISECONDS);
|
||||
this.metrics = metrics;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void readTimedOut(ChannelHandlerContext context) {
|
||||
metrics.handshakeTimeout();
|
||||
context.close();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void exceptionCaught(ChannelHandlerContext context, Throwable cause) throws Exception {
|
||||
if (cause instanceof ReadTimeoutException) {
|
||||
metrics.handshakeTimeout();
|
||||
context.close();
|
||||
return;
|
||||
}
|
||||
super.exceptionCaught(context, cause);
|
||||
}
|
||||
}
|
||||
Loaded 100 of 166 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user