Compare commits
3 commits
main
...
kbatman/ga
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f98d58beea | ||
|
|
e2dce0ded6 | ||
|
|
fde85a5f7c |
14 changed files with 774 additions and 14 deletions
|
|
@ -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;
|
||||
|
|
|
|||
48
Projects/Server/Tasks/TaskExtensions.cs
Normal file
48
Projects/Server/Tasks/TaskExtensions.cs
Normal 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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
using Server.Commands;
|
||||
using Server.Network;
|
||||
using Server.Targeting;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Server.Gumps;
|
||||
using Server.Items;
|
||||
using Server.Mobiles;
|
||||
using Server.Network;
|
||||
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -360,10 +360,20 @@ public static class IncomingAccountPackets
|
|||
|
||||
var authId = reader.ReadInt32();
|
||||
|
||||
if (Systems.Gateway.GatewayConfig.Enabled)
|
||||
{
|
||||
// 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;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!_authIDWindow.TryGetValue(authId, out var ap))
|
||||
{
|
||||
state.LogInfo("Invalid client detected, disconnecting...");
|
||||
state.Disconnect("Unable to find auth id.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.AuthId != 0 && authId != state.AuthId || state.AuthId == 0 && authId != state.Seed)
|
||||
|
|
@ -376,6 +386,7 @@ public static class IncomingAccountPackets
|
|||
_authIDWindow.Remove(authId);
|
||||
state.Version = ap.Version;
|
||||
state.Seeded = true;
|
||||
}
|
||||
|
||||
var username = reader.ReadLatin1Safe(30);
|
||||
var password = reader.ReadLatin1Safe(30);
|
||||
|
|
@ -384,6 +395,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;
|
||||
|
|
|
|||
48
Projects/UOContent/Systems/Gateway/GatewayAdminHandler.cs
Normal file
48
Projects/UOContent/Systems/Gateway/GatewayAdminHandler.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
210
Projects/UOContent/Systems/Gateway/GatewayClient.cs
Normal file
210
Projects/UOContent/Systems/Gateway/GatewayClient.cs
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
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 Initialize()
|
||||
{
|
||||
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}");
|
||||
|
||||
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);
|
||||
|
||||
// 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 =>
|
||||
{
|
||||
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[]
|
||||
{
|
||||
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.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<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, result.ClientVersion);
|
||||
}
|
||||
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("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,
|
||||
[property: JsonPropertyName("clientVersion")] string? ClientVersion
|
||||
);
|
||||
|
||||
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,
|
||||
[property: JsonPropertyName("clientVersion")] string? ClientVersion
|
||||
);
|
||||
}
|
||||
41
Projects/UOContent/Systems/Gateway/GatewayConfig.cs
Normal file
41
Projects/UOContent/Systems/Gateway/GatewayConfig.cs
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
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 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);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
78
Projects/UOContent/Systems/Gateway/GatewayHeartbeat.cs
Normal file
78
Projects/UOContent/Systems/Gateway/GatewayHeartbeat.cs
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
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
|
||||
{
|
||||
_ = GatewayClient.ConnectSignalRAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private static async void SendHeartbeat()
|
||||
{
|
||||
if (!GatewayClient.IsReady)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var playerCount = NetState.Instances.Count;
|
||||
var request = new GatewayClient.HeartbeatRequest(playerCount, 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
211
Projects/UOContent/Systems/Gateway/GatewayLoginHandler.cs
Normal file
211
Projects/UOContent/Systems/Gateway/GatewayLoginHandler.cs
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
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 (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;
|
||||
}
|
||||
|
||||
// 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 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);
|
||||
}
|
||||
|
||||
/// <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();
|
||||
|
||||
// Sync real account state back to gateway
|
||||
_ = GatewayClient.SyncAccountStateAsync(username, acct.AccessLevel.ToString(), acct.Banned);
|
||||
}
|
||||
|
||||
/// <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, string clientVersion = null)
|
||||
{
|
||||
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
|
||||
// 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);
|
||||
}
|
||||
|
||||
/// <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;
|
||||
}
|
||||
}
|
||||
92
Projects/UOContent/Systems/Gateway/GatewaySessionStore.cs
Normal file
92
Projects/UOContent/Systems/Gateway/GatewaySessionStore.cs
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
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)
|
||||
{
|
||||
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,
|
||||
string? ClientVersion,
|
||||
DateTime ExpiresAt
|
||||
);
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue