using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics; using System.Linq; using System.Net; 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 : ICultNetSchemaServer, ICultNetSchemaServerPeerLifecycle, 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 _cultNetMessageDelegates = new(); private readonly ConcurrentDictionary _cultNetServerPeerMessageDelegates = new(); private readonly ConcurrentDictionary _cultNetSchemaPeerAdapters = 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; /// public event Action? PeerDisconnected; /// /// 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); /// /// Gets a transport profile describing the server's LiteNetLib production lane. /// public CultNetTransportProfile TransportProfile => CultNetTransportProfiles.CreateLiteNetLib( "csharp-server", new LiteNetLibTransportProfileOptions { TransportId = "litenetlib-server", Host = "0.0.0.0", Port = ServerPort }); /// /// 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(); _cultNetMessageDelegates.Clear(); _cultNetServerPeerMessageDelegates.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); } /// /// Adds a listener for a modern CultNet schema-v0 message type. /// public void AddCultNetMessageListener(Func callback) where T : ICultNetSchemaMessage { var type = typeof(T); _cultNetMessageDelegates.AddOrUpdate(type, _ => callback, (t, current) => { var combined = Delegate.Combine(current, callback) as Func; return combined ?? throw new InvalidOperationException($"Failed to combine delegates for {t.Name}"); }); } /// /// Adds a listener for a modern CultNet schema-v0 message type. /// public void AddCultNetMessageListener(Action callback) where T : ICultNetSchemaMessage { AddCultNetMessageListener((message, peer) => { callback(message, peer); return Task.CompletedTask; }); } /// /// Adds a transport-aware listener for a modern CultNet schema-v0 message type. /// public void AddCultNetMessageListener(Func callback) where T : ICultNetSchemaMessage { var type = typeof(T); _cultNetServerPeerMessageDelegates.AddOrUpdate(type, _ => callback, (t, current) => { var combined = Delegate.Combine(current, callback) as Func; return combined ?? throw new InvalidOperationException($"Failed to combine delegates for {t.Name}"); }); } /// /// Adds a transport-aware listener for a modern CultNet schema-v0 message type. /// public void AddCultNetMessageListener(Action callback) where T : ICultNetSchemaMessage { AddCultNetMessageListener((message, peer) => { callback(message, peer); return Task.CompletedTask; }); } /// /// Adds a listener for a modern CultNet schema-v0 message type. /// public void OnCultNet(Func callback) where T : ICultNetSchemaMessage { AddCultNetMessageListener(callback); } /// /// Adds a listener for a modern CultNet schema-v0 message type. /// public void OnCultNet(Action callback) where T : ICultNetSchemaMessage { AddCultNetMessageListener(callback); } /// /// Adds a transport-aware listener for a modern CultNet schema-v0 message type. /// public void OnCultNet(Func callback) where T : ICultNetSchemaMessage { AddCultNetMessageListener(callback); } /// /// Adds a transport-aware listener for a modern CultNet schema-v0 message type. /// public void OnCultNet(Action callback) where T : ICultNetSchemaMessage { AddCultNetMessageListener(callback); } /// public void OnCultNet(Func callback) where T : ICultNetSchemaMessage { if (callback == null) throw new ArgumentNullException(nameof(callback)); Func adapter = (message, peer) => callback(message, peer); _cultNetSchemaPeerAdapters[callback] = adapter; AddCultNetMessageListener(adapter); } /// /// Sends a legacy GameCult.Networking union message through this server's per-peer LiteNetLib adapter. /// public void Send(NetPeer? peer, Message message) { SendLegacy(peer, message); } /// /// Sends a CultNet schema-v0 message through this server's per-peer LiteNetLib adapter. /// public void SendCultNet(NetPeer? peer, T message) where T : ICultNetSchemaMessage { SendSchema(peer, message); } /// /// Gets the transport-aware server peer context for a connected LiteNetLib peer. /// public CultNetServerPeer GetPeerContext(NetPeer peer) { if (peer == null) throw new ArgumentNullException(nameof(peer)); var user = _users.GetOrAdd( peer.Id, _ => new User { Peer = peer, Transport = new LiteNetLibTransportConnection(peer) }); user.Transport ??= new LiteNetLibTransportConnection(peer); return new CultNetServerPeer(peer, user.Transport); } /// /// 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); } /// /// Removes a previously registered modern CultNet schema-v0 listener. /// public void RemoveCultNetMessageListener(Delegate callback) where T : ICultNetSchemaMessage { if (_cultNetSchemaPeerAdapters.TryRemove(callback, out var adapter)) { callback = adapter; } if (_cultNetMessageDelegates.TryGetValue(typeof(T), out var currentDelegate)) { var newDelegate = Delegate.Remove(currentDelegate, callback); if (newDelegate == null) { _cultNetMessageDelegates.TryRemove(typeof(T), out _); } else { _cultNetMessageDelegates[typeof(T)] = newDelegate; } } if (_cultNetServerPeerMessageDelegates.TryGetValue(typeof(T), out var currentServerPeerDelegate)) { var newDelegate = Delegate.Remove(currentServerPeerDelegate, callback); if (newDelegate == null) { _cultNetServerPeerMessageDelegates.TryRemove(typeof(T), out _); } else { _cultNetServerPeerMessageDelegates[typeof(T)] = newDelegate; } } } /// /// 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, Transport = new LiteNetLibTransportConnection(peer) }); }; listener.PeerDisconnectedEvent += (peer, info) => { Logger.LogInfo($"User Disconnected: {peer.Address}"); var context = GetPeerContext(peer); _users.TryRemove(peer.Id, out _); PeerDisconnected?.Invoke(context); }; 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 user = _users.GetOrAdd(peer.Id, _ => new User { Peer = peer, Transport = new LiteNetLibTransportConnection(peer) }); user.Transport ??= new LiteNetLibTransportConnection(peer); var transport = user.Transport; var frame = transport.Receive(bytes); if (string.Equals(frame.ChannelId, "schema", StringComparison.Ordinal)) { var cultNetMessage = LiteNetLibTransportConnection.DecodeSchema(frame); Logger.LogDebug($"Received CultNet schema message {cultNetMessage.SchemaVersion}"); await HandleCultNetSchemaMessageAsync(peer, user, cultNetMessage).ConfigureAwait(false); return; } var message = LiteNetLibTransportConnection.DecodeLegacy(frame); if (LogSensitivePayloads) { Logger.LogDebug($"Received message: {MessagePackSerializer.ConvertToJson(new ReadOnlyMemory(frame.Payload))}"); } else { Logger.LogDebug($"Received message {message?.GetType().Name ?? "unknown"}"); } if (message == null) { return; } message.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())) { SendLegacy(peer, 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 { SendLegacy(peer, new ErrorMessage { Error = "User Not Verified" }); } } catch (Exception ex) { Logger.LogError($"Message processing failed: {ex.Message}"); } }; AddMessageListener(async message => { if (!IsValidUsername(message.Name)) { SendLegacy(message.Peer, new ErrorMessage { Error = "Username Invalid" }); return; } if (_database.GetByName(message.Name) != null) { SendLegacy(message.Peer, 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) { SendLegacy(message.Peer, new ErrorMessage { Error = "User Not Verified" }); return; } data.Username = message.Name; await _database.AddAsync(data); } }); Logger.LogInfo($"Server started on port {ServerPort}."); } private async Task HandleCultNetSchemaMessageAsync(NetPeer peer, User user, ICultNetSchemaMessage message) { if (!IsVerified(user) && !CanProcessBeforeVerification(message)) { SendSchema(peer, new CultNetErrorMessage { Error = "User Not Verified" }); return; } var handled = false; if (_cultNetMessageDelegates.TryGetValue(message.GetType(), out var del) && del != null) { foreach (var listener in del.GetInvocationList()) { var result = listener.DynamicInvoke(message, peer); if (result is Task task) { await task.ConfigureAwait(false); } } handled = true; } if (_cultNetServerPeerMessageDelegates.TryGetValue(message.GetType(), out var serverPeerDel) && serverPeerDel != null) { var serverPeer = GetPeerContext(peer); foreach (var listener in serverPeerDel.GetInvocationList()) { var result = listener.DynamicInvoke(message, serverPeer); if (result is Task task) { await task.ConfigureAwait(false); } } handled = true; } if (handled) { user.SessionExpiresAt = DateTimeOffset.UtcNow.AddSeconds(SessionTimeoutSeconds); RefreshSessionIfNeeded(peer, user); } else { Logger.LogWarning($"No listener for CultNet schema message {message.SchemaVersion}"); } } private static bool CanProcessBeforeVerification(ICultNetSchemaMessage message) { return message is CultNetHelloMessage or CultNetSchemaCatalogRequestMessage or CultNetDocumentPutRawMessage or CultNetDocumentDeleteMessage or CultNetDatabaseSubscribeMessage or CultNetDatabaseUnsubscribeMessage or CultNetSnapshotRequestMessage // R-A: v1 requests reach the same pre-verification handling as their v0 counterparts - // otherwise a v1 snapshot/subscribe from an unverified peer is silently dropped before // any listener runs, unlike v0 (docs/cultnet-selection-cut.md, Soul's whole-cut pass F1). or CultNetSnapshotRequestV1Message or CultNetDatabaseSubscribeV1Message; } /// 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)) { SendLegacy(peer, new ErrorMessage { Error = "Username Invalid" }); return; } if (string.IsNullOrWhiteSpace(email) || !IsValidEmail(email)) { SendLegacy(peer, new ErrorMessage { Error = "Email Invalid" }); return; } if (_database.GetByIndex("Email", email) != null) { SendLegacy(peer, new ErrorMessage { Error = "Email Taken" }); return; } if (_database.GetByName(name) != null) { SendLegacy(peer, new ErrorMessage { Error = "Username Taken" }); return; } if (string.IsNullOrWhiteSpace(password)) { SendLegacy(peer, 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)) { SendLegacy(peer, new ErrorMessage { Error = "Session Invalid" }); return; } var player = _database.GetByIndex("PlayerId", playerId.ToString("D")); if (player == null) { SendLegacy(peer, new ErrorMessage { Error = "Session Not Found" }); return; } if (player.SessionVersion != sessionVersion) { SendLegacy(peer, 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)) { SendLegacy(peer, new ErrorMessage { Error = "Credentials Invalid" }); return; } var isEmail = IsValidEmail(auth); var userData = isEmail ? _database.GetByIndex("Email", auth) : _database.GetByName(auth); if (userData == null) { SendLegacy(peer, new ErrorMessage { Error = isEmail ? "Email Not Found" : "Username Not Found" }); return; } if (!Argon2.Verify(userData.PasswordHash, password)) { SendLegacy(peer, 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) { SendLegacy(peer, 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; SendLegacy(peer, 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 void SendLegacy(NetPeer? peer, Message message) { if (peer == null) { return; } GetPeerContext(peer).Send(message); } private void SendSchema(NetPeer? peer, T message) where T : ICultNetSchemaMessage { if (peer == null) { return; } GetPeerContext(peer).SendCultNet(message); } 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; } } } /// /// Transport-aware server peer context for built-in CultNet service bodies. /// public sealed class CultNetServerPeer : ICultNetSchemaServerPeer, ICultNetSchemaServerPeerLocation { internal CultNetServerPeer(NetPeer peer, LiteNetLibTransportConnection transport) { Peer = peer ?? throw new ArgumentNullException(nameof(peer)); Transport = transport ?? throw new ArgumentNullException(nameof(transport)); } /// /// Gets the underlying LiteNetLib peer identity. /// public NetPeer Peer { get; } /// public EndPoint RemoteEndPoint => new IPEndPoint(Peer.Address, Peer.Port); /// /// Gets the channel-aware LiteNetLib transport adapter for this peer. /// public LiteNetLibTransportConnection Transport { get; } /// /// Gets a snapshot of transport counters for this peer. /// public CultNetTransportStats Stats => Transport.Stats; /// /// Sends a legacy GameCult.Networking union message through the peer transport adapter. /// public void Send(Message message) { Transport.SendLegacy(message); } /// /// Sends a CultNet schema-v0 message through the peer transport adapter. /// public void SendCultNet(T message) where T : ICultNetSchemaMessage { Transport.SendSchema(message); } } /// /// 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!; /// /// Channel-aware LiteNetLib transport adapter for this peer. /// public LiteNetLibTransportConnection? Transport; /// /// 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; } }