Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,5 @@
tmp
.geyser
./gate
.DS_Store
config-forge-test.yml
11 changes: 9 additions & 2 deletions .web/docs/guide/compatibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,11 +51,18 @@ Gate has excellent compatibility with modded Minecraft servers:
- **Command support** - Proper handling of modded commands
- **Cross-version support** - Works with various Minecraft versions

### Legacy Forge
### Forge 1.13–1.20.1 (FML2/FML3) <VPBadge>Fully Supported</VPBadge>

- **All forwarding modes** - Velocity modern forwarding (with [PCF](https://modrinth.com/mod/proxy-compatible-forge)), BungeeCord, and BungeeGuard
- **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
- **No client-side mods required** - The player doesn't need any special mods
- **Server switch support** - Cached FML responses are replayed for compatible server switches

### Legacy Forge (1.8–1.12.2)

- **Limited support** - Basic functionality works
- **Legacy forwarding only** - Use BungeeCord forwarding
- **Older versions** - 1.12.2 and below may have compatibility issues
- **Older versions** - May have compatibility issues

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

Expand Down
52 changes: 49 additions & 3 deletions .web/docs/guide/modded-servers.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
---
title: "Gate Minecraft Proxy with Modded Servers - Fabric & NeoForge"
description: "Complete guide to using Gate with modded Minecraft servers including Fabric and NeoForge. Velocity modern forwarding, player info forwarding, and mod compatibility."
title: "Gate Minecraft Proxy with Modded Servers - Fabric, Forge & NeoForge"
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."
---

# Modded Server Compatibility

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.
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.

## Overview

Expand Down Expand Up @@ -146,6 +146,52 @@ config:

:::

## Forge 1.13–1.20.1 Server Setup

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.

::: info Forge 1.20.2+ uses the CONFIG phase
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.
:::

### Required Mods (Server-Side Only)

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.

### Server Configuration

::: code-group

```properties [server.properties]
server-port=25566
online-mode=false # [!code ++]
```

```toml [config/proxy-compatible-forge.toml]
[forwarding]
enabled = true
mode = "MODERN"
secret = "your-secret-key-here" # [!code ++]
```

```yaml [Gate config.yml]
config:
bind: 0.0.0.0:25565
servers:
forge-server: localhost:25566
try:
- forge-server
forwarding:
mode: velocity # [!code ++]
velocitySecret: 'your-secret-key-here' # [!code ++]
```

:::

### Server Switching

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.

## Multi-Server Setup

You can run both Fabric and NeoForge servers behind the same Gate proxy:
Expand Down
6 changes: 0 additions & 6 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -370,20 +370,14 @@ google.golang.org/genproto v0.0.0-20180831171423-11092d34479b/go.mod h1:JiN7NxoA
google.golang.org/genproto v0.0.0-20181029155118-b69ba1387ce2/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20181202183823-bd91e49a0898/go.mod h1:7Ep/1NZk928CDR8SjdVbjWNpdIf6nzjE3BTgJDr2Atg=
google.golang.org/genproto v0.0.0-20190306203927-b5d61aea6440/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto/googleapis/api v0.0.0-20251029180050-ab9386a59fda h1:+2XxjfsAu6vqFxwGBRcHiMaDCuZiqXGDUDVWVtrFAnE=
google.golang.org/genproto/googleapis/api v0.0.0-20251029180050-ab9386a59fda/go.mod h1:fDMmzKV90WSg1NbozdqrE64fkuTv6mlq2zxo9ad+3yo=
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls=
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251029180050-ab9386a59fda h1:i/Q+bfisr7gq6feoJnS/DlpdwEL4ihp41fvRiM3Ork0=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251029180050-ab9386a59fda/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/grpc v1.14.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw=
google.golang.org/grpc v1.16.0/go.mod h1:0JHn/cJsOMiMfNA9+DeHDlAU7KAAB5GDlYFpa9MZMio=
google.golang.org/grpc v1.17.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
Expand Down
46 changes: 26 additions & 20 deletions pkg/edition/java/proto/codec/decoder.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"io"
"os"
"sync"
"sync/atomic"

"github.com/go-logr/logr"

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

mu sync.Mutex // Protects following field and locked while reading a packet.
mu sync.Mutex // Protects following fields and locked while reading a packet.
rd io.Reader // The underlying reader.
registry *state.ProtocolRegistry
state *state.Registry
compression bool
compressionThreshold int
zrd io.ReadCloser

// registry and state use atomic pointers so SetState/SetProtocol can be
// called without holding mu. This allows changing the decoder state while
// another goroutine is blocked in Decode (waiting for network I/O).
registry atomic.Pointer[state.ProtocolRegistry]
state atomic.Pointer[state.Registry]
}

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

func NewDecoder(r io.Reader, direction proto.Direction, log logr.Logger) *Decoder {
return &Decoder{
d := &Decoder{
rd: &fullReader{r}, // using the fullReader is essential here!
direction: direction,
state: state.Handshake,
registry: state.FromDirection(direction, state.Handshake, version.MinimumVersion.Protocol),
log: log.WithName("decoder"),
hexDump: os.Getenv("HEXDUMP") == "true",
}
d.state.Store(state.Handshake)
d.registry.Store(state.FromDirection(direction, state.Handshake, version.MinimumVersion.Protocol))
return d
}

type fullReader struct{ io.Reader }

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

func (d *Decoder) SetState(state *state.Registry) {
d.mu.Lock()
d.state = state
d.setProtocol(d.registry.Protocol)
d.mu.Unlock()
// SetState changes the decoder's protocol state (e.g., Login → Play).
// This is safe to call while Decode is blocked on network I/O in another
// goroutine — the new state takes effect on the next packet decode.
func (d *Decoder) SetState(newState *state.Registry) {
d.state.Store(newState)
protocol := d.registry.Load().Protocol
d.registry.Store(state.FromDirection(d.direction, newState, protocol))
}

// SetProtocol changes the decoder's protocol version.
// Safe to call concurrently with Decode.
func (d *Decoder) SetProtocol(protocol proto.Protocol) {
d.mu.Lock()
d.setProtocol(protocol)
d.mu.Unlock()
}

func (d *Decoder) setProtocol(protocol proto.Protocol) {
d.registry = state.FromDirection(d.direction, d.state, protocol)
currentState := d.state.Load()
d.registry.Store(state.FromDirection(d.direction, currentState, protocol))
}

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

// Try find and create packet from the id.
ctx.Packet = d.registry.CreatePacket(ctx.PacketID)
ctx.Packet = registry.CreatePacket(ctx.PacketID)
if ctx.Packet == nil {
// Packet id is unknown in this registry,
// the payload is probably being forwarded as is.
Expand Down
164 changes: 164 additions & 0 deletions pkg/edition/java/proxy/forge_login_relay.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
package proxy

import (
"sync"

"go.minekube.com/gate/pkg/edition/java/netmc"
"go.minekube.com/gate/pkg/edition/java/proto/packet"
"go.minekube.com/gate/pkg/edition/java/proxy/message"
)

// ForgeLoginWrapperChannel is the Forge login wrapper channel used for FML2/FML3
// mod negotiation during the LOGIN phase (Minecraft 1.13-1.20.1).
const ForgeLoginWrapperChannel = "fml:loginwrapper"

// modernForgeLoginRelay relays LoginPluginMessages between a backend Forge server
// and a client during the LOGIN phase. This enables Forge 1.13-1.20.1 mod negotiation
// through the proxy when using Velocity modern forwarding.
//
// The relay uses loginInboundConn to send LoginPluginMessages to the client and
// receive responses via consumer callbacks. The client's read loop goroutine
// processes responses via the auth session handler's HandlePacket.
type modernForgeLoginRelay struct {
clientLogin *loginInboundConn
player *connectedPlayer

// pendingLoginSuccess is the ServerLoginSuccess packet to send to the client
// after the FML handshake completes.
pendingLoginSuccess *packet.ServerLoginSuccess

mu sync.Mutex
cachedExchanges []forgeLoginExchange
}

// forgeLoginExchange records a single FML LoginPluginMessage exchange
// between the backend and client, for replay during server switch.
type forgeLoginExchange struct {
channel string
request []byte // data sent by backend
response []byte // data from client (nil if rejected)
}

func newModernForgeLoginRelay(
clientLogin *loginInboundConn,
player *connectedPlayer,
pendingLoginSuccess *packet.ServerLoginSuccess,
) *modernForgeLoginRelay {
return &modernForgeLoginRelay{
clientLogin: clientLogin,
player: player,
pendingLoginSuccess: pendingLoginSuccess,
}
}

// relayToClient forwards a backend LoginPluginMessage to the client via the
// login plugin message mechanism. The consumer callback will forward the
// client's response back to the backend.
func (r *modernForgeLoginRelay) relayToClient(
backendConn netmc.MinecraftConn,
msg *packet.LoginPluginMessage,
) error {
identifier, err := message.ChannelIdentifierFrom(msg.Channel)
if err != nil {
return err
}

data := msg.Data
if len(data) == 0 {
// SendLoginPluginMessage requires non-empty data.
data = []byte{0}
}

consumer := &forgeRelayConsumer{
relay: r,
backendConn: backendConn,
backendMsgID: msg.ID,
channel: msg.Channel,
requestData: msg.Data,
}
return r.clientLogin.SendLoginPluginMessage(identifier, data, consumer)
}

// complete sends the pending ServerLoginSuccess to the client,
// completing the delayed login.
func (r *modernForgeLoginRelay) complete() error {
return r.player.WritePacket(r.pendingLoginSuccess)
}

// exchanges returns a copy of the cached FML exchanges for server switch replay.
func (r *modernForgeLoginRelay) exchanges() []forgeLoginExchange {
r.mu.Lock()
defer r.mu.Unlock()
out := make([]forgeLoginExchange, len(r.cachedExchanges))
copy(out, r.cachedExchanges)
return out
}

// forgeRelayConsumer forwards a client's LoginPluginResponse back to the
// backend server. It also caches the exchange for future server switch replay.
type forgeRelayConsumer struct {
relay *modernForgeLoginRelay
backendConn netmc.MinecraftConn
backendMsgID int
channel string
requestData []byte
}

func (c *forgeRelayConsumer) OnMessageResponse(responseBody []byte) error {
// Cache the exchange for server switch replay.
c.relay.mu.Lock()
c.relay.cachedExchanges = append(c.relay.cachedExchanges, forgeLoginExchange{
channel: c.channel,
request: c.requestData,
response: responseBody,
})
c.relay.mu.Unlock()

// Forward the response to the backend.
return c.backendConn.WritePacket(&packet.LoginPluginResponse{
ID: c.backendMsgID,
Success: responseBody != nil,
Data: responseBody,
})
}

// modernForgeReplayRelay replays cached FML LoginPluginMessage responses
// during a server switch when the client is already in PLAY state and
// cannot participate in a new LOGIN-phase FML handshake.
type modernForgeReplayRelay struct {
cachedExchanges []forgeLoginExchange
mu sync.Mutex
replayIndex int
}

func newModernForgeReplayRelay(cached []forgeLoginExchange) *modernForgeReplayRelay {
return &modernForgeReplayRelay{
cachedExchanges: cached,
}
}

// replayResponse sends the next cached response to the backend.
// If no more cached responses are available, sends Success=false.
func (r *modernForgeReplayRelay) replayResponse(
backendMsgID int,
backendConn netmc.MinecraftConn,
) error {
r.mu.Lock()
defer r.mu.Unlock()

if r.replayIndex >= len(r.cachedExchanges) {
return backendConn.WritePacket(&packet.LoginPluginResponse{
ID: backendMsgID,
Success: false,
})
}

exchange := r.cachedExchanges[r.replayIndex]
r.replayIndex++

return backendConn.WritePacket(&packet.LoginPluginResponse{
ID: backendMsgID,
Success: exchange.response != nil,
Data: exchange.response,
})
}
Loading
Loading