Skip to content

Commit df65927

Browse files
authored
feat: Modern Forge (FML2/FML3) login relay for 1.13-1.20.1 (#680)
* feat: Modern Forge (FML2/FML3) login relay for Minecraft 1.13-1.20.1 Enable Forge mod negotiation through the proxy when using Velocity modern forwarding, which was previously unsupported (same as upstream Velocity). The proxy now relays fml:loginwrapper LoginPluginMessages between the backend Forge server and the client during the LOGIN phase. For initial connections, LoginSuccess is delayed to keep the client in LOGIN state while the FML handshake runs. For server switches, cached client responses are replayed to the new backend. * test: add wire-level integration test for Modern Forge login relay Simulates a Forge 1.20.1 (FML3) client connecting through a real Gate proxy (with real TCP connections) to a mock Forge backend. Verifies the complete relay flow: 3 fml:loginwrapper messages relayed from backend to client, 3 responses forwarded back, and LoginSuccess sent after the FML handshake completes. * fix: use synchronous I/O for forge relay to avoid decoder mutex deadlock The previous approach ran connectToInitialServer in a goroutine so the client's read loop could process LoginPluginResponse packets. But this meant the read loop blocked in Decode holding the decoder mutex, making SetActiveSessionHandler hang for 30s (the read timeout). Fix: read client responses directly from the backend goroutine via Reader().ReadPacket(). Since the client goroutine is blocked in connectToInitialServer (not in Decode), the decoder mutex is free. handleJoinGame can then switch the client to PLAY without blocking. * fix: make decoder SetState lock-free to prevent cross-goroutine deadlock The decoder held its mutex during blocking network I/O in Decode(). When the backend goroutine called SetActiveSessionHandler on the client (to switch from LOGIN to PLAY after the FML relay), SetState blocked waiting for the decoder mutex — causing a 30s hang. Fix: use atomic.Pointer for the decoder's registry and state fields so SetState/SetProtocol can proceed without the mutex. This is safe because the registry is only read (not mutated) during decode, and the atomic swap ensures the next decode uses the updated registry. This restores the async goroutine approach for the FML relay, which correctly handles client LoginPluginResponses via the auth session handler's read loop. * cleanup: fix deadlock, remove accidental files, unexport fields - Fix deadlock: CurrentServer() called while holding player.mu write lock in server.go — use connectedServer_ field directly - Remove .DS_Store files and config-forge-test.yml from PR - Unexport forgeLoginExchange fields, remove redundant Success field (derivable from response != nil) - Remove dead first loginState.Store (immediately overwritten) - Simplify replayRelay cleanup in handleServerLoginSuccess - Remove no-op init() and dead var _ assertion from integration test - Remove unused imports * docs: add Forge 1.13-1.20.1 setup guide and compatibility info Document the built-in FML login relay for Forge 1.13-1.20.1, covering PCF setup for Velocity forwarding, server switching behavior, and the distinction from 1.20.2+ (which uses CONFIG phase natively). * style: fix lint errcheck warnings in integration test
1 parent d12d659 commit df65927

13 files changed

Lines changed: 1211 additions & 58 deletions

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,5 @@
44
tmp
55
.geyser
66
./gate
7+
.DS_Store
8+
config-forge-test.yml

.web/docs/guide/compatibility.md

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,11 +51,18 @@ Gate has excellent compatibility with modded Minecraft servers:
5151
- **Command support** - Proper handling of modded commands
5252
- **Cross-version support** - Works with various Minecraft versions
5353

54-
### Legacy Forge
54+
### Forge 1.13–1.20.1 (FML2/FML3) <VPBadge>Fully Supported</VPBadge>
55+
56+
- **All forwarding modes** - Velocity modern forwarding (with [PCF](https://modrinth.com/mod/proxy-compatible-forge)), BungeeCord, and BungeeGuard
57+
- **Built-in FML login relay** - Gate relays `fml:loginwrapper` LoginPluginMessages during the LOGIN phase, similar to what [Ambassador](https://modrinth.com/plugin/ambassador) does for Velocity
58+
- **No client-side mods required** - The player doesn't need any special mods
59+
- **Server switch support** - Cached FML responses are replayed for compatible server switches
60+
61+
### Legacy Forge (1.8–1.12.2)
5562

5663
- **Limited support** - Basic functionality works
5764
- **Legacy forwarding only** - Use BungeeCord forwarding
58-
- **Older versions** - 1.12.2 and below may have compatibility issues
65+
- **Older versions** - May have compatibility issues
5966

6067
For detailed setup instructions, configuration examples, and troubleshooting, see our comprehensive [Modded Servers Guide](modded-servers).
6168

.web/docs/guide/modded-servers.md

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
---
2-
title: "Gate Minecraft Proxy with Modded Servers - Fabric & NeoForge"
3-
description: "Complete guide to using Gate with modded Minecraft servers including Fabric and NeoForge. Velocity modern forwarding, player info forwarding, and mod compatibility."
2+
title: "Gate Minecraft Proxy with Modded Servers - Fabric, Forge & NeoForge"
3+
description: "Complete guide to using Gate with modded Minecraft servers including Fabric, Forge 1.13-1.20.1, and NeoForge. Velocity modern forwarding, player info forwarding, and mod compatibility."
44
---
55

66
# Modded Server Compatibility
77

8-
Gate provides excellent compatibility with modded Minecraft servers including **Fabric** and **NeoForge**. This guide will help you set up Gate to work seamlessly with your modded servers.
8+
Gate provides excellent compatibility with modded Minecraft servers including **Fabric**, **Forge** (1.13–1.20.1), and **NeoForge**. This guide will help you set up Gate to work seamlessly with your modded servers.
99

1010
## Overview
1111

@@ -146,6 +146,52 @@ config:
146146

147147
:::
148148

149+
## Forge 1.13–1.20.1 Server Setup
150+
151+
Gate has built-in support for Forge 1.13–1.20.1 (FML2/FML3). During login, Gate relays the Forge mod negotiation (`fml:loginwrapper` LoginPluginMessages) between the backend and the client — no client-side mods required. This is similar to what [Ambassador](https://modrinth.com/plugin/ambassador) does for Velocity.
152+
153+
::: info Forge 1.20.2+ uses the CONFIG phase
154+
For Forge/NeoForge 1.20.2 and above, mod negotiation happens in the CONFIG phase (after login), which Gate handles natively. The login relay described here is only needed for 1.13–1.20.1.
155+
:::
156+
157+
### Required Mods (Server-Side Only)
158+
159+
For **Velocity modern forwarding**, install [Proxy-Compatible-Forge (PCF)](https://modrinth.com/mod/proxy-compatible-forge) on the Forge server to handle player info forwarding. For **legacy BungeeCord forwarding**, you can use [BungeeForge](https://github.com/caunt/BungeeForge) instead — no PCF needed.
160+
161+
### Server Configuration
162+
163+
::: code-group
164+
165+
```properties [server.properties]
166+
server-port=25566
167+
online-mode=false # [!code ++]
168+
```
169+
170+
```toml [config/proxy-compatible-forge.toml]
171+
[forwarding]
172+
enabled = true
173+
mode = "MODERN"
174+
secret = "your-secret-key-here" # [!code ++]
175+
```
176+
177+
```yaml [Gate config.yml]
178+
config:
179+
bind: 0.0.0.0:25565
180+
servers:
181+
forge-server: localhost:25566
182+
try:
183+
- forge-server
184+
forwarding:
185+
mode: velocity # [!code ++]
186+
velocitySecret: 'your-secret-key-here' # [!code ++]
187+
```
188+
189+
:::
190+
191+
### Server Switching
192+
193+
When switching a player between Forge servers, Gate replays the cached FML handshake responses from the initial connection. This works transparently when the servers have compatible mod lists (same mods and registries). If the servers are incompatible, the player will be disconnected.
194+
149195
## Multi-Server Setup
150196

151197
You can run both Fabric and NeoForge servers behind the same Gate proxy:

go.sum

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -370,20 +370,14 @@ google.golang.org/genproto v0.0.0-20180831171423-11092d34479b/go.mod h1:JiN7NxoA
370370
google.golang.org/genproto v0.0.0-20181029155118-b69ba1387ce2/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
371371
google.golang.org/genproto v0.0.0-20181202183823-bd91e49a0898/go.mod h1:7Ep/1NZk928CDR8SjdVbjWNpdIf6nzjE3BTgJDr2Atg=
372372
google.golang.org/genproto v0.0.0-20190306203927-b5d61aea6440/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
373-
google.golang.org/genproto/googleapis/api v0.0.0-20251029180050-ab9386a59fda h1:+2XxjfsAu6vqFxwGBRcHiMaDCuZiqXGDUDVWVtrFAnE=
374-
google.golang.org/genproto/googleapis/api v0.0.0-20251029180050-ab9386a59fda/go.mod h1:fDMmzKV90WSg1NbozdqrE64fkuTv6mlq2zxo9ad+3yo=
375373
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls=
376374
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto=
377-
google.golang.org/genproto/googleapis/rpc v0.0.0-20251029180050-ab9386a59fda h1:i/Q+bfisr7gq6feoJnS/DlpdwEL4ihp41fvRiM3Ork0=
378-
google.golang.org/genproto/googleapis/rpc v0.0.0-20251029180050-ab9386a59fda/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
379375
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=
380376
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
381377
google.golang.org/grpc v1.14.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw=
382378
google.golang.org/grpc v1.16.0/go.mod h1:0JHn/cJsOMiMfNA9+DeHDlAU7KAAB5GDlYFpa9MZMio=
383379
google.golang.org/grpc v1.17.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs=
384380
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
385-
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
386-
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
387381
google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
388382
google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
389383
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=

pkg/edition/java/proto/codec/decoder.go

Lines changed: 26 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"io"
1010
"os"
1111
"sync"
12+
"sync/atomic"
1213

1314
"github.com/go-logr/logr"
1415

@@ -26,47 +27,51 @@ type Decoder struct {
2627
hexDump bool // for debugging
2728
direction proto.Direction
2829

29-
mu sync.Mutex // Protects following field and locked while reading a packet.
30+
mu sync.Mutex // Protects following fields and locked while reading a packet.
3031
rd io.Reader // The underlying reader.
31-
registry *state.ProtocolRegistry
32-
state *state.Registry
3332
compression bool
3433
compressionThreshold int
3534
zrd io.ReadCloser
35+
36+
// registry and state use atomic pointers so SetState/SetProtocol can be
37+
// called without holding mu. This allows changing the decoder state while
38+
// another goroutine is blocked in Decode (waiting for network I/O).
39+
registry atomic.Pointer[state.ProtocolRegistry]
40+
state atomic.Pointer[state.Registry]
3641
}
3742

3843
var _ proto.PacketDecoder = (*Decoder)(nil)
3944

4045
func NewDecoder(r io.Reader, direction proto.Direction, log logr.Logger) *Decoder {
41-
return &Decoder{
46+
d := &Decoder{
4247
rd: &fullReader{r}, // using the fullReader is essential here!
4348
direction: direction,
44-
state: state.Handshake,
45-
registry: state.FromDirection(direction, state.Handshake, version.MinimumVersion.Protocol),
4649
log: log.WithName("decoder"),
4750
hexDump: os.Getenv("HEXDUMP") == "true",
4851
}
52+
d.state.Store(state.Handshake)
53+
d.registry.Store(state.FromDirection(direction, state.Handshake, version.MinimumVersion.Protocol))
54+
return d
4955
}
5056

5157
type fullReader struct{ io.Reader }
5258

5359
func (fr *fullReader) Read(p []byte) (int, error) { return io.ReadFull(fr.Reader, p) }
5460

55-
func (d *Decoder) SetState(state *state.Registry) {
56-
d.mu.Lock()
57-
d.state = state
58-
d.setProtocol(d.registry.Protocol)
59-
d.mu.Unlock()
61+
// SetState changes the decoder's protocol state (e.g., Login → Play).
62+
// This is safe to call while Decode is blocked on network I/O in another
63+
// goroutine — the new state takes effect on the next packet decode.
64+
func (d *Decoder) SetState(newState *state.Registry) {
65+
d.state.Store(newState)
66+
protocol := d.registry.Load().Protocol
67+
d.registry.Store(state.FromDirection(d.direction, newState, protocol))
6068
}
6169

70+
// SetProtocol changes the decoder's protocol version.
71+
// Safe to call concurrently with Decode.
6272
func (d *Decoder) SetProtocol(protocol proto.Protocol) {
63-
d.mu.Lock()
64-
d.setProtocol(protocol)
65-
d.mu.Unlock()
66-
}
67-
68-
func (d *Decoder) setProtocol(protocol proto.Protocol) {
69-
d.registry = state.FromDirection(d.direction, d.state, protocol)
73+
currentState := d.state.Load()
74+
d.registry.Store(state.FromDirection(d.direction, currentState, protocol))
7075
}
7176

7277
func (d *Decoder) SetReader(rd io.Reader) {
@@ -218,9 +223,10 @@ func (d *Decoder) decompress(claimedUncompressedSize int, rd io.Reader) (decompr
218223
// that is returned when the payload's data had more bytes than the decoder has read,
219224
// or drop the packet.
220225
func (d *Decoder) decodePayload(p []byte) (ctx *proto.PacketContext, err error) {
226+
registry := d.registry.Load()
221227
ctx = &proto.PacketContext{
222228
Direction: d.direction,
223-
Protocol: d.registry.Protocol,
229+
Protocol: registry.Protocol,
224230
Payload: p,
225231
}
226232
payload := bytes.NewReader(p)
@@ -234,7 +240,7 @@ func (d *Decoder) decodePayload(p []byte) (ctx *proto.PacketContext, err error)
234240
// Now the payload reader should only have left the packet's actual data.
235241

236242
// Try find and create packet from the id.
237-
ctx.Packet = d.registry.CreatePacket(ctx.PacketID)
243+
ctx.Packet = registry.CreatePacket(ctx.PacketID)
238244
if ctx.Packet == nil {
239245
// Packet id is unknown in this registry,
240246
// the payload is probably being forwarded as is.
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
package proxy
2+
3+
import (
4+
"sync"
5+
6+
"go.minekube.com/gate/pkg/edition/java/netmc"
7+
"go.minekube.com/gate/pkg/edition/java/proto/packet"
8+
"go.minekube.com/gate/pkg/edition/java/proxy/message"
9+
)
10+
11+
// ForgeLoginWrapperChannel is the Forge login wrapper channel used for FML2/FML3
12+
// mod negotiation during the LOGIN phase (Minecraft 1.13-1.20.1).
13+
const ForgeLoginWrapperChannel = "fml:loginwrapper"
14+
15+
// modernForgeLoginRelay relays LoginPluginMessages between a backend Forge server
16+
// and a client during the LOGIN phase. This enables Forge 1.13-1.20.1 mod negotiation
17+
// through the proxy when using Velocity modern forwarding.
18+
//
19+
// The relay uses loginInboundConn to send LoginPluginMessages to the client and
20+
// receive responses via consumer callbacks. The client's read loop goroutine
21+
// processes responses via the auth session handler's HandlePacket.
22+
type modernForgeLoginRelay struct {
23+
clientLogin *loginInboundConn
24+
player *connectedPlayer
25+
26+
// pendingLoginSuccess is the ServerLoginSuccess packet to send to the client
27+
// after the FML handshake completes.
28+
pendingLoginSuccess *packet.ServerLoginSuccess
29+
30+
mu sync.Mutex
31+
cachedExchanges []forgeLoginExchange
32+
}
33+
34+
// forgeLoginExchange records a single FML LoginPluginMessage exchange
35+
// between the backend and client, for replay during server switch.
36+
type forgeLoginExchange struct {
37+
channel string
38+
request []byte // data sent by backend
39+
response []byte // data from client (nil if rejected)
40+
}
41+
42+
func newModernForgeLoginRelay(
43+
clientLogin *loginInboundConn,
44+
player *connectedPlayer,
45+
pendingLoginSuccess *packet.ServerLoginSuccess,
46+
) *modernForgeLoginRelay {
47+
return &modernForgeLoginRelay{
48+
clientLogin: clientLogin,
49+
player: player,
50+
pendingLoginSuccess: pendingLoginSuccess,
51+
}
52+
}
53+
54+
// relayToClient forwards a backend LoginPluginMessage to the client via the
55+
// login plugin message mechanism. The consumer callback will forward the
56+
// client's response back to the backend.
57+
func (r *modernForgeLoginRelay) relayToClient(
58+
backendConn netmc.MinecraftConn,
59+
msg *packet.LoginPluginMessage,
60+
) error {
61+
identifier, err := message.ChannelIdentifierFrom(msg.Channel)
62+
if err != nil {
63+
return err
64+
}
65+
66+
data := msg.Data
67+
if len(data) == 0 {
68+
// SendLoginPluginMessage requires non-empty data.
69+
data = []byte{0}
70+
}
71+
72+
consumer := &forgeRelayConsumer{
73+
relay: r,
74+
backendConn: backendConn,
75+
backendMsgID: msg.ID,
76+
channel: msg.Channel,
77+
requestData: msg.Data,
78+
}
79+
return r.clientLogin.SendLoginPluginMessage(identifier, data, consumer)
80+
}
81+
82+
// complete sends the pending ServerLoginSuccess to the client,
83+
// completing the delayed login.
84+
func (r *modernForgeLoginRelay) complete() error {
85+
return r.player.WritePacket(r.pendingLoginSuccess)
86+
}
87+
88+
// exchanges returns a copy of the cached FML exchanges for server switch replay.
89+
func (r *modernForgeLoginRelay) exchanges() []forgeLoginExchange {
90+
r.mu.Lock()
91+
defer r.mu.Unlock()
92+
out := make([]forgeLoginExchange, len(r.cachedExchanges))
93+
copy(out, r.cachedExchanges)
94+
return out
95+
}
96+
97+
// forgeRelayConsumer forwards a client's LoginPluginResponse back to the
98+
// backend server. It also caches the exchange for future server switch replay.
99+
type forgeRelayConsumer struct {
100+
relay *modernForgeLoginRelay
101+
backendConn netmc.MinecraftConn
102+
backendMsgID int
103+
channel string
104+
requestData []byte
105+
}
106+
107+
func (c *forgeRelayConsumer) OnMessageResponse(responseBody []byte) error {
108+
// Cache the exchange for server switch replay.
109+
c.relay.mu.Lock()
110+
c.relay.cachedExchanges = append(c.relay.cachedExchanges, forgeLoginExchange{
111+
channel: c.channel,
112+
request: c.requestData,
113+
response: responseBody,
114+
})
115+
c.relay.mu.Unlock()
116+
117+
// Forward the response to the backend.
118+
return c.backendConn.WritePacket(&packet.LoginPluginResponse{
119+
ID: c.backendMsgID,
120+
Success: responseBody != nil,
121+
Data: responseBody,
122+
})
123+
}
124+
125+
// modernForgeReplayRelay replays cached FML LoginPluginMessage responses
126+
// during a server switch when the client is already in PLAY state and
127+
// cannot participate in a new LOGIN-phase FML handshake.
128+
type modernForgeReplayRelay struct {
129+
cachedExchanges []forgeLoginExchange
130+
mu sync.Mutex
131+
replayIndex int
132+
}
133+
134+
func newModernForgeReplayRelay(cached []forgeLoginExchange) *modernForgeReplayRelay {
135+
return &modernForgeReplayRelay{
136+
cachedExchanges: cached,
137+
}
138+
}
139+
140+
// replayResponse sends the next cached response to the backend.
141+
// If no more cached responses are available, sends Success=false.
142+
func (r *modernForgeReplayRelay) replayResponse(
143+
backendMsgID int,
144+
backendConn netmc.MinecraftConn,
145+
) error {
146+
r.mu.Lock()
147+
defer r.mu.Unlock()
148+
149+
if r.replayIndex >= len(r.cachedExchanges) {
150+
return backendConn.WritePacket(&packet.LoginPluginResponse{
151+
ID: backendMsgID,
152+
Success: false,
153+
})
154+
}
155+
156+
exchange := r.cachedExchanges[r.replayIndex]
157+
r.replayIndex++
158+
159+
return backendConn.WritePacket(&packet.LoginPluginResponse{
160+
ID: backendMsgID,
161+
Success: exchange.response != nil,
162+
Data: exchange.response,
163+
})
164+
}

0 commit comments

Comments
 (0)