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("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.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) { 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, result.ClientVersion); } 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("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 ); }