From a870c821180c7d91339f755255bddb0b86a7bf69 Mon Sep 17 00:00:00 2001 From: robinbraemer Date: Wed, 5 Aug 2020 19:17:08 +0200 Subject: [PATCH] add connections & logins rate limiting per IP block --- README.md | 16 ++++++ go.mod | 3 ++ go.sum | 1 + internal/quotautil/quota.go | 54 ++++++++++++++++++++ pkg/config/config.go | 91 +++++++++++++++++++++++++--------- pkg/proxy/connect.go | 33 +++++++++--- pkg/proxy/session_handshake.go | 16 +++++- 7 files changed, 181 insertions(+), 33 deletions(-) create mode 100644 internal/quotautil/quota.go diff --git a/README.md b/README.md index 6ee34b53..9f4037cd 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ - simply pick a download from the [releases](https://github.com/minekube/gate/releases) - (No Java runtime needed for Gate itself) - A simple API to [extend Gate](#extending-gate-with-custom-code) +- Built-in [rate limiter](#rate-limiter) - Benefits from Go's awesome language features - simple, reliable, efficient - [and much more](https://golang.org/) @@ -58,6 +59,21 @@ players (Minecraft client) and servers (e.g. Minecraft spigot, paper, sponge, .. logs state changes and emits different events that custom plugins/code can react to. +## Rate Limiter + +Rate limiting is an important mechanism for controlling +resource utilization and managing quality of service. + +Defaults set should never affect legitimate operations, +but rate limit aggressive behaviours. + +In the `quota` section you can configure rate limiter +to block too many connections from the same IP-block (255.255.255.xxx). + +**Note:** _The limiter only prevents attacks on a per IP block bases +and cannot mitigate against distributed denial of services (DDoS), since this type +of attack should be handled on a higher networking layer._ + ## Benchmarks > TODO diff --git a/go.mod b/go.mod index b4c3949d..c0414f34 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,9 @@ module go.minekube.com/gate go 1.14 require ( + github.com/davecgh/go-spew v1.1.1 github.com/gammazero/deque v0.0.0-20200721202602-07291166fe33 + github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef github.com/google/uuid v1.1.1 github.com/sandertv/gophertunnel v1.7.11 github.com/spf13/cobra v1.0.0 @@ -15,5 +17,6 @@ require ( go.uber.org/zap v1.15.0 golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae // indirect golang.org/x/text v0.3.2 + golang.org/x/time v0.0.0-20190308202827-9d24e82272b4 gopkg.in/yaml.v2 v2.2.7 // indirect ) diff --git a/go.sum b/go.sum index 28f32055..10f50751 100644 --- a/go.sum +++ b/go.sum @@ -75,6 +75,7 @@ github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/me github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef h1:veQD95Isof8w9/WXiA+pa3tz3fJXkt5B7QaRBrM62gk= github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/lint v0.0.0-20180702182130-06c8688daad7/go.mod h1:tluoj9z5200jBnyusfRPU2LqT6J+DAorxEvtC7LHB+E= github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= diff --git a/internal/quotautil/quota.go b/internal/quotautil/quota.go new file mode 100644 index 00000000..c1f8e5fd --- /dev/null +++ b/internal/quotautil/quota.go @@ -0,0 +1,54 @@ +package quotautil + +import ( + "github.com/golang/groupcache/lru" + "golang.org/x/time/rate" + "net" + "sync" +) + +// Quota implements a simple IP-based rate limiter. +// Each set of incoming IP addresses with the same +// low-order byte gets events per second. +// Information is kept in an LRU cache of size maxEntries. +type Quota struct { + eps float32 // allowed events per second + burst int // maximum event per second (queue) + mu sync.Mutex // protects cache + cache *lru.Cache +} + +func (q *Quota) Blocked(addr net.Addr) bool { + var limiter *rate.Limiter + key := ipKey(addr) + if key != "" { + q.mu.Lock() + if v, ok := q.cache.Get(key); ok { + limiter = v.(*rate.Limiter) + } else { + limiter = rate.NewLimiter(rate.Limit(q.eps), q.burst) + q.cache.Add(key, limiter) + } + q.mu.Unlock() + } + return limiter != nil && !limiter.Allow() +} + +func NewQuota(eventsPerSecond float32, burst, maxEntries int) *Quota { + return &Quota{ + eps: eventsPerSecond, + burst: burst, + cache: lru.New(maxEntries), + } +} + +func ipKey(addr net.Addr) string { + host, _, _ := net.SplitHostPort(addr.String()) + ip := net.ParseIP(host) + if ip == nil { + return "" + } + // Zero out last byte, to cover ranges. + ip[len(ip)-1] = 0 + return ip.String() +} diff --git a/pkg/config/config.go b/pkg/config/config.go index 7d2bba07..ab8e14c7 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -17,59 +17,102 @@ type Config struct { // File is for reading a config file into this struct. type File struct { // The address to listen for connections. - Bind string + Bind string + OnlineMode bool OnlineModeKickExistingPlayers bool - Forwarding struct { - Mode ForwardingMode - VelocitySecret string - } - Status struct { + + Forwarding Forwarding + Status Status + Query Query + // Whether the proxy should present itself as a + // Forge/FML-compatible server. By default, this is disabled. + AnnounceForge bool + + Servers map[string]string // name:address + Try []string // Try server names order + ForcedHosts ForcedHosts // virtualhost:server names + FailoverOnUnexpectedServerDisconnect bool + + ConnectionTimeout int + ReadTimeout int + + Quota Quota + Compression Compression + ProxyProtocol bool // ha-proxy compatibility + ShouldPreventClientProxyConnections bool // sends player ip to mojang + + BungeePluginChannelEnabled bool + + Debug bool + ConfigAutoUpdate bool +} + +type ( + ForcedHosts map[string][]string + Status struct { MaxPlayers int Motd string FavIconFile string ShowPingRequests bool } - // Whether the proxy should present itself as a - // Forge/FML-compatible server. By default, this is disabled. - AnnounceForge bool - Servers map[string]string // name:address - Try []string // Try server names order - ForcedHosts map[string][]string // virtualhost:server names - Query struct { + Query struct { Enabled bool Port int ShowPlugins bool } - ConnectionTimeout int - ReadTimeout int - FailoverOnUnexpectedServerDisconnect bool - Compression struct { + Forwarding struct { + Mode ForwardingMode + VelocitySecret string + } + Compression struct { Threshold int Level int } - ShouldPreventClientProxyConnections bool // sends player ip to mojang - BungeePluginChannelEnabled bool - ProxyProtocol bool // ha-proxy compatibility - ConfigAutoUpdate bool - Debug bool -} + // Quota is the config for rate limiting. + Quota struct { + Connections QuotaSettings // Limits new connections per second, per IP block. + Logins QuotaSettings // Limits logins per second, per IP block. + // Maybe add a bytes-per-sec limiter, or should be managed by a higher layer. + } + QuotaSettings struct { + Enabled bool // If false, there is no such limiting. + OPS float32 // Allowed operations/events per second, per IP block + Burst int // The maximum events per second, per block; the size of the token bucket + MaxEntries int // Maximum number of IP blocks to keep track of in cache + } +) func init() { viper.SetDefault("bind", "0.0.0.0:25565") viper.SetDefault("onlineMode", true) viper.SetDefault("forwarding.mode", LegacyForwardingMode) viper.SetDefault("announceForge", false) + viper.SetDefault("status.motd", "§bA Gate Proxy Server!") viper.SetDefault("status.maxplayers", 1000) viper.SetDefault("status.faviconfile", "server-icon.png") viper.SetDefault("status.showPingRequests", false) + viper.SetDefault("compression.threshold", 256) viper.SetDefault("compression.level", 1) + viper.SetDefault("query.enabled", false) viper.SetDefault("query.port", 25577) viper.SetDefault("query.showplugins", false) - viper.SetDefault("loginratelimit", 3000) + + // Default quotas should never affect legitimate operations, + // but rate limits aggressive behaviours. + viper.SetDefault("quota.connections.Enabled", true) + viper.SetDefault("quota.connections.OPS", 5) + viper.SetDefault("quota.connections.burst", 10) + viper.SetDefault("quota.connections.MaxEntries", 1000) + + viper.SetDefault("quota.logins.Enabled", true) + viper.SetDefault("quota.logins.OPS", 0.4) + viper.SetDefault("quota.logins.burst", 3) + viper.SetDefault("quota.logins.MaxEntries", 1000) + viper.SetDefault("connectiontimeout", 5000) viper.SetDefault("readtimeout", 30000) viper.SetDefault("BungeePluginChannelEnabled", true) diff --git a/pkg/proxy/connect.go b/pkg/proxy/connect.go index e7c5b4c0..4d24ec44 100644 --- a/pkg/proxy/connect.go +++ b/pkg/proxy/connect.go @@ -3,6 +3,7 @@ package proxy import ( "fmt" "go.minekube.com/common/minecraft/component" + "go.minekube.com/gate/internal/quotautil" "go.minekube.com/gate/pkg/config" "go.minekube.com/gate/pkg/util/uuid" "go.uber.org/zap" @@ -12,21 +13,32 @@ import ( ) type Connect struct { - proxy *Proxy - closeListenChan chan struct{} + proxy *Proxy + closeListenChan chan struct{} + connectionsQuota *quotautil.Quota + loginsQuota *quotautil.Quota mu sync.RWMutex // Protects following fields names map[string]*connectedPlayer // lower case usernames map ids map[uuid.UUID]*connectedPlayer // uuids map } -func NewConnect(s *Proxy) *Connect { - return &Connect{ - proxy: s, +func NewConnect(proxy *Proxy) *Connect { + c := &Connect{ + proxy: proxy, closeListenChan: make(chan struct{}), names: map[string]*connectedPlayer{}, ids: map[uuid.UUID]*connectedPlayer{}, } + quota := proxy.config.Quota.Connections + if quota.Enabled { + c.connectionsQuota = quotautil.NewQuota(quota.OPS, quota.Burst, quota.MaxEntries) + } + quota = proxy.config.Quota.Logins + if quota.Enabled { + c.loginsQuota = quotautil.NewQuota(quota.OPS, quota.Burst, quota.MaxEntries) + } + return c } func (c *Connect) closeListener() { @@ -61,15 +73,22 @@ func (c *Connect) listen(address string) error { if err != nil { return fmt.Errorf("error accepting new connection: %w", err) } + go c.handleRawConn(conn) } } // handleRawConn handles a just-accepted connection that // has not had any I/O performed on it yet. -func (c *Connect) handleRawConn(rawConn net.Conn) { +func (c *Connect) handleRawConn(raw net.Conn) { + if c.connectionsQuota != nil && c.connectionsQuota.Blocked(raw.RemoteAddr()) { + _ = raw.Close() + zap.L().Info("A connection was exceeded the rate limit", zap.Stringer("remoteAddr", raw.RemoteAddr())) + return + } + // Create client connection - conn := newMinecraftConn(rawConn, c.proxy, true, func() []zap.Field { + conn := newMinecraftConn(raw, c.proxy, true, func() []zap.Field { return []zap.Field{zap.Bool("player", true)} }) conn.setSessionHandler0(newHandshakeSessionHandler(conn)) diff --git a/pkg/proxy/session_handshake.go b/pkg/proxy/session_handshake.go index 7b874f26..68f42d00 100644 --- a/pkg/proxy/session_handshake.go +++ b/pkg/proxy/session_handshake.go @@ -2,7 +2,9 @@ package proxy import ( "fmt" + "go.minekube.com/common/minecraft/color" "go.minekube.com/common/minecraft/component" + "go.minekube.com/gate/internal/quotautil" "go.minekube.com/gate/pkg/config" "go.minekube.com/gate/pkg/proto" "go.minekube.com/gate/pkg/proto/packet" @@ -77,8 +79,14 @@ func (h *handshakeSessionHandler) handleLogin(p *packet.Handshake, inbound Inbou return } - // TODO add client IP rate limiter preventing too fast logins - + // Client IP-block rate limiter preventing too fast logins hitting the Mojang API + if loginsQuota := h.loginsQuota(); loginsQuota != nil && loginsQuota.Blocked(inbound.RemoteAddr()) { + _ = h.conn.closeWith(packet.DisconnectWith(&component.Text{ + Content: "You are logging in to fast, wait a little and retry.", + S: component.Style{Color: color.Red}, + })) + return + } h.conn.SetType(connTypeForHandshake(p)) // If the proxy is configured for velocity's forwarding mode, we must deny connections from 1.12.2 @@ -95,6 +103,10 @@ func (h *handshakeSessionHandler) handleLogin(p *packet.Handshake, inbound Inbou h.conn.setSessionHandler(newLoginSessionHandler(h.conn, inbound)) } +func (h *handshakeSessionHandler) loginsQuota() *quotautil.Quota { + return h.conn.proxy.Connect().loginsQuota +} + func stateForProtocol(status int) *state.Registry { switch proto.State(status) { case proto.StatusState: