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 |