Skip to content

Commit d12d659

Browse files
authored
feat: add Minecraft 26.1 (protocol 775) support (#679)
* fix: Modern Forge (FML2/FML3) proxy support and LoginPluginMessage codec Fix several bugs preventing Forge 1.13-1.20.1 clients from connecting through Gate to modded backend servers: - Fix LoginPluginMessage to use raw bytes instead of length-prefixed encoding, matching the Minecraft protocol spec and LoginPluginResponse. This also fixes Velocity forwarding version negotiation which silently fell back to v1. - Add FML2/FML3 marker detection in handshake connection type. Forge 1.13-1.20.1 clients use these tokens instead of FORGE. - Preserve FML2/FML3 tokens in ModernToken() when connecting to backend. - Add BungeeForge extraData property to legacy and BungeeGuard forwarding so backend servers with BungeeForge can identify Forge clients. - Add omitempty to Property.Signature JSON tag to avoid empty signatures in forwarded properties. Closes #613 * test: add Forge e2e tests verifying FML2/FML3 proxy flow Add end-to-end tests that simulate the full Forge client connection flow: handshake detection → backend token preservation → legacy forwarding with BungeeForge extraData → LoginPluginMessage decoding. These tests confirm that all bugs from #613 are caught (verified by running against pre-fix code where they all fail) and fixed. * feat: add Minecraft 26.1 (protocol 775) support Port packet ID mappings from Velocity for the new Minecraft 26.1 version. Synced from: robinbraemer/Velocity#10 * feat: add ServerIdHash to LoginEvent Port server ID hash from Velocity's LoginEvent so plugins can access the hash sent to Mojang during online-mode authentication. * fix: add pre-sizing caps and queue limits to prevent memory exhaustion Port security hardening from Velocity: - Cap pre-allocation of arrays decoded from untrusted packet data to 32768 entries (ReadStringArray, ReadVarIntArray, ReadIntArray, AvailableCommands wireNodes) to prevent memory bombs - Add negative length validation to ReadStringArray - Add max queue length (1024 packets) to PlayPacketQueue to prevent unbounded memory growth from malicious or buggy peers * fix: cap pre-allocation in more packet decode methods Cap slice/map pre-allocations from untrusted VarInt lengths in: - playerinfo.Remove: cap UUID slice allocation - CustomReportDetails: cap map allocation - KnownPacks: cap slice allocation for clientbound direction * test: add tests for 26.1 packet IDs, reader caps, and queue limit - Test all 28 packet ID mappings for Minecraft 26.1 (serverbound + clientbound) - Test ReadStringArray, ReadVarIntArray, ReadIntArray negative length rejection - Test capped allocation prevents OOM from huge untrusted lengths - Test roundtrip encode/decode for string and varint arrays - Test PlayPacketQueue max limit and nil safety * refactor: simplify pre-alloc caps and fix naming - Export MaxPreAllocSize constant and use it everywhere instead of hardcoded 1<<15 magic numbers - Harden ReadKeyArray and ReadProperties with same pre-alloc cap (missed in initial hardening pass) - Rename ServerIdHash to ServerIDHash per Go naming conventions * style: go fmt * fix: check error returns in tests (lint) * fix: use %g format for float32 quota.OPS in validation error
1 parent 2217f5f commit d12d659

26 files changed

Lines changed: 873 additions & 41 deletions

pkg/edition/java/config/config.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -204,7 +204,7 @@ func (c *Config) Validate() (warns []error, errs []error) {
204204
for _, quota := range []QuotaSettings{c.Quota.Connections, c.Quota.Logins} {
205205
if quota.Enabled {
206206
if quota.OPS <= 0 {
207-
e("Invalid quota ops %d, use a number > 0", quota.OPS)
207+
e("Invalid quota ops %g, use a number > 0", quota.OPS)
208208
}
209209
if quota.Burst < 1 {
210210
e("Invalid quota burst %d, use a number >= 1", quota.Burst)

pkg/edition/java/forge/modernforge/modern.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,17 @@ import (
99
const Token = "FORGE"
1010

1111
// ModernToken aligns the acquisition logic with the internal code of Forge.
12+
// It preserves FML2/FML3 tokens for Forge 1.13-1.20.1, and FORGE tokens for 1.20.2+.
1213
func ModernToken(hostName string) string {
1314
natVersion := 0
1415
idx := strings.Index(hostName, "\000")
1516
if idx != -1 {
1617
for _, pt := range strings.Split(hostName, "\000") {
18+
// FML2 (1.13-1.17) and FML3 (1.18-1.20.1) use their own tokens
19+
// with trailing null bytes as part of the Forge handshake format.
20+
if strings.HasPrefix(pt, "FML2") || strings.HasPrefix(pt, "FML3") {
21+
return "\000" + pt + "\000"
22+
}
1723
if strings.HasPrefix(pt, Token) {
1824
if len(pt) > len(Token) {
1925
natVersion, _ = strconv.Atoi(pt[len(Token):])
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
package modernforge
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/assert"
7+
)
8+
9+
func TestModernToken(t *testing.T) {
10+
tests := []struct {
11+
name string
12+
hostName string
13+
want string
14+
}{
15+
{
16+
name: "FORGE token without NAT version",
17+
hostName: "server.example.com\000FORGE",
18+
want: "\000FORGE",
19+
},
20+
{
21+
name: "FORGE token with NAT version 2",
22+
hostName: "server.example.com\000FORGE2",
23+
want: "\000FORGE2",
24+
},
25+
{
26+
name: "FML2 token (Forge 1.13-1.17)",
27+
hostName: "server.example.com\000FML2\000",
28+
want: "\000FML2\000",
29+
},
30+
{
31+
name: "FML3 token (Forge 1.18-1.20.1)",
32+
hostName: "server.example.com\000FML3\000",
33+
want: "\000FML3\000",
34+
},
35+
{
36+
name: "no token",
37+
hostName: "server.example.com",
38+
want: "\000FORGE",
39+
},
40+
}
41+
42+
for _, tt := range tests {
43+
t.Run(tt.name, func(t *testing.T) {
44+
got := ModernToken(tt.hostName)
45+
assert.Equal(t, tt.want, got)
46+
})
47+
}
48+
}

pkg/edition/java/netmc/connection.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -344,7 +344,11 @@ func (c *minecraftConn) bufferPacket(packet proto.Packet, canQueue bool) (err er
344344
c.mu.Lock()
345345
playPacketQueue := c.playPacketQueue
346346
c.mu.Unlock()
347-
if playPacketQueue.Queue(packet) {
347+
queued, queueErr := playPacketQueue.Queue(packet)
348+
if queueErr != nil {
349+
return queueErr
350+
}
351+
if queued {
348352
// Packet was queued, don't write it now
349353
c.log.V(1).Info("queued packet", "packet", fmt.Sprintf("%T", packet))
350354
return nil

pkg/edition/java/profile/gameprofile.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ func (g *GameProfile) UnmarshalJSON(data []byte) (err error) {
5757
type Property struct {
5858
Name string `json:"name"`
5959
Value string `json:"value"`
60-
Signature string `json:"signature"`
60+
Signature string `json:"signature,omitempty"`
6161
}
6262

6363
func (p *Property) String() string {

pkg/edition/java/proto/packet/available_commands.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -155,13 +155,13 @@ func (a *AvailableCommands) Decode(c *proto.PacketContext, rd io.Reader) error {
155155
if err != nil {
156156
return err
157157
}
158-
wireNodes := make([]*WireNode, commands)
158+
wireNodes := make([]*WireNode, 0, min(commands, util.MaxPreAllocSize))
159159
for i := 0; i < commands; i++ {
160160
wn := &WireNode{IDx: i}
161161
if err = wn.decode(rd, c.Protocol); err != nil {
162162
return err
163163
}
164-
wireNodes[i] = wn
164+
wireNodes = append(wireNodes, wn)
165165
}
166166

167167
var ok bool

pkg/edition/java/proto/packet/config/KnownPacks.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,11 @@ func (p *KnownPacks) Decode(c *proto.PacketContext, rd io.Reader) error {
2626
if c.Direction == proto.ServerBound && packCount > MaxLengthPacks {
2727
return fmt.Errorf("%w: %d", ErrTooManyPacks, packCount)
2828
}
29-
packs := make([]KnownPack, packCount)
29+
packs := make([]KnownPack, 0, min(packCount, MaxLengthPacks))
3030
for i := 0; i < packCount; i++ {
3131
var pack KnownPack
3232
pack.Read(rd)
33-
packs[i] = pack
33+
packs = append(packs, pack)
3434
}
3535
p.Packs = packs
3636
return nil

pkg/edition/java/proto/packet/customreportdetails.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ func (p *CustomReportDetails) Decode(c *proto.PacketContext, rd io.Reader) (err
2424
r := protoutil.PanicReader(rd)
2525
var detailsCount int
2626
r.VarInt(&detailsCount)
27-
p.Details = make(map[string]string, detailsCount)
27+
p.Details = make(map[string]string, min(detailsCount, protoutil.MaxPreAllocSize))
2828
for i := 0; i < detailsCount; i++ {
2929
var key, value string
3030
r.String(&key)

pkg/edition/java/proto/packet/login.go

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -412,15 +412,18 @@ func (l *LoginPluginMessage) Encode(_ *proto.PacketContext, wr io.Writer) error
412412
w := util.PanicWriter(wr)
413413
w.VarInt(l.ID)
414414
w.String(l.Channel)
415-
w.Bytes(l.Data)
416-
return nil
415+
// Data is the remaining bytes in the packet (not length-prefixed),
416+
// same format as LoginPluginResponse.Data.
417+
return util.WriteRawBytes(wr, l.Data)
417418
}
418419

419420
func (l *LoginPluginMessage) Decode(_ *proto.PacketContext, rd io.Reader) (err error) {
420421
r := util.PanicReader(rd)
421422
r.VarInt(&l.ID)
422423
r.String(&l.Channel)
423-
l.Data, err = util.ReadBytes(rd)
424+
// Data is the remaining bytes in the packet (not length-prefixed),
425+
// same format as LoginPluginResponse.Data.
426+
l.Data, err = util.ReadRawBytes(rd)
424427
if errors.Is(err, io.EOF) {
425428
// Ignore if we couldn't read data
426429
return nil
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
package packet
2+
3+
import (
4+
"bytes"
5+
"testing"
6+
7+
"github.com/stretchr/testify/assert"
8+
"github.com/stretchr/testify/require"
9+
"go.minekube.com/gate/pkg/edition/java/proto/util"
10+
"go.minekube.com/gate/pkg/edition/java/proto/version"
11+
"go.minekube.com/gate/pkg/gate/proto"
12+
)
13+
14+
// TestLoginPluginMessage_WireFormat verifies that LoginPluginMessage.Data is encoded
15+
// as raw remaining bytes (NOT length-prefixed), matching the Minecraft protocol spec
16+
// and consistent with LoginPluginResponse which already uses raw bytes.
17+
func TestLoginPluginMessage_WireFormat(t *testing.T) {
18+
msg := &LoginPluginMessage{
19+
ID: 42,
20+
Channel: "velocity:player_info",
21+
Data: []byte{0x04}, // e.g. forwarding version 4
22+
}
23+
24+
ctx := &proto.PacketContext{
25+
Direction: proto.ClientBound,
26+
Protocol: version.Minecraft_1_20_2.Protocol,
27+
}
28+
29+
// Encode
30+
var buf bytes.Buffer
31+
require.NoError(t, msg.Encode(ctx, &buf))
32+
encoded := buf.Bytes()
33+
34+
// Manually build the expected wire format:
35+
// VarInt(42) + String("velocity:player_info") + raw byte 0x04
36+
var expected bytes.Buffer
37+
util.PanicWriter(&expected).VarInt(42)
38+
util.PanicWriter(&expected).String("velocity:player_info")
39+
expected.WriteByte(0x04) // raw data, no length prefix
40+
assert.Equal(t, expected.Bytes(), encoded,
41+
"LoginPluginMessage data must be written as raw bytes, not length-prefixed")
42+
43+
// Verify decode reads raw bytes (no length prefix)
44+
decoded := &LoginPluginMessage{}
45+
require.NoError(t, decoded.Decode(ctx, bytes.NewReader(encoded)))
46+
assert.Equal(t, 42, decoded.ID)
47+
assert.Equal(t, "velocity:player_info", decoded.Channel)
48+
assert.Equal(t, []byte{0x04}, decoded.Data,
49+
"Decoded data should be the raw remaining bytes")
50+
}
51+
52+
// TestLoginPluginMessage_MultiByteData tests with larger data payloads.
53+
func TestLoginPluginMessage_MultiByteData(t *testing.T) {
54+
data := []byte("hello forge handshake data")
55+
original := &LoginPluginMessage{
56+
ID: 1,
57+
Channel: "fml:loginwrapper",
58+
Data: data,
59+
}
60+
61+
ctx := &proto.PacketContext{
62+
Direction: proto.ClientBound,
63+
Protocol: version.Minecraft_1_20.Protocol,
64+
}
65+
66+
var buf bytes.Buffer
67+
require.NoError(t, original.Encode(ctx, &buf))
68+
69+
// Build expected wire format manually
70+
var expected bytes.Buffer
71+
util.PanicWriter(&expected).VarInt(1)
72+
util.PanicWriter(&expected).String("fml:loginwrapper")
73+
expected.Write(data) // raw data
74+
assert.Equal(t, expected.Bytes(), buf.Bytes(),
75+
"Multi-byte data must be written as raw bytes")
76+
77+
decoded := &LoginPluginMessage{}
78+
require.NoError(t, decoded.Decode(ctx, bytes.NewReader(buf.Bytes())))
79+
assert.Equal(t, original.ID, decoded.ID)
80+
assert.Equal(t, original.Channel, decoded.Channel)
81+
assert.Equal(t, data, decoded.Data)
82+
}
83+
84+
// TestLoginPluginMessage_EmptyData tests with no data payload.
85+
func TestLoginPluginMessage_EmptyData(t *testing.T) {
86+
original := &LoginPluginMessage{
87+
ID: 5,
88+
Channel: "test:channel",
89+
Data: nil,
90+
}
91+
92+
ctx := &proto.PacketContext{
93+
Direction: proto.ClientBound,
94+
Protocol: version.Minecraft_1_20.Protocol,
95+
}
96+
97+
var buf bytes.Buffer
98+
require.NoError(t, original.Encode(ctx, &buf))
99+
100+
decoded := &LoginPluginMessage{}
101+
require.NoError(t, decoded.Decode(ctx, &buf))
102+
103+
assert.Equal(t, original.ID, decoded.ID)
104+
assert.Equal(t, original.Channel, decoded.Channel)
105+
assert.Empty(t, decoded.Data)
106+
}

0 commit comments

Comments
 (0)