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>
This commit is contained in:
1 parent
e640c5bf02
commit
b53af0e196
29 files changed
+2640
No files matched your search
@@ -0,0 +1,215 @@
|
||||
// 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)
|
||||
}
|
||||
Reference in new issue
Block a user