refactor(network): move Firewall to UOContent; core keeps only the filter seam
Core now owns the question -- "should this socket be denied?" -- and none of the answers. The firewall was the last implementation left in core, and the reasons to keep it did not survive scrutiny: it is not extended downstream, and a shard running bare core has no way to populate it anyway, since the admin gump and the commands that mutate it are both content. Larger shards front the server with an upstream proxy or edge scrubbing and never use it; it survives as the fallback an admin reaches for over a single player, which is squarely content's concern. Nothing about the firewall changes for operators: same Server.Network namespace, same Configuration/firewall.json, same gump and commands, same legacy .cfg migration. It reaches the accept path through ConnectionFilters like any other filter, and registers itself first because an empty set is the cheapest gate. Untangling core from the firewall entry types first: - NetworkUtilities built its reserved-network tables out of CidrFirewallEntry, which made core depend on the firewall for something with nothing to do with banning. Those are constant CIDR blocks answering "is this address in one of these ranges?", so they are now a SortedRangeIndex<UInt128> -- the same primitive the firewall and blocklist already share. Same semantics, same public API, one linear scan replaced by a binary search. - The CIDR -> normalized range parse those tables needed is now IPAddressUtility.TryParseCidrRange, and CidrFirewallEntry drops its private copy of that logic in favor of it. Core no longer references IFirewallEntry or Firewall anywhere. 1344 tests pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
2be79d054a
commit
50c8287c7e
14 changed files with 98 additions and 57 deletions
|
|
@ -21,8 +21,9 @@ namespace Server.Network;
|
|||
/// <summary>
|
||||
/// 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 <c>Firewall</c>) 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 <c>Firewall</c> and <c>BlocklistFilter</c> in UOContent).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
|
|
|
|||
|
|
@ -72,6 +72,49 @@ public static class IPAddressUtility
|
|||
return new IPAddress(bytes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses <c>a.b.c.d/n</c>, <c>::/n</c>, or a bare address (treated as a single-host range) into an
|
||||
/// inclusive <see cref="UInt128"/> 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.
|
||||
/// </summary>
|
||||
public static bool TryParseCidrRange(ReadOnlySpan<char> 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<byte> bytes = stackalloc byte[16];
|
||||
ip.WriteMappedIPv6To(bytes);
|
||||
|
||||
min = Utility.CreateCidrAddress(bytes, prefixLength, false);
|
||||
max = Utility.CreateCidrAddress(bytes, prefixLength, true);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Extracts the big-endian uint of an <see cref="AddressFamily.InterNetwork"/> address.</summary>
|
||||
public static bool TryV4(IPAddress ip, out uint v)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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<UInt128> _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<UInt128> _privateNetworkV6 = BuildIndex(
|
||||
"fc00::/7",
|
||||
"fe80::/10"
|
||||
);
|
||||
|
||||
public static bool IsPrivateNetworkV4(this IPAddress ip)
|
||||
private static SortedRangeIndex<UInt128> BuildIndex(params ReadOnlySpan<string> cidrs)
|
||||
{
|
||||
for (var i = 0; i < _privateNetworkV4.Length; i++)
|
||||
var ranges = new SortedRangeIndex<UInt128>.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<UInt128>.Range(min, max);
|
||||
}
|
||||
|
||||
return false;
|
||||
Array.Sort(ranges, SortedRangeIndex<UInt128>.ByMin);
|
||||
return SortedRangeIndex<UInt128>.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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<char> 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -22,8 +22,8 @@ namespace Server.Network;
|
|||
/// Exposes the admin-curated <see cref="Firewall"/> set to the accept path as an
|
||||
/// <see cref="IConnectionFilter"/>. 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
|
||||
/// <see cref="Firewall.Configure"/> registers it first.
|
||||
/// </summary>
|
||||
internal sealed class FirewallConnectionFilter : IConnectionFilter
|
||||
{
|
||||
|
|
@ -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 |
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue