CI / test (push) Successful in 2m32s
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>
216 lines
5.9 KiB
Go
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)
|
|
}
|