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 _itemCount;
|
||||||
private static int _mobileCount;
|
private static int _mobileCount;
|
||||||
public static EventLoopContext LoopContext { get; set; }
|
public static EventLoopContext LoopContext { get; set; }
|
||||||
|
public static TaskScheduler LoopContextTaskScheduler { get; set; }
|
||||||
|
|
||||||
private static readonly Type[] _serialTypeArray = { typeof(Serial) };
|
private static readonly Type[] _serialTypeArray = { typeof(Serial) };
|
||||||
|
|
||||||
|
|
@ -372,6 +373,7 @@ public static class Core
|
||||||
Thread = Thread.CurrentThread;
|
Thread = Thread.CurrentThread;
|
||||||
LoopContext = new EventLoopContext();
|
LoopContext = new EventLoopContext();
|
||||||
SynchronizationContext.SetSynchronizationContext(LoopContext);
|
SynchronizationContext.SetSynchronizationContext(LoopContext);
|
||||||
|
LoopContextTaskScheduler = TaskScheduler.FromCurrentSynchronizationContext();
|
||||||
|
|
||||||
AppDomain.CurrentDomain.UnhandledException += CurrentDomain_UnhandledException;
|
AppDomain.CurrentDomain.UnhandledException += CurrentDomain_UnhandledException;
|
||||||
AppDomain.CurrentDomain.ProcessExit += CurrentDomain_ProcessExit;
|
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))]
|
[OnEvent(nameof(GameServer.GameServerLoginEvent))]
|
||||||
public static void OnGameServerLogin(GameServer.GameLoginEventArgs e)
|
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 un = e.Username;
|
||||||
var pw = e.Password;
|
var pw = e.Password;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
using Server.Commands;
|
|
||||||
using Server.Network;
|
using Server.Network;
|
||||||
using Server.Targeting;
|
using Server.Targeting;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using Server.Gumps;
|
using Server.Gumps;
|
||||||
using Server.Items;
|
|
||||||
using Server.Mobiles;
|
using Server.Mobiles;
|
||||||
using Server.Network;
|
using Server.Network;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,13 @@ public static partial class GameServer
|
||||||
|
|
||||||
public bool Accepted { get; set; }
|
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; }
|
public CityInfo[] CityInfo { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -360,22 +360,33 @@ public static class IncomingAccountPackets
|
||||||
|
|
||||||
var authId = reader.ReadInt32();
|
var authId = reader.ReadInt32();
|
||||||
|
|
||||||
if (!_authIDWindow.TryGetValue(authId, out var ap))
|
if (Systems.Gateway.GatewayConfig.Enabled)
|
||||||
{
|
{
|
||||||
state.LogInfo("Invalid client detected, disconnecting...");
|
// Gateway mode: AuthId was generated by the gateway, not by this server.
|
||||||
state.Disconnect("Unable to find auth id.");
|
// Skip the local _authIDWindow and seed checks -- the gateway handler validates the AuthId.
|
||||||
|
state.AuthId = authId;
|
||||||
|
state.Seeded = true;
|
||||||
}
|
}
|
||||||
|
else
|
||||||
if (state.AuthId != 0 && authId != state.AuthId || state.AuthId == 0 && authId != state.Seed)
|
|
||||||
{
|
{
|
||||||
state.LogInfo("Invalid client detected, disconnecting...");
|
if (!_authIDWindow.TryGetValue(authId, out var ap))
|
||||||
state.Disconnect("Invalid auth id in game login packet.");
|
{
|
||||||
return;
|
state.LogInfo("Invalid client detected, disconnecting...");
|
||||||
}
|
state.Disconnect("Unable to find auth id.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
_authIDWindow.Remove(authId);
|
if (state.AuthId != 0 && authId != state.AuthId || state.AuthId == 0 && authId != state.Seed)
|
||||||
state.Version = ap.Version;
|
{
|
||||||
state.Seeded = true;
|
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 username = reader.ReadLatin1Safe(30);
|
||||||
var password = reader.ReadLatin1Safe(30);
|
var password = reader.ReadLatin1Safe(30);
|
||||||
|
|
@ -384,6 +395,13 @@ public static class IncomingAccountPackets
|
||||||
|
|
||||||
GameServer.GameServerLoginEvent(e);
|
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)
|
if (e.Accepted)
|
||||||
{
|
{
|
||||||
state.CityInfo = e.CityInfo;
|
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" />
|
<Delete Files="..\..\Distribution\Assemblies\ModernUO.CodeGeneratedEvents.Generator.dll" ContinueOnError="true" />
|
||||||
</Target>
|
</Target>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="10.0.5" />
|
||||||
<ProjectReference Include="..\Logger\Logger.csproj" />
|
<ProjectReference Include="..\Logger\Logger.csproj" />
|
||||||
<ProjectReference Include="..\Server\Server.csproj" Private="false" PrivateAssets="All" IncludeAssets="None">
|
<ProjectReference Include="..\Server\Server.csproj" Private="false" PrivateAssets="All" IncludeAssets="None">
|
||||||
<IncludeInPackage>false</IncludeInPackage>
|
<IncludeInPackage>false</IncludeInPackage>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue