Skip to content

Commit

Permalink
add connections & logins rate limiting per IP block
Browse files Browse the repository at this point in the history
  • Loading branch information
robinbraemer committed Aug 5, 2020
1 parent 9e453d6 commit a870c82
Show file tree
Hide file tree
Showing 7 changed files with 181 additions and 33 deletions.
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/)
Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
)
1 change: 1 addition & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
54 changes: 54 additions & 0 deletions internal/quotautil/quota.go
Original file line number Diff line number Diff line change
@@ -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()
}
91 changes: 67 additions & 24 deletions pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
33 changes: 26 additions & 7 deletions pkg/proxy/connect.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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() {
Expand Down Expand Up @@ -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))
Expand Down
16 changes: 14 additions & 2 deletions pkg/proxy/session_handshake.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand Down

0 comments on commit a870c82

Please sign in to comment.