770 lines
27 KiB
C#
770 lines
27 KiB
C#
namespace GeoSus.Server;
|
|
|
|
using Microsoft.Extensions.Logging;
|
|
using System.Collections.Concurrent;
|
|
using System.Net;
|
|
using System.Net.Sockets;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
|
|
// Hlavní vstupní bod serveru
|
|
public class Program
|
|
{
|
|
static readonly ConcurrentDictionary<string, ConnectedClient> _clients = new();
|
|
static readonly DateTime _startTime = DateTime.UtcNow;
|
|
static ServerConfig _config = null!;
|
|
static ServerEncryption _encryption = null!;
|
|
static LobbyManager _lobbyManager = null!;
|
|
static StatsDb _statsDb = null!;
|
|
static Persistence _persistence = null!;
|
|
static OverpassService _overpassService = null!;
|
|
static AdminPanel _adminPanel = null!;
|
|
static ILoggerFactory _loggerFactory = null!;
|
|
static ILogger<Program> _logger = null!;
|
|
static CancellationTokenSource _cts = new();
|
|
|
|
// Public accessor for admin panel
|
|
public static DateTime StartTime => _startTime;
|
|
|
|
public static async Task Main(string[] args)
|
|
{
|
|
Console.OutputEncoding = Encoding.UTF8;
|
|
|
|
// Inicializace
|
|
_loggerFactory = LoggerFactory.Create(builder =>
|
|
{
|
|
builder.AddConsole();
|
|
builder.SetMinimumLevel(LogLevel.Information);
|
|
});
|
|
_logger = _loggerFactory.CreateLogger<Program>();
|
|
|
|
// Načtení konfigurace
|
|
_config = ServerConfig.Load("appsettings.json");
|
|
|
|
// Uložíme výchozí config pokud neexistuje
|
|
if (!File.Exists("appsettings.json"))
|
|
{
|
|
_config.Save("appsettings.json");
|
|
_logger.LogInformation("Vytvořen výchozí appsettings.json");
|
|
}
|
|
|
|
// Inicializace komponent
|
|
_encryption = new ServerEncryption(_config.RsaKeySizeBits);
|
|
_statsDb = new StatsDb(Path.Combine(_config.DataPath, "stats.db"), _loggerFactory.CreateLogger<StatsDb>());
|
|
_persistence = new Persistence(_config, _loggerFactory.CreateLogger<Persistence>());
|
|
_overpassService = new OverpassService(_config, _loggerFactory.CreateLogger<OverpassService>());
|
|
_lobbyManager = new LobbyManager(_config, _loggerFactory.CreateLogger<LobbyManager>(), _statsDb, _persistence, _overpassService);
|
|
_adminPanel = new AdminPanel(_loggerFactory.CreateLogger<AdminPanel>(), _config, _lobbyManager, _statsDb);
|
|
|
|
_logger.LogInformation("GeoSus Server spouštím...");
|
|
_logger.LogInformation("TCP port: {TcpPort}, HTTP port: {HttpPort}", _config.TcpPort, _config.HttpPort);
|
|
|
|
// Handle shutdown signals - but only on explicit request
|
|
var shutdownRequested = false;
|
|
Console.CancelKeyPress += (s, e) =>
|
|
{
|
|
if (!shutdownRequested)
|
|
{
|
|
shutdownRequested = true;
|
|
e.Cancel = true;
|
|
_logger.LogInformation("Shutdown requested (Ctrl+C)... press again to force");
|
|
}
|
|
else
|
|
{
|
|
_logger.LogInformation("Force shutdown...");
|
|
_cts.Cancel();
|
|
}
|
|
};
|
|
|
|
// Spustíme TCP listener a HTTP API paralelně
|
|
var tcpTask = RunTcpServerAsync();
|
|
var httpTask = RunHttpServerAsync();
|
|
var cleanupTask = RunCleanupLoopAsync();
|
|
var broadcastTask = RunBroadcastLoopAsync();
|
|
|
|
_logger.LogInformation("Server běží. Stiskni Ctrl+C dvakrát pro ukončení.");
|
|
|
|
// Just wait forever until cancellation - don't monitor individual tasks
|
|
try
|
|
{
|
|
await Task.Delay(Timeout.Infinite, _cts.Token);
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
_logger.LogInformation("Shutting down...");
|
|
}
|
|
|
|
_statsDb.Dispose();
|
|
_encryption.Dispose();
|
|
|
|
_logger.LogInformation("Server ukončen");
|
|
}
|
|
|
|
#region TCP Server
|
|
|
|
static async Task RunTcpServerAsync()
|
|
{
|
|
var listener = new TcpListener(IPAddress.Any, _config.TcpPort);
|
|
listener.Start();
|
|
|
|
_logger.LogInformation("TCP server naslouchá na portu {Port}", _config.TcpPort);
|
|
|
|
try
|
|
{
|
|
while (!_cts.IsCancellationRequested)
|
|
{
|
|
try
|
|
{
|
|
var tcpClient = await listener.AcceptTcpClientAsync(_cts.Token);
|
|
_ = HandleClientAsync(tcpClient);
|
|
}
|
|
catch (SocketException ex)
|
|
{
|
|
_logger.LogWarning(ex, "Socket error while accepting client");
|
|
}
|
|
}
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
_logger.LogInformation("TCP server ukončen (cancelled)");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "TCP server fatal error");
|
|
}
|
|
finally
|
|
{
|
|
listener.Stop();
|
|
}
|
|
}
|
|
|
|
static async Task HandleClientAsync(TcpClient tcpClient)
|
|
{
|
|
var endpoint = tcpClient.Client.RemoteEndPoint?.ToString() ?? "unknown";
|
|
_logger.LogDebug("Nové spojení z {Endpoint}", endpoint);
|
|
|
|
using var stream = tcpClient.GetStream();
|
|
ConnectedClient? client = null;
|
|
|
|
try
|
|
{
|
|
// Handshake - plain JSON
|
|
client = await PerformHandshakeAsync(stream, endpoint);
|
|
|
|
if (client == null)
|
|
{
|
|
_logger.LogWarning("Handshake selhal pro {Endpoint}", endpoint);
|
|
return;
|
|
}
|
|
|
|
_clients[client.ClientUuid] = client;
|
|
_logger.LogInformation("Klient {Uuid} připojen z {Endpoint}", client.ClientUuid, endpoint);
|
|
|
|
// Hlavní smyčka zpráv - šifrované
|
|
await ProcessMessagesAsync(stream, client);
|
|
}
|
|
catch (Exception ex) when (ex is not OperationCanceledException)
|
|
{
|
|
_logger.LogError(ex, "Chyba při zpracování klienta {Endpoint}", endpoint);
|
|
}
|
|
finally
|
|
{
|
|
if (client != null)
|
|
{
|
|
_clients.TryRemove(client.ClientUuid, out _);
|
|
|
|
// Odebrat z lobby pokud byl přihlášen
|
|
if (client.Lobby != null)
|
|
{
|
|
client.Lobby.UnregisterConnection(client.ClientUuid);
|
|
await client.Lobby.RemovePlayerAsync(client.ClientUuid, "Disconnected");
|
|
}
|
|
|
|
client.Session?.Dispose();
|
|
_logger.LogInformation("Klient {Uuid} odpojen", client.ClientUuid);
|
|
}
|
|
|
|
tcpClient.Close();
|
|
}
|
|
}
|
|
|
|
static async Task<ConnectedClient?> PerformHandshakeAsync(NetworkStream stream, string endpoint)
|
|
{
|
|
// 1. Čekáme na ClientHello (plain)
|
|
var helloData = await ReadMessageAsync(stream);
|
|
if (helloData == null) return null;
|
|
|
|
// Debug: log co přichází
|
|
_logger.LogInformation("Handshake from {Endpoint}: {Length} bytes, first: 0x{First:X2}, content: {Content}",
|
|
endpoint, helloData.Length, helloData[0],
|
|
System.Text.Encoding.UTF8.GetString(helloData.AsSpan(0, Math.Min(200, helloData.Length))));
|
|
|
|
var hello = MessageSerializer.Deserialize(helloData) as ClientHello;
|
|
if (hello == null)
|
|
{
|
|
_logger.LogWarning("Neplatný ClientHello z {Endpoint}", endpoint);
|
|
return null;
|
|
}
|
|
|
|
// 2. Posíláme ServerHello s RSA public key (plain)
|
|
var serverHello = new ServerHello
|
|
{
|
|
RsaPublicKeyPem = _encryption.PublicKeyPem,
|
|
ServerId = Environment.MachineName
|
|
};
|
|
await SendMessagePlainAsync(stream, serverHello);
|
|
|
|
// 3. Čekáme na KeyExchange (plain - obsahuje RSA šifrovaný session key)
|
|
var keyExData = await ReadMessageAsync(stream);
|
|
if (keyExData == null) return null;
|
|
|
|
// Debug: hex dump prvních 20 bajtů
|
|
var hexDump = BitConverter.ToString(keyExData, 0, Math.Min(20, keyExData.Length));
|
|
_logger.LogInformation("KeyExchange from {Endpoint}: {Length} bytes, hex: {Hex}",
|
|
endpoint, keyExData.Length, hexDump);
|
|
|
|
var keyEx = MessageSerializer.Deserialize(keyExData) as KeyExchange;
|
|
if (keyEx == null)
|
|
{
|
|
_logger.LogWarning("Neplatný KeyExchange z {Endpoint}", endpoint);
|
|
return null;
|
|
}
|
|
|
|
// 4. Dešifrujeme session key
|
|
byte[] sessionKey, sessionIv;
|
|
try
|
|
{
|
|
(sessionKey, sessionIv) = _encryption.DecryptSessionKey(keyEx.EncryptedSessionKey, keyEx.EncryptedIV);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning(ex, "Chyba při dešifrování session key z {Endpoint}", endpoint);
|
|
return null;
|
|
}
|
|
|
|
var session = new SessionCrypto(sessionKey, sessionIv);
|
|
|
|
// 5. Posíláme KeyExchangeAck (šifrovaně)
|
|
var ack = new KeyExchangeAck { Status = "success" };
|
|
await SendMessageEncryptedAsync(stream, ack, session);
|
|
|
|
return new ConnectedClient
|
|
{
|
|
ClientUuid = hello.ClientUuid,
|
|
DisplayName = hello.DisplayName ?? $"Player_{hello.ClientUuid[..4]}",
|
|
Endpoint = endpoint,
|
|
Session = session,
|
|
Stream = stream
|
|
};
|
|
}
|
|
|
|
static async Task ProcessMessagesAsync(NetworkStream stream, ConnectedClient client)
|
|
{
|
|
int decryptFailures = 0;
|
|
|
|
while (!_cts.IsCancellationRequested)
|
|
{
|
|
var encrypted = await ReadMessageAsync(stream);
|
|
if (encrypted == null) break;
|
|
|
|
// Dešifrovat
|
|
var decrypted = client.Session!.Decrypt(encrypted);
|
|
if (decrypted == null)
|
|
{
|
|
decryptFailures++;
|
|
_logger.LogWarning("Dešifrování selhalo pro {Uuid} ({Count}x)", client.ClientUuid, decryptFailures);
|
|
|
|
if (decryptFailures >= 3)
|
|
{
|
|
_logger.LogWarning("Příliš mnoho chyb dešifrování, odpojuji {Uuid}", client.ClientUuid);
|
|
break;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
decryptFailures = 0;
|
|
|
|
var message = MessageSerializer.Deserialize(decrypted);
|
|
if (message == null)
|
|
{
|
|
_logger.LogWarning("Neplatná zpráva od {Uuid}", client.ClientUuid);
|
|
continue;
|
|
}
|
|
|
|
await HandleMessageAsync(client, message);
|
|
}
|
|
}
|
|
|
|
static async Task HandleMessageAsync(ConnectedClient client, Message message)
|
|
{
|
|
switch (message)
|
|
{
|
|
case CreateLobby m:
|
|
await HandleCreateLobbyAsync(client, m);
|
|
break;
|
|
|
|
case JoinLobby m:
|
|
await HandleJoinLobbyAsync(client, m);
|
|
break;
|
|
|
|
case Reconnect m:
|
|
await HandleReconnectAsync(client, m);
|
|
break;
|
|
|
|
default:
|
|
// Předat do lobby pokud je přihlášen
|
|
if (client.Lobby != null)
|
|
{
|
|
await client.Lobby.HandleMessageAsync(client.ClientUuid, message);
|
|
}
|
|
else
|
|
{
|
|
var error = new ErrorMessage
|
|
{
|
|
ErrorCode = "NOT_IN_LOBBY",
|
|
ErrorText = "Nejsi v žádném lobby"
|
|
};
|
|
await SendToClientAsync(client, error);
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
static async Task HandleCreateLobbyAsync(ConnectedClient client, CreateLobby message)
|
|
{
|
|
var (lobby, joinCode, error) = await _lobbyManager.CreateLobbyAsync(
|
|
client.ClientUuid, client.DisplayName, message);
|
|
|
|
var response = new CreateLobbyResponse
|
|
{
|
|
ClientSeq = message.ClientSeq,
|
|
Success = lobby != null,
|
|
JoinCode = joinCode,
|
|
LobbyId = lobby?.Settings.LobbyId,
|
|
Error = error,
|
|
LobbyState = lobby?.GetLobbyState()
|
|
};
|
|
|
|
if (lobby != null)
|
|
{
|
|
client.Lobby = lobby;
|
|
lobby.RegisterConnection(client.ClientUuid, new ClientConnection
|
|
{
|
|
ClientUuid = client.ClientUuid,
|
|
SendAsync = msg => SendToClientAsync(client, msg)
|
|
});
|
|
}
|
|
|
|
await SendToClientAsync(client, response);
|
|
}
|
|
|
|
static async Task HandleJoinLobbyAsync(ConnectedClient client, JoinLobby message)
|
|
{
|
|
var clientIp = client.Endpoint.Split(':')[0];
|
|
var (lobby, error) = await _lobbyManager.JoinLobbyAsync(
|
|
clientIp, client.ClientUuid, client.DisplayName, message.JoinCode, message.Password);
|
|
|
|
var response = new JoinLobbyResponse
|
|
{
|
|
ClientSeq = message.ClientSeq,
|
|
Success = lobby != null,
|
|
LobbyId = lobby?.Settings.LobbyId,
|
|
Error = error,
|
|
LobbyState = lobby?.GetLobbyState()
|
|
};
|
|
|
|
if (lobby != null)
|
|
{
|
|
client.Lobby = lobby;
|
|
lobby.RegisterConnection(client.ClientUuid, new ClientConnection
|
|
{
|
|
ClientUuid = client.ClientUuid,
|
|
SendAsync = msg => SendToClientAsync(client, msg)
|
|
});
|
|
}
|
|
|
|
await SendToClientAsync(client, response);
|
|
}
|
|
|
|
static async Task HandleReconnectAsync(ConnectedClient client, Reconnect message)
|
|
{
|
|
var lobby = _lobbyManager.GetLobby(message.LobbyId);
|
|
|
|
if (lobby == null)
|
|
{
|
|
await SendToClientAsync(client, new ReconnectResponse
|
|
{
|
|
Success = false,
|
|
Error = "Lobby neexistuje"
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Načteme snapshot a eventy
|
|
var snapshot = _persistence.LoadLatestSnapshot(message.LobbyId);
|
|
var events = _persistence.ReadWal(message.LobbyId, message.LastEventId);
|
|
|
|
client.Lobby = lobby;
|
|
lobby.RegisterConnection(client.ClientUuid, new ClientConnection
|
|
{
|
|
ClientUuid = client.ClientUuid,
|
|
SendAsync = msg => SendToClientAsync(client, msg)
|
|
});
|
|
|
|
await lobby.AddPlayerAsync(client.ClientUuid, client.DisplayName, isOwner: false);
|
|
|
|
await SendToClientAsync(client, new ReconnectResponse
|
|
{
|
|
Success = true,
|
|
Snapshot = snapshot,
|
|
MissedEvents = events
|
|
});
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region HTTP Stats API
|
|
|
|
static async Task RunHttpServerAsync()
|
|
{
|
|
HttpListener? listener = null;
|
|
bool isDocker = Environment.GetEnvironmentVariable("DOTNET_RUNNING_IN_CONTAINER") == "true"
|
|
|| Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT") == "Production";
|
|
|
|
// V Dockeru/produkci bindujeme na 0.0.0.0 (všechny interfaces)
|
|
// Lokálně bindujeme na localhost (nevyžaduje admin práva na Windows)
|
|
var prefixes = isDocker
|
|
? new[] { $"http://+:{_config.HttpPort}/", $"http://*:{_config.HttpPort}/" }
|
|
: new[] { $"http://localhost:{_config.HttpPort}/", $"http://127.0.0.1:{_config.HttpPort}/" };
|
|
|
|
foreach (var prefixSet in new[] { prefixes })
|
|
{
|
|
try
|
|
{
|
|
listener = new HttpListener();
|
|
foreach (var prefix in prefixSet)
|
|
listener.Prefixes.Add(prefix);
|
|
listener.Start();
|
|
_logger.LogInformation("HTTP Stats API naslouchá na portu {Port} (Docker: {IsDocker})", _config.HttpPort, isDocker);
|
|
break;
|
|
}
|
|
catch (HttpListenerException ex)
|
|
{
|
|
_logger.LogWarning("Nelze spustit HTTP Stats API: {Error}", ex.Message);
|
|
listener?.Close();
|
|
listener = null;
|
|
}
|
|
}
|
|
|
|
if (listener == null)
|
|
{
|
|
_logger.LogWarning("HTTP Stats API není dostupné - nelze otevřít port {Port}. Server běží bez Stats API.", _config.HttpPort);
|
|
// Just wait for cancellation instead of crashing
|
|
try { await Task.Delay(Timeout.Infinite, _cts.Token); } catch (OperationCanceledException) { }
|
|
return;
|
|
}
|
|
|
|
try
|
|
{
|
|
while (!_cts.IsCancellationRequested)
|
|
{
|
|
var context = await listener.GetContextAsync();
|
|
_ = HandleHttpRequestAsync(context);
|
|
}
|
|
}
|
|
catch (HttpListenerException) when (_cts.IsCancellationRequested)
|
|
{
|
|
// Normal shutdown
|
|
}
|
|
catch (ObjectDisposedException) when (_cts.IsCancellationRequested)
|
|
{
|
|
// Normal shutdown
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "HTTP listener error");
|
|
}
|
|
finally
|
|
{
|
|
listener?.Stop();
|
|
}
|
|
}
|
|
|
|
static async Task HandleHttpRequestAsync(HttpListenerContext context)
|
|
{
|
|
var request = context.Request;
|
|
var response = context.Response;
|
|
|
|
// CORS headers
|
|
response.Headers.Add("Access-Control-Allow-Origin", "*");
|
|
response.Headers.Add("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
|
response.Headers.Add("Access-Control-Allow-Headers", "Content-Type, Authorization");
|
|
|
|
if (request.HttpMethod == "OPTIONS")
|
|
{
|
|
response.StatusCode = 200;
|
|
response.Close();
|
|
return;
|
|
}
|
|
|
|
try
|
|
{
|
|
var path = request.Url?.AbsolutePath ?? "/";
|
|
|
|
// Admin panel routes
|
|
if (path.StartsWith("/admin"))
|
|
{
|
|
await _adminPanel.HandleRequestAsync(context);
|
|
return;
|
|
}
|
|
|
|
if (path.StartsWith("/stats/leaderboard"))
|
|
{
|
|
await HandleLeaderboardAsync(request, response);
|
|
}
|
|
else if (path.StartsWith("/stats/"))
|
|
{
|
|
var clientUuid = path[7..]; // Remove "/stats/"
|
|
await HandlePlayerStatsAsync(clientUuid, response);
|
|
}
|
|
else if (path == "/health")
|
|
{
|
|
var uptime = DateTime.UtcNow - _startTime;
|
|
await WriteJsonResponseAsync(response, new
|
|
{
|
|
status = "ok",
|
|
version = "1.0.0",
|
|
uptimeSeconds = (long)uptime.TotalSeconds,
|
|
activeLobbies = _lobbyManager.LobbyCount,
|
|
connectedPlayers = _lobbyManager.TotalPlayerCount
|
|
});
|
|
}
|
|
else
|
|
{
|
|
response.StatusCode = 404;
|
|
await WriteJsonResponseAsync(response, new { error = "Not found" });
|
|
}
|
|
|
|
response.Close();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "HTTP error");
|
|
try
|
|
{
|
|
response.StatusCode = 500;
|
|
await WriteJsonResponseAsync(response, new { error = "Internal server error" });
|
|
response.Close();
|
|
}
|
|
catch { /* Already closed */ }
|
|
}
|
|
}
|
|
|
|
static async Task HandlePlayerStatsAsync(string clientUuid, HttpListenerResponse response)
|
|
{
|
|
var stats = _statsDb.GetPlayerStats(clientUuid);
|
|
|
|
if (stats == null)
|
|
{
|
|
response.StatusCode = 404;
|
|
await WriteJsonResponseAsync(response, new { error = "Player not found" });
|
|
return;
|
|
}
|
|
|
|
var result = new
|
|
{
|
|
clientUuid = stats.ClientUuid,
|
|
displayName = stats.DisplayName,
|
|
totalGames = stats.TotalGames,
|
|
gamesAsCrew = stats.GamesAsCrew,
|
|
gamesAsImpostor = stats.GamesAsImpostor,
|
|
crewWins = stats.CrewWins,
|
|
impostorWins = stats.ImpostorWins,
|
|
crewWinRate = Math.Round(stats.CrewWinRate, 2),
|
|
impostorWinRate = Math.Round(stats.ImpostorWinRate, 2),
|
|
totalKills = stats.TotalKills,
|
|
totalDeaths = stats.TotalDeaths,
|
|
killDeathRatio = Math.Round(stats.KillDeathRatio, 2),
|
|
tasksCompleted = stats.TasksCompleted,
|
|
averageTasksPerGame = Math.Round(stats.AverageTasksPerGame, 2),
|
|
bodiesReported = stats.BodiesReported,
|
|
emergencyMeetingsCalled = stats.EmergencyMeetingsCalled,
|
|
timesVotedOut = stats.TimesVotedOut,
|
|
successfulVotes = stats.SuccessfulVotes,
|
|
totalPlaytimeSeconds = stats.TotalPlaytimeSeconds,
|
|
cheatIncidents = stats.CheatIncidents,
|
|
lastSeen = stats.LastSeenUtc
|
|
};
|
|
|
|
await WriteJsonResponseAsync(response, result);
|
|
}
|
|
|
|
static async Task HandleLeaderboardAsync(HttpListenerRequest request, HttpListenerResponse response)
|
|
{
|
|
var sortBy = request.QueryString["sortBy"] ?? "total_games";
|
|
var limitStr = request.QueryString["limit"] ?? "100";
|
|
var limit = int.TryParse(limitStr, out var l) ? l : 100;
|
|
limit = Math.Min(limit, 1000);
|
|
|
|
var leaderboard = _statsDb.GetLeaderboard(sortBy, limit);
|
|
|
|
var result = leaderboard.Select(s => new
|
|
{
|
|
clientUuid = s.ClientUuid,
|
|
displayName = s.DisplayName,
|
|
totalGames = s.TotalGames,
|
|
crewWins = s.CrewWins,
|
|
impostorWins = s.ImpostorWins,
|
|
totalKills = s.TotalKills,
|
|
tasksCompleted = s.TasksCompleted
|
|
});
|
|
|
|
await WriteJsonResponseAsync(response, result);
|
|
}
|
|
|
|
static async Task WriteJsonResponseAsync(HttpListenerResponse response, object data)
|
|
{
|
|
response.ContentType = "application/json";
|
|
var json = JsonSerializer.Serialize(data, JsonOptions.Default);
|
|
var bytes = Encoding.UTF8.GetBytes(json);
|
|
response.ContentLength64 = bytes.Length;
|
|
await response.OutputStream.WriteAsync(bytes);
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Background tasks
|
|
|
|
static async Task RunCleanupLoopAsync()
|
|
{
|
|
try
|
|
{
|
|
using var timer = new PeriodicTimer(TimeSpan.FromMinutes(5));
|
|
|
|
while (await timer.WaitForNextTickAsync(_cts.Token))
|
|
{
|
|
try
|
|
{
|
|
await _lobbyManager.CleanupIdleLobbiesAsync();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Chyba při cleanup");
|
|
}
|
|
}
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
// Normal shutdown
|
|
}
|
|
}
|
|
|
|
static async Task RunBroadcastLoopAsync()
|
|
{
|
|
try
|
|
{
|
|
using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(_config.PositionBroadcastRateMs));
|
|
|
|
while (await timer.WaitForNextTickAsync(_cts.Token))
|
|
{
|
|
foreach (var client in _clients.Values)
|
|
{
|
|
client.Lobby?.BroadcastPositions();
|
|
}
|
|
}
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
// Normal shutdown
|
|
}
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Network helpers
|
|
|
|
static async Task<byte[]?> ReadMessageAsync(NetworkStream stream)
|
|
{
|
|
// 4 bytes length prefix (big-endian)
|
|
var lengthBuffer = new byte[4];
|
|
var read = await stream.ReadAsync(lengthBuffer, 0, 4);
|
|
if (read < 4) return null;
|
|
|
|
if (BitConverter.IsLittleEndian)
|
|
Array.Reverse(lengthBuffer);
|
|
var length = BitConverter.ToInt32(lengthBuffer, 0);
|
|
|
|
if (length <= 0 || length > _config.MaxPacketSizeBytes)
|
|
return null;
|
|
|
|
var buffer = new byte[length];
|
|
var totalRead = 0;
|
|
while (totalRead < length)
|
|
{
|
|
read = await stream.ReadAsync(buffer, totalRead, length - totalRead);
|
|
if (read == 0) return null;
|
|
totalRead += read;
|
|
}
|
|
|
|
return buffer;
|
|
}
|
|
|
|
static async Task SendMessagePlainAsync(NetworkStream stream, Message message)
|
|
{
|
|
var data = MessageSerializer.Serialize(message);
|
|
await SendDataAsync(stream, data);
|
|
}
|
|
|
|
static async Task SendMessageEncryptedAsync(NetworkStream stream, Message message, SessionCrypto session)
|
|
{
|
|
var plain = MessageSerializer.Serialize(message);
|
|
var encrypted = session.Encrypt(plain);
|
|
await SendDataAsync(stream, encrypted);
|
|
}
|
|
|
|
static async Task SendDataAsync(NetworkStream stream, byte[] data)
|
|
{
|
|
var lengthBuffer = BitConverter.GetBytes(data.Length);
|
|
if (BitConverter.IsLittleEndian)
|
|
Array.Reverse(lengthBuffer);
|
|
|
|
await stream.WriteAsync(lengthBuffer);
|
|
await stream.WriteAsync(data);
|
|
await stream.FlushAsync();
|
|
}
|
|
|
|
static async Task SendToClientAsync(ConnectedClient client, Message message)
|
|
{
|
|
if (client.Session == null || client.Stream == null) return;
|
|
|
|
var plain = MessageSerializer.Serialize(message);
|
|
var encrypted = client.Session.Encrypt(plain);
|
|
|
|
lock (client.SendLock)
|
|
{
|
|
try
|
|
{
|
|
SendDataAsync(client.Stream, encrypted).Wait();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogWarning(ex, "Chyba při odesílání do {Uuid}", client.ClientUuid);
|
|
}
|
|
}
|
|
}
|
|
|
|
#endregion
|
|
}
|
|
|
|
// Connected client state
|
|
class ConnectedClient
|
|
{
|
|
public required string ClientUuid { get; set; }
|
|
public required string DisplayName { get; set; }
|
|
public required string Endpoint { get; set; }
|
|
public SessionCrypto? Session { get; set; }
|
|
public NetworkStream? Stream { get; set; }
|
|
public LobbyActor? Lobby { get; set; }
|
|
public object SendLock { get; } = new();
|
|
}
|