feat: add legacy forge protocol switching support

Introduce protocol profiles and legacy custom payload codecs for 1.7.10/1.8 behavior.

Add Forge handshake tracking, gating, reset, plugin channel replay, legacy switch cleanup, diagnostics, and local 1.7.10 deployment scripts.

Document the protocol version matrix and current support limits.
This commit is contained in:
layue13 committed 2026-07-03 00:04:29 +08:00
1 parent 03bbbcabe1
commit 4b64a6819e
57 files changed
+10616 -139

No files matched your search

+4
View File
@@ -20,3 +20,7 @@ replay_pid*
data/
*.invalid-*
deployment/performance/results/
deployment/local-minecraft-1.7.10/bin/
deployment/local-minecraft-1.7.10/backend-*/
deployment/local-minecraft-1.7.10/*.pid
deployment/local-minecraft-1.7.10/*.log
@@ -0,0 +1,53 @@
# Local Minecraft 1.7.10 Backend Pool
This folder contains scripts and config for a local three-backend Minecraft 1.7.10 pool for StrataProxy testing.
## Backends
| Name | Address | Route host | Tags |
| --- | --- | --- | --- |
| lobby-1 | 127.0.0.1:25565 | lobby.local | lobby, mc-1.7.10 |
| survival-1 | 127.0.0.1:25566 | survival.local | survival, mc-1.7.10 |
| minigame-1 | 127.0.0.1:25567 | minigame.local | minigame, mc-1.7.10 |
The server jar is downloaded on demand from Mojang version metadata into `bin/minecraft_server.1.7.10.jar`. The script verifies this SHA-1:
```text
952438ac4e01b4d115c5fc38f891710c4941df29
```
## Start
Read and accept the Minecraft EULA before starting the servers. If you accept it, run:
```powershell
.\deployment\local-minecraft-1.7.10\start-backends.ps1 -AcceptEula
```
Start StrataProxy with the local 1.7.10 config:
```powershell
.\deployment\local-minecraft-1.7.10\start-proxy.ps1
```
Connect a Minecraft 1.7.10 client to:
```text
127.0.0.1:25577
```
## Check and stop
```powershell
.\deployment\local-minecraft-1.7.10\status.ps1
.\deployment\local-minecraft-1.7.10\stop-backends.ps1
```
Backend stdout/stderr logs are written inside each backend directory.
Generated runtime files are intentionally ignored by Git:
- `bin/`
- `backend-*/`
- `*.pid`
- `*.log`
@@ -0,0 +1,121 @@
param(
[switch] $AcceptEula,
[string] $JavaExe = "java",
[string] $Xms = "256M",
[string] $Xmx = "768M"
)
$ErrorActionPreference = "Stop"
$Root = Split-Path -Parent $MyInvocation.MyCommand.Path
$Bin = Join-Path $Root "bin"
$Jar = Join-Path $Root "bin\minecraft_server.1.7.10.jar"
$ExpectedServerSha1 = "952438ac4e01b4d115c5fc38f891710c4941df29"
function Get-ServerJar {
if (Test-Path -LiteralPath $Jar) {
$ActualSha1 = (Get-FileHash -LiteralPath $Jar -Algorithm SHA1).Hash.ToLowerInvariant()
if ($ActualSha1 -ne $ExpectedServerSha1) {
throw "Server jar SHA-1 mismatch: expected $ExpectedServerSha1, got $ActualSha1"
}
return
}
New-Item -ItemType Directory -Path $Bin -Force | Out-Null
$Manifest = Invoke-RestMethod -Uri "https://piston-meta.mojang.com/mc/game/version_manifest_v2.json"
$Version = $Manifest.versions | Where-Object { $_.id -eq "1.7.10" } | Select-Object -First 1
if (-not $Version) {
throw "Could not find Minecraft 1.7.10 in Mojang version manifest"
}
$VersionMetadata = Invoke-RestMethod -Uri $Version.url
$ServerUrl = $VersionMetadata.downloads.server.url
if (-not $ServerUrl) {
throw "Minecraft 1.7.10 manifest does not include a server download"
}
$TempJar = "$Jar.download"
Invoke-WebRequest -Uri $ServerUrl -OutFile $TempJar
$ActualSha1 = (Get-FileHash -LiteralPath $TempJar -Algorithm SHA1).Hash.ToLowerInvariant()
if ($ActualSha1 -ne $ExpectedServerSha1) {
Remove-Item -LiteralPath $TempJar -Force
throw "Downloaded server jar SHA-1 mismatch: expected $ExpectedServerSha1, got $ActualSha1"
}
Move-Item -LiteralPath $TempJar -Destination $Jar -Force
Write-Host "Downloaded Minecraft 1.7.10 server jar to $Jar"
}
function Ensure-BackendConfig {
param(
[string] $Dir,
[string] $Name,
[int] $Port
)
New-Item -ItemType Directory -Path $Dir -Force | Out-Null
$ServerProperties = Join-Path $Dir "server.properties"
if (Test-Path -LiteralPath $ServerProperties) {
return
}
$Properties = @(
"server-ip=127.0.0.1",
"server-port=$Port",
"online-mode=false",
"enable-query=false",
"enable-rcon=false",
"motd=StrataProxy $Name",
"max-players=100",
"spawn-protection=0",
"view-distance=6",
"difficulty=1",
"gamemode=0",
"white-list=false",
"generate-structures=false"
)
Set-Content -LiteralPath $ServerProperties -Value $Properties -Encoding ASCII
}
$Backends = @(
@{ Name = "backend-lobby-1"; Port = 25565 },
@{ Name = "backend-survival-1"; Port = 25566 },
@{ Name = "backend-minigame-1"; Port = 25567 }
)
Get-ServerJar
foreach ($Backend in $Backends) {
$Dir = Join-Path $Root $Backend.Name
Ensure-BackendConfig -Dir $Dir -Name $Backend.Name -Port $Backend.Port
$Eula = Join-Path $Dir "eula.txt"
$PidFile = Join-Path $Dir "server.pid"
$OutLog = Join-Path $Dir "stdout.log"
$ErrLog = Join-Path $Dir "stderr.log"
if (-not (Test-Path -LiteralPath $Eula)) {
Set-Content -LiteralPath $Eula -Value "eula=false" -Encoding ASCII
}
if ($AcceptEula) {
Set-Content -LiteralPath $Eula -Value "eula=true" -Encoding ASCII
}
if (-not (Select-String -LiteralPath $Eula -Pattern "^eula=true$" -Quiet)) {
throw "EULA is not accepted for $($Backend.Name). Read Minecraft EULA, then rerun with -AcceptEula if you accept it."
}
if (Test-Path -LiteralPath $PidFile) {
$ExistingPid = (Get-Content -LiteralPath $PidFile -Raw).Trim()
if ($ExistingPid -and (Get-Process -Id ([int] $ExistingPid) -ErrorAction SilentlyContinue)) {
Write-Host "$($Backend.Name) already running on port $($Backend.Port), pid $ExistingPid"
continue
}
}
$Args = @("-Xms$Xms", "-Xmx$Xmx", "-jar", $Jar, "nogui")
$Process = Start-Process -FilePath $JavaExe `
-ArgumentList $Args `
-WorkingDirectory $Dir `
-RedirectStandardOutput $OutLog `
-RedirectStandardError $ErrLog `
-WindowStyle Hidden `
-PassThru
Set-Content -LiteralPath $PidFile -Value $Process.Id -Encoding ASCII
Write-Host "Started $($Backend.Name) on 127.0.0.1:$($Backend.Port), pid $($Process.Id)"
}
@@ -0,0 +1,21 @@
param(
[string] $JavaHome = "C:\Program Files\Zulu\zulu-25"
)
$ErrorActionPreference = "Stop"
$Root = Split-Path -Parent $MyInvocation.MyCommand.Path
$Repo = Split-Path -Parent (Split-Path -Parent $Root)
$Config = Join-Path $Root "strataproxy-1.7.10-local.yml"
$Proxy = Join-Path $Repo "proxy-app\build\install\strataproxy\bin\strataproxy.bat"
if (-not (Test-Path -LiteralPath $Proxy)) {
Push-Location $Repo
try {
$env:JAVA_HOME = $JavaHome
& .\gradlew.bat --no-daemon :proxy-app:installDist
} finally {
Pop-Location
}
}
& $Proxy $Config
@@ -0,0 +1,36 @@
$ErrorActionPreference = "Stop"
$Backends = @(
@{ Name = "lobby-1"; Port = 25565 },
@{ Name = "survival-1"; Port = 25566 },
@{ Name = "minigame-1"; Port = 25567 }
)
function Test-Port {
param(
[string] $HostName,
[int] $Port,
[int] $TimeoutMillis = 500
)
$Client = [System.Net.Sockets.TcpClient]::new()
try {
$Connect = $Client.BeginConnect($HostName, $Port, $null, $null)
if (-not $Connect.AsyncWaitHandle.WaitOne($TimeoutMillis)) {
return $false
}
$Client.EndConnect($Connect)
return $true
} catch {
return $false
} finally {
$Client.Close()
}
}
foreach ($Backend in $Backends) {
$State = if (Test-Port -HostName "127.0.0.1" -Port $Backend.Port) { "open" } else { "closed" }
Write-Host "$($Backend.Name) 127.0.0.1:$($Backend.Port) $State"
}
$ProxyState = if (Test-Port -HostName "127.0.0.1" -Port 25577) { "open" } else { "closed" }
Write-Host "strataproxy 127.0.0.1:25577 $ProxyState"
@@ -0,0 +1,25 @@
$ErrorActionPreference = "Stop"
$Root = Split-Path -Parent $MyInvocation.MyCommand.Path
$Backends = @("backend-lobby-1", "backend-survival-1", "backend-minigame-1")
foreach ($Backend in $Backends) {
$PidFile = Join-Path (Join-Path $Root $Backend) "server.pid"
if (-not (Test-Path -LiteralPath $PidFile)) {
Write-Host "$Backend is not tracked"
continue
}
$PidText = (Get-Content -LiteralPath $PidFile -Raw).Trim()
if (-not $PidText) {
Remove-Item -LiteralPath $PidFile -Force
Write-Host "$Backend has an empty pid file"
continue
}
$Process = Get-Process -Id ([int] $PidText) -ErrorAction SilentlyContinue
if ($Process) {
Stop-Process -Id $Process.Id -Force
Write-Host "Stopped $Backend, pid $PidText"
} else {
Write-Host "$Backend pid $PidText is not running"
}
Remove-Item -LiteralPath $PidFile -Force
}
@@ -0,0 +1,136 @@
network:
bind: "0.0.0.0:25577"
workerThreads: 0
nativeTransport: true
maxFrameBytes: "8mb"
connectTimeoutMillis: 5000
writeBufferLow: "4mb"
writeBufferHigh: "16mb"
maxConnections: 10000
maxConnectionsPerAddress: 200
maxNewConnectionsPerSecond: 0
maxNewConnectionsPerAddressPerSecond: 0
initialHandshakeTimeoutMillis: 5000
proxyProtocol: false
registry:
staticServers: true
persistenceEnabled: false
persistencePath: "data/registry.json"
healthCheckEnabled: true
healthCheckInterval: "5s"
healthCheckTimeout: "2s"
healthCheckMode: "minecraft-status"
compression:
mode: off
codec: zlib
minThreshold: 256
maxThreshold: 8192
cpuGuard: 0.75
rewriteEnabled: false
rewriteMaxEventLoopDelayMillis: 25
zstdLevel: 1
zstdDictionaryPath: ""
packetAnalysis:
largePayloadWarnBytes: "1mb"
unknownChannelThrottleBytes: "256kb"
moddedHandshakeWarnBytes: "2mb"
customPayloadFloodMaxCount: 200
customPayloadFloodWindow: "10s"
observability:
prometheus: true
packetTopN: 50
anomalySampling: true
flushIntervalSeconds: 5
status:
enabled: true
motd: "StrataProxy local 1.7.10 pool"
protocolName: "Minecraft 1.7.10"
protocolVersion: 5
maxPlayers: 300
favicon: ""
faviconPath: ""
samplePlayers: []
auth:
onlineMode: false
rsaKeyBits: 1024
verifyTokenBytes: 4
sessionVerification: false
sessionVerificationTimeout: "5s"
forwarding:
mode: "none"
secret: ""
native:
enabled: true
autoDetect: true
preferNativeTransport: true
requireNativeTransport: false
preferOpenSslTls: false
preferNativeCompression: false
disabledFeatures: []
forcedFeatures: []
admin:
enabled: true
bind: "127.0.0.1:8080"
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", "mc-1.7.10"]
capabilities: []
protocolRange: "5"
weight: 100
softCapacity: 80
hardCapacity: 100
drainMode: false
metadata:
group: "lobby"
host: "lobby.local"
route: "lobby"
version: "1.7.10"
- name: "survival-1"
address: "127.0.0.1:25566"
tags: ["survival", "mc-1.7.10"]
capabilities: []
protocolRange: "5"
weight: 100
softCapacity: 80
hardCapacity: 100
drainMode: false
metadata:
group: "survival"
host: "survival.local"
route: "survival"
version: "1.7.10"
- name: "minigame-1"
address: "127.0.0.1:25567"
tags: ["minigame", "mc-1.7.10"]
capabilities: []
protocolRange: "5"
weight: 100
softCapacity: 80
hardCapacity: 100
drainMode: false
metadata:
group: "minigame"
host: "minigame.local"
route: "minigame"
version: "1.7.10"
+5 -2
View File
@@ -10,8 +10,9 @@ Chinese documentation is under [zh-CN](zh-CN/README.md).
2. [Configuration Guide](configuration.md): every important YAML section explained with copyable examples.
3. [Command Reference](commands.md): Admin CLI and query/load-test commands grouped by task.
4. [Zstd Compression Tuning](compression-zstd.md): optional modded-client Zstd codec, sample collection, dictionary training, and rollout guidance.
5. [JavaDoc Reference](javadoc.md): source-level API documentation scope and generation workflow.
6. [Operations Manual](operations-manual.md): full production operations, observability, troubleshooting, and acceptance checks.
5. [Protocol Version Matrix](protocol-version-matrix.md): exact Minecraft version support, packet-format assumptions, and required gates before claiming compatibility.
6. [JavaDoc Reference](javadoc.md): source-level API documentation scope and generation workflow.
7. [Operations Manual](operations-manual.md): full production operations, observability, troubleshooting, and acceptance checks.
## Common Tasks
@@ -24,6 +25,7 @@ Chinese documentation is under [zh-CN](zh-CN/README.md).
| Write a proxy-side plugin or use in-game commands | [Plugins and In-Game Commands](plugins.md) |
| Expose the Admin API safely | [Configuration Guide: Admin API](configuration.md#admin-api) |
| Tune compression for modded traffic | [Zstd Compression Tuning](compression-zstd.md) |
| Check whether a Minecraft version is actually supported | [Protocol Version Matrix](protocol-version-matrix.md) |
| Deploy with systemd or a container | [Deployment Notes](../deployment/README.md) |
| Run smoke or acceptance load checks | [Performance Profiles](../deployment/performance/README.md) |
| Add Prometheus or Grafana | [Prometheus](../deployment/observability/prometheus/README.md) and [Grafana](../deployment/observability/grafana/README.md) |
@@ -36,6 +38,7 @@ Chinese documentation is under [zh-CN](zh-CN/README.md).
| [Configuration Guide](configuration.md) | Practical YAML configuration reference |
| [Command Reference](commands.md) | Operator command cookbook |
| [Plugins and In-Game Commands](plugins.md) | Player commands and plugin jar entry points |
| [Protocol Version Matrix](protocol-version-matrix.md) | Supported Minecraft protocol profiles and version-gate checklist |
| [JavaDoc Reference](javadoc.md) | Source-level API documentation scope and generation workflow |
| [Operations Manual](operations-manual.md) | Complete production runbook |
| [Zstd Compression Tuning](compression-zstd.md) | Minecraft-specific Zstd and dictionary workflow |
+72
View File
@@ -0,0 +1,72 @@
# Protocol Version Matrix
This matrix is the compatibility contract for Minecraft protocol-specific behavior in StrataProxy. It must be updated together with `MinecraftProtocolProfile` whenever a new client/server version is added.
Status meanings:
- `supported`: implemented in `MinecraftProtocolProfile` and covered by focused tests.
- `partial`: selected behavior exists, but the version is not production-compatible as a full multi-version profile.
- `not supported`: do not advertise stable compatibility. Add a profile and tests first.
## Current Runtime Matrix
| Minecraft Java version | Protocol | Status | Login compression | Serverbound command packets | Custom payload format | Backend switch | Legacy Forge/FML |
| --- | ---: | --- | --- | --- | --- | --- | --- |
| 1.7.10 | 5 | supported for targeted local/legacy switching work | no vanilla login compression | `0x01` chat message | serverbound unsigned-short length, clientbound varshort length | respawn switch; Forge-safe Join Game + Respawn path | partial: REGISTER tracking, reset injection, handshake observation/gates, queued non-FML payloads, plugin-channel replay |
| 1.8.x | 47 | supported for targeted compression/legacy switching work | yes, Login Set Compression `0x03` | `0x01` chat message | remaining bytes in both directions | compressed/uncompressed respawn switch; Forge-safe Join Game + Respawn path | partial: compressed handshake observation/gates, compressed race suppression, compressed REGISTER replay |
| 1.20.1 | 763 | partial | yes | `0x03`, `0x04`, `0x05` | serverbound modern custom payload support only where explicit samplers exist | not supported by legacy backend replacement path | no legacy Forge/FML state machine |
| Other 1.9+ versions | varies | not supported | do not infer from version number alone | not defined | not defined | not supported | not supported |
| Current/latest Java releases | varies | not supported until profiled | must be read from the target version's protocol data | must be profiled | must be profiled | must be profiled | modern mod loaders need separate state machines |
Important compression rule: protocol 5 must not negotiate vanilla login compression. Some legacy proxies have a play-state 1.7.x compression packet, but StrataProxy does not treat that as normal login compression.
## Required Profile Fields
A version is not compatibility-supported until these fields are known and tested:
| Field | Why it matters |
| --- | --- |
| protocol number | Route filtering and status response selection |
| login packet ids | Login success, disconnect, encryption request, Set Compression |
| compression framing | Whether packet payloads are compressed after login negotiation |
| serverbound command packet ids | Proxy commands such as `/server` must be intercepted without forwarding to the backend |
| custom payload packet ids | Forge/Fabric/Bungee/Velocity plugin messages depend on this |
| custom payload length format | 1.7.10 uses legacy length encodings; 1.8 differs; modern versions use namespaced payloads |
| Join Game and Respawn packet ids/layouts | Backend switching depends on converting the first backend Join Game safely |
| player list, scoreboard, team clear packets | Safe server switching needs client state cleanup |
| Forge/mod-loader handshake phases | Large modded server switching needs reset, registry, and queued payload behavior |
| forwarding/login plugin behavior | Velocity modern and Bungee-style identity forwarding are version-sensitive |
## Test Gates Per Version
| Gate | 1.7.10 | 1.8.x | 1.20.1 | New versions |
| --- | --- | --- | --- | --- |
| command interception | covered | covered through shared legacy profile paths | covered | required |
| backend switch over real TCP | covered for protocol 5 `/server` | unit covered for compressed switch | not covered | required before claiming switch support |
| login compression | explicitly disabled | covered | covered in existing modern paths | required |
| custom payload parsing | covered | covered compressed/uncompressed | partial | required |
| Forge handshake tracking | covered | covered compressed/uncompressed | not applicable | Forge/Fabric-specific design required |
| Forge race suppression | covered | covered compressed/uncompressed | not applicable | required where legacy Forge applies |
| plugin channel REGISTER replay | covered | covered compressed/uncompressed | not applicable | required where plugin channels are replayed |
| real modded server acceptance | not yet proven | not yet proven | not applicable | required for production claims |
## Implementation Rule
Do not add packet ids directly inside relay handlers for a new version. Add or extend a profile first, then wire the relay/controller logic through profile capabilities. If a behavior cannot be expressed through `MinecraftProtocolProfile`, that is a signal to add a dedicated state-machine abstraction rather than another version check in the relay.
## Roadmap
1. Extract explicit `ConnectionStateMachine`: login, compression, play, and mod-loader phases.
2. Extract `TransferOrchestrator`: server switch lifecycle, initial clientbound frames, rollback, and metrics.
3. Keep `MinecraftProtocolProfile` as data: packet ids, formats, layouts, and capability flags only.
4. Add version profiles in groups, with tests before advertising support:
- 1.9-1.12.2 legacy Forge era.
- 1.13-1.16.5 flattening/namespaced payload transition.
- 1.17-1.18.2 modern network changes.
- 1.19-1.20.6 signed chat and login/configuration transition.
- 1.21+ and 26.x current releases.
## References
- Minecraft Wiki protocol version data documents that each build exposes `protocol_version` in `version.json` from the client/server jar for modern builds.
- Mojang announced that Java Edition version numbers move to year-based `26.x` style starting in 2026; version names and protocol numbers must therefore be treated as separate data.
+1
View File
@@ -11,6 +11,7 @@ StrataProxy 是面向大型 Minecraft 模组服网络的新代理项目,不以
- [命令速查](commands.md):按任务整理 Admin CLI 和 query/load-test 命令。
- [插件和游戏内命令](plugins.md):玩家命令、插件 jar 入口和扩展点。
- [Zstd 压缩调参指南](compression-zstd.md):样本采集、dictionary 训练和上线建议。
- [协议版本能力矩阵](protocol-version-matrix.md):明确哪些 Minecraft 版本真的支持,哪些只是部分透传或未支持。
- [JavaDoc 参考](javadoc.md):源码级 API 文档范围和生成流程。
- [运维说明书](operations-manual.md):完整生产运维、观测、排障和验收。
- [英文文档目录](../README.md)
+70
View File
@@ -0,0 +1,70 @@
# 协议版本能力矩阵
这张表是 StrataProxy 的 Minecraft 协议兼容契约。以后新增版本时,必须同时更新 `MinecraftProtocolProfile`、测试和这份文档。
状态含义:
- `已支持`:已经进入 `MinecraftProtocolProfile`,并有针对性测试覆盖。
- `部分支持`:有部分行为,但不能宣称完整多版本生产兼容。
- `未支持`:不能对外宣称稳定兼容;必须先补 profile 和测试。
## 当前运行矩阵
| Minecraft Java 版本 | Protocol | 状态 | 登录压缩 | 上行命令包 | Custom Payload 格式 | 后端切服 | Legacy Forge/FML |
| --- | ---: | --- | --- | --- | --- | --- | --- |
| 1.7.10 | 5 | 已支持当前 1.7.10/legacy 切服目标 | 不启用 vanilla login compression | `0x01` chat message | 上行 unsigned-short 长度,下行 varshort 长度 | Respawn 切服;Forge 安全路径为 Join Game + Respawn | 部分支持:REGISTER 跟踪、reset 注入、握手观测/gate、非 FML payload 排队、plugin-channel replay |
| 1.8.x | 47 | 已支持当前压缩和 legacy 切服目标 | 支持 Login Set Compression `0x03` | `0x01` chat message | 上下行都是 remaining bytes | 支持压缩/未压缩 Respawn 切服;Forge 安全路径为 Join Game + Respawn | 部分支持:压缩握手观测/gate、压缩 race suppression、压缩 REGISTER replay |
| 1.20.1 | 763 | 部分支持 | 支持 | `0x03`、`0x04`、`0x05` | 只在已有 sampler 覆盖范围内支持现代上行 payload | 不支持 legacy backend replacement | 不支持 legacy Forge/FML 状态机 |
| 其他 1.9+ 版本 | 各不相同 | 未支持 | 不能只按版本号推断 | 未定义 | 未定义 | 未支持 | 未支持 |
| 当前/latest Java 版本 | 各不相同 | 未支持,直到补 profile | 必须从目标版本协议数据读取 | 必须建模 | 必须建模 | 必须建模 | 现代模组加载器需要单独状态机 |
重要压缩规则:protocol 5 不启用 vanilla 登录压缩。旧代理里存在 1.7.x play-state 压缩包的历史实现,但 StrataProxy 不能把它当作普通 login compression。
## 新版本必须补齐的 Profile 字段
| 字段 | 原因 |
| --- | --- |
| protocol number | 路由过滤和 status response 需要 |
| login packet id | Login Success、Disconnect、Encryption Request、Set Compression 都依赖它 |
| compression framing | 登录协商后 packet 是否压缩以及如何解码 |
| serverbound command packet id | `/server` 等代理命令必须被代理吃掉,不能发给后端 |
| custom payload packet id | Forge/Fabric/Bungee/Velocity plugin message 都依赖它 |
| custom payload 长度格式 | 1.7.10 和 1.8 不同,现代版本又是另一套 payload 语义 |
| Join Game / Respawn id 和布局 | 切服时要安全转换新后端首个 Join Game |
| player list / scoreboard / team 清理包 | 安全切服需要清客户端状态 |
| Forge/mod-loader 握手阶段 | 大型模组服切服需要 reset、registry 和 payload 排队 |
| forwarding/login plugin 行为 | Velocity modern、Bungee legacy 身份转发都和版本有关 |
## 每个版本的测试门槛
| 门槛 | 1.7.10 | 1.8.x | 1.20.1 | 新版本 |
| --- | --- | --- | --- | --- |
| 命令拦截 | 已覆盖 | 通过 legacy profile 路径覆盖 | 已覆盖 | 必须覆盖 |
| 真实 TCP 后端切服 | 已覆盖 protocol 5 `/server` | 已有压缩切服单元覆盖 | 未覆盖 | 宣称切服支持前必须覆盖 |
| 登录压缩 | 明确禁用 | 已覆盖 | 已覆盖现代路径 | 必须覆盖 |
| custom payload 解析 | 已覆盖 | 已覆盖压缩/未压缩 | 部分覆盖 | 必须覆盖 |
| Forge 握手跟踪 | 已覆盖 | 已覆盖压缩/未压缩 | 不适用 | 按 Forge/Fabric 设计补 |
| Forge race suppression | 已覆盖 | 已覆盖压缩/未压缩 | 不适用 | legacy Forge 适用时必须覆盖 |
| plugin channel REGISTER replay | 已覆盖 | 已覆盖压缩/未压缩 | 不适用 | 需要 replay 的版本必须覆盖 |
| 真实大型模组服验收 | 尚未证明 | 尚未证明 | 不适用 | 生产宣称前必须验收 |
## 实现规则
新增版本时,不要直接在 relay handler 里散落 packet id。先扩展 `MinecraftProtocolProfile`,再让 relay/controller 通过 profile 能力工作。如果一个行为无法用 profile 表达,说明应该提取专门状态机,而不是继续在 relay 里加版本判断。
## 后续路线
1. 抽出明确的 `ConnectionStateMachine`:登录、压缩、play、mod-loader 阶段。
2. 抽出 `TransferOrchestrator`:切服生命周期、初始 clientbound 包、回滚和指标。
3. `MinecraftProtocolProfile` 只保留数据:packet id、格式、布局和能力开关。
4. 按版本组补 profile 和测试,再宣称支持:
- 1.9-1.12.2:legacy Forge 时代。
- 1.13-1.16.5:扁平化与 namespaced payload 过渡。
- 1.17-1.18.2:现代网络变更。
- 1.19-1.20.6:签名聊天、登录/configuration 过渡。
- 1.21+ 和 26.x 当前版本。
## 参考
- Minecraft Wiki 的 protocol version 数据说明,现代构建可从 client/server jar 的 `version.json` 读取 `protocol_version`。
- Mojang 已宣布 Java Edition 从 2026 年开始使用 `26.x` 年份式版本号;因此版本名和 protocol number 必须分开建模。
@@ -245,6 +245,7 @@ public final class AdminHttpServer implements AutoCloseable {
this.server.createContext("/packet-anomalies", this::packetAnomalies);
this.server.createContext("/packet-traffic", this::packetTraffic);
this.server.createContext("/custom-payloads", this::customPayloads);
this.server.createContext("/forge-handshakes", this::forgeHandshakes);
this.server.createContext("/player-sessions", this::playerSessions);
this.server.createContext("/payload-captures", this::payloadCaptures);
this.server.createContext("/routes", this::routes);
@@ -382,6 +383,7 @@ public final class AdminHttpServer implements AutoCloseable {
appendCompressionRewriteMetrics(body, snapshot.compressionRewrites());
appendPacketTrafficMetrics(body, snapshot.packetTraffic());
appendCustomPayloadMetrics(body, snapshot.customPayloads());
appendForgeHandshakeMetrics(body, snapshot.forgeHandshakes());
appendRelayBackpressureMetrics(body, snapshot.relayBackpressure());
respond(exchange, 200, "text/plain; version=0.0.4; charset=utf-8", body.toString());
}
@@ -419,6 +421,17 @@ public final class AdminHttpServer implements AutoCloseable {
respondJson(exchange, 200, customPayloadReport(metrics.snapshot()));
}
private void forgeHandshakes(HttpExchange exchange) throws IOException {
if (!authorized(exchange)) {
return;
}
if (!exchange.getRequestMethod().equals("GET")) {
respondError(exchange, 405, "method not allowed");
return;
}
respondJson(exchange, 200, forgeHandshakeReport(metrics.snapshot()));
}
private void playerSessions(HttpExchange exchange) throws IOException {
if (!authorized(exchange)) {
return;
@@ -585,6 +598,24 @@ public final class AdminHttpServer implements AutoCloseable {
return new CustomPayloadReport(totalPackets, totalPayloadBytes, totalCompressedBytes, rows, samples);
}
private ForgeHandshakeReport forgeHandshakeReport(ProxyMetrics.Snapshot snapshot) {
var rows = snapshot.forgeHandshakes().entrySet().stream()
.map(entry -> ForgeHandshakeView.from(entry.getKey(), entry.getValue()))
.sorted(java.util.Comparator
.comparing(ForgeHandshakeView::server)
.thenComparing(ForgeHandshakeView::player)
.thenComparing(ForgeHandshakeView::remoteAddress))
.limit(packetTopN)
.toList();
var blocked = snapshot.forgeHandshakes().values().stream()
.filter(ProxyMetrics.ForgeHandshake::backendSwitchBlocked)
.count();
var incomplete = snapshot.forgeHandshakes().values().stream()
.filter(handshake -> !handshake.complete())
.count();
return new ForgeHandshakeReport(snapshot.forgeHandshakes().size(), blocked, incomplete, rows);
}
private CompressionReport compressionReport(ProxyMetrics.Snapshot snapshot) {
var rows = new java.util.ArrayList<CompressionReportRow>();
rows.add(CompressionReportRow.from("global", "all", snapshot.compression(), -1, snapshot.compressionNegotiations()));
@@ -677,8 +708,10 @@ public final class AdminHttpServer implements AutoCloseable {
packetTrafficReport(snapshot),
customPayloadReport(snapshot),
packetAnomalyReport(snapshot),
forgeHandshakeReport(snapshot),
relayBackpressureReport(snapshot),
payloadCaptureReport(snapshot),
playerTransferReport(snapshot),
playerSessionReport(snapshot)));
}
@@ -818,6 +851,27 @@ public final class AdminHttpServer implements AutoCloseable {
}
}
private static void appendForgeHandshakeMetrics(
StringBuilder body,
Map<ProxyMetrics.ForgeHandshakeKey, ProxyMetrics.ForgeHandshake> handshakes) {
for (var entry : handshakes.entrySet()) {
var key = entry.getKey();
var value = entry.getValue();
var labels = "server=\"" + label(key.server())
+ "\",player=\"" + label(key.player())
+ "\",remote=\"" + label(key.remoteAddress())
+ "\",stage=\"" + label(value.stage())
+ "\",client_phase=\"" + label(value.clientPhase())
+ "\",backend_phase=\"" + label(value.backendPhase()) + "\"";
appendMetric(body, "gauge", "strataproxy_forge_handshake_complete", labels, value.complete() ? 1 : 0);
appendMetric(body, "gauge", "strataproxy_forge_handshake_backend_switch_blocked", labels, value.backendSwitchBlocked() ? 1 : 0);
appendMetric(body, "gauge", "strataproxy_forge_handshake_client_mods", labels, value.clientMods());
appendMetric(body, "gauge", "strataproxy_forge_handshake_server_mods", labels, value.serverMods());
appendMetric(body, "gauge", "strataproxy_forge_handshake_registry_packets", labels, value.registryPackets());
appendMetric(body, "gauge", "strataproxy_forge_handshake_registry_bytes", labels, value.registryBytes());
}
}
private static void appendRelayBackpressureMetrics(
StringBuilder body,
Map<ProxyMetrics.RelayBackpressureKey, ProxyMetrics.RelayBackpressure> backpressure) {
@@ -1631,8 +1685,10 @@ public final class AdminHttpServer implements AutoCloseable {
* @param packetTraffic packet traffic
* @param customPayloads custom payloads
* @param packetAnomalies packet anomalies
* @param forgeHandshakes forge handshakes
* @param relayBackpressure relay backpressure
* @param payloadCaptures payload captures
* @param playerTransfers player transfers
* @param playerSessions player sessions
*/
public record DiagnosticReport(
@@ -1645,8 +1701,10 @@ public final class AdminHttpServer implements AutoCloseable {
PacketTrafficReport packetTraffic,
CustomPayloadReport customPayloads,
PacketAnomalyReport packetAnomalies,
ForgeHandshakeReport forgeHandshakes,
RelayBackpressureReport relayBackpressure,
PayloadCaptureReport payloadCaptures,
PlayerTransferReport playerTransfers,
PlayerSessionReport playerSessions) {
}
@@ -1658,6 +1716,54 @@ public final class AdminHttpServer implements AutoCloseable {
public record RejectionReport(long rejectedConnections, Map<String, Long> rejectedConnectionsByReason) {
}
private PlayerTransferReport playerTransferReport(ProxyMetrics.Snapshot snapshot) {
var transfers = snapshot.recentPlayerTransfers().stream()
.map(PlayerTransferSampleView::from)
.toList();
return new PlayerTransferReport(transfers.size(), transfers);
}
/**
* Recent player transfer report.
* @param recent recent count
* @param transfers transfers
*/
public record PlayerTransferReport(int recent, List<PlayerTransferSampleView> transfers) {
}
/**
* Admin response view of one recent player transfer.
* @param sequence sequence
* @param success success
* @param outcome outcome
* @param player player
* @param sourceServer source server
* @param targetServer target server
* @param remoteAddress remote address
* @param timestamp timestamp
*/
public record PlayerTransferSampleView(
long sequence,
boolean success,
String outcome,
String player,
String sourceServer,
String targetServer,
String remoteAddress,
String timestamp) {
static PlayerTransferSampleView from(ProxyMetrics.PlayerTransferSample sample) {
return new PlayerTransferSampleView(
sample.sequence(),
sample.success(),
sample.outcome(),
sample.player(),
sample.sourceServer(),
sample.targetServer(),
sample.remoteAddress(),
sample.timestamp().toString());
}
}
private PlayerSessionReport playerSessionReport(ProxyMetrics.Snapshot snapshot) {
var players = snapshot.playerSessions().values().stream()
.map(PlayerSessionView::from)
@@ -1990,6 +2096,68 @@ public final class AdminHttpServer implements AutoCloseable {
}
}
/**
* Current legacy Forge handshake report response.
* @param active active
* @param backendSwitchBlocked backend switch blocked
* @param incomplete incomplete
* @param handshakes handshakes
*/
public record ForgeHandshakeReport(
int active,
long backendSwitchBlocked,
long incomplete,
List<ForgeHandshakeView> handshakes) {
}
/**
* Current legacy Forge handshake row.
* @param server server
* @param player player
* @param remoteAddress remote address
* @param stage stage
* @param clientPhase client phase
* @param backendPhase backend phase
* @param complete complete
* @param backendSwitchBlocked backend switch blocked
* @param clientMods client mods
* @param serverMods server mods
* @param registryPackets registry packets
* @param registryBytes registry bytes
* @param updatedAt updated at
*/
public record ForgeHandshakeView(
String server,
String player,
String remoteAddress,
String stage,
String clientPhase,
String backendPhase,
boolean complete,
boolean backendSwitchBlocked,
int clientMods,
int serverMods,
int registryPackets,
long registryBytes,
String updatedAt) {
static ForgeHandshakeView from(ProxyMetrics.ForgeHandshakeKey key, ProxyMetrics.ForgeHandshake value) {
return new ForgeHandshakeView(
key.server(),
key.player(),
key.remoteAddress(),
value.stage(),
value.clientPhase(),
value.backendPhase(),
value.complete(),
value.backendSwitchBlocked(),
value.clientMods(),
value.serverMods(),
value.registryPackets(),
value.registryBytes(),
value.updatedAt().toString());
}
}
/** Documents this public API element. */
public static final class PayloadCaptureRequest {
/**
@@ -585,9 +585,11 @@ final class AdminHttpServerTest {
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.forgeHandshake("survival-1", "Steve", "127.0.0.1:50000", "REGISTRY_DATA", "WAITING_SERVER_COMPLETE", "REGISTRY_DATA_SENT", false, true, 64, 66, 3, 32768);
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.playerTransfer(true, "success", "Steve", "lobby-1", "survival-1", "127.0.0.1:50000");
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();
@@ -611,10 +613,14 @@ final class AdminHttpServerTest {
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("\"forgeHandshakes\""));
assertTrue(response.body().contains("\"stage\":\"REGISTRY_DATA\",\"clientPhase\":\"WAITING_SERVER_COMPLETE\",\"backendPhase\":\"REGISTRY_DATA_SENT\""));
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("\"playerTransfers\""));
assertTrue(response.body().contains("\"success\":true,\"outcome\":\"success\",\"player\":\"Steve\",\"sourceServer\":\"lobby-1\",\"targetServer\":\"survival-1\""));
assertTrue(response.body().contains("\"playerSessions\""));
assertTrue(response.body().contains("\"player\":\"Steve\",\"server\":\"survival-1\""));
assertTrue(response.body().contains("\"remoteAddress\":\"127.0.0.1:50000\""));
@@ -622,6 +628,42 @@ final class AdminHttpServerTest {
}
}
@Test
void exposesLegacyForgeHandshakeState() throws Exception {
var registry = new InMemoryServerRegistry();
var metrics = new ProxyMetrics();
metrics.forgeHandshake(
"lobby-1",
"Alex",
"127.0.0.1:50001",
"REGISTRY_DATA",
"WAITING_SERVER_COMPLETE",
"REGISTRY_DATA_SENT",
false,
true,
128,
130,
7,
65536);
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 handshakes = client.send(HttpRequest.newBuilder(URI.create(base + "/forge-handshakes")).GET().build(), HttpResponse.BodyHandlers.ofString());
var prometheus = client.send(HttpRequest.newBuilder(URI.create(base + "/metrics")).GET().build(), HttpResponse.BodyHandlers.ofString());
assertEquals(200, handshakes.statusCode());
assertTrue(handshakes.body().contains("\"active\":1"));
assertTrue(handshakes.body().contains("\"backendSwitchBlocked\":1"));
assertTrue(handshakes.body().contains("\"stage\":\"REGISTRY_DATA\""));
assertTrue(handshakes.body().contains("\"clientMods\":128"));
assertEquals(200, prometheus.statusCode());
assertTrue(prometheus.body().contains("strataproxy_forge_handshake_backend_switch_blocked{server=\"lobby-1\",player=\"Alex\",remote=\"127.0.0.1:50001\",stage=\"REGISTRY_DATA\",client_phase=\"WAITING_SERVER_COMPLETE\",backend_phase=\"REGISTRY_DATA_SENT\"} 1"));
assertTrue(prometheus.body().contains("strataproxy_forge_handshake_registry_bytes{server=\"lobby-1\",player=\"Alex\",remote=\"127.0.0.1:50001\",stage=\"REGISTRY_DATA\",client_phase=\"WAITING_SERVER_COMPLETE\",backend_phase=\"REGISTRY_DATA_SENT\"} 65536"));
}
}
@Test
void managesPayloadCaptures() throws Exception {
var registry = new InMemoryServerRegistry();
@@ -22,6 +22,21 @@ public final class MinecraftCustomPayloadClassifier {
* @return custom payload classification
*/
public static CustomPayloadClassification classify(ByteBuf packetFrame, int largePayloadBytes) {
return classify(packetFrame, largePayloadBytes, PayloadLengthFormat.REMAINING_BYTES);
}
/**
* Classifies a play-state custom payload frame.
*
* @param packetFrame packet payload containing packet id, channel, and custom payload bytes
* @param largePayloadBytes threshold used to mark a payload as large; zero disables the flag
* @param payloadLengthFormat custom payload body length encoding used by the protocol
* @return custom payload classification
*/
public static CustomPayloadClassification classify(
ByteBuf packetFrame,
int largePayloadBytes,
PayloadLengthFormat payloadLengthFormat) {
if (packetFrame == null) {
throw new IllegalArgumentException("packetFrame must not be null");
}
@@ -32,7 +47,7 @@ public final class MinecraftCustomPayloadClassifier {
try {
var packetId = MinecraftVarInts.read(view);
var channel = readString(view, MAX_CHANNEL_BYTES);
var payloadBytes = view.readableBytes();
var payloadBytes = payloadLength(payloadLengthFormat, view);
return new CustomPayloadClassification(
packetId,
channel,
@@ -44,6 +59,44 @@ public final class MinecraftCustomPayloadClassifier {
}
}
private static int payloadLength(PayloadLengthFormat format, ByteBuf input) {
var resolved = format == null ? PayloadLengthFormat.REMAINING_BYTES : format;
return switch (resolved) {
case REMAINING_BYTES -> input.readableBytes();
case UNSIGNED_SHORT -> unsignedShortPayloadLength(input);
case VARSHORT -> varShortPayloadLength(input);
};
}
private static int unsignedShortPayloadLength(ByteBuf input) {
if (input.readableBytes() < Short.BYTES) {
throw new MinecraftCodecException("truncated custom payload length");
}
var length = input.readUnsignedShort();
if (input.readableBytes() < length) {
throw new MinecraftCodecException("truncated custom payload body");
}
return length;
}
private static int varShortPayloadLength(ByteBuf input) {
if (input.readableBytes() < Short.BYTES) {
throw new MinecraftCodecException("truncated custom payload length");
}
var low = input.readUnsignedShort();
var length = low & 0x7FFF;
if ((low & 0x8000) != 0) {
if (!input.isReadable()) {
throw new MinecraftCodecException("truncated custom payload varshort extension");
}
length |= input.readUnsignedByte() << 15;
}
if (input.readableBytes() < length) {
throw new MinecraftCodecException("truncated custom payload body");
}
return length;
}
/**
* Classifies a login plugin request frame.
*
@@ -95,7 +148,10 @@ public final class MinecraftCustomPayloadClassifier {
}
if (normalized.startsWith("fml:")
|| normalized.startsWith("forge:")
|| normalized.equals("fml")
|| normalized.equals("fml|hs")
|| normalized.equals("fml|mp")
|| normalized.equals("register")
|| normalized.equals("forge")) {
return CustomPayloadKind.FORGE_HANDSHAKE;
}
@@ -126,6 +182,18 @@ public final class MinecraftCustomPayloadClassifier {
UNKNOWN
}
/**
* Custom payload body length encoding.
*/
public enum PayloadLengthFormat {
/** Payload consumes the rest of the packet after the channel field. */
REMAINING_BYTES,
/** Payload is prefixed by a two-byte unsigned length. */
UNSIGNED_SHORT,
/** Forge-compatible 1.7 clientbound extension over the two-byte length. */
VARSHORT
}
/**
* Metadata extracted from a custom payload packet.
*
@@ -44,6 +44,38 @@ final class MinecraftCustomPayloadClassifierTest {
}
}
@Test
void classifiesLegacyForgeChannelsWithShortPayloadLength() {
var fmlHandshake = legacyCustomPayload(0x17, "FML|HS", 64);
var fmlMultipart = legacyCustomPayload(0x17, "FML|MP", 32);
var register = legacyCustomPayload(0x17, "REGISTER", 16);
try {
var handshake = MinecraftCustomPayloadClassifier.classify(
fmlHandshake,
1024,
MinecraftCustomPayloadClassifier.PayloadLengthFormat.UNSIGNED_SHORT);
var multipart = MinecraftCustomPayloadClassifier.classify(
fmlMultipart,
1024,
MinecraftCustomPayloadClassifier.PayloadLengthFormat.UNSIGNED_SHORT);
var registration = MinecraftCustomPayloadClassifier.classify(
register,
1024,
MinecraftCustomPayloadClassifier.PayloadLengthFormat.UNSIGNED_SHORT);
assertEquals(0x17, handshake.packetId());
assertEquals("FML|HS", handshake.channel());
assertEquals(64, handshake.payloadBytes());
assertEquals(MinecraftCustomPayloadClassifier.CustomPayloadKind.FORGE_HANDSHAKE, handshake.kind());
assertEquals(MinecraftCustomPayloadClassifier.CustomPayloadKind.FORGE_HANDSHAKE, multipart.kind());
assertEquals(MinecraftCustomPayloadClassifier.CustomPayloadKind.FORGE_HANDSHAKE, registration.kind());
} finally {
fmlHandshake.release();
fmlMultipart.release();
register.release();
}
}
@Test
void classifiesLoginPluginRequestWithoutConsumingInput() {
var frame = loginPluginRequest(0x04, 7, "fml:handshake", 128);
@@ -107,6 +139,17 @@ final class MinecraftCustomPayloadClassifierTest {
return frame;
}
private static io.netty.buffer.ByteBuf legacyCustomPayload(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.writeShort(payloadBytes);
frame.writeZero(payloadBytes);
return frame;
}
private static io.netty.buffer.ByteBuf loginPluginRequest(int packetId, int messageId, String channel, int payloadBytes) {
var frame = Unpooled.buffer();
MinecraftVarInts.write(frame, packetId);
@@ -18,6 +18,7 @@ final class BackendConnector {
private final MinecraftForwardingRuntime forwardingRuntime;
private final boolean compressionRewriteEnabled;
private final int compressionRewriteMaxEventLoopDelayMillis;
private final MinecraftProtocolProfile profile;
BackendConnector(
ProxyMetrics metrics,
@@ -26,7 +27,28 @@ final class BackendConnector {
CompressionRuntime compressionRuntime,
MinecraftForwardingRuntime forwardingRuntime,
boolean compressionRewriteEnabled,
int compressionRewriteMaxEventLoopDelayMillis) {
int compressionRewriteMaxEventLoopDelayMillis,
int protocolVersion) {
this(
metrics,
tuning,
backendChannel,
compressionRuntime,
forwardingRuntime,
compressionRewriteEnabled,
compressionRewriteMaxEventLoopDelayMillis,
MinecraftProtocolProfile.forVersion(protocolVersion));
}
BackendConnector(
ProxyMetrics metrics,
NetworkTuning tuning,
Class<? extends Channel> backendChannel,
CompressionRuntime compressionRuntime,
MinecraftForwardingRuntime forwardingRuntime,
boolean compressionRewriteEnabled,
int compressionRewriteMaxEventLoopDelayMillis,
MinecraftProtocolProfile profile) {
this.metrics = metrics;
this.tuning = tuning;
this.backendChannel = backendChannel;
@@ -34,6 +56,9 @@ final class BackendConnector {
this.forwardingRuntime = forwardingRuntime == null ? MinecraftForwardingRuntime.none() : forwardingRuntime;
this.compressionRewriteEnabled = compressionRewriteEnabled;
this.compressionRewriteMaxEventLoopDelayMillis = compressionRewriteMaxEventLoopDelayMillis;
this.profile = profile == null
? MinecraftProtocolProfile.forVersion(MinecraftProtocolProfile.PROTOCOL_1_20_1)
: profile;
}
ChannelFuture connect(
@@ -58,6 +83,7 @@ final class BackendConnector {
.handler(new ChannelInitializer<Channel>() {
@Override
protected void initChannel(Channel backend) {
var forgeHandshakeTracker = session.startForgeHandshakeTracker(tuning.maxFrameBytes(), profile);
backend.pipeline().addLast("backend-relay", new BackendRelayHandler(
frontend,
metrics,
@@ -69,7 +95,9 @@ final class BackendConnector {
forwardingRuntime,
compressionRewriteEnabled,
compressionRewriteMaxEventLoopDelayMillis,
replacementController));
profile,
replacementController,
forgeHandshakeTracker));
}
});
return bootstrap.connect(selected.descriptor().address());
@@ -80,6 +108,7 @@ final class BackendConnector {
RegisteredServer selected,
MinecraftCompressionAuditState compressionAudit,
RelaySession session,
MinecraftForgeHandshakeTracker forgeHandshakeTracker,
BackendSwitchLoginHandler.Listener listener) {
var serverName = selected.descriptor().name();
var bootstrap = new Bootstrap()
@@ -103,6 +132,8 @@ final class BackendConnector {
compressionAudit,
forwardingRuntime,
session.identity(),
profile,
forgeHandshakeTracker,
listener));
}
});
@@ -1,5 +1,6 @@
package dev.strataproxy.network;
import dev.strataproxy.codec.minecraft.MinecraftCompressionCodec;
import dev.strataproxy.compression.CompressionAction;
import dev.strataproxy.observability.ProxyMetrics;
import dev.strataproxy.observability.ProxyMetrics.CompressionDirection;
@@ -8,20 +9,27 @@ import io.netty.channel.Channel;
import io.netty.channel.ChannelFutureListener;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.ChannelInboundHandlerAdapter;
import io.netty.util.AttributeKey;
import io.netty.util.ReferenceCountUtil;
import java.util.ArrayList;
import java.util.List;
final class BackendRelayHandler extends ChannelInboundHandlerAdapter {
static final AttributeKey<BackendRelayHandler> HANDLER = AttributeKey.valueOf("strataproxy.backendRelayHandler");
private static final String RULE_COMPRESSION_NEGOTIATION_MALFORMED = "compression-negotiation-malformed";
private static final String RULE_COMPRESSION_AUDIT_MALFORMED = "compression-audit-malformed";
private static final String RULE_LEGACY_SWITCH_JOIN_GAME_MALFORMED = "legacy-switch-join-game-malformed";
private static final String RULE_LEGACY_FORGE_HANDSHAKE_GATE_MALFORMED = "legacy-forge-handshake-gate-malformed";
private static final int MAX_LEGACY_FORGE_QUEUED_BYTES = 1_048_576;
private final Channel frontend;
private final ProxyMetrics metrics;
private final String serverName;
private final MinecraftCompressionAuditState compressionAudit;
private final MinecraftCompressionNegotiationDetector compressionDetector;
private final MinecraftCompressionCodec legacySwitchCompressionCodec = new MinecraftCompressionCodec();
private final CompressionRuntime compressionRuntime;
private final MinecraftPacketTrafficSampler packetTrafficSampler;
private final MinecraftLoginPluginRequestInspectionSampler loginPluginRequestInspection;
@@ -31,6 +39,15 @@ final class BackendRelayHandler extends ChannelInboundHandlerAdapter {
private final CompressionRewriteRuntime compressionRewriteRuntime;
private final BackendReplacementController replacementController;
private final int maxFrameBytes;
private final MinecraftProtocolProfile profile;
private final MinecraftForgeHandshakeTracker forgeHandshakeTracker;
private ByteBuf legacyJoinGamePending;
private ByteBuf legacyForgeGatePending;
private ByteBuf legacyForgeQueuedFrames;
private ChannelHandlerContext backendContext;
private MinecraftProtocolProfile.BackendSwitchStrategy backendSwitchStrategy;
private boolean replayPluginChannelsAfterClientboundFlush;
private boolean deferredBackendReplacementReady;
private boolean closed;
BackendRelayHandler(Channel frontend, ProxyMetrics metrics, String serverName) {
@@ -133,20 +150,230 @@ final class BackendRelayHandler extends ChannelInboundHandlerAdapter {
boolean compressionRewriteEnabled,
int compressionRewriteMaxEventLoopDelayMillis,
BackendReplacementController replacementController) {
this(
frontend,
metrics,
serverName,
maxFrameBytes,
compressionAudit,
compressionRuntime,
identity,
forwardingRuntime,
compressionRewriteEnabled,
compressionRewriteMaxEventLoopDelayMillis,
763,
replacementController);
}
BackendRelayHandler(
Channel frontend,
ProxyMetrics metrics,
String serverName,
int maxFrameBytes,
MinecraftCompressionAuditState compressionAudit,
CompressionRuntime compressionRuntime,
RelaySessionIdentity identity,
MinecraftForwardingRuntime forwardingRuntime,
boolean compressionRewriteEnabled,
int compressionRewriteMaxEventLoopDelayMillis,
int protocolVersion,
BackendReplacementController replacementController) {
this(
frontend,
metrics,
serverName,
maxFrameBytes,
compressionAudit,
compressionRuntime,
identity,
forwardingRuntime,
compressionRewriteEnabled,
compressionRewriteMaxEventLoopDelayMillis,
MinecraftProtocolProfile.forVersion(protocolVersion),
replacementController);
}
BackendRelayHandler(
Channel frontend,
ProxyMetrics metrics,
String serverName,
int maxFrameBytes,
MinecraftCompressionAuditState compressionAudit,
CompressionRuntime compressionRuntime,
RelaySessionIdentity identity,
MinecraftForwardingRuntime forwardingRuntime,
boolean compressionRewriteEnabled,
int compressionRewriteMaxEventLoopDelayMillis,
MinecraftProtocolProfile profile,
BackendReplacementController replacementController) {
this(
frontend,
metrics,
serverName,
maxFrameBytes,
compressionAudit,
compressionRuntime,
identity,
forwardingRuntime,
compressionRewriteEnabled,
compressionRewriteMaxEventLoopDelayMillis,
profile,
replacementController,
null);
}
BackendRelayHandler(
Channel frontend,
ProxyMetrics metrics,
String serverName,
int maxFrameBytes,
MinecraftCompressionAuditState compressionAudit,
CompressionRuntime compressionRuntime,
RelaySessionIdentity identity,
MinecraftForwardingRuntime forwardingRuntime,
boolean compressionRewriteEnabled,
int compressionRewriteMaxEventLoopDelayMillis,
MinecraftProtocolProfile profile,
BackendReplacementController replacementController,
MinecraftForgeHandshakeTracker forgeHandshakeTracker) {
this(
frontend,
metrics,
serverName,
maxFrameBytes,
compressionAudit,
compressionRuntime,
identity,
forwardingRuntime,
compressionRewriteEnabled,
compressionRewriteMaxEventLoopDelayMillis,
profile,
replacementController,
forgeHandshakeTracker,
false);
}
BackendRelayHandler(
Channel frontend,
ProxyMetrics metrics,
String serverName,
int maxFrameBytes,
MinecraftCompressionAuditState compressionAudit,
CompressionRuntime compressionRuntime,
RelaySessionIdentity identity,
MinecraftForwardingRuntime forwardingRuntime,
boolean compressionRewriteEnabled,
int compressionRewriteMaxEventLoopDelayMillis,
MinecraftProtocolProfile profile,
BackendReplacementController replacementController,
MinecraftForgeHandshakeTracker forgeHandshakeTracker,
boolean convertNextLegacyJoinGameToRespawn) {
this(
frontend,
metrics,
serverName,
maxFrameBytes,
compressionAudit,
compressionRuntime,
identity,
forwardingRuntime,
compressionRewriteEnabled,
compressionRewriteMaxEventLoopDelayMillis,
profile,
replacementController,
forgeHandshakeTracker,
convertNextLegacyJoinGameToRespawn
? MinecraftProtocolProfile.BackendSwitchStrategy.RESPAWN_ONLY
: MinecraftProtocolProfile.BackendSwitchStrategy.NONE);
}
BackendRelayHandler(
Channel frontend,
ProxyMetrics metrics,
String serverName,
int maxFrameBytes,
MinecraftCompressionAuditState compressionAudit,
CompressionRuntime compressionRuntime,
RelaySessionIdentity identity,
MinecraftForwardingRuntime forwardingRuntime,
boolean compressionRewriteEnabled,
int compressionRewriteMaxEventLoopDelayMillis,
MinecraftProtocolProfile profile,
BackendReplacementController replacementController,
MinecraftForgeHandshakeTracker forgeHandshakeTracker,
boolean convertNextLegacyJoinGameToRespawn,
boolean includeLegacySwitchJoinGame) {
this(
frontend,
metrics,
serverName,
maxFrameBytes,
compressionAudit,
compressionRuntime,
identity,
forwardingRuntime,
compressionRewriteEnabled,
compressionRewriteMaxEventLoopDelayMillis,
profile,
replacementController,
forgeHandshakeTracker,
convertNextLegacyJoinGameToRespawn
? includeLegacySwitchJoinGame
? MinecraftProtocolProfile.BackendSwitchStrategy.JOIN_GAME_THEN_RESPAWN
: MinecraftProtocolProfile.BackendSwitchStrategy.RESPAWN_ONLY
: MinecraftProtocolProfile.BackendSwitchStrategy.NONE);
}
BackendRelayHandler(
Channel frontend,
ProxyMetrics metrics,
String serverName,
int maxFrameBytes,
MinecraftCompressionAuditState compressionAudit,
CompressionRuntime compressionRuntime,
RelaySessionIdentity identity,
MinecraftForwardingRuntime forwardingRuntime,
boolean compressionRewriteEnabled,
int compressionRewriteMaxEventLoopDelayMillis,
MinecraftProtocolProfile profile,
BackendReplacementController replacementController,
MinecraftForgeHandshakeTracker forgeHandshakeTracker,
MinecraftProtocolProfile.BackendSwitchStrategy backendSwitchStrategy) {
this.frontend = frontend;
this.metrics = metrics;
this.serverName = serverName;
this.compressionAudit = compressionAudit;
this.compressionDetector = new MinecraftCompressionNegotiationDetector(maxFrameBytes);
this.profile = profile == null
? MinecraftProtocolProfile.forVersion(MinecraftProtocolProfile.PROTOCOL_1_20_1)
: profile;
this.compressionDetector = new MinecraftCompressionNegotiationDetector(maxFrameBytes, this.profile);
this.compressionRuntime = compressionRuntime;
this.packetTrafficSampler = new MinecraftPacketTrafficSampler(maxFrameBytes);
this.loginPluginRequestInspection = new MinecraftLoginPluginRequestInspectionSampler(maxFrameBytes, maxFrameBytes);
this.bungeeConnectSampler = new BungeeConnectRequestSampler(maxFrameBytes);
this.bungeeConnectSampler = new BungeeConnectRequestSampler(maxFrameBytes, this.profile);
this.identity = identity;
this.forwardingRuntime = forwardingRuntime == null ? MinecraftForwardingRuntime.none() : forwardingRuntime;
this.compressionRewriteRuntime = new CompressionRewriteRuntime(compressionRewriteEnabled, compressionRewriteMaxEventLoopDelayMillis);
this.replacementController = replacementController;
this.maxFrameBytes = maxFrameBytes;
this.forgeHandshakeTracker = forgeHandshakeTracker;
this.backendSwitchStrategy = backendSwitchStrategy == null
? MinecraftProtocolProfile.BackendSwitchStrategy.NONE
: backendSwitchStrategy;
}
@Override
/** Provides handler added. */
public void handlerAdded(ChannelHandlerContext context) {
backendContext = context;
context.channel().attr(HANDLER).set(this);
}
@Override
/** Provides handler removed. */
public void handlerRemoved(ChannelHandlerContext context) {
context.channel().attr(HANDLER).set(null);
backendContext = null;
}
@Override
@@ -161,9 +388,31 @@ final class BackendRelayHandler extends ChannelInboundHandlerAdapter {
var forward = true;
List<CompressionAction> compressionActions = List.of();
if (message instanceof ByteBuf buffer) {
ByteBuf converted;
try {
converted = convertLegacySwitchJoinGameIfNeeded(context, buffer);
} catch (RuntimeException exception) {
ReferenceCountUtil.release(outbound);
context.close();
if (frontend.isOpen()) {
frontend.close();
}
return;
}
if (converted == null) {
ReferenceCountUtil.release(message);
context.channel().read();
return;
}
if (converted != buffer) {
outbound = converted;
ReferenceCountUtil.release(message);
buffer = converted;
}
var legacyForgeBlockedBefore = legacyForgeHandshakeBlocksClientboundFrames();
var forwardingRequest = observeVelocityForwardingRequest(context, buffer);
if (forwardingRequest.matched()) {
ReferenceCountUtil.release(message);
ReferenceCountUtil.release(outbound);
var response = VelocityModernForwarding.response(
context.alloc(),
forwardingRequest.messageId(),
@@ -183,16 +432,41 @@ final class BackendRelayHandler extends ChannelInboundHandlerAdapter {
return;
}
if (observeBungeeConnectRequest(context, buffer)) {
ReferenceCountUtil.release(message);
ReferenceCountUtil.release(outbound);
return;
}
metrics.backendToFrontendBytes(serverName, buffer.readableBytes());
capturePayloadPrefix(buffer, CompressionDirection.BACKEND_TO_FRONTEND);
observeForgeHandshake(context, buffer);
observeLegacyClientState(context, buffer);
observeLoginPluginRequests(buffer);
observePacketTraffic(buffer);
var compression = observeCompression(buffer);
forward = compression.shouldForward();
compressionActions = compression.actions();
if (forward && outbound instanceof ByteBuf outboundBuffer) {
ByteBuf gated;
try {
gated = gateLegacyForgeHandshakeFrames(context, outboundBuffer, legacyForgeBlockedBefore);
} catch (RuntimeException exception) {
ReferenceCountUtil.release(outbound);
context.close();
if (frontend.isOpen()) {
frontend.close();
}
return;
}
if (gated == null) {
ReferenceCountUtil.release(outbound);
context.channel().read();
return;
}
if (gated != outboundBuffer) {
ReferenceCountUtil.release(outbound);
outbound = gated;
buffer = gated;
}
}
if (forward && compression.rewriteEligible()) {
var rewrite = compressionRewriteRuntime.rewrite(
context.alloc(),
@@ -204,12 +478,12 @@ final class BackendRelayHandler extends ChannelInboundHandlerAdapter {
compressionActions,
maxFrameBytes);
if (rewrite.suppressed()) {
ReferenceCountUtil.release(message);
ReferenceCountUtil.release(outbound);
context.channel().read();
return;
}
if (rewrite.replaced()) {
ReferenceCountUtil.release(message);
ReferenceCountUtil.release(outbound);
outbound = rewrite.frame();
}
}
@@ -225,6 +499,9 @@ final class BackendRelayHandler extends ChannelInboundHandlerAdapter {
recordBackpressureIfNeeded(frontend);
frontend.writeAndFlush(outbound).addListener((ChannelFutureListener) future -> {
if (future.isSuccess()) {
replayPluginChannelsAfterClientboundFlush(context.channel());
flushQueuedLegacyForgeFramesIfReady();
runDeferredBackendReplacementIfReady();
context.channel().read();
} else {
future.channel().close();
@@ -233,6 +510,142 @@ final class BackendRelayHandler extends ChannelInboundHandlerAdapter {
});
}
private ByteBuf convertLegacySwitchJoinGameIfNeeded(ChannelHandlerContext context, ByteBuf buffer) {
if (backendSwitchStrategy == MinecraftProtocolProfile.BackendSwitchStrategy.NONE
|| profile.clientboundPlayLoginPacketId().isEmpty()) {
return buffer;
}
if (legacyJoinGamePending == null) {
legacyJoinGamePending = context.alloc().buffer(buffer.readableBytes());
}
if (legacyJoinGamePending.readableBytes() + buffer.readableBytes() > maxFrameBytes + 5) {
recordLegacyJoinGameConversionFailure(
"pending_overflow",
legacyJoinGamePending.readableBytes() + buffer.readableBytes());
releaseLegacyJoinGamePending();
backendSwitchStrategy = MinecraftProtocolProfile.BackendSwitchStrategy.NONE;
throw new IllegalStateException("legacy switch Join Game frame exceeded maximum size");
}
legacyJoinGamePending.writeBytes(buffer, buffer.readerIndex(), buffer.readableBytes());
var output = context.alloc().buffer(legacyJoinGamePending.readableBytes());
try {
while (legacyJoinGamePending.isReadable()) {
var probe = MinecraftProtocolCodec.probeFrame(legacyJoinGamePending, maxFrameBytes);
if (!probe.complete()) {
break;
}
var frame = legacyJoinGamePending.readRetainedSlice(probe.totalBytes());
try {
convertLegacySwitchJoinGameFrame(context, output, frame);
} finally {
frame.release();
}
if (backendSwitchStrategy == MinecraftProtocolProfile.BackendSwitchStrategy.NONE) {
if (legacyJoinGamePending.isReadable()) {
output.writeBytes(legacyJoinGamePending, legacyJoinGamePending.readerIndex(), legacyJoinGamePending.readableBytes());
}
releaseLegacyJoinGamePending();
return output;
}
legacyJoinGamePending.discardReadBytes();
}
if (output.isReadable()) {
return output;
}
output.release();
return null;
} catch (RuntimeException exception) {
output.release();
recordLegacyJoinGameConversionFailure("malformed", legacyJoinGamePending.readableBytes());
releaseLegacyJoinGamePending();
backendSwitchStrategy = MinecraftProtocolProfile.BackendSwitchStrategy.NONE;
throw exception;
}
}
private void convertLegacySwitchJoinGameFrame(ChannelHandlerContext context, ByteBuf output, ByteBuf frame) {
var packet = decodeLegacySwitchFrame(context, frame);
try {
var packetStart = packet.readerIndex();
var packetId = MinecraftProtocolCodec.readVarInt(packet);
if (packetId == profile.clientboundPlayLoginPacketId().getAsInt()) {
packet.readerIndex(packetStart);
if (backendSwitchStrategy == MinecraftProtocolProfile.BackendSwitchStrategy.JOIN_GAME_THEN_RESPAWN) {
output.writeBytes(frame, frame.readerIndex(), frame.readableBytes());
}
var respawns = compressionAudit.negotiated()
? MinecraftLegacyTransferPackets.compressedRespawnSequenceFromJoinGame(
context.alloc(),
packet,
profile,
legacySwitchCompressionCodec,
compressionAudit.threshold())
: MinecraftLegacyTransferPackets.respawnSequenceFromJoinGame(context.alloc(), packet, profile);
try {
output.writeBytes(respawns, respawns.readerIndex(), respawns.readableBytes());
} finally {
respawns.release();
}
replayPluginChannelsAfterClientboundFlush = true;
backendSwitchStrategy = MinecraftProtocolProfile.BackendSwitchStrategy.NONE;
} else {
output.writeBytes(frame, frame.readerIndex(), frame.readableBytes());
}
} finally {
packet.release();
}
}
private ByteBuf decodeLegacySwitchFrame(ChannelHandlerContext context, ByteBuf frame) {
if (compressionAudit.negotiated()) {
var duplicate = frame.retainedDuplicate();
try {
return legacySwitchCompressionCodec.decodeFrame(
context.alloc(),
duplicate,
compressionAudit.threshold(),
maxFrameBytes);
} finally {
duplicate.release();
}
}
var packet = frame.retainedDuplicate();
try {
MinecraftProtocolCodec.readVarInt(packet);
return packet.readRetainedSlice(packet.readableBytes());
} finally {
packet.release();
}
}
private void replayPluginChannelsAfterClientboundFlush(Channel backend) {
if (!replayPluginChannelsAfterClientboundFlush || replacementController == null) {
return;
}
replayPluginChannelsAfterClientboundFlush = false;
replacementController.replayServerboundPluginChannels(backend, compressionAudit);
}
private void recordLegacyJoinGameConversionFailure(String reason, int bytes) {
metrics.packetAnomaly(
RULE_LEGACY_SWITCH_JOIN_GAME_MALFORMED,
identity.remoteAddress(),
serverName,
CompressionDirection.BACKEND_TO_FRONTEND.label(),
"PLAY",
profile.clientboundPlayLoginPacketId().orElse(-1),
Math.max(0, bytes),
-1,
reason);
}
private void releaseLegacyJoinGamePending() {
if (legacyJoinGamePending != null && legacyJoinGamePending.refCnt() > 0) {
legacyJoinGamePending.release();
}
legacyJoinGamePending = null;
}
private VelocityModernForwarding.ForwardingRequest observeVelocityForwardingRequest(ChannelHandlerContext context, ByteBuf buffer) {
if (!forwardingRuntime.velocityModern() || compressionAudit.negotiated()) {
return VelocityModernForwarding.ForwardingRequest.none();
@@ -265,11 +678,313 @@ final class BackendRelayHandler extends ChannelInboundHandlerAdapter {
}
closed = true;
compressionDetector.close();
legacySwitchCompressionCodec.close();
compressionAudit.closeBackendSampler();
packetTrafficSampler.close();
loginPluginRequestInspection.close();
bungeeConnectSampler.close();
compressionRewriteRuntime.close();
releaseLegacyJoinGamePending();
releaseLegacyForgeGatePending();
releaseLegacyForgeQueuedFrames();
metrics.forgeHandshakeClosed(serverName, identity.playerName(), identity.remoteAddress());
}
private void observeForgeHandshake(ChannelHandlerContext context, ByteBuf buffer) {
if (forgeHandshakeTracker == null) {
return;
}
try {
var events = compressionAudit.negotiated()
? forgeHandshakeTracker.observeCompressed(
MinecraftForgeHandshakeTracker.Direction.CLIENTBOUND,
context.alloc(),
buffer,
compressionAudit.threshold())
: forgeHandshakeTracker.observe(MinecraftForgeHandshakeTracker.Direction.CLIENTBOUND, buffer);
for (var event : events) {
if ("register".equals(event.type())
&& forgeHandshakeTracker.consumeResetRequiredOnNextForgeServer()
&& frontend.isActive()) {
forgeHandshakeTracker.resetHandshakeFromProxy();
frontend.write(forgeHandshakeResetFrame(context));
}
recordForgeHandshakeEvent(event);
}
recordForgeHandshakeState();
markDeferredBackendReplacementReady();
} catch (RuntimeException exception) {
metrics.packetAnomaly(
"forge-handshake-inspection-malformed",
identity.remoteAddress(),
serverName,
CompressionDirection.BACKEND_TO_FRONTEND.label(),
"PLAY",
-1,
buffer.readableBytes(),
-1,
exception.getMessage());
}
}
private ByteBuf forgeHandshakeResetFrame(ChannelHandlerContext context) {
var reset = MinecraftLegacyTransferPackets.forgeHandshakeResetFrame(context.alloc(), profile);
if (!compressionAudit.negotiated()) {
return reset;
}
try {
return MinecraftLegacyTransferPackets.compressFrameBatch(
context.alloc(),
reset,
legacySwitchCompressionCodec,
compressionAudit.threshold(),
maxFrameBytes);
} finally {
reset.release();
}
}
private void observeLegacyClientState(ChannelHandlerContext context, ByteBuf buffer) {
if (replacementController == null) {
return;
}
try {
if (compressionAudit.negotiated()) {
replacementController.observeCompressedLegacyClientState(
context.alloc(),
buffer,
compressionAudit.threshold(),
profile);
} else {
replacementController.observeLegacyClientState(buffer, profile);
}
} catch (RuntimeException exception) {
metrics.packetAnomaly(
"legacy-client-state-inspection-malformed",
identity.remoteAddress(),
serverName,
CompressionDirection.BACKEND_TO_FRONTEND.label(),
"PLAY",
-1,
buffer.readableBytes(),
-1,
exception.getMessage());
}
}
private void markDeferredBackendReplacementReady() {
if (replacementController != null && forgeHandshakeTracker.complete()) {
deferredBackendReplacementReady = true;
}
}
private void runDeferredBackendReplacementIfReady() {
if (deferredBackendReplacementReady && replacementController != null && forgeHandshakeTracker.complete()) {
deferredBackendReplacementReady = false;
replacementController.runDeferredBackendReplacementIfReady();
}
}
private boolean legacyForgeHandshakeBlocksClientboundFrames() {
return forgeHandshakeTracker != null
&& profile.legacyForgeHandshakeSupported()
&& forgeHandshakeTracker.backendSwitchBlocked();
}
private ByteBuf gateLegacyForgeHandshakeFrames(
ChannelHandlerContext context,
ByteBuf buffer,
boolean legacyForgeBlockedBefore) {
if (!legacyForgeBlockedBefore || !profile.legacyForgeHandshakeSupported()) {
return buffer;
}
if (legacyForgeGatePending == null) {
legacyForgeGatePending = context.alloc().buffer(buffer.readableBytes());
}
if (legacyForgeGatePending.readableBytes() + buffer.readableBytes() > maxFrameBytes + 5) {
recordLegacyForgeGateFailure("pending_overflow", legacyForgeGatePending.readableBytes() + buffer.readableBytes());
releaseLegacyForgeGatePending();
throw new IllegalStateException("legacy Forge handshake gate pending frame exceeded maximum size");
}
legacyForgeGatePending.writeBytes(buffer, buffer.readerIndex(), buffer.readableBytes());
var output = context.alloc().buffer(legacyForgeGatePending.readableBytes());
try {
while (legacyForgeGatePending.isReadable()) {
var probe = MinecraftProtocolCodec.probeFrame(legacyForgeGatePending, maxFrameBytes);
if (!probe.complete()) {
break;
}
var frame = legacyForgeGatePending.readRetainedSlice(probe.totalBytes());
try {
if (legacyForgeFrameAllowedDuringHandshake(context, frame)) {
output.writeBytes(frame, frame.readerIndex(), frame.readableBytes());
} else {
queueLegacyForgeFrame(context, frame);
}
} finally {
frame.release();
}
legacyForgeGatePending.discardReadBytes();
}
if (output.isReadable()) {
return output;
}
output.release();
return null;
} catch (RuntimeException exception) {
output.release();
releaseLegacyForgeGatePending();
throw exception;
}
}
private boolean legacyForgeFrameAllowedDuringHandshake(ChannelHandlerContext context, ByteBuf frame) {
var packet = legacyForgeGatePacket(context, frame);
try {
var packetId = MinecraftProtocolCodec.readVarInt(packet);
if (profile.clientboundCustomPayloadPacketId().isEmpty()
|| packetId != profile.clientboundCustomPayloadPacketId().getAsInt()) {
return true;
}
var channel = MinecraftProtocolCodec.readString(packet, 128);
return legacyForgeChannel(channel);
} finally {
packet.release();
}
}
private ByteBuf legacyForgeGatePacket(ChannelHandlerContext context, ByteBuf frame) {
if (compressionAudit.negotiated()) {
var duplicate = frame.retainedDuplicate();
try {
return legacySwitchCompressionCodec.decodeFrame(
context.alloc(),
duplicate,
compressionAudit.threshold(),
maxFrameBytes);
} finally {
duplicate.release();
}
}
var packet = frame.retainedDuplicate();
try {
MinecraftProtocolCodec.readVarInt(packet);
return packet.readRetainedSlice(packet.readableBytes());
} finally {
packet.release();
}
}
private void queueLegacyForgeFrame(ChannelHandlerContext context, ByteBuf frame) {
if (legacyForgeQueuedFrames == null) {
legacyForgeQueuedFrames = context.alloc().buffer(Math.min(frame.readableBytes(), 1024));
}
if (legacyForgeQueuedFrames.readableBytes() + frame.readableBytes() > MAX_LEGACY_FORGE_QUEUED_BYTES) {
recordLegacyForgeGateFailure("queue_overflow", legacyForgeQueuedFrames.readableBytes() + frame.readableBytes());
releaseLegacyForgeQueuedFrames();
throw new IllegalStateException("legacy Forge handshake queued frames exceeded maximum size");
}
legacyForgeQueuedFrames.writeBytes(frame, frame.readerIndex(), frame.readableBytes());
}
void flushQueuedLegacyForgeFramesIfReady() {
if (legacyForgeQueuedFrames == null
|| !legacyForgeQueuedFrames.isReadable()
|| forgeHandshakeTracker == null
|| !forgeHandshakeTracker.complete()
|| !frontend.isActive()) {
return;
}
var queued = legacyForgeQueuedFrames;
legacyForgeQueuedFrames = null;
frontend.writeAndFlush(queued).addListener((ChannelFutureListener) future -> {
if (!future.isSuccess()) {
future.channel().close();
if (backendContext != null) {
backendContext.close();
}
}
});
}
private void recordLegacyForgeGateFailure(String reason, int bytes) {
metrics.packetAnomaly(
RULE_LEGACY_FORGE_HANDSHAKE_GATE_MALFORMED,
identity.remoteAddress(),
serverName,
CompressionDirection.BACKEND_TO_FRONTEND.label(),
"PLAY",
-1,
Math.max(0, bytes),
-1,
reason);
}
private static boolean legacyForgeChannel(String channel) {
return "REGISTER".equals(channel)
|| "FML|HS".equals(channel)
|| "FML|MP".equals(channel)
|| "FML".equals(channel)
|| "FORGE".equals(channel);
}
private void releaseLegacyForgeGatePending() {
if (legacyForgeGatePending != null && legacyForgeGatePending.refCnt() > 0) {
legacyForgeGatePending.release();
}
legacyForgeGatePending = null;
}
private void releaseLegacyForgeQueuedFrames() {
if (legacyForgeQueuedFrames != null && legacyForgeQueuedFrames.refCnt() > 0) {
legacyForgeQueuedFrames.release();
}
legacyForgeQueuedFrames = null;
}
private void recordForgeHandshakeEvent(MinecraftForgeHandshakeTracker.Event event) {
metrics.customPayload(
serverName,
CompressionDirection.BACKEND_TO_FRONTEND,
"FORGE_HANDSHAKE_" + event.type().toUpperCase(java.util.Locale.ROOT),
event.channel(),
event.payloadBytes(),
0,
identity.playerName(),
identity.remoteAddress(),
"PLAY",
profile.clientboundCustomPayloadPacketId().orElse(-1));
if (!event.expected()) {
metrics.packetAnomaly(
"forge-handshake-unexpected-order",
identity.remoteAddress(),
serverName,
CompressionDirection.BACKEND_TO_FRONTEND.label(),
"PLAY",
profile.clientboundCustomPayloadPacketId().orElse(-1),
event.payloadBytes(),
-1,
event.type() + " stage=" + event.stage());
}
}
private void recordForgeHandshakeState() {
if (forgeHandshakeTracker == null) {
return;
}
metrics.forgeHandshake(
serverName,
identity.playerName(),
identity.remoteAddress(),
forgeHandshakeTracker.stage().name(),
forgeHandshakeTracker.clientPhase().name(),
forgeHandshakeTracker.backendPhase().name(),
forgeHandshakeTracker.complete(),
forgeHandshakeTracker.backendSwitchBlocked(),
forgeHandshakeTracker.clientModCount(),
forgeHandshakeTracker.serverModCount(),
forgeHandshakeTracker.clientboundRegistryPackets(),
forgeHandshakeTracker.clientboundRegistryBytes());
}
private boolean observeBungeeConnectRequest(ChannelHandlerContext context, ByteBuf buffer) {
@@ -4,15 +4,18 @@ import dev.strataproxy.analysis.CustomPayloadAnomalyPolicy;
import dev.strataproxy.observability.ProxyMetrics;
import dev.strataproxy.plugin.command.CommandRegistry;
import dev.strataproxy.plugin.event.EventBus;
import dev.strataproxy.codec.minecraft.MinecraftCompressionCodec;
import io.netty.channel.Channel;
import io.netty.channel.ChannelFutureListener;
import io.netty.util.AttributeKey;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.TimeUnit;
final class BackendReplacementController {
static final AttributeKey<Boolean> MIGRATING_BACKEND = AttributeKey.valueOf("strataproxy.migratingBackend");
private static final long FORGE_DEFERRED_TRANSFER_TIMEOUT_MILLIS = 30_000L;
private final ServerTargetResolver targetResolver;
private final BackendConnector backendConnector;
@@ -24,7 +27,7 @@ final class BackendReplacementController {
private final RelaySessionRegistry sessions;
private final CommandRegistry commands;
private final EventBus events;
private final int protocolVersion;
private final MinecraftProtocolProfile profile;
private final boolean compressionRewriteEnabled;
private final int compressionRewriteMaxEventLoopDelayMillis;
@@ -42,6 +45,36 @@ final class BackendReplacementController {
CommandRegistry commands,
EventBus events,
int protocolVersion) {
this(
targetResolver,
backendConnector,
metrics,
tuning,
compressionRuntime,
customPayloadPolicy,
session,
sessions,
compressionRewriteEnabled,
compressionRewriteMaxEventLoopDelayMillis,
commands,
events,
MinecraftProtocolProfile.forVersion(protocolVersion));
}
BackendReplacementController(
ServerTargetResolver targetResolver,
BackendConnector backendConnector,
ProxyMetrics metrics,
NetworkTuning tuning,
CompressionRuntime compressionRuntime,
CustomPayloadAnomalyPolicy customPayloadPolicy,
RelaySession session,
RelaySessionRegistry sessions,
boolean compressionRewriteEnabled,
int compressionRewriteMaxEventLoopDelayMillis,
CommandRegistry commands,
EventBus events,
MinecraftProtocolProfile profile) {
this.targetResolver = targetResolver == null ? ServerTargetResolver.unavailable() : targetResolver;
this.backendConnector = backendConnector;
this.metrics = metrics;
@@ -52,7 +85,9 @@ final class BackendReplacementController {
this.sessions = sessions;
this.commands = commands;
this.events = events;
this.protocolVersion = protocolVersion;
this.profile = profile == null
? MinecraftProtocolProfile.forVersion(MinecraftProtocolProfile.PROTOCOL_1_20_1)
: profile;
this.compressionRewriteEnabled = compressionRewriteEnabled;
this.compressionRewriteMaxEventLoopDelayMillis = compressionRewriteMaxEventLoopDelayMillis;
this.session.replacementController(this);
@@ -63,7 +98,7 @@ final class BackendReplacementController {
var frontend = session.frontend();
if (frontend == null || !frontend.isActive()) {
metrics.backendReplacement("inactive_session");
result.complete(PlayerTransferResult.failure(
completeTransfer(result, PlayerTransferResult.failure(
"inactive_session",
session.identity().playerName(),
session.serverName(),
@@ -77,7 +112,7 @@ final class BackendReplacementController {
try {
frontend.eventLoop().execute(task);
} catch (RuntimeException exception) {
result.complete(PlayerTransferResult.failure(
completeTransfer(result, PlayerTransferResult.failure(
"event_loop_unavailable",
session.identity().playerName(),
session.serverName(),
@@ -95,12 +130,17 @@ final class BackendReplacementController {
CompletableFuture<PlayerTransferResult> result) {
if (targetServerName == null || targetServerName.isBlank()) {
metrics.backendReplacement("invalid_target");
result.complete(failure("invalid_target", currentServerName, targetServerName));
completeTransfer(result, failure("invalid_target", currentServerName, targetServerName));
return;
}
if (!profile.backendReplacementSupported()) {
metrics.backendReplacement("unsupported_protocol_switch");
completeTransfer(result, failure("unsupported_protocol_switch", currentServerName, targetServerName));
return;
}
if (!session.beginBackendReplacement()) {
metrics.backendReplacement("busy");
result.complete(failure("busy", currentServerName, targetServerName));
completeTransfer(result, failure("busy", currentServerName, targetServerName));
return;
}
metrics.backendReplacement("attempted");
@@ -109,7 +149,7 @@ final class BackendReplacementController {
session.finishBackendReplacement();
metrics.failedRoute();
metrics.backendReplacement("target_unavailable");
result.complete(failure("target_unavailable", currentServerName, targetServerName));
completeTransfer(result, failure("target_unavailable", currentServerName, targetServerName));
return;
}
@@ -117,23 +157,57 @@ final class BackendReplacementController {
if (nextServerName.equalsIgnoreCase(currentServerName)) {
session.finishBackendReplacement();
metrics.backendReplacement("same_server");
result.complete(failure("same_server", currentServerName, nextServerName));
completeTransfer(result, failure("same_server", currentServerName, nextServerName));
return;
}
var forgeHandshakeTracker = session.forgeHandshakeTracker();
if (forgeHandshakeTracker != null && forgeHandshakeTracker.backendSwitchBlocked()) {
session.finishBackendReplacement();
var pending = session.deferBackendTransfer(nextServerName, result);
if (pending != null) {
metrics.backendReplacement("forge_handshake_deferred");
metrics.playerTransfer(
false,
"forge_handshake_deferred",
session.identity().playerName(),
currentServerName,
nextServerName,
session.identity().remoteAddress());
scheduleDeferredTransferTimeout(frontend, pending);
} else {
metrics.backendReplacement("forge_handshake_transfer_pending");
completeTransfer(result, failure("forge_handshake_transfer_pending", currentServerName, nextServerName));
}
return;
}
var loginSession = session.loginSession();
if (loginSession == null || !loginSession.available()) {
session.finishBackendReplacement();
metrics.backendReplacement("missing_login_session");
result.complete(failure("missing_login_session", currentServerName, nextServerName));
completeTransfer(result, failure("missing_login_session", currentServerName, nextServerName));
return;
}
var resetClientForgeHandshake = forgeHandshakeTracker != null && forgeHandshakeTracker.complete();
var forgeTrackerSwap = session.beginForgeHandshakeTrackerSwap(tuning.maxFrameBytes(), profile);
var nextCompressionAudit = new MinecraftCompressionAuditState(tuning.maxFrameBytes());
frontend.config().setAutoRead(false);
pauseOldBackend(oldBackend);
var listener = new BackendSwitchLoginHandler.Listener() {
@Override
/** Provides backend login ready. */
public void backendLoginReady(Channel nextBackend) {
completeReplacement(frontend, oldBackend, currentServerName, nextServerName, nextBackend, nextCompressionAudit, result);
public void backendLoginReady(Channel nextBackend, io.netty.buffer.ByteBuf clientboundFrames, io.netty.buffer.ByteBuf remainingBackendFrames) {
completeReplacement(
frontend,
oldBackend,
currentServerName,
nextServerName,
nextBackend,
nextCompressionAudit,
clientboundFrames,
remainingBackendFrames,
resetClientForgeHandshake,
forgeTrackerSwap,
result);
}
@Override
@@ -144,18 +218,20 @@ final class BackendReplacementController {
if (nextBackend != null && nextBackend.isOpen()) {
nextBackend.close();
}
session.rollbackForgeHandshakeTrackerSwap(forgeTrackerSwap);
resumeOldRelay(frontend, oldBackend);
result.complete(failure(outcome, currentServerName, nextServerName));
completeTransfer(result, failure(outcome, currentServerName, nextServerName));
}
};
try {
backendConnector.connectForSwitchLogin(frontend, selected, nextCompressionAudit, session, listener).addListener((ChannelFutureListener) future -> {
backendConnector.connectForSwitchLogin(frontend, selected, nextCompressionAudit, session, forgeTrackerSwap.next(), listener).addListener((ChannelFutureListener) future -> {
if (!future.isSuccess()) {
metrics.backendConnectFailure();
metrics.backendReplacement("connect_failure");
session.finishBackendReplacement();
session.rollbackForgeHandshakeTrackerSwap(forgeTrackerSwap);
resumeOldRelay(frontend, oldBackend);
result.complete(failure("connect_failure", currentServerName, nextServerName));
completeTransfer(result, failure("connect_failure", currentServerName, nextServerName));
return;
}
@@ -170,32 +246,45 @@ final class BackendReplacementController {
if (nextBackend.isOpen()) {
nextBackend.close();
}
session.rollbackForgeHandshakeTrackerSwap(forgeTrackerSwap);
resumeOldRelay(frontend, oldBackend);
result.complete(failure("backend_login_write_failure", currentServerName, nextServerName));
completeTransfer(result, failure("backend_login_write_failure", currentServerName, nextServerName));
}
});
} catch (RuntimeException exception) {
metrics.backendConnectFailure();
metrics.backendReplacement("connect_failure");
session.finishBackendReplacement();
session.rollbackForgeHandshakeTrackerSwap(forgeTrackerSwap);
resumeOldRelay(frontend, oldBackend);
result.complete(failure("connect_failure", currentServerName, nextServerName));
completeTransfer(result, failure("connect_failure", currentServerName, nextServerName));
}
}
private void completeReplacement(
void completeReplacement(
Channel frontend,
Channel oldBackend,
String currentServerName,
String nextServerName,
Channel nextBackend,
MinecraftCompressionAuditState nextCompressionAudit,
io.netty.buffer.ByteBuf clientboundFrames,
io.netty.buffer.ByteBuf remainingBackendFrames,
boolean resetClientForgeHandshake,
RelaySession.ForgeHandshakeTrackerSwap forgeTrackerSwap,
CompletableFuture<PlayerTransferResult> result) {
if (result.isDone()) {
release(clientboundFrames);
release(remainingBackendFrames);
session.rollbackForgeHandshakeTrackerSwap(forgeTrackerSwap);
return;
}
var initialClientbound = prependForgeResetIfNeeded(frontend, clientboundFrames, resetClientForgeHandshake, nextCompressionAudit);
initialClientbound = prependLegacyClientStateResetIfNeeded(frontend, initialClientbound, nextCompressionAudit);
var initialBackend = remainingBackendFrames;
try {
var identity = session.identity();
var forgeHandshakeTracker = session.forgeHandshakeTracker();
var backendRelay = new BackendRelayHandler(
frontend,
metrics,
@@ -207,7 +296,10 @@ final class BackendReplacementController {
MinecraftForwardingRuntime.none(),
compressionRewriteEnabled,
compressionRewriteMaxEventLoopDelayMillis,
this);
profile,
this,
forgeHandshakeTracker,
profile.backendSwitchStrategy(session.legacyForgeClientDetected()));
if (nextBackend.pipeline().context("backend-switch-login") != null) {
nextBackend.pipeline().replace("backend-switch-login", "backend-relay", backendRelay);
} else {
@@ -228,7 +320,9 @@ final class BackendReplacementController {
this,
commands,
events,
protocolVersion);
profile,
null,
forgeHandshakeTracker);
var frontendPipeline = frontend.pipeline();
var oldRelay = session.frontendRelay();
if (oldRelay != null) {
@@ -245,10 +339,83 @@ final class BackendReplacementController {
}
relayAttached(nextRelay, frontend, nextBackend, nextServerName);
if (oldBackend != null && oldBackend.isOpen()) {
oldBackend.attr(MIGRATING_BACKEND).set(true);
oldBackend.close();
if (initialClientbound != null && initialClientbound.isReadable()) {
var pendingBackendFrames = initialBackend;
initialBackend = null;
frontend.writeAndFlush(initialClientbound).addListener((ChannelFutureListener) future -> {
if (future.isSuccess()) {
activateReplacementAfterInitialClientbound(
frontend,
oldBackend,
currentServerName,
nextServerName,
nextBackend,
pendingBackendFrames,
forgeTrackerSwap,
result);
} else {
release(pendingBackendFrames);
failReplacementAfterPipelineAttached(
frontend,
oldBackend,
nextBackend,
forgeTrackerSwap,
result,
"initial_clientbound_write_failure",
currentServerName,
nextServerName);
}
});
initialClientbound = null;
} else {
release(initialClientbound);
initialClientbound = null;
var pendingBackendFrames = initialBackend;
initialBackend = null;
activateReplacementAfterInitialClientbound(
frontend,
oldBackend,
currentServerName,
nextServerName,
nextBackend,
pendingBackendFrames,
forgeTrackerSwap,
result);
}
} catch (RuntimeException exception) {
release(initialClientbound);
release(initialBackend);
metrics.backendReplacement("pipeline_failure");
session.finishBackendReplacement();
if (nextBackend.isOpen()) {
nextBackend.close();
}
closeOldBackend(oldBackend);
closeFrontend(frontend);
session.rollbackForgeHandshakeTrackerSwap(forgeTrackerSwap);
completeTransfer(result, failure("pipeline_failure", currentServerName, nextServerName));
}
}
private void activateReplacementAfterInitialClientbound(
Channel frontend,
Channel oldBackend,
String currentServerName,
String nextServerName,
Channel nextBackend,
io.netty.buffer.ByteBuf initialBackend,
RelaySession.ForgeHandshakeTrackerSwap forgeTrackerSwap,
CompletableFuture<PlayerTransferResult> result) {
try {
var identity = session.identity();
if (initialBackend != null && initialBackend.isReadable()) {
nextBackend.pipeline().fireChannelRead(initialBackend);
initialBackend = null;
} else {
release(initialBackend);
initialBackend = null;
}
closeOldBackend(oldBackend);
session.finishBackendReplacement();
frontend.config().setAutoRead(false);
nextBackend.config().setAutoRead(false);
@@ -260,18 +427,45 @@ final class BackendReplacementController {
if (!identity.playerName().isBlank()) {
metrics.playerSessionStarted(identity.playerName(), nextServerName, identity.remoteAddress());
}
result.complete(PlayerTransferResult.success(identity.playerName(), currentServerName, nextServerName));
session.commitForgeHandshakeTrackerSwap(forgeTrackerSwap);
completeTransfer(result, PlayerTransferResult.success(identity.playerName(), currentServerName, nextServerName));
} catch (RuntimeException exception) {
metrics.backendReplacement("pipeline_failure");
session.finishBackendReplacement();
if (nextBackend.isOpen()) {
nextBackend.close();
}
resumeOldRelay(frontend, oldBackend);
result.complete(failure("pipeline_failure", currentServerName, nextServerName));
release(initialBackend);
failReplacementAfterPipelineAttached(
frontend,
oldBackend,
nextBackend,
forgeTrackerSwap,
result,
"pipeline_failure",
currentServerName,
nextServerName);
}
}
private void failReplacementAfterPipelineAttached(
Channel frontend,
Channel oldBackend,
Channel nextBackend,
RelaySession.ForgeHandshakeTrackerSwap forgeTrackerSwap,
CompletableFuture<PlayerTransferResult> result,
String outcome,
String currentServerName,
String nextServerName) {
if (result.isDone()) {
return;
}
metrics.backendReplacement(outcome);
session.finishBackendReplacement();
if (nextBackend.isOpen()) {
nextBackend.close();
}
closeOldBackend(oldBackend);
closeFrontend(frontend);
session.rollbackForgeHandshakeTrackerSwap(forgeTrackerSwap);
completeTransfer(result, failure(outcome, currentServerName, nextServerName));
}
void relayAttached(FrontendRelayHandler handler, Channel frontend, Channel backend, String serverName) {
session.attach(frontend, backend, serverName);
session.frontendRelay(handler);
@@ -284,22 +478,128 @@ final class BackendReplacementController {
}
}
void legacyForgeClientDetected() {
session.legacyForgeClientDetected(profile.legacyForgeHandshakeSupported());
}
void closeServerConnection(String serverName) {
metrics.serverConnectionClosed(serverName);
}
void closePlayerSession(String playerName) {
var pendingFailure = session.failPendingBackendTransfer("player_session_closed");
if (pendingFailure != null) {
metrics.playerTransfer(
pendingFailure.success(),
pendingFailure.outcome(),
pendingFailure.player(),
pendingFailure.sourceServer(),
pendingFailure.targetServer(),
session.identity().remoteAddress());
}
session.closeClientStateTracker();
session.closePluginChannelRegistry();
sessions.unregister(session);
metrics.playerSessionClosed(playerName);
}
void observeServerboundPluginChannels(io.netty.buffer.ByteBuf buffer, MinecraftProtocolProfile profile) {
session.pluginChannelRegistry().observeServerbound(buffer, profile);
}
void observeCompressedServerboundPluginChannels(
io.netty.buffer.ByteBufAllocator allocator,
io.netty.buffer.ByteBuf buffer,
int threshold,
MinecraftProtocolProfile profile) {
session.pluginChannelRegistry().observeServerboundCompressed(allocator, buffer, threshold, profile);
}
void replayServerboundPluginChannels(Channel backend) {
replayServerboundPluginChannels(backend, null);
}
void replayServerboundPluginChannels(Channel backend, MinecraftCompressionAuditState compressionAudit) {
if (backend == null || !backend.isActive()) {
return;
}
var frame = session.pluginChannelRegistry().registrationFrame(backend.alloc(), profile);
if (frame == null || !frame.isReadable()) {
release(frame);
return;
}
if (compressionAudit != null && compressionAudit.negotiated()) {
frame = compressedFrameBatch(backend, frame, compressionAudit);
}
backend.writeAndFlush(frame);
}
void observeLegacyClientState(io.netty.buffer.ByteBuf buffer, MinecraftProtocolProfile profile) {
session.clientStateTracker().observe(buffer, profile);
}
void observeCompressedLegacyClientState(
io.netty.buffer.ByteBufAllocator allocator,
io.netty.buffer.ByteBuf buffer,
int threshold,
MinecraftProtocolProfile profile) {
session.clientStateTracker().observeCompressed(allocator, buffer, threshold, profile);
}
void runDeferredBackendReplacementIfReady() {
var tracker = session.forgeHandshakeTracker();
if (tracker != null && tracker.backendSwitchBlocked()) {
return;
}
var pending = session.consumePendingBackendTransfer();
if (pending == null) {
return;
}
replaceBackend(pending.targetServerName()).whenComplete((result, exception) -> {
if (exception != null) {
completeTransfer(pending.result(), PlayerTransferResult.failure(
"deferred_transfer_exception",
session.identity().playerName(),
session.serverName(),
pending.targetServerName()));
} else {
pending.result().complete(result);
}
});
}
private void scheduleDeferredTransferTimeout(Channel frontend, RelaySession.PendingBackendTransfer pending) {
frontend.eventLoop().schedule(() -> {
if (!session.consumePendingBackendTransfer(pending)) {
return;
}
metrics.backendReplacement("forge_handshake_deferred_timeout");
completeTransfer(pending.result(), PlayerTransferResult.failure(
"forge_handshake_deferred_timeout",
session.identity().playerName(),
session.serverName(),
pending.targetServerName()));
}, FORGE_DEFERRED_TRANSFER_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS);
}
private PlayerTransferResult failure(String outcome, String sourceServer, String targetServer) {
return PlayerTransferResult.failure(outcome, session.identity().playerName(), sourceServer, targetServer);
}
private void completeTransfer(CompletableFuture<PlayerTransferResult> result, PlayerTransferResult transfer) {
metrics.playerTransfer(
transfer.success(),
transfer.outcome(),
transfer.player(),
transfer.sourceServer(),
transfer.targetServer(),
session.identity().remoteAddress());
result.complete(transfer);
}
private dev.strataproxy.api.server.RegisteredServer target(String targetServerName) {
try {
return targetResolver.resolveTarget(targetServerName.trim()).orElse(null);
return targetResolver.resolveTarget(targetServerName.trim(), profile.protocolVersion()).orElse(null);
} catch (RuntimeException exception) {
return null;
}
@@ -314,4 +614,119 @@ final class BackendReplacementController {
oldBackend.read();
}
}
private static void pauseOldBackend(Channel oldBackend) {
if (oldBackend != null && oldBackend.isActive()) {
oldBackend.config().setAutoRead(false);
}
}
private static void closeOldBackend(Channel oldBackend) {
if (oldBackend != null && oldBackend.isOpen()) {
oldBackend.attr(MIGRATING_BACKEND).set(true);
oldBackend.close();
}
}
private static void closeFrontend(Channel frontend) {
if (frontend != null && frontend.isOpen()) {
frontend.close();
}
}
private static void release(io.netty.buffer.ByteBuf buffer) {
if (buffer != null && buffer.refCnt() > 0) {
buffer.release();
}
}
io.netty.buffer.ByteBuf prependForgeResetIfNeeded(
Channel frontend,
io.netty.buffer.ByteBuf clientboundFrames,
boolean resetClientForgeHandshake) {
return prependForgeResetIfNeeded(frontend, clientboundFrames, resetClientForgeHandshake, null);
}
io.netty.buffer.ByteBuf prependForgeResetIfNeeded(
Channel frontend,
io.netty.buffer.ByteBuf clientboundFrames,
boolean resetClientForgeHandshake,
MinecraftCompressionAuditState compressionAudit) {
if (!resetClientForgeHandshake || !profile.legacyForgeHandshakeSupported()) {
return clientboundFrames;
}
var reset = MinecraftLegacyTransferPackets.forgeHandshakeResetFrame(frontend.alloc(), profile);
if (compressionAudit != null && compressionAudit.negotiated()) {
reset = compressedClientboundFrames(frontend, reset, compressionAudit);
}
if (clientboundFrames == null || !clientboundFrames.isReadable()) {
release(clientboundFrames);
return reset;
}
var combined = frontend.alloc().buffer(reset.readableBytes() + clientboundFrames.readableBytes());
try {
combined.writeBytes(reset, reset.readerIndex(), reset.readableBytes());
combined.writeBytes(clientboundFrames, clientboundFrames.readerIndex(), clientboundFrames.readableBytes());
return combined;
} finally {
reset.release();
clientboundFrames.release();
}
}
io.netty.buffer.ByteBuf prependLegacyClientStateResetIfNeeded(
Channel frontend,
io.netty.buffer.ByteBuf clientboundFrames) {
return prependLegacyClientStateResetIfNeeded(frontend, clientboundFrames, null);
}
io.netty.buffer.ByteBuf prependLegacyClientStateResetIfNeeded(
Channel frontend,
io.netty.buffer.ByteBuf clientboundFrames,
MinecraftCompressionAuditState compressionAudit) {
var reset = session.clientStateTracker().clearFrames(frontend.alloc(), profile);
if (reset == null || !reset.isReadable()) {
release(reset);
return clientboundFrames;
}
if (compressionAudit != null && compressionAudit.negotiated()) {
reset = compressedClientboundFrames(frontend, reset, compressionAudit);
}
if (clientboundFrames == null || !clientboundFrames.isReadable()) {
release(clientboundFrames);
return reset;
}
var combined = frontend.alloc().buffer(reset.readableBytes() + clientboundFrames.readableBytes());
try {
combined.writeBytes(reset, reset.readerIndex(), reset.readableBytes());
combined.writeBytes(clientboundFrames, clientboundFrames.readerIndex(), clientboundFrames.readableBytes());
return combined;
} finally {
reset.release();
clientboundFrames.release();
}
}
private io.netty.buffer.ByteBuf compressedClientboundFrames(
Channel frontend,
io.netty.buffer.ByteBuf frames,
MinecraftCompressionAuditState compressionAudit) {
return compressedFrameBatch(frontend, frames, compressionAudit);
}
private io.netty.buffer.ByteBuf compressedFrameBatch(
Channel channel,
io.netty.buffer.ByteBuf frames,
MinecraftCompressionAuditState compressionAudit) {
try (var codec = new MinecraftCompressionCodec()) {
return MinecraftLegacyTransferPackets.compressFrameBatch(
channel.alloc(),
frames,
codec,
compressionAudit.threshold(),
tuning.maxFrameBytes());
} finally {
release(frames);
}
}
}
@@ -9,17 +9,13 @@ import io.netty.handler.codec.ByteToMessageDecoder;
import java.util.List;
final class BackendSwitchLoginHandler extends ByteToMessageDecoder {
private static final int CLIENTBOUND_LOGIN_DISCONNECT_PACKET_ID = 0x00;
private static final int CLIENTBOUND_LOGIN_ENCRYPTION_REQUEST_PACKET_ID = 0x01;
private static final int CLIENTBOUND_LOGIN_SUCCESS_PACKET_ID = 0x02;
private static final int CLIENTBOUND_LOGIN_SET_COMPRESSION_PACKET_ID = 0x03;
private final dev.strataproxy.observability.ProxyMetrics metrics;
private final String serverName;
private final int maxFrameBytes;
private final MinecraftCompressionAuditState compressionAudit;
private final MinecraftForwardingRuntime forwardingRuntime;
private final RelaySessionIdentity identity;
private final MinecraftProtocolProfile profile;
private final Listener listener;
private final MinecraftCompressionCodec compressionCodec = new MinecraftCompressionCodec();
private boolean terminal;
@@ -32,6 +28,50 @@ final class BackendSwitchLoginHandler extends ByteToMessageDecoder {
MinecraftCompressionAuditState compressionAudit,
MinecraftForwardingRuntime forwardingRuntime,
RelaySessionIdentity identity,
int protocolVersion,
Listener listener) {
this(
metrics,
serverName,
maxFrameBytes,
compressionAudit,
forwardingRuntime,
identity,
MinecraftProtocolProfile.forVersion(protocolVersion),
null,
listener);
}
BackendSwitchLoginHandler(
dev.strataproxy.observability.ProxyMetrics metrics,
String serverName,
int maxFrameBytes,
MinecraftCompressionAuditState compressionAudit,
MinecraftForwardingRuntime forwardingRuntime,
RelaySessionIdentity identity,
MinecraftProtocolProfile profile,
Listener listener) {
this(
metrics,
serverName,
maxFrameBytes,
compressionAudit,
forwardingRuntime,
identity,
profile,
null,
listener);
}
BackendSwitchLoginHandler(
dev.strataproxy.observability.ProxyMetrics metrics,
String serverName,
int maxFrameBytes,
MinecraftCompressionAuditState compressionAudit,
MinecraftForwardingRuntime forwardingRuntime,
RelaySessionIdentity identity,
MinecraftProtocolProfile profile,
MinecraftForgeHandshakeTracker forgeHandshakeTracker,
Listener listener) {
this.metrics = metrics;
this.serverName = serverName;
@@ -39,6 +79,9 @@ final class BackendSwitchLoginHandler extends ByteToMessageDecoder {
this.compressionAudit = compressionAudit;
this.forwardingRuntime = forwardingRuntime == null ? MinecraftForwardingRuntime.none() : forwardingRuntime;
this.identity = identity;
this.profile = profile == null
? MinecraftProtocolProfile.forVersion(MinecraftProtocolProfile.PROTOCOL_1_20_1)
: profile;
this.listener = listener;
}
@@ -54,12 +97,10 @@ final class BackendSwitchLoginHandler extends ByteToMessageDecoder {
}
var frame = input.readRetainedSlice(probe.totalBytes());
try {
if (handleFrame(context, frame)) {
var action = handleLoginFrame(context, frame);
if (action == FrameAction.LOGIN_SUCCEEDED) {
terminal = true;
listener.backendLoginReady(context.channel());
if (input.isReadable()) {
context.fireChannelRead(input.readRetainedSlice(input.readableBytes()));
}
listener.backendLoginReady(context.channel(), null, remaining(input));
return;
}
} catch (RuntimeException exception) {
@@ -92,31 +133,35 @@ final class BackendSwitchLoginHandler extends ByteToMessageDecoder {
closeCodec();
}
private boolean handleFrame(ChannelHandlerContext context, ByteBuf frame) {
private FrameAction handleLoginFrame(ChannelHandlerContext context, ByteBuf frame) {
var packet = decodePacket(context, frame);
try {
var packetId = MinecraftProtocolCodec.readVarInt(packet);
if (packetId == CLIENTBOUND_LOGIN_SET_COMPRESSION_PACKET_ID && !compressionAudit.negotiated()) {
var threshold = MinecraftProtocolCodec.readVarInt(packet);
compressionAudit.negotiate(threshold);
metrics.compressionNegotiated(serverName, threshold);
return false;
if (profile.clientboundLoginSetCompressionPacketId().isPresent()
&& packetId == profile.clientboundLoginSetCompressionPacketId().getAsInt()
&& !compressionAudit.negotiated()) {
if (profile.compressionNegotiationSupported()) {
var threshold = MinecraftProtocolCodec.readVarInt(packet);
compressionAudit.negotiate(threshold);
metrics.compressionNegotiated(serverName, threshold);
return FrameAction.WAIT;
}
}
if (packetId == CLIENTBOUND_LOGIN_SUCCESS_PACKET_ID) {
return true;
if (packetId == profile.clientboundLoginSuccessPacketId()) {
return FrameAction.LOGIN_SUCCEEDED;
}
if (packetId == CLIENTBOUND_LOGIN_DISCONNECT_PACKET_ID) {
if (packetId == profile.clientboundLoginDisconnectPacketId()) {
fail(context, "backend_login_disconnect");
return false;
return FrameAction.WAIT;
}
if (packetId == CLIENTBOUND_LOGIN_ENCRYPTION_REQUEST_PACKET_ID) {
if (packetId == profile.clientboundLoginEncryptionRequestPacketId()) {
fail(context, "backend_login_encryption_request");
return false;
return FrameAction.WAIT;
}
if (handleVelocityForwarding(context, frame)) {
return false;
return FrameAction.WAIT;
}
return false;
return FrameAction.WAIT;
} finally {
packet.release();
}
@@ -180,9 +225,18 @@ final class BackendSwitchLoginHandler extends ByteToMessageDecoder {
}
}
private ByteBuf remaining(ByteBuf input) {
return input.isReadable() ? input.readRetainedSlice(input.readableBytes()) : null;
}
interface Listener {
void backendLoginReady(io.netty.channel.Channel backend);
void backendLoginReady(io.netty.channel.Channel backend, ByteBuf clientboundFrames, ByteBuf remainingBackendFrames);
void backendLoginFailed(io.netty.channel.Channel backend, String outcome);
}
private enum FrameAction {
WAIT,
LOGIN_SUCCEEDED
}
}
@@ -17,15 +17,23 @@ final class BungeeConnectRequestSampler implements AutoCloseable {
private static final int MAX_BUNGEE_STRING_BYTES = 32767;
private final int maxFrameBytes;
private final MinecraftProtocolProfile profile;
private final MinecraftCompressionCodec codec = new MinecraftCompressionCodec();
private ByteBuf pending = Unpooled.buffer();
private boolean closed;
BungeeConnectRequestSampler(int maxFrameBytes) {
this(maxFrameBytes, MinecraftProtocolProfile.forVersion(MinecraftProtocolProfile.PROTOCOL_1_20_1));
}
BungeeConnectRequestSampler(int maxFrameBytes, MinecraftProtocolProfile profile) {
if (maxFrameBytes <= 0) {
throw new IllegalArgumentException("maxFrameBytes must be positive");
}
this.maxFrameBytes = maxFrameBytes;
this.profile = profile == null
? MinecraftProtocolProfile.forVersion(MinecraftProtocolProfile.PROTOCOL_1_20_1)
: profile;
}
List<ConnectRequest> observeUncompressed(ByteBuf input) {
@@ -107,15 +115,21 @@ final class BungeeConnectRequestSampler implements AutoCloseable {
var view = packet.retainedDuplicate();
try {
var packetId = MinecraftVarInts.read(view);
if (!profile.serverboundBungeeCustomPayloadPacketIds().contains(packetId)) {
return Optional.empty();
}
var channel = readMinecraftString(view, MAX_CHANNEL_BYTES);
if (!isBungeeChannel(channel)) {
return Optional.empty();
}
var subchannel = readUnsignedShortString(view);
var payload = MinecraftCustomPayloadBodyCodec.readBody(
view,
profile.serverboundCustomPayloadLengthFormat());
var subchannel = readUnsignedShortString(payload);
if (!"connect".equalsIgnoreCase(subchannel)) {
return Optional.empty();
}
var targetServer = readUnsignedShortString(view);
var targetServer = readUnsignedShortString(payload);
if (targetServer.isBlank()) {
return Optional.empty();
}
@@ -3,6 +3,7 @@ package dev.strataproxy.network;
import dev.strataproxy.analysis.CustomPayloadAnomalyPolicy;
import dev.strataproxy.analysis.AnomalyAction;
import dev.strataproxy.compression.CompressionAction;
import dev.strataproxy.codec.minecraft.MinecraftCompressionCodec;
import dev.strataproxy.observability.ProxyMetrics;
import dev.strataproxy.observability.ProxyMetrics.CompressionDirection;
import dev.strataproxy.analysis.PacketAnomaly;
@@ -21,6 +22,8 @@ import java.util.List;
final class FrontendRelayHandler extends ChannelInboundHandlerAdapter {
private static final String RULE_COMPRESSION_AUDIT_MALFORMED = "compression-audit-malformed";
private static final String RULE_LEGACY_FORGE_HANDSHAKE_GATE_MALFORMED = "legacy-forge-handshake-gate-malformed";
private static final int MAX_LEGACY_FORGE_QUEUED_BYTES = 1_048_576;
private final Channel backend;
private final ProxyMetrics metrics;
@@ -30,6 +33,7 @@ final class FrontendRelayHandler extends ChannelInboundHandlerAdapter {
private final MinecraftCustomPayloadInspectionSampler customPayloadInspection;
private final MinecraftPacketTrafficSampler packetTrafficSampler;
private final MinecraftLoginStartSampler loginStartSampler;
private final MinecraftCompressionCodec legacyForgeGateCompressionCodec = new MinecraftCompressionCodec();
private final int maxFrameBytes;
private final CustomPayloadAnomalyPolicy customPayloadPolicy;
private final RelaySessionIdentity identity;
@@ -38,9 +42,15 @@ final class FrontendRelayHandler extends ChannelInboundHandlerAdapter {
private final CommandRegistry commands;
private final EventBus events;
private final int protocolVersion;
private final MinecraftProtocolProfile profile;
private final MinecraftForgeHandshakeTracker forgeHandshakeTracker;
private MinecraftCompressedCustomPayloadInspectionSampler compressedCustomPayloadInspection;
private GameCommandFrameInterceptor commandInterceptor;
private ByteBuf legacyForgeRacePending;
private ByteBuf legacyForgeGatePending;
private ByteBuf legacyForgeQueuedFrames;
private String playerName;
private boolean deferredBackendReplacementReady;
private boolean closed;
FrontendRelayHandler(Channel backend, ProxyMetrics metrics, String serverName) {
@@ -147,7 +157,8 @@ final class FrontendRelayHandler extends ChannelInboundHandlerAdapter {
null,
null,
null,
763);
763,
null);
}
FrontendRelayHandler(
@@ -165,7 +176,83 @@ final class FrontendRelayHandler extends ChannelInboundHandlerAdapter {
BackendReplacementController replacementController,
CommandRegistry commands,
EventBus events,
int protocolVersion) {
int protocolVersion,
ByteBuf initialLoginStartSeed) {
this(
backend,
metrics,
serverName,
compressionAudit,
compressionRuntime,
maxFrameBytes,
customPayloadPolicy,
initialPlayerName,
identity,
compressionRewriteEnabled,
compressionRewriteMaxEventLoopDelayMillis,
replacementController,
commands,
events,
MinecraftProtocolProfile.forVersion(protocolVersion),
initialLoginStartSeed,
null);
}
FrontendRelayHandler(
Channel backend,
ProxyMetrics metrics,
String serverName,
MinecraftCompressionAuditState compressionAudit,
CompressionRuntime compressionRuntime,
int maxFrameBytes,
CustomPayloadAnomalyPolicy customPayloadPolicy,
String initialPlayerName,
RelaySessionIdentity identity,
boolean compressionRewriteEnabled,
int compressionRewriteMaxEventLoopDelayMillis,
BackendReplacementController replacementController,
CommandRegistry commands,
EventBus events,
MinecraftProtocolProfile profile,
ByteBuf initialLoginStartSeed) {
this(
backend,
metrics,
serverName,
compressionAudit,
compressionRuntime,
maxFrameBytes,
customPayloadPolicy,
initialPlayerName,
identity,
compressionRewriteEnabled,
compressionRewriteMaxEventLoopDelayMillis,
replacementController,
commands,
events,
profile,
initialLoginStartSeed,
null);
}
FrontendRelayHandler(
Channel backend,
ProxyMetrics metrics,
String serverName,
MinecraftCompressionAuditState compressionAudit,
CompressionRuntime compressionRuntime,
int maxFrameBytes,
CustomPayloadAnomalyPolicy customPayloadPolicy,
String initialPlayerName,
RelaySessionIdentity identity,
boolean compressionRewriteEnabled,
int compressionRewriteMaxEventLoopDelayMillis,
BackendReplacementController replacementController,
CommandRegistry commands,
EventBus events,
MinecraftProtocolProfile profile,
ByteBuf initialLoginStartSeed,
MinecraftForgeHandshakeTracker forgeHandshakeTracker) {
this.backend = backend;
this.metrics = metrics;
this.serverName = serverName;
@@ -173,18 +260,25 @@ final class FrontendRelayHandler extends ChannelInboundHandlerAdapter {
this.compressionRuntime = compressionRuntime;
this.maxFrameBytes = maxFrameBytes;
this.customPayloadPolicy = customPayloadPolicy;
this.profile = profile == null
? MinecraftProtocolProfile.forVersion(MinecraftProtocolProfile.PROTOCOL_1_20_1)
: profile;
this.customPayloadInspection = new MinecraftCustomPayloadInspectionSampler(
maxFrameBytes,
Math.min(maxFrameBytes, customPayloadPolicy.largePayloadWarnBytes()),
customPayloadPolicy);
customPayloadPolicy,
this.profile);
this.packetTrafficSampler = new MinecraftPacketTrafficSampler(maxFrameBytes);
this.loginStartSampler = new MinecraftLoginStartSampler(maxFrameBytes);
this.loginStartSampler = initialLoginStartSeed == null
? new MinecraftLoginStartSampler(maxFrameBytes)
: new MinecraftLoginStartSampler(maxFrameBytes, initialLoginStartSeed);
this.identity = identity;
this.compressionRewriteRuntime = new CompressionRewriteRuntime(compressionRewriteEnabled, compressionRewriteMaxEventLoopDelayMillis);
this.replacementController = replacementController;
this.commands = commands;
this.events = events;
this.protocolVersion = protocolVersion;
this.protocolVersion = this.profile.protocolVersion();
this.forgeHandshakeTracker = forgeHandshakeTracker;
this.playerName = initialPlayerName;
if (initialPlayerName != null && !initialPlayerName.isBlank()) {
this.identity.playerName(initialPlayerName);
@@ -203,23 +297,69 @@ final class FrontendRelayHandler extends ChannelInboundHandlerAdapter {
var forward = true;
List<CompressionAction> compressionActions = List.of();
if (message instanceof ByteBuf buffer) {
var legacyForgeBlockedBefore = legacyForgeHandshakeBlocksServerboundFrames();
metrics.frontendToBackendBytes(serverName, buffer.readableBytes());
capturePayloadPrefix(buffer, CompressionDirection.FRONTEND_TO_BACKEND);
observeLoginStart(context, buffer);
observeForgeHandshake(context, buffer);
observeServerboundPluginChannels(context, buffer);
observePacketTraffic(buffer);
var compression = observeCompressionFrames(buffer);
forward = observeCustomPayload(context, buffer) && compression.shouldForward();
compressionActions = compression.actions();
if (forward && outbound instanceof ByteBuf outboundBuffer) {
ByteBuf filtered;
try {
filtered = suppressLegacyForgeRaceFrames(context, outboundBuffer);
} catch (RuntimeException exception) {
ReferenceCountUtil.release(outbound);
context.close();
closeBackend();
return;
}
if (filtered == null) {
ReferenceCountUtil.release(outbound);
context.channel().read();
return;
}
if (filtered != outboundBuffer) {
ReferenceCountUtil.release(outbound);
outbound = filtered;
buffer = filtered;
}
}
if (forward) {
var command = observeCommand(context, buffer);
if (!command.forward()) {
ReferenceCountUtil.release(message);
ReferenceCountUtil.release(outbound);
context.channel().read();
return;
}
if (command.message() != buffer) {
outbound = command.message();
ReferenceCountUtil.release(message);
ReferenceCountUtil.release(buffer);
buffer = command.message();
}
}
if (forward && outbound instanceof ByteBuf outboundBuffer) {
ByteBuf gated;
try {
gated = gateLegacyForgeHandshakeFrames(context, outboundBuffer, legacyForgeBlockedBefore);
} catch (RuntimeException exception) {
ReferenceCountUtil.release(outbound);
context.close();
closeBackend();
return;
}
if (gated == null) {
ReferenceCountUtil.release(outbound);
context.channel().read();
return;
}
if (gated != outboundBuffer) {
ReferenceCountUtil.release(outbound);
outbound = gated;
buffer = gated;
}
}
if (forward && compression.rewriteEligible()) {
@@ -253,6 +393,9 @@ final class FrontendRelayHandler extends ChannelInboundHandlerAdapter {
recordBackpressureIfNeeded(backend, CompressionDirection.FRONTEND_TO_BACKEND);
backend.writeAndFlush(outbound).addListener((ChannelFutureListener) future -> {
if (future.isSuccess()) {
flushQueuedLegacyForgeFramesIfReady(context);
flushBackendQueuedLegacyForgeFramesIfReady();
runDeferredBackendReplacementIfReady();
context.channel().read();
} else {
future.channel().close();
@@ -261,6 +404,13 @@ final class FrontendRelayHandler extends ChannelInboundHandlerAdapter {
});
}
private void flushBackendQueuedLegacyForgeFramesIfReady() {
var backendRelay = backend.attr(BackendRelayHandler.HANDLER).get();
if (backendRelay != null) {
backendRelay.flushQueuedLegacyForgeFramesIfReady();
}
}
@Override
/** Provides channel inactive. */
public void channelInactive(ChannelHandlerContext context) {
@@ -292,11 +442,16 @@ final class FrontendRelayHandler extends ChannelInboundHandlerAdapter {
compressedCustomPayloadInspection.close();
}
packetTrafficSampler.close();
legacyForgeGateCompressionCodec.close();
compressionRewriteRuntime.close();
releaseLegacyForgeGatePending();
releaseLegacyForgeQueuedFrames();
releaseLegacyForgeRacePending();
if (commandInterceptor != null) {
commandInterceptor.close();
commandInterceptor = null;
}
metrics.forgeHandshakeClosed(serverName, forgePlayerName(), identity.remoteAddress());
if (closePlayerSession) {
if (replacementController == null) {
metrics.playerSessionClosed(playerName);
@@ -311,6 +466,360 @@ final class FrontendRelayHandler extends ChannelInboundHandlerAdapter {
}
}
private void observeForgeHandshake(ChannelHandlerContext context, ByteBuf buffer) {
if (forgeHandshakeTracker == null) {
return;
}
try {
var events = compressionAudit.negotiated()
? forgeHandshakeTracker.observeCompressed(
MinecraftForgeHandshakeTracker.Direction.SERVERBOUND,
context.alloc(),
buffer,
compressionAudit.threshold())
: forgeHandshakeTracker.observe(MinecraftForgeHandshakeTracker.Direction.SERVERBOUND, buffer);
for (var event : events) {
markLegacyForgeClientDetected(event);
recordForgeHandshakeEvent(context, event);
}
recordForgeHandshakeState();
markDeferredBackendReplacementReady();
} catch (RuntimeException exception) {
metrics.packetAnomaly(
"forge-handshake-inspection-malformed",
remoteAddress(context),
serverName,
CompressionDirection.FRONTEND_TO_BACKEND.label(),
"PLAY",
-1,
buffer.readableBytes(),
-1,
exception.getMessage());
}
}
private void observeServerboundPluginChannels(ChannelHandlerContext context, ByteBuf buffer) {
if (replacementController == null) {
return;
}
try {
if (compressionAudit.negotiated()) {
replacementController.observeCompressedServerboundPluginChannels(
context.alloc(),
buffer,
compressionAudit.threshold(),
profile);
} else {
replacementController.observeServerboundPluginChannels(buffer, profile);
}
} catch (RuntimeException exception) {
metrics.packetAnomaly(
"plugin-channel-registry-malformed",
remoteAddress(context),
serverName,
CompressionDirection.FRONTEND_TO_BACKEND.label(),
"PLAY",
profile.serverboundCustomPayloadPacketId().orElse(-1),
buffer.readableBytes(),
-1,
exception.getMessage());
}
}
private void markLegacyForgeClientDetected(MinecraftForgeHandshakeTracker.Event event) {
if (replacementController == null || event == null || !event.expected()) {
return;
}
if ("register".equals(event.type()) || "FML|HS".equals(event.channel())) {
replacementController.legacyForgeClientDetected();
}
}
private String forgePlayerName() {
return identity.playerName().isBlank() ? playerName : identity.playerName();
}
private void markDeferredBackendReplacementReady() {
if (replacementController != null && forgeHandshakeTracker.complete()) {
deferredBackendReplacementReady = true;
}
}
private void runDeferredBackendReplacementIfReady() {
if (deferredBackendReplacementReady && replacementController != null && forgeHandshakeTracker.complete()) {
deferredBackendReplacementReady = false;
replacementController.runDeferredBackendReplacementIfReady();
}
}
private void recordForgeHandshakeState() {
if (forgeHandshakeTracker == null) {
return;
}
metrics.forgeHandshake(
serverName,
identity.playerName(),
identity.remoteAddress(),
forgeHandshakeTracker.stage().name(),
forgeHandshakeTracker.clientPhase().name(),
forgeHandshakeTracker.backendPhase().name(),
forgeHandshakeTracker.complete(),
forgeHandshakeTracker.backendSwitchBlocked(),
forgeHandshakeTracker.clientModCount(),
forgeHandshakeTracker.serverModCount(),
forgeHandshakeTracker.clientboundRegistryPackets(),
forgeHandshakeTracker.clientboundRegistryBytes());
}
private boolean legacyForgeHandshakeBlocksServerboundFrames() {
return forgeHandshakeTracker != null
&& profile.legacyForgeHandshakeSupported()
&& forgeHandshakeTracker.backendSwitchBlocked();
}
private ByteBuf suppressLegacyForgeRaceFrames(ChannelHandlerContext context, ByteBuf buffer) {
if (!profile.legacyForgeHandshakeSupported()) {
return buffer;
}
if (legacyForgeRacePending == null) {
legacyForgeRacePending = context.alloc().buffer(buffer.readableBytes());
}
if (legacyForgeRacePending.readableBytes() + buffer.readableBytes() > maxFrameBytes + 5) {
releaseLegacyForgeRacePending();
throw new IllegalStateException("legacy Forge race guard pending frame exceeded maximum size");
}
legacyForgeRacePending.writeBytes(buffer, buffer.readerIndex(), buffer.readableBytes());
var output = context.alloc().buffer(legacyForgeRacePending.readableBytes());
try {
while (legacyForgeRacePending.isReadable()) {
var probe = MinecraftProtocolCodec.probeFrame(legacyForgeRacePending, maxFrameBytes);
if (!probe.complete()) {
break;
}
var frame = legacyForgeRacePending.readRetainedSlice(probe.totalBytes());
try {
if (!legacyForgeRaceFrame(context, frame)) {
output.writeBytes(frame, frame.readerIndex(), frame.readableBytes());
}
} finally {
frame.release();
}
legacyForgeRacePending.discardReadBytes();
}
if (output.isReadable()) {
return output;
}
output.release();
return null;
} catch (RuntimeException exception) {
output.release();
releaseLegacyForgeRacePending();
throw exception;
}
}
private boolean legacyForgeRaceFrame(ChannelHandlerContext context, ByteBuf frame) {
var packet = legacyForgeGatePacket(context, frame);
try {
var packetId = MinecraftProtocolCodec.readVarInt(packet);
if (profile.serverboundCustomPayloadPacketId().isEmpty()
|| packetId != profile.serverboundCustomPayloadPacketId().getAsInt()) {
return false;
}
var channel = MinecraftProtocolCodec.readString(packet, 128);
var payload = MinecraftCustomPayloadBodyCodec.readBody(
packet,
profile.serverboundCustomPayloadLengthFormat());
return "FML".equals(channel)
&& payload.isReadable()
&& payload.getUnsignedByte(payload.readerIndex()) == 1;
} finally {
packet.release();
}
}
private ByteBuf gateLegacyForgeHandshakeFrames(
ChannelHandlerContext context,
ByteBuf buffer,
boolean legacyForgeBlockedBefore) {
if (!legacyForgeBlockedBefore || !profile.legacyForgeHandshakeSupported()) {
return buffer;
}
if (legacyForgeGatePending == null) {
legacyForgeGatePending = context.alloc().buffer(buffer.readableBytes());
}
if (legacyForgeGatePending.readableBytes() + buffer.readableBytes() > maxFrameBytes + 5) {
recordLegacyForgeGateFailure("pending_overflow", legacyForgeGatePending.readableBytes() + buffer.readableBytes());
releaseLegacyForgeGatePending();
throw new IllegalStateException("legacy Forge handshake gate pending frame exceeded maximum size");
}
legacyForgeGatePending.writeBytes(buffer, buffer.readerIndex(), buffer.readableBytes());
var output = context.alloc().buffer(legacyForgeGatePending.readableBytes());
try {
while (legacyForgeGatePending.isReadable()) {
var probe = MinecraftProtocolCodec.probeFrame(legacyForgeGatePending, maxFrameBytes);
if (!probe.complete()) {
break;
}
var frame = legacyForgeGatePending.readRetainedSlice(probe.totalBytes());
try {
if (legacyForgeFrameAllowedDuringHandshake(context, frame)) {
output.writeBytes(frame, frame.readerIndex(), frame.readableBytes());
} else {
queueLegacyForgeFrame(context, frame);
}
} finally {
frame.release();
}
legacyForgeGatePending.discardReadBytes();
}
if (output.isReadable()) {
return output;
}
output.release();
return null;
} catch (RuntimeException exception) {
output.release();
releaseLegacyForgeGatePending();
throw exception;
}
}
private boolean legacyForgeFrameAllowedDuringHandshake(ChannelHandlerContext context, ByteBuf frame) {
var packet = legacyForgeGatePacket(context, frame);
try {
var packetId = MinecraftProtocolCodec.readVarInt(packet);
if (profile.serverboundCustomPayloadPacketId().isEmpty()
|| packetId != profile.serverboundCustomPayloadPacketId().getAsInt()) {
return true;
}
var channel = MinecraftProtocolCodec.readString(packet, 128);
return legacyForgeChannel(channel);
} finally {
packet.release();
}
}
private ByteBuf legacyForgeGatePacket(ChannelHandlerContext context, ByteBuf frame) {
if (compressionAudit.negotiated()) {
var duplicate = frame.retainedDuplicate();
try {
return legacyForgeGateCompressionCodec.decodeFrame(
context.alloc(),
duplicate,
compressionAudit.threshold(),
maxFrameBytes);
} finally {
duplicate.release();
}
}
var packet = frame.retainedDuplicate();
try {
MinecraftProtocolCodec.readVarInt(packet);
return packet.readRetainedSlice(packet.readableBytes());
} finally {
packet.release();
}
}
private void queueLegacyForgeFrame(ChannelHandlerContext context, ByteBuf frame) {
if (legacyForgeQueuedFrames == null) {
legacyForgeQueuedFrames = context.alloc().buffer(Math.min(frame.readableBytes(), 1024));
}
if (legacyForgeQueuedFrames.readableBytes() + frame.readableBytes() > MAX_LEGACY_FORGE_QUEUED_BYTES) {
recordLegacyForgeGateFailure("queue_overflow", legacyForgeQueuedFrames.readableBytes() + frame.readableBytes());
releaseLegacyForgeQueuedFrames();
throw new IllegalStateException("legacy Forge handshake queued frames exceeded maximum size");
}
legacyForgeQueuedFrames.writeBytes(frame, frame.readerIndex(), frame.readableBytes());
}
private void flushQueuedLegacyForgeFramesIfReady(ChannelHandlerContext context) {
if (legacyForgeQueuedFrames == null
|| !legacyForgeQueuedFrames.isReadable()
|| forgeHandshakeTracker == null
|| !forgeHandshakeTracker.complete()
|| !backend.isActive()) {
return;
}
var queued = legacyForgeQueuedFrames;
legacyForgeQueuedFrames = null;
backend.writeAndFlush(queued).addListener((ChannelFutureListener) future -> {
if (!future.isSuccess()) {
future.channel().close();
context.close();
}
});
}
private void recordLegacyForgeGateFailure(String reason, int bytes) {
metrics.packetAnomaly(
RULE_LEGACY_FORGE_HANDSHAKE_GATE_MALFORMED,
identity.remoteAddress(),
serverName,
CompressionDirection.FRONTEND_TO_BACKEND.label(),
"PLAY",
-1,
Math.max(0, bytes),
-1,
reason);
}
private static boolean legacyForgeChannel(String channel) {
return "REGISTER".equals(channel)
|| "FML|HS".equals(channel)
|| "FML|MP".equals(channel)
|| "FML".equals(channel)
|| "FORGE".equals(channel);
}
private void releaseLegacyForgeGatePending() {
if (legacyForgeGatePending != null && legacyForgeGatePending.refCnt() > 0) {
legacyForgeGatePending.release();
}
legacyForgeGatePending = null;
}
private void releaseLegacyForgeQueuedFrames() {
if (legacyForgeQueuedFrames != null && legacyForgeQueuedFrames.refCnt() > 0) {
legacyForgeQueuedFrames.release();
}
legacyForgeQueuedFrames = null;
}
private void releaseLegacyForgeRacePending() {
if (legacyForgeRacePending != null && legacyForgeRacePending.refCnt() > 0) {
legacyForgeRacePending.release();
}
legacyForgeRacePending = null;
}
private void recordForgeHandshakeEvent(ChannelHandlerContext context, MinecraftForgeHandshakeTracker.Event event) {
metrics.customPayload(
serverName,
CompressionDirection.FRONTEND_TO_BACKEND,
"FORGE_HANDSHAKE_" + event.type().toUpperCase(java.util.Locale.ROOT),
event.channel(),
event.payloadBytes(),
0,
identity.playerName(),
identity.remoteAddress(),
"PLAY",
profile.serverboundCustomPayloadPacketId().orElse(-1));
if (!event.expected()) {
metrics.packetAnomaly(
"forge-handshake-unexpected-order",
remoteAddress(context),
serverName,
CompressionDirection.FRONTEND_TO_BACKEND.label(),
"PLAY",
profile.serverboundCustomPayloadPacketId().orElse(-1),
event.payloadBytes(),
-1,
event.type() + " stage=" + event.stage());
}
}
private void closeBackend() {
if (backend.isOpen()) {
backend.close();
@@ -449,14 +958,14 @@ final class FrontendRelayHandler extends ChannelInboundHandlerAdapter {
return GameCommandFrameInterceptor.Interception.forward(buffer);
}
if (commandInterceptor == null) {
commandInterceptor = new GameCommandFrameInterceptor(commands, events, maxFrameBytes);
commandInterceptor = new GameCommandFrameInterceptor(commands, events, maxFrameBytes, profile);
}
return commandInterceptor.intercept(
context.alloc(),
buffer,
compressionAudit.negotiated(),
compressionAudit.threshold(),
new PlayerSource(context.channel(), playerName, protocolVersion, compressionAudit));
new PlayerSource(context.channel(), playerName, profile, compressionAudit));
}
private void observeLoginStart(ChannelHandlerContext context, ByteBuf buffer) {
@@ -542,17 +1051,19 @@ final class FrontendRelayHandler extends ChannelInboundHandlerAdapter {
private static final class PlayerSource implements CommandSource {
private final Channel frontend;
private final String name;
private final int protocolVersion;
private final MinecraftProtocolProfile profile;
private final MinecraftCompressionAuditState compressionAudit;
private PlayerSource(
Channel frontend,
String name,
int protocolVersion,
MinecraftProtocolProfile profile,
MinecraftCompressionAuditState compressionAudit) {
this.frontend = frontend;
this.name = name == null ? "" : name;
this.protocolVersion = protocolVersion;
this.profile = profile == null
? MinecraftProtocolProfile.forVersion(MinecraftProtocolProfile.PROTOCOL_1_20_1)
: profile;
this.compressionAudit = compressionAudit;
}
@@ -568,7 +1079,7 @@ final class FrontendRelayHandler extends ChannelInboundHandlerAdapter {
var write = (Runnable) () -> {
var frame = MinecraftPlayMessages.systemChatFrame(
frontend.alloc(),
protocolVersion,
profile,
message,
compressionAudit.negotiated(),
compressionAudit.negotiated() ? compressionAudit.threshold() : 0);
@@ -11,21 +11,30 @@ import io.netty.buffer.ByteBufAllocator;
import io.netty.buffer.Unpooled;
import java.util.HashSet;
import java.util.Set;
final class GameCommandFrameInterceptor implements AutoCloseable {
private static final Set<Integer> COMMAND_PACKET_CANDIDATES = Set.of(0x03, 0x04, 0x05);
private final CommandRegistry commands;
private final EventBus events;
private final int maxFrameBytes;
private final MinecraftProtocolProfile profile;
private final MinecraftCompressionCodec compressionCodec = new MinecraftCompressionCodec();
private ByteBuf pending = Unpooled.buffer();
GameCommandFrameInterceptor(CommandRegistry commands, EventBus events, int maxFrameBytes) {
this(commands, events, maxFrameBytes, MinecraftProtocolProfile.forVersion(MinecraftProtocolProfile.PROTOCOL_1_20_1));
}
GameCommandFrameInterceptor(
CommandRegistry commands,
EventBus events,
int maxFrameBytes,
MinecraftProtocolProfile profile) {
this.commands = commands;
this.events = events;
this.maxFrameBytes = maxFrameBytes;
this.profile = profile == null
? MinecraftProtocolProfile.forVersion(MinecraftProtocolProfile.PROTOCOL_1_20_1)
: profile;
}
Interception intercept(
@@ -122,7 +131,7 @@ final class GameCommandFrameInterceptor implements AutoCloseable {
var view = packet.retainedDuplicate();
try {
var packetId = MinecraftProtocolCodec.readVarInt(view);
if (!COMMAND_PACKET_CANDIDATES.contains(packetId)) {
if (!profile.serverboundCommandPacketIds().contains(packetId)) {
return null;
}
var text = MinecraftProtocolCodec.readString(view, 256);
@@ -445,6 +445,8 @@ final class InitialHandshakeRouteHandler extends ByteToMessageDecoder {
var compressionAudit = new MinecraftCompressionAuditState(tuning.maxFrameBytes());
var session = new RelaySession(existingIdentity == null ? new RelaySessionIdentity(ClientAddress.text(frontend)) : existingIdentity);
var identity = session.identity();
var profile = MinecraftProtocolProfile.forVersion(handshake.protocolVersion());
session.legacyForgeClientDetected(profile.legacyForgeHandshakeSupported() && handshake.legacyForgeClientMarker());
var backendConnector = new BackendConnector(
metrics,
tuning,
@@ -452,7 +454,8 @@ final class InitialHandshakeRouteHandler extends ByteToMessageDecoder {
compressionRuntime,
forwardingRuntime,
compressionRewriteEnabled,
compressionRewriteMaxEventLoopDelayMillis);
compressionRewriteMaxEventLoopDelayMillis,
profile);
var replacementController = new BackendReplacementController(
ServerTargetResolver.from(backendResolver),
backendConnector,
@@ -466,7 +469,7 @@ final class InitialHandshakeRouteHandler extends ByteToMessageDecoder {
compressionRewriteMaxEventLoopDelayMillis,
commands,
events,
handshake.protocolVersion());
profile);
backendConnector.connect(frontend, selected, compressionAudit, session, replacementController).addListener((ChannelFutureListener) future -> {
if (!future.isSuccess()) {
@@ -514,40 +517,51 @@ final class InitialHandshakeRouteHandler extends ByteToMessageDecoder {
session.loginSession(new RelayLoginSession(outboundFirstFrame, pendingBytes));
}
frontend.pipeline().remove("initial-handshake-timeout");
var frontendRelay = new FrontendRelayHandler(
backend,
metrics,
serverName,
compressionAudit,
compressionRuntime,
tuning.maxFrameBytes(),
customPayloadPolicy,
initialLoginStart == null ? null : initialLoginStart.username(),
identity,
compressionRewriteEnabled,
compressionRewriteMaxEventLoopDelayMillis,
replacementController,
commands,
events,
handshake.protocolVersion());
replacementController.relayAttached(frontendRelay, frontend, backend, serverName);
if (frontendHandlerNameToReplace == null) {
frontend.pipeline().replace(this, "frontend-relay", frontendRelay);
} else {
frontend.pipeline().replace(frontendHandlerNameToReplace, "frontend-relay", frontendRelay);
var loginStartSeed = initialLoginStart == null && pendingBytes != null && pendingBytes.isReadable()
? pendingBytes.retainedDuplicate()
: null;
try {
var frontendRelay = new FrontendRelayHandler(
backend,
metrics,
serverName,
compressionAudit,
compressionRuntime,
tuning.maxFrameBytes(),
customPayloadPolicy,
initialLoginStart == null ? null : initialLoginStart.username(),
identity,
compressionRewriteEnabled,
compressionRewriteMaxEventLoopDelayMillis,
replacementController,
commands,
events,
profile,
loginStartSeed,
session.forgeHandshakeTracker());
replacementController.relayAttached(frontendRelay, frontend, backend, serverName);
if (frontendHandlerNameToReplace == null) {
frontend.pipeline().replace(this, "frontend-relay", frontendRelay);
} else {
frontend.pipeline().replace(frontendHandlerNameToReplace, "frontend-relay", frontendRelay);
}
backend.write(outboundFirstFrame);
if (pendingBytes != null) {
backend.write(pendingBytes);
}
backend.flush();
frontend.config().setAutoRead(false);
backend.config().setAutoRead(false);
if (initialLoginStart != null) {
metrics.playerSessionStarted(initialLoginStart.username(), serverName, ClientAddress.text(frontend));
}
frontend.read();
backend.read();
} finally {
if (loginStartSeed != null && loginStartSeed.refCnt() > 0) {
loginStartSeed.release();
}
}
backend.write(outboundFirstFrame);
if (pendingBytes != null) {
backend.write(pendingBytes);
}
backend.flush();
frontend.config().setAutoRead(false);
backend.config().setAutoRead(false);
if (initialLoginStart != null) {
metrics.playerSessionStarted(initialLoginStart.username(), serverName, ClientAddress.text(frontend));
}
frontend.read();
backend.read();
});
}
@@ -8,17 +8,32 @@ 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 final MinecraftProtocolProfile profile;
private ByteBuf pending = Unpooled.buffer();
private boolean complete;
MinecraftCompressionNegotiationDetector(int maxFrameBytes) {
this(maxFrameBytes, MinecraftProtocolProfile.forVersion(MinecraftProtocolProfile.PROTOCOL_1_20_1));
}
MinecraftCompressionNegotiationDetector(int maxFrameBytes, int protocolVersion) {
this(maxFrameBytes, MinecraftProtocolProfile.forVersion(protocolVersion));
}
MinecraftCompressionNegotiationDetector(int maxFrameBytes, MinecraftProtocolProfile profile) {
if (maxFrameBytes <= 0) {
throw new IllegalArgumentException("maxFrameBytes must be positive");
}
this.maxFrameBytes = maxFrameBytes;
this.profile = profile == null
? MinecraftProtocolProfile.forVersion(MinecraftProtocolProfile.PROTOCOL_1_20_1)
: profile;
if (!this.profile.compressionNegotiationSupported()) {
pending.release();
complete = true;
pending = Unpooled.EMPTY_BUFFER;
}
}
OptionalInt observe(ByteBuf input) {
@@ -49,7 +64,13 @@ final class MinecraftCompressionNegotiationDetector {
try {
var payload = frame.slice(frameLength.bytes(), frameLength.value());
var packetId = MinecraftVarInts.read(payload);
if (packetId == CLIENTBOUND_LOGIN_SET_COMPRESSION_PACKET_ID) {
if (packetId == profile.clientboundLoginSuccessPacketId()) {
complete = true;
discardPending();
return OptionalInt.empty();
}
if (profile.clientboundLoginSetCompressionPacketId().isPresent()
&& packetId == profile.clientboundLoginSetCompressionPacketId().getAsInt()) {
var threshold = MinecraftVarInts.read(payload);
complete = true;
discardPending();
@@ -0,0 +1,87 @@
package dev.strataproxy.network;
import io.netty.buffer.ByteBuf;
final class MinecraftCustomPayloadBodyCodec {
private MinecraftCustomPayloadBodyCodec() {
}
static ByteBuf readBody(
ByteBuf input,
MinecraftProtocolProfile.CustomPayloadLengthFormat format) {
return switch (format) {
case REMAINING_BYTES -> input.readSlice(input.readableBytes());
case UNSIGNED_SHORT -> readUnsignedShortBody(input);
case VARSHORT -> readVarShortBody(input);
};
}
static void writeLength(
ByteBuf output,
MinecraftProtocolProfile.CustomPayloadLengthFormat format,
int length) {
if (length < 0) {
throw new IllegalArgumentException("custom payload length must be non-negative");
}
switch (format) {
case REMAINING_BYTES -> {
}
case UNSIGNED_SHORT -> {
if (length > 0xFFFF) {
throw new IllegalArgumentException("custom payload too large for unsigned-short length");
}
output.writeShort(length);
}
case VARSHORT -> writeVarShort(output, length);
}
}
private static ByteBuf readUnsignedShortBody(ByteBuf input) {
if (input.readableBytes() < Short.BYTES) {
throw new IllegalArgumentException("truncated custom payload length");
}
var length = input.readUnsignedShort();
if (input.readableBytes() < length) {
throw new IllegalArgumentException("truncated custom payload body");
}
return input.readSlice(length);
}
private static ByteBuf readVarShortBody(ByteBuf input) {
var length = readVarShort(input);
if (input.readableBytes() < length) {
throw new IllegalArgumentException("truncated custom payload body");
}
return input.readSlice(length);
}
private static int readVarShort(ByteBuf input) {
if (input.readableBytes() < Short.BYTES) {
throw new IllegalArgumentException("truncated custom payload length");
}
var low = input.readUnsignedShort();
var length = low & 0x7FFF;
if ((low & 0x8000) != 0) {
if (!input.isReadable()) {
throw new IllegalArgumentException("truncated custom payload varshort extension");
}
length |= input.readUnsignedByte() << 15;
}
return length;
}
private static void writeVarShort(ByteBuf output, int value) {
if (value > 0x7FFFFF) {
throw new IllegalArgumentException("custom payload too large for varshort length");
}
var low = value & 0x7FFF;
var high = (value & 0x7F8000) >>> 15;
if (high != 0) {
low |= 0x8000;
}
output.writeShort(low);
if (high != 0) {
output.writeByte(high);
}
}
}
@@ -20,12 +20,25 @@ final class MinecraftCustomPayloadInspectionSampler {
private final int maxFrameBytes;
private final int largePayloadBytes;
private final CustomPayloadAnomalyPolicy policy;
private final MinecraftProtocolProfile profile;
private ByteBuf pending = Unpooled.buffer();
private long floodWindowStartedNanos;
private int floodWindowCount;
private boolean closed;
MinecraftCustomPayloadInspectionSampler(int maxFrameBytes, int largePayloadBytes, CustomPayloadAnomalyPolicy policy) {
this(
maxFrameBytes,
largePayloadBytes,
policy,
MinecraftProtocolProfile.forVersion(MinecraftProtocolProfile.PROTOCOL_1_20_1));
}
MinecraftCustomPayloadInspectionSampler(
int maxFrameBytes,
int largePayloadBytes,
CustomPayloadAnomalyPolicy policy,
MinecraftProtocolProfile profile) {
if (maxFrameBytes <= 0) {
throw new IllegalArgumentException("maxFrameBytes must be positive");
}
@@ -38,6 +51,9 @@ final class MinecraftCustomPayloadInspectionSampler {
this.maxFrameBytes = maxFrameBytes;
this.largePayloadBytes = largePayloadBytes;
this.policy = policy;
this.profile = profile == null
? MinecraftProtocolProfile.forVersion(MinecraftProtocolProfile.PROTOCOL_1_20_1)
: profile;
}
static MinecraftCustomPayloadInspectionSampler defaults(int maxFrameBytes) {
@@ -80,11 +96,15 @@ final class MinecraftCustomPayloadInspectionSampler {
} finally {
bodyProbe.release();
}
if (packetId == SERVERBOUND_CONFIGURATION_CUSTOM_PAYLOAD_PACKET_ID) {
var classification = MinecraftCustomPayloadClassifier.classify(body, largePayloadBytes);
if (profile.serverboundCustomPayloadPacketId().isPresent()
&& packetId == profile.serverboundCustomPayloadPacketId().getAsInt()) {
var classification = MinecraftCustomPayloadClassifier.classify(
body,
largePayloadBytes,
payloadLengthFormat());
var packet = new PacketView(
PacketDirection.SERVERBOUND,
ProtocolState.CONFIGURATION,
protocolState(),
-1,
packetId,
body.readableBytes(),
@@ -106,6 +126,18 @@ final class MinecraftCustomPayloadInspectionSampler {
return results;
}
private ProtocolState protocolState() {
return profile.serverboundCustomPayloadInspectionState();
}
private MinecraftCustomPayloadClassifier.PayloadLengthFormat payloadLengthFormat() {
return switch (profile.serverboundCustomPayloadLengthFormat()) {
case REMAINING_BYTES -> MinecraftCustomPayloadClassifier.PayloadLengthFormat.REMAINING_BYTES;
case UNSIGNED_SHORT -> MinecraftCustomPayloadClassifier.PayloadLengthFormat.UNSIGNED_SHORT;
case VARSHORT -> MinecraftCustomPayloadClassifier.PayloadLengthFormat.VARSHORT;
};
}
private int recordCustomPayloadInWindow(long nowNanos) {
var windowNanos = policy.customPayloadFloodWindow().toNanos();
if (policy.customPayloadFloodMaxCount() <= 0) {
@@ -0,0 +1,667 @@
package dev.strataproxy.network;
import dev.strataproxy.codec.minecraft.MinecraftCompressionCodec;
import dev.strataproxy.codec.minecraft.MinecraftVarInts;
import io.netty.buffer.ByteBuf;
import io.netty.buffer.ByteBufAllocator;
import io.netty.buffer.Unpooled;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
final class MinecraftForgeHandshakeTracker implements AutoCloseable {
private static final int MAX_CHANNEL_BYTES = 128;
private static final int MAX_MULTIPART_CHANNEL_BYTES = 20;
private static final int MAX_MULTIPART_PARTS = 64;
private static final int MAX_MULTIPART_BYTES = 52_424_750;
private static final int MAX_MOD_LIST_ENTRIES_TO_SCAN = 4096;
private final int maxFrameBytes;
private final MinecraftProtocolProfile profile;
private final MinecraftCompressionCodec compressionCodec = new MinecraftCompressionCodec();
private boolean resetRequiredOnNextForgeServer;
private ByteBuf serverboundPending = Unpooled.buffer();
private ByteBuf clientboundPending = Unpooled.buffer();
private ByteBuf serverboundCompressedPending = Unpooled.buffer();
private ByteBuf clientboundCompressedPending = Unpooled.buffer();
private MultipartState clientboundMultipart;
private Map<String, String> clientMods = Map.of();
private Map<String, String> serverMods = Map.of();
private int clientboundRegistryPackets;
private long clientboundRegistryBytes;
private ClientPhase clientPhase = ClientPhase.NOT_STARTED;
private BackendPhase backendPhase = BackendPhase.NOT_STARTED;
private Stage stage = Stage.IDLE;
private boolean closed;
MinecraftForgeHandshakeTracker(int maxFrameBytes, MinecraftProtocolProfile profile) {
this(maxFrameBytes, profile, false);
}
MinecraftForgeHandshakeTracker(int maxFrameBytes, MinecraftProtocolProfile profile, boolean resetRequiredOnNextForgeServer) {
if (maxFrameBytes <= 0) {
throw new IllegalArgumentException("maxFrameBytes must be positive");
}
this.maxFrameBytes = maxFrameBytes;
this.profile = profile == null
? MinecraftProtocolProfile.forVersion(MinecraftProtocolProfile.PROTOCOL_1_20_1)
: profile;
this.resetRequiredOnNextForgeServer = resetRequiredOnNextForgeServer;
}
List<Event> observe(Direction direction, ByteBuf input) {
if (closed || !profile.legacyForgeHandshakeSupported() || input == null || !input.isReadable()) {
return List.of();
}
var pending = pending(direction);
if (pending.readableBytes() + input.readableBytes() > maxFrameBytes + 5) {
close();
return List.of(new Event(direction, "pending_overflow", "", -1, 0, stage, false));
}
pending.writeBytes(input, input.readerIndex(), input.readableBytes());
var events = new ArrayList<Event>();
while (pending.isReadable()) {
var frameLength = MinecraftVarInts.probe(pending);
if (!frameLength.complete()) {
break;
}
if (frameLength.value() < 0 || frameLength.value() > maxFrameBytes) {
close();
events.add(new Event(direction, "frame_oversized", "", -1, 0, stage, false));
break;
}
var totalBytes = frameLength.bytes() + frameLength.value();
if (pending.readableBytes() < totalBytes) {
break;
}
var frame = pending.readRetainedSlice(totalBytes);
try {
parseFrame(direction, frame, frameLength.bytes(), frameLength.value()).forEach(events::add);
} finally {
frame.release();
}
pending.discardReadBytes();
}
return events;
}
List<Event> observeCompressed(Direction direction, ByteBufAllocator allocator, ByteBuf input, int threshold) {
if (closed
|| !profile.legacyForgeHandshakeSupported()
|| input == null
|| !input.isReadable()
|| allocator == null) {
return List.of();
}
var pending = compressedPending(direction);
if (pending.readableBytes() + input.readableBytes() > maxFrameBytes + 5) {
close();
return List.of(new Event(direction, "pending_overflow", "", -1, 0, stage, false));
}
pending.writeBytes(input, input.readerIndex(), input.readableBytes());
var events = new ArrayList<Event>();
while (pending.isReadable()) {
var frameLength = MinecraftVarInts.probe(pending);
if (!frameLength.complete()) {
break;
}
if (frameLength.value() < 0 || frameLength.value() > maxFrameBytes) {
close();
events.add(new Event(direction, "frame_oversized", "", -1, 0, stage, false));
break;
}
var totalBytes = frameLength.bytes() + frameLength.value();
if (pending.readableBytes() < totalBytes) {
break;
}
var frame = pending.readRetainedSlice(totalBytes);
try {
var packet = compressionCodec.decodeFrame(allocator, frame, threshold, maxFrameBytes);
try {
parsePacket(direction, packet, packet.readableBytes()).forEach(events::add);
} finally {
packet.release();
}
} catch (RuntimeException exception) {
events.add(new Event(direction, "malformed", "", -1, frame.readableBytes(), stage, false));
} finally {
frame.release();
}
pending.discardReadBytes();
}
return events;
}
Stage stage() {
return stage;
}
boolean complete() {
return clientPhase == ClientPhase.COMPLETE && backendPhase == BackendPhase.COMPLETE;
}
boolean backendSwitchBlocked() {
if (!profile.legacyForgeHandshakeSupported()) {
return false;
}
if (stage == Stage.IDLE
&& clientPhase == ClientPhase.NOT_STARTED
&& backendPhase == BackendPhase.NOT_STARTED) {
return false;
}
if (backendPhase == BackendPhase.NOT_STARTED
&& stage == Stage.CHANNELS_REGISTERED
&& clientPhase == ClientPhase.CHANNELS_REGISTERED) {
return false;
}
if (stage == Stage.RESET
&& clientPhase == ClientPhase.RESET
&& backendPhase == BackendPhase.RESET) {
return false;
}
return !complete();
}
boolean consumeResetRequiredOnNextForgeServer() {
if (!resetRequiredOnNextForgeServer) {
return false;
}
resetRequiredOnNextForgeServer = false;
return true;
}
void resetHandshakeFromProxy() {
if (closed || !profile.legacyForgeHandshakeSupported()) {
return;
}
resetHandshakeState(false);
clearPending(clientboundPending);
stage = Stage.RESET;
clientPhase = ClientPhase.RESET;
backendPhase = BackendPhase.RESET;
}
private ByteBuf pending(Direction direction) {
return direction == Direction.SERVERBOUND ? serverboundPending : clientboundPending;
}
private ByteBuf compressedPending(Direction direction) {
return direction == Direction.SERVERBOUND ? serverboundCompressedPending : clientboundCompressedPending;
}
private List<Event> parseFrame(Direction direction, ByteBuf frame, int lengthBytes, int payloadBytes) {
var packet = frame.retainedDuplicate();
try {
packet.skipBytes(lengthBytes);
return parsePacket(direction, packet, payloadBytes);
} catch (RuntimeException exception) {
return List.of(new Event(direction, "malformed", "", -1, payloadBytes, stage, false));
} finally {
packet.release();
}
}
private List<Event> parsePacket(Direction direction, ByteBuf packet, int packetBytes) {
try {
var packetId = MinecraftProtocolCodec.readVarInt(packet);
if (!customPayloadPacket(direction, packetId)) {
return List.of();
}
var channel = readString(packet, MAX_CHANNEL_BYTES);
var payload = customPayloadBody(direction, packet);
if (!forgeChannel(channel)) {
return List.of();
}
return parseForgePayload(direction, channel, payload, packetBytes);
} catch (RuntimeException exception) {
return List.of(new Event(direction, "malformed", "", -1, packetBytes, stage, false));
}
}
private boolean customPayloadPacket(Direction direction, int packetId) {
if (direction == Direction.SERVERBOUND) {
return profile.serverboundCustomPayloadPacketId().isPresent()
&& packetId == profile.serverboundCustomPayloadPacketId().getAsInt();
}
return profile.clientboundCustomPayloadPacketId().isPresent()
&& packetId == profile.clientboundCustomPayloadPacketId().getAsInt();
}
private ByteBuf customPayloadBody(Direction direction, ByteBuf packet) {
var format = direction == Direction.SERVERBOUND
? profile.serverboundCustomPayloadLengthFormat()
: profile.clientboundCustomPayloadLengthFormat();
return MinecraftCustomPayloadBodyCodec.readBody(packet, format);
}
private List<Event> parseForgePayload(Direction direction, String channel, ByteBuf payload, int packetBytes) {
if ("REGISTER".equals(channel)) {
var registered = registeredChannels(payload);
var matched = registered.contains("FML|HS") || registered.contains("FML|MP") || registered.contains("FORGE");
if (matched) {
if (direction == Direction.SERVERBOUND) {
advanceClientPhase(ClientPhase.CHANNELS_REGISTERED);
} else {
advanceBackendPhase(BackendPhase.CHANNELS_REGISTERED);
}
advance(Stage.CHANNELS_REGISTERED);
}
return List.of(new Event(direction, matched ? "register" : "register_other", channel, -1, payload.readableBytes(), stage, true));
}
if ("FML|MP".equals(channel)) {
return parseMultipart(direction, payload);
}
if (!"FML|HS".equals(channel)) {
return List.of(new Event(direction, channel.toLowerCase(Locale.ROOT), channel, -1, payload.readableBytes(), stage, true));
}
return List.of(parseHandshakePayload(direction, channel, payload, packetBytes));
}
private List<Event> parseMultipart(Direction direction, ByteBuf payload) {
if (direction != Direction.CLIENTBOUND) {
return List.of(new Event(direction, "multipart_unexpected_direction", "FML|MP", -1, payload.readableBytes(), stage, false));
}
if (clientboundMultipart == null) {
var readable = payload.readableBytes();
var wrappedChannel = readString(payload, MAX_MULTIPART_CHANNEL_BYTES);
if (payload.readableBytes() < 5) {
return List.of(new Event(direction, "multipart_malformed", "FML|MP", -1, readable, stage, false));
}
var parts = payload.readUnsignedByte();
var totalLength = payload.readInt();
var expected = parts > 0
&& parts <= MAX_MULTIPART_PARTS
&& totalLength >= 0
&& totalLength <= MAX_MULTIPART_BYTES
&& forgeChannel(wrappedChannel);
if (expected) {
clientboundMultipart = new MultipartState(wrappedChannel, parts, totalLength);
}
return List.of(new Event(direction, expected ? "multipart_start" : "multipart_start_invalid",
wrappedChannel, -1, totalLength, stage, expected));
}
if (!payload.isReadable()) {
clearMultipart();
return List.of(new Event(direction, "multipart_empty_part", "FML|MP", -1, 0, stage, false));
}
var multipart = clientboundMultipart;
var part = payload.readUnsignedByte();
var dataBytes = payload.readableBytes();
var expected = part == multipart.nextPart
&& multipart.receivedBytes + dataBytes <= multipart.totalLength;
var events = new ArrayList<Event>();
events.add(new Event(direction, expected ? "multipart_part" : "multipart_part_unexpected",
multipart.wrappedChannel, -1, dataBytes, stage, expected));
if (expected && part == 0 && "FML|HS".equals(multipart.wrappedChannel) && payload.isReadable()) {
var discriminator = payload.getUnsignedByte(payload.readerIndex());
if (discriminator == 3 || discriminator == 255) {
var handshakePayload = payload.retainedDuplicate();
try {
var handshake = parseHandshakePayload(direction, multipart.wrappedChannel, handshakePayload, dataBytes);
if (discriminator == 3) {
clientboundRegistryBytes += Math.max(0, multipart.totalLength - dataBytes);
}
events.add(new Event(direction, handshake.type() + "_multipart", handshake.channel(),
handshake.discriminator(), multipart.totalLength, handshake.stage(), handshake.expected()));
} finally {
handshakePayload.release();
}
}
}
if (expected) {
multipart.nextPart++;
multipart.receivedBytes += dataBytes;
if (multipart.nextPart == multipart.parts || multipart.receivedBytes == multipart.totalLength) {
events.add(new Event(direction, multipart.receivedBytes == multipart.totalLength
? "multipart_complete"
: "multipart_length_mismatch",
multipart.wrappedChannel, -1, multipart.receivedBytes, stage,
multipart.receivedBytes == multipart.totalLength));
clearMultipart();
}
} else {
clearMultipart();
}
return List.copyOf(events);
}
private Event parseHandshakePayload(Direction direction, String channel, ByteBuf payload, int packetBytes) {
if (!payload.isReadable()) {
return new Event(direction, "empty_handshake", channel, -1, packetBytes, stage, false);
}
var discriminator = payload.readUnsignedByte();
var type = switch (discriminator) {
case 0 -> "server_hello";
case 1 -> "client_hello";
case 2 -> "mod_list";
case 3 -> "registry_data";
case 254 -> "handshake_reset";
case 255 -> "handshake_ack";
default -> "unknown_handshake";
};
var expected = expected(direction, discriminator);
if (expected) {
updateStage(direction, discriminator, payload);
}
return new Event(direction, type, channel, discriminator, payload.readableBytes(), stage, expected);
}
private void updateStage(Direction direction, int discriminator, ByteBuf payload) {
if (direction == Direction.CLIENTBOUND && discriminator == 254) {
resetHandshakeState(true);
stage = Stage.RESET;
clientPhase = ClientPhase.RESET;
backendPhase = BackendPhase.RESET;
return;
}
if (direction == Direction.CLIENTBOUND && discriminator == 0) {
advanceClientPhase(ClientPhase.SERVER_HELLO_RECEIVED);
advanceBackendPhase(BackendPhase.SERVER_HELLO_SENT);
advance(Stage.SERVER_HELLO);
return;
}
if (direction == Direction.SERVERBOUND && discriminator == 1) {
advanceClientPhase(ClientPhase.CLIENT_HELLO_SENT);
advance(Stage.CLIENT_HELLO);
return;
}
if (discriminator == 2) {
var mods = scanModList(payload);
if (direction == Direction.SERVERBOUND) {
clientMods = mods;
advanceClientPhase(ClientPhase.MOD_LIST_SENT);
} else {
serverMods = mods;
advanceBackendPhase(BackendPhase.MOD_LIST_SENT);
}
advance(direction == Direction.SERVERBOUND ? Stage.CLIENT_MOD_LIST : Stage.SERVER_MOD_LIST);
return;
}
if (direction == Direction.CLIENTBOUND && discriminator == 3) {
clientboundRegistryPackets++;
clientboundRegistryBytes += payload.readableBytes();
advanceBackendPhase(BackendPhase.REGISTRY_DATA_SENT);
advance(Stage.REGISTRY_DATA);
return;
}
if (discriminator == 255 && payload.isReadable()) {
updateAckStage(direction, payload.readUnsignedByte());
}
}
private boolean expected(Direction direction, int discriminator) {
if (discriminator == 254 && direction == Direction.CLIENTBOUND) {
return true;
}
return switch (stage) {
case IDLE, CHANNELS_REGISTERED -> direction == Direction.CLIENTBOUND && discriminator == 0;
case SERVER_HELLO -> direction == Direction.SERVERBOUND && (discriminator == 1 || discriminator == 2);
case CLIENT_HELLO -> direction == Direction.SERVERBOUND && discriminator == 2;
case CLIENT_MOD_LIST -> direction == Direction.CLIENTBOUND && discriminator == 2;
case SERVER_MOD_LIST -> direction == Direction.SERVERBOUND && discriminator == 255;
case WAITING_SERVER_DATA, REGISTRY_DATA -> direction == Direction.CLIENTBOUND && discriminator == 3
|| direction == Direction.SERVERBOUND && discriminator == 255;
case WAITING_SERVER_COMPLETE -> direction == Direction.SERVERBOUND && discriminator == 255
|| direction == Direction.CLIENTBOUND && discriminator == 255;
case WAITING_CACK -> direction == Direction.SERVERBOUND && discriminator == 255;
case PENDING_COMPLETE -> direction == Direction.CLIENTBOUND && discriminator == 255;
case COMPLETE -> true;
case RESET -> direction == Direction.CLIENTBOUND && discriminator == 0;
};
}
private void updateAckStage(Direction direction, int phase) {
if (direction == Direction.SERVERBOUND) {
if (phase == 2) {
advanceClientPhase(ClientPhase.WAITING_SERVER_DATA);
advance(Stage.WAITING_SERVER_DATA);
} else if (phase == 3) {
advanceClientPhase(ClientPhase.WAITING_SERVER_COMPLETE);
advance(Stage.WAITING_SERVER_COMPLETE);
} else if (phase == 4) {
advanceClientPhase(ClientPhase.PENDING_COMPLETE);
advance(Stage.PENDING_COMPLETE);
} else if (phase == 5) {
advanceClientPhase(ClientPhase.COMPLETE);
advance(Stage.COMPLETE);
}
return;
}
if (phase == 2) {
advanceBackendPhase(BackendPhase.WAITING_CACK);
advance(Stage.WAITING_CACK);
} else if (phase == 3) {
advanceBackendPhase(BackendPhase.COMPLETE);
advance(Stage.COMPLETE);
}
}
private void advance(Stage next) {
if (stage == Stage.RESET && next != Stage.RESET) {
stage = next;
return;
}
if (next.ordinal() > stage.ordinal() || next == Stage.RESET) {
stage = next;
}
}
private void advanceClientPhase(ClientPhase next) {
if (clientPhase == ClientPhase.RESET && next != ClientPhase.RESET) {
clientPhase = next;
return;
}
if (next.ordinal() > clientPhase.ordinal() || next == ClientPhase.RESET) {
clientPhase = next;
}
}
private void advanceBackendPhase(BackendPhase next) {
if (backendPhase == BackendPhase.RESET && next != BackendPhase.RESET) {
backendPhase = next;
return;
}
if (next.ordinal() > backendPhase.ordinal() || next == BackendPhase.RESET) {
backendPhase = next;
}
}
int clientModCount() {
return clientMods.size();
}
int serverModCount() {
return serverMods.size();
}
int clientboundRegistryPackets() {
return clientboundRegistryPackets;
}
long clientboundRegistryBytes() {
return clientboundRegistryBytes;
}
Map<String, String> clientMods() {
return clientMods;
}
Map<String, String> serverMods() {
return serverMods;
}
ClientPhase clientPhase() {
return clientPhase;
}
BackendPhase backendPhase() {
return backendPhase;
}
private static Map<String, String> scanModList(ByteBuf payload) {
if (!payload.isReadable()) {
return Map.of();
}
var count = MinecraftProtocolCodec.readVarInt(payload);
if (count < 0 || count > MAX_MOD_LIST_ENTRIES_TO_SCAN) {
throw new IllegalArgumentException("invalid FML mod list size");
}
var mods = new LinkedHashMap<String, String>(Math.min(count, 256));
for (var index = 0; index < count && payload.isReadable(); index++) {
var modId = readString(payload, 256);
var version = readString(payload, 256);
mods.put(modId, version);
}
return Collections.unmodifiableMap(mods);
}
private static List<String> registeredChannels(ByteBuf payload) {
if (!payload.isReadable()) {
return List.of();
}
var channels = new ArrayList<String>();
var start = payload.readerIndex();
var end = payload.writerIndex();
for (var index = start; index <= end; index++) {
if (index == end || payload.getByte(index) == 0) {
if (index > start) {
channels.add(payload.toString(start, index - start, StandardCharsets.UTF_8));
}
start = index + 1;
}
}
return List.copyOf(channels);
}
private void resetHandshakeState(boolean keepCurrentClientboundPending) {
clientMods = Map.of();
serverMods = Map.of();
clientboundRegistryPackets = 0;
clientboundRegistryBytes = 0;
clearPending(serverboundPending);
clearPending(serverboundCompressedPending);
if (!keepCurrentClientboundPending) {
clearPending(clientboundPending);
clearPending(clientboundCompressedPending);
}
clearMultipart();
}
private static void clearPending(ByteBuf pending) {
if (pending.refCnt() > 0) {
pending.clear();
}
}
private void clearMultipart() {
clientboundMultipart = null;
}
private static boolean forgeChannel(String channel) {
return "REGISTER".equals(channel)
|| "FML|HS".equals(channel)
|| "FML|MP".equals(channel)
|| "FML".equals(channel)
|| "FORGE".equals(channel);
}
private static String readString(ByteBuf input, int maxCharacters) {
return MinecraftProtocolCodec.readString(input, maxCharacters);
}
@Override
public void close() {
if (closed) {
return;
}
closed = true;
if (serverboundPending.refCnt() > 0) {
serverboundPending.release();
}
if (clientboundPending.refCnt() > 0) {
clientboundPending.release();
}
if (serverboundCompressedPending.refCnt() > 0) {
serverboundCompressedPending.release();
}
if (clientboundCompressedPending.refCnt() > 0) {
clientboundCompressedPending.release();
}
serverboundPending = Unpooled.EMPTY_BUFFER;
clientboundPending = Unpooled.EMPTY_BUFFER;
serverboundCompressedPending = Unpooled.EMPTY_BUFFER;
clientboundCompressedPending = Unpooled.EMPTY_BUFFER;
compressionCodec.close();
clearMultipart();
}
enum Direction {
SERVERBOUND,
CLIENTBOUND
}
enum Stage {
IDLE,
CHANNELS_REGISTERED,
SERVER_HELLO,
CLIENT_HELLO,
CLIENT_MOD_LIST,
SERVER_MOD_LIST,
WAITING_SERVER_DATA,
REGISTRY_DATA,
WAITING_SERVER_COMPLETE,
WAITING_CACK,
PENDING_COMPLETE,
COMPLETE,
RESET
}
enum ClientPhase {
NOT_STARTED,
RESET,
CHANNELS_REGISTERED,
SERVER_HELLO_RECEIVED,
CLIENT_HELLO_SENT,
MOD_LIST_SENT,
WAITING_SERVER_DATA,
WAITING_SERVER_COMPLETE,
PENDING_COMPLETE,
COMPLETE
}
enum BackendPhase {
NOT_STARTED,
RESET,
CHANNELS_REGISTERED,
SERVER_HELLO_SENT,
MOD_LIST_SENT,
REGISTRY_DATA_SENT,
WAITING_CACK,
COMPLETE
}
record Event(Direction direction, String type, String channel, int discriminator, int payloadBytes, Stage stage, boolean expected) {
}
private static final class MultipartState {
private final String wrappedChannel;
private final int parts;
private final int totalLength;
private int nextPart;
private int receivedBytes;
private MultipartState(String wrappedChannel, int parts, int totalLength) {
this.wrappedChannel = wrappedChannel;
this.parts = parts;
this.totalLength = totalLength;
}
}
}
@@ -24,4 +24,8 @@ public record MinecraftHandshake(
throw new IllegalArgumentException("requestedPort must fit unsigned short");
}
}
boolean legacyForgeClientMarker() {
return requestedHost.contains("\0FML\0");
}
}
@@ -0,0 +1,415 @@
package dev.strataproxy.network;
import dev.strataproxy.codec.minecraft.MinecraftVarInts;
import dev.strataproxy.codec.minecraft.MinecraftCompressionCodec;
import io.netty.buffer.ByteBuf;
import io.netty.buffer.ByteBufAllocator;
import io.netty.buffer.Unpooled;
import java.nio.charset.StandardCharsets;
import java.util.LinkedHashSet;
import java.util.Set;
import java.util.UUID;
final class MinecraftLegacyClientStateTracker implements AutoCloseable {
private static final int DEFAULT_MAX_PLAYER_LIST_ENTRIES = 16_384;
private static final int DEFAULT_MAX_OBJECTIVES = 4_096;
private static final int DEFAULT_MAX_TEAMS = 4_096;
private final int maxFrameBytes;
private final int maxPlayerListEntries;
private final int maxObjectives;
private final int maxTeams;
private final Set<String> legacyPlayerListNames = new LinkedHashSet<>();
private final Set<UUID> playerListUuids = new LinkedHashSet<>();
private final Set<String> objectives = new LinkedHashSet<>();
private final Set<String> teams = new LinkedHashSet<>();
private final MinecraftCompressionCodec compressionCodec = new MinecraftCompressionCodec();
private ByteBuf pending = Unpooled.buffer();
private ByteBuf compressedPending = Unpooled.buffer();
private boolean closed;
MinecraftLegacyClientStateTracker(int maxFrameBytes) {
this(maxFrameBytes, DEFAULT_MAX_PLAYER_LIST_ENTRIES, DEFAULT_MAX_OBJECTIVES, DEFAULT_MAX_TEAMS);
}
MinecraftLegacyClientStateTracker(
int maxFrameBytes,
int maxPlayerListEntries,
int maxObjectives,
int maxTeams) {
if (maxFrameBytes <= 0) {
throw new IllegalArgumentException("maxFrameBytes must be positive");
}
if (maxPlayerListEntries <= 0 || maxObjectives <= 0 || maxTeams <= 0) {
throw new IllegalArgumentException("tracked state limits must be positive");
}
this.maxFrameBytes = maxFrameBytes;
this.maxPlayerListEntries = maxPlayerListEntries;
this.maxObjectives = maxObjectives;
this.maxTeams = maxTeams;
}
void observe(ByteBuf input, MinecraftProtocolProfile profile) {
if (closed || input == null || !input.isReadable() || !supported(profile)) {
return;
}
if (pending.readableBytes() + input.readableBytes() > maxFrameBytes + 5) {
pending.clear();
throw new IllegalArgumentException("legacy client state tracker exceeded maximum frame size");
}
pending.writeBytes(input, input.readerIndex(), input.readableBytes());
while (pending.isReadable()) {
var probe = MinecraftProtocolCodec.probeFrame(pending, maxFrameBytes);
if (!probe.complete()) {
break;
}
var frame = pending.readRetainedSlice(probe.totalBytes());
try {
observeFrame(frame, probe.varIntBytes(), profile);
} finally {
frame.release();
}
pending.discardReadBytes();
}
}
void observeCompressed(ByteBufAllocator allocator, ByteBuf input, int threshold, MinecraftProtocolProfile profile) {
if (closed || input == null || !input.isReadable() || !supported(profile)) {
return;
}
if (threshold < 0) {
throw new IllegalArgumentException("compression threshold must be non-negative");
}
if (compressedPending.readableBytes() + input.readableBytes() > maxFrameBytes + 5) {
compressedPending.clear();
throw new IllegalArgumentException("legacy client state tracker compressed frame exceeded maximum size");
}
compressedPending.writeBytes(input, input.readerIndex(), input.readableBytes());
while (compressedPending.isReadable()) {
var probe = MinecraftProtocolCodec.probeFrame(compressedPending, maxFrameBytes);
if (!probe.complete()) {
break;
}
var frame = compressedPending.readRetainedSlice(probe.totalBytes());
ByteBuf decoded = null;
try {
decoded = compressionCodec.decodeFrame(allocator, frame, threshold, maxFrameBytes);
observePacket(decoded, profile);
} finally {
if (decoded != null) {
decoded.release();
}
frame.release();
}
compressedPending.discardReadBytes();
}
}
ByteBuf clearFrames(ByteBufAllocator allocator, MinecraftProtocolProfile profile) {
if (!supported(profile) || empty()) {
return Unpooled.EMPTY_BUFFER;
}
var output = allocator.buffer();
try {
writePlayerListRemoveFrames(allocator, output, profile);
for (var objective : objectives) {
writeScoreboardObjectiveRemoveFrame(allocator, output, profile, objective);
}
for (var team : teams) {
writeTeamRemoveFrame(allocator, output, profile, team);
}
clearTrackedState();
return output;
} catch (RuntimeException exception) {
output.release();
throw exception;
}
}
int playerListCount() {
return legacyPlayerListNames.size() + playerListUuids.size();
}
int objectiveCount() {
return objectives.size();
}
int teamCount() {
return teams.size();
}
private void observeFrame(ByteBuf frame, int varIntBytes, MinecraftProtocolProfile profile) {
var packet = frame.retainedDuplicate();
try {
packet.skipBytes(varIntBytes);
observePacket(packet, profile);
} catch (RuntimeException ignored) {
} finally {
packet.release();
}
}
private void observePacket(ByteBuf packet, MinecraftProtocolProfile profile) {
var packetId = MinecraftProtocolCodec.readVarInt(packet);
if (profile.clientboundPlayerListItemPacketId().isPresent()
&& packetId == profile.clientboundPlayerListItemPacketId().getAsInt()) {
observePlayerListItem(packet, profile);
return;
}
if (profile.clientboundScoreboardObjectivePacketId().isPresent()
&& packetId == profile.clientboundScoreboardObjectivePacketId().getAsInt()) {
observeScoreboardObjective(packet, profile);
return;
}
if (profile.clientboundTeamPacketId().isPresent()
&& packetId == profile.clientboundTeamPacketId().getAsInt()) {
observeTeam(packet);
}
}
private void observePlayerListItem(ByteBuf packet, MinecraftProtocolProfile profile) {
switch (profile.playerListItemLayout()) {
case LEGACY_NAME -> {
var name = MinecraftProtocolCodec.readString(packet, 16);
var online = packet.readBoolean();
packet.readShort();
if (online) {
addBounded(legacyPlayerListNames, name, maxPlayerListEntries, "legacy player list");
} else {
legacyPlayerListNames.remove(name);
}
return;
}
case UUID_ACTION -> {
}
case NONE -> {
return;
}
}
var action = MinecraftProtocolCodec.readVarInt(packet);
var count = MinecraftProtocolCodec.readVarInt(packet);
if (count < 0 || count > 1024) {
throw new IllegalArgumentException("player list item count out of bounds: " + count);
}
for (var i = 0; i < count; i++) {
var uuid = new UUID(packet.readLong(), packet.readLong());
if (action == 0) {
skipModernPlayerListAdd(packet);
addBounded(playerListUuids, uuid, maxPlayerListEntries, "modern player list");
} else if (action == 4) {
playerListUuids.remove(uuid);
} else {
skipModernPlayerListUpdate(packet, action);
}
}
}
private void observeScoreboardObjective(ByteBuf packet, MinecraftProtocolProfile profile) {
var name = MinecraftProtocolCodec.readString(packet, 16);
int action;
if (profile.scoreboardObjectiveLayout() == MinecraftProtocolProfile.ScoreboardObjectiveLayout.NAME_VALUE_ACTION) {
MinecraftProtocolCodec.readString(packet, 32);
action = packet.readUnsignedByte();
} else if (profile.scoreboardObjectiveLayout() == MinecraftProtocolProfile.ScoreboardObjectiveLayout.NAME_ACTION) {
action = packet.readUnsignedByte();
} else {
return;
}
if (action == 0) {
addBounded(objectives, name, maxObjectives, "scoreboard objectives");
} else if (action == 1) {
objectives.remove(name);
}
}
private void skipModernPlayerListAdd(ByteBuf packet) {
MinecraftProtocolCodec.readString(packet, 16);
var properties = MinecraftProtocolCodec.readVarInt(packet);
if (properties < 0 || properties > 64) {
throw new IllegalArgumentException("player list properties out of bounds: " + properties);
}
for (var i = 0; i < properties; i++) {
MinecraftProtocolCodec.readString(packet, 32767);
MinecraftProtocolCodec.readString(packet, 32767);
if (packet.readBoolean()) {
MinecraftProtocolCodec.readString(packet, 32767);
}
}
MinecraftProtocolCodec.readVarInt(packet);
MinecraftProtocolCodec.readVarInt(packet);
if (packet.readBoolean()) {
MinecraftProtocolCodec.readString(packet, 32767);
}
}
private void skipModernPlayerListUpdate(ByteBuf packet, int action) {
switch (action) {
case 1, 2 -> MinecraftProtocolCodec.readVarInt(packet);
case 3 -> {
if (packet.readBoolean()) {
MinecraftProtocolCodec.readString(packet, 32767);
}
}
default -> throw new IllegalArgumentException("unknown player list action: " + action);
}
}
private void writePlayerListRemoveFrames(
ByteBufAllocator allocator,
ByteBuf output,
MinecraftProtocolProfile profile) {
if (profile.clientboundPlayerListItemPacketId().isEmpty()) {
return;
}
if (profile.playerListItemLayout() == MinecraftProtocolProfile.PlayerListItemLayout.LEGACY_NAME) {
for (var name : legacyPlayerListNames) {
writeLegacyPlayerListRemoveFrame(allocator, output, profile, name);
}
} else if (profile.playerListItemLayout() == MinecraftProtocolProfile.PlayerListItemLayout.UUID_ACTION
&& !playerListUuids.isEmpty()) {
writeModernPlayerListRemoveFrame(allocator, output, profile);
}
}
private void writeLegacyPlayerListRemoveFrame(
ByteBufAllocator allocator,
ByteBuf output,
MinecraftProtocolProfile profile,
String name) {
var packet = allocator.buffer();
try {
MinecraftVarInts.write(packet, profile.clientboundPlayerListItemPacketId().orElseThrow());
writeString(packet, name);
packet.writeBoolean(false);
packet.writeShort(0);
writeFrame(output, packet);
} finally {
packet.release();
}
}
private void writeModernPlayerListRemoveFrame(
ByteBufAllocator allocator,
ByteBuf output,
MinecraftProtocolProfile profile) {
var packet = allocator.buffer();
try {
MinecraftVarInts.write(packet, profile.clientboundPlayerListItemPacketId().orElseThrow());
MinecraftVarInts.write(packet, 4);
MinecraftVarInts.write(packet, playerListUuids.size());
for (var uuid : playerListUuids) {
packet.writeLong(uuid.getMostSignificantBits());
packet.writeLong(uuid.getLeastSignificantBits());
}
writeFrame(output, packet);
} finally {
packet.release();
}
}
private void observeTeam(ByteBuf packet) {
var name = MinecraftProtocolCodec.readString(packet, 16);
var mode = packet.readUnsignedByte();
if (mode == 0) {
addBounded(teams, name, maxTeams, "scoreboard teams");
} else if (mode == 1) {
teams.remove(name);
}
}
private static void writeScoreboardObjectiveRemoveFrame(
ByteBufAllocator allocator,
ByteBuf output,
MinecraftProtocolProfile profile,
String name) {
var packet = allocator.buffer();
try {
MinecraftVarInts.write(packet, profile.clientboundScoreboardObjectivePacketId().orElseThrow());
writeString(packet, name);
if (profile.scoreboardObjectiveLayout() == MinecraftProtocolProfile.ScoreboardObjectiveLayout.NAME_VALUE_ACTION) {
writeString(packet, "");
packet.writeByte(1);
} else if (profile.scoreboardObjectiveLayout() == MinecraftProtocolProfile.ScoreboardObjectiveLayout.NAME_ACTION) {
packet.writeByte(1);
} else {
return;
}
writeFrame(output, packet);
} finally {
packet.release();
}
}
private static void writeTeamRemoveFrame(
ByteBufAllocator allocator,
ByteBuf output,
MinecraftProtocolProfile profile,
String name) {
var packet = allocator.buffer();
try {
MinecraftVarInts.write(packet, profile.clientboundTeamPacketId().orElseThrow());
writeString(packet, name);
packet.writeByte(1);
writeFrame(output, packet);
} finally {
packet.release();
}
}
private static void writeFrame(ByteBuf output, ByteBuf packet) {
MinecraftVarInts.write(output, packet.readableBytes());
output.writeBytes(packet, packet.readerIndex(), packet.readableBytes());
}
private static void writeString(ByteBuf output, String value) {
var bytes = value.getBytes(StandardCharsets.UTF_8);
MinecraftVarInts.write(output, bytes.length);
output.writeBytes(bytes);
}
private static boolean supported(MinecraftProtocolProfile profile) {
return profile != null
&& (profile.clientboundPlayerListItemPacketId().isPresent()
|| profile.clientboundScoreboardObjectivePacketId().isPresent()
|| profile.clientboundTeamPacketId().isPresent());
}
private boolean empty() {
return legacyPlayerListNames.isEmpty()
&& playerListUuids.isEmpty()
&& objectives.isEmpty()
&& teams.isEmpty();
}
private <T> void addBounded(Set<T> values, T value, int maxValues, String label) {
if (!values.contains(value) && values.size() >= maxValues) {
clearTrackedState();
throw new IllegalStateException("legacy client state tracker exceeded " + label + " limit");
}
values.add(value);
}
private void clearTrackedState() {
legacyPlayerListNames.clear();
playerListUuids.clear();
objectives.clear();
teams.clear();
}
@Override
public void close() {
closed = true;
clearTrackedState();
if (pending.refCnt() > 0) {
pending.release();
}
if (compressedPending.refCnt() > 0) {
compressedPending.release();
}
pending = Unpooled.EMPTY_BUFFER;
compressedPending = Unpooled.EMPTY_BUFFER;
compressionCodec.close();
}
}
@@ -0,0 +1,223 @@
package dev.strataproxy.network;
import dev.strataproxy.codec.minecraft.MinecraftVarInts;
import dev.strataproxy.codec.minecraft.MinecraftCompressionCodec;
import io.netty.buffer.ByteBuf;
import io.netty.buffer.ByteBufAllocator;
import io.netty.buffer.Unpooled;
import java.nio.charset.StandardCharsets;
final class MinecraftLegacyTransferPackets {
private MinecraftLegacyTransferPackets() {
}
static ByteBuf respawnSequenceFromJoinGame(ByteBufAllocator allocator, ByteBuf joinGamePacket, MinecraftProtocolProfile profile) {
if (profile.clientboundPlayRespawnPacketId().isEmpty()) {
throw new IllegalArgumentException("profile does not define a respawn packet");
}
var template = respawnTemplateFromJoinGame(joinGamePacket, profile);
var output = allocator.buffer();
writeRespawnFrame(allocator, output, profile.clientboundPlayRespawnPacketId().getAsInt(),
template.dimension() >= 0 ? -1 : 0,
template.difficulty(),
template.gameMode(),
template.levelType());
writeRespawnFrame(allocator, output, profile.clientboundPlayRespawnPacketId().getAsInt(),
template.dimension(),
template.difficulty(),
template.gameMode(),
template.levelType());
return output;
}
static ByteBuf compressedRespawnSequenceFromJoinGame(
ByteBufAllocator allocator,
ByteBuf joinGamePacket,
MinecraftProtocolProfile profile,
MinecraftCompressionCodec codec,
int threshold) {
if (profile.clientboundPlayRespawnPacketId().isEmpty()) {
throw new IllegalArgumentException("profile does not define a respawn packet");
}
if (codec == null) {
throw new IllegalArgumentException("codec is required");
}
var template = respawnTemplateFromJoinGame(joinGamePacket, profile);
var output = allocator.buffer();
writeCompressedRespawnFrame(allocator, output, codec, threshold, profile.clientboundPlayRespawnPacketId().getAsInt(),
template.dimension() >= 0 ? -1 : 0,
template.difficulty(),
template.gameMode(),
template.levelType());
writeCompressedRespawnFrame(allocator, output, codec, threshold, profile.clientboundPlayRespawnPacketId().getAsInt(),
template.dimension(),
template.difficulty(),
template.gameMode(),
template.levelType());
return output;
}
private static RespawnTemplate respawnTemplateFromJoinGame(
ByteBuf joinGamePacket,
MinecraftProtocolProfile profile) {
var view = joinGamePacket.retainedDuplicate();
try {
var packetId = MinecraftProtocolCodec.readVarInt(view);
if (profile.clientboundPlayLoginPacketId().isEmpty()
|| packetId != profile.clientboundPlayLoginPacketId().getAsInt()) {
throw new IllegalArgumentException("expected legacy Join Game packet, got " + packetId);
}
view.readInt();
var gameMode = view.readUnsignedByte();
var dimension = view.readByte();
var difficulty = view.readUnsignedByte();
view.readUnsignedByte();
var levelType = MinecraftProtocolCodec.readString(view, 16);
return new RespawnTemplate(dimension, difficulty, gameMode, levelType);
} finally {
view.release();
}
}
static ByteBuf forgeHandshakeResetFrame(ByteBufAllocator allocator, MinecraftProtocolProfile profile) {
if (profile.clientboundCustomPayloadPacketId().isEmpty()) {
throw new IllegalArgumentException("profile does not define a clientbound custom payload packet");
}
var packet = allocator.buffer();
try {
MinecraftVarInts.write(packet, profile.clientboundCustomPayloadPacketId().getAsInt());
writeString(packet, "FML|HS");
MinecraftCustomPayloadBodyCodec.writeLength(packet, profile.clientboundCustomPayloadLengthFormat(), 2);
packet.writeByte(0xFE);
packet.writeByte(0);
var frame = allocator.buffer(MinecraftVarInts.encodedSize(packet.readableBytes()) + packet.readableBytes());
MinecraftVarInts.write(frame, packet.readableBytes());
frame.writeBytes(packet, packet.readerIndex(), packet.readableBytes());
return frame;
} finally {
packet.release();
}
}
static ByteBuf compressFrameBatch(
ByteBufAllocator allocator,
ByteBuf frames,
MinecraftCompressionCodec codec,
int threshold,
int maxFrameBytes) {
if (frames == null || !frames.isReadable()) {
return Unpooled.EMPTY_BUFFER;
}
if (codec == null) {
throw new IllegalArgumentException("codec is required");
}
var output = allocator.buffer(frames.readableBytes());
try {
var source = frames.retainedDuplicate();
try {
while (source.isReadable()) {
var probe = MinecraftVarInts.probe(source);
if (!probe.complete()) {
throw new IllegalArgumentException("truncated frame length");
}
if (probe.value() < 0 || probe.value() > maxFrameBytes) {
throw new IllegalArgumentException("frame exceeds maximum size");
}
var totalBytes = probe.bytes() + probe.value();
if (source.readableBytes() < totalBytes) {
throw new IllegalArgumentException("truncated frame");
}
source.skipBytes(probe.bytes());
var packet = source.readRetainedSlice(probe.value());
try {
var compressed = codec.encodeFrame(allocator, packet, threshold);
try {
output.writeBytes(compressed, compressed.readerIndex(), compressed.readableBytes());
} finally {
compressed.release();
}
} finally {
packet.release();
}
}
} finally {
source.release();
}
return output;
} catch (RuntimeException exception) {
output.release();
throw exception;
}
}
private static void writeRespawnFrame(
ByteBufAllocator allocator,
ByteBuf output,
int packetId,
int dimension,
int difficulty,
int gameMode,
String levelType) {
var packet = allocator.buffer();
try {
MinecraftVarInts.write(packet, packetId);
packet.writeInt(dimension);
packet.writeByte(difficulty);
packet.writeByte(gameMode);
writeString(packet, levelType == null || levelType.isBlank() ? "default" : levelType);
MinecraftVarInts.write(output, packet.readableBytes());
output.writeBytes(packet, packet.readerIndex(), packet.readableBytes());
} finally {
packet.release();
}
}
private static void writeCompressedRespawnFrame(
ByteBufAllocator allocator,
ByteBuf output,
MinecraftCompressionCodec codec,
int threshold,
int packetId,
int dimension,
int difficulty,
int gameMode,
String levelType) {
var packet = respawnPacket(allocator, packetId, dimension, difficulty, gameMode, levelType);
try {
var frame = codec.encodeFrame(allocator, packet, threshold);
try {
output.writeBytes(frame, frame.readerIndex(), frame.readableBytes());
} finally {
frame.release();
}
} finally {
packet.release();
}
}
private static ByteBuf respawnPacket(
ByteBufAllocator allocator,
int packetId,
int dimension,
int difficulty,
int gameMode,
String levelType) {
var packet = allocator.buffer();
MinecraftVarInts.write(packet, packetId);
packet.writeInt(dimension);
packet.writeByte(difficulty);
packet.writeByte(gameMode);
writeString(packet, levelType == null || levelType.isBlank() ? "default" : levelType);
return packet;
}
private static void writeString(ByteBuf output, String value) {
var bytes = value.getBytes(StandardCharsets.UTF_8);
MinecraftVarInts.write(output, bytes.length);
output.writeBytes(bytes);
}
private record RespawnTemplate(int dimension, int difficulty, int gameMode, String levelType) {
}
}
@@ -14,10 +14,17 @@ final class MinecraftLoginStartSampler {
private boolean found;
MinecraftLoginStartSampler(int maxFrameBytes) {
this(maxFrameBytes, Unpooled.EMPTY_BUFFER);
}
MinecraftLoginStartSampler(int maxFrameBytes, ByteBuf seed) {
if (maxFrameBytes <= 0) {
throw new IllegalArgumentException("maxFrameBytes must be positive");
}
this.maxFrameBytes = maxFrameBytes;
if (seed != null && seed.isReadable()) {
this.pending.writeBytes(seed, seed.readerIndex(), seed.readableBytes());
}
}
Optional<String> observe(ByteBuf input) {
@@ -9,9 +9,6 @@ import java.nio.charset.StandardCharsets;
import java.util.Optional;
final class MinecraftPlayMessages {
private static final int PROTOCOL_1_20_1 = 763;
private static final int CLIENTBOUND_SYSTEM_CHAT_1_20_1 = 0x64;
private MinecraftPlayMessages() {
}
@@ -21,15 +18,34 @@ final class MinecraftPlayMessages {
String message,
boolean compressed,
int compressionThreshold) {
var packetId = systemChatPacketId(protocolVersion);
if (packetId < 0 || message == null || message.isBlank()) {
return systemChatFrame(
allocator,
MinecraftProtocolProfile.forVersion(protocolVersion),
message,
compressed,
compressionThreshold);
}
static Optional<ByteBuf> systemChatFrame(
ByteBufAllocator allocator,
MinecraftProtocolProfile profile,
String message,
boolean compressed,
int compressionThreshold) {
var resolvedProfile = profile == null
? MinecraftProtocolProfile.forVersion(MinecraftProtocolProfile.PROTOCOL_1_20_1)
: profile;
var packetId = resolvedProfile.clientboundChatPacketId();
if (packetId.isEmpty() || message == null || message.isBlank()) {
return Optional.empty();
}
var packet = allocator.buffer();
try {
MinecraftVarInts.write(packet, packetId);
MinecraftVarInts.write(packet, packetId.getAsInt());
writeString(packet, "{\"text\":\"" + escapeJson(message) + "\"}");
packet.writeBoolean(false);
if (resolvedProfile.clientboundChatHasOverlayFlag()) {
packet.writeBoolean(false);
}
if (compressed) {
try (var codec = new MinecraftCompressionCodec()) {
return Optional.of(codec.encodeFrame(allocator, packet, compressionThreshold));
@@ -41,10 +57,6 @@ final class MinecraftPlayMessages {
}
}
private static int systemChatPacketId(int protocolVersion) {
return protocolVersion == PROTOCOL_1_20_1 ? CLIENTBOUND_SYSTEM_CHAT_1_20_1 : -1;
}
private static ByteBuf frame(ByteBufAllocator allocator, ByteBuf packet) {
var output = allocator.buffer(MinecraftVarInts.encodedSize(packet.readableBytes()) + packet.readableBytes());
MinecraftVarInts.write(output, packet.readableBytes());
@@ -0,0 +1,213 @@
package dev.strataproxy.network;
import dev.strataproxy.codec.minecraft.MinecraftCompressionCodec;
import dev.strataproxy.codec.minecraft.MinecraftVarInts;
import io.netty.buffer.ByteBuf;
import io.netty.buffer.ByteBufAllocator;
import io.netty.buffer.Unpooled;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
final class MinecraftPluginChannelRegistry implements AutoCloseable {
private static final int MAX_CHANNELS = 1024;
private static final int MAX_CHANNEL_BYTES = 128;
private final int maxFrameBytes;
private final LinkedHashSet<String> channels = new LinkedHashSet<>();
private final MinecraftCompressionCodec compressionCodec = new MinecraftCompressionCodec();
private ByteBuf pending = Unpooled.buffer();
private Boolean pendingCompressed;
private boolean closed;
MinecraftPluginChannelRegistry(int maxFrameBytes) {
this.maxFrameBytes = maxFrameBytes;
}
synchronized void observeServerbound(ByteBuf input, MinecraftProtocolProfile profile) {
if (closed
|| input == null
|| !input.isReadable()
|| profile == null
|| profile.serverboundCustomPayloadPacketId().isEmpty()) {
return;
}
appendPending(input, false);
while (pending.isReadable()) {
var probe = MinecraftProtocolCodec.probeFrame(pending, maxFrameBytes);
if (!probe.complete()) {
break;
}
var frame = pending.readRetainedSlice(probe.totalBytes());
try {
observeFrame(frame, profile);
} finally {
frame.release();
}
pending.discardReadBytes();
}
}
synchronized void observeServerboundCompressed(
ByteBufAllocator allocator,
ByteBuf input,
int threshold,
MinecraftProtocolProfile profile) {
if (closed
|| input == null
|| !input.isReadable()
|| allocator == null
|| profile == null
|| profile.serverboundCustomPayloadPacketId().isEmpty()) {
return;
}
appendPending(input, true);
while (pending.isReadable()) {
var probe = MinecraftProtocolCodec.probeFrame(pending, maxFrameBytes);
if (!probe.complete()) {
break;
}
var frame = pending.readRetainedSlice(probe.totalBytes());
try {
var packet = compressionCodec.decodeFrame(allocator, frame, threshold, maxFrameBytes);
try {
observePacket(packet, profile);
} finally {
packet.release();
}
} finally {
frame.release();
}
pending.discardReadBytes();
}
}
synchronized ByteBuf registrationFrame(ByteBufAllocator allocator, MinecraftProtocolProfile profile) {
if (closed
|| channels.isEmpty()
|| profile == null
|| profile.serverboundCustomPayloadPacketId().isEmpty()) {
return null;
}
var payloadBytes = joinedChannelsSnapshot().getBytes(StandardCharsets.UTF_8);
if (payloadBytes.length == 0 || payloadBytes.length > maxFrameBytes) {
return null;
}
var packet = allocator.buffer();
try {
MinecraftVarInts.write(packet, profile.serverboundCustomPayloadPacketId().getAsInt());
writeString(packet, "REGISTER");
MinecraftCustomPayloadBodyCodec.writeLength(
packet,
profile.serverboundCustomPayloadLengthFormat(),
payloadBytes.length);
packet.writeBytes(payloadBytes);
var frame = allocator.buffer(MinecraftVarInts.encodedSize(packet.readableBytes()) + packet.readableBytes());
MinecraftVarInts.write(frame, packet.readableBytes());
frame.writeBytes(packet, packet.readerIndex(), packet.readableBytes());
return frame;
} finally {
packet.release();
}
}
synchronized Set<String> channels() {
return Set.copyOf(channels);
}
@Override
public synchronized void close() {
closed = true;
channels.clear();
if (pending != null && pending.refCnt() > 0) {
pending.release();
}
pending = Unpooled.EMPTY_BUFFER;
compressionCodec.close();
}
private void observeFrame(ByteBuf frame, MinecraftProtocolProfile profile) {
var packet = frame.retainedDuplicate();
try {
MinecraftProtocolCodec.readVarInt(packet);
observePacket(packet, profile);
} finally {
packet.release();
}
}
private void observePacket(ByteBuf packet, MinecraftProtocolProfile profile) {
var packetId = MinecraftProtocolCodec.readVarInt(packet);
if (packetId != profile.serverboundCustomPayloadPacketId().getAsInt()) {
return;
}
var channel = MinecraftProtocolCodec.readString(packet, MAX_CHANNEL_BYTES);
if (!"REGISTER".equals(channel) && !"UNREGISTER".equals(channel)) {
return;
}
var payload = MinecraftCustomPayloadBodyCodec.readBody(
packet,
profile.serverboundCustomPayloadLengthFormat());
var names = splitChannels(payload);
if ("REGISTER".equals(channel)) {
for (var name : names) {
if (channels.size() >= MAX_CHANNELS && !channels.contains(name)) {
break;
}
channels.add(name);
}
} else {
channels.removeAll(names);
}
}
private void appendPending(ByteBuf input, boolean compressed) {
if (pendingCompressed == null) {
pendingCompressed = compressed;
} else if (pendingCompressed != compressed) {
pending.clear();
pendingCompressed = compressed;
}
if (pending.readableBytes() + input.readableBytes() > maxFrameBytes + 5) {
pending.clear();
throw new IllegalStateException("plugin channel registry pending frame exceeded maximum size");
}
pending.writeBytes(input, input.readerIndex(), input.readableBytes());
}
private String joinedChannelsSnapshot() {
return String.join("\0", channels);
}
private static List<String> splitChannels(ByteBuf payload) {
if (!payload.isReadable()) {
return List.of();
}
var bytes = new byte[payload.readableBytes()];
payload.getBytes(payload.readerIndex(), bytes);
var names = new ArrayList<String>();
var start = 0;
for (var index = 0; index <= bytes.length; index++) {
if (index == bytes.length || bytes[index] == 0) {
if (index > start && index - start <= MAX_CHANNEL_BYTES) {
var name = new String(bytes, start, index - start, StandardCharsets.UTF_8);
if (!name.isBlank()) {
names.add(name);
}
}
start = index + 1;
}
}
return names;
}
private static void writeString(ByteBuf output, String value) {
var bytes = value.getBytes(StandardCharsets.UTF_8);
MinecraftVarInts.write(output, bytes.length);
output.writeBytes(bytes);
}
}
@@ -0,0 +1,356 @@
package dev.strataproxy.network;
import dev.strataproxy.protocol.ProtocolState;
import java.util.OptionalInt;
import java.util.Set;
final class MinecraftProtocolProfile {
static final int PROTOCOL_1_7_10 = 5;
static final int PROTOCOL_1_8 = 47;
static final int PROTOCOL_1_20_1 = 763;
private static final MinecraftProtocolProfile LEGACY_1_7_10 = new MinecraftProtocolProfile(
PROTOCOL_1_7_10,
// HexaCord registers a 1.7.x play-state SetCompression packet, but only enables
// login compression for >= 1.8 clients. Protocol 5 must not negotiate vanilla
// login compression.
false,
0x00,
0x01,
0x02,
OptionalInt.empty(),
Set.of(0x01),
OptionalInt.of(0x02),
false,
OptionalInt.of(0x17),
Set.of(0x17),
CustomPayloadLengthFormat.UNSIGNED_SHORT,
ProtocolState.PLAY,
OptionalInt.of(0x3F),
CustomPayloadLengthFormat.VARSHORT,
OptionalInt.of(0x01),
OptionalInt.of(0x07),
OptionalInt.of(0x38),
PlayerListItemLayout.LEGACY_NAME,
OptionalInt.of(0x3B),
ScoreboardObjectiveLayout.NAME_ACTION,
OptionalInt.of(0x3E),
BackendSwitchStrategy.RESPAWN_ONLY,
BackendSwitchStrategy.JOIN_GAME_THEN_RESPAWN,
true,
true);
private static final MinecraftProtocolProfile LEGACY_1_8 = new MinecraftProtocolProfile(
PROTOCOL_1_8,
true,
0x00,
0x01,
0x02,
OptionalInt.of(0x03),
Set.of(0x01),
OptionalInt.of(0x02),
true,
OptionalInt.of(0x17),
Set.of(0x17),
CustomPayloadLengthFormat.REMAINING_BYTES,
ProtocolState.PLAY,
OptionalInt.of(0x3F),
CustomPayloadLengthFormat.REMAINING_BYTES,
OptionalInt.of(0x01),
OptionalInt.of(0x07),
OptionalInt.of(0x38),
PlayerListItemLayout.UUID_ACTION,
OptionalInt.of(0x3B),
ScoreboardObjectiveLayout.NAME_ACTION,
OptionalInt.of(0x3E),
BackendSwitchStrategy.RESPAWN_ONLY,
BackendSwitchStrategy.JOIN_GAME_THEN_RESPAWN,
true,
true);
private static final MinecraftProtocolProfile MODERN_1_20_1 = new MinecraftProtocolProfile(
PROTOCOL_1_20_1,
true,
0x00,
0x01,
0x02,
OptionalInt.of(0x03),
Set.of(0x03, 0x04, 0x05),
OptionalInt.of(0x64),
true,
OptionalInt.of(0x01),
Set.of(0x18),
CustomPayloadLengthFormat.REMAINING_BYTES,
ProtocolState.CONFIGURATION,
OptionalInt.empty(),
CustomPayloadLengthFormat.REMAINING_BYTES,
OptionalInt.empty(),
OptionalInt.empty(),
OptionalInt.empty(),
PlayerListItemLayout.NONE,
OptionalInt.empty(),
ScoreboardObjectiveLayout.NONE,
OptionalInt.empty(),
BackendSwitchStrategy.NONE,
BackendSwitchStrategy.NONE,
false,
true);
private final int protocolVersion;
private final boolean compressionNegotiationSupported;
private final int clientboundLoginDisconnectPacketId;
private final int clientboundLoginEncryptionRequestPacketId;
private final int clientboundLoginSuccessPacketId;
private final OptionalInt clientboundLoginSetCompressionPacketId;
private final Set<Integer> serverboundCommandPacketIds;
private final OptionalInt clientboundChatPacketId;
private final boolean clientboundChatHasOverlayFlag;
private final OptionalInt serverboundCustomPayloadPacketId;
private final Set<Integer> serverboundBungeeCustomPayloadPacketIds;
private final CustomPayloadLengthFormat serverboundCustomPayloadLengthFormat;
private final ProtocolState serverboundCustomPayloadInspectionState;
private final OptionalInt clientboundCustomPayloadPacketId;
private final CustomPayloadLengthFormat clientboundCustomPayloadLengthFormat;
private final OptionalInt clientboundPlayLoginPacketId;
private final OptionalInt clientboundPlayRespawnPacketId;
private final OptionalInt clientboundPlayerListItemPacketId;
private final PlayerListItemLayout playerListItemLayout;
private final OptionalInt clientboundScoreboardObjectivePacketId;
private final ScoreboardObjectiveLayout scoreboardObjectiveLayout;
private final OptionalInt clientboundTeamPacketId;
private final BackendSwitchStrategy backendSwitchStrategy;
private final BackendSwitchStrategy legacyForgeBackendSwitchStrategy;
private final boolean legacyForgeHandshakeSupported;
private final boolean backendReplacementSupported;
private MinecraftProtocolProfile(
int protocolVersion,
boolean compressionNegotiationSupported,
int clientboundLoginDisconnectPacketId,
int clientboundLoginEncryptionRequestPacketId,
int clientboundLoginSuccessPacketId,
OptionalInt clientboundLoginSetCompressionPacketId,
Set<Integer> serverboundCommandPacketIds,
OptionalInt clientboundChatPacketId,
boolean clientboundChatHasOverlayFlag,
OptionalInt serverboundCustomPayloadPacketId,
Set<Integer> serverboundBungeeCustomPayloadPacketIds,
CustomPayloadLengthFormat serverboundCustomPayloadLengthFormat,
ProtocolState serverboundCustomPayloadInspectionState,
OptionalInt clientboundCustomPayloadPacketId,
CustomPayloadLengthFormat clientboundCustomPayloadLengthFormat,
OptionalInt clientboundPlayLoginPacketId,
OptionalInt clientboundPlayRespawnPacketId,
OptionalInt clientboundPlayerListItemPacketId,
PlayerListItemLayout playerListItemLayout,
OptionalInt clientboundScoreboardObjectivePacketId,
ScoreboardObjectiveLayout scoreboardObjectiveLayout,
OptionalInt clientboundTeamPacketId,
BackendSwitchStrategy backendSwitchStrategy,
BackendSwitchStrategy legacyForgeBackendSwitchStrategy,
boolean legacyForgeHandshakeSupported,
boolean backendReplacementSupported) {
this.protocolVersion = protocolVersion;
this.compressionNegotiationSupported = compressionNegotiationSupported;
this.clientboundLoginDisconnectPacketId = clientboundLoginDisconnectPacketId;
this.clientboundLoginEncryptionRequestPacketId = clientboundLoginEncryptionRequestPacketId;
this.clientboundLoginSuccessPacketId = clientboundLoginSuccessPacketId;
this.clientboundLoginSetCompressionPacketId = clientboundLoginSetCompressionPacketId;
this.serverboundCommandPacketIds = Set.copyOf(serverboundCommandPacketIds);
this.clientboundChatPacketId = clientboundChatPacketId;
this.clientboundChatHasOverlayFlag = clientboundChatHasOverlayFlag;
this.serverboundCustomPayloadPacketId = serverboundCustomPayloadPacketId;
this.serverboundBungeeCustomPayloadPacketIds = Set.copyOf(serverboundBungeeCustomPayloadPacketIds);
this.serverboundCustomPayloadLengthFormat = serverboundCustomPayloadLengthFormat;
this.serverboundCustomPayloadInspectionState = serverboundCustomPayloadInspectionState == null
? ProtocolState.PLAY
: serverboundCustomPayloadInspectionState;
this.clientboundCustomPayloadPacketId = clientboundCustomPayloadPacketId;
this.clientboundCustomPayloadLengthFormat = clientboundCustomPayloadLengthFormat;
this.clientboundPlayLoginPacketId = clientboundPlayLoginPacketId;
this.clientboundPlayRespawnPacketId = clientboundPlayRespawnPacketId;
this.clientboundPlayerListItemPacketId = clientboundPlayerListItemPacketId;
this.playerListItemLayout = playerListItemLayout == null ? PlayerListItemLayout.NONE : playerListItemLayout;
this.clientboundScoreboardObjectivePacketId = clientboundScoreboardObjectivePacketId;
this.scoreboardObjectiveLayout = scoreboardObjectiveLayout == null
? ScoreboardObjectiveLayout.NONE
: scoreboardObjectiveLayout;
this.clientboundTeamPacketId = clientboundTeamPacketId;
this.backendSwitchStrategy = backendSwitchStrategy == null ? BackendSwitchStrategy.NONE : backendSwitchStrategy;
this.legacyForgeBackendSwitchStrategy = legacyForgeBackendSwitchStrategy == null
? this.backendSwitchStrategy
: legacyForgeBackendSwitchStrategy;
this.legacyForgeHandshakeSupported = legacyForgeHandshakeSupported;
this.backendReplacementSupported = backendReplacementSupported;
}
static MinecraftProtocolProfile forVersion(int protocolVersion) {
if (protocolVersion == PROTOCOL_1_7_10) {
return LEGACY_1_7_10;
}
if (protocolVersion == PROTOCOL_1_8) {
return LEGACY_1_8;
}
if (protocolVersion == PROTOCOL_1_20_1) {
return MODERN_1_20_1;
}
return new MinecraftProtocolProfile(
protocolVersion,
protocolVersion >= PROTOCOL_1_8,
0x00,
0x01,
0x02,
protocolVersion >= PROTOCOL_1_8 ? OptionalInt.of(0x03) : OptionalInt.empty(),
Set.of(),
OptionalInt.empty(),
false,
OptionalInt.empty(),
Set.of(),
CustomPayloadLengthFormat.REMAINING_BYTES,
ProtocolState.PLAY,
OptionalInt.empty(),
CustomPayloadLengthFormat.REMAINING_BYTES,
OptionalInt.empty(),
OptionalInt.empty(),
OptionalInt.empty(),
PlayerListItemLayout.NONE,
OptionalInt.empty(),
ScoreboardObjectiveLayout.NONE,
OptionalInt.empty(),
BackendSwitchStrategy.NONE,
BackendSwitchStrategy.NONE,
false,
false);
}
int protocolVersion() {
return protocolVersion;
}
boolean compressionNegotiationSupported() {
return compressionNegotiationSupported;
}
int clientboundLoginDisconnectPacketId() {
return clientboundLoginDisconnectPacketId;
}
int clientboundLoginEncryptionRequestPacketId() {
return clientboundLoginEncryptionRequestPacketId;
}
int clientboundLoginSuccessPacketId() {
return clientboundLoginSuccessPacketId;
}
OptionalInt clientboundLoginSetCompressionPacketId() {
return clientboundLoginSetCompressionPacketId;
}
Set<Integer> serverboundCommandPacketIds() {
return serverboundCommandPacketIds;
}
OptionalInt clientboundChatPacketId() {
return clientboundChatPacketId;
}
boolean clientboundChatHasOverlayFlag() {
return clientboundChatHasOverlayFlag;
}
OptionalInt serverboundCustomPayloadPacketId() {
return serverboundCustomPayloadPacketId;
}
Set<Integer> serverboundBungeeCustomPayloadPacketIds() {
return serverboundBungeeCustomPayloadPacketIds;
}
CustomPayloadLengthFormat serverboundCustomPayloadLengthFormat() {
return serverboundCustomPayloadLengthFormat;
}
ProtocolState serverboundCustomPayloadInspectionState() {
return serverboundCustomPayloadInspectionState;
}
OptionalInt clientboundCustomPayloadPacketId() {
return clientboundCustomPayloadPacketId;
}
CustomPayloadLengthFormat clientboundCustomPayloadLengthFormat() {
return clientboundCustomPayloadLengthFormat;
}
OptionalInt clientboundPlayLoginPacketId() {
return clientboundPlayLoginPacketId;
}
OptionalInt clientboundPlayRespawnPacketId() {
return clientboundPlayRespawnPacketId;
}
OptionalInt clientboundPlayerListItemPacketId() {
return clientboundPlayerListItemPacketId;
}
PlayerListItemLayout playerListItemLayout() {
return playerListItemLayout;
}
OptionalInt clientboundScoreboardObjectivePacketId() {
return clientboundScoreboardObjectivePacketId;
}
ScoreboardObjectiveLayout scoreboardObjectiveLayout() {
return scoreboardObjectiveLayout;
}
OptionalInt clientboundTeamPacketId() {
return clientboundTeamPacketId;
}
boolean backendSwitchUsesRespawn() {
return backendSwitchStrategy != BackendSwitchStrategy.NONE;
}
boolean backendReplacementSupported() {
return backendReplacementSupported;
}
BackendSwitchStrategy backendSwitchStrategy(boolean legacyForgeClient) {
return legacyForgeClient && legacyForgeHandshakeSupported
? legacyForgeBackendSwitchStrategy
: backendSwitchStrategy;
}
boolean legacyForgeHandshakeSupported() {
return legacyForgeHandshakeSupported;
}
enum CustomPayloadLengthFormat {
REMAINING_BYTES,
UNSIGNED_SHORT,
VARSHORT
}
enum PlayerListItemLayout {
NONE,
LEGACY_NAME,
UUID_ACTION
}
enum ScoreboardObjectiveLayout {
NONE,
NAME_VALUE_ACTION,
NAME_ACTION
}
enum BackendSwitchStrategy {
NONE,
RESPAWN_ONLY,
JOIN_GAME_THEN_RESPAWN
}
}
@@ -12,13 +12,24 @@ final class RelaySession {
private final AtomicReference<String> serverName = new AtomicReference<>("");
private final AtomicReference<BackendReplacementController> replacementController = new AtomicReference<>();
private final AtomicReference<RelayLoginSession> loginSession = new AtomicReference<>();
private final AtomicReference<MinecraftForgeHandshakeTracker> forgeHandshakeTracker = new AtomicReference<>();
private final AtomicReference<PendingBackendTransfer> pendingBackendTransfer = new AtomicReference<>();
private final AtomicBoolean legacyForgeClientDetected = new AtomicBoolean();
private final MinecraftLegacyClientStateTracker clientStateTracker;
private final MinecraftPluginChannelRegistry pluginChannelRegistry;
RelaySession(String remoteAddress) {
this(new RelaySessionIdentity(remoteAddress));
}
RelaySession(RelaySessionIdentity identity) {
this(identity, NetworkTuning.defaults().maxFrameBytes());
}
RelaySession(RelaySessionIdentity identity, int maxFrameBytes) {
this.identity = identity == null ? new RelaySessionIdentity("") : identity;
this.clientStateTracker = new MinecraftLegacyClientStateTracker(maxFrameBytes);
this.pluginChannelRegistry = new MinecraftPluginChannelRegistry(maxFrameBytes);
}
RelaySessionIdentity identity() {
@@ -74,4 +85,122 @@ final class RelaySession {
void loginSession(RelayLoginSession loginSession) {
this.loginSession.set(loginSession);
}
void legacyForgeClientDetected(boolean detected) {
if (detected) {
legacyForgeClientDetected.set(true);
}
}
boolean legacyForgeClientDetected() {
return legacyForgeClientDetected.get();
}
MinecraftForgeHandshakeTracker startForgeHandshakeTracker(int maxFrameBytes, MinecraftProtocolProfile profile) {
var previous = forgeHandshakeTracker.get();
var resetRequired = resetRequiredForNextForgeServer(previous, profile);
var next = new MinecraftForgeHandshakeTracker(maxFrameBytes, profile, resetRequired);
previous = forgeHandshakeTracker.getAndSet(next);
if (previous != null) {
previous.close();
}
return next;
}
ForgeHandshakeTrackerSwap beginForgeHandshakeTrackerSwap(int maxFrameBytes, MinecraftProtocolProfile profile) {
var previous = forgeHandshakeTracker.get();
var next = new MinecraftForgeHandshakeTracker(maxFrameBytes, profile, resetRequiredForNextForgeServer(previous, profile));
previous = forgeHandshakeTracker.getAndSet(next);
return new ForgeHandshakeTrackerSwap(previous, next);
}
void commitForgeHandshakeTrackerSwap(ForgeHandshakeTrackerSwap swap) {
if (swap == null) {
return;
}
if (swap.previous() != null) {
swap.previous().close();
}
}
void rollbackForgeHandshakeTrackerSwap(ForgeHandshakeTrackerSwap swap) {
if (swap == null) {
return;
}
if (forgeHandshakeTracker.compareAndSet(swap.next(), swap.previous())) {
swap.next().close();
} else if (forgeHandshakeTracker.get() != swap.next()) {
swap.next().close();
}
}
private boolean resetRequiredForNextForgeServer(
MinecraftForgeHandshakeTracker previous,
MinecraftProtocolProfile profile) {
return legacyForgeClientDetected.get()
&& profile != null
&& profile.legacyForgeHandshakeSupported()
&& !serverName.get().isBlank()
&& (previous == null || !previous.complete());
}
MinecraftForgeHandshakeTracker forgeHandshakeTracker() {
return forgeHandshakeTracker.get();
}
MinecraftLegacyClientStateTracker clientStateTracker() {
return clientStateTracker;
}
void closeClientStateTracker() {
clientStateTracker.close();
}
MinecraftPluginChannelRegistry pluginChannelRegistry() {
return pluginChannelRegistry;
}
void closePluginChannelRegistry() {
pluginChannelRegistry.close();
}
PendingBackendTransfer deferBackendTransfer(String targetServerName, java.util.concurrent.CompletableFuture<PlayerTransferResult> result) {
if (targetServerName == null || targetServerName.isBlank() || result == null) {
return null;
}
var pending = new PendingBackendTransfer(targetServerName, result);
return pendingBackendTransfer.compareAndSet(null, pending) ? pending : null;
}
PendingBackendTransfer consumePendingBackendTransfer() {
return pendingBackendTransfer.getAndSet(null);
}
boolean consumePendingBackendTransfer(PendingBackendTransfer pending) {
return pending != null && pendingBackendTransfer.compareAndSet(pending, null);
}
PendingBackendTransfer pendingBackendTransfer() {
return pendingBackendTransfer.get();
}
PlayerTransferResult failPendingBackendTransfer(String outcome) {
var pending = consumePendingBackendTransfer();
if (pending != null) {
var result = PlayerTransferResult.failure(
outcome,
identity.playerName(),
serverName(),
pending.targetServerName());
pending.result.complete(result);
return result;
}
return null;
}
record PendingBackendTransfer(String targetServerName, java.util.concurrent.CompletableFuture<PlayerTransferResult> result) {
}
record ForgeHandshakeTrackerSwap(MinecraftForgeHandshakeTracker previous, MinecraftForgeHandshakeTracker next) {
}
}
@@ -64,10 +64,17 @@ public final class RoutingBackendResolver implements BackendResolver, ServerTarg
@Override
/** Provides resolve target. */
public Optional<RegisteredServer> resolveTarget(String serverName) {
return resolveTarget(serverName, -1);
}
@Override
/** Provides resolve target. */
public Optional<RegisteredServer> resolveTarget(String serverName, int protocolVersion) {
if (registry == null || serverName == null || serverName.isBlank()) {
return Optional.empty();
}
return registry.get(serverName.trim())
.filter(server -> protocolVersion < 0 || server.descriptor().protocolRange().accepts(protocolVersion))
.filter(server -> !server.draining())
.filter(server -> server.health().canReceiveNewConnections())
.filter(server -> !server.load().isHardFull());
@@ -7,6 +7,10 @@ import java.util.Optional;
interface ServerTargetResolver {
Optional<RegisteredServer> resolveTarget(String serverName);
default Optional<RegisteredServer> resolveTarget(String serverName, int protocolVersion) {
return resolveTarget(serverName);
}
static ServerTargetResolver unavailable() {
return ignored -> Optional.empty();
}
@@ -0,0 +1,413 @@
package dev.strataproxy.network;
import dev.strataproxy.analysis.CustomPayloadAnomalyPolicy;
import dev.strataproxy.codec.minecraft.MinecraftCompressionCodec;
import dev.strataproxy.codec.minecraft.MinecraftVarInts;
import dev.strataproxy.observability.ProxyMetrics;
import io.netty.buffer.ByteBuf;
import io.netty.buffer.Unpooled;
import io.netty.buffer.UnpooledByteBufAllocator;
import io.netty.channel.embedded.EmbeddedChannel;
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.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
final class BackendForgeHandshakeGateTest {
@Test
void queuesClientboundNonForgeCustomPayloadsUntilLegacyForgeHandshakeCompletes() {
var profile = MinecraftProtocolProfile.forVersion(MinecraftProtocolProfile.PROTOCOL_1_7_10);
var tracker = new MinecraftForgeHandshakeTracker(4096, profile);
advanceToPendingComplete(tracker);
var frontendOutbound = new EmbeddedChannel();
var backend = new EmbeddedChannel(new BackendRelayHandler(
frontendOutbound,
new ProxyMetrics(),
"forge-1",
4096,
new MinecraftCompressionAuditState(4096),
CompressionRuntime.defaults(),
new RelaySessionIdentity("127.0.0.1:50000"),
MinecraftForwardingRuntime.none(),
false,
25,
profile,
null,
tracker));
var frontend = new EmbeddedChannel(new FrontendRelayHandler(
backend,
new ProxyMetrics(),
"forge-1",
new MinecraftCompressionAuditState(4096),
CompressionRuntime.defaults(),
4096,
CustomPayloadAnomalyPolicy.defaults(),
"Steve",
new RelaySessionIdentity("127.0.0.1:50000"),
false,
25,
null,
null,
null,
profile,
null,
tracker));
try {
assertFalse(backend.writeInbound(customPayload(0x3F, "MOD|EARLY", bytes(1, 2, 3), true)));
assertNull(frontendOutbound.readOutbound());
assertFalse(frontend.writeInbound(customPayload(0x17, "FML|HS", bytes(255, 5), false)));
var finalAck = (ByteBuf) backend.readOutbound();
var queuedPayload = (ByteBuf) frontendOutbound.readOutbound();
try {
assertCustomPayload(finalAck, 0x17, "FML|HS", false, 255, 5);
assertCustomPayload(queuedPayload, 0x3F, "MOD|EARLY", true, 1, 2, 3);
assertTrue(tracker.complete());
} finally {
release(finalAck);
release(queuedPayload);
}
} finally {
frontend.finishAndReleaseAll();
backend.finishAndReleaseAll();
frontendOutbound.finishAndReleaseAll();
tracker.close();
}
}
@Test
void forwardsClientboundRegularPlayFramesDuringLegacyForgeHandshake() {
var profile = MinecraftProtocolProfile.forVersion(MinecraftProtocolProfile.PROTOCOL_1_7_10);
var tracker = new MinecraftForgeHandshakeTracker(4096, profile);
advanceToPendingComplete(tracker);
var frontendOutbound = new EmbeddedChannel();
var backend = new EmbeddedChannel(new BackendRelayHandler(
frontendOutbound,
new ProxyMetrics(),
"forge-1",
4096,
new MinecraftCompressionAuditState(4096),
CompressionRuntime.defaults(),
new RelaySessionIdentity("127.0.0.1:50000"),
MinecraftForwardingRuntime.none(),
false,
25,
profile,
null,
tracker));
try {
assertFalse(backend.writeInbound(frame(packet(0x08))));
var play = (ByteBuf) frontendOutbound.readOutbound();
try {
assertEquals(0x08, packetId(play));
assertFalse(tracker.complete());
} finally {
release(play);
}
} finally {
backend.finishAndReleaseAll();
frontendOutbound.finishAndReleaseAll();
tracker.close();
}
}
@Test
void queuesCompressedClientboundNonForgeCustomPayloadsUntilLegacyForgeHandshakeCompletes() {
var profile = MinecraftProtocolProfile.forVersion(MinecraftProtocolProfile.PROTOCOL_1_8);
var tracker = new MinecraftForgeHandshakeTracker(4096, profile);
advanceCompressed18ToPendingComplete(tracker);
var backendCompression = new MinecraftCompressionAuditState(4096);
backendCompression.negotiate(0);
var frontendCompression = new MinecraftCompressionAuditState(4096);
frontendCompression.negotiate(0);
var frontendOutbound = new EmbeddedChannel();
var backend = new EmbeddedChannel(new BackendRelayHandler(
frontendOutbound,
new ProxyMetrics(),
"forge-1",
4096,
backendCompression,
CompressionRuntime.defaults(),
new RelaySessionIdentity("127.0.0.1:50000"),
MinecraftForwardingRuntime.none(),
false,
25,
profile,
null,
tracker));
var frontend = new EmbeddedChannel(new FrontendRelayHandler(
backend,
new ProxyMetrics(),
"forge-1",
frontendCompression,
CompressionRuntime.defaults(),
4096,
CustomPayloadAnomalyPolicy.defaults(),
"Steve",
new RelaySessionIdentity("127.0.0.1:50000"),
false,
25,
null,
null,
null,
profile,
null,
tracker));
try {
assertFalse(backend.writeInbound(compressedCustomPayload18(0x3F, "MOD|EARLY", bytes(1, 2, 3), 0)));
assertNull(frontendOutbound.readOutbound());
assertFalse(frontend.writeInbound(compressedCustomPayload18(0x17, "FML|HS", bytes(255, 5), 0)));
var finalAck = (ByteBuf) backend.readOutbound();
var queuedPayload = (ByteBuf) frontendOutbound.readOutbound();
try (var codec = new MinecraftCompressionCodec()) {
assertCompressedCustomPayload18(codec, finalAck, 0, 0x17, "FML|HS", 255, 5);
assertCompressedCustomPayload18(codec, queuedPayload, 0, 0x3F, "MOD|EARLY", 1, 2, 3);
assertTrue(tracker.complete());
} finally {
release(finalAck);
release(queuedPayload);
}
} finally {
frontend.finishAndReleaseAll();
backend.finishAndReleaseAll();
frontendOutbound.finishAndReleaseAll();
tracker.close();
}
}
private static void advanceToPendingComplete(MinecraftForgeHandshakeTracker tracker) {
observe(tracker, MinecraftForgeHandshakeTracker.Direction.CLIENTBOUND,
customPayload(0x3F, "FML|HS", bytes(0, 2, 0, 0, 0, 0), true));
observe(tracker, MinecraftForgeHandshakeTracker.Direction.SERVERBOUND,
customPayload(0x17, "REGISTER", registerPayload(), false));
observe(tracker, MinecraftForgeHandshakeTracker.Direction.SERVERBOUND,
customPayload(0x17, "FML|HS", bytes(1, 2), false));
observe(tracker, MinecraftForgeHandshakeTracker.Direction.SERVERBOUND,
customPayload(0x17, "FML|HS", modListPayload(), false));
observe(tracker, MinecraftForgeHandshakeTracker.Direction.CLIENTBOUND,
customPayload(0x3F, "FML|HS", modListPayload(), true));
observe(tracker, MinecraftForgeHandshakeTracker.Direction.SERVERBOUND,
customPayload(0x17, "FML|HS", bytes(255, 2), false));
observe(tracker, MinecraftForgeHandshakeTracker.Direction.CLIENTBOUND,
customPayload(0x3F, "FML|HS", bytes(3, 0), true));
observe(tracker, MinecraftForgeHandshakeTracker.Direction.SERVERBOUND,
customPayload(0x17, "FML|HS", bytes(255, 3), false));
observe(tracker, MinecraftForgeHandshakeTracker.Direction.CLIENTBOUND,
customPayload(0x3F, "FML|HS", bytes(255, 2), true));
observe(tracker, MinecraftForgeHandshakeTracker.Direction.SERVERBOUND,
customPayload(0x17, "FML|HS", bytes(255, 4), false));
observe(tracker, MinecraftForgeHandshakeTracker.Direction.CLIENTBOUND,
customPayload(0x3F, "FML|HS", bytes(255, 3), true));
assertFalse(tracker.complete());
assertTrue(tracker.backendSwitchBlocked());
}
private static void advanceCompressed18ToPendingComplete(MinecraftForgeHandshakeTracker tracker) {
observeCompressed(tracker, MinecraftForgeHandshakeTracker.Direction.CLIENTBOUND,
compressedCustomPayload18(0x3F, "FML|HS", bytes(0, 2, 0, 0, 0, 0), 0));
observeCompressed(tracker, MinecraftForgeHandshakeTracker.Direction.SERVERBOUND,
compressedCustomPayload18(0x17, "REGISTER", registerPayload(), 0));
observeCompressed(tracker, MinecraftForgeHandshakeTracker.Direction.SERVERBOUND,
compressedCustomPayload18(0x17, "FML|HS", bytes(1, 2), 0));
observeCompressed(tracker, MinecraftForgeHandshakeTracker.Direction.SERVERBOUND,
compressedCustomPayload18(0x17, "FML|HS", modListPayload(), 0));
observeCompressed(tracker, MinecraftForgeHandshakeTracker.Direction.CLIENTBOUND,
compressedCustomPayload18(0x3F, "FML|HS", modListPayload(), 0));
observeCompressed(tracker, MinecraftForgeHandshakeTracker.Direction.SERVERBOUND,
compressedCustomPayload18(0x17, "FML|HS", bytes(255, 2), 0));
observeCompressed(tracker, MinecraftForgeHandshakeTracker.Direction.CLIENTBOUND,
compressedCustomPayload18(0x3F, "FML|HS", bytes(3, 0), 0));
observeCompressed(tracker, MinecraftForgeHandshakeTracker.Direction.SERVERBOUND,
compressedCustomPayload18(0x17, "FML|HS", bytes(255, 3), 0));
observeCompressed(tracker, MinecraftForgeHandshakeTracker.Direction.CLIENTBOUND,
compressedCustomPayload18(0x3F, "FML|HS", bytes(255, 2), 0));
observeCompressed(tracker, MinecraftForgeHandshakeTracker.Direction.SERVERBOUND,
compressedCustomPayload18(0x17, "FML|HS", bytes(255, 4), 0));
observeCompressed(tracker, MinecraftForgeHandshakeTracker.Direction.CLIENTBOUND,
compressedCustomPayload18(0x3F, "FML|HS", bytes(255, 3), 0));
assertFalse(tracker.complete());
assertTrue(tracker.backendSwitchBlocked());
}
private static void observe(
MinecraftForgeHandshakeTracker tracker,
MinecraftForgeHandshakeTracker.Direction direction,
ByteBuf frame) {
try {
tracker.observe(direction, frame);
} finally {
frame.release();
}
}
private static void observeCompressed(
MinecraftForgeHandshakeTracker tracker,
MinecraftForgeHandshakeTracker.Direction direction,
ByteBuf frame) {
try {
tracker.observeCompressed(direction, UnpooledByteBufAllocator.DEFAULT, frame, 0);
} finally {
frame.release();
}
}
private static ByteBuf customPayload(int packetId, String channel, ByteBuf payload, boolean clientbound) {
var packet = Unpooled.buffer();
try {
MinecraftVarInts.write(packet, packetId);
writeString(packet, channel);
if (clientbound) {
writeVarShort(packet, payload.readableBytes());
} else {
packet.writeShort(payload.readableBytes());
}
packet.writeBytes(payload, payload.readerIndex(), payload.readableBytes());
return frame(packet);
} finally {
payload.release();
}
}
private static ByteBuf compressedCustomPayload18(int packetId, String channel, ByteBuf payload, int threshold) {
var packet = Unpooled.buffer();
try (var codec = new MinecraftCompressionCodec()) {
MinecraftVarInts.write(packet, packetId);
writeString(packet, channel);
packet.writeBytes(payload, payload.readerIndex(), payload.readableBytes());
return codec.encodeFrame(UnpooledByteBufAllocator.DEFAULT, packet, threshold);
} finally {
payload.release();
packet.release();
}
}
private static ByteBuf registerPayload() {
return Unpooled.wrappedBuffer("FML|HS\0FML\0FML|MP\0FORGE".getBytes(StandardCharsets.UTF_8));
}
private static ByteBuf modListPayload() {
var payload = Unpooled.buffer();
payload.writeByte(2);
MinecraftVarInts.write(payload, 1);
writeString(payload, "mcp");
writeString(payload, "9.05");
return payload;
}
private static ByteBuf frame(ByteBuf packet) {
var frame = Unpooled.buffer();
MinecraftVarInts.write(frame, packet.readableBytes());
frame.writeBytes(packet, packet.readerIndex(), packet.readableBytes());
packet.release();
return frame;
}
private static ByteBuf packet(int packetId) {
var packet = Unpooled.buffer();
MinecraftVarInts.write(packet, packetId);
return packet;
}
private static ByteBuf bytes(int... values) {
var payload = Unpooled.buffer(values.length);
for (var value : values) {
payload.writeByte(value);
}
return payload;
}
private static int packetId(ByteBuf frame) {
var payload = payload(frame);
try {
return MinecraftVarInts.read(payload);
} finally {
payload.release();
}
}
private static void assertCustomPayload(ByteBuf frame, int packetId, String channel, boolean clientbound, int... data) {
var payload = payload(frame);
try {
assertEquals(packetId, MinecraftVarInts.read(payload));
assertEquals(channel, readString(payload));
assertEquals(data.length, clientbound ? readVarShort(payload) : payload.readUnsignedShort());
for (var value : data) {
assertEquals(value, payload.readUnsignedByte());
}
assertEquals(0, payload.readableBytes());
} finally {
payload.release();
}
}
private static void assertCompressedCustomPayload18(
MinecraftCompressionCodec codec,
ByteBuf frame,
int threshold,
int packetId,
String channel,
int... data) {
var packet = codec.decodeFrame(UnpooledByteBufAllocator.DEFAULT, frame, threshold, 4096);
try {
assertEquals(packetId, MinecraftVarInts.read(packet));
assertEquals(channel, readString(packet));
for (var value : data) {
assertEquals(value, packet.readUnsignedByte());
}
assertEquals(0, packet.readableBytes());
} finally {
packet.release();
}
}
private static ByteBuf payload(ByteBuf frame) {
var length = MinecraftVarInts.read(frame);
return frame.readRetainedSlice(length);
}
private static void writeString(ByteBuf output, String value) {
var bytes = value.getBytes(StandardCharsets.UTF_8);
MinecraftVarInts.write(output, bytes.length);
output.writeBytes(bytes);
}
private static String readString(ByteBuf input) {
return MinecraftProtocolCodec.readString(input, 32767);
}
private static void writeVarShort(ByteBuf output, int value) {
if ((value & 0xFFFF8000) != 0) {
output.writeShort((value & 0x7FFF) | 0x8000);
output.writeByte(value >>> 15);
} else {
output.writeShort(value);
}
}
private static int readVarShort(ByteBuf input) {
var low = input.readUnsignedShort();
var length = low & 0x7FFF;
if ((low & 0x8000) != 0) {
length |= input.readUnsignedByte() << 15;
}
return length;
}
private static void release(ByteBuf buffer) {
if (buffer != null && buffer.refCnt() > 0) {
buffer.release();
}
}
}
@@ -0,0 +1,658 @@
package dev.strataproxy.network;
import dev.strataproxy.codec.minecraft.MinecraftVarInts;
import dev.strataproxy.observability.ProxyMetrics;
import io.netty.buffer.ByteBuf;
import io.netty.buffer.Unpooled;
import io.netty.channel.Channel;
import io.netty.channel.embedded.EmbeddedChannel;
import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.atomic.AtomicReference;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
final class BackendSwitchLoginHandlerTest {
@Test
void legacySwitchReturnsJoinGameAsRemainingImmediatelyAfterLoginSuccess() {
var clientbound = new AtomicReference<ByteBuf>();
var remaining = new AtomicReference<ByteBuf>();
var channel = new EmbeddedChannel(new BackendSwitchLoginHandler(
new ProxyMetrics(),
"lobby-1",
4096,
new MinecraftCompressionAuditState(4096),
MinecraftForwardingRuntime.none(),
new RelaySessionIdentity("127.0.0.1:50000"),
MinecraftProtocolProfile.forVersion(MinecraftProtocolProfile.PROTOCOL_1_7_10),
new BackendSwitchLoginHandler.Listener() {
@Override
public void backendLoginReady(Channel backend, ByteBuf clientboundFrames, ByteBuf remainingBackendFrames) {
clientbound.set(clientboundFrames);
remaining.set(remainingBackendFrames);
}
@Override
public void backendLoginFailed(Channel backend, String outcome) {
}
}));
channel.writeInbound(combine(
frame(packet(0x02)),
joinGameFrame(12, 0, 0, 2, "default"),
frame(packet(0x08))));
var clientboundFrames = clientbound.get();
var remainingBackendFrames = remaining.get();
try {
assertNull(clientboundFrames);
assertNotNull(remainingBackendFrames);
var joinGame = payload(remainingBackendFrames);
try {
assertEquals(0x01, MinecraftVarInts.read(joinGame));
} finally {
joinGame.release();
}
var payload = payload(remainingBackendFrames);
try {
assertEquals(0x08, MinecraftVarInts.read(payload));
} finally {
payload.release();
}
assertEquals(0, remainingBackendFrames.readableBytes());
} finally {
release(clientboundFrames);
release(remainingBackendFrames);
channel.finishAndReleaseAll();
}
}
@Test
void modernSwitchReturnsRemainingBackendFramesWithoutClientReset() {
var clientbound = new AtomicReference<ByteBuf>();
var remaining = new AtomicReference<ByteBuf>();
var channel = new EmbeddedChannel(new BackendSwitchLoginHandler(
new ProxyMetrics(),
"survival-1",
4096,
new MinecraftCompressionAuditState(4096),
MinecraftForwardingRuntime.none(),
new RelaySessionIdentity("127.0.0.1:50000"),
MinecraftProtocolProfile.forVersion(MinecraftProtocolProfile.PROTOCOL_1_20_1),
new BackendSwitchLoginHandler.Listener() {
@Override
public void backendLoginReady(Channel backend, ByteBuf clientboundFrames, ByteBuf remainingBackendFrames) {
clientbound.set(clientboundFrames);
remaining.set(remainingBackendFrames);
}
@Override
public void backendLoginFailed(Channel backend, String outcome) {
}
}));
channel.writeInbound(combine(frame(packet(0x02)), frame(packet(0x29))));
try {
assertNull(clientbound.get());
var remainingBackendFrames = remaining.get();
assertNotNull(remainingBackendFrames);
var payload = payload(remainingBackendFrames);
try {
assertEquals(0x29, MinecraftVarInts.read(payload));
} finally {
payload.release();
}
} finally {
release(remaining.get());
channel.finishAndReleaseAll();
}
}
@Test
void legacyForgeSwitchReturnsPreJoinRegisterImmediatelyAfterLoginSuccess() {
var clientbound = new AtomicReference<ByteBuf>();
var remaining = new AtomicReference<ByteBuf>();
var profile = MinecraftProtocolProfile.forVersion(MinecraftProtocolProfile.PROTOCOL_1_7_10);
var tracker = new MinecraftForgeHandshakeTracker(4096, profile, true);
var channel = new EmbeddedChannel(new BackendSwitchLoginHandler(
new ProxyMetrics(),
"forge-1",
4096,
new MinecraftCompressionAuditState(4096),
MinecraftForwardingRuntime.none(),
new RelaySessionIdentity("127.0.0.1:50000"),
profile,
tracker,
new BackendSwitchLoginHandler.Listener() {
@Override
public void backendLoginReady(Channel backend, ByteBuf clientboundFrames, ByteBuf remainingBackendFrames) {
clientbound.set(clientboundFrames);
remaining.set(remainingBackendFrames);
}
@Override
public void backendLoginFailed(Channel backend, String outcome) {
}
}));
channel.writeInbound(combine(
frame(packet(0x02)),
clientboundCustomPayloadFrame("REGISTER", registerPayload()),
joinGameFrame(12, 0, 0, 2, "default")));
var clientboundFrames = clientbound.get();
var remainingBackendFrames = remaining.get();
try {
assertNull(clientboundFrames);
assertNotNull(remainingBackendFrames);
assertRegister(remainingBackendFrames);
var joinGame = payload(remainingBackendFrames);
try {
assertEquals(0x01, MinecraftVarInts.read(joinGame));
} finally {
joinGame.release();
}
} finally {
release(clientboundFrames);
release(remainingBackendFrames);
channel.finishAndReleaseAll();
tracker.close();
}
}
@Test
void legacySwitchDoesNotBufferPreJoinGameFramesAgainstPendingLimit() {
var failure = new AtomicReference<String>();
var remaining = new AtomicReference<ByteBuf>();
var channel = new EmbeddedChannel(new BackendSwitchLoginHandler(
new ProxyMetrics(),
"lobby-1",
8,
new MinecraftCompressionAuditState(8),
MinecraftForwardingRuntime.none(),
new RelaySessionIdentity("127.0.0.1:50000"),
MinecraftProtocolProfile.forVersion(MinecraftProtocolProfile.PROTOCOL_1_7_10),
new BackendSwitchLoginHandler.Listener() {
@Override
public void backendLoginReady(Channel backend, ByteBuf clientboundFrames, ByteBuf remainingBackendFrames) {
release(clientboundFrames);
remaining.set(remainingBackendFrames);
}
@Override
public void backendLoginFailed(Channel backend, String outcome) {
failure.set(outcome);
}
}));
channel.writeInbound(combine(
frame(packet(0x02)),
manyFrames(0x08, 40)));
assertNull(failure.get());
assertNotNull(remaining.get());
release(remaining.get());
channel.finishAndReleaseAll();
}
@Test
void backendRelayConvertsFirstLegacySwitchJoinGameToRespawns() {
var profile = MinecraftProtocolProfile.forVersion(MinecraftProtocolProfile.PROTOCOL_1_7_10);
var frontend = new EmbeddedChannel();
var backend = new EmbeddedChannel(new BackendRelayHandler(
frontend,
new ProxyMetrics(),
"lobby-1",
4096,
new MinecraftCompressionAuditState(4096),
CompressionRuntime.defaults(),
new RelaySessionIdentity("127.0.0.1:50000"),
MinecraftForwardingRuntime.none(),
false,
25,
profile,
null,
null,
true));
backend.writeInbound(combine(
joinGameFrame(12, 0, 0, 2, "default"),
frame(packet(0x08))));
var frames = (ByteBuf) frontend.readOutbound();
try {
assertRespawn(frames, -1, 2, 0, "default");
assertRespawn(frames, 0, 2, 0, "default");
var payload = payload(frames);
try {
assertEquals(0x08, MinecraftVarInts.read(payload));
} finally {
payload.release();
}
assertEquals(0, frames.readableBytes());
} finally {
release(frames);
backend.finishAndReleaseAll();
frontend.finishAndReleaseAll();
}
}
@Test
void backendRelayKeepsJoinGameForLegacyForgeSafeSwitch() {
var profile = MinecraftProtocolProfile.forVersion(MinecraftProtocolProfile.PROTOCOL_1_7_10);
var frontend = new EmbeddedChannel();
var backend = new EmbeddedChannel(new BackendRelayHandler(
frontend,
new ProxyMetrics(),
"forge-1",
4096,
new MinecraftCompressionAuditState(4096),
CompressionRuntime.defaults(),
new RelaySessionIdentity("127.0.0.1:50000"),
MinecraftForwardingRuntime.none(),
false,
25,
profile,
null,
null,
true,
true));
backend.writeInbound(combine(
joinGameFrame(12, 0, 0, 2, "default"),
frame(packet(0x08))));
var frames = (ByteBuf) frontend.readOutbound();
try {
assertJoinGame(frames, 12, 0, 0, 2, "default");
assertRespawn(frames, -1, 2, 0, "default");
assertRespawn(frames, 0, 2, 0, "default");
var payload = payload(frames);
try {
assertEquals(0x08, MinecraftVarInts.read(payload));
} finally {
payload.release();
}
assertEquals(0, frames.readableBytes());
} finally {
release(frames);
backend.finishAndReleaseAll();
frontend.finishAndReleaseAll();
}
}
@Test
void backendRelayUsesLegacyForgeSafeSwitchFor18JoinGame() {
var profile = MinecraftProtocolProfile.forVersion(MinecraftProtocolProfile.PROTOCOL_1_8);
var frontend = new EmbeddedChannel();
var backend = new EmbeddedChannel(new BackendRelayHandler(
frontend,
new ProxyMetrics(),
"forge-1",
4096,
new MinecraftCompressionAuditState(4096),
CompressionRuntime.defaults(),
new RelaySessionIdentity("127.0.0.1:50000"),
MinecraftForwardingRuntime.none(),
false,
25,
profile,
null,
null,
profile.backendSwitchStrategy(true)));
backend.writeInbound(combine(
joinGame18Frame(12, 0, 0, 2, "default", true),
frame(packet(0x08))));
var frames = (ByteBuf) frontend.readOutbound();
try {
assertJoinGame18(frames, 12, 0, 0, 2, "default", true);
assertRespawn(frames, -1, 2, 0, "default");
assertRespawn(frames, 0, 2, 0, "default");
var payload = payload(frames);
try {
assertEquals(0x08, MinecraftVarInts.read(payload));
} finally {
payload.release();
}
assertEquals(0, frames.readableBytes());
} finally {
release(frames);
backend.finishAndReleaseAll();
frontend.finishAndReleaseAll();
}
}
@Test
void backendRelayBuffersSplitLegacySwitchJoinGameUntilComplete() {
var profile = MinecraftProtocolProfile.forVersion(MinecraftProtocolProfile.PROTOCOL_1_7_10);
var frontend = new EmbeddedChannel();
var backend = new EmbeddedChannel(new BackendRelayHandler(
frontend,
new ProxyMetrics(),
"lobby-1",
4096,
new MinecraftCompressionAuditState(4096),
CompressionRuntime.defaults(),
new RelaySessionIdentity("127.0.0.1:50000"),
MinecraftForwardingRuntime.none(),
false,
25,
profile,
null,
null,
true));
var joinGame = joinGameFrame(12, 0, 0, 2, "default");
var firstHalf = joinGame.readRetainedSlice(joinGame.readableBytes() / 2);
var secondHalf = joinGame.readRetainedSlice(joinGame.readableBytes());
joinGame.release();
assertFalse(backend.writeInbound(firstHalf));
assertNull(frontend.readOutbound());
backend.writeInbound(secondHalf);
var frames = (ByteBuf) frontend.readOutbound();
try {
assertRespawn(frames, -1, 2, 0, "default");
assertRespawn(frames, 0, 2, 0, "default");
assertEquals(0, frames.readableBytes());
} finally {
release(frames);
backend.finishAndReleaseAll();
frontend.finishAndReleaseAll();
}
}
@Test
void backendRelayClosesOnOversizedLegacySwitchJoinGame() {
var profile = MinecraftProtocolProfile.forVersion(MinecraftProtocolProfile.PROTOCOL_1_7_10);
var frontend = new EmbeddedChannel();
var backend = new EmbeddedChannel(new BackendRelayHandler(
frontend,
new ProxyMetrics(),
"lobby-1",
8,
new MinecraftCompressionAuditState(8),
CompressionRuntime.defaults(),
new RelaySessionIdentity("127.0.0.1:50000"),
MinecraftForwardingRuntime.none(),
false,
25,
profile,
null,
null,
true));
var joinGame = joinGameFrame(12, 0, 0, 2, "default");
var first = joinGame.readRetainedSlice(6);
var second = joinGame.readRetainedSlice(joinGame.readableBytes());
joinGame.release();
try {
assertFalse(backend.writeInbound(first));
assertNull(frontend.readOutbound());
assertFalse(backend.isOpen());
assertFalse(frontend.isOpen());
assertNull(frontend.readOutbound());
} finally {
release(second);
backend.finishAndReleaseAll();
frontend.finishAndReleaseAll();
}
}
@Test
void backendRelayClosesOnMalformedLegacySwitchJoinGame() {
var profile = MinecraftProtocolProfile.forVersion(MinecraftProtocolProfile.PROTOCOL_1_7_10);
var frontend = new EmbeddedChannel();
var backend = new EmbeddedChannel(new BackendRelayHandler(
frontend,
new ProxyMetrics(),
"lobby-1",
4096,
new MinecraftCompressionAuditState(4096),
CompressionRuntime.defaults(),
new RelaySessionIdentity("127.0.0.1:50000"),
MinecraftForwardingRuntime.none(),
false,
25,
profile,
null,
null,
true));
try {
assertFalse(backend.writeInbound(frame(packet(0x01))));
assertFalse(backend.isOpen());
assertFalse(frontend.isOpen());
assertNull(frontend.readOutbound());
} finally {
backend.finishAndReleaseAll();
frontend.finishAndReleaseAll();
}
}
private static ByteBuf joinGameFrame(int entityId, int gameMode, int dimension, int difficulty, String levelType) {
var packet = Unpooled.buffer();
MinecraftVarInts.write(packet, 0x01);
packet.writeInt(entityId);
packet.writeByte(gameMode);
packet.writeByte(dimension);
packet.writeByte(difficulty);
packet.writeByte(20);
writeString(packet, levelType);
return frame(packet);
}
private static ByteBuf joinGame18Frame(
int entityId,
int gameMode,
int dimension,
int difficulty,
String levelType,
boolean reducedDebugInfo) {
var packet = Unpooled.buffer();
MinecraftVarInts.write(packet, 0x01);
packet.writeInt(entityId);
packet.writeByte(gameMode);
packet.writeByte(dimension);
packet.writeByte(difficulty);
packet.writeByte(20);
writeString(packet, levelType);
packet.writeBoolean(reducedDebugInfo);
return frame(packet);
}
private static ByteBuf clientboundCustomPayloadFrame(String channel, ByteBuf payload) {
var packet = Unpooled.buffer();
try {
MinecraftVarInts.write(packet, 0x3F);
writeString(packet, channel);
writeVarShort(packet, payload.readableBytes());
packet.writeBytes(payload, payload.readerIndex(), payload.readableBytes());
return frame(packet);
} finally {
payload.release();
}
}
private static ByteBuf registerPayload() {
return Unpooled.wrappedBuffer("FML|HS\0FML\0FML|MP\0FORGE".getBytes(StandardCharsets.UTF_8));
}
private static ByteBuf frame(ByteBuf packet) {
var frame = Unpooled.buffer();
MinecraftVarInts.write(frame, packet.readableBytes());
frame.writeBytes(packet, packet.readerIndex(), packet.readableBytes());
packet.release();
return frame;
}
private static ByteBuf packet(int packetId) {
var packet = Unpooled.buffer();
MinecraftVarInts.write(packet, packetId);
return packet;
}
private static ByteBuf combine(ByteBuf... frames) {
var combined = Unpooled.buffer();
for (var frame : frames) {
try {
combined.writeBytes(frame, frame.readerIndex(), frame.readableBytes());
} finally {
frame.release();
}
}
return combined;
}
private static ByteBuf manyFrames(int packetId, int count) {
var combined = Unpooled.buffer();
for (var index = 0; index < count; index++) {
var frame = frame(packet(packetId));
try {
combined.writeBytes(frame, frame.readerIndex(), frame.readableBytes());
} finally {
frame.release();
}
}
return combined;
}
private static void assertForgeReset(ByteBuf frames) {
var payload = payload(frames);
try {
assertEquals(0x3F, MinecraftVarInts.read(payload));
assertEquals("FML|HS", readString(payload));
assertEquals(2, readVarShort(payload));
assertEquals(0xFE, payload.readUnsignedByte());
assertEquals(0, payload.readUnsignedByte());
assertEquals(0, payload.readableBytes());
} finally {
payload.release();
}
}
private static void assertRegister(ByteBuf frames) {
var payload = payload(frames);
try {
assertEquals(0x3F, MinecraftVarInts.read(payload));
assertEquals("REGISTER", readString(payload));
var length = readVarShort(payload);
assertEquals("FML|HS\0FML\0FML|MP\0FORGE",
payload.toString(payload.readerIndex(), length, StandardCharsets.UTF_8));
} finally {
payload.release();
}
}
private static ByteBuf payload(ByteBuf frames) {
var length = MinecraftVarInts.read(frames);
return frames.readRetainedSlice(length);
}
private static void assertJoinGame(
ByteBuf frames,
int entityId,
int gameMode,
int dimension,
int difficulty,
String levelType) {
var payload = payload(frames);
try {
assertEquals(0x01, MinecraftVarInts.read(payload));
assertEquals(entityId, payload.readInt());
assertEquals(gameMode, payload.readUnsignedByte());
assertEquals(dimension, payload.readByte());
assertEquals(difficulty, payload.readUnsignedByte());
payload.readUnsignedByte();
assertEquals(levelType, readString(payload));
assertEquals(0, payload.readableBytes());
} finally {
payload.release();
}
}
private static void assertJoinGame18(
ByteBuf frames,
int entityId,
int gameMode,
int dimension,
int difficulty,
String levelType,
boolean reducedDebugInfo) {
var payload = payload(frames);
try {
assertEquals(0x01, MinecraftVarInts.read(payload));
assertEquals(entityId, payload.readInt());
assertEquals(gameMode, payload.readUnsignedByte());
assertEquals(dimension, payload.readByte());
assertEquals(difficulty, payload.readUnsignedByte());
payload.readUnsignedByte();
assertEquals(levelType, readString(payload));
assertEquals(reducedDebugInfo, payload.readBoolean());
assertEquals(0, payload.readableBytes());
} finally {
payload.release();
}
}
private static void assertRespawn(ByteBuf frames, int dimension, int difficulty, int gameMode, String levelType) {
var payload = payload(frames);
try {
assertEquals(0x07, MinecraftVarInts.read(payload));
assertEquals(dimension, payload.readInt());
assertEquals(difficulty, payload.readUnsignedByte());
assertEquals(gameMode, payload.readUnsignedByte());
assertEquals(levelType, readString(payload));
assertEquals(0, payload.readableBytes());
} finally {
payload.release();
}
}
private static void writeString(ByteBuf output, String value) {
var bytes = value.getBytes(StandardCharsets.UTF_8);
MinecraftVarInts.write(output, bytes.length);
output.writeBytes(bytes);
}
private static void writeVarShort(ByteBuf output, int value) {
if ((value & 0xFFFF8000) != 0) {
output.writeShort((value & 0x7FFF) | 0x8000);
output.writeByte(value >>> 15);
} else {
output.writeShort(value);
}
}
private static String readString(ByteBuf input) {
var bytes = new byte[MinecraftVarInts.read(input)];
input.readBytes(bytes);
return new String(bytes, StandardCharsets.UTF_8);
}
private static int readVarShort(ByteBuf input) {
var low = input.readUnsignedShort();
var length = low & 0x7FFF;
if ((low & 0x8000) != 0) {
length |= input.readUnsignedByte() << 15;
}
return length;
}
private static void release(ByteBuf buffer) {
if (buffer != null && buffer.refCnt() > 0) {
buffer.release();
}
}
}
@@ -0,0 +1,867 @@
package dev.strataproxy.network;
import dev.strataproxy.analysis.CustomPayloadAnomalyPolicy;
import dev.strataproxy.codec.minecraft.MinecraftCompressionCodec;
import dev.strataproxy.codec.minecraft.MinecraftVarInts;
import dev.strataproxy.observability.ProxyMetrics;
import io.netty.buffer.ByteBuf;
import io.netty.buffer.Unpooled;
import io.netty.buffer.UnpooledByteBufAllocator;
import io.netty.channel.embedded.EmbeddedChannel;
import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
import java.util.Optional;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
final class FrontendForgeHandshakeGateTest {
@Test
void marksLegacyForgeClientFromServerboundRegisterWithoutHostnameToken() {
var profile = MinecraftProtocolProfile.forVersion(MinecraftProtocolProfile.PROTOCOL_1_7_10);
var tracker = new MinecraftForgeHandshakeTracker(4096, profile);
var metrics = new ProxyMetrics();
var session = new RelaySession("127.0.0.1:50000");
var controller = new BackendReplacementController(
name -> Optional.empty(),
null,
metrics,
NetworkTuning.defaults(),
CompressionRuntime.defaults(),
CustomPayloadAnomalyPolicy.defaults(),
session,
new RelaySessionRegistry(),
false,
25,
null,
null,
profile);
var backend = new EmbeddedChannel();
var frontend = new EmbeddedChannel(new FrontendRelayHandler(
backend,
metrics,
"forge-1",
new MinecraftCompressionAuditState(4096),
CompressionRuntime.defaults(),
4096,
CustomPayloadAnomalyPolicy.defaults(),
"Steve",
session.identity(),
false,
25,
controller,
null,
null,
profile,
null,
tracker));
try {
assertFalse(session.legacyForgeClientDetected());
assertFalse(frontend.writeInbound(customPayload(0x17, "REGISTER", registerPayload(), false)));
assertTrue(session.legacyForgeClientDetected());
var register = (ByteBuf) backend.readOutbound();
try {
assertCustomPayload(register, 0x17, "REGISTER", registerPayloadString());
} finally {
release(register);
}
} finally {
frontend.finishAndReleaseAll();
backend.finishAndReleaseAll();
tracker.close();
}
}
@Test
void suppressesLegacyForgeRacePacket() {
var profile = MinecraftProtocolProfile.forVersion(MinecraftProtocolProfile.PROTOCOL_1_7_10);
var tracker = new MinecraftForgeHandshakeTracker(4096, profile);
var backend = new EmbeddedChannel();
var frontend = new EmbeddedChannel(new FrontendRelayHandler(
backend,
new ProxyMetrics(),
"forge-1",
new MinecraftCompressionAuditState(4096),
CompressionRuntime.defaults(),
4096,
CustomPayloadAnomalyPolicy.defaults(),
"Steve",
new RelaySessionIdentity("127.0.0.1:50000"),
false,
25,
null,
null,
null,
profile,
null,
tracker));
try {
assertFalse(frontend.writeInbound(customPayload(0x17, "FML", bytes(1), false)));
assertNull(backend.readOutbound());
} finally {
frontend.finishAndReleaseAll();
backend.finishAndReleaseAll();
tracker.close();
}
}
@Test
void suppressesCompressedLegacyForgeRacePacket() {
var profile = MinecraftProtocolProfile.forVersion(MinecraftProtocolProfile.PROTOCOL_1_8);
var tracker = new MinecraftForgeHandshakeTracker(4096, profile);
var compression = new MinecraftCompressionAuditState(4096);
compression.negotiate(0);
var backend = new EmbeddedChannel();
var frontend = new EmbeddedChannel(new FrontendRelayHandler(
backend,
new ProxyMetrics(),
"forge-1",
compression,
CompressionRuntime.defaults(),
4096,
CustomPayloadAnomalyPolicy.defaults(),
"Steve",
new RelaySessionIdentity("127.0.0.1:50000"),
false,
25,
null,
null,
null,
profile,
null,
tracker));
try {
assertFalse(frontend.writeInbound(compressedCustomPayload18(0x17, "FML", bytes(1), 0)));
assertNull(backend.readOutbound());
} finally {
frontend.finishAndReleaseAll();
backend.finishAndReleaseAll();
tracker.close();
}
}
@Test
void suppressesOnlyLegacyForgeRacePacketFromCombinedFrames() {
var profile = MinecraftProtocolProfile.forVersion(MinecraftProtocolProfile.PROTOCOL_1_7_10);
var tracker = new MinecraftForgeHandshakeTracker(4096, profile);
var backend = new EmbeddedChannel();
var frontend = new EmbeddedChannel(new FrontendRelayHandler(
backend,
new ProxyMetrics(),
"forge-1",
new MinecraftCompressionAuditState(4096),
CompressionRuntime.defaults(),
4096,
CustomPayloadAnomalyPolicy.defaults(),
"Steve",
new RelaySessionIdentity("127.0.0.1:50000"),
false,
25,
null,
null,
null,
profile,
null,
tracker));
try {
var combined = combine(
customPayload(0x17, "FML", bytes(1), false),
customPayload(0x17, "MOD|OK", bytes(7, 8), false));
assertFalse(frontend.writeInbound(combined));
var forwarded = (ByteBuf) backend.readOutbound();
try {
assertCustomPayload(forwarded, 0x17, "MOD|OK", 7, 8);
assertNull(backend.readOutbound());
} finally {
release(forwarded);
}
} finally {
frontend.finishAndReleaseAll();
backend.finishAndReleaseAll();
tracker.close();
}
}
@Test
void suppressesOnlyCompressedLegacyForgeRacePacketFromCombinedFrames() {
var profile = MinecraftProtocolProfile.forVersion(MinecraftProtocolProfile.PROTOCOL_1_8);
var tracker = new MinecraftForgeHandshakeTracker(4096, profile);
var compression = new MinecraftCompressionAuditState(4096);
compression.negotiate(0);
var backend = new EmbeddedChannel();
var frontend = new EmbeddedChannel(new FrontendRelayHandler(
backend,
new ProxyMetrics(),
"forge-1",
compression,
CompressionRuntime.defaults(),
4096,
CustomPayloadAnomalyPolicy.defaults(),
"Steve",
new RelaySessionIdentity("127.0.0.1:50000"),
false,
25,
null,
null,
null,
profile,
null,
tracker));
try {
var combined = combine(
compressedCustomPayload18(0x17, "FML", bytes(1), 0),
compressedCustomPayload18(0x17, "MOD|OK", bytes(7, 8), 0));
assertFalse(frontend.writeInbound(combined));
var forwarded = (ByteBuf) backend.readOutbound();
try (var codec = new MinecraftCompressionCodec()) {
assertCompressedCustomPayload18(codec, forwarded, 0, "MOD|OK", 7, 8);
assertNull(backend.readOutbound());
} finally {
release(forwarded);
}
} finally {
frontend.finishAndReleaseAll();
backend.finishAndReleaseAll();
tracker.close();
}
}
@Test
void queuesNonForgeCustomPayloadsUntilLegacyForgeHandshakeCompletes() {
var profile = MinecraftProtocolProfile.forVersion(MinecraftProtocolProfile.PROTOCOL_1_7_10);
var tracker = new MinecraftForgeHandshakeTracker(4096, profile);
advanceToPendingComplete(tracker);
var backend = new EmbeddedChannel();
var frontend = new EmbeddedChannel(new FrontendRelayHandler(
backend,
new ProxyMetrics(),
"forge-1",
new MinecraftCompressionAuditState(4096),
CompressionRuntime.defaults(),
4096,
CustomPayloadAnomalyPolicy.defaults(),
"Steve",
new RelaySessionIdentity("127.0.0.1:50000"),
false,
25,
null,
null,
null,
profile,
null,
tracker));
try {
assertFalse(frontend.writeInbound(customPayload(0x17, "MOD|EARLY", bytes(1, 2, 3), false)));
assertNull(backend.readOutbound());
assertFalse(frontend.writeInbound(customPayload(0x17, "FML|HS", bytes(255, 5), false)));
var finalAck = (ByteBuf) backend.readOutbound();
var queuedPayload = (ByteBuf) backend.readOutbound();
try {
assertCustomPayload(finalAck, 0x17, "FML|HS", 255, 5);
assertCustomPayload(queuedPayload, 0x17, "MOD|EARLY", 1, 2, 3);
assertTrue(tracker.complete());
} finally {
release(finalAck);
release(queuedPayload);
}
} finally {
frontend.finishAndReleaseAll();
backend.finishAndReleaseAll();
tracker.close();
}
}
@Test
void flushesQueuedPayloadAfterFinalAckWhenBothArriveTogether() {
var profile = MinecraftProtocolProfile.forVersion(MinecraftProtocolProfile.PROTOCOL_1_7_10);
var tracker = new MinecraftForgeHandshakeTracker(4096, profile);
advanceToPendingComplete(tracker);
var backend = new EmbeddedChannel();
var frontend = new EmbeddedChannel(new FrontendRelayHandler(
backend,
new ProxyMetrics(),
"forge-1",
new MinecraftCompressionAuditState(4096),
CompressionRuntime.defaults(),
4096,
CustomPayloadAnomalyPolicy.defaults(),
"Steve",
new RelaySessionIdentity("127.0.0.1:50000"),
false,
25,
null,
null,
null,
profile,
null,
tracker));
try {
var combined = combine(
customPayload(0x17, "MOD|EARLY", bytes(1, 2, 3), false),
customPayload(0x17, "FML|HS", bytes(255, 5), false));
assertFalse(frontend.writeInbound(combined));
var finalAck = (ByteBuf) backend.readOutbound();
var queuedPayload = (ByteBuf) backend.readOutbound();
try {
assertCustomPayload(finalAck, 0x17, "FML|HS", 255, 5);
assertCustomPayload(queuedPayload, 0x17, "MOD|EARLY", 1, 2, 3);
assertTrue(tracker.complete());
} finally {
release(finalAck);
release(queuedPayload);
}
} finally {
frontend.finishAndReleaseAll();
backend.finishAndReleaseAll();
tracker.close();
}
}
@Test
void forwardsRegularPlayFramesDuringLegacyForgeHandshake() {
var profile = MinecraftProtocolProfile.forVersion(MinecraftProtocolProfile.PROTOCOL_1_7_10);
var tracker = new MinecraftForgeHandshakeTracker(4096, profile);
advanceToPendingComplete(tracker);
var backend = new EmbeddedChannel();
var frontend = new EmbeddedChannel(new FrontendRelayHandler(
backend,
new ProxyMetrics(),
"forge-1",
new MinecraftCompressionAuditState(4096),
CompressionRuntime.defaults(),
4096,
CustomPayloadAnomalyPolicy.defaults(),
"Steve",
new RelaySessionIdentity("127.0.0.1:50000"),
false,
25,
null,
null,
null,
profile,
null,
tracker));
try {
assertFalse(frontend.writeInbound(frame(packet(0x0B))));
var play = (ByteBuf) backend.readOutbound();
try {
assertEquals(0x0B, packetId(play));
assertFalse(tracker.complete());
} finally {
release(play);
}
} finally {
frontend.finishAndReleaseAll();
backend.finishAndReleaseAll();
tracker.close();
}
}
@Test
void removesForgeHandshakeStateWhenFrontendRelayCloses() {
var profile = MinecraftProtocolProfile.forVersion(MinecraftProtocolProfile.PROTOCOL_1_7_10);
var tracker = new MinecraftForgeHandshakeTracker(4096, profile);
advanceToPendingComplete(tracker);
var metrics = new ProxyMetrics();
var backend = new EmbeddedChannel();
var frontend = new EmbeddedChannel(new FrontendRelayHandler(
backend,
metrics,
"forge-1",
new MinecraftCompressionAuditState(4096),
CompressionRuntime.defaults(),
4096,
CustomPayloadAnomalyPolicy.defaults(),
"Steve",
new RelaySessionIdentity("127.0.0.1:50000"),
false,
25,
null,
null,
null,
profile,
null,
tracker));
try {
assertFalse(frontend.writeInbound(customPayload(0x17, "FML|HS", bytes(255, 5), false)));
var finalAck = (ByteBuf) backend.readOutbound();
release(finalAck);
assertEquals(1, metrics.snapshot().forgeHandshakes().size());
frontend.close();
frontend.runPendingTasks();
assertTrue(metrics.snapshot().forgeHandshakes().isEmpty());
} finally {
frontend.finishAndReleaseAll();
backend.finishAndReleaseAll();
tracker.close();
}
}
@Test
void tracksCompressedClientPluginChannelsThroughFrontendRelay() {
var profile = MinecraftProtocolProfile.forVersion(MinecraftProtocolProfile.PROTOCOL_1_8);
var metrics = new ProxyMetrics();
var session = new RelaySession("127.0.0.1:50000");
var controller = new BackendReplacementController(
name -> Optional.empty(),
null,
metrics,
NetworkTuning.defaults(),
CompressionRuntime.defaults(),
CustomPayloadAnomalyPolicy.defaults(),
session,
new RelaySessionRegistry(),
false,
25,
null,
null,
profile);
var compression = new MinecraftCompressionAuditState(4096);
compression.negotiate(0);
var backend = new EmbeddedChannel();
var frontend = new EmbeddedChannel(new FrontendRelayHandler(
backend,
metrics,
"forge-1",
compression,
CompressionRuntime.defaults(),
4096,
CustomPayloadAnomalyPolicy.defaults(),
"Steve",
session.identity(),
false,
25,
controller,
null,
null,
profile,
null,
null));
try {
assertFalse(frontend.writeInbound(compressedCustomPayload18("REGISTER", registerPayloadString(), 0)));
assertEquals(Set.of("FML|HS", "FML", "FML|MP", "FORGE"),
session.pluginChannelRegistry().channels());
var forwarded = (ByteBuf) backend.readOutbound();
try (var codec = new MinecraftCompressionCodec()) {
assertCompressedCustomPayload18(codec, forwarded, 0, "REGISTER", registerPayloadString());
} finally {
release(forwarded);
}
} finally {
frontend.finishAndReleaseAll();
backend.finishAndReleaseAll();
session.closePluginChannelRegistry();
}
}
@Test
void marksLegacyForgeClientFromCompressedServerboundRegister() {
var profile = MinecraftProtocolProfile.forVersion(MinecraftProtocolProfile.PROTOCOL_1_8);
var tracker = new MinecraftForgeHandshakeTracker(4096, profile);
var metrics = new ProxyMetrics();
var session = new RelaySession("127.0.0.1:50000");
var controller = new BackendReplacementController(
name -> Optional.empty(),
null,
metrics,
NetworkTuning.defaults(),
CompressionRuntime.defaults(),
CustomPayloadAnomalyPolicy.defaults(),
session,
new RelaySessionRegistry(),
false,
25,
null,
null,
profile);
var compression = new MinecraftCompressionAuditState(4096);
compression.negotiate(0);
var backend = new EmbeddedChannel();
var frontend = new EmbeddedChannel(new FrontendRelayHandler(
backend,
metrics,
"forge-1",
compression,
CompressionRuntime.defaults(),
4096,
CustomPayloadAnomalyPolicy.defaults(),
"Steve",
session.identity(),
false,
25,
controller,
null,
null,
profile,
null,
tracker));
try {
assertFalse(session.legacyForgeClientDetected());
assertFalse(frontend.writeInbound(compressedCustomPayload18("REGISTER", registerPayloadString(), 0)));
assertTrue(session.legacyForgeClientDetected());
assertEquals(MinecraftForgeHandshakeTracker.Stage.CHANNELS_REGISTERED, tracker.stage());
var forwarded = (ByteBuf) backend.readOutbound();
try (var codec = new MinecraftCompressionCodec()) {
assertCompressedCustomPayload18(codec, forwarded, 0, "REGISTER", registerPayloadString());
} finally {
release(forwarded);
}
} finally {
frontend.finishAndReleaseAll();
backend.finishAndReleaseAll();
tracker.close();
session.closePluginChannelRegistry();
}
}
@Test
void queuesCompressedNonForgeCustomPayloadsUntilLegacyForgeHandshakeCompletes() {
var profile = MinecraftProtocolProfile.forVersion(MinecraftProtocolProfile.PROTOCOL_1_8);
var tracker = new MinecraftForgeHandshakeTracker(4096, profile);
advanceCompressed18ToPendingComplete(tracker);
var compression = new MinecraftCompressionAuditState(4096);
compression.negotiate(0);
var backend = new EmbeddedChannel();
var frontend = new EmbeddedChannel(new FrontendRelayHandler(
backend,
new ProxyMetrics(),
"forge-1",
compression,
CompressionRuntime.defaults(),
4096,
CustomPayloadAnomalyPolicy.defaults(),
"Steve",
new RelaySessionIdentity("127.0.0.1:50000"),
false,
25,
null,
null,
null,
profile,
null,
tracker));
try {
assertFalse(frontend.writeInbound(compressedCustomPayload18(0x17, "MOD|EARLY", bytes(1, 2, 3), 0)));
assertNull(backend.readOutbound());
assertFalse(frontend.writeInbound(compressedCustomPayload18(0x17, "FML|HS", bytes(255, 5), 0)));
var finalAck = (ByteBuf) backend.readOutbound();
var queuedPayload = (ByteBuf) backend.readOutbound();
try (var codec = new MinecraftCompressionCodec()) {
assertCompressedCustomPayload18(codec, finalAck, 0, "FML|HS", 255, 5);
assertCompressedCustomPayload18(codec, queuedPayload, 0, "MOD|EARLY", 1, 2, 3);
assertTrue(tracker.complete());
} finally {
release(finalAck);
release(queuedPayload);
}
} finally {
frontend.finishAndReleaseAll();
backend.finishAndReleaseAll();
tracker.close();
}
}
private static void advanceToPendingComplete(MinecraftForgeHandshakeTracker tracker) {
observe(tracker, MinecraftForgeHandshakeTracker.Direction.CLIENTBOUND,
customPayload(0x3F, "FML|HS", bytes(0, 2, 0, 0, 0, 0), true));
observe(tracker, MinecraftForgeHandshakeTracker.Direction.SERVERBOUND,
customPayload(0x17, "REGISTER", registerPayload(), false));
observe(tracker, MinecraftForgeHandshakeTracker.Direction.SERVERBOUND,
customPayload(0x17, "FML|HS", bytes(1, 2), false));
observe(tracker, MinecraftForgeHandshakeTracker.Direction.SERVERBOUND,
customPayload(0x17, "FML|HS", modListPayload(), false));
observe(tracker, MinecraftForgeHandshakeTracker.Direction.CLIENTBOUND,
customPayload(0x3F, "FML|HS", modListPayload(), true));
observe(tracker, MinecraftForgeHandshakeTracker.Direction.SERVERBOUND,
customPayload(0x17, "FML|HS", bytes(255, 2), false));
observe(tracker, MinecraftForgeHandshakeTracker.Direction.CLIENTBOUND,
customPayload(0x3F, "FML|HS", bytes(3, 0), true));
observe(tracker, MinecraftForgeHandshakeTracker.Direction.SERVERBOUND,
customPayload(0x17, "FML|HS", bytes(255, 3), false));
observe(tracker, MinecraftForgeHandshakeTracker.Direction.CLIENTBOUND,
customPayload(0x3F, "FML|HS", bytes(255, 2), true));
observe(tracker, MinecraftForgeHandshakeTracker.Direction.SERVERBOUND,
customPayload(0x17, "FML|HS", bytes(255, 4), false));
observe(tracker, MinecraftForgeHandshakeTracker.Direction.CLIENTBOUND,
customPayload(0x3F, "FML|HS", bytes(255, 3), true));
assertFalse(tracker.complete());
assertTrue(tracker.backendSwitchBlocked());
}
private static void advanceCompressed18ToPendingComplete(MinecraftForgeHandshakeTracker tracker) {
observeCompressed(tracker, MinecraftForgeHandshakeTracker.Direction.CLIENTBOUND,
compressedCustomPayload18(0x3F, "FML|HS", bytes(0, 2, 0, 0, 0, 0), 0));
observeCompressed(tracker, MinecraftForgeHandshakeTracker.Direction.SERVERBOUND,
compressedCustomPayload18(0x17, "REGISTER", registerPayload(), 0));
observeCompressed(tracker, MinecraftForgeHandshakeTracker.Direction.SERVERBOUND,
compressedCustomPayload18(0x17, "FML|HS", bytes(1, 2), 0));
observeCompressed(tracker, MinecraftForgeHandshakeTracker.Direction.SERVERBOUND,
compressedCustomPayload18(0x17, "FML|HS", modListPayload(), 0));
observeCompressed(tracker, MinecraftForgeHandshakeTracker.Direction.CLIENTBOUND,
compressedCustomPayload18(0x3F, "FML|HS", modListPayload(), 0));
observeCompressed(tracker, MinecraftForgeHandshakeTracker.Direction.SERVERBOUND,
compressedCustomPayload18(0x17, "FML|HS", bytes(255, 2), 0));
observeCompressed(tracker, MinecraftForgeHandshakeTracker.Direction.CLIENTBOUND,
compressedCustomPayload18(0x3F, "FML|HS", bytes(3, 0), 0));
observeCompressed(tracker, MinecraftForgeHandshakeTracker.Direction.SERVERBOUND,
compressedCustomPayload18(0x17, "FML|HS", bytes(255, 3), 0));
observeCompressed(tracker, MinecraftForgeHandshakeTracker.Direction.CLIENTBOUND,
compressedCustomPayload18(0x3F, "FML|HS", bytes(255, 2), 0));
observeCompressed(tracker, MinecraftForgeHandshakeTracker.Direction.SERVERBOUND,
compressedCustomPayload18(0x17, "FML|HS", bytes(255, 4), 0));
observeCompressed(tracker, MinecraftForgeHandshakeTracker.Direction.CLIENTBOUND,
compressedCustomPayload18(0x3F, "FML|HS", bytes(255, 3), 0));
assertFalse(tracker.complete());
assertTrue(tracker.backendSwitchBlocked());
}
private static void observe(
MinecraftForgeHandshakeTracker tracker,
MinecraftForgeHandshakeTracker.Direction direction,
ByteBuf frame) {
try {
tracker.observe(direction, frame);
} finally {
frame.release();
}
}
private static void observeCompressed(
MinecraftForgeHandshakeTracker tracker,
MinecraftForgeHandshakeTracker.Direction direction,
ByteBuf frame) {
try {
tracker.observeCompressed(direction, UnpooledByteBufAllocator.DEFAULT, frame, 0);
} finally {
frame.release();
}
}
private static ByteBuf customPayload(int packetId, String channel, ByteBuf payload, boolean clientbound) {
var packet = Unpooled.buffer();
try {
MinecraftVarInts.write(packet, packetId);
writeString(packet, channel);
if (clientbound) {
writeVarShort(packet, payload.readableBytes());
} else {
packet.writeShort(payload.readableBytes());
}
packet.writeBytes(payload, payload.readerIndex(), payload.readableBytes());
return frame(packet);
} finally {
payload.release();
}
}
private static ByteBuf compressedCustomPayload18(String channel, String payloadText, int threshold) {
var payload = Unpooled.wrappedBuffer(payloadText.getBytes(StandardCharsets.UTF_8));
try {
return compressedCustomPayload18(0x17, channel, payload.retainedDuplicate(), threshold);
} finally {
payload.release();
}
}
private static ByteBuf compressedCustomPayload18(int packetId, String channel, ByteBuf payload, int threshold) {
var packet = Unpooled.buffer();
try (var codec = new MinecraftCompressionCodec()) {
MinecraftVarInts.write(packet, packetId);
writeString(packet, channel);
packet.writeBytes(payload, payload.readerIndex(), payload.readableBytes());
return codec.encodeFrame(UnpooledByteBufAllocator.DEFAULT, packet, threshold);
} finally {
payload.release();
packet.release();
}
}
private static ByteBuf registerPayload() {
return Unpooled.wrappedBuffer(registerPayloadString().getBytes(StandardCharsets.UTF_8));
}
private static String registerPayloadString() {
return "FML|HS\0FML\0FML|MP\0FORGE";
}
private static ByteBuf modListPayload() {
var payload = Unpooled.buffer();
payload.writeByte(2);
MinecraftVarInts.write(payload, 1);
writeString(payload, "mcp");
writeString(payload, "9.05");
return payload;
}
private static ByteBuf frame(ByteBuf packet) {
var frame = Unpooled.buffer();
MinecraftVarInts.write(frame, packet.readableBytes());
frame.writeBytes(packet, packet.readerIndex(), packet.readableBytes());
packet.release();
return frame;
}
private static ByteBuf combine(ByteBuf... frames) {
var combined = Unpooled.buffer();
for (var frame : frames) {
try {
combined.writeBytes(frame, frame.readerIndex(), frame.readableBytes());
} finally {
frame.release();
}
}
return combined;
}
private static ByteBuf packet(int packetId) {
var packet = Unpooled.buffer();
MinecraftVarInts.write(packet, packetId);
return packet;
}
private static ByteBuf bytes(int... values) {
var payload = Unpooled.buffer(values.length);
for (var value : values) {
payload.writeByte(value);
}
return payload;
}
private static int packetId(ByteBuf frame) {
var payload = payload(frame);
try {
return MinecraftVarInts.read(payload);
} finally {
payload.release();
}
}
private static void assertCustomPayload(ByteBuf frame, int packetId, String channel, int... data) {
var payload = payload(frame);
try {
assertEquals(packetId, MinecraftVarInts.read(payload));
assertEquals(channel, readString(payload));
assertEquals(data.length, payload.readUnsignedShort());
for (var value : data) {
assertEquals(value, payload.readUnsignedByte());
}
assertEquals(0, payload.readableBytes());
} finally {
payload.release();
}
}
private static void assertCustomPayload(ByteBuf frame, int packetId, String channel, String data) {
var payload = payload(frame);
try {
assertEquals(packetId, MinecraftVarInts.read(payload));
assertEquals(channel, readString(payload));
assertEquals(data.length(), payload.readUnsignedShort());
assertEquals(data, payload.readCharSequence(data.length(), StandardCharsets.UTF_8).toString());
assertEquals(0, payload.readableBytes());
} finally {
payload.release();
}
}
private static void assertCompressedCustomPayload18(
MinecraftCompressionCodec codec,
ByteBuf frame,
int threshold,
String channel,
String data) {
var packet = codec.decodeFrame(UnpooledByteBufAllocator.DEFAULT, frame, threshold, 4096);
try {
assertEquals(0x17, MinecraftVarInts.read(packet));
assertEquals(channel, readString(packet));
assertEquals(data, packet.readCharSequence(packet.readableBytes(), StandardCharsets.UTF_8).toString());
assertEquals(0, packet.readableBytes());
} finally {
packet.release();
}
}
private static void assertCompressedCustomPayload18(
MinecraftCompressionCodec codec,
ByteBuf frame,
int threshold,
String channel,
int... data) {
var packet = codec.decodeFrame(UnpooledByteBufAllocator.DEFAULT, frame, threshold, 4096);
try {
assertEquals(0x17, MinecraftVarInts.read(packet));
assertEquals(channel, readString(packet));
for (var value : data) {
assertEquals(value, packet.readUnsignedByte());
}
assertEquals(0, packet.readableBytes());
} finally {
packet.release();
}
}
private static ByteBuf payload(ByteBuf frame) {
var length = MinecraftVarInts.read(frame);
return frame.readRetainedSlice(length);
}
private static void writeString(ByteBuf output, String value) {
var bytes = value.getBytes(StandardCharsets.UTF_8);
MinecraftVarInts.write(output, bytes.length);
output.writeBytes(bytes);
}
private static String readString(ByteBuf input) {
return MinecraftProtocolCodec.readString(input, 32767);
}
private static void writeVarShort(ByteBuf output, int value) {
if ((value & 0xFFFF8000) != 0) {
output.writeShort((value & 0x7FFF) | 0x8000);
output.writeByte(value >>> 15);
} else {
output.writeShort(value);
}
}
private static void release(ByteBuf buffer) {
if (buffer != null && buffer.refCnt() > 0) {
buffer.release();
}
}
}
@@ -35,6 +35,41 @@ final class GameCommandFrameInterceptorTest {
}
}
@Test
void suppressesLegacyUncompressedCommand() {
var executions = new AtomicInteger();
var registry = registry(executions);
try (var interceptor = new GameCommandFrameInterceptor(
registry,
null,
1024,
MinecraftProtocolProfile.forVersion(MinecraftProtocolProfile.PROTOCOL_1_7_10))) {
var frame = commandFrame(0x01, "server survival-2");
var result = interceptor.intercept(UnpooledByteBufAllocator.DEFAULT, frame, false, -1, source());
assertFalse(result.forward());
assertEquals(1, executions.get());
frame.release();
}
}
@Test
void forwardsLegacyPacketIdOnModernProfile() {
var executions = new AtomicInteger();
var registry = registry(executions);
try (var interceptor = new GameCommandFrameInterceptor(registry, null, 1024)) {
var frame = commandFrame(0x01, "server survival-2");
var result = interceptor.intercept(UnpooledByteBufAllocator.DEFAULT, frame, false, -1, source());
assertTrue(result.forward());
assertEquals(0, executions.get());
assertEquals(frame.readableBytes(), result.message().readableBytes());
result.message().release();
}
}
@Test
void forwardsUnknownUncompressedCommand() {
var registry = registry(new AtomicInteger());
@@ -49,6 +84,24 @@ final class GameCommandFrameInterceptorTest {
}
}
@Test
void forwardsUnknownLegacyUncompressedCommand() {
var registry = registry(new AtomicInteger());
try (var interceptor = new GameCommandFrameInterceptor(
registry,
null,
1024,
MinecraftProtocolProfile.forVersion(MinecraftProtocolProfile.PROTOCOL_1_7_10))) {
var frame = commandFrame(0x01, "spawn");
var result = interceptor.intercept(UnpooledByteBufAllocator.DEFAULT, frame, false, -1, source());
assertTrue(result.forward());
assertEquals(frame.readableBytes(), result.message().readableBytes());
result.message().release();
}
}
@Test
void suppressesRegisteredCompressedCommand() {
var executions = new AtomicInteger();
@@ -0,0 +1,99 @@
package dev.strataproxy.network;
import dev.strataproxy.codec.minecraft.MinecraftVarInts;
import io.netty.buffer.ByteBuf;
import io.netty.buffer.Unpooled;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
final class MinecraftCompressionNegotiationDetectorTest {
@Test
void legacy1710DoesNotNegotiateVanillaLoginCompression() {
var detector = new MinecraftCompressionNegotiationDetector(
4096,
MinecraftProtocolProfile.forVersion(MinecraftProtocolProfile.PROTOCOL_1_7_10));
var playSetCompressionLikeFrame = frame(packetWithVarInt(0x46, 256));
try {
assertTrue(detector.complete());
assertTrue(detector.observe(playSetCompressionLikeFrame).isEmpty());
} finally {
playSetCompressionLikeFrame.release();
detector.close();
}
}
@Test
void legacy18NegotiatesLoginCompressionBeforeLoginSuccess() {
var detector = new MinecraftCompressionNegotiationDetector(
4096,
MinecraftProtocolProfile.forVersion(MinecraftProtocolProfile.PROTOCOL_1_8));
var setCompression = frame(packetWithVarInt(0x03, 256));
try {
var threshold = detector.observe(setCompression);
assertTrue(threshold.isPresent());
assertTrue(detector.complete());
} finally {
setCompression.release();
detector.close();
}
}
@Test
void legacy18StopsProbingAfterLoginSuccess() {
var detector = new MinecraftCompressionNegotiationDetector(
4096,
MinecraftProtocolProfile.forVersion(MinecraftProtocolProfile.PROTOCOL_1_8));
var loginSuccess = frame(packetWithVarInt(0x02));
try {
assertTrue(detector.observe(loginSuccess).isEmpty());
assertTrue(detector.complete());
} finally {
loginSuccess.release();
detector.close();
}
}
@Test
void legacy18WaitsForSplitCompressionFrame() {
var detector = new MinecraftCompressionNegotiationDetector(
4096,
MinecraftProtocolProfile.forVersion(MinecraftProtocolProfile.PROTOCOL_1_8));
var setCompression = frame(packetWithVarInt(0x03, 512));
var first = setCompression.readRetainedSlice(1);
var second = setCompression.readRetainedSlice(setCompression.readableBytes());
try {
assertTrue(detector.observe(first).isEmpty());
assertFalse(detector.complete());
var threshold = detector.observe(second);
assertTrue(threshold.isPresent());
assertTrue(detector.complete());
} finally {
first.release();
second.release();
setCompression.release();
detector.close();
}
}
private static ByteBuf packetWithVarInt(int packetId, int... values) {
var packet = Unpooled.buffer();
MinecraftVarInts.write(packet, packetId);
for (var value : values) {
MinecraftVarInts.write(packet, value);
}
return packet;
}
private static ByteBuf frame(ByteBuf packet) {
var frame = Unpooled.buffer();
MinecraftVarInts.write(frame, packet.readableBytes());
frame.writeBytes(packet, packet.readerIndex(), packet.readableBytes());
packet.release();
return frame;
}
}
@@ -0,0 +1,81 @@
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.assertThrows;
final class MinecraftCustomPayloadBodyCodecTest {
@Test
void readsRemainingBytesBody() {
var buffer = Unpooled.wrappedBuffer(bytes("abc"));
var body = MinecraftCustomPayloadBodyCodec.readBody(
buffer,
MinecraftProtocolProfile.CustomPayloadLengthFormat.REMAINING_BYTES);
assertEquals("abc", body.toString(StandardCharsets.UTF_8));
assertEquals(0, buffer.readableBytes());
}
@Test
void readsUnsignedShortBody() {
var buffer = Unpooled.buffer();
buffer.writeShort(3);
buffer.writeBytes(bytes("abc"));
buffer.writeByte(7);
var body = MinecraftCustomPayloadBodyCodec.readBody(
buffer,
MinecraftProtocolProfile.CustomPayloadLengthFormat.UNSIGNED_SHORT);
assertEquals("abc", body.toString(StandardCharsets.UTF_8));
assertEquals(1, buffer.readableBytes());
assertEquals(7, buffer.readUnsignedByte());
}
@Test
void readsVarShortBody() {
var buffer = Unpooled.buffer();
MinecraftCustomPayloadBodyCodec.writeLength(
buffer,
MinecraftProtocolProfile.CustomPayloadLengthFormat.VARSHORT,
0x8001);
buffer.writeZero(0x8001);
var body = MinecraftCustomPayloadBodyCodec.readBody(
buffer,
MinecraftProtocolProfile.CustomPayloadLengthFormat.VARSHORT);
assertEquals(0x8001, body.readableBytes());
assertEquals(0, buffer.readableBytes());
}
@Test
void rejectsOversizedUnsignedShortLength() {
var buffer = Unpooled.buffer();
assertThrows(IllegalArgumentException.class, () -> MinecraftCustomPayloadBodyCodec.writeLength(
buffer,
MinecraftProtocolProfile.CustomPayloadLengthFormat.UNSIGNED_SHORT,
0x1_0000));
}
@Test
void rejectsTruncatedLengthPrefixedBody() {
var buffer = Unpooled.buffer();
buffer.writeShort(4);
buffer.writeBytes(bytes("abc"));
assertThrows(IllegalArgumentException.class, () -> MinecraftCustomPayloadBodyCodec.readBody(
buffer,
MinecraftProtocolProfile.CustomPayloadLengthFormat.UNSIGNED_SHORT));
}
private static byte[] bytes(String value) {
return value.getBytes(StandardCharsets.UTF_8);
}
}
@@ -0,0 +1,780 @@
package dev.strataproxy.network;
import dev.strataproxy.codec.minecraft.MinecraftCompressionCodec;
import dev.strataproxy.codec.minecraft.MinecraftVarInts;
import dev.strataproxy.observability.ProxyMetrics;
import io.netty.buffer.ByteBuf;
import io.netty.buffer.Unpooled;
import io.netty.buffer.UnpooledByteBufAllocator;
import io.netty.channel.embedded.EmbeddedChannel;
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 MinecraftForgeHandshakeTrackerTest {
@Test
void backendRelaySendsResetBeforeNewForgeServerRegistrationWhenPreviousHandshakeCompleted() {
var profile = MinecraftProtocolProfile.forVersion(MinecraftProtocolProfile.PROTOCOL_1_7_10);
var tracker = new MinecraftForgeHandshakeTracker(4096, profile, true);
var frontend = new EmbeddedChannel();
var backend = new EmbeddedChannel(new BackendRelayHandler(
frontend,
new ProxyMetrics(),
"forge-1",
4096,
new MinecraftCompressionAuditState(4096),
CompressionRuntime.defaults(),
new RelaySessionIdentity("127.0.0.1:50000"),
MinecraftForwardingRuntime.none(),
false,
25,
profile,
null,
tracker));
backend.writeInbound(customPayload(0x3F, "REGISTER", registerPayload(), true));
var reset = (ByteBuf) frontend.readOutbound();
var register = (ByteBuf) frontend.readOutbound();
try {
assertForgeReset(reset);
assertEquals(0x3F, packetId(register));
} finally {
release(reset);
release(register);
backend.finishAndReleaseAll();
frontend.finishAndReleaseAll();
tracker.close();
}
}
@Test
void backendRelaySendsCompressedResetBeforeNew18ForgeServerRegistrationWhenCompressionNegotiated() {
var profile = MinecraftProtocolProfile.forVersion(MinecraftProtocolProfile.PROTOCOL_1_8);
var tracker = new MinecraftForgeHandshakeTracker(4096, profile, true);
var compression = new MinecraftCompressionAuditState(4096);
compression.negotiate(0);
var frontend = new EmbeddedChannel();
var backend = new EmbeddedChannel(new BackendRelayHandler(
frontend,
new ProxyMetrics(),
"forge-1",
4096,
compression,
CompressionRuntime.defaults(),
new RelaySessionIdentity("127.0.0.1:50000"),
MinecraftForwardingRuntime.none(),
false,
25,
profile,
null,
tracker));
backend.writeInbound(compressedCustomPayload18(0x3F, "REGISTER", registerPayload(), 0));
var reset = (ByteBuf) frontend.readOutbound();
var register = (ByteBuf) frontend.readOutbound();
try (var codec = new MinecraftCompressionCodec()) {
assertCompressedForgeReset(codec, reset, 0);
assertCompressedCustomPayload18(codec, register, 0, 0x3F, "REGISTER", registerPayloadString());
} finally {
release(reset);
release(register);
backend.finishAndReleaseAll();
frontend.finishAndReleaseAll();
tracker.close();
}
}
@Test
void tracksLegacyForgeHandshakeToComplete() {
var tracker = tracker();
try {
assertEvent(tracker, MinecraftForgeHandshakeTracker.Direction.CLIENTBOUND,
customPayload(0x3F, "REGISTER", registerPayload(), true), "register");
assertEvent(tracker, MinecraftForgeHandshakeTracker.Direction.CLIENTBOUND,
customPayload(0x3F, "FML|HS", bytes(0, 2, 0, 0, 0, 0), true), "server_hello");
assertEvent(tracker, MinecraftForgeHandshakeTracker.Direction.SERVERBOUND,
customPayload(0x17, "REGISTER", registerPayload(), false), "register");
assertEvent(tracker, MinecraftForgeHandshakeTracker.Direction.SERVERBOUND,
customPayload(0x17, "FML|HS", bytes(1, 2), false), "client_hello");
assertEvent(tracker, MinecraftForgeHandshakeTracker.Direction.SERVERBOUND,
customPayload(0x17, "FML|HS", modListPayload(), false), "mod_list");
assertEvent(tracker, MinecraftForgeHandshakeTracker.Direction.CLIENTBOUND,
customPayload(0x3F, "FML|HS", modListPayload(), true), "mod_list");
assertEquals(1, tracker.clientModCount());
assertEquals("9.05", tracker.clientMods().get("mcp"));
assertEquals(1, tracker.serverModCount());
assertEquals("9.05", tracker.serverMods().get("mcp"));
assertEvent(tracker, MinecraftForgeHandshakeTracker.Direction.SERVERBOUND,
customPayload(0x17, "FML|HS", bytes(255, 2), false), "handshake_ack");
assertEvent(tracker, MinecraftForgeHandshakeTracker.Direction.CLIENTBOUND,
customPayload(0x3F, "FML|HS", bytes(3, 0), true), "registry_data");
assertEvent(tracker, MinecraftForgeHandshakeTracker.Direction.SERVERBOUND,
customPayload(0x17, "FML|HS", bytes(255, 3), false), "handshake_ack");
assertEvent(tracker, MinecraftForgeHandshakeTracker.Direction.CLIENTBOUND,
customPayload(0x3F, "FML|HS", bytes(255, 2), true), "handshake_ack");
assertEvent(tracker, MinecraftForgeHandshakeTracker.Direction.SERVERBOUND,
customPayload(0x17, "FML|HS", bytes(255, 4), false), "handshake_ack");
assertEvent(tracker, MinecraftForgeHandshakeTracker.Direction.CLIENTBOUND,
customPayload(0x3F, "FML|HS", bytes(255, 3), true), "handshake_ack");
assertEquals(MinecraftForgeHandshakeTracker.BackendPhase.COMPLETE, tracker.backendPhase());
assertEquals(MinecraftForgeHandshakeTracker.ClientPhase.PENDING_COMPLETE, tracker.clientPhase());
assertFalse(tracker.complete());
assertEvent(tracker, MinecraftForgeHandshakeTracker.Direction.SERVERBOUND,
customPayload(0x17, "FML|HS", bytes(255, 5), false), "handshake_ack");
assertEquals(MinecraftForgeHandshakeTracker.Stage.COMPLETE, tracker.stage());
assertEquals(MinecraftForgeHandshakeTracker.ClientPhase.COMPLETE, tracker.clientPhase());
assertEquals(MinecraftForgeHandshakeTracker.BackendPhase.COMPLETE, tracker.backendPhase());
assertTrue(tracker.complete());
} finally {
tracker.close();
}
}
@Test
void tracksCompressedLegacyForgeHandshakeToComplete() {
var profile = MinecraftProtocolProfile.forVersion(MinecraftProtocolProfile.PROTOCOL_1_8);
var tracker = new MinecraftForgeHandshakeTracker(4096, profile);
try {
assertCompressedEvent(tracker, MinecraftForgeHandshakeTracker.Direction.CLIENTBOUND,
compressedCustomPayload18(0x3F, "REGISTER", registerPayload(), 0), "register");
assertCompressedEvent(tracker, MinecraftForgeHandshakeTracker.Direction.CLIENTBOUND,
compressedCustomPayload18(0x3F, "FML|HS", bytes(0, 2, 0, 0, 0, 0), 0), "server_hello");
assertCompressedEvent(tracker, MinecraftForgeHandshakeTracker.Direction.SERVERBOUND,
compressedCustomPayload18(0x17, "REGISTER", registerPayload(), 0), "register");
assertCompressedEvent(tracker, MinecraftForgeHandshakeTracker.Direction.SERVERBOUND,
compressedCustomPayload18(0x17, "FML|HS", bytes(1, 2), 0), "client_hello");
assertCompressedEvent(tracker, MinecraftForgeHandshakeTracker.Direction.SERVERBOUND,
compressedCustomPayload18(0x17, "FML|HS", modListPayload(), 0), "mod_list");
assertCompressedEvent(tracker, MinecraftForgeHandshakeTracker.Direction.CLIENTBOUND,
compressedCustomPayload18(0x3F, "FML|HS", modListPayload(), 0), "mod_list");
assertCompressedEvent(tracker, MinecraftForgeHandshakeTracker.Direction.SERVERBOUND,
compressedCustomPayload18(0x17, "FML|HS", bytes(255, 2), 0), "handshake_ack");
assertCompressedEvent(tracker, MinecraftForgeHandshakeTracker.Direction.CLIENTBOUND,
compressedCustomPayload18(0x3F, "FML|HS", bytes(3, 0), 0), "registry_data");
assertCompressedEvent(tracker, MinecraftForgeHandshakeTracker.Direction.SERVERBOUND,
compressedCustomPayload18(0x17, "FML|HS", bytes(255, 3), 0), "handshake_ack");
assertCompressedEvent(tracker, MinecraftForgeHandshakeTracker.Direction.CLIENTBOUND,
compressedCustomPayload18(0x3F, "FML|HS", bytes(255, 2), 0), "handshake_ack");
assertCompressedEvent(tracker, MinecraftForgeHandshakeTracker.Direction.SERVERBOUND,
compressedCustomPayload18(0x17, "FML|HS", bytes(255, 4), 0), "handshake_ack");
assertCompressedEvent(tracker, MinecraftForgeHandshakeTracker.Direction.CLIENTBOUND,
compressedCustomPayload18(0x3F, "FML|HS", bytes(255, 3), 0), "handshake_ack");
assertCompressedEvent(tracker, MinecraftForgeHandshakeTracker.Direction.SERVERBOUND,
compressedCustomPayload18(0x17, "FML|HS", bytes(255, 5), 0), "handshake_ack");
assertEquals(MinecraftForgeHandshakeTracker.Stage.COMPLETE, tracker.stage());
assertEquals(MinecraftForgeHandshakeTracker.ClientPhase.COMPLETE, tracker.clientPhase());
assertEquals(MinecraftForgeHandshakeTracker.BackendPhase.COMPLETE, tracker.backendPhase());
assertTrue(tracker.complete());
assertFalse(tracker.backendSwitchBlocked());
} finally {
tracker.close();
}
}
@Test
void tracksMultipartRegistryData() {
var tracker = tracker();
try {
advanceToWaitingServerData(tracker);
assertEvent(tracker, MinecraftForgeHandshakeTracker.Direction.CLIENTBOUND,
customPayload(0x3F, "FML|MP", multipartStartPayload("FML|HS", 2, 6), true),
"multipart_start");
var events = tracker.observe(MinecraftForgeHandshakeTracker.Direction.CLIENTBOUND,
customPayload(0x3F, "FML|MP", multipartPartPayload(0, 3, 1, 2), true));
assertEquals(2, events.size());
assertEquals("multipart_part", events.get(0).type());
assertTrue(events.get(0).expected());
assertEquals("registry_data_multipart", events.get(1).type());
assertTrue(events.get(1).expected());
assertEquals(MinecraftForgeHandshakeTracker.Stage.REGISTRY_DATA, tracker.stage());
assertEvent(tracker, MinecraftForgeHandshakeTracker.Direction.CLIENTBOUND,
customPayload(0x3F, "FML|MP", multipartPartPayload(1, 3, 4, 5), true),
"multipart_part", "multipart_complete");
assertEquals(1, tracker.clientboundRegistryPackets());
assertEquals(5, tracker.clientboundRegistryBytes());
} finally {
tracker.close();
}
}
@Test
void resetAllowsHandshakeToRestart() {
var tracker = tracker();
try {
assertEvent(tracker, MinecraftForgeHandshakeTracker.Direction.CLIENTBOUND,
customPayload(0x3F, "FML|HS", bytes(254, 0), true), "handshake_reset");
assertEquals(MinecraftForgeHandshakeTracker.Stage.RESET, tracker.stage());
assertEquals(MinecraftForgeHandshakeTracker.ClientPhase.RESET, tracker.clientPhase());
assertEquals(MinecraftForgeHandshakeTracker.BackendPhase.RESET, tracker.backendPhase());
assertEvent(tracker, MinecraftForgeHandshakeTracker.Direction.CLIENTBOUND,
customPayload(0x3F, "FML|HS", bytes(0, 2, 0, 0, 0, 0), true), "server_hello");
assertEquals(MinecraftForgeHandshakeTracker.Stage.SERVER_HELLO, tracker.stage());
assertEquals(MinecraftForgeHandshakeTracker.ClientPhase.SERVER_HELLO_RECEIVED, tracker.clientPhase());
assertEquals(MinecraftForgeHandshakeTracker.BackendPhase.SERVER_HELLO_SENT, tracker.backendPhase());
} finally {
tracker.close();
}
}
@Test
void staleAckAfterResetDoesNotAdvanceHandshake() {
var tracker = tracker();
try {
assertEvent(tracker, MinecraftForgeHandshakeTracker.Direction.CLIENTBOUND,
customPayload(0x3F, "FML|HS", bytes(254, 0), true), "handshake_reset");
assertUnexpectedEvent(tracker, MinecraftForgeHandshakeTracker.Direction.SERVERBOUND,
customPayload(0x17, "FML|HS", bytes(255, 5), false), "handshake_ack");
assertEquals(MinecraftForgeHandshakeTracker.Stage.RESET, tracker.stage());
assertEquals(MinecraftForgeHandshakeTracker.ClientPhase.RESET, tracker.clientPhase());
assertEquals(MinecraftForgeHandshakeTracker.BackendPhase.RESET, tracker.backendPhase());
assertFalse(tracker.complete());
assertFalse(tracker.backendSwitchBlocked());
assertEvent(tracker, MinecraftForgeHandshakeTracker.Direction.CLIENTBOUND,
customPayload(0x3F, "FML|HS", bytes(0, 2, 0, 0, 0, 0), true), "server_hello");
assertEquals(MinecraftForgeHandshakeTracker.Stage.SERVER_HELLO, tracker.stage());
} finally {
tracker.close();
}
}
@Test
void resetClearsPreviousForgeHandshakeData() {
var tracker = tracker();
try {
advanceToComplete(tracker);
assertEquals(1, tracker.clientModCount());
assertEquals(1, tracker.serverModCount());
assertEquals(1, tracker.clientboundRegistryPackets());
assertTrue(tracker.clientboundRegistryBytes() > 0);
assertEvent(tracker, MinecraftForgeHandshakeTracker.Direction.CLIENTBOUND,
customPayload(0x3F, "FML|HS", bytes(254, 0), true), "handshake_reset");
assertEquals(MinecraftForgeHandshakeTracker.Stage.RESET, tracker.stage());
assertEquals(0, tracker.clientModCount());
assertEquals(0, tracker.serverModCount());
assertEquals(0, tracker.clientboundRegistryPackets());
assertEquals(0, tracker.clientboundRegistryBytes());
assertTrue(tracker.clientMods().isEmpty());
assertTrue(tracker.serverMods().isEmpty());
assertFalse(tracker.backendSwitchBlocked());
} finally {
tracker.close();
}
}
@Test
void resetDropsStaleServerboundPartialFrame() {
var tracker = tracker();
try {
var stalePartial = Unpooled.wrappedBuffer(new byte[] {0x7F});
try {
assertTrue(tracker.observe(MinecraftForgeHandshakeTracker.Direction.SERVERBOUND, stalePartial).isEmpty());
} finally {
stalePartial.release();
}
assertEvent(tracker, MinecraftForgeHandshakeTracker.Direction.CLIENTBOUND,
customPayload(0x3F, "FML|HS", bytes(254, 0), true), "handshake_reset");
assertEvent(tracker, MinecraftForgeHandshakeTracker.Direction.SERVERBOUND,
customPayload(0x17, "REGISTER", registerPayload(), false), "register");
assertEquals(MinecraftForgeHandshakeTracker.Stage.CHANNELS_REGISTERED, tracker.stage());
assertEquals(MinecraftForgeHandshakeTracker.ClientPhase.CHANNELS_REGISTERED, tracker.clientPhase());
} finally {
tracker.close();
}
}
@Test
void proxyInjectedResetClearsTrackerStateAndAllowsRestart() {
var profile = MinecraftProtocolProfile.forVersion(MinecraftProtocolProfile.PROTOCOL_1_7_10);
var tracker = new MinecraftForgeHandshakeTracker(4096, profile, true);
try {
assertEvent(tracker, MinecraftForgeHandshakeTracker.Direction.CLIENTBOUND,
customPayload(0x3F, "REGISTER", registerPayload(), true), "register");
assertTrue(tracker.consumeResetRequiredOnNextForgeServer());
tracker.resetHandshakeFromProxy();
assertEquals(MinecraftForgeHandshakeTracker.Stage.RESET, tracker.stage());
assertEquals(MinecraftForgeHandshakeTracker.ClientPhase.RESET, tracker.clientPhase());
assertEquals(MinecraftForgeHandshakeTracker.BackendPhase.RESET, tracker.backendPhase());
assertFalse(tracker.backendSwitchBlocked());
assertEvent(tracker, MinecraftForgeHandshakeTracker.Direction.CLIENTBOUND,
customPayload(0x3F, "FML|HS", bytes(0, 2, 0, 0, 0, 0), true), "server_hello");
assertEquals(MinecraftForgeHandshakeTracker.Stage.SERVER_HELLO, tracker.stage());
assertEquals(MinecraftForgeHandshakeTracker.ClientPhase.SERVER_HELLO_RECEIVED, tracker.clientPhase());
assertEquals(MinecraftForgeHandshakeTracker.BackendPhase.SERVER_HELLO_SENT, tracker.backendPhase());
} finally {
tracker.close();
}
}
@Test
void staleAckAfterProxyInjectedResetDoesNotAdvanceHandshake() {
var profile = MinecraftProtocolProfile.forVersion(MinecraftProtocolProfile.PROTOCOL_1_7_10);
var tracker = new MinecraftForgeHandshakeTracker(4096, profile, true);
try {
assertEvent(tracker, MinecraftForgeHandshakeTracker.Direction.CLIENTBOUND,
customPayload(0x3F, "REGISTER", registerPayload(), true), "register");
assertTrue(tracker.consumeResetRequiredOnNextForgeServer());
tracker.resetHandshakeFromProxy();
assertUnexpectedEvent(tracker, MinecraftForgeHandshakeTracker.Direction.SERVERBOUND,
customPayload(0x17, "FML|HS", bytes(255, 5), false), "handshake_ack");
assertEquals(MinecraftForgeHandshakeTracker.Stage.RESET, tracker.stage());
assertEquals(MinecraftForgeHandshakeTracker.ClientPhase.RESET, tracker.clientPhase());
assertEquals(MinecraftForgeHandshakeTracker.BackendPhase.RESET, tracker.backendPhase());
assertFalse(tracker.complete());
assertFalse(tracker.backendSwitchBlocked());
assertEvent(tracker, MinecraftForgeHandshakeTracker.Direction.CLIENTBOUND,
customPayload(0x3F, "FML|HS", bytes(0, 2, 0, 0, 0, 0), true), "server_hello");
assertEquals(MinecraftForgeHandshakeTracker.Stage.SERVER_HELLO, tracker.stage());
} finally {
tracker.close();
}
}
@Test
void proxyInjectedResetDropsStaleClientboundPartialFrame() {
var profile = MinecraftProtocolProfile.forVersion(MinecraftProtocolProfile.PROTOCOL_1_7_10);
var tracker = new MinecraftForgeHandshakeTracker(4096, profile, true);
try {
var stalePartial = Unpooled.wrappedBuffer(new byte[] {0x7F});
try {
assertTrue(tracker.observe(MinecraftForgeHandshakeTracker.Direction.CLIENTBOUND, stalePartial).isEmpty());
} finally {
stalePartial.release();
}
tracker.resetHandshakeFromProxy();
assertEvent(tracker, MinecraftForgeHandshakeTracker.Direction.CLIENTBOUND,
customPayload(0x3F, "FML|HS", bytes(0, 2, 0, 0, 0, 0), true), "server_hello");
assertEquals(MinecraftForgeHandshakeTracker.Stage.SERVER_HELLO, tracker.stage());
} finally {
tracker.close();
}
}
@Test
void registerChannelMatchingIsExact() {
var tracker = tracker();
try {
assertEvent(tracker, MinecraftForgeHandshakeTracker.Direction.CLIENTBOUND,
customPayload(0x3F, "REGISTER", registerPayload("NOT_FML|HS_SUFFIX\0OTHER"), true),
"register_other");
assertEquals(MinecraftForgeHandshakeTracker.Stage.IDLE, tracker.stage());
assertEquals(MinecraftForgeHandshakeTracker.ClientPhase.NOT_STARTED, tracker.clientPhase());
assertEquals(MinecraftForgeHandshakeTracker.BackendPhase.NOT_STARTED, tracker.backendPhase());
assertFalse(tracker.backendSwitchBlocked());
} finally {
tracker.close();
}
}
@Test
void blocksBackendSwitchUntilBothForgePhasesComplete() {
var tracker = tracker();
try {
assertFalse(tracker.backendSwitchBlocked());
assertEvent(tracker, MinecraftForgeHandshakeTracker.Direction.CLIENTBOUND,
customPayload(0x3F, "REGISTER", registerPayload(), true), "register");
assertTrue(tracker.backendSwitchBlocked());
advanceToComplete(tracker);
assertFalse(tracker.backendSwitchBlocked());
} finally {
tracker.close();
}
}
@Test
void clientOnlyForgeChannelRegistrationDoesNotBlockBackendSwitch() {
var tracker = tracker();
try {
assertEvent(tracker, MinecraftForgeHandshakeTracker.Direction.SERVERBOUND,
customPayload(0x17, "REGISTER", registerPayload(), false), "register");
assertEquals(MinecraftForgeHandshakeTracker.Stage.CHANNELS_REGISTERED, tracker.stage());
assertEquals(MinecraftForgeHandshakeTracker.ClientPhase.CHANNELS_REGISTERED, tracker.clientPhase());
assertEquals(MinecraftForgeHandshakeTracker.BackendPhase.NOT_STARTED, tracker.backendPhase());
assertFalse(tracker.backendSwitchBlocked());
} finally {
tracker.close();
}
}
@Test
void unexpectedServerboundModListDoesNotAdvanceHandshake() {
var tracker = tracker();
try {
assertUnexpectedEvent(tracker, MinecraftForgeHandshakeTracker.Direction.SERVERBOUND,
customPayload(0x17, "FML|HS", modListPayload(), false), "mod_list");
assertEquals(MinecraftForgeHandshakeTracker.Stage.IDLE, tracker.stage());
assertEquals(MinecraftForgeHandshakeTracker.ClientPhase.NOT_STARTED, tracker.clientPhase());
assertEquals(MinecraftForgeHandshakeTracker.BackendPhase.NOT_STARTED, tracker.backendPhase());
assertEquals(0, tracker.clientModCount());
assertFalse(tracker.complete());
assertFalse(tracker.backendSwitchBlocked());
} finally {
tracker.close();
}
}
@Test
void unexpectedFinalClientAckDoesNotCompleteHandshake() {
var tracker = tracker();
try {
assertUnexpectedEvent(tracker, MinecraftForgeHandshakeTracker.Direction.SERVERBOUND,
customPayload(0x17, "FML|HS", bytes(255, 5), false), "handshake_ack");
assertEquals(MinecraftForgeHandshakeTracker.Stage.IDLE, tracker.stage());
assertEquals(MinecraftForgeHandshakeTracker.ClientPhase.NOT_STARTED, tracker.clientPhase());
assertEquals(MinecraftForgeHandshakeTracker.BackendPhase.NOT_STARTED, tracker.backendPhase());
assertFalse(tracker.complete());
assertFalse(tracker.backendSwitchBlocked());
} finally {
tracker.close();
}
}
@Test
void unexpectedBackendCompleteAckDoesNotAdvanceBackendPhase() {
var tracker = tracker();
try {
advanceToWaitingServerData(tracker);
assertUnexpectedEvent(tracker, MinecraftForgeHandshakeTracker.Direction.CLIENTBOUND,
customPayload(0x3F, "FML|HS", bytes(255, 3), true), "handshake_ack");
assertEquals(MinecraftForgeHandshakeTracker.Stage.WAITING_SERVER_DATA, tracker.stage());
assertEquals(MinecraftForgeHandshakeTracker.BackendPhase.MOD_LIST_SENT, tracker.backendPhase());
assertFalse(tracker.complete());
assertTrue(tracker.backendSwitchBlocked());
} finally {
tracker.close();
}
}
private static MinecraftForgeHandshakeTracker tracker() {
return new MinecraftForgeHandshakeTracker(
4096,
MinecraftProtocolProfile.forVersion(MinecraftProtocolProfile.PROTOCOL_1_7_10));
}
private static void advanceToWaitingServerData(MinecraftForgeHandshakeTracker tracker) {
assertEvent(tracker, MinecraftForgeHandshakeTracker.Direction.CLIENTBOUND,
customPayload(0x3F, "REGISTER", registerPayload(), true), "register");
assertEvent(tracker, MinecraftForgeHandshakeTracker.Direction.CLIENTBOUND,
customPayload(0x3F, "FML|HS", bytes(0, 2, 0, 0, 0, 0), true), "server_hello");
assertEvent(tracker, MinecraftForgeHandshakeTracker.Direction.SERVERBOUND,
customPayload(0x17, "REGISTER", registerPayload(), false), "register");
assertEvent(tracker, MinecraftForgeHandshakeTracker.Direction.SERVERBOUND,
customPayload(0x17, "FML|HS", bytes(1, 2), false), "client_hello");
assertEvent(tracker, MinecraftForgeHandshakeTracker.Direction.SERVERBOUND,
customPayload(0x17, "FML|HS", modListPayload(), false), "mod_list");
assertEvent(tracker, MinecraftForgeHandshakeTracker.Direction.CLIENTBOUND,
customPayload(0x3F, "FML|HS", modListPayload(), true), "mod_list");
assertEvent(tracker, MinecraftForgeHandshakeTracker.Direction.SERVERBOUND,
customPayload(0x17, "FML|HS", bytes(255, 2), false), "handshake_ack");
assertEquals(MinecraftForgeHandshakeTracker.Stage.WAITING_SERVER_DATA, tracker.stage());
}
static void advanceToComplete(MinecraftForgeHandshakeTracker tracker) {
assertEvent(tracker, MinecraftForgeHandshakeTracker.Direction.CLIENTBOUND,
customPayload(0x3F, "FML|HS", bytes(0, 2, 0, 0, 0, 0), true), "server_hello");
assertEvent(tracker, MinecraftForgeHandshakeTracker.Direction.SERVERBOUND,
customPayload(0x17, "REGISTER", registerPayload(), false), "register");
assertEvent(tracker, MinecraftForgeHandshakeTracker.Direction.SERVERBOUND,
customPayload(0x17, "FML|HS", bytes(1, 2), false), "client_hello");
assertEvent(tracker, MinecraftForgeHandshakeTracker.Direction.SERVERBOUND,
customPayload(0x17, "FML|HS", modListPayload(), false), "mod_list");
assertEvent(tracker, MinecraftForgeHandshakeTracker.Direction.CLIENTBOUND,
customPayload(0x3F, "FML|HS", modListPayload(), true), "mod_list");
assertEvent(tracker, MinecraftForgeHandshakeTracker.Direction.SERVERBOUND,
customPayload(0x17, "FML|HS", bytes(255, 2), false), "handshake_ack");
assertEvent(tracker, MinecraftForgeHandshakeTracker.Direction.CLIENTBOUND,
customPayload(0x3F, "FML|HS", bytes(3, 0), true), "registry_data");
assertEvent(tracker, MinecraftForgeHandshakeTracker.Direction.SERVERBOUND,
customPayload(0x17, "FML|HS", bytes(255, 3), false), "handshake_ack");
assertEvent(tracker, MinecraftForgeHandshakeTracker.Direction.CLIENTBOUND,
customPayload(0x3F, "FML|HS", bytes(255, 2), true), "handshake_ack");
assertEvent(tracker, MinecraftForgeHandshakeTracker.Direction.SERVERBOUND,
customPayload(0x17, "FML|HS", bytes(255, 4), false), "handshake_ack");
assertEvent(tracker, MinecraftForgeHandshakeTracker.Direction.CLIENTBOUND,
customPayload(0x3F, "FML|HS", bytes(255, 3), true), "handshake_ack");
assertEvent(tracker, MinecraftForgeHandshakeTracker.Direction.SERVERBOUND,
customPayload(0x17, "FML|HS", bytes(255, 5), false), "handshake_ack");
}
private static void assertEvent(
MinecraftForgeHandshakeTracker tracker,
MinecraftForgeHandshakeTracker.Direction direction,
ByteBuf frame,
String type) {
try {
var events = tracker.observe(direction, frame);
assertEquals(1, events.size());
assertEquals(type, events.get(0).type());
assertTrue(events.get(0).expected(), events.get(0).type() + " should be in the expected order");
} finally {
frame.release();
}
}
private static void assertCompressedEvent(
MinecraftForgeHandshakeTracker tracker,
MinecraftForgeHandshakeTracker.Direction direction,
ByteBuf frame,
String type) {
try {
var events = tracker.observeCompressed(direction, UnpooledByteBufAllocator.DEFAULT, frame, 0);
assertEquals(1, events.size());
assertEquals(type, events.get(0).type());
assertTrue(events.get(0).expected(), events.get(0).type() + " should be in the expected order");
} finally {
frame.release();
}
}
private static void assertUnexpectedEvent(
MinecraftForgeHandshakeTracker tracker,
MinecraftForgeHandshakeTracker.Direction direction,
ByteBuf frame,
String type) {
try {
var events = tracker.observe(direction, frame);
assertEquals(1, events.size());
assertEquals(type, events.get(0).type());
assertFalse(events.get(0).expected(), events.get(0).type() + " should be rejected as out of order");
} finally {
frame.release();
}
}
private static void assertEvent(
MinecraftForgeHandshakeTracker tracker,
MinecraftForgeHandshakeTracker.Direction direction,
ByteBuf frame,
String firstType,
String secondType) {
try {
var events = tracker.observe(direction, frame);
assertEquals(2, events.size());
assertEquals(firstType, events.get(0).type());
assertTrue(events.get(0).expected(), events.get(0).type() + " should be in the expected order");
assertEquals(secondType, events.get(1).type());
assertTrue(events.get(1).expected(), events.get(1).type() + " should be in the expected order");
} finally {
frame.release();
}
}
private static ByteBuf customPayload(int packetId, String channel, ByteBuf payload, boolean clientbound) {
var packet = Unpooled.buffer();
try {
MinecraftVarInts.write(packet, packetId);
writeString(packet, channel);
if (clientbound) {
writeVarShort(packet, payload.readableBytes());
} else {
packet.writeShort(payload.readableBytes());
}
packet.writeBytes(payload, payload.readerIndex(), payload.readableBytes());
var frame = Unpooled.buffer();
MinecraftVarInts.write(frame, packet.readableBytes());
frame.writeBytes(packet, packet.readerIndex(), packet.readableBytes());
return frame;
} finally {
payload.release();
packet.release();
}
}
private static ByteBuf compressedCustomPayload18(int packetId, String channel, ByteBuf payload, int threshold) {
var packet = Unpooled.buffer();
try (var codec = new MinecraftCompressionCodec()) {
MinecraftVarInts.write(packet, packetId);
writeString(packet, channel);
packet.writeBytes(payload, payload.readerIndex(), payload.readableBytes());
return codec.encodeFrame(UnpooledByteBufAllocator.DEFAULT, packet, threshold);
} finally {
payload.release();
packet.release();
}
}
private static ByteBuf registerPayload() {
return registerPayload(registerPayloadString());
}
private static String registerPayloadString() {
return "FML|HS\0FML\0FML|MP\0FORGE";
}
private static ByteBuf registerPayload(String channels) {
return Unpooled.wrappedBuffer(channels.getBytes(StandardCharsets.UTF_8));
}
private static ByteBuf modListPayload() {
var payload = Unpooled.buffer();
payload.writeByte(2);
MinecraftVarInts.write(payload, 1);
writeString(payload, "mcp");
writeString(payload, "9.05");
return payload;
}
private static ByteBuf multipartStartPayload(String wrappedChannel, int parts, int totalLength) {
var payload = Unpooled.buffer();
writeString(payload, wrappedChannel);
payload.writeByte(parts);
payload.writeInt(totalLength);
return payload;
}
private static ByteBuf multipartPartPayload(int part, int... values) {
var payload = Unpooled.buffer(values.length + 1);
payload.writeByte(part);
for (var value : values) {
payload.writeByte(value);
}
return payload;
}
private static ByteBuf bytes(int... values) {
var payload = Unpooled.buffer(values.length);
for (var value : values) {
payload.writeByte(value);
}
return payload;
}
private static void writeString(ByteBuf output, String value) {
var bytes = value.getBytes(StandardCharsets.UTF_8);
MinecraftVarInts.write(output, bytes.length);
output.writeBytes(bytes);
}
private static void writeVarShort(ByteBuf output, int value) {
if ((value & 0xFFFF8000) != 0) {
output.writeShort((value & 0x7FFF) | 0x8000);
output.writeByte(value >>> 15);
} else {
output.writeShort(value);
}
}
private static void assertForgeReset(ByteBuf frame) {
var payload = payload(frame);
try {
assertEquals(0x3F, MinecraftVarInts.read(payload));
assertEquals("FML|HS", readString(payload));
assertEquals(2, readVarShort(payload));
assertEquals(0xFE, payload.readUnsignedByte());
assertEquals(0, payload.readUnsignedByte());
} finally {
payload.release();
}
}
private static void assertCompressedForgeReset(MinecraftCompressionCodec codec, ByteBuf frame, int threshold) {
var payload = codec.decodeFrame(UnpooledByteBufAllocator.DEFAULT, frame, threshold, 4096);
try {
assertEquals(0x3F, MinecraftVarInts.read(payload));
assertEquals("FML|HS", readString(payload));
assertEquals(2, payload.readableBytes());
assertEquals(0xFE, payload.readUnsignedByte());
assertEquals(0, payload.readUnsignedByte());
} finally {
payload.release();
}
}
private static void assertCompressedCustomPayload18(
MinecraftCompressionCodec codec,
ByteBuf frame,
int threshold,
int packetId,
String channel,
String payloadText) {
var payload = codec.decodeFrame(UnpooledByteBufAllocator.DEFAULT, frame, threshold, 4096);
try {
assertEquals(packetId, MinecraftVarInts.read(payload));
assertEquals(channel, readString(payload));
assertEquals(payloadText, payload.readCharSequence(payload.readableBytes(), StandardCharsets.UTF_8).toString());
assertEquals(0, payload.readableBytes());
} finally {
payload.release();
}
}
private static int packetId(ByteBuf frame) {
var payload = payload(frame);
try {
return MinecraftVarInts.read(payload);
} finally {
payload.release();
}
}
private static ByteBuf payload(ByteBuf frame) {
var view = frame.retainedDuplicate();
try {
var length = MinecraftVarInts.read(view);
return view.readRetainedSlice(length);
} finally {
view.release();
}
}
private static String readString(ByteBuf input) {
var length = MinecraftVarInts.read(input);
var value = input.toString(input.readerIndex(), length, StandardCharsets.UTF_8);
input.skipBytes(length);
return value;
}
private static int readVarShort(ByteBuf input) {
var low = input.readUnsignedShort();
var length = low & 0x7FFF;
if ((low & 0x8000) != 0) {
length |= input.readUnsignedByte() << 15;
}
return length;
}
private static void release(ByteBuf buffer) {
if (buffer != null && buffer.refCnt() > 0) {
buffer.release();
}
}
}
@@ -0,0 +1,318 @@
package dev.strataproxy.network;
import dev.strataproxy.codec.minecraft.MinecraftCompressionCodec;
import dev.strataproxy.codec.minecraft.MinecraftVarInts;
import io.netty.buffer.ByteBuf;
import io.netty.buffer.Unpooled;
import io.netty.buffer.UnpooledByteBufAllocator;
import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
import java.util.UUID;
import static org.junit.jupiter.api.Assertions.assertEquals;
final class MinecraftLegacyClientStateTrackerTest {
@Test
void tracksAndClears1710ObjectivesAndTeams() {
var profile = MinecraftProtocolProfile.forVersion(MinecraftProtocolProfile.PROTOCOL_1_7_10);
var tracker = new MinecraftLegacyClientStateTracker(4096);
var frames = combine(playerList1710("Alex", true), objective1710("sidebar", 0), team("red", 0));
try {
tracker.observe(frames, profile);
assertEquals(1, tracker.playerListCount());
assertEquals(1, tracker.objectiveCount());
assertEquals(1, tracker.teamCount());
var cleanup = tracker.clearFrames(UnpooledByteBufAllocator.DEFAULT, profile);
try {
var playerList = payload(cleanup);
try {
assertEquals(0x38, MinecraftVarInts.read(playerList));
assertEquals("Alex", readString(playerList));
assertEquals(0, playerList.readUnsignedByte());
assertEquals(0, playerList.readShort());
} finally {
playerList.release();
}
var objective = payload(cleanup);
try {
assertEquals(0x3B, MinecraftVarInts.read(objective));
assertEquals("sidebar", readString(objective));
assertEquals(1, objective.readUnsignedByte());
assertEquals(0, objective.readableBytes());
} finally {
objective.release();
}
var team = payload(cleanup);
try {
assertEquals(0x3E, MinecraftVarInts.read(team));
assertEquals("red", readString(team));
assertEquals(1, team.readUnsignedByte());
} finally {
team.release();
}
assertEquals(0, cleanup.readableBytes());
assertEquals(0, tracker.playerListCount());
assertEquals(0, tracker.objectiveCount());
assertEquals(0, tracker.teamCount());
} finally {
cleanup.release();
}
} finally {
frames.release();
tracker.close();
}
}
@Test
void tracksAndClears18ObjectivesAndTeams() {
var profile = MinecraftProtocolProfile.forVersion(MinecraftProtocolProfile.PROTOCOL_1_8);
var tracker = new MinecraftLegacyClientStateTracker(4096);
var uuid = UUID.fromString("00000000-0000-0000-0000-000000000123");
var frames = combine(playerList18(uuid, 0), objective18("list", 0), team("blue", 0));
try {
tracker.observe(frames, profile);
var cleanup = tracker.clearFrames(UnpooledByteBufAllocator.DEFAULT, profile);
try {
var playerList = payload(cleanup);
try {
assertEquals(0x38, MinecraftVarInts.read(playerList));
assertEquals(4, MinecraftVarInts.read(playerList));
assertEquals(1, MinecraftVarInts.read(playerList));
assertEquals(uuid.getMostSignificantBits(), playerList.readLong());
assertEquals(uuid.getLeastSignificantBits(), playerList.readLong());
} finally {
playerList.release();
}
var objective = payload(cleanup);
try {
assertEquals(0x3B, MinecraftVarInts.read(objective));
assertEquals("list", readString(objective));
assertEquals(1, objective.readUnsignedByte());
} finally {
objective.release();
}
var team = payload(cleanup);
try {
assertEquals(0x3E, MinecraftVarInts.read(team));
assertEquals("blue", readString(team));
assertEquals(1, team.readUnsignedByte());
} finally {
team.release();
}
assertEquals(0, cleanup.readableBytes());
} finally {
cleanup.release();
}
} finally {
frames.release();
tracker.close();
}
}
@Test
void tracksCompressed18PlayerListEntries() {
var profile = MinecraftProtocolProfile.forVersion(MinecraftProtocolProfile.PROTOCOL_1_8);
var uuid = UUID.fromString("00000000-0000-0000-0000-000000000456");
var tracker = new MinecraftLegacyClientStateTracker(4096);
try (var codec = new MinecraftCompressionCodec()) {
var packet = playerList18Packet(uuid, 0);
var compressed = codec.encodeFrame(UnpooledByteBufAllocator.DEFAULT, packet, 0);
packet.release();
try {
tracker.observeCompressed(UnpooledByteBufAllocator.DEFAULT, compressed, 0, profile);
} finally {
compressed.release();
}
var cleanup = tracker.clearFrames(UnpooledByteBufAllocator.DEFAULT, profile);
try {
var playerList = payload(cleanup);
try {
assertEquals(0x38, MinecraftVarInts.read(playerList));
assertEquals(4, MinecraftVarInts.read(playerList));
assertEquals(1, MinecraftVarInts.read(playerList));
assertEquals(uuid.getMostSignificantBits(), playerList.readLong());
assertEquals(uuid.getLeastSignificantBits(), playerList.readLong());
} finally {
playerList.release();
}
assertEquals(0, cleanup.readableBytes());
} finally {
cleanup.release();
}
} finally {
tracker.close();
}
}
@Test
void removePacketsDeleteTrackedState() {
var profile = MinecraftProtocolProfile.forVersion(MinecraftProtocolProfile.PROTOCOL_1_7_10);
var tracker = new MinecraftLegacyClientStateTracker(4096);
var frames = combine(
playerList1710("Alex", true),
objective1710("sidebar", 0),
team("red", 0),
playerList1710("Alex", false),
objective1710("sidebar", 1),
team("red", 1));
try {
tracker.observe(frames, profile);
var cleanup = tracker.clearFrames(UnpooledByteBufAllocator.DEFAULT, profile);
try {
assertEquals(0, cleanup.readableBytes());
assertEquals(0, tracker.playerListCount());
assertEquals(0, tracker.objectiveCount());
assertEquals(0, tracker.teamCount());
} finally {
cleanup.release();
}
} finally {
frames.release();
tracker.close();
}
}
@Test
void clearsTrackedStateWhenPlayerListLimitIsExceeded() {
var profile = MinecraftProtocolProfile.forVersion(MinecraftProtocolProfile.PROTOCOL_1_7_10);
var tracker = new MinecraftLegacyClientStateTracker(4096, 1, 4, 4);
var frames = combine(playerList1710("Alex", true), playerList1710("Steve", true));
try {
tracker.observe(frames, profile);
assertEquals(0, tracker.playerListCount());
var cleanup = tracker.clearFrames(UnpooledByteBufAllocator.DEFAULT, profile);
try {
assertEquals(0, cleanup.readableBytes());
} finally {
cleanup.release();
}
} finally {
frames.release();
tracker.close();
}
}
@Test
void clearsTrackedStateWhenScoreboardLimitIsExceeded() {
var profile = MinecraftProtocolProfile.forVersion(MinecraftProtocolProfile.PROTOCOL_1_7_10);
var tracker = new MinecraftLegacyClientStateTracker(4096, 4, 1, 1);
var frames = combine(
objective1710("one", 0),
objective1710("two", 0),
team("red", 0),
team("blue", 0));
try {
tracker.observe(frames, profile);
assertEquals(0, tracker.objectiveCount());
assertEquals(0, tracker.teamCount());
var cleanup = tracker.clearFrames(UnpooledByteBufAllocator.DEFAULT, profile);
try {
assertEquals(0, cleanup.readableBytes());
} finally {
cleanup.release();
}
} finally {
frames.release();
tracker.close();
}
}
private static ByteBuf playerList1710(String name, boolean online) {
var packet = Unpooled.buffer();
MinecraftVarInts.write(packet, 0x38);
writeString(packet, name);
packet.writeBoolean(online);
packet.writeShort(42);
return frame(packet);
}
private static ByteBuf playerList18(UUID uuid, int action) {
return frame(playerList18Packet(uuid, action));
}
private static ByteBuf playerList18Packet(UUID uuid, int action) {
var packet = Unpooled.buffer();
MinecraftVarInts.write(packet, 0x38);
MinecraftVarInts.write(packet, action);
MinecraftVarInts.write(packet, 1);
packet.writeLong(uuid.getMostSignificantBits());
packet.writeLong(uuid.getLeastSignificantBits());
if (action == 0) {
writeString(packet, "Alex");
MinecraftVarInts.write(packet, 0);
MinecraftVarInts.write(packet, 0);
MinecraftVarInts.write(packet, 42);
packet.writeBoolean(false);
}
return packet;
}
private static ByteBuf objective1710(String name, int mode) {
var packet = Unpooled.buffer();
MinecraftVarInts.write(packet, 0x3B);
writeString(packet, name);
packet.writeByte(mode);
return frame(packet);
}
private static ByteBuf objective18(String name, int mode) {
var packet = Unpooled.buffer();
MinecraftVarInts.write(packet, 0x3B);
writeString(packet, name);
packet.writeByte(mode);
if (mode == 0 || mode == 2) {
writeString(packet, name);
writeString(packet, "integer");
}
return frame(packet);
}
private static ByteBuf team(String name, int mode) {
var packet = Unpooled.buffer();
MinecraftVarInts.write(packet, 0x3E);
writeString(packet, name);
packet.writeByte(mode);
return frame(packet);
}
private static ByteBuf combine(ByteBuf... frames) {
var output = Unpooled.buffer();
for (var frame : frames) {
output.writeBytes(frame, frame.readerIndex(), frame.readableBytes());
frame.release();
}
return output;
}
private static ByteBuf frame(ByteBuf packet) {
var frame = Unpooled.buffer();
MinecraftVarInts.write(frame, packet.readableBytes());
frame.writeBytes(packet, packet.readerIndex(), packet.readableBytes());
packet.release();
return frame;
}
private static ByteBuf payload(ByteBuf frames) {
var length = MinecraftVarInts.read(frames);
return frames.readRetainedSlice(length);
}
private static void writeString(ByteBuf output, String value) {
var bytes = value.getBytes(StandardCharsets.UTF_8);
MinecraftVarInts.write(output, bytes.length);
output.writeBytes(bytes);
}
private static String readString(ByteBuf input) {
var bytes = new byte[MinecraftVarInts.read(input)];
input.readBytes(bytes);
return new String(bytes, StandardCharsets.UTF_8);
}
}
@@ -0,0 +1,102 @@
package dev.strataproxy.network;
import dev.strataproxy.analysis.CustomPayloadAnomalyPolicy;
import dev.strataproxy.codec.minecraft.MinecraftCustomPayloadClassifier;
import dev.strataproxy.codec.minecraft.MinecraftVarInts;
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.assertTrue;
final class MinecraftLegacyCustomPayloadSamplerTest {
@Test
void observesLegacyForgeHandshakePayload() {
var sampler = new MinecraftCustomPayloadInspectionSampler(
4096,
1024,
CustomPayloadAnomalyPolicy.defaults(),
MinecraftProtocolProfile.forVersion(MinecraftProtocolProfile.PROTOCOL_1_7_10));
var frame = legacyCustomPayloadFrame(0x17, "FML|HS", new byte[] {0, 2, 0, 0, 0, 0});
try {
var results = sampler.observe(frame);
assertEquals(1, results.size());
var classification = results.get(0).classification();
assertEquals(0x17, classification.packetId());
assertEquals("FML|HS", classification.channel());
assertEquals(6, classification.payloadBytes());
assertEquals(MinecraftCustomPayloadClassifier.CustomPayloadKind.FORGE_HANDSHAKE, classification.kind());
} finally {
sampler.close();
frame.release();
}
}
@Test
void ignoresLegacyCustomPayloadPacketIdOnModernProfile() {
var sampler = MinecraftCustomPayloadInspectionSampler.defaults(4096);
var frame = legacyCustomPayloadFrame(0x17, "FML|HS", new byte[] {0, 2});
try {
assertTrue(sampler.observe(frame).isEmpty());
} finally {
sampler.close();
frame.release();
}
}
@Test
void parsesLegacyBungeeConnectPayload() {
var sampler = new BungeeConnectRequestSampler(
4096,
MinecraftProtocolProfile.forVersion(MinecraftProtocolProfile.PROTOCOL_1_7_10));
var payload = Unpooled.buffer();
try {
writeUnsignedShortString(payload, "Connect");
writeUnsignedShortString(payload, "survival-1");
var bytes = new byte[payload.readableBytes()];
payload.getBytes(payload.readerIndex(), bytes);
var frame = legacyCustomPayloadFrame(0x17, "BungeeCord", bytes);
try {
var requests = sampler.observeUncompressed(frame);
assertEquals(1, requests.size());
assertEquals(0x17, requests.get(0).packetId());
assertEquals("BungeeCord", requests.get(0).channel());
assertEquals("survival-1", requests.get(0).targetServer());
} finally {
frame.release();
}
} finally {
sampler.close();
payload.release();
}
}
private static io.netty.buffer.ByteBuf legacyCustomPayloadFrame(int packetId, String channel, byte[] payload) {
var packet = Unpooled.buffer();
MinecraftVarInts.write(packet, packetId);
writeMinecraftString(packet, channel);
packet.writeShort(payload.length);
packet.writeBytes(payload);
var frame = Unpooled.buffer();
MinecraftVarInts.write(frame, packet.readableBytes());
frame.writeBytes(packet);
packet.release();
return frame;
}
private static void writeMinecraftString(io.netty.buffer.ByteBuf output, String value) {
var bytes = value.getBytes(StandardCharsets.UTF_8);
MinecraftVarInts.write(output, bytes.length);
output.writeBytes(bytes);
}
private static void writeUnsignedShortString(io.netty.buffer.ByteBuf output, String value) {
var bytes = value.getBytes(StandardCharsets.UTF_8);
output.writeShort(bytes.length);
output.writeBytes(bytes);
}
}
@@ -0,0 +1,70 @@
package dev.strataproxy.network;
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.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
final class MinecraftLoginStartSamplerTest {
@Test
void parsesCompleteLoginStartFromSeededFragments() {
var frame = loginStartFrame("Steve");
var fullFrame = frame.readableBytes();
var first = frame.readRetainedSlice(Math.max(1, fullFrame / 2));
var second = frame.readRetainedSlice(frame.readableBytes());
var sampler = new MinecraftLoginStartSampler(1024, first);
try {
assertEquals(java.util.Optional.empty(), sampler.observe(Unpooled.EMPTY_BUFFER));
assertEquals(java.util.Optional.empty(), sampler.observe(Unpooled.EMPTY_BUFFER));
assertEquals("Steve", sampler.observe(second).orElseThrow());
assertTrue(sampler.observe(Unpooled.EMPTY_BUFFER).isEmpty());
} finally {
frame.release();
first.release();
second.release();
}
}
@Test
void observesLoginStartNormallyWithoutSeed() {
var frame = loginStartFrame("Steve");
var sampler = new MinecraftLoginStartSampler(1024);
try {
assertEquals("Steve", sampler.observe(frame).orElseThrow());
assertEquals(java.util.Optional.empty(), sampler.observe(Unpooled.EMPTY_BUFFER));
assertEquals(false, sampler.observe(Unpooled.EMPTY_BUFFER).isPresent());
} finally {
frame.release();
}
}
private static ByteBuf loginStartFrame(String username) {
var payload = Unpooled.buffer();
writeVarInt(payload, 0);
var usernameBytes = username.getBytes(StandardCharsets.UTF_8);
writeVarInt(payload, usernameBytes.length);
payload.writeBytes(usernameBytes);
writeVarInt(payload, 0);
var frame = Unpooled.buffer();
writeVarInt(frame, payload.readableBytes());
frame.writeBytes(payload);
payload.release();
return frame;
}
private static void writeVarInt(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);
}
}
@@ -0,0 +1,75 @@
package dev.strataproxy.network;
import io.netty.buffer.ByteBuf;
import io.netty.buffer.UnpooledByteBufAllocator;
import org.junit.jupiter.api.Test;
import java.util.Optional;
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 MinecraftPlayMessagesTest {
@Test
void buildsLegacyChatPacketForProtocol5() {
var frame = MinecraftPlayMessages.systemChatFrame(
UnpooledByteBufAllocator.DEFAULT,
5,
"Servers: survival-1",
false,
0);
assertTrue(frame.isPresent());
try {
var decoded = decodeFrame(frame.get());
var packetId = MinecraftProtocolCodec.readVarInt(decoded);
assertEquals(0x02, packetId);
var message = MinecraftProtocolCodec.readString(decoded, 32767);
assertEquals("{\"text\":\"Servers: survival-1\"}", message);
assertFalse(decoded.isReadable());
} finally {
frame.ifPresent(ByteBuf::release);
}
}
@Test
void supportsCurrentProtocolSystemChatPacket() {
var frame = MinecraftPlayMessages.systemChatFrame(
UnpooledByteBufAllocator.DEFAULT,
763,
"ok",
false,
0);
assertTrue(frame.isPresent());
try {
var decoded = decodeFrame(frame.get());
try {
var packetId = MinecraftProtocolCodec.readVarInt(decoded);
assertEquals(0x64, packetId);
assertEquals("{\"text\":\"ok\"}", MinecraftProtocolCodec.readString(decoded, 32767));
assertEquals((byte) 0, decoded.readByte());
assertFalse(decoded.isReadable());
} finally {
decoded.release();
}
} finally {
frame.ifPresent(ByteBuf::release);
}
}
@Test
void ignoresUnsupportedProtocolForSystemChatPacket() {
var frame = MinecraftPlayMessages.systemChatFrame(
UnpooledByteBufAllocator.DEFAULT,
1,
"unsupported",
false,
0);
assertEquals(Optional.empty(), frame);
}
private static ByteBuf decodeFrame(ByteBuf frame) {
var frameLength = MinecraftProtocolCodec.readVarInt(frame);
return frame.readRetainedSlice(frameLength);
}
}
@@ -0,0 +1,193 @@
package dev.strataproxy.network;
import dev.strataproxy.codec.minecraft.MinecraftCompressionCodec;
import dev.strataproxy.codec.minecraft.MinecraftVarInts;
import io.netty.buffer.ByteBuf;
import io.netty.buffer.Unpooled;
import io.netty.buffer.UnpooledByteBufAllocator;
import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
final class MinecraftPluginChannelRegistryTest {
@Test
void tracksServerboundRegisterAndUnregisterChannels() {
var profile = MinecraftProtocolProfile.forVersion(MinecraftProtocolProfile.PROTOCOL_1_7_10);
var registry = new MinecraftPluginChannelRegistry(4096);
try {
var register = customPayload(0x17, "REGISTER", "FML|HS\0FML\0MYMOD");
try {
registry.observeServerbound(register, profile);
} finally {
register.release();
}
assertEquals(Set.of("FML|HS", "FML", "MYMOD"), registry.channels());
var unregister = customPayload(0x17, "UNREGISTER", "MYMOD");
try {
registry.observeServerbound(unregister, profile);
} finally {
unregister.release();
}
assertEquals(Set.of("FML|HS", "FML"), registry.channels());
} finally {
registry.close();
}
}
@Test
void buildsLegacyServerboundRegisterFrameFromCurrentChannels() {
var profile = MinecraftProtocolProfile.forVersion(MinecraftProtocolProfile.PROTOCOL_1_7_10);
var registry = new MinecraftPluginChannelRegistry(4096);
try {
var register = customPayload(0x17, "REGISTER", "FML|HS\0FML\0FML|MP\0FORGE");
try {
registry.observeServerbound(register, profile);
} finally {
register.release();
}
var frame = registry.registrationFrame(UnpooledByteBufAllocator.DEFAULT, profile);
try {
assertServerboundRegister(frame, "FML|HS\0FML\0FML|MP\0FORGE");
} finally {
release(frame);
}
} finally {
registry.close();
}
}
@Test
void observesSplitFrames() {
var profile = MinecraftProtocolProfile.forVersion(MinecraftProtocolProfile.PROTOCOL_1_7_10);
var registry = new MinecraftPluginChannelRegistry(4096);
var frame = customPayload(0x17, "REGISTER", "FML|HS\0FML");
try {
var first = frame.readRetainedSlice(2);
var second = frame.readRetainedSlice(frame.readableBytes());
try {
registry.observeServerbound(first, profile);
assertTrue(registry.channels().isEmpty());
registry.observeServerbound(second, profile);
assertEquals(Set.of("FML|HS", "FML"), registry.channels());
} finally {
release(first);
release(second);
}
} finally {
frame.release();
registry.close();
}
}
@Test
void tracksCompressedServerboundRegisterChannels() {
var profile = MinecraftProtocolProfile.forVersion(MinecraftProtocolProfile.PROTOCOL_1_8);
var registry = new MinecraftPluginChannelRegistry(4096);
try {
var register = compressedCustomPayload18("REGISTER", "FML|HS\0FML\0FML|MP\0FORGE", 0);
try {
registry.observeServerboundCompressed(UnpooledByteBufAllocator.DEFAULT, register, 0, profile);
} finally {
register.release();
}
assertEquals(Set.of("FML|HS", "FML", "FML|MP", "FORGE"), registry.channels());
var frame = registry.registrationFrame(UnpooledByteBufAllocator.DEFAULT, profile);
try {
assertServerboundRegisterRemaining(frame, "FML|HS\0FML\0FML|MP\0FORGE");
} finally {
release(frame);
}
} finally {
registry.close();
}
}
private static ByteBuf customPayload(int packetId, String channel, String payloadText) {
var payload = Unpooled.wrappedBuffer(payloadText.getBytes(StandardCharsets.UTF_8));
var packet = Unpooled.buffer();
try {
MinecraftVarInts.write(packet, packetId);
writeString(packet, channel);
packet.writeShort(payload.readableBytes());
packet.writeBytes(payload, payload.readerIndex(), payload.readableBytes());
return frame(packet);
} finally {
payload.release();
}
}
private static ByteBuf compressedCustomPayload18(String channel, String payloadText, int threshold) {
var payload = Unpooled.wrappedBuffer(payloadText.getBytes(StandardCharsets.UTF_8));
var packet = Unpooled.buffer();
try (var codec = new MinecraftCompressionCodec()) {
MinecraftVarInts.write(packet, 0x17);
writeString(packet, channel);
packet.writeBytes(payload, payload.readerIndex(), payload.readableBytes());
return codec.encodeFrame(UnpooledByteBufAllocator.DEFAULT, packet, threshold);
} finally {
payload.release();
packet.release();
}
}
private static ByteBuf frame(ByteBuf packet) {
var frame = Unpooled.buffer();
MinecraftVarInts.write(frame, packet.readableBytes());
frame.writeBytes(packet, packet.readerIndex(), packet.readableBytes());
packet.release();
return frame;
}
private static void assertServerboundRegister(ByteBuf frame, String expectedChannels) {
var packet = payload(frame);
try {
assertEquals(0x17, MinecraftVarInts.read(packet));
assertEquals("REGISTER", MinecraftProtocolCodec.readString(packet, 32767));
var length = packet.readUnsignedShort();
assertEquals(expectedChannels, packet.readCharSequence(length, StandardCharsets.UTF_8).toString());
assertEquals(0, packet.readableBytes());
} finally {
packet.release();
}
}
private static void assertServerboundRegisterRemaining(ByteBuf frame, String expectedChannels) {
var packet = payload(frame);
try {
assertEquals(0x17, MinecraftVarInts.read(packet));
assertEquals("REGISTER", MinecraftProtocolCodec.readString(packet, 32767));
assertEquals(expectedChannels, packet.readCharSequence(packet.readableBytes(), StandardCharsets.UTF_8).toString());
assertEquals(0, packet.readableBytes());
} finally {
packet.release();
}
}
private static ByteBuf payload(ByteBuf frame) {
var length = MinecraftVarInts.read(frame);
return frame.readRetainedSlice(length);
}
private static void writeString(ByteBuf output, String value) {
var bytes = value.getBytes(StandardCharsets.UTF_8);
MinecraftVarInts.write(output, bytes.length);
output.writeBytes(bytes);
}
private static void release(ByteBuf buffer) {
if (buffer != null && buffer.refCnt() > 0) {
buffer.release();
}
}
}
@@ -0,0 +1,112 @@
package dev.strataproxy.network;
import org.junit.jupiter.api.Test;
import dev.strataproxy.protocol.ProtocolState;
import java.util.OptionalInt;
import java.util.Set;
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 MinecraftProtocolProfileTest {
@Test
void resolvesLegacy1710Profile() {
var profile = MinecraftProtocolProfile.forVersion(MinecraftProtocolProfile.PROTOCOL_1_7_10);
assertFalse(profile.compressionNegotiationSupported());
assertEquals(OptionalInt.empty(), profile.clientboundLoginSetCompressionPacketId());
assertEquals(Set.of(0x01), profile.serverboundCommandPacketIds());
assertEquals(OptionalInt.of(0x17), profile.serverboundCustomPayloadPacketId());
assertEquals(MinecraftProtocolProfile.CustomPayloadLengthFormat.UNSIGNED_SHORT,
profile.serverboundCustomPayloadLengthFormat());
assertEquals(ProtocolState.PLAY, profile.serverboundCustomPayloadInspectionState());
assertEquals(OptionalInt.of(0x3F), profile.clientboundCustomPayloadPacketId());
assertEquals(MinecraftProtocolProfile.CustomPayloadLengthFormat.VARSHORT,
profile.clientboundCustomPayloadLengthFormat());
assertEquals(OptionalInt.of(0x38), profile.clientboundPlayerListItemPacketId());
assertEquals(MinecraftProtocolProfile.PlayerListItemLayout.LEGACY_NAME,
profile.playerListItemLayout());
assertEquals(OptionalInt.of(0x3B), profile.clientboundScoreboardObjectivePacketId());
assertEquals(MinecraftProtocolProfile.ScoreboardObjectiveLayout.NAME_ACTION,
profile.scoreboardObjectiveLayout());
assertEquals(OptionalInt.of(0x3E), profile.clientboundTeamPacketId());
assertEquals(MinecraftProtocolProfile.BackendSwitchStrategy.RESPAWN_ONLY,
profile.backendSwitchStrategy(false));
assertEquals(MinecraftProtocolProfile.BackendSwitchStrategy.JOIN_GAME_THEN_RESPAWN,
profile.backendSwitchStrategy(true));
assertTrue(profile.backendReplacementSupported());
assertTrue(profile.legacyForgeHandshakeSupported());
}
@Test
void resolvesLegacy18ProfileExplicitly() {
var profile = MinecraftProtocolProfile.forVersion(MinecraftProtocolProfile.PROTOCOL_1_8);
assertTrue(profile.compressionNegotiationSupported());
assertEquals(OptionalInt.of(0x03), profile.clientboundLoginSetCompressionPacketId());
assertEquals(Set.of(0x01), profile.serverboundCommandPacketIds());
assertEquals(OptionalInt.of(0x02), profile.clientboundChatPacketId());
assertTrue(profile.clientboundChatHasOverlayFlag());
assertEquals(OptionalInt.of(0x17), profile.serverboundCustomPayloadPacketId());
assertEquals(MinecraftProtocolProfile.CustomPayloadLengthFormat.REMAINING_BYTES,
profile.serverboundCustomPayloadLengthFormat());
assertEquals(ProtocolState.PLAY, profile.serverboundCustomPayloadInspectionState());
assertEquals(OptionalInt.of(0x3F), profile.clientboundCustomPayloadPacketId());
assertEquals(MinecraftProtocolProfile.CustomPayloadLengthFormat.REMAINING_BYTES,
profile.clientboundCustomPayloadLengthFormat());
assertEquals(OptionalInt.of(0x01), profile.clientboundPlayLoginPacketId());
assertEquals(OptionalInt.of(0x07), profile.clientboundPlayRespawnPacketId());
assertEquals(OptionalInt.of(0x38), profile.clientboundPlayerListItemPacketId());
assertEquals(MinecraftProtocolProfile.PlayerListItemLayout.UUID_ACTION,
profile.playerListItemLayout());
assertEquals(OptionalInt.of(0x3B), profile.clientboundScoreboardObjectivePacketId());
assertEquals(MinecraftProtocolProfile.ScoreboardObjectiveLayout.NAME_ACTION,
profile.scoreboardObjectiveLayout());
assertEquals(OptionalInt.of(0x3E), profile.clientboundTeamPacketId());
assertEquals(MinecraftProtocolProfile.BackendSwitchStrategy.RESPAWN_ONLY,
profile.backendSwitchStrategy(false));
assertEquals(MinecraftProtocolProfile.BackendSwitchStrategy.JOIN_GAME_THEN_RESPAWN,
profile.backendSwitchStrategy(true));
assertTrue(profile.backendReplacementSupported());
assertTrue(profile.legacyForgeHandshakeSupported());
}
@Test
void resolvesKnownModernProfileAsReplacementSupported() {
var profile = MinecraftProtocolProfile.forVersion(MinecraftProtocolProfile.PROTOCOL_1_20_1);
assertTrue(profile.compressionNegotiationSupported());
assertEquals(OptionalInt.of(0x03), profile.clientboundLoginSetCompressionPacketId());
assertEquals(ProtocolState.CONFIGURATION, profile.serverboundCustomPayloadInspectionState());
assertEquals(MinecraftProtocolProfile.BackendSwitchStrategy.NONE,
profile.backendSwitchStrategy(false));
assertTrue(profile.backendReplacementSupported());
assertFalse(profile.legacyForgeHandshakeSupported());
}
@Test
void unknownModernProfileRemainsConservative() {
var profile = MinecraftProtocolProfile.forVersion(9999);
assertTrue(profile.compressionNegotiationSupported());
assertEquals(OptionalInt.of(0x03), profile.clientboundLoginSetCompressionPacketId());
assertTrue(profile.serverboundCommandPacketIds().isEmpty());
assertEquals(OptionalInt.empty(), profile.serverboundCustomPayloadPacketId());
assertEquals(ProtocolState.PLAY, profile.serverboundCustomPayloadInspectionState());
assertEquals(OptionalInt.empty(), profile.clientboundCustomPayloadPacketId());
assertEquals(OptionalInt.empty(), profile.clientboundPlayerListItemPacketId());
assertEquals(MinecraftProtocolProfile.PlayerListItemLayout.NONE,
profile.playerListItemLayout());
assertEquals(OptionalInt.empty(), profile.clientboundScoreboardObjectivePacketId());
assertEquals(MinecraftProtocolProfile.ScoreboardObjectiveLayout.NONE,
profile.scoreboardObjectiveLayout());
assertEquals(OptionalInt.empty(), profile.clientboundTeamPacketId());
assertEquals(MinecraftProtocolProfile.BackendSwitchStrategy.NONE,
profile.backendSwitchStrategy(true));
assertFalse(profile.backendReplacementSupported());
assertFalse(profile.legacyForgeHandshakeSupported());
}
}
@@ -495,6 +495,101 @@ final class NettyProxyNetworkServerSmokeTest {
}
}
@Test
void legacy1710GameCommandTransferSendsRespawnSwitchFrames() throws Exception {
var handshake = handshakeFrame(5, "play.example.net", 25565, 2);
var login = loginStartFrame("Steve");
var clientBytes = concat(handshake, login);
var commandFrame = playCommandFrame(0x01, "server survival-2");
var afterReplacementBytes = packetFrame(0x03, 117);
var executor = Executors.newFixedThreadPool(2);
try (var firstBackend = new ServerSocket(0);
var secondBackend = new ServerSocket(0)) {
firstBackend.setSoTimeout(5_000);
secondBackend.setSoTimeout(5_000);
var firstBackendClosedWithoutCommand = executor.submit(() -> {
try (var accepted = firstBackend.accept()) {
accepted.setSoTimeout(5_000);
readMinecraftFrame(accepted.getInputStream());
readMinecraftFrame(accepted.getInputStream());
return accepted.getInputStream().read() < 0;
}
});
var secondBackendRead = executor.submit(() -> {
try (var accepted = secondBackend.accept()) {
accepted.setSoTimeout(5_000);
var first = readMinecraftFrame(accepted.getInputStream());
var second = readMinecraftFrame(accepted.getInputStream());
accepted.getOutputStream().write(concat(loginSuccessFrame(), joinGame1710Frame(12, 0, 0, 2, "default")));
accepted.getOutputStream().flush();
return new SwitchBackendRead(concat(first, second), accepted.getInputStream().readNBytes(afterReplacementBytes.length));
}
});
var first = server("survival-1", firstBackend.getLocalPort());
var second = server("survival-2", secondBackend.getLocalPort());
var metrics = new ProxyMetrics();
var tuning = new NetworkTuning(4096, 1_000, 128, 1024, 100, 100, 5_000);
var resolver = new ReplacementBackendResolver(first, second);
var proxyReference = new AtomicReference<NettyProxyNetworkServer>();
var commands = new DefaultCommandRegistry();
commands.register(new CommandSpec("server", List.of(), "", "", context ->
proxyReference.get().transferPlayer(context.source().name(), context.arguments().get(0))
.thenApply(result -> result.success()
? CommandResult.ok()
: CommandResult.failure(result.outcome()))));
try (var proxy = new NettyProxyNetworkServer(
1,
resolver,
metrics,
tuning,
commands,
new SimpleEventBus())) {
proxyReference.set(proxy);
proxy.bind(new InetSocketAddress("127.0.0.1", 0))
.toCompletableFuture()
.get(5, TimeUnit.SECONDS);
try (var client = new Socket()) {
client.connect(proxy.bindAddress(), 5_000);
client.setSoTimeout(5_000);
client.getOutputStream().write(clientBytes);
client.getOutputStream().flush();
awaitPlayerSession(metrics, "Steve", "survival-1");
client.getOutputStream().write(commandFrame);
client.getOutputStream().flush();
awaitServerActiveConnections(metrics, "survival-2", 1);
try {
assertEquals(0x07, packetId(readMinecraftFrame(client.getInputStream())));
assertEquals(0x07, packetId(readMinecraftFrame(client.getInputStream())));
} catch (java.io.IOException exception) {
var backendRead = secondBackendRead.isDone() ? secondBackendRead.get(5, TimeUnit.SECONDS) : null;
throw new AssertionError(
"secondBackendDone=" + secondBackendRead.isDone()
+ " secondPlayBytes="
+ (backendRead == null ? "pending" : backendRead.playBytes().length)
+ " snapshot=" + metrics.snapshot(),
exception);
}
client.getOutputStream().write(afterReplacementBytes);
client.getOutputStream().flush();
var secondRead = secondBackendRead.get(5, TimeUnit.SECONDS);
assertArrayEquals(clientBytes, secondRead.loginBytes());
assertArrayEquals(afterReplacementBytes, secondRead.playBytes());
assertTrue(firstBackendClosedWithoutCommand.get(5, TimeUnit.SECONDS));
assertEquals("survival-2", metrics.snapshot().playerSessions().get("Steve").server());
}
}
} finally {
executor.shutdownNow();
}
}
@Test
void gameCommandResultMessageIsSentToClientAndNotForwarded() throws Exception {
var handshake = handshakeFrame(763, "play.example.net", 25565, 2);
@@ -967,6 +1062,37 @@ final class NettyProxyNetworkServerSmokeTest {
return packetFrame(0x02, 0);
}
private static byte[] joinGame1710Frame(int entityId, int gameMode, int dimension, int difficulty, String levelType) {
var payload = new ByteArrayOutputStream();
writeVarInt(payload, 0x01);
payload.write((entityId >>> 24) & 0xFF);
payload.write((entityId >>> 16) & 0xFF);
payload.write((entityId >>> 8) & 0xFF);
payload.write(entityId & 0xFF);
payload.write(gameMode & 0xFF);
payload.write(dimension & 0xFF);
payload.write(difficulty & 0xFF);
payload.write(100);
writeString(payload, levelType);
var payloadBytes = payload.toByteArray();
var frame = new ByteArrayOutputStream();
writeVarInt(frame, payloadBytes.length);
frame.writeBytes(payloadBytes);
return frame.toByteArray();
}
private static int packetId(byte[] frameBytes) {
var frame = Unpooled.wrappedBuffer(frameBytes);
try {
var length = MinecraftVarInts.read(frame);
var body = frame.readSlice(length);
return MinecraftVarInts.read(body);
} finally {
frame.release();
}
}
private static byte[] readMinecraftFrame(InputStream input) throws java.io.IOException {
var lengthBytes = new ByteArrayOutputStream();
var value = 0;
@@ -0,0 +1,84 @@
package dev.strataproxy.network;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertTrue;
final class RelaySessionTest {
@Test
void forgeClientMarkerRequiresResetWhenSwitchingFromPreviousServer() {
var profile = MinecraftProtocolProfile.forVersion(MinecraftProtocolProfile.PROTOCOL_1_7_10);
var session = new RelaySession("127.0.0.1:50000");
session.legacyForgeClientDetected(new MinecraftHandshake(
MinecraftProtocolProfile.PROTOCOL_1_7_10,
"play.example.net\0FML\0",
25565,
2).legacyForgeClientMarker());
var first = session.startForgeHandshakeTracker(4096, profile);
assertFalse(first.consumeResetRequiredOnNextForgeServer());
session.attach(null, null, "vanilla-1");
var next = session.startForgeHandshakeTracker(4096, profile);
try {
assertTrue(next.consumeResetRequiredOnNextForgeServer());
assertFalse(next.consumeResetRequiredOnNextForgeServer());
assertTrue(session.legacyForgeClientDetected());
} finally {
next.close();
}
}
@Test
void completedLegacyForgeHandshakeDoesNotRequireSecondRegisterResetOnNextServer() {
var profile = MinecraftProtocolProfile.forVersion(MinecraftProtocolProfile.PROTOCOL_1_7_10);
var session = new RelaySession("127.0.0.1:50000");
session.legacyForgeClientDetected(true);
var first = session.startForgeHandshakeTracker(4096, profile);
try {
MinecraftForgeHandshakeTrackerTest.advanceToComplete(first);
session.attach(null, null, "forge-1");
var next = session.startForgeHandshakeTracker(4096, profile);
try {
assertFalse(next.consumeResetRequiredOnNextForgeServer());
} finally {
next.close();
}
} finally {
first.close();
}
}
@Test
void forgeTrackerSwapRollbackRestoresPreviousTrackerAfterFailedSwitch() {
var profile = MinecraftProtocolProfile.forVersion(MinecraftProtocolProfile.PROTOCOL_1_7_10);
var session = new RelaySession("127.0.0.1:50000");
session.attach(null, null, "forge-1");
var previous = session.startForgeHandshakeTracker(4096, profile);
var swap = session.beginForgeHandshakeTrackerSwap(4096, profile);
assertSame(swap.next(), session.forgeHandshakeTracker());
session.rollbackForgeHandshakeTrackerSwap(swap);
assertSame(previous, session.forgeHandshakeTracker());
previous.close();
}
@Test
void forgeTrackerSwapCommitKeepsNextTrackerAfterSuccessfulSwitch() {
var profile = MinecraftProtocolProfile.forVersion(MinecraftProtocolProfile.PROTOCOL_1_7_10);
var session = new RelaySession("127.0.0.1:50000");
session.attach(null, null, "forge-1");
session.startForgeHandshakeTracker(4096, profile);
var swap = session.beginForgeHandshakeTrackerSwap(4096, profile);
session.commitForgeHandshakeTrackerSwap(swap);
assertSame(swap.next(), session.forgeHandshakeTracker());
swap.next().close();
}
}
@@ -326,6 +326,59 @@ final class RelayTrafficMetricsTest {
frontend.finishAndReleaseAll();
}
@Test
void doesNotTreatPlayPacketThreeAsCompressionAfterLoginSuccess() {
var metrics = new ProxyMetrics();
var frontend = new EmbeddedChannel();
var backend = new EmbeddedChannel(new BackendRelayHandler(frontend, metrics, "survival-1", 1024));
var loginSuccess = packetFrame(0x02, 0);
var playPacketThree = packetFrame(0x03, 0);
backend.writeInbound(loginSuccess.retainedDuplicate());
backend.writeInbound(playPacketThree.retainedDuplicate());
releaseOutbound(frontend);
var snapshot = metrics.snapshot();
assertEquals(0, snapshot.compressionNegotiations());
assertEquals(false, snapshot.serverCompressionThresholds().containsKey("survival-1"));
loginSuccess.release();
playPacketThree.release();
backend.finishAndReleaseAll();
frontend.finishAndReleaseAll();
}
@Test
void doesNotDetectCompressionNegotiationForLegacyProtocol() {
var metrics = new ProxyMetrics();
var frontend = new EmbeddedChannel();
var backend = new EmbeddedChannel(new BackendRelayHandler(
frontend,
metrics,
"survival-1",
1024,
new MinecraftCompressionAuditState(1024),
CompressionRuntime.defaults(),
new RelaySessionIdentity(""),
MinecraftForwardingRuntime.none(),
false,
25,
5,
null));
var playPacketThree = packetFrame(0x03, 128);
backend.writeInbound(playPacketThree.retainedDuplicate());
releaseOutbound(frontend);
var snapshot = metrics.snapshot();
assertEquals(0, snapshot.compressionNegotiations());
assertEquals(false, snapshot.serverCompressionThresholds().containsKey("survival-1"));
playPacketThree.release();
backend.finishAndReleaseAll();
frontend.finishAndReleaseAll();
}
@Test
void malformedCompressionNegotiationClosesRelay() {
var metrics = new ProxyMetrics();
@@ -12,9 +12,11 @@ import java.net.InetSocketAddress;
import java.util.ArrayList;
import java.util.List;
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;
import static org.junit.jupiter.api.Assertions.assertTrue;
final class RoutingBackendResolverTest {
@@ -58,14 +60,29 @@ final class RoutingBackendResolverTest {
assertEquals(List.of("unknown.example.net", ""), routes);
}
@Test
void explicitTransferTargetMustAcceptClientProtocol() {
var legacy = server("legacy-1", new ProtocolRange(5, 5, "1.7.10"));
var resolver = new RoutingBackendResolver(
ignored -> new RoutingDecision.Rejected("unused"),
new SingleServerRegistry(legacy));
assertTrue(resolver.resolveTarget("legacy-1", 5).isPresent());
assertFalse(resolver.resolveTarget("legacy-1", 763).isPresent());
}
private static RegisteredServer server(String name) {
return server(name, new ProtocolRange(0, Integer.MAX_VALUE, "any"));
}
private static RegisteredServer server(String name, ProtocolRange protocolRange) {
return new TestRegisteredServer(
new ServerDescriptor(
name,
new InetSocketAddress("127.0.0.1", 25565),
Set.of("modded"),
Set.of(),
new ProtocolRange(0, Integer.MAX_VALUE, "any"),
protocolRange,
100,
100,
120,
@@ -82,4 +99,46 @@ final class RoutingBackendResolverTest {
ServerLoad load,
boolean draining) implements RegisteredServer {
}
private record SingleServerRegistry(RegisteredServer server) implements dev.strataproxy.api.server.ServerRegistry {
@Override
public RegisteredServer register(ServerDescriptor descriptor) {
throw new UnsupportedOperationException();
}
@Override
public RegisteredServer registerOrReplace(ServerDescriptor descriptor) {
throw new UnsupportedOperationException();
}
@Override
public boolean unregister(String name, dev.strataproxy.api.server.DrainPolicy policy) {
throw new UnsupportedOperationException();
}
@Override
public Optional<RegisteredServer> get(String name) {
return server.descriptor().name().equals(name) ? Optional.of(server) : Optional.empty();
}
@Override
public java.util.Collection<RegisteredServer> snapshot() {
return List.of(server);
}
@Override
public void updateHealth(String name, ServerHealth health) {
throw new UnsupportedOperationException();
}
@Override
public void updateLoad(String name, ServerLoad load) {
throw new UnsupportedOperationException();
}
@Override
public void updateDrainMode(String name, boolean drainMode) {
throw new UnsupportedOperationException();
}
}
}
@@ -20,6 +20,7 @@ import java.util.stream.Collectors;
public final class ProxyMetrics {
private static final int RECENT_PACKET_ANOMALY_CAPACITY = 256;
private static final int RECENT_CUSTOM_PAYLOAD_CAPACITY = 256;
private static final int RECENT_PLAYER_TRANSFER_CAPACITY = 256;
private final LongAdder acceptedConnections = new LongAdder();
private final AtomicLong activeConnections = new AtomicLong();
@@ -39,6 +40,9 @@ public final class ProxyMetrics {
private final AtomicLong customPayloadSequence = new AtomicLong();
private final AtomicReferenceArray<CustomPayloadSample> recentCustomPayloads =
new AtomicReferenceArray<>(RECENT_CUSTOM_PAYLOAD_CAPACITY);
private final AtomicLong playerTransferSequence = new AtomicLong();
private final AtomicReferenceArray<PlayerTransferSample> recentPlayerTransfers =
new AtomicReferenceArray<>(RECENT_PLAYER_TRANSFER_CAPACITY);
private final boolean packetAnomalySampling;
private final ConcurrentHashMap<String, LongAdder> packetAnomalies = new ConcurrentHashMap<>();
private final ConcurrentHashMap<String, ServerTrafficCounters> serverTraffic = new ConcurrentHashMap<>();
@@ -46,6 +50,7 @@ public final class ProxyMetrics {
private final ConcurrentHashMap<String, PlayerSession> playerSessions = new ConcurrentHashMap<>();
private final ConcurrentHashMap<PacketTrafficKey, PacketTrafficCounters> packetTraffic = new ConcurrentHashMap<>();
private final ConcurrentHashMap<CustomPayloadKey, CustomPayloadCounters> customPayloads = new ConcurrentHashMap<>();
private final ConcurrentHashMap<ForgeHandshakeKey, ForgeHandshake> forgeHandshakes = new ConcurrentHashMap<>();
private final ConcurrentHashMap<RelayBackpressureKey, RelayBackpressureCounters> relayBackpressure = new ConcurrentHashMap<>();
private final ConcurrentHashMap<String, PayloadCaptureBuffer> payloadCaptures = new ConcurrentHashMap<>();
private final CompressionCounters compression = new CompressionCounters();
@@ -181,6 +186,35 @@ public final class ProxyMetrics {
backendReplacements.computeIfAbsent(normalized, ignored -> new LongAdder()).increment();
}
/**
* Provides player transfer result.
* @param success success value
* @param outcome outcome value
* @param player player value
* @param sourceServer source server value
* @param targetServer target server value
* @param remoteAddress remote address value
*/
public void playerTransfer(
boolean success,
String outcome,
String player,
String sourceServer,
String targetServer,
String remoteAddress) {
var sequence = playerTransferSequence.incrementAndGet();
var index = (int) ((sequence - 1) % RECENT_PLAYER_TRANSFER_CAPACITY);
recentPlayerTransfers.set(index, new PlayerTransferSample(
sequence,
success,
sanitize(outcome),
sanitize(player),
sanitize(sourceServer),
sanitize(targetServer),
sanitize(remoteAddress),
Instant.now()));
}
/**
* Provides frontend to backend bytes.
* @param bytes bytes value
@@ -650,6 +684,64 @@ public final class ProxyMetrics {
ignored -> new CompressionRewriteCounters()).add(cpuNanos);
}
/**
* Provides current legacy Forge handshake state.
* @param server server value
* @param player player value
* @param remoteAddress remote address value
* @param stage stage value
* @param clientPhase client phase value
* @param backendPhase backend phase value
* @param complete complete value
* @param backendSwitchBlocked backend switch blocked value
* @param clientMods client mods value
* @param serverMods server mods value
* @param registryPackets registry packets value
* @param registryBytes registry bytes value
*/
public void forgeHandshake(
String server,
String player,
String remoteAddress,
String stage,
String clientPhase,
String backendPhase,
boolean complete,
boolean backendSwitchBlocked,
int clientMods,
int serverMods,
int registryPackets,
long registryBytes) {
if (server == null || server.isBlank()) {
return;
}
var key = new ForgeHandshakeKey(sanitize(server), sanitize(player), sanitize(remoteAddress));
forgeHandshakes.put(key, new ForgeHandshake(
sanitize(stage),
sanitize(clientPhase),
sanitize(backendPhase),
complete,
backendSwitchBlocked,
Math.max(0, clientMods),
Math.max(0, serverMods),
Math.max(0, registryPackets),
Math.max(0, registryBytes),
Instant.now()));
}
/**
* Removes current legacy Forge handshake state for a connection.
* @param server server value
* @param player player value
* @param remoteAddress remote address value
*/
public void forgeHandshakeClosed(String server, String player, String remoteAddress) {
if (server == null || server.isBlank()) {
return;
}
forgeHandshakes.remove(new ForgeHandshakeKey(sanitize(server), sanitize(player), sanitize(remoteAddress)));
}
/**
* Provides current event loop delay nanos.
* @return result of the operation
@@ -739,6 +831,7 @@ public final class ProxyMetrics {
backendConnectFailures.sum(),
backendReplacements.entrySet().stream()
.collect(Collectors.toUnmodifiableMap(Map.Entry::getKey, entry -> entry.getValue().sum())),
recentPlayerTransferSamples(),
frontendToBackendBytes.sum(),
backendToFrontendBytes.sum(),
compressionNegotiations.sum(),
@@ -768,6 +861,7 @@ public final class ProxyMetrics {
Map.Entry::getKey,
entry -> entry.getValue().snapshot())),
recentCustomPayloadSamples(),
Map.copyOf(forgeHandshakes),
relayBackpressure.entrySet().stream()
.collect(Collectors.toUnmodifiableMap(
Map.Entry::getKey,
@@ -940,6 +1034,28 @@ public final class ProxyMetrics {
public record PlayerSession(String player, String server, String remoteAddress, Instant connectedAt) {
}
/**
* Recent player transfer result retained for diagnostics.
* @param sequence sequence
* @param success success
* @param outcome outcome
* @param player player
* @param sourceServer source server
* @param targetServer target server
* @param remoteAddress remote address
* @param timestamp timestamp
*/
public record PlayerTransferSample(
long sequence,
boolean success,
String outcome,
String player,
String sourceServer,
String targetServer,
String remoteAddress,
Instant timestamp) {
}
private static final class PacketTrafficCounters {
private final LongAdder packets = new LongAdder();
private final LongAdder rawBytes = new LongAdder();
@@ -1390,6 +1506,41 @@ public final class ProxyMetrics {
public record RelayBackpressureKey(String server, CompressionDirection direction) {
}
/**
* Map key for current legacy Forge handshake state.
* @param server server
* @param player player
* @param remoteAddress remote address
*/
public record ForgeHandshakeKey(String server, String player, String remoteAddress) {
}
/**
* Current legacy Forge handshake state.
* @param stage stage
* @param clientPhase client phase
* @param backendPhase backend phase
* @param complete complete
* @param backendSwitchBlocked backend switch blocked
* @param clientMods client mods
* @param serverMods server mods
* @param registryPackets registry packets
* @param registryBytes registry bytes
* @param updatedAt updated at
*/
public record ForgeHandshake(
String stage,
String clientPhase,
String backendPhase,
boolean complete,
boolean backendSwitchBlocked,
int clientMods,
int serverMods,
int registryPackets,
long registryBytes,
Instant updatedAt) {
}
/**
* Complete point-in-time metrics snapshot.
* @param acceptedConnections accepted connections
@@ -1401,6 +1552,7 @@ public final class ProxyMetrics {
* @param failedRoutes failed routes
* @param backendConnectFailures backend connect failures
* @param backendReplacements backend replacements
* @param recentPlayerTransfers recent player transfers
* @param frontendToBackendBytes frontend to backend bytes
* @param backendToFrontendBytes backend to frontend bytes
* @param compressionNegotiations compression negotiations
@@ -1417,6 +1569,7 @@ public final class ProxyMetrics {
* @param packetTraffic packet traffic
* @param customPayloads custom payloads
* @param recentCustomPayloads recent custom payloads
* @param forgeHandshakes forge handshakes
* @param relayBackpressure relay backpressure
* @param compression compression
* @param serverCompression server compression
@@ -1437,6 +1590,7 @@ public final class ProxyMetrics {
long failedRoutes,
long backendConnectFailures,
Map<String, Long> backendReplacements,
List<PlayerTransferSample> recentPlayerTransfers,
long frontendToBackendBytes,
long backendToFrontendBytes,
long compressionNegotiations,
@@ -1453,6 +1607,7 @@ public final class ProxyMetrics {
Map<PacketTrafficKey, PacketTraffic> packetTraffic,
Map<CustomPayloadKey, CustomPayloadTraffic> customPayloads,
List<CustomPayloadSample> recentCustomPayloads,
Map<ForgeHandshakeKey, ForgeHandshake> forgeHandshakes,
Map<RelayBackpressureKey, RelayBackpressure> relayBackpressure,
CompressionAudit compression,
Map<String, CompressionAudit> serverCompression,
@@ -1463,4 +1618,16 @@ public final class ProxyMetrics {
List<PayloadCapture> payloadCaptures,
Map<String, List<PayloadCaptureSample>> payloadCaptureSamples) {
}
private List<PlayerTransferSample> recentPlayerTransferSamples() {
var samples = new ArrayList<PlayerTransferSample>(RECENT_PLAYER_TRANSFER_CAPACITY);
for (var i = 0; i < RECENT_PLAYER_TRANSFER_CAPACITY; i++) {
var sample = recentPlayerTransfers.get(i);
if (sample != null) {
samples.add(sample);
}
}
samples.sort(Comparator.comparingLong(PlayerTransferSample::sequence).reversed());
return List.copyOf(samples);
}
}
@@ -29,6 +29,19 @@ final class ProxyMetricsTest {
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.forgeHandshake(
"survival-1",
"Alex",
"127.0.0.1:50000",
"REGISTRY_DATA",
"WAITING_SERVER_COMPLETE",
"REGISTRY_DATA_SENT",
false,
true,
128,
130,
9,
65536);
metrics.frontendToBackendBytes("survival-1", 128);
metrics.backendToFrontendBytes("survival-1", 256);
metrics.compressionSample("survival-1", 1000, 400, 2_000_000);
@@ -99,6 +112,20 @@ final class ProxyMetricsTest {
assertEquals("survival-1", snapshot.recentCustomPayloads().getFirst().server());
assertEquals("CONFIGURATION", snapshot.recentCustomPayloads().getFirst().protocolState());
assertEquals(-1, snapshot.recentCustomPayloads().getFirst().packetId());
var forgeHandshake = snapshot.forgeHandshakes().get(new ProxyMetrics.ForgeHandshakeKey(
"survival-1",
"Alex",
"127.0.0.1:50000"));
assertEquals("REGISTRY_DATA", forgeHandshake.stage());
assertEquals("WAITING_SERVER_COMPLETE", forgeHandshake.clientPhase());
assertEquals("REGISTRY_DATA_SENT", forgeHandshake.backendPhase());
assertEquals(false, forgeHandshake.complete());
assertEquals(true, forgeHandshake.backendSwitchBlocked());
assertEquals(128, forgeHandshake.clientMods());
assertEquals(130, forgeHandshake.serverMods());
assertEquals(9, forgeHandshake.registryPackets());
assertEquals(65536, forgeHandshake.registryBytes());
assertTrue(forgeHandshake.updatedAt() != null);
assertEquals(128, snapshot.frontendToBackendBytes());
assertEquals(256, snapshot.backendToFrontendBytes());
assertEquals(128, snapshot.serverTraffic().get("survival-1").frontendToBackendBytes());
@@ -129,6 +156,30 @@ final class ProxyMetricsTest {
assertEquals(1, snapshot.serverConnections().get("survival-1").activeConnections());
}
@Test
void keepsOnlyMostRecentPlayerTransferSamples() {
var metrics = new ProxyMetrics();
for (var i = 0; i < 300; i++) {
metrics.playerTransfer(
i % 2 == 0,
i % 2 == 0 ? "success" : "connect_failure",
"Player" + i,
"lobby-1",
"survival-" + i,
"127.0.0.1:" + (50000 + i));
}
var samples = metrics.snapshot().recentPlayerTransfers();
assertEquals(256, samples.size());
assertEquals("Player299", samples.getFirst().player());
assertEquals("survival-299", samples.getFirst().targetServer());
assertEquals("connect_failure", samples.getFirst().outcome());
assertEquals("127.0.0.1:50299", samples.getFirst().remoteAddress());
assertEquals("Player44", samples.getLast().player());
assertTrue(samples.getFirst().sequence() > samples.getLast().sequence());
}
@Test
void keepsOnlyMostRecentPacketAnomalySamples() {
var metrics = new ProxyMetrics();
@@ -171,6 +222,46 @@ final class ProxyMetricsTest {
assertTrue(samples.getFirst().sequence() > samples.getLast().sequence());
}
@Test
void removesClosedForgeHandshakeStateByConnectionKey() {
var metrics = new ProxyMetrics();
metrics.forgeHandshake(
"survival-1",
"Alex",
"127.0.0.1:50000",
"COMPLETE",
"COMPLETE",
"COMPLETE",
true,
false,
1,
1,
1,
128);
metrics.forgeHandshake(
"survival-1",
"Steve",
"127.0.0.1:50001",
"REGISTRY_DATA",
"WAITING_SERVER_COMPLETE",
"REGISTRY_DATA_SENT",
false,
true,
2,
2,
2,
256);
metrics.forgeHandshakeClosed("survival-1", "Alex", "127.0.0.1:50000");
var snapshot = metrics.snapshot();
assertEquals(1, snapshot.forgeHandshakes().size());
assertTrue(snapshot.forgeHandshakes().containsKey(new ProxyMetrics.ForgeHandshakeKey(
"survival-1",
"Steve",
"127.0.0.1:50001")));
}
@Test
void canDisablePacketAnomalySamplingWithoutDisablingCounters() {
var metrics = new ProxyMetrics(false);