Files
Server/Program.cs
2026-04-26 12:44:06 +02:00

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();
}