Add support for allow/deny clients by IP (#355)

This commit is contained in:
Geoff Bourne 2024-12-19 07:37:08 -06:00 committed by GitHub
parent 513e0b86a7
commit 7526a7078a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 334 additions and 33 deletions

View File

@ -14,6 +14,10 @@ Routes Minecraft client connections to backend servers based upon the requested
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 -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 -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 -cpu-profile string
@ -22,12 +26,12 @@ Routes Minecraft client connections to backend servers based upon the requested
Enable debug logs (env DEBUG) Enable debug logs (env DEBUG)
-default string -default string
host:port of a default Minecraft server to use when mapping not found (env DEFAULT) 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")
-docker-refresh-interval int -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 -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 -in-docker
Use Docker service discovery (env IN_DOCKER) Use Docker service discovery (env IN_DOCKER)
-in-docker-swarm -in-docker-swarm
@ -37,7 +41,7 @@ Routes Minecraft client connections to backend servers based upon the requested
-kube-config string -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 -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 -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 -metrics-backend-config-influxdb-addr string

View File

@ -53,6 +53,9 @@ type Config struct {
RoutesConfig string `usage:"Name or full path to routes config file"` 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."` 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"` SimplifySRV bool `default:"false" usage:"Simplify fully qualified SRV records for mapping"`
} }
@ -88,6 +91,7 @@ func main() {
if err != nil { if err != nil {
logrus.WithError(err).Fatal("trying to create cpu profile file") logrus.WithError(err).Fatal("trying to create cpu profile file")
} }
//goland:noinspection GoUnhandledErrorResult
defer cpuProfileFile.Close() defer cpuProfileFile.Close()
logrus.WithField("file", config.CpuProfile).Info("Starting cpu profiling") logrus.WithField("file", config.CpuProfile).Info("Starting cpu profiling")
@ -131,7 +135,12 @@ func main() {
trustedIpNets = append(trustedIpNets, ipNet) 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 != "" { if config.NgrokToken != "" {
connector.UseNgrok(config.NgrokToken) connector.UseNgrok(config.NgrokToken)
} }

101
server/client_filter.go Normal file
View File

@ -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
}

View File

@ -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))
})
}
}

View File

@ -33,13 +33,15 @@ type ConnectorMetrics struct {
ActiveConnections metrics.Gauge 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{ return &Connector{
metrics: metrics, metrics: metrics,
sendProxyProto: sendProxyProto, sendProxyProto: sendProxyProto,
connectionsCond: sync.NewCond(&sync.Mutex{}), connectionsCond: sync.NewCond(&sync.Mutex{}),
receiveProxyProto: receiveProxyProto, receiveProxyProto: receiveProxyProto,
trustedProxyNets: trustedProxyNets, trustedProxyNets: trustedProxyNets,
clientFilter: clientFilter,
} }
} }
@ -53,6 +55,7 @@ type Connector struct {
activeConnections int32 activeConnections int32
connectionsCond *sync.Cond connectionsCond *sync.Cond
ngrokToken string ngrokToken string
clientFilter *ClientFilter
} }
func (c *Connector) StartAcceptingConnections(ctx context.Context, listenAddress string, connRateLimit int) error { 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() defer frontendConn.Close()
clientAddr := frontendConn.RemoteAddr() 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. logrus.
WithField("client", clientAddr). WithField("client", clientAddr).
Info("Got connection") Info("Got connection")