initial commit
This commit is contained in:
@@ -0,0 +1,250 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
crand "crypto/rand"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/iceBear67/redapricot/client/wire"
|
||||
)
|
||||
|
||||
// Client is a redapricot client: it holds a control session with the hub and a
|
||||
// pool of worker connections used to serve player streams.
|
||||
type Client struct {
|
||||
cfg *Config
|
||||
pskBytes []byte
|
||||
pskAddr string
|
||||
serverPort uint16
|
||||
|
||||
mappings map[string]Mapping // normalized pattern -> mapping
|
||||
pool *WorkerPool
|
||||
|
||||
mu sync.Mutex
|
||||
ctrl *wire.FramedConn
|
||||
}
|
||||
|
||||
// New builds a client from config.
|
||||
func New(cfg *Config) *Client {
|
||||
c := &Client{
|
||||
cfg: cfg,
|
||||
pskBytes: []byte(cfg.PSK),
|
||||
pskAddr: wire.PSKAddress([]byte(cfg.PSK)),
|
||||
mappings: make(map[string]Mapping),
|
||||
}
|
||||
if _, portStr, err := net.SplitHostPort(cfg.Server); err == nil {
|
||||
if p, err := net.LookupPort("tcp", portStr); err == nil {
|
||||
c.serverPort = uint16(p)
|
||||
}
|
||||
}
|
||||
for _, m := range cfg.Mappings {
|
||||
c.mappings[NormalizeAddress(m.Pattern)] = m
|
||||
}
|
||||
c.pool = newWorkerPool(c, cfg.MaxConn)
|
||||
return c
|
||||
}
|
||||
|
||||
// dialSession opens a TCP connection, performs the Intent-17 handshake, the
|
||||
// Phase-A rekey, and reads SessionReady, returning an established frame conn.
|
||||
func (c *Client) dialSession(magic byte) (*wire.FramedConn, error) {
|
||||
conn, err := net.DialTimeout("tcp", c.cfg.Server, 10*time.Second)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if tcp, ok := conn.(*net.TCPConn); ok {
|
||||
_ = tcp.SetNoDelay(true)
|
||||
}
|
||||
ok := false
|
||||
defer func() {
|
||||
if !ok {
|
||||
_ = conn.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
// 1. plaintext Minecraft Handshake, Intent 17, address = hex(SHA3-224(PSK)).
|
||||
hs := wire.BuildHandshake(ProtocolVersion, c.pskAddr, c.serverPort, IntentRedapricot)
|
||||
if _, err := conn.Write(hs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 2. Phase-A ciphers derived from the PSK.
|
||||
fc := wire.NewFramedConn(conn,
|
||||
wire.CipherFor(c.pskBytes, wire.DirS2C), // in: server -> client
|
||||
wire.CipherFor(c.pskBytes, wire.DirC2S), // out: client -> server
|
||||
)
|
||||
|
||||
// 3. Rekey frame (Phase A).
|
||||
rnd := make([]byte, 16)
|
||||
if _, err := crand.Read(rnd); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ts := time.Now().UnixMilli()
|
||||
rekeyMsg := wire.NewWriter().U8(magic).VarInt(len(rnd)).Bytes(rnd).I64(ts).Out()
|
||||
if err := fc.WriteFrame(rekeyMsg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 4. Switch to Phase-B ciphers: REKEY = Rand || Timestamp(I64 BE).
|
||||
rekey := make([]byte, 0, len(rnd)+8)
|
||||
rekey = append(rekey, rnd...)
|
||||
var tsb [8]byte
|
||||
binary.BigEndian.PutUint64(tsb[:], uint64(ts))
|
||||
rekey = append(rekey, tsb[:]...)
|
||||
fc.SwitchCiphers(
|
||||
wire.CipherFor(rekey, wire.DirS2C),
|
||||
wire.CipherFor(rekey, wire.DirC2S),
|
||||
)
|
||||
|
||||
// 5. SessionReady.
|
||||
payload, err := fc.ReadFrame()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(payload) < 1 || payload[0] != CtlSessionReady {
|
||||
return nil, fmt.Errorf("expected SessionReady, got %v", payload)
|
||||
}
|
||||
ok = true
|
||||
return fc, nil
|
||||
}
|
||||
|
||||
// Start establishes the control session and registers all patterns. It returns
|
||||
// once the initial connection succeeds; subsequent drops are handled in the
|
||||
// background with reconnect.
|
||||
func (c *Client) Start(ctx context.Context) error {
|
||||
return c.connectControl(ctx)
|
||||
}
|
||||
|
||||
func (c *Client) connectControl(ctx context.Context) error {
|
||||
fc, err := c.dialSession(MagicControl)
|
||||
if err != nil {
|
||||
return fmt.Errorf("control connect: %w", err)
|
||||
}
|
||||
c.registerAll(fc)
|
||||
c.mu.Lock()
|
||||
c.ctrl = fc
|
||||
c.mu.Unlock()
|
||||
log.Printf("control session established with %s", c.cfg.Server)
|
||||
go c.serveControl(ctx, fc)
|
||||
go c.pingLoop(ctx, fc)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) registerAll(fc *wire.FramedConn) {
|
||||
for pattern := range c.mappings {
|
||||
msg := wire.NewWriter().U8(CtlRegister).String(pattern).Out()
|
||||
if err := fc.WriteFrame(msg); err != nil {
|
||||
log.Printf("register %q: %v", pattern, err)
|
||||
return
|
||||
}
|
||||
log.Printf("registered pattern %q", pattern)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) serveControl(ctx context.Context, fc *wire.FramedConn) {
|
||||
for {
|
||||
payload, err := fc.ReadFrame()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
c.dispatchControl(payload)
|
||||
}
|
||||
_ = fc.Close()
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
// Reconnect with backoff.
|
||||
for backoff := 500 * time.Millisecond; ctx.Err() == nil; backoff *= 2 {
|
||||
if backoff > 10*time.Second {
|
||||
backoff = 10 * time.Second
|
||||
}
|
||||
time.Sleep(backoff)
|
||||
if err := c.connectControl(ctx); err == nil {
|
||||
return
|
||||
} else {
|
||||
log.Printf("control reconnect failed: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) dispatchControl(payload []byte) {
|
||||
r := wire.NewReader(payload)
|
||||
t, err := r.U8()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
switch t {
|
||||
case CtlSessionReady:
|
||||
// ignore
|
||||
case CtlRegisterAck:
|
||||
pattern, _ := r.String()
|
||||
status, _ := r.U8()
|
||||
log.Printf("register ack %q status=%d", pattern, status)
|
||||
case CtlControlRequest:
|
||||
cid, err := r.Bytes(CIDLen)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
pattern, _ := r.String()
|
||||
ip, _ := r.String()
|
||||
port, _ := r.U16()
|
||||
go c.handleControlRequest(cid, pattern, ip, int(port))
|
||||
case CtlPong:
|
||||
// ignore
|
||||
default:
|
||||
log.Printf("control: unknown message type %d", t)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) pingLoop(ctx context.Context, fc *wire.FramedConn) {
|
||||
ticker := time.NewTicker(time.Duration(c.cfg.PingIntervalMs) * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
msg := wire.NewWriter().U8(CtlPing).I64(time.Now().UnixMilli()).Out()
|
||||
if err := fc.WriteFrame(msg); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// handleControlRequest reacts to a matched player: allocate a worker stream,
|
||||
// SYN it, and bridge it to the mapped destination.
|
||||
func (c *Client) handleControlRequest(cid []byte, pattern, ip string, port int) {
|
||||
mapping, ok := c.mappings[NormalizeAddress(pattern)]
|
||||
if !ok {
|
||||
log.Printf("control-request for unmapped pattern %q; ignoring", pattern)
|
||||
return
|
||||
}
|
||||
wc, sid, err := c.pool.Allocate()
|
||||
if err != nil {
|
||||
log.Printf("worker allocate failed: %v", err)
|
||||
return
|
||||
}
|
||||
st := newStream(wc, sid, cid, mapping, ip, port)
|
||||
wc.registerStream(sid, st)
|
||||
wc.sendSyn(sid, cid)
|
||||
go st.run()
|
||||
}
|
||||
|
||||
// WorkerConnCount reports the current number of open worker connections
|
||||
// (exposed for tests/observability).
|
||||
func (c *Client) WorkerConnCount() int { return c.pool.count() }
|
||||
|
||||
// Close tears down the control session and all worker connections.
|
||||
func (c *Client) Close() {
|
||||
c.mu.Lock()
|
||||
fc := c.ctrl
|
||||
c.mu.Unlock()
|
||||
if fc != nil {
|
||||
_ = fc.Close()
|
||||
}
|
||||
c.pool.closeAll()
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"server": "hub.example.com:25565",
|
||||
"psk": "change-me-to-a-long-random-passphrase",
|
||||
"maxConn": 4,
|
||||
"pingIntervalMs": 20000,
|
||||
"mappings": [
|
||||
{
|
||||
"pattern": "mc.example.com",
|
||||
"destination": "127.0.0.1:25566",
|
||||
"proxyProtocol": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Protocol constants (mirror of the Java Protocol class; see PROTOCOL.md).
|
||||
const (
|
||||
IntentRedapricot = 17
|
||||
ProtocolVersion = 767 // arbitrary; the hub ignores it
|
||||
|
||||
MagicControl = 0x01
|
||||
MagicWorker = 0x02
|
||||
|
||||
CIDLen = 16
|
||||
|
||||
CtlSessionReady = 0x00
|
||||
CtlRegister = 0x01
|
||||
CtlUnregister = 0x02
|
||||
CtlRegisterAck = 0x03
|
||||
CtlControlRequest = 0x04
|
||||
CtlPing = 0x05
|
||||
CtlPong = 0x06
|
||||
|
||||
MuxSyn = 0x00
|
||||
MuxData = 0x01
|
||||
MuxFin = 0x02
|
||||
MuxRst = 0x03
|
||||
|
||||
FrameError = 0x7F
|
||||
|
||||
SaturationThreshold = 8
|
||||
)
|
||||
|
||||
// Mapping routes a registered pattern to a real destination.
|
||||
type Mapping struct {
|
||||
Pattern string `json:"pattern"`
|
||||
Destination string `json:"destination"`
|
||||
ProxyProtocol bool `json:"proxyProtocol"`
|
||||
}
|
||||
|
||||
// Config is the client configuration (PROTOCOL.md §9.2).
|
||||
type Config struct {
|
||||
Server string `json:"server"`
|
||||
PSK string `json:"psk"`
|
||||
MaxConn int `json:"maxConn"`
|
||||
PingIntervalMs int `json:"pingIntervalMs"`
|
||||
Mappings []Mapping `json:"mappings"`
|
||||
}
|
||||
|
||||
// LoadConfig reads and validates a JSON config file.
|
||||
func LoadConfig(path string) (*Config, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var c Config
|
||||
if err := json.Unmarshal(data, &c); err != nil {
|
||||
return nil, fmt.Errorf("parse config: %w", err)
|
||||
}
|
||||
if c.Server == "" {
|
||||
return nil, fmt.Errorf("server is required")
|
||||
}
|
||||
if c.PSK == "" {
|
||||
return nil, fmt.Errorf("psk is required")
|
||||
}
|
||||
if c.MaxConn < 1 {
|
||||
c.MaxConn = 1
|
||||
}
|
||||
if c.MaxConn > 8 {
|
||||
c.MaxConn = 8
|
||||
}
|
||||
if c.PingIntervalMs <= 0 {
|
||||
c.PingIntervalMs = 20000
|
||||
}
|
||||
if len(c.Mappings) == 0 {
|
||||
return nil, fmt.Errorf("at least one mapping is required")
|
||||
}
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
// NormalizeAddress matches the hub's normalization: lower-cased, FML-suffix and
|
||||
// trailing-dot stripped.
|
||||
func NormalizeAddress(addr string) string {
|
||||
if i := strings.IndexByte(addr, 0); i >= 0 {
|
||||
addr = addr[:i]
|
||||
}
|
||||
addr = strings.ToLower(addr)
|
||||
addr = strings.TrimRight(addr, ".")
|
||||
return addr
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"net"
|
||||
)
|
||||
|
||||
// proxyV2Signature is the fixed 12-byte HAProxy v2 signature.
|
||||
var proxyV2Signature = []byte{
|
||||
0x0D, 0x0A, 0x0D, 0x0A, 0x00, 0x0D, 0x0A, 0x51, 0x55, 0x49, 0x54, 0x0A,
|
||||
}
|
||||
|
||||
// BuildProxyV2 builds a HAProxy protocol v2 PROXY header conveying the real
|
||||
// source (player) and destination addresses (PROTOCOL.md §8).
|
||||
func BuildProxyV2(srcIP net.IP, srcPort int, dstIP net.IP, dstPort int) []byte {
|
||||
s4, d4 := srcIP.To4(), dstIP.To4()
|
||||
out := make([]byte, 0, 16+36)
|
||||
out = append(out, proxyV2Signature...)
|
||||
out = append(out, 0x21) // version 2, PROXY command
|
||||
|
||||
var addr []byte
|
||||
if s4 != nil && d4 != nil {
|
||||
out = append(out, 0x11) // TCP over IPv4
|
||||
addr = make([]byte, 0, 12)
|
||||
addr = append(addr, s4...)
|
||||
addr = append(addr, d4...)
|
||||
} else {
|
||||
out = append(out, 0x21) // TCP over IPv6
|
||||
addr = make([]byte, 0, 36)
|
||||
addr = append(addr, srcIP.To16()...)
|
||||
addr = append(addr, dstIP.To16()...)
|
||||
}
|
||||
var ports [4]byte
|
||||
binary.BigEndian.PutUint16(ports[0:], uint16(srcPort))
|
||||
binary.BigEndian.PutUint16(ports[2:], uint16(dstPort))
|
||||
addr = append(addr, ports[:]...)
|
||||
|
||||
var lenField [2]byte
|
||||
binary.BigEndian.PutUint16(lenField[:], uint16(len(addr)))
|
||||
out = append(out, lenField[:]...)
|
||||
out = append(out, addr...)
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package wire
|
||||
|
||||
import (
|
||||
"crypto/sha3"
|
||||
"encoding/hex"
|
||||
|
||||
"golang.org/x/crypto/chacha20"
|
||||
)
|
||||
|
||||
// Direction labels for per-direction key derivation (PROTOCOL.md §3).
|
||||
const (
|
||||
DirC2S byte = 0x01 // client -> server
|
||||
DirS2C byte = 0x02 // server -> client
|
||||
)
|
||||
|
||||
// SHA3_224 returns the SHA3-224 digest of in.
|
||||
func SHA3_224(in []byte) []byte {
|
||||
h := sha3.New224()
|
||||
h.Write(in)
|
||||
return h.Sum(nil)
|
||||
}
|
||||
|
||||
// PSKAddress is the Handshake Server Address for Intent 17: hex(SHA3-224(PSK)).
|
||||
func PSKAddress(psk []byte) string {
|
||||
return hex.EncodeToString(SHA3_224(psk))
|
||||
}
|
||||
|
||||
// DeriveKey computes the 32-byte ChaCha20 key: SHA3-256(phaseKey || dir).
|
||||
func DeriveKey(phaseKey []byte, dir byte) []byte {
|
||||
h := sha3.New256()
|
||||
h.Write(phaseKey)
|
||||
h.Write([]byte{dir})
|
||||
return h.Sum(nil)
|
||||
}
|
||||
|
||||
// CipherFor builds a ChaCha20 stream cipher for the given phase key and
|
||||
// direction. ChaCha20 is symmetric, so the same cipher encrypts and decrypts.
|
||||
func CipherFor(phaseKey []byte, dir byte) *chacha20.Cipher {
|
||||
c, err := chacha20.NewUnauthenticatedCipher(DeriveKey(phaseKey, dir), make([]byte, 12))
|
||||
if err != nil {
|
||||
panic("wire: chacha20 init: " + err.Error())
|
||||
}
|
||||
return c
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package wire
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"sync"
|
||||
|
||||
"golang.org/x/crypto/chacha20"
|
||||
)
|
||||
|
||||
// MaxFrame is the maximum decrypted frame payload size (1 MiB).
|
||||
const MaxFrame = 1 << 20
|
||||
|
||||
var errFrameTooBig = errors.New("wire: frame exceeds max size")
|
||||
|
||||
// FramedConn is the encrypted, length-prefixed frame transport (PROTOCOL.md §3.1).
|
||||
// The VarInt length prefix is plaintext; the payload is ChaCha20-encrypted with a
|
||||
// continuous per-direction keystream. Writes are serialized; reads are expected
|
||||
// from a single goroutine.
|
||||
type FramedConn struct {
|
||||
conn net.Conn
|
||||
r *bufio.Reader
|
||||
in *chacha20.Cipher
|
||||
out *chacha20.Cipher
|
||||
wmu sync.Mutex
|
||||
}
|
||||
|
||||
func NewFramedConn(conn net.Conn, in, out *chacha20.Cipher) *FramedConn {
|
||||
return &FramedConn{
|
||||
conn: conn,
|
||||
r: bufio.NewReader(conn),
|
||||
in: in,
|
||||
out: out,
|
||||
}
|
||||
}
|
||||
|
||||
// SwitchCiphers swaps both ciphers at a frame boundary (Phase A → Phase B).
|
||||
// Only call this from the same goroutine sequence as reads/writes during the
|
||||
// handshake, before concurrency begins.
|
||||
func (f *FramedConn) SwitchCiphers(in, out *chacha20.Cipher) {
|
||||
f.in = in
|
||||
f.out = out
|
||||
}
|
||||
|
||||
// ReadFrame reads and decrypts one frame payload.
|
||||
func (f *FramedConn) ReadFrame() ([]byte, error) {
|
||||
n, err := ReadVarInt(f.r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if n < 0 || n > MaxFrame {
|
||||
return nil, errFrameTooBig
|
||||
}
|
||||
ct := make([]byte, n)
|
||||
if _, err := io.ReadFull(f.r, ct); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
f.in.XORKeyStream(ct, ct) // decrypt in place
|
||||
return ct, nil
|
||||
}
|
||||
|
||||
// WriteFrame encrypts and sends one frame payload. Safe for concurrent callers.
|
||||
func (f *FramedConn) WriteFrame(payload []byte) error {
|
||||
f.wmu.Lock()
|
||||
defer f.wmu.Unlock()
|
||||
ct := make([]byte, len(payload))
|
||||
f.out.XORKeyStream(ct, payload)
|
||||
out := AppendVarInt(make([]byte, 0, VarIntMaxBytes+len(ct)), len(ct))
|
||||
out = append(out, ct...)
|
||||
_, err := f.conn.Write(out)
|
||||
return err
|
||||
}
|
||||
|
||||
func (f *FramedConn) Close() error { return f.conn.Close() }
|
||||
|
||||
func (f *FramedConn) RemoteAddr() net.Addr { return f.conn.RemoteAddr() }
|
||||
@@ -0,0 +1,122 @@
|
||||
package wire
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
)
|
||||
|
||||
// Writer builds redapricot/Minecraft primitive types into a byte slice.
|
||||
type Writer struct {
|
||||
buf []byte
|
||||
}
|
||||
|
||||
func NewWriter() *Writer { return &Writer{} }
|
||||
|
||||
func (w *Writer) U8(v byte) *Writer { w.buf = append(w.buf, v); return w }
|
||||
func (w *Writer) VarInt(v int) *Writer { w.buf = AppendVarInt(w.buf, v); return w }
|
||||
func (w *Writer) U16(v uint16) *Writer { w.buf = append(w.buf, byte(v>>8), byte(v)); return w }
|
||||
func (w *Writer) Bytes(p []byte) *Writer { w.buf = append(w.buf, p...); return w }
|
||||
|
||||
func (w *Writer) I64(v int64) *Writer {
|
||||
var b [8]byte
|
||||
binary.BigEndian.PutUint64(b[:], uint64(v))
|
||||
w.buf = append(w.buf, b[:]...)
|
||||
return w
|
||||
}
|
||||
|
||||
func (w *Writer) String(s string) *Writer {
|
||||
w.VarInt(len(s))
|
||||
w.buf = append(w.buf, s...)
|
||||
return w
|
||||
}
|
||||
|
||||
// Out returns the built bytes.
|
||||
func (w *Writer) Out() []byte { return w.buf }
|
||||
|
||||
// Reader consumes redapricot/Minecraft primitive types from a byte slice.
|
||||
type Reader struct {
|
||||
buf []byte
|
||||
pos int
|
||||
}
|
||||
|
||||
func NewReader(b []byte) *Reader { return &Reader{buf: b} }
|
||||
|
||||
var errUnderflow = errors.New("wire: read underflow")
|
||||
|
||||
func (r *Reader) U8() (byte, error) {
|
||||
if r.pos >= len(r.buf) {
|
||||
return 0, errUnderflow
|
||||
}
|
||||
v := r.buf[r.pos]
|
||||
r.pos++
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func (r *Reader) VarInt() (int, error) {
|
||||
br := bytes.NewReader(r.buf[r.pos:])
|
||||
before := br.Len()
|
||||
v, err := ReadVarInt(br)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
r.pos += before - br.Len()
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func (r *Reader) U16() (uint16, error) {
|
||||
if r.pos+2 > len(r.buf) {
|
||||
return 0, errUnderflow
|
||||
}
|
||||
v := binary.BigEndian.Uint16(r.buf[r.pos:])
|
||||
r.pos += 2
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func (r *Reader) I64() (int64, error) {
|
||||
if r.pos+8 > len(r.buf) {
|
||||
return 0, errUnderflow
|
||||
}
|
||||
v := int64(binary.BigEndian.Uint64(r.buf[r.pos:]))
|
||||
r.pos += 8
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func (r *Reader) Bytes(n int) ([]byte, error) {
|
||||
if n < 0 || r.pos+n > len(r.buf) {
|
||||
return nil, errUnderflow
|
||||
}
|
||||
out := make([]byte, n)
|
||||
copy(out, r.buf[r.pos:r.pos+n])
|
||||
r.pos += n
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *Reader) String() (string, error) {
|
||||
n, err := r.VarInt()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
b, err := r.Bytes(n)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
// Remaining returns the unread bytes (a copy is not made).
|
||||
func (r *Reader) Remaining() []byte { return r.buf[r.pos:] }
|
||||
|
||||
// BuildHandshake produces a full uncompressed Minecraft Handshake packet
|
||||
// (length-prefixed, packet id 0x00).
|
||||
func BuildHandshake(protocolVersion int, address string, port uint16, intent int) []byte {
|
||||
body := NewWriter().
|
||||
VarInt(0x00). // packet id
|
||||
VarInt(protocolVersion).
|
||||
String(address).
|
||||
U16(port).
|
||||
VarInt(intent).
|
||||
Out()
|
||||
out := AppendVarInt(nil, len(body))
|
||||
return append(out, body...)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package wire
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
)
|
||||
|
||||
// VarIntMaxBytes is the maximum encoded length of a Minecraft VarInt.
|
||||
const VarIntMaxBytes = 5
|
||||
|
||||
var errVarIntTooBig = errors.New("wire: VarInt exceeds 5 bytes")
|
||||
|
||||
// ReadVarInt reads a Minecraft-style VarInt from a byte reader.
|
||||
func ReadVarInt(r io.ByteReader) (int, error) {
|
||||
var value, shift int
|
||||
for {
|
||||
b, err := r.ReadByte()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
value |= int(b&0x7F) << shift
|
||||
if b&0x80 == 0 {
|
||||
return value, nil
|
||||
}
|
||||
shift += 7
|
||||
if shift >= 32 {
|
||||
return 0, errVarIntTooBig
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// AppendVarInt appends v encoded as a VarInt to dst.
|
||||
func AppendVarInt(dst []byte, v int) []byte {
|
||||
u := uint32(v)
|
||||
for u&^uint32(0x7F) != 0 {
|
||||
dst = append(dst, byte(u&0x7F)|0x80)
|
||||
u >>= 7
|
||||
}
|
||||
return append(dst, byte(u))
|
||||
}
|
||||
|
||||
// VarIntSize returns the encoded byte length of v.
|
||||
func VarIntSize(v int) int {
|
||||
n := 1
|
||||
u := uint32(v)
|
||||
for u&^uint32(0x7F) != 0 {
|
||||
u >>= 7
|
||||
n++
|
||||
}
|
||||
return n
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package wire
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// newFramedPair returns two FramedConns wired over an in-memory pipe with
|
||||
// matching per-direction ciphers (client out=C2S/in=S2C, server the reverse).
|
||||
func newFramedPair(key []byte) (client, server *FramedConn) {
|
||||
c, s := net.Pipe()
|
||||
client = NewFramedConn(c, CipherFor(key, DirS2C), CipherFor(key, DirC2S))
|
||||
server = NewFramedConn(s, CipherFor(key, DirC2S), CipherFor(key, DirS2C))
|
||||
return client, server
|
||||
}
|
||||
|
||||
func TestVarIntRoundTrip(t *testing.T) {
|
||||
cases := []int{0, 1, 127, 128, 255, 300, 16384, 2097151, 1 << 30}
|
||||
for _, v := range cases {
|
||||
enc := AppendVarInt(nil, v)
|
||||
if len(enc) != VarIntSize(v) {
|
||||
t.Fatalf("size mismatch for %d: got %d want %d", v, len(enc), VarIntSize(v))
|
||||
}
|
||||
got, err := ReadVarInt(bytes.NewReader(enc))
|
||||
if err != nil {
|
||||
t.Fatalf("read %d: %v", v, err)
|
||||
}
|
||||
if got != v {
|
||||
t.Fatalf("roundtrip %d -> %d", v, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestPSKAddress cross-validates SHA3-224 against the value the Java hub prints
|
||||
// for the PSK "test-psk" (locks the two implementations together).
|
||||
func TestPSKAddress(t *testing.T) {
|
||||
const want = "90188f2d84e273e4d6fb27194b4a88ad10bcc20de00c493beae6d18f"
|
||||
if got := PSKAddress([]byte("test-psk")); got != want {
|
||||
t.Fatalf("PSKAddress = %s, want %s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFramedConnRoundTrip exercises the encrypted framing + keystream continuity
|
||||
// in both directions over an in-memory pipe.
|
||||
func TestFramedConnRoundTrip(t *testing.T) {
|
||||
cli, srv := newFramedPair([]byte("unit-key"))
|
||||
|
||||
// Frames of varying sizes to exercise partial-block keystream state.
|
||||
payloads := [][]byte{
|
||||
[]byte("a"),
|
||||
bytes.Repeat([]byte{0xAB}, 63),
|
||||
bytes.Repeat([]byte{0xCD}, 64),
|
||||
bytes.Repeat([]byte{0xEF}, 65),
|
||||
bytes.Repeat([]byte("mux"), 5000),
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for _, p := range payloads {
|
||||
if err := cli.WriteFrame(p); err != nil {
|
||||
t.Errorf("client write: %v", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
for _, want := range payloads {
|
||||
got, err := srv.ReadFrame()
|
||||
if err != nil {
|
||||
t.Fatalf("server read: %v", err)
|
||||
}
|
||||
if !bytes.Equal(got, want) {
|
||||
t.Fatalf("frame mismatch: len(got)=%d len(want)=%d", len(got), len(want))
|
||||
}
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
// Reverse direction.
|
||||
go func() {
|
||||
_ = srv.WriteFrame([]byte("pong"))
|
||||
}()
|
||||
got, err := cli.ReadFrame()
|
||||
if err != nil {
|
||||
t.Fatalf("client read: %v", err)
|
||||
}
|
||||
if !bytes.Equal(got, []byte("pong")) {
|
||||
t.Fatalf("reverse frame mismatch: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxyBufferShapeIsStable(t *testing.T) {
|
||||
// A frame with an empty payload must still be a valid (zero-length) frame.
|
||||
cli, srv := newFramedPair([]byte("k"))
|
||||
go func() { _ = cli.WriteFrame(nil) }()
|
||||
got, err := srv.ReadFrame()
|
||||
if err != nil {
|
||||
t.Fatalf("read empty frame: %v", err)
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("expected empty payload, got %d bytes", len(got))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/iceBear67/redapricot/client/wire"
|
||||
)
|
||||
|
||||
// WorkerPool manages up to maxConn worker connections and allocates streams
|
||||
// using the least-loaded strategy (PROTOCOL.md §7.1).
|
||||
type WorkerPool struct {
|
||||
client *Client
|
||||
maxConn int
|
||||
|
||||
mu sync.Mutex
|
||||
conns []*WorkerConn
|
||||
}
|
||||
|
||||
func newWorkerPool(c *Client, maxConn int) *WorkerPool {
|
||||
return &WorkerPool{client: c, maxConn: maxConn}
|
||||
}
|
||||
|
||||
// Allocate returns a worker conn and a fresh stream id to place a new stream on.
|
||||
func (p *WorkerPool) Allocate() (*WorkerConn, int, error) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
var best *WorkerConn
|
||||
bestCount := 0
|
||||
for _, wc := range p.conns {
|
||||
n := wc.streamCount()
|
||||
if best == nil || n < bestCount {
|
||||
best = wc
|
||||
bestCount = n
|
||||
}
|
||||
}
|
||||
|
||||
needNew := best == nil || (bestCount > SaturationThreshold && len(p.conns) < p.maxConn)
|
||||
if needNew {
|
||||
wc, err := p.dialWorker()
|
||||
if err != nil {
|
||||
if best == nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
log.Printf("worker dial failed, reusing existing conn: %v", err)
|
||||
} else {
|
||||
p.conns = append(p.conns, wc)
|
||||
best = wc
|
||||
}
|
||||
}
|
||||
return best, best.newSid(), nil
|
||||
}
|
||||
|
||||
func (p *WorkerPool) dialWorker() (*WorkerConn, error) {
|
||||
fc, err := p.client.dialSession(MagicWorker)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
wc := &WorkerConn{
|
||||
pool: p,
|
||||
fc: fc,
|
||||
streams: make(map[int]*Stream),
|
||||
nextSid: 1,
|
||||
}
|
||||
go wc.readLoop()
|
||||
log.Printf("opened worker conn (#%d in pool)", len(p.conns)+1)
|
||||
return wc, nil
|
||||
}
|
||||
|
||||
func (p *WorkerPool) count() int {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
return len(p.conns)
|
||||
}
|
||||
|
||||
func (p *WorkerPool) remove(wc *WorkerConn) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
for i, c := range p.conns {
|
||||
if c == wc {
|
||||
p.conns = append(p.conns[:i], p.conns[i+1:]...)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *WorkerPool) closeAll() {
|
||||
p.mu.Lock()
|
||||
conns := append([]*WorkerConn(nil), p.conns...)
|
||||
p.mu.Unlock()
|
||||
for _, wc := range conns {
|
||||
_ = wc.fc.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// WorkerConn is one multiplexed worker connection to the hub.
|
||||
type WorkerConn struct {
|
||||
pool *WorkerPool
|
||||
fc *wire.FramedConn
|
||||
|
||||
mu sync.Mutex
|
||||
streams map[int]*Stream
|
||||
nextSid int
|
||||
}
|
||||
|
||||
func (wc *WorkerConn) streamCount() int {
|
||||
wc.mu.Lock()
|
||||
defer wc.mu.Unlock()
|
||||
return len(wc.streams)
|
||||
}
|
||||
|
||||
func (wc *WorkerConn) newSid() int {
|
||||
wc.mu.Lock()
|
||||
defer wc.mu.Unlock()
|
||||
sid := wc.nextSid
|
||||
wc.nextSid++
|
||||
return sid
|
||||
}
|
||||
|
||||
func (wc *WorkerConn) registerStream(sid int, st *Stream) {
|
||||
wc.mu.Lock()
|
||||
wc.streams[sid] = st
|
||||
wc.mu.Unlock()
|
||||
}
|
||||
|
||||
func (wc *WorkerConn) getStream(sid int) *Stream {
|
||||
wc.mu.Lock()
|
||||
defer wc.mu.Unlock()
|
||||
return wc.streams[sid]
|
||||
}
|
||||
|
||||
func (wc *WorkerConn) removeStream(sid int) *Stream {
|
||||
wc.mu.Lock()
|
||||
defer wc.mu.Unlock()
|
||||
st := wc.streams[sid]
|
||||
delete(wc.streams, sid)
|
||||
return st
|
||||
}
|
||||
|
||||
func (wc *WorkerConn) readLoop() {
|
||||
for {
|
||||
payload, err := wc.fc.ReadFrame()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
r := wire.NewReader(payload)
|
||||
ftype, err := r.U8()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
sid, err := r.VarInt()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
switch ftype {
|
||||
case MuxData:
|
||||
if st := wc.getStream(sid); st != nil {
|
||||
st.deliverFromHub(r.Remaining())
|
||||
}
|
||||
case MuxFin, MuxRst:
|
||||
if st := wc.removeStream(sid); st != nil {
|
||||
st.shutdown(false)
|
||||
}
|
||||
default:
|
||||
log.Printf("worker: unknown mux type %d", ftype)
|
||||
}
|
||||
}
|
||||
// Connection lost: tear down all streams and drop from pool.
|
||||
wc.pool.remove(wc)
|
||||
wc.mu.Lock()
|
||||
streams := make([]*Stream, 0, len(wc.streams))
|
||||
for _, st := range wc.streams {
|
||||
streams = append(streams, st)
|
||||
}
|
||||
wc.streams = make(map[int]*Stream)
|
||||
wc.mu.Unlock()
|
||||
for _, st := range streams {
|
||||
st.shutdown(false)
|
||||
}
|
||||
}
|
||||
|
||||
func (wc *WorkerConn) sendSyn(sid int, cid []byte) {
|
||||
_ = wc.fc.WriteFrame(wire.NewWriter().U8(MuxSyn).VarInt(sid).Bytes(cid).Out())
|
||||
}
|
||||
|
||||
func (wc *WorkerConn) sendData(sid int, data []byte) error {
|
||||
return wc.fc.WriteFrame(wire.NewWriter().U8(MuxData).VarInt(sid).Bytes(data).Out())
|
||||
}
|
||||
|
||||
func (wc *WorkerConn) sendFin(sid int) {
|
||||
_ = wc.fc.WriteFrame(wire.NewWriter().U8(MuxFin).VarInt(sid).Out())
|
||||
}
|
||||
|
||||
func (wc *WorkerConn) sendRst(sid int) {
|
||||
_ = wc.fc.WriteFrame(wire.NewWriter().U8(MuxRst).VarInt(sid).Out())
|
||||
}
|
||||
|
||||
// Stream bridges one player (via the hub) to one destination connection.
|
||||
type Stream struct {
|
||||
wc *WorkerConn
|
||||
sid int
|
||||
cid []byte
|
||||
mapping Mapping
|
||||
srcIP string
|
||||
srcPort int
|
||||
|
||||
mu sync.Mutex
|
||||
dest net.Conn
|
||||
connected bool
|
||||
preBuf []byte
|
||||
closed bool
|
||||
}
|
||||
|
||||
func newStream(wc *WorkerConn, sid int, cid []byte, m Mapping, ip string, port int) *Stream {
|
||||
return &Stream{wc: wc, sid: sid, cid: cid, mapping: m, srcIP: ip, srcPort: port}
|
||||
}
|
||||
|
||||
// run dials the destination, optionally writes the PROXY v2 header, flushes any
|
||||
// buffered hub bytes, then pumps destination -> hub.
|
||||
func (s *Stream) run() {
|
||||
dest, err := net.DialTimeout("tcp", s.mapping.Destination, 10*time.Second)
|
||||
if err != nil {
|
||||
log.Printf("stream %d: dial %s failed: %v", s.sid, s.mapping.Destination, err)
|
||||
s.wc.removeStream(s.sid)
|
||||
s.wc.sendRst(s.sid)
|
||||
return
|
||||
}
|
||||
if tcp, ok := dest.(*net.TCPConn); ok {
|
||||
_ = tcp.SetNoDelay(true)
|
||||
}
|
||||
|
||||
if s.mapping.ProxyProtocol {
|
||||
if hdr := s.buildProxyHeader(dest); hdr != nil {
|
||||
if _, err := dest.Write(hdr); err != nil {
|
||||
log.Printf("stream %d: proxy header write: %v", s.sid, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Atomically flush pre-connect buffer and enable direct writes.
|
||||
s.mu.Lock()
|
||||
if s.closed {
|
||||
s.mu.Unlock()
|
||||
_ = dest.Close()
|
||||
return
|
||||
}
|
||||
s.dest = dest
|
||||
if len(s.preBuf) > 0 {
|
||||
_, _ = dest.Write(s.preBuf)
|
||||
s.preBuf = nil
|
||||
}
|
||||
s.connected = true
|
||||
s.mu.Unlock()
|
||||
|
||||
// destination -> hub
|
||||
buf := make([]byte, 32*1024)
|
||||
for {
|
||||
n, err := dest.Read(buf)
|
||||
if n > 0 {
|
||||
if werr := s.wc.sendData(s.sid, buf[:n]); werr != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
s.shutdown(true)
|
||||
}
|
||||
|
||||
func (s *Stream) buildProxyHeader(dest net.Conn) []byte {
|
||||
srcIP := net.ParseIP(s.srcIP)
|
||||
if srcIP == nil {
|
||||
return nil
|
||||
}
|
||||
dstTCP, ok := dest.RemoteAddr().(*net.TCPAddr)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return BuildProxyV2(srcIP, s.srcPort, dstTCP.IP, dstTCP.Port)
|
||||
}
|
||||
|
||||
// deliverFromHub writes bytes coming from the hub to the destination, buffering
|
||||
// until the destination connection is established.
|
||||
func (s *Stream) deliverFromHub(data []byte) {
|
||||
s.mu.Lock()
|
||||
if s.closed {
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
if !s.connected {
|
||||
s.preBuf = append(s.preBuf, data...)
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
dest := s.dest
|
||||
s.mu.Unlock()
|
||||
if _, err := dest.Write(data); err != nil {
|
||||
s.shutdown(true)
|
||||
}
|
||||
}
|
||||
|
||||
// shutdown closes the stream; notifyHub sends a FIN to the hub when true.
|
||||
func (s *Stream) shutdown(notifyHub bool) {
|
||||
s.mu.Lock()
|
||||
if s.closed {
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
s.closed = true
|
||||
dest := s.dest
|
||||
s.mu.Unlock()
|
||||
|
||||
if dest != nil {
|
||||
_ = dest.Close()
|
||||
}
|
||||
s.wc.removeStream(s.sid)
|
||||
if notifyHub {
|
||||
s.wc.sendFin(s.sid)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user