Files
Spinner/internal/protocol/protocol.go
T
VeillaxandClaude Sonnet 5.5 b53af0e196
CI / test (push) Successful in 2m32s
Add Spinner, a Minecraft hostname-routing reverse proxy
Routes Java Edition connections by the hostname in the handshake, with
wildcard domains, backend failover, PROXY protocol v2, cached server-list
status from health probes, hot-reloaded config, and an admin page and
metrics endpoint. Includes CI, tagged releases and a container image.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 19:40:40 -06:00

216 lines
5.9 KiB
Go

// Package protocol implements the small slice of the Minecraft Java protocol
// Spinner needs: the handshake, the status exchange and the login disconnect.
// Everything after the handshake is passed through untouched.
package protocol
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"strconv"
"time"
)
const (
maxHandshake = 1024
maxStatus = 1 << 20
)
// ErrLegacy is returned for pre-1.7 server list pings, which Spinner does not route.
var ErrLegacy = errors.New("legacy ping")
type Handshake struct {
Protocol int32
Host string
Port uint16
NextState int32 // 1 status, 2 login, 3 transfer
}
func (h Handshake) IsStatus() bool { return h.NextState == 1 }
func readVarInt(r io.Reader, raw *[]byte) (int32, error) {
var v uint32
var b [1]byte
for i := 0; i < 5; i++ {
if _, err := io.ReadFull(r, b[:]); err != nil {
return 0, err
}
if raw != nil {
*raw = append(*raw, b[0])
}
v |= uint32(b[0]&0x7f) << (7 * i)
if b[0]&0x80 == 0 {
return int32(v), nil
}
}
return 0, errors.New("varint too long")
}
func AppendVarInt(buf []byte, v int32) []byte {
u := uint32(v)
for u >= 0x80 {
buf = append(buf, byte(u)|0x80)
u >>= 7
}
return append(buf, byte(u))
}
func appendString(buf []byte, s string) []byte {
buf = AppendVarInt(buf, int32(len(s)))
return append(buf, s...)
}
// Frame prefixes a packet body (id + payload) with its length.
func Frame(body []byte) []byte {
return append(AppendVarInt(make([]byte, 0, len(body)+5), int32(len(body))), body...)
}
// ReadHandshake reads one handshake packet and returns it along with the exact
// bytes consumed, so they can be replayed to the backend unchanged.
func ReadHandshake(r io.Reader) (Handshake, []byte, error) {
var raw []byte
n, err := readVarInt(r, &raw)
if err != nil {
return Handshake{}, raw, err
}
if len(raw) > 0 && raw[0] == 0xFE {
return Handshake{}, raw, ErrLegacy
}
if n <= 0 || n > maxHandshake {
return Handshake{}, raw, fmt.Errorf("invalid handshake length %d", n)
}
body := make([]byte, n)
if _, err := io.ReadFull(r, body); err != nil {
return Handshake{}, raw, err
}
raw = append(raw, body...)
br := bytes.NewReader(body)
id, err := readVarInt(br, nil)
if err != nil || id != 0 {
return Handshake{}, raw, errors.New("expected handshake packet")
}
var h Handshake
if h.Protocol, err = readVarInt(br, nil); err != nil {
return Handshake{}, raw, err
}
hl, err := readVarInt(br, nil)
if err != nil || hl < 0 || int(hl) > br.Len()-3 {
return Handshake{}, raw, errors.New("invalid host length")
}
host := make([]byte, hl)
io.ReadFull(br, host)
h.Host = string(host)
var port [2]byte
io.ReadFull(br, port[:])
h.Port = uint16(port[0])<<8 | uint16(port[1])
if h.NextState, err = readVarInt(br, nil); err != nil {
return Handshake{}, raw, err
}
return h, raw, nil
}
func BuildHandshake(protocol int32, host string, port uint16, next int32) []byte {
body := AppendVarInt(nil, 0)
body = AppendVarInt(body, protocol)
body = appendString(body, host)
body = append(body, byte(port>>8), byte(port))
body = AppendVarInt(body, next)
return Frame(body)
}
// ReadPacket reads one length-prefixed packet and returns its id and payload.
func ReadPacket(r io.Reader, max int32) (int32, []byte, error) {
n, err := readVarInt(r, nil)
if err != nil {
return 0, nil, err
}
if n <= 0 || n > max {
return 0, nil, fmt.Errorf("invalid packet length %d", n)
}
body := make([]byte, n)
if _, err := io.ReadFull(r, body); err != nil {
return 0, nil, err
}
br := bytes.NewReader(body)
id, err := readVarInt(br, nil)
if err != nil {
return 0, nil, err
}
return id, body[len(body)-br.Len():], nil
}
func stringPacket(id int32, s string) []byte {
return Frame(appendString(AppendVarInt(nil, id), s))
}
// StatusJSON builds a server list response.
func StatusJSON(clientProtocol int32, version, motd string, online, max int) string {
b, _ := json.Marshal(map[string]any{
"version": map[string]any{"name": version, "protocol": clientProtocol},
"players": map[string]any{"max": max, "online": online},
"description": map[string]any{"text": motd},
})
return string(b)
}
// LoginDisconnect is the packet a client shows as "Disconnected: <message>".
func LoginDisconnect(message string) []byte {
b, _ := json.Marshal(map[string]string{"text": message})
return stringPacket(0, string(b))
}
// ServeStatus plays the server side of the status exchange with a canned
// response: request, response, then an echoed ping if the client sends one.
func ServeStatus(rw io.ReadWriter, statusJSON string) error {
if id, _, err := ReadPacket(rw, 16); err != nil || id != 0 {
return errors.New("bad status request")
}
if _, err := rw.Write(stringPacket(0, statusJSON)); err != nil {
return err
}
id, payload, err := ReadPacket(rw, 32)
if err != nil || id != 1 {
return nil // client closed after the response
}
_, err = rw.Write(Frame(append(AppendVarInt(nil, 1), payload...)))
return err
}
// QueryStatus performs a status ping against a backend and returns the raw JSON.
func QueryStatus(conn net.Conn, host string, port uint16, timeout time.Duration) (string, error) {
conn.SetDeadline(time.Now().Add(timeout))
if _, err := conn.Write(BuildHandshake(767, host, port, 1)); err != nil {
return "", err
}
if _, err := conn.Write(Frame([]byte{0})); err != nil {
return "", err
}
id, payload, err := ReadPacket(conn, maxStatus)
if err != nil {
return "", err
}
if id != 0 {
return "", fmt.Errorf("unexpected packet id %d", id)
}
br := bytes.NewReader(payload)
n, err := readVarInt(br, nil)
if err != nil || n < 0 || int(n) > br.Len() {
return "", errors.New("bad status string")
}
return string(payload[len(payload)-br.Len() : len(payload)-br.Len()+int(n)]), nil
}
// SplitHostPort parses a backend "host:port" address.
func SplitHostPort(addr string) (string, uint16) {
h, p, err := net.SplitHostPort(addr)
if err != nil {
return addr, 25565
}
n, _ := strconv.Atoi(p)
return h, uint16(n)
}