Add Spinner, a Minecraft hostname-routing reverse proxy
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>
This commit is contained in:
VeillaxandClaude Sonnet 5.5 committed 2026-10-26 19:40:40 -06:00
1 parent e640c5bf02
commit b53af0e196
29 files changed
+2640

No files matched your search

+215
View File
@@ -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)
}
+87
View File
@@ -0,0 +1,87 @@
package protocol
import (
"bytes"
"net"
"testing"
"time"
)
func TestHandshakeRoundTrip(t *testing.T) {
in := BuildHandshake(767, "mc.example.com", 25565, 2)
h, raw, err := ReadHandshake(bytes.NewReader(in))
if err != nil {
t.Fatal(err)
}
want := Handshake{Protocol: 767, Host: "mc.example.com", Port: 25565, NextState: 2}
if h != want {
t.Fatalf("got %+v want %+v", h, want)
}
if !bytes.Equal(raw, in) {
t.Fatal("raw bytes not preserved")
}
}
func TestReadHandshakeRejects(t *testing.T) {
cases := map[string][]byte{
"legacy": {0xfe, 0x01, 0xfa},
"huge length": {0xff, 0xff, 0xff, 0xff, 0x07},
"zero length": {0x00},
"wrong id": Frame([]byte{0x05, 0x00}),
"short host": Frame([]byte{0x00, 0x01, 0x40, 'a'}),
"truncated": {0x10, 0x00},
}
for name, b := range cases {
if _, _, err := ReadHandshake(bytes.NewReader(b)); err == nil {
t.Errorf("%s: expected error", name)
}
}
if _, _, err := ReadHandshake(bytes.NewReader([]byte{0xfe, 0x01})); err != ErrLegacy {
t.Errorf("want ErrLegacy, got %v", err)
}
}
func TestVarInt(t *testing.T) {
if got := AppendVarInt(nil, 300); !bytes.Equal(got, []byte{0xac, 0x02}) {
t.Fatalf("got %x", got)
}
if got := AppendVarInt(nil, -1); len(got) != 5 {
t.Fatalf("negative should take 5 bytes, got %x", got)
}
}
func TestStatusExchange(t *testing.T) {
c, s := net.Pipe()
defer c.Close()
go func() {
defer s.Close()
ServeStatus(s, `{"ok":true}`)
}()
c.SetDeadline(time.Now().Add(2 * time.Second))
c.Write(Frame([]byte{0}))
id, payload, err := ReadPacket(c, maxStatus)
if err != nil || id != 0 || !bytes.Contains(payload, []byte(`{"ok":true}`)) {
t.Fatalf("id=%d payload=%q err=%v", id, payload, err)
}
c.Write(Frame(append([]byte{1}, 0, 0, 0, 0, 0, 0, 0, 42)))
id, payload, err = ReadPacket(c, 32)
if err != nil || id != 1 || payload[7] != 42 {
t.Fatalf("pong id=%d payload=%x err=%v", id, payload, err)
}
}
func TestQueryStatus(t *testing.T) {
c, s := net.Pipe()
defer c.Close()
go func() {
defer s.Close()
if _, _, err := ReadHandshake(s); err != nil {
return
}
ServeStatus(s, `{"players":{"online":3}}`)
}()
got, err := QueryStatus(c, "x", 25565, 2*time.Second)
if err != nil || got != `{"players":{"online":3}}` {
t.Fatalf("got %q err=%v", got, err)
}
}