commit 648fd7601224eacf96a5053388e2c8a2ec021080 Author: layue13 Date: Tue Jun 30 17:28:50 2026 +0800 Initial StrataProxy implementation diff --git a/README.md b/README.md new file mode 100644 index 0000000..3120306 --- /dev/null +++ b/README.md @@ -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-.sbom.cdx.json`, and `strataproxy-.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-` 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 +``` + +`--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. diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..2b51391 --- /dev/null +++ b/build.gradle.kts @@ -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("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("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("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("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("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("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("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("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 { + 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 + + @get:Input + abstract val versionValue: Property + + @get:Input + abstract val appArchive: Property + + @get:Input + abstract val adminArchive: Property + + @get:Input + abstract val queryArchive: Property + + @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 + + @get:Input + abstract val components: ListProperty + + @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 + + @get:Input + abstract val versionValue: Property + + @get:Input + abstract val signingKey: Property + + @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() + 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" } + } +} diff --git a/deployment/README.md b/deployment/README.md new file mode 100644 index 0000000..a16465c --- /dev/null +++ b/deployment/README.md @@ -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. diff --git a/deployment/container/Containerfile b/deployment/container/Containerfile new file mode 100644 index 0000000..b2f2b20 --- /dev/null +++ b/deployment/container/Containerfile @@ -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"] diff --git a/deployment/observability/grafana/README.md b/deployment/observability/grafana/README.md new file mode 100644 index 0000000..2760e89 --- /dev/null +++ b/deployment/observability/grafana/README.md @@ -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. diff --git a/deployment/observability/grafana/strataproxy-overview.json b/deployment/observability/grafana/strataproxy-overview.json new file mode 100644 index 0000000..ee89df7 --- /dev/null +++ b/deployment/observability/grafana/strataproxy-overview.json @@ -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"} + ] + } + ] +} diff --git a/deployment/observability/prometheus/README.md b/deployment/observability/prometheus/README.md new file mode 100644 index 0000000..047b45b --- /dev/null +++ b/deployment/observability/prometheus/README.md @@ -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. diff --git a/deployment/observability/prometheus/strataproxy-alerts.yml b/deployment/observability/prometheus/strataproxy-alerts.yml new file mode 100644 index 0000000..3cb4c6b --- /dev/null +++ b/deployment/observability/prometheus/strataproxy-alerts.yml @@ -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." diff --git a/deployment/performance/README.md b/deployment/performance/README.md new file mode 100644 index 0000000..3c5ecb2 --- /dev/null +++ b/deployment/performance/README.md @@ -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`. diff --git a/deployment/performance/profile-result-template.json b/deployment/performance/profile-result-template.json new file mode 100644 index 0000000..89c6052 --- /dev/null +++ b/deployment/performance/profile-result-template.json @@ -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": "" +} diff --git a/deployment/performance/profiles/acceptance-linux-native-java25.json b/deployment/performance/profiles/acceptance-linux-native-java25.json new file mode 100644 index 0000000..1d0f6ce --- /dev/null +++ b/deployment/performance/profiles/acceptance-linux-native-java25.json @@ -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 + } +} diff --git a/deployment/performance/profiles/compression-rewrite-linux-native-java25.json b/deployment/performance/profiles/compression-rewrite-linux-native-java25.json new file mode 100644 index 0000000..fa16032 --- /dev/null +++ b/deployment/performance/profiles/compression-rewrite-linux-native-java25.json @@ -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 + } +} diff --git a/deployment/performance/profiles/smoke.json b/deployment/performance/profiles/smoke.json new file mode 100644 index 0000000..a5dc828 --- /dev/null +++ b/deployment/performance/profiles/smoke.json @@ -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 + } +} diff --git a/deployment/systemd/strataproxy.env b/deployment/systemd/strataproxy.env new file mode 100644 index 0000000..76c87df --- /dev/null +++ b/deployment/systemd/strataproxy.env @@ -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" diff --git a/deployment/systemd/strataproxy.service b/deployment/systemd/strataproxy.service new file mode 100644 index 0000000..e292231 --- /dev/null +++ b/deployment/systemd/strataproxy.service @@ -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 diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..984e619 --- /dev/null +++ b/gradle.properties @@ -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 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..7d37e95 --- /dev/null +++ b/gradle/libs.versions.toml @@ -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" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..b1b8ef5 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..a9db115 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -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 diff --git a/gradlew b/gradlew new file mode 100644 index 0000000..249efbb --- /dev/null +++ b/gradlew @@ -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" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..a51ec4f --- /dev/null +++ b/gradlew.bat @@ -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% diff --git a/proxy-admin-api/build.gradle.kts b/proxy-admin-api/build.gradle.kts new file mode 100644 index 0000000..8f7b5e7 --- /dev/null +++ b/proxy-admin-api/build.gradle.kts @@ -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")) +} diff --git a/proxy-admin-api/src/main/java/dev/strataproxy/admin/AdminHttpServer.java b/proxy-admin-api/src/main/java/dev/strataproxy/admin/AdminHttpServer.java new file mode 100644 index 0000000..24f9d3c --- /dev/null +++ b/proxy-admin-api/src/main/java/dev/strataproxy/admin/AdminHttpServer.java @@ -0,0 +1,1591 @@ +package dev.strataproxy.admin; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.JsonMappingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import com.sun.net.httpserver.HttpsConfigurator; +import com.sun.net.httpserver.HttpsParameters; +import com.sun.net.httpserver.HttpsServer; +import dev.strataproxy.api.server.DrainPolicy; +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.api.server.ServerHealth; +import dev.strataproxy.api.server.ServerHealthStatus; +import dev.strataproxy.api.server.ServerLoad; +import dev.strataproxy.api.server.ServerRegistry; +import dev.strataproxy.observability.ProxyMetrics; +import dev.strataproxy.routing.RoutingDecision; +import dev.strataproxy.routing.RoutingRequest; +import dev.strataproxy.routing.WeightedHealthAwareRouter; + +import java.io.IOException; +import java.lang.management.ManagementFactory; +import java.net.InetSocketAddress; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.Instant; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.Base64; +import java.util.UUID; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.stream.Collectors; +import javax.net.ssl.SSLContext; + +public final class AdminHttpServer implements AutoCloseable { + private static final String JSON = "application/json; charset=utf-8"; + + private final ObjectMapper mapper = new ObjectMapper() + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + private final HttpServer server; + private final AdminRegistryService registry; + private final ProxyMetrics metrics; + private final String bearerToken; + private final int packetTopN; + private final boolean prometheusEnabled; + private final ExecutorService executor; + private final AtomicBoolean closed = new AtomicBoolean(); + + public AdminHttpServer(InetSocketAddress bindAddress, ServerRegistry registry, ProxyMetrics metrics) throws IOException { + this(bindAddress, registry, metrics, ""); + } + + public AdminHttpServer(InetSocketAddress bindAddress, ServerRegistry registry, ProxyMetrics metrics, String bearerToken) throws IOException { + this(bindAddress, new AdminRegistryService(registry, NoopRegistryStore.INSTANCE), metrics, bearerToken); + } + + public AdminHttpServer( + InetSocketAddress bindAddress, + ServerRegistry registry, + ProxyMetrics metrics, + String bearerToken, + int packetTopN) throws IOException { + this(bindAddress, new AdminRegistryService(registry, NoopRegistryStore.INSTANCE), metrics, bearerToken, packetTopN); + } + + public AdminHttpServer( + InetSocketAddress bindAddress, + ServerRegistry registry, + ProxyMetrics metrics, + String bearerToken, + int packetTopN, + boolean prometheusEnabled) throws IOException { + this(bindAddress, new AdminRegistryService(registry, NoopRegistryStore.INSTANCE), metrics, bearerToken, packetTopN, prometheusEnabled); + } + + public AdminHttpServer(InetSocketAddress bindAddress, AdminRegistryService registry, ProxyMetrics metrics, String bearerToken) throws IOException { + this(bindAddress, registry, metrics, bearerToken, 50); + } + + public AdminHttpServer( + InetSocketAddress bindAddress, + AdminRegistryService registry, + ProxyMetrics metrics, + String bearerToken, + int packetTopN) throws IOException { + this(bindAddress, registry, metrics, bearerToken, packetTopN, true); + } + + public AdminHttpServer( + InetSocketAddress bindAddress, + AdminRegistryService registry, + ProxyMetrics metrics, + String bearerToken, + int packetTopN, + boolean prometheusEnabled) throws IOException { + this(bindAddress, registry, metrics, bearerToken, packetTopN, prometheusEnabled, null, false); + } + + public AdminHttpServer( + InetSocketAddress bindAddress, + AdminRegistryService registry, + ProxyMetrics metrics, + String bearerToken, + int packetTopN, + boolean prometheusEnabled, + SSLContext sslContext, + boolean requireClientAuth) throws IOException { + this.registry = registry; + this.metrics = metrics; + this.bearerToken = bearerToken == null ? "" : bearerToken; + this.packetTopN = Math.max(0, packetTopN); + this.prometheusEnabled = prometheusEnabled; + this.executor = Executors.newVirtualThreadPerTaskExecutor(); + this.server = createServer(bindAddress, sslContext, requireClientAuth); + this.server.createContext("/healthz", this::health); + this.server.createContext("/readyz", this::ready); + this.server.createContext("/overview", this::overview); + this.server.createContext("/metrics", this::metrics); + this.server.createContext("/compression-report", this::compressionReport); + this.server.createContext("/diagnostic-report", this::diagnosticReport); + this.server.createContext("/packet-anomalies", this::packetAnomalies); + this.server.createContext("/packet-traffic", this::packetTraffic); + this.server.createContext("/custom-payloads", this::customPayloads); + this.server.createContext("/player-sessions", this::playerSessions); + this.server.createContext("/payload-captures", this::payloadCaptures); + this.server.createContext("/routes", this::routes); + this.server.createContext("/servers", this::servers); + this.server.setExecutor(executor); + } + + private static HttpServer createServer( + InetSocketAddress bindAddress, + SSLContext sslContext, + boolean requireClientAuth) throws IOException { + if (sslContext == null) { + return HttpServer.create(bindAddress, 128); + } + var https = HttpsServer.create(bindAddress, 128); + https.setHttpsConfigurator(new HttpsConfigurator(sslContext) { + @Override + public void configure(HttpsParameters parameters) { + var engine = getSSLContext().createSSLEngine(); + var sslParameters = getSSLContext().getDefaultSSLParameters(); + sslParameters.setNeedClientAuth(requireClientAuth); + sslParameters.setCipherSuites(engine.getEnabledCipherSuites()); + sslParameters.setProtocols(engine.getEnabledProtocols()); + parameters.setSSLParameters(sslParameters); + } + }); + return https; + } + + public void start() { + server.start(); + } + + public InetSocketAddress bindAddress() { + return server.getAddress(); + } + + @Override + public void close() { + if (!closed.compareAndSet(false, true)) { + return; + } + server.stop(1); + executor.shutdownNow(); + } + + private void health(HttpExchange exchange) throws IOException { + var body = "{\"status\":\"UP\",\"servers\":" + registry.snapshot().size() + ",\"timestamp\":\"" + Instant.now() + "\"}\n"; + respond(exchange, 200, JSON, body); + } + + private void ready(HttpExchange exchange) throws IOException { + if (!exchange.getRequestMethod().equals("GET")) { + respondError(exchange, 405, "method not allowed"); + return; + } + var servers = registry.snapshot(); + var readyServers = servers.stream().filter(AdminHttpServer::canReceiveNewConnections).count(); + var status = readyServers > 0 ? "READY" : "NOT_READY"; + respondJson(exchange, readyServers > 0 ? 200 : 503, new ReadinessView( + status, + readyServers, + servers.size(), + Instant.now().toString())); + } + + private void overview(HttpExchange exchange) throws IOException { + if (!authorized(exchange)) { + return; + } + if (!exchange.getRequestMethod().equals("GET")) { + respondError(exchange, 405, "method not allowed"); + return; + } + respondJson(exchange, 200, overviewView(metrics.snapshot(), registry.snapshot().size())); + } + + private void metrics(HttpExchange exchange) throws IOException { + if (!authorized(exchange)) { + return; + } + if (!prometheusEnabled) { + respondError(exchange, 404, "prometheus metrics disabled"); + return; + } + var snapshot = metrics.snapshot(); + var runtime = Runtime.getRuntime(); + var heapUsed = runtime.totalMemory() - runtime.freeMemory(); + var heapCommitted = runtime.totalMemory(); + var heapMax = runtime.maxMemory(); + var servers = registry.snapshot(); + var body = new StringBuilder(4096); + appendMetric(body, "counter", "strataproxy_connections_accepted_total", snapshot.acceptedConnections()); + appendMetric(body, "gauge", "strataproxy_connections_active", snapshot.activeConnections()); + appendMetric(body, "counter", "strataproxy_connections_rejected_total", snapshot.rejectedConnections()); + for (var entry : snapshot.rejectedConnectionsByReason().entrySet()) { + appendMetric(body, "counter", "strataproxy_connections_rejected_total", "reason=\"" + label(entry.getKey()) + "\"", entry.getValue()); + } + appendMetric(body, "counter", "strataproxy_handshake_timeouts_total", snapshot.handshakeTimeouts()); + appendMetric(body, "counter", "strataproxy_connections_routed_total", snapshot.routedConnections()); + appendMetric(body, "counter", "strataproxy_routes_failed_total", snapshot.failedRoutes()); + appendMetric(body, "counter", "strataproxy_backend_connect_failures_total", snapshot.backendConnectFailures()); + appendMetric(body, "counter", "strataproxy_frontend_to_backend_bytes_total", snapshot.frontendToBackendBytes()); + appendMetric(body, "counter", "strataproxy_backend_to_frontend_bytes_total", snapshot.backendToFrontendBytes()); + appendMetric(body, "counter", "strataproxy_compression_negotiations_total", snapshot.compressionNegotiations()); + for (var entry : snapshot.packetAnomalies().entrySet()) { + appendMetric(body, "counter", "strataproxy_packet_anomalies_total", "rule=\"" + label(entry.getKey()) + "\"", entry.getValue()); + } + appendCompressionMetrics(body, "", snapshot.compression()); + appendMetric(body, "gauge", "strataproxy_servers_registered", servers.size()); + appendMetric(body, "gauge", "strataproxy_event_loop_delay_seconds", snapshot.eventLoopDelayNanos() / 1_000_000_000.0d); + appendMetric(body, "gauge", "strataproxy_event_loop_delay_max_seconds", snapshot.maxEventLoopDelayNanos() / 1_000_000_000.0d); + appendMetric(body, "gauge", "strataproxy_pooled_direct_memory_bytes", snapshot.pooledDirectMemoryBytes()); + appendMetric(body, "gauge", "strataproxy_network_transport_info", "transport=\"" + + label(snapshot.networkTransport().name()) + "\",native=\"" + + snapshot.networkTransport().nativeTransport() + "\"", 1); + appendMetric(body, "gauge", "strataproxy_jvm_heap_used_bytes", heapUsed); + appendMetric(body, "gauge", "strataproxy_jvm_heap_committed_bytes", heapCommitted); + appendMetric(body, "gauge", "strataproxy_jvm_heap_max_bytes", heapMax); + appendJvmMetrics(body); + appendServerMetrics(body, servers, snapshot.serverTraffic(), snapshot.serverConnections(), snapshot.serverCompression(), snapshot.serverCompressionByDirection(), snapshot.serverCompressionThresholds()); + appendPlayerSessionMetrics(body, snapshot.playerSessions()); + appendCompressionDecisionMetrics(body, snapshot.compressionDecisions()); + appendCompressionRewriteMetrics(body, snapshot.compressionRewrites()); + appendPacketTrafficMetrics(body, snapshot.packetTraffic()); + appendCustomPayloadMetrics(body, snapshot.customPayloads()); + appendRelayBackpressureMetrics(body, snapshot.relayBackpressure()); + respond(exchange, 200, "text/plain; version=0.0.4; charset=utf-8", body.toString()); + } + + private void packetAnomalies(HttpExchange exchange) throws IOException { + if (!authorized(exchange)) { + return; + } + if (!exchange.getRequestMethod().equals("GET")) { + respondError(exchange, 405, "method not allowed"); + return; + } + respondJson(exchange, 200, packetAnomalyReport(metrics.snapshot())); + } + + private void packetTraffic(HttpExchange exchange) throws IOException { + if (!authorized(exchange)) { + return; + } + if (!exchange.getRequestMethod().equals("GET")) { + respondError(exchange, 405, "method not allowed"); + return; + } + respondJson(exchange, 200, packetTrafficReport(metrics.snapshot())); + } + + private void customPayloads(HttpExchange exchange) throws IOException { + if (!authorized(exchange)) { + return; + } + if (!exchange.getRequestMethod().equals("GET")) { + respondError(exchange, 405, "method not allowed"); + return; + } + respondJson(exchange, 200, customPayloadReport(metrics.snapshot())); + } + + private void playerSessions(HttpExchange exchange) throws IOException { + if (!authorized(exchange)) { + return; + } + if (!exchange.getRequestMethod().equals("GET")) { + respondError(exchange, 405, "method not allowed"); + return; + } + respondJson(exchange, 200, playerSessionReport(metrics.snapshot())); + } + + private void compressionReport(HttpExchange exchange) throws IOException { + if (!authorized(exchange)) { + return; + } + if (!exchange.getRequestMethod().equals("GET")) { + respondError(exchange, 405, "method not allowed"); + return; + } + respondJson(exchange, 200, compressionReport(metrics.snapshot())); + } + + private OverviewView overviewView(ProxyMetrics.Snapshot snapshot, long servers) { + var totalAnomalies = snapshot.packetAnomalies().values().stream().mapToLong(Long::longValue).sum(); + return new OverviewView( + "UP", + servers, + snapshot.activeConnections(), + snapshot.routedConnections(), + snapshot.rejectedConnections(), + rejectionReasons(snapshot), + snapshot.failedRoutes(), + snapshot.backendConnectFailures(), + snapshot.frontendToBackendBytes(), + snapshot.backendToFrontendBytes(), + snapshot.compressionNegotiations(), + snapshot.compression().savedBytes(), + snapshot.compression().ratio(), + totalAnomalies, + snapshot.eventLoopDelayNanos() / 1_000_000_000.0d, + snapshot.pooledDirectMemoryBytes(), + snapshot.networkTransport().name(), + snapshot.networkTransport().nativeTransport()); + } + + private RejectionReport rejectionReport(ProxyMetrics.Snapshot snapshot) { + return new RejectionReport(snapshot.rejectedConnections(), rejectionReasons(snapshot)); + } + + private static Map rejectionReasons(ProxyMetrics.Snapshot snapshot) { + return snapshot.rejectedConnectionsByReason().entrySet().stream() + .sorted(Map.Entry.comparingByKey()) + .collect(Collectors.toMap( + Map.Entry::getKey, + Map.Entry::getValue, + (left, right) -> left, + java.util.LinkedHashMap::new)); + } + + private PacketAnomalyReport packetAnomalyReport(ProxyMetrics.Snapshot snapshot) { + var rules = snapshot.packetAnomalies().entrySet().stream() + .map(entry -> new PacketAnomalyView(entry.getKey(), entry.getValue())) + .sorted(java.util.Comparator + .comparingLong(PacketAnomalyView::count) + .reversed() + .thenComparing(PacketAnomalyView::rule)) + .limit(packetTopN) + .toList(); + var total = snapshot.packetAnomalies().values().stream().mapToLong(Long::longValue).sum(); + var samples = snapshot.recentPacketAnomalies().stream() + .map(PacketAnomalySampleView::from) + .toList(); + return new PacketAnomalyReport(total, rules, samples); + } + + private PacketTrafficReport packetTrafficReport(ProxyMetrics.Snapshot snapshot) { + var rows = snapshot.packetTraffic().entrySet().stream() + .map(entry -> PacketTrafficView.from(entry.getKey(), entry.getValue())) + .sorted(java.util.Comparator + .comparingLong(PacketTrafficView::rawBytes) + .reversed() + .thenComparing(PacketTrafficView::server) + .thenComparing(PacketTrafficView::direction) + .thenComparing(PacketTrafficView::protocolState) + .thenComparingInt(PacketTrafficView::packetId)) + .limit(packetTopN) + .toList(); + var totalPackets = snapshot.packetTraffic().values().stream().mapToLong(ProxyMetrics.PacketTraffic::packets).sum(); + var totalRawBytes = snapshot.packetTraffic().values().stream().mapToLong(ProxyMetrics.PacketTraffic::rawBytes).sum(); + var totalCompressedBytes = snapshot.packetTraffic().values().stream().mapToLong(ProxyMetrics.PacketTraffic::compressedBytes).sum(); + return new PacketTrafficReport(totalPackets, totalRawBytes, totalCompressedBytes, rows); + } + + private CustomPayloadReport customPayloadReport(ProxyMetrics.Snapshot snapshot) { + var rows = snapshot.customPayloads().entrySet().stream() + .map(entry -> CustomPayloadView.from(entry.getKey(), entry.getValue())) + .sorted(java.util.Comparator + .comparingLong(CustomPayloadView::payloadBytes) + .reversed() + .thenComparing(CustomPayloadView::server) + .thenComparing(CustomPayloadView::direction) + .thenComparing(CustomPayloadView::kind) + .thenComparing(CustomPayloadView::channel)) + .limit(packetTopN) + .toList(); + var totalPackets = snapshot.customPayloads().values().stream().mapToLong(ProxyMetrics.CustomPayloadTraffic::packets).sum(); + var totalPayloadBytes = snapshot.customPayloads().values().stream().mapToLong(ProxyMetrics.CustomPayloadTraffic::payloadBytes).sum(); + var totalCompressedBytes = snapshot.customPayloads().values().stream().mapToLong(ProxyMetrics.CustomPayloadTraffic::compressedBytes).sum(); + return new CustomPayloadReport(totalPackets, totalPayloadBytes, totalCompressedBytes, rows); + } + + private CompressionReport compressionReport(ProxyMetrics.Snapshot snapshot) { + var rows = new java.util.ArrayList(); + rows.add(CompressionReportRow.from("global", "all", snapshot.compression(), -1, snapshot.compressionNegotiations())); + for (var server : registry.snapshot()) { + var name = server.descriptor().name(); + var serverCompression = snapshot.serverCompression().getOrDefault( + name, + new ProxyMetrics.CompressionAudit(0, 0, 0, 0, 0)); + rows.add(CompressionReportRow.from( + name, + "all", + serverCompression, + snapshot.serverCompressionThresholds().getOrDefault(name, -1), + 0)); + for (var direction : ProxyMetrics.CompressionDirection.values()) { + var directedCompression = snapshot.serverCompressionByDirection().getOrDefault( + new ProxyMetrics.CompressionDirectionKey(name, direction), + new ProxyMetrics.CompressionAudit(0, 0, 0, 0, 0)); + rows.add(CompressionReportRow.from( + name, + direction.label(), + directedCompression, + -1, + 0)); + } + } + var decisions = snapshot.compressionDecisions().entrySet().stream() + .map(entry -> CompressionDecisionView.from(entry.getKey(), entry.getValue())) + .sorted(java.util.Comparator + .comparing(CompressionDecisionView::scope) + .thenComparing(CompressionDecisionView::direction) + .thenComparing(CompressionDecisionView::action) + .thenComparingLong(CompressionDecisionView::threshold)) + .toList(); + var rewrites = snapshot.compressionRewrites().entrySet().stream() + .map(entry -> CompressionRewriteView.from(entry.getKey(), entry.getValue())) + .sorted(java.util.Comparator + .comparing(CompressionRewriteView::scope) + .thenComparing(CompressionRewriteView::direction) + .thenComparing(CompressionRewriteView::outcome)) + .toList(); + return new CompressionReport(rows, decisions, rewrites); + } + + private RelayBackpressureReport relayBackpressureReport(ProxyMetrics.Snapshot snapshot) { + var rows = snapshot.relayBackpressure().entrySet().stream() + .map(entry -> RelayBackpressureView.from(entry.getKey(), entry.getValue())) + .sorted(java.util.Comparator + .comparingLong(RelayBackpressureView::events) + .reversed() + .thenComparing(RelayBackpressureView::server) + .thenComparing(RelayBackpressureView::direction)) + .limit(packetTopN) + .toList(); + var totalEvents = snapshot.relayBackpressure().values().stream() + .mapToLong(ProxyMetrics.RelayBackpressure::events) + .sum(); + return new RelayBackpressureReport(totalEvents, rows); + } + + private PayloadCaptureReport payloadCaptureReport(ProxyMetrics.Snapshot snapshot) { + var rows = snapshot.payloadCaptures().stream() + .map(capture -> PayloadCaptureView.from( + capture, + snapshot.payloadCaptureSamples().getOrDefault(capture.id(), List.of()).size())) + .toList(); + return new PayloadCaptureReport(rows); + } + + private void diagnosticReport(HttpExchange exchange) throws IOException { + if (!authorized(exchange)) { + return; + } + if (!exchange.getRequestMethod().equals("GET")) { + respondError(exchange, 405, "method not allowed"); + return; + } + var snapshot = metrics.snapshot(); + var servers = registry.snapshot().stream() + .map(ServerView::from) + .sorted(java.util.Comparator.comparing(ServerView::name)) + .toList(); + respondJson(exchange, 200, new DiagnosticReport( + Instant.now().toString(), + overviewView(snapshot, servers.size()), + rejectionReport(snapshot), + servers, + compressionReport(snapshot), + packetTrafficReport(snapshot), + customPayloadReport(snapshot), + packetAnomalyReport(snapshot), + relayBackpressureReport(snapshot), + payloadCaptureReport(snapshot), + playerSessionReport(snapshot))); + } + + private static void appendCompressionMetrics(StringBuilder body, String labels, ProxyMetrics.CompressionAudit audit) { + appendMetric(body, "counter", "strataproxy_compression_samples_total", labels, audit.samples()); + appendMetric(body, "counter", "strataproxy_compression_raw_bytes_total", labels, audit.rawBytes()); + appendMetric(body, "counter", "strataproxy_compression_compressed_bytes_total", labels, audit.compressedBytes()); + appendMetric(body, "counter", "strataproxy_compression_saved_bytes_total", labels, audit.savedBytes()); + appendMetric(body, "counter", "strataproxy_compression_cpu_seconds_total", labels, audit.cpuNanos() / 1_000_000_000.0d); + appendMetric(body, "gauge", "strataproxy_compression_ratio", labels, audit.ratio()); + } + + private static void appendJvmMetrics(StringBuilder body) { + var threads = ManagementFactory.getThreadMXBean(); + appendMetric(body, "gauge", "strataproxy_jvm_threads_live", threads.getThreadCount()); + appendMetric(body, "gauge", "strataproxy_jvm_threads_daemon", threads.getDaemonThreadCount()); + appendMetric(body, "gauge", "strataproxy_jvm_threads_peak", threads.getPeakThreadCount()); + for (var collector : ManagementFactory.getGarbageCollectorMXBeans()) { + var labels = "gc=\"" + label(collector.getName()) + "\""; + appendMetric(body, "counter", "strataproxy_jvm_gc_collections_total", labels, Math.max(0, collector.getCollectionCount())); + appendMetric(body, "counter", "strataproxy_jvm_gc_collection_seconds_total", labels, Math.max(0, collector.getCollectionTime()) / 1_000.0d); + } + } + + private static void appendServerMetrics( + StringBuilder body, + java.util.Collection servers, + Map serverTraffic, + Map serverConnections, + Map serverCompression, + Map serverCompressionByDirection, + Map serverCompressionThresholds) { + for (var server : servers) { + var descriptor = server.descriptor(); + var serverLabel = "server=\"" + label(descriptor.name()) + "\""; + var traffic = serverTraffic.get(descriptor.name()); + var connections = serverConnections.getOrDefault(descriptor.name(), new ProxyMetrics.ServerConnections(0, 0)); + var compression = serverCompression.getOrDefault(descriptor.name(), new ProxyMetrics.CompressionAudit(0, 0, 0, 0, 0)); + appendMetric(body, "gauge", "strataproxy_server_draining", serverLabel, server.draining() ? 1 : 0); + appendMetric(body, "gauge", "strataproxy_server_connections_active", serverLabel, connections.activeConnections()); + appendMetric(body, "counter", "strataproxy_server_connections_routed_total", serverLabel, connections.routedConnections()); + appendMetric(body, "gauge", "strataproxy_server_backend_ping_millis", serverLabel, server.health().backendPingMillis()); + appendMetric(body, "gauge", "strataproxy_server_recent_failure_ratio", serverLabel, server.health().recentFailureRate()); + appendMetric(body, "gauge", "strataproxy_server_players", serverLabel, server.load().players()); + appendMetric(body, "gauge", "strataproxy_server_soft_capacity", serverLabel, server.load().softCapacity()); + appendMetric(body, "gauge", "strataproxy_server_hard_capacity", serverLabel, server.load().hardCapacity()); + appendMetric(body, "gauge", "strataproxy_server_packets_per_second", serverLabel, server.load().packetsPerSecond()); + appendMetric(body, "gauge", "strataproxy_server_inbound_bytes_per_second", serverLabel, server.load().inboundBytesPerSecond()); + appendMetric(body, "gauge", "strataproxy_server_outbound_bytes_per_second", serverLabel, server.load().outboundBytesPerSecond()); + appendMetric(body, "counter", "strataproxy_server_frontend_to_backend_bytes_total", serverLabel, traffic == null ? 0 : traffic.frontendToBackendBytes()); + appendMetric(body, "counter", "strataproxy_server_backend_to_frontend_bytes_total", serverLabel, traffic == null ? 0 : traffic.backendToFrontendBytes()); + appendMetric(body, "gauge", "strataproxy_server_compression_threshold_bytes", serverLabel, serverCompressionThresholds.getOrDefault(descriptor.name(), -1)); + appendCompressionMetrics(body, serverLabel, compression); + for (var direction : ProxyMetrics.CompressionDirection.values()) { + var labels = serverLabel + ",direction=\"" + direction.label() + "\""; + var directedCompression = serverCompressionByDirection.getOrDefault( + new ProxyMetrics.CompressionDirectionKey(descriptor.name(), direction), + new ProxyMetrics.CompressionAudit(0, 0, 0, 0, 0)); + appendCompressionMetrics(body, labels, directedCompression); + } + for (var status : ServerHealthStatus.values()) { + var labels = serverLabel + ",status=\"" + status.name() + "\""; + appendMetric(body, "gauge", "strataproxy_server_health", labels, server.health().status() == status ? 1 : 0); + } + } + } + + private static void appendPlayerSessionMetrics( + StringBuilder body, + Map players) { + for (var session : players.values()) { + var labels = "player=\"" + label(session.player()) + + "\",server=\"" + label(session.server()) + + "\",remote=\"" + label(session.remoteAddress()) + "\""; + appendMetric(body, "gauge", "strataproxy_player_session_active", labels, 1); + } + } + + private static void appendCompressionDecisionMetrics( + StringBuilder body, + Map decisions) { + for (var entry : decisions.entrySet()) { + var key = entry.getKey(); + var labels = "server=\"" + label(key.server()) + + "\",direction=\"" + key.direction().label() + + "\",action=\"" + label(key.action()) + + "\",threshold=\"" + key.threshold() + "\""; + appendMetric(body, "counter", "strataproxy_compression_decisions_total", labels, entry.getValue()); + } + } + + private static void appendCompressionRewriteMetrics( + StringBuilder body, + Map rewrites) { + for (var entry : rewrites.entrySet()) { + var key = entry.getKey(); + var value = entry.getValue(); + var labels = "server=\"" + label(key.server()) + + "\",direction=\"" + key.direction().label() + + "\",outcome=\"" + label(key.outcome()) + "\""; + appendMetric(body, "counter", "strataproxy_compression_rewrites_total", labels, value.count()); + appendMetric(body, "counter", "strataproxy_compression_rewrite_cpu_seconds_total", labels, value.cpuNanos() / 1_000_000_000.0d); + } + } + + private static void appendPacketTrafficMetrics( + StringBuilder body, + Map traffic) { + for (var entry : traffic.entrySet()) { + var key = entry.getKey(); + var value = entry.getValue(); + var labels = "server=\"" + label(key.server()) + + "\",direction=\"" + key.direction().label() + + "\",state=\"" + label(key.protocolState()) + + "\",packet_id=\"" + key.packetId() + "\""; + appendMetric(body, "counter", "strataproxy_packet_traffic_packets_total", labels, value.packets()); + appendMetric(body, "counter", "strataproxy_packet_traffic_raw_bytes_total", labels, value.rawBytes()); + appendMetric(body, "counter", "strataproxy_packet_traffic_compressed_bytes_total", labels, value.compressedBytes()); + } + } + + private static void appendCustomPayloadMetrics( + StringBuilder body, + Map payloads) { + for (var entry : payloads.entrySet()) { + var key = entry.getKey(); + var value = entry.getValue(); + var labels = "server=\"" + label(key.server()) + + "\",direction=\"" + key.direction().label() + + "\",kind=\"" + label(key.kind()) + + "\",channel=\"" + label(key.channel()) + "\""; + appendMetric(body, "counter", "strataproxy_custom_payload_packets_total", labels, value.packets()); + appendMetric(body, "counter", "strataproxy_custom_payload_bytes_total", labels, value.payloadBytes()); + appendMetric(body, "counter", "strataproxy_custom_payload_compressed_bytes_total", labels, value.compressedBytes()); + appendMetric(body, "gauge", "strataproxy_custom_payload_max_bytes", labels, value.maxPayloadBytes()); + appendMetric(body, "gauge", "strataproxy_custom_payload_max_compressed_bytes", labels, value.maxCompressedBytes()); + } + } + + private static void appendRelayBackpressureMetrics( + StringBuilder body, + Map backpressure) { + for (var entry : backpressure.entrySet()) { + var key = entry.getKey(); + var value = entry.getValue(); + var labels = "server=\"" + label(key.server()) + + "\",direction=\"" + key.direction().label() + "\""; + appendMetric(body, "counter", "strataproxy_relay_backpressure_events_total", labels, value.events()); + appendMetric(body, "gauge", "strataproxy_relay_backpressure_last_bytes_before_writable", labels, value.lastBytesBeforeWritable()); + appendMetric(body, "gauge", "strataproxy_relay_backpressure_max_bytes_before_writable", labels, value.maxBytesBeforeWritable()); + } + } + + private static void appendMetric(StringBuilder body, String type, String name, long value) { + appendType(body, type, name); + body.append(name).append(' ').append(value).append('\n'); + } + + private static void appendMetric(StringBuilder body, String type, String name, double value) { + appendType(body, type, name); + body.append(name).append(' ').append(value).append('\n'); + } + + private static void appendMetric(StringBuilder body, String type, String name, String labels, long value) { + appendType(body, type, name); + appendMetricLine(body, name, labels, value); + } + + private static void appendMetric(StringBuilder body, String type, String name, String labels, double value) { + appendType(body, type, name); + appendMetricLine(body, name, labels, value); + } + + private static void appendMetricLine(StringBuilder body, String name, String labels, long value) { + if (labels == null || labels.isBlank()) { + body.append(name).append(' ').append(value).append('\n'); + } else { + body.append(name).append('{').append(labels).append("} ").append(value).append('\n'); + } + } + + private static void appendMetricLine(StringBuilder body, String name, String labels, double value) { + if (labels == null || labels.isBlank()) { + body.append(name).append(' ').append(value).append('\n'); + } else { + body.append(name).append('{').append(labels).append("} ").append(value).append('\n'); + } + } + + private static void appendType(StringBuilder body, String type, String name) { + body.append("# TYPE ").append(name).append(' ').append(type).append('\n'); + } + + private static String label(String value) { + return value.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n"); + } + + private void payloadCaptures(HttpExchange exchange) throws IOException { + if (!authorized(exchange)) { + return; + } + try { + var method = exchange.getRequestMethod(); + var segments = pathSegments(exchange); + if (segments.size() == 1 && method.equals("GET")) { + respondJson(exchange, 200, payloadCaptureReport(metrics.snapshot())); + return; + } + if (segments.size() == 1 && method.equals("POST")) { + var request = readRequest(exchange, PayloadCaptureRequest.class); + var capture = metrics.startPayloadCapture( + request.id == null || request.id.isBlank() ? "capture-" + UUID.randomUUID() : request.id, + request.server, + parseDirection(request.direction), + request.maxSamples <= 0 ? 64 : request.maxSamples, + request.maxBytesPerSample <= 0 ? 256 : request.maxBytesPerSample, + Instant.now().plusMillis(request.durationMillis <= 0 ? 30_000 : request.durationMillis)); + respondJson(exchange, 201, PayloadCaptureView.from(capture, 0)); + return; + } + if (segments.size() == 2 && method.equals("GET")) { + var id = segments.get(1); + var capture = metrics.payloadCaptures().stream() + .filter(item -> item.id().equals(id)) + .findFirst(); + if (capture.isEmpty()) { + respondError(exchange, 404, "payload capture not found"); + return; + } + respondJson(exchange, 200, PayloadCaptureExport.from(capture.get(), metrics.payloadCaptureSamples(id))); + return; + } + if (segments.size() == 2 && method.equals("DELETE")) { + var removed = metrics.stopPayloadCapture(segments.get(1)); + respondJson(exchange, removed ? 200 : 404, Map.of("removed", removed)); + return; + } + respondError(exchange, 404, "unsupported payload capture route"); + } catch (IllegalArgumentException exception) { + respondError(exchange, 400, exception.getMessage()); + } + } + + private void routes(HttpExchange exchange) throws IOException { + if (!authorized(exchange)) { + return; + } + try { + var method = exchange.getRequestMethod(); + var segments = pathSegments(exchange); + if (segments.size() == 2 && segments.get(1).equals("preview") && method.equals("GET")) { + respondJson(exchange, 200, routePreview(exchange)); + return; + } + respondError(exchange, 404, "unsupported route operation"); + } catch (IllegalArgumentException exception) { + respondError(exchange, 400, exception.getMessage()); + } + } + + private void servers(HttpExchange exchange) throws IOException { + if (!authorized(exchange)) { + return; + } + try { + var method = exchange.getRequestMethod(); + var segments = pathSegments(exchange); + if (segments.size() == 1 && method.equals("GET")) { + respondJson(exchange, 200, registry.snapshot().stream().map(ServerView::from).toList()); + return; + } + if (segments.size() == 1 && method.equals("POST")) { + var request = readRequest(exchange, ServerRequest.class); + var descriptor = request.toDescriptor(); + var replacing = registry.get(descriptor.name()).isPresent(); + var server = registry.register(descriptor); + respondJson(exchange, replacing ? 200 : 201, ServerView.from(server)); + return; + } + if (segments.size() == 2 && method.equals("GET")) { + var server = registry.get(segments.get(1)); + if (server.isEmpty()) { + respondError(exchange, 404, "server not found"); + return; + } + respondJson(exchange, 200, ServerView.from(server.get())); + return; + } + if (segments.size() == 2 && method.equals("PATCH")) { + var server = registry.get(segments.get(1)); + if (server.isEmpty()) { + respondError(exchange, 404, "server not found"); + return; + } + var request = readRequest(exchange, ServerPatchRequest.class); + var updated = registry.register(request.patch(server.get().descriptor())); + respondJson(exchange, 200, ServerView.from(updated)); + return; + } + if (segments.size() == 2 && method.equals("DELETE")) { + var removed = registry.unregister(segments.get(1), DrainPolicy.rejectNew()); + respondJson(exchange, removed ? 200 : 404, Map.of("removed", removed)); + return; + } + if (segments.size() == 3 && method.equals("POST") && segments.get(2).equals("drain")) { + var drained = registry.updateDrainMode(segments.get(1), true); + respondJson(exchange, drained ? 200 : 404, Map.of("draining", drained)); + return; + } + if (segments.size() == 3 && method.equals("POST") && segments.get(2).equals("undrain")) { + var undrained = registry.updateDrainMode(segments.get(1), false); + respondJson(exchange, undrained ? 200 : 404, Map.of("draining", false)); + return; + } + if (segments.size() == 3 && method.equals("POST") && segments.get(2).equals("health")) { + if (registry.get(segments.get(1)).isEmpty()) { + respondError(exchange, 404, "server not found"); + return; + } + var request = readRequest(exchange, HealthRequest.class); + registry.updateHealth(segments.get(1), request.toHealth()); + respondJson(exchange, 200, registry.get(segments.get(1)).map(ServerView::from).orElseThrow()); + return; + } + if (segments.size() == 3 && method.equals("POST") && segments.get(2).equals("load")) { + if (registry.get(segments.get(1)).isEmpty()) { + respondError(exchange, 404, "server not found"); + return; + } + var request = readRequest(exchange, LoadRequest.class); + registry.updateLoad(segments.get(1), request.toLoad()); + respondJson(exchange, 200, registry.get(segments.get(1)).map(ServerView::from).orElseThrow()); + return; + } + respondError(exchange, 404, "unsupported admin route"); + } catch (IllegalArgumentException exception) { + respondError(exchange, 400, exception.getMessage()); + } catch (IOException exception) { + respondError(exchange, 500, exception.getMessage()); + } + } + + private T readRequest(HttpExchange exchange, Class type) { + try { + return mapper.readValue(exchange.getRequestBody(), type); + } catch (JsonMappingException exception) { + throw new IllegalArgumentException("invalid json request: " + exception.getOriginalMessage(), exception); + } catch (IOException exception) { + throw new IllegalArgumentException("invalid json request: " + exception.getMessage(), exception); + } + } + + private void respondJson(HttpExchange exchange, int status, Object value) throws IOException { + respond(exchange, status, JSON, mapper.writeValueAsString(value) + "\n"); + } + + private void respondError(HttpExchange exchange, int status, String message) throws IOException { + respondJson(exchange, status, Map.of("error", message == null ? "" : message)); + } + + private static void respond(HttpExchange exchange, int status, String contentType, String body) throws IOException { + var bytes = body.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", contentType); + exchange.sendResponseHeaders(status, bytes.length); + try (var output = exchange.getResponseBody()) { + output.write(bytes); + } + } + + private boolean authorized(HttpExchange exchange) throws IOException { + if (bearerToken.isBlank()) { + return true; + } + var expected = "Bearer " + bearerToken; + var actual = exchange.getRequestHeaders().getFirst("Authorization"); + if (actual != null && constantTimeEquals(actual, expected)) { + return true; + } + exchange.getResponseHeaders().set("WWW-Authenticate", "Bearer"); + respondError(exchange, 401, "unauthorized"); + return false; + } + + private static boolean constantTimeEquals(String actual, String expected) { + return MessageDigest.isEqual( + actual.getBytes(StandardCharsets.UTF_8), + expected.getBytes(StandardCharsets.UTF_8)); + } + + private static List pathSegments(HttpExchange exchange) { + return java.util.Arrays.stream(exchange.getRequestURI().getPath().split("/")) + .filter(segment -> !segment.isBlank()) + .toList(); + } + + private RoutePreviewView routePreview(HttpExchange exchange) { + var query = queryParams(exchange); + var protocolVersionText = first(query, "protocolVersion"); + if (protocolVersionText == null || protocolVersionText.isBlank()) { + throw new IllegalArgumentException("protocolVersion query parameter is required"); + } + var protocolVersion = Integer.parseInt(protocolVersionText); + var route = first(query, "route"); + var remoteAddress = parseAddress(firstOrDefault(query, "remoteAddress", "127.0.0.1:0"), 0); + var tags = queryValues(query, "tag"); + var capabilities = queryValues(query, "capability").stream() + .map(ServerRequest::parseCapability) + .collect(Collectors.toUnmodifiableSet()); + var request = new RoutingRequest(route, Set.copyOf(tags), capabilities, protocolVersion, remoteAddress); + var router = new WeightedHealthAwareRouter(readOnlyRegistry()); + var decision = router.route(request); + var candidates = router.explain(request).stream() + .map(candidate -> new RouteCandidateView( + candidate.serverName(), + candidate.eligible(), + candidate.reason(), + candidate.effectiveWeight(), + candidate.selectionKey())) + .toList(); + if (decision instanceof RoutingDecision.Selected selected) { + return new RoutePreviewView( + true, + selected.server().descriptor().name(), + selected.score(), + "", + route == null ? "" : route, + protocolVersion, + remoteAddress.toString(), + candidates); + } + var rejected = (RoutingDecision.Rejected) decision; + return new RoutePreviewView( + false, + "", + 0.0d, + rejected.reason(), + route == null ? "" : route, + protocolVersion, + remoteAddress.toString(), + candidates); + } + + private ServerRegistry readOnlyRegistry() { + return new ServerRegistry() { + @Override + public RegisteredServer register(ServerDescriptor descriptor) { + throw new UnsupportedOperationException("read-only registry"); + } + + @Override + public RegisteredServer registerOrReplace(ServerDescriptor descriptor) { + throw new UnsupportedOperationException("read-only registry"); + } + + @Override + public boolean unregister(String name, DrainPolicy policy) { + throw new UnsupportedOperationException("read-only registry"); + } + + @Override + public java.util.Optional get(String name) { + return registry.get(name); + } + + @Override + public java.util.Collection snapshot() { + return registry.snapshot(); + } + + @Override + public void updateHealth(String name, ServerHealth health) { + throw new UnsupportedOperationException("read-only registry"); + } + + @Override + public void updateLoad(String name, ServerLoad load) { + throw new UnsupportedOperationException("read-only registry"); + } + + @Override + public void updateDrainMode(String name, boolean drainMode) { + throw new UnsupportedOperationException("read-only registry"); + } + }; + } + + private static Map> queryParams(HttpExchange exchange) { + var query = exchange.getRequestURI().getRawQuery(); + if (query == null || query.isBlank()) { + return Map.of(); + } + var result = new java.util.LinkedHashMap>(); + for (var pair : query.split("&")) { + if (pair.isBlank()) { + continue; + } + var splitAt = pair.indexOf('='); + var key = decode(splitAt < 0 ? pair : pair.substring(0, splitAt)); + var value = decode(splitAt < 0 ? "" : pair.substring(splitAt + 1)); + result.compute(key, (ignored, values) -> { + var next = values == null ? new java.util.ArrayList() : new java.util.ArrayList<>(values); + next.add(value); + return List.copyOf(next); + }); + } + return Map.copyOf(result); + } + + private static String decode(String value) { + return URLDecoder.decode(value, StandardCharsets.UTF_8); + } + + private static String first(Map> query, String name) { + var values = query.get(name); + return values == null || values.isEmpty() ? null : values.getFirst(); + } + + private static String firstOrDefault(Map> query, String name, String fallback) { + var value = first(query, name); + return value == null || value.isBlank() ? fallback : value; + } + + private static Set queryValues(Map> query, String name) { + var values = query.get(name); + if (values == null || values.isEmpty()) { + return Set.of(); + } + return values.stream() + .flatMap(value -> java.util.Arrays.stream(value.split(","))) + .map(String::trim) + .filter(value -> !value.isBlank()) + .collect(Collectors.toUnmodifiableSet()); + } + + public static final class ServerRequest { + public String name; + public String address; + public Set tags = Set.of(); + public Set 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 metadata = Map.of(); + + ServerDescriptor toDescriptor() { + if (name == null || name.isBlank()) { + throw new IllegalArgumentException("server name must not be blank"); + } + if (address == null || address.isBlank()) { + throw new IllegalArgumentException("server address must not be blank"); + } + if (weight <= 0) { + throw new IllegalArgumentException("server weight must be positive"); + } + if (softCapacity < 0 || hardCapacity < 0) { + throw new IllegalArgumentException("server capacity values must be non-negative"); + } + if (hardCapacity > 0 && softCapacity > hardCapacity) { + throw new IllegalArgumentException("server softCapacity must be <= hardCapacity"); + } + return new ServerDescriptor( + name, + parseAddress(address, 25565), + tags, + capabilities.stream().map(ServerRequest::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)); + } + } + + public static final class HealthRequest { + public String status = "UP"; + public long backendPingMillis = -1; + public double recentFailureRate = 0.0d; + public String reason = ""; + + ServerHealth toHealth() { + if (status == null || status.isBlank()) { + throw new IllegalArgumentException("health status must not be blank"); + } + return new ServerHealth( + ServerHealthStatus.valueOf(status.trim().replace('-', '_').toUpperCase(Locale.ROOT)), + backendPingMillis, + recentFailureRate, + reason, + Instant.now()); + } + } + + public static final class ServerPatchRequest { + public String address; + public Set tags; + public Set capabilities; + public String protocolRange; + public Integer weight; + public Integer softCapacity; + public Integer hardCapacity; + public Boolean drainMode; + public Map metadata; + + ServerDescriptor patch(ServerDescriptor current) { + var nextWeight = weight == null ? current.weight() : weight; + var nextSoftCapacity = softCapacity == null ? current.softCapacity() : softCapacity; + var nextHardCapacity = hardCapacity == null ? current.hardCapacity() : hardCapacity; + if (nextWeight <= 0) { + throw new IllegalArgumentException("server weight must be positive"); + } + if (nextSoftCapacity < 0 || nextHardCapacity < 0) { + throw new IllegalArgumentException("server capacity values must be non-negative"); + } + if (nextHardCapacity > 0 && nextSoftCapacity > nextHardCapacity) { + throw new IllegalArgumentException("server softCapacity must be <= hardCapacity"); + } + return new ServerDescriptor( + current.name(), + address == null ? current.address() : parseAddress(address, current.address().getPort()), + tags == null ? current.tags() : tags, + capabilities == null + ? current.capabilities() + : capabilities.stream() + .map(ServerRequest::parseCapability) + .collect(Collectors.toUnmodifiableSet()), + protocolRange == null ? current.protocolRange() : parseProtocolRange(protocolRange), + nextWeight, + nextSoftCapacity, + nextHardCapacity, + drainMode == null ? current.drainMode() : drainMode, + metadata == null ? current.metadata() : metadata); + } + } + + public static final class LoadRequest { + public int players; + public int softCapacity; + public int hardCapacity; + public long inboundBytesPerSecond; + public long outboundBytesPerSecond; + public long packetsPerSecond; + public double eventLoopDelayMillis; + + ServerLoad toLoad() { + if (players < 0 || softCapacity < 0 || hardCapacity < 0) { + throw new IllegalArgumentException("load values must be non-negative"); + } + if (inboundBytesPerSecond < 0 || outboundBytesPerSecond < 0 || packetsPerSecond < 0) { + throw new IllegalArgumentException("load traffic values must be non-negative"); + } + if (eventLoopDelayMillis < 0.0d) { + throw new IllegalArgumentException("eventLoopDelayMillis must be non-negative"); + } + return new ServerLoad( + players, + softCapacity, + hardCapacity, + inboundBytesPerSecond, + outboundBytesPerSecond, + packetsPerSecond, + eventLoopDelayMillis); + } + } + + public record ServerView( + String name, + String address, + Set tags, + Set capabilities, + ProtocolRange protocolRange, + int weight, + boolean draining, + HealthView health, + ServerLoad load, + Map metadata) { + static ServerView from(RegisteredServer server) { + var descriptor = server.descriptor(); + return new ServerView( + descriptor.name(), + descriptor.address().getHostString() + ":" + descriptor.address().getPort(), + descriptor.tags(), + descriptor.capabilities(), + descriptor.protocolRange(), + descriptor.weight(), + server.draining(), + HealthView.from(server.health()), + server.load(), + descriptor.metadata()); + } + } + + public record HealthView( + ServerHealthStatus status, + long backendPingMillis, + double recentFailureRate, + String reason, + String updatedAt) { + static HealthView from(ServerHealth health) { + return new HealthView( + health.status(), + health.backendPingMillis(), + health.recentFailureRate(), + health.reason(), + health.updatedAt().toString()); + } + } + + public record OverviewView( + String status, + long servers, + long activeConnections, + long routedConnections, + long rejectedConnections, + Map rejectedConnectionsByReason, + long failedRoutes, + long backendConnectFailures, + long frontendToBackendBytes, + long backendToFrontendBytes, + long compressionNegotiations, + long compressionSavedBytes, + double compressionRatio, + long packetAnomalies, + double eventLoopDelaySeconds, + long pooledDirectMemoryBytes, + String transport, + boolean nativeTransport) { + } + + private static boolean canReceiveNewConnections(RegisteredServer server) { + return !server.draining() + && server.health().canReceiveNewConnections() + && !server.load().isHardFull(); + } + + public record ReadinessView(String status, long readyServers, long registeredServers, String timestamp) { + } + + public record RoutePreviewView( + boolean selected, + String server, + double score, + String reason, + String route, + int protocolVersion, + String remoteAddress, + List candidates) { + } + + public record RouteCandidateView( + String server, + boolean eligible, + String reason, + double effectiveWeight, + double selectionKey) { + } + + public record DiagnosticReport( + String generatedAt, + OverviewView overview, + RejectionReport admission, + List servers, + CompressionReport compression, + PacketTrafficReport packetTraffic, + CustomPayloadReport customPayloads, + PacketAnomalyReport packetAnomalies, + RelayBackpressureReport relayBackpressure, + PayloadCaptureReport payloadCaptures, + PlayerSessionReport playerSessions) { + } + + public record RejectionReport(long rejectedConnections, Map rejectedConnectionsByReason) { + } + + private PlayerSessionReport playerSessionReport(ProxyMetrics.Snapshot snapshot) { + var players = snapshot.playerSessions().values().stream() + .map(PlayerSessionView::from) + .sorted(java.util.Comparator + .comparing(PlayerSessionView::server) + .thenComparing(PlayerSessionView::player)) + .toList(); + return new PlayerSessionReport(players.size(), players); + } + + public record PlayerSessionReport(int active, List players) { + } + + public record PlayerSessionView(String player, String server, String remoteAddress, String connectedAt) { + static PlayerSessionView from(ProxyMetrics.PlayerSession session) { + return new PlayerSessionView( + session.player(), + session.server(), + session.remoteAddress(), + session.connectedAt().toString()); + } + } + + public record PacketAnomalyReport(long total, List rules, List recentSamples) { + } + + public record PacketAnomalyView(String rule, long count) { + } + + public record PacketAnomalySampleView( + long sequence, + String rule, + String remoteAddress, + String server, + String direction, + String protocolState, + int packetId, + long rawSize, + long compressedSize, + String detail, + String timestamp) { + static PacketAnomalySampleView from(ProxyMetrics.PacketAnomalySample sample) { + return new PacketAnomalySampleView( + sample.sequence(), + sample.rule(), + sample.remoteAddress(), + sample.server(), + sample.direction(), + sample.protocolState(), + sample.packetId(), + sample.rawSize(), + sample.compressedSize(), + sample.detail(), + sample.timestamp().toString()); + } + } + + public record PacketTrafficReport( + long totalPackets, + long totalRawBytes, + long totalCompressedBytes, + List top) { + } + + public record PacketTrafficView( + String server, + String direction, + String protocolState, + int packetId, + long packets, + long rawBytes, + long compressedBytes) { + static PacketTrafficView from(ProxyMetrics.PacketTrafficKey key, ProxyMetrics.PacketTraffic traffic) { + return new PacketTrafficView( + key.server(), + key.direction().label(), + key.protocolState(), + key.packetId(), + traffic.packets(), + traffic.rawBytes(), + traffic.compressedBytes()); + } + } + + public record CustomPayloadReport( + long totalPackets, + long totalPayloadBytes, + long totalCompressedBytes, + List top) { + } + + public record CustomPayloadView( + String server, + String direction, + String kind, + String channel, + long packets, + long payloadBytes, + long compressedBytes, + long maxPayloadBytes, + long maxCompressedBytes, + String firstSeen, + String lastSeen) { + static CustomPayloadView from(ProxyMetrics.CustomPayloadKey key, ProxyMetrics.CustomPayloadTraffic traffic) { + return new CustomPayloadView( + key.server(), + key.direction().label(), + key.kind(), + key.channel(), + traffic.packets(), + traffic.payloadBytes(), + traffic.compressedBytes(), + traffic.maxPayloadBytes(), + traffic.maxCompressedBytes(), + traffic.firstSeen() == null ? "" : traffic.firstSeen().toString(), + traffic.lastSeen() == null ? "" : traffic.lastSeen().toString()); + } + } + + public record RelayBackpressureReport(long totalEvents, List top) { + } + + public record RelayBackpressureView( + String server, + String direction, + long events, + long lastBytesBeforeWritable, + long maxBytesBeforeWritable) { + static RelayBackpressureView from(ProxyMetrics.RelayBackpressureKey key, ProxyMetrics.RelayBackpressure value) { + return new RelayBackpressureView( + key.server(), + key.direction().label(), + value.events(), + value.lastBytesBeforeWritable(), + value.maxBytesBeforeWritable()); + } + } + + public static final class PayloadCaptureRequest { + public String id = ""; + public String server = ""; + public String direction = ""; + public int maxSamples = 64; + public int maxBytesPerSample = 256; + public long durationMillis = 30_000; + } + + public record PayloadCaptureReport(List captures) { + } + + public record PayloadCaptureExport(PayloadCaptureView capture, List samples) { + static PayloadCaptureExport from(ProxyMetrics.PayloadCapture capture, List samples) { + return new PayloadCaptureExport( + PayloadCaptureView.from(capture, samples.size()), + samples.stream().map(PayloadCaptureSampleView::from).toList()); + } + } + + public record PayloadCaptureView( + String id, + String server, + String direction, + int maxSamples, + int maxBytesPerSample, + String expiresAt, + int sampleCount) { + static PayloadCaptureView from(ProxyMetrics.PayloadCapture capture, int sampleCount) { + return new PayloadCaptureView( + capture.id(), + capture.server(), + capture.direction().label(), + capture.maxSamples(), + capture.maxBytesPerSample(), + capture.expiresAt().toString(), + sampleCount); + } + } + + public record PayloadCaptureSampleView( + long sequence, + String captureId, + String server, + String direction, + long rawBytes, + long compressedBytes, + String prefixBase64, + String player, + String remoteAddress, + String timestamp) { + static PayloadCaptureSampleView from(ProxyMetrics.PayloadCaptureSample sample) { + return new PayloadCaptureSampleView( + sample.sequence(), + sample.captureId(), + sample.server(), + sample.direction().label(), + sample.rawBytes(), + sample.compressedBytes(), + Base64.getEncoder().encodeToString(sample.prefixBytes()), + sample.player(), + sample.remoteAddress(), + sample.timestamp().toString()); + } + } + + public record CompressionReport( + List rows, + List decisions, + List rewrites) { + } + + public record CompressionReportRow( + String scope, + String direction, + long rawBytes, + long compressedBytes, + long savedBytes, + double ratio, + long samples, + int threshold, + long negotiations) { + static CompressionReportRow from( + String scope, + String direction, + ProxyMetrics.CompressionAudit audit, + int threshold, + long negotiations) { + return new CompressionReportRow( + scope, + direction, + audit.rawBytes(), + audit.compressedBytes(), + audit.savedBytes(), + audit.ratio(), + audit.samples(), + threshold, + negotiations); + } + } + + public record CompressionDecisionView( + String scope, + String direction, + String action, + int threshold, + long count) { + static CompressionDecisionView from(ProxyMetrics.CompressionDecisionKey key, long count) { + return new CompressionDecisionView( + key.server(), + key.direction().label(), + key.action(), + key.threshold(), + count); + } + } + + public record CompressionRewriteView( + String scope, + String direction, + String outcome, + long count, + long cpuNanos) { + static CompressionRewriteView from(ProxyMetrics.CompressionRewriteKey key, ProxyMetrics.CompressionRewrite rewrite) { + return new CompressionRewriteView( + key.server(), + key.direction().label(), + key.outcome(), + rewrite.count(), + rewrite.cpuNanos()); + } + } + + 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))); + } + + private 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); + } + + private static ProxyMetrics.CompressionDirection parseDirection(String value) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException("direction must not be blank"); + } + var normalized = value.trim().toLowerCase(Locale.ROOT); + for (var direction : ProxyMetrics.CompressionDirection.values()) { + if (direction.label().equals(normalized) || direction.name().equalsIgnoreCase(value)) { + return direction; + } + } + throw new IllegalArgumentException("direction must be frontend_to_backend or backend_to_frontend"); + } +} diff --git a/proxy-admin-api/src/main/java/dev/strataproxy/admin/AdminRegistryService.java b/proxy-admin-api/src/main/java/dev/strataproxy/admin/AdminRegistryService.java new file mode 100644 index 0000000..9a3d6ca --- /dev/null +++ b/proxy-admin-api/src/main/java/dev/strataproxy/admin/AdminRegistryService.java @@ -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 get(String name) { + return registry.get(name); + } + + public Collection snapshot() { + return registry.snapshot(); + } + + public void persist() throws IOException { + store.save(registry.snapshot()); + } +} diff --git a/proxy-admin-api/src/main/java/dev/strataproxy/admin/AdminSnapshot.java b/proxy-admin-api/src/main/java/dev/strataproxy/admin/AdminSnapshot.java new file mode 100644 index 0000000..65c9366 --- /dev/null +++ b/proxy-admin-api/src/main/java/dev/strataproxy/admin/AdminSnapshot.java @@ -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 servers, + List 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; + } +} diff --git a/proxy-admin-api/src/main/java/dev/strataproxy/admin/JsonRegistryStore.java b/proxy-admin-api/src/main/java/dev/strataproxy/admin/JsonRegistryStore.java new file mode 100644 index 0000000..9d56578 --- /dev/null +++ b/proxy-admin-api/src/main/java/dev/strataproxy/admin/JsonRegistryStore.java @@ -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 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 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 tags = Set.of(); + public Set 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 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))); + } +} diff --git a/proxy-admin-api/src/main/java/dev/strataproxy/admin/NoopRegistryStore.java b/proxy-admin-api/src/main/java/dev/strataproxy/admin/NoopRegistryStore.java new file mode 100644 index 0000000..7923a92 --- /dev/null +++ b/proxy-admin-api/src/main/java/dev/strataproxy/admin/NoopRegistryStore.java @@ -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 load() { + return List.of(); + } + + @Override + public void save(Collection servers) throws IOException { + } +} diff --git a/proxy-admin-api/src/main/java/dev/strataproxy/admin/RegistryStore.java b/proxy-admin-api/src/main/java/dev/strataproxy/admin/RegistryStore.java new file mode 100644 index 0000000..0b979fd --- /dev/null +++ b/proxy-admin-api/src/main/java/dev/strataproxy/admin/RegistryStore.java @@ -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 load() throws IOException; + + void save(Collection servers) throws IOException; +} diff --git a/proxy-admin-api/src/test/java/dev/strataproxy/admin/AdminHttpServerTest.java b/proxy-admin-api/src/test/java/dev/strataproxy/admin/AdminHttpServerTest.java new file mode 100644 index 0000000..7e0549f --- /dev/null +++ b/proxy-admin-api/src/test/java/dev/strataproxy/admin/AdminHttpServerTest.java @@ -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 post(HttpClient client, String uri, String body) throws IOException, InterruptedException { + return post(client, uri, body, null); + } + + private static HttpResponse 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 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 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 load() { + return List.of(); + } + + @Override + public void save(Collection servers) throws IOException { + throw new IOException("persist failed"); + } + } +} diff --git a/proxy-admin-api/src/test/java/dev/strataproxy/admin/JsonRegistryStoreTest.java b/proxy-admin-api/src/test/java/dev/strataproxy/admin/JsonRegistryStoreTest.java new file mode 100644 index 0000000..2983bd5 --- /dev/null +++ b/proxy-admin-api/src/test/java/dev/strataproxy/admin/JsonRegistryStoreTest.java @@ -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 load() { + return List.of(); + } + + @Override + public void save(Collection servers) throws IOException { + throw new IOException("persist failed"); + } + } +} diff --git a/proxy-admin-cli/build.gradle.kts b/proxy-admin-cli/build.gradle.kts new file mode 100644 index 0000000..95e4ae5 --- /dev/null +++ b/proxy-admin-cli/build.gradle.kts @@ -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("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) +} diff --git a/proxy-admin-cli/src/main/java/dev/strataproxy/admin/cli/StrataProxyAdminCli.java b/proxy-admin-cli/src/main/java/dev/strataproxy/admin/cli/StrataProxyAdminCli.java new file mode 100644 index 0000000..708b770 --- /dev/null +++ b/proxy-admin-cli/src/main/java/dev/strataproxy/admin/cli/StrataProxyAdminCli.java @@ -0,0 +1,2278 @@ +package dev.strataproxy.admin.cli; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import picocli.CommandLine; +import picocli.CommandLine.Command; +import picocli.CommandLine.Option; +import picocli.CommandLine.ParentCommand; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.net.URI; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.GeneralSecurityException; +import java.security.KeyStore; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.Callable; +import javax.net.ssl.KeyManager; +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.SSLContext; +import javax.net.ssl.TrustManager; +import javax.net.ssl.TrustManagerFactory; + +@Command( + name = "strataproxy-admin", + mixinStandardHelpOptions = true, + version = "strataproxy-admin 0.1", + description = "Admin CLI for a running StrataProxy instance.", + subcommands = { + StrataProxyAdminCli.HealthCommand.class, + StrataProxyAdminCli.ReadyCommand.class, + StrataProxyAdminCli.OverviewCommand.class, + StrataProxyAdminCli.SloCommand.class, + StrataProxyAdminCli.MetricsCommand.class, + StrataProxyAdminCli.DiagnosticsCommand.class, + StrataProxyAdminCli.CompressionCommand.class, + StrataProxyAdminCli.PacketsCommand.class, + StrataProxyAdminCli.ModPayloadsCommand.class, + StrataProxyAdminCli.PlayersCommand.class, + StrataProxyAdminCli.AnomaliesCommand.class, + StrataProxyAdminCli.BackpressureCommand.class, + StrataProxyAdminCli.CapturesCommand.class, + StrataProxyAdminCli.RoutesCommand.class, + StrataProxyAdminCli.ServersCommand.class + }) +public final class StrataProxyAdminCli implements Callable { + private static final ObjectMapper MAPPER = new ObjectMapper(); + + @Option(names = "--base-url", defaultValue = "http://127.0.0.1:8080", description = "Admin API base URL.") + private URI baseUrl; + + @Option(names = "--token", description = "Bearer token for protected admin endpoints.") + private String token; + + @Option(names = "--timeout-ms", defaultValue = "5000", description = "HTTP request timeout in milliseconds.") + private long timeoutMillis; + + @Option(names = "--trust-store-path", description = "Truststore path for HTTPS Admin API certificates.") + private Path trustStorePath; + + @Option(names = "--trust-store-password", defaultValue = "", description = "Truststore password.") + private String trustStorePassword; + + @Option(names = "--trust-store-type", defaultValue = "PKCS12", description = "Truststore type.") + private String trustStoreType; + + @Option(names = "--key-store-path", description = "Client keystore path for mTLS Admin API authentication.") + private Path keyStorePath; + + @Option(names = "--key-store-password", defaultValue = "", description = "Client keystore password.") + private String keyStorePassword; + + @Option(names = "--key-store-type", defaultValue = "PKCS12", description = "Client keystore type.") + private String keyStoreType; + + private HttpClient client; + + public static void main(String[] args) { + System.exit(new CommandLine(new StrataProxyAdminCli()).execute(args)); + } + + @Override + public Integer call() { + CommandLine.usage(this, System.out); + return 0; + } + + Response request(String method, String path, String body) { + try { + var builder = HttpRequest.newBuilder(baseUrl.resolve(path)) + .timeout(Duration.ofMillis(timeoutMillis)); + if (token != null && !token.isBlank()) { + builder.header("Authorization", "Bearer " + token); + } + if (body == null) { + builder.method(method, HttpRequest.BodyPublishers.noBody()); + } else { + builder.header("Content-Type", "application/json"); + builder.method(method, HttpRequest.BodyPublishers.ofString(body)); + } + var response = httpClient().send(builder.build(), HttpResponse.BodyHandlers.ofString()); + return new Response(response.statusCode(), response.body()); + } catch (IOException exception) { + throw new UncheckedIOException(exception); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("request interrupted", exception); + } + } + + int print(Response response) { + System.out.print(response.body()); + return response.statusCode() >= 200 && response.statusCode() < 300 ? 0 : 1; + } + + void client(HttpClient client) { + this.client = client; + } + + void baseUrl(URI baseUrl) { + this.baseUrl = baseUrl; + } + + void token(String token) { + this.token = token; + } + + HttpClient httpClient() { + if (client == null) { + client = createHttpClient(); + } + return client; + } + + private HttpClient createHttpClient() { + var builder = HttpClient.newBuilder() + .connectTimeout(Duration.ofMillis(timeoutMillis)); + if (trustStorePath != null || keyStorePath != null) { + builder.sslContext(createSslContext()); + } + return builder.build(); + } + + private SSLContext createSslContext() { + try { + var keyManagers = keyStorePath == null ? null : keyManagers(loadStore(keyStorePath, keyStoreType, keyStorePassword), keyStorePassword); + var trustManagers = trustStorePath == null ? null : trustManagers(loadStore(trustStorePath, trustStoreType, trustStorePassword)); + var context = SSLContext.getInstance("TLS"); + context.init(keyManagers, trustManagers, null); + return context; + } catch (GeneralSecurityException exception) { + throw new IllegalArgumentException("failed to initialize TLS context: " + exception.getMessage(), exception); + } + } + + private static KeyStore loadStore(Path path, String type, String password) { + if (path == null) { + throw new IllegalArgumentException("store path must not be null"); + } + if (type == null || type.isBlank()) { + throw new IllegalArgumentException("store type must not be blank"); + } + if (Files.notExists(path)) { + throw new IllegalArgumentException("store path does not exist: " + path); + } + try (var input = Files.newInputStream(path)) { + var store = KeyStore.getInstance(type); + store.load(input, passwordChars(password)); + return store; + } catch (IOException exception) { + throw new UncheckedIOException("failed to read store: " + path, exception); + } catch (GeneralSecurityException exception) { + throw new IllegalArgumentException("failed to load store " + path + ": " + exception.getMessage(), exception); + } + } + + private static KeyManager[] keyManagers(KeyStore keyStore, String password) throws GeneralSecurityException { + var factory = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); + factory.init(keyStore, passwordChars(password)); + return factory.getKeyManagers(); + } + + private static TrustManager[] trustManagers(KeyStore trustStore) throws GeneralSecurityException { + var factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); + factory.init(trustStore); + return factory.getTrustManagers(); + } + + private static char[] passwordChars(String password) { + return password == null ? new char[0] : password.toCharArray(); + } + + record Response(int statusCode, String body) { + } + + @Command(name = "health", description = "Fetch /healthz.") + static final class HealthCommand implements Callable { + @ParentCommand + private StrataProxyAdminCli root; + + @Override + public Integer call() { + return root.print(root.request("GET", "/healthz", null)); + } + } + + @Command(name = "ready", description = "Fetch /readyz.") + static final class ReadyCommand implements Callable { + @ParentCommand + private StrataProxyAdminCli root; + + @Override + public Integer call() { + return root.print(root.request("GET", "/readyz", null)); + } + } + + @Command(name = "overview", description = "Show a compact operational summary.") + static final class OverviewCommand implements Callable { + @ParentCommand + private StrataProxyAdminCli root; + + @Override + public Integer call() { + var overview = root.request("GET", "/overview", null); + if (overview.statusCode() >= 200 && overview.statusCode() < 300) { + System.out.print(OverviewMetricsView.parseJson(overview.body()).render()); + return 0; + } + if (overview.statusCode() != 404 && overview.statusCode() != 405) { + return root.print(overview); + } + var health = root.request("GET", "/healthz", null); + if (health.statusCode() < 200 || health.statusCode() >= 300) { + return root.print(health); + } + var metrics = root.request("GET", "/metrics", null); + if (metrics.statusCode() < 200 || metrics.statusCode() >= 300) { + return root.print(metrics); + } + var view = OverviewMetricsView.parse(health.body(), metrics.body()); + System.out.print(view.render()); + return 0; + } + } + + @Command(name = "slo", description = "Check operational SLO gates from /overview.") + static final class SloCommand implements Callable { + @ParentCommand + private StrataProxyAdminCli root; + + @Option(names = "--max-event-loop-delay-ms", defaultValue = "-1", description = "Maximum event loop delay in milliseconds. Negative disables this gate.") + private double maxEventLoopDelayMillis; + + @Option(names = "--max-active-connections", defaultValue = "-1", description = "Maximum active connections. Negative disables this gate.") + private long maxActiveConnections; + + @Option(names = "--max-rejected", defaultValue = "-1", description = "Maximum rejected connections. Negative disables this gate.") + private long maxRejectedConnections; + + @Option(names = "--max-anomalies", defaultValue = "-1", description = "Maximum packet anomalies. Negative disables this gate.") + private long maxAnomalies; + + @Option(names = "--require-native-transport", description = "Fail unless native transport is active.") + private boolean requireNativeTransport; + + @Option(names = "--require-ready", description = "Fail unless /readyz reports READY.") + private boolean requireReady; + + @Override + public Integer call() { + var overview = root.request("GET", "/overview", null); + OverviewMetricsView view; + if (overview.statusCode() >= 200 && overview.statusCode() < 300) { + view = OverviewMetricsView.parseJson(overview.body()); + } else if (overview.statusCode() == 404 || overview.statusCode() == 405) { + var health = root.request("GET", "/healthz", null); + if (health.statusCode() < 200 || health.statusCode() >= 300) { + return root.print(health); + } + var metrics = root.request("GET", "/metrics", null); + if (metrics.statusCode() < 200 || metrics.statusCode() >= 300) { + return root.print(metrics); + } + view = OverviewMetricsView.parse(health.body(), metrics.body()); + } else { + return root.print(overview); + } + ReadyMetricsView readiness = null; + if (requireReady) { + var ready = root.request("GET", "/readyz", null); + readiness = ReadyMetricsView.parseJson(ready.body(), ready.statusCode()); + } + var slo = SloView.from( + view, + readiness, + maxEventLoopDelayMillis, + maxActiveConnections, + maxRejectedConnections, + maxAnomalies, + requireNativeTransport); + System.out.print(slo.render()); + return slo.passed() ? 0 : 1; + } + } + + @Command(name = "diagnostics", description = "Export one structured diagnostic report JSON document.") + static final class DiagnosticsCommand implements Callable { + @ParentCommand + private StrataProxyAdminCli root; + + @Override + public Integer call() { + return root.print(root.request("GET", "/diagnostic-report", null)); + } + } + + @Command(name = "metrics", description = "Fetch Prometheus /metrics.") + static final class MetricsCommand implements Callable { + @ParentCommand + private StrataProxyAdminCli root; + + @Override + public Integer call() { + return root.print(root.request("GET", "/metrics", null)); + } + } + + @Command(name = "compression", description = "Show compression audit summary from /metrics.") + static final class CompressionCommand implements Callable { + @ParentCommand + private StrataProxyAdminCli root; + + @Override + public Integer call() { + var report = root.request("GET", "/compression-report", null); + if (report.statusCode() >= 200 && report.statusCode() < 300) { + System.out.print(CompressionMetricsView.parseJson(report.body()).render()); + return 0; + } + if (report.statusCode() != 404 && report.statusCode() != 405) { + return root.print(report); + } + var response = root.request("GET", "/metrics", null); + if (response.statusCode() < 200 || response.statusCode() >= 300) { + return root.print(response); + } + var view = CompressionMetricsView.parse(response.body()); + System.out.print(view.render()); + return 0; + } + } + + @Command(name = "packets", description = "Show top packet traffic counters.") + static final class PacketsCommand implements Callable { + @ParentCommand + private StrataProxyAdminCli root; + + @Override + public Integer call() { + var response = root.request("GET", "/packet-traffic", null); + if (response.statusCode() < 200 || response.statusCode() >= 300) { + if (response.statusCode() == 404 || response.statusCode() == 405) { + var metrics = root.request("GET", "/metrics", null); + if (metrics.statusCode() < 200 || metrics.statusCode() >= 300) { + return root.print(metrics); + } + System.out.print(PacketTrafficMetricsView.parse(metrics.body()).render()); + return 0; + } + return root.print(response); + } + System.out.print(PacketTrafficMetricsView.parseJson(response.body()).render()); + return 0; + } + } + + @Command(name = "mod-payloads", description = "Show classified modded custom payload counters.") + static final class ModPayloadsCommand implements Callable { + @ParentCommand + private StrataProxyAdminCli root; + + @Override + public Integer call() { + var response = root.request("GET", "/custom-payloads", null); + if (response.statusCode() < 200 || response.statusCode() >= 300) { + return root.print(response); + } + System.out.print(CustomPayloadMetricsView.parseJson(response.body()).render()); + return 0; + } + } + + @Command(name = "players", description = "Show active player sessions.") + static final class PlayersCommand implements Callable { + @ParentCommand + private StrataProxyAdminCli root; + + @Override + public Integer call() { + var response = root.request("GET", "/player-sessions", null); + if (response.statusCode() < 200 || response.statusCode() >= 300) { + return root.print(response); + } + System.out.print(PlayerSessionMetricsView.parseJson(response.body()).render()); + return 0; + } + } + + @Command(name = "anomalies", description = "Show packet anomaly counters.") + static final class AnomaliesCommand implements Callable { + @ParentCommand + private StrataProxyAdminCli root; + + @Option(names = "--samples", description = "Print recent packet anomaly samples after the rule summary.") + private boolean samples; + + @Override + public Integer call() { + var response = root.request("GET", "/packet-anomalies", null); + if (response.statusCode() < 200 || response.statusCode() >= 300) { + if (response.statusCode() == 404 || response.statusCode() == 405) { + var metrics = root.request("GET", "/metrics", null); + if (metrics.statusCode() < 200 || metrics.statusCode() >= 300) { + return root.print(metrics); + } + System.out.print(AnomalyMetricsView.parse(metrics.body()).render()); + return 0; + } + return root.print(response); + } + var view = AnomalyMetricsView.parseJson(response.body()); + System.out.print(view.render()); + if (samples) { + System.out.print(view.renderSamples()); + } + return 0; + } + } + + @Command(name = "backpressure", description = "Show relay write backpressure summary.") + static final class BackpressureCommand implements Callable { + @ParentCommand + private StrataProxyAdminCli root; + + @Override + public Integer call() { + var diagnostics = root.request("GET", "/diagnostic-report", null); + if (diagnostics.statusCode() >= 200 && diagnostics.statusCode() < 300) { + System.out.print(BackpressureMetricsView.parseJson(diagnostics.body()).render()); + return 0; + } + if (diagnostics.statusCode() != 404 && diagnostics.statusCode() != 405) { + return root.print(diagnostics); + } + var metrics = root.request("GET", "/metrics", null); + if (metrics.statusCode() < 200 || metrics.statusCode() >= 300) { + return root.print(metrics); + } + System.out.print(BackpressureMetricsView.parse(metrics.body()).render()); + return 0; + } + } + + @Command(name = "captures", mixinStandardHelpOptions = true, description = "Manage payload prefix captures.", subcommands = { + CapturesCommand.StartCommand.class, + CapturesCommand.GetCommand.class, + CapturesCommand.StopCommand.class + }) + static final class CapturesCommand implements Callable { + @ParentCommand + private StrataProxyAdminCli root; + + @Override + public Integer call() { + var response = root.request("GET", "/payload-captures", null); + if (response.statusCode() < 200 || response.statusCode() >= 300) { + return root.print(response); + } + System.out.print(PayloadCaptureMetricsView.parseListJson(response.body()).render()); + return 0; + } + + @Command(name = "start", description = "Start a bounded payload prefix capture.") + static final class StartCommand implements Callable { + @ParentCommand + private CapturesCommand captures; + + @Option(names = "--id", description = "Capture id. Generated by the server when omitted.") + private String id; + + @Option(names = "--server", required = true, description = "Backend server name to capture.") + private String server; + + @Option(names = "--direction", defaultValue = "frontend_to_backend", description = "Traffic direction.") + private String direction; + + @Option(names = "--max-samples", defaultValue = "64", description = "Maximum retained samples.") + private int maxSamples; + + @Option(names = "--max-bytes", defaultValue = "256", description = "Maximum bytes retained per sample.") + private int maxBytesPerSample; + + @Option(names = "--duration-ms", defaultValue = "30000", description = "Capture duration in milliseconds.") + private long durationMillis; + + @Override + public Integer call() { + var json = MAPPER.createObjectNode(); + if (id != null && !id.isBlank()) { + json.put("id", id); + } + json.put("server", server); + json.put("direction", direction); + json.put("maxSamples", maxSamples); + json.put("maxBytesPerSample", maxBytesPerSample); + json.put("durationMillis", durationMillis); + + var response = captures.root.request("POST", "/payload-captures", json.toString()); + if (response.statusCode() < 200 || response.statusCode() >= 300) { + return captures.root.print(response); + } + System.out.print(PayloadCaptureMetricsView.parseCaptureJson(response.body()).render()); + return 0; + } + } + + @Command(name = "get", description = "Export one payload prefix capture.") + static final class GetCommand implements Callable { + @ParentCommand + private CapturesCommand captures; + + @CommandLine.Parameters(index = "0", description = "Capture id.") + private String id; + + @Override + public Integer call() { + var response = captures.root.request("GET", "/payload-captures/" + id, null); + if (response.statusCode() < 200 || response.statusCode() >= 300) { + return captures.root.print(response); + } + System.out.print(PayloadCaptureExportView.parseJson(response.body()).render()); + return 0; + } + } + + @Command(name = "stop", description = "Stop and remove one payload prefix capture.") + static final class StopCommand implements Callable { + @ParentCommand + private CapturesCommand captures; + + @CommandLine.Parameters(index = "0", description = "Capture id.") + private String id; + + @Override + public Integer call() { + return captures.root.print(captures.root.request("DELETE", "/payload-captures/" + id, null)); + } + } + } + + @Command(name = "routes", mixinStandardHelpOptions = true, description = "Preview routing decisions.", subcommands = { + RoutesCommand.PreviewCommand.class + }) + static final class RoutesCommand implements Callable { + @ParentCommand + private StrataProxyAdminCli root; + + @Override + public Integer call() { + CommandLine.usage(this, System.out); + return 0; + } + + @Command(name = "preview", description = "Preview where a route request would land.") + static final class PreviewCommand implements Callable { + @ParentCommand + private RoutesCommand routes; + + @Option(names = "--route", description = "Requested route, host, server name, tag, or alias.") + private String route; + + @Option(names = "--protocol-version", required = true, description = "Minecraft protocol version.") + private int protocolVersion; + + @Option(names = "--remote-address", defaultValue = "127.0.0.1:0", description = "Client remote address used for stable weighted selection.") + private String remoteAddress; + + @Option(names = "--tag", split = ",", description = "Required tag. Can be repeated or comma-separated.") + private List tags = List.of(); + + @Option(names = "--capability", split = ",", description = "Required capability. Can be repeated or comma-separated.") + private List capabilities = List.of(); + + @Override + public Integer call() { + var response = routes.root.request("GET", path(), null); + if (response.statusCode() < 200 || response.statusCode() >= 300) { + return routes.root.print(response); + } + var view = RoutePreviewMetricsView.parseJson(response.body()); + System.out.print(view.render()); + return view.selected ? 0 : 1; + } + + private String path() { + var params = new ArrayList(); + if (route != null && !route.isBlank()) { + params.add("route=" + encode(route)); + } + params.add("protocolVersion=" + protocolVersion); + params.add("remoteAddress=" + encode(remoteAddress)); + for (var tag : compact(tags)) { + params.add("tag=" + encode(tag)); + } + for (var capability : compact(capabilities)) { + params.add("capability=" + encode(capability)); + } + return "/routes/preview?" + String.join("&", params); + } + + private static String encode(String value) { + return URLEncoder.encode(value, StandardCharsets.UTF_8); + } + + private static List compact(List values) { + var result = new ArrayList(); + for (var value : values) { + if (value != null && !value.isBlank()) { + result.add(value.trim()); + } + } + return List.copyOf(result); + } + } + } + + @Command(name = "servers", mixinStandardHelpOptions = true, description = "Manage registered backend servers.", subcommands = { + ServersCommand.ListCommand.class, + ServersCommand.GetCommand.class, + ServersCommand.RegisterCommand.class, + ServersCommand.UpdateCommand.class, + ServersCommand.RemoveCommand.class, + ServersCommand.DrainCommand.class, + ServersCommand.UndrainCommand.class, + ServersCommand.HealthCommand.class, + ServersCommand.LoadCommand.class + }) + static final class ServersCommand implements Callable { + @ParentCommand + private StrataProxyAdminCli root; + + @Override + public Integer call() { + return root.print(root.request("GET", "/servers", null)); + } + + @Command(name = "list", description = "List registered backend servers.") + static final class ListCommand implements Callable { + @ParentCommand + private ServersCommand servers; + + @Override + public Integer call() { + return servers.root.print(servers.root.request("GET", "/servers", null)); + } + } + + @Command(name = "get", description = "Get one backend server.") + static final class GetCommand implements Callable { + @ParentCommand + private ServersCommand servers; + + @CommandLine.Parameters(index = "0", description = "Server name.") + private String name; + + @Override + public Integer call() { + return servers.root.print(servers.root.request("GET", "/servers/" + name, null)); + } + } + + @Command(name = "register", description = "Register or replace a backend server.") + static final class RegisterCommand implements Callable { + @ParentCommand + private ServersCommand servers; + + @Option(names = "--name", required = true) + private String name; + + @Option(names = "--address", required = true, description = "Host:port backend address.") + private String address; + + @Option(names = "--tag", split = ",", description = "Server tag. Can be repeated or comma-separated.") + private List tags = List.of(); + + @Option(names = "--capability", split = ",", description = "Server capability. Can be repeated or comma-separated.") + private List capabilities = List.of(); + + @Option(names = "--protocol-range", defaultValue = "any") + private String protocolRange; + + @Option(names = "--weight", defaultValue = "100") + private int weight; + + @Option(names = "--soft-capacity", defaultValue = "500") + private int softCapacity; + + @Option(names = "--hard-capacity", defaultValue = "600") + private int hardCapacity; + + @Option(names = "--drain") + private boolean drainMode; + + @Option(names = "--metadata", split = ",", description = "Metadata key=value. Can be repeated or comma-separated.") + private List metadata = List.of(); + + @Override + public Integer call() { + var json = MAPPER.createObjectNode(); + json.put("name", name); + json.put("address", address); + json.set("tags", MAPPER.valueToTree(compact(tags))); + json.set("capabilities", MAPPER.valueToTree(compact(capabilities))); + json.put("protocolRange", protocolRange); + json.put("weight", weight); + json.put("softCapacity", softCapacity); + json.put("hardCapacity", hardCapacity); + json.put("drainMode", drainMode); + json.set("metadata", MAPPER.valueToTree(parseMetadata(metadata))); + return servers.root.print(servers.root.request("POST", "/servers", json.toString())); + } + } + + @Command(name = "update", description = "Partially update a registered backend server.") + static final class UpdateCommand implements Callable { + @ParentCommand + private ServersCommand servers; + + @CommandLine.Parameters(index = "0", description = "Server name.") + private String name; + + @Option(names = "--address", description = "Host:port backend address.") + private String address; + + @Option(names = "--tag", split = ",", description = "Replace server tags. Can be repeated or comma-separated.") + private List tags; + + @Option(names = "--capability", split = ",", description = "Replace server capabilities. Can be repeated or comma-separated.") + private List capabilities; + + @Option(names = "--protocol-range") + private String protocolRange; + + @Option(names = "--weight") + private Integer weight; + + @Option(names = "--soft-capacity") + private Integer softCapacity; + + @Option(names = "--hard-capacity") + private Integer hardCapacity; + + @Option(names = "--drain-mode", arity = "1", description = "Set descriptor drain mode to true or false.") + private Boolean drainMode; + + @Option(names = "--metadata", split = ",", description = "Replace metadata with key=value pairs. Can be repeated or comma-separated.") + private List metadata; + + @Override + public Integer call() { + var json = MAPPER.createObjectNode(); + if (address != null) { + json.put("address", address); + } + if (tags != null) { + json.set("tags", MAPPER.valueToTree(compact(tags))); + } + if (capabilities != null) { + json.set("capabilities", MAPPER.valueToTree(compact(capabilities))); + } + if (protocolRange != null) { + json.put("protocolRange", protocolRange); + } + if (weight != null) { + json.put("weight", weight); + } + if (softCapacity != null) { + json.put("softCapacity", softCapacity); + } + if (hardCapacity != null) { + json.put("hardCapacity", hardCapacity); + } + if (drainMode != null) { + json.put("drainMode", drainMode); + } + if (metadata != null) { + json.set("metadata", MAPPER.valueToTree(parseMetadata(metadata))); + } + return servers.root.print(servers.root.request("PATCH", "/servers/" + name, json.toString())); + } + } + + @Command(name = "remove", description = "Remove a backend server.") + static final class RemoveCommand implements Callable { + @ParentCommand + private ServersCommand servers; + + @CommandLine.Parameters(index = "0", description = "Server name.") + private String name; + + @Override + public Integer call() { + return servers.root.print(servers.root.request("DELETE", "/servers/" + name, null)); + } + } + + @Command(name = "drain", description = "Put a backend server into drain mode.") + static final class DrainCommand implements Callable { + @ParentCommand + private ServersCommand servers; + + @CommandLine.Parameters(index = "0", description = "Server name.") + private String name; + + @Override + public Integer call() { + return servers.root.print(servers.root.request("POST", "/servers/" + name + "/drain", "{}")); + } + } + + @Command(name = "undrain", description = "Take a backend server out of drain mode.") + static final class UndrainCommand implements Callable { + @ParentCommand + private ServersCommand servers; + + @CommandLine.Parameters(index = "0", description = "Server name.") + private String name; + + @Override + public Integer call() { + return servers.root.print(servers.root.request("POST", "/servers/" + name + "/undrain", "{}")); + } + } + + @Command(name = "health", description = "Update backend server health.") + static final class HealthCommand implements Callable { + @ParentCommand + private ServersCommand servers; + + @CommandLine.Parameters(index = "0", description = "Server name.") + private String name; + + @Option(names = "--status", defaultValue = "UP") + private String status; + + @Option(names = "--ping-ms", defaultValue = "-1") + private long backendPingMillis; + + @Option(names = "--failure-rate", defaultValue = "0.0") + private double recentFailureRate; + + @Option(names = "--reason", defaultValue = "") + private String reason; + + @Override + public Integer call() { + var json = MAPPER.createObjectNode(); + json.put("status", status); + json.put("backendPingMillis", backendPingMillis); + json.put("recentFailureRate", recentFailureRate); + json.put("reason", reason); + return servers.root.print(servers.root.request("POST", "/servers/" + name + "/health", json.toString())); + } + } + + @Command(name = "load", description = "Update backend server load.") + static final class LoadCommand implements Callable { + @ParentCommand + private ServersCommand servers; + + @CommandLine.Parameters(index = "0", description = "Server name.") + private String name; + + @Option(names = "--players", defaultValue = "0") + private int players; + + @Option(names = "--soft-capacity", defaultValue = "500") + private int softCapacity; + + @Option(names = "--hard-capacity", defaultValue = "600") + private int hardCapacity; + + @Option(names = "--in-bps", defaultValue = "0") + private long inboundBytesPerSecond; + + @Option(names = "--out-bps", defaultValue = "0") + private long outboundBytesPerSecond; + + @Option(names = "--pps", defaultValue = "0") + private long packetsPerSecond; + + @Option(names = "--event-loop-delay-ms", defaultValue = "0.0") + private double eventLoopDelayMillis; + + @Override + public Integer call() { + ObjectNode json = MAPPER.createObjectNode(); + json.put("players", players); + json.put("softCapacity", softCapacity); + json.put("hardCapacity", hardCapacity); + json.put("inboundBytesPerSecond", inboundBytesPerSecond); + json.put("outboundBytesPerSecond", outboundBytesPerSecond); + json.put("packetsPerSecond", packetsPerSecond); + json.put("eventLoopDelayMillis", eventLoopDelayMillis); + return servers.root.print(servers.root.request("POST", "/servers/" + name + "/load", json.toString())); + } + } + + private static List compact(List values) { + var result = new ArrayList(); + for (var value : values) { + if (value != null && !value.isBlank()) { + result.add(value.trim()); + } + } + return List.copyOf(result); + } + + private static Map parseMetadata(List values) { + var result = new java.util.LinkedHashMap(); + for (var value : compact(values)) { + var splitAt = value.indexOf('='); + if (splitAt <= 0) { + throw new IllegalArgumentException("metadata must use key=value: " + value); + } + result.put(value.substring(0, splitAt), value.substring(splitAt + 1)); + } + return Map.copyOf(result); + } + } + + private static final class CompressionMetricsView { + private final CompressionRow global = new CompressionRow("global"); + private final Map servers = new LinkedHashMap<>(); + private final Map directions = new LinkedHashMap<>(); + private final List decisions = new ArrayList<>(); + private final List rewrites = new ArrayList<>(); + private long negotiations; + + static CompressionMetricsView parse(String metrics) { + var view = new CompressionMetricsView(); + for (var line : metrics.split("\\R")) { + if (line.isBlank() || line.startsWith("#")) { + continue; + } + view.accept(line); + } + return view; + } + + static CompressionMetricsView parseJson(String json) { + var view = new CompressionMetricsView(); + try { + var root = MAPPER.readTree(json); + var rows = root.get("rows"); + if (rows != null && rows.isArray()) { + for (var rowNode : rows) { + var row = new CompressionRow(text(rowNode, "scope", "")); + row.direction = text(rowNode, "direction", "all"); + row.rawBytes = number(rowNode, "rawBytes"); + row.compressedBytes = number(rowNode, "compressedBytes"); + row.savedBytes = number(rowNode, "savedBytes"); + row.ratio = decimal(rowNode, "ratio", 1.0d); + row.samples = number(rowNode, "samples"); + row.threshold = number(rowNode, "threshold"); + var negotiations = number(rowNode, "negotiations"); + if (row.scope.equals("global") && row.direction.equals("all")) { + view.global.samples = row.samples; + view.global.rawBytes = row.rawBytes; + view.global.compressedBytes = row.compressedBytes; + view.global.savedBytes = row.savedBytes; + view.global.ratio = row.ratio; + view.global.threshold = row.threshold; + view.negotiations = negotiations; + } else if (row.direction.equals("all")) { + view.servers.put(row.scope, row); + } else { + view.directions.put(row.scope + "|" + row.direction, row); + } + } + } + var decisions = root.get("decisions"); + if (decisions != null && decisions.isArray()) { + for (var decision : decisions) { + view.decisions.add(new CompressionDecisionRow( + text(decision, "scope", ""), + text(decision, "direction", ""), + text(decision, "action", ""), + number(decision, "threshold"), + number(decision, "count"))); + } + } + var rewrites = root.get("rewrites"); + if (rewrites != null && rewrites.isArray()) { + for (var rewrite : rewrites) { + view.rewrites.add(new CompressionRewriteRow( + text(rewrite, "scope", ""), + text(rewrite, "direction", ""), + text(rewrite, "outcome", ""), + number(rewrite, "count"), + number(rewrite, "cpuNanos"))); + } + } + return view; + } catch (IOException exception) { + return view; + } + } + + String render() { + var output = new StringBuilder(); + output.append("scope direction raw_bytes compressed_bytes saved_bytes ratio samples threshold negotiations\n"); + output.append(global.render(negotiations)).append('\n'); + for (var row : servers.values()) { + output.append(row.render(0)).append('\n'); + } + for (var row : directions.values()) { + output.append(row.render(0)).append('\n'); + } + output.append("decision_scope direction action threshold count\n"); + decisions.stream() + .sorted(Comparator + .comparing(CompressionDecisionRow::scope) + .thenComparing(CompressionDecisionRow::direction) + .thenComparing(CompressionDecisionRow::action) + .thenComparingLong(CompressionDecisionRow::threshold)) + .forEach(row -> output.append(row.render()).append('\n')); + output.append("rewrite_scope direction outcome count cpu_millis\n"); + rewrites.stream() + .sorted(Comparator + .comparing(CompressionRewriteRow::scope) + .thenComparing(CompressionRewriteRow::direction) + .thenComparing(CompressionRewriteRow::outcome)) + .forEach(row -> output.append(row.render()).append('\n')); + return output.toString(); + } + + private void accept(String line) { + var splitAt = line.lastIndexOf(' '); + if (splitAt <= 0 || splitAt == line.length() - 1) { + return; + } + var nameAndLabels = line.substring(0, splitAt); + var value = parseDouble(line.substring(splitAt + 1)); + var name = metricName(nameAndLabels); + var server = labelValue(nameAndLabels, "server"); + var direction = labelValue(nameAndLabels, "direction"); + if (name.equals("strataproxy_compression_decisions_total")) { + acceptDecision(nameAndLabels, value); + return; + } + if (name.equals("strataproxy_compression_rewrites_total")) { + acceptRewrite(nameAndLabels, value, 0); + return; + } + if (name.equals("strataproxy_compression_rewrite_cpu_seconds_total")) { + acceptRewrite(nameAndLabels, 0, Math.round(value * 1_000_000_000L)); + return; + } + if (server == null) { + acceptGlobal(name, value); + } else if (direction == null) { + acceptServer(servers.computeIfAbsent(server, CompressionRow::new), name, value); + } else { + acceptServer(directionalRow(server, direction), name, value); + } + } + + private void acceptGlobal(String name, double value) { + switch (name) { + case "strataproxy_compression_samples_total" -> global.samples = (long) value; + case "strataproxy_compression_raw_bytes_total" -> global.rawBytes = (long) value; + case "strataproxy_compression_compressed_bytes_total" -> global.compressedBytes = (long) value; + case "strataproxy_compression_saved_bytes_total" -> global.savedBytes = (long) value; + case "strataproxy_compression_ratio" -> global.ratio = value; + case "strataproxy_compression_negotiations_total" -> negotiations = (long) value; + default -> { + } + } + } + + private void acceptServer(CompressionRow row, String name, double value) { + switch (name) { + case "strataproxy_compression_samples_total" -> row.samples = (long) value; + case "strataproxy_compression_raw_bytes_total" -> row.rawBytes = (long) value; + case "strataproxy_compression_compressed_bytes_total" -> row.compressedBytes = (long) value; + case "strataproxy_compression_saved_bytes_total" -> row.savedBytes = (long) value; + case "strataproxy_compression_ratio" -> row.ratio = value; + case "strataproxy_server_compression_threshold_bytes" -> row.threshold = (long) value; + default -> { + } + } + } + + private CompressionRow directionalRow(String server, String direction) { + return directions.computeIfAbsent(server + "|" + direction, ignored -> { + var row = new CompressionRow(server); + row.direction = direction; + return row; + }); + } + + private void acceptDecision(String nameAndLabels, double value) { + var server = labelValue(nameAndLabels, "server"); + var direction = labelValue(nameAndLabels, "direction"); + var action = labelValue(nameAndLabels, "action"); + var threshold = labelValue(nameAndLabels, "threshold"); + if (server == null || direction == null || action == null || threshold == null) { + return; + } + decisions.add(new CompressionDecisionRow(server, direction, action, parseLong(threshold), (long) value)); + } + + private void acceptRewrite(String nameAndLabels, double count, long cpuNanos) { + var server = labelValue(nameAndLabels, "server"); + var direction = labelValue(nameAndLabels, "direction"); + var outcome = labelValue(nameAndLabels, "outcome"); + if (server == null || direction == null || outcome == null) { + return; + } + var existing = -1; + for (var i = 0; i < rewrites.size(); i++) { + var row = rewrites.get(i); + if (row.scope().equals(server) && row.direction().equals(direction) && row.outcome().equals(outcome)) { + existing = i; + break; + } + } + if (existing >= 0) { + var row = rewrites.get(existing); + rewrites.set(existing, new CompressionRewriteRow( + row.scope(), + row.direction(), + row.outcome(), + row.count() + (long) count, + row.cpuNanos() + cpuNanos)); + } else { + rewrites.add(new CompressionRewriteRow(server, direction, outcome, (long) count, cpuNanos)); + } + } + + private static String metricName(String nameAndLabels) { + var labelsAt = nameAndLabels.indexOf('{'); + return labelsAt < 0 ? nameAndLabels : nameAndLabels.substring(0, labelsAt); + } + + private static String labelValue(String nameAndLabels, String label) { + var labelsAt = nameAndLabels.indexOf('{'); + if (labelsAt < 0 || !nameAndLabels.endsWith("}")) { + return null; + } + var needle = label + "=\""; + var start = nameAndLabels.indexOf(needle, labelsAt); + if (start < 0) { + return null; + } + start += needle.length(); + var end = nameAndLabels.indexOf('"', start); + return end < 0 ? null : nameAndLabels.substring(start, end); + } + + private static double parseDouble(String value) { + try { + return Double.parseDouble(value); + } catch (NumberFormatException exception) { + return 0.0d; + } + } + + private static long parseLong(String value) { + try { + return Long.parseLong(value); + } catch (NumberFormatException exception) { + return 0L; + } + } + + private static String text(com.fasterxml.jackson.databind.JsonNode node, String field, String fallback) { + return node.has(field) ? node.get(field).asText() : fallback; + } + + private static long number(com.fasterxml.jackson.databind.JsonNode node, String field) { + return node.has(field) ? node.get(field).asLong() : 0L; + } + + private static double decimal(com.fasterxml.jackson.databind.JsonNode node, String field, double fallback) { + return node.has(field) ? node.get(field).asDouble() : fallback; + } + } + + private static final class OverviewMetricsView { + private String healthStatus = "unknown"; + private long configuredServers; + private long activeConnections; + private long routedConnections; + private long rejectedConnections; + private final Map rejectedConnectionsByReason = new LinkedHashMap<>(); + private long failedRoutes; + private long backendConnectFailures; + private long frontendToBackendBytes; + private long backendToFrontendBytes; + private long compressionNegotiations; + private long compressionSavedBytes; + private double compressionRatio = 1.0d; + private long anomalyCount; + private double eventLoopDelaySeconds; + private long directMemoryBytes; + private String transport = "unknown"; + private String nativeTransport = "false"; + + static OverviewMetricsView parse(String healthJson, String metrics) { + var view = new OverviewMetricsView(); + view.acceptHealth(healthJson); + for (var line : metrics.split("\\R")) { + if (line.isBlank() || line.startsWith("#")) { + continue; + } + view.acceptMetric(line); + } + return view; + } + + static OverviewMetricsView parseJson(String json) { + var view = new OverviewMetricsView(); + try { + var root = MAPPER.readTree(json); + view.healthStatus = text(root, "status", "unknown"); + view.configuredServers = number(root, "servers"); + view.activeConnections = number(root, "activeConnections"); + view.routedConnections = number(root, "routedConnections"); + view.rejectedConnections = number(root, "rejectedConnections"); + view.acceptRejectionReasons(root.get("rejectedConnectionsByReason")); + view.failedRoutes = number(root, "failedRoutes"); + view.backendConnectFailures = number(root, "backendConnectFailures"); + view.frontendToBackendBytes = number(root, "frontendToBackendBytes"); + view.backendToFrontendBytes = number(root, "backendToFrontendBytes"); + view.compressionNegotiations = number(root, "compressionNegotiations"); + view.compressionSavedBytes = number(root, "compressionSavedBytes"); + view.compressionRatio = decimal(root, "compressionRatio", 1.0d); + view.anomalyCount = number(root, "packetAnomalies"); + view.eventLoopDelaySeconds = decimal(root, "eventLoopDelaySeconds", 0.0d); + view.directMemoryBytes = number(root, "pooledDirectMemoryBytes"); + view.transport = text(root, "transport", "unknown"); + view.nativeTransport = Boolean.toString(root.has("nativeTransport") && root.get("nativeTransport").asBoolean()); + return view; + } catch (IOException exception) { + view.healthStatus = "unparseable"; + return view; + } + } + + String render() { + var output = new StringBuilder() + .append("status ").append(healthStatus).append('\n') + .append("servers ").append(configuredServers).append('\n') + .append("connections_active ").append(activeConnections).append('\n') + .append("connections_routed_total ").append(routedConnections).append('\n') + .append("connections_rejected_total ").append(rejectedConnections).append('\n'); + rejectedConnectionsByReason.entrySet().stream() + .sorted(Map.Entry.comparingByKey()) + .forEach(entry -> output.append("connections_rejected_total{reason=\"") + .append(entry.getKey()) + .append("\"} ") + .append(entry.getValue()) + .append('\n')); + return output + .append("routes_failed_total ").append(failedRoutes).append('\n') + .append("backend_connect_failures_total ").append(backendConnectFailures).append('\n') + .append("frontend_to_backend_bytes_total ").append(frontendToBackendBytes).append('\n') + .append("backend_to_frontend_bytes_total ").append(backendToFrontendBytes).append('\n') + .append("compression_negotiations_total ").append(compressionNegotiations).append('\n') + .append("compression_saved_bytes_total ").append(compressionSavedBytes).append('\n') + .append("compression_ratio ").append(String.format(java.util.Locale.ROOT, "%.4f", compressionRatio)).append('\n') + .append("packet_anomalies_total ").append(anomalyCount).append('\n') + .append("event_loop_delay_seconds ").append(String.format(java.util.Locale.ROOT, "%.6f", eventLoopDelaySeconds)).append('\n') + .append("pooled_direct_memory_bytes ").append(directMemoryBytes).append('\n') + .append("transport ").append(transport).append('\n') + .append("native_transport ").append(nativeTransport).append('\n') + .toString(); + } + + private void acceptHealth(String body) { + try { + var json = MAPPER.readTree(body); + if (json.has("status")) { + healthStatus = json.get("status").asText(); + } + if (json.has("servers")) { + configuredServers = json.get("servers").asLong(); + } + } catch (IOException exception) { + healthStatus = "unparseable"; + } + } + + private void acceptMetric(String line) { + var splitAt = line.lastIndexOf(' '); + if (splitAt <= 0 || splitAt == line.length() - 1) { + return; + } + var nameAndLabels = line.substring(0, splitAt); + var name = CompressionMetricsView.metricName(nameAndLabels); + var value = CompressionMetricsView.parseDouble(line.substring(splitAt + 1)); + var labeled = nameAndLabels.indexOf('{') >= 0; + if (name.equals("strataproxy_packet_anomalies_total")) { + anomalyCount += (long) value; + return; + } + switch (name) { + case "strataproxy_connections_active" -> activeConnections = (long) value; + case "strataproxy_connections_routed_total" -> routedConnections = (long) value; + case "strataproxy_connections_rejected_total" -> { + if (!labeled) { + rejectedConnections = (long) value; + } else { + var reason = CompressionMetricsView.labelValue(nameAndLabels, "reason"); + if (reason != null) { + rejectedConnectionsByReason.put(reason, (long) value); + } + } + } + case "strataproxy_routes_failed_total" -> failedRoutes = (long) value; + case "strataproxy_backend_connect_failures_total" -> backendConnectFailures = (long) value; + case "strataproxy_frontend_to_backend_bytes_total" -> frontendToBackendBytes = (long) value; + case "strataproxy_backend_to_frontend_bytes_total" -> backendToFrontendBytes = (long) value; + case "strataproxy_compression_negotiations_total" -> compressionNegotiations = (long) value; + case "strataproxy_compression_saved_bytes_total" -> { + if (!labeled) { + compressionSavedBytes = (long) value; + } + } + case "strataproxy_compression_ratio" -> { + if (!labeled) { + compressionRatio = value; + } + } + case "strataproxy_event_loop_delay_seconds" -> eventLoopDelaySeconds = value; + case "strataproxy_pooled_direct_memory_bytes" -> directMemoryBytes = (long) value; + case "strataproxy_network_transport_info" -> { + var transportLabel = CompressionMetricsView.labelValue(nameAndLabels, "transport"); + var nativeLabel = CompressionMetricsView.labelValue(nameAndLabels, "native"); + if (transportLabel != null) { + transport = transportLabel; + } + if (nativeLabel != null) { + nativeTransport = nativeLabel; + } + } + default -> { + } + } + } + + private static String text(com.fasterxml.jackson.databind.JsonNode node, String field, String fallback) { + return node.has(field) ? node.get(field).asText() : fallback; + } + + private static long number(com.fasterxml.jackson.databind.JsonNode node, String field) { + return node.has(field) ? node.get(field).asLong() : 0L; + } + + private static double decimal(com.fasterxml.jackson.databind.JsonNode node, String field, double fallback) { + return node.has(field) ? node.get(field).asDouble() : fallback; + } + + private void acceptRejectionReasons(com.fasterxml.jackson.databind.JsonNode node) { + if (node == null || !node.isObject()) { + return; + } + var fields = node.fields(); + while (fields.hasNext()) { + var entry = fields.next(); + rejectedConnectionsByReason.put(entry.getKey(), entry.getValue().asLong()); + } + } + } + + private static final class ReadyMetricsView { + private String status = "unknown"; + private long readyServers; + private long registeredServers; + private int httpStatus; + + static ReadyMetricsView parseJson(String json, int httpStatus) { + var view = new ReadyMetricsView(); + view.httpStatus = httpStatus; + try { + var root = MAPPER.readTree(json); + view.status = text(root, "status", "unknown"); + view.readyServers = number(root, "readyServers"); + view.registeredServers = number(root, "registeredServers"); + return view; + } catch (IOException exception) { + view.status = "unparseable"; + return view; + } + } + + private static String text(com.fasterxml.jackson.databind.JsonNode node, String field, String fallback) { + return node.has(field) ? node.get(field).asText() : fallback; + } + + private static long number(com.fasterxml.jackson.databind.JsonNode node, String field) { + return node.has(field) ? node.get(field).asLong() : 0L; + } + } + + private static final class SloView { + private final List rows = new ArrayList<>(); + + static SloView from( + OverviewMetricsView overview, + ReadyMetricsView readiness, + double maxEventLoopDelayMillis, + long maxActiveConnections, + long maxRejectedConnections, + long maxAnomalies, + boolean requireNativeTransport) { + var view = new SloView(); + view.rows.add(new SloRow( + "health_status", + overview.healthStatus, + "UP", + "UP".equalsIgnoreCase(overview.healthStatus))); + if (readiness != null) { + view.rows.add(new SloRow( + "readiness_status", + readiness.status + "/" + readiness.readyServers + "/" + readiness.registeredServers + "/" + readiness.httpStatus, + "READY/>=1/registered/200", + "READY".equalsIgnoreCase(readiness.status) + && readiness.readyServers >= 1 + && readiness.httpStatus == 200)); + } + if (maxEventLoopDelayMillis >= 0.0d) { + var actual = overview.eventLoopDelaySeconds * 1000.0d; + view.rows.add(new SloRow( + "event_loop_delay_ms", + format(actual), + "<=" + format(maxEventLoopDelayMillis), + actual <= maxEventLoopDelayMillis)); + } + if (maxActiveConnections >= 0) { + view.rows.add(new SloRow( + "active_connections", + Long.toString(overview.activeConnections), + "<=" + maxActiveConnections, + overview.activeConnections <= maxActiveConnections)); + } + if (maxRejectedConnections >= 0) { + view.rows.add(new SloRow( + "rejected_connections", + Long.toString(overview.rejectedConnections), + "<=" + maxRejectedConnections, + overview.rejectedConnections <= maxRejectedConnections)); + } + if (maxAnomalies >= 0) { + view.rows.add(new SloRow( + "packet_anomalies", + Long.toString(overview.anomalyCount), + "<=" + maxAnomalies, + overview.anomalyCount <= maxAnomalies)); + } + if (requireNativeTransport) { + view.rows.add(new SloRow( + "native_transport", + overview.nativeTransport, + "true", + Boolean.parseBoolean(overview.nativeTransport))); + } + return view; + } + + boolean passed() { + return rows.stream().allMatch(SloRow::passed); + } + + String render() { + var output = new StringBuilder("gate actual expected status\n"); + for (var row : rows) { + output.append(row.name()).append(' ') + .append(value(row.actual())).append(' ') + .append(value(row.expected())).append(' ') + .append(row.passed() ? "PASS" : "FAIL") + .append('\n'); + } + return output.toString(); + } + + private static String format(double value) { + return String.format(java.util.Locale.ROOT, "%.3f", value); + } + + private static String value(String value) { + return value == null || value.isBlank() ? "-" : value.replace(' ', '_'); + } + } + + private record SloRow(String name, String actual, String expected, boolean passed) { + } + + private static final class AnomalyMetricsView { + private final Map rules = new LinkedHashMap<>(); + private final List samples = new ArrayList<>(); + + static AnomalyMetricsView parse(String metrics) { + var view = new AnomalyMetricsView(); + for (var line : metrics.split("\\R")) { + if (line.isBlank() || line.startsWith("#")) { + continue; + } + view.accept(line); + } + return view; + } + + static AnomalyMetricsView parseJson(String json) { + var view = new AnomalyMetricsView(); + try { + var root = MAPPER.readTree(json); + var rules = root.get("rules"); + if (rules == null || !rules.isArray()) { + return view; + } + for (var rule : rules) { + var name = rule.has("rule") ? rule.get("rule").asText() : ""; + if (!name.isBlank()) { + view.rules.put(name, rule.has("count") ? rule.get("count").asLong() : 0L); + } + } + var recentSamples = root.get("recentSamples"); + if (recentSamples != null && recentSamples.isArray()) { + for (var sample : recentSamples) { + view.samples.add(new AnomalySampleRow( + sample.has("sequence") ? sample.get("sequence").asLong() : 0L, + sample.has("timestamp") ? sample.get("timestamp").asText() : "", + sample.has("rule") ? sample.get("rule").asText() : "", + sample.has("server") ? sample.get("server").asText() : "", + sample.has("direction") ? sample.get("direction").asText() : "", + sample.has("protocolState") ? sample.get("protocolState").asText() : "", + sample.has("packetId") ? sample.get("packetId").asInt() : -1, + sample.has("rawSize") ? sample.get("rawSize").asLong() : -1, + sample.has("compressedSize") ? sample.get("compressedSize").asLong() : -1, + sample.has("remoteAddress") ? sample.get("remoteAddress").asText() : "", + sample.has("detail") ? sample.get("detail").asText() : "")); + } + } + return view; + } catch (IOException exception) { + return view; + } + } + + String renderSamples() { + var output = new StringBuilder("sequence timestamp rule server direction state packet_id raw_size compressed_size remote detail\n"); + samples.stream() + .sorted(Comparator.comparingLong(AnomalySampleRow::sequence).reversed()) + .forEach(sample -> output + .append(sample.sequence()).append(' ') + .append(value(sample.timestamp())).append(' ') + .append(value(sample.rule())).append(' ') + .append(value(sample.server())).append(' ') + .append(value(sample.direction())).append(' ') + .append(value(sample.protocolState())).append(' ') + .append(sample.packetId()).append(' ') + .append(sample.rawSize()).append(' ') + .append(sample.compressedSize()).append(' ') + .append(value(sample.remoteAddress())).append(' ') + .append(value(sample.detail())).append('\n')); + return output.toString(); + } + + String render() { + var output = new StringBuilder("rule count\n"); + rules.entrySet().stream() + .sorted(Map.Entry.comparingByValue(Comparator.reverseOrder()) + .thenComparing(Map.Entry.comparingByKey())) + .forEach(entry -> output + .append(entry.getKey()) + .append(' ') + .append(entry.getValue()) + .append('\n')); + return output.toString(); + } + + private void accept(String line) { + var splitAt = line.lastIndexOf(' '); + if (splitAt <= 0 || splitAt == line.length() - 1) { + return; + } + var nameAndLabels = line.substring(0, splitAt); + if (!CompressionMetricsView.metricName(nameAndLabels).equals("strataproxy_packet_anomalies_total")) { + return; + } + var rule = CompressionMetricsView.labelValue(nameAndLabels, "rule"); + if (rule == null || rule.isBlank()) { + return; + } + rules.put(rule, (long) CompressionMetricsView.parseDouble(line.substring(splitAt + 1))); + } + + private static String value(String value) { + return value == null || value.isBlank() ? "-" : value.replace(' ', '_'); + } + } + + private record AnomalySampleRow( + long sequence, + String timestamp, + String rule, + String server, + String direction, + String protocolState, + int packetId, + long rawSize, + long compressedSize, + String remoteAddress, + String detail) { + } + + private static final class PacketTrafficMetricsView { + private final List rows = new ArrayList<>(); + + static PacketTrafficMetricsView parseJson(String json) { + var view = new PacketTrafficMetricsView(); + try { + var root = MAPPER.readTree(json); + var top = root.get("top"); + if (top == null || !top.isArray()) { + return view; + } + for (var row : top) { + view.rows.add(new PacketTrafficRow( + row.has("server") ? row.get("server").asText() : "", + row.has("direction") ? row.get("direction").asText() : "", + row.has("protocolState") ? row.get("protocolState").asText() : "", + row.has("packetId") ? row.get("packetId").asInt() : -1, + row.has("packets") ? row.get("packets").asLong() : 0L, + row.has("rawBytes") ? row.get("rawBytes").asLong() : 0L, + row.has("compressedBytes") ? row.get("compressedBytes").asLong() : 0L)); + } + return view; + } catch (IOException exception) { + return view; + } + } + + static PacketTrafficMetricsView parse(String metrics) { + var counters = new LinkedHashMap(); + for (var line : metrics.split("\\R")) { + if (line.isBlank() || line.startsWith("#")) { + continue; + } + var splitAt = line.lastIndexOf(' '); + if (splitAt <= 0 || splitAt == line.length() - 1) { + continue; + } + var nameAndLabels = line.substring(0, splitAt); + var name = CompressionMetricsView.metricName(nameAndLabels); + if (!name.startsWith("strataproxy_packet_traffic_")) { + continue; + } + var server = CompressionMetricsView.labelValue(nameAndLabels, "server"); + var direction = CompressionMetricsView.labelValue(nameAndLabels, "direction"); + var state = CompressionMetricsView.labelValue(nameAndLabels, "state"); + var packetId = CompressionMetricsView.labelValue(nameAndLabels, "packet_id"); + if (server == null || direction == null || state == null || packetId == null) { + continue; + } + var key = server + "|" + direction + "|" + state + "|" + packetId; + var builder = counters.computeIfAbsent(key, ignored -> new PacketTrafficRowBuilder( + server, + direction, + state, + (int) CompressionMetricsView.parseLong(packetId))); + var value = (long) CompressionMetricsView.parseDouble(line.substring(splitAt + 1)); + switch (name) { + case "strataproxy_packet_traffic_packets_total" -> builder.packets = value; + case "strataproxy_packet_traffic_raw_bytes_total" -> builder.rawBytes = value; + case "strataproxy_packet_traffic_compressed_bytes_total" -> builder.compressedBytes = value; + default -> { + } + } + } + var view = new PacketTrafficMetricsView(); + counters.values().stream() + .map(PacketTrafficRowBuilder::build) + .sorted(Comparator + .comparingLong(PacketTrafficRow::rawBytes) + .reversed() + .thenComparing(PacketTrafficRow::server) + .thenComparing(PacketTrafficRow::direction) + .thenComparing(PacketTrafficRow::protocolState) + .thenComparingInt(PacketTrafficRow::packetId)) + .forEach(view.rows::add); + return view; + } + + String render() { + var output = new StringBuilder("server direction state packet_id packets raw_bytes compressed_bytes\n"); + for (var row : rows) { + output.append(value(row.server())).append(' ') + .append(value(row.direction())).append(' ') + .append(value(row.protocolState())).append(' ') + .append(row.packetId()).append(' ') + .append(row.packets()).append(' ') + .append(row.rawBytes()).append(' ') + .append(row.compressedBytes()).append('\n'); + } + return output.toString(); + } + + private static String value(String value) { + return value == null || value.isBlank() ? "-" : value.replace(' ', '_'); + } + } + + private static final class PacketTrafficRowBuilder { + private final String server; + private final String direction; + private final String protocolState; + private final int packetId; + private long packets; + private long rawBytes; + private long compressedBytes; + + private PacketTrafficRowBuilder(String server, String direction, String protocolState, int packetId) { + this.server = server; + this.direction = direction; + this.protocolState = protocolState; + this.packetId = packetId; + } + + private PacketTrafficRow build() { + return new PacketTrafficRow(server, direction, protocolState, packetId, packets, rawBytes, compressedBytes); + } + } + + private record PacketTrafficRow( + String server, + String direction, + String protocolState, + int packetId, + long packets, + long rawBytes, + long compressedBytes) { + } + + private static final class CustomPayloadMetricsView { + private final List rows = new ArrayList<>(); + + static CustomPayloadMetricsView parseJson(String json) { + var view = new CustomPayloadMetricsView(); + try { + var root = MAPPER.readTree(json); + var top = root.get("top"); + if (top == null || !top.isArray()) { + return view; + } + for (var row : top) { + view.rows.add(new CustomPayloadRow( + row.has("server") ? row.get("server").asText() : "", + row.has("direction") ? row.get("direction").asText() : "", + row.has("kind") ? row.get("kind").asText() : "", + row.has("channel") ? row.get("channel").asText() : "", + row.has("packets") ? row.get("packets").asLong() : 0L, + row.has("payloadBytes") ? row.get("payloadBytes").asLong() : 0L, + row.has("compressedBytes") ? row.get("compressedBytes").asLong() : 0L, + row.has("maxPayloadBytes") ? row.get("maxPayloadBytes").asLong() : 0L, + row.has("maxCompressedBytes") ? row.get("maxCompressedBytes").asLong() : 0L, + row.has("firstSeen") ? row.get("firstSeen").asText() : "", + row.has("lastSeen") ? row.get("lastSeen").asText() : "")); + } + return view; + } catch (IOException exception) { + return view; + } + } + + String render() { + var output = new StringBuilder("server direction kind channel packets payload_bytes compressed_bytes max_payload_bytes max_compressed_bytes first_seen last_seen\n"); + for (var row : rows) { + output.append(value(row.server())).append(' ') + .append(value(row.direction())).append(' ') + .append(value(row.kind())).append(' ') + .append(value(row.channel())).append(' ') + .append(row.packets()).append(' ') + .append(row.payloadBytes()).append(' ') + .append(row.compressedBytes()).append(' ') + .append(row.maxPayloadBytes()).append(' ') + .append(row.maxCompressedBytes()).append(' ') + .append(value(row.firstSeen())).append(' ') + .append(value(row.lastSeen())).append('\n'); + } + return output.toString(); + } + + private static String value(String value) { + return value == null || value.isBlank() ? "-" : value.replace(' ', '_'); + } + } + + private record CustomPayloadRow( + String server, + String direction, + String kind, + String channel, + long packets, + long payloadBytes, + long compressedBytes, + long maxPayloadBytes, + long maxCompressedBytes, + String firstSeen, + String lastSeen) { + } + + private static final class PlayerSessionMetricsView { + private final List rows = new ArrayList<>(); + + static PlayerSessionMetricsView parseJson(String json) { + var view = new PlayerSessionMetricsView(); + try { + var root = MAPPER.readTree(json); + var players = root.get("players"); + if (players == null || !players.isArray()) { + return view; + } + for (var player : players) { + view.rows.add(new PlayerSessionRow( + text(player, "player"), + text(player, "server"), + text(player, "remoteAddress"), + text(player, "connectedAt"))); + } + return view; + } catch (IOException exception) { + return view; + } + } + + String render() { + var output = new StringBuilder("player server remote_address connected_at\n"); + for (var row : rows) { + output.append(value(row.player())).append(' ') + .append(value(row.server())).append(' ') + .append(value(row.remoteAddress())).append(' ') + .append(value(row.connectedAt())).append('\n'); + } + return output.toString(); + } + + private static String text(com.fasterxml.jackson.databind.JsonNode node, String field) { + return node.has(field) && !node.get(field).isNull() ? node.get(field).asText() : ""; + } + + private static String value(String value) { + return value == null || value.isBlank() ? "-" : value.replace(' ', '_'); + } + } + + private record PlayerSessionRow(String player, String server, String remoteAddress, String connectedAt) { + } + + private static final class RoutePreviewMetricsView { + private boolean selected; + private String server = ""; + private double score; + private String reason = ""; + private String route = ""; + private int protocolVersion; + private String remoteAddress = ""; + private final List candidates = new ArrayList<>(); + + static RoutePreviewMetricsView parseJson(String json) { + var view = new RoutePreviewMetricsView(); + try { + var root = MAPPER.readTree(json); + view.selected = root.has("selected") && root.get("selected").asBoolean(); + view.server = text(root, "server"); + view.score = root.has("score") ? root.get("score").asDouble() : 0.0d; + view.reason = text(root, "reason"); + view.route = text(root, "route"); + view.protocolVersion = root.has("protocolVersion") ? root.get("protocolVersion").asInt() : 0; + view.remoteAddress = text(root, "remoteAddress"); + var candidates = root.get("candidates"); + if (candidates != null && candidates.isArray()) { + for (var candidate : candidates) { + view.candidates.add(new RouteCandidateRow( + text(candidate, "server"), + candidate.has("eligible") && candidate.get("eligible").asBoolean(), + text(candidate, "reason"), + candidate.has("effectiveWeight") ? candidate.get("effectiveWeight").asDouble() : 0.0d, + candidate.has("selectionKey") ? candidate.get("selectionKey").asDouble() : 0.0d)); + } + } + return view; + } catch (IOException exception) { + view.reason = "unparseable"; + return view; + } + } + + String render() { + var output = new StringBuilder("selected server score reason route protocol_version remote_address\n"); + output.append(selected).append(' ') + .append(value(server)).append(' ') + .append(String.format(java.util.Locale.ROOT, "%.4f", score)).append(' ') + .append(value(reason)).append(' ') + .append(value(route)).append(' ') + .append(protocolVersion).append(' ') + .append(value(remoteAddress)).append('\n'); + output.append("candidate eligible reason effective_weight selection_key\n"); + for (var candidate : candidates) { + output.append(value(candidate.server())).append(' ') + .append(candidate.eligible()).append(' ') + .append(value(candidate.reason())).append(' ') + .append(String.format(java.util.Locale.ROOT, "%.4f", candidate.effectiveWeight())).append(' ') + .append(candidate.selectionKey() == Double.POSITIVE_INFINITY + ? "inf" + : String.format(java.util.Locale.ROOT, "%.6f", candidate.selectionKey())) + .append('\n'); + } + return output.toString(); + } + + private static String text(com.fasterxml.jackson.databind.JsonNode node, String field) { + return node.has(field) && !node.get(field).isNull() ? node.get(field).asText() : ""; + } + + private static String value(String value) { + return value == null || value.isBlank() ? "-" : value.replace(' ', '_'); + } + } + + private record RouteCandidateRow( + String server, + boolean eligible, + String reason, + double effectiveWeight, + double selectionKey) { + } + + private static final class PayloadCaptureMetricsView { + private static final String HEADER = "id server direction max_samples max_bytes_per_sample expires_at sample_count\n"; + private final List rows = new ArrayList<>(); + + static PayloadCaptureMetricsView parseListJson(String json) { + var view = new PayloadCaptureMetricsView(); + try { + var root = MAPPER.readTree(json); + var captures = root.get("captures"); + if (captures != null && captures.isArray()) { + for (var capture : captures) { + view.rows.add(row(capture)); + } + } + return view; + } catch (IOException exception) { + return view; + } + } + + static PayloadCaptureMetricsView parseCaptureJson(String json) { + var view = new PayloadCaptureMetricsView(); + try { + view.rows.add(row(MAPPER.readTree(json))); + return view; + } catch (IOException exception) { + return view; + } + } + + String render() { + var output = new StringBuilder(HEADER); + for (var row : rows) { + output.append(value(row.id())).append(' ') + .append(value(row.server())).append(' ') + .append(value(row.direction())).append(' ') + .append(row.maxSamples()).append(' ') + .append(row.maxBytesPerSample()).append(' ') + .append(value(row.expiresAt())).append(' ') + .append(row.sampleCount()).append('\n'); + } + return output.toString(); + } + + private static PayloadCaptureRow row(com.fasterxml.jackson.databind.JsonNode node) { + return new PayloadCaptureRow( + text(node, "id", ""), + text(node, "server", ""), + text(node, "direction", ""), + intValue(node, "maxSamples"), + intValue(node, "maxBytesPerSample"), + text(node, "expiresAt", ""), + intValue(node, "sampleCount")); + } + + private static int intValue(com.fasterxml.jackson.databind.JsonNode node, String field) { + return node.has(field) ? node.get(field).asInt() : 0; + } + + private static String text(com.fasterxml.jackson.databind.JsonNode node, String field, String fallback) { + return node.has(field) && !node.get(field).isNull() ? node.get(field).asText() : fallback; + } + + private static String value(String value) { + return value == null || value.isBlank() ? "-" : value.replace(' ', '_'); + } + } + + private static final class PayloadCaptureExportView { + private PayloadCaptureRow capture = new PayloadCaptureRow("", "", "", 0, 0, "", 0); + private final List samples = new ArrayList<>(); + + static PayloadCaptureExportView parseJson(String json) { + var view = new PayloadCaptureExportView(); + try { + var root = MAPPER.readTree(json); + var capture = root.get("capture"); + if (capture != null && capture.isObject()) { + view.capture = PayloadCaptureMetricsView.row(capture); + } + var samples = root.get("samples"); + if (samples != null && samples.isArray()) { + for (var sample : samples) { + view.samples.add(new PayloadCaptureSampleRow( + sample.has("sequence") ? sample.get("sequence").asLong() : 0L, + text(sample, "captureId", ""), + text(sample, "server", ""), + text(sample, "direction", ""), + sample.has("rawBytes") ? sample.get("rawBytes").asLong() : 0L, + sample.has("compressedBytes") ? sample.get("compressedBytes").asLong() : 0L, + text(sample, "prefixBase64", ""), + text(sample, "player", ""), + text(sample, "remoteAddress", ""), + text(sample, "timestamp", ""))); + } + } + return view; + } catch (IOException exception) { + return view; + } + } + + String render() { + var output = new StringBuilder(PayloadCaptureMetricsView.HEADER); + output.append(PayloadCaptureMetricsView.value(capture.id())).append(' ') + .append(PayloadCaptureMetricsView.value(capture.server())).append(' ') + .append(PayloadCaptureMetricsView.value(capture.direction())).append(' ') + .append(capture.maxSamples()).append(' ') + .append(capture.maxBytesPerSample()).append(' ') + .append(PayloadCaptureMetricsView.value(capture.expiresAt())).append(' ') + .append(capture.sampleCount()).append('\n'); + output.append("sequence capture_id server direction raw_bytes compressed_bytes player remote_address prefix_base64 timestamp\n"); + for (var sample : samples) { + output.append(sample.sequence()).append(' ') + .append(PayloadCaptureMetricsView.value(sample.captureId())).append(' ') + .append(PayloadCaptureMetricsView.value(sample.server())).append(' ') + .append(PayloadCaptureMetricsView.value(sample.direction())).append(' ') + .append(sample.rawBytes()).append(' ') + .append(sample.compressedBytes()).append(' ') + .append(PayloadCaptureMetricsView.value(sample.player())).append(' ') + .append(PayloadCaptureMetricsView.value(sample.remoteAddress())).append(' ') + .append(PayloadCaptureMetricsView.value(sample.prefixBase64())).append(' ') + .append(PayloadCaptureMetricsView.value(sample.timestamp())).append('\n'); + } + return output.toString(); + } + + private static String text(com.fasterxml.jackson.databind.JsonNode node, String field, String fallback) { + return node.has(field) && !node.get(field).isNull() ? node.get(field).asText() : fallback; + } + } + + private record PayloadCaptureRow( + String id, + String server, + String direction, + int maxSamples, + int maxBytesPerSample, + String expiresAt, + int sampleCount) { + } + + private record PayloadCaptureSampleRow( + long sequence, + String captureId, + String server, + String direction, + long rawBytes, + long compressedBytes, + String prefixBase64, + String player, + String remoteAddress, + String timestamp) { + } + + private static final class BackpressureMetricsView { + private final List rows = new ArrayList<>(); + + static BackpressureMetricsView parseJson(String json) { + var view = new BackpressureMetricsView(); + try { + var root = MAPPER.readTree(json); + var report = root.get("relayBackpressure"); + if (report == null) { + return view; + } + var top = report.get("top"); + if (top == null || !top.isArray()) { + return view; + } + for (var row : top) { + view.rows.add(new BackpressureRow( + row.has("server") ? row.get("server").asText() : "", + row.has("direction") ? row.get("direction").asText() : "", + row.has("events") ? row.get("events").asLong() : 0L, + row.has("lastBytesBeforeWritable") ? row.get("lastBytesBeforeWritable").asLong() : 0L, + row.has("maxBytesBeforeWritable") ? row.get("maxBytesBeforeWritable").asLong() : 0L)); + } + return view; + } catch (IOException exception) { + return view; + } + } + + static BackpressureMetricsView parse(String metrics) { + var counters = new LinkedHashMap(); + for (var line : metrics.split("\\R")) { + if (line.isBlank() || line.startsWith("#")) { + continue; + } + var splitAt = line.lastIndexOf(' '); + if (splitAt <= 0 || splitAt == line.length() - 1) { + continue; + } + var nameAndLabels = line.substring(0, splitAt); + var name = CompressionMetricsView.metricName(nameAndLabels); + if (!name.startsWith("strataproxy_relay_backpressure_")) { + continue; + } + var server = CompressionMetricsView.labelValue(nameAndLabels, "server"); + var direction = CompressionMetricsView.labelValue(nameAndLabels, "direction"); + if (server == null || direction == null) { + continue; + } + var builder = counters.computeIfAbsent(server + "|" + direction, ignored -> new BackpressureRowBuilder(server, direction)); + var value = (long) CompressionMetricsView.parseDouble(line.substring(splitAt + 1)); + switch (name) { + case "strataproxy_relay_backpressure_events_total" -> builder.events = value; + case "strataproxy_relay_backpressure_last_bytes_before_writable" -> builder.lastBytesBeforeWritable = value; + case "strataproxy_relay_backpressure_max_bytes_before_writable" -> builder.maxBytesBeforeWritable = value; + default -> { + } + } + } + var view = new BackpressureMetricsView(); + counters.values().stream() + .map(BackpressureRowBuilder::build) + .sorted(Comparator + .comparingLong(BackpressureRow::events) + .reversed() + .thenComparing(BackpressureRow::server) + .thenComparing(BackpressureRow::direction)) + .forEach(view.rows::add); + return view; + } + + String render() { + var output = new StringBuilder("server direction events last_bytes_before_writable max_bytes_before_writable\n"); + for (var row : rows) { + output.append(value(row.server())).append(' ') + .append(value(row.direction())).append(' ') + .append(row.events()).append(' ') + .append(row.lastBytesBeforeWritable()).append(' ') + .append(row.maxBytesBeforeWritable()).append('\n'); + } + return output.toString(); + } + + private static String value(String value) { + return value == null || value.isBlank() ? "-" : value.replace(' ', '_'); + } + } + + private static final class BackpressureRowBuilder { + private final String server; + private final String direction; + private long events; + private long lastBytesBeforeWritable; + private long maxBytesBeforeWritable; + + private BackpressureRowBuilder(String server, String direction) { + this.server = server; + this.direction = direction; + } + + private BackpressureRow build() { + return new BackpressureRow(server, direction, events, lastBytesBeforeWritable, maxBytesBeforeWritable); + } + } + + private record BackpressureRow( + String server, + String direction, + long events, + long lastBytesBeforeWritable, + long maxBytesBeforeWritable) { + } + + private static final class CompressionRow { + private final String scope; + private String direction = "all"; + private long samples; + private long rawBytes; + private long compressedBytes; + private long savedBytes; + private double ratio = 1.0d; + private long threshold = -1; + + private CompressionRow(String scope) { + this.scope = scope; + } + + private String render(long negotiations) { + return scope + + ' ' + direction + + ' ' + rawBytes + + ' ' + compressedBytes + + ' ' + savedBytes + + ' ' + String.format(java.util.Locale.ROOT, "%.4f", ratio) + + ' ' + samples + + ' ' + threshold + + ' ' + negotiations; + } + } + + private record CompressionDecisionRow(String scope, String direction, String action, long threshold, long count) { + private String render() { + return scope + + ' ' + direction + + ' ' + action + + ' ' + threshold + + ' ' + count; + } + } + + private record CompressionRewriteRow(String scope, String direction, String outcome, long count, long cpuNanos) { + private String render() { + return scope + + ' ' + direction + + ' ' + outcome + + ' ' + count + + ' ' + String.format(java.util.Locale.ROOT, "%.3f", cpuNanos / 1_000_000.0d); + } + } +} diff --git a/proxy-admin-cli/src/test/java/dev/strataproxy/admin/cli/StrataProxyAdminCliTest.java b/proxy-admin-cli/src/test/java/dev/strataproxy/admin/cli/StrataProxyAdminCliTest.java new file mode 100644 index 0000000..f2850ed --- /dev/null +++ b/proxy-admin-cli/src/test/java/dev/strataproxy/admin/cli/StrataProxyAdminCliTest.java @@ -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 args) throws Exception { + var executable = Path.of(System.getProperty("java.home"), "bin", windows() ? "keytool.exe" : "keytool").toString(); + var command = new ArrayList(); + 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) { + } +} diff --git a/proxy-api/build.gradle.kts b/proxy-api/build.gradle.kts new file mode 100644 index 0000000..5f94ddd --- /dev/null +++ b/proxy-api/build.gradle.kts @@ -0,0 +1,3 @@ +plugins { + id("strataproxy.java-library-conventions") +} diff --git a/proxy-api/src/main/java/dev/strataproxy/api/server/DrainPolicy.java b/proxy-api/src/main/java/dev/strataproxy/api/server/DrainPolicy.java new file mode 100644 index 0000000..2f7ee56 --- /dev/null +++ b/proxy-api/src/main/java/dev/strataproxy/api/server/DrainPolicy.java @@ -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); + } +} diff --git a/proxy-api/src/main/java/dev/strataproxy/api/server/ProtocolRange.java b/proxy-api/src/main/java/dev/strataproxy/api/server/ProtocolRange.java new file mode 100644 index 0000000..3af3558 --- /dev/null +++ b/proxy-api/src/main/java/dev/strataproxy/api/server/ProtocolRange.java @@ -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; + } +} diff --git a/proxy-api/src/main/java/dev/strataproxy/api/server/RegisteredServer.java b/proxy-api/src/main/java/dev/strataproxy/api/server/RegisteredServer.java new file mode 100644 index 0000000..e5ff5bd --- /dev/null +++ b/proxy-api/src/main/java/dev/strataproxy/api/server/RegisteredServer.java @@ -0,0 +1,11 @@ +package dev.strataproxy.api.server; + +public interface RegisteredServer { + ServerDescriptor descriptor(); + + ServerHealth health(); + + ServerLoad load(); + + boolean draining(); +} diff --git a/proxy-api/src/main/java/dev/strataproxy/api/server/ServerCapability.java b/proxy-api/src/main/java/dev/strataproxy/api/server/ServerCapability.java new file mode 100644 index 0000000..e3fd9b8 --- /dev/null +++ b/proxy-api/src/main/java/dev/strataproxy/api/server/ServerCapability.java @@ -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 +} diff --git a/proxy-api/src/main/java/dev/strataproxy/api/server/ServerDescriptor.java b/proxy-api/src/main/java/dev/strataproxy/api/server/ServerDescriptor.java new file mode 100644 index 0000000..7330f09 --- /dev/null +++ b/proxy-api/src/main/java/dev/strataproxy/api/server/ServerDescriptor.java @@ -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 tags, + Set capabilities, + ProtocolRange protocolRange, + int weight, + int softCapacity, + int hardCapacity, + boolean drainMode, + Map 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); + } +} diff --git a/proxy-api/src/main/java/dev/strataproxy/api/server/ServerHealth.java b/proxy-api/src/main/java/dev/strataproxy/api/server/ServerHealth.java new file mode 100644 index 0000000..db1fbf0 --- /dev/null +++ b/proxy-api/src/main/java/dev/strataproxy/api/server/ServerHealth.java @@ -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; + } +} diff --git a/proxy-api/src/main/java/dev/strataproxy/api/server/ServerHealthStatus.java b/proxy-api/src/main/java/dev/strataproxy/api/server/ServerHealthStatus.java new file mode 100644 index 0000000..ea5d23e --- /dev/null +++ b/proxy-api/src/main/java/dev/strataproxy/api/server/ServerHealthStatus.java @@ -0,0 +1,8 @@ +package dev.strataproxy.api.server; + +public enum ServerHealthStatus { + UP, + DEGRADED, + MAINTENANCE, + DOWN +} diff --git a/proxy-api/src/main/java/dev/strataproxy/api/server/ServerLoad.java b/proxy-api/src/main/java/dev/strataproxy/api/server/ServerLoad.java new file mode 100644 index 0000000..02f0098 --- /dev/null +++ b/proxy-api/src/main/java/dev/strataproxy/api/server/ServerLoad.java @@ -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; + } +} diff --git a/proxy-api/src/main/java/dev/strataproxy/api/server/ServerRegistry.java b/proxy-api/src/main/java/dev/strataproxy/api/server/ServerRegistry.java new file mode 100644 index 0000000..cbd565b --- /dev/null +++ b/proxy-api/src/main/java/dev/strataproxy/api/server/ServerRegistry.java @@ -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 get(String name); + + Collection snapshot(); + + void updateHealth(String name, ServerHealth health); + + void updateLoad(String name, ServerLoad load); + + void updateDrainMode(String name, boolean drainMode); +} diff --git a/proxy-app/build.gradle.kts b/proxy-app/build.gradle.kts new file mode 100644 index 0000000..ab03347 --- /dev/null +++ b/proxy-app/build.gradle.kts @@ -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("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("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("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("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") + } + } + } +} diff --git a/proxy-app/src/main/java/dev/strataproxy/app/ConfigPathResolver.java b/proxy-app/src/main/java/dev/strataproxy/app/ConfigPathResolver.java new file mode 100644 index 0000000..9000285 --- /dev/null +++ b/proxy-app/src/main/java/dev/strataproxy/app/ConfigPathResolver.java @@ -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 environment, Path workingDirectory) { + return resolve(args, environment, workingDirectory, null); + } + + static Path resolve(String[] args, Map 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; + } +} diff --git a/proxy-app/src/main/java/dev/strataproxy/app/ProxyObservedLoadReporter.java b/proxy-app/src/main/java/dev/strataproxy/app/ProxyObservedLoadReporter.java new file mode 100644 index 0000000..8ff9927 --- /dev/null +++ b/proxy-app/src/main/java/dev/strataproxy/app/ProxyObservedLoadReporter.java @@ -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 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(); + 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) { + } +} diff --git a/proxy-app/src/main/java/dev/strataproxy/app/StrataProxyLauncher.java b/proxy-app/src/main/java/dev/strataproxy/app/StrataProxyLauncher.java new file mode 100644 index 0000000..f001d53 --- /dev/null +++ b/proxy-app/src/main/java/dev/strataproxy/app/StrataProxyLauncher.java @@ -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(); + 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 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 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 started, + ProxyMetrics metrics, + AtomicBoolean closed, + CountDownLatch stopped) implements AutoCloseable { + RunningProxy(NettyProxyNetworkServer server, List 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(); + 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); + } + } +} diff --git a/proxy-app/src/main/resources/config/strataproxy-production.yml b/proxy-app/src/main/resources/config/strataproxy-production.yml new file mode 100644 index 0000000..1006bc1 --- /dev/null +++ b/proxy-app/src/main/resources/config/strataproxy-production.yml @@ -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: [] diff --git a/proxy-app/src/main/resources/config/strataproxy.yml b/proxy-app/src/main/resources/config/strataproxy.yml new file mode 100644 index 0000000..7e4b0ea --- /dev/null +++ b/proxy-app/src/main/resources/config/strataproxy.yml @@ -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" diff --git a/proxy-app/src/test/java/dev/strataproxy/app/ConfigPathResolverTest.java b/proxy-app/src/test/java/dev/strataproxy/app/ConfigPathResolverTest.java new file mode 100644 index 0000000..40abde0 --- /dev/null +++ b/proxy-app/src/test/java/dev/strataproxy/app/ConfigPathResolverTest.java @@ -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); + } +} diff --git a/proxy-app/src/test/java/dev/strataproxy/app/ProxyObservedLoadReporterTest.java b/proxy-app/src/test/java/dev/strataproxy/app/ProxyObservedLoadReporterTest.java new file mode 100644 index 0000000..f3853f5 --- /dev/null +++ b/proxy-app/src/test/java/dev/strataproxy/app/ProxyObservedLoadReporterTest.java @@ -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()); + } +} diff --git a/proxy-app/src/test/java/dev/strataproxy/app/StrataProxyLauncherValidationTest.java b/proxy-app/src/test/java/dev/strataproxy/app/StrataProxyLauncherValidationTest.java new file mode 100644 index 0000000..0825381 --- /dev/null +++ b/proxy-app/src/test/java/dev/strataproxy/app/StrataProxyLauncherValidationTest.java @@ -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) { + } +} diff --git a/proxy-bootstrap/build.gradle.kts b/proxy-bootstrap/build.gradle.kts new file mode 100644 index 0000000..4216972 --- /dev/null +++ b/proxy-bootstrap/build.gradle.kts @@ -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()) +} diff --git a/proxy-bootstrap/src/main/java/dev/strataproxy/bootstrap/ConfigLoader.java b/proxy-bootstrap/src/main/java/dev/strataproxy/bootstrap/ConfigLoader.java new file mode 100644 index 0000000..edb22f8 --- /dev/null +++ b/proxy-bootstrap/src/main/java/dev/strataproxy/bootstrap/ConfigLoader.java @@ -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 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 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 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.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 tags = Set.of(); + public Set 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 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 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; + } +} diff --git a/proxy-bootstrap/src/main/java/dev/strataproxy/bootstrap/ConfigValidationResult.java b/proxy-bootstrap/src/main/java/dev/strataproxy/bootstrap/ConfigValidationResult.java new file mode 100644 index 0000000..e52ed30 --- /dev/null +++ b/proxy-bootstrap/src/main/java/dev/strataproxy/bootstrap/ConfigValidationResult.java @@ -0,0 +1,14 @@ +package dev.strataproxy.bootstrap; + +import java.util.List; + +public record ConfigValidationResult(List errors, List 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(); + } +} diff --git a/proxy-bootstrap/src/main/java/dev/strataproxy/bootstrap/ConfigValidator.java b/proxy-bootstrap/src/main/java/dev/strataproxy/bootstrap/ConfigValidator.java new file mode 100644 index 0000000..d4ae29b --- /dev/null +++ b/proxy-bootstrap/src/main/java/dev/strataproxy/bootstrap/ConfigValidator.java @@ -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(); + var warnings = new ArrayList(); + 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 errors, ArrayList 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 errors, ArrayList 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 servers, ArrayList errors, ArrayList warnings) { + var names = new HashSet(); + 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 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 errors) { + if (duration == null || duration.isZero() || duration.isNegative()) { + errors.add(field + " must be positive"); + } + } +} diff --git a/proxy-bootstrap/src/main/java/dev/strataproxy/bootstrap/ProxyConfig.java b/proxy-bootstrap/src/main/java/dev/strataproxy/bootstrap/ProxyConfig.java new file mode 100644 index 0000000..7eb0390 --- /dev/null +++ b/proxy-bootstrap/src/main/java/dev/strataproxy/bootstrap/ProxyConfig.java @@ -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); + } + } +} diff --git a/proxy-bootstrap/src/test/java/dev/strataproxy/bootstrap/ConfigLoaderTest.java b/proxy-bootstrap/src/test/java/dev/strataproxy/bootstrap/ConfigLoaderTest.java new file mode 100644 index 0000000..a2ea1a2 --- /dev/null +++ b/proxy-bootstrap/src/test/java/dev/strataproxy/bootstrap/ConfigLoaderTest.java @@ -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())); + } +} diff --git a/proxy-bootstrap/src/test/java/dev/strataproxy/bootstrap/ConfigValidatorTest.java b/proxy-bootstrap/src/test/java/dev/strataproxy/bootstrap/ConfigValidatorTest.java new file mode 100644 index 0000000..8ad5536 --- /dev/null +++ b/proxy-bootstrap/src/test/java/dev/strataproxy/bootstrap/ConfigValidatorTest.java @@ -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()); + } +} diff --git a/proxy-build-logic/build.gradle.kts b/proxy-build-logic/build.gradle.kts new file mode 100644 index 0000000..52ebebc --- /dev/null +++ b/proxy-build-logic/build.gradle.kts @@ -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" + } + } +} diff --git a/proxy-build-logic/settings.gradle.kts b/proxy-build-logic/settings.gradle.kts new file mode 100644 index 0000000..1502958 --- /dev/null +++ b/proxy-build-logic/settings.gradle.kts @@ -0,0 +1,8 @@ +dependencyResolutionManagement { + repositories { + gradlePluginPortal() + mavenCentral() + } +} + +rootProject.name = "proxy-build-logic" diff --git a/proxy-build-logic/src/main/kotlin/StrataProxyApplicationConventionsPlugin.kt b/proxy-build-logic/src/main/kotlin/StrataProxyApplicationConventionsPlugin.kt new file mode 100644 index 0000000..0b6aba5 --- /dev/null +++ b/proxy-build-logic/src/main/kotlin/StrataProxyApplicationConventionsPlugin.kt @@ -0,0 +1,10 @@ +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.api.plugins.ApplicationPlugin + +class StrataProxyApplicationConventionsPlugin : Plugin { + override fun apply(project: Project) { + project.plugins.apply(StrataProxyJavaLibraryConventionsPlugin::class.java) + project.plugins.apply(ApplicationPlugin::class.java) + } +} diff --git a/proxy-build-logic/src/main/kotlin/StrataProxyJavaLibraryConventionsPlugin.kt b/proxy-build-logic/src/main/kotlin/StrataProxyJavaLibraryConventionsPlugin.kt new file mode 100644 index 0000000..8cd2227 --- /dev/null +++ b/proxy-build-logic/src/main/kotlin/StrataProxyJavaLibraryConventionsPlugin.kt @@ -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 { + override fun apply(project: Project) { + project.plugins.apply(JavaLibraryPlugin::class.java) + + project.extensions.configure { + toolchain { + languageVersion.set(JavaLanguageVersion.of(25)) + } + } + + project.tasks.withType().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") + } + } +} diff --git a/proxy-build-logic/src/main/kotlin/VersionCatalogAccess.kt b/proxy-build-logic/src/main/kotlin/VersionCatalogAccess.kt new file mode 100644 index 0000000..1b03531 --- /dev/null +++ b/proxy-build-logic/src/main/kotlin/VersionCatalogAccess.kt @@ -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().named("libs") diff --git a/proxy-codec-minecraft/build.gradle.kts b/proxy-codec-minecraft/build.gradle.kts new file mode 100644 index 0000000..3fce48c --- /dev/null +++ b/proxy-codec-minecraft/build.gradle.kts @@ -0,0 +1,8 @@ +plugins { + id("strataproxy.java-library-conventions") +} + +dependencies { + api(project(":proxy-protocol")) + implementation(libs.findLibrary("netty-codec").get()) +} diff --git a/proxy-codec-minecraft/src/main/java/dev/strataproxy/codec/minecraft/MinecraftCodecException.java b/proxy-codec-minecraft/src/main/java/dev/strataproxy/codec/minecraft/MinecraftCodecException.java new file mode 100644 index 0000000..108a528 --- /dev/null +++ b/proxy-codec-minecraft/src/main/java/dev/strataproxy/codec/minecraft/MinecraftCodecException.java @@ -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); + } +} diff --git a/proxy-codec-minecraft/src/main/java/dev/strataproxy/codec/minecraft/MinecraftCompressedFrameRewriter.java b/proxy-codec-minecraft/src/main/java/dev/strataproxy/codec/minecraft/MinecraftCompressedFrameRewriter.java new file mode 100644 index 0000000..199c753 --- /dev/null +++ b/proxy-codec-minecraft/src/main/java/dev/strataproxy/codec/minecraft/MinecraftCompressedFrameRewriter.java @@ -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(); + 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(); + } + } +} diff --git a/proxy-codec-minecraft/src/main/java/dev/strataproxy/codec/minecraft/MinecraftCompressionCodec.java b/proxy-codec-minecraft/src/main/java/dev/strataproxy/codec/minecraft/MinecraftCompressionCodec.java new file mode 100644 index 0000000..d7d9678 --- /dev/null +++ b/proxy-codec-minecraft/src/main/java/dev/strataproxy/codec/minecraft/MinecraftCompressionCodec.java @@ -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; + } +} diff --git a/proxy-codec-minecraft/src/main/java/dev/strataproxy/codec/minecraft/MinecraftCompressionFrameDecoder.java b/proxy-codec-minecraft/src/main/java/dev/strataproxy/codec/minecraft/MinecraftCompressionFrameDecoder.java new file mode 100644 index 0000000..264d222 --- /dev/null +++ b/proxy-codec-minecraft/src/main/java/dev/strataproxy/codec/minecraft/MinecraftCompressionFrameDecoder.java @@ -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 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(); + } +} diff --git a/proxy-codec-minecraft/src/main/java/dev/strataproxy/codec/minecraft/MinecraftCompressionFrameEncoder.java b/proxy-codec-minecraft/src/main/java/dev/strataproxy/codec/minecraft/MinecraftCompressionFrameEncoder.java new file mode 100644 index 0000000..65c2782 --- /dev/null +++ b/proxy-codec-minecraft/src/main/java/dev/strataproxy/codec/minecraft/MinecraftCompressionFrameEncoder.java @@ -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 { + 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 output) { + output.add(codec.encodeFrame(context.alloc(), message, threshold)); + } + + @Override + public void handlerRemoved(ChannelHandlerContext context) { + codec.close(); + } +} diff --git a/proxy-codec-minecraft/src/main/java/dev/strataproxy/codec/minecraft/MinecraftCustomPayloadClassifier.java b/proxy-codec-minecraft/src/main/java/dev/strataproxy/codec/minecraft/MinecraftCustomPayloadClassifier.java new file mode 100644 index 0000000..b800b05 --- /dev/null +++ b/proxy-codec-minecraft/src/main/java/dev/strataproxy/codec/minecraft/MinecraftCustomPayloadClassifier.java @@ -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) { + } +} diff --git a/proxy-codec-minecraft/src/main/java/dev/strataproxy/codec/minecraft/MinecraftPacketDefinitions.java b/proxy-codec-minecraft/src/main/java/dev/strataproxy/codec/minecraft/MinecraftPacketDefinitions.java new file mode 100644 index 0000000..49e4d27 --- /dev/null +++ b/proxy-codec-minecraft/src/main/java/dev/strataproxy/codec/minecraft/MinecraftPacketDefinitions.java @@ -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"))); + } +} diff --git a/proxy-codec-minecraft/src/main/java/dev/strataproxy/codec/minecraft/MinecraftVarInts.java b/proxy-codec-minecraft/src/main/java/dev/strataproxy/codec/minecraft/MinecraftVarInts.java new file mode 100644 index 0000000..1ae2550 --- /dev/null +++ b/proxy-codec-minecraft/src/main/java/dev/strataproxy/codec/minecraft/MinecraftVarInts.java @@ -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); + } + } +} diff --git a/proxy-codec-minecraft/src/test/java/dev/strataproxy/codec/minecraft/MinecraftCompressedFrameRewriterTest.java b/proxy-codec-minecraft/src/test/java/dev/strataproxy/codec/minecraft/MinecraftCompressedFrameRewriterTest.java new file mode 100644 index 0000000..972f605 --- /dev/null +++ b/proxy-codec-minecraft/src/test/java/dev/strataproxy/codec/minecraft/MinecraftCompressedFrameRewriterTest.java @@ -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; +} diff --git a/proxy-codec-minecraft/src/test/java/dev/strataproxy/codec/minecraft/MinecraftCompressionCodecTest.java b/proxy-codec-minecraft/src/test/java/dev/strataproxy/codec/minecraft/MinecraftCompressionCodecTest.java new file mode 100644 index 0000000..7926af2 --- /dev/null +++ b/proxy-codec-minecraft/src/test/java/dev/strataproxy/codec/minecraft/MinecraftCompressionCodecTest.java @@ -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; + } +} diff --git a/proxy-codec-minecraft/src/test/java/dev/strataproxy/codec/minecraft/MinecraftCompressionFrameHandlersTest.java b/proxy-codec-minecraft/src/test/java/dev/strataproxy/codec/minecraft/MinecraftCompressionFrameHandlersTest.java new file mode 100644 index 0000000..c69ce73 --- /dev/null +++ b/proxy-codec-minecraft/src/test/java/dev/strataproxy/codec/minecraft/MinecraftCompressionFrameHandlersTest.java @@ -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); + } + +} diff --git a/proxy-codec-minecraft/src/test/java/dev/strataproxy/codec/minecraft/MinecraftCustomPayloadClassifierTest.java b/proxy-codec-minecraft/src/test/java/dev/strataproxy/codec/minecraft/MinecraftCustomPayloadClassifierTest.java new file mode 100644 index 0000000..eafa80a --- /dev/null +++ b/proxy-codec-minecraft/src/test/java/dev/strataproxy/codec/minecraft/MinecraftCustomPayloadClassifierTest.java @@ -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; + } +} diff --git a/proxy-codec-minecraft/src/test/java/dev/strataproxy/codec/minecraft/MinecraftPacketDefinitionsTest.java b/proxy-codec-minecraft/src/test/java/dev/strataproxy/codec/minecraft/MinecraftPacketDefinitionsTest.java new file mode 100644 index 0000000..f7e7741 --- /dev/null +++ b/proxy-codec-minecraft/src/test/java/dev/strataproxy/codec/minecraft/MinecraftPacketDefinitionsTest.java @@ -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)); + } +} diff --git a/proxy-codec-minecraft/src/test/java/dev/strataproxy/codec/minecraft/MinecraftVarIntsTest.java b/proxy-codec-minecraft/src/test/java/dev/strataproxy/codec/minecraft/MinecraftVarIntsTest.java new file mode 100644 index 0000000..aba6d94 --- /dev/null +++ b/proxy-codec-minecraft/src/test/java/dev/strataproxy/codec/minecraft/MinecraftVarIntsTest.java @@ -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(); + } + } +} diff --git a/proxy-common/build.gradle.kts b/proxy-common/build.gradle.kts new file mode 100644 index 0000000..ba059cd --- /dev/null +++ b/proxy-common/build.gradle.kts @@ -0,0 +1,7 @@ +plugins { + id("strataproxy.java-library-conventions") +} + +dependencies { + api(project(":proxy-api")) +} diff --git a/proxy-compression/build.gradle.kts b/proxy-compression/build.gradle.kts new file mode 100644 index 0000000..4a57840 --- /dev/null +++ b/proxy-compression/build.gradle.kts @@ -0,0 +1,8 @@ +plugins { + id("strataproxy.java-library-conventions") +} + +dependencies { + api(project(":proxy-api")) + implementation(project(":proxy-protocol")) +} diff --git a/proxy-compression/src/main/java/dev/strataproxy/compression/AdaptiveCompressionStrategy.java b/proxy-compression/src/main/java/dev/strataproxy/compression/AdaptiveCompressionStrategy.java new file mode 100644 index 0000000..ffe816b --- /dev/null +++ b/proxy-compression/src/main/java/dev/strataproxy/compression/AdaptiveCompressionStrategy.java @@ -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)); + } +} diff --git a/proxy-compression/src/main/java/dev/strataproxy/compression/CompressionAction.java b/proxy-compression/src/main/java/dev/strataproxy/compression/CompressionAction.java new file mode 100644 index 0000000..be47360 --- /dev/null +++ b/proxy-compression/src/main/java/dev/strataproxy/compression/CompressionAction.java @@ -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 { + } +} diff --git a/proxy-compression/src/main/java/dev/strataproxy/compression/CompressionContext.java b/proxy-compression/src/main/java/dev/strataproxy/compression/CompressionContext.java new file mode 100644 index 0000000..0c80864 --- /dev/null +++ b/proxy-compression/src/main/java/dev/strataproxy/compression/CompressionContext.java @@ -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"); + } + } +} diff --git a/proxy-compression/src/main/java/dev/strataproxy/compression/CompressionStrategies.java b/proxy-compression/src/main/java/dev/strataproxy/compression/CompressionStrategies.java new file mode 100644 index 0000000..41d6817 --- /dev/null +++ b/proxy-compression/src/main/java/dev/strataproxy/compression/CompressionStrategies.java @@ -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); + } +} diff --git a/proxy-compression/src/main/java/dev/strataproxy/compression/CompressionStrategy.java b/proxy-compression/src/main/java/dev/strataproxy/compression/CompressionStrategy.java new file mode 100644 index 0000000..3a33e7f --- /dev/null +++ b/proxy-compression/src/main/java/dev/strataproxy/compression/CompressionStrategy.java @@ -0,0 +1,5 @@ +package dev.strataproxy.compression; + +public interface CompressionStrategy { + CompressionAction choose(CompressionContext context); +} diff --git a/proxy-compression/src/main/java/dev/strataproxy/compression/DisabledCompressionStrategy.java b/proxy-compression/src/main/java/dev/strataproxy/compression/DisabledCompressionStrategy.java new file mode 100644 index 0000000..008fd80 --- /dev/null +++ b/proxy-compression/src/main/java/dev/strataproxy/compression/DisabledCompressionStrategy.java @@ -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"); + } +} diff --git a/proxy-compression/src/main/java/dev/strataproxy/compression/FixedCompressionStrategy.java b/proxy-compression/src/main/java/dev/strataproxy/compression/FixedCompressionStrategy.java new file mode 100644 index 0000000..6154ec4 --- /dev/null +++ b/proxy-compression/src/main/java/dev/strataproxy/compression/FixedCompressionStrategy.java @@ -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()); + } +} diff --git a/proxy-compression/src/test/java/dev/strataproxy/compression/CompressionStrategiesTest.java b/proxy-compression/src/test/java/dev/strataproxy/compression/CompressionStrategiesTest.java new file mode 100644 index 0000000..5a409f3 --- /dev/null +++ b/proxy-compression/src/test/java/dev/strataproxy/compression/CompressionStrategiesTest.java @@ -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); + } +} diff --git a/proxy-native/build.gradle.kts b/proxy-native/build.gradle.kts new file mode 100644 index 0000000..3eba05e --- /dev/null +++ b/proxy-native/build.gradle.kts @@ -0,0 +1,7 @@ +plugins { + id("strataproxy.java-library-conventions") +} + +dependencies { + api(project(":proxy-network")) +} diff --git a/proxy-network/build.gradle.kts b/proxy-network/build.gradle.kts new file mode 100644 index 0000000..ddfaf26 --- /dev/null +++ b/proxy-network/build.gradle.kts @@ -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") +} diff --git a/proxy-network/src/main/java/dev/strataproxy/network/BackendRelayHandler.java b/proxy-network/src/main/java/dev/strataproxy/network/BackendRelayHandler.java new file mode 100644 index 0000000..c48240e --- /dev/null +++ b/proxy-network/src/main/java/dev/strataproxy/network/BackendRelayHandler.java @@ -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 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(); + 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 actions) { + private static CompressionObservation allow() { + return new CompressionObservation(true, false, List.of()); + } + + private static CompressionObservation block() { + return new CompressionObservation(false, false, List.of()); + } + } +} diff --git a/proxy-network/src/main/java/dev/strataproxy/network/BackendResolver.java b/proxy-network/src/main/java/dev/strataproxy/network/BackendResolver.java new file mode 100644 index 0000000..4f78a15 --- /dev/null +++ b/proxy-network/src/main/java/dev/strataproxy/network/BackendResolver.java @@ -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 resolve(MinecraftHandshake handshake, SocketAddress remoteAddress); +} diff --git a/proxy-network/src/main/java/dev/strataproxy/network/CompressionRewriteRuntime.java b/proxy-network/src/main/java/dev/strataproxy/network/CompressionRewriteRuntime.java new file mode 100644 index 0000000..3b4e777 --- /dev/null +++ b/proxy-network/src/main/java/dev/strataproxy/network/CompressionRewriteRuntime.java @@ -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 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 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); + } + } +} diff --git a/proxy-network/src/main/java/dev/strataproxy/network/CompressionRuntime.java b/proxy-network/src/main/java/dev/strataproxy/network/CompressionRuntime.java new file mode 100644 index 0000000..fe73ab9 --- /dev/null +++ b/proxy-network/src/main/java/dev/strataproxy/network/CompressionRuntime.java @@ -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(); + }; + } +} diff --git a/proxy-network/src/main/java/dev/strataproxy/network/ConnectionAdmissionControl.java b/proxy-network/src/main/java/dev/strataproxy/network/ConnectionAdmissionControl.java new file mode 100644 index 0000000..e480af2 --- /dev/null +++ b/proxy-network/src/main/java/dev/strataproxy/network/ConnectionAdmissionControl.java @@ -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 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); + } + } +} diff --git a/proxy-network/src/main/java/dev/strataproxy/network/ConnectionAdmissionHandler.java b/proxy-network/src/main/java/dev/strataproxy/network/ConnectionAdmissionHandler.java new file mode 100644 index 0000000..0f3ae91 --- /dev/null +++ b/proxy-network/src/main/java/dev/strataproxy/network/ConnectionAdmissionHandler.java @@ -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); + } +} diff --git a/proxy-network/src/main/java/dev/strataproxy/network/FrontendRelayHandler.java b/proxy-network/src/main/java/dev/strataproxy/network/FrontendRelayHandler.java new file mode 100644 index 0000000..13e82a3 --- /dev/null +++ b/proxy-network/src/main/java/dev/strataproxy/network/FrontendRelayHandler.java @@ -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 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(); + 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 actions) { + private static CompressionObservation allow() { + return new CompressionObservation(true, false, List.of()); + } + + private static CompressionObservation block() { + return new CompressionObservation(false, false, List.of()); + } + } +} diff --git a/proxy-network/src/main/java/dev/strataproxy/network/InitialHandshakeRouteHandler.java b/proxy-network/src/main/java/dev/strataproxy/network/InitialHandshakeRouteHandler.java new file mode 100644 index 0000000..f9a644a --- /dev/null +++ b/proxy-network/src/main/java/dev/strataproxy/network/InitialHandshakeRouteHandler.java @@ -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 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 backendChannel, + CompressionRuntime compressionRuntime, + CustomPayloadAnomalyPolicy customPayloadPolicy) { + this(backendResolver, metrics, tuning, backendChannel, compressionRuntime, customPayloadPolicy, false); + } + + InitialHandshakeRouteHandler( + BackendResolver backendResolver, + ProxyMetrics metrics, + NetworkTuning tuning, + Class backendChannel, + CompressionRuntime compressionRuntime, + CustomPayloadAnomalyPolicy customPayloadPolicy, + boolean compressionRewriteEnabled) { + this(backendResolver, metrics, tuning, backendChannel, compressionRuntime, customPayloadPolicy, compressionRewriteEnabled, 25); + } + + InitialHandshakeRouteHandler( + BackendResolver backendResolver, + ProxyMetrics metrics, + NetworkTuning tuning, + Class 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 backendChannel, + CompressionRuntime compressionRuntime) { + this(backendResolver, metrics, tuning, backendChannel, compressionRuntime, CustomPayloadAnomalyPolicy.defaults()); + } + + @Override + protected void decode(ChannelHandlerContext context, ByteBuf input, List 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() { + @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(); + } +} diff --git a/proxy-network/src/main/java/dev/strataproxy/network/InitialHandshakeTimeoutHandler.java b/proxy-network/src/main/java/dev/strataproxy/network/InitialHandshakeTimeoutHandler.java new file mode 100644 index 0000000..ad1fb2c --- /dev/null +++ b/proxy-network/src/main/java/dev/strataproxy/network/InitialHandshakeTimeoutHandler.java @@ -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); + } +} diff --git a/proxy-network/src/main/java/dev/strataproxy/network/MinecraftCompressedCustomPayloadInspectionSampler.java b/proxy-network/src/main/java/dev/strataproxy/network/MinecraftCompressedCustomPayloadInspectionSampler.java new file mode 100644 index 0000000..61dba7e --- /dev/null +++ b/proxy-network/src/main/java/dev/strataproxy/network/MinecraftCompressedCustomPayloadInspectionSampler.java @@ -0,0 +1,186 @@ +package dev.strataproxy.network; + +import dev.strataproxy.analysis.CustomPayloadAnomalyPolicy; +import dev.strataproxy.analysis.PacketAnomaly; +import dev.strataproxy.codec.minecraft.MinecraftCodecException; +import dev.strataproxy.codec.minecraft.MinecraftCompressionCodec; +import dev.strataproxy.codec.minecraft.MinecraftCustomPayloadClassifier; +import dev.strataproxy.codec.minecraft.MinecraftVarInts; +import dev.strataproxy.protocol.PacketDirection; +import dev.strataproxy.protocol.PacketView; +import dev.strataproxy.protocol.ProtocolState; +import io.netty.buffer.ByteBuf; +import io.netty.buffer.Unpooled; +import io.netty.buffer.UnpooledByteBufAllocator; + +import java.util.ArrayList; +import java.util.List; + +final class MinecraftCompressedCustomPayloadInspectionSampler implements AutoCloseable { + private static final int DEFAULT_MAX_INSPECTED_FRAMES = 512; + + private final int threshold; + private final int maxFrameBytes; + private final int largePayloadBytes; + private final int maxInspectedFrames; + private final CustomPayloadAnomalyPolicy policy; + private final MinecraftCompressionCodec codec = new MinecraftCompressionCodec(); + private ByteBuf pending = Unpooled.buffer(); + private long floodWindowStartedNanos; + private int floodWindowCount; + private int inspectedFrames; + private boolean closed; + + MinecraftCompressedCustomPayloadInspectionSampler( + int threshold, + int maxFrameBytes, + int largePayloadBytes, + CustomPayloadAnomalyPolicy policy) { + this(threshold, maxFrameBytes, largePayloadBytes, policy, DEFAULT_MAX_INSPECTED_FRAMES); + } + + MinecraftCompressedCustomPayloadInspectionSampler( + int threshold, + int maxFrameBytes, + int largePayloadBytes, + CustomPayloadAnomalyPolicy policy, + int maxInspectedFrames) { + if (threshold < 0) { + throw new IllegalArgumentException("threshold must be non-negative"); + } + if (maxFrameBytes <= 0) { + throw new IllegalArgumentException("maxFrameBytes must be positive"); + } + if (largePayloadBytes < 0) { + throw new IllegalArgumentException("largePayloadBytes must be non-negative"); + } + if (policy == null) { + throw new IllegalArgumentException("policy must not be null"); + } + if (maxInspectedFrames <= 0) { + throw new IllegalArgumentException("maxInspectedFrames must be positive"); + } + this.threshold = threshold; + this.maxFrameBytes = maxFrameBytes; + this.largePayloadBytes = largePayloadBytes; + this.policy = policy; + this.maxInspectedFrames = maxInspectedFrames; + } + + List observe(ByteBuf input) { + if (closed || !input.isReadable()) { + return List.of(); + } + if (pending.readableBytes() + input.readableBytes() > maxFrameBytes + 5) { + close(); + throw new MinecraftCodecException("compressed custom payload inspection exceeded maximum frame size"); + } + pending.writeBytes(input, input.readerIndex(), input.readableBytes()); + var results = new ArrayList(); + while (pending.isReadable() && !closed) { + var frameLength = MinecraftVarInts.probe(pending); + if (!frameLength.complete()) { + break; + } + if (frameLength.value() < 0 || frameLength.value() > maxFrameBytes) { + close(); + throw new MinecraftCodecException("compressed custom payload inspection frame exceeds maximum size"); + } + var totalBytes = frameLength.bytes() + frameLength.value(); + if (pending.readableBytes() < totalBytes) { + break; + } + var frame = pending.readRetainedSlice(totalBytes); + try { + inspectedFrames++; + inspectFrame(frame, totalBytes, results); + if (inspectedFrames >= maxInspectedFrames) { + close(); + } + } finally { + frame.release(); + } + if (!closed) { + pending.discardReadBytes(); + } + } + return results; + } + + private void inspectFrame(ByteBuf frame, int compressedBytes, List results) { + ByteBuf encoded = null; + ByteBuf decoded = null; + try { + encoded = frame.retainedDuplicate(); + decoded = codec.decodeFrame(UnpooledByteBufAllocator.DEFAULT, encoded, threshold, maxFrameBytes); + var packetId = packetId(decoded); + if (packetId != MinecraftCustomPayloadInspectionSampler.SERVERBOUND_CONFIGURATION_CUSTOM_PAYLOAD_PACKET_ID) { + return; + } + var classification = MinecraftCustomPayloadClassifier.classify(decoded, largePayloadBytes); + var packet = new PacketView( + PacketDirection.SERVERBOUND, + ProtocolState.CONFIGURATION, + -1, + packetId, + decoded.readableBytes(), + compressedBytes, + true); + var anomalies = new ArrayList(policy.evaluate(packet, classification)); + anomalies.addAll(policy.evaluateFlood( + packet, + classification, + recordCustomPayloadInWindow(System.nanoTime()), + policy.customPayloadFloodWindow())); + results.add(new InspectionResult(classification, anomalies, compressedBytes)); + } finally { + if (encoded != null) { + encoded.release(); + } + if (decoded != null) { + decoded.release(); + } + } + } + + private static int packetId(ByteBuf decoded) { + var view = decoded.retainedDuplicate(); + try { + return MinecraftVarInts.read(view); + } finally { + view.release(); + } + } + + private int recordCustomPayloadInWindow(long nowNanos) { + var windowNanos = policy.customPayloadFloodWindow().toNanos(); + if (policy.customPayloadFloodMaxCount() <= 0) { + return 0; + } + if (floodWindowStartedNanos == 0 || nowNanos - floodWindowStartedNanos >= windowNanos) { + floodWindowStartedNanos = nowNanos; + floodWindowCount = 0; + } + floodWindowCount++; + return floodWindowCount; + } + + @Override + public void close() { + if (closed) { + return; + } + closed = true; + if (pending.refCnt() > 0) { + pending.release(); + } + pending = Unpooled.EMPTY_BUFFER; + codec.close(); + } + + record InspectionResult( + MinecraftCustomPayloadClassifier.CustomPayloadClassification classification, + List anomalies, + int compressedBytes) { + } +} diff --git a/proxy-network/src/main/java/dev/strataproxy/network/MinecraftCompressedFrameAuditSampler.java b/proxy-network/src/main/java/dev/strataproxy/network/MinecraftCompressedFrameAuditSampler.java new file mode 100644 index 0000000..ddde8f0 --- /dev/null +++ b/proxy-network/src/main/java/dev/strataproxy/network/MinecraftCompressedFrameAuditSampler.java @@ -0,0 +1,97 @@ +package dev.strataproxy.network; + +import dev.strataproxy.codec.minecraft.MinecraftCodecException; +import dev.strataproxy.codec.minecraft.MinecraftVarInts; +import io.netty.buffer.ByteBuf; +import io.netty.buffer.Unpooled; + +import java.util.ArrayList; +import java.util.List; + +final class MinecraftCompressedFrameAuditSampler { + private final int threshold; + private final int maxFrameBytes; + private ByteBuf pending = Unpooled.buffer(); + private boolean closed; + + MinecraftCompressedFrameAuditSampler(int threshold, int maxFrameBytes) { + if (threshold < 0) { + throw new IllegalArgumentException("threshold must be non-negative"); + } + if (maxFrameBytes <= 0) { + throw new IllegalArgumentException("maxFrameBytes must be positive"); + } + this.threshold = threshold; + this.maxFrameBytes = maxFrameBytes; + } + + List observe(ByteBuf input) { + if (closed || !input.isReadable()) { + return List.of(); + } + var samples = new ArrayList(); + if (pending.isReadable()) { + if (pending.readableBytes() + input.readableBytes() > maxFrameBytes + 5) { + close(); + throw new MinecraftCodecException("compressed frame audit exceeded maximum frame size"); + } + pending.writeBytes(input, input.readerIndex(), input.readableBytes()); + drainCompleteFrames(pending, samples); + pending.discardReadBytes(); + return samples; + } + + var view = input.slice(); + drainCompleteFrames(view, samples); + if (view.isReadable()) { + if (view.readableBytes() > maxFrameBytes + 5) { + close(); + throw new MinecraftCodecException("compressed frame audit exceeded maximum frame size"); + } + pending.writeBytes(view, view.readerIndex(), view.readableBytes()); + } + return samples; + } + + private void drainCompleteFrames(ByteBuf input, ArrayList samples) { + while (input.isReadable()) { + var frameLength = MinecraftVarInts.probe(input); + if (!frameLength.complete()) { + break; + } + if (frameLength.value() < 0 || frameLength.value() > maxFrameBytes) { + close(); + throw new MinecraftCodecException("compressed audit frame exceeds maximum size"); + } + var totalBytes = frameLength.bytes() + frameLength.value(); + if (input.readableBytes() < totalBytes) { + break; + } + var frame = input.readSlice(totalBytes); + var body = frame.slice(frameLength.bytes(), frameLength.value()); + var dataLength = MinecraftVarInts.read(body); + if (dataLength == 0) { + samples.add(new CompressionFrameSample(body.readableBytes(), totalBytes)); + continue; + } + if (dataLength < threshold) { + throw new MinecraftCodecException("compressed audit frame below negotiated threshold"); + } + if (dataLength > maxFrameBytes) { + throw new MinecraftCodecException("compressed audit frame exceeds maximum uncompressed size"); + } + samples.add(new CompressionFrameSample(dataLength, totalBytes)); + } + } + + void close() { + closed = true; + if (pending.refCnt() > 0) { + pending.release(); + } + pending = Unpooled.EMPTY_BUFFER; + } + + record CompressionFrameSample(long rawBytes, long compressedBytes) { + } +} diff --git a/proxy-network/src/main/java/dev/strataproxy/network/MinecraftCompressionAuditState.java b/proxy-network/src/main/java/dev/strataproxy/network/MinecraftCompressionAuditState.java new file mode 100644 index 0000000..6556a1a --- /dev/null +++ b/proxy-network/src/main/java/dev/strataproxy/network/MinecraftCompressionAuditState.java @@ -0,0 +1,75 @@ +package dev.strataproxy.network; + +import io.netty.buffer.ByteBuf; + +import java.util.List; + +final class MinecraftCompressionAuditState { + private static final int UNNEGOTIATED = -1; + + private final int maxFrameBytes; + private int threshold = UNNEGOTIATED; + private MinecraftCompressedFrameAuditSampler frontendSampler; + private MinecraftCompressedFrameAuditSampler backendSampler; + + MinecraftCompressionAuditState(int maxFrameBytes) { + if (maxFrameBytes <= 0) { + throw new IllegalArgumentException("maxFrameBytes must be positive"); + } + this.maxFrameBytes = maxFrameBytes; + } + + boolean negotiated() { + return threshold >= 0; + } + + int threshold() { + return threshold; + } + + void negotiate(int threshold) { + if (threshold < 0) { + throw new IllegalArgumentException("threshold must be non-negative"); + } + this.threshold = threshold; + } + + List observeFrontend(ByteBuf input) { + if (!negotiated()) { + return List.of(); + } + if (frontendSampler == null) { + frontendSampler = new MinecraftCompressedFrameAuditSampler(threshold, maxFrameBytes); + } + return frontendSampler.observe(input); + } + + List observeBackend(ByteBuf input) { + if (!negotiated()) { + return List.of(); + } + if (backendSampler == null) { + backendSampler = new MinecraftCompressedFrameAuditSampler(threshold, maxFrameBytes); + } + return backendSampler.observe(input); + } + + void closeFrontendSampler() { + if (frontendSampler != null) { + frontendSampler.close(); + frontendSampler = null; + } + } + + void closeBackendSampler() { + if (backendSampler != null) { + backendSampler.close(); + backendSampler = null; + } + } + + void close() { + closeFrontendSampler(); + closeBackendSampler(); + } +} diff --git a/proxy-network/src/main/java/dev/strataproxy/network/MinecraftCompressionNegotiationDetector.java b/proxy-network/src/main/java/dev/strataproxy/network/MinecraftCompressionNegotiationDetector.java new file mode 100644 index 0000000..0be3256 --- /dev/null +++ b/proxy-network/src/main/java/dev/strataproxy/network/MinecraftCompressionNegotiationDetector.java @@ -0,0 +1,81 @@ +package dev.strataproxy.network; + +import dev.strataproxy.codec.minecraft.MinecraftCodecException; +import dev.strataproxy.codec.minecraft.MinecraftVarInts; +import io.netty.buffer.ByteBuf; +import io.netty.buffer.Unpooled; + +import java.util.OptionalInt; + +final class MinecraftCompressionNegotiationDetector { + static final int CLIENTBOUND_LOGIN_SET_COMPRESSION_PACKET_ID = 0x03; + + private final int maxFrameBytes; + private ByteBuf pending = Unpooled.buffer(); + private boolean complete; + + MinecraftCompressionNegotiationDetector(int maxFrameBytes) { + if (maxFrameBytes <= 0) { + throw new IllegalArgumentException("maxFrameBytes must be positive"); + } + this.maxFrameBytes = maxFrameBytes; + } + + OptionalInt observe(ByteBuf input) { + if (complete || !input.isReadable()) { + return OptionalInt.empty(); + } + if (pending.readableBytes() + input.readableBytes() > maxFrameBytes + 5) { + complete = true; + discardPending(); + throw new MinecraftCodecException("compression negotiation probe exceeded maximum frame size"); + } + pending.writeBytes(input, input.readerIndex(), input.readableBytes()); + while (pending.isReadable()) { + var frameLength = MinecraftVarInts.probe(pending); + if (!frameLength.complete()) { + return OptionalInt.empty(); + } + if (frameLength.value() < 0 || frameLength.value() > maxFrameBytes) { + complete = true; + discardPending(); + throw new MinecraftCodecException("compression negotiation frame exceeds maximum size"); + } + var totalBytes = frameLength.bytes() + frameLength.value(); + if (pending.readableBytes() < totalBytes) { + return OptionalInt.empty(); + } + var frame = pending.readRetainedSlice(totalBytes); + try { + var payload = frame.slice(frameLength.bytes(), frameLength.value()); + var packetId = MinecraftVarInts.read(payload); + if (packetId == CLIENTBOUND_LOGIN_SET_COMPRESSION_PACKET_ID) { + var threshold = MinecraftVarInts.read(payload); + complete = true; + discardPending(); + return OptionalInt.of(threshold); + } + } finally { + frame.release(); + } + pending.discardReadBytes(); + } + return OptionalInt.empty(); + } + + boolean complete() { + return complete; + } + + void close() { + complete = true; + discardPending(); + } + + private void discardPending() { + if (pending.refCnt() > 0) { + pending.release(); + } + pending = Unpooled.EMPTY_BUFFER; + } +} diff --git a/proxy-network/src/main/java/dev/strataproxy/network/MinecraftCustomPayloadInspectionSampler.java b/proxy-network/src/main/java/dev/strataproxy/network/MinecraftCustomPayloadInspectionSampler.java new file mode 100644 index 0000000..9b02415 --- /dev/null +++ b/proxy-network/src/main/java/dev/strataproxy/network/MinecraftCustomPayloadInspectionSampler.java @@ -0,0 +1,134 @@ +package dev.strataproxy.network; + +import dev.strataproxy.analysis.CustomPayloadAnomalyPolicy; +import dev.strataproxy.analysis.PacketAnomaly; +import dev.strataproxy.codec.minecraft.MinecraftCodecException; +import dev.strataproxy.codec.minecraft.MinecraftCustomPayloadClassifier; +import dev.strataproxy.codec.minecraft.MinecraftVarInts; +import dev.strataproxy.protocol.PacketDirection; +import dev.strataproxy.protocol.PacketView; +import dev.strataproxy.protocol.ProtocolState; +import io.netty.buffer.ByteBuf; +import io.netty.buffer.Unpooled; + +import java.util.ArrayList; +import java.util.List; + +final class MinecraftCustomPayloadInspectionSampler { + static final int SERVERBOUND_CONFIGURATION_CUSTOM_PAYLOAD_PACKET_ID = 0x01; + + private final int maxFrameBytes; + private final int largePayloadBytes; + private final CustomPayloadAnomalyPolicy policy; + private ByteBuf pending = Unpooled.buffer(); + private long floodWindowStartedNanos; + private int floodWindowCount; + private boolean closed; + + MinecraftCustomPayloadInspectionSampler(int maxFrameBytes, int largePayloadBytes, CustomPayloadAnomalyPolicy policy) { + if (maxFrameBytes <= 0) { + throw new IllegalArgumentException("maxFrameBytes must be positive"); + } + if (largePayloadBytes < 0) { + throw new IllegalArgumentException("largePayloadBytes must be non-negative"); + } + if (policy == null) { + throw new IllegalArgumentException("policy must not be null"); + } + this.maxFrameBytes = maxFrameBytes; + this.largePayloadBytes = largePayloadBytes; + this.policy = policy; + } + + static MinecraftCustomPayloadInspectionSampler defaults(int maxFrameBytes) { + return new MinecraftCustomPayloadInspectionSampler( + maxFrameBytes, + 1 * 1024 * 1024, + CustomPayloadAnomalyPolicy.defaults()); + } + + List observe(ByteBuf input) { + if (closed || !input.isReadable()) { + return List.of(); + } + if (pending.readableBytes() + input.readableBytes() > maxFrameBytes + 5) { + close(); + throw new MinecraftCodecException("custom payload inspection exceeded maximum frame size"); + } + pending.writeBytes(input, input.readerIndex(), input.readableBytes()); + var results = new ArrayList(); + while (pending.isReadable()) { + var frameLength = MinecraftVarInts.probe(pending); + if (!frameLength.complete()) { + break; + } + if (frameLength.value() < 0 || frameLength.value() > maxFrameBytes) { + close(); + throw new MinecraftCodecException("custom payload inspection frame exceeds maximum size"); + } + var totalBytes = frameLength.bytes() + frameLength.value(); + if (pending.readableBytes() < totalBytes) { + break; + } + var frame = pending.readRetainedSlice(totalBytes); + try { + var body = frame.slice(frameLength.bytes(), frameLength.value()); + var bodyProbe = body.retainedDuplicate(); + int packetId; + try { + packetId = MinecraftVarInts.read(bodyProbe); + } finally { + bodyProbe.release(); + } + if (packetId == SERVERBOUND_CONFIGURATION_CUSTOM_PAYLOAD_PACKET_ID) { + var classification = MinecraftCustomPayloadClassifier.classify(body, largePayloadBytes); + var packet = new PacketView( + PacketDirection.SERVERBOUND, + ProtocolState.CONFIGURATION, + -1, + packetId, + body.readableBytes(), + 0, + false); + var anomalies = new ArrayList(policy.evaluate(packet, classification)); + anomalies.addAll(policy.evaluateFlood( + packet, + classification, + recordCustomPayloadInWindow(System.nanoTime()), + policy.customPayloadFloodWindow())); + results.add(new InspectionResult(classification, anomalies)); + } + } finally { + frame.release(); + } + pending.discardReadBytes(); + } + return results; + } + + private int recordCustomPayloadInWindow(long nowNanos) { + var windowNanos = policy.customPayloadFloodWindow().toNanos(); + if (policy.customPayloadFloodMaxCount() <= 0) { + return 0; + } + if (floodWindowStartedNanos == 0 || nowNanos - floodWindowStartedNanos >= windowNanos) { + floodWindowStartedNanos = nowNanos; + floodWindowCount = 0; + } + floodWindowCount++; + return floodWindowCount; + } + + void close() { + closed = true; + if (pending.refCnt() > 0) { + pending.release(); + } + pending = Unpooled.EMPTY_BUFFER; + } + + record InspectionResult( + MinecraftCustomPayloadClassifier.CustomPayloadClassification classification, + List anomalies) { + } +} diff --git a/proxy-network/src/main/java/dev/strataproxy/network/MinecraftHandshake.java b/proxy-network/src/main/java/dev/strataproxy/network/MinecraftHandshake.java new file mode 100644 index 0000000..5c76943 --- /dev/null +++ b/proxy-network/src/main/java/dev/strataproxy/network/MinecraftHandshake.java @@ -0,0 +1,16 @@ +package dev.strataproxy.network; + +public record MinecraftHandshake( + int protocolVersion, + String requestedHost, + int requestedPort, + int nextState) { + public MinecraftHandshake { + if (requestedHost == null || requestedHost.isBlank()) { + throw new IllegalArgumentException("requestedHost must not be blank"); + } + if (requestedPort < 0 || requestedPort > 65535) { + throw new IllegalArgumentException("requestedPort must fit unsigned short"); + } + } +} diff --git a/proxy-network/src/main/java/dev/strataproxy/network/MinecraftLoginStartSampler.java b/proxy-network/src/main/java/dev/strataproxy/network/MinecraftLoginStartSampler.java new file mode 100644 index 0000000..a042ce1 --- /dev/null +++ b/proxy-network/src/main/java/dev/strataproxy/network/MinecraftLoginStartSampler.java @@ -0,0 +1,72 @@ +package dev.strataproxy.network; + +import dev.strataproxy.codec.minecraft.MinecraftCodecException; +import dev.strataproxy.codec.minecraft.MinecraftVarInts; +import io.netty.buffer.ByteBuf; +import io.netty.buffer.Unpooled; + +import java.util.Optional; + +final class MinecraftLoginStartSampler { + private final int maxFrameBytes; + private ByteBuf pending = Unpooled.buffer(); + private boolean closed; + private boolean found; + + MinecraftLoginStartSampler(int maxFrameBytes) { + if (maxFrameBytes <= 0) { + throw new IllegalArgumentException("maxFrameBytes must be positive"); + } + this.maxFrameBytes = maxFrameBytes; + } + + Optional observe(ByteBuf input) { + if (closed || found || !input.isReadable()) { + return Optional.empty(); + } + if (pending.readableBytes() + input.readableBytes() > maxFrameBytes + 5) { + close(); + throw new MinecraftCodecException("login start sampling exceeded maximum frame size"); + } + pending.writeBytes(input, input.readerIndex(), input.readableBytes()); + var frameLength = MinecraftVarInts.probe(pending); + if (!frameLength.complete()) { + return Optional.empty(); + } + if (frameLength.value() < 0 || frameLength.value() > maxFrameBytes) { + close(); + throw new MinecraftCodecException("login start frame exceeds maximum size"); + } + var totalBytes = frameLength.bytes() + frameLength.value(); + if (pending.readableBytes() < totalBytes) { + return Optional.empty(); + } + var frame = pending.readRetainedSlice(totalBytes); + try { + var body = frame.slice(frameLength.bytes(), frameLength.value()).retainedDuplicate(); + try { + var packetId = MinecraftVarInts.read(body); + if (packetId != 0) { + close(); + return Optional.empty(); + } + var username = MinecraftProtocolCodec.readString(body, 16); + found = true; + close(); + return Optional.of(username); + } finally { + body.release(); + } + } finally { + frame.release(); + } + } + + void close() { + closed = true; + if (pending.refCnt() > 0) { + pending.release(); + } + pending = Unpooled.EMPTY_BUFFER; + } +} diff --git a/proxy-network/src/main/java/dev/strataproxy/network/MinecraftPacketTrafficSampler.java b/proxy-network/src/main/java/dev/strataproxy/network/MinecraftPacketTrafficSampler.java new file mode 100644 index 0000000..c12d2bd --- /dev/null +++ b/proxy-network/src/main/java/dev/strataproxy/network/MinecraftPacketTrafficSampler.java @@ -0,0 +1,74 @@ +package dev.strataproxy.network; + +import dev.strataproxy.codec.minecraft.MinecraftCodecException; +import dev.strataproxy.codec.minecraft.MinecraftVarInts; +import io.netty.buffer.ByteBuf; +import io.netty.buffer.Unpooled; + +import java.util.ArrayList; +import java.util.List; + +final class MinecraftPacketTrafficSampler { + private final int maxFrameBytes; + private ByteBuf pending = Unpooled.buffer(); + private boolean closed; + + MinecraftPacketTrafficSampler(int maxFrameBytes) { + if (maxFrameBytes <= 0) { + throw new IllegalArgumentException("maxFrameBytes must be positive"); + } + this.maxFrameBytes = maxFrameBytes; + } + + List observe(ByteBuf input) { + if (closed || !input.isReadable()) { + return List.of(); + } + if (pending.readableBytes() + input.readableBytes() > maxFrameBytes + 5) { + close(); + throw new MinecraftCodecException("packet traffic sampling exceeded maximum frame size"); + } + pending.writeBytes(input, input.readerIndex(), input.readableBytes()); + var samples = new ArrayList(); + while (pending.isReadable()) { + var frameLength = MinecraftVarInts.probe(pending); + if (!frameLength.complete()) { + break; + } + if (frameLength.value() < 0 || frameLength.value() > maxFrameBytes) { + close(); + throw new MinecraftCodecException("packet traffic frame exceeds maximum size"); + } + var totalBytes = frameLength.bytes() + frameLength.value(); + if (pending.readableBytes() < totalBytes) { + break; + } + var frame = pending.readRetainedSlice(totalBytes); + try { + var body = frame.slice(frameLength.bytes(), frameLength.value()); + var bodyProbe = body.retainedDuplicate(); + try { + var packetId = MinecraftVarInts.read(bodyProbe); + samples.add(new PacketTrafficSample(packetId, totalBytes, frameLength.value())); + } finally { + bodyProbe.release(); + } + } finally { + frame.release(); + } + pending.discardReadBytes(); + } + return samples; + } + + void close() { + closed = true; + if (pending.refCnt() > 0) { + pending.release(); + } + pending = Unpooled.EMPTY_BUFFER; + } + + record PacketTrafficSample(int packetId, int frameBytes, int payloadBytes) { + } +} diff --git a/proxy-network/src/main/java/dev/strataproxy/network/MinecraftProtocolCodec.java b/proxy-network/src/main/java/dev/strataproxy/network/MinecraftProtocolCodec.java new file mode 100644 index 0000000..6a4dd84 --- /dev/null +++ b/proxy-network/src/main/java/dev/strataproxy/network/MinecraftProtocolCodec.java @@ -0,0 +1,95 @@ +package dev.strataproxy.network; + +import io.netty.buffer.ByteBuf; + +import java.nio.charset.StandardCharsets; + +final class MinecraftProtocolCodec { + private static final int MAX_VAR_INT_BYTES = 5; + + private MinecraftProtocolCodec() { + } + + static FrameProbe probeFrame(ByteBuf input, int maxFrameLength) { + var readerIndex = input.readerIndex(); + var readable = input.readableBytes(); + var value = 0; + var position = 0; + + while (position < MAX_VAR_INT_BYTES) { + if (position >= readable) { + return FrameProbe.incomplete(); + } + var current = input.getByte(readerIndex + position) & 0xFF; + value |= (current & 0x7F) << (position * 7); + position++; + if ((current & 0x80) == 0) { + if (value < 0 || value > maxFrameLength) { + throw new IllegalArgumentException("minecraft frame length out of bounds: " + value); + } + var totalLength = position + value; + return readable >= totalLength + ? new FrameProbe(true, position, value, totalLength) + : FrameProbe.incomplete(); + } + } + throw new IllegalArgumentException("malformed VarInt frame length"); + } + + static MinecraftHandshake readHandshake(ByteBuf fullFrame, FrameProbe probe) { + var payload = fullFrame.retainedDuplicate(); + try { + payload.skipBytes(probe.varIntBytes()); + var packetId = readVarInt(payload); + if (packetId != 0) { + throw new IllegalArgumentException("first packet is not a handshake: " + packetId); + } + var protocolVersion = readVarInt(payload); + var host = readString(payload, 255); + var port = payload.readUnsignedShort(); + var nextState = readVarInt(payload); + return new MinecraftHandshake(protocolVersion, host, port, nextState); + } finally { + payload.release(); + } + } + + static int readVarInt(ByteBuf input) { + var value = 0; + var position = 0; + while (position < MAX_VAR_INT_BYTES) { + if (!input.isReadable()) { + throw new IllegalArgumentException("truncated VarInt"); + } + var current = input.readByte() & 0xFF; + value |= (current & 0x7F) << (position * 7); + position++; + if ((current & 0x80) == 0) { + return value; + } + } + throw new IllegalArgumentException("malformed VarInt"); + } + + static String readString(ByteBuf input, int maxCharacters) { + var length = readVarInt(input); + if (length < 0 || length > maxCharacters * 4) { + throw new IllegalArgumentException("string byte length out of bounds: " + length); + } + if (input.readableBytes() < length) { + throw new IllegalArgumentException("truncated string"); + } + var value = input.toString(input.readerIndex(), length, StandardCharsets.UTF_8); + input.skipBytes(length); + if (value.length() > maxCharacters) { + throw new IllegalArgumentException("string character length out of bounds: " + value.length()); + } + return value; + } + + record FrameProbe(boolean complete, int varIntBytes, int payloadBytes, int totalBytes) { + static FrameProbe incomplete() { + return new FrameProbe(false, 0, 0, 0); + } + } +} diff --git a/proxy-network/src/main/java/dev/strataproxy/network/NettyProxyNetworkServer.java b/proxy-network/src/main/java/dev/strataproxy/network/NettyProxyNetworkServer.java new file mode 100644 index 0000000..8befd59 --- /dev/null +++ b/proxy-network/src/main/java/dev/strataproxy/network/NettyProxyNetworkServer.java @@ -0,0 +1,325 @@ +package dev.strataproxy.network; + +import dev.strataproxy.analysis.CustomPayloadAnomalyPolicy; +import dev.strataproxy.compression.CompressionStrategy; +import dev.strataproxy.observability.ProxyMetrics; +import io.netty.bootstrap.ServerBootstrap; +import io.netty.buffer.PooledByteBufAllocator; +import io.netty.channel.Channel; +import io.netty.channel.EventLoopGroup; +import io.netty.channel.ChannelInitializer; +import io.netty.channel.ChannelOption; +import io.netty.channel.WriteBufferWaterMark; + +import java.net.InetSocketAddress; +import java.time.Duration; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +public final class NettyProxyNetworkServer implements ProxyNetworkServer { + private final EventLoopGroup bossGroup; + private final EventLoopGroup workerGroup; + private final NettyTransport transport; + private final BackendResolver backendResolver; + private final ProxyMetrics metrics; + private final NetworkTuning tuning; + private final CompressionRuntime compressionRuntime; + private final CustomPayloadAnomalyPolicy customPayloadPolicy; + private final boolean compressionRewriteEnabled; + private final int compressionRewriteMaxEventLoopDelayMillis; + private final NetworkRuntimeMonitor runtimeMonitor; + private final ConnectionAdmissionControl admissionControl; + private final AtomicBoolean closed = new AtomicBoolean(); + private Channel channel; + + public NettyProxyNetworkServer(int workerThreads, BackendResolver backendResolver) { + this(workerThreads, backendResolver, new ProxyMetrics(), NetworkTuning.defaults(), false); + } + + public NettyProxyNetworkServer(int workerThreads, BackendResolver backendResolver, ProxyMetrics metrics, int maxFrameLength) { + this(workerThreads, backendResolver, metrics, new NetworkTuning( + maxFrameLength, + NetworkTuning.defaults().connectTimeoutMillis(), + NetworkTuning.defaults().writeBufferLowBytes(), + NetworkTuning.defaults().writeBufferHighBytes(), + NetworkTuning.defaults().maxConnections(), + NetworkTuning.defaults().maxConnectionsPerAddress(), + NetworkTuning.defaults().initialHandshakeTimeoutMillis()), + false); + } + + public NettyProxyNetworkServer(int workerThreads, BackendResolver backendResolver, ProxyMetrics metrics, NetworkTuning tuning) { + this(workerThreads, backendResolver, metrics, tuning, false); + } + + public NettyProxyNetworkServer(int workerThreads, BackendResolver backendResolver, ProxyMetrics metrics, NetworkTuning tuning, boolean nativeTransport) { + this(workerThreads, backendResolver, metrics, tuning, nativeTransport, CompressionRuntime.defaults()); + } + + public NettyProxyNetworkServer( + int workerThreads, + BackendResolver backendResolver, + ProxyMetrics metrics, + NetworkTuning tuning, + boolean nativeTransport, + CompressionStrategy compressionStrategy, + int compressionMinThreshold, + int compressionMaxThreshold, + double compressionCpuGuard) { + this( + workerThreads, + backendResolver, + metrics, + tuning, + nativeTransport, + compressionStrategy, + compressionMinThreshold, + compressionMaxThreshold, + compressionCpuGuard, + CustomPayloadAnomalyPolicy.defaults()); + } + + public NettyProxyNetworkServer( + int workerThreads, + BackendResolver backendResolver, + ProxyMetrics metrics, + NetworkTuning tuning, + boolean nativeTransport, + CompressionStrategy compressionStrategy, + int compressionMinThreshold, + int compressionMaxThreshold, + double compressionCpuGuard, + CustomPayloadAnomalyPolicy customPayloadPolicy) { + this( + workerThreads, + backendResolver, + metrics, + tuning, + nativeTransport, + compressionStrategy, + compressionMinThreshold, + compressionMaxThreshold, + compressionCpuGuard, + customPayloadPolicy, + false); + } + + public NettyProxyNetworkServer( + int workerThreads, + BackendResolver backendResolver, + ProxyMetrics metrics, + NetworkTuning tuning, + boolean nativeTransport, + CompressionStrategy compressionStrategy, + int compressionMinThreshold, + int compressionMaxThreshold, + double compressionCpuGuard, + CustomPayloadAnomalyPolicy customPayloadPolicy, + boolean compressionRewriteEnabled) { + this( + workerThreads, + backendResolver, + metrics, + tuning, + nativeTransport, + compressionStrategy, + compressionMinThreshold, + compressionMaxThreshold, + compressionCpuGuard, + customPayloadPolicy, + compressionRewriteEnabled, + 25); + } + + public NettyProxyNetworkServer( + int workerThreads, + BackendResolver backendResolver, + ProxyMetrics metrics, + NetworkTuning tuning, + boolean nativeTransport, + CompressionStrategy compressionStrategy, + int compressionMinThreshold, + int compressionMaxThreshold, + double compressionCpuGuard, + CustomPayloadAnomalyPolicy customPayloadPolicy, + boolean compressionRewriteEnabled, + int compressionRewriteMaxEventLoopDelayMillis) { + this(workerThreads, backendResolver, metrics, tuning, nativeTransport, new CompressionRuntime( + compressionStrategy, + compressionMinThreshold, + compressionMaxThreshold, + compressionCpuGuard), + customPayloadPolicy, + compressionRewriteEnabled, + compressionRewriteMaxEventLoopDelayMillis); + } + + private NettyProxyNetworkServer( + int workerThreads, + BackendResolver backendResolver, + ProxyMetrics metrics, + NetworkTuning tuning, + boolean nativeTransport, + CompressionRuntime compressionRuntime) { + this(workerThreads, backendResolver, metrics, tuning, nativeTransport, compressionRuntime, CustomPayloadAnomalyPolicy.defaults()); + } + + private NettyProxyNetworkServer( + int workerThreads, + BackendResolver backendResolver, + ProxyMetrics metrics, + NetworkTuning tuning, + boolean nativeTransport, + CompressionRuntime compressionRuntime, + CustomPayloadAnomalyPolicy customPayloadPolicy) { + this(workerThreads, backendResolver, metrics, tuning, nativeTransport, compressionRuntime, customPayloadPolicy, false); + } + + private NettyProxyNetworkServer( + int workerThreads, + BackendResolver backendResolver, + ProxyMetrics metrics, + NetworkTuning tuning, + boolean nativeTransport, + CompressionRuntime compressionRuntime, + CustomPayloadAnomalyPolicy customPayloadPolicy, + boolean compressionRewriteEnabled) { + this(workerThreads, backendResolver, metrics, tuning, nativeTransport, compressionRuntime, customPayloadPolicy, compressionRewriteEnabled, 25); + } + + private NettyProxyNetworkServer( + int workerThreads, + BackendResolver backendResolver, + ProxyMetrics metrics, + NetworkTuning tuning, + boolean nativeTransport, + CompressionRuntime compressionRuntime, + CustomPayloadAnomalyPolicy customPayloadPolicy, + boolean compressionRewriteEnabled, + int compressionRewriteMaxEventLoopDelayMillis) { + this.transport = NettyTransport.select(nativeTransport, workerThreads); + this.bossGroup = transport.bossGroup(); + this.workerGroup = transport.workerGroup(); + this.backendResolver = backendResolver; + this.metrics = metrics; + this.metrics.networkTransport(transport.name(), transport.nativeTransport()); + this.tuning = tuning; + this.compressionRuntime = compressionRuntime; + this.customPayloadPolicy = customPayloadPolicy == null ? CustomPayloadAnomalyPolicy.defaults() : customPayloadPolicy; + this.compressionRewriteEnabled = compressionRewriteEnabled; + this.compressionRewriteMaxEventLoopDelayMillis = compressionRewriteMaxEventLoopDelayMillis; + this.runtimeMonitor = new NetworkRuntimeMonitor(workerGroup, metrics, Duration.ofSeconds(1)); + this.admissionControl = new ConnectionAdmissionControl(tuning.maxConnections(), tuning.maxConnectionsPerAddress()); + } + + @Override + public CompletionStage bind(InetSocketAddress address) { + if (closed.get()) { + return CompletableFuture.failedFuture(new IllegalStateException("server is closed")); + } + var future = new CompletableFuture(); + var bootstrap = new ServerBootstrap() + .group(bossGroup, workerGroup) + .channel(transport.serverChannel()) + .option(ChannelOption.ALLOCATOR, PooledByteBufAllocator.DEFAULT) + .option(ChannelOption.SO_BACKLOG, 1024) + .childOption(ChannelOption.ALLOCATOR, PooledByteBufAllocator.DEFAULT) + .childOption(ChannelOption.TCP_NODELAY, true) + .childOption(ChannelOption.SO_KEEPALIVE, true) + .childOption(ChannelOption.AUTO_READ, false) + .childOption(ChannelOption.WRITE_BUFFER_WATER_MARK, new WriteBufferWaterMark( + tuning.writeBufferLowBytes(), + tuning.writeBufferHighBytes())) + .childHandler(new ChannelInitializer() { + @Override + protected void initChannel(Channel channel) { + channel.config().setAutoRead(true); + channel.pipeline().addLast("connection-admission", new ConnectionAdmissionHandler(admissionControl, metrics)); + channel.pipeline().addLast("initial-handshake-timeout", new InitialHandshakeTimeoutHandler( + tuning.initialHandshakeTimeoutMillis(), + metrics)); + channel.pipeline().addLast("initial-handshake-route", new InitialHandshakeRouteHandler( + backendResolver, + metrics, + tuning, + transport.clientChannel(), + compressionRuntime, + customPayloadPolicy, + compressionRewriteEnabled, + compressionRewriteMaxEventLoopDelayMillis)); + } + }); + + bootstrap.bind(address).addListener(result -> { + if (result.isSuccess()) { + channel = ((io.netty.channel.ChannelFuture) result).channel(); + runtimeMonitor.start(); + future.complete(null); + } else { + close(); + future.completeExceptionally(result.cause()); + } + }); + return future; + } + + @Override + public InetSocketAddress bindAddress() { + if (channel == null) { + throw new IllegalStateException("server is not bound"); + } + return (InetSocketAddress) channel.localAddress(); + } + + public String transportName() { + return transport.name(); + } + + public boolean nativeTransport() { + return transport.nativeTransport(); + } + + @Override + public void close() { + closeResources(!inEventLoop()); + } + + private void closeResources(boolean await) { + if (!closed.compareAndSet(false, true)) { + return; + } + if (channel != null) { + var closeFuture = channel.close(); + if (await) { + closeFuture.awaitUninterruptibly(); + } + } + runtimeMonitor.close(); + var bossShutdown = bossGroup.shutdownGracefully(0, 5, TimeUnit.SECONDS); + var workerShutdown = workerGroup.shutdownGracefully(0, 5, TimeUnit.SECONDS); + if (await) { + bossShutdown.awaitUninterruptibly(); + workerShutdown.awaitUninterruptibly(); + } + } + + private boolean inEventLoop() { + for (var executor : bossGroup) { + if (executor.inEventLoop()) { + return true; + } + } + for (var executor : workerGroup) { + if (executor.inEventLoop()) { + return true; + } + } + return false; + } + + boolean shuttingDown() { + return bossGroup.isShuttingDown() && workerGroup.isShuttingDown(); + } +} diff --git a/proxy-network/src/main/java/dev/strataproxy/network/NettyTransport.java b/proxy-network/src/main/java/dev/strataproxy/network/NettyTransport.java new file mode 100644 index 0000000..8aea61a --- /dev/null +++ b/proxy-network/src/main/java/dev/strataproxy/network/NettyTransport.java @@ -0,0 +1,52 @@ +package dev.strataproxy.network; + +import io.netty.channel.Channel; +import io.netty.channel.EventLoopGroup; +import io.netty.channel.ServerChannel; +import io.netty.channel.epoll.Epoll; +import io.netty.channel.epoll.EpollEventLoopGroup; +import io.netty.channel.epoll.EpollServerSocketChannel; +import io.netty.channel.epoll.EpollSocketChannel; +import io.netty.channel.kqueue.KQueue; +import io.netty.channel.kqueue.KQueueEventLoopGroup; +import io.netty.channel.kqueue.KQueueServerSocketChannel; +import io.netty.channel.kqueue.KQueueSocketChannel; +import io.netty.channel.nio.NioEventLoopGroup; +import io.netty.channel.socket.nio.NioServerSocketChannel; +import io.netty.channel.socket.nio.NioSocketChannel; + +record NettyTransport( + String name, + boolean nativeTransport, + EventLoopGroup bossGroup, + EventLoopGroup workerGroup, + Class serverChannel, + Class clientChannel) { + static NettyTransport select(boolean preferNative, int workerThreads) { + if (preferNative && Epoll.isAvailable()) { + return new NettyTransport( + "epoll", + true, + new EpollEventLoopGroup(1), + new EpollEventLoopGroup(workerThreads), + EpollServerSocketChannel.class, + EpollSocketChannel.class); + } + if (preferNative && KQueue.isAvailable()) { + return new NettyTransport( + "kqueue", + true, + new KQueueEventLoopGroup(1), + new KQueueEventLoopGroup(workerThreads), + KQueueServerSocketChannel.class, + KQueueSocketChannel.class); + } + return new NettyTransport( + "nio", + false, + new NioEventLoopGroup(1), + new NioEventLoopGroup(workerThreads), + NioServerSocketChannel.class, + NioSocketChannel.class); + } +} diff --git a/proxy-network/src/main/java/dev/strataproxy/network/NetworkRuntimeMonitor.java b/proxy-network/src/main/java/dev/strataproxy/network/NetworkRuntimeMonitor.java new file mode 100644 index 0000000..a671f13 --- /dev/null +++ b/proxy-network/src/main/java/dev/strataproxy/network/NetworkRuntimeMonitor.java @@ -0,0 +1,46 @@ +package dev.strataproxy.network; + +import dev.strataproxy.observability.ProxyMetrics; +import io.netty.buffer.PooledByteBufAllocator; +import io.netty.channel.EventLoopGroup; + +import java.time.Duration; +import java.util.Objects; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +final class NetworkRuntimeMonitor implements AutoCloseable { + private final EventLoopGroup workerGroup; + private final ProxyMetrics metrics; + private final Duration interval; + private final ScheduledExecutorService scheduler; + + NetworkRuntimeMonitor(EventLoopGroup workerGroup, ProxyMetrics metrics, Duration interval) { + this.workerGroup = Objects.requireNonNull(workerGroup, "workerGroup"); + this.metrics = Objects.requireNonNull(metrics, "metrics"); + this.interval = interval == null ? Duration.ofSeconds(1) : interval; + this.scheduler = Executors.newSingleThreadScheduledExecutor(task -> { + var thread = new Thread(task, "strataproxy-runtime-monitor"); + thread.setDaemon(true); + return thread; + }); + } + + void start() { + scheduler.scheduleWithFixedDelay(this::sample, 0, interval.toMillis(), TimeUnit.MILLISECONDS); + } + + void sample() { + metrics.pooledDirectMemoryBytes(PooledByteBufAllocator.DEFAULT.metric().usedDirectMemory()); + for (var eventExecutor : workerGroup) { + var submittedAt = System.nanoTime(); + eventExecutor.execute(() -> metrics.eventLoopDelayNanos(System.nanoTime() - submittedAt)); + } + } + + @Override + public void close() { + scheduler.shutdownNow(); + } +} diff --git a/proxy-network/src/main/java/dev/strataproxy/network/NetworkTuning.java b/proxy-network/src/main/java/dev/strataproxy/network/NetworkTuning.java new file mode 100644 index 0000000..bb5a6af --- /dev/null +++ b/proxy-network/src/main/java/dev/strataproxy/network/NetworkTuning.java @@ -0,0 +1,32 @@ +package dev.strataproxy.network; + +public record NetworkTuning( + int maxFrameBytes, + int connectTimeoutMillis, + int writeBufferLowBytes, + int writeBufferHighBytes, + int maxConnections, + int maxConnectionsPerAddress, + int initialHandshakeTimeoutMillis) { + public NetworkTuning { + if (maxFrameBytes <= 0) { + throw new IllegalArgumentException("maxFrameBytes must be positive"); + } + if (connectTimeoutMillis <= 0) { + throw new IllegalArgumentException("connectTimeoutMillis must be positive"); + } + if (writeBufferLowBytes < 0 || writeBufferHighBytes <= 0 || writeBufferLowBytes >= writeBufferHighBytes) { + throw new IllegalArgumentException("write buffer watermarks must satisfy 0 <= low < high"); + } + if (maxConnections <= 0 || maxConnectionsPerAddress <= 0) { + throw new IllegalArgumentException("connection limits must be positive"); + } + if (initialHandshakeTimeoutMillis <= 0) { + throw new IllegalArgumentException("initialHandshakeTimeoutMillis must be positive"); + } + } + + public static NetworkTuning defaults() { + return new NetworkTuning(8 * 1024 * 1024, 5_000, 4 * 1024 * 1024, 16 * 1024 * 1024, 10_000, 200, 5_000); + } +} diff --git a/proxy-network/src/main/java/dev/strataproxy/network/ProxyNetworkServer.java b/proxy-network/src/main/java/dev/strataproxy/network/ProxyNetworkServer.java new file mode 100644 index 0000000..df5c008 --- /dev/null +++ b/proxy-network/src/main/java/dev/strataproxy/network/ProxyNetworkServer.java @@ -0,0 +1,13 @@ +package dev.strataproxy.network; + +import java.net.InetSocketAddress; +import java.util.concurrent.CompletionStage; + +public interface ProxyNetworkServer extends AutoCloseable { + CompletionStage bind(InetSocketAddress address); + + InetSocketAddress bindAddress(); + + @Override + void close(); +} diff --git a/proxy-network/src/main/java/dev/strataproxy/network/RelaySessionIdentity.java b/proxy-network/src/main/java/dev/strataproxy/network/RelaySessionIdentity.java new file mode 100644 index 0000000..e514f62 --- /dev/null +++ b/proxy-network/src/main/java/dev/strataproxy/network/RelaySessionIdentity.java @@ -0,0 +1,22 @@ +package dev.strataproxy.network; + +final class RelaySessionIdentity { + private final String remoteAddress; + private volatile String playerName; + + RelaySessionIdentity(String remoteAddress) { + this.remoteAddress = remoteAddress == null ? "" : remoteAddress; + } + + String remoteAddress() { + return remoteAddress; + } + + String playerName() { + return playerName == null ? "" : playerName; + } + + void playerName(String playerName) { + this.playerName = playerName == null ? "" : playerName; + } +} diff --git a/proxy-network/src/main/java/dev/strataproxy/network/RoutingBackendResolver.java b/proxy-network/src/main/java/dev/strataproxy/network/RoutingBackendResolver.java new file mode 100644 index 0000000..9e994e9 --- /dev/null +++ b/proxy-network/src/main/java/dev/strataproxy/network/RoutingBackendResolver.java @@ -0,0 +1,47 @@ +package dev.strataproxy.network; + +import dev.strataproxy.api.server.RegisteredServer; +import dev.strataproxy.routing.RoutingDecision; +import dev.strataproxy.routing.RoutingRequest; +import dev.strataproxy.routing.ServerRouter; + +import java.net.InetSocketAddress; +import java.net.SocketAddress; +import java.util.Optional; +import java.util.Set; + +public final class RoutingBackendResolver implements BackendResolver { + private final ServerRouter router; + + public RoutingBackendResolver(ServerRouter router) { + this.router = router; + } + + @Override + public Optional resolve(MinecraftHandshake handshake, SocketAddress remoteAddress) { + var inetRemote = remoteAddress instanceof InetSocketAddress inet ? inet : InetSocketAddress.createUnresolved("unknown", 0); + var hostSpecific = new RoutingRequest( + handshake.requestedHost(), + Set.of(), + Set.of(), + handshake.protocolVersion(), + inetRemote); + var selected = select(hostSpecific); + if (selected.isPresent()) { + return selected; + } + return select(new RoutingRequest( + "", + Set.of(), + Set.of(), + handshake.protocolVersion(), + inetRemote)); + } + + private Optional select(RoutingRequest request) { + return switch (router.route(request)) { + case RoutingDecision.Selected selected -> Optional.of(selected.server()); + case RoutingDecision.Rejected ignored -> Optional.empty(); + }; + } +} diff --git a/proxy-network/src/test/java/dev/strataproxy/network/ConnectionAdmissionControlTest.java b/proxy-network/src/test/java/dev/strataproxy/network/ConnectionAdmissionControlTest.java new file mode 100644 index 0000000..dc5749f --- /dev/null +++ b/proxy-network/src/test/java/dev/strataproxy/network/ConnectionAdmissionControlTest.java @@ -0,0 +1,46 @@ +package dev.strataproxy.network; + +import org.junit.jupiter.api.Test; + +import java.net.InetSocketAddress; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +final class ConnectionAdmissionControlTest { + @Test + void enforcesGlobalLimit() { + var control = new ConnectionAdmissionControl(1, 10); + var first = control.acquire(new InetSocketAddress("127.0.0.1", 50000)); + var second = control.acquire(new InetSocketAddress("127.0.0.2", 50001)); + + assertTrue(first.accepted()); + assertFalse(second.accepted()); + assertEquals("global_limit", second.rejectionReason()); + assertEquals(1, control.activeConnections()); + } + + @Test + void enforcesPerAddressLimit() { + var control = new ConnectionAdmissionControl(10, 1); + var first = control.acquire(new InetSocketAddress("127.0.0.1", 50000)); + var second = control.acquire(new InetSocketAddress("127.0.0.1", 50001)); + + assertTrue(first.accepted()); + assertFalse(second.accepted()); + assertEquals("per_address_limit", second.rejectionReason()); + assertEquals(1, control.activeConnectionsFor("127.0.0.1")); + } + + @Test + void releaseAllowsNewConnection() { + var control = new ConnectionAdmissionControl(1, 1); + var first = control.acquire(new InetSocketAddress("127.0.0.1", 50000)); + control.release(first); + var second = control.acquire(new InetSocketAddress("127.0.0.1", 50001)); + + assertTrue(second.accepted()); + assertEquals(1, control.activeConnections()); + } +} diff --git a/proxy-network/src/test/java/dev/strataproxy/network/ConnectionAdmissionHandlerTest.java b/proxy-network/src/test/java/dev/strataproxy/network/ConnectionAdmissionHandlerTest.java new file mode 100644 index 0000000..2125880 --- /dev/null +++ b/proxy-network/src/test/java/dev/strataproxy/network/ConnectionAdmissionHandlerTest.java @@ -0,0 +1,45 @@ +package dev.strataproxy.network; + +import dev.strataproxy.observability.ProxyMetrics; +import io.netty.channel.embedded.EmbeddedChannel; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +final class ConnectionAdmissionHandlerTest { + @Test + void acceptedConnectionIsReleasedOnlyOnce() { + var control = new ConnectionAdmissionControl(1, 10); + var metrics = new ProxyMetrics(); + var channel = new EmbeddedChannel(new ConnectionAdmissionHandler(control, metrics)); + + assertEquals(1, control.activeConnections()); + assertEquals(1, metrics.snapshot().activeConnections()); + + channel.close(); + channel.pipeline().fireChannelInactive(); + + assertEquals(0, control.activeConnections()); + assertEquals(0, metrics.snapshot().activeConnections()); + assertEquals(1, metrics.snapshot().acceptedConnections()); + } + + @Test + void rejectedConnectionDoesNotConsumeAdmissionSlot() { + var control = new ConnectionAdmissionControl(1, 1); + var metrics = new ProxyMetrics(); + var accepted = new EmbeddedChannel(new ConnectionAdmissionHandler(control, metrics)); + var rejected = new EmbeddedChannel(new ConnectionAdmissionHandler(control, metrics)); + + assertEquals(1, control.activeConnections()); + assertEquals(1, metrics.snapshot().activeConnections()); + assertEquals(1, metrics.snapshot().acceptedConnections()); + assertEquals(1, metrics.snapshot().rejectedConnections()); + assertEquals(1, metrics.snapshot().rejectedConnectionsByReason().get("global_limit")); + + rejected.finishAndReleaseAll(); + accepted.finishAndReleaseAll(); + assertEquals(0, control.activeConnections()); + assertEquals(0, metrics.snapshot().activeConnections()); + } +} diff --git a/proxy-network/src/test/java/dev/strataproxy/network/InitialHandshakeRouteHandlerTest.java b/proxy-network/src/test/java/dev/strataproxy/network/InitialHandshakeRouteHandlerTest.java new file mode 100644 index 0000000..4c3574f --- /dev/null +++ b/proxy-network/src/test/java/dev/strataproxy/network/InitialHandshakeRouteHandlerTest.java @@ -0,0 +1,138 @@ +package dev.strataproxy.network; + +import dev.strataproxy.api.server.ProtocolRange; +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.observability.ProxyMetrics; +import io.netty.buffer.Unpooled; +import io.netty.channel.embedded.EmbeddedChannel; +import org.junit.jupiter.api.Test; + +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; + +final class InitialHandshakeRouteHandlerTest { + @Test + void recordsMalformedInitialFrame() { + var metrics = new ProxyMetrics(); + var channel = new EmbeddedChannel(new InitialHandshakeRouteHandler( + (handshake, remoteAddress) -> Optional.empty(), + metrics, + NetworkTuning.defaults())); + + assertFalse(channel.writeInbound(Unpooled.wrappedBuffer(new byte[] { + (byte) 0xFF, + (byte) 0xFF, + (byte) 0xFF, + (byte) 0xFF, + (byte) 0x7F + }))); + + var snapshot = metrics.snapshot(); + assertEquals(1, snapshot.failedRoutes()); + assertEquals(1, snapshot.packetAnomalies().get("initial-handshake-malformed-frame")); + } + + @Test + void recordsMissingRouteForValidHandshake() { + var metrics = new ProxyMetrics(); + var channel = new EmbeddedChannel(new InitialHandshakeRouteHandler( + (handshake, remoteAddress) -> Optional.empty(), + metrics, + NetworkTuning.defaults())); + + assertFalse(channel.writeInbound(handshakeFrame(763, "play.example.net", 25565, 2))); + + var snapshot = metrics.snapshot(); + assertEquals(1, snapshot.failedRoutes()); + assertEquals(1, snapshot.packetAnomalies().get("initial-handshake-no-route")); + } + + @Test + void rejectsOversizedPendingBytesAfterHandshakeBeforeBackendConnect() { + var metrics = new ProxyMetrics(); + var tuning = new NetworkTuning(64, 1_000, 16, 64, 100, 100, 5_000); + var selected = server("lobby-1"); + var channel = new EmbeddedChannel(new InitialHandshakeRouteHandler( + (handshake, remoteAddress) -> Optional.of(selected), + metrics, + tuning)); + var input = Unpooled.buffer(); + input.writeBytes(handshakeFrame(763, "play.example.net", 25565, 2)); + input.writeZero(65); + + assertFalse(channel.writeInbound(input)); + + var snapshot = metrics.snapshot(); + assertEquals(1, snapshot.failedRoutes()); + assertEquals(1, snapshot.packetAnomalies().get("initial-handshake-pending-too-large")); + assertEquals(0, snapshot.backendConnectFailures()); + assertEquals(0, snapshot.routedConnections()); + } + + private static io.netty.buffer.ByteBuf handshakeFrame(int protocol, String host, int port, int nextState) { + var payload = Unpooled.buffer(); + writeVarInt(payload, 0); + writeVarInt(payload, protocol); + writeString(payload, host); + payload.writeShort(port); + writeVarInt(payload, nextState); + + var frame = Unpooled.buffer(); + writeVarInt(frame, payload.readableBytes()); + frame.writeBytes(payload); + payload.release(); + return frame; + } + + private static void writeString(io.netty.buffer.ByteBuf output, String value) { + var bytes = value.getBytes(StandardCharsets.UTF_8); + writeVarInt(output, bytes.length); + output.writeBytes(bytes); + } + + private static void writeVarInt(io.netty.buffer.ByteBuf output, int value) { + var current = value; + do { + var temp = current & 0x7F; + current >>>= 7; + if (current != 0) { + temp |= 0x80; + } + output.writeByte(temp); + } while (current != 0); + } + + private static RegisteredServer server(String name) { + return new TestRegisteredServer( + new ServerDescriptor( + name, + 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()), + ServerHealth.up(1), + new ServerLoad(0, 100, 120, 0, 0, 0, 0), + false); + } + + private record TestRegisteredServer( + ServerDescriptor descriptor, + ServerHealth health, + ServerLoad load, + boolean draining) implements RegisteredServer { + } +} diff --git a/proxy-network/src/test/java/dev/strataproxy/network/MinecraftProtocolCodecTest.java b/proxy-network/src/test/java/dev/strataproxy/network/MinecraftProtocolCodecTest.java new file mode 100644 index 0000000..2952133 --- /dev/null +++ b/proxy-network/src/test/java/dev/strataproxy/network/MinecraftProtocolCodecTest.java @@ -0,0 +1,69 @@ +package dev.strataproxy.network; + +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.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +final class MinecraftProtocolCodecTest { + @Test + void parsesHandshakeFrameWithoutConsumingOriginalBuffer() { + var frame = handshakeFrame(763, "play.example.net", 25565, 2); + var probe = MinecraftProtocolCodec.probeFrame(frame, 1024); + + var handshake = MinecraftProtocolCodec.readHandshake(frame, probe); + + assertTrue(probe.complete()); + assertEquals(763, handshake.protocolVersion()); + assertEquals("play.example.net", handshake.requestedHost()); + assertEquals(25565, handshake.requestedPort()); + assertEquals(2, handshake.nextState()); + assertEquals(0, frame.readerIndex()); + } + + @Test + void reportsIncompleteFrame() { + var partial = Unpooled.wrappedBuffer(new byte[] {(byte) 0x80}); + + var probe = MinecraftProtocolCodec.probeFrame(partial, 1024); + + assertFalse(probe.complete()); + } + + private static io.netty.buffer.ByteBuf handshakeFrame(int protocol, String host, int port, int nextState) { + var payload = Unpooled.buffer(); + writeVarInt(payload, 0); + writeVarInt(payload, protocol); + writeString(payload, host); + payload.writeShort(port); + writeVarInt(payload, nextState); + + var frame = Unpooled.buffer(); + writeVarInt(frame, payload.readableBytes()); + frame.writeBytes(payload); + payload.release(); + return frame; + } + + private static void writeString(io.netty.buffer.ByteBuf output, String value) { + var bytes = value.getBytes(StandardCharsets.UTF_8); + writeVarInt(output, bytes.length); + output.writeBytes(bytes); + } + + private static void writeVarInt(io.netty.buffer.ByteBuf output, int value) { + var current = value; + do { + var temp = current & 0x7F; + current >>>= 7; + if (current != 0) { + temp |= 0x80; + } + output.writeByte(temp); + } while (current != 0); + } +} diff --git a/proxy-network/src/test/java/dev/strataproxy/network/NettyProxyNetworkServerSmokeTest.java b/proxy-network/src/test/java/dev/strataproxy/network/NettyProxyNetworkServerSmokeTest.java new file mode 100644 index 0000000..215272c --- /dev/null +++ b/proxy-network/src/test/java/dev/strataproxy/network/NettyProxyNetworkServerSmokeTest.java @@ -0,0 +1,322 @@ +package dev.strataproxy.network; + +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.api.server.ServerHealth; +import dev.strataproxy.api.server.ServerLoad; +import dev.strataproxy.observability.ProxyMetrics; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayOutputStream; +import java.net.InetSocketAddress; +import java.net.ServerSocket; +import java.net.Socket; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.time.Duration; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; + +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 NettyProxyNetworkServerSmokeTest { + @Test + void cleansUpEventLoopsWhenBindFails() throws Exception { + try (var first = new NettyProxyNetworkServer( + 1, + (request, remoteAddress) -> java.util.Optional.empty(), + new ProxyMetrics(), + NetworkTuning.defaults())) { + first.bind(new InetSocketAddress("127.0.0.1", 0)) + .toCompletableFuture() + .get(5, TimeUnit.SECONDS); + + var contender = new NettyProxyNetworkServer( + 1, + (request, remoteAddress) -> java.util.Optional.empty(), + new ProxyMetrics(), + NetworkTuning.defaults()); + + assertThrows(ExecutionException.class, () -> contender.bind(first.bindAddress()) + .toCompletableFuture() + .get(5, TimeUnit.SECONDS)); + assertTrue(contender.shuttingDown()); + contender.close(); + } + } + + @Test + void rejectsPerAddressConnectionStormOverRealTcp() throws Exception { + var metrics = new ProxyMetrics(); + var tuning = new NetworkTuning(1024, 1_000, 128, 1024, 100, 2, 10_000); + try (var proxy = new NettyProxyNetworkServer( + 1, + (request, remoteAddress) -> java.util.Optional.empty(), + metrics, + tuning)) { + proxy.bind(new InetSocketAddress("127.0.0.1", 0)) + .toCompletableFuture() + .get(5, TimeUnit.SECONDS); + + var attempts = 12; + var connectedSockets = Collections.synchronizedList(new ArrayList()); + var start = new CountDownLatch(1); + var connected = new CountDownLatch(attempts); + try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { + for (var i = 0; i < attempts; i++) { + executor.submit(() -> { + start.await(); + var socket = new Socket(); + socket.connect(proxy.bindAddress(), 5_000); + connectedSockets.add(socket); + connected.countDown(); + return null; + }); + } + + start.countDown(); + assertTrue(connected.await(5, TimeUnit.SECONDS)); + awaitConnectionStormMetrics(metrics, 2, attempts - 2); + } finally { + closeAll(connectedSockets); + } + + awaitGlobalActiveConnections(metrics, 0); + } + } + + @Test + void forwardsMinecraftHandshakeAndBackendBytesOverRealTcp() throws Exception { + var handshake = handshakeFrame(763, "play.example.net", 25565, 2); + var pendingLoginFrame = packetFrame(0x01, 42); + var clientBytes = concat(handshake, pendingLoginFrame); + var backendResponse = new byte[] {0x11, 0x22, 0x33, 0x44}; + var backendExecutor = Executors.newSingleThreadExecutor(); + + try (var backendSocket = new ServerSocket(0)) { + backendSocket.setSoTimeout(5_000); + var backendPort = backendSocket.getLocalPort(); + var backendRead = backendExecutor.submit(() -> { + try (var accepted = backendSocket.accept()) { + accepted.setSoTimeout(5_000); + var received = accepted.getInputStream().readNBytes(clientBytes.length); + accepted.getOutputStream().write(backendResponse); + accepted.getOutputStream().flush(); + return received; + } + }); + + var selected = server("survival-1", backendPort); + var metrics = new ProxyMetrics(); + var tuning = new NetworkTuning(1024, 1_000, 128, 1024, 100, 100, 5_000); + try (var proxy = new NettyProxyNetworkServer( + 1, + (request, remoteAddress) -> java.util.Optional.of(selected), + metrics, + tuning)) { + proxy.bind(new InetSocketAddress("127.0.0.1", 0)) + .toCompletableFuture() + .get(5, TimeUnit.SECONDS); + assertEquals("nio", proxy.transportName()); + + try (var client = new Socket()) { + client.connect(proxy.bindAddress(), 5_000); + client.setSoTimeout(5_000); + client.getOutputStream().write(clientBytes); + client.getOutputStream().flush(); + + assertArrayEquals(backendResponse, client.getInputStream().readNBytes(backendResponse.length)); + } + + assertArrayEquals(clientBytes, backendRead.get(5, TimeUnit.SECONDS)); + awaitMetrics(metrics, clientBytes.length, backendResponse.length); + awaitServerActiveConnections(metrics, 0); + awaitGlobalActiveConnections(metrics, 0); + var snapshot = metrics.snapshot(); + var traffic = snapshot.serverTraffic().get("survival-1"); + var connections = snapshot.serverConnections().get("survival-1"); + assertEquals(1, snapshot.routedConnections()); + assertEquals(0, snapshot.activeConnections()); + assertEquals(clientBytes.length, snapshot.frontendToBackendBytes()); + assertEquals(backendResponse.length, snapshot.backendToFrontendBytes()); + assertEquals(1, connections.routedConnections()); + assertEquals(0, connections.activeConnections()); + assertEquals(clientBytes.length, traffic.frontendToBackendBytes()); + assertEquals(backendResponse.length, traffic.backendToFrontendBytes()); + assertEquals(new ProxyMetrics.PacketTraffic(1, handshake.length, 0), snapshot.packetTraffic().get(new ProxyMetrics.PacketTrafficKey( + "survival-1", + ProxyMetrics.CompressionDirection.FRONTEND_TO_BACKEND, + "HANDSHAKE", + 0))); + assertEquals(new ProxyMetrics.PacketTraffic(1, pendingLoginFrame.length, 0), snapshot.packetTraffic().get(new ProxyMetrics.PacketTrafficKey( + "survival-1", + ProxyMetrics.CompressionDirection.FRONTEND_TO_BACKEND, + "UNCOMPRESSED", + 0x01))); + } + } finally { + backendExecutor.shutdownNow(); + } + } + + private static void awaitMetrics(ProxyMetrics metrics, int frontendBytes, int backendBytes) throws InterruptedException { + var deadline = System.nanoTime() + Duration.ofSeconds(5).toNanos(); + while (System.nanoTime() < deadline) { + var snapshot = metrics.snapshot(); + if (snapshot.frontendToBackendBytes() == frontendBytes + && snapshot.backendToFrontendBytes() == backendBytes + && snapshot.routedConnections() == 1) { + return; + } + Thread.sleep(10); + } + var snapshot = metrics.snapshot(); + assertTrue(snapshot.routedConnections() == 1, "route count was " + snapshot.routedConnections()); + assertEquals(frontendBytes, snapshot.frontendToBackendBytes()); + assertEquals(backendBytes, snapshot.backendToFrontendBytes()); + } + + private static void awaitGlobalActiveConnections(ProxyMetrics metrics, long activeConnections) throws InterruptedException { + var deadline = System.nanoTime() + Duration.ofSeconds(5).toNanos(); + while (System.nanoTime() < deadline) { + if (metrics.snapshot().activeConnections() == activeConnections) { + return; + } + Thread.sleep(10); + } + assertEquals(activeConnections, metrics.snapshot().activeConnections()); + } + + private static void awaitServerActiveConnections(ProxyMetrics metrics, long activeConnections) throws InterruptedException { + var deadline = System.nanoTime() + Duration.ofSeconds(5).toNanos(); + while (System.nanoTime() < deadline) { + var connections = metrics.snapshot().serverConnections().get("survival-1"); + if (connections != null && connections.activeConnections() == activeConnections) { + return; + } + Thread.sleep(10); + } + var connections = metrics.snapshot().serverConnections().get("survival-1"); + assertEquals(activeConnections, connections == null ? 0 : connections.activeConnections()); + } + + private static void awaitConnectionStormMetrics( + ProxyMetrics metrics, + long acceptedConnections, + long rejectedConnections) throws InterruptedException { + var deadline = System.nanoTime() + Duration.ofSeconds(5).toNanos(); + while (System.nanoTime() < deadline) { + var snapshot = metrics.snapshot(); + if (snapshot.acceptedConnections() == acceptedConnections + && snapshot.activeConnections() == acceptedConnections + && snapshot.rejectedConnections() == rejectedConnections) { + return; + } + Thread.sleep(10); + } + var snapshot = metrics.snapshot(); + assertEquals(acceptedConnections, snapshot.acceptedConnections()); + assertEquals(acceptedConnections, snapshot.activeConnections()); + assertEquals(rejectedConnections, snapshot.rejectedConnections()); + } + + private static void closeAll(List sockets) { + for (var socket : sockets) { + try { + socket.close(); + } catch (java.io.IOException ignored) { + // Best effort cleanup for sockets that were already rejected by the proxy. + } + } + } + + private static RegisteredServer server(String name, int port) { + var descriptor = new ServerDescriptor( + name, + new InetSocketAddress("127.0.0.1", port), + Set.of("survival"), + Set.of(ServerCapability.LARGE_PAYLOAD), + new ProtocolRange(0, Integer.MAX_VALUE, "any"), + 100, + 100, + 120, + false, + Map.of("host", "play.example.net")); + return new TestRegisteredServer( + descriptor, + ServerHealth.up(1), + new ServerLoad(0, 100, 120, 0, 0, 0, 0), + false); + } + + private static byte[] handshakeFrame(int protocol, String host, int port, int nextState) { + var payload = new ByteArrayOutputStream(); + writeVarInt(payload, 0); + writeVarInt(payload, protocol); + writeString(payload, host); + payload.write((port >>> 8) & 0xFF); + payload.write(port & 0xFF); + writeVarInt(payload, nextState); + + var payloadBytes = payload.toByteArray(); + var frame = new ByteArrayOutputStream(); + writeVarInt(frame, payloadBytes.length); + frame.writeBytes(payloadBytes); + return frame.toByteArray(); + } + + private static byte[] packetFrame(int packetId, int value) { + var payload = new ByteArrayOutputStream(); + writeVarInt(payload, packetId); + writeVarInt(payload, value); + + var payloadBytes = payload.toByteArray(); + var frame = new ByteArrayOutputStream(); + writeVarInt(frame, payloadBytes.length); + frame.writeBytes(payloadBytes); + return frame.toByteArray(); + } + + private static byte[] concat(byte[] first, byte[] second) { + var combined = new byte[first.length + second.length]; + System.arraycopy(first, 0, combined, 0, first.length); + System.arraycopy(second, 0, combined, first.length, second.length); + return combined; + } + + 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 record TestRegisteredServer( + ServerDescriptor descriptor, + ServerHealth health, + ServerLoad load, + boolean draining) implements RegisteredServer { + } +} diff --git a/proxy-network/src/test/java/dev/strataproxy/network/NettyTransportTest.java b/proxy-network/src/test/java/dev/strataproxy/network/NettyTransportTest.java new file mode 100644 index 0000000..4a63842 --- /dev/null +++ b/proxy-network/src/test/java/dev/strataproxy/network/NettyTransportTest.java @@ -0,0 +1,20 @@ +package dev.strataproxy.network; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; + +final class NettyTransportTest { + @Test + void selectsNioWhenNativeTransportIsDisabled() { + var transport = NettyTransport.select(false, 1); + try { + assertEquals("nio", transport.name()); + assertFalse(transport.nativeTransport()); + } finally { + transport.bossGroup().shutdownGracefully(); + transport.workerGroup().shutdownGracefully(); + } + } +} diff --git a/proxy-network/src/test/java/dev/strataproxy/network/NetworkTuningTest.java b/proxy-network/src/test/java/dev/strataproxy/network/NetworkTuningTest.java new file mode 100644 index 0000000..7ec9c18 --- /dev/null +++ b/proxy-network/src/test/java/dev/strataproxy/network/NetworkTuningTest.java @@ -0,0 +1,30 @@ +package dev.strataproxy.network; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +final class NetworkTuningTest { + @Test + void acceptsValidWatermarks() { + var tuning = new NetworkTuning(1024, 500, 128, 1024, 100, 10, 250); + + assertEquals(1024, tuning.maxFrameBytes()); + assertEquals(500, tuning.connectTimeoutMillis()); + assertEquals(128, tuning.writeBufferLowBytes()); + assertEquals(1024, tuning.writeBufferHighBytes()); + assertEquals(100, tuning.maxConnections()); + assertEquals(10, tuning.maxConnectionsPerAddress()); + assertEquals(250, tuning.initialHandshakeTimeoutMillis()); + } + + @Test + void rejectsInvalidWatermarks() { + assertThrows(IllegalArgumentException.class, () -> new NetworkTuning(1024, 500, 1024, 1024, 100, 10, 250)); + assertThrows(IllegalArgumentException.class, () -> new NetworkTuning(1024, 0, 128, 1024, 100, 10, 250)); + assertThrows(IllegalArgumentException.class, () -> new NetworkTuning(0, 500, 128, 1024, 100, 10, 250)); + assertThrows(IllegalArgumentException.class, () -> new NetworkTuning(1024, 500, 128, 1024, 0, 10, 250)); + assertThrows(IllegalArgumentException.class, () -> new NetworkTuning(1024, 500, 128, 1024, 100, 10, 0)); + } +} diff --git a/proxy-network/src/test/java/dev/strataproxy/network/RelayTrafficMetricsTest.java b/proxy-network/src/test/java/dev/strataproxy/network/RelayTrafficMetricsTest.java new file mode 100644 index 0000000..268c2d9 --- /dev/null +++ b/proxy-network/src/test/java/dev/strataproxy/network/RelayTrafficMetricsTest.java @@ -0,0 +1,1061 @@ +package dev.strataproxy.network; + +import dev.strataproxy.analysis.CustomPayloadAnomalyPolicy; +import dev.strataproxy.codec.minecraft.MinecraftVarInts; +import dev.strataproxy.codec.minecraft.MinecraftCompressionCodec; +import dev.strataproxy.compression.FixedCompressionStrategy; +import dev.strataproxy.observability.ProxyMetrics; +import io.netty.buffer.ByteBuf; +import io.netty.buffer.Unpooled; +import io.netty.channel.embedded.EmbeddedChannel; +import org.junit.jupiter.api.Test; + +import java.time.Duration; +import java.time.Instant; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +final class RelayTrafficMetricsTest { + @Test + void attributesFrontendTrafficToSelectedServer() { + var metrics = new ProxyMetrics(); + var backend = new EmbeddedChannel(); + var frontend = new EmbeddedChannel(new FrontendRelayHandler(backend, metrics, "survival-1")); + + frontend.writeInbound(Unpooled.wrappedBuffer(new byte[] {1, 2, 3, 4})); + + releaseOutbound(backend); + var traffic = metrics.snapshot().serverTraffic().get("survival-1"); + assertEquals(4, traffic.frontendToBackendBytes()); + assertEquals(0, traffic.backendToFrontendBytes()); + + frontend.finishAndReleaseAll(); + backend.finishAndReleaseAll(); + } + + @Test + void capturesRelayPayloadPrefixesWhenEnabled() { + var metrics = new ProxyMetrics(); + metrics.startPayloadCapture( + "cap-1", + "survival-1", + ProxyMetrics.CompressionDirection.FRONTEND_TO_BACKEND, + 4, + 2, + Instant.now().plusSeconds(30)); + var backend = new EmbeddedChannel(); + var frontend = new EmbeddedChannel(new FrontendRelayHandler(backend, metrics, "survival-1")); + + frontend.writeInbound(Unpooled.wrappedBuffer(new byte[] {1, 2, 3, 4})); + + releaseOutbound(backend); + var samples = metrics.payloadCaptureSamples("cap-1"); + assertEquals(1, samples.size()); + assertEquals(4, samples.getFirst().rawBytes()); + assertEquals(2, samples.getFirst().prefixBytes().length); + assertEquals(1, samples.getFirst().prefixBytes()[0]); + assertEquals(2, samples.getFirst().prefixBytes()[1]); + + frontend.finishAndReleaseAll(); + backend.finishAndReleaseAll(); + } + + @Test + void capturesRelayPayloadPrefixesWithConnectionIdentity() { + var metrics = new ProxyMetrics(); + metrics.startPayloadCapture( + "cap-1", + "survival-1", + ProxyMetrics.CompressionDirection.FRONTEND_TO_BACKEND, + 4, + 2, + Instant.now().plusSeconds(30)); + var backend = new EmbeddedChannel(); + var identity = new RelaySessionIdentity("127.0.0.1:50000"); + identity.playerName("Steve"); + var frontend = new EmbeddedChannel(new FrontendRelayHandler( + backend, + metrics, + "survival-1", + new MinecraftCompressionAuditState(4096), + CompressionRuntime.defaults(), + 4096, + CustomPayloadAnomalyPolicy.defaults(), + "Steve", + identity)); + + frontend.writeInbound(Unpooled.wrappedBuffer(new byte[] {1, 2, 3, 4})); + + releaseOutbound(backend); + var sample = metrics.payloadCaptureSamples("cap-1").getFirst(); + assertEquals("Steve", sample.player()); + assertEquals("127.0.0.1:50000", sample.remoteAddress()); + + frontend.finishAndReleaseAll(); + backend.finishAndReleaseAll(); + } + + @Test + void attributesBackendTrafficToSelectedServer() { + var metrics = new ProxyMetrics(); + var frontend = new EmbeddedChannel(); + var backend = new EmbeddedChannel(new BackendRelayHandler(frontend, metrics, "survival-1")); + + backend.writeInbound(Unpooled.wrappedBuffer(new byte[] {1, 2, 3})); + + releaseOutbound(frontend); + var traffic = metrics.snapshot().serverTraffic().get("survival-1"); + assertEquals(0, traffic.frontendToBackendBytes()); + assertEquals(3, traffic.backendToFrontendBytes()); + + backend.finishAndReleaseAll(); + frontend.finishAndReleaseAll(); + } + + @Test + void capturesBackendPayloadPrefixesWithConnectionIdentity() { + var metrics = new ProxyMetrics(); + metrics.startPayloadCapture( + "cap-1", + "survival-1", + ProxyMetrics.CompressionDirection.BACKEND_TO_FRONTEND, + 4, + 2, + Instant.now().plusSeconds(30)); + var frontend = new EmbeddedChannel(); + var identity = new RelaySessionIdentity("127.0.0.1:50000"); + identity.playerName("Steve"); + var backend = new EmbeddedChannel(new BackendRelayHandler( + frontend, + metrics, + "survival-1", + 4096, + new MinecraftCompressionAuditState(4096), + CompressionRuntime.defaults(), + identity)); + + backend.writeInbound(Unpooled.wrappedBuffer(new byte[] {1, 2, 3})); + + releaseOutbound(frontend); + var sample = metrics.payloadCaptureSamples("cap-1").getFirst(); + assertEquals("Steve", sample.player()); + assertEquals("127.0.0.1:50000", sample.remoteAddress()); + + backend.finishAndReleaseAll(); + frontend.finishAndReleaseAll(); + } + + @Test + void frontendRelayClosesServerConnectionOnlyOnce() { + var metrics = new ProxyMetrics(); + metrics.serverConnectionOpened("survival-1"); + var backend = new EmbeddedChannel(); + var frontend = new EmbeddedChannel(new FrontendRelayHandler(backend, metrics, "survival-1")); + + frontend.pipeline().fireExceptionCaught(new RuntimeException("boom")); + frontend.pipeline().fireChannelInactive(); + + var connections = metrics.snapshot().serverConnections().get("survival-1"); + assertEquals(1, connections.routedConnections()); + assertEquals(0, connections.activeConnections()); + + frontend.finishAndReleaseAll(); + backend.finishAndReleaseAll(); + } + + @Test + void recordsUncompressedPacketTrafficByDirectionAndPacketId() { + var metrics = new ProxyMetrics(); + var backendOutbound = new EmbeddedChannel(); + var frontendRelay = new EmbeddedChannel(new FrontendRelayHandler(backendOutbound, metrics, "survival-1")); + var frontendFrame = packetFrame(0x15, 0); + var frontendFirst = frontendFrame.readRetainedSlice(1); + var frontendSecond = frontendFrame.readRetainedSlice(frontendFrame.readableBytes()); + + frontendRelay.writeInbound(frontendFirst); + frontendRelay.writeInbound(frontendSecond); + releaseOutbound(backendOutbound); + + var frontendTraffic = metrics.snapshot().packetTraffic().get(new ProxyMetrics.PacketTrafficKey( + "survival-1", + ProxyMetrics.CompressionDirection.FRONTEND_TO_BACKEND, + "UNCOMPRESSED", + 0x15)); + assertEquals(1, frontendTraffic.packets()); + assertTrue(frontendTraffic.rawBytes() > 0); + + var frontendOutbound = new EmbeddedChannel(); + var backendRelay = new EmbeddedChannel(new BackendRelayHandler(frontendOutbound, metrics, "survival-1", 4096)); + var backendFrame = packetFrame(0x22, 0); + backendRelay.writeInbound(backendFrame.retainedDuplicate()); + releaseOutbound(frontendOutbound); + + var backendTraffic = metrics.snapshot().packetTraffic().get(new ProxyMetrics.PacketTrafficKey( + "survival-1", + ProxyMetrics.CompressionDirection.BACKEND_TO_FRONTEND, + "UNCOMPRESSED", + 0x22)); + assertEquals(1, backendTraffic.packets()); + assertEquals(backendFrame.readableBytes(), backendTraffic.rawBytes()); + + frontendFrame.release(); + backendFrame.release(); + frontendRelay.finishAndReleaseAll(); + backendRelay.finishAndReleaseAll(); + backendOutbound.finishAndReleaseAll(); + frontendOutbound.finishAndReleaseAll(); + } + + @Test + void frontendRelayTracksLoginStartPlayerSessionAcrossSplits() { + var metrics = new ProxyMetrics(); + var backend = new EmbeddedChannel(); + var frontend = new EmbeddedChannel(new FrontendRelayHandler(backend, metrics, "survival-1")); + var login = loginStartFrame("Steve"); + var first = login.readRetainedSlice(2); + var second = login.readRetainedSlice(login.readableBytes()); + + frontend.writeInbound(first); + frontend.writeInbound(second); + releaseOutbound(backend); + + var session = metrics.snapshot().playerSessions().get("Steve"); + assertEquals("survival-1", session.server()); + + frontend.pipeline().fireChannelInactive(); + + assertTrue(metrics.snapshot().playerSessions().isEmpty()); + login.release(); + frontend.finishAndReleaseAll(); + backend.finishAndReleaseAll(); + } + + @Test + void recordsBackendCompressionNegotiationWithoutChangingForwardedBytes() { + var metrics = new ProxyMetrics(); + var frontend = new EmbeddedChannel(); + var backend = new EmbeddedChannel(new BackendRelayHandler(frontend, metrics, "survival-1", 1024)); + var frame = packetFrame(0x03, 256); + + backend.writeInbound(frame.retainedDuplicate()); + + var forwarded = (ByteBuf) frontend.readOutbound(); + try { + assertEquals(frame.readableBytes(), forwarded.readableBytes()); + } finally { + forwarded.release(); + frame.release(); + } + var snapshot = metrics.snapshot(); + assertEquals(1, snapshot.compressionNegotiations()); + assertEquals(256, snapshot.serverCompressionThresholds().get("survival-1")); + + backend.finishAndReleaseAll(); + frontend.finishAndReleaseAll(); + } + + @Test + void recordsCompressionNegotiationAcrossSplitBackendFrames() { + var metrics = new ProxyMetrics(); + var frontend = new EmbeddedChannel(); + var backend = new EmbeddedChannel(new BackendRelayHandler(frontend, metrics, "survival-1", 1024)); + var frame = packetFrame(0x03, 512); + var first = frame.readRetainedSlice(1); + var second = frame.readRetainedSlice(frame.readableBytes()); + + backend.writeInbound(first); + backend.writeInbound(second); + + releaseOutbound(frontend); + var snapshot = metrics.snapshot(); + assertEquals(1, snapshot.compressionNegotiations()); + assertEquals(512, snapshot.serverCompressionThresholds().get("survival-1")); + + frame.release(); + backend.finishAndReleaseAll(); + frontend.finishAndReleaseAll(); + } + + @Test + void malformedCompressionNegotiationClosesRelay() { + var metrics = new ProxyMetrics(); + var frontend = new EmbeddedChannel(); + var backend = new EmbeddedChannel(new BackendRelayHandler(frontend, metrics, "survival-1", 2)); + var frame = Unpooled.buffer(); + MinecraftVarInts.write(frame, 3); + frame.writeZero(3); + + backend.writeInbound(frame.retainedDuplicate()); + + assertNull(frontend.readOutbound()); + var snapshot = metrics.snapshot(); + assertEquals(0, snapshot.compressionNegotiations()); + assertEquals(1, snapshot.packetAnomalies().get("compression-negotiation-malformed")); + + frame.release(); + backend.finishAndReleaseAll(); + frontend.finishAndReleaseAll(); + } + + @Test + void samplesCompressedBackendFramesAfterNegotiation() { + var metrics = new ProxyMetrics(); + var frontend = new EmbeddedChannel(); + var backend = new EmbeddedChannel(new BackendRelayHandler(frontend, metrics, "survival-1", 4096)); + var negotiation = packetFrame(0x03, 32); + var packet = Unpooled.wrappedBuffer("compress-me-".repeat(32).getBytes(java.nio.charset.StandardCharsets.UTF_8)); + try (var codec = new MinecraftCompressionCodec()) { + var compressed = codec.encodeFrame(UnpooledByteBufAllocatorHolder.ALLOCATOR, packet, 32); + try { + backend.writeInbound(negotiation.retainedDuplicate()); + releaseOutbound(frontend); + + backend.writeInbound(compressed.retainedDuplicate()); + releaseOutbound(frontend); + + var audit = metrics.snapshot().serverCompression().get("survival-1"); + assertEquals(1, audit.samples()); + assertEquals(packet.readableBytes(), audit.rawBytes()); + assertEquals(compressed.readableBytes(), audit.compressedBytes()); + assertTrue(audit.savedBytes() > 0); + assertEquals(1, metrics.snapshot().compressionDecisions().values().stream().mapToLong(Long::longValue).sum()); + } finally { + compressed.release(); + packet.release(); + negotiation.release(); + } + } + + backend.finishAndReleaseAll(); + frontend.finishAndReleaseAll(); + } + + @Test + void samplesCompressedFrontendFramesAfterBackendNegotiation() { + var metrics = new ProxyMetrics(); + var compressionAudit = new MinecraftCompressionAuditState(4096); + var frontendOutbound = new EmbeddedChannel(); + var backendOutbound = new EmbeddedChannel(); + var backendRelay = new EmbeddedChannel(new BackendRelayHandler( + frontendOutbound, + metrics, + "survival-1", + 4096, + compressionAudit)); + var frontendRelay = new EmbeddedChannel(new FrontendRelayHandler( + backendOutbound, + metrics, + "survival-1", + compressionAudit)); + var negotiation = packetFrame(0x03, 32); + var packet = Unpooled.wrappedBuffer("client-compress-me-".repeat(16).getBytes(java.nio.charset.StandardCharsets.UTF_8)); + try (var codec = new MinecraftCompressionCodec()) { + var compressed = codec.encodeFrame(UnpooledByteBufAllocatorHolder.ALLOCATOR, packet, 32); + try { + backendRelay.writeInbound(negotiation.retainedDuplicate()); + releaseOutbound(frontendOutbound); + + frontendRelay.writeInbound(compressed.retainedDuplicate()); + var forwarded = (ByteBuf) backendOutbound.readOutbound(); + try { + assertEquals(compressed.readableBytes(), forwarded.readableBytes()); + } finally { + forwarded.release(); + } + + var audit = metrics.snapshot().serverCompression().get("survival-1"); + assertEquals(1, audit.samples()); + assertEquals(packet.readableBytes(), audit.rawBytes()); + assertEquals(compressed.readableBytes(), audit.compressedBytes()); + assertTrue(audit.savedBytes() > 0); + assertEquals(1, metrics.snapshot().compressionDecisions().values().stream().mapToLong(Long::longValue).sum()); + } finally { + compressed.release(); + packet.release(); + negotiation.release(); + } + } + + backendRelay.finishAndReleaseAll(); + frontendRelay.finishAndReleaseAll(); + frontendOutbound.finishAndReleaseAll(); + backendOutbound.finishAndReleaseAll(); + } + + @Test + void rewritesCompressedFrontendFrameWhenEnabledAndPolicyChangesThreshold() { + var metrics = new ProxyMetrics(); + var compressionAudit = new MinecraftCompressionAuditState(4096); + var frontendOutbound = new EmbeddedChannel(); + var backendOutbound = new EmbeddedChannel(); + var targetThreshold = 4096; + var backendRelay = new EmbeddedChannel(new BackendRelayHandler( + frontendOutbound, + metrics, + "survival-1", + 4096, + compressionAudit)); + var frontendRelay = new EmbeddedChannel(new FrontendRelayHandler( + backendOutbound, + metrics, + "survival-1", + compressionAudit, + new CompressionRuntime(new FixedCompressionStrategy(), targetThreshold, targetThreshold, 0.75d), + 4096, + CustomPayloadAnomalyPolicy.defaults(), + null, + new RelaySessionIdentity(""), + true)); + var negotiation = packetFrame(0x03, 32); + var packet = Unpooled.wrappedBuffer("client-compress-me-".repeat(16).getBytes(java.nio.charset.StandardCharsets.UTF_8)); + try (var codec = new MinecraftCompressionCodec()) { + var compressed = codec.encodeFrame(UnpooledByteBufAllocatorHolder.ALLOCATOR, packet, 32); + ByteBuf forwarded = null; + ByteBuf decoded = null; + try { + backendRelay.writeInbound(negotiation.retainedDuplicate()); + releaseOutbound(frontendOutbound); + + frontendRelay.writeInbound(compressed.retainedDuplicate()); + forwarded = backendOutbound.readOutbound(); + + assertTrue(forwarded.readableBytes() > compressed.readableBytes()); + decoded = codec.decodeFrame(UnpooledByteBufAllocatorHolder.ALLOCATOR, forwarded.slice(), targetThreshold, 4096); + assertEquals(packet.readableBytes(), decoded.readableBytes()); + + var rewriteCount = metrics.snapshot().compressionRewrites().get(new ProxyMetrics.CompressionRewriteKey( + "survival-1", + ProxyMetrics.CompressionDirection.FRONTEND_TO_BACKEND, + "rewritten")); + assertEquals(1, rewriteCount.count()); + } finally { + release(forwarded); + release(decoded); + compressed.release(); + packet.release(); + negotiation.release(); + } + } + + backendRelay.finishAndReleaseAll(); + frontendRelay.finishAndReleaseAll(); + frontendOutbound.finishAndReleaseAll(); + backendOutbound.finishAndReleaseAll(); + } + + @Test + void rewritesCoalescedCompressedFrontendFramesWhenEnabled() { + var metrics = new ProxyMetrics(); + var compressionAudit = new MinecraftCompressionAuditState(8192); + var frontendOutbound = new EmbeddedChannel(); + var backendOutbound = new EmbeddedChannel(); + var targetThreshold = 8192; + var backendRelay = new EmbeddedChannel(new BackendRelayHandler( + frontendOutbound, + metrics, + "survival-1", + 8192, + compressionAudit)); + var frontendRelay = new EmbeddedChannel(new FrontendRelayHandler( + backendOutbound, + metrics, + "survival-1", + compressionAudit, + new CompressionRuntime(new FixedCompressionStrategy(), targetThreshold, targetThreshold, 0.75d), + 8192, + CustomPayloadAnomalyPolicy.defaults(), + null, + new RelaySessionIdentity(""), + true)); + var negotiation = packetFrame(0x03, 32); + var firstPacket = Unpooled.wrappedBuffer("first-client-compress-me-".repeat(12).getBytes(java.nio.charset.StandardCharsets.UTF_8)); + var secondPacket = Unpooled.wrappedBuffer("second-client-compress-me-".repeat(12).getBytes(java.nio.charset.StandardCharsets.UTF_8)); + try (var codec = new MinecraftCompressionCodec()) { + var firstCompressed = codec.encodeFrame(UnpooledByteBufAllocatorHolder.ALLOCATOR, firstPacket, 32); + var secondCompressed = codec.encodeFrame(UnpooledByteBufAllocatorHolder.ALLOCATOR, secondPacket, 32); + var coalesced = Unpooled.buffer(firstCompressed.readableBytes() + secondCompressed.readableBytes()); + ByteBuf forwarded = null; + ByteBuf firstDecoded = null; + ByteBuf secondDecoded = null; + try { + coalesced.writeBytes(firstCompressed, firstCompressed.readerIndex(), firstCompressed.readableBytes()); + coalesced.writeBytes(secondCompressed, secondCompressed.readerIndex(), secondCompressed.readableBytes()); + + backendRelay.writeInbound(negotiation.retainedDuplicate()); + releaseOutbound(frontendOutbound); + + frontendRelay.writeInbound(coalesced.retainedDuplicate()); + forwarded = backendOutbound.readOutbound(); + assertTrue(forwarded.readableBytes() > coalesced.readableBytes()); + + var view = forwarded.slice(); + firstDecoded = codec.decodeFrame(UnpooledByteBufAllocatorHolder.ALLOCATOR, view, targetThreshold, 8192); + secondDecoded = codec.decodeFrame(UnpooledByteBufAllocatorHolder.ALLOCATOR, view, targetThreshold, 8192); + assertEquals(0, view.readableBytes()); + assertArrayEquals(toBytes(firstPacket), toBytes(firstDecoded)); + assertArrayEquals(toBytes(secondPacket), toBytes(secondDecoded)); + + var rewriteCount = metrics.snapshot().compressionRewrites().get(new ProxyMetrics.CompressionRewriteKey( + "survival-1", + ProxyMetrics.CompressionDirection.FRONTEND_TO_BACKEND, + "rewritten")); + assertEquals(1, rewriteCount.count()); + assertEquals(2, metrics.snapshot().compressionDecisions().values().stream().mapToLong(Long::longValue).sum()); + } finally { + release(forwarded); + release(firstDecoded); + release(secondDecoded); + coalesced.release(); + firstCompressed.release(); + secondCompressed.release(); + firstPacket.release(); + secondPacket.release(); + negotiation.release(); + } + } + + backendRelay.finishAndReleaseAll(); + frontendRelay.finishAndReleaseAll(); + frontendOutbound.finishAndReleaseAll(); + backendOutbound.finishAndReleaseAll(); + } + + @Test + void buffersPartialCompressedFrontendFrameUntilRewriteCanComplete() { + var metrics = new ProxyMetrics(); + var compressionAudit = new MinecraftCompressionAuditState(8192); + var frontendOutbound = new EmbeddedChannel(); + var backendOutbound = new EmbeddedChannel(); + var targetThreshold = 8192; + var backendRelay = new EmbeddedChannel(new BackendRelayHandler( + frontendOutbound, + metrics, + "survival-1", + 8192, + compressionAudit)); + var frontendRelay = new EmbeddedChannel(new FrontendRelayHandler( + backendOutbound, + metrics, + "survival-1", + compressionAudit, + new CompressionRuntime(new FixedCompressionStrategy(), targetThreshold, targetThreshold, 0.75d), + 8192, + CustomPayloadAnomalyPolicy.defaults(), + null, + new RelaySessionIdentity(""), + true)); + var negotiation = packetFrame(0x03, 32); + var packet = Unpooled.wrappedBuffer("split-client-compress-me-".repeat(16).getBytes(java.nio.charset.StandardCharsets.UTF_8)); + try (var codec = new MinecraftCompressionCodec()) { + var compressed = codec.encodeFrame(UnpooledByteBufAllocatorHolder.ALLOCATOR, packet, 32); + ByteBuf forwarded = null; + ByteBuf decoded = null; + try { + backendRelay.writeInbound(negotiation.retainedDuplicate()); + releaseOutbound(frontendOutbound); + + var splitAt = Math.max(1, compressed.readableBytes() / 2); + frontendRelay.writeInbound(compressed.retainedSlice(compressed.readerIndex(), splitAt)); + assertNull(backendOutbound.readOutbound()); + + frontendRelay.writeInbound(compressed.retainedSlice(compressed.readerIndex() + splitAt, compressed.readableBytes() - splitAt)); + forwarded = backendOutbound.readOutbound(); + assertTrue(forwarded.readableBytes() > compressed.readableBytes()); + + decoded = codec.decodeFrame(UnpooledByteBufAllocatorHolder.ALLOCATOR, forwarded.slice(), targetThreshold, 8192); + assertArrayEquals(toBytes(packet), toBytes(decoded)); + + var rewriteCount = metrics.snapshot().compressionRewrites().get(new ProxyMetrics.CompressionRewriteKey( + "survival-1", + ProxyMetrics.CompressionDirection.FRONTEND_TO_BACKEND, + "rewritten")); + assertEquals(1, rewriteCount.count()); + } finally { + release(forwarded); + release(decoded); + compressed.release(); + packet.release(); + negotiation.release(); + } + } + + backendRelay.finishAndReleaseAll(); + frontendRelay.finishAndReleaseAll(); + frontendOutbound.finishAndReleaseAll(); + backendOutbound.finishAndReleaseAll(); + } + + @Test + void skipsCompressionRewriteWhenEventLoopDelayExceedsGuard() { + var metrics = new ProxyMetrics(); + metrics.eventLoopDelayNanos(50_000_000L); + var compressionAudit = new MinecraftCompressionAuditState(4096); + var frontendOutbound = new EmbeddedChannel(); + var backendOutbound = new EmbeddedChannel(); + var targetThreshold = 4096; + var backendRelay = new EmbeddedChannel(new BackendRelayHandler( + frontendOutbound, + metrics, + "survival-1", + 4096, + compressionAudit)); + var frontendRelay = new EmbeddedChannel(new FrontendRelayHandler( + backendOutbound, + metrics, + "survival-1", + compressionAudit, + new CompressionRuntime(new FixedCompressionStrategy(), targetThreshold, targetThreshold, 0.75d), + 4096, + CustomPayloadAnomalyPolicy.defaults(), + null, + new RelaySessionIdentity(""), + true, + 10)); + var negotiation = packetFrame(0x03, 32); + var packet = Unpooled.wrappedBuffer("guard-client-compress-me-".repeat(12).getBytes(java.nio.charset.StandardCharsets.UTF_8)); + try (var codec = new MinecraftCompressionCodec()) { + var compressed = codec.encodeFrame(UnpooledByteBufAllocatorHolder.ALLOCATOR, packet, 32); + ByteBuf forwarded = null; + try { + backendRelay.writeInbound(negotiation.retainedDuplicate()); + releaseOutbound(frontendOutbound); + + frontendRelay.writeInbound(compressed.retainedDuplicate()); + forwarded = backendOutbound.readOutbound(); + assertEquals(compressed.readableBytes(), forwarded.readableBytes()); + + var guardCount = metrics.snapshot().compressionRewrites().get(new ProxyMetrics.CompressionRewriteKey( + "survival-1", + ProxyMetrics.CompressionDirection.FRONTEND_TO_BACKEND, + "event_loop_guard")); + assertEquals(1, guardCount.count()); + } finally { + release(forwarded); + compressed.release(); + packet.release(); + negotiation.release(); + } + } + + backendRelay.finishAndReleaseAll(); + frontendRelay.finishAndReleaseAll(); + frontendOutbound.finishAndReleaseAll(); + backendOutbound.finishAndReleaseAll(); + } + + @Test + void warnsOnLargeKnownCustomPayloadsWithoutChangingForwardedBytes() { + var metrics = new ProxyMetrics(); + var backend = new EmbeddedChannel(); + var frontend = new EmbeddedChannel(new FrontendRelayHandler(backend, metrics, "survival-1")); + var frame = customPayloadFrame(0x01, "minecraft:brand", 2 * 1024 * 1024); + + frontend.writeInbound(frame.retainedDuplicate()); + + var forwarded = (ByteBuf) backend.readOutbound(); + try { + assertEquals(frame.readableBytes(), forwarded.readableBytes()); + } finally { + forwarded.release(); + frame.release(); + } + var snapshot = metrics.snapshot(); + var payload = snapshot.customPayloads().get(new ProxyMetrics.CustomPayloadKey( + "survival-1", + ProxyMetrics.CompressionDirection.FRONTEND_TO_BACKEND, + "BRAND", + "minecraft:brand")); + assertEquals(1, payload.packets()); + assertEquals(2 * 1024 * 1024, payload.payloadBytes()); + assertEquals(0, payload.compressedBytes()); + assertEquals(2 * 1024 * 1024, payload.maxPayloadBytes()); + assertEquals(0, payload.maxCompressedBytes()); + assertTrue(payload.firstSeen() != null); + assertTrue(payload.lastSeen() != null); + assertEquals(1, snapshot.packetAnomalies().get("custom-payload-large")); + assertEquals(0, snapshot.packetAnomalies().getOrDefault("custom-payload-unknown-large", 0L)); + assertTrue(snapshot.recentPacketAnomalies().stream() + .anyMatch(sample -> sample.rule().equals("custom-payload-large") + && sample.server().equals("survival-1") + && sample.direction().equals("frontend_to_backend") + && sample.protocolState().equals("CONFIGURATION") + && sample.detail().contains("minecraft:brand"))); + + frontend.finishAndReleaseAll(); + backend.finishAndReleaseAll(); + } + + @Test + void throttlesLargeUnknownCustomPayloadsBeforeForwarding() { + var metrics = new ProxyMetrics(); + var backend = new EmbeddedChannel(); + var frontend = new EmbeddedChannel(new FrontendRelayHandler(backend, metrics, "survival-1")); + var frame = customPayloadFrame(0x01, "unknown:blob", 2 * 1024 * 1024); + + frontend.writeInbound(frame.retainedDuplicate()); + + assertNull(backend.readOutbound()); + frame.release(); + var snapshot = metrics.snapshot(); + assertEquals(1, snapshot.packetAnomalies().get("custom-payload-large")); + assertEquals(1, snapshot.packetAnomalies().get("custom-payload-unknown-large")); + assertTrue(snapshot.recentPacketAnomalies().stream() + .anyMatch(sample -> sample.rule().equals("custom-payload-unknown-large") + && sample.server().equals("survival-1") + && sample.direction().equals("frontend_to_backend") + && sample.protocolState().equals("CONFIGURATION") + && sample.detail().contains("action=THROTTLE"))); + + frontend.finishAndReleaseAll(); + backend.finishAndReleaseAll(); + } + + @Test + void customPayloadInspectionUsesConfiguredThrottleThresholds() { + var metrics = new ProxyMetrics(); + var backend = new EmbeddedChannel(); + var frontend = new EmbeddedChannel(new FrontendRelayHandler( + backend, + metrics, + "survival-1", + new MinecraftCompressionAuditState(4096), + CompressionRuntime.defaults(), + 4096, + new CustomPayloadAnomalyPolicy(4096, 1024, 4096))); + var frame = customPayloadFrame(0x01, "unknown:blob", 2048); + + frontend.writeInbound(frame.retainedDuplicate()); + + assertNull(backend.readOutbound()); + frame.release(); + var anomalies = metrics.snapshot().packetAnomalies(); + assertEquals(0, anomalies.getOrDefault("custom-payload-large", 0L)); + assertEquals(1, anomalies.get("custom-payload-unknown-large")); + + frontend.finishAndReleaseAll(); + backend.finishAndReleaseAll(); + } + + @Test + void throttlesCompressedLargeUnknownCustomPayloadsBeforeForwarding() { + var metrics = new ProxyMetrics(); + var compressionAudit = new MinecraftCompressionAuditState(4096); + compressionAudit.negotiate(32); + var backend = new EmbeddedChannel(); + var frontend = new EmbeddedChannel(new FrontendRelayHandler( + backend, + metrics, + "survival-1", + compressionAudit, + CompressionRuntime.defaults(), + 4096, + new CustomPayloadAnomalyPolicy(4096, 1024, 4096))); + var packet = customPayloadPacket(0x01, "unknown:blob", 2048); + try (var codec = new MinecraftCompressionCodec()) { + var compressed = codec.encodeFrame(UnpooledByteBufAllocatorHolder.ALLOCATOR, packet, 32); + try { + frontend.writeInbound(compressed.retainedDuplicate()); + + assertNull(backend.readOutbound()); + var snapshot = metrics.snapshot(); + assertEquals(1, snapshot.packetAnomalies().get("custom-payload-unknown-large")); + assertTrue(snapshot.recentPacketAnomalies().stream() + .anyMatch(sample -> sample.rule().equals("custom-payload-unknown-large") + && sample.server().equals("survival-1") + && sample.direction().equals("frontend_to_backend") + && sample.protocolState().equals("CONFIGURATION") + && sample.compressedSize() == compressed.readableBytes() + && sample.detail().contains("action=THROTTLE"))); + } finally { + compressed.release(); + packet.release(); + } + } + + frontend.finishAndReleaseAll(); + backend.finishAndReleaseAll(); + } + + @Test + void throttlesCustomPayloadFloodsBeforeForwardingTriggeringFrame() { + var metrics = new ProxyMetrics(); + var backend = new EmbeddedChannel(); + var frontend = new EmbeddedChannel(new FrontendRelayHandler( + backend, + metrics, + "survival-1", + new MinecraftCompressionAuditState(4096), + CompressionRuntime.defaults(), + 4096, + new CustomPayloadAnomalyPolicy(4096, 4096, 4096, 2, Duration.ofSeconds(10)))); + var first = customPayloadFrame(0x01, "minecraft:brand", 16); + var second = customPayloadFrame(0x01, "minecraft:brand", 16); + var third = customPayloadFrame(0x01, "minecraft:brand", 16); + + frontend.writeInbound(first.retainedDuplicate()); + frontend.writeInbound(second.retainedDuplicate()); + releaseOutbound(backend); + frontend.writeInbound(third.retainedDuplicate()); + + assertNull(backend.readOutbound()); + first.release(); + second.release(); + third.release(); + var snapshot = metrics.snapshot(); + assertEquals(1, snapshot.packetAnomalies().get("custom-payload-flood")); + assertTrue(snapshot.recentPacketAnomalies().stream() + .anyMatch(sample -> sample.rule().equals("custom-payload-flood") + && sample.detail().contains("action=THROTTLE"))); + + frontend.finishAndReleaseAll(); + backend.finishAndReleaseAll(); + } + + @Test + void inspectsUncompressedFrontendCustomPayloadsAcrossSplits() { + var metrics = new ProxyMetrics(); + var backend = new EmbeddedChannel(); + var frontend = new EmbeddedChannel(new FrontendRelayHandler(backend, metrics, "survival-1")); + var frame = customPayloadFrame(0x01, "fml:handshake", 2 * 1024 * 1024); + var first = frame.readRetainedSlice(2); + var second = frame.readRetainedSlice(frame.readableBytes()); + + frontend.writeInbound(first); + frontend.writeInbound(second); + + releaseOutbound(backend); + assertEquals(1, metrics.snapshot().packetAnomalies().get("modded-handshake-large")); + + frame.release(); + frontend.finishAndReleaseAll(); + backend.finishAndReleaseAll(); + } + + @Test + void samplesCompressedFrontendFramesAcrossSplits() { + var metrics = new ProxyMetrics(); + var compressionAudit = new MinecraftCompressionAuditState(4096); + compressionAudit.negotiate(32); + var backendOutbound = new EmbeddedChannel(); + var frontendRelay = new EmbeddedChannel(new FrontendRelayHandler( + backendOutbound, + metrics, + "survival-1", + compressionAudit)); + var packet = Unpooled.wrappedBuffer("client-split-compress-me-".repeat(16).getBytes(java.nio.charset.StandardCharsets.UTF_8)); + try (var codec = new MinecraftCompressionCodec()) { + var compressed = codec.encodeFrame(UnpooledByteBufAllocatorHolder.ALLOCATOR, packet, 32); + var first = compressed.readRetainedSlice(2); + var second = compressed.readRetainedSlice(compressed.readableBytes()); + try { + frontendRelay.writeInbound(first); + frontendRelay.writeInbound(second); + releaseOutbound(backendOutbound); + + var audit = metrics.snapshot().serverCompression().get("survival-1"); + assertEquals(1, audit.samples()); + assertEquals(packet.readableBytes(), audit.rawBytes()); + } finally { + compressed.release(); + packet.release(); + } + } + + frontendRelay.finishAndReleaseAll(); + backendOutbound.finishAndReleaseAll(); + } + + @Test + void samplesCompressedBackendFramesAcrossSplits() { + var metrics = new ProxyMetrics(); + var frontend = new EmbeddedChannel(); + var backend = new EmbeddedChannel(new BackendRelayHandler(frontend, metrics, "survival-1", 4096)); + var negotiation = packetFrame(0x03, 32); + var packet = Unpooled.wrappedBuffer("split-compress-me-".repeat(16).getBytes(java.nio.charset.StandardCharsets.UTF_8)); + try (var codec = new MinecraftCompressionCodec()) { + var compressed = codec.encodeFrame(UnpooledByteBufAllocatorHolder.ALLOCATOR, packet, 32); + var first = compressed.readRetainedSlice(2); + var second = compressed.readRetainedSlice(compressed.readableBytes()); + try { + backend.writeInbound(negotiation.retainedDuplicate()); + releaseOutbound(frontend); + backend.writeInbound(first); + backend.writeInbound(second); + releaseOutbound(frontend); + + var audit = metrics.snapshot().serverCompression().get("survival-1"); + assertEquals(1, audit.samples()); + assertEquals(packet.readableBytes(), audit.rawBytes()); + } finally { + compressed.release(); + packet.release(); + negotiation.release(); + } + } + + backend.finishAndReleaseAll(); + frontend.finishAndReleaseAll(); + } + + @Test + void malformedCompressionAuditClosesRelay() { + var metrics = new ProxyMetrics(); + var frontend = new EmbeddedChannel(); + var backend = new EmbeddedChannel(new BackendRelayHandler(frontend, metrics, "survival-1", 4096)); + var negotiation = packetFrame(0x03, 64); + var malformed = Unpooled.buffer(); + MinecraftVarInts.write(malformed, 2); + MinecraftVarInts.write(malformed, 8); + malformed.writeByte(0); + + backend.writeInbound(negotiation.retainedDuplicate()); + releaseOutbound(frontend); + backend.writeInbound(malformed.retainedDuplicate()); + + assertNull(frontend.readOutbound()); + malformed.release(); + negotiation.release(); + var snapshot = metrics.snapshot(); + assertEquals(1, snapshot.packetAnomalies().get("compression-audit-malformed")); + assertEquals(0, snapshot.serverCompression().getOrDefault( + "survival-1", + new ProxyMetrics.CompressionAudit(0, 0, 0, 0, 0)).samples()); + + backend.finishAndReleaseAll(); + frontend.finishAndReleaseAll(); + } + + @Test + void compressionBombDeclaredSizeClosesRelay() { + var metrics = new ProxyMetrics(); + var frontend = new EmbeddedChannel(); + var backend = new EmbeddedChannel(new BackendRelayHandler(frontend, metrics, "survival-1", 4096)); + var negotiation = packetFrame(0x03, 64); + var bomb = Unpooled.buffer(); + var body = Unpooled.buffer(); + MinecraftVarInts.write(body, 8192); + body.writeByte(0); + MinecraftVarInts.write(bomb, body.readableBytes()); + bomb.writeBytes(body); + body.release(); + + backend.writeInbound(negotiation.retainedDuplicate()); + releaseOutbound(frontend); + backend.writeInbound(bomb.retainedDuplicate()); + + assertNull(frontend.readOutbound()); + bomb.release(); + negotiation.release(); + var snapshot = metrics.snapshot(); + assertEquals(1, snapshot.packetAnomalies().get("compression-audit-malformed")); + assertTrue(snapshot.recentPacketAnomalies().stream() + .anyMatch(sample -> sample.detail().contains("maximum uncompressed size"))); + + backend.finishAndReleaseAll(); + frontend.finishAndReleaseAll(); + } + + @Test + void frontendCompressionBombDeclaredSizeClosesRelay() { + var metrics = new ProxyMetrics(); + var compressionAudit = new MinecraftCompressionAuditState(4096); + compressionAudit.negotiate(64); + var backend = new EmbeddedChannel(); + var frontend = new EmbeddedChannel(new FrontendRelayHandler( + backend, + metrics, + "survival-1", + compressionAudit)); + var bomb = Unpooled.buffer(); + var body = Unpooled.buffer(); + MinecraftVarInts.write(body, 8192); + body.writeByte(0); + MinecraftVarInts.write(bomb, body.readableBytes()); + bomb.writeBytes(body); + body.release(); + + frontend.writeInbound(bomb.retainedDuplicate()); + + assertNull(backend.readOutbound()); + bomb.release(); + var snapshot = metrics.snapshot(); + assertEquals(1, snapshot.packetAnomalies().get("compression-audit-malformed")); + assertTrue(snapshot.recentPacketAnomalies().stream() + .anyMatch(sample -> sample.direction().equals("frontend_to_backend") + && sample.detail().contains("maximum uncompressed size"))); + + frontend.finishAndReleaseAll(); + backend.finishAndReleaseAll(); + } + + private static void releaseOutbound(EmbeddedChannel channel) { + ByteBuf outbound; + while ((outbound = channel.readOutbound()) != null) { + outbound.release(); + } + } + + private static void release(ByteBuf buffer) { + if (buffer != null) { + buffer.release(); + } + } + + private static byte[] toBytes(ByteBuf buffer) { + var bytes = new byte[buffer.readableBytes()]; + buffer.getBytes(buffer.readerIndex(), bytes); + return bytes; + } + + private static ByteBuf packetFrame(int packetId, int threshold) { + var payload = Unpooled.buffer(); + MinecraftVarInts.write(payload, packetId); + MinecraftVarInts.write(payload, threshold); + + var frame = Unpooled.buffer(); + MinecraftVarInts.write(frame, payload.readableBytes()); + frame.writeBytes(payload); + payload.release(); + return frame; + } + + private static ByteBuf loginStartFrame(String username) { + var payload = Unpooled.buffer(); + MinecraftVarInts.write(payload, 0); + var bytes = username.getBytes(java.nio.charset.StandardCharsets.UTF_8); + MinecraftVarInts.write(payload, bytes.length); + payload.writeBytes(bytes); + + var frame = Unpooled.buffer(); + MinecraftVarInts.write(frame, payload.readableBytes()); + frame.writeBytes(payload); + payload.release(); + return frame; + } + + private static ByteBuf customPayloadFrame(int packetId, String channel, int payloadBytes) { + var payload = customPayloadPacket(packetId, channel, payloadBytes); + var frame = Unpooled.buffer(); + MinecraftVarInts.write(frame, payload.readableBytes()); + frame.writeBytes(payload); + payload.release(); + return frame; + } + + private static ByteBuf customPayloadPacket(int packetId, String channel, int payloadBytes) { + var payload = Unpooled.buffer(); + MinecraftVarInts.write(payload, packetId); + var channelBytes = channel.getBytes(java.nio.charset.StandardCharsets.UTF_8); + MinecraftVarInts.write(payload, channelBytes.length); + payload.writeBytes(channelBytes); + payload.writeZero(payloadBytes); + return payload; + } + + private static final class UnpooledByteBufAllocatorHolder { + private static final io.netty.buffer.ByteBufAllocator ALLOCATOR = io.netty.buffer.UnpooledByteBufAllocator.DEFAULT; + } +} diff --git a/proxy-observability/build.gradle.kts b/proxy-observability/build.gradle.kts new file mode 100644 index 0000000..ba059cd --- /dev/null +++ b/proxy-observability/build.gradle.kts @@ -0,0 +1,7 @@ +plugins { + id("strataproxy.java-library-conventions") +} + +dependencies { + api(project(":proxy-api")) +} diff --git a/proxy-observability/src/main/java/dev/strataproxy/observability/EventSink.java b/proxy-observability/src/main/java/dev/strataproxy/observability/EventSink.java new file mode 100644 index 0000000..a8b3e9c --- /dev/null +++ b/proxy-observability/src/main/java/dev/strataproxy/observability/EventSink.java @@ -0,0 +1,5 @@ +package dev.strataproxy.observability; + +public interface EventSink { + void publish(MetricEvent event); +} diff --git a/proxy-observability/src/main/java/dev/strataproxy/observability/InMemoryEventSink.java b/proxy-observability/src/main/java/dev/strataproxy/observability/InMemoryEventSink.java new file mode 100644 index 0000000..f375aed --- /dev/null +++ b/proxy-observability/src/main/java/dev/strataproxy/observability/InMemoryEventSink.java @@ -0,0 +1,17 @@ +package dev.strataproxy.observability; + +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; + +public final class InMemoryEventSink implements EventSink { + private final CopyOnWriteArrayList events = new CopyOnWriteArrayList<>(); + + @Override + public void publish(MetricEvent event) { + events.add(event); + } + + public List snapshot() { + return List.copyOf(events); + } +} diff --git a/proxy-observability/src/main/java/dev/strataproxy/observability/MetricEvent.java b/proxy-observability/src/main/java/dev/strataproxy/observability/MetricEvent.java new file mode 100644 index 0000000..b3732b3 --- /dev/null +++ b/proxy-observability/src/main/java/dev/strataproxy/observability/MetricEvent.java @@ -0,0 +1,15 @@ +package dev.strataproxy.observability; + +import java.time.Instant; +import java.util.Map; + +public record MetricEvent( + String name, + Map attributes, + double value, + Instant timestamp) { + public MetricEvent { + attributes = Map.copyOf(attributes == null ? Map.of() : attributes); + timestamp = timestamp == null ? Instant.now() : timestamp; + } +} diff --git a/proxy-observability/src/main/java/dev/strataproxy/observability/ProxyMetrics.java b/proxy-observability/src/main/java/dev/strataproxy/observability/ProxyMetrics.java new file mode 100644 index 0000000..13a17c3 --- /dev/null +++ b/proxy-observability/src/main/java/dev/strataproxy/observability/ProxyMetrics.java @@ -0,0 +1,892 @@ +package dev.strataproxy.observability; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.LongAdder; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; +import java.util.concurrent.atomic.AtomicReferenceArray; +import java.util.stream.Collectors; + +public final class ProxyMetrics { + private static final int RECENT_PACKET_ANOMALY_CAPACITY = 256; + + private final LongAdder acceptedConnections = new LongAdder(); + private final AtomicLong activeConnections = new AtomicLong(); + private final LongAdder rejectedConnections = new LongAdder(); + private final ConcurrentHashMap rejectedConnectionsByReason = new ConcurrentHashMap<>(); + private final LongAdder handshakeTimeouts = new LongAdder(); + private final LongAdder routedConnections = new LongAdder(); + private final LongAdder failedRoutes = new LongAdder(); + private final LongAdder backendConnectFailures = new LongAdder(); + private final LongAdder frontendToBackendBytes = new LongAdder(); + private final LongAdder backendToFrontendBytes = new LongAdder(); + private final LongAdder compressionNegotiations = new LongAdder(); + private final AtomicLong packetAnomalySequence = new AtomicLong(); + private final AtomicReferenceArray recentPacketAnomalies = + new AtomicReferenceArray<>(RECENT_PACKET_ANOMALY_CAPACITY); + private final boolean packetAnomalySampling; + private final ConcurrentHashMap packetAnomalies = new ConcurrentHashMap<>(); + private final ConcurrentHashMap serverTraffic = new ConcurrentHashMap<>(); + private final ConcurrentHashMap serverConnections = new ConcurrentHashMap<>(); + private final ConcurrentHashMap playerSessions = new ConcurrentHashMap<>(); + private final ConcurrentHashMap packetTraffic = new ConcurrentHashMap<>(); + private final ConcurrentHashMap customPayloads = new ConcurrentHashMap<>(); + private final ConcurrentHashMap relayBackpressure = new ConcurrentHashMap<>(); + private final ConcurrentHashMap payloadCaptures = new ConcurrentHashMap<>(); + private final CompressionCounters compression = new CompressionCounters(); + private final ConcurrentHashMap serverCompression = new ConcurrentHashMap<>(); + private final ConcurrentHashMap serverCompressionByDirection = new ConcurrentHashMap<>(); + private final ConcurrentHashMap serverCompressionThresholds = new ConcurrentHashMap<>(); + private final ConcurrentHashMap compressionDecisions = new ConcurrentHashMap<>(); + private final ConcurrentHashMap compressionRewrites = new ConcurrentHashMap<>(); + private final AtomicLong eventLoopDelayNanos = new AtomicLong(); + private final AtomicLong maxEventLoopDelayNanos = new AtomicLong(); + private final AtomicLong pooledDirectMemoryBytes = new AtomicLong(); + private final AtomicReference networkTransport = new AtomicReference<>(new NetworkTransport("unknown", false)); + + public ProxyMetrics() { + this(true); + } + + public ProxyMetrics(boolean packetAnomalySampling) { + this.packetAnomalySampling = packetAnomalySampling; + } + + public void acceptedConnection() { + acceptedConnections.increment(); + activeConnections.incrementAndGet(); + } + + public void closedConnection() { + decrementGauge(activeConnections); + } + + public void rejectedConnection() { + rejectedConnection("unspecified"); + } + + public void rejectedConnection(String reason) { + var normalized = reason == null || reason.isBlank() ? "unspecified" : sanitize(reason.trim()); + rejectedConnections.increment(); + rejectedConnectionsByReason.computeIfAbsent(normalized, ignored -> new LongAdder()).increment(); + } + + public void handshakeTimeout() { + handshakeTimeouts.increment(); + } + + public void routedConnection() { + routedConnections.increment(); + } + + public void serverConnectionOpened(String server) { + serverConnections(server).connectionOpened(); + } + + public void serverConnectionClosed(String server) { + serverConnections(server).connectionClosed(); + } + + public void playerSessionStarted(String player, String server, String remoteAddress) { + if (player == null || player.isBlank()) { + throw new IllegalArgumentException("player must not be blank"); + } + if (server == null || server.isBlank()) { + throw new IllegalArgumentException("server must not be blank"); + } + playerSessions.put(player.trim(), new PlayerSession( + sanitize(player.trim()), + sanitize(server), + sanitize(remoteAddress), + Instant.now())); + } + + public void playerSessionClosed(String player) { + if (player != null && !player.isBlank()) { + playerSessions.remove(player.trim()); + } + } + + public void failedRoute() { + failedRoutes.increment(); + } + + public void backendConnectFailure() { + backendConnectFailures.increment(); + } + + public void frontendToBackendBytes(long bytes) { + frontendToBackendBytes.add(bytes); + } + + public void backendToFrontendBytes(long bytes) { + backendToFrontendBytes.add(bytes); + } + + public void frontendToBackendBytes(String server, long bytes) { + frontendToBackendBytes(bytes); + serverTraffic(server).frontendToBackendBytes.add(bytes); + } + + public void backendToFrontendBytes(String server, long bytes) { + backendToFrontendBytes(bytes); + serverTraffic(server).backendToFrontendBytes.add(bytes); + } + + public void packetAnomaly(String rule) { + packetAnomaly(rule, "", "", "", "", -1, -1, -1, ""); + } + + public void packetAnomaly( + String rule, + String remoteAddress, + String server, + String direction, + String protocolState, + int packetId, + long rawSize, + long compressedSize, + String detail) { + packetAnomalies.computeIfAbsent(rule, ignored -> new LongAdder()).increment(); + if (!packetAnomalySampling) { + return; + } + var sequence = packetAnomalySequence.incrementAndGet(); + var index = (int) ((sequence - 1) % RECENT_PACKET_ANOMALY_CAPACITY); + recentPacketAnomalies.set(index, new PacketAnomalySample( + sequence, + sanitize(rule), + sanitize(remoteAddress), + sanitize(server), + sanitize(direction), + sanitize(protocolState), + packetId, + rawSize, + compressedSize, + sanitize(detail), + Instant.now())); + } + + public void packetTraffic( + String server, + CompressionDirection direction, + String protocolState, + int packetId, + long rawBytes, + long compressedBytes) { + if (server == null || server.isBlank()) { + throw new IllegalArgumentException("server must not be blank"); + } + if (direction == null) { + throw new IllegalArgumentException("direction must not be null"); + } + if (protocolState == null || protocolState.isBlank()) { + throw new IllegalArgumentException("protocolState must not be blank"); + } + if (rawBytes < 0 || compressedBytes < 0) { + throw new IllegalArgumentException("packet traffic byte values must be non-negative"); + } + packetTraffic.computeIfAbsent( + new PacketTrafficKey(server, direction, sanitize(protocolState), packetId), + ignored -> new PacketTrafficCounters()).add(rawBytes, compressedBytes); + } + + public void customPayload( + String server, + CompressionDirection direction, + String kind, + String channel, + long payloadBytes, + long compressedBytes) { + if (server == null || server.isBlank()) { + throw new IllegalArgumentException("server must not be blank"); + } + if (direction == null) { + throw new IllegalArgumentException("direction must not be null"); + } + if (kind == null || kind.isBlank()) { + throw new IllegalArgumentException("kind must not be blank"); + } + if (payloadBytes < 0 || compressedBytes < 0) { + throw new IllegalArgumentException("custom payload byte values must be non-negative"); + } + var normalizedKind = sanitize(kind.trim()); + customPayloads.computeIfAbsent( + new CustomPayloadKey(server, direction, normalizedKind, normalizedChannel(normalizedKind, channel)), + ignored -> new CustomPayloadCounters()).add(payloadBytes, compressedBytes); + } + + public void relayBackpressure(String server, CompressionDirection direction, long bytesBeforeWritable) { + if (server == null || server.isBlank()) { + throw new IllegalArgumentException("server must not be blank"); + } + if (direction == null) { + throw new IllegalArgumentException("direction must not be null"); + } + relayBackpressure.computeIfAbsent( + new RelayBackpressureKey(server, direction), + ignored -> new RelayBackpressureCounters()).record(Math.max(0, bytesBeforeWritable)); + } + + public PayloadCapture startPayloadCapture( + String id, + String server, + CompressionDirection direction, + int maxSamples, + int maxBytesPerSample, + Instant expiresAt) { + if (id == null || id.isBlank()) { + throw new IllegalArgumentException("payload capture id must not be blank"); + } + if (server == null || server.isBlank()) { + throw new IllegalArgumentException("payload capture server must not be blank"); + } + if (direction == null) { + throw new IllegalArgumentException("payload capture direction must not be null"); + } + if (maxSamples <= 0) { + throw new IllegalArgumentException("payload capture maxSamples must be positive"); + } + if (maxBytesPerSample <= 0) { + throw new IllegalArgumentException("payload capture maxBytesPerSample must be positive"); + } + if (expiresAt == null || !expiresAt.isAfter(Instant.now())) { + throw new IllegalArgumentException("payload capture expiresAt must be in the future"); + } + var capture = new PayloadCapture(id.trim(), server.trim(), direction, maxSamples, maxBytesPerSample, expiresAt); + payloadCaptures.put(capture.id(), new PayloadCaptureBuffer(capture)); + return capture; + } + + public boolean stopPayloadCapture(String id) { + return id != null && payloadCaptures.remove(id) != null; + } + + public List payloadCaptures() { + expirePayloadCaptures(); + return payloadCaptures.values().stream() + .map(PayloadCaptureBuffer::capture) + .sorted(Comparator.comparing(PayloadCapture::id)) + .toList(); + } + + public List payloadCaptureSamples(String id) { + expirePayloadCaptures(); + var capture = payloadCaptures.get(id); + return capture == null ? List.of() : capture.samples(); + } + + public PayloadCaptureRequest payloadCaptureRequest(String server, CompressionDirection direction) { + if (server == null || server.isBlank() || direction == null || payloadCaptures.isEmpty()) { + return PayloadCaptureRequest.none(); + } + var now = Instant.now(); + var maxBytes = 0; + for (var entry : payloadCaptures.entrySet()) { + var capture = entry.getValue().capture(); + if (!capture.expiresAt().isAfter(now)) { + payloadCaptures.remove(entry.getKey(), entry.getValue()); + continue; + } + if (capture.server().equals(server) && capture.direction() == direction) { + maxBytes = Math.max(maxBytes, capture.maxBytesPerSample()); + } + } + return maxBytes <= 0 ? PayloadCaptureRequest.none() : new PayloadCaptureRequest(true, maxBytes); + } + + public void payloadCaptured( + String server, + CompressionDirection direction, + long rawBytes, + long compressedBytes, + byte[] prefixBytes) { + payloadCaptured(server, direction, rawBytes, compressedBytes, prefixBytes, "", ""); + } + + public void payloadCaptured( + String server, + CompressionDirection direction, + long rawBytes, + long compressedBytes, + byte[] prefixBytes, + String player, + String remoteAddress) { + if (server == null || server.isBlank() || direction == null || prefixBytes == null || payloadCaptures.isEmpty()) { + return; + } + var now = Instant.now(); + for (var entry : payloadCaptures.entrySet()) { + var buffer = entry.getValue(); + var capture = buffer.capture(); + if (!capture.expiresAt().isAfter(now)) { + payloadCaptures.remove(entry.getKey(), buffer); + continue; + } + if (capture.server().equals(server) && capture.direction() == direction) { + var limit = Math.min(capture.maxBytesPerSample(), prefixBytes.length); + buffer.record(rawBytes, compressedBytes, Arrays.copyOf(prefixBytes, limit), player, remoteAddress, now); + } + } + } + + public void compressionSample(long rawBytes, long compressedBytes, long cpuNanos) { + compression.add(rawBytes, compressedBytes, cpuNanos); + } + + public void compressionSample(String server, long rawBytes, long compressedBytes, long cpuNanos) { + compressionSample(rawBytes, compressedBytes, cpuNanos); + serverCompression(server).add(rawBytes, compressedBytes, cpuNanos); + } + + public void compressionSample(String server, CompressionDirection direction, long rawBytes, long compressedBytes, long cpuNanos) { + compressionSample(server, rawBytes, compressedBytes, cpuNanos); + serverCompression(server, direction).add(rawBytes, compressedBytes, cpuNanos); + } + + public void compressionNegotiated(String server, int threshold) { + if (threshold < 0) { + throw new IllegalArgumentException("compression threshold must be non-negative"); + } + compressionNegotiations.increment(); + serverCompressionThreshold(server).set(threshold); + } + + public void compressionDecision(String server, CompressionDirection direction, String action, int threshold) { + if (server == null || server.isBlank()) { + throw new IllegalArgumentException("server must not be blank"); + } + if (direction == null) { + throw new IllegalArgumentException("direction must not be null"); + } + if (action == null || action.isBlank()) { + throw new IllegalArgumentException("action must not be blank"); + } + compressionDecisions.computeIfAbsent( + new CompressionDecisionKey(server, direction, action.trim(), threshold), + ignored -> new LongAdder()).increment(); + } + + public void compressionRewrite(String server, CompressionDirection direction, String outcome) { + compressionRewrite(server, direction, outcome, 0); + } + + public void compressionRewrite(String server, CompressionDirection direction, String outcome, long cpuNanos) { + if (server == null || server.isBlank()) { + throw new IllegalArgumentException("server must not be blank"); + } + if (direction == null) { + throw new IllegalArgumentException("direction must not be null"); + } + if (outcome == null || outcome.isBlank()) { + throw new IllegalArgumentException("outcome must not be blank"); + } + if (cpuNanos < 0) { + throw new IllegalArgumentException("compression rewrite cpuNanos must be non-negative"); + } + compressionRewrites.computeIfAbsent( + new CompressionRewriteKey(server, direction, sanitize(outcome.trim())), + ignored -> new CompressionRewriteCounters()).add(cpuNanos); + } + + public long currentEventLoopDelayNanos() { + return eventLoopDelayNanos.get(); + } + + public void eventLoopDelayNanos(long nanos) { + eventLoopDelayNanos.set(Math.max(0, nanos)); + maxEventLoopDelayNanos.accumulateAndGet(Math.max(0, nanos), Math::max); + } + + public void pooledDirectMemoryBytes(long bytes) { + pooledDirectMemoryBytes.set(Math.max(0, bytes)); + } + + public void networkTransport(String name, boolean nativeTransport) { + if (name == null || name.isBlank()) { + throw new IllegalArgumentException("transport name must not be blank"); + } + networkTransport.set(new NetworkTransport(name, nativeTransport)); + } + + public Snapshot snapshot() { + expirePayloadCaptures(); + return new Snapshot( + acceptedConnections.sum(), + activeConnections.get(), + rejectedConnections.sum(), + rejectedConnectionsByReason.entrySet().stream() + .collect(Collectors.toUnmodifiableMap(Map.Entry::getKey, entry -> entry.getValue().sum())), + handshakeTimeouts.sum(), + routedConnections.sum(), + failedRoutes.sum(), + backendConnectFailures.sum(), + frontendToBackendBytes.sum(), + backendToFrontendBytes.sum(), + compressionNegotiations.sum(), + eventLoopDelayNanos.get(), + maxEventLoopDelayNanos.get(), + pooledDirectMemoryBytes.get(), + networkTransport.get(), + packetAnomalies.entrySet().stream() + .collect(Collectors.toUnmodifiableMap(Map.Entry::getKey, entry -> entry.getValue().sum())), + recentPacketAnomalySamples(), + serverTraffic.entrySet().stream() + .collect(Collectors.toUnmodifiableMap( + Map.Entry::getKey, + entry -> entry.getValue().snapshot())), + serverConnections.entrySet().stream() + .collect(Collectors.toUnmodifiableMap( + Map.Entry::getKey, + entry -> entry.getValue().snapshot())), + Map.copyOf(playerSessions), + packetTraffic.entrySet().stream() + .collect(Collectors.toUnmodifiableMap( + Map.Entry::getKey, + entry -> entry.getValue().snapshot())), + customPayloads.entrySet().stream() + .collect(Collectors.toUnmodifiableMap( + Map.Entry::getKey, + entry -> entry.getValue().snapshot())), + relayBackpressure.entrySet().stream() + .collect(Collectors.toUnmodifiableMap( + Map.Entry::getKey, + entry -> entry.getValue().snapshot())), + compression.snapshot(), + serverCompression.entrySet().stream() + .collect(Collectors.toUnmodifiableMap( + Map.Entry::getKey, + entry -> entry.getValue().snapshot())), + serverCompressionByDirection.entrySet().stream() + .collect(Collectors.toUnmodifiableMap( + Map.Entry::getKey, + entry -> entry.getValue().snapshot())), + serverCompressionThresholds.entrySet().stream() + .collect(Collectors.toUnmodifiableMap( + Map.Entry::getKey, + entry -> entry.getValue().get())), + compressionDecisions.entrySet().stream() + .collect(Collectors.toUnmodifiableMap( + Map.Entry::getKey, + entry -> entry.getValue().sum())), + compressionRewrites.entrySet().stream() + .collect(Collectors.toUnmodifiableMap( + Map.Entry::getKey, + entry -> entry.getValue().snapshot())), + payloadCaptures(), + payloadCaptures.entrySet().stream() + .collect(Collectors.toUnmodifiableMap( + Map.Entry::getKey, + entry -> entry.getValue().samples()))); + } + + private void expirePayloadCaptures() { + var now = Instant.now(); + for (var entry : payloadCaptures.entrySet()) { + if (!entry.getValue().capture().expiresAt().isAfter(now)) { + payloadCaptures.remove(entry.getKey(), entry.getValue()); + } + } + } + + private List recentPacketAnomalySamples() { + var samples = new ArrayList(RECENT_PACKET_ANOMALY_CAPACITY); + for (var i = 0; i < recentPacketAnomalies.length(); i++) { + var sample = recentPacketAnomalies.get(i); + if (sample != null) { + samples.add(sample); + } + } + samples.sort(Comparator.comparingLong(PacketAnomalySample::sequence).reversed()); + return List.copyOf(samples); + } + + private static String sanitize(String value) { + return value == null ? "" : value; + } + + private static String normalizedChannel(String kind, String channel) { + if ("UNKNOWN".equals(kind)) { + return "unknown"; + } + var value = channel == null || channel.isBlank() ? "unknown" : sanitize(channel.trim()); + return value.length() <= 128 ? value : value.substring(0, 128); + } + + private static void decrementGauge(AtomicLong gauge) { + gauge.updateAndGet(value -> Math.max(0, value - 1)); + } + + private ServerTrafficCounters serverTraffic(String server) { + if (server == null || server.isBlank()) { + throw new IllegalArgumentException("server must not be blank"); + } + return serverTraffic.computeIfAbsent(server, ignored -> new ServerTrafficCounters()); + } + + private ServerConnectionCounters serverConnections(String server) { + if (server == null || server.isBlank()) { + throw new IllegalArgumentException("server must not be blank"); + } + return serverConnections.computeIfAbsent(server, ignored -> new ServerConnectionCounters()); + } + + private CompressionCounters serverCompression(String server) { + if (server == null || server.isBlank()) { + throw new IllegalArgumentException("server must not be blank"); + } + return serverCompression.computeIfAbsent(server, ignored -> new CompressionCounters()); + } + + private CompressionCounters serverCompression(String server, CompressionDirection direction) { + if (server == null || server.isBlank()) { + throw new IllegalArgumentException("server must not be blank"); + } + if (direction == null) { + throw new IllegalArgumentException("direction must not be null"); + } + return serverCompressionByDirection.computeIfAbsent(new CompressionDirectionKey(server, direction), ignored -> new CompressionCounters()); + } + + private AtomicInteger serverCompressionThreshold(String server) { + if (server == null || server.isBlank()) { + throw new IllegalArgumentException("server must not be blank"); + } + return serverCompressionThresholds.computeIfAbsent(server, ignored -> new AtomicInteger(-1)); + } + + private static final class ServerTrafficCounters { + private final LongAdder frontendToBackendBytes = new LongAdder(); + private final LongAdder backendToFrontendBytes = new LongAdder(); + + private ServerTraffic snapshot() { + return new ServerTraffic(frontendToBackendBytes.sum(), backendToFrontendBytes.sum()); + } + } + + public record ServerTraffic(long frontendToBackendBytes, long backendToFrontendBytes) { + } + + private static final class ServerConnectionCounters { + private final LongAdder routedConnections = new LongAdder(); + private final AtomicLong activeConnections = new AtomicLong(); + + private void connectionOpened() { + routedConnections.increment(); + activeConnections.incrementAndGet(); + } + + private void connectionClosed() { + decrementGauge(activeConnections); + } + + private ServerConnections snapshot() { + return new ServerConnections(routedConnections.sum(), activeConnections.get()); + } + } + + public record ServerConnections(long routedConnections, long activeConnections) { + } + + public record PlayerSession(String player, String server, String remoteAddress, Instant connectedAt) { + } + + private static final class PacketTrafficCounters { + private final LongAdder packets = new LongAdder(); + private final LongAdder rawBytes = new LongAdder(); + private final LongAdder compressedBytes = new LongAdder(); + + private void add(long rawBytes, long compressedBytes) { + packets.increment(); + this.rawBytes.add(rawBytes); + this.compressedBytes.add(compressedBytes); + } + + private PacketTraffic snapshot() { + return new PacketTraffic(packets.sum(), rawBytes.sum(), compressedBytes.sum()); + } + } + + public record PacketTraffic(long packets, long rawBytes, long compressedBytes) { + } + + private static final class CustomPayloadCounters { + private final LongAdder packets = new LongAdder(); + private final LongAdder payloadBytes = new LongAdder(); + private final LongAdder compressedBytes = new LongAdder(); + private final AtomicLong maxPayloadBytes = new AtomicLong(); + private final AtomicLong maxCompressedBytes = new AtomicLong(); + private final AtomicReference firstSeen = new AtomicReference<>(); + private final AtomicReference lastSeen = new AtomicReference<>(); + + private void add(long payloadBytes, long compressedBytes) { + packets.increment(); + this.payloadBytes.add(payloadBytes); + this.compressedBytes.add(compressedBytes); + maxPayloadBytes.accumulateAndGet(payloadBytes, Math::max); + maxCompressedBytes.accumulateAndGet(compressedBytes, Math::max); + var now = Instant.now(); + firstSeen.compareAndSet(null, now); + lastSeen.set(now); + } + + private CustomPayloadTraffic snapshot() { + return new CustomPayloadTraffic( + packets.sum(), + payloadBytes.sum(), + compressedBytes.sum(), + maxPayloadBytes.get(), + maxCompressedBytes.get(), + firstSeen.get(), + lastSeen.get()); + } + } + + public record CustomPayloadTraffic( + long packets, + long payloadBytes, + long compressedBytes, + long maxPayloadBytes, + long maxCompressedBytes, + Instant firstSeen, + Instant lastSeen) { + } + + private static final class RelayBackpressureCounters { + private final LongAdder events = new LongAdder(); + private final AtomicLong lastBytesBeforeWritable = new AtomicLong(); + private final AtomicLong maxBytesBeforeWritable = new AtomicLong(); + + private void record(long bytesBeforeWritable) { + events.increment(); + lastBytesBeforeWritable.set(bytesBeforeWritable); + maxBytesBeforeWritable.accumulateAndGet(bytesBeforeWritable, Math::max); + } + + private RelayBackpressure snapshot() { + return new RelayBackpressure(events.sum(), lastBytesBeforeWritable.get(), maxBytesBeforeWritable.get()); + } + } + + public record RelayBackpressure(long events, long lastBytesBeforeWritable, long maxBytesBeforeWritable) { + } + + private static final class PayloadCaptureBuffer { + private final PayloadCapture capture; + private final AtomicLong sequence = new AtomicLong(); + private final AtomicReferenceArray samples; + + private PayloadCaptureBuffer(PayloadCapture capture) { + this.capture = capture; + this.samples = new AtomicReferenceArray<>(capture.maxSamples()); + } + + private PayloadCapture capture() { + return capture; + } + + private void record( + long rawBytes, + long compressedBytes, + byte[] prefixBytes, + String player, + String remoteAddress, + Instant timestamp) { + var next = sequence.incrementAndGet(); + var index = (int) ((next - 1) % samples.length()); + samples.set(index, new PayloadCaptureSample( + next, + capture.id(), + capture.server(), + capture.direction(), + Math.max(0, rawBytes), + Math.max(0, compressedBytes), + prefixBytes, + sanitize(player), + sanitize(remoteAddress), + timestamp)); + } + + private List samples() { + var result = new ArrayList(samples.length()); + for (var i = 0; i < samples.length(); i++) { + var sample = samples.get(i); + if (sample != null) { + result.add(sample); + } + } + result.sort(Comparator.comparingLong(PayloadCaptureSample::sequence).reversed()); + return List.copyOf(result); + } + } + + public record PayloadCapture( + String id, + String server, + CompressionDirection direction, + int maxSamples, + int maxBytesPerSample, + Instant expiresAt) { + } + + public record PayloadCaptureRequest(boolean enabled, int maxBytesPerSample) { + private static PayloadCaptureRequest none() { + return new PayloadCaptureRequest(false, 0); + } + } + + public record PayloadCaptureSample( + long sequence, + String captureId, + String server, + CompressionDirection direction, + long rawBytes, + long compressedBytes, + byte[] prefixBytes, + String player, + String remoteAddress, + Instant timestamp) { + } + + private static final class CompressionCounters { + private final LongAdder samples = new LongAdder(); + private final LongAdder rawBytes = new LongAdder(); + private final LongAdder compressedBytes = new LongAdder(); + private final LongAdder cpuNanos = new LongAdder(); + + private void add(long rawBytes, long compressedBytes, long cpuNanos) { + if (rawBytes < 0 || compressedBytes < 0 || cpuNanos < 0) { + throw new IllegalArgumentException("compression sample values must be non-negative"); + } + samples.increment(); + this.rawBytes.add(rawBytes); + this.compressedBytes.add(compressedBytes); + this.cpuNanos.add(cpuNanos); + } + + private CompressionAudit snapshot() { + var raw = rawBytes.sum(); + var compressed = compressedBytes.sum(); + return new CompressionAudit(samples.sum(), raw, compressed, Math.max(0, raw - compressed), cpuNanos.sum()); + } + } + + public record CompressionAudit( + long samples, + long rawBytes, + long compressedBytes, + long savedBytes, + long cpuNanos) { + public double ratio() { + return rawBytes == 0 ? 1.0d : (double) compressedBytes / rawBytes; + } + } + + public record NetworkTransport(String name, boolean nativeTransport) { + } + + public record PacketAnomalySample( + long sequence, + String rule, + String remoteAddress, + String server, + String direction, + String protocolState, + int packetId, + long rawSize, + long compressedSize, + String detail, + Instant timestamp) { + } + + public enum CompressionDirection { + FRONTEND_TO_BACKEND("frontend_to_backend"), + BACKEND_TO_FRONTEND("backend_to_frontend"); + + private final String label; + + CompressionDirection(String label) { + this.label = label; + } + + public String label() { + return label; + } + } + + public record CompressionDirectionKey(String server, CompressionDirection direction) { + } + + public record CompressionDecisionKey(String server, CompressionDirection direction, String action, int threshold) { + } + + public record CompressionRewriteKey(String server, CompressionDirection direction, String outcome) { + } + + private static final class CompressionRewriteCounters { + private final LongAdder count = new LongAdder(); + private final LongAdder cpuNanos = new LongAdder(); + + private void add(long cpuNanos) { + count.increment(); + this.cpuNanos.add(cpuNanos); + } + + private CompressionRewrite snapshot() { + return new CompressionRewrite(count.sum(), cpuNanos.sum()); + } + } + + public record CompressionRewrite(long count, long cpuNanos) { + } + + public record PacketTrafficKey(String server, CompressionDirection direction, String protocolState, int packetId) { + } + + public record CustomPayloadKey(String server, CompressionDirection direction, String kind, String channel) { + } + + public record RelayBackpressureKey(String server, CompressionDirection direction) { + } + + public record Snapshot( + long acceptedConnections, + long activeConnections, + long rejectedConnections, + Map rejectedConnectionsByReason, + long handshakeTimeouts, + long routedConnections, + long failedRoutes, + long backendConnectFailures, + long frontendToBackendBytes, + long backendToFrontendBytes, + long compressionNegotiations, + long eventLoopDelayNanos, + long maxEventLoopDelayNanos, + long pooledDirectMemoryBytes, + NetworkTransport networkTransport, + Map packetAnomalies, + List recentPacketAnomalies, + Map serverTraffic, + Map serverConnections, + Map playerSessions, + Map packetTraffic, + Map customPayloads, + Map relayBackpressure, + CompressionAudit compression, + Map serverCompression, + Map serverCompressionByDirection, + Map serverCompressionThresholds, + Map compressionDecisions, + Map compressionRewrites, + List payloadCaptures, + Map> payloadCaptureSamples) { + } +} diff --git a/proxy-observability/src/test/java/dev/strataproxy/observability/ProxyMetricsTest.java b/proxy-observability/src/test/java/dev/strataproxy/observability/ProxyMetricsTest.java new file mode 100644 index 0000000..854e4e9 --- /dev/null +++ b/proxy-observability/src/test/java/dev/strataproxy/observability/ProxyMetricsTest.java @@ -0,0 +1,216 @@ +package dev.strataproxy.observability; + +import org.junit.jupiter.api.Test; + +import java.time.Instant; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +final class ProxyMetricsTest { + @Test + void recordsRuntimeMetrics() { + var metrics = new ProxyMetrics(); + + metrics.eventLoopDelayNanos(100); + metrics.eventLoopDelayNanos(50); + metrics.pooledDirectMemoryBytes(4096); + metrics.networkTransport("nio", false); + metrics.rejectedConnection("global_limit"); + metrics.rejectedConnection("per_address_limit"); + metrics.rejectedConnection("per_address_limit"); + metrics.handshakeTimeout(); + metrics.packetAnomaly("malformed-varint"); + metrics.packetAnomaly("malformed-varint", "127.0.0.1:50000", "survival-1", "frontend_to_backend", "HANDSHAKE", 0, 64, -1, "bad frame"); + metrics.packetTraffic("survival-1", ProxyMetrics.CompressionDirection.FRONTEND_TO_BACKEND, "UNCOMPRESSED", 1, 64, 0); + metrics.packetTraffic("survival-1", ProxyMetrics.CompressionDirection.FRONTEND_TO_BACKEND, "UNCOMPRESSED", 1, 32, 0); + metrics.customPayload("survival-1", ProxyMetrics.CompressionDirection.FRONTEND_TO_BACKEND, "FORGE_HANDSHAKE", "fml:handshake", 128, 0); + metrics.customPayload("survival-1", ProxyMetrics.CompressionDirection.FRONTEND_TO_BACKEND, "FORGE_HANDSHAKE", "fml:handshake", 256, 0); + metrics.customPayload("survival-1", ProxyMetrics.CompressionDirection.FRONTEND_TO_BACKEND, "UNKNOWN", "attacker:random", 512, 0); + metrics.frontendToBackendBytes("survival-1", 128); + metrics.backendToFrontendBytes("survival-1", 256); + metrics.compressionSample("survival-1", 1000, 400, 2_000_000); + metrics.compressionSample("survival-1", 500, 250, 1_000_000); + metrics.compressionSample("survival-1", ProxyMetrics.CompressionDirection.FRONTEND_TO_BACKEND, 300, 100, 0); + metrics.compressionNegotiated("survival-1", 256); + metrics.compressionDecision("survival-1", ProxyMetrics.CompressionDirection.FRONTEND_TO_BACKEND, "threshold", 1024); + metrics.compressionDecision("survival-1", ProxyMetrics.CompressionDirection.FRONTEND_TO_BACKEND, "threshold", 1024); + metrics.relayBackpressure("survival-1", ProxyMetrics.CompressionDirection.FRONTEND_TO_BACKEND, 128); + metrics.relayBackpressure("survival-1", ProxyMetrics.CompressionDirection.FRONTEND_TO_BACKEND, 256); + metrics.serverConnectionOpened("survival-1"); + metrics.serverConnectionOpened("survival-1"); + metrics.serverConnectionClosed("survival-1"); + + var snapshot = metrics.snapshot(); + assertEquals(50, snapshot.eventLoopDelayNanos()); + assertEquals(100, snapshot.maxEventLoopDelayNanos()); + assertEquals(4096, snapshot.pooledDirectMemoryBytes()); + assertEquals("nio", snapshot.networkTransport().name()); + assertEquals(false, snapshot.networkTransport().nativeTransport()); + assertEquals(3, snapshot.rejectedConnections()); + assertEquals(1, snapshot.rejectedConnectionsByReason().get("global_limit")); + assertEquals(2, snapshot.rejectedConnectionsByReason().get("per_address_limit")); + assertEquals(1, snapshot.handshakeTimeouts()); + assertEquals(2, snapshot.packetAnomalies().get("malformed-varint")); + assertEquals(2, snapshot.recentPacketAnomalies().size()); + assertEquals("127.0.0.1:50000", snapshot.recentPacketAnomalies().getFirst().remoteAddress()); + assertEquals("survival-1", snapshot.recentPacketAnomalies().getFirst().server()); + assertEquals("frontend_to_backend", snapshot.recentPacketAnomalies().getFirst().direction()); + assertEquals("HANDSHAKE", snapshot.recentPacketAnomalies().getFirst().protocolState()); + assertEquals(0, snapshot.recentPacketAnomalies().getFirst().packetId()); + assertEquals(64, snapshot.recentPacketAnomalies().getFirst().rawSize()); + assertEquals("bad frame", snapshot.recentPacketAnomalies().getFirst().detail()); + assertEquals(new ProxyMetrics.PacketTraffic(2, 96, 0), snapshot.packetTraffic().get(new ProxyMetrics.PacketTrafficKey( + "survival-1", + ProxyMetrics.CompressionDirection.FRONTEND_TO_BACKEND, + "UNCOMPRESSED", + 1))); + var forgePayload = snapshot.customPayloads().get(new ProxyMetrics.CustomPayloadKey( + "survival-1", + ProxyMetrics.CompressionDirection.FRONTEND_TO_BACKEND, + "FORGE_HANDSHAKE", + "fml:handshake")); + assertEquals(2, forgePayload.packets()); + assertEquals(384, forgePayload.payloadBytes()); + assertEquals(0, forgePayload.compressedBytes()); + assertEquals(256, forgePayload.maxPayloadBytes()); + assertEquals(0, forgePayload.maxCompressedBytes()); + assertTrue(forgePayload.firstSeen() != null); + assertTrue(forgePayload.lastSeen() != null); + var unknownPayload = snapshot.customPayloads().get(new ProxyMetrics.CustomPayloadKey( + "survival-1", + ProxyMetrics.CompressionDirection.FRONTEND_TO_BACKEND, + "UNKNOWN", + "unknown")); + assertEquals(1, unknownPayload.packets()); + assertEquals(512, unknownPayload.payloadBytes()); + assertEquals(512, unknownPayload.maxPayloadBytes()); + assertEquals(128, snapshot.frontendToBackendBytes()); + assertEquals(256, snapshot.backendToFrontendBytes()); + assertEquals(128, snapshot.serverTraffic().get("survival-1").frontendToBackendBytes()); + assertEquals(256, snapshot.serverTraffic().get("survival-1").backendToFrontendBytes()); + assertEquals(3, snapshot.compression().samples()); + assertEquals(1800, snapshot.compression().rawBytes()); + assertEquals(750, snapshot.compression().compressedBytes()); + assertEquals(1050, snapshot.compression().savedBytes()); + assertEquals(3_000_000, snapshot.compression().cpuNanos()); + assertEquals(750.0d / 1800.0d, snapshot.compression().ratio()); + assertEquals(1050, snapshot.serverCompression().get("survival-1").savedBytes()); + assertEquals(200, snapshot.serverCompressionByDirection().get(new ProxyMetrics.CompressionDirectionKey( + "survival-1", + ProxyMetrics.CompressionDirection.FRONTEND_TO_BACKEND)).savedBytes()); + assertEquals(1, snapshot.compressionNegotiations()); + assertEquals(256, snapshot.serverCompressionThresholds().get("survival-1")); + assertEquals(2, snapshot.compressionDecisions().get(new ProxyMetrics.CompressionDecisionKey( + "survival-1", + ProxyMetrics.CompressionDirection.FRONTEND_TO_BACKEND, + "threshold", + 1024))); + assertEquals(new ProxyMetrics.RelayBackpressure(2, 256, 256), snapshot.relayBackpressure().get(new ProxyMetrics.RelayBackpressureKey( + "survival-1", + ProxyMetrics.CompressionDirection.FRONTEND_TO_BACKEND))); + assertEquals(2, snapshot.serverConnections().get("survival-1").routedConnections()); + assertEquals(1, snapshot.serverConnections().get("survival-1").activeConnections()); + } + + @Test + void keepsOnlyMostRecentPacketAnomalySamples() { + var metrics = new ProxyMetrics(); + for (var i = 0; i < 300; i++) { + metrics.packetAnomaly("rule-" + i); + } + + var samples = metrics.snapshot().recentPacketAnomalies(); + + assertEquals(256, samples.size()); + assertEquals("rule-299", samples.getFirst().rule()); + assertEquals("rule-44", samples.getLast().rule()); + assertTrue(samples.getFirst().sequence() > samples.getLast().sequence()); + } + + @Test + void canDisablePacketAnomalySamplingWithoutDisablingCounters() { + var metrics = new ProxyMetrics(false); + + metrics.packetAnomaly("oversized-payload", "127.0.0.1:50000", "survival-1", "frontend_to_backend", "CONFIGURATION", 1, 2048, -1, "unknown:blob"); + + var snapshot = metrics.snapshot(); + assertEquals(1, snapshot.packetAnomalies().get("oversized-payload")); + assertTrue(snapshot.recentPacketAnomalies().isEmpty()); + } + + @Test + void recordsBoundedPayloadCaptureSamples() { + var metrics = new ProxyMetrics(); + metrics.startPayloadCapture( + "cap-1", + "survival-1", + ProxyMetrics.CompressionDirection.FRONTEND_TO_BACKEND, + 2, + 3, + Instant.now().plusSeconds(30)); + + var request = metrics.payloadCaptureRequest("survival-1", ProxyMetrics.CompressionDirection.FRONTEND_TO_BACKEND); + assertTrue(request.enabled()); + assertEquals(3, request.maxBytesPerSample()); + + metrics.payloadCaptured("survival-1", ProxyMetrics.CompressionDirection.FRONTEND_TO_BACKEND, 10, -1, new byte[] {1, 2, 3, 4}); + metrics.payloadCaptured("survival-1", ProxyMetrics.CompressionDirection.FRONTEND_TO_BACKEND, 11, -1, new byte[] {5, 6, 7, 8}); + metrics.payloadCaptured( + "survival-1", + ProxyMetrics.CompressionDirection.FRONTEND_TO_BACKEND, + 12, + -1, + new byte[] {9, 10, 11, 12}, + "Steve", + "127.0.0.1:50000"); + + var samples = metrics.payloadCaptureSamples("cap-1"); + assertEquals(2, samples.size()); + assertEquals(12, samples.getFirst().rawBytes()); + assertEquals(3, samples.getFirst().prefixBytes().length); + assertEquals(9, samples.getFirst().prefixBytes()[0]); + assertEquals("Steve", samples.getFirst().player()); + assertEquals("127.0.0.1:50000", samples.getFirst().remoteAddress()); + assertEquals(11, samples.getLast().rawBytes()); + + assertTrue(metrics.stopPayloadCapture("cap-1")); + assertTrue(metrics.payloadCaptures().isEmpty()); + } + + @Test + void activeConnectionGaugesNeverGoBelowZero() { + var metrics = new ProxyMetrics(); + + metrics.closedConnection(); + metrics.serverConnectionClosed("survival-1"); + metrics.acceptedConnection(); + metrics.closedConnection(); + metrics.closedConnection(); + metrics.serverConnectionOpened("survival-1"); + metrics.serverConnectionClosed("survival-1"); + metrics.serverConnectionClosed("survival-1"); + + var snapshot = metrics.snapshot(); + assertEquals(0, snapshot.activeConnections()); + assertEquals(0, snapshot.serverConnections().get("survival-1").activeConnections()); + assertEquals(1, snapshot.acceptedConnections()); + assertEquals(1, snapshot.serverConnections().get("survival-1").routedConnections()); + } + + @Test + void tracksActivePlayerSessions() { + var metrics = new ProxyMetrics(); + + metrics.playerSessionStarted("Steve", "survival-1", "127.0.0.1:50000"); + + var session = metrics.snapshot().playerSessions().get("Steve"); + assertEquals("Steve", session.player()); + assertEquals("survival-1", session.server()); + assertEquals("127.0.0.1:50000", session.remoteAddress()); + + metrics.playerSessionClosed("Steve"); + + assertTrue(metrics.snapshot().playerSessions().isEmpty()); + } +} diff --git a/proxy-packet-analysis/build.gradle.kts b/proxy-packet-analysis/build.gradle.kts new file mode 100644 index 0000000..9289f5b --- /dev/null +++ b/proxy-packet-analysis/build.gradle.kts @@ -0,0 +1,9 @@ +plugins { + id("strataproxy.java-library-conventions") +} + +dependencies { + api(project(":proxy-api")) + implementation(project(":proxy-codec-minecraft")) + implementation(project(":proxy-protocol")) +} diff --git a/proxy-packet-analysis/src/main/java/dev/strataproxy/analysis/AnomalyAction.java b/proxy-packet-analysis/src/main/java/dev/strataproxy/analysis/AnomalyAction.java new file mode 100644 index 0000000..ae5f9b1 --- /dev/null +++ b/proxy-packet-analysis/src/main/java/dev/strataproxy/analysis/AnomalyAction.java @@ -0,0 +1,8 @@ +package dev.strataproxy.analysis; + +public enum AnomalyAction { + WARN, + THROTTLE, + DROP, + DISCONNECT +} diff --git a/proxy-packet-analysis/src/main/java/dev/strataproxy/analysis/CustomPayloadAnomalyPolicy.java b/proxy-packet-analysis/src/main/java/dev/strataproxy/analysis/CustomPayloadAnomalyPolicy.java new file mode 100644 index 0000000..3a90468 --- /dev/null +++ b/proxy-packet-analysis/src/main/java/dev/strataproxy/analysis/CustomPayloadAnomalyPolicy.java @@ -0,0 +1,141 @@ +package dev.strataproxy.analysis; + +import dev.strataproxy.codec.minecraft.MinecraftCustomPayloadClassifier.CustomPayloadClassification; +import dev.strataproxy.codec.minecraft.MinecraftCustomPayloadClassifier.CustomPayloadKind; +import dev.strataproxy.protocol.PacketView; + +import java.time.Instant; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; + +public final class CustomPayloadAnomalyPolicy { + private final int largePayloadWarnBytes; + private final int unknownChannelThrottleBytes; + private final int moddedHandshakeWarnBytes; + private final int customPayloadFloodMaxCount; + private final Duration customPayloadFloodWindow; + + public CustomPayloadAnomalyPolicy( + int largePayloadWarnBytes, + int unknownChannelThrottleBytes, + int moddedHandshakeWarnBytes) { + this( + largePayloadWarnBytes, + unknownChannelThrottleBytes, + moddedHandshakeWarnBytes, + 200, + Duration.ofSeconds(10)); + } + + public CustomPayloadAnomalyPolicy( + int largePayloadWarnBytes, + int unknownChannelThrottleBytes, + int moddedHandshakeWarnBytes, + int customPayloadFloodMaxCount, + Duration customPayloadFloodWindow) { + if (largePayloadWarnBytes < 0 + || unknownChannelThrottleBytes < 0 + || moddedHandshakeWarnBytes < 0 + || customPayloadFloodMaxCount < 0) { + throw new IllegalArgumentException("custom payload thresholds must be non-negative"); + } + if (customPayloadFloodMaxCount > 0 && (customPayloadFloodWindow == null || customPayloadFloodWindow.isZero() || customPayloadFloodWindow.isNegative())) { + throw new IllegalArgumentException("custom payload flood window must be positive when flood max count is enabled"); + } + this.largePayloadWarnBytes = largePayloadWarnBytes; + this.unknownChannelThrottleBytes = unknownChannelThrottleBytes; + this.moddedHandshakeWarnBytes = moddedHandshakeWarnBytes; + this.customPayloadFloodMaxCount = customPayloadFloodMaxCount; + this.customPayloadFloodWindow = customPayloadFloodWindow == null ? Duration.ofSeconds(10) : customPayloadFloodWindow; + } + + public static CustomPayloadAnomalyPolicy defaults() { + return new CustomPayloadAnomalyPolicy(1 * 1024 * 1024, 256 * 1024, 2 * 1024 * 1024, 200, Duration.ofSeconds(10)); + } + + public int largePayloadWarnBytes() { + return largePayloadWarnBytes; + } + + public int unknownChannelThrottleBytes() { + return unknownChannelThrottleBytes; + } + + public int moddedHandshakeWarnBytes() { + return moddedHandshakeWarnBytes; + } + + public int customPayloadFloodMaxCount() { + return customPayloadFloodMaxCount; + } + + public Duration customPayloadFloodWindow() { + return customPayloadFloodWindow; + } + + public List evaluate(PacketView packet, CustomPayloadClassification payload) { + var anomalies = new ArrayList(2); + if (payload.payloadBytes() >= largePayloadWarnBytes && largePayloadWarnBytes > 0) { + anomalies.add(anomaly( + "custom-payload-large", + packet, + AnomalyAction.WARN, + payload, + "custom payload channel " + payload.channel() + " has " + payload.payloadBytes() + " bytes")); + } + if (payload.kind() == CustomPayloadKind.UNKNOWN + && payload.payloadBytes() >= unknownChannelThrottleBytes + && unknownChannelThrottleBytes > 0) { + anomalies.add(anomaly( + "custom-payload-unknown-large", + packet, + AnomalyAction.THROTTLE, + payload, + "unknown custom payload channel " + payload.channel() + " exceeded " + unknownChannelThrottleBytes + " bytes")); + } + if ((payload.kind() == CustomPayloadKind.FORGE_HANDSHAKE || payload.kind() == CustomPayloadKind.FABRIC_HANDSHAKE) + && payload.payloadBytes() >= moddedHandshakeWarnBytes + && moddedHandshakeWarnBytes > 0) { + anomalies.add(anomaly( + "modded-handshake-large", + packet, + AnomalyAction.WARN, + payload, + payload.kind().name().toLowerCase(java.util.Locale.ROOT) + " payload exceeded " + + moddedHandshakeWarnBytes + " bytes")); + } + return List.copyOf(anomalies); + } + + public List evaluateFlood( + PacketView packet, + CustomPayloadClassification payload, + int observedCount, + Duration observedWindow) { + if (customPayloadFloodMaxCount <= 0 || observedCount <= customPayloadFloodMaxCount) { + return List.of(); + } + return List.of(anomaly( + "custom-payload-flood", + packet, + AnomalyAction.THROTTLE, + payload, + "custom payload count " + observedCount + " exceeded " + customPayloadFloodMaxCount + + " in " + observedWindow.toMillis() + "ms")); + } + + private static PacketAnomaly anomaly( + String rule, + PacketView packet, + AnomalyAction action, + CustomPayloadClassification payload, + String explanation) { + return new PacketAnomaly( + rule, + packet, + action, + explanation + " (kind=" + payload.kind() + ", packetId=" + payload.packetId() + ")", + Instant.now()); + } +} diff --git a/proxy-packet-analysis/src/main/java/dev/strataproxy/analysis/MaxPacketSizeRule.java b/proxy-packet-analysis/src/main/java/dev/strataproxy/analysis/MaxPacketSizeRule.java new file mode 100644 index 0000000..6913135 --- /dev/null +++ b/proxy-packet-analysis/src/main/java/dev/strataproxy/analysis/MaxPacketSizeRule.java @@ -0,0 +1,18 @@ +package dev.strataproxy.analysis; + +import dev.strataproxy.protocol.PacketView; +import dev.strataproxy.protocol.ProtocolState; + +import java.time.Instant; +import java.util.Optional; + +public record MaxPacketSizeRule(String id, ProtocolState state, int maxBytes, AnomalyAction action) implements PacketRule { + @Override + public Optional evaluate(PacketView packet) { + if (packet.state() == state && packet.rawSize() > maxBytes) { + var explanation = "packet exceeded " + maxBytes + " bytes in " + state + " state"; + return Optional.of(new PacketAnomaly(id, packet, action, explanation, Instant.now())); + } + return Optional.empty(); + } +} diff --git a/proxy-packet-analysis/src/main/java/dev/strataproxy/analysis/PacketAnalyzer.java b/proxy-packet-analysis/src/main/java/dev/strataproxy/analysis/PacketAnalyzer.java new file mode 100644 index 0000000..38d5aaf --- /dev/null +++ b/proxy-packet-analysis/src/main/java/dev/strataproxy/analysis/PacketAnalyzer.java @@ -0,0 +1,17 @@ +package dev.strataproxy.analysis; + +import dev.strataproxy.protocol.PacketView; + +import java.util.List; + +public final class PacketAnalyzer { + private final List rules; + + public PacketAnalyzer(List rules) { + this.rules = List.copyOf(rules); + } + + public List analyze(PacketView packet) { + return rules.stream().flatMap(rule -> rule.evaluate(packet).stream()).toList(); + } +} diff --git a/proxy-packet-analysis/src/main/java/dev/strataproxy/analysis/PacketAnomaly.java b/proxy-packet-analysis/src/main/java/dev/strataproxy/analysis/PacketAnomaly.java new file mode 100644 index 0000000..49aba85 --- /dev/null +++ b/proxy-packet-analysis/src/main/java/dev/strataproxy/analysis/PacketAnomaly.java @@ -0,0 +1,16 @@ +package dev.strataproxy.analysis; + +import dev.strataproxy.protocol.PacketView; + +import java.time.Instant; + +public record PacketAnomaly( + String ruleId, + PacketView packet, + AnomalyAction action, + String explanation, + Instant timestamp) { + public PacketAnomaly { + timestamp = timestamp == null ? Instant.now() : timestamp; + } +} diff --git a/proxy-packet-analysis/src/main/java/dev/strataproxy/analysis/PacketRule.java b/proxy-packet-analysis/src/main/java/dev/strataproxy/analysis/PacketRule.java new file mode 100644 index 0000000..c84e025 --- /dev/null +++ b/proxy-packet-analysis/src/main/java/dev/strataproxy/analysis/PacketRule.java @@ -0,0 +1,9 @@ +package dev.strataproxy.analysis; + +import dev.strataproxy.protocol.PacketView; + +import java.util.Optional; + +public interface PacketRule { + Optional evaluate(PacketView packet); +} diff --git a/proxy-packet-analysis/src/test/java/dev/strataproxy/analysis/CustomPayloadAnomalyPolicyTest.java b/proxy-packet-analysis/src/test/java/dev/strataproxy/analysis/CustomPayloadAnomalyPolicyTest.java new file mode 100644 index 0000000..10b03f8 --- /dev/null +++ b/proxy-packet-analysis/src/test/java/dev/strataproxy/analysis/CustomPayloadAnomalyPolicyTest.java @@ -0,0 +1,97 @@ +package dev.strataproxy.analysis; + +import dev.strataproxy.codec.minecraft.MinecraftCustomPayloadClassifier.CustomPayloadClassification; +import dev.strataproxy.codec.minecraft.MinecraftCustomPayloadClassifier.CustomPayloadKind; +import dev.strataproxy.protocol.PacketDirection; +import dev.strataproxy.protocol.PacketView; +import dev.strataproxy.protocol.ProtocolState; +import org.junit.jupiter.api.Test; + +import java.time.Duration; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +final class CustomPayloadAnomalyPolicyTest { + @Test + void allowsSmallKnownPayloads() { + var policy = new CustomPayloadAnomalyPolicy(1024, 512, 2048); + + var anomalies = policy.evaluate(packet(64), payload("minecraft:brand", 64, CustomPayloadKind.BRAND)); + + assertTrue(anomalies.isEmpty()); + } + + @Test + void warnsForLargePayloads() { + var policy = new CustomPayloadAnomalyPolicy(1024, 512, 4096); + + var anomalies = policy.evaluate(packet(2048), payload("modded:registry_sync", 2048, CustomPayloadKind.REGISTRY_OR_CONFIG_SYNC)); + + assertEquals(1, anomalies.size()); + assertEquals("custom-payload-large", anomalies.getFirst().ruleId()); + assertEquals(AnomalyAction.WARN, anomalies.getFirst().action()); + } + + @Test + void throttlesUnknownLargePayloads() { + var policy = new CustomPayloadAnomalyPolicy(4096, 512, 4096); + + var anomalies = policy.evaluate(packet(1024), payload("unknown:blob", 1024, CustomPayloadKind.UNKNOWN)); + + assertEquals(1, anomalies.size()); + assertEquals("custom-payload-unknown-large", anomalies.getFirst().ruleId()); + assertEquals(AnomalyAction.THROTTLE, anomalies.getFirst().action()); + } + + @Test + void warnsForLargeForgeOrFabricHandshakePayloads() { + var policy = new CustomPayloadAnomalyPolicy(4096, 2048, 1024); + + var forge = policy.evaluate(packet(2048), payload("fml:handshake", 2048, CustomPayloadKind.FORGE_HANDSHAKE)); + var fabric = policy.evaluate(packet(2048), payload("fabric:registry/sync", 2048, CustomPayloadKind.FABRIC_HANDSHAKE)); + + assertEquals("modded-handshake-large", forge.getFirst().ruleId()); + assertEquals("modded-handshake-large", fabric.getFirst().ruleId()); + } + + @Test + void canReportMultipleRulesForSamePayload() { + var policy = new CustomPayloadAnomalyPolicy(1024, 512, 4096); + + var anomalies = policy.evaluate(packet(2048), payload("unknown:blob", 2048, CustomPayloadKind.UNKNOWN)); + + assertEquals(2, anomalies.size()); + assertTrue(anomalies.stream().anyMatch(anomaly -> anomaly.ruleId().equals("custom-payload-large"))); + assertTrue(anomalies.stream().anyMatch(anomaly -> anomaly.ruleId().equals("custom-payload-unknown-large"))); + } + + @Test + void throttlesCustomPayloadFloods() { + var policy = new CustomPayloadAnomalyPolicy(4096, 4096, 4096, 2, Duration.ofSeconds(10)); + + var allowed = policy.evaluateFlood( + packet(64), + payload("minecraft:brand", 64, CustomPayloadKind.BRAND), + 2, + Duration.ofSeconds(10)); + var throttled = policy.evaluateFlood( + packet(64), + payload("minecraft:brand", 64, CustomPayloadKind.BRAND), + 3, + Duration.ofSeconds(10)); + + assertTrue(allowed.isEmpty()); + assertEquals(1, throttled.size()); + assertEquals("custom-payload-flood", throttled.getFirst().ruleId()); + assertEquals(AnomalyAction.THROTTLE, throttled.getFirst().action()); + } + + private static PacketView packet(int rawSize) { + return new PacketView(PacketDirection.SERVERBOUND, ProtocolState.CONFIGURATION, 763, 0x01, rawSize, 0, false); + } + + private static CustomPayloadClassification payload(String channel, int bytes, CustomPayloadKind kind) { + return new CustomPayloadClassification(0x01, channel, bytes, kind, bytes >= 1024); + } +} diff --git a/proxy-protocol/build.gradle.kts b/proxy-protocol/build.gradle.kts new file mode 100644 index 0000000..ba059cd --- /dev/null +++ b/proxy-protocol/build.gradle.kts @@ -0,0 +1,7 @@ +plugins { + id("strataproxy.java-library-conventions") +} + +dependencies { + api(project(":proxy-api")) +} diff --git a/proxy-protocol/src/main/java/dev/strataproxy/protocol/InMemoryPacketRegistry.java b/proxy-protocol/src/main/java/dev/strataproxy/protocol/InMemoryPacketRegistry.java new file mode 100644 index 0000000..c005213 --- /dev/null +++ b/proxy-protocol/src/main/java/dev/strataproxy/protocol/InMemoryPacketRegistry.java @@ -0,0 +1,22 @@ +package dev.strataproxy.protocol; + +import java.util.List; +import java.util.Optional; + +public final class InMemoryPacketRegistry implements PacketRegistry { + private final List definitions; + + public InMemoryPacketRegistry(List definitions) { + this.definitions = List.copyOf(definitions); + } + + @Override + public Optional find(PacketView packet) { + return definitions.stream() + .filter(definition -> definition.id() == packet.packetId()) + .filter(definition -> definition.state() == packet.state()) + .filter(definition -> definition.direction() == packet.direction()) + .filter(definition -> definition.appliesTo(packet.protocolVersion())) + .findFirst(); + } +} diff --git a/proxy-protocol/src/main/java/dev/strataproxy/protocol/MetadataPacketClassifier.java b/proxy-protocol/src/main/java/dev/strataproxy/protocol/MetadataPacketClassifier.java new file mode 100644 index 0000000..87c0dfd --- /dev/null +++ b/proxy-protocol/src/main/java/dev/strataproxy/protocol/MetadataPacketClassifier.java @@ -0,0 +1,25 @@ +package dev.strataproxy.protocol; + +import java.util.Objects; + +public final class MetadataPacketClassifier { + private final PacketRegistry registry; + + public MetadataPacketClassifier(PacketRegistry registry) { + this.registry = Objects.requireNonNull(registry, "registry"); + } + + public PacketClassification classify(PacketView packet, boolean policySubscriberPresent) { + var definition = registry.find(packet).orElse(null); + if (definition == null) { + return new PacketClassification(packet, null, false, true, false, true); + } + + var rewrite = definition.has(PacketFlag.REQUIRES_ENTITY_REWRITE); + var inspect = policySubscriberPresent || definition.has(PacketFlag.REQUIRES_INSPECTION); + var fastForward = definition.has(PacketFlag.CAN_FAST_FORWARD) && !rewrite && !inspect; + var deepDecode = !fastForward; + + return new PacketClassification(packet, definition, fastForward, deepDecode, rewrite, inspect); + } +} diff --git a/proxy-protocol/src/main/java/dev/strataproxy/protocol/PacketClassification.java b/proxy-protocol/src/main/java/dev/strataproxy/protocol/PacketClassification.java new file mode 100644 index 0000000..a501cbc --- /dev/null +++ b/proxy-protocol/src/main/java/dev/strataproxy/protocol/PacketClassification.java @@ -0,0 +1,10 @@ +package dev.strataproxy.protocol; + +public record PacketClassification( + PacketView packet, + PacketDefinition definition, + boolean fastForward, + boolean deepDecode, + boolean rewrite, + boolean inspect) { +} diff --git a/proxy-protocol/src/main/java/dev/strataproxy/protocol/PacketDefinition.java b/proxy-protocol/src/main/java/dev/strataproxy/protocol/PacketDefinition.java new file mode 100644 index 0000000..6848485 --- /dev/null +++ b/proxy-protocol/src/main/java/dev/strataproxy/protocol/PacketDefinition.java @@ -0,0 +1,31 @@ +package dev.strataproxy.protocol; + +import java.util.EnumSet; +import java.util.Objects; + +public record PacketDefinition( + int id, + ProtocolState state, + PacketDirection direction, + int minProtocol, + int maxProtocol, + EnumSet flags, + String name) { + public PacketDefinition { + state = Objects.requireNonNull(state, "state"); + direction = Objects.requireNonNull(direction, "direction"); + flags = flags == null ? EnumSet.noneOf(PacketFlag.class) : EnumSet.copyOf(flags); + name = Objects.requireNonNull(name, "name"); + if (id < 0 || minProtocol < 0 || maxProtocol < minProtocol) { + throw new IllegalArgumentException("invalid packet definition bounds"); + } + } + + public boolean appliesTo(int protocolVersion) { + return protocolVersion >= minProtocol && protocolVersion <= maxProtocol; + } + + public boolean has(PacketFlag flag) { + return flags.contains(flag); + } +} diff --git a/proxy-protocol/src/main/java/dev/strataproxy/protocol/PacketDirection.java b/proxy-protocol/src/main/java/dev/strataproxy/protocol/PacketDirection.java new file mode 100644 index 0000000..84ff5f9 --- /dev/null +++ b/proxy-protocol/src/main/java/dev/strataproxy/protocol/PacketDirection.java @@ -0,0 +1,6 @@ +package dev.strataproxy.protocol; + +public enum PacketDirection { + CLIENTBOUND, + SERVERBOUND +} diff --git a/proxy-protocol/src/main/java/dev/strataproxy/protocol/PacketFlag.java b/proxy-protocol/src/main/java/dev/strataproxy/protocol/PacketFlag.java new file mode 100644 index 0000000..cb073e4 --- /dev/null +++ b/proxy-protocol/src/main/java/dev/strataproxy/protocol/PacketFlag.java @@ -0,0 +1,12 @@ +package dev.strataproxy.protocol; + +public enum PacketFlag { + CAN_FAST_FORWARD, + REQUIRES_ENTITY_REWRITE, + REQUIRES_INSPECTION, + CAN_COMPRESS, + HIGH_FREQUENCY, + SUSPICIOUS_WHEN_LARGE, + MUST_PRESERVE_ORDER, + MODDED_PAYLOAD +} diff --git a/proxy-protocol/src/main/java/dev/strataproxy/protocol/PacketRegistry.java b/proxy-protocol/src/main/java/dev/strataproxy/protocol/PacketRegistry.java new file mode 100644 index 0000000..00da9eb --- /dev/null +++ b/proxy-protocol/src/main/java/dev/strataproxy/protocol/PacketRegistry.java @@ -0,0 +1,7 @@ +package dev.strataproxy.protocol; + +import java.util.Optional; + +public interface PacketRegistry { + Optional find(PacketView packet); +} diff --git a/proxy-protocol/src/main/java/dev/strataproxy/protocol/PacketView.java b/proxy-protocol/src/main/java/dev/strataproxy/protocol/PacketView.java new file mode 100644 index 0000000..fbb9a74 --- /dev/null +++ b/proxy-protocol/src/main/java/dev/strataproxy/protocol/PacketView.java @@ -0,0 +1,16 @@ +package dev.strataproxy.protocol; + +public record PacketView( + PacketDirection direction, + ProtocolState state, + int protocolVersion, + int packetId, + int rawSize, + int compressedSize, + boolean compressed) { + public PacketView { + if (rawSize < 0 || compressedSize < 0) { + throw new IllegalArgumentException("packet sizes must be non-negative"); + } + } +} diff --git a/proxy-protocol/src/main/java/dev/strataproxy/protocol/ProtocolState.java b/proxy-protocol/src/main/java/dev/strataproxy/protocol/ProtocolState.java new file mode 100644 index 0000000..a283268 --- /dev/null +++ b/proxy-protocol/src/main/java/dev/strataproxy/protocol/ProtocolState.java @@ -0,0 +1,9 @@ +package dev.strataproxy.protocol; + +public enum ProtocolState { + HANDSHAKE, + STATUS, + LOGIN, + CONFIGURATION, + PLAY +} diff --git a/proxy-query/build.gradle.kts b/proxy-query/build.gradle.kts new file mode 100644 index 0000000..6a2050b --- /dev/null +++ b/proxy-query/build.gradle.kts @@ -0,0 +1,34 @@ +import org.gradle.api.tasks.Exec + +plugins { + id("strataproxy.application-conventions") +} + +dependencies { + api(project(":proxy-api")) + implementation(libs.findLibrary("cli-picocli").get()) +} + +application { + mainClass.set("dev.strataproxy.query.StrataProxyQueryCli") + applicationName = "strataproxy-query" +} + +val installedDistSmokeTest = tasks.register("installedDistSmokeTest") { + group = "verification" + description = "Runs the installed strataproxy-query distribution help command." + dependsOn(tasks.named("installDist")) + + val installDir = layout.buildDirectory.dir("install/strataproxy-query") + inputs.dir(installDir) + + doFirst { + val windows = System.getProperty("os.name").lowercase().contains("windows") + val script = installDir.get().file("bin/" + if (windows) "strataproxy-query.bat" else "strataproxy-query").asFile + commandLine(script.absolutePath, "--help") + } +} + +tasks.named("check") { + dependsOn(installedDistSmokeTest) +} diff --git a/proxy-query/src/main/java/dev/strataproxy/query/MinecraftQueryProtocol.java b/proxy-query/src/main/java/dev/strataproxy/query/MinecraftQueryProtocol.java new file mode 100644 index 0000000..b6c7f72 --- /dev/null +++ b/proxy-query/src/main/java/dev/strataproxy/query/MinecraftQueryProtocol.java @@ -0,0 +1,215 @@ +package dev.strataproxy.query; + +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.EOFException; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.zip.Deflater; + +final class MinecraftQueryProtocol { + private static final int MAX_VAR_INT_BYTES = 5; + private static final int MAX_STATUS_BYTES = 2 * 1024 * 1024; + + private MinecraftQueryProtocol() { + } + + static byte[] statusHandshake(int protocolVersion, String host, int port) { + return handshake(protocolVersion, host, port, 1); + } + + static byte[] loginHandshake(int protocolVersion, String host, int port) { + return handshake(protocolVersion, host, port, 2); + } + + private static byte[] handshake(int protocolVersion, String host, int port, int nextState) { + var payload = new ByteArrayOutputStream(); + writeVarInt(payload, 0); + writeVarInt(payload, protocolVersion); + writeString(payload, host); + payload.write((port >>> 8) & 0xFF); + payload.write(port & 0xFF); + writeVarInt(payload, nextState); + return frame(payload.toByteArray()); + } + + static byte[] statusRequest() { + var payload = new ByteArrayOutputStream(); + writeVarInt(payload, 0); + return frame(payload.toByteArray()); + } + + static byte[] loginStart(String username) { + var payload = new ByteArrayOutputStream(); + writeVarInt(payload, 0); + writeString(payload, username); + return frame(payload.toByteArray()); + } + + static byte[] rawPacketFrame(int packetId, int payloadBytes) { + if (packetId < 0) { + throw new IllegalArgumentException("packetId must be non-negative"); + } + if (payloadBytes < 0) { + throw new IllegalArgumentException("payloadBytes must be non-negative"); + } + var payload = new ByteArrayOutputStream(); + writeVarInt(payload, packetId); + payload.writeBytes(new byte[payloadBytes]); + return frame(payload.toByteArray()); + } + + static byte[] compressedPacketFrame(int packetId, int payloadBytes, int threshold) { + if (threshold < 0) { + throw new IllegalArgumentException("threshold must be non-negative"); + } + var payload = new ByteArrayOutputStream(); + writeVarInt(payload, packetId); + payload.writeBytes(new byte[payloadBytes]); + var packet = payload.toByteArray(); + var body = new ByteArrayOutputStream(); + if (packet.length < threshold) { + writeVarInt(body, 0); + body.writeBytes(packet); + } else { + writeVarInt(body, packet.length); + body.writeBytes(deflate(packet)); + } + return frame(body.toByteArray()); + } + + static int readCompressionThreshold(InputStream input, int maxFrameBytes) throws IOException { + var data = new DataInputStream(input); + var frameLength = readVarInt(data); + if (frameLength <= 0 || frameLength > maxFrameBytes) { + throw new IOException("compression negotiation frame length out of bounds: " + frameLength); + } + var frame = data.readNBytes(frameLength); + if (frame.length != frameLength) { + throw new EOFException("truncated compression negotiation frame"); + } + var cursor = new Cursor(frame); + var packetId = readVarInt(cursor); + if (packetId != 3) { + throw new IOException("unexpected compression negotiation packet id: " + packetId); + } + return readVarInt(cursor); + } + + static String readStatusResponse(InputStream input) throws IOException { + var data = new DataInputStream(input); + var frameLength = readVarInt(data); + if (frameLength <= 0 || frameLength > MAX_STATUS_BYTES) { + throw new IOException("status response frame length out of bounds: " + frameLength); + } + var frame = data.readNBytes(frameLength); + if (frame.length != frameLength) { + throw new EOFException("truncated status response"); + } + var cursor = new Cursor(frame); + var packetId = readVarInt(cursor); + if (packetId != 0) { + throw new IOException("unexpected status packet id: " + packetId); + } + var jsonLength = readVarInt(cursor); + if (jsonLength < 0 || jsonLength > cursor.remaining()) { + throw new IOException("status json length out of bounds: " + jsonLength); + } + return new String(frame, cursor.position(), jsonLength, StandardCharsets.UTF_8); + } + + 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 byte[] frame(byte[] payload) { + var frame = new ByteArrayOutputStream(); + writeVarInt(frame, payload.length); + frame.writeBytes(payload); + return frame.toByteArray(); + } + + private static byte[] deflate(byte[] input) { + var deflater = new Deflater(); + try { + deflater.setInput(input); + deflater.finish(); + var output = new ByteArrayOutputStream(Math.max(64, input.length / 2)); + var buffer = new byte[8192]; + while (!deflater.finished()) { + var bytes = deflater.deflate(buffer); + output.write(buffer, 0, bytes); + } + return output.toByteArray(); + } finally { + deflater.end(); + } + } + + private static void writeString(ByteArrayOutputStream output, String value) { + var bytes = value.getBytes(StandardCharsets.UTF_8); + writeVarInt(output, bytes.length); + output.writeBytes(bytes); + } + + private static int readVarInt(DataInputStream input) throws IOException { + var value = 0; + var position = 0; + while (position < MAX_VAR_INT_BYTES) { + var current = input.readUnsignedByte(); + value |= (current & 0x7F) << (position * 7); + position++; + if ((current & 0x80) == 0) { + return value; + } + } + throw new IOException("malformed VarInt"); + } + + private static int readVarInt(Cursor input) throws IOException { + var value = 0; + var position = 0; + while (position < MAX_VAR_INT_BYTES) { + if (input.remaining() <= 0) { + throw new EOFException("truncated VarInt"); + } + var current = input.readUnsignedByte(); + value |= (current & 0x7F) << (position * 7); + position++; + if ((current & 0x80) == 0) { + return value; + } + } + throw new IOException("malformed VarInt"); + } + + private static final class Cursor { + private final byte[] data; + private int position; + + private Cursor(byte[] data) { + this.data = data; + } + + private int readUnsignedByte() { + return data[position++] & 0xFF; + } + + private int position() { + return position; + } + + private int remaining() { + return data.length - position; + } + } +} diff --git a/proxy-query/src/main/java/dev/strataproxy/query/StrataProxyQueryCli.java b/proxy-query/src/main/java/dev/strataproxy/query/StrataProxyQueryCli.java new file mode 100644 index 0000000..194f0e7 --- /dev/null +++ b/proxy-query/src/main/java/dev/strataproxy/query/StrataProxyQueryCli.java @@ -0,0 +1,1524 @@ +package dev.strataproxy.query; + +import picocli.CommandLine; +import picocli.CommandLine.Command; +import picocli.CommandLine.Option; +import picocli.CommandLine.ParentCommand; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.PrintStream; +import java.net.InetSocketAddress; +import java.net.ServerSocket; +import java.net.Socket; +import java.net.SocketTimeoutException; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.concurrent.Callable; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +@Command( + name = "strataproxy-query", + mixinStandardHelpOptions = true, + version = "strataproxy-query 0.1", + description = "Minecraft status and lightweight connection-load probe tool.", + subcommands = { + StrataProxyQueryCli.StatusCommand.class, + StrataProxyQueryCli.IdleLoadCommand.class, + StrataProxyQueryCli.HandshakeLoadCommand.class, + StrataProxyQueryCli.RouteStormCommand.class, + StrataProxyQueryCli.TrafficLoadCommand.class, + StrataProxyQueryCli.CompressionRewriteLoadCommand.class, + StrataProxyQueryCli.LoadSuiteCommand.class, + StrataProxyQueryCli.SlowSinkCommand.class + }) +public final class StrataProxyQueryCli implements Callable { + @Option(names = "--host", defaultValue = "127.0.0.1", description = "Target host.") + private String host; + + @Option(names = "--port", defaultValue = "25577", description = "Target port.") + private int port; + + @Option(names = "--timeout-ms", defaultValue = "5000", description = "Connect/read timeout in milliseconds.") + private int timeoutMillis; + + public static void main(String[] args) { + System.exit(new CommandLine(new StrataProxyQueryCli()).execute(args)); + } + + @Override + public Integer call() { + CommandLine.usage(this, System.out); + return 0; + } + + InetSocketAddress address() { + return new InetSocketAddress(host, port); + } + + int timeoutMillis() { + return timeoutMillis; + } + + @Command(name = "status", description = "Run a Minecraft status ping and print the status JSON.") + static final class StatusCommand implements Callable { + @ParentCommand + private StrataProxyQueryCli root; + + @Option(names = "--protocol", defaultValue = "763", description = "Minecraft protocol version.") + private int protocolVersion; + + @Option(names = "--virtual-host", description = "Hostname to place in the Minecraft handshake.") + private String virtualHost; + + @Override + public Integer call() throws Exception { + var address = root.address(); + var handshakeHost = virtualHost == null || virtualHost.isBlank() ? address.getHostString() : virtualHost; + try (var socket = new Socket()) { + socket.connect(address, root.timeoutMillis()); + socket.setSoTimeout(root.timeoutMillis()); + socket.getOutputStream().write(MinecraftQueryProtocol.statusHandshake(protocolVersion, handshakeHost, address.getPort())); + socket.getOutputStream().write(MinecraftQueryProtocol.statusRequest()); + socket.getOutputStream().flush(); + System.out.println(MinecraftQueryProtocol.readStatusResponse(socket.getInputStream())); + return 0; + } + } + } + + @Command(name = "handshake-load", description = "Open connections, send Minecraft login handshakes, and probe route survivability.") + static final class HandshakeLoadCommand implements Callable { + @ParentCommand + private StrataProxyQueryCli root; + + @Option(names = "--connections", defaultValue = "100", description = "Number of Minecraft handshakes to send.") + private int connections; + + @Option(names = "--hold-ms", defaultValue = "5000", description = "How long to hold successful handshaken connections.") + private long holdMillis; + + @Option(names = "--parallelism", defaultValue = "128", description = "Maximum concurrent connection attempts.") + private int parallelism; + + @Option(names = "--settle-ms", defaultValue = "100", description = "Delay before probing whether handshaken sockets are still open.") + private long settleMillis; + + @Option(names = "--probe-timeout-ms", defaultValue = "50", description = "Read timeout used to distinguish routed-open sockets from closed sockets.") + private int probeTimeoutMillis; + + @Option(names = "--protocol", defaultValue = "763", description = "Minecraft protocol version.") + private int protocolVersion; + + @Option(names = "--virtual-host", description = "Hostname to place in the Minecraft handshake.") + private String virtualHost; + + @Option(names = "--fail-on-closed", description = "Exit with a non-zero code when any handshaken socket is closed during the probe.") + private boolean failOnClosed; + + @Option(names = "--min-handshaken", defaultValue = "0", description = "Minimum successful handshakes required for exit code 0.") + private int minHandshaken; + + @Option(names = "--min-alive", defaultValue = "0", description = "Minimum alive sockets required for exit code 0.") + private int minAlive; + + @Option(names = "--max-closed", defaultValue = "2147483647", description = "Maximum closed sockets allowed for exit code 0.") + private int maxClosed; + + @Option(names = "--max-failed", defaultValue = "0", description = "Maximum failed connection attempts allowed for exit code 0.") + private int maxFailed; + + @Option(names = "--min-handshake-rate", defaultValue = "0.0", description = "Minimum handshakes per second required for exit code 0.") + private double minHandshakeRate; + + @Option(names = "--json", description = "Print a machine-readable JSON result.") + private boolean json; + + @Override + public Integer call() throws Exception { + validateLoadOptions(connections, parallelism, settleMillis, probeTimeoutMillis); + validateAcceptance("min-handshaken", minHandshaken); + validateAcceptance("min-alive", minAlive); + validateAcceptance("max-closed", maxClosed); + validateAcceptance("max-failed", maxFailed); + validateAcceptance("min-handshake-rate", minHandshakeRate); + var startedAt = Instant.now(); + var sockets = Collections.synchronizedList(new ArrayList(connections)); + var attempted = new AtomicInteger(); + var connected = new AtomicInteger(); + var handshaken = new AtomicInteger(); + var failed = new AtomicInteger(); + var alive = new AtomicInteger(); + var closed = new AtomicInteger(); + var address = root.address(); + var handshakeHost = virtualHost == null || virtualHost.isBlank() ? address.getHostString() : virtualHost; + + try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { + var futures = new ArrayList>(); + var permits = new java.util.concurrent.Semaphore(parallelism); + for (var i = 0; i < connections; i++) { + permits.acquire(); + futures.add(executor.submit(() -> { + try { + attempted.incrementAndGet(); + var socket = new Socket(); + socket.connect(address, root.timeoutMillis()); + connected.incrementAndGet(); + socket.getOutputStream().write(MinecraftQueryProtocol.loginHandshake(protocolVersion, handshakeHost, address.getPort())); + socket.getOutputStream().flush(); + sockets.add(socket); + handshaken.incrementAndGet(); + } catch (IOException exception) { + failed.incrementAndGet(); + } finally { + permits.release(); + } + })); + } + for (var future : futures) { + future.get(); + } + TimeUnit.MILLISECONDS.sleep(settleMillis); + var probes = new ArrayList>(); + synchronized (sockets) { + for (var socket : sockets) { + probes.add(executor.submit(() -> { + if (isSocketAlive(socket, probeTimeoutMillis)) { + alive.incrementAndGet(); + } else { + closed.incrementAndGet(); + } + })); + } + } + for (var probe : probes) { + probe.get(); + } + TimeUnit.MILLISECONDS.sleep(Math.max(0, holdMillis)); + } finally { + for (var socket : sockets) { + close(socket); + } + } + + var elapsedMillis = Math.max(1, Duration.between(startedAt, Instant.now()).toMillis()); + var handshakeRate = handshaken.get() * 1000.0d / elapsedMillis; + var allowedClosed = failOnClosed ? 0 : maxClosed; + var passed = failed.get() <= maxFailed + && handshaken.get() >= minHandshaken + && alive.get() >= minAlive + && closed.get() <= allowedClosed + && handshakeRate >= minHandshakeRate; + if (json) { + printJson("handshake-load", passed, + "attempted", attempted.get(), + "connected", connected.get(), + "handshaken", handshaken.get(), + "alive", alive.get(), + "closed", closed.get(), + "failed", failed.get(), + "heldMillis", Math.max(0, holdMillis), + "settleMillis", settleMillis, + "elapsedMillis", elapsedMillis, + "handshakeRatePerSecond", handshakeRate); + } else { + System.out.printf( + "attempted=%d connected=%d handshaken=%d alive=%d closed=%d failed=%d heldMillis=%d settleMillis=%d elapsedMillis=%d handshakeRatePerSecond=%.2f%n", + attempted.get(), + connected.get(), + handshaken.get(), + alive.get(), + closed.get(), + failed.get(), + Math.max(0, holdMillis), + settleMillis, + elapsedMillis, + handshakeRate); + } + return passed ? 0 : 1; + } + } + + @Command(name = "route-storm", description = "Send login handshakes across many virtual hosts to probe routing storm behavior.") + static final class RouteStormCommand implements Callable { + @ParentCommand + private StrataProxyQueryCli root; + + @Option(names = "--connections", defaultValue = "1000", description = "Number of Minecraft handshakes to send.") + private int connections; + + @Option(names = "--routes", defaultValue = "100", description = "Number of distinct virtual hosts to cycle through.") + private int routes; + + @Option(names = "--virtual-host-template", defaultValue = "route-%d.example.net", description = "Virtual host template. Must contain one integer format placeholder.") + private String virtualHostTemplate; + + @Option(names = "--hold-ms", defaultValue = "0", description = "How long to hold successful handshaken connections after probing.") + private long holdMillis; + + @Option(names = "--parallelism", defaultValue = "256", description = "Maximum concurrent connection attempts.") + private int parallelism; + + @Option(names = "--settle-ms", defaultValue = "100", description = "Delay before probing whether handshaken sockets are still open.") + private long settleMillis; + + @Option(names = "--probe-timeout-ms", defaultValue = "50", description = "Read timeout used to distinguish routed-open sockets from closed sockets.") + private int probeTimeoutMillis; + + @Option(names = "--protocol", defaultValue = "763", description = "Minecraft protocol version.") + private int protocolVersion; + + @Option(names = "--fail-on-closed", description = "Exit with a non-zero code when any handshaken socket is closed during the probe.") + private boolean failOnClosed; + + @Option(names = "--min-handshaken", defaultValue = "0", description = "Minimum successful handshakes required for exit code 0.") + private int minHandshaken; + + @Option(names = "--min-alive", defaultValue = "0", description = "Minimum alive sockets required for exit code 0.") + private int minAlive; + + @Option(names = "--min-routes-handshaken", defaultValue = "0", description = "Minimum distinct virtual hosts that must complete a handshake.") + private int minRoutesHandshaken; + + @Option(names = "--max-closed", defaultValue = "2147483647", description = "Maximum closed sockets allowed for exit code 0.") + private int maxClosed; + + @Option(names = "--max-failed", defaultValue = "0", description = "Maximum failed connection attempts allowed for exit code 0.") + private int maxFailed; + + @Option(names = "--min-handshake-rate", defaultValue = "0.0", description = "Minimum handshakes per second required for exit code 0.") + private double minHandshakeRate; + + @Option(names = "--min-route-rate", defaultValue = "0.0", description = "Minimum distinct handshaken routes per second required for exit code 0.") + private double minRouteRate; + + @Option(names = "--json", description = "Print a machine-readable JSON result.") + private boolean json; + + @Override + public Integer call() throws Exception { + validateLoadOptions(connections, parallelism, settleMillis, probeTimeoutMillis); + if (routes <= 0) { + throw new IllegalArgumentException("routes must be positive"); + } + validateHostTemplate(virtualHostTemplate); + validateAcceptance("min-handshaken", minHandshaken); + validateAcceptance("min-alive", minAlive); + validateAcceptance("min-routes-handshaken", minRoutesHandshaken); + validateAcceptance("max-closed", maxClosed); + validateAcceptance("max-failed", maxFailed); + validateAcceptance("min-handshake-rate", minHandshakeRate); + validateAcceptance("min-route-rate", minRouteRate); + + var startedAt = Instant.now(); + var sockets = Collections.synchronizedList(new ArrayList(connections)); + Set attemptedRoutes = ConcurrentHashMap.newKeySet(); + Set handshakenRoutes = ConcurrentHashMap.newKeySet(); + var attempted = new AtomicInteger(); + var connected = new AtomicInteger(); + var handshaken = new AtomicInteger(); + var failed = new AtomicInteger(); + var alive = new AtomicInteger(); + var closed = new AtomicInteger(); + var address = root.address(); + + try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { + var futures = new ArrayList>(); + var permits = new java.util.concurrent.Semaphore(parallelism); + for (var i = 0; i < connections; i++) { + var routeIndex = Math.floorMod(i, routes); + var handshakeHost = routeHost(virtualHostTemplate, routeIndex); + attemptedRoutes.add(handshakeHost); + permits.acquire(); + futures.add(executor.submit(() -> { + try { + attempted.incrementAndGet(); + var socket = new Socket(); + socket.connect(address, root.timeoutMillis()); + connected.incrementAndGet(); + socket.getOutputStream().write(MinecraftQueryProtocol.loginHandshake(protocolVersion, handshakeHost, address.getPort())); + socket.getOutputStream().flush(); + sockets.add(socket); + handshaken.incrementAndGet(); + handshakenRoutes.add(handshakeHost); + } catch (IOException exception) { + failed.incrementAndGet(); + } finally { + permits.release(); + } + })); + } + for (var future : futures) { + future.get(); + } + TimeUnit.MILLISECONDS.sleep(settleMillis); + var probes = new ArrayList>(); + synchronized (sockets) { + for (var socket : sockets) { + probes.add(executor.submit(() -> { + if (isSocketAlive(socket, probeTimeoutMillis)) { + alive.incrementAndGet(); + } else { + closed.incrementAndGet(); + } + })); + } + } + for (var probe : probes) { + probe.get(); + } + TimeUnit.MILLISECONDS.sleep(Math.max(0, holdMillis)); + } finally { + for (var socket : sockets) { + close(socket); + } + } + + var elapsedMillis = Math.max(1, Duration.between(startedAt, Instant.now()).toMillis()); + var handshakeRate = handshaken.get() * 1000.0d / elapsedMillis; + var routeRate = handshakenRoutes.size() * 1000.0d / elapsedMillis; + var allowedClosed = failOnClosed ? 0 : maxClosed; + var passed = failed.get() <= maxFailed + && handshaken.get() >= minHandshaken + && alive.get() >= minAlive + && handshakenRoutes.size() >= minRoutesHandshaken + && closed.get() <= allowedClosed + && handshakeRate >= minHandshakeRate + && routeRate >= minRouteRate; + if (json) { + printJson("route-storm", passed, + "attempted", attempted.get(), + "connected", connected.get(), + "handshaken", handshaken.get(), + "alive", alive.get(), + "closed", closed.get(), + "failed", failed.get(), + "routesConfigured", routes, + "routesAttempted", attemptedRoutes.size(), + "routesHandshaken", handshakenRoutes.size(), + "heldMillis", Math.max(0, holdMillis), + "settleMillis", settleMillis, + "elapsedMillis", elapsedMillis, + "handshakeRatePerSecond", handshakeRate, + "routeRatePerSecond", routeRate); + } else { + System.out.printf( + "attempted=%d connected=%d handshaken=%d alive=%d closed=%d failed=%d routesConfigured=%d routesAttempted=%d routesHandshaken=%d heldMillis=%d settleMillis=%d elapsedMillis=%d handshakeRatePerSecond=%.2f routeRatePerSecond=%.2f%n", + attempted.get(), + connected.get(), + handshaken.get(), + alive.get(), + closed.get(), + failed.get(), + routes, + attemptedRoutes.size(), + handshakenRoutes.size(), + Math.max(0, holdMillis), + settleMillis, + elapsedMillis, + handshakeRate, + routeRate); + } + return passed ? 0 : 1; + } + } + + @Command(name = "traffic-load", description = "Send Minecraft-framed traffic after login handshakes to probe relay throughput.") + static final class TrafficLoadCommand implements Callable { + @ParentCommand + private StrataProxyQueryCli root; + + @Option(names = "--connections", defaultValue = "100", description = "Number of concurrent handshaken connections.") + private int connections; + + @Option(names = "--parallelism", defaultValue = "128", description = "Maximum concurrent connection workers.") + private int parallelism; + + @Option(names = "--protocol", defaultValue = "763", description = "Minecraft protocol version.") + private int protocolVersion; + + @Option(names = "--virtual-host", description = "Hostname to place in the Minecraft handshake.") + private String virtualHost; + + @Option(names = "--login-start", description = "Send a Login Start packet with a generated player name before traffic frames.") + private boolean loginStart; + + @Option(names = "--player-template", defaultValue = "load%05d", description = "Player name template for --login-start. Must contain one integer format placeholder and fit in 16 characters.") + private String playerTemplate; + + @Option(names = "--packets-per-connection", defaultValue = "100", description = "Number of packet frames each connection sends.") + private int packetsPerConnection; + + @Option(names = "--payload-bytes", defaultValue = "32", description = "Zero-filled payload bytes after the packet id.") + private int payloadBytes; + + @Option(names = "--packet-id", defaultValue = "1", description = "Packet id to use for generated frames.") + private int packetId; + + @Option(names = "--inter-packet-delay-ms", defaultValue = "0", description = "Optional delay between generated packets on each connection.") + private long interPacketDelayMillis; + + @Option(names = "--hold-ms", defaultValue = "0", description = "How long to hold connections after sending traffic.") + private long holdMillis; + + @Option(names = "--min-handshaken", defaultValue = "0", description = "Minimum successful handshakes required for exit code 0.") + private int minHandshaken; + + @Option(names = "--min-packets-sent", defaultValue = "0", description = "Minimum sent packet frames required for exit code 0.") + private long minPacketsSent; + + @Option(names = "--min-bytes-sent", defaultValue = "0", description = "Minimum sent bytes required for exit code 0.") + private long minBytesSent; + + @Option(names = "--max-failed", defaultValue = "0", description = "Maximum failed connection attempts allowed for exit code 0.") + private int maxFailed; + + @Option(names = "--min-packet-rate", defaultValue = "0.0", description = "Minimum packets per second required for exit code 0.") + private double minPacketRate; + + @Option(names = "--min-byte-rate", defaultValue = "0.0", description = "Minimum bytes per second required for exit code 0.") + private double minByteRate; + + @Option(names = "--measure-echo-latency", description = "Read one echoed frame after every sent packet and report latency percentiles.") + private boolean measureEchoLatency; + + @Option(names = "--max-p99-latency-ms", defaultValue = "0.0", description = "Maximum p99 echo latency in milliseconds for exit code 0. Use 0 to disable.") + private double maxP99LatencyMillis; + + @Option(names = "--json", description = "Print a machine-readable JSON result.") + private boolean json; + + @Override + public Integer call() throws Exception { + validateLoadOptions(connections, parallelism, 0, 1); + if (packetsPerConnection < 0) { + throw new IllegalArgumentException("packets-per-connection must be non-negative"); + } + if (payloadBytes < 0) { + throw new IllegalArgumentException("payload-bytes must be non-negative"); + } + if (interPacketDelayMillis < 0) { + throw new IllegalArgumentException("inter-packet-delay-ms must not be negative"); + } + if (loginStart) { + validatePlayerTemplate(playerTemplate, connections); + } + validateAcceptance("min-handshaken", minHandshaken); + validateAcceptance("min-packets-sent", minPacketsSent); + validateAcceptance("min-bytes-sent", minBytesSent); + validateAcceptance("max-failed", maxFailed); + validateAcceptance("min-packet-rate", minPacketRate); + validateAcceptance("min-byte-rate", minByteRate); + validateAcceptance("max-p99-latency-ms", maxP99LatencyMillis); + var startedAt = Instant.now(); + var sockets = Collections.synchronizedList(new ArrayList(connections)); + var attempted = new AtomicInteger(); + var connected = new AtomicInteger(); + var handshaken = new AtomicInteger(); + var failed = new AtomicInteger(); + var loginStartsSent = new AtomicInteger(); + var loginStartBytesSent = new java.util.concurrent.atomic.LongAdder(); + var packetsSent = new java.util.concurrent.atomic.LongAdder(); + var bytesSent = new java.util.concurrent.atomic.LongAdder(); + var latencySamples = Collections.synchronizedList(new ArrayList()); + var address = root.address(); + var handshakeHost = virtualHost == null || virtualHost.isBlank() ? address.getHostString() : virtualHost; + var handshake = MinecraftQueryProtocol.loginHandshake(protocolVersion, handshakeHost, address.getPort()); + var packet = MinecraftQueryProtocol.rawPacketFrame(packetId, payloadBytes); + + try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { + var futures = new ArrayList>(); + var permits = new java.util.concurrent.Semaphore(parallelism); + for (var i = 0; i < connections; i++) { + var connectionIndex = i; + permits.acquire(); + futures.add(executor.submit(() -> { + try { + attempted.incrementAndGet(); + var socket = new Socket(); + socket.connect(address, root.timeoutMillis()); + connected.incrementAndGet(); + var output = socket.getOutputStream(); + output.write(handshake); + output.flush(); + sockets.add(socket); + handshaken.incrementAndGet(); + if (loginStart) { + var loginStartFrame = MinecraftQueryProtocol.loginStart(playerName(playerTemplate, connectionIndex)); + output.write(loginStartFrame); + loginStartsSent.incrementAndGet(); + loginStartBytesSent.add(loginStartFrame.length); + } + for (var packetIndex = 0; packetIndex < packetsPerConnection; packetIndex++) { + var startedPacketAt = measureEchoLatency ? System.nanoTime() : 0L; + output.write(packet); + packetsSent.increment(); + bytesSent.add(packet.length); + if (measureEchoLatency) { + output.flush(); + var echoed = socket.getInputStream().readNBytes(packet.length); + if (echoed.length != packet.length) { + failed.incrementAndGet(); + break; + } + latencySamples.add(System.nanoTime() - startedPacketAt); + } + if (interPacketDelayMillis > 0) { + TimeUnit.MILLISECONDS.sleep(interPacketDelayMillis); + } + } + output.flush(); + } catch (IOException exception) { + failed.incrementAndGet(); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + failed.incrementAndGet(); + } finally { + permits.release(); + } + })); + } + for (var future : futures) { + future.get(); + } + TimeUnit.MILLISECONDS.sleep(Math.max(0, holdMillis)); + } finally { + for (var socket : sockets) { + close(socket); + } + } + + var elapsedMillis = Math.max(1, Duration.between(startedAt, Instant.now()).toMillis()); + var packetRate = packetsSent.sum() * 1000.0d / elapsedMillis; + var byteRate = bytesSent.sum() * 1000.0d / elapsedMillis; + var latency = LatencySummary.from(latencySamples); + var latencyPassed = maxP99LatencyMillis <= 0.0d || (latency.samples() > 0 && latency.p99Millis() <= maxP99LatencyMillis); + var passed = failed.get() <= maxFailed + && handshaken.get() >= minHandshaken + && packetsSent.sum() >= minPacketsSent + && bytesSent.sum() >= minBytesSent + && packetRate >= minPacketRate + && byteRate >= minByteRate + && latencyPassed; + if (json) { + printJson("traffic-load", passed, + "attempted", attempted.get(), + "connected", connected.get(), + "handshaken", handshaken.get(), + "loginStartsSent", loginStartsSent.get(), + "loginStartBytesSent", loginStartBytesSent.sum(), + "packetsSent", packetsSent.sum(), + "bytesSent", bytesSent.sum(), + "failed", failed.get(), + "heldMillis", Math.max(0, holdMillis), + "elapsedMillis", elapsedMillis, + "packetRatePerSecond", packetRate, + "byteRatePerSecond", byteRate, + "latencySamples", latency.samples(), + "p50LatencyMillis", latency.p50Millis(), + "p95LatencyMillis", latency.p95Millis(), + "p99LatencyMillis", latency.p99Millis(), + "maxLatencyMillis", latency.maxMillis()); + } else { + System.out.printf( + "attempted=%d connected=%d handshaken=%d loginStartsSent=%d loginStartBytesSent=%d packetsSent=%d bytesSent=%d failed=%d heldMillis=%d elapsedMillis=%d packetRatePerSecond=%.2f byteRatePerSecond=%.2f latencySamples=%d p50LatencyMillis=%.3f p95LatencyMillis=%.3f p99LatencyMillis=%.3f maxLatencyMillis=%.3f%n", + attempted.get(), + connected.get(), + handshaken.get(), + loginStartsSent.get(), + loginStartBytesSent.sum(), + packetsSent.sum(), + bytesSent.sum(), + failed.get(), + Math.max(0, holdMillis), + elapsedMillis, + packetRate, + byteRate, + latency.samples(), + latency.p50Millis(), + latency.p95Millis(), + latency.p99Millis(), + latency.maxMillis()); + } + return passed ? 0 : 1; + } + } + + @Command(name = "compression-rewrite-load", description = "Send compressed Minecraft frames after Set Compression to probe live rewrite throughput.") + static final class CompressionRewriteLoadCommand implements Callable { + @ParentCommand + private StrataProxyQueryCli root; + + @Option(names = "--connections", defaultValue = "100", description = "Number of concurrent login connections.") + private int connections; + + @Option(names = "--parallelism", defaultValue = "128", description = "Maximum concurrent connection workers.") + private int parallelism; + + @Option(names = "--protocol", defaultValue = "763", description = "Minecraft protocol version.") + private int protocolVersion; + + @Option(names = "--virtual-host", description = "Hostname to place in the Minecraft handshake.") + private String virtualHost; + + @Option(names = "--player-template", defaultValue = "rewrite%05d", description = "Player name template for Login Start. Must contain one integer format placeholder and fit in 16 characters.") + private String playerTemplate; + + @Option(names = "--packets-per-connection", defaultValue = "100", description = "Number of compressed packet frames each connection sends.") + private int packetsPerConnection; + + @Option(names = "--payload-bytes", defaultValue = "1024", description = "Zero-filled payload bytes after the packet id before compression.") + private int payloadBytes; + + @Option(names = "--packet-id", defaultValue = "1", description = "Packet id to use for generated compressed frames.") + private int packetId; + + @Option(names = "--threshold", defaultValue = "256", description = "Expected Set Compression threshold.") + private int threshold; + + @Option(names = "--max-negotiation-frame-bytes", defaultValue = "1024", description = "Maximum Set Compression frame size to accept.") + private int maxNegotiationFrameBytes; + + @Option(names = "--split-frames", description = "Split every generated frame across two writes to exercise partial compressed-frame buffering.") + private boolean splitFrames; + + @Option(names = "--inter-packet-delay-ms", defaultValue = "0", description = "Optional delay between generated compressed packets on each connection.") + private long interPacketDelayMillis; + + @Option(names = "--hold-ms", defaultValue = "0", description = "How long to hold connections after sending traffic.") + private long holdMillis; + + @Option(names = "--min-handshaken", defaultValue = "0", description = "Minimum successful handshakes required for exit code 0.") + private int minHandshaken; + + @Option(names = "--min-negotiated", defaultValue = "0", description = "Minimum successful Set Compression negotiations required for exit code 0.") + private int minNegotiated; + + @Option(names = "--min-packets-sent", defaultValue = "0", description = "Minimum sent compressed packet frames required for exit code 0.") + private long minPacketsSent; + + @Option(names = "--min-bytes-sent", defaultValue = "0", description = "Minimum sent bytes required for exit code 0.") + private long minBytesSent; + + @Option(names = "--max-failed", defaultValue = "0", description = "Maximum failed connection attempts allowed for exit code 0.") + private int maxFailed; + + @Option(names = "--min-packet-rate", defaultValue = "0.0", description = "Minimum packets per second required for exit code 0.") + private double minPacketRate; + + @Option(names = "--min-byte-rate", defaultValue = "0.0", description = "Minimum bytes per second required for exit code 0.") + private double minByteRate; + + @Option(names = "--json", description = "Print a machine-readable JSON result.") + private boolean json; + + @Override + public Integer call() throws Exception { + validateLoadOptions(connections, parallelism, 0, 1); + if (packetsPerConnection < 0) { + throw new IllegalArgumentException("packets-per-connection must be non-negative"); + } + if (payloadBytes < 0) { + throw new IllegalArgumentException("payload-bytes must be non-negative"); + } + if (packetId < 0) { + throw new IllegalArgumentException("packet-id must be non-negative"); + } + if (threshold < 0) { + throw new IllegalArgumentException("threshold must be non-negative"); + } + if (maxNegotiationFrameBytes <= 0) { + throw new IllegalArgumentException("max-negotiation-frame-bytes must be positive"); + } + if (interPacketDelayMillis < 0) { + throw new IllegalArgumentException("inter-packet-delay-ms must not be negative"); + } + validatePlayerTemplate(playerTemplate, connections); + validateAcceptance("min-handshaken", minHandshaken); + validateAcceptance("min-negotiated", minNegotiated); + validateAcceptance("min-packets-sent", minPacketsSent); + validateAcceptance("min-bytes-sent", minBytesSent); + validateAcceptance("max-failed", maxFailed); + validateAcceptance("min-packet-rate", minPacketRate); + validateAcceptance("min-byte-rate", minByteRate); + + var startedAt = Instant.now(); + var sockets = Collections.synchronizedList(new ArrayList(connections)); + var attempted = new AtomicInteger(); + var connected = new AtomicInteger(); + var handshaken = new AtomicInteger(); + var negotiated = new AtomicInteger(); + var failed = new AtomicInteger(); + var packetsSent = new java.util.concurrent.atomic.LongAdder(); + var bytesSent = new java.util.concurrent.atomic.LongAdder(); + var address = root.address(); + var handshakeHost = virtualHost == null || virtualHost.isBlank() ? address.getHostString() : virtualHost; + var handshake = MinecraftQueryProtocol.loginHandshake(protocolVersion, handshakeHost, address.getPort()); + var packet = MinecraftQueryProtocol.compressedPacketFrame(packetId, payloadBytes, threshold); + + try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { + var futures = new ArrayList>(); + var permits = new java.util.concurrent.Semaphore(parallelism); + for (var i = 0; i < connections; i++) { + var connectionIndex = i; + permits.acquire(); + futures.add(executor.submit(() -> { + try { + attempted.incrementAndGet(); + var socket = new Socket(); + socket.connect(address, root.timeoutMillis()); + connected.incrementAndGet(); + sockets.add(socket); + socket.setSoTimeout(root.timeoutMillis()); + var output = socket.getOutputStream(); + output.write(handshake); + output.write(MinecraftQueryProtocol.loginStart(playerName(playerTemplate, connectionIndex))); + output.flush(); + handshaken.incrementAndGet(); + var negotiatedThreshold = MinecraftQueryProtocol.readCompressionThreshold( + socket.getInputStream(), + maxNegotiationFrameBytes); + if (negotiatedThreshold != threshold) { + failed.incrementAndGet(); + return; + } + negotiated.incrementAndGet(); + for (var packetIndex = 0; packetIndex < packetsPerConnection; packetIndex++) { + if (splitFrames && packet.length > 1) { + var splitIndex = packet.length / 2; + output.write(packet, 0, splitIndex); + output.flush(); + output.write(packet, splitIndex, packet.length - splitIndex); + } else { + output.write(packet); + } + packetsSent.increment(); + bytesSent.add(packet.length); + if (interPacketDelayMillis > 0) { + TimeUnit.MILLISECONDS.sleep(interPacketDelayMillis); + } + } + output.flush(); + } catch (IOException exception) { + failed.incrementAndGet(); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + failed.incrementAndGet(); + } finally { + permits.release(); + } + })); + } + for (var future : futures) { + future.get(); + } + TimeUnit.MILLISECONDS.sleep(Math.max(0, holdMillis)); + } finally { + for (var socket : sockets) { + close(socket); + } + } + + var elapsedMillis = Math.max(1, Duration.between(startedAt, Instant.now()).toMillis()); + var packetRate = packetsSent.sum() * 1000.0d / elapsedMillis; + var byteRate = bytesSent.sum() * 1000.0d / elapsedMillis; + var passed = failed.get() <= maxFailed + && handshaken.get() >= minHandshaken + && negotiated.get() >= minNegotiated + && packetsSent.sum() >= minPacketsSent + && bytesSent.sum() >= minBytesSent + && packetRate >= minPacketRate + && byteRate >= minByteRate; + if (json) { + printJson("compression-rewrite-load", passed, + "attempted", attempted.get(), + "connected", connected.get(), + "handshaken", handshaken.get(), + "negotiated", negotiated.get(), + "packetsSent", packetsSent.sum(), + "bytesSent", bytesSent.sum(), + "failed", failed.get(), + "heldMillis", Math.max(0, holdMillis), + "elapsedMillis", elapsedMillis, + "packetRatePerSecond", packetRate, + "byteRatePerSecond", byteRate, + "splitFrames", splitFrames); + } else { + System.out.printf( + "attempted=%d connected=%d handshaken=%d negotiated=%d packetsSent=%d bytesSent=%d failed=%d heldMillis=%d elapsedMillis=%d packetRatePerSecond=%.2f byteRatePerSecond=%.2f splitFrames=%s%n", + attempted.get(), + connected.get(), + handshaken.get(), + negotiated.get(), + packetsSent.sum(), + bytesSent.sum(), + failed.get(), + Math.max(0, holdMillis), + elapsedMillis, + packetRate, + byteRate, + splitFrames); + } + return passed ? 0 : 1; + } + } + + @Command(name = "load-suite", description = "Run a repeatable load-test scenario using the built-in probes.") + static final class LoadSuiteCommand implements Callable { + @ParentCommand + private StrataProxyQueryCli root; + + @Option(names = "--profile", defaultValue = "smoke", description = "Scenario profile: smoke or acceptance.") + private String profile; + + @Option(names = "--idle-connections", description = "Idle TCP connections. Defaults to 100 for smoke and 10000 for acceptance.") + private Integer idleConnections; + + @Option(names = "--active-connections", description = "Traffic connections. Defaults to 10 for smoke and 2000 for acceptance.") + private Integer activeConnections; + + @Option(names = "--parallelism", defaultValue = "256", description = "Maximum concurrent connection workers for each probe.") + private int parallelism; + + @Option(names = "--virtual-host", description = "Hostname to place in Minecraft handshakes.") + private String virtualHost; + + @Option(names = "--player-template", defaultValue = "suite%05d", description = "Player name template for active-player traffic.") + private String playerTemplate; + + @Option(names = "--traffic-packets-per-connection", description = "Traffic packet frames per active connection. Defaults to 10 for smoke and 200 for acceptance.") + private Integer trafficPacketsPerConnection; + + @Option(names = "--payload-bytes", defaultValue = "64", description = "Traffic payload bytes after packet id.") + private int payloadBytes; + + @Option(names = "--hold-ms", defaultValue = "0", description = "How long each probe holds connections after sending traffic.") + private long holdMillis; + + @Option(names = "--settle-ms", defaultValue = "100", description = "Idle-load settle delay before probing sockets.") + private long settleMillis; + + @Option(names = "--probe-timeout-ms", defaultValue = "50", description = "Idle-load read timeout for alive probes.") + private int probeTimeoutMillis; + + @Option(names = "--include-compression-rewrite", description = "Also run compression-rewrite-load after traffic-load.") + private boolean includeCompressionRewrite; + + @Option(names = "--compression-payload-bytes", defaultValue = "1024", description = "Payload bytes for compression-rewrite-load.") + private int compressionPayloadBytes; + + @Option(names = "--compression-threshold", defaultValue = "256", description = "Expected Set Compression threshold for compression-rewrite-load.") + private int compressionThreshold; + + @Option(names = "--json", description = "Print a machine-readable JSON suite summary.") + private boolean json; + + @Override + public Integer call() { + var selectedProfile = profile.toLowerCase(Locale.ROOT); + if (!selectedProfile.equals("smoke") && !selectedProfile.equals("acceptance")) { + throw new IllegalArgumentException("profile must be smoke or acceptance"); + } + if (parallelism <= 0) { + throw new IllegalArgumentException("parallelism must be positive"); + } + if (payloadBytes < 0) { + throw new IllegalArgumentException("payload-bytes must be non-negative"); + } + if (compressionPayloadBytes < 0) { + throw new IllegalArgumentException("compression-payload-bytes must be non-negative"); + } + if (compressionThreshold < 0) { + throw new IllegalArgumentException("compression-threshold must be non-negative"); + } + + var resolvedIdleConnections = idleConnections == null + ? (selectedProfile.equals("acceptance") ? 10_000 : 100) + : idleConnections; + var resolvedActiveConnections = activeConnections == null + ? (selectedProfile.equals("acceptance") ? 2_000 : 10) + : activeConnections; + var resolvedTrafficPackets = trafficPacketsPerConnection == null + ? (selectedProfile.equals("acceptance") ? 200 : 10) + : trafficPacketsPerConnection; + validateLoadOptions(resolvedIdleConnections, parallelism, settleMillis, probeTimeoutMillis); + validateLoadOptions(resolvedActiveConnections, parallelism, 0, 1); + if (resolvedTrafficPackets < 0) { + throw new IllegalArgumentException("traffic-packets-per-connection must be non-negative"); + } + validatePlayerTemplate(playerTemplate, resolvedActiveConnections); + + var startedAt = Instant.now(); + var results = new ArrayList(); + results.add(runNested(root, + "idle-load", + "--connections", Integer.toString(resolvedIdleConnections), + "--parallelism", Integer.toString(parallelism), + "--settle-ms", Long.toString(settleMillis), + "--probe-timeout-ms", Integer.toString(probeTimeoutMillis), + "--hold-ms", Long.toString(holdMillis), + "--fail-on-closed", + "--min-connected", Integer.toString(resolvedIdleConnections), + "--min-alive", Integer.toString(resolvedIdleConnections), + "--max-failed", "0")); + + var hostArgs = virtualHost == null || virtualHost.isBlank() + ? List.of() + : List.of("--virtual-host", virtualHost); + var trafficArgs = new ArrayList(); + Collections.addAll(trafficArgs, + "traffic-load", + "--connections", Integer.toString(resolvedActiveConnections), + "--parallelism", Integer.toString(parallelism), + "--login-start", + "--player-template", playerTemplate, + "--packets-per-connection", Integer.toString(resolvedTrafficPackets), + "--payload-bytes", Integer.toString(payloadBytes), + "--hold-ms", Long.toString(holdMillis), + "--min-handshaken", Integer.toString(resolvedActiveConnections), + "--min-packets-sent", Long.toString((long) resolvedActiveConnections * resolvedTrafficPackets), + "--max-failed", "0"); + trafficArgs.addAll(hostArgs); + results.add(runNested(root, trafficArgs.toArray(String[]::new))); + + if (includeCompressionRewrite) { + var rewriteArgs = new ArrayList(); + Collections.addAll(rewriteArgs, + "compression-rewrite-load", + "--connections", Integer.toString(resolvedActiveConnections), + "--parallelism", Integer.toString(parallelism), + "--player-template", playerTemplate, + "--packets-per-connection", Integer.toString(resolvedTrafficPackets), + "--payload-bytes", Integer.toString(compressionPayloadBytes), + "--threshold", Integer.toString(compressionThreshold), + "--hold-ms", Long.toString(holdMillis), + "--min-handshaken", Integer.toString(resolvedActiveConnections), + "--min-negotiated", Integer.toString(resolvedActiveConnections), + "--min-packets-sent", Long.toString((long) resolvedActiveConnections * resolvedTrafficPackets), + "--max-failed", "0"); + rewriteArgs.addAll(hostArgs); + results.add(runNested(root, rewriteArgs.toArray(String[]::new))); + } + + var elapsedMillis = Math.max(1, Duration.between(startedAt, Instant.now()).toMillis()); + var passed = results.stream().allMatch(SuiteStepResult::passed); + if (json) { + printJson("load-suite", passed, + "profile", selectedProfile, + "idleConnections", resolvedIdleConnections, + "activeConnections", resolvedActiveConnections, + "trafficPacketsPerConnection", resolvedTrafficPackets, + "includeCompressionRewrite", includeCompressionRewrite, + "steps", suiteStepsJson(results), + "elapsedMillis", elapsedMillis); + } else { + System.out.printf( + "profile=%s idleConnections=%d activeConnections=%d trafficPacketsPerConnection=%d includeCompressionRewrite=%s passed=%s elapsedMillis=%d%n", + selectedProfile, + resolvedIdleConnections, + resolvedActiveConnections, + resolvedTrafficPackets, + includeCompressionRewrite, + passed, + elapsedMillis); + for (var result : results) { + System.out.printf("step=%s exitCode=%d output=%s%n", + result.name(), + result.exitCode(), + result.output().replace(System.lineSeparator(), " ").trim()); + } + } + return passed ? 0 : 1; + } + } + + @Command(name = "idle-load", description = "Open and hold many idle TCP connections to probe admission limits.") + static final class IdleLoadCommand implements Callable { + @ParentCommand + private StrataProxyQueryCli root; + + @Option(names = "--connections", defaultValue = "100", description = "Number of TCP connections to open.") + private int connections; + + @Option(names = "--hold-ms", defaultValue = "5000", description = "How long to hold successful connections.") + private long holdMillis; + + @Option(names = "--parallelism", defaultValue = "128", description = "Maximum concurrent connection attempts.") + private int parallelism; + + @Option(names = "--settle-ms", defaultValue = "100", description = "Delay before probing whether connected sockets are still open.") + private long settleMillis; + + @Option(names = "--probe-timeout-ms", defaultValue = "50", description = "Read timeout used to distinguish idle-open sockets from closed sockets.") + private int probeTimeoutMillis; + + @Option(names = "--fail-on-closed", description = "Exit with a non-zero code when any connected socket is closed during the probe.") + private boolean failOnClosed; + + @Option(names = "--min-connected", defaultValue = "0", description = "Minimum connected sockets required for exit code 0.") + private int minConnected; + + @Option(names = "--min-alive", defaultValue = "0", description = "Minimum alive sockets required for exit code 0.") + private int minAlive; + + @Option(names = "--max-closed", defaultValue = "2147483647", description = "Maximum closed sockets allowed for exit code 0.") + private int maxClosed; + + @Option(names = "--max-failed", defaultValue = "0", description = "Maximum failed connection attempts allowed for exit code 0.") + private int maxFailed; + + @Option(names = "--min-connect-rate", defaultValue = "0.0", description = "Minimum connects per second required for exit code 0.") + private double minConnectRate; + + @Option(names = "--json", description = "Print a machine-readable JSON result.") + private boolean json; + + @Override + public Integer call() throws Exception { + validateLoadOptions(connections, parallelism, settleMillis, probeTimeoutMillis); + validateAcceptance("min-connected", minConnected); + validateAcceptance("min-alive", minAlive); + validateAcceptance("max-closed", maxClosed); + validateAcceptance("max-failed", maxFailed); + validateAcceptance("min-connect-rate", minConnectRate); + var startedAt = Instant.now(); + var sockets = Collections.synchronizedList(new ArrayList(connections)); + var attempted = new AtomicInteger(); + var connected = new AtomicInteger(); + var failed = new AtomicInteger(); + var alive = new AtomicInteger(); + var closed = new AtomicInteger(); + + try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { + var futures = new ArrayList>(); + var permits = new java.util.concurrent.Semaphore(parallelism); + for (var i = 0; i < connections; i++) { + permits.acquire(); + futures.add(executor.submit(() -> { + try { + attempted.incrementAndGet(); + var socket = new Socket(); + socket.connect(root.address(), root.timeoutMillis()); + sockets.add(socket); + connected.incrementAndGet(); + } catch (IOException exception) { + failed.incrementAndGet(); + } finally { + permits.release(); + } + })); + } + for (var future : futures) { + future.get(); + } + TimeUnit.MILLISECONDS.sleep(settleMillis); + var probes = new ArrayList>(); + synchronized (sockets) { + for (var socket : sockets) { + probes.add(executor.submit(() -> { + if (isSocketAlive(socket, probeTimeoutMillis)) { + alive.incrementAndGet(); + } else { + closed.incrementAndGet(); + } + })); + } + } + for (var probe : probes) { + probe.get(); + } + TimeUnit.MILLISECONDS.sleep(Math.max(0, holdMillis)); + } finally { + for (var socket : sockets) { + close(socket); + } + } + + var elapsedMillis = Math.max(1, Duration.between(startedAt, Instant.now()).toMillis()); + var connectRate = connected.get() * 1000.0d / elapsedMillis; + var allowedClosed = failOnClosed ? 0 : maxClosed; + var passed = failed.get() <= maxFailed + && connected.get() >= minConnected + && alive.get() >= minAlive + && closed.get() <= allowedClosed + && connectRate >= minConnectRate; + if (json) { + printJson("idle-load", passed, + "attempted", attempted.get(), + "connected", connected.get(), + "alive", alive.get(), + "closed", closed.get(), + "failed", failed.get(), + "heldMillis", Math.max(0, holdMillis), + "settleMillis", settleMillis, + "elapsedMillis", elapsedMillis, + "connectRatePerSecond", connectRate); + } else { + System.out.printf( + "attempted=%d connected=%d alive=%d closed=%d failed=%d heldMillis=%d settleMillis=%d elapsedMillis=%d connectRatePerSecond=%.2f%n", + attempted.get(), + connected.get(), + alive.get(), + closed.get(), + failed.get(), + Math.max(0, holdMillis), + settleMillis, + elapsedMillis, + connectRate); + } + return passed ? 0 : 1; + } + } + + @Command(name = "slow-sink", description = "Run a slow-reading TCP backend for relay backpressure tests.") + static final class SlowSinkCommand implements Callable { + @Option(names = "--bind-host", defaultValue = "127.0.0.1", description = "Local address to bind.") + private String bindHost; + + @Option(names = "--port", defaultValue = "25565", description = "Local port to bind. Use 0 for an ephemeral port.") + private int port; + + @Option(names = "--duration-ms", defaultValue = "30000", description = "How long the sink server runs.") + private long durationMillis; + + @Option(names = "--accept-timeout-ms", defaultValue = "100", description = "Accept loop timeout used for shutdown checks.") + private int acceptTimeoutMillis; + + @Option(names = "--read-chunk-bytes", defaultValue = "1", description = "Bytes to read per socket read. Use 0 to accept and hold without reading.") + private int readChunkBytes; + + @Option(names = "--read-delay-ms", defaultValue = "100", description = "Delay after each socket read.") + private long readDelayMillis; + + @Option(names = "--backlog", defaultValue = "1024", description = "Server socket backlog.") + private int backlog; + + @Override + public Integer call() throws Exception { + if (durationMillis < 0) { + throw new IllegalArgumentException("duration-ms must not be negative"); + } + if (acceptTimeoutMillis <= 0) { + throw new IllegalArgumentException("accept-timeout-ms must be positive"); + } + if (readChunkBytes < 0) { + throw new IllegalArgumentException("read-chunk-bytes must not be negative"); + } + if (readDelayMillis < 0) { + throw new IllegalArgumentException("read-delay-ms must not be negative"); + } + if (backlog <= 0) { + throw new IllegalArgumentException("backlog must be positive"); + } + + var accepted = new AtomicInteger(); + var closed = new AtomicInteger(); + var failed = new AtomicInteger(); + var bytesRead = new java.util.concurrent.atomic.LongAdder(); + var startedAt = Instant.now(); + var deadline = startedAt.plusMillis(durationMillis); + + try (var server = new ServerSocket()) { + server.setReuseAddress(true); + server.bind(new InetSocketAddress(bindHost, port), backlog); + server.setSoTimeout(acceptTimeoutMillis); + System.out.printf("listening=%s:%d readChunkBytes=%d readDelayMillis=%d durationMillis=%d%n", + server.getInetAddress().getHostAddress(), + server.getLocalPort(), + readChunkBytes, + readDelayMillis, + durationMillis); + System.out.flush(); + + try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { + while (!Instant.now().isAfter(deadline)) { + try { + var socket = server.accept(); + accepted.incrementAndGet(); + executor.submit(() -> handleSlowSinkSocket( + socket, + readChunkBytes, + readDelayMillis, + acceptTimeoutMillis, + deadline, + bytesRead, + closed, + failed)); + } catch (SocketTimeoutException exception) { + // Timeout is the normal shutdown check cadence. + } + } + } + } + + var elapsedMillis = Math.max(1, Duration.between(startedAt, Instant.now()).toMillis()); + System.out.printf( + "accepted=%d closed=%d failed=%d bytesRead=%d elapsedMillis=%d readBytesPerSecond=%.2f%n", + accepted.get(), + closed.get(), + failed.get(), + bytesRead.sum(), + elapsedMillis, + bytesRead.sum() * 1000.0d / elapsedMillis); + return failed.get() == 0 ? 0 : 1; + } + } + + private static void validateLoadOptions(int connections, int parallelism, long settleMillis, int probeTimeoutMillis) { + if (connections <= 0) { + throw new IllegalArgumentException("connections must be positive"); + } + if (parallelism <= 0) { + throw new IllegalArgumentException("parallelism must be positive"); + } + if (settleMillis < 0) { + throw new IllegalArgumentException("settle-ms must not be negative"); + } + if (probeTimeoutMillis <= 0) { + throw new IllegalArgumentException("probe-timeout-ms must be positive"); + } + } + + private static void validateAcceptance(String name, long value) { + if (value < 0) { + throw new IllegalArgumentException(name + " must be non-negative"); + } + } + + private static void validateAcceptance(String name, double value) { + if (value < 0.0d || Double.isNaN(value)) { + throw new IllegalArgumentException(name + " must be non-negative"); + } + } + + private static void validateHostTemplate(String template) { + if (template == null || template.isBlank()) { + throw new IllegalArgumentException("virtual-host-template must not be blank"); + } + var first = routeHost(template, 0); + var second = routeHost(template, 1); + if (first.equals(second)) { + throw new IllegalArgumentException("virtual-host-template must include a varying integer placeholder such as %d"); + } + } + + private static void validatePlayerTemplate(String template, int connections) { + if (template == null || template.isBlank()) { + throw new IllegalArgumentException("player-template must not be blank"); + } + var first = playerName(template, 0); + var last = playerName(template, Math.max(0, connections - 1)); + if (first.equals(last) && connections > 1) { + throw new IllegalArgumentException("player-template must include a varying integer placeholder such as %d"); + } + if (first.length() > 16 || last.length() > 16) { + throw new IllegalArgumentException("player-template must generate names no longer than 16 characters"); + } + } + + private static String routeHost(String template, int routeIndex) { + try { + return String.format(Locale.ROOT, template, routeIndex); + } catch (RuntimeException exception) { + throw new IllegalArgumentException("virtual-host-template must be a valid integer String.format template", exception); + } + } + + private static String playerName(String template, int playerIndex) { + try { + return String.format(Locale.ROOT, template, playerIndex); + } catch (RuntimeException exception) { + throw new IllegalArgumentException("player-template must be a valid integer String.format template", exception); + } + } + + private static boolean isSocketAlive(Socket socket, int probeTimeoutMillis) { + try { + socket.setSoTimeout(probeTimeoutMillis); + return socket.getInputStream().read() != -1; + } catch (SocketTimeoutException exception) { + return true; + } catch (IOException exception) { + return false; + } + } + + private static void handleSlowSinkSocket( + Socket socket, + int readChunkBytes, + long readDelayMillis, + int readTimeoutMillis, + Instant deadline, + java.util.concurrent.atomic.LongAdder bytesRead, + AtomicInteger closed, + AtomicInteger failed) { + try (socket) { + socket.setSoTimeout(readTimeoutMillis); + if (readChunkBytes == 0) { + while (Instant.now().isBefore(deadline)) { + TimeUnit.MILLISECONDS.sleep(Math.max(1, readDelayMillis)); + } + closed.incrementAndGet(); + return; + } + var input = socket.getInputStream(); + var buffer = new byte[readChunkBytes]; + while (Instant.now().isBefore(deadline)) { + int read; + try { + read = input.read(buffer); + } catch (SocketTimeoutException exception) { + continue; + } + if (read < 0) { + closed.incrementAndGet(); + return; + } + bytesRead.add(read); + if (readDelayMillis > 0) { + TimeUnit.MILLISECONDS.sleep(readDelayMillis); + } + } + closed.incrementAndGet(); + } catch (IOException exception) { + failed.incrementAndGet(); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + failed.incrementAndGet(); + } + } + + private static SuiteStepResult runNested(StrataProxyQueryCli root, String... args) { + var nested = new ArrayList(); + Collections.addAll(nested, + "--host", root.host, + "--port", Integer.toString(root.port), + "--timeout-ms", Integer.toString(root.timeoutMillis)); + Collections.addAll(nested, args); + var originalOut = System.out; + var output = new ByteArrayOutputStream(); + try { + System.setOut(new PrintStream(output, true, StandardCharsets.UTF_8)); + var exitCode = new CommandLine(new StrataProxyQueryCli()).execute(nested.toArray(String[]::new)); + return new SuiteStepResult(args[0], exitCode, output.toString(StandardCharsets.UTF_8).trim()); + } finally { + System.setOut(originalOut); + } + } + + private static void close(Socket socket) { + try { + socket.close(); + } catch (IOException exception) { + // Best effort cleanup after the probe result has already been recorded. + } + } + + private static void printJson(String command, boolean passed, Object... fields) { + var body = new StringBuilder(256); + body.append('{'); + appendJsonField(body, "command", command); + body.append(','); + appendJsonField(body, "passed", passed); + for (var i = 0; i < fields.length; i += 2) { + body.append(','); + appendJsonField(body, (String) fields[i], fields[i + 1]); + } + body.append('}'); + System.out.println(body); + } + + private static void appendJsonField(StringBuilder body, String name, Object value) { + body.append('"').append(jsonEscape(name)).append("\":"); + if (value instanceof Number number) { + body.append(formatNumber(number)); + } else if (value instanceof Boolean bool) { + body.append(bool); + } else if (value instanceof RawJson rawJson) { + body.append(rawJson.value()); + } else { + body.append('"').append(jsonEscape(String.valueOf(value))).append('"'); + } + } + + private static RawJson suiteStepsJson(List results) { + var body = new StringBuilder(); + body.append('['); + for (var i = 0; i < results.size(); i++) { + if (i > 0) { + body.append(','); + } + var result = results.get(i); + body.append('{'); + appendJsonField(body, "name", result.name()); + body.append(','); + appendJsonField(body, "exitCode", result.exitCode()); + body.append(','); + appendJsonField(body, "passed", result.passed()); + body.append(','); + appendJsonField(body, "output", result.output()); + body.append('}'); + } + body.append(']'); + return new RawJson(body.toString()); + } + + private static String formatNumber(Number value) { + if (value instanceof Float || value instanceof Double) { + return String.format(Locale.ROOT, "%.6f", value.doubleValue()); + } + return value.toString(); + } + + private static String jsonEscape(String value) { + return value.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n").replace("\r", "\\r"); + } + + private record LatencySummary( + int samples, + double p50Millis, + double p95Millis, + double p99Millis, + double maxMillis) { + private static LatencySummary from(List nanos) { + if (nanos.isEmpty()) { + return new LatencySummary(0, 0.0d, 0.0d, 0.0d, 0.0d); + } + var sorted = new ArrayList(nanos); + Collections.sort(sorted); + return new LatencySummary( + sorted.size(), + millis(percentile(sorted, 0.50d)), + millis(percentile(sorted, 0.95d)), + millis(percentile(sorted, 0.99d)), + millis(sorted.get(sorted.size() - 1))); + } + + private static long percentile(List sorted, double percentile) { + var index = (int) Math.ceil(percentile * sorted.size()) - 1; + return sorted.get(Math.max(0, Math.min(sorted.size() - 1, index))); + } + + private static double millis(long nanos) { + return nanos / 1_000_000.0d; + } + } + + private record SuiteStepResult(String name, int exitCode, String output) { + private boolean passed() { + return exitCode == 0; + } + } + + private record RawJson(String value) { + } +} diff --git a/proxy-query/src/test/java/dev/strataproxy/query/MinecraftQueryProtocolTest.java b/proxy-query/src/test/java/dev/strataproxy/query/MinecraftQueryProtocolTest.java new file mode 100644 index 0000000..f29b232 --- /dev/null +++ b/proxy-query/src/test/java/dev/strataproxy/query/MinecraftQueryProtocolTest.java @@ -0,0 +1,130 @@ +package dev.strataproxy.query; + +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import java.util.zip.Inflater; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +final class MinecraftQueryProtocolTest { + @Test + void encodesStatusHandshakeAndRequest() { + var handshake = MinecraftQueryProtocol.statusHandshake(763, "play.example.net", 25565); + var request = MinecraftQueryProtocol.statusRequest(); + + assertTrue(handshake.length > 0); + assertEquals(2, request.length); + assertEquals(1, request[0]); + assertEquals(0, request[1]); + } + + @Test + void encodesLoginHandshakeWithLoginNextState() throws Exception { + var handshake = MinecraftQueryProtocol.loginHandshake(763, "play.example.net", 25565); + var input = new java.io.DataInputStream(new ByteArrayInputStream(handshake)); + + var frame = input.readNBytes(readVarInt(input)); + var cursor = new java.io.DataInputStream(new ByteArrayInputStream(frame)); + + assertEquals(0, readVarInt(cursor)); + assertEquals(763, readVarInt(cursor)); + readString(cursor); + cursor.readUnsignedShort(); + assertEquals(2, readVarInt(cursor)); + } + + @Test + void encodesRawPacketFrame() throws Exception { + var packet = MinecraftQueryProtocol.rawPacketFrame(7, 5); + var input = new java.io.DataInputStream(new ByteArrayInputStream(packet)); + var frame = input.readNBytes(readVarInt(input)); + var cursor = new java.io.DataInputStream(new ByteArrayInputStream(frame)); + + assertEquals(7, readVarInt(cursor)); + assertEquals(5, cursor.readNBytes(8).length); + } + + @Test + void encodesCompressedPacketFrame() throws Exception { + var packet = MinecraftQueryProtocol.compressedPacketFrame(7, 512, 64); + var input = new java.io.DataInputStream(new ByteArrayInputStream(packet)); + var frame = input.readNBytes(readVarInt(input)); + var cursor = new java.io.DataInputStream(new ByteArrayInputStream(frame)); + + assertEquals(513, readVarInt(cursor)); + var compressed = cursor.readAllBytes(); + var inflated = inflate(compressed); + var inflatedInput = new java.io.DataInputStream(new ByteArrayInputStream(inflated)); + assertEquals(7, readVarInt(inflatedInput)); + assertEquals(512, inflatedInput.readNBytes(1024).length); + } + + @Test + void readsCompressionThresholdFrame() throws Exception { + var payload = new ByteArrayOutputStream(); + MinecraftQueryProtocol.writeVarInt(payload, 3); + MinecraftQueryProtocol.writeVarInt(payload, 256); + var frame = new ByteArrayOutputStream(); + MinecraftQueryProtocol.writeVarInt(frame, payload.size()); + frame.writeBytes(payload.toByteArray()); + + assertEquals(256, MinecraftQueryProtocol.readCompressionThreshold( + new ByteArrayInputStream(frame.toByteArray()), + 1024)); + } + + @Test + void decodesStatusResponseJson() throws Exception { + var json = "{\"version\":{\"name\":\"test\",\"protocol\":763}}"; + var payload = new ByteArrayOutputStream(); + MinecraftQueryProtocol.writeVarInt(payload, 0); + var jsonBytes = json.getBytes(StandardCharsets.UTF_8); + MinecraftQueryProtocol.writeVarInt(payload, jsonBytes.length); + payload.writeBytes(jsonBytes); + + var frame = new ByteArrayOutputStream(); + MinecraftQueryProtocol.writeVarInt(frame, payload.size()); + frame.writeBytes(payload.toByteArray()); + + assertEquals(json, MinecraftQueryProtocol.readStatusResponse(new ByteArrayInputStream(frame.toByteArray()))); + } + + private static String readString(java.io.DataInputStream input) throws Exception { + var bytes = input.readNBytes(readVarInt(input)); + return new String(bytes, StandardCharsets.UTF_8); + } + + private static int readVarInt(java.io.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 static byte[] inflate(byte[] input) throws Exception { + var inflater = new Inflater(); + try { + inflater.setInput(input); + var output = new ByteArrayOutputStream(); + var buffer = new byte[1024]; + while (!inflater.finished()) { + var bytes = inflater.inflate(buffer); + output.write(buffer, 0, bytes); + } + return output.toByteArray(); + } finally { + inflater.end(); + } + } +} diff --git a/proxy-query/src/test/java/dev/strataproxy/query/StrataProxyQueryCliTest.java b/proxy-query/src/test/java/dev/strataproxy/query/StrataProxyQueryCliTest.java new file mode 100644 index 0000000..88e9d1b --- /dev/null +++ b/proxy-query/src/test/java/dev/strataproxy/query/StrataProxyQueryCliTest.java @@ -0,0 +1,1139 @@ +package dev.strataproxy.query; + +import org.junit.jupiter.api.Test; +import picocli.CommandLine; + +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.PrintStream; +import java.net.ServerSocket; +import java.net.Socket; +import java.net.SocketException; +import java.util.concurrent.CountDownLatch; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.ConcurrentHashMap; +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 StrataProxyQueryCliTest { + @Test + void idleLoadReportsAliveConnections() throws Exception { + var accepted = new CountDownLatch(1); + var release = new CountDownLatch(1); + var executor = Executors.newSingleThreadExecutor(); + try (var server = new ServerSocket(0)) { + server.setSoTimeout(5_000); + var future = executor.submit(() -> { + try (var socket = server.accept()) { + accepted.countDown(); + release.await(5, TimeUnit.SECONDS); + } catch (Exception exception) { + throw new RuntimeException(exception); + } + }); + + var result = execute( + "--host", "127.0.0.1", + "--port", Integer.toString(server.getLocalPort()), + "--timeout-ms", "1000", + "idle-load", + "--connections", "1", + "--parallelism", "1", + "--settle-ms", "25", + "--probe-timeout-ms", "25", + "--hold-ms", "0"); + + release.countDown(); + assertTrue(accepted.await(5, TimeUnit.SECONDS)); + assertEquals(0, result.exitCode()); + assertTrue(result.output().contains("connected=1")); + assertTrue(result.output().contains("alive=1")); + assertTrue(result.output().contains("closed=0")); + assertTrue(result.output().contains("failed=0")); + future.get(5, TimeUnit.SECONDS); + } finally { + release.countDown(); + executor.shutdownNow(); + } + } + + @Test + void idleLoadReportsClosedConnectionsAndCanFailOnClosed() throws Exception { + var executor = Executors.newSingleThreadExecutor(); + try (var server = new ServerSocket(0)) { + server.setSoTimeout(5_000); + var future = executor.submit(() -> { + try (var socket = server.accept()) { + socket.close(); + } catch (Exception exception) { + throw new RuntimeException(exception); + } + }); + + var result = execute( + "--host", "127.0.0.1", + "--port", Integer.toString(server.getLocalPort()), + "--timeout-ms", "1000", + "idle-load", + "--connections", "1", + "--parallelism", "1", + "--settle-ms", "25", + "--probe-timeout-ms", "25", + "--hold-ms", "0", + "--fail-on-closed"); + + assertEquals(1, result.exitCode()); + assertTrue(result.output().contains("connected=1")); + assertTrue(result.output().contains("alive=0")); + assertTrue(result.output().contains("closed=1")); + assertTrue(result.output().contains("failed=0")); + future.get(5, TimeUnit.SECONDS); + } finally { + executor.shutdownNow(); + } + } + + @Test + void idleLoadFailsWhenAcceptanceThresholdsAreNotMet() throws Exception { + var accepted = new CountDownLatch(1); + var release = new CountDownLatch(1); + var executor = Executors.newSingleThreadExecutor(); + try (var server = new ServerSocket(0)) { + server.setSoTimeout(5_000); + var future = executor.submit(() -> { + try (var socket = server.accept()) { + accepted.countDown(); + release.await(5, TimeUnit.SECONDS); + } catch (Exception exception) { + throw new RuntimeException(exception); + } + }); + + var result = execute( + "--host", "127.0.0.1", + "--port", Integer.toString(server.getLocalPort()), + "--timeout-ms", "1000", + "idle-load", + "--connections", "1", + "--parallelism", "1", + "--settle-ms", "25", + "--probe-timeout-ms", "25", + "--hold-ms", "0", + "--min-connected", "2"); + + release.countDown(); + assertTrue(accepted.await(5, TimeUnit.SECONDS)); + assertEquals(1, result.exitCode()); + assertTrue(result.output().contains("connected=1")); + future.get(5, TimeUnit.SECONDS); + } finally { + release.countDown(); + executor.shutdownNow(); + } + } + + @Test + void idleLoadCanPrintJsonResult() throws Exception { + var accepted = new CountDownLatch(1); + var release = new CountDownLatch(1); + var executor = Executors.newSingleThreadExecutor(); + try (var server = new ServerSocket(0)) { + server.setSoTimeout(5_000); + var future = executor.submit(() -> { + try (var socket = server.accept()) { + accepted.countDown(); + release.await(5, TimeUnit.SECONDS); + } catch (Exception exception) { + throw new RuntimeException(exception); + } + }); + + var result = execute( + "--host", "127.0.0.1", + "--port", Integer.toString(server.getLocalPort()), + "--timeout-ms", "1000", + "idle-load", + "--connections", "1", + "--parallelism", "1", + "--settle-ms", "25", + "--probe-timeout-ms", "25", + "--hold-ms", "0", + "--json"); + + release.countDown(); + assertTrue(accepted.await(5, TimeUnit.SECONDS)); + assertEquals(0, result.exitCode()); + assertTrue(result.output().startsWith("{\"command\":\"idle-load\",\"passed\":true")); + assertTrue(result.output().contains("\"connected\":1")); + assertTrue(result.output().contains("\"alive\":1")); + assertTrue(result.output().contains("\"connectRatePerSecond\":")); + future.get(5, TimeUnit.SECONDS); + } finally { + release.countDown(); + executor.shutdownNow(); + } + } + + @Test + void handshakeLoadSendsLoginHandshakesAndReportsAliveConnections() throws Exception { + var accepted = new CountDownLatch(1); + var release = new CountDownLatch(1); + var executor = Executors.newSingleThreadExecutor(); + try (var server = new ServerSocket(0)) { + server.setSoTimeout(5_000); + var future = executor.submit(() -> { + try (var socket = server.accept()) { + socket.setSoTimeout(5_000); + var input = new DataInputStream(socket.getInputStream()); + var frame = input.readNBytes(readVarInt(input)); + var frameInput = new DataInputStream(new java.io.ByteArrayInputStream(frame)); + assertEquals(0, readVarInt(frameInput)); + assertEquals(763, readVarInt(frameInput)); + readString(frameInput); + frameInput.readUnsignedShort(); + assertEquals(2, readVarInt(frameInput)); + accepted.countDown(); + release.await(5, TimeUnit.SECONDS); + } catch (Exception exception) { + throw new RuntimeException(exception); + } + }); + + var result = execute( + "--host", "127.0.0.1", + "--port", Integer.toString(server.getLocalPort()), + "--timeout-ms", "1000", + "handshake-load", + "--connections", "1", + "--parallelism", "1", + "--settle-ms", "25", + "--probe-timeout-ms", "25", + "--hold-ms", "0", + "--virtual-host", "play.example.net"); + + release.countDown(); + assertTrue(accepted.await(5, TimeUnit.SECONDS)); + assertEquals(0, result.exitCode()); + assertTrue(result.output().contains("connected=1")); + assertTrue(result.output().contains("handshaken=1")); + assertTrue(result.output().contains("alive=1")); + assertTrue(result.output().contains("closed=0")); + assertTrue(result.output().contains("failed=0")); + future.get(5, TimeUnit.SECONDS); + } finally { + release.countDown(); + executor.shutdownNow(); + } + } + + @Test + void handshakeLoadFailsWhenAcceptanceThresholdsAreNotMet() throws Exception { + var accepted = new CountDownLatch(1); + var release = new CountDownLatch(1); + var executor = Executors.newSingleThreadExecutor(); + try (var server = new ServerSocket(0)) { + server.setSoTimeout(5_000); + var future = executor.submit(() -> { + try (var socket = server.accept()) { + socket.setSoTimeout(5_000); + var input = new DataInputStream(socket.getInputStream()); + input.readNBytes(readVarInt(input)); + accepted.countDown(); + release.await(5, TimeUnit.SECONDS); + } catch (Exception exception) { + throw new RuntimeException(exception); + } + }); + + var result = execute( + "--host", "127.0.0.1", + "--port", Integer.toString(server.getLocalPort()), + "--timeout-ms", "1000", + "handshake-load", + "--connections", "1", + "--parallelism", "1", + "--settle-ms", "25", + "--probe-timeout-ms", "25", + "--hold-ms", "0", + "--virtual-host", "play.example.net", + "--min-handshaken", "2"); + + release.countDown(); + assertTrue(accepted.await(5, TimeUnit.SECONDS)); + assertEquals(1, result.exitCode()); + assertTrue(result.output().contains("handshaken=1")); + future.get(5, TimeUnit.SECONDS); + } finally { + release.countDown(); + executor.shutdownNow(); + } + } + + @Test + void handshakeLoadCanPrintJsonResult() throws Exception { + var accepted = new CountDownLatch(1); + var release = new CountDownLatch(1); + var executor = Executors.newSingleThreadExecutor(); + try (var server = new ServerSocket(0)) { + server.setSoTimeout(5_000); + var future = executor.submit(() -> { + try (var socket = server.accept()) { + socket.setSoTimeout(5_000); + var input = new DataInputStream(socket.getInputStream()); + input.readNBytes(readVarInt(input)); + accepted.countDown(); + release.await(5, TimeUnit.SECONDS); + } catch (Exception exception) { + throw new RuntimeException(exception); + } + }); + + var result = execute( + "--host", "127.0.0.1", + "--port", Integer.toString(server.getLocalPort()), + "--timeout-ms", "1000", + "handshake-load", + "--connections", "1", + "--parallelism", "1", + "--settle-ms", "25", + "--probe-timeout-ms", "25", + "--hold-ms", "0", + "--virtual-host", "play.example.net", + "--json"); + + release.countDown(); + assertTrue(accepted.await(5, TimeUnit.SECONDS)); + assertEquals(0, result.exitCode()); + assertTrue(result.output().startsWith("{\"command\":\"handshake-load\",\"passed\":true")); + assertTrue(result.output().contains("\"handshaken\":1")); + assertTrue(result.output().contains("\"alive\":1")); + assertTrue(result.output().contains("\"handshakeRatePerSecond\":")); + future.get(5, TimeUnit.SECONDS); + } finally { + release.countDown(); + executor.shutdownNow(); + } + } + + @Test + void routeStormCyclesVirtualHostsAndReportsDistinctRoutes() throws Exception { + var accepted = new CountDownLatch(3); + var release = new CountDownLatch(1); + var hosts = ConcurrentHashMap.newKeySet(); + var executor = Executors.newSingleThreadExecutor(); + try (var server = new ServerSocket(0)) { + server.setSoTimeout(5_000); + var future = executor.submit(() -> { + var sockets = new java.util.ArrayList(); + try { + for (var i = 0; i < 3; i++) { + var socket = server.accept(); + sockets.add(socket); + socket.setSoTimeout(5_000); + var input = new DataInputStream(socket.getInputStream()); + var frame = input.readNBytes(readVarInt(input)); + var frameInput = new DataInputStream(new java.io.ByteArrayInputStream(frame)); + assertEquals(0, readVarInt(frameInput)); + assertEquals(763, readVarInt(frameInput)); + hosts.add(readString(frameInput)); + frameInput.readUnsignedShort(); + assertEquals(2, readVarInt(frameInput)); + accepted.countDown(); + } + release.await(5, TimeUnit.SECONDS); + } catch (Exception exception) { + throw new RuntimeException(exception); + } finally { + for (var socket : sockets) { + try { + socket.close(); + } catch (Exception ignored) { + } + } + } + }); + + var result = execute( + "--host", "127.0.0.1", + "--port", Integer.toString(server.getLocalPort()), + "--timeout-ms", "1000", + "route-storm", + "--connections", "3", + "--routes", "3", + "--parallelism", "3", + "--settle-ms", "25", + "--probe-timeout-ms", "25", + "--hold-ms", "0", + "--virtual-host-template", "route-%d.example.net", + "--min-handshaken", "3", + "--min-routes-handshaken", "3"); + + release.countDown(); + assertTrue(accepted.await(5, TimeUnit.SECONDS)); + assertEquals(0, result.exitCode()); + assertTrue(hosts.contains("route-0.example.net"), hosts.toString()); + assertTrue(hosts.contains("route-1.example.net"), hosts.toString()); + assertTrue(hosts.contains("route-2.example.net"), hosts.toString()); + assertTrue(result.output().contains("handshaken=3")); + assertTrue(result.output().contains("routesConfigured=3")); + assertTrue(result.output().contains("routesAttempted=3")); + assertTrue(result.output().contains("routesHandshaken=3")); + assertTrue(result.output().contains("routeRatePerSecond=")); + future.get(5, TimeUnit.SECONDS); + } finally { + release.countDown(); + executor.shutdownNow(); + } + } + + @Test + void routeStormCanPrintJsonResult() throws Exception { + var accepted = new CountDownLatch(2); + var release = new CountDownLatch(1); + var executor = Executors.newSingleThreadExecutor(); + try (var server = new ServerSocket(0)) { + server.setSoTimeout(5_000); + var future = executor.submit(() -> { + var sockets = new java.util.ArrayList(); + try { + for (var i = 0; i < 2; i++) { + var socket = server.accept(); + sockets.add(socket); + socket.setSoTimeout(5_000); + var input = new DataInputStream(socket.getInputStream()); + input.readNBytes(readVarInt(input)); + accepted.countDown(); + } + release.await(5, TimeUnit.SECONDS); + } catch (Exception exception) { + throw new RuntimeException(exception); + } finally { + for (var socket : sockets) { + try { + socket.close(); + } catch (Exception ignored) { + } + } + } + }); + + var result = execute( + "--host", "127.0.0.1", + "--port", Integer.toString(server.getLocalPort()), + "--timeout-ms", "1000", + "route-storm", + "--connections", "2", + "--routes", "2", + "--parallelism", "2", + "--settle-ms", "25", + "--probe-timeout-ms", "25", + "--hold-ms", "0", + "--json"); + + release.countDown(); + assertTrue(accepted.await(5, TimeUnit.SECONDS)); + assertEquals(0, result.exitCode()); + assertTrue(result.output().startsWith("{\"command\":\"route-storm\",\"passed\":true")); + assertTrue(result.output().contains("\"routesAttempted\":2")); + assertTrue(result.output().contains("\"routesHandshaken\":2")); + assertTrue(result.output().contains("\"routeRatePerSecond\":")); + future.get(5, TimeUnit.SECONDS); + } finally { + release.countDown(); + executor.shutdownNow(); + } + } + + @Test + void routeStormFailsWhenRouteThresholdsAreNotMet() throws Exception { + var accepted = new CountDownLatch(1); + var release = new CountDownLatch(1); + var executor = Executors.newSingleThreadExecutor(); + try (var server = new ServerSocket(0)) { + server.setSoTimeout(5_000); + var future = executor.submit(() -> { + try (var socket = server.accept()) { + socket.setSoTimeout(5_000); + var input = new DataInputStream(socket.getInputStream()); + input.readNBytes(readVarInt(input)); + accepted.countDown(); + release.await(5, TimeUnit.SECONDS); + } catch (Exception exception) { + throw new RuntimeException(exception); + } + }); + + var result = execute( + "--host", "127.0.0.1", + "--port", Integer.toString(server.getLocalPort()), + "--timeout-ms", "1000", + "route-storm", + "--connections", "1", + "--routes", "1", + "--parallelism", "1", + "--settle-ms", "25", + "--probe-timeout-ms", "25", + "--hold-ms", "0", + "--min-routes-handshaken", "2"); + + release.countDown(); + assertTrue(accepted.await(5, TimeUnit.SECONDS)); + assertEquals(1, result.exitCode()); + assertTrue(result.output().contains("routesHandshaken=1")); + future.get(5, TimeUnit.SECONDS); + } finally { + release.countDown(); + executor.shutdownNow(); + } + } + + @Test + void trafficLoadSendsHandshakeAndPacketFrames() throws Exception { + var accepted = new CountDownLatch(1); + var release = new CountDownLatch(1); + var executor = Executors.newSingleThreadExecutor(); + try (var server = new ServerSocket(0)) { + server.setSoTimeout(5_000); + var future = executor.submit(() -> { + try (var socket = server.accept()) { + socket.setSoTimeout(5_000); + var input = new DataInputStream(socket.getInputStream()); + var handshake = input.readNBytes(readVarInt(input)); + var handshakeInput = new DataInputStream(new java.io.ByteArrayInputStream(handshake)); + assertEquals(0, readVarInt(handshakeInput)); + readVarInt(handshakeInput); + readString(handshakeInput); + handshakeInput.readUnsignedShort(); + assertEquals(2, readVarInt(handshakeInput)); + + for (var i = 0; i < 2; i++) { + var packet = input.readNBytes(readVarInt(input)); + var packetInput = new DataInputStream(new java.io.ByteArrayInputStream(packet)); + assertEquals(7, readVarInt(packetInput)); + assertEquals(5, packetInput.readNBytes(8).length); + } + accepted.countDown(); + release.await(5, TimeUnit.SECONDS); + } catch (Exception exception) { + throw new RuntimeException(exception); + } + }); + + var result = execute( + "--host", "127.0.0.1", + "--port", Integer.toString(server.getLocalPort()), + "--timeout-ms", "1000", + "traffic-load", + "--connections", "1", + "--parallelism", "1", + "--packets-per-connection", "2", + "--payload-bytes", "5", + "--packet-id", "7", + "--hold-ms", "0", + "--virtual-host", "play.example.net"); + + release.countDown(); + assertTrue(accepted.await(5, TimeUnit.SECONDS)); + assertEquals(0, result.exitCode()); + assertTrue(result.output().contains("connected=1")); + assertTrue(result.output().contains("handshaken=1")); + assertTrue(result.output().contains("packetsSent=2")); + assertTrue(result.output().contains("bytesSent=14")); + assertTrue(result.output().contains("failed=0")); + future.get(5, TimeUnit.SECONDS); + } finally { + release.countDown(); + executor.shutdownNow(); + } + } + + @Test + void trafficLoadCanSendLoginStartPlayerNamesBeforeTraffic() throws Exception { + var accepted = new CountDownLatch(1); + var release = new CountDownLatch(1); + var executor = Executors.newSingleThreadExecutor(); + try (var server = new ServerSocket(0)) { + server.setSoTimeout(5_000); + var future = executor.submit(() -> { + try (var socket = server.accept()) { + socket.setSoTimeout(5_000); + var input = new DataInputStream(socket.getInputStream()); + input.readNBytes(readVarInt(input)); + + var loginStart = input.readNBytes(readVarInt(input)); + var loginStartInput = new DataInputStream(new java.io.ByteArrayInputStream(loginStart)); + assertEquals(0, readVarInt(loginStartInput)); + assertEquals("player-0", readString(loginStartInput)); + + var packet = input.readNBytes(readVarInt(input)); + var packetInput = new DataInputStream(new java.io.ByteArrayInputStream(packet)); + assertEquals(7, readVarInt(packetInput)); + assertEquals(5, packetInput.readNBytes(8).length); + accepted.countDown(); + release.await(5, TimeUnit.SECONDS); + } catch (Exception exception) { + throw new RuntimeException(exception); + } + }); + + var result = execute( + "--host", "127.0.0.1", + "--port", Integer.toString(server.getLocalPort()), + "--timeout-ms", "1000", + "traffic-load", + "--connections", "1", + "--parallelism", "1", + "--login-start", + "--player-template", "player-%d", + "--packets-per-connection", "1", + "--payload-bytes", "5", + "--packet-id", "7", + "--hold-ms", "0", + "--virtual-host", "play.example.net"); + + release.countDown(); + assertTrue(accepted.await(5, TimeUnit.SECONDS)); + assertEquals(0, result.exitCode()); + assertTrue(result.output().contains("loginStartsSent=1")); + assertTrue(result.output().contains("loginStartBytesSent=11")); + assertTrue(result.output().contains("packetsSent=1")); + future.get(5, TimeUnit.SECONDS); + } finally { + release.countDown(); + executor.shutdownNow(); + } + } + + @Test + void trafficLoadCanPrintJsonResult() throws Exception { + var accepted = new CountDownLatch(1); + var release = new CountDownLatch(1); + var executor = Executors.newSingleThreadExecutor(); + try (var server = new ServerSocket(0)) { + server.setSoTimeout(5_000); + var future = executor.submit(() -> { + try (var socket = server.accept()) { + socket.setSoTimeout(5_000); + var input = new DataInputStream(socket.getInputStream()); + input.readNBytes(readVarInt(input)); + input.readNBytes(readVarInt(input)); + accepted.countDown(); + release.await(5, TimeUnit.SECONDS); + } catch (Exception exception) { + throw new RuntimeException(exception); + } + }); + + var result = execute( + "--host", "127.0.0.1", + "--port", Integer.toString(server.getLocalPort()), + "--timeout-ms", "1000", + "traffic-load", + "--connections", "1", + "--parallelism", "1", + "--packets-per-connection", "1", + "--payload-bytes", "5", + "--packet-id", "7", + "--hold-ms", "0", + "--virtual-host", "play.example.net", + "--json"); + + release.countDown(); + assertTrue(accepted.await(5, TimeUnit.SECONDS)); + assertEquals(0, result.exitCode()); + assertTrue(result.output().startsWith("{\"command\":\"traffic-load\",\"passed\":true")); + assertTrue(result.output().contains("\"loginStartsSent\":0")); + assertTrue(result.output().contains("\"loginStartBytesSent\":0")); + assertTrue(result.output().contains("\"packetsSent\":1")); + assertTrue(result.output().contains("\"bytesSent\":7")); + assertTrue(result.output().contains("\"packetRatePerSecond\":")); + future.get(5, TimeUnit.SECONDS); + } finally { + release.countDown(); + executor.shutdownNow(); + } + } + + @Test + void compressionRewriteLoadNegotiatesCompressionAndSendsSplitFrames() throws Exception { + var accepted = new CountDownLatch(1); + var release = new CountDownLatch(1); + var executor = Executors.newSingleThreadExecutor(); + try (var server = new ServerSocket(0)) { + server.setSoTimeout(5_000); + var future = executor.submit(() -> { + try (var socket = server.accept()) { + socket.setSoTimeout(5_000); + var input = new DataInputStream(socket.getInputStream()); + input.readNBytes(readVarInt(input)); + + var loginStart = input.readNBytes(readVarInt(input)); + var loginStartInput = new DataInputStream(new java.io.ByteArrayInputStream(loginStart)); + assertEquals(0, readVarInt(loginStartInput)); + assertEquals("rewrite00000", readString(loginStartInput)); + + socket.getOutputStream().write(setCompression(64)); + socket.getOutputStream().flush(); + + for (var i = 0; i < 2; i++) { + var packet = input.readNBytes(readVarInt(input)); + var packetInput = new DataInputStream(new java.io.ByteArrayInputStream(packet)); + assertTrue(readVarInt(packetInput) > 64); + assertTrue(packetInput.readAllBytes().length > 0); + } + accepted.countDown(); + release.await(5, TimeUnit.SECONDS); + } catch (Exception exception) { + throw new RuntimeException(exception); + } + }); + + var result = execute( + "--host", "127.0.0.1", + "--port", Integer.toString(server.getLocalPort()), + "--timeout-ms", "1000", + "compression-rewrite-load", + "--connections", "1", + "--parallelism", "1", + "--packets-per-connection", "2", + "--payload-bytes", "512", + "--packet-id", "7", + "--threshold", "64", + "--split-frames", + "--hold-ms", "0", + "--virtual-host", "play.example.net", + "--min-handshaken", "1", + "--min-negotiated", "1", + "--min-packets-sent", "2", + "--max-failed", "0"); + + release.countDown(); + assertTrue(accepted.await(5, TimeUnit.SECONDS)); + assertEquals(0, result.exitCode()); + assertTrue(result.output().contains("connected=1")); + assertTrue(result.output().contains("handshaken=1")); + assertTrue(result.output().contains("negotiated=1")); + assertTrue(result.output().contains("packetsSent=2")); + assertTrue(result.output().contains("failed=0")); + assertTrue(result.output().contains("splitFrames=true")); + future.get(5, TimeUnit.SECONDS); + } finally { + release.countDown(); + executor.shutdownNow(); + } + } + + @Test + void compressionRewriteLoadCanPrintJsonResult() throws Exception { + var accepted = new CountDownLatch(1); + var release = new CountDownLatch(1); + var executor = Executors.newSingleThreadExecutor(); + try (var server = new ServerSocket(0)) { + server.setSoTimeout(5_000); + var future = executor.submit(() -> { + try (var socket = server.accept()) { + socket.setSoTimeout(5_000); + var input = new DataInputStream(socket.getInputStream()); + input.readNBytes(readVarInt(input)); + input.readNBytes(readVarInt(input)); + socket.getOutputStream().write(setCompression(64)); + socket.getOutputStream().flush(); + input.readNBytes(readVarInt(input)); + accepted.countDown(); + release.await(5, TimeUnit.SECONDS); + } catch (Exception exception) { + throw new RuntimeException(exception); + } + }); + + var result = execute( + "--host", "127.0.0.1", + "--port", Integer.toString(server.getLocalPort()), + "--timeout-ms", "1000", + "compression-rewrite-load", + "--connections", "1", + "--parallelism", "1", + "--packets-per-connection", "1", + "--payload-bytes", "512", + "--threshold", "64", + "--hold-ms", "0", + "--json"); + + release.countDown(); + assertTrue(accepted.await(5, TimeUnit.SECONDS)); + assertEquals(0, result.exitCode()); + assertTrue(result.output().startsWith("{\"command\":\"compression-rewrite-load\",\"passed\":true")); + assertTrue(result.output().contains("\"negotiated\":1")); + assertTrue(result.output().contains("\"packetsSent\":1")); + assertTrue(result.output().contains("\"splitFrames\":false")); + future.get(5, TimeUnit.SECONDS); + } finally { + release.countDown(); + executor.shutdownNow(); + } + } + + @Test + void loadSuiteRunsIdleAndTrafficProbes() throws Exception { + var trafficObserved = new CountDownLatch(1); + var executor = Executors.newSingleThreadExecutor(); + try (var server = new ServerSocket(0)) { + server.setSoTimeout(5_000); + var future = executor.submit(() -> { + try (var idleSocket = server.accept()) { + idleSocket.setSoTimeout(5_000); + while (idleSocket.getInputStream().read() >= 0) { + // Wait for idle-load to finish probing and close the socket. + } + } catch (Exception exception) { + throw new RuntimeException(exception); + } + try (var trafficSocket = server.accept()) { + trafficSocket.setSoTimeout(5_000); + var input = new DataInputStream(trafficSocket.getInputStream()); + input.readNBytes(readVarInt(input)); + input.readNBytes(readVarInt(input)); + input.readNBytes(readVarInt(input)); + trafficObserved.countDown(); + } catch (Exception exception) { + throw new RuntimeException(exception); + } + }); + + var result = execute( + "--host", "127.0.0.1", + "--port", Integer.toString(server.getLocalPort()), + "--timeout-ms", "1000", + "load-suite", + "--profile", "smoke", + "--idle-connections", "1", + "--active-connections", "1", + "--traffic-packets-per-connection", "1", + "--parallelism", "1", + "--payload-bytes", "5", + "--hold-ms", "0", + "--settle-ms", "25", + "--probe-timeout-ms", "25", + "--virtual-host", "play.example.net"); + + assertTrue(trafficObserved.await(5, TimeUnit.SECONDS)); + assertEquals(0, result.exitCode()); + assertTrue(result.output().contains("profile=smoke")); + assertTrue(result.output().contains("idleConnections=1")); + assertTrue(result.output().contains("activeConnections=1")); + assertTrue(result.output().contains("step=idle-load exitCode=0")); + assertTrue(result.output().contains("step=traffic-load exitCode=0")); + future.get(5, TimeUnit.SECONDS); + } finally { + executor.shutdownNow(); + } + } + + @Test + void loadSuiteCanPrintJsonResult() throws Exception { + var trafficObserved = new CountDownLatch(1); + var executor = Executors.newSingleThreadExecutor(); + try (var server = new ServerSocket(0)) { + server.setSoTimeout(5_000); + var future = executor.submit(() -> { + try (var idleSocket = server.accept()) { + idleSocket.setSoTimeout(5_000); + while (idleSocket.getInputStream().read() >= 0) { + // Wait for idle-load to finish probing and close the socket. + } + } catch (Exception exception) { + throw new RuntimeException(exception); + } + try (var trafficSocket = server.accept()) { + trafficSocket.setSoTimeout(5_000); + var input = new DataInputStream(trafficSocket.getInputStream()); + input.readNBytes(readVarInt(input)); + input.readNBytes(readVarInt(input)); + input.readNBytes(readVarInt(input)); + trafficObserved.countDown(); + } catch (Exception exception) { + throw new RuntimeException(exception); + } + }); + + var result = execute( + "--host", "127.0.0.1", + "--port", Integer.toString(server.getLocalPort()), + "--timeout-ms", "1000", + "load-suite", + "--profile", "smoke", + "--idle-connections", "1", + "--active-connections", "1", + "--traffic-packets-per-connection", "1", + "--parallelism", "1", + "--payload-bytes", "5", + "--hold-ms", "0", + "--settle-ms", "25", + "--probe-timeout-ms", "25", + "--json"); + + assertTrue(trafficObserved.await(5, TimeUnit.SECONDS)); + assertEquals(0, result.exitCode()); + assertTrue(result.output().startsWith("{\"command\":\"load-suite\",\"passed\":true")); + assertTrue(result.output().contains("\"profile\":\"smoke\"")); + assertTrue(result.output().contains("\"steps\":[")); + assertTrue(result.output().contains("\"name\":\"idle-load\"")); + assertTrue(result.output().contains("\"name\":\"traffic-load\"")); + future.get(5, TimeUnit.SECONDS); + } finally { + executor.shutdownNow(); + } + } + + @Test + void trafficLoadFailsWhenAcceptanceThresholdsAreNotMet() throws Exception { + var accepted = new CountDownLatch(1); + var release = new CountDownLatch(1); + var executor = Executors.newSingleThreadExecutor(); + try (var server = new ServerSocket(0)) { + server.setSoTimeout(5_000); + var future = executor.submit(() -> { + try (var socket = server.accept()) { + socket.setSoTimeout(5_000); + var input = new DataInputStream(socket.getInputStream()); + input.readNBytes(readVarInt(input)); + input.readNBytes(readVarInt(input)); + accepted.countDown(); + release.await(5, TimeUnit.SECONDS); + } catch (Exception exception) { + throw new RuntimeException(exception); + } + }); + + var result = execute( + "--host", "127.0.0.1", + "--port", Integer.toString(server.getLocalPort()), + "--timeout-ms", "1000", + "traffic-load", + "--connections", "1", + "--parallelism", "1", + "--packets-per-connection", "1", + "--payload-bytes", "5", + "--packet-id", "7", + "--hold-ms", "0", + "--virtual-host", "play.example.net", + "--min-packets-sent", "2"); + + release.countDown(); + assertTrue(accepted.await(5, TimeUnit.SECONDS)); + assertEquals(1, result.exitCode()); + assertTrue(result.output().contains("packetsSent=1")); + future.get(5, TimeUnit.SECONDS); + } finally { + release.countDown(); + executor.shutdownNow(); + } + } + + @Test + void trafficLoadMeasuresEchoLatency() throws Exception { + var executor = Executors.newSingleThreadExecutor(); + try (var server = new ServerSocket(0)) { + server.setSoTimeout(5_000); + var future = executor.submit(() -> { + try (var socket = server.accept()) { + socket.setSoTimeout(5_000); + var input = new DataInputStream(socket.getInputStream()); + input.readNBytes(readVarInt(input)); + for (var i = 0; i < 2; i++) { + var packet = readFrameBytes(input); + socket.getOutputStream().write(packet); + socket.getOutputStream().flush(); + } + } catch (Exception exception) { + throw new RuntimeException(exception); + } + }); + + var result = execute( + "--host", "127.0.0.1", + "--port", Integer.toString(server.getLocalPort()), + "--timeout-ms", "1000", + "traffic-load", + "--connections", "1", + "--parallelism", "1", + "--packets-per-connection", "2", + "--payload-bytes", "5", + "--packet-id", "7", + "--hold-ms", "0", + "--virtual-host", "play.example.net", + "--measure-echo-latency", + "--max-p99-latency-ms", "1000"); + + assertEquals(0, result.exitCode()); + assertTrue(result.output().contains("packetsSent=2")); + assertTrue(result.output().contains("latencySamples=2")); + assertTrue(result.output().contains("p99LatencyMillis=")); + future.get(5, TimeUnit.SECONDS); + } finally { + executor.shutdownNow(); + } + } + + @Test + void slowSinkAcceptsConnectionsAndReportsReadCounters() throws Exception { + var port = freePort(); + var executor = Executors.newSingleThreadExecutor(); + try { + var future = executor.submit(() -> execute( + "slow-sink", + "--bind-host", "127.0.0.1", + "--port", Integer.toString(port), + "--duration-ms", "250", + "--accept-timeout-ms", "25", + "--read-chunk-bytes", "2", + "--read-delay-ms", "0")); + + try (var socket = connectWithRetry(port)) { + socket.getOutputStream().write(new byte[] {1, 2, 3, 4, 5, 6}); + socket.getOutputStream().flush(); + } + + var result = future.get(5, TimeUnit.SECONDS); + assertEquals(0, result.exitCode()); + assertTrue(result.output().contains("listening=127.0.0.1:" + port)); + assertTrue(result.output().contains("readChunkBytes=2")); + assertTrue(result.output().contains("accepted=1")); + assertTrue(result.output().contains("failed=0")); + assertTrue(result.output().contains("bytesRead=6")); + } finally { + executor.shutdownNow(); + } + } + + @Test + void statusCommandPrintsMinecraftStatusJson() throws Exception { + var status = "{\"players\":{\"online\":0,\"max\":100}}"; + var executor = Executors.newSingleThreadExecutor(); + try (var server = new ServerSocket(0)) { + server.setSoTimeout(5_000); + var future = executor.submit(() -> { + try (var socket = server.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 result = execute( + "--host", "127.0.0.1", + "--port", Integer.toString(server.getLocalPort()), + "status", + "--virtual-host", "play.example.net"); + + assertEquals(0, result.exitCode()); + assertTrue(result.output().contains(status)); + future.get(5, TimeUnit.SECONDS); + } finally { + executor.shutdownNow(); + } + } + + 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 StrataProxyQueryCli()).execute(args); + return new Result(exitCode, output.toString(StandardCharsets.UTF_8)); + } finally { + System.setOut(originalOut); + } + } + + private static int freePort() throws Exception { + try (var socket = new ServerSocket(0)) { + return socket.getLocalPort(); + } + } + + private static Socket connectWithRetry(int port) throws Exception { + var deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(3); + SocketException last = null; + while (System.nanoTime() < deadline) { + try { + return new Socket("127.0.0.1", port); + } catch (SocketException exception) { + last = exception; + TimeUnit.MILLISECONDS.sleep(10); + } + } + throw last == null ? new SocketException("could not connect to slow sink") : last; + } + + private static byte[] statusResponse(String json) { + var payload = new ByteArrayOutputStream(); + MinecraftQueryProtocol.writeVarInt(payload, 0); + var jsonBytes = json.getBytes(StandardCharsets.UTF_8); + MinecraftQueryProtocol.writeVarInt(payload, jsonBytes.length); + payload.writeBytes(jsonBytes); + + var frame = new ByteArrayOutputStream(); + MinecraftQueryProtocol.writeVarInt(frame, payload.size()); + frame.writeBytes(payload.toByteArray()); + return frame.toByteArray(); + } + + private static byte[] setCompression(int threshold) { + var payload = new ByteArrayOutputStream(); + MinecraftQueryProtocol.writeVarInt(payload, 3); + MinecraftQueryProtocol.writeVarInt(payload, threshold); + + var frame = new ByteArrayOutputStream(); + MinecraftQueryProtocol.writeVarInt(frame, payload.size()); + frame.writeBytes(payload.toByteArray()); + return frame.toByteArray(); + } + + 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 static byte[] readFrameBytes(DataInputStream input) throws Exception { + var frame = new ByteArrayOutputStream(); + var value = 0; + var position = 0; + while (position < 5) { + var current = input.readUnsignedByte(); + frame.write(current); + value |= (current & 0x7F) << (position * 7); + position++; + if ((current & 0x80) == 0) { + frame.writeBytes(input.readNBytes(value)); + return frame.toByteArray(); + } + } + throw new IllegalArgumentException("malformed VarInt"); + } + + private static String readString(DataInputStream input) throws Exception { + var bytes = input.readNBytes(readVarInt(input)); + return new String(bytes, StandardCharsets.UTF_8); + } + + private record Result(int exitCode, String output) { + } +} diff --git a/proxy-registry/build.gradle.kts b/proxy-registry/build.gradle.kts new file mode 100644 index 0000000..d7f7bdb --- /dev/null +++ b/proxy-registry/build.gradle.kts @@ -0,0 +1,8 @@ +plugins { + id("strataproxy.java-library-conventions") +} + +dependencies { + api(project(":proxy-api")) + implementation(project(":proxy-common")) +} diff --git a/proxy-registry/src/main/java/dev/strataproxy/registry/InMemoryServerRegistry.java b/proxy-registry/src/main/java/dev/strataproxy/registry/InMemoryServerRegistry.java new file mode 100644 index 0000000..c6c3c91 --- /dev/null +++ b/proxy-registry/src/main/java/dev/strataproxy/registry/InMemoryServerRegistry.java @@ -0,0 +1,86 @@ +package dev.strataproxy.registry; + +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.util.Collection; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; + +public final class InMemoryServerRegistry implements ServerRegistry { + private final ConcurrentHashMap servers = new ConcurrentHashMap<>(); + + @Override + public RegisteredServer register(ServerDescriptor descriptor) { + var server = new MutableRegisteredServer(descriptor); + var existing = servers.putIfAbsent(descriptor.name(), server); + if (existing != null) { + throw new IllegalArgumentException("server already registered: " + descriptor.name()); + } + return server; + } + + @Override + public RegisteredServer registerOrReplace(ServerDescriptor descriptor) { + return servers.compute(descriptor.name(), (ignored, existing) -> { + if (existing == null) { + return new MutableRegisteredServer(descriptor); + } + existing.replaceDescriptor(descriptor); + return existing; + }); + } + + @Override + public boolean unregister(String name, DrainPolicy policy) { + var server = servers.get(name); + if (server == null) { + return false; + } + server.updateDrainMode(true); + if (policy != null && policy.rejectNewConnections() && !policy.migrateExistingPlayers()) { + return servers.remove(name, server); + } + return true; + } + + @Override + public Optional get(String name) { + return Optional.ofNullable(servers.get(name)).map(RegisteredServer.class::cast); + } + + @Override + public Collection snapshot() { + return servers.values().stream().map(RegisteredServer.class::cast).toList(); + } + + @Override + public void updateHealth(String name, ServerHealth health) { + var server = requireServer(name); + server.updateHealth(health); + } + + @Override + public void updateLoad(String name, ServerLoad load) { + var server = requireServer(name); + server.updateLoad(load); + } + + @Override + public void updateDrainMode(String name, boolean drainMode) { + var server = requireServer(name); + server.updateDrainMode(drainMode); + } + + private MutableRegisteredServer requireServer(String name) { + var server = servers.get(name); + if (server == null) { + throw new IllegalArgumentException("unknown server: " + name); + } + return server; + } +} diff --git a/proxy-registry/src/main/java/dev/strataproxy/registry/MutableRegisteredServer.java b/proxy-registry/src/main/java/dev/strataproxy/registry/MutableRegisteredServer.java new file mode 100644 index 0000000..68517e6 --- /dev/null +++ b/proxy-registry/src/main/java/dev/strataproxy/registry/MutableRegisteredServer.java @@ -0,0 +1,89 @@ +package dev.strataproxy.registry; + +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 java.util.Objects; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; + +final class MutableRegisteredServer implements RegisteredServer { + private final AtomicReference descriptor; + private final AtomicReference health; + private final AtomicReference load; + private final AtomicBoolean draining; + + MutableRegisteredServer(ServerDescriptor descriptor) { + this.descriptor = new AtomicReference<>(Objects.requireNonNull(descriptor, "descriptor")); + this.health = new AtomicReference<>(ServerHealth.up(-1)); + this.load = new AtomicReference<>(new ServerLoad(0, descriptor.softCapacity(), descriptor.hardCapacity(), 0, 0, 0, 0.0d)); + this.draining = new AtomicBoolean(descriptor.drainMode()); + } + + @Override + public ServerDescriptor descriptor() { + var descriptor = this.descriptor.get(); + var drainMode = draining.get(); + if (descriptor.drainMode() == drainMode) { + return descriptor; + } + return new ServerDescriptor( + descriptor.name(), + descriptor.address(), + descriptor.tags(), + descriptor.capabilities(), + descriptor.protocolRange(), + descriptor.weight(), + descriptor.softCapacity(), + descriptor.hardCapacity(), + drainMode, + descriptor.metadata()); + } + + @Override + public ServerHealth health() { + return health.get(); + } + + @Override + public ServerLoad load() { + return load.get(); + } + + @Override + public boolean draining() { + return draining.get(); + } + + void updateHealth(ServerHealth next) { + health.set(Objects.requireNonNull(next, "next")); + } + + void updateLoad(ServerLoad next) { + load.set(Objects.requireNonNull(next, "next")); + } + + void beginDrain() { + draining.set(true); + } + + void updateDrainMode(boolean drainMode) { + draining.set(drainMode); + } + + void replaceDescriptor(ServerDescriptor next) { + var descriptor = Objects.requireNonNull(next, "next"); + this.descriptor.set(descriptor); + draining.set(descriptor.drainMode()); + load.updateAndGet(current -> new ServerLoad( + current.players(), + descriptor.softCapacity(), + descriptor.hardCapacity(), + current.inboundBytesPerSecond(), + current.outboundBytesPerSecond(), + current.packetsPerSecond(), + current.eventLoopDelayMillis())); + } +} diff --git a/proxy-registry/src/main/java/dev/strataproxy/registry/TcpServerHealthChecker.java b/proxy-registry/src/main/java/dev/strataproxy/registry/TcpServerHealthChecker.java new file mode 100644 index 0000000..f6c56bc --- /dev/null +++ b/proxy-registry/src/main/java/dev/strataproxy/registry/TcpServerHealthChecker.java @@ -0,0 +1,62 @@ +package dev.strataproxy.registry; + +import dev.strataproxy.api.server.ServerHealth; +import dev.strataproxy.api.server.ServerHealthStatus; +import dev.strataproxy.api.server.ServerRegistry; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.Socket; +import java.time.Duration; +import java.time.Instant; +import java.util.Objects; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +public final class TcpServerHealthChecker implements AutoCloseable { + private final ServerRegistry registry; + private final Duration interval; + private final Duration timeout; + private final ScheduledExecutorService scheduler; + + public TcpServerHealthChecker(ServerRegistry registry, Duration interval, Duration timeout) { + this.registry = Objects.requireNonNull(registry, "registry"); + this.interval = interval == null ? Duration.ofSeconds(5) : interval; + this.timeout = timeout == null ? Duration.ofSeconds(2) : timeout; + this.scheduler = Executors.newSingleThreadScheduledExecutor(task -> { + var thread = new Thread(task, "strataproxy-health-check"); + thread.setDaemon(true); + return thread; + }); + } + + public void start() { + scheduler.execute(this::checkAll); + scheduler.scheduleWithFixedDelay(this::checkAll, interval.toMillis(), interval.toMillis(), TimeUnit.MILLISECONDS); + } + + void checkAll() { + for (var server : registry.snapshot()) { + var descriptor = server.descriptor(); + var started = System.nanoTime(); + try (var socket = new Socket()) { + socket.connect(descriptor.address(), Math.toIntExact(timeout.toMillis())); + var latencyMillis = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - started); + registry.updateHealth(descriptor.name(), ServerHealth.up(latencyMillis)); + } catch (IOException | RuntimeException exception) { + registry.updateHealth(descriptor.name(), new ServerHealth( + ServerHealthStatus.DOWN, + -1, + 1.0d, + exception.getClass().getSimpleName(), + Instant.now())); + } + } + } + + @Override + public void close() { + scheduler.shutdownNow(); + } +} diff --git a/proxy-registry/src/test/java/dev/strataproxy/registry/InMemoryServerRegistryTest.java b/proxy-registry/src/test/java/dev/strataproxy/registry/InMemoryServerRegistryTest.java new file mode 100644 index 0000000..46e0e66 --- /dev/null +++ b/proxy-registry/src/test/java/dev/strataproxy/registry/InMemoryServerRegistryTest.java @@ -0,0 +1,74 @@ +package dev.strataproxy.registry; + +import dev.strataproxy.api.server.ProtocolRange; +import dev.strataproxy.api.server.ServerCapability; +import dev.strataproxy.api.server.ServerDescriptor; +import dev.strataproxy.api.server.ServerHealth; +import dev.strataproxy.api.server.ServerHealthStatus; +import dev.strataproxy.api.server.ServerLoad; +import org.junit.jupiter.api.Test; + +import java.net.InetSocketAddress; +import java.time.Instant; +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 InMemoryServerRegistryTest { + @Test + void registerRejectsDuplicateNames() { + var registry = new InMemoryServerRegistry(); + registry.register(descriptor("survival-1", 25565, Set.of("blue"), 100, 120, false)); + + assertThrows(IllegalArgumentException.class, + () -> registry.register(descriptor("survival-1", 25566, Set.of("green"), 200, 240, false))); + } + + @Test + void registerOrReplaceUpdatesDescriptorWithoutDroppingRuntimeState() { + var registry = new InMemoryServerRegistry(); + registry.register(descriptor("survival-1", 25565, Set.of("blue"), 100, 120, false)); + registry.updateHealth("survival-1", new ServerHealth( + ServerHealthStatus.DEGRADED, + 42, + 0.2d, + "canary", + Instant.now())); + registry.updateLoad("survival-1", new ServerLoad(12, 100, 120, 1024, 2048, 300, 1.5d)); + + var replaced = registry.registerOrReplace(descriptor("survival-1", 25566, Set.of("green", "survival"), 200, 240, true)); + + assertEquals(25566, replaced.descriptor().address().getPort()); + assertEquals(Set.of("green", "survival"), replaced.descriptor().tags()); + assertEquals(200, replaced.load().softCapacity()); + assertEquals(240, replaced.load().hardCapacity()); + assertEquals(12, replaced.load().players()); + assertEquals(1024, replaced.load().inboundBytesPerSecond()); + assertEquals(ServerHealthStatus.DEGRADED, replaced.health().status()); + assertEquals(42, replaced.health().backendPingMillis()); + assertTrue(replaced.draining()); + } + + private static ServerDescriptor descriptor( + String name, + int port, + Set tags, + int softCapacity, + int hardCapacity, + boolean drainMode) { + return new ServerDescriptor( + name, + new InetSocketAddress("127.0.0.1", port), + tags, + Set.of(ServerCapability.LARGE_PAYLOAD), + new ProtocolRange(0, Integer.MAX_VALUE, "any"), + 100, + softCapacity, + hardCapacity, + drainMode, + Map.of("host", "survival.local")); + } +} diff --git a/proxy-registry/src/test/java/dev/strataproxy/registry/TcpServerHealthCheckerTest.java b/proxy-registry/src/test/java/dev/strataproxy/registry/TcpServerHealthCheckerTest.java new file mode 100644 index 0000000..ae0fb98 --- /dev/null +++ b/proxy-registry/src/test/java/dev/strataproxy/registry/TcpServerHealthCheckerTest.java @@ -0,0 +1,60 @@ +package dev.strataproxy.registry; + +import dev.strataproxy.api.server.ProtocolRange; +import dev.strataproxy.api.server.ServerDescriptor; +import dev.strataproxy.api.server.ServerHealthStatus; +import org.junit.jupiter.api.Test; + +import java.net.InetSocketAddress; +import java.net.ServerSocket; +import java.time.Duration; +import java.util.Map; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +final class TcpServerHealthCheckerTest { + @Test + void marksReachableBackendUp() throws Exception { + try (var backend = new ServerSocket(0)) { + var registry = new InMemoryServerRegistry(); + registry.register(descriptor("up", backend.getLocalPort())); + + try (var checker = new TcpServerHealthChecker(registry, Duration.ofSeconds(5), Duration.ofMillis(500))) { + checker.checkAll(); + } + + assertEquals(ServerHealthStatus.UP, registry.get("up").orElseThrow().health().status()); + } + } + + @Test + void marksUnreachableBackendDown() throws Exception { + int closedPort; + try (var socket = new ServerSocket(0)) { + closedPort = socket.getLocalPort(); + } + var registry = new InMemoryServerRegistry(); + registry.register(descriptor("down", closedPort)); + + try (var checker = new TcpServerHealthChecker(registry, Duration.ofSeconds(5), Duration.ofMillis(100))) { + checker.checkAll(); + } + + assertEquals(ServerHealthStatus.DOWN, registry.get("down").orElseThrow().health().status()); + } + + private static ServerDescriptor descriptor(String name, int port) { + return new ServerDescriptor( + name, + new InetSocketAddress("127.0.0.1", port), + Set.of(), + Set.of(), + new ProtocolRange(0, Integer.MAX_VALUE, "any"), + 100, + 10, + 20, + false, + Map.of()); + } +} diff --git a/proxy-routing/build.gradle.kts b/proxy-routing/build.gradle.kts new file mode 100644 index 0000000..69e0c9f --- /dev/null +++ b/proxy-routing/build.gradle.kts @@ -0,0 +1,8 @@ +plugins { + id("strataproxy.java-library-conventions") +} + +dependencies { + api(project(":proxy-api")) + implementation(project(":proxy-registry")) +} diff --git a/proxy-routing/src/main/java/dev/strataproxy/routing/RoutingDecision.java b/proxy-routing/src/main/java/dev/strataproxy/routing/RoutingDecision.java new file mode 100644 index 0000000..ff4511a --- /dev/null +++ b/proxy-routing/src/main/java/dev/strataproxy/routing/RoutingDecision.java @@ -0,0 +1,11 @@ +package dev.strataproxy.routing; + +import dev.strataproxy.api.server.RegisteredServer; + +public sealed interface RoutingDecision permits RoutingDecision.Selected, RoutingDecision.Rejected { + record Selected(RegisteredServer server, double score) implements RoutingDecision { + } + + record Rejected(String reason) implements RoutingDecision { + } +} diff --git a/proxy-routing/src/main/java/dev/strataproxy/routing/RoutingRequest.java b/proxy-routing/src/main/java/dev/strataproxy/routing/RoutingRequest.java new file mode 100644 index 0000000..e7464a8 --- /dev/null +++ b/proxy-routing/src/main/java/dev/strataproxy/routing/RoutingRequest.java @@ -0,0 +1,18 @@ +package dev.strataproxy.routing; + +import dev.strataproxy.api.server.ServerCapability; + +import java.net.InetSocketAddress; +import java.util.Set; + +public record RoutingRequest( + String route, + Set requiredTags, + Set requiredCapabilities, + int protocolVersion, + InetSocketAddress remoteAddress) { + public RoutingRequest { + requiredTags = Set.copyOf(requiredTags == null ? Set.of() : requiredTags); + requiredCapabilities = Set.copyOf(requiredCapabilities == null ? Set.of() : requiredCapabilities); + } +} diff --git a/proxy-routing/src/main/java/dev/strataproxy/routing/ServerRouter.java b/proxy-routing/src/main/java/dev/strataproxy/routing/ServerRouter.java new file mode 100644 index 0000000..b926481 --- /dev/null +++ b/proxy-routing/src/main/java/dev/strataproxy/routing/ServerRouter.java @@ -0,0 +1,5 @@ +package dev.strataproxy.routing; + +public interface ServerRouter { + RoutingDecision route(RoutingRequest request); +} diff --git a/proxy-routing/src/main/java/dev/strataproxy/routing/WeightedHealthAwareRouter.java b/proxy-routing/src/main/java/dev/strataproxy/routing/WeightedHealthAwareRouter.java new file mode 100644 index 0000000..f2b3176 --- /dev/null +++ b/proxy-routing/src/main/java/dev/strataproxy/routing/WeightedHealthAwareRouter.java @@ -0,0 +1,156 @@ +package dev.strataproxy.routing; + +import dev.strataproxy.api.server.RegisteredServer; +import dev.strataproxy.api.server.ServerRegistry; + +import java.util.List; +import java.util.Objects; + +public final class WeightedHealthAwareRouter implements ServerRouter { + private final ServerRegistry registry; + + public WeightedHealthAwareRouter(ServerRegistry registry) { + this.registry = Objects.requireNonNull(registry, "registry"); + } + + @Override + public RoutingDecision route(RoutingRequest request) { + return explain(request).stream() + .filter(RouteCandidate::eligible) + .filter(candidate -> candidate.effectiveWeight() > 0.0d) + .min(java.util.Comparator + .comparingDouble(RouteCandidate::selectionKey) + .thenComparing(RouteCandidate::serverName)) + .map(candidate -> new RoutingDecision.Selected(candidate.server(), candidate.effectiveWeight())) + .map(selected -> selected) + .orElseGet(() -> new RoutingDecision.Rejected("no healthy backend matched route constraints")); + } + + public List explain(RoutingRequest request) { + return registry.snapshot().stream() + .map(server -> explain(request, server)) + .sorted(java.util.Comparator + .comparing(RouteCandidate::eligible).reversed() + .thenComparingDouble(RouteCandidate::selectionKey) + .thenComparing(RouteCandidate::serverName)) + .toList(); + } + + private static RouteCandidate explain(RoutingRequest request, RegisteredServer server) { + var reason = rejectionReason(request, server); + var eligible = reason.isBlank(); + var weight = eligible ? effectiveWeight(server) : 0.0d; + var key = eligible ? weightedKey(request, server) : Double.POSITIVE_INFINITY; + return new RouteCandidate(server, server.descriptor().name(), eligible, reason, weight, key); + } + + private static String rejectionReason(RoutingRequest request, RegisteredServer server) { + var descriptor = server.descriptor(); + if (server.draining()) { + return "draining"; + } + if (!server.health().canReceiveNewConnections()) { + return "health_" + server.health().status().name().toLowerCase(java.util.Locale.ROOT); + } + if (server.load().isHardFull()) { + return "hard_full"; + } + if (!descriptor.protocolRange().accepts(request.protocolVersion())) { + return "protocol_mismatch"; + } + if (!routeMatches(request.route(), server)) { + return "route_mismatch"; + } + if (!descriptor.tags().containsAll(request.requiredTags())) { + return "tag_mismatch"; + } + if (!descriptor.capabilities().containsAll(request.requiredCapabilities())) { + return "capability_mismatch"; + } + return ""; + } + + private static boolean routeMatches(String route, RegisteredServer server) { + if (route == null || route.isBlank()) { + return true; + } + var normalizedRoute = route.toLowerCase(java.util.Locale.ROOT); + var descriptor = server.descriptor(); + if (descriptor.name().equalsIgnoreCase(route)) { + return true; + } + if (descriptor.tags().stream().anyMatch(tag -> tag.equalsIgnoreCase(route))) { + return true; + } + var metadata = descriptor.metadata(); + var host = metadata.get("host"); + var routeAlias = metadata.get("route"); + return normalizedRoute.equalsIgnoreCase(host) || normalizedRoute.equalsIgnoreCase(routeAlias); + } + + private static double effectiveWeight(RegisteredServer server) { + var descriptor = server.descriptor(); + var health = server.health(); + var load = server.load(); + var baseWeight = Math.max(0.0d, descriptor.weight()); + if (baseWeight == 0.0d) { + return 0.0d; + } + var capacityFactor = Math.max(0.05d, 1.0d - Math.min(1.0d, load.capacityPressure())); + var failureFactor = Math.max(0.05d, 1.0d - health.recentFailureRate()); + var latencyFactor = health.backendPingMillis() < 0 + ? 1.0d + : 1.0d / (1.0d + Math.max(0.0d, health.backendPingMillis()) / 100.0d); + var eventLoopFactor = 1.0d / (1.0d + Math.max(0.0d, load.eventLoopDelayMillis()) / 10.0d); + return baseWeight * capacityFactor * failureFactor * latencyFactor * eventLoopFactor; + } + + private static double weightedKey(RoutingRequest request, RegisteredServer server) { + var weight = effectiveWeight(server); + if (weight <= 0.0d) { + return Double.POSITIVE_INFINITY; + } + var uniform = uniformHash(selectionKey(request, server)); + return -Math.log(uniform) / weight; + } + + private static String selectionKey(RoutingRequest request, RegisteredServer server) { + var remote = request.remoteAddress(); + var remoteKey = remote == null ? "" : remote.getAddress().getHostAddress() + ":" + remote.getPort(); + return nullToEmpty(request.route()) + "|" + request.protocolVersion() + "|" + remoteKey + "|" + server.descriptor().name(); + } + + private static double uniformHash(String key) { + var hash = 0xcbf29ce484222325L; + for (var index = 0; index < key.length(); index++) { + hash ^= key.charAt(index); + hash *= 0x100000001b3L; + } + var mixed = mix64(hash); + var bits = (mixed >>> 11) & ((1L << 53) - 1); + return (bits + 1.0d) / ((1L << 53) + 1.0d); + } + + private static long mix64(long value) { + var mixed = value; + mixed ^= mixed >>> 33; + mixed *= 0xff51afd7ed558ccdL; + mixed ^= mixed >>> 33; + mixed *= 0xc4ceb9fe1a85ec53L; + mixed ^= mixed >>> 33; + return mixed; + } + + private static String nullToEmpty(String value) { + return value == null ? "" : value; + } + + public record RouteCandidate( + RegisteredServer server, + String serverName, + boolean eligible, + String reason, + double effectiveWeight, + double selectionKey) { + } +} diff --git a/proxy-routing/src/test/java/dev/strataproxy/routing/WeightedHealthAwareRouterTest.java b/proxy-routing/src/test/java/dev/strataproxy/routing/WeightedHealthAwareRouterTest.java new file mode 100644 index 0000000..b1902d5 --- /dev/null +++ b/proxy-routing/src/test/java/dev/strataproxy/routing/WeightedHealthAwareRouterTest.java @@ -0,0 +1,223 @@ +package dev.strataproxy.routing; + +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.registry.InMemoryServerRegistry; +import org.junit.jupiter.api.Test; + +import java.net.InetSocketAddress; +import java.util.Map; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertTrue; + +final class WeightedHealthAwareRouterTest { + @Test + void selectsHealthyServerMatchingTagsCapabilitiesAndProtocol() { + var registry = new InMemoryServerRegistry(); + registry.register(new ServerDescriptor( + "survival-1", + new InetSocketAddress("127.0.0.1", 25565), + Set.of("survival", "forge"), + Set.of(ServerCapability.LARGE_PAYLOAD), + new ProtocolRange(763, 763, "1.20.1"), + 100, + 100, + 120, + false, + Map.of())); + + var router = new WeightedHealthAwareRouter(registry); + var decision = router.route(new RoutingRequest( + "survival", + Set.of("survival"), + Set.of(ServerCapability.LARGE_PAYLOAD), + 763, + new InetSocketAddress("127.0.0.1", 50000))); + + assertInstanceOf(RoutingDecision.Selected.class, decision); + } + + @Test + void rejectsWhenRequestedRouteDoesNotMatchNameTagOrMetadata() { + var registry = new InMemoryServerRegistry(); + registry.register(new ServerDescriptor( + "survival-1", + new InetSocketAddress("127.0.0.1", 25565), + Set.of("survival", "forge"), + Set.of(ServerCapability.LARGE_PAYLOAD), + new ProtocolRange(763, 763, "1.20.1"), + 100, + 100, + 120, + false, + Map.of("host", "survival.example.net"))); + + var router = new WeightedHealthAwareRouter(registry); + var decision = router.route(new RoutingRequest( + "creative.example.net", + Set.of(), + Set.of(ServerCapability.LARGE_PAYLOAD), + 763, + new InetSocketAddress("127.0.0.1", 50000))); + + assertInstanceOf(RoutingDecision.Rejected.class, decision); + } + + @Test + void ignoresDrainedServersUntilUndrained() { + 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(763, 763, "1.20.1"), + 100, + 100, + 120, + false, + Map.of())); + var router = new WeightedHealthAwareRouter(registry); + var request = new RoutingRequest( + "survival", + Set.of(), + Set.of(ServerCapability.LARGE_PAYLOAD), + 763, + new InetSocketAddress("127.0.0.1", 50000)); + + registry.updateDrainMode("survival-1", true); + assertInstanceOf(RoutingDecision.Rejected.class, router.route(request)); + + registry.updateDrainMode("survival-1", false); + assertInstanceOf(RoutingDecision.Selected.class, router.route(request)); + } + + @Test + void explainsCandidateEligibilityAndRejectionReasons() { + var registry = new InMemoryServerRegistry(); + registry.register(descriptor("survival-ready", 100)); + registry.register(descriptor("survival-draining", 100)); + registry.register(new ServerDescriptor( + "creative-1", + new InetSocketAddress("127.0.0.1", 25566), + Set.of("creative"), + Set.of(ServerCapability.LARGE_PAYLOAD), + new ProtocolRange(763, 763, "1.20.1"), + 100, + 100, + 120, + false, + Map.of())); + registry.updateDrainMode("survival-draining", true); + var router = new WeightedHealthAwareRouter(registry); + + var explanation = router.explain(new RoutingRequest( + "survival", + Set.of("survival"), + Set.of(ServerCapability.LARGE_PAYLOAD), + 763, + new InetSocketAddress("127.0.0.1", 50000))); + + assertEquals("survival-ready", explanation.getFirst().serverName()); + assertTrue(explanation.getFirst().eligible()); + assertTrue(explanation.stream().anyMatch(candidate -> + candidate.serverName().equals("survival-draining") && candidate.reason().equals("draining"))); + assertTrue(explanation.stream().anyMatch(candidate -> + candidate.serverName().equals("creative-1") && candidate.reason().equals("route_mismatch"))); + } + + @Test + void distributesTrafficAcrossWeightedCandidatesForGrayRollouts() { + var registry = new InMemoryServerRegistry(); + registry.register(descriptor("survival-blue", 100)); + registry.register(descriptor("survival-canary", 25)); + var router = new WeightedHealthAwareRouter(registry); + + var blue = 0; + var canary = 0; + for (var port = 10_000; port < 11_000; port++) { + var decision = router.route(new RoutingRequest( + "survival", + Set.of("survival"), + Set.of(ServerCapability.LARGE_PAYLOAD), + 763, + new InetSocketAddress("127.0.0.1", port))); + var selected = assertInstanceOf(RoutingDecision.Selected.class, decision); + if (selected.server().descriptor().name().equals("survival-blue")) { + blue++; + } else if (selected.server().descriptor().name().equals("survival-canary")) { + canary++; + } + } + + assertTrue(blue > canary); + assertTrue(canary > 120, "canary should receive real rollout traffic"); + assertTrue(canary < 280, "canary share should remain bounded by its lower weight"); + } + + @Test + void keepsSelectionStableForSameRemoteAddress() { + var registry = new InMemoryServerRegistry(); + registry.register(descriptor("survival-blue", 100)); + registry.register(descriptor("survival-canary", 25)); + var router = new WeightedHealthAwareRouter(registry); + var request = new RoutingRequest( + "survival", + Set.of("survival"), + Set.of(ServerCapability.LARGE_PAYLOAD), + 763, + new InetSocketAddress("127.0.0.1", 25_000)); + + var first = assertInstanceOf(RoutingDecision.Selected.class, router.route(request)); + for (var attempt = 0; attempt < 20; attempt++) { + var next = assertInstanceOf(RoutingDecision.Selected.class, router.route(request)); + assertEquals(first.server().descriptor().name(), next.server().descriptor().name()); + } + } + + @Test + void capacityPressureShiftsSelectionTowardEmptierBackends() { + var registry = new InMemoryServerRegistry(); + registry.register(descriptor("survival-fuller", 100)); + registry.register(descriptor("survival-empty", 100)); + registry.updateLoad("survival-fuller", new ServerLoad(95, 100, 120, 0, 0, 0, 0)); + var router = new WeightedHealthAwareRouter(registry); + + var fuller = 0; + var empty = 0; + for (var port = 20_000; port < 21_000; port++) { + var selected = assertInstanceOf(RoutingDecision.Selected.class, router.route(new RoutingRequest( + "survival", + Set.of("survival"), + Set.of(ServerCapability.LARGE_PAYLOAD), + 763, + new InetSocketAddress("127.0.0.1", port)))); + if (selected.server().descriptor().name().equals("survival-fuller")) { + fuller++; + } else if (selected.server().descriptor().name().equals("survival-empty")) { + empty++; + } + } + + assertTrue(empty > fuller * 10, "capacity pressure should strongly reduce effective weight"); + } + + private static ServerDescriptor descriptor(String name, int weight) { + return new ServerDescriptor( + name, + new InetSocketAddress("127.0.0.1", 25565), + Set.of("survival", "forge"), + Set.of(ServerCapability.LARGE_PAYLOAD), + new ProtocolRange(763, 763, "1.20.1"), + weight, + 100, + 120, + false, + Map.of()); + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..36ce385 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,35 @@ +pluginManagement { + includeBuild("proxy-build-logic") + repositories { + gradlePluginPortal() + mavenCentral() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + mavenCentral() + } +} + +rootProject.name = "strataproxy" + +include( + "proxy-api", + "proxy-bootstrap", + "proxy-common", + "proxy-network", + "proxy-protocol", + "proxy-codec-minecraft", + "proxy-registry", + "proxy-routing", + "proxy-compression", + "proxy-observability", + "proxy-packet-analysis", + "proxy-admin-api", + "proxy-admin-cli", + "proxy-query", + "proxy-native", + "proxy-app", +)