using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics; using System.Linq; using System.Text.RegularExpressions; using System.Threading.Tasks; using GameCult.Caching; using GameCult.Logging; using Isopoh.Cryptography.Argon2; using LiteNetLib; using MessagePack; using R3; namespace GameCult.Networking { /// /// Hosts the server-side authentication, session, and message dispatch pipeline. /// public class Server : IDisposable { private const string EmailPattern = @"^([0-9a-zA-Z]([\+\-_\.][0-9a-zA-Z]+)*)+@(([0-9a-zA-Z][-\w]*[0-9a-zA-Z]*\.)+[a-zA-Z0-9]{2,17})$"; private const string UsernamePattern = @"^[A-Za-z0-9]+(?:[ _-][A-Za-z0-9]+)*$"; private const int ServerPort = 3075; private const float SessionTimeoutSeconds = 1800; // 30 minutes private const float SessionRefreshThresholdSeconds = 300; // 5 minutes private const int MaxConnectionAttemptsPerMinute = 30; private const int MaxLoginAttemptsPerMinute = 5; private readonly ConcurrentDictionary _messageDelegates = new(); private readonly ConcurrentDictionary _users = new(); private readonly ConcurrentDictionary> _connectionAttempts = new(); private readonly ConcurrentDictionary _connectionAttemptLocks = new(); private readonly ConcurrentDictionary> _authAttempts = new(); private readonly ConcurrentDictionary _authAttemptLocks = new(); private readonly IDisposable _cleanupSubscription; private readonly CultCache _database; private readonly ServerSecurityOptions _security; private NetManager? _netManager; private Stopwatch? _timer; private ILogger _logger = new NullLogger(); private bool _disposed; /// /// Gets or sets the logger used by the server. /// public ILogger Logger { get => _logger; set => _logger = value ?? new NullLogger(); } /// /// Gets or sets whether raw payload bodies may be logged for diagnostics. /// public bool LogSensitivePayloads { get; set; } private float Time => (float)(_timer?.Elapsed.TotalSeconds ?? 0d); /// /// Initializes a new server instance over the supplied cache. /// /// The backing cache used for player persistence. /// Optional validated server security options. When omitted, strict environment-based configuration is used. public Server(CultCache cache, ServerSecurityOptions? security = null) { _database = cache; _security = security ?? ServerSecurityOptions.FromEnvironment(); LogSensitivePayloads = _security.IsDevelopment; _cleanupSubscription = Observable.Timer(TimeSpan.FromSeconds(60), TimeSpan.FromSeconds(60)) .Subscribe(_ => CleanupExpiredSessions()); } /// /// Validates an email address against the server's accepted pattern. /// /// The email address to validate. /// true when the address matches the expected format. public bool IsValidEmail(string email) => Regex.IsMatch(email, EmailPattern); /// /// Validates a username against the server's accepted pattern. /// /// The username to validate. /// true when the username matches the expected format. public bool IsValidUsername(string name) => Regex.IsMatch(name, UsernamePattern); /// /// Removes all registered message listeners. /// public void ClearMessageListeners() { _messageDelegates.Clear(); } /// /// Adds a listener for a specific authenticated message type. /// /// The message type to subscribe to. /// The callback to invoke when the message is received. public void AddMessageListener(Action callback) where T : Message { var type = typeof(T); _messageDelegates.AddOrUpdate(type, _ => callback, (t, current) => { var combined = Delegate.Combine(current, callback) as Action; return combined ?? throw new InvalidOperationException($"Failed to combine delegates for {t.Name}"); }); } /// /// Adds a listener for a specific authenticated message type. /// public void On(Action callback) where T : Message { AddMessageListener(callback); } /// /// Removes a previously registered listener for a specific message type. /// /// The message type to unsubscribe from. /// The callback to remove. public void RemoveMessageListener(Action callback) where T : Message { if (_messageDelegates.TryGetValue(typeof(T), out var currentDelegate)) { var newDelegate = Delegate.Remove(currentDelegate, callback) as Action; _messageDelegates[typeof(T)] = newDelegate!; } } /// /// Removes a previously registered listener for a specific authenticated message type. /// public void Off(Action callback) where T : Message { RemoveMessageListener(callback); } /// /// Stops the underlying LiteNetLib server. /// public void Stop() { _netManager?.Stop(); _netManager = null; } /// /// Starts listening for connections and configures message handlers. /// public void Start() { if (_disposed) { throw new ObjectDisposedException(nameof(Server)); } Stop(); _timer = Stopwatch.StartNew(); var listener = new EventBasedNetListener(); _netManager = new NetManager(listener) { UnsyncedEvents = true, NatPunchEnabled = true }; _netManager.Start(ServerPort); listener.NetworkErrorEvent += (point, code) => Logger.LogInfo($"{point.Address}: Error {code}"); listener.ConnectionRequestEvent += request => { if (CheckConnectionRateLimit(request.RemoteEndPoint.Address.ToString())) { request.AcceptIfKey(_security.ConnectionKey); } else { request.Reject(); } }; listener.PeerConnectedEvent += peer => { Logger.LogInfo($"User Connected: {peer.Address}"); _users.TryAdd(peer.Id, new User { Peer = peer }); }; listener.PeerDisconnectedEvent += (peer, info) => { Logger.LogInfo($"User Disconnected: {peer.Address}"); _users.TryRemove(peer.Id, out _); }; listener.NetworkLatencyUpdateEvent += (peer, latency) => { if (_users.TryGetValue(peer.Id, out var user)) { user.Latency = latency; } }; listener.NetworkReceiveEvent += async (peer, reader, channel, method) => { try { var bytes = reader.GetRemainingBytes(); var message = MessageSerialization.Deserialize(bytes); if (LogSensitivePayloads) { Logger.LogDebug($"Received message: {MessagePackSerializer.ConvertToJson(new ReadOnlyMemory(bytes))}"); } else { Logger.LogDebug($"Received message {message?.GetType().Name ?? "unknown"}"); } if (message == null) { return; } message.Peer = peer; var user = _users.GetOrAdd(peer.Id, _ => new User { Peer = peer }); if (message is LoginMessage or RegisterMessage or VerifyMessage) { if (IsVerified(user)) { SendSessionToken(peer, user.PlayerId); return; } if (message is LoginMessage or RegisterMessage && !CheckAuthRateLimit(peer.Address.ToString())) { peer.Send(new ErrorMessage { Error = "Too Many Attempts" }); return; } switch (message) { case RegisterMessage register: await HandleRegisterAsync(peer, user, register); break; case VerifyMessage verify: HandleVerify(peer, user, verify); break; case LoginMessage login: HandleLogin(peer, user, login); break; } } else if (IsVerified(user)) { if (_messageDelegates.TryGetValue(message.GetType(), out var del) && del != null) { del.DynamicInvoke(message); } else { Logger.LogWarning($"No listener for {message.GetType().Name}"); } user.SessionExpiresAt = DateTimeOffset.UtcNow.AddSeconds(SessionTimeoutSeconds); RefreshSessionIfNeeded(peer, user); } else { peer.Send(new ErrorMessage { Error = "User Not Verified" }); } } catch (Exception ex) { Logger.LogError($"Message processing failed: {ex.Message}"); } }; AddMessageListener(async message => { if (!IsValidUsername(message.Name)) { message.Peer?.Send(new ErrorMessage { Error = "Username Invalid" }); return; } if (_database.GetByName(message.Name) != null) { message.Peer?.Send(new ErrorMessage { Error = "Username Taken" }); return; } if (message.Peer != null && _users.TryGetValue(message.Peer.Id, out var user)) { var data = SessionData(user); if (data == null) { message.Peer.Send(new ErrorMessage { Error = "User Not Verified" }); return; } data.Username = message.Name; await _database.AddAsync(data); } }); Logger.LogInfo($"Server started on port {ServerPort}."); } /// public void Dispose() { if (_disposed) { return; } _disposed = true; _cleanupSubscription.Dispose(); Stop(); } private async Task HandleRegisterAsync(NetPeer peer, User user, RegisterMessage register) { var name = Secret.DecryptString(register.Name, register.Nonce, _security); var email = Secret.DecryptString(register.Email, register.Nonce, _security); var password = Secret.DecryptString(register.Password, register.Nonce, _security); if (string.IsNullOrWhiteSpace(name) || !IsValidUsername(name)) { peer.Send(new ErrorMessage { Error = "Username Invalid" }); return; } if (string.IsNullOrWhiteSpace(email) || !IsValidEmail(email)) { peer.Send(new ErrorMessage { Error = "Email Invalid" }); return; } if (_database.GetByIndex("Email", email) != null) { peer.Send(new ErrorMessage { Error = "Email Taken" }); return; } if (_database.GetByName(name) != null) { peer.Send(new ErrorMessage { Error = "Username Taken" }); return; } if (string.IsNullOrWhiteSpace(password)) { peer.Send(new ErrorMessage { Error = "Password Invalid" }); return; } var newUserData = new PlayerData { PlayerId = Guid.NewGuid(), Email = email, PasswordHash = Argon2.Hash(password, memoryCost: 16384), Username = name }; await _database.AddAsync(newUserData); AttachUser(user, newUserData.PlayerId); SendSessionToken(peer, newUserData.PlayerId); } private void HandleVerify(NetPeer peer, User user, VerifyMessage verify) { var token = Secret.DecryptString(verify.Session, verify.Nonce, _security); if (!Secret.TryValidateSessionToken(token, _security, out var playerId, out _, out var sessionVersion)) { peer.Send(new ErrorMessage { Error = "Session Invalid" }); return; } var player = _database.GetByIndex("PlayerId", playerId.ToString("D")); if (player == null) { peer.Send(new ErrorMessage { Error = "Session Not Found" }); return; } if (player.SessionVersion != sessionVersion) { peer.Send(new ErrorMessage { Error = "Session Superseded" }); return; } AttachUser(user, playerId); SendSessionToken(peer, player); } private void HandleLogin(NetPeer peer, User user, LoginMessage login) { var auth = Secret.DecryptString(login.Auth, login.Nonce, _security); var password = Secret.DecryptString(login.Password, login.Nonce, _security); if (string.IsNullOrWhiteSpace(auth) || string.IsNullOrWhiteSpace(password)) { peer.Send(new ErrorMessage { Error = "Credentials Invalid" }); return; } var isEmail = IsValidEmail(auth); var userData = isEmail ? _database.GetByIndex("Email", auth) : _database.GetByName(auth); if (userData == null) { peer.Send(new ErrorMessage { Error = isEmail ? "Email Not Found" : "Username Not Found" }); return; } if (!Argon2.Verify(userData.PasswordHash, password)) { peer.Send(new ErrorMessage { Error = "Password Incorrect" }); return; } AttachUser(user, userData.PlayerId); SendSessionToken(peer, userData); } private bool IsVerified(User? user) => user != null && user.PlayerId != Guid.Empty && user.SessionExpiresAt > DateTimeOffset.UtcNow && _database.GetByIndex("PlayerId", user.PlayerId.ToString("D")) != null; private PlayerData? SessionData(User user) => IsVerified(user) ? _database.GetByIndex("PlayerId", user.PlayerId.ToString("D")) : null; internal bool CheckConnectionRateLimit(string ip) { return CheckRateLimit(ip, MaxConnectionAttemptsPerMinute, _connectionAttempts, _connectionAttemptLocks); } internal bool CheckAuthRateLimit(string ip) { return CheckRateLimit(ip, MaxLoginAttemptsPerMinute, _authAttempts, _authAttemptLocks); } private void CleanupExpiredSessions() { foreach (var entry in _users.ToArray()) { if (entry.Value.SessionExpiresAt != default && entry.Value.SessionExpiresAt <= DateTimeOffset.UtcNow) { if (_users.TryRemove(entry.Key, out var user)) { user.Peer.Disconnect(); } } } } private void AttachUser(User user, Guid playerId) { user.PlayerId = playerId; user.SessionExpiresAt = DateTimeOffset.UtcNow.AddSeconds(SessionTimeoutSeconds); } private void SendSessionToken(NetPeer peer, Guid playerId) { var player = _database.GetByIndex("PlayerId", playerId.ToString("D")); if (player == null) { peer.Send(new ErrorMessage { Error = "Session Not Found" }); return; } SendSessionToken(peer, player); } private void SendSessionToken(NetPeer peer, PlayerData player) { var expiresAtUtc = DateTimeOffset.UtcNow.AddSeconds(SessionTimeoutSeconds); player.SessionVersion++; var token = Secret.CreateSessionToken(player.PlayerId, expiresAtUtc, player.SessionVersion, _security); _database.AddAsync(player).GetAwaiter().GetResult(); if (_users.TryGetValue(peer.Id, out var user)) { user.PlayerId = player.PlayerId; user.SessionExpiresAt = expiresAtUtc; user.SessionVersion = player.SessionVersion; user.SessionToken = token; } var nonce = Secret.NewNonce; peer.Send(new LoginSuccessMessage { Nonce = nonce, Session = Secret.EncryptString(token, nonce, _security) ?? Array.Empty() }); } private void RefreshSessionIfNeeded(NetPeer peer, User user) { if ((user.SessionExpiresAt - DateTimeOffset.UtcNow).TotalSeconds > SessionRefreshThresholdSeconds) { return; } SendSessionToken(peer, user.PlayerId); } private static bool CheckRateLimit( string ip, int maxAttemptsPerMinute, ConcurrentDictionary> attemptBuckets, ConcurrentDictionary attemptLocks) { var now = DateTimeOffset.UtcNow; var windowStart = now.AddMinutes(-1); var queue = attemptBuckets.GetOrAdd(ip, _ => new Queue()); var gate = attemptLocks.GetOrAdd(ip, _ => new object()); lock (gate) { while (queue.Count > 0 && queue.Peek() < windowStart) { queue.Dequeue(); } queue.Enqueue(now); return queue.Count <= maxAttemptsPerMinute; } } } /// /// Represents an active authenticated session. /// public class Session { /// /// The last time the session was observed as active. /// public DateTime LastUpdate; /// /// Player data associated with the session. /// public PlayerData Data = null!; } /// /// Tracks connection state for a connected peer. /// public class User { /// /// The connected network peer. /// public NetPeer Peer = null!; /// /// The last reported latency for the peer. /// public int Latency; /// /// The authenticated player identifier. /// public Guid PlayerId; /// /// The current session expiration timestamp in UTC. /// public DateTimeOffset SessionExpiresAt; /// /// The latest signed session token issued to the peer. /// public string SessionToken = string.Empty; /// /// The session version associated with the latest issued token. /// public long SessionVersion; } }