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>
191 lines
4.3 KiB
Go
191 lines
4.3 KiB
Go
// 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
|
|
}
|