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,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
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.veillax.com/Veillax/spinner/internal/config"
|
||||
"git.veillax.com/Veillax/spinner/internal/health"
|
||||
"git.veillax.com/Veillax/spinner/internal/protocol"
|
||||
)
|
||||
|
||||
var quiet = slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
|
||||
// backend is a fake Minecraft server: status pings get a canned response, and
|
||||
// login connections are answered with "hello from <name>" plus the PROXY header
|
||||
// it saw, if any.
|
||||
func backend(t *testing.T, name string, expectProxy bool) string {
|
||||
t.Helper()
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { ln.Close() })
|
||||
go func() {
|
||||
for {
|
||||
c, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
defer c.Close()
|
||||
c.SetDeadline(time.Now().Add(5 * time.Second))
|
||||
seen := "none"
|
||||
if expectProxy {
|
||||
hdr := make([]byte, 16)
|
||||
if _, err := io.ReadFull(c, hdr); err != nil {
|
||||
return
|
||||
}
|
||||
rest := make([]byte, binary.BigEndian.Uint16(hdr[14:]))
|
||||
io.ReadFull(c, rest)
|
||||
if bytes.Equal(hdr[:12], v2sig) && hdr[12] == 0x21 && hdr[13] == 0x11 {
|
||||
seen = fmt.Sprintf("%d.%d.%d.%d", rest[0], rest[1], rest[2], rest[3])
|
||||
}
|
||||
}
|
||||
hs, _, err := protocol.ReadHandshake(c)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if hs.IsStatus() {
|
||||
protocol.ServeStatus(c, `{"version":{"name":"fake","protocol":767},"players":{"online":2,"max":10},"description":{"text":"`+name+`"}}`)
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(c, "hello from %s proxy=%s host=%s", name, seen, hs.Host)
|
||||
}()
|
||||
}
|
||||
}()
|
||||
return ln.Addr().String()
|
||||
}
|
||||
|
||||
func start(t *testing.T, cfgJSON string) (addr string, p *Proxy, h *health.Tracker) {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "spinner.json")
|
||||
if err := os.WriteFile(path, []byte(cfgJSON), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m, err := config.NewManager(path, quiet)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h = health.New(quiet)
|
||||
p = New(m, h, quiet)
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
t.Cleanup(cancel)
|
||||
go p.Serve(ctx, ln)
|
||||
return ln.Addr().String(), p, h
|
||||
}
|
||||
|
||||
func login(t *testing.T, addr, host string) string {
|
||||
t.Helper()
|
||||
c, err := net.Dial("tcp", addr)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer c.Close()
|
||||
c.SetDeadline(time.Now().Add(5 * time.Second))
|
||||
c.Write(protocol.BuildHandshake(767, host, 25565, 2))
|
||||
b, _ := io.ReadAll(c)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func status(t *testing.T, addr, host string) string {
|
||||
t.Helper()
|
||||
c, err := net.Dial("tcp", addr)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer c.Close()
|
||||
js, err := protocol.QueryStatus(c, host, 25565, 5*time.Second)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return js
|
||||
}
|
||||
|
||||
func TestRoutesByHostname(t *testing.T) {
|
||||
a, b := backend(t, "alpha", false), backend(t, "beta", false)
|
||||
addr, _, _ := start(t, fmt.Sprintf(`{"servers":[
|
||||
{"name":"alpha","domains":["alpha.test"],"addresses":[%q]},
|
||||
{"name":"beta","domains":["*.beta.test"],"addresses":[%q]}]}`, a, b))
|
||||
|
||||
if got := login(t, addr, "alpha.test"); !strings.HasPrefix(got, "hello from alpha") {
|
||||
t.Errorf("alpha: %q", got)
|
||||
}
|
||||
if got := login(t, addr, "x.beta.test"); !strings.HasPrefix(got, "hello from beta") || !strings.Contains(got, "host=x.beta.test") {
|
||||
t.Errorf("beta: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxyProtocolHeader(t *testing.T) {
|
||||
a := backend(t, "pp", true)
|
||||
addr, _, _ := start(t, fmt.Sprintf(`{"servers":[{"name":"pp","domains":["pp.test"],"addresses":[%q],"send_proxy_protocol":true}]}`, a))
|
||||
if got := login(t, addr, "pp.test"); !strings.Contains(got, "proxy=127.0.0.1") {
|
||||
t.Errorf("backend did not see client address in PROXY header: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFallsBackToSecondAddress(t *testing.T) {
|
||||
good := backend(t, "good", false)
|
||||
addr, _, _ := start(t, fmt.Sprintf(`{"servers":[{"name":"s","domains":["s.test"],"addresses":["127.0.0.1:1",%q]}]}`, good))
|
||||
if got := login(t, addr, "s.test"); !strings.HasPrefix(got, "hello from good") {
|
||||
t.Errorf("got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOfflineBackend(t *testing.T) {
|
||||
addr, p, _ := start(t, `{"servers":[{"name":"dead","domains":["dead.test"],"addresses":["127.0.0.1:1"],"offline_motd":"napping"}]}`)
|
||||
if js := status(t, addr, "dead.test"); !strings.Contains(js, "napping") {
|
||||
t.Errorf("status: %s", js)
|
||||
}
|
||||
if got := login(t, addr, "dead.test"); !strings.Contains(got, "napping") {
|
||||
t.Errorf("login: %q", got)
|
||||
}
|
||||
if f := p.Stats.Snapshot().Servers[0].Failures; f < 2 {
|
||||
t.Errorf("failures = %d", f)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownHost(t *testing.T) {
|
||||
addr, p, _ := start(t, `{"unknown_motd":"nobody home","servers":[]}`)
|
||||
if js := status(t, addr, "who.test"); !strings.Contains(js, "nobody home") {
|
||||
t.Errorf("status: %s", js)
|
||||
}
|
||||
if got := login(t, addr, "who.test"); !strings.Contains(got, "nobody home") {
|
||||
t.Errorf("login: %q", got)
|
||||
}
|
||||
if p.Stats.Snapshot().Unknown != 2 {
|
||||
t.Errorf("unknown = %d", p.Stats.Snapshot().Unknown)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusServedFromHealthCache(t *testing.T) {
|
||||
a := backend(t, "live", false)
|
||||
addr, p, h := start(t, fmt.Sprintf(`{"probe_interval_secs":60,"servers":[{"name":"live","domains":["live.test"],"addresses":[%q]}]}`, a))
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
go h.Run(ctx, p.cfg)
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
for {
|
||||
if _, ok := h.Cached("live", time.Minute); ok {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("probe never succeeded")
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
if js := status(t, addr, "live.test"); !strings.Contains(js, `"online":2`) {
|
||||
t.Errorf("status: %s", js)
|
||||
}
|
||||
if snap := h.Snapshot(); len(snap) != 1 || !snap[0].Up || snap[0].Online != 2 || snap[0].Version != "fake" {
|
||||
t.Errorf("snapshot: %+v", snap)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPerIPLimit(t *testing.T) {
|
||||
addr, p, _ := start(t, `{"max_conns_per_ip":1,"servers":[]}`)
|
||||
hold, err := net.Dial("tcp", addr) // never sends a handshake, so it keeps its slot
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer hold.Close()
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
c, _ := net.Dial("tcp", addr)
|
||||
defer c.Close()
|
||||
c.SetDeadline(time.Now().Add(2 * time.Second))
|
||||
if _, err := io.ReadAll(c); err != nil && !strings.Contains(err.Error(), "reset") {
|
||||
t.Errorf("expected close, got %v", err)
|
||||
}
|
||||
if p.Stats.Snapshot().Rejected != 1 {
|
||||
t.Errorf("rejected = %d", p.Stats.Snapshot().Rejected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigHotSwap(t *testing.T) {
|
||||
a := backend(t, "one", false)
|
||||
addr, p, _ := start(t, `{"servers":[]}`)
|
||||
if got := login(t, addr, "one.test"); strings.HasPrefix(got, "hello") {
|
||||
t.Fatalf("should be unknown: %q", got)
|
||||
}
|
||||
cfg, err := config.Parse([]byte(fmt.Sprintf(`{"servers":[{"name":"one","domains":["one.test"],"addresses":[%q]}]}`, a)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := p.cfg.Save(cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := login(t, addr, "one.test"); !strings.HasPrefix(got, "hello from one") {
|
||||
t.Errorf("after save: %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"net"
|
||||
)
|
||||
|
||||
var v2sig = []byte("\r\n\r\n\x00\r\nQUIT\n")
|
||||
|
||||
// proxyV2Header builds a PROXY protocol v2 header carrying the client's real
|
||||
// address so the backend can see it. Falls back to a LOCAL header if the
|
||||
// addresses aren't TCP.
|
||||
func proxyV2Header(src, dst net.Addr) []byte {
|
||||
s, sok := src.(*net.TCPAddr)
|
||||
d, dok := dst.(*net.TCPAddr)
|
||||
h := append([]byte{}, v2sig...)
|
||||
if !sok || !dok {
|
||||
return append(h, 0x20, 0x00, 0, 0)
|
||||
}
|
||||
if s4, d4 := s.IP.To4(), d.IP.To4(); s4 != nil && d4 != nil {
|
||||
h = append(h, 0x21, 0x11, 0, 12)
|
||||
h = append(h, s4...)
|
||||
h = append(h, d4...)
|
||||
} else {
|
||||
h = append(h, 0x21, 0x21, 0, 36)
|
||||
h = append(h, s.IP.To16()...)
|
||||
h = append(h, d.IP.To16()...)
|
||||
}
|
||||
h = binary.BigEndian.AppendUint16(h, uint16(s.Port))
|
||||
return binary.BigEndian.AppendUint16(h, uint16(d.Port))
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
type serverStats struct {
|
||||
active atomic.Int64
|
||||
total atomic.Int64
|
||||
failures atomic.Int64
|
||||
bytesIn atomic.Int64 // client -> backend
|
||||
bytesOut atomic.Int64 // backend -> client
|
||||
}
|
||||
|
||||
type Stats struct {
|
||||
Started time.Time
|
||||
active atomic.Int64
|
||||
total atomic.Int64
|
||||
rejected atomic.Int64
|
||||
unknown atomic.Int64
|
||||
mu sync.Mutex
|
||||
servers map[string]*serverStats
|
||||
}
|
||||
|
||||
func newStats() *Stats {
|
||||
return &Stats{Started: time.Now(), servers: map[string]*serverStats{}}
|
||||
}
|
||||
|
||||
func (s *Stats) server(name string) *serverStats {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
ss := s.servers[name]
|
||||
if ss == nil {
|
||||
ss = &serverStats{}
|
||||
s.servers[name] = ss
|
||||
}
|
||||
return ss
|
||||
}
|
||||
|
||||
type ServerSnapshot struct {
|
||||
Name string `json:"name"`
|
||||
Active int64 `json:"active"`
|
||||
Total int64 `json:"total"`
|
||||
Failures int64 `json:"failures"`
|
||||
BytesIn int64 `json:"bytes_in"`
|
||||
BytesOut int64 `json:"bytes_out"`
|
||||
}
|
||||
|
||||
type Snapshot struct {
|
||||
UptimeSecs int64 `json:"uptime_secs"`
|
||||
Active int64 `json:"active"`
|
||||
Total int64 `json:"total"`
|
||||
Rejected int64 `json:"rejected"`
|
||||
Unknown int64 `json:"unknown_host"`
|
||||
Servers []ServerSnapshot `json:"servers"`
|
||||
}
|
||||
|
||||
func (s *Stats) Snapshot() Snapshot {
|
||||
out := Snapshot{
|
||||
UptimeSecs: int64(time.Since(s.Started).Seconds()),
|
||||
Active: s.active.Load(), Total: s.total.Load(),
|
||||
Rejected: s.rejected.Load(), Unknown: s.unknown.Load(),
|
||||
}
|
||||
s.mu.Lock()
|
||||
for n, ss := range s.servers {
|
||||
out.Servers = append(out.Servers, ServerSnapshot{n, ss.active.Load(), ss.total.Load(),
|
||||
ss.failures.Load(), ss.bytesIn.Load(), ss.bytesOut.Load()})
|
||||
}
|
||||
s.mu.Unlock()
|
||||
sort.Slice(out.Servers, func(i, j int) bool { return out.Servers[i].Name < out.Servers[j].Name })
|
||||
return out
|
||||
}
|
||||
Reference in new issue
Block a user