feat(network): pluggable connection filters; file blocklist + contribute-first CrowdSec (#2542)

Reshapes IP banning around one idea: **core owns the question, content owns every answer.**

Core gains a single accept-path seam — `IConnectionFilter` — and loses everything that used to implement one. The firewall moves to UOContent, a new file-backed blocklist joins it there, and CrowdSec is repositioned from an in-app enforcer to a contribute-first reporter.

## The seam

```csharp
public interface IConnectionFilter
{
    string Name { get; }
    void Configure();
    void Start(CancellationToken token);
    void Stop();
    bool ShouldDeny(IPAddress address);
}
```

The accept path went from hardcoded branches to one question:

```csharp
else if (ConnectionFilters.ShouldDeny(remoteIP, out var deniedBy))
{
    logger.Debug("{Address} denied by connection filter '{Filter}'", remoteIP, deniedBy);
}
```

Filters register during the Configure sweep. The registry is a plain array walked by an indexed loop — no enumerator, no closure, no allocation — and the first denial short-circuits. An interface dispatch is noise next to the `accept()` syscall, so pluggability costs nothing measurable on the path that has to survive a DDoS.

Whatever a hit implies — persisting, promoting to an OS bouncer, contributing to the ban channel — is the filter's business, not the accept path's.

A filter that throws is **unregistered and the connection fails open**. A filter that faults once faults for every subsequent connection, so leaving it registered means an exception and a log line per accept — exactly the amplification an attacker wants — and a broken filter must not be able to deny everyone either.

This deliberately does **not** reuse `EventSink.InvokeSocketConnect`: that fires later and allocates a `SocketConnectEventArgs` per connection, which is what the accept path avoids for rejected traffic.

## What ships behind it

**`firewall`** (UOContent) — the existing admin-curated set. Collapsed from `Firewall` + `AdminFirewall` + a threaded enforcer into one single-threaded store with **zero concurrency primitives**: the accept path, admin gump, TTL expiry and boot load all run on the game loop. Persists to `Configuration/firewall.json` with automatic migration from the legacy `firewall.cfg`. No behavior change for operators — same namespace, same gump, same commands.

**`blocklist`** (UOContent) — new. Holds a millions-strong list in-app and **demand-pages** hits up to CrowdSec, which promotes them to the OS firewall.

The motivation is concrete: CrowdSec's Windows bouncer cannot load the ~3.9M IPs that 91 community feeds produce, but it handles ~100k fine. So the millions live in-process behind a binary search, and only addresses that *actually connect* get promoted. A `PromotedGuard` suppresses re-reporting an address until the bouncer picks it up.

The list is parsed straight from UTF-8 file bytes with no per-line string allocation, off the game loop, and published as an immutable snapshot swapped through a single `volatile` reference. Reloads yield to world saves.

**`tools/Export-IpBlocklist.ps1`** — the producer. Requires PowerShell 7 and runs on Windows, Linux and macOS; Windows PowerShell 5.1 is refused up front via `#requires`. Merges a thin, non-overlapping feed set into one de-duplicated, bogon-filtered file. Parsing runs in a compiled `Add-Type` hot loop (~1s for ~4M lines instead of minutes). Written to a `.tmp` sibling and swapped with `File.Replace`, so the shard never reads a half-written list, and a total feed outage refuses to overwrite a good list with an empty one. Re-running is idempotent — it exits without downloading anything while the list on disk is younger than `-MinInterval` (default 2h, the anchor feed's own refresh period), so a misconfigured scheduler can't hammer upstream.

## CrowdSec: contribute-first

`IBanReporter` + `BanChannel` fan locally-decided bans out to external systems. `CrowdSecReporter` (UOContent) posts to LAPI `POST /v1/alerts` and retracts via `DELETE /v1/decisions`.

Reporting is **enqueue-only** on the accept path: a bounded, coalescing channel drained off-loop with bounded retry, counted drops on overflow, and a flush on shutdown. Under a DDoS the accept path never does synchronous or lock-contending per-IP work.

### Why not pull decisions from CrowdSec?

The original design streamed decisions into an in-app snapshot and enforced them at the accept gate. That's the wrong layer: by the time the shard sees the connection, the TCP handshake and socket setup are already paid for. `cs-firewall-bouncer` drops the same traffic **at the kernel**, and it's what CrowdSec is built to do. So the shard now contributes what it uniquely knows (rate-limit trips, blocklist hits from real connection attempts) and lets the OS enforce.

The one thing the OS can't do — hold millions of entries on Windows — is exactly what the in-app blocklist covers, and it feeds the same pipeline.

## Threading policy

`CLAUDE.md` rule #3 is rewritten as an explicit three-part policy, with rule #10 restated in tandem:

- Anything touching game state runs **only** on the main loop.
- Heavy work that *needs* game state must be **chunked** across ticks, never threaded.
- Heavy work that does *not* need game state (large-file parse, external I/O) **must** run off-loop **and must yield to world saves**.

Results come back via an immutable snapshot swapped through a single `volatile` reference, or `Core.LoopContext.Post` — never by letting the scheduler decide where heavy work runs. Both new subsystems follow it.

## Shared primitives

`SortedRangeIndex<T> where T : IBinaryInteger<T>` — coalesced disjoint interval arrays plus a binary search. The firewall, the blocklist, and (as of this PR) core's reserved-network tables all use it.

Coalescing is a correctness requirement, not an optimization: multi-feed lists nest CIDRs (`/24` containing a `/32`), and a search that inspects only the rightmost run whose minimum is ≤ the value is sound **only** over disjoint runs. That bug was caught in review and is covered by regression tests.

`IPAddressUtility` collects the allocation-free `IPAddress` ↔ `UInt128` conversions and CIDR parsing that were previously scattered or duplicated.

## Config

| File | Owner | Keys |
|---|---|---|
| `Configuration/bans.json` | core | `reportRateLimitTrips`, `autoBanDuration` |
| `Configuration/blocklist.json` | content | `file`, `reloadInterval`, `reportHits`, `banDuration`, `promoteSuppression` |
| `Configuration/crowdsec.json` | content | `lapiUrl`, `machineId`, `password`, `origin`, `manualBanDuration`, `flushInterval`, `maxQueue` |
| `Configuration/firewall.json` | content | persisted firewall entries (migrated from `firewall.cfg`) |

Everything is inert by default. CrowdSec self-disables without credentials; the blocklist self-disables until its file exists. A shard that changes nothing sees no behavior change.

## Notes for review

- **Core no longer references `Firewall` or `IFirewallEntry` anywhere.** `NetworkUtilities` used to build its reserved-network tables out of `CidrFirewallEntry`, which coupled core to the firewall for something unrelated to banning; those are now a `SortedRangeIndex<UInt128>`, same semantics and public API.
- **`BanChannel.Stop()` no longer persists the firewall** — a contribution coordinator has no business saving an enforcement store. That's the firewall filter's `Stop()`.
- **A dead `whitelisted` parameter was dropped** from the blocklist gate: it was hardcoded `false` at its only call site, and no whitelist concept exists in core.
- **The blocklist filter is an instance, not a static.** The static version forced its tests onto the sequential collection with a reset hook; they now run in parallel.
- `dev-docs/networking-packets.md` documents the seam for content authors, plus a known wart in the `IPAddress` ↔ `UInt128` normalization flagged for a follow-up PR.
- The generator was verified on Linux, macOS and Windows under a temporary CI matrix (since removed). It caught two portability bugs — a Windows-only path separator, and a culture-sensitive duration parse that read `2.5` as `25` on comma-decimal locales and *silently* turned a 2.5h cooldown into 25h — plus a third that made the script unparseable on Windows PowerShell 5.1. The source is ASCII-only for that last reason: `#requires` is only honored once a file parses, so non-ASCII in a BOM-less script produces parse errors instead of the version message.

## Tests

**1344 pass** (782 `Server.Tests`, 562 `UOContent.Tests`). New coverage: filter registry (registration, short-circuit, fault-disable), blocklist parsing/CIDR/coalescing, snapshot reload markers, promote-guard TTL, ban-channel fan-out, CrowdSec alert building/dedup/flush-on-stop, and the generator's output-format contract pinned against the reader.
This commit is contained in:
Kamron Batman 2026-07-25 11:59:37 -07:00 • committed by GitHub
parent bec4cfa910
commit c39454137e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
52 changed files with 4655 additions and 547 deletions

View file

@ -0,0 +1,134 @@
/*************************************************************************
* ModernUO *
* Copyright 2019-2026 - ModernUO Development Team *
* Email: hi@modernuo.com *
* File: SortedRangeIndex.cs *
* *
* This program is free software: you can redistribute it and/or modify *
* it under the terms of the GNU General Public License as published by *
* the Free Software Foundation, either version 3 of the License, or *
* (at your option) any later version. *
* *
* You should have received a copy of the GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
*************************************************************************/
using System;
using System.Numerics;
namespace Server.Collections;
/// <summary>
/// Immutable, allocation-lean membership index over a set of inclusive integer ranges. Ranges are
/// stored as two parallel arrays (<c>_mins</c>/<c>_maxs</c>) sorted by minimum and coalesced into
/// disjoint runs, so a single binary search decides membership. Coalescing is required for
/// correctness: <see cref="Contains"/> only inspects the rightmost run whose minimum is &lt;= the
/// value, which is only sound when the runs never overlap or nest.
/// </summary>
public sealed class SortedRangeIndex<T> where T : IBinaryInteger<T>
{
public static readonly SortedRangeIndex<T> Empty = new([], []);
/// <summary>An inclusive <c>[Min, Max]</c> range. Singles are represented as <c>Min == Max</c>.</summary>
public readonly record struct Range(T Min, T Max);
/// <summary>Orders ranges ascending by minimum; the coalescing pass in <see cref="Build"/> requires this.</summary>
public static readonly Comparison<Range> ByMin = static (a, b) => a.Min.CompareTo(b.Min);
private readonly T[] _mins;
private readonly T[] _maxs;
private SortedRangeIndex(T[] mins, T[] maxs)
{
_mins = mins;
_maxs = maxs;
}
public int Count => _mins.Length;
/// <summary>
/// True when <paramref name="value"/> falls in any range. Binary-searches for the rightmost run
/// whose minimum is &lt;= the value, then tests that value against that run's maximum.
/// </summary>
public bool Contains(T value)
{
var lo = 0;
var hi = _mins.Length - 1;
var found = -1;
while (lo <= hi)
{
var mid = (lo + hi) >> 1;
if (_mins[mid] <= value)
{
found = mid;
lo = mid + 1;
}
else
{
hi = mid - 1;
}
}
return found >= 0 && value <= _maxs[found];
}
/// <summary>
/// Builds an index from ranges that are already sorted ascending by <see cref="Range.Min"/> (sort
/// the source with <see cref="ByMin"/> first). Overlapping and nested ranges are merged into disjoint
/// runs. Two passes over the (pooled) input keep the run count exact so only the two final arrays are
/// heap-allocated: pass one counts the runs, pass two fills the exact-size arrays.
/// </summary>
public static SortedRangeIndex<T> Build(ReadOnlySpan<Range> sortedByMin)
{
if (sortedByMin.IsEmpty)
{
return Empty;
}
// Pass 1: count the disjoint runs so the final arrays can be sized exactly.
var runs = 1;
var curMax = sortedByMin[0].Max;
for (var i = 1; i < sortedByMin.Length; i++)
{
var r = sortedByMin[i];
if (r.Min <= curMax)
{
if (r.Max > curMax)
{
curMax = r.Max;
}
}
else
{
runs++;
curMax = r.Max;
}
}
// Pass 2: write the coalesced runs into the exact-size final arrays.
var mins = new T[runs];
var maxs = new T[runs];
var w = 0;
mins[0] = sortedByMin[0].Min;
maxs[0] = sortedByMin[0].Max;
for (var i = 1; i < sortedByMin.Length; i++)
{
var r = sortedByMin[i];
if (r.Min <= maxs[w])
{
if (r.Max > maxs[w])
{
maxs[w] = r.Max;
}
}
else
{
w++;
mins[w] = r.Min;
maxs[w] = r.Max;
}
}
return new SortedRangeIndex<T>(mins, maxs);
}
}

View file

@ -1,4 +1,4 @@
/*************************************************************************
/*************************************************************************
* ModernUO *
* Copyright 2019-2026 - ModernUO Development Team *
* Email: hi@modernuo.com *
@ -30,6 +30,7 @@ using Server.Compression;
using Server.Json;
using Server.Logging;
using Server.Network;
using Server.Network.Bans;
using Server.Text;
namespace Server;
@ -260,7 +261,7 @@ public static class Core
// ignored
}
if (!close && !Core.Headless)
if (!close && !Headless)
{
Console.WriteLine("This exception is fatal, press return to exit");
ConsoleInputHandler.ReadLine();
@ -342,6 +343,8 @@ public static class Core
World.ExitSerializationThreads();
PingServer.Shutdown();
NetState.Shutdown();
BanChannel.Stop();
ConnectionFilters.Stop();
if (!_crashed)
{
@ -461,6 +464,8 @@ public static class Core
AssemblyHandler.Invoke("Initialize");
BanChannel.Start(ClosingTokenSource.Token);
ConnectionFilters.Start(ClosingTokenSource.Token);
NetState.Start();
PingServer.Start();
EventSink.InvokeServerStarted();

View file

@ -0,0 +1,143 @@
/*************************************************************************
* ModernUO *
* Copyright 2019-2026 - ModernUO Development Team *
* Email: hi@modernuo.com *
* File: BanChannel.cs *
* *
* This program is free software: you can redistribute it and/or modify *
* it under the terms of the GNU General Public License as published by *
* the Free Software Foundation, either version 3 of the License, or *
* (at your option) any later version. *
* *
* You should have received a copy of the GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
*************************************************************************/
using System;
using System.Collections.Generic;
using System.Net;
using System.Threading;
using Server.Logging;
namespace Server.Network.Bans;
/// <summary>
/// Coordinates the configured <see cref="IBanReporter"/> contribution sinks. Enforcement is NOT here —
/// the accept path asks <see cref="ConnectionFilters"/>. This channel only fans locally-decided bans out
/// to external systems (CrowdSec), which distribute them to OS-level bouncers.
/// </summary>
public static class BanChannel
{
private static readonly ILogger logger = LogFactory.GetLogger(typeof(BanChannel));
private static IBanReporter[] _reporters = [];
public static IReadOnlyList<IBanReporter> Reporters => _reporters;
/// <summary>
/// Registers a contribution sink from content (inversion of control). Idempotent by
/// <see cref="IBanReporter.Name"/>: a second registration of the same name is ignored. Configures the
/// reporter immediately so it is ready before <see cref="Start"/>.
/// </summary>
public static void Register(IBanReporter reporter)
{
if (reporter == null)
{
return;
}
var reporters = _reporters;
for (var i = 0; i < reporters.Length; i++)
{
if (reporters[i].Name == reporter.Name)
{
return;
}
}
reporter.Register();
var updated = new IBanReporter[_reporters.Length + 1];
Array.Copy(_reporters, updated, _reporters.Length);
updated[^1] = reporter;
_reporters = updated;
logger.Information("Ban channel registered reporter '{Name}'", reporter.Name);
}
internal static void ConfigureForTesting(IBanReporter[] reporters) => _reporters = reporters ?? [];
public static void Start(CancellationToken token)
{
var reporters = _reporters;
for (var i = 0; i < reporters.Length; i++)
{
var reporter = reporters[i];
try
{
reporter.Start(token);
}
catch (Exception e)
{
// A broken contribution path must not crash boot — enforcement is local and unaffected.
logger.Error(e, "Ban reporter '{Name}' failed to start; continuing without it", reporter.Name);
}
}
}
public static void Stop()
{
var reporters = _reporters;
for (var i = 0; i < reporters.Length; i++)
{
var reporter = reporters[i];
try
{
reporter.Stop();
}
catch (Exception e)
{
logger.Warning(e, "Ban reporter '{Name}' threw while stopping", reporter.Name);
}
}
}
/// <summary>Fans a locally-decided ban out to every reporter. Non-blocking; never throws.</summary>
public static void Report(IPAddress ip, TimeSpan ttl, string reason)
{
var reporters = _reporters;
for (var i = 0; i < reporters.Length; i++)
{
try
{
reporters[i].Report(ip, ttl, reason);
}
catch (Exception e)
{
logger.Warning(e, "Ban reporter '{Name}' threw during Report", reporters[i].Name);
}
}
}
/// <summary>Fans a retraction (manual unban) out to every retract-capable reporter.</summary>
public static void Retract(IPAddress ip)
{
var reporters = _reporters;
for (var i = 0; i < reporters.Length; i++)
{
if (!reporters[i].CanRetract)
{
continue;
}
try
{
reporters[i].Retract(ip);
}
catch (Exception e)
{
logger.Warning(e, "Ban reporter '{Name}' threw during Retract", reporters[i].Name);
}
}
}
}

View file

@ -0,0 +1,76 @@
/*************************************************************************
* ModernUO *
* Copyright 2019-2026 - ModernUO Development Team *
* Email: hi@modernuo.com *
* File: BanConfiguration.cs *
* *
* This program is free software: you can redistribute it and/or modify *
* it under the terms of the GNU General Public License as published by *
* the Free Software Foundation, either version 3 of the License, or *
* (at your option) any later version. *
* *
* You should have received a copy of the GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
*************************************************************************/
using System;
using System.IO;
using System.Text.Json.Serialization;
using Server.Json;
namespace Server.Network.Bans;
/// <summary>
/// Loads the <see cref="BanSettings"/> from <c>Configuration/bans.json</c> (matching the per-feature
/// JSON config pattern used by <c>AssistantConfiguration</c>). Loaded once; a missing file writes a
/// local-only, fail-open template so operators have something to edit.
/// </summary>
public static class BanConfiguration
{
private const string _path = "Configuration/bans.json";
public static BanSettings Settings { get; private set; }
public static void Configure()
{
// Idempotent: a second call must not re-deserialize or overwrite an operator's edits.
if (Settings != null)
{
return;
}
var path = Path.Join(Core.BaseDirectory, _path);
if (File.Exists(path))
{
Settings = JsonConfig.Deserialize<BanSettings>(path);
}
else
{
Settings = new BanSettings
{
ReportRateLimitTrips = true,
AutoBanDuration = TimeSpan.FromHours(4)
};
Save();
}
}
private static void Save()
{
JsonConfig.Serialize(Path.Join(Core.BaseDirectory, _path), Settings);
}
}
/// <summary>Ban-channel policy: which reporters receive contributions, and how auto-detections are handled.</summary>
public record BanSettings
{
/// <summary>Whether IP rate-limiter trips are contributed to reporters. They never enter the local firewall set.</summary>
[JsonPropertyName("reportRateLimitTrips")]
public bool ReportRateLimitTrips { get; set; } = true;
/// <summary>Duration reported for an auto-detected (rate-limit) ban.</summary>
[JsonPropertyName("autoBanDuration")]
public TimeSpan AutoBanDuration { get; set; } = TimeSpan.FromHours(4);
}

View file

@ -0,0 +1,55 @@
/*************************************************************************
* ModernUO *
* Copyright 2019-2026 - ModernUO Development Team *
* Email: hi@modernuo.com *
* File: IBanReporter.cs *
* *
* This program is free software: you can redistribute it and/or modify *
* it under the terms of the GNU General Public License as published by *
* the Free Software Foundation, either version 3 of the License, or *
* (at your option) any later version. *
* *
* You should have received a copy of the GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
*************************************************************************/
using System;
using System.Net;
using System.Threading;
namespace Server.Network.Bans;
/// <summary>
/// A contribution sink behind <see cref="BanChannel"/>. Reporters receive locally-decided bans
/// (manual admin bans, rate-limit trips, blocklist promotions) and forward them to an external system
/// (e.g. CrowdSec), which distributes them to OS-level bouncers. Reporters never answer the accept-path
/// membership query — that is an <see cref="IConnectionFilter"/>'s job.
/// </summary>
public interface IBanReporter
{
/// <summary>Stable id for logging/config (e.g. <c>crowdsec</c>).</summary>
string Name { get; }
/// <summary>Reads configuration. No network or file I/O here.</summary>
void Register();
/// <summary>Starts background delivery. The token is cancelled on shutdown.</summary>
void Start(CancellationToken token);
/// <summary>Flushes and tears down background delivery.</summary>
void Stop();
/// <summary>
/// Enqueues a ban contribution. MUST be non-blocking and safe on the accept path: it may only
/// enqueue (bounded, drop-on-overflow) and never perform synchronous I/O.
/// </summary>
/// <param name="ttl"><see cref="TimeSpan.Zero"/> or negative = use the reporter's default duration.</param>
/// <param name="reason">Short slug (<c>manual</c>, <c>rate-limit</c>) used as the scenario suffix.</param>
void Report(IPAddress address, TimeSpan ttl, string reason);
/// <summary>True if this reporter can retract a previously-reported ban.</summary>
bool CanRetract { get; }
/// <summary>Enqueues a retraction (e.g. a manual unban). No-op if unsupported.</summary>
void Retract(IPAddress address);
}

View file

@ -0,0 +1,155 @@
/*************************************************************************
* ModernUO *
* Copyright 2019-2026 - ModernUO Development Team *
* Email: hi@modernuo.com *
* File: ConnectionFilters.cs *
* *
* This program is free software: you can redistribute it and/or modify *
* it under the terms of the GNU General Public License as published by *
* the Free Software Foundation, either version 3 of the License, or *
* (at your option) any later version. *
* *
* You should have received a copy of the GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
*************************************************************************/
using System;
using System.Collections.Generic;
using System.Net;
using System.Threading;
using Server.Logging;
namespace Server.Network;
/// <summary>
/// Registry of the <see cref="IConnectionFilter"/> gates the accept path consults, and their lifecycle.
/// Filters are registered during the Configure sweep and the backing store is a plain array, so
/// <see cref="ShouldDeny"/> is an indexed loop over a field read — no enumerator, no closure, no
/// allocation. The whole accept path runs on the game loop, so no synchronization is needed.
/// </summary>
public static class ConnectionFilters
{
private static readonly ILogger logger = LogFactory.GetLogger(typeof(ConnectionFilters));
private static IConnectionFilter[] _filters = [];
public static IReadOnlyList<IConnectionFilter> Filters => _filters;
/// <summary>
/// Registers a gate (inversion of control, mirroring <c>BanChannel.Register</c>). Idempotent by
/// <see cref="IConnectionFilter.Name"/>. Filters are consulted in registration order, so register
/// the cheapest and most selective first — core registers the firewall before content is swept.
/// </summary>
public static void Register(IConnectionFilter filter)
{
if (filter == null)
{
return;
}
var filters = _filters;
for (var i = 0; i < filters.Length; i++)
{
if (filters[i].Name == filter.Name)
{
return;
}
}
filter.Register();
var updated = new IConnectionFilter[_filters.Length + 1];
Array.Copy(_filters, updated, _filters.Length);
updated[^1] = filter;
_filters = updated;
logger.Information("Registered connection filter '{Name}'", filter.Name);
}
/// <summary>
/// True when any filter denies the connection. Short-circuits on the first denial;
/// <paramref name="deniedBy"/> names it for logging.
/// </summary>
public static bool ShouldDeny(IPAddress address, out string deniedBy)
{
var filters = _filters;
for (var i = 0; i < filters.Length; i++)
{
// A faulty filter must not take down the accept loop for every connection.
try
{
if (filters[i].ShouldDeny(address))
{
deniedBy = filters[i].Name;
return true;
}
}
catch (Exception e)
{
Disable(filters[i], e);
}
}
deniedBy = null;
return false;
}
/// <summary>
/// Drops a filter that threw on the accept path: one that faults once faults for every subsequent
/// connection, costing an exception and a log line per accept. Failing open is deliberate — a broken
/// filter must not be able to deny every connection either.
/// </summary>
private static void Disable(IConnectionFilter filter, Exception e)
{
logger.Error(e, "Connection filter '{Name}' threw on the accept path; unregistering it", filter.Name);
var filters = _filters;
var updated = new List<IConnectionFilter>(filters.Length);
for (var i = 0; i < filters.Length; i++)
{
if (!ReferenceEquals(filters[i], filter))
{
updated.Add(filters[i]);
}
}
_filters = updated.ToArray();
}
public static void Start(CancellationToken token)
{
var filters = _filters;
for (var i = 0; i < filters.Length; i++)
{
var filter = filters[i];
try
{
filter.Start(token);
}
catch (Exception e)
{
// A filter that cannot hydrate must not crash boot; it simply denies nothing.
logger.Error(e, "Connection filter '{Name}' failed to start; continuing without it", filter.Name);
}
}
}
public static void Stop()
{
var filters = _filters;
for (var i = 0; i < filters.Length; i++)
{
var filter = filters[i];
try
{
filter.Stop();
}
catch (Exception e)
{
logger.Warning(e, "Connection filter '{Name}' threw while stopping", filter.Name);
}
}
}
internal static void ResetForTesting() => _filters = [];
}

View file

@ -1,71 +0,0 @@
/*************************************************************************
* ModernUO *
* Copyright 2019-2026 - ModernUO Development Team *
* Email: hi@modernuo.com *
* File: BaseFirewallEntry.cs *
* *
* This program is free software: you can redistribute it and/or modify *
* it under the terms of the GNU General Public License as published by *
* the Free Software Foundation, either version 3 of the License, or *
* (at your option) any later version. *
* *
* You should have received a copy of the GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
*************************************************************************/
using System;
using System.Net;
using System.Runtime.CompilerServices;
namespace Server.Network;
public abstract class BaseFirewallEntry : IFirewallEntry, ISpanFormattable
{
public abstract UInt128 MinIpAddress { get; }
public abstract UInt128 MaxIpAddress { get; }
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public bool IsBlocked(IPAddress address) => IsBlocked(address.ToUInt128());
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public bool IsBlocked(UInt128 address) => address >= MinIpAddress && address <= MaxIpAddress;
public override string ToString() =>
MinIpAddress == MaxIpAddress ? MinIpAddress.ToIpAddress().ToString()
: $"{MinIpAddress.ToIpAddress()}-{MaxIpAddress.ToIpAddress()}";
public string ToString(string? format, IFormatProvider? formatProvider) =>
// format and provider are explicitly ignored
ToString();
public bool TryFormat(
Span<char> destination,
out int charsWritten,
ReadOnlySpan<char> format,
IFormatProvider? provider
)
{
if (!((ISpanFormattable)MinIpAddress.ToIpAddress()).TryFormat(destination, out charsWritten, format, provider))
{
return false;
}
if (MinIpAddress == MaxIpAddress)
{
return true;
}
// Range
destination[charsWritten++] = '-';
var total = charsWritten;
if (!((ISpanFormattable)MaxIpAddress.ToIpAddress()).TryFormat(destination[charsWritten..], out charsWritten, format, provider))
{
return false;
}
charsWritten += total;
return true;
}
}

View file

@ -1,75 +0,0 @@
/*************************************************************************
* ModernUO *
* Copyright 2019-2026 - ModernUO Development Team *
* Email: hi@modernuo.com *
* File: CidrFirewallEntry.cs *
* *
* This program is free software: you can redistribute it and/or modify *
* it under the terms of the GNU General Public License as published by *
* the Free Software Foundation, either version 3 of the License, or *
* (at your option) any later version. *
* *
* You should have received a copy of the GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
*************************************************************************/
using System;
using System.Net;
using System.Net.Sockets;
namespace Server.Network;
public class CidrFirewallEntry : BaseFirewallEntry
{
public override UInt128 MinIpAddress { get; }
public override UInt128 MaxIpAddress { get; }
public CidrFirewallEntry(string ipAddressOrCidr)
: this(ParseIPAddress(ipAddressOrCidr, out var prefixLength), prefixLength)
{
}
public CidrFirewallEntry(IPAddress minAddress, IPAddress maxAddress)
{
MinIpAddress = minAddress.ToUInt128();
MaxIpAddress = maxAddress.ToUInt128();
}
public CidrFirewallEntry(IPAddress ipAddress, int prefixLength)
{
Span<byte> bytes = stackalloc byte[16];
if (ipAddress.AddressFamily != AddressFamily.InterNetworkV6)
{
prefixLength += 96; // 32 -> 128
}
ipAddress.WriteMappedIPv6To(bytes);
MinIpAddress = Utility.CreateCidrAddress(bytes, prefixLength, false);
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;
}
}

View file

@ -1,168 +0,0 @@
/*************************************************************************
* ModernUO *
* Copyright 2019-2026 - ModernUO Development Team *
* Email: hi@modernuo.com *
* File: Firewall.cs *
* *
* This program is free software: you can redistribute it and/or modify *
* it under the terms of the GNU General Public License as published by *
* the Free Software Foundation, either version 3 of the License, or *
* (at your option) any later version. *
* *
* You should have received a copy of the GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
*************************************************************************/
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Net;
using System.Runtime.CompilerServices;
using System.Threading;
namespace Server.Network;
public static class Firewall
{
[ThreadStatic]
private static InternalValidationEntry _validationEntry;
private static readonly ConcurrentDictionary<IPAddress, int> _isBlockedCache = [];
private static readonly ReaderWriterLockSlim _firewallLock = new(LockRecursionPolicy.NoRecursion);
private static int _firewallVersion;
private static readonly SortedSet<IFirewallEntry> _firewallSet = [];
public static int FirewallSetCount => _firewallSet.Count;
public static void ReadFirewallSet(Action<IReadOnlySet<IFirewallEntry>> callback)
{
_firewallLock.EnterReadLock();
try
{
callback(_firewallSet);
}
finally
{
_firewallLock.ExitReadLock();
}
}
internal static bool IsBlocked(IPAddress address)
{
if (_isBlockedCache.TryGetValue(address, out var blockVersion) && blockVersion == _firewallVersion)
{
return true;
}
if (_validationEntry == null)
{
_validationEntry = new InternalValidationEntry(address);
}
else
{
_validationEntry.Address = address;
}
if (CheckBlocked(_validationEntry))
{
_isBlockedCache[address] = _firewallVersion;
return true;
}
return false;
}
private static bool CheckBlocked(IFirewallEntry validationEntry)
{
if (_firewallSet.Count == 0)
{
return false;
}
_firewallLock.EnterReadLock();
try
{
var min = _firewallSet.Min;
if (validationEntry.CompareTo(min) < 0)
{
return false;
}
// Get all entries that are lower than our validation entry
var view = _firewallSet.GetViewBetween(min, validationEntry);
// Loop backward since there shouldn't be any entries where the Min address is higher than ours
foreach (var firewallEntry in view.Reverse())
{
if (firewallEntry.IsBlocked(validationEntry.MinIpAddress))
{
return true;
}
}
return view.Max?.IsBlocked(validationEntry.MinIpAddress) == true;
}
finally
{
_firewallLock.ExitReadLock();
}
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static bool Add(IFirewallEntry firewallEntry)
{
_firewallLock.EnterWriteLock();
try
{
if (_firewallSet.Add(firewallEntry))
{
Interlocked.Increment(ref _firewallVersion); // Update version
return true;
}
return false;
}
finally
{
_firewallLock.ExitWriteLock();
}
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static bool Remove(IFirewallEntry entry)
{
if (entry == null)
{
return false;
}
_firewallLock.EnterWriteLock();
try
{
if (_firewallSet.Remove(entry))
{
Interlocked.Increment(ref _firewallVersion); // Update version
return true;
}
return false;
}
finally
{
_firewallLock.ExitWriteLock();
}
}
private class InternalValidationEntry : BaseFirewallEntry
{
private UInt128 _address;
public IPAddress Address
{
set => _address = value.ToUInt128();
}
public override UInt128 MinIpAddress => _address;
public override UInt128 MaxIpAddress => _address;
public InternalValidationEntry(IPAddress ipAddress) => Address = ipAddress;
}
}

View file

@ -1,59 +0,0 @@
/*************************************************************************
* ModernUO *
* Copyright 2019-2026 - ModernUO Development Team *
* Email: hi@modernuo.com *
* File: IFirewallEntry.cs *
* *
* This program is free software: you can redistribute it and/or modify *
* it under the terms of the GNU General Public License as published by *
* the Free Software Foundation, either version 3 of the License, or *
* (at your option) any later version. *
* *
* You should have received a copy of the GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
*************************************************************************/
using System;
using System.Net;
namespace Server.Network;
public interface IFirewallEntry : IComparable<IFirewallEntry>
{
UInt128 MinIpAddress { get; }
UInt128 MaxIpAddress { get; }
int IComparable<IFirewallEntry>.CompareTo(IFirewallEntry? other)
{
if (other == null)
{
return 1;
}
if (MinIpAddress < other.MinIpAddress)
{
return -1;
}
if (MinIpAddress > other.MinIpAddress)
{
return 1;
}
if (MaxIpAddress > other.MaxIpAddress)
{
return -1;
}
if (MaxIpAddress < other.MaxIpAddress)
{
return 1;
}
return 0; // Equal ranges
}
bool IsBlocked(IPAddress address);
bool IsBlocked(UInt128 address);
}

View file

@ -1,30 +0,0 @@
/*************************************************************************
* ModernUO *
* Copyright 2019-2026 - ModernUO Development Team *
* Email: hi@modernuo.com *
* File: SingleIpFirewallEntry.cs *
* *
* This program is free software: you can redistribute it and/or modify *
* it under the terms of the GNU General Public License as published by *
* the Free Software Foundation, either version 3 of the License, or *
* (at your option) any later version. *
* *
* You should have received a copy of the GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
*************************************************************************/
using System;
using System.Net;
namespace Server.Network;
public class SingleIpFirewallEntry : BaseFirewallEntry
{
public override UInt128 MinIpAddress { get; }
public override UInt128 MaxIpAddress => MinIpAddress;
public SingleIpFirewallEntry(string ipAddress) => MinIpAddress = IPAddress.Parse(ipAddress).ToUInt128();
public SingleIpFirewallEntry(IPAddress ipAddress) => MinIpAddress = ipAddress.ToUInt128();
}

View file

@ -0,0 +1,60 @@
/*************************************************************************
* ModernUO *
* Copyright 2019-2026 - ModernUO Development Team *
* Email: hi@modernuo.com *
* File: IConnectionFilter.cs *
* *
* This program is free software: you can redistribute it and/or modify *
* it under the terms of the GNU General Public License as published by *
* the Free Software Foundation, either version 3 of the License, or *
* (at your option) any later version. *
* *
* You should have received a copy of the GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
*************************************************************************/
using System.Net;
using System.Threading;
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 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>
/// <see cref="ShouldDeny"/> runs on the game loop once per accepted socket, which is the path that has
/// to survive a DDoS. Implementations MUST be allocation-free and O(log n) at worst, MUST NOT perform
/// I/O, and MUST NOT block. Anything expensive (parsing, reloading, reporting to an external service)
/// belongs off the loop or behind a bounded, non-blocking enqueue.
/// </para>
/// <para>
/// Side effects that a hit implies (contributing to <c>BanChannel</c>, promoting to an OS firewall,
/// suppressing duplicate reports) are the filter's own business, not the accept path's. This is why
/// <see cref="ShouldDeny"/> returns a bare bool: the accept path asks one question and does one thing.
/// </para>
/// </remarks>
public interface IConnectionFilter
{
/// <summary>Stable id for logging/config (e.g. <c>firewall</c>, <c>blocklist</c>).</summary>
string Name { get; }
/// <summary>Reads configuration. Called by <see cref="ConnectionFilters.Register"/>. No I/O beyond config.</summary>
void Register();
/// <summary>Starts any background hydration. The token is cancelled on shutdown.</summary>
void Start(CancellationToken token);
/// <summary>Flushes and tears down. Called during shutdown.</summary>
void Stop();
/// <summary>
/// True to deny the connection. Must be allocation-free and non-blocking; see the remarks on
/// <see cref="IConnectionFilter"/>.
/// </summary>
bool ShouldDeny(IPAddress address);
}

View file

@ -224,10 +224,19 @@ public partial class NetState
if (_ipRateLimiter != null && !_ipRateLimiter.Verify(remoteIP, out var totalAttempts))
{
logger.Debug("{Address} Past IP limit threshold ({TotalAttempts})", remoteIP, totalAttempts);
if (Bans.BanConfiguration.Settings.ReportRateLimitTrips)
{
// Enqueue-only contribution; NOT added to the local firewall set (the limiter already
// gates it here and the OS bouncer drops it at the kernel).
Bans.BanChannel.Report(remoteIP, Bans.BanConfiguration.Settings.AutoBanDuration, "rate-limit");
}
}
else if (Firewall.IsBlocked(remoteIP))
else if (ConnectionFilters.ShouldDeny(remoteIP, out var deniedBy))
{
logger.Debug("{Address} Firewalled", remoteIP);
// Whatever a hit implies (persisting, promoting to an OS bouncer, contributing to the
// ban channel) is the filter's own business; the accept path just drops the socket.
logger.Debug("{Address} denied by connection filter '{Filter}'", remoteIP, deniedBy);
}
else
{

View file

@ -0,0 +1,240 @@
/*************************************************************************
* ModernUO *
* Copyright 2019-2026 - ModernUO Development Team *
* Email: hi@modernuo.com *
* File: IPAddressUtility.cs *
* *
* This program is free software: you can redistribute it and/or modify *
* it under the terms of the GNU General Public License as published by *
* the Free Software Foundation, either version 3 of the License, or *
* (at your option) any later version. *
* *
* You should have received a copy of the GNU General Public License *
* along with this program. If not, see <http://www.gnu.org/licenses/>. *
*************************************************************************/
using System;
using System.Buffers.Binary;
using System.Net;
using System.Net.Sockets;
using System.Numerics;
namespace Server;
/// <summary>
/// Low-level IPAddress conversion and parsing helpers shared by the firewall, ban channel, and
/// blocklist. All members are allocation-free (stack buffers only) so they are safe on hot accept
/// paths and inside tight parse loops.
/// </summary>
public static class IPAddressUtility
{
// Converts an IPAddress to a UInt128 in IPv6 format.
// The IsIPv4MappedToIPv6 clause below looks redundant (the BCL only ever sets it on InterNetworkV6),
// but it guards the v4 -> UInt128 -> IPAddress round-trip, which can return a mapped v6 address for
// what is really a v4 one.
//TODO Rework as an explicit "to canonical v6 bits" step that needs no family check
// (see dev-docs/networking-packets.md, "IP Address Normalization")
public static UInt128 ToUInt128(this IPAddress ip)
{
if (ip.AddressFamily == AddressFamily.InterNetwork && !ip.IsIPv4MappedToIPv6)
{
Span<byte> integer = stackalloc byte[4];
return !ip.TryWriteBytes(integer, out _)
? (UInt128)0
: new UInt128(0, 0xFFFF00000000UL | BinaryPrimitives.ReadUInt32BigEndian(integer));
}
Span<byte> bytes = stackalloc byte[16];
if (!ip.TryWriteBytes(bytes, out _))
{
return 0;
}
var high = BinaryPrimitives.ReadUInt64BigEndian(bytes[..8]);
var low = BinaryPrimitives.ReadUInt64BigEndian(bytes.Slice(8, 8));
return new UInt128(high, low);
}
// Converts a UInt128 in IPv6 format to an IPAddress
public static IPAddress ToIpAddress(this UInt128 value, bool mapToIpv6 = false)
{
// IPv4 mapped IPv6 address
if (!mapToIpv6 && value >= 0xFFFF00000000UL && value <= 0xFFFFFFFFFFFFUL)
{
var newAddress = IPAddress.HostToNetworkOrder((int)value);
return new IPAddress(unchecked((uint)newAddress));
}
Span<byte> bytes = stackalloc byte[16]; // 128 bits for IPv6 address
((IBinaryInteger<UInt128>)value).WriteBigEndian(bytes);
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)
{
Span<byte> b = stackalloc byte[4];
if (ip.TryWriteBytes(b, out var n) && n == 4)
{
v = ((uint)b[0] << 24) | ((uint)b[1] << 16) | ((uint)b[2] << 8) | b[3];
return true;
}
v = 0;
return false;
}
/// <summary>
/// Extracts the embedded v4 uint from a v4-mapped-v6 address directly from the mapped bytes,
/// avoiding the allocation of <see cref="IPAddress.MapToIPv4"/>.
/// </summary>
public static bool TryMappedV4(IPAddress ip, out uint v)
{
Span<byte> b = stackalloc byte[16];
if (ip.TryWriteBytes(b, out var n) && n == 16)
{
v = ((uint)b[12] << 24) | ((uint)b[13] << 16) | ((uint)b[14] << 8) | b[15];
return true;
}
v = 0;
return false;
}
/// <summary>Parses a dotted-quad IPv4 literal into a big-endian uint. Allocation-free, strict.</summary>
public static bool TryParseV4(ReadOnlySpan<char> s, out uint v)
{
v = 0;
uint acc = 0;
int octet = 0, digits = 0, dots = 0;
for (var i = 0; i < s.Length; i++)
{
var c = s[i];
if (c == '.')
{
if (digits == 0 || octet > 255)
{
return false;
}
acc = (acc << 8) | (uint)octet;
dots++;
octet = 0;
digits = 0;
}
else if (c is >= '0' and <= '9')
{
octet = octet * 10 + (c - '0');
if (++digits > 3)
{
return false;
}
}
else
{
return false;
}
}
if (dots != 3 || digits == 0 || octet > 255)
{
return false;
}
v = (acc << 8) | (uint)octet;
return true;
}
/// <summary>
/// UTF-8/ASCII byte overload of <see cref="TryParseV4(ReadOnlySpan{char}, out uint)"/>, mirroring its
/// validation exactly so the blocklist can parse dotted-quads straight from file bytes with no
/// per-line string allocation.
/// </summary>
public static bool TryParseV4(ReadOnlySpan<byte> s, out uint v)
{
v = 0;
uint acc = 0;
int octet = 0, digits = 0, dots = 0;
for (var i = 0; i < s.Length; i++)
{
var c = s[i];
if (c == (byte)'.')
{
if (digits == 0 || octet > 255)
{
return false;
}
acc = (acc << 8) | (uint)octet;
dots++;
octet = 0;
digits = 0;
}
else if (c is >= (byte)'0' and <= (byte)'9')
{
octet = octet * 10 + (c - '0');
if (++digits > 3)
{
return false;
}
}
else
{
return false;
}
}
if (dots != 3 || digits == 0 || octet > 255)
{
return false;
}
v = (acc << 8) | (uint)octet;
return true;
}
}

View file

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

View file

@ -108,45 +108,6 @@ public static partial class Utility
}
}
// Converts an IPAddress to a UInt128 in IPv6 format
public static UInt128 ToUInt128(this IPAddress ip)
{
if (ip.AddressFamily == AddressFamily.InterNetwork && !ip.IsIPv4MappedToIPv6)
{
Span<byte> integer = stackalloc byte[4];
return !ip.TryWriteBytes(integer, out _)
? (UInt128)0
: new UInt128(0, 0xFFFF00000000UL | BinaryPrimitives.ReadUInt32BigEndian(integer));
}
Span<byte> bytes = stackalloc byte[16];
if (!ip.TryWriteBytes(bytes, out _))
{
return 0;
}
var high = BinaryPrimitives.ReadUInt64BigEndian(bytes[..8]);
var low = BinaryPrimitives.ReadUInt64BigEndian(bytes.Slice(8, 8));
return new UInt128(high, low);
}
// Converts a UInt128 in IPv6 format to an IPAddress
public static IPAddress ToIpAddress(this UInt128 value, bool mapToIpv6 = false)
{
// IPv4 mapped IPv6 address
if (!mapToIpv6 && value >= 0xFFFF00000000UL && value <= 0xFFFFFFFFFFFFUL)
{
var newAddress = IPAddress.HostToNetworkOrder((int)value);
return new IPAddress(unchecked((uint)newAddress));
}
Span<byte> bytes = stackalloc byte[16]; // 128 bits for IPv6 address
((IBinaryInteger<UInt128>)value).WriteBigEndian(bytes);
return new IPAddress(bytes);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static UInt128 CreateCidrAddress(ReadOnlySpan<byte> bytes, int prefixLength, bool isMax)
{