Add option for scale-up allow/deny lists for servers (#397)

This commit is contained in:
Samuel McBroom
2025-04-26 08:57:33 -07:00
committed by GitHub
parent cc5d77e4c8
commit da52e7096f
6 changed files with 450 additions and 17 deletions
+38 -1
View File
@@ -79,7 +79,9 @@ Routes Minecraft client connections to backend servers based upon the requested
-webhook-url string
If set, a POST request that contains connection status notifications will be sent to this HTTP address (env WEBHOOK_URL)
-record-logins
Log and generate metrics on player logins. Metrics only supported with influxdb or prometheus backend
Log and generate metrics on player logins. Metrics only supported with influxdb or prometheus backend (env RECORD_LOGINS)
-auto-scale-up-allow-deny string
Path to config for server allowlists and denylists. If -auto-scale-up is enabled and a global/server entry is specified, only players allowed to connect to the server will be able to trigger a scale up (env AUTO_SCALE_UP_ALLOW_DENY)
```
## Docker Multi-Architecture Image
@@ -170,6 +172,41 @@ The following shows a JSON file for routes config, where `default-server` can al
}
```
## Auto Scale Up Allow/Deny List
The allow/deny list configuration allows limiting which players can scale up servers when using the `-auto-scale-up` option or the `AUTO_SCALE_UP` env variable. Global allow/deny lists can be configured that apply to all backend servers, but server-specific lists can be added as well. There are a few important things to note about the configuration:
- The `mc-router` process will not automatically pick up changes to the config. If updates to the config are made, the router must be restarted.
- Allowlists always take priority over denylists. This means if a player is included in a sever-specific allowlist and the global denylist, the player will still be considered allowed on that server. If a player is listed in both a global allowlist and denylist, the denylist entry will be ignored.
- Player entries only require a `uuid` or `name`. Both will be checked if specified, but otherwise a `uuid` will take priority over a `name`.
An example configuration might look something like:
```json
{
"global": {
"denylist": [
{"uuid": "<some player's uuid>", "name": "<some player's name>"}
]
},
"servers": {
"my.server.domain": {
"allowlist": [
{"uuid": "<some player's uuid>"}
]
},
"my.other-server.domain": {
"denylist": [
{"uuid": "<some player's uuid>"}
]
}
}
}
```
In the example, players in the `my.server.domain` allowlist will be able to scale up `my.server.domain`. Players in the global denylist and the `my.other-server.domain` denylist will **not** be able to scale up `my.other-server.domain`. Any servers not listed in the config will also be affected by the global allowlist. Note that if a global allowlist is specified, no denylists will have any effect as that global allowlist will affect all servers.
For more information on the allow/deny list configuration, see the [json schema](docs/allow-deny-list.schema.json).
## Kubernetes Usage
### Using Kubernetes Service auto-discovery
+10 -1
View File
@@ -58,6 +58,7 @@ type Config struct {
MetricsBackendConfig MetricsBackendConfig
RoutesConfig string `usage:"Name or full path to routes config file"`
NgrokToken string `usage:"If set, an ngrok tunnel will be established. It is HIGHLY recommended to pass as an environment variable."`
AutoScaleUpAllowDeny string `usage:"Path to config for server allowlists and denylists. If -auto-scale-up is enabled and a global/server entry is specified, only players allowed to connect to the server will be able to trigger a scale up"`
ClientsToAllow []string `usage:"Zero or more client IP addresses or CIDRs to allow. Takes precedence over deny."`
ClientsToDeny []string `usage:"Zero or more client IP addresses or CIDRs to deny. Ignored if any configured to allow"`
@@ -110,6 +111,14 @@ func main() {
defer pprof.StopCPUProfile()
}
var autoScaleUpAllowDenyConfig *server.AllowDenyConfig = nil
if config.AutoScaleUpAllowDeny != "" {
autoScaleUpAllowDenyConfig, err = server.ParseAllowDenyConfig(config.AutoScaleUpAllowDeny)
if err != nil {
logrus.WithError(err).Fatal("trying to parse autoscale up allow-deny-list file")
}
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
@@ -143,7 +152,7 @@ func main() {
trustedIpNets = append(trustedIpNets, ipNet)
}
connector := server.NewConnector(metricsBuilder.BuildConnectorMetrics(), config.UseProxyProtocol, config.ReceiveProxyProtocol, trustedIpNets, config.RecordLogins)
connector := server.NewConnector(metricsBuilder.BuildConnectorMetrics(), config.UseProxyProtocol, config.ReceiveProxyProtocol, trustedIpNets, config.RecordLogins, autoScaleUpAllowDenyConfig)
clientFilter, err := server.NewClientFilter(config.ClientsToAllow, config.ClientsToDeny)
if err != nil {
+62
View File
@@ -0,0 +1,62 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/itzg/mc-router/docs/allow-deny-list.schema.json",
"title": "Player allow/deny list",
"description": "Per-server and/or global player allow/deny list",
"type": "object",
"$defs": {
"userInfo": {
"description": "Player to allow/deny by uuid and/or name",
"type": "object",
"properties": {
"uuid": {
"description": "Player username (takes priority over name if specified)",
"type": "string",
"format": "uuid"
},
"name": {
"description": "Player name",
"type": "string"
}
},
"additionalProperties": false
},
"allowDenyLists": {
"description": "Allow and deny lists of player information",
"type": "object",
"properties": {
"allowlist": {
"description": "List of allowed players (takes priority over denylist if specified)",
"type": "array",
"items": {
"$ref": "#/$defs/userInfo"
}
},
"denylist": {
"description": "List of denied players",
"type": "array",
"items": {
"$ref": "#/$defs/userInfo"
}
}
},
"additionalProperties": false
}
},
"properties": {
"global": {
"description": "Global allow and deny lists of player information (allowlists take priority over denylists so if a player is denylisted globally but allowlisted in a server block, they will be allowed on that server)",
"$ref": "#/$defs/allowDenyLists"
},
"servers": {
"description": "Server-specific allow and deny lists of player information with each object key being a server address",
"type": "object",
"patternProperties": {
"^.+$": {
"$ref": "#/$defs/allowDenyLists"
}
}
}
},
"additionalProperties": false
}
+84
View File
@@ -0,0 +1,84 @@
package server
import (
"encoding/json"
"github.com/google/uuid"
"os"
)
type AllowDenyLists struct {
Allowlist []PlayerInfo
Denylist []PlayerInfo
}
type AllowDenyConfig struct {
Global AllowDenyLists
Servers map[string]AllowDenyLists
}
func ParseAllowDenyConfig(allowDenyListPath string) (*AllowDenyConfig, error) {
allowDenyConfig := AllowDenyConfig{}
data, err := os.ReadFile(allowDenyListPath)
if err != nil {
return nil, err
}
err = json.Unmarshal(data, &allowDenyConfig)
if err != nil {
return nil, err
}
return &allowDenyConfig, nil
}
func entryMatchesPlayer(entry *PlayerInfo, userInfo *PlayerInfo) bool {
// User has added an "empty" entry
// This should never match player info
if entry.Name == "" && entry.Uuid == uuid.Nil {
return false
}
if entry.Name != "" && entry.Uuid != uuid.Nil {
return *entry == *userInfo
}
if entry.Uuid != uuid.Nil {
return entry.Uuid == userInfo.Uuid
}
return entry.Name == userInfo.Name
}
func (allowDenyConfig *AllowDenyConfig) ServerAllowsPlayer(serverAddress string, userInfo *PlayerInfo) bool {
if allowDenyConfig == nil {
return true
}
allowlist := allowDenyConfig.Global.Allowlist
denylist := allowDenyConfig.Global.Denylist
serverAllowDenyConfig, ok := allowDenyConfig.Servers[serverAddress]
// Merges global allow/deny lists with server-specific allow/deny lists if provided
if ok {
allowlist = append(allowlist, serverAllowDenyConfig.Allowlist...)
denylist = append(denylist, serverAllowDenyConfig.Denylist...)
}
// If the allowlist is not empty, the player must have an entry or they will be denied
// If the allowlist is empty, then the denylist is checked
// If the allowlist is empty and the player was not in the denylist, then they are allowed
for _, allowedPlayer := range allowlist {
if entryMatchesPlayer(&allowedPlayer, userInfo) {
return true
}
}
if len(allowlist) > 0 {
return false
}
for _, deniedPlayer := range denylist {
if entryMatchesPlayer(&deniedPlayer, userInfo) {
return false
}
}
return true
}
+230
View File
@@ -0,0 +1,230 @@
package server
import (
"github.com/google/uuid"
"testing"
"github.com/stretchr/testify/assert"
)
func Test_allowDenyConfig_ServerAllowsPlayer(t *testing.T) {
type args struct {
serverAddress string
userInfo *PlayerInfo
}
validUserInfo := &PlayerInfo{
Name: "player_name",
Uuid: uuid.MustParse("53036a8f-cbc8-4074-bbc5-98e5e19b0b14"),
}
otherUserInfo := &PlayerInfo{
Name: "other_player",
Uuid: uuid.MustParse("0d51a0ca-f498-44bf-813f-635c18594b8c"),
}
tests := []struct {
name string
allowDenyConfig *AllowDenyConfig
args args
want bool
}{
{
name: "nil config",
allowDenyConfig: nil,
args: args{
serverAddress: "server.my.domain",
userInfo: validUserInfo,
},
want: true,
},
{
name: "empty config",
allowDenyConfig: &AllowDenyConfig{},
args: args{
serverAddress: "server.my.domain",
userInfo: validUserInfo,
},
want: true,
},
{
name: "impossible global allowlist",
allowDenyConfig: &AllowDenyConfig{
Global: AllowDenyLists{
Allowlist: []PlayerInfo{
PlayerInfo{
Name: "",
Uuid: uuid.Nil,
},
},
},
},
args: args{
serverAddress: "server.my.domain",
userInfo: validUserInfo,
},
want: false,
},
{
name: "player allowed globally",
allowDenyConfig: &AllowDenyConfig{
Global: AllowDenyLists{
Allowlist: []PlayerInfo{
*validUserInfo,
},
},
},
args: args{
serverAddress: "server.my.domain",
userInfo: validUserInfo,
},
want: true,
},
{
name: "player not in allowlist",
allowDenyConfig: &AllowDenyConfig{
Global: AllowDenyLists{
Allowlist: []PlayerInfo{
*otherUserInfo,
},
},
},
args: args{
serverAddress: "server.my.domain",
userInfo: validUserInfo,
},
want: false,
},
{
name: "player denied globally",
allowDenyConfig: &AllowDenyConfig{
Global: AllowDenyLists{
Denylist: []PlayerInfo{
*validUserInfo,
},
},
},
args: args{
serverAddress: "server.my.domain",
userInfo: validUserInfo,
},
want: false,
},
{
name: "player allowed and denied globally",
allowDenyConfig: &AllowDenyConfig{
Global: AllowDenyLists{
Allowlist: []PlayerInfo{
*validUserInfo,
},
Denylist: []PlayerInfo{
*validUserInfo,
},
},
},
args: args{
serverAddress: "server.my.domain",
userInfo: validUserInfo,
},
want: true,
},
{
name: "player allowed on server",
allowDenyConfig: &AllowDenyConfig{
Servers: map[string]AllowDenyLists{
"server.my.domain": AllowDenyLists{
Allowlist: []PlayerInfo{
*validUserInfo,
},
},
},
},
args: args{
serverAddress: "server.my.domain",
userInfo: validUserInfo,
},
want: true,
},
{
name: "player not allowed on server",
allowDenyConfig: &AllowDenyConfig{
Servers: map[string]AllowDenyLists{
"server.my.domain": AllowDenyLists{
Allowlist: []PlayerInfo{
*otherUserInfo,
},
},
},
},
args: args{
serverAddress: "server.my.domain",
userInfo: validUserInfo,
},
want: false,
},
{
name: "player denied on server",
allowDenyConfig: &AllowDenyConfig{
Servers: map[string]AllowDenyLists{
"server.my.domain": AllowDenyLists{
Denylist: []PlayerInfo{
*validUserInfo,
},
},
},
},
args: args{
serverAddress: "server.my.domain",
userInfo: validUserInfo,
},
want: false,
},
{
name: "player allowed globally but denied on server",
allowDenyConfig: &AllowDenyConfig{
Global: AllowDenyLists{
Allowlist: []PlayerInfo{
*validUserInfo,
},
},
Servers: map[string]AllowDenyLists{
"server.my.domain": AllowDenyLists{
Denylist: []PlayerInfo{
*validUserInfo,
},
},
},
},
args: args{
serverAddress: "server.my.domain",
userInfo: validUserInfo,
},
want: true,
},
{
name: "player denied globally but allowed on server",
allowDenyConfig: &AllowDenyConfig{
Global: AllowDenyLists{
Denylist: []PlayerInfo{
*validUserInfo,
},
},
Servers: map[string]AllowDenyLists{
"server.my.domain": AllowDenyLists{
Allowlist: []PlayerInfo{
*validUserInfo,
},
},
},
},
args: args{
serverAddress: "server.my.domain",
userInfo: validUserInfo,
},
want: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
allowed := tt.allowDenyConfig.ServerAllowsPlayer(tt.args.serverAddress, tt.args.userInfo)
assert.Equal(t, tt.want, allowed)
})
}
}
+26 -15
View File
@@ -59,14 +59,15 @@ type PlayerInfo struct {
Uuid uuid.UUID `json:"uuid"`
}
func NewConnector(metrics *ConnectorMetrics, sendProxyProto bool, receiveProxyProto bool, trustedProxyNets []*net.IPNet, recordLogins bool) *Connector {
func NewConnector(metrics *ConnectorMetrics, sendProxyProto bool, receiveProxyProto bool, trustedProxyNets []*net.IPNet, recordLogins bool, autoScaleUpAllowDenyConfig *AllowDenyConfig) *Connector {
return &Connector{
metrics: metrics,
sendProxyProto: sendProxyProto,
connectionsCond: sync.NewCond(&sync.Mutex{}),
receiveProxyProto: receiveProxyProto,
trustedProxyNets: trustedProxyNets,
recordLogins: recordLogins,
metrics: metrics,
sendProxyProto: sendProxyProto,
connectionsCond: sync.NewCond(&sync.Mutex{}),
receiveProxyProto: receiveProxyProto,
trustedProxyNets: trustedProxyNets,
recordLogins: recordLogins,
autoScaleUpAllowDenyConfig: autoScaleUpAllowDenyConfig,
}
}
@@ -78,10 +79,11 @@ type Connector struct {
recordLogins bool
trustedProxyNets []*net.IPNet
activeConnections int32
connectionsCond *sync.Cond
ngrokToken string
clientFilter *ClientFilter
activeConnections int32
connectionsCond *sync.Cond
ngrokToken string
clientFilter *ClientFilter
autoScaleUpAllowDenyConfig *AllowDenyConfig
connectionNotifier ConnectionNotifier
}
@@ -335,10 +337,19 @@ func (c *Connector) findAndConnectBackend(ctx context.Context, frontendConn net.
backendHostPort, resolvedHost, waker := Routes.FindBackendForServerAddress(ctx, serverAddress)
if waker != nil && nextState > mcproto.StateStatus {
if err := waker(ctx); err != nil {
logrus.WithFields(logrus.Fields{"serverAddress": serverAddress}).WithError(err).Error("failed to wake up backend")
c.metrics.Errors.With("type", "wakeup_failed").Add(1)
return
serverAllowsPlayer := c.autoScaleUpAllowDenyConfig.ServerAllowsPlayer(serverAddress, userInfo)
logrus.
WithField("client", clientAddr).
WithField("server", serverAddress).
WithField("userInfo", userInfo).
WithField("serverAllowsPlayer", serverAllowsPlayer).
Debug("checked if player is allowed to wake up the server")
if serverAllowsPlayer {
if err := waker(ctx); err != nil {
logrus.WithFields(logrus.Fields{"serverAddress": serverAddress}).WithError(err).Error("failed to wake up backend")
c.metrics.Errors.With("type", "wakeup_failed").Add(1)
return
}
}
}