Adds gateway support including SingalR

This commit is contained in:
Kamron Batman 2026-03-30 18:52:28 -07:00
parent c207c2c19f
commit fde85a5f7c
No known key found for this signature in database
GPG key ID: 7D81DF26D9A5D94A
13 changed files with 692 additions and 2 deletions

View file

@ -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;

View file

@ -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<Task> onFaulted, CancellationToken cancellationToken = default)
{
var scheduler = TaskScheduler.FromCurrentSynchronizationContext();
task.ContinueWith(onFaulted, cancellationToken, TaskContinuationOptions.OnlyOnFaulted, scheduler);
}
public static void ContinueWithOnCurrentSyncContext<T>(
this Task<T> task,
Action<Task<T>> onRanToCompletion,
Action<Task<T>> 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<T>(
this Task<T> task,
Action<Task<T>> onCompletion,
CancellationToken cancellationToken = default
)
{
var scheduler = TaskScheduler.FromCurrentSynchronizationContext();
task.ContinueWith(onCompletion, cancellationToken, TaskContinuationOptions.None, scheduler);
}
public static void ContinueWithOnGameThread<T>(
this Task<T> task,
Action<Task<T>> onCompletion,
CancellationToken cancellationToken = default
) => task.ContinueWith(onCompletion, cancellationToken, TaskContinuationOptions.None, Core.LoopContextTaskScheduler);
public static void ContinueWithOnGameThread(
this Task task,
Action<Task> onCompletion,
CancellationToken cancellationToken = default
) => task.ContinueWith(onCompletion, cancellationToken, TaskContinuationOptions.None, Core.LoopContextTaskScheduler);
}

View file

@ -1,4 +1,3 @@
using Server.Commands;
using Server.Network;
using Server.Targeting;

View file

@ -1,7 +1,6 @@
using System;
using System.Collections.Generic;
using Server.Gumps;
using Server.Items;
using Server.Mobiles;
using Server.Network;

View file

@ -21,6 +21,13 @@ public static partial class GameServer
public bool Accepted { get; set; }
/// <summary>
/// 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.
/// </summary>
public bool Deferred { get; set; }
public CityInfo[] CityInfo { get; set; }
}

View file

@ -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;

View file

@ -0,0 +1,48 @@
using Server.Logging;
using Server.Network;
namespace Server.Systems.Gateway;
/// <summary>
/// Handles admin commands pushed from the gateway web portal via SignalR.
/// All methods run on the game thread (marshaled by GatewayClient callback handlers).
/// </summary>
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;
}
}
}

View file

@ -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<PushedSession>("PushSession", session =>
{
Core.LoopContext.Post(() => GatewaySessionStore.Add(session), EventLoopContext.Priority.High);
});
_hubConnection.On<AdminCommandData>("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)
}
}
/// <summary>
/// Connects the SignalR hub. Called after server is fully loaded.
/// </summary>
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<GameLoginResponse?> ValidateSessionAsync(int authId)
{
if (_hubConnection?.State != HubConnectionState.Connected)
{
return null;
}
try
{
var result = await _hubConnection.InvokeAsync<SessionValidationResult>("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<HttpResponseMessage> PostAsJsonAsync<T>(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
);
}

View file

@ -0,0 +1,45 @@
using Server.Logging;
namespace Server.Systems.Gateway;
/// <summary>
/// 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.
/// </summary>
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);
}
}

View file

@ -0,0 +1,79 @@
using System;
using Server.Logging;
using Server.Network;
namespace Server.Systems.Gateway;
/// <summary>
/// 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.
/// </summary>
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);
}
}
}

View file

@ -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;
/// <summary>
/// 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.
/// </summary>
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<GatewayClient.GameLoginResponse> 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<GatewayClient.GameLoginResponse>();
}
/// <summary>
/// Runs on the game thread after the background network call completes.
/// </summary>
private static void OnValidationComplete(Task<GatewayClient.GameLoginResponse> 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);
}
/// <summary>
/// Handles login acceptance for the fast path (non-deferred, from pushed session store).
/// Sets event args so the packet handler sends the response.
/// </summary>
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();
}
/// <summary>
/// Handles login acceptance for the deferred path (after async network validation).
/// Sends packets directly since the original packet handler has already returned.
/// </summary>
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();
}
/// <summary>
/// Finds an existing local account or creates a new shell account.
/// Gateway owns credentials; local account is for character storage only.
/// </summary>
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>(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;
}
}

View file

@ -0,0 +1,91 @@
using System;
using System.Collections.Generic;
using Server.Logging;
namespace Server.Systems.Gateway;
/// <summary>
/// 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.
/// </summary>
public static class GatewaySessionStore
{
private static readonly ILogger logger = LogFactory.GetLogger(typeof(GatewaySessionStore));
private static readonly Dictionary<int, PushedSession> _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);
}
/// <summary>
/// Adds a pushed session. Called on the game thread via Core.LoopContext.Post.
/// </summary>
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);
}
/// <summary>
/// Tries to consume a session (one-time use). Returns true if found and not expired.
/// Called on the game thread from the login handler.
/// </summary>
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<int>();
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
);

View file

@ -36,6 +36,7 @@
<Delete Files="..\..\Distribution\Assemblies\ModernUO.CodeGeneratedEvents.Generator.dll" ContinueOnError="true" />
</Target>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.5" />
<ProjectReference Include="..\Logger\Logger.csproj" />
<ProjectReference Include="..\Server\Server.csproj" Private="false" PrivateAssets="All" IncludeAssets="None">
<IncludeInPackage>false</IncludeInPackage>