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

+190
View File
@@ -0,0 +1,190 @@
// Package proxy accepts Minecraft connections, routes them by hostname and
// pipes bytes to the chosen backend.
package proxy
import (
"context"
"errors"
"io"
"log/slog"
"net"
"sync"
"sync/atomic"
"time"
"git.veillax.com/Veillax/spinner/internal/config"
"git.veillax.com/Veillax/spinner/internal/health"
"git.veillax.com/Veillax/spinner/internal/protocol"
"git.veillax.com/Veillax/spinner/internal/router"
)
type Proxy struct {
cfg *config.Manager
health *health.Tracker
log *slog.Logger
rt atomic.Pointer[router.Router]
Stats *Stats
ipMu sync.Mutex
ipConns map[string]int
}
func New(cfg *config.Manager, h *health.Tracker, log *slog.Logger) *Proxy {
p := &Proxy{cfg: cfg, health: h, log: log, Stats: newStats(), ipConns: map[string]int{}}
p.rt.Store(router.New(cfg.Current()))
cfg.OnChange(func(c *config.Config) { p.rt.Store(router.New(c)) })
return p
}
// Serve accepts connections until ctx ends or the listener fails.
func (p *Proxy) Serve(ctx context.Context, ln net.Listener) error {
go func() { <-ctx.Done(); ln.Close() }()
for {
c, err := ln.Accept()
if err != nil {
if ctx.Err() != nil {
return nil
}
var ne net.Error
if errors.As(err, &ne) && ne.Timeout() {
continue
}
return err
}
go p.handle(c)
}
}
func (p *Proxy) acquireIP(ip string, limit int) bool {
p.ipMu.Lock()
defer p.ipMu.Unlock()
if limit > 0 && p.ipConns[ip] >= limit {
return false
}
p.ipConns[ip]++
return true
}
func (p *Proxy) releaseIP(ip string) {
p.ipMu.Lock()
defer p.ipMu.Unlock()
if p.ipConns[ip]--; p.ipConns[ip] <= 0 {
delete(p.ipConns, ip)
}
}
// reject answers a status ping with a canned MOTD, or a login with a disconnect message.
func (p *Proxy) reject(c net.Conn, h protocol.Handshake, msg string) {
c.SetDeadline(time.Now().Add(10 * time.Second))
if h.IsStatus() {
protocol.ServeStatus(c, protocol.StatusJSON(h.Protocol, "Spinner", msg, 0, 0))
return
}
c.Write(protocol.LoginDisconnect(msg))
}
func (p *Proxy) handle(c net.Conn) {
defer c.Close()
cfg := p.cfg.Current()
p.Stats.total.Add(1)
ip, _, _ := net.SplitHostPort(c.RemoteAddr().String())
if !p.acquireIP(ip, cfg.MaxConnsPerIP) {
p.Stats.rejected.Add(1)
return
}
defer p.releaseIP(ip)
c.SetReadDeadline(time.Now().Add(cfg.HandshakeTimeout()))
hs, raw, err := protocol.ReadHandshake(c)
if err != nil {
p.log.Debug("bad handshake", "remote", ip, "err", err)
return
}
c.SetReadDeadline(time.Time{})
srv := p.rt.Load().Match(hs.Host)
if srv == nil {
p.Stats.unknown.Add(1)
p.log.Debug("unknown host", "remote", ip, "host", hs.Host)
p.reject(c, hs, cfg.UnknownMOTD)
return
}
ss := p.Stats.server(srv.Name)
ss.total.Add(1)
if srv.MaxConnections > 0 && ss.active.Load() >= int64(srv.MaxConnections) {
p.Stats.rejected.Add(1)
p.reject(c, hs, "§cServer is full")
return
}
if hs.IsStatus() {
if js, ok := p.health.Cached(srv.Name, 3*cfg.ProbeInterval()); ok {
c.SetDeadline(time.Now().Add(10 * time.Second))
protocol.ServeStatus(c, js)
return
}
}
b, err := dialAny(srv.Addresses, cfg.ConnectTimeout())
if err != nil {
ss.failures.Add(1)
p.log.Warn("backend unreachable", "server", srv.Name, "err", err)
msg := srv.OfflineMOTD
if msg == "" {
msg = "§c" + srv.Name + " is offline"
}
p.reject(c, hs, msg)
return
}
defer b.Close()
if srv.SendProxyProtocol {
if _, err := b.Write(proxyV2Header(c.RemoteAddr(), c.LocalAddr())); err != nil {
return
}
}
if _, err := b.Write(raw); err != nil {
return
}
p.Stats.active.Add(1)
ss.active.Add(1)
defer func() { p.Stats.active.Add(-1); ss.active.Add(-1) }()
if !hs.IsStatus() {
p.log.Info("connected", "remote", ip, "host", hs.Host, "server", srv.Name)
}
pipe(c, b, ss)
}
func dialAny(addrs []string, timeout time.Duration) (net.Conn, error) {
var errs []error
for _, a := range addrs {
conn, err := net.DialTimeout("tcp", a, timeout)
if err == nil {
return conn, nil
}
errs = append(errs, err)
}
return nil, errors.Join(errs...)
}
// pipe copies both ways and tears both connections down when either side ends.
func pipe(client, backend net.Conn, ss *serverStats) {
done := make(chan struct{}, 2)
go func() {
n, _ := io.Copy(backend, client)
ss.bytesIn.Add(n)
done <- struct{}{}
}()
go func() {
n, _ := io.Copy(client, backend)
ss.bytesOut.Add(n)
done <- struct{}{}
}()
<-done
client.Close()
backend.Close()
<-done
}