fix
This commit is contained in:
@@ -0,0 +1,372 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/md5"
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/iceBear67/redapricot/client/wire"
|
||||
)
|
||||
|
||||
// Velocity "modern forwarding" support.
|
||||
//
|
||||
// A Paper backend configured with Velocity modern player-info forwarding runs
|
||||
// in offline mode and instead trusts a signed login payload from its proxy:
|
||||
// during the login phase it sends a Login Plugin Request on channel
|
||||
// "velocity:player_info" and expects a Login Plugin Response whose data is an
|
||||
// HMAC-SHA256 signature followed by the player's real address, UUID, username
|
||||
// and profile properties.
|
||||
//
|
||||
// redapricot is a transparent tunnel, so that request would reach the vanilla
|
||||
// player, who cannot answer it and gets kicked. When a mapping sets
|
||||
// "velocitySecret", the stream answers on the player's behalf: it observes the
|
||||
// player's Handshake and Login Start to learn the protocol version, username
|
||||
// and UUID, swallows the backend's velocity:player_info request instead of
|
||||
// forwarding it, and injects the signed response. Everything else — and
|
||||
// everything after the exchange — is forwarded verbatim. On any traffic that
|
||||
// does not look like a vanilla login (status pings, parse errors, oversized
|
||||
// packets) the stream fails open into pure passthrough.
|
||||
//
|
||||
// The forwarded profile carries no properties (skin/cape textures): the tunnel
|
||||
// never talks to Mojang, exactly like an offline-mode proxy.
|
||||
const (
|
||||
velocityChannel = "velocity:player_info"
|
||||
|
||||
// Forwarding payload versions (Velocity's VelocityConstants). We never use
|
||||
// versions 2/3 (WITH_KEY): they exist only for 1.19–1.19.2 chat signing,
|
||||
// and version 1 remains acceptable to every backend.
|
||||
velocityVersionDefault = 1
|
||||
velocityVersionLazySession = 4
|
||||
|
||||
// Minecraft protocol versions at which the Login Start layout changes.
|
||||
protocol1_19 = 759 // + optional signature key
|
||||
protocol1_19_1 = 760 // + optional profile UUID (after the key)
|
||||
protocol1_19_3 = 761 // key removed, optional UUID stays
|
||||
protocol1_20_2 = 764 // UUID mandatory
|
||||
|
||||
// Handshake intents that enter the login phase.
|
||||
intentLogin = 2
|
||||
intentTransfer = 3
|
||||
|
||||
// Login-phase packet ids (stable across protocol versions).
|
||||
loginC2SPluginResponse = 0x02
|
||||
loginS2CDisconnect = 0x00
|
||||
loginS2CEncryptionRequest = 0x01
|
||||
loginS2CSuccess = 0x02
|
||||
loginS2CSetCompression = 0x03
|
||||
loginS2CPluginRequest = 0x04
|
||||
|
||||
// Sniff-buffer caps. Login-phase packets are small; anything larger means
|
||||
// this is not the exchange we are looking for.
|
||||
maxC2SSniff = 8 << 10
|
||||
maxS2CSniff = 64 << 10
|
||||
)
|
||||
|
||||
var errVelocitySniff = errors.New("velocity: connection does not follow the vanilla login flow")
|
||||
|
||||
// velocityForwarder is the per-stream login interceptor. ObserveC2S is called
|
||||
// from the worker read loop, ProcessS2C from the stream's destination-read
|
||||
// goroutine; the mutex orders them, and passthrough short-circuits both once
|
||||
// interception is over.
|
||||
type velocityForwarder struct {
|
||||
passthrough atomic.Bool // fully transparent, buffers empty: skip the mutex
|
||||
|
||||
mu sync.Mutex
|
||||
secret []byte
|
||||
srcIP string
|
||||
|
||||
// player -> server observation
|
||||
c2sBuf []byte
|
||||
c2sDone bool
|
||||
handshakeParsed bool
|
||||
protocol int
|
||||
loginStartSeen bool
|
||||
username string
|
||||
uuid [16]byte
|
||||
|
||||
// server -> player interception; done means the s2c side (and with it the
|
||||
// whole interceptor) is finished.
|
||||
s2cBuf []byte
|
||||
done bool
|
||||
}
|
||||
|
||||
func newVelocityForwarder(secret, srcIP string) *velocityForwarder {
|
||||
return &velocityForwarder{secret: []byte(secret), srcIP: srcIP}
|
||||
}
|
||||
|
||||
// Passthrough reports that interception is over and both directions may skip
|
||||
// the forwarder entirely.
|
||||
func (v *velocityForwarder) Passthrough() bool { return v.passthrough.Load() }
|
||||
|
||||
// abortLocked gives up on interception: the stream becomes pure passthrough.
|
||||
// s2cBuf is deliberately kept — ProcessS2C flushes it to the player.
|
||||
func (v *velocityForwarder) abortLocked() {
|
||||
v.done = true
|
||||
v.c2sDone = true
|
||||
v.c2sBuf = nil
|
||||
if len(v.s2cBuf) == 0 {
|
||||
v.passthrough.Store(true)
|
||||
}
|
||||
}
|
||||
|
||||
// ObserveC2S watches player->server bytes (already being forwarded verbatim by
|
||||
// the caller) until the Handshake and Login Start have been parsed.
|
||||
func (v *velocityForwarder) ObserveC2S(data []byte) {
|
||||
if v.passthrough.Load() {
|
||||
return
|
||||
}
|
||||
v.mu.Lock()
|
||||
defer v.mu.Unlock()
|
||||
if v.c2sDone {
|
||||
return
|
||||
}
|
||||
v.c2sBuf = append(v.c2sBuf, data...)
|
||||
for !v.c2sDone {
|
||||
_, body, rest, ok, err := nextPacket(v.c2sBuf, maxC2SSniff)
|
||||
if err != nil {
|
||||
v.abortLocked()
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
if len(v.c2sBuf) > maxC2SSniff {
|
||||
v.abortLocked()
|
||||
}
|
||||
return
|
||||
}
|
||||
v.c2sBuf = rest
|
||||
if err := v.observeC2SPacket(body); err != nil {
|
||||
v.abortLocked()
|
||||
return
|
||||
}
|
||||
}
|
||||
v.c2sBuf = nil
|
||||
}
|
||||
|
||||
// observeC2SPacket handles one player packet: first the Handshake, then Login
|
||||
// Start. Any deviation from the vanilla login flow is an error (→ fail open).
|
||||
func (v *velocityForwarder) observeC2SPacket(body []byte) error {
|
||||
r := wire.NewReader(body)
|
||||
id, err := r.VarInt()
|
||||
if err != nil || id != 0x00 { // Handshake and Login Start are both 0x00
|
||||
return errVelocitySniff
|
||||
}
|
||||
if !v.handshakeParsed {
|
||||
proto, perr := r.VarInt()
|
||||
_, aerr := r.String() // address
|
||||
_, poerr := r.U16() // port
|
||||
intent, ierr := r.VarInt()
|
||||
if perr != nil || aerr != nil || poerr != nil || ierr != nil {
|
||||
return errVelocitySniff
|
||||
}
|
||||
if intent != intentLogin && intent != intentTransfer {
|
||||
return errVelocitySniff // status ping etc.: nothing to intercept
|
||||
}
|
||||
v.protocol = proto
|
||||
v.handshakeParsed = true
|
||||
return nil
|
||||
}
|
||||
return v.parseLoginStart(r)
|
||||
}
|
||||
|
||||
func (v *velocityForwarder) parseLoginStart(r *wire.Reader) error {
|
||||
name, err := r.String()
|
||||
if err != nil || len(name) == 0 || len(name) > 16 {
|
||||
return errVelocitySniff
|
||||
}
|
||||
if v.protocol >= protocol1_19 && v.protocol < protocol1_19_3 {
|
||||
// Optional chat-signing key: expiry + public key + signature.
|
||||
hasKey, err := r.U8()
|
||||
if err != nil {
|
||||
return errVelocitySniff
|
||||
}
|
||||
if hasKey != 0 {
|
||||
if _, err := r.I64(); err != nil {
|
||||
return errVelocitySniff
|
||||
}
|
||||
for i := 0; i < 2; i++ {
|
||||
n, err := r.VarInt()
|
||||
if err != nil {
|
||||
return errVelocitySniff
|
||||
}
|
||||
if _, err := r.Bytes(n); err != nil {
|
||||
return errVelocitySniff
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
haveUUID := false
|
||||
switch {
|
||||
case v.protocol >= protocol1_20_2:
|
||||
haveUUID = true
|
||||
case v.protocol >= protocol1_19_1:
|
||||
flag, err := r.U8()
|
||||
if err != nil {
|
||||
return errVelocitySniff
|
||||
}
|
||||
haveUUID = flag != 0
|
||||
}
|
||||
if haveUUID {
|
||||
b, err := r.Bytes(16)
|
||||
if err != nil {
|
||||
return errVelocitySniff
|
||||
}
|
||||
copy(v.uuid[:], b)
|
||||
} else {
|
||||
v.uuid = offlineUUID(name)
|
||||
}
|
||||
v.username = name
|
||||
v.loginStartSeen = true
|
||||
v.c2sDone = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// ProcessS2C consumes one chunk of server->player bytes. It returns the bytes
|
||||
// to forward to the player and, once the velocity query has been answered, the
|
||||
// Login Plugin Response to inject towards the server. Complete packets are
|
||||
// forwarded as they parse; a trailing partial packet stays buffered until the
|
||||
// next chunk.
|
||||
func (v *velocityForwarder) ProcessS2C(data []byte) (forward, inject []byte) {
|
||||
v.mu.Lock()
|
||||
defer v.mu.Unlock()
|
||||
if v.done {
|
||||
// Interception ended from the c2s side while bytes sat buffered here.
|
||||
if len(v.s2cBuf) > 0 {
|
||||
forward = append(v.s2cBuf, data...)
|
||||
v.s2cBuf = nil
|
||||
v.passthrough.Store(true)
|
||||
return forward, nil
|
||||
}
|
||||
v.passthrough.Store(true)
|
||||
return data, nil
|
||||
}
|
||||
v.s2cBuf = append(v.s2cBuf, data...)
|
||||
loop:
|
||||
for {
|
||||
raw, body, rest, ok, err := nextPacket(v.s2cBuf, maxS2CSniff)
|
||||
if err != nil || (!ok && len(v.s2cBuf) > maxS2CSniff) {
|
||||
v.abortLocked() // unconsumed bytes are flushed below
|
||||
break
|
||||
}
|
||||
if !ok {
|
||||
break // partial packet: wait for the next chunk
|
||||
}
|
||||
r := wire.NewReader(body)
|
||||
id, err := r.VarInt()
|
||||
if err != nil {
|
||||
v.abortLocked()
|
||||
break
|
||||
}
|
||||
switch id {
|
||||
case loginS2CPluginRequest:
|
||||
msgID, merr := r.VarInt()
|
||||
channel, cerr := r.String()
|
||||
if merr != nil || cerr != nil {
|
||||
v.abortLocked()
|
||||
break loop
|
||||
}
|
||||
if channel == velocityChannel {
|
||||
if !v.loginStartSeen {
|
||||
// Cannot answer without a parsed Login Start; let the
|
||||
// request through — the backend will kick the player with
|
||||
// its own clear message.
|
||||
v.abortLocked()
|
||||
break loop
|
||||
}
|
||||
inject = v.buildResponseLocked(msgID, r.Remaining())
|
||||
v.s2cBuf = rest // swallow the request: the player never sees it
|
||||
v.done = true
|
||||
break loop
|
||||
}
|
||||
// Another plugin channel (e.g. a mod handshake): the player
|
||||
// answers it itself; forward and keep watching.
|
||||
case loginS2CDisconnect, loginS2CEncryptionRequest, loginS2CSuccess, loginS2CSetCompression:
|
||||
// Login phase is over (or turning encrypted/compressed) and no
|
||||
// velocity query showed up: stop watching.
|
||||
v.done = true
|
||||
default:
|
||||
// Cookie Request (0x05, 1.20.5+) or future packets: forward.
|
||||
}
|
||||
v.s2cBuf = rest
|
||||
forward = append(forward, raw...)
|
||||
if v.done {
|
||||
break
|
||||
}
|
||||
}
|
||||
if v.done {
|
||||
forward = append(forward, v.s2cBuf...)
|
||||
v.s2cBuf = nil
|
||||
v.c2sBuf = nil
|
||||
v.c2sDone = true
|
||||
v.passthrough.Store(true)
|
||||
}
|
||||
return forward, inject
|
||||
}
|
||||
|
||||
// buildResponseLocked crafts the serverbound Login Plugin Response carrying the
|
||||
// signed forwarding payload (mirrors Velocity's createForwardingData).
|
||||
func (v *velocityForwarder) buildResponseLocked(msgID int, reqData []byte) []byte {
|
||||
// The request data is the backend's maximum supported forwarding version
|
||||
// (absent on very old backends → 1).
|
||||
requested := velocityVersionDefault
|
||||
if len(reqData) > 0 {
|
||||
if n, err := wire.NewReader(reqData).VarInt(); err == nil {
|
||||
requested = n
|
||||
}
|
||||
}
|
||||
version := velocityVersionDefault
|
||||
if requested >= velocityVersionLazySession && v.protocol >= protocol1_19_3 {
|
||||
version = velocityVersionLazySession
|
||||
}
|
||||
payload := wire.NewWriter().
|
||||
VarInt(version).
|
||||
String(v.srcIP).
|
||||
Bytes(v.uuid[:]). // UUID = 16 raw bytes (two big-endian longs)
|
||||
String(v.username).
|
||||
VarInt(0). // profile properties
|
||||
Out()
|
||||
mac := hmac.New(sha256.New, v.secret)
|
||||
mac.Write(payload)
|
||||
body := wire.NewWriter().
|
||||
VarInt(loginC2SPluginResponse).
|
||||
VarInt(msgID).
|
||||
U8(1). // successful
|
||||
Bytes(mac.Sum(nil)).
|
||||
Bytes(payload).
|
||||
Out()
|
||||
return append(wire.AppendVarInt(nil, len(body)), body...)
|
||||
}
|
||||
|
||||
// nextPacket splits one length-prefixed Minecraft packet off buf. raw includes
|
||||
// the length header, body is the packet payload, rest what follows. ok is
|
||||
// false while the packet is still incomplete; err reports a malformed or
|
||||
// oversized length header.
|
||||
func nextPacket(buf []byte, max int) (raw, body, rest []byte, ok bool, err error) {
|
||||
r := wire.NewReader(buf)
|
||||
n, verr := r.VarInt()
|
||||
if verr != nil {
|
||||
if len(buf) >= wire.VarIntMaxBytes {
|
||||
return nil, nil, buf, false, verr
|
||||
}
|
||||
return nil, nil, buf, false, nil // header not complete yet
|
||||
}
|
||||
if n <= 0 || n > max {
|
||||
return nil, nil, buf, false, errVelocitySniff
|
||||
}
|
||||
hdr := len(buf) - len(r.Remaining())
|
||||
if len(buf) < hdr+n {
|
||||
return nil, nil, buf, false, nil
|
||||
}
|
||||
return buf[:hdr+n], buf[hdr : hdr+n], buf[hdr+n:], true, nil
|
||||
}
|
||||
|
||||
// offlineUUID derives the offline-mode UUID for a username, identical to
|
||||
// Java's UUID.nameUUIDFromBytes("OfflinePlayer:" + name): a v3 (MD5) UUID.
|
||||
func offlineUUID(name string) [16]byte {
|
||||
sum := md5.Sum([]byte("OfflinePlayer:" + name))
|
||||
sum[6] = sum[6]&0x0f | 0x30 // version 3
|
||||
sum[8] = sum[8]&0x3f | 0x80 // IETF variant
|
||||
return sum
|
||||
}
|
||||
Reference in New Issue
Block a user