This commit is contained in:
iceBear67
2026-07-25 16:33:28 +08:00
parent a41cb7965e
commit e63a34d53a
20 changed files with 1787 additions and 95 deletions
+372
View File
@@ -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.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
}