// 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 }