From 74d34b9538453c6eb8c648b1326b984b3974df97 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Thu, 23 Jul 2026 20:38:23 -0700 Subject: [PATCH] feat(network): wire ban channel + blocklist into the accept path; threading policy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hook the accept gate (NetState) so a rate-limit trip and a blocklist hit each report to the ban channel and a blocklisted IP is denied; start/stop the ban channel and FileBlocklist with the server; contribute single-IP manual bans from the Admin gump and [Firewall command. Rewrites CLAUDE.md rule #3 into a three-part threading policy (paired with rule #10: background→loop handoff via volatile snapshot / Core.LoopContext.Post), and stops tracking the local docs/ folder. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 3 ++ CLAUDE.md | 4 +-- .../Fixtures/TestServerInitializer.cs | 7 ++-- Projects/Server/Main.cs | 8 ++++- .../Network/NetState/NetState.Network.cs | 19 +++++++++++ Projects/Server/Network/NetState/NetState.cs | 9 +++++ .../Fixtures/TestServerInitializer.cs | 6 +++- .../Commands/Generic/Commands/Commands.cs | 3 +- Projects/UOContent/Gumps/AdminGump.cs | 33 ++++++++++++++++--- 9 files changed, 80 insertions(+), 12 deletions(-) diff --git a/.gitignore b/.gitignore index 7bb942e52..6cf6fa6c4 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,8 @@ /Distribution/bsdtar /Distribution/Configuration/antimacro.json /Distribution/Configuration/assistants.json +/Distribution/Configuration/bans.json +/Distribution/Configuration/crowdsec.json /Distribution/Configuration/expansion.json /Distribution/Configuration/modernuo.json /Distribution/Configuration/email-settings.json @@ -19,6 +21,7 @@ /Distribution/Backups /Distribution/Saves /Distribution/docs +/docs/ /Distribution/temp /Distribution/*.dylib /Distribution/*.so diff --git a/CLAUDE.md b/CLAUDE.md index 5335c036d..1b52b6816 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,14 +12,14 @@ Apply these when writing or reviewing `.cs` files under `Projects/`. 1. **LINQ** — Tier 1 (zero-cost patterns) free on hot paths; Tier 2 (low overhead) OK on warm paths; Tier 3 (allocating) forbidden on hot paths → `dev-docs/code-standards.md` 2. **No `Console.WriteLine`** — use `LogFactory.GetLogger(typeof(MyClass))` → `logger.Information(...)` (requires `using Server.Logging;`) -3. **No concurrency primitives** — no `lock`, `volatile`, `ConcurrentDictionary`, `Mutex`, etc. Server is single-threaded. +3. **Threading policy** — game logic runs only on the main loop; **never** touch game state (`World`, mobiles, items, maps, timers) from a background thread. Heavy work that *needs* game state must be **chunked** across ticks, not threaded. Heavy work that does *not* need game state (large-file parse, external I/O) **must** run on a background thread **and must yield to world saves** (defer while `World.Saving`/`WorldState.PendingSave`). Publish results back to the loop as an immutable snapshot swapped via a single `volatile` reference — the only sanctioned `volatile`. No `lock`/`Mutex`/`ConcurrentDictionary` in game logic. Rule #10 covers how background work hands results back to the loop → `dev-docs/threading-model.md` 4. **No `World.Mobiles`/`World.Items` iteration** — use spatial queries: `map.GetMobilesInRange()`, `map.GetItemsInRange()` 5. **Clean up refs in `OnDelete()`/`OnAfterDelete()`** — null out `Item`/`Mobile` references 6. **Cancel timers in `OnDelete()`/`OnAfterDelete()`** — call `_token.Cancel()` or `_timer?.Stop()` 7. **`STArrayPool.Shared`** not `ArrayPool.Shared` — single-threaded optimized, no locks 8. **`PooledRefList`** not `new List()` on hot paths — zero GC pressure, stack-allocated ref struct 9. **Serialization** — class must be `partial`, constructor needs `[Constructible]`, `TimerExecutionToken` must NOT have `[SerializableField]`. New classes: use `[SerializationGenerator(version)]` (omit `encoded`). When bumping versions, add `MigrateFrom(VXContent)` (X = previous version). Never modify `Deserialize(reader, version)` for version bumps — that method is only for pre-codegen legacy saves. When migrating from pre-codegen Serialize/Deserialize: pass `false` if old code used `reader.ReadInt()`, bump version +1, and keep old logic as `private void Deserialize(IGenericReader reader, int version)` → `dev-docs/runuo-migration-docs/02-serialization.md` -10. **No `Task.Run`/`new Thread()`** in game code — game logic is single-threaded event loop +10. **No `Task.Run`/`new Thread()` for game logic** (tandem with rule #3) — game logic is the single-threaded event loop. Backgrounding is allowed only for work that does not itself touch game state (external service calls, large-file parse). When such work must *feed* game logic: run the heavy/I/O part off-loop and `ConfigureAwait(false)` its awaits so a continuation never resumes on the loop and silently foregrounds heavy work; then hand the result back **explicitly** — publish an immutable snapshot swapped via a `volatile` reference (the loop reads it lock-free), or marshal the apply step with `Core.LoopContext.Post(() => …)`. Never touch game state off-thread; never let the scheduler decide where the heavy work runs → `dev-docs/threading-model.md` 11. **Never assume era** — if code uses `Core.AOS`/`Core.SE`/etc., ask which expansion to target 12. **Naming** — `_camelCase` private fields, `PascalCase` properties/methods/classes; don't flag legacy `m_` but use `_` for new code 13. **No empty gumps** — every gump must produce visual elements. An empty gump leaks on client+server (no way to close it). Use static `DisplayTo()` to validate before constructing → `dev-docs/gump-system.md` diff --git a/Projects/Server.Tests/Fixtures/TestServerInitializer.cs b/Projects/Server.Tests/Fixtures/TestServerInitializer.cs index e84c16509..3902478a2 100644 --- a/Projects/Server.Tests/Fixtures/TestServerInitializer.cs +++ b/Projects/Server.Tests/Fixtures/TestServerInitializer.cs @@ -78,6 +78,11 @@ internal static class TestServerInitializer Core.LoopContext = new EventLoopContext(); Core.Expansion = Expansion.EJ; + // Timer wheel must exist before NetState.Configure(), which schedules a recurring + // sweep via Timer.DelayCall (matches production ordering in Main.cs: Timer.Init runs + // before AssemblyHandler.Invoke("Configure")). + Timer.Init(0); + // Configure networking (initializes RingSocketManager for tests) Server.Network.NetState.Configure(); @@ -87,8 +92,6 @@ internal static class TestServerInitializer // Configure the world World.Configure(); - Timer.Init(0); - // Load the world World.Load(); diff --git a/Projects/Server/Main.cs b/Projects/Server/Main.cs index f89fd3084..c3dbacdf4 100644 --- a/Projects/Server/Main.cs +++ b/Projects/Server/Main.cs @@ -30,6 +30,8 @@ using Server.Compression; using Server.Json; using Server.Logging; using Server.Network; +using Server.Network.Bans; +using Server.Network.Bans.Blocklist; using Server.Text; namespace Server; @@ -260,7 +262,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 +344,8 @@ public static class Core World.ExitSerializationThreads(); PingServer.Shutdown(); NetState.Shutdown(); + BanChannel.Stop(); + FileBlocklist.Stop(); if (!_crashed) { @@ -461,6 +465,8 @@ public static class Core AssemblyHandler.Invoke("Initialize"); + BanChannel.Start(ClosingTokenSource.Token); + FileBlocklist.Start(ClosingTokenSource.Token); NetState.Start(); PingServer.Start(); EventSink.InvokeServerStarted(); diff --git a/Projects/Server/Network/NetState/NetState.Network.cs b/Projects/Server/Network/NetState/NetState.Network.cs index 9bfd39673..97249dcd6 100644 --- a/Projects/Server/Network/NetState/NetState.Network.cs +++ b/Projects/Server/Network/NetState/NetState.Network.cs @@ -73,6 +73,7 @@ public partial class NetState public static IPEndPoint[] ListeningAddresses { get; private set; } private static IPRateLimiter _ipRateLimiter; + private static readonly Bans.Blocklist.PromotedGuard _blocklistGuard = new(); /// /// Configures the IORingGroup and socket manager. @@ -224,11 +225,29 @@ 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)) { logger.Debug("{Address} Firewalled", remoteIP); } + else if (Bans.Blocklist.BlocklistGate.Evaluate(remoteIP, false, _blocklistGuard, Core.TickCount, + Bans.BanConfiguration.Settings.ReportBlocklistHits, + (long)Bans.BanConfiguration.Settings.BlocklistPromoteSuppression.TotalMilliseconds, out var promote)) + { + logger.Debug("{Address} Blocklisted", remoteIP); + + if (promote) + { + Bans.BanChannel.Report(remoteIP, Bans.BanConfiguration.Settings.BlocklistBanDuration, "blocklist"); + } + } else { // Allow event handlers to reject the connection diff --git a/Projects/Server/Network/NetState/NetState.cs b/Projects/Server/Network/NetState/NetState.cs index 6c6262ae4..faabc11f7 100755 --- a/Projects/Server/Network/NetState/NetState.cs +++ b/Projects/Server/Network/NetState/NetState.cs @@ -96,6 +96,15 @@ public partial class NetState : IComparable, IValueLinkListNode _blocklistGuard.Sweep(Core.TickCount)); } // Internal constructor for accepted sockets diff --git a/Projects/UOContent.Tests/Fixtures/TestServerInitializer.cs b/Projects/UOContent.Tests/Fixtures/TestServerInitializer.cs index 076f88141..dad2411ce 100644 --- a/Projects/UOContent.Tests/Fixtures/TestServerInitializer.cs +++ b/Projects/UOContent.Tests/Fixtures/TestServerInitializer.cs @@ -61,6 +61,11 @@ internal static class TestServerInitializer AssemblyHandler.LoadAssemblies(["Server.dll", "UOContent.dll"]); SkillsInfo.Configure(); + + // Timer wheel must exist before NetState.Configure(), which schedules a recurring + // sweep via Timer.DelayCall (matches production ordering in Main.cs: Timer.Init runs + // before AssemblyHandler.Invoke("Configure")). + Timer.Init(0); Server.Network.NetState.Configure(); TestMapDefinitions.ConfigureTestMapDefinitions(); @@ -91,7 +96,6 @@ internal static class TestServerInitializer } World.Configure(); - Timer.Init(0); RaceDefinitions.Configure(); MovementImpl.Configure(); PathFollower.Configure(); diff --git a/Projects/UOContent/Commands/Generic/Commands/Commands.cs b/Projects/UOContent/Commands/Generic/Commands/Commands.cs index 81d85fc0a..3f21ea4eb 100644 --- a/Projects/UOContent/Commands/Generic/Commands/Commands.cs +++ b/Projects/UOContent/Commands/Generic/Commands/Commands.cs @@ -1154,7 +1154,8 @@ namespace Server.Commands.Generic try { - AdminFirewall.Add(state.Address); + Firewall.Add(new SingleIpFirewallEntry(state.Address)); + Server.Network.Bans.BanChannel.Report(state.Address, TimeSpan.Zero, "manual"); AddResponse("They have been firewalled."); } catch (Exception ex) diff --git a/Projects/UOContent/Gumps/AdminGump.cs b/Projects/UOContent/Gumps/AdminGump.cs index 6e7c3ac23..dd646eba1 100644 --- a/Projects/UOContent/Gumps/AdminGump.cs +++ b/Projects/UOContent/Gumps/AdminGump.cs @@ -1743,7 +1743,8 @@ namespace Server.Gumps { for (var i = 0; i < a.LoginIPs.Length; ++i) { - AdminFirewall.Add(a.LoginIPs[i]); + Firewall.Add(new SingleIpFirewallEntry(a.LoginIPs[i])); + Server.Network.Bans.BanChannel.Report(a.LoginIPs[i], TimeSpan.Zero, "manual"); } notice = "All addresses in the list have been firewalled."; @@ -1767,7 +1768,13 @@ namespace Server.Gumps if (okay) { - AdminFirewall.Add(toFirewall); + var firewallEntry = Firewall.ToFirewallEntry(toFirewall); + Firewall.Add(firewallEntry); + + if (firewallEntry.MinIpAddress == firewallEntry.MaxIpAddress) + { + Server.Network.Bans.BanChannel.Report(firewallEntry.MinIpAddress.ToIpAddress(), TimeSpan.Zero, "manual"); + } notice = $"{toFirewall} : Added to firewall."; } @@ -3559,7 +3566,7 @@ namespace Server.Gumps IFirewallEntry firewallEntry; try { - firewallEntry = AdminFirewall.ToFirewallEntry(text); + firewallEntry = Firewall.ToFirewallEntry(text); } catch { @@ -3581,7 +3588,17 @@ namespace Server.Gumps $"{from.AccessLevel} {CommandLogging.Format(from)} firewalling {firewallEntry}" ); - AdminFirewall.Add(firewallEntry); + Firewall.Add(firewallEntry); + + if (firewallEntry.MinIpAddress == firewallEntry.MaxIpAddress) + { + Server.Network.Bans.BanChannel.Report( + firewallEntry.MinIpAddress.ToIpAddress(), + TimeSpan.Zero, + "manual" + ); + } + from.SendGump( new AdminGump( from, @@ -3620,7 +3637,13 @@ namespace Server.Gumps $"{from.AccessLevel} {CommandLogging.Format(from)} removing {m_State} from firewall list" ); - AdminFirewall.Remove(m_State); + Firewall.Remove(m_State as IFirewallEntry); + + if (m_State is IFirewallEntry fe && fe.MinIpAddress == fe.MaxIpAddress) + { + Server.Network.Bans.BanChannel.Retract(fe.MinIpAddress.ToIpAddress()); + } + from.SendGump( new AdminGump( from,