373 lines
11 KiB
Go
373 lines
11 KiB
Go
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
|
||
}
|