diff --git a/README.md b/README.md index 3feee99..e2030d7 100644 --- a/README.md +++ b/README.md @@ -11,65 +11,69 @@ Routes Minecraft client connections to backend servers based upon the requested ```text -api-binding host:port - The host:port bound for servicing API requests (env API_BINDING) + The host:port bound for servicing API requests (env API_BINDING) -auto-scale-up - Increase Kubernetes StatefulSet Replicas (only) from 0 to 1 on respective backend servers when accessed (env AUTO_SCALE_UP) + Increase Kubernetes StatefulSet Replicas (only) from 0 to 1 on respective backend servers when accessed (env AUTO_SCALE_UP) + -clients-to-allow value + Zero or more client IP addresses or CIDRs to allow. Takes precedence over deny. (env CLIENTS_TO_ALLOW) + -clients-to-deny value + Zero or more client IP addresses or CIDRs to deny. Ignored if any configured to allow (env CLIENTS_TO_DENY) -connection-rate-limit int - Max number of connections to allow per second (env CONNECTION_RATE_LIMIT) (default 1) + Max number of connections to allow per second (env CONNECTION_RATE_LIMIT) (default 1) -cpu-profile string - Enables CPU profiling and writes to given path (env CPU_PROFILE) + Enables CPU profiling and writes to given path (env CPU_PROFILE) -debug - Enable debug logs (env DEBUG) + Enable debug logs (env DEBUG) -default string - host:port of a default Minecraft server to use when mapping not found (env DEFAULT) - -docker-socket - Path to Docker socket to use (env DOCKER_SOCKET) (default "unix:///var/run/docker.sock") + host:port of a default Minecraft server to use when mapping not found (env DEFAULT) -docker-refresh-interval int - Refresh interval in seconds for the Docker Swarm integration (env DOCKER_REFRESH_INTERVAL) (default 15) + Refresh interval in seconds for the Docker integrations (env DOCKER_REFRESH_INTERVAL) (default 15) + -docker-socket string + Path to Docker socket to use (env DOCKER_SOCKET) (default "unix:///var/run/docker.sock") -docker-timeout int - Timeout configuration in seconds for the Docker Swarm integration (env DOCKER_TIMEOUT) + Timeout configuration in seconds for the Docker integrations (env DOCKER_TIMEOUT) -in-docker - Use Docker service discovery (env IN_DOCKER) + Use Docker service discovery (env IN_DOCKER) -in-docker-swarm - Use Docker Swarm service discovery (env IN_DOCKER_SWARM) + Use Docker Swarm service discovery (env IN_DOCKER_SWARM) -in-kube-cluster - Use in-cluster Kubernetes config (env IN_KUBE_CLUSTER) + Use in-cluster Kubernetes config (env IN_KUBE_CLUSTER) -kube-config string - The path to a Kubernetes configuration file (env KUBE_CONFIG) + The path to a Kubernetes configuration file (env KUBE_CONFIG) -mapping value - Comma-separated or repeated mappings of externalHostname=host:port (env MAPPING) + Comma or newline delimited or repeated mappings of externalHostname=host:port (env MAPPING) -metrics-backend string - Backend to use for metrics exposure/publishing: discard,expvar,influxdb (env METRICS_BACKEND) (default "discard") + Backend to use for metrics exposure/publishing: discard,expvar,influxdb (env METRICS_BACKEND) (default "discard") -metrics-backend-config-influxdb-addr string - (env METRICS_BACKEND_CONFIG_INFLUXDB_ADDR) + (env METRICS_BACKEND_CONFIG_INFLUXDB_ADDR) -metrics-backend-config-influxdb-database string - (env METRICS_BACKEND_CONFIG_INFLUXDB_DATABASE) + (env METRICS_BACKEND_CONFIG_INFLUXDB_DATABASE) -metrics-backend-config-influxdb-interval duration - (env METRICS_BACKEND_CONFIG_INFLUXDB_INTERVAL) (default 1m0s) + (env METRICS_BACKEND_CONFIG_INFLUXDB_INTERVAL) (default 1m0s) -metrics-backend-config-influxdb-password string - (env METRICS_BACKEND_CONFIG_INFLUXDB_PASSWORD) + (env METRICS_BACKEND_CONFIG_INFLUXDB_PASSWORD) -metrics-backend-config-influxdb-retention-policy string - (env METRICS_BACKEND_CONFIG_INFLUXDB_RETENTION_POLICY) + (env METRICS_BACKEND_CONFIG_INFLUXDB_RETENTION_POLICY) -metrics-backend-config-influxdb-tags value - any extra tags to be included with all reported metrics (env METRICS_BACKEND_CONFIG_INFLUXDB_TAGS) + any extra tags to be included with all reported metrics (env METRICS_BACKEND_CONFIG_INFLUXDB_TAGS) -metrics-backend-config-influxdb-username string - (env METRICS_BACKEND_CONFIG_INFLUXDB_USERNAME) + (env METRICS_BACKEND_CONFIG_INFLUXDB_USERNAME) -ngrok-token string - If set, an ngrok tunnel will be established. It is HIGHLY recommended to pass as an environment variable. (env NGROK_TOKEN) + If set, an ngrok tunnel will be established. It is HIGHLY recommended to pass as an environment variable. (env NGROK_TOKEN) -port port - The port bound to listen for Minecraft client connections (env PORT) (default 25565) + The port bound to listen for Minecraft client connections (env PORT) (default 25565) -receive-proxy-protocol - Receive PROXY protocol from backend servers, by default trusts every proxy header that it receives, combine with -trusted-proxies to specify a list of trusted proxies (env RECEIVE_PROXY_PROTOCOL) + Receive PROXY protocol from backend servers, by default trusts every proxy header that it receives, combine with -trusted-proxies to specify a list of trusted proxies (env RECEIVE_PROXY_PROTOCOL) -routes-config string - Name or full path to routes config file (env ROUTES_CONFIG) + Name or full path to routes config file (env ROUTES_CONFIG) -simplify-srv - Simplify fully qualified SRV records for mapping (env SIMPLIFY_SRV) + Simplify fully qualified SRV records for mapping (env SIMPLIFY_SRV) -trusted-proxies value - Comma delimited list of CIDR notation IP blocks to trust when receiving PROXY protocol (env TRUSTED_PROXIES) + Comma delimited list of CIDR notation IP blocks to trust when receiving PROXY protocol (env TRUSTED_PROXIES) -use-proxy-protocol - Send PROXY protocol to backend servers (env USE_PROXY_PROTOCOL) + Send PROXY protocol to backend servers (env USE_PROXY_PROTOCOL) -version - Output version and exit (env VERSION) + Output version and exit (env VERSION) ``` diff --git a/cmd/mc-router/main.go b/cmd/mc-router/main.go index 2dcc6a7..dc54421 100644 --- a/cmd/mc-router/main.go +++ b/cmd/mc-router/main.go @@ -53,6 +53,9 @@ type Config struct { 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."` + 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"` + SimplifySRV bool `default:"false" usage:"Simplify fully qualified SRV records for mapping"` } @@ -88,6 +91,7 @@ func main() { if err != nil { logrus.WithError(err).Fatal("trying to create cpu profile file") } + //goland:noinspection GoUnhandledErrorResult defer cpuProfileFile.Close() logrus.WithField("file", config.CpuProfile).Info("Starting cpu profiling") @@ -131,7 +135,12 @@ func main() { trustedIpNets = append(trustedIpNets, ipNet) } - connector := server.NewConnector(metricsBuilder.BuildConnectorMetrics(), config.UseProxyProtocol, config.ReceiveProxyProtocol, trustedIpNets) + clientFilter, err := server.NewClientFilter(config.ClientsToAllow, config.ClientsToDeny) + if err != nil { + logrus.WithError(err).Fatal("Unable to create client filter") + } + + connector := server.NewConnector(metricsBuilder.BuildConnectorMetrics(), config.UseProxyProtocol, config.ReceiveProxyProtocol, trustedIpNets, clientFilter) if config.NgrokToken != "" { connector.UseNgrok(config.NgrokToken) } diff --git a/server/client_filter.go b/server/client_filter.go new file mode 100644 index 0000000..f0eade2 --- /dev/null +++ b/server/client_filter.go @@ -0,0 +1,101 @@ +package server + +import ( + "github.com/pkg/errors" + "net/netip" + "strings" +) + +type addrMatcher struct { + addrs []netip.Addr + prefixes []netip.Prefix +} + +func newAddrMatcher(filters []string) (*addrMatcher, error) { + addrs := make([]netip.Addr, 0) + prefixes := make([]netip.Prefix, 0) + + if filters != nil { + for _, filter := range filters { + if strings.Contains(filter, "/") { + prefix, err := netip.ParsePrefix(filter) + if err != nil { + return nil, err + } + prefixes = append(prefixes, prefix) + } else { + addr, err := netip.ParseAddr(filter) + if err != nil { + return nil, err + } + addrs = append(addrs, addr) + } + } + } + + return &addrMatcher{ + addrs: addrs, + prefixes: prefixes, + }, nil +} + +func (a *addrMatcher) Match(addr netip.Addr) bool { + for _, a := range a.addrs { + + // Before comparison, need to unmap addresses such as + // ::ffff:127.0.0.1 + unmapped := addr.Unmap() + if a == unmapped { + return true + } + } + for _, p := range a.prefixes { + if p.Contains(addr) { + return true + } + } + return false +} + +func (a *addrMatcher) Empty() bool { + return len(a.addrs) == 0 && len(a.prefixes) == 0 +} + +// ClientFilter performs allow/deny filtering of client IP addresses +type ClientFilter struct { + allow *addrMatcher + deny *addrMatcher +} + +// NewClientFilter provides a mechanism to evaluate client IP addresses and determine if +// they should be allowed access or not. +// The allows and denies can each or both be nil or netip.ParseAddr allowed values. +func NewClientFilter(allows []string, denies []string) (*ClientFilter, error) { + allow, err := newAddrMatcher(allows) + if err != nil { + return nil, errors.Wrap(err, "invalid allow filter") + } + deny, err := newAddrMatcher(denies) + if err != nil { + return nil, errors.Wrap(err, "invalid deny filter") + } + return &ClientFilter{ + allow: allow, + deny: deny, + }, nil +} + +// Allow determines if the given address is allowed by this filter +// where addrStr is a netip.ParseAddr allowed address +func (f *ClientFilter) Allow(addrPort netip.AddrPort) bool { + if !f.allow.Empty() { + matched := f.allow.Match(addrPort.Addr()) + return matched + } + if !f.deny.Empty() { + matched := f.deny.Match(addrPort.Addr()) + return !matched + } + + return true +} diff --git a/server/client_filter_test.go b/server/client_filter_test.go new file mode 100644 index 0000000..dc7bc69 --- /dev/null +++ b/server/client_filter_test.go @@ -0,0 +1,173 @@ +package server + +import ( + "fmt" + "github.com/stretchr/testify/assert" + "net/netip" + "testing" +) + +func TestClientFilter_Allow(t *testing.T) { + type args struct { + allow []string + deny []string + input string + } + tests := []struct { + name string + args args + want bool + assertErr assert.ErrorAssertionFunc + }{ + { + name: "defaults", + args: args{ + input: "192.168.1.1", + }, + want: true, + assertErr: assert.NoError, + }, + { + name: "just allow - matches", + args: args{ + allow: []string{"192.168.1.1"}, + input: "192.168.1.1", + }, + want: true, + assertErr: assert.NoError, + }, + { + name: "just allow - not match", + args: args{ + allow: []string{"192.168.1.1"}, + input: "192.168.1.2", + }, + want: false, + assertErr: assert.NoError, + }, + { + name: "just allow cidr - matches", + args: args{ + allow: []string{"192.168.1.0/8"}, + input: "192.168.1.2", + }, + want: true, + assertErr: assert.NoError, + }, + { + name: "just allow cidr or specific - matches cidr", + args: args{ + allow: []string{"192.168.1.0/8", "192.168.2.5"}, + input: "192.168.1.2", + }, + want: true, + assertErr: assert.NoError, + }, + { + name: "just allow cidr or specific - matches specific", + args: args{ + allow: []string{"192.168.1.0/8", "192.168.2.5"}, + input: "192.168.2.5", + }, + want: true, + assertErr: assert.NoError, + }, + { + name: "just deny - matches", + args: args{ + deny: []string{"192.168.2.5"}, + input: "192.168.2.5", + }, + want: false, + assertErr: assert.NoError, + }, + { + name: "just deny - not match", + args: args{ + deny: []string{"192.168.2.5"}, + input: "192.168.1.1", + }, + want: true, + assertErr: assert.NoError, + }, + { + name: "mix allow", + args: args{ + allow: []string{"192.168.1.6"}, + deny: []string{"192.168.1.0/8"}, + input: "192.168.1.6", + }, + want: true, + assertErr: assert.NoError, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f, err := NewClientFilter(tt.args.allow, tt.args.deny) + assert.NoError(t, err) + addr, err := netip.ParseAddr(tt.args.input) + assert.NoError(t, err) + got := f.Allow(netip.AddrPortFrom(addr, 25565)) + assert.Equalf(t, tt.want, got, "Allow(%v)", tt.args.input) + }) + } +} + +func TestNewClientFilter(t *testing.T) { + type args struct { + allow []string + deny []string + } + tests := []struct { + name string + args args + assertErr assert.ErrorAssertionFunc + }{ + { + name: "default", + assertErr: assert.NoError, + }, + { + name: "allow single", + args: args{ + allow: []string{"192.168.1.1"}, + }, + assertErr: assert.NoError, + }, + { + name: "allow cidr", + args: args{ + allow: []string{"192.168.1.0/8"}, + }, + assertErr: assert.NoError, + }, + { + name: "deny single", + args: args{ + deny: []string{"192.168.1.1"}, + }, + assertErr: assert.NoError, + }, + { + name: "allow invalid", + args: args{ + allow: []string{"7"}, + }, + assertErr: assert.Error, + }, + { + name: "deny invalid", + args: args{ + deny: []string{"7"}, + }, + assertErr: assert.Error, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := NewClientFilter(tt.args.allow, tt.args.deny) + tt.assertErr(t, err, fmt.Sprintf("NewClientFilter(%v, %v)", tt.args.allow, tt.args.deny)) + }) + } +} diff --git a/server/connector.go b/server/connector.go index a31b694..6119f0b 100644 --- a/server/connector.go +++ b/server/connector.go @@ -33,13 +33,15 @@ type ConnectorMetrics struct { ActiveConnections metrics.Gauge } -func NewConnector(metrics *ConnectorMetrics, sendProxyProto bool, receiveProxyProto bool, trustedProxyNets []*net.IPNet) *Connector { +func NewConnector(metrics *ConnectorMetrics, sendProxyProto bool, receiveProxyProto bool, trustedProxyNets []*net.IPNet, + clientFilter *ClientFilter) *Connector { return &Connector{ metrics: metrics, sendProxyProto: sendProxyProto, connectionsCond: sync.NewCond(&sync.Mutex{}), receiveProxyProto: receiveProxyProto, trustedProxyNets: trustedProxyNets, + clientFilter: clientFilter, } } @@ -53,6 +55,7 @@ type Connector struct { activeConnections int32 connectionsCond *sync.Cond ngrokToken string + clientFilter *ClientFilter } func (c *Connector) StartAcceptingConnections(ctx context.Context, listenAddress string, connRateLimit int) error { @@ -164,6 +167,17 @@ func (c *Connector) HandleConnection(ctx context.Context, frontendConn net.Conn) defer frontendConn.Close() clientAddr := frontendConn.RemoteAddr() + + if tcpAddr, ok := clientAddr.(*net.TCPAddr); ok { + allow := c.clientFilter.Allow(tcpAddr.AddrPort()) + if !allow { + logrus.WithField("client", clientAddr).Debug("Client is blocked") + return + } + } else { + logrus.WithField("client", clientAddr).Warn("Remote address is not a TCP address, skipping filtering") + } + logrus. WithField("client", clientAddr). Info("Got connection")