From fde85a5f7c0ab97ff4629ef75d874e069b29a88d Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Mon, 30 Mar 2026 18:52:28 -0700 Subject: [PATCH 1/3] Adds gateway support including SingalR --- Projects/Server/Main.cs | 2 + Projects/Server/Tasks/TaskExtensions.cs | 48 +++++ .../Commands/MovementDebugCommands.cs | 1 - .../BountyReportMurdererGump.cs | 1 - Projects/UOContent/Network/GameServer.cs | 7 + .../Network/Packets/IncomingAccountPackets.cs | 7 + .../Systems/Gateway/GatewayAdminHandler.cs | 48 +++++ .../Systems/Gateway/GatewayClient.cs | 176 ++++++++++++++++ .../Systems/Gateway/GatewayConfig.cs | 45 +++++ .../Systems/Gateway/GatewayHeartbeat.cs | 79 ++++++++ .../Systems/Gateway/GatewayLoginHandler.cs | 188 ++++++++++++++++++ .../Systems/Gateway/GatewaySessionStore.cs | 91 +++++++++ Projects/UOContent/UOContent.csproj | 1 + 13 files changed, 692 insertions(+), 2 deletions(-) create mode 100644 Projects/Server/Tasks/TaskExtensions.cs create mode 100644 Projects/UOContent/Systems/Gateway/GatewayAdminHandler.cs create mode 100644 Projects/UOContent/Systems/Gateway/GatewayClient.cs create mode 100644 Projects/UOContent/Systems/Gateway/GatewayConfig.cs create mode 100644 Projects/UOContent/Systems/Gateway/GatewayHeartbeat.cs create mode 100644 Projects/UOContent/Systems/Gateway/GatewayLoginHandler.cs create mode 100644 Projects/UOContent/Systems/Gateway/GatewaySessionStore.cs diff --git a/Projects/Server/Main.cs b/Projects/Server/Main.cs index 1eabf25fc..73336ba70 100644 --- a/Projects/Server/Main.cs +++ b/Projects/Server/Main.cs @@ -50,6 +50,7 @@ public static class Core private static int _itemCount; private static int _mobileCount; public static EventLoopContext LoopContext { get; set; } + public static TaskScheduler LoopContextTaskScheduler { get; set; } private static readonly Type[] _serialTypeArray = { typeof(Serial) }; @@ -372,6 +373,7 @@ public static class Core Thread = Thread.CurrentThread; LoopContext = new EventLoopContext(); SynchronizationContext.SetSynchronizationContext(LoopContext); + LoopContextTaskScheduler = TaskScheduler.FromCurrentSynchronizationContext(); AppDomain.CurrentDomain.UnhandledException += CurrentDomain_UnhandledException; AppDomain.CurrentDomain.ProcessExit += CurrentDomain_ProcessExit; diff --git a/Projects/Server/Tasks/TaskExtensions.cs b/Projects/Server/Tasks/TaskExtensions.cs new file mode 100644 index 000000000..76686d9e9 --- /dev/null +++ b/Projects/Server/Tasks/TaskExtensions.cs @@ -0,0 +1,48 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Server.Tasks; + +public static class TaskExtensions +{ + public static void OnFaultedCurrentSyncContext(this Task task, Action onFaulted, CancellationToken cancellationToken = default) + { + var scheduler = TaskScheduler.FromCurrentSynchronizationContext(); + task.ContinueWith(onFaulted, cancellationToken, TaskContinuationOptions.OnlyOnFaulted, scheduler); + } + + public static void ContinueWithOnCurrentSyncContext( + this Task task, + Action> onRanToCompletion, + Action> onFaulted, + CancellationToken cancellationToken = default + ) + { + var scheduler = TaskScheduler.FromCurrentSynchronizationContext(); + task.ContinueWith(onRanToCompletion, cancellationToken, TaskContinuationOptions.OnlyOnRanToCompletion, scheduler); + task.ContinueWith(onFaulted, cancellationToken, TaskContinuationOptions.OnlyOnFaulted, scheduler); + } + + public static void ContinueWithOnCurrentSyncContext( + this Task task, + Action> onCompletion, + CancellationToken cancellationToken = default + ) + { + var scheduler = TaskScheduler.FromCurrentSynchronizationContext(); + task.ContinueWith(onCompletion, cancellationToken, TaskContinuationOptions.None, scheduler); + } + + public static void ContinueWithOnGameThread( + this Task task, + Action> onCompletion, + CancellationToken cancellationToken = default + ) => task.ContinueWith(onCompletion, cancellationToken, TaskContinuationOptions.None, Core.LoopContextTaskScheduler); + + public static void ContinueWithOnGameThread( + this Task task, + Action onCompletion, + CancellationToken cancellationToken = default + ) => task.ContinueWith(onCompletion, cancellationToken, TaskContinuationOptions.None, Core.LoopContextTaskScheduler); +} diff --git a/Projects/UOContent/Commands/MovementDebugCommands.cs b/Projects/UOContent/Commands/MovementDebugCommands.cs index 090ada367..90dbb0596 100644 --- a/Projects/UOContent/Commands/MovementDebugCommands.cs +++ b/Projects/UOContent/Commands/MovementDebugCommands.cs @@ -1,4 +1,3 @@ -using Server.Commands; using Server.Network; using Server.Targeting; diff --git a/Projects/UOContent/Engines/Player Murder System/BountyReportMurdererGump.cs b/Projects/UOContent/Engines/Player Murder System/BountyReportMurdererGump.cs index c099fd2b9..2f68a22e8 100644 --- a/Projects/UOContent/Engines/Player Murder System/BountyReportMurdererGump.cs +++ b/Projects/UOContent/Engines/Player Murder System/BountyReportMurdererGump.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using Server.Gumps; -using Server.Items; using Server.Mobiles; using Server.Network; diff --git a/Projects/UOContent/Network/GameServer.cs b/Projects/UOContent/Network/GameServer.cs index 9fc170d69..e8fc075ac 100644 --- a/Projects/UOContent/Network/GameServer.cs +++ b/Projects/UOContent/Network/GameServer.cs @@ -21,6 +21,13 @@ public static partial class GameServer public bool Accepted { get; set; } + /// + /// When true, the login will be completed asynchronously (e.g., by a gateway handler). + /// The packet handler skips both the accept and reject paths. + /// The handler that sets this is responsible for calling SendCharacterList or Disconnect. + /// + public bool Deferred { get; set; } + public CityInfo[] CityInfo { get; set; } } diff --git a/Projects/UOContent/Network/Packets/IncomingAccountPackets.cs b/Projects/UOContent/Network/Packets/IncomingAccountPackets.cs index 07a444d03..c9b9f71ea 100644 --- a/Projects/UOContent/Network/Packets/IncomingAccountPackets.cs +++ b/Projects/UOContent/Network/Packets/IncomingAccountPackets.cs @@ -384,6 +384,13 @@ public static class IncomingAccountPackets GameServer.GameServerLoginEvent(e); + if (e.Deferred) + { + // Login is being handled asynchronously (e.g., gateway validation). + // The handler that set Deferred is responsible for completing the login. + return; + } + if (e.Accepted) { state.CityInfo = e.CityInfo; diff --git a/Projects/UOContent/Systems/Gateway/GatewayAdminHandler.cs b/Projects/UOContent/Systems/Gateway/GatewayAdminHandler.cs new file mode 100644 index 000000000..a9245af44 --- /dev/null +++ b/Projects/UOContent/Systems/Gateway/GatewayAdminHandler.cs @@ -0,0 +1,48 @@ +using Server.Logging; +using Server.Network; + +namespace Server.Systems.Gateway; + +/// +/// Handles admin commands pushed from the gateway web portal via SignalR. +/// All methods run on the game thread (marshaled by GatewayClient callback handlers). +/// +public static class GatewayAdminHandler +{ + private static readonly ILogger logger = LogFactory.GetLogger(typeof(GatewayAdminHandler)); + + public static void HandleCommand(GatewayClient.AdminCommandData command) + { + logger.Information("Received admin command: {Type}", command.Type); + + // Dispatch based on command type + // For now, just log. Specific handlers will be implemented as features are built. + switch (command.Type.ToLowerInvariant()) + { + case "banaccount": + // TODO: Find online player, disconnect them, mark account banned + logger.Information("Ban account command received: {Payload}", command.Payload); + break; + case "kickplayer": + // TODO: Find online player by name/serial, disconnect + logger.Information("Kick player command received: {Payload}", command.Payload); + break; + case "broadcastmessage": + // Broadcast to all online players + foreach (var ns in NetState.Instances) + { + ns.Mobile?.SendMessage(command.Payload); + } + + logger.Information("Broadcast message sent: {Payload}", command.Payload); + break; + case "gmpage": + // TODO: Route to online GM + logger.Information("GM page received: {Payload}", command.Payload); + break; + default: + logger.Warning("Unknown admin command type: {Type}", command.Type); + break; + } + } +} diff --git a/Projects/UOContent/Systems/Gateway/GatewayClient.cs b/Projects/UOContent/Systems/Gateway/GatewayClient.cs new file mode 100644 index 000000000..8fc524d10 --- /dev/null +++ b/Projects/UOContent/Systems/Gateway/GatewayClient.cs @@ -0,0 +1,176 @@ +using System; +using System.Net.Http; +using System.Net.Http.Json; +using System.Text.Json.Serialization; +using System.Threading.Tasks; +using Microsoft.AspNetCore.SignalR.Client; +using Server.Logging; + +namespace Server.Systems.Gateway; + +public static class GatewayClient +{ + private static readonly ILogger logger = LogFactory.GetLogger(typeof(GatewayClient)); + + // REST client (always available as fallback) + private static HttpClient _httpClient; + + // SignalR client (optional, preferred when connected) + private static HubConnection _hubConnection; + + public static bool IsReady => _httpClient != null; + public static bool IsSignalRConnected => _hubConnection?.State == HubConnectionState.Connected; + + public static void Configure() + { + if (!GatewayConfig.Enabled) + { + return; + } + + // REST client (always configured) + _httpClient = new HttpClient + { + BaseAddress = new Uri(GatewayConfig.GatewayUrl.TrimEnd('/')), + Timeout = TimeSpan.FromSeconds(10) + }; + _httpClient.DefaultRequestHeaders.Add("Authorization", $"ApiKey {GatewayConfig.ApiKey}"); + + // SignalR client (optional) + if (GatewayConfig.SignalREnabled) + { + var hubUrl = $"{GatewayConfig.GatewayUrl.TrimEnd('/')}/hubs/gameserver?apiKey={GatewayConfig.ApiKey}"; + + _hubConnection = new HubConnectionBuilder() + .WithUrl(hubUrl) + .WithAutomaticReconnect(new[] + { + TimeSpan.Zero, + TimeSpan.FromSeconds(2), + TimeSpan.FromSeconds(5), + TimeSpan.FromSeconds(10), + TimeSpan.FromSeconds(30) + }) + .Build(); + + // Register handlers -- all marshal to game thread + _hubConnection.On("PushSession", session => + { + Core.LoopContext.Post(() => GatewaySessionStore.Add(session), EventLoopContext.Priority.High); + }); + + _hubConnection.On("AdminCommand", command => + { + Core.LoopContext.Post(() => GatewayAdminHandler.HandleCommand(command)); + }); + + _hubConnection.On("Ping", () => + { + // No-op, connection keep-alive handled by SignalR internally + }); + + _hubConnection.Reconnected += connectionId => + { + logger.Information("SignalR reconnected to gateway (ConnectionId: {Id})", connectionId); + return Task.CompletedTask; + }; + + _hubConnection.Closed += error => + { + logger.Warning("SignalR connection to gateway closed: {Message}", error?.Message ?? "clean disconnect"); + return Task.CompletedTask; + }; + + // Connect after server starts (via EventSink.ServerStarted) + } + } + + /// + /// Connects the SignalR hub. Called after server is fully loaded. + /// + public static async Task ConnectSignalRAsync() + { + if (_hubConnection == null) + { + return; + } + + try + { + await _hubConnection.StartAsync(); + logger.Information("SignalR connected to gateway at {Url}", GatewayConfig.GatewayUrl); + } + catch (Exception ex) + { + logger.Warning("SignalR connection to gateway failed: {Message}. Will retry automatically.", ex.Message); + } + } + + // --- SignalR methods --- + + public static Task SendHeartbeatAsync(HeartbeatRequest data) => + _hubConnection?.InvokeAsync("Heartbeat", new { data.PlayerCount, data.MaxPlayers, data.IsOnline }) + ?? Task.CompletedTask; + + public static async Task ValidateSessionAsync(int authId) + { + if (_hubConnection?.State != HubConnectionState.Connected) + { + return null; + } + + try + { + var result = await _hubConnection.InvokeAsync("ValidateSession", authId); + if (result == null) + { + return null; + } + + return new GameLoginResponse(result.Valid, result.Reason, result.GameAccountId, result.AccessLevel); + } + catch (Exception ex) + { + logger.Warning("SignalR ValidateSession failed: {Message}", ex.Message); + return null; + } + } + + // --- REST methods (fallback) --- + + public static Task PostAsJsonAsync(string path, T content) => + _httpClient.PostAsJsonAsync(path, content); + + // --- Types --- + + public record HeartbeatRequest( + [property: JsonPropertyName("playerCount")] int PlayerCount, + [property: JsonPropertyName("maxPlayers")] int MaxPlayers, + [property: JsonPropertyName("isOnline")] bool IsOnline + ); + + public record GameLoginRequest( + [property: JsonPropertyName("authId")] int AuthId, + [property: JsonPropertyName("username")] string Username, + [property: JsonPropertyName("password")] string Password + ); + + public record GameLoginResponse( + [property: JsonPropertyName("accepted")] bool Accepted, + [property: JsonPropertyName("reason")] string? Reason, + [property: JsonPropertyName("accountId")] Guid? AccountId, + [property: JsonPropertyName("accessLevel")] string? AccessLevel + ); + + public record AdminCommandData( + [property: JsonPropertyName("type")] string Type, + [property: JsonPropertyName("payload")] string Payload + ); + + public record SessionValidationResult( + [property: JsonPropertyName("valid")] bool Valid, + [property: JsonPropertyName("gameAccountId")] Guid? GameAccountId, + [property: JsonPropertyName("accessLevel")] string? AccessLevel, + [property: JsonPropertyName("reason")] string? Reason + ); +} diff --git a/Projects/UOContent/Systems/Gateway/GatewayConfig.cs b/Projects/UOContent/Systems/Gateway/GatewayConfig.cs new file mode 100644 index 000000000..b6d2231f8 --- /dev/null +++ b/Projects/UOContent/Systems/Gateway/GatewayConfig.cs @@ -0,0 +1,45 @@ +using Server.Logging; + +namespace Server.Systems.Gateway; + +/// +/// Configuration for the ModernUO Gateway integration. +/// When enabled, game server login (0x91) is validated against the Gateway API +/// and heartbeats are sent periodically. +/// +/// Settings are stored in modernuo.json under the "gateway" prefix. +/// +public static class GatewayConfig +{ + private static readonly ILogger logger = LogFactory.GetLogger(typeof(GatewayConfig)); + + public static bool Enabled { get; private set; } + public static string GatewayUrl { get; private set; } = ""; + public static string ApiKey { get; private set; } = ""; + public static int HeartbeatIntervalSeconds { get; private set; } + public static int MaxPlayers { get; private set; } + public static bool SignalREnabled { get; private set; } + + public static void Configure() + { + Enabled = ServerConfiguration.GetOrUpdateSetting("gateway.enabled", false); + + if (!Enabled) + { + return; + } + + GatewayUrl = ServerConfiguration.GetOrUpdateSetting("gateway.url", "http://localhost:5000"); + ApiKey = ServerConfiguration.GetOrUpdateSetting("gateway.apiKey", ""); + HeartbeatIntervalSeconds = ServerConfiguration.GetOrUpdateSetting("gateway.heartbeatIntervalSeconds", 30); + MaxPlayers = ServerConfiguration.GetOrUpdateSetting("gateway.maxPlayers", 200); + SignalREnabled = ServerConfiguration.GetOrUpdateSetting("gateway.signalREnabled", false); + + if (string.IsNullOrWhiteSpace(ApiKey)) + { + logger.Warning("Gateway is enabled but gateway.apiKey is empty. Heartbeats and login validation will fail."); + } + + logger.Information("Gateway integration enabled: {Url}", GatewayUrl); + } +} diff --git a/Projects/UOContent/Systems/Gateway/GatewayHeartbeat.cs b/Projects/UOContent/Systems/Gateway/GatewayHeartbeat.cs new file mode 100644 index 000000000..5c0020bcc --- /dev/null +++ b/Projects/UOContent/Systems/Gateway/GatewayHeartbeat.cs @@ -0,0 +1,79 @@ +using System; +using Server.Logging; +using Server.Network; + +namespace Server.Systems.Gateway; + +/// +/// Sends periodic heartbeats to the ModernUO Gateway so it knows this server is online. +/// Reports player count and online status. +/// Prefers SignalR when connected, falls back to REST. +/// +public static class GatewayHeartbeat +{ + private static readonly ILogger logger = LogFactory.GetLogger(typeof(GatewayHeartbeat)); + + private static TimerExecutionToken _heartbeatTimer; + + public static void Configure() + { + if (!GatewayConfig.Enabled) + { + return; + } + + EventSink.ServerStarted += OnServerStarted; + } + + private static void OnServerStarted() + { + logger.Information( + "Gateway heartbeat started, sending every {Interval}s", + GatewayConfig.HeartbeatIntervalSeconds); + + var interval = TimeSpan.FromSeconds(GatewayConfig.HeartbeatIntervalSeconds); + Timer.StartTimer(interval, interval, SendHeartbeat, out _heartbeatTimer); + + // Send first heartbeat immediately + SendHeartbeat(); + + // Connect SignalR if enabled + if (GatewayConfig.SignalREnabled) + { + _ = GatewayClient.ConnectSignalRAsync(); + } + } + + private static async void SendHeartbeat() + { + if (!GatewayClient.IsReady) + { + return; + } + + try + { + var playerCount = NetState.Instances.Count; + var request = new GatewayClient.HeartbeatRequest(playerCount, GatewayConfig.MaxPlayers, true); + + if (GatewayClient.IsSignalRConnected) + { + await GatewayClient.SendHeartbeatAsync(request); + } + else + { + // REST fallback + var response = await GatewayClient.PostAsJsonAsync("/api/heartbeat", request); + + if (!response.IsSuccessStatusCode) + { + logger.Warning("Gateway heartbeat failed: {StatusCode}", (int)response.StatusCode); + } + } + } + catch (Exception ex) + { + logger.Warning("Gateway heartbeat error: {Message}", ex.Message); + } + } +} diff --git a/Projects/UOContent/Systems/Gateway/GatewayLoginHandler.cs b/Projects/UOContent/Systems/Gateway/GatewayLoginHandler.cs new file mode 100644 index 000000000..3884e1a02 --- /dev/null +++ b/Projects/UOContent/Systems/Gateway/GatewayLoginHandler.cs @@ -0,0 +1,188 @@ +using System; +using System.Net.Http.Json; +using System.Threading.Tasks; +using ModernUO.CodeGeneratedEvents; +using Server.Accounting; +using Server.Engines.CharacterCreation; +using Server.Logging; +using Server.Network; +using Server.Tasks; + +namespace Server.Systems.Gateway; + +/// +/// Intercepts the game server login event (0x91 packet) and validates against the Gateway API +/// instead of using local account storage. +/// +/// Uses a three-tier approach: +/// 1. Fast path: check local pushed session store (O(1), no network) -- sessions pushed via SignalR +/// 2. SignalR path: validate session over SignalR hub connection +/// 3. REST path: fallback HTTP validation +/// +/// When gateway.enabled is false, this handler returns immediately and the default +/// AccountHandler processes the login as normal. +/// +public static class GatewayLoginHandler +{ + private static readonly ILogger logger = LogFactory.GetLogger(typeof(GatewayLoginHandler)); + + [OnEvent(nameof(GameServer.GameServerLoginEvent))] + public static void OnGameServerLogin(GameServer.GameLoginEventArgs e) + { + if (!GatewayConfig.Enabled || !GatewayClient.IsReady) + { + return; + } + + var state = e.State; + var username = e.Username; + var authId = state.AuthId; + + // Fast path: check local pushed session store (O(1), no network) + if (GatewaySessionStore.TryConsume(authId, out var session)) + { + logger.Information("Login: {NetState} Account '{Username}' accepted via pushed session", state, username); + AcceptLogin(e, state, username, session.AccessLevel); + return; + } + + // Slow path: network validation (SignalR or REST) + var password = e.Password; + + // Defer the login -- tells the packet handler to skip both accept and reject paths. + e.Deferred = true; + + // HTTP call on background thread, result posted back to game thread. + Task.Run(() => ValidateViaNetworkAsync(authId, username, password)) + .ContinueWithOnGameThread(t => OnValidationComplete(t, state, username)); + } + + private static async Task ValidateViaNetworkAsync( + int authId, + string username, + string password) + { + // Try SignalR first + if (GatewayClient.IsSignalRConnected) + { + var signalRResult = await GatewayClient.ValidateSessionAsync(authId); + if (signalRResult != null) + { + return signalRResult; + } + } + + // Fall back to REST + var request = new GatewayClient.GameLoginRequest(authId, username, password); + var httpResponse = await GatewayClient.PostAsJsonAsync("/api/validate-game-login", request); + httpResponse.EnsureSuccessStatusCode(); + return await httpResponse.Content.ReadFromJsonAsync(); + } + + /// + /// Runs on the game thread after the background network call completes. + /// + private static void OnValidationComplete(Task task, NetState state, string username) + { + if (!state.Running) + { + return; // Client disconnected while we were validating + } + + if (task.IsFaulted) + { + logger.Error(task.Exception, "Gateway login validation failed for '{Username}'", username); + state.Disconnect($"Gateway validation error."); + return; + } + + var response = task.Result; + + if (response is not { Accepted: true }) + { + logger.Information( + "Login: {NetState} Gateway rejected '{Username}': {Reason}", + state, username, response?.Reason ?? "unknown"); + state.Disconnect($"Gateway rejected login: {response?.Reason ?? "unknown"}"); + return; + } + + // Gateway accepted -- use common accept logic (deferred path) + logger.Information("Login: {NetState} Account '{Username}' at character list (via Gateway)", state, username); + AcceptLoginDeferred(state, username, response.AccessLevel); + } + + /// + /// Handles login acceptance for the fast path (non-deferred, from pushed session store). + /// Sets event args so the packet handler sends the response. + /// + private static void AcceptLogin(GameServer.GameLoginEventArgs e, NetState state, string username, string accessLevel) + { + var acct = FindOrCreateAccount(state, username, accessLevel); + + if (acct.Banned) + { + logger.Information("Login: {NetState} Locally banned account '{Username}'", state, username); + e.Accepted = false; + return; + } + + acct.LogAccess(state); + + state.Account = acct; + state.CityInfo = CharacterCreation.GetStartingCities(); + e.Accepted = true; + e.CityInfo = CharacterCreation.GetStartingCities(); + } + + /// + /// Handles login acceptance for the deferred path (after async network validation). + /// Sends packets directly since the original packet handler has already returned. + /// + private static void AcceptLoginDeferred(NetState state, string username, string accessLevel) + { + var acct = FindOrCreateAccount(state, username, accessLevel); + + if (acct.Banned) + { + logger.Information("Login: {NetState} Locally banned account '{Username}'", state, username); + state.Disconnect("Account is locally banned."); + return; + } + + acct.LogAccess(state); + + state.Account = acct; + state.CityInfo = CharacterCreation.GetStartingCities(); + + // Send the same packets that IncomingAccountPackets.GameLogin sends on e.Accepted = true + state.CompressionEnabled = true; + state.SendSupportedFeature(); + state.SendCharacterList(); + } + + /// + /// Finds an existing local account or creates a new shell account. + /// Gateway owns credentials; local account is for character storage only. + /// + private static Account FindOrCreateAccount(NetState state, string username, string accessLevel) + { + var acct = Accounts.GetAccount(username) as Account; + + if (acct == null) + { + acct = new Account(username, Guid.NewGuid().ToString()); + + if (Enum.TryParse(accessLevel, true, out var level)) + { + acct.AccessLevel = level; + } + + logger.Information( + "Login: {NetState} Created local account for '{Username}' (AccessLevel: {Level})", + state, username, acct.AccessLevel); + } + + return acct; + } +} diff --git a/Projects/UOContent/Systems/Gateway/GatewaySessionStore.cs b/Projects/UOContent/Systems/Gateway/GatewaySessionStore.cs new file mode 100644 index 000000000..251080cc7 --- /dev/null +++ b/Projects/UOContent/Systems/Gateway/GatewaySessionStore.cs @@ -0,0 +1,91 @@ +using System; +using System.Collections.Generic; +using Server.Logging; + +namespace Server.Systems.Gateway; + +/// +/// Stores sessions pushed from the gateway via SignalR. +/// Accessed only on the game thread -- no concurrency needed. +/// When a client sends 0x91, the login handler checks here first (O(1) lookup) +/// before falling back to network validation. +/// +public static class GatewaySessionStore +{ + private static readonly ILogger logger = LogFactory.GetLogger(typeof(GatewaySessionStore)); + private static readonly Dictionary _sessions = new(); + private static TimerExecutionToken _cleanupTimer; + + public static void Configure() + { + if (!GatewayConfig.Enabled || !GatewayConfig.SignalREnabled) + { + return; + } + + // Cleanup expired sessions every 60 seconds + Timer.StartTimer(TimeSpan.FromSeconds(60), TimeSpan.FromSeconds(60), Cleanup, out _cleanupTimer); + } + + /// + /// Adds a pushed session. Called on the game thread via Core.LoopContext.Post. + /// + public static void Add(PushedSession session) + { + _sessions[session.AuthId] = session; + logger.Debug("Cached pushed session AuthId=0x{AuthId:X8} for '{Username}'", session.AuthId, session.Username); + } + + /// + /// Tries to consume a session (one-time use). Returns true if found and not expired. + /// Called on the game thread from the login handler. + /// + public static bool TryConsume(int authId, out PushedSession session) + { + if (_sessions.Remove(authId, out session!)) + { + if (session.ExpiresAt > DateTime.UtcNow) + { + return true; + } + + // Expired + session = default!; + } + + return false; + } + + private static void Cleanup() + { + var now = DateTime.UtcNow; + var expired = new List(); + + foreach (var (authId, session) in _sessions) + { + if (session.ExpiresAt < now) + { + expired.Add(authId); + } + } + + foreach (var authId in expired) + { + _sessions.Remove(authId); + } + + if (expired.Count > 0) + { + logger.Debug("Cleaned up {Count} expired pushed sessions", expired.Count); + } + } +} + +public record PushedSession( + int AuthId, + Guid GameAccountId, + string Username, + string AccessLevel, + string ClientIP, + DateTime ExpiresAt +); diff --git a/Projects/UOContent/UOContent.csproj b/Projects/UOContent/UOContent.csproj index a127f62c4..730920330 100644 --- a/Projects/UOContent/UOContent.csproj +++ b/Projects/UOContent/UOContent.csproj @@ -36,6 +36,7 @@ + false From e2dce0ded6e422d7851cac49467305fa6fa3411a Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Tue, 31 Mar 2026 00:24:39 -0700 Subject: [PATCH 2/3] Fixes gateway support --- .../UOContent/Accounting/AccountHandler.cs | 6 + .../Network/Packets/IncomingAccountPackets.cs | 35 +++-- .../Systems/Gateway/GatewayClient.cs | 121 +++++++++++------- .../Systems/Gateway/GatewayConfig.cs | 2 - .../Systems/Gateway/GatewayHeartbeat.cs | 3 +- .../Systems/Gateway/GatewayLoginHandler.cs | 33 ++++- .../Systems/Gateway/GatewaySessionStore.cs | 3 +- 7 files changed, 138 insertions(+), 65 deletions(-) diff --git a/Projects/UOContent/Accounting/AccountHandler.cs b/Projects/UOContent/Accounting/AccountHandler.cs index 1e676ed9e..266b2d786 100644 --- a/Projects/UOContent/Accounting/AccountHandler.cs +++ b/Projects/UOContent/Accounting/AccountHandler.cs @@ -330,6 +330,12 @@ public static class AccountHandler [OnEvent(nameof(GameServer.GameServerLoginEvent))] public static void OnGameServerLogin(GameServer.GameLoginEventArgs e) { + // When gateway is enabled, the GatewayLoginHandler handles login validation. + if (Systems.Gateway.GatewayConfig.Enabled) + { + return; + } + var un = e.Username; var pw = e.Password; diff --git a/Projects/UOContent/Network/Packets/IncomingAccountPackets.cs b/Projects/UOContent/Network/Packets/IncomingAccountPackets.cs index c9b9f71ea..7c63bcf62 100644 --- a/Projects/UOContent/Network/Packets/IncomingAccountPackets.cs +++ b/Projects/UOContent/Network/Packets/IncomingAccountPackets.cs @@ -360,22 +360,33 @@ public static class IncomingAccountPackets var authId = reader.ReadInt32(); - if (!_authIDWindow.TryGetValue(authId, out var ap)) + if (Systems.Gateway.GatewayConfig.Enabled) { - state.LogInfo("Invalid client detected, disconnecting..."); - state.Disconnect("Unable to find auth id."); + // Gateway mode: AuthId was generated by the gateway, not by this server. + // Skip the local _authIDWindow and seed checks -- the gateway handler validates the AuthId. + state.AuthId = authId; + state.Seeded = true; } - - if (state.AuthId != 0 && authId != state.AuthId || state.AuthId == 0 && authId != state.Seed) + else { - state.LogInfo("Invalid client detected, disconnecting..."); - state.Disconnect("Invalid auth id in game login packet."); - return; - } + if (!_authIDWindow.TryGetValue(authId, out var ap)) + { + state.LogInfo("Invalid client detected, disconnecting..."); + state.Disconnect("Unable to find auth id."); + return; + } - _authIDWindow.Remove(authId); - state.Version = ap.Version; - state.Seeded = true; + if (state.AuthId != 0 && authId != state.AuthId || state.AuthId == 0 && authId != state.Seed) + { + state.LogInfo("Invalid client detected, disconnecting..."); + state.Disconnect("Invalid auth id in game login packet."); + return; + } + + _authIDWindow.Remove(authId); + state.Version = ap.Version; + state.Seeded = true; + } var username = reader.ReadLatin1Safe(30); var password = reader.ReadLatin1Safe(30); diff --git a/Projects/UOContent/Systems/Gateway/GatewayClient.cs b/Projects/UOContent/Systems/Gateway/GatewayClient.cs index 8fc524d10..21159930a 100644 --- a/Projects/UOContent/Systems/Gateway/GatewayClient.cs +++ b/Projects/UOContent/Systems/Gateway/GatewayClient.cs @@ -21,7 +21,7 @@ public static class GatewayClient public static bool IsReady => _httpClient != null; public static bool IsSignalRConnected => _hubConnection?.State == HubConnectionState.Connected; - public static void Configure() + public static void Initialize() { if (!GatewayConfig.Enabled) { @@ -36,53 +36,76 @@ public static class GatewayClient }; _httpClient.DefaultRequestHeaders.Add("Authorization", $"ApiKey {GatewayConfig.ApiKey}"); - // SignalR client (optional) - if (GatewayConfig.SignalREnabled) - { - var hubUrl = $"{GatewayConfig.GatewayUrl.TrimEnd('/')}/hubs/gameserver?apiKey={GatewayConfig.ApiKey}"; + logger.Information("Gateway API key configured: length={Length}, first4='{First4}', last4='{Last4}'", + GatewayConfig.ApiKey.Length, + GatewayConfig.ApiKey.Length >= 4 ? GatewayConfig.ApiKey[..4] : GatewayConfig.ApiKey, + GatewayConfig.ApiKey.Length >= 4 ? GatewayConfig.ApiKey[^4..] : GatewayConfig.ApiKey); - _hubConnection = new HubConnectionBuilder() - .WithUrl(hubUrl) - .WithAutomaticReconnect(new[] + // SignalR client + var encodedApiKey = Uri.EscapeDataString(GatewayConfig.ApiKey); + var hubUrl = $"{GatewayConfig.GatewayUrl.TrimEnd('/')}/hubs/gameserver?apiKey={encodedApiKey}"; + + _hubConnection = new HubConnectionBuilder() + .WithUrl(hubUrl, options => + { + // Prefer IPv4 to avoid ::1 vs 127.0.0.1 mismatch in IP allowlists + options.HttpMessageHandlerFactory = handler => { - TimeSpan.Zero, - TimeSpan.FromSeconds(2), - TimeSpan.FromSeconds(5), - TimeSpan.FromSeconds(10), - TimeSpan.FromSeconds(30) - }) - .Build(); - - // Register handlers -- all marshal to game thread - _hubConnection.On("PushSession", session => + if (handler is SocketsHttpHandler socketsHandler) + { + socketsHandler.ConnectCallback = async (context, ct) => + { + var socket = new System.Net.Sockets.Socket( + System.Net.Sockets.AddressFamily.InterNetwork, + System.Net.Sockets.SocketType.Stream, + System.Net.Sockets.ProtocolType.Tcp); + socket.NoDelay = true; + await socket.ConnectAsync(context.DnsEndPoint, ct); + return new System.Net.Sockets.NetworkStream(socket, ownsSocket: true); + }; + } + return handler; + }; + }) + .WithAutomaticReconnect(new[] { - Core.LoopContext.Post(() => GatewaySessionStore.Add(session), EventLoopContext.Priority.High); - }); + TimeSpan.Zero, + TimeSpan.FromSeconds(2), + TimeSpan.FromSeconds(5), + TimeSpan.FromSeconds(10), + TimeSpan.FromSeconds(30) + }) + .Build(); - _hubConnection.On("AdminCommand", command => - { - Core.LoopContext.Post(() => GatewayAdminHandler.HandleCommand(command)); - }); + // Register handlers -- all marshal to game thread + _hubConnection.On("PushSession", session => + { + Core.LoopContext.Post(() => GatewaySessionStore.Add(session), EventLoopContext.Priority.High); + }); - _hubConnection.On("Ping", () => - { - // No-op, connection keep-alive handled by SignalR internally - }); + _hubConnection.On("AdminCommand", command => + { + Core.LoopContext.Post(() => GatewayAdminHandler.HandleCommand(command)); + }); - _hubConnection.Reconnected += connectionId => - { - logger.Information("SignalR reconnected to gateway (ConnectionId: {Id})", connectionId); - return Task.CompletedTask; - }; + _hubConnection.On("Ping", () => + { + // No-op, connection keep-alive handled by SignalR internally + }); - _hubConnection.Closed += error => - { - logger.Warning("SignalR connection to gateway closed: {Message}", error?.Message ?? "clean disconnect"); - return Task.CompletedTask; - }; + _hubConnection.Reconnected += connectionId => + { + logger.Information("SignalR reconnected to gateway (ConnectionId: {Id})", connectionId); + return Task.CompletedTask; + }; - // Connect after server starts (via EventSink.ServerStarted) - } + _hubConnection.Closed += error => + { + logger.Warning("SignalR connection to gateway closed: {Message}", error?.Message ?? "clean disconnect"); + return Task.CompletedTask; + }; + + // Connect after server starts (via EventSink.ServerStarted) } /// @@ -112,6 +135,16 @@ public static class GatewayClient _hubConnection?.InvokeAsync("Heartbeat", new { data.PlayerCount, data.MaxPlayers, data.IsOnline }) ?? Task.CompletedTask; + public static Task SyncAccountStateAsync(string username, string accessLevel, bool isBanned) + { + if (_hubConnection?.State != HubConnectionState.Connected) + { + return Task.CompletedTask; + } + + return _hubConnection.SendAsync("SyncAccountState", new { username, accessLevel, isBanned }); + } + public static async Task ValidateSessionAsync(int authId) { if (_hubConnection?.State != HubConnectionState.Connected) @@ -127,7 +160,7 @@ public static class GatewayClient return null; } - return new GameLoginResponse(result.Valid, result.Reason, result.GameAccountId, result.AccessLevel); + return new GameLoginResponse(result.Valid, result.Reason, result.GameAccountId, result.AccessLevel, result.ClientVersion); } catch (Exception ex) { @@ -159,7 +192,8 @@ public static class GatewayClient [property: JsonPropertyName("accepted")] bool Accepted, [property: JsonPropertyName("reason")] string? Reason, [property: JsonPropertyName("accountId")] Guid? AccountId, - [property: JsonPropertyName("accessLevel")] string? AccessLevel + [property: JsonPropertyName("accessLevel")] string? AccessLevel, + [property: JsonPropertyName("clientVersion")] string? ClientVersion ); public record AdminCommandData( @@ -171,6 +205,7 @@ public static class GatewayClient [property: JsonPropertyName("valid")] bool Valid, [property: JsonPropertyName("gameAccountId")] Guid? GameAccountId, [property: JsonPropertyName("accessLevel")] string? AccessLevel, - [property: JsonPropertyName("reason")] string? Reason + [property: JsonPropertyName("reason")] string? Reason, + [property: JsonPropertyName("clientVersion")] string? ClientVersion ); } diff --git a/Projects/UOContent/Systems/Gateway/GatewayConfig.cs b/Projects/UOContent/Systems/Gateway/GatewayConfig.cs index b6d2231f8..0b506fca3 100644 --- a/Projects/UOContent/Systems/Gateway/GatewayConfig.cs +++ b/Projects/UOContent/Systems/Gateway/GatewayConfig.cs @@ -18,7 +18,6 @@ public static class GatewayConfig public static string ApiKey { get; private set; } = ""; public static int HeartbeatIntervalSeconds { get; private set; } public static int MaxPlayers { get; private set; } - public static bool SignalREnabled { get; private set; } public static void Configure() { @@ -33,7 +32,6 @@ public static class GatewayConfig ApiKey = ServerConfiguration.GetOrUpdateSetting("gateway.apiKey", ""); HeartbeatIntervalSeconds = ServerConfiguration.GetOrUpdateSetting("gateway.heartbeatIntervalSeconds", 30); MaxPlayers = ServerConfiguration.GetOrUpdateSetting("gateway.maxPlayers", 200); - SignalREnabled = ServerConfiguration.GetOrUpdateSetting("gateway.signalREnabled", false); if (string.IsNullOrWhiteSpace(ApiKey)) { diff --git a/Projects/UOContent/Systems/Gateway/GatewayHeartbeat.cs b/Projects/UOContent/Systems/Gateway/GatewayHeartbeat.cs index 5c0020bcc..9db98c819 100644 --- a/Projects/UOContent/Systems/Gateway/GatewayHeartbeat.cs +++ b/Projects/UOContent/Systems/Gateway/GatewayHeartbeat.cs @@ -37,8 +37,7 @@ public static class GatewayHeartbeat // Send first heartbeat immediately SendHeartbeat(); - // Connect SignalR if enabled - if (GatewayConfig.SignalREnabled) + // Connect SignalR { _ = GatewayClient.ConnectSignalRAsync(); } diff --git a/Projects/UOContent/Systems/Gateway/GatewayLoginHandler.cs b/Projects/UOContent/Systems/Gateway/GatewayLoginHandler.cs index 3884e1a02..b8453538f 100644 --- a/Projects/UOContent/Systems/Gateway/GatewayLoginHandler.cs +++ b/Projects/UOContent/Systems/Gateway/GatewayLoginHandler.cs @@ -41,7 +41,13 @@ public static class GatewayLoginHandler // Fast path: check local pushed session store (O(1), no network) if (GatewaySessionStore.TryConsume(authId, out var session)) { - logger.Information("Login: {NetState} Account '{Username}' accepted via pushed session", state, username); + logger.Information("Login: {NetState} Account '{Username}' accepted via pushed session (fast path)", state, username); + // Set client version from the pushed session before the packet handler sends the response + if (state.Version == null && session.ClientVersion != null) + { + state.Version = new ClientVersion(session.ClientVersion); + } + state.Version ??= ClientVersion.Version70654; AcceptLogin(e, state, username, session.AccessLevel); return; } @@ -107,9 +113,9 @@ public static class GatewayLoginHandler return; } - // Gateway accepted -- use common accept logic (deferred path) - logger.Information("Login: {NetState} Account '{Username}' at character list (via Gateway)", state, username); - AcceptLoginDeferred(state, username, response.AccessLevel); + // Gateway accepted -- use deferred accept logic (sends packets directly) + logger.Information("Login: {NetState} Account '{Username}' at character list (via Gateway, deferred path)", state, username); + AcceptLoginDeferred(state, username, response.AccessLevel, response.ClientVersion); } /// @@ -133,13 +139,16 @@ public static class GatewayLoginHandler state.CityInfo = CharacterCreation.GetStartingCities(); e.Accepted = true; e.CityInfo = CharacterCreation.GetStartingCities(); + + // Sync real account state back to gateway + _ = GatewayClient.SyncAccountStateAsync(username, acct.AccessLevel.ToString(), acct.Banned); } /// /// Handles login acceptance for the deferred path (after async network validation). /// Sends packets directly since the original packet handler has already returned. /// - private static void AcceptLoginDeferred(NetState state, string username, string accessLevel) + private static void AcceptLoginDeferred(NetState state, string username, string accessLevel, string clientVersion = null) { var acct = FindOrCreateAccount(state, username, accessLevel); @@ -156,9 +165,23 @@ public static class GatewayLoginHandler state.CityInfo = CharacterCreation.GetStartingCities(); // Send the same packets that IncomingAccountPackets.GameLogin sends on e.Accepted = true + // The game server connection doesn't have the client version (0xEF goes to the login server, + // not the game server). Set it from the gateway session data, or fall back to latest known. + if (state.Version == null) + { + state.Version = clientVersion != null + ? new ClientVersion(clientVersion) + : ClientVersion.Version70654; + } + state.CompressionEnabled = true; state.SendSupportedFeature(); state.SendCharacterList(); + + // Sync the real account state back to the gateway (access level, ban status). + // This ensures the gateway DB reflects the game server's actual state, + // especially for accounts that existed before the gateway was deployed. + _ = GatewayClient.SyncAccountStateAsync(username, acct.AccessLevel.ToString(), acct.Banned); } /// diff --git a/Projects/UOContent/Systems/Gateway/GatewaySessionStore.cs b/Projects/UOContent/Systems/Gateway/GatewaySessionStore.cs index 251080cc7..29caf875d 100644 --- a/Projects/UOContent/Systems/Gateway/GatewaySessionStore.cs +++ b/Projects/UOContent/Systems/Gateway/GatewaySessionStore.cs @@ -18,7 +18,7 @@ public static class GatewaySessionStore public static void Configure() { - if (!GatewayConfig.Enabled || !GatewayConfig.SignalREnabled) + if (!GatewayConfig.Enabled) { return; } @@ -87,5 +87,6 @@ public record PushedSession( string Username, string AccessLevel, string ClientIP, + string? ClientVersion, DateTime ExpiresAt ); From f98d58beeaad4a11ee79d0721c43dbbee46ffc85 Mon Sep 17 00:00:00 2001 From: Kamron Batman <3953314+kamronbatman@users.noreply.github.com> Date: Tue, 31 Mar 2026 00:44:09 -0700 Subject: [PATCH 3/3] Removes max players --- Projects/UOContent/Systems/Gateway/GatewayClient.cs | 3 +-- Projects/UOContent/Systems/Gateway/GatewayConfig.cs | 2 -- Projects/UOContent/Systems/Gateway/GatewayHeartbeat.cs | 2 +- 3 files changed, 2 insertions(+), 5 deletions(-) diff --git a/Projects/UOContent/Systems/Gateway/GatewayClient.cs b/Projects/UOContent/Systems/Gateway/GatewayClient.cs index 21159930a..bf075d154 100644 --- a/Projects/UOContent/Systems/Gateway/GatewayClient.cs +++ b/Projects/UOContent/Systems/Gateway/GatewayClient.cs @@ -132,7 +132,7 @@ public static class GatewayClient // --- SignalR methods --- public static Task SendHeartbeatAsync(HeartbeatRequest data) => - _hubConnection?.InvokeAsync("Heartbeat", new { data.PlayerCount, data.MaxPlayers, data.IsOnline }) + _hubConnection?.InvokeAsync("Heartbeat", new { data.PlayerCount, data.IsOnline }) ?? Task.CompletedTask; public static Task SyncAccountStateAsync(string username, string accessLevel, bool isBanned) @@ -178,7 +178,6 @@ public static class GatewayClient public record HeartbeatRequest( [property: JsonPropertyName("playerCount")] int PlayerCount, - [property: JsonPropertyName("maxPlayers")] int MaxPlayers, [property: JsonPropertyName("isOnline")] bool IsOnline ); diff --git a/Projects/UOContent/Systems/Gateway/GatewayConfig.cs b/Projects/UOContent/Systems/Gateway/GatewayConfig.cs index 0b506fca3..b34c905f9 100644 --- a/Projects/UOContent/Systems/Gateway/GatewayConfig.cs +++ b/Projects/UOContent/Systems/Gateway/GatewayConfig.cs @@ -17,7 +17,6 @@ public static class GatewayConfig public static string GatewayUrl { get; private set; } = ""; public static string ApiKey { get; private set; } = ""; public static int HeartbeatIntervalSeconds { get; private set; } - public static int MaxPlayers { get; private set; } public static void Configure() { @@ -31,7 +30,6 @@ public static class GatewayConfig GatewayUrl = ServerConfiguration.GetOrUpdateSetting("gateway.url", "http://localhost:5000"); ApiKey = ServerConfiguration.GetOrUpdateSetting("gateway.apiKey", ""); HeartbeatIntervalSeconds = ServerConfiguration.GetOrUpdateSetting("gateway.heartbeatIntervalSeconds", 30); - MaxPlayers = ServerConfiguration.GetOrUpdateSetting("gateway.maxPlayers", 200); if (string.IsNullOrWhiteSpace(ApiKey)) { diff --git a/Projects/UOContent/Systems/Gateway/GatewayHeartbeat.cs b/Projects/UOContent/Systems/Gateway/GatewayHeartbeat.cs index 9db98c819..b3a7ad9b0 100644 --- a/Projects/UOContent/Systems/Gateway/GatewayHeartbeat.cs +++ b/Projects/UOContent/Systems/Gateway/GatewayHeartbeat.cs @@ -53,7 +53,7 @@ public static class GatewayHeartbeat try { var playerCount = NetState.Instances.Count; - var request = new GatewayClient.HeartbeatRequest(playerCount, GatewayConfig.MaxPlayers, true); + var request = new GatewayClient.HeartbeatRequest(playerCount, true); if (GatewayClient.IsSignalRConnected) {