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
|
||||
}
|
||||
Reference in new issue
Block a user