add whitelist

This commit is contained in:
Meo597
2026-10-20 18:43:40 +08:00
parent 210ed92454
commit c8622967e1
7 changed files with 279 additions and 155 deletions
+6 -1
View File
@@ -99,6 +99,7 @@ var (
type IPFilter struct {
BlockedIPs []string `json:"blockedIPs"`
AllowedIPs []string `json:"allowedIPs"`
}
func (c *IPFilter) Build() (proto.Message, error) {
@@ -106,7 +107,11 @@ func (c *IPFilter) Build() (proto.Message, error) {
if err != nil {
return nil, errors.New(`invalid "blockedIPs"`).Base(err)
}
return &ipfilter.Config{BlockedIps: rules}, nil
allowed, err := geodata.ParseIPRules(c.AllowedIPs)
if err != nil {
return nil, errors.New(`invalid "allowedIPs"`).Base(err)
}
return &ipfilter.Config{BlockedIps: rules, AllowedIps: allowed}, nil
}
type TCPItem struct {
+60 -38
View File
@@ -7,6 +7,7 @@ import (
"strings"
"testing"
"github.com/xtls/xray-core/common/geodata"
. "github.com/xtls/xray-core/infra/conf"
"github.com/xtls/xray-core/transport/internet"
"github.com/xtls/xray-core/transport/internet/finalmask/ipfilter"
@@ -14,44 +15,65 @@ import (
func TestFinalMaskIPFilter(t *testing.T) {
for _, network := range []string{"tcp", "udp"} {
t.Run(network, func(t *testing.T) {
input := fmt.Sprintf(`{"finalmask":{"%s":[{"type":"ip-filter","settings":{"blockedIPs":["192.0.2.0/24"]}}]}}`, network)
var config StreamConfig
if err := json.Unmarshal([]byte(input), &config); err != nil {
t.Fatal(err)
}
built, err := config.Build()
if err != nil {
t.Fatal(err)
}
masks := built.Tcpmasks
if network == "udp" {
masks = built.Udpmasks
}
if len(masks) != 1 {
t.Fatalf("expected one mask, got %d", len(masks))
}
instance, err := masks[0].GetInstance()
if err != nil {
t.Fatal(err)
}
filter, ok := instance.(*ipfilter.Config)
if !ok {
t.Fatalf("unexpected mask type: %T", instance)
}
if len(filter.BlockedIps) != 1 {
t.Fatalf("expected one blocked IP rule, got %d", len(filter.BlockedIps))
}
cidr := filter.BlockedIps[0].GetCustom().GetCidr()
if cidr.GetPrefix() != 24 || !net.IP(cidr.GetIp()).Equal(net.IPv4(192, 0, 2, 0)) {
t.Fatalf("unexpected blocked IP rule: %v", cidr)
}
if _, err := internet.ToMemoryStreamConfig(built); err != nil {
t.Fatalf("failed to initialize runtime finalmask: %v", err)
}
})
for _, test := range []struct {
name, settings string
blocked, allowed int
}{
{"blacklist", `{"blockedIPs":["192.0.2.0/24"]}`, 1, 0},
{"whitelist", `{"allowedIPs":["192.0.2.0/24"]}`, 0, 1},
{"both", `{"blockedIPs":["192.0.2.0/24"],"allowedIPs":["192.0.2.0/24"]}`, 1, 1},
{"empty_whitelist", `{"allowedIPs":[]}`, 0, 0},
{"empty_blacklist", `{"blockedIPs":[]}`, 0, 0},
} {
t.Run(network+"/"+test.name, func(t *testing.T) {
input := fmt.Sprintf(`{"finalmask":{"%s":[{"type":"ip-filter","settings":%s}]}}`, network, test.settings)
var config StreamConfig
if err := json.Unmarshal([]byte(input), &config); err != nil {
t.Fatal(err)
}
built, err := config.Build()
if err != nil {
t.Fatal(err)
}
masks := built.Tcpmasks
if network == "udp" {
masks = built.Udpmasks
}
if len(masks) != 1 {
t.Fatalf("expected one mask, got %d", len(masks))
}
instance, err := masks[0].GetInstance()
if err != nil {
t.Fatal(err)
}
filter, ok := instance.(*ipfilter.Config)
if !ok {
t.Fatalf("unexpected mask type: %T", instance)
}
if len(filter.BlockedIps) != test.blocked || len(filter.AllowedIps) != test.allowed {
t.Fatalf("unexpected IP filter policy: %v", filter)
}
for _, rules := range [][]*geodata.IPRule{filter.BlockedIps, filter.AllowedIps} {
for _, rule := range rules {
cidr := rule.GetCustom().GetCidr()
if cidr.GetPrefix() != 24 || !net.IP(cidr.GetIp()).Equal(net.IPv4(192, 0, 2, 0)) {
t.Fatalf("unexpected IP rule: %v", cidr)
}
}
}
if _, err := internet.ToMemoryStreamConfig(built); err != nil {
t.Fatalf("failed to initialize runtime finalmask: %v", err)
}
})
}
}
if _, err := (&IPFilter{BlockedIPs: []string{"192.0.2.0/33"}}).Build(); err == nil || !strings.Contains(err.Error(), "blockedIPs") {
t.Errorf("expected invalid blockedIPs, got %v", err)
for _, field := range []string{"blockedIPs", "allowedIPs"} {
var config IPFilter
if err := json.Unmarshal([]byte(fmt.Sprintf(`{"%s":["192.0.2.0/33"]}`, field)), &config); err != nil {
t.Fatal(err)
}
if _, err := config.Build(); err == nil || !strings.Contains(err.Error(), field) {
t.Errorf("expected invalid %s, got %v", field, err)
}
}
}
@@ -6,22 +6,36 @@ import (
"github.com/xtls/xray-core/transport/internet/finalmask"
)
func (c *Config) matcher() (geodata.IPMatcher, error) {
if len(c.BlockedIps) == 0 {
func (c *Config) buildFilter() (*filter, error) {
if len(c.BlockedIps) == 0 && len(c.AllowedIps) == 0 {
return nil, nil
}
return geodata.IPReg.BuildIPMatcher(c.BlockedIps)
f := new(filter)
var err error
if len(c.BlockedIps) > 0 {
f.blocked, err = geodata.IPReg.BuildIPMatcher(c.BlockedIps)
if err != nil {
return nil, err
}
}
if len(c.AllowedIps) > 0 {
f.allowed, err = geodata.IPReg.BuildIPMatcher(c.AllowedIps)
if err != nil {
return nil, err
}
}
return f, nil
}
func (c *Config) Listen(inner net.Listener) (net.Listener, error) {
matcher, err := c.matcher()
f, err := c.buildFilter()
if err != nil {
return nil, err
}
if matcher == nil {
if f == nil {
return inner, nil
}
return &listener{Listener: inner, matcher: matcher}, nil
return &listener{Listener: inner, filter: f}, nil
}
func (c *Config) WrapConnClient(conn net.Conn, dest *net.Destination, dialer *finalmask.Dialer) (net.Conn, error) {
@@ -38,12 +52,12 @@ func (c *Config) WrapPacketConnClient(conn net.PacketConn, dest *net.Destination
}
func (c *Config) WrapPacketConnServer(conn net.PacketConn, addr net.Addr, lc *finalmask.ListenConfig) (net.PacketConn, error) {
matcher, err := c.matcher()
f, err := c.buildFilter()
if err != nil {
return nil, err
}
if matcher == nil {
if f == nil {
return conn, nil
}
return &packetConn{PacketConn: conn, matcher: matcher}, nil
return &packetConn{PacketConn: conn, filter: f}, nil
}
@@ -25,6 +25,7 @@ const (
type Config struct {
state protoimpl.MessageState `protogen:"open.v1"`
BlockedIps []*geodata.IPRule `protobuf:"bytes,1,rep,name=blocked_ips,json=blockedIps,proto3" json:"blocked_ips,omitempty"`
AllowedIps []*geodata.IPRule `protobuf:"bytes,2,rep,name=allowed_ips,json=allowedIps,proto3" json:"allowed_ips,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@@ -66,14 +67,23 @@ func (x *Config) GetBlockedIps() []*geodata.IPRule {
return nil
}
func (x *Config) GetAllowedIps() []*geodata.IPRule {
if x != nil {
return x.AllowedIps
}
return nil
}
var File_transport_internet_finalmask_ipfilter_config_proto protoreflect.FileDescriptor
const file_transport_internet_finalmask_ipfilter_config_proto_rawDesc = "" +
"\n" +
"2transport/internet/finalmask/ipfilter/config.proto\x12*xray.transport.internet.finalmask.ipfilter\x1a\x1bcommon/geodata/geodat.proto\"F\n" +
"2transport/internet/finalmask/ipfilter/config.proto\x12*xray.transport.internet.finalmask.ipfilter\x1a\x1bcommon/geodata/geodat.proto\"\x84\x01\n" +
"\x06Config\x12<\n" +
"\vblocked_ips\x18\x01 \x03(\v2\x1b.xray.common.geodata.IPRuleR\n" +
"blockedIpsB\xa0\x01\n" +
"blockedIps\x12<\n" +
"\vallowed_ips\x18\x02 \x03(\v2\x1b.xray.common.geodata.IPRuleR\n" +
"allowedIpsB\xa0\x01\n" +
".com.xray.transport.internet.finalmask.ipfilterP\x01Z?github.com/xtls/xray-core/transport/internet/finalmask/ipfilter\xaa\x02*Xray.Transport.Internet.Finalmask.Ipfilterb\x06proto3"
var (
@@ -95,11 +105,12 @@ var file_transport_internet_finalmask_ipfilter_config_proto_goTypes = []any{
}
var file_transport_internet_finalmask_ipfilter_config_proto_depIdxs = []int32{
1, // 0: xray.transport.internet.finalmask.ipfilter.Config.blocked_ips:type_name -> xray.common.geodata.IPRule
1, // [1:1] is the sub-list for method output_type
1, // [1:1] is the sub-list for method input_type
1, // [1:1] is the sub-list for extension type_name
1, // [1:1] is the sub-list for extension extendee
0, // [0:1] is the sub-list for field type_name
1, // 1: xray.transport.internet.finalmask.ipfilter.Config.allowed_ips:type_name -> xray.common.geodata.IPRule
2, // [2:2] is the sub-list for method output_type
2, // [2:2] is the sub-list for method input_type
2, // [2:2] is the sub-list for extension type_name
2, // [2:2] is the sub-list for extension extendee
0, // [0:2] is the sub-list for field type_name
}
func init() { file_transport_internet_finalmask_ipfilter_config_proto_init() }
@@ -10,4 +10,5 @@ import "common/geodata/geodat.proto";
message Config {
repeated xray.common.geodata.IPRule blocked_ips = 1;
repeated xray.common.geodata.IPRule allowed_ips = 2;
}
@@ -7,7 +7,7 @@ import (
type listener struct {
net.Listener
matcher geodata.IPMatcher
filter *filter
}
// Accept filters connections after the TCP handshake and before higher-level processing.
@@ -17,7 +17,7 @@ func (l *listener) Accept() (net.Conn, error) {
if err != nil {
return nil, err
}
if matchAddress(l.matcher, conn.RemoteAddr()) {
if l.filter.rejects(conn.RemoteAddr()) {
conn.Close()
continue
}
@@ -27,14 +27,14 @@ func (l *listener) Accept() (net.Conn, error) {
type packetConn struct {
net.PacketConn
matcher geodata.IPMatcher
filter *filter
}
func (c *packetConn) ReadFrom(p []byte) (int, net.Addr, error) {
for {
n, addr, err := c.PacketConn.ReadFrom(p)
// Reads may return both data and an error; filter the data first.
if matchAddress(c.matcher, addr) {
if c.filter.rejects(addr) {
if err != nil {
return 0, nil, err
}
@@ -44,15 +44,26 @@ func (c *packetConn) ReadFrom(p []byte) (int, net.Addr, error) {
}
}
func matchAddress(matcher geodata.IPMatcher, addr net.Addr) bool {
type filter struct {
blocked geodata.IPMatcher
allowed geodata.IPMatcher
}
func (f *filter) rejects(addr net.Addr) bool {
var ip net.IP
switch addr := addr.(type) {
case *net.TCPAddr:
return matcher.Match(addr.IP)
ip = addr.IP
case *net.UDPAddr:
return matcher.Match(addr.IP)
ip = addr.IP
case *net.IPAddr:
return matcher.Match(addr.IP)
ip = addr.IP
default:
// Unknown sources cannot satisfy an allowlist.
return f.allowed != nil
}
if f.blocked != nil && f.blocked.Match(ip) {
return true
}
return f.allowed != nil && !f.allowed.Match(ip)
}
@@ -15,33 +15,49 @@ import (
)
func TestTCPFilterBeforeOtherMasks(t *testing.T) {
blocked4 := sourceConn("192.0.2.1")
blocked6 := sourceConn("2001:db8::1")
unknown := &testConn{addr: &net.UnixAddr{Name: "ipfilter.sock", Net: "unix"}}
missing := new(testConn)
allowed := sourceConn("198.51.100.1")
raw := &testListener{conns: []net.Conn{blocked4, blocked6, unknown, missing, allowed}}
spy := new(recordMask)
fm := finalmask.NewFinalMask([]finalmask.TCPMask{spy, testConfig(t, "192.0.2.0/24", "2001:db8::/32")}, nil, nil,
func(context.Context, net.Addr) (net.Listener, error) { return raw, nil }, nil, nil)
l, err := fm.Listen(context.Background(), &net.TCPAddr{})
if err != nil {
t.Fatal(err)
}
defer l.Close()
got, err := l.Accept()
if err != nil || got != allowed {
t.Fatalf("Accept() = (%v, %v), want unblocked connection", got, err)
}
// Fake sockets panic on Read or Write. Rejection must not require handshake data.
if !blocked4.closed || !blocked6.closed || !unknown.closed || !missing.closed || allowed.closed || raw.closed {
t.Fatal("filter did not close only the blocked connections")
}
if len(spy.seen) != 1 || spy.seen[0] != allowed {
t.Fatal("blocked connections reached the next mask")
}
if conn, err := l.Accept(); conn != nil || !errors.Is(err, io.EOF) {
t.Fatalf("listener error was not propagated: (%v, %v)", conn, err)
for _, policy := range testPolicies(t) {
t.Run(policy.name, func(t *testing.T) {
blocked4 := sourceConn("192.0.2.1")
blocked6 := sourceConn("2001:db8::1")
unknown := &testConn{addr: &net.UnixAddr{Name: "ipfilter.sock", Net: "unix"}}
missing := new(testConn)
allowed := sourceConn("198.51.100.1")
allowed6 := sourceConn("2001:db8:1::1")
raw := &testListener{conns: []net.Conn{blocked4, blocked6, unknown, missing, allowed, allowed6}}
spy := new(recordMask)
fm := finalmask.NewFinalMask([]finalmask.TCPMask{spy, policy.config}, nil, nil,
func(context.Context, net.Addr) (net.Listener, error) { return raw, nil }, nil, nil)
l, err := fm.Listen(context.Background(), &net.TCPAddr{})
if err != nil {
t.Fatal(err)
}
defer l.Close()
whitelist := len(policy.config.AllowedIps) > 0
want := []net.Conn{allowed, allowed6}
if !whitelist {
want = append([]net.Conn{unknown, missing}, want...)
}
for _, expected := range want {
if got, err := l.Accept(); err != nil || got != expected {
t.Fatalf("Accept() = (%v, %v), want %v", got, err, expected)
}
}
// Fake sockets panic on Read or Write. Rejection must not require handshake data.
if !blocked4.closed || !blocked6.closed || unknown.closed != whitelist || missing.closed != whitelist || allowed.closed || allowed6.closed || raw.closed {
t.Fatal("filter did not close only the blocked connections")
}
if len(spy.seen) != len(want) {
t.Fatal("blocked connections reached the next mask")
}
for i, conn := range spy.seen {
if conn != want[i] {
t.Fatal("unexpected connection reached the next mask")
}
}
if conn, err := l.Accept(); conn != nil || !errors.Is(err, io.EOF) {
t.Fatalf("listener error was not propagated: (%v, %v)", conn, err)
}
})
}
}
@@ -93,84 +109,128 @@ func TestTCPFilterUsesProxyHeader(t *testing.T) {
}
func TestUDPFilter(t *testing.T) {
allowed := &net.UDPAddr{IP: net.ParseIP("198.51.100.1"), Port: 1234}
raw := &testPacketConn{reads: []datagram{
{data: []byte("blocked"), addr: &net.UDPAddr{IP: net.ParseIP("192.0.2.1"), Port: 1234}},
{addr: &net.UDPAddr{IP: net.ParseIP("2001:db8::1"), Port: 1234}},
{data: []byte("unknown"), addr: &net.UnixAddr{Name: "ipfilter.sock", Net: "unixgram"}},
{data: []byte("missing address")},
{addr: allowed},
{data: []byte("allowed"), addr: allowed},
{data: []byte("blocked again"), addr: &net.UDPAddr{IP: net.ParseIP("192.0.2.2"), Port: 1234}},
}}
fm := finalmask.NewFinalMask(nil, []finalmask.UDPMask{testConfig(t, "192.0.2.0/24", "2001:db8::/32")}, nil, nil, nil,
func(context.Context, net.Addr) (net.PacketConn, error) { return raw, nil })
conn, err := fm.ListenPacket(context.Background(), &net.UDPAddr{})
if err != nil {
t.Fatal(err)
}
defer conn.Close()
buf := make([]byte, 64)
if n, addr, err := conn.ReadFrom(buf); n != 0 || addr != allowed || err != nil {
t.Fatalf("zero-length allowed datagram was lost: (%d, %v, %v)", n, addr, err)
}
n, addr, err := conn.ReadFrom(buf)
if err != nil || addr != allowed || string(buf[:n]) != "allowed" {
t.Fatalf("ReadFrom() = (%q, %v, %v)", buf[:n], addr, err)
}
if _, _, err := conn.ReadFrom(buf); !errors.Is(err, io.EOF) {
t.Fatalf("underlying read error was not propagated: %v", err)
}
if raw.closed {
t.Fatal("dropping a packet closed the shared UDP socket")
for _, policy := range testPolicies(t) {
t.Run(policy.name, func(t *testing.T) {
allowed := &net.UDPAddr{IP: net.ParseIP("198.51.100.1"), Port: 1234}
allowed6 := &net.UDPAddr{IP: net.ParseIP("2001:db8:1::1"), Port: 1234}
unknown := datagram{data: []byte("unknown"), addr: &net.UnixAddr{Name: "ipfilter.sock", Net: "unixgram"}}
missing := datagram{data: []byte("missing address")}
raw := &testPacketConn{reads: []datagram{
{data: []byte("blocked"), addr: &net.UDPAddr{IP: net.ParseIP("192.0.2.1"), Port: 1234}},
{addr: &net.UDPAddr{IP: net.ParseIP("2001:db8::1"), Port: 1234}},
unknown,
missing,
{addr: allowed},
{data: []byte("allowed"), addr: allowed},
{data: []byte("allowed6"), addr: allowed6},
{data: []byte("blocked again"), addr: &net.UDPAddr{IP: net.ParseIP("192.0.2.2"), Port: 1234}},
}}
fm := finalmask.NewFinalMask(nil, []finalmask.UDPMask{policy.config}, nil, nil, nil,
func(context.Context, net.Addr) (net.PacketConn, error) { return raw, nil })
conn, err := fm.ListenPacket(context.Background(), &net.UDPAddr{})
if err != nil {
t.Fatal(err)
}
defer conn.Close()
buf := make([]byte, 64)
if len(policy.config.AllowedIps) == 0 {
for _, expected := range []datagram{unknown, missing} {
n, addr, err := conn.ReadFrom(buf)
if err != nil || addr != expected.addr || string(buf[:n]) != string(expected.data) {
t.Fatalf("unknown source was not allowed by blacklist: (%q, %v, %v)", buf[:n], addr, err)
}
}
}
if n, addr, err := conn.ReadFrom(buf); n != 0 || addr != allowed || err != nil {
t.Fatalf("zero-length allowed datagram was lost: (%d, %v, %v)", n, addr, err)
}
n, addr, err := conn.ReadFrom(buf)
if err != nil || addr != allowed || string(buf[:n]) != "allowed" {
t.Fatalf("ReadFrom() = (%q, %v, %v)", buf[:n], addr, err)
}
n, addr, err = conn.ReadFrom(buf)
if err != nil || addr != allowed6 || string(buf[:n]) != "allowed6" {
t.Fatalf("IPv6 ReadFrom() = (%q, %v, %v)", buf[:n], addr, err)
}
if _, _, err := conn.ReadFrom(buf); !errors.Is(err, io.EOF) {
t.Fatalf("underlying read error was not propagated: %v", err)
}
if raw.closed {
t.Fatal("dropping a packet closed the shared UDP socket")
}
})
}
}
func TestEmptyFilter(t *testing.T) {
rawListener := new(testListener)
rawPacket := new(testPacketConn)
empty := new(Config)
if l, err := empty.Listen(rawListener); err != nil || l != rawListener {
t.Fatal("an empty TCP filter should preserve the listener")
}
if conn, err := empty.WrapPacketConnServer(rawPacket, nil, nil); err != nil || conn != rawPacket {
t.Fatal("an empty UDP filter should preserve the socket")
for _, empty := range []*Config{{}, {AllowedIps: []*geodata.IPRule{}}} {
rawListener := new(testListener)
rawPacket := new(testPacketConn)
if l, err := empty.Listen(rawListener); err != nil || l != rawListener {
t.Fatal("an empty TCP filter should preserve the listener")
}
if conn, err := empty.WrapPacketConnServer(rawPacket, nil, nil); err != nil || conn != rawPacket {
t.Fatal("an empty UDP filter should preserve the socket")
}
}
}
func TestUDPFilterReadError(t *testing.T) {
for _, test := range []struct {
name string
source net.Addr
blocked bool
}{
{"blocked", &net.UDPAddr{IP: net.ParseIP("192.0.2.1")}, true},
{"allowed", &net.UDPAddr{IP: net.ParseIP("198.51.100.1")}, false},
{"missing address", nil, true},
} {
t.Run(test.name, func(t *testing.T) {
source := test.source
raw := &testPacketConn{reads: []datagram{{data: []byte("partial"), addr: source, err: io.ErrShortBuffer}}}
conn, err := testConfig(t, "192.0.2.0/24").WrapPacketConnServer(raw, nil, nil)
if err != nil {
t.Fatal(err)
}
buf := make([]byte, 32)
n, addr, err := conn.ReadFrom(buf)
if !errors.Is(err, io.ErrShortBuffer) {
t.Fatalf("read error was not preserved: %v", err)
}
if test.blocked {
if n != 0 || addr != nil {
t.Fatal("partial data from a blocked source was returned with an error")
}
} else if n != 7 || addr != source || string(buf[:n]) != "partial" {
t.Fatal("partial data from an unblocked source was changed")
for _, policy := range testPolicies(t) {
t.Run(policy.name, func(t *testing.T) {
for _, test := range []struct {
name string
source net.Addr
blocked bool
}{
{"blocked", &net.UDPAddr{IP: net.ParseIP("192.0.2.1")}, true},
{"allowed", &net.UDPAddr{IP: net.ParseIP("198.51.100.1")}, false},
{"missing address", nil, len(policy.config.AllowedIps) > 0},
{"unknown address", &net.UnixAddr{Name: "ipfilter.sock", Net: "unixgram"}, len(policy.config.AllowedIps) > 0},
} {
t.Run(test.name, func(t *testing.T) {
source := test.source
raw := &testPacketConn{reads: []datagram{{data: []byte("partial"), addr: source, err: io.ErrShortBuffer}}}
conn, err := policy.config.WrapPacketConnServer(raw, nil, nil)
if err != nil {
t.Fatal(err)
}
buf := make([]byte, 32)
n, addr, err := conn.ReadFrom(buf)
if !errors.Is(err, io.ErrShortBuffer) {
t.Fatalf("read error was not preserved: %v", err)
}
if test.blocked {
if n != 0 || addr != nil {
t.Fatal("partial data from a blocked source was returned with an error")
}
} else if n != 7 || addr != source || string(buf[:n]) != "partial" {
t.Fatal("partial data from an unblocked source was changed")
}
})
}
})
}
}
func testPolicies(t *testing.T) []struct {
name string
config *Config
} {
t.Helper()
blocked := testConfig(t, "192.0.2.0/24", "2001:db8::/48").BlockedIps
allowed := testConfig(t, "198.51.100.0/24", "2001:db8:1::/48").BlockedIps
broader := testConfig(t, "192.0.2.0/24", "198.51.100.0/24", "2001:db8::/32").BlockedIps
return []struct {
name string
config *Config
}{
{"blacklist", &Config{BlockedIps: blocked}},
{"whitelist", &Config{AllowedIps: allowed}},
{"both", &Config{BlockedIps: blocked, AllowedIps: broader}},
}
}
func testConfig(t *testing.T, entries ...string) *Config {
t.Helper()
rules, err := geodata.ParseIPRules(entries)