diff --git a/Projects/Server/Network/IConnectionFilter.cs b/Projects/Server/Network/IConnectionFilter.cs
index 15853947f..225b54be1 100644
--- a/Projects/Server/Network/IConnectionFilter.cs
+++ b/Projects/Server/Network/IConnectionFilter.cs
@@ -21,8 +21,9 @@ namespace Server.Network;
///
/// A gate consulted for every inbound connection, before the socket is configured and before any
/// per-connection allocation. Implementations decide membership only — the accept path neither knows
-/// nor cares where a filter's data comes from, so a filter may be a small admin-curated set held in
-/// core (see Firewall) or a millions-strong list hydrated from a file by content.
+/// nor cares where a filter's data comes from, so a filter may be a handful of admin-curated entries,
+/// a millions-strong list hydrated from a file, or a query against something else entirely. Core owns
+/// the question; content owns every answer (see Firewall and BlocklistFilter in UOContent).
///
///
///
diff --git a/Projects/Server/Utilities/IPAddressUtility.cs b/Projects/Server/Utilities/IPAddressUtility.cs
index ef9a390e5..245bd2b46 100644
--- a/Projects/Server/Utilities/IPAddressUtility.cs
+++ b/Projects/Server/Utilities/IPAddressUtility.cs
@@ -72,6 +72,49 @@ public static class IPAddressUtility
return new IPAddress(bytes);
}
+ ///
+ /// Parses a.b.c.d/n, ::/n, or a bare address (treated as a single-host range) into an
+ /// inclusive range in normalized IPv6 form. A bare IPv4 prefix is widened by 96
+ /// bits so v4 and v6 ranges are directly comparable. Returns false on anything malformed.
+ ///
+ public static bool TryParseCidrRange(ReadOnlySpan cidr, out UInt128 min, out UInt128 max)
+ {
+ min = default;
+ max = default;
+
+ var slash = cidr.IndexOf('/');
+ if (!IPAddress.TryParse(slash >= 0 ? cidr[..slash] : cidr, out var ip))
+ {
+ return false;
+ }
+
+ var isV6 = ip.AddressFamily == AddressFamily.InterNetworkV6;
+ var maxPrefixLength = isV6 ? 128 : 32;
+ int prefixLength;
+
+ if (slash < 0)
+ {
+ prefixLength = maxPrefixLength;
+ }
+ else if (!int.TryParse(cidr[(slash + 1)..], out prefixLength) ||
+ prefixLength < 0 || prefixLength > maxPrefixLength)
+ {
+ return false;
+ }
+
+ if (!isV6)
+ {
+ prefixLength += 96; // 32 -> 128
+ }
+
+ Span bytes = stackalloc byte[16];
+ ip.WriteMappedIPv6To(bytes);
+
+ min = Utility.CreateCidrAddress(bytes, prefixLength, false);
+ max = Utility.CreateCidrAddress(bytes, prefixLength, true);
+ return true;
+ }
+
/// Extracts the big-endian uint of an address.
public static bool TryV4(IPAddress ip, out uint v)
{
diff --git a/Projects/Server/Utilities/NetworkUtilities.cs b/Projects/Server/Utilities/NetworkUtilities.cs
index 7d54ddf5e..deeffb66f 100644
--- a/Projects/Server/Utilities/NetworkUtilities.cs
+++ b/Projects/Server/Utilities/NetworkUtilities.cs
@@ -1,6 +1,7 @@
+using System;
using System.Net;
using System.Net.Sockets;
-using Server.Network;
+using Server.Collections;
namespace Server;
@@ -14,36 +15,42 @@ public static class NetworkUtilities
_ => false
};
- private static readonly IFirewallEntry[] _privateNetworkV4 =
- [
- new CidrFirewallEntry("127.0.0.1/8"),
- new CidrFirewallEntry("192.168.0.0/16"),
- new CidrFirewallEntry("10.0.0.0/8"),
- new CidrFirewallEntry("172.16.0.0/12"),
- new CidrFirewallEntry("169.254.0.0/16"),
- new CidrFirewallEntry("100.64.0.0/10")
- ];
+ // These are constant reserved ranges, not firewall entries -- they only ever answer "is this address
+ // in one of these blocks?", which is exactly what SortedRangeIndex is for. Building them through the
+ // firewall entry types was a convenience that made core depend on the firewall for something that has
+ // nothing to do with banning.
+ private static readonly SortedRangeIndex _privateNetworkV4 = BuildIndex(
+ "127.0.0.1/8",
+ "192.168.0.0/16",
+ "10.0.0.0/8",
+ "172.16.0.0/12",
+ "169.254.0.0/16",
+ "100.64.0.0/10"
+ );
- private static readonly IFirewallEntry[] _privateNetworkV6 =
- [
- new CidrFirewallEntry("fc00::/7"),
- new CidrFirewallEntry("fe80::/10")
- ];
+ private static readonly SortedRangeIndex _privateNetworkV6 = BuildIndex(
+ "fc00::/7",
+ "fe80::/10"
+ );
- public static bool IsPrivateNetworkV4(this IPAddress ip)
+ private static SortedRangeIndex BuildIndex(params ReadOnlySpan cidrs)
{
- for (var i = 0; i < _privateNetworkV4.Length; i++)
+ var ranges = new SortedRangeIndex.Range[cidrs.Length];
+ for (var i = 0; i < cidrs.Length; i++)
{
- if (_privateNetworkV4[i].IsBlocked(ip))
+ if (!IPAddressUtility.TryParseCidrRange(cidrs[i], out var min, out var max))
{
- return true;
+ throw new ArgumentException($"Invalid reserved-network CIDR \"{cidrs[i]}\"");
}
+
+ ranges[i] = new SortedRangeIndex.Range(min, max);
}
- return false;
+ Array.Sort(ranges, SortedRangeIndex.ByMin);
+ return SortedRangeIndex.Build(ranges);
}
- public static bool IsPrivateNetworkV6(this IPAddress ip) =>
- _privateNetworkV6[0].IsBlocked(ip) ||
- _privateNetworkV6[1].IsBlocked(ip);
+ public static bool IsPrivateNetworkV4(this IPAddress ip) => _privateNetworkV4.Contains(ip.ToUInt128());
+
+ public static bool IsPrivateNetworkV6(this IPAddress ip) => _privateNetworkV6.Contains(ip.ToUInt128());
}
diff --git a/Projects/Server.Tests/Tests/Network/Firewall/FirewallEntryTests.cs b/Projects/UOContent.Tests/Tests/Network/Firewall/FirewallEntryTests.cs
similarity index 100%
rename from Projects/Server.Tests/Tests/Network/Firewall/FirewallEntryTests.cs
rename to Projects/UOContent.Tests/Tests/Network/Firewall/FirewallEntryTests.cs
diff --git a/Projects/Server.Tests/Tests/Network/Firewall/FirewallPersistenceTests.cs b/Projects/UOContent.Tests/Tests/Network/Firewall/FirewallPersistenceTests.cs
similarity index 100%
rename from Projects/Server.Tests/Tests/Network/Firewall/FirewallPersistenceTests.cs
rename to Projects/UOContent.Tests/Tests/Network/Firewall/FirewallPersistenceTests.cs
diff --git a/Projects/Server.Tests/Tests/Network/Firewall/FirewallTests.cs b/Projects/UOContent.Tests/Tests/Network/Firewall/FirewallTests.cs
similarity index 100%
rename from Projects/Server.Tests/Tests/Network/Firewall/FirewallTests.cs
rename to Projects/UOContent.Tests/Tests/Network/Firewall/FirewallTests.cs
diff --git a/Projects/Server/Network/Firewall/BaseFirewallEntry.cs b/Projects/UOContent/Misc/Firewall/BaseFirewallEntry.cs
similarity index 100%
rename from Projects/Server/Network/Firewall/BaseFirewallEntry.cs
rename to Projects/UOContent/Misc/Firewall/BaseFirewallEntry.cs
diff --git a/Projects/Server/Network/Firewall/CidrFirewallEntry.cs b/Projects/UOContent/Misc/Firewall/CidrFirewallEntry.cs
similarity index 70%
rename from Projects/Server/Network/Firewall/CidrFirewallEntry.cs
rename to Projects/UOContent/Misc/Firewall/CidrFirewallEntry.cs
index db74cf620..2016853df 100644
--- a/Projects/Server/Network/Firewall/CidrFirewallEntry.cs
+++ b/Projects/UOContent/Misc/Firewall/CidrFirewallEntry.cs
@@ -25,8 +25,15 @@ public class CidrFirewallEntry : BaseFirewallEntry
public override UInt128 MaxIpAddress { get; }
public CidrFirewallEntry(string ipAddressOrCidr)
- : this(ParseIPAddress(ipAddressOrCidr, out var prefixLength), prefixLength)
{
+ // Core owns the CIDR -> normalized range parse (IPAddressUtility); this used to be a private copy.
+ if (!IPAddressUtility.TryParseCidrRange(ipAddressOrCidr, out var min, out var max))
+ {
+ throw new ArgumentException("Invalid IP address or CIDR.", nameof(ipAddressOrCidr));
+ }
+
+ MinIpAddress = min;
+ MaxIpAddress = max;
}
public CidrFirewallEntry(IPAddress minAddress, IPAddress maxAddress)
@@ -50,26 +57,4 @@ public class CidrFirewallEntry : BaseFirewallEntry
MaxIpAddress = Utility.CreateCidrAddress(bytes, prefixLength, true);
}
- private static IPAddress ParseIPAddress(ReadOnlySpan ipString, out int prefixLength)
- {
- var slashIndex = ipString.IndexOf('/');
- var ipAddress = IPAddress.Parse(slashIndex > -1 ? ipString[..slashIndex] : ipString);
- var maxPrefixLength = ipAddress.AddressFamily == AddressFamily.InterNetworkV6 ? 128 : 32;
-
- if (slashIndex == -1)
- {
- prefixLength = maxPrefixLength;
- }
- else
- {
- var prefixPart = ipString[(slashIndex + 1)..];
-
- if (!int.TryParse(prefixPart, out prefixLength) || prefixLength < 0 || prefixLength > maxPrefixLength)
- {
- throw new ArgumentException("Invalid prefix length.");
- }
- }
-
- return ipAddress;
- }
}
diff --git a/Projects/Server/Network/Firewall/Firewall.cs b/Projects/UOContent/Misc/Firewall/Firewall.cs
similarity index 100%
rename from Projects/Server/Network/Firewall/Firewall.cs
rename to Projects/UOContent/Misc/Firewall/Firewall.cs
diff --git a/Projects/Server/Network/Firewall/FirewallConnectionFilter.cs b/Projects/UOContent/Misc/Firewall/FirewallConnectionFilter.cs
similarity index 92%
rename from Projects/Server/Network/Firewall/FirewallConnectionFilter.cs
rename to Projects/UOContent/Misc/Firewall/FirewallConnectionFilter.cs
index fc39eebff..efab6c609 100644
--- a/Projects/Server/Network/Firewall/FirewallConnectionFilter.cs
+++ b/Projects/UOContent/Misc/Firewall/FirewallConnectionFilter.cs
@@ -22,8 +22,8 @@ namespace Server.Network;
/// Exposes the admin-curated set to the accept path as an
/// . The firewall keeps its own API (the admin gump and commands mutate
/// it directly); this is only the accept-path adapter, since a static class cannot implement an
-/// interface. It registers from the core assembly, so it is consulted before any content filter — which
-/// is what we want, as it is the cheapest check (an empty set costs one length compare).
+/// interface. It is the cheapest gate to consult — an empty set costs one length compare — so
+/// registers it first.
///
internal sealed class FirewallConnectionFilter : IConnectionFilter
{
diff --git a/Projects/Server/Network/Firewall/FirewallSettings.cs b/Projects/UOContent/Misc/Firewall/FirewallSettings.cs
similarity index 100%
rename from Projects/Server/Network/Firewall/FirewallSettings.cs
rename to Projects/UOContent/Misc/Firewall/FirewallSettings.cs
diff --git a/Projects/Server/Network/Firewall/IFirewallEntry.cs b/Projects/UOContent/Misc/Firewall/IFirewallEntry.cs
similarity index 100%
rename from Projects/Server/Network/Firewall/IFirewallEntry.cs
rename to Projects/UOContent/Misc/Firewall/IFirewallEntry.cs
diff --git a/Projects/Server/Network/Firewall/SingleIpFirewallEntry.cs b/Projects/UOContent/Misc/Firewall/SingleIpFirewallEntry.cs
similarity index 100%
rename from Projects/Server/Network/Firewall/SingleIpFirewallEntry.cs
rename to Projects/UOContent/Misc/Firewall/SingleIpFirewallEntry.cs
diff --git a/dev-docs/networking-packets.md b/dev-docs/networking-packets.md
index aae52439d..5eed62249 100644
--- a/dev-docs/networking-packets.md
+++ b/dev-docs/networking-packets.md
@@ -504,14 +504,19 @@ Rules:
- Side effects a hit implies (reporting to `BanChannel`, promoting to an OS firewall, suppressing
duplicate reports) are the **filter's** business, not the accept path's.
- Filters are consulted in registration order and the first denial short-circuits, so register the
- cheapest and most selective first. Core registers before content is swept.
+ cheapest and most selective first. Order affects only how quickly a denial is reached, never whether
+ one happens.
- A filter that throws is **unregistered** and the connection fails open. A filter that faults once
faults for every connection, so leaving it registered would mean an exception per accept.
-Built-in filters: `firewall` (core, admin-curated, mutable at runtime) and `blocklist` (UOContent,
-file-sourced, millions of entries, demand-pages hits to CrowdSec). Do **not** route this kind of check
-through `EventSink.InvokeSocketConnect` -- that fires later and allocates a `SocketConnectEventArgs`
-per connection, which is exactly what the accept path avoids for rejected traffic.
+Core owns the question; **every implementation lives in UOContent**. The two that ship are `firewall`
+(admin-curated, mutable at runtime, persisted to `Configuration/firewall.json`) and `blocklist`
+(file-sourced, millions of entries, demand-pages hits to CrowdSec). A shard that fronts its server with
+an upstream proxy or edge scrubbing can drop both and register nothing.
+
+Do **not** route this kind of check through `EventSink.InvokeSocketConnect` -- that fires later and
+allocates a `SocketConnectEventArgs` per connection, which is exactly what the accept path avoids for
+rejected traffic.
### IP Address Normalization (`IPAddressUtility`)
@@ -548,6 +553,6 @@ those, so the helpers check both.
| `Projects/Server/Network/PacketHandler.cs` | PacketHandler class |
| `Projects/Server/Network/IConnectionFilter.cs` | Accept-path gate contract |
| `Projects/Server/Network/ConnectionFilters.cs` | Filter registry + lifecycle |
-| `Projects/Server/Network/Firewall/Firewall.cs` | Admin-curated firewall set |
-| `Projects/Server/Utilities/IPAddressUtility.cs` | IPAddress <-> UInt128 normalization |
+| `Projects/UOContent/Misc/Firewall/Firewall.cs` | Admin-curated firewall set |
+| `Projects/Server/Utilities/IPAddressUtility.cs` | IPAddress <-> UInt128 normalization, CIDR parsing |
| `Projects/UOContent/Misc/Blocklist/BlocklistFilter.cs` | File-sourced blocklist filter |