Files
2026-07-25 16:33:28 +08:00

373 lines
11 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.191.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
}