diff --git a/README.md b/README.md index d582e57..ea98ed4 100644 --- a/README.md +++ b/README.md @@ -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": "", "name": ""} + ] + }, + "servers": { + "my.server.domain": { + "allowlist": [ + {"uuid": ""} + ] + }, + "my.other-server.domain": { + "denylist": [ + {"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 diff --git a/cmd/mc-router/main.go b/cmd/mc-router/main.go index a3daa1c..23d062a 100644 --- a/cmd/mc-router/main.go +++ b/cmd/mc-router/main.go @@ -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 { diff --git a/docs/allow-deny-list.schema.json b/docs/allow-deny-list.schema.json new file mode 100644 index 0000000..ebf0919 --- /dev/null +++ b/docs/allow-deny-list.schema.json @@ -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 +} diff --git a/server/allow_deny_list.go b/server/allow_deny_list.go new file mode 100644 index 0000000..3ad3198 --- /dev/null +++ b/server/allow_deny_list.go @@ -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 +} diff --git a/server/allow_deny_list_test.go b/server/allow_deny_list_test.go new file mode 100644 index 0000000..45f500e --- /dev/null +++ b/server/allow_deny_list_test.go @@ -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) + }) + } +} diff --git a/server/connector.go b/server/connector.go index a6efeef..cbc9c37 100644 --- a/server/connector.go +++ b/server/connector.go @@ -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 + } } }