253 lines
6.3 KiB
Go
253 lines
6.3 KiB
Go
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()
|
|
username, _ := r.String()
|
|
go c.handleControlRequest(cid, pattern, ip, int(port), username)
|
|
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, username string) {
|
|
mapping, ok := c.mappings[NormalizeAddress(pattern)]
|
|
if !ok {
|
|
log.Printf("control-request for unmapped pattern %q; ignoring", pattern)
|
|
return
|
|
}
|
|
log.Printf("player %s:%d joined as %q via pattern %q -> %s", ip, port, username, pattern, mapping.Destination)
|
|
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()
|
|
}
|