CultLib/src/GameCult.Networking/Server.cs

359 lines
15 KiB
C#
Raw Normal View History

2026-04-18 06:04:39 +01:00
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
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
{
public class Server
{
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 int MaxLoginAttemptsPerMinute = 5;
private readonly ConcurrentDictionary<Type, Delegate> _messageDelegates = new(); // Aggregated multicast Action<T> per type
private readonly ConcurrentDictionary<long, User> _users = new();
private readonly ConcurrentDictionary<Guid, Session> _sessions = new();
private readonly ConcurrentDictionary<Guid, long> _sessionToUserId = new();
private readonly ConcurrentDictionary<string, Queue<DateTime>> _loginAttempts = new(); // IP to attempt timestamps
private NetManager _netManager;
private Stopwatch _timer;
private readonly CultCache _database;
private ILogger _logger = new NullLogger();
public ILogger Logger
{
get => _logger;
set => _logger = value ?? new NullLogger();
}
private float Time => (float)_timer.Elapsed.TotalSeconds;
public Server(CultCache cache)
{
_database = cache;
_database.RegisterIndex<PlayerData>("Email");
Observable.Timer(TimeSpan.FromSeconds(60), TimeSpan.FromSeconds(60))
.Subscribe(_ => CleanupExpiredSessions());
}
public bool IsValidEmail(string email) => Regex.IsMatch(email, EmailPattern);
public bool IsValidUsername(string name) => Regex.IsMatch(name, UsernamePattern);
public void ClearMessageListeners()
{
_messageDelegates.Clear();
}
public void AddMessageListener<T>(Action<T> callback) where T : Message
{
var type = typeof(T);
// Atomically aggregate into multicast delegate (thread-safe via CAS)
_messageDelegates.AddOrUpdate(type,
_ => callback, // First callback
(t, current) =>
{
// Combine with existing (supports multiple callbacks)
var combined = Delegate.Combine(current, callback) as Action<T>;
return combined ?? throw new InvalidOperationException($"Failed to combine delegates for {t.Name}");
});
}
public void RemoveMessageListener<T>(Action<T> callback) where T : Message
{
if (_messageDelegates.TryGetValue(typeof(T), out var currentDelegate))
{
var newDelegate = Delegate.Remove(currentDelegate, callback) as Action<T>;
_messageDelegates[typeof(T)] = newDelegate; // Null if empty
}
}
public void Stop()
{
_netManager.Stop();
}
public void Start()
{
_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 (CheckRateLimit(request.RemoteEndPoint.Address.ToString()))
request.AcceptIfKey(Secret.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}");
if (_users.TryRemove(peer.Id, out var user) && user.SessionGuid != Guid.Empty)
{
_sessions.TryRemove(user.SessionGuid, out _);
_sessionToUserId.TryRemove(user.SessionGuid, 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 = MessagePackSerializer.Deserialize<Message>(bytes);
Logger.LogDebug($"Received message: {MessagePackSerializer.ConvertToJson(new ReadOnlyMemory<byte>(bytes))}");
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))
{
var nonce = Secret.NewNonce;
peer.Send(new LoginSuccessMessage { Nonce = nonce, Session = Secret.EncryptBytes(user.SessionGuid.ToByteArray(), nonce) });
return;
}
Guid sessionGuid;
if (message is LoginMessage or RegisterMessage && !CheckRateLimit(peer.Address.ToString()))
{
peer.Send(new ErrorMessage { Error = "Too Many Attempts" });
return;
}
switch (message)
{
case RegisterMessage register:
{
var name = Secret.DecryptString(register.Name, register.Nonce);
var email = Secret.DecryptString(register.Email, register.Nonce);
if (!IsValidUsername(name))
{
peer.Send(new ErrorMessage { Error = "Username Invalid" });
return;
}
if (!IsValidEmail(email))
{
peer.Send(new ErrorMessage { Error = "Email Invalid" });
return;
}
if (_database.GetIdByIndex<PlayerData>("Email", email) != null)
{
peer.Send(new ErrorMessage { Error = "Email Taken" });
return;
}
if (_database.GetIdByName<PlayerData>(name) != null)
{
peer.Send(new ErrorMessage { Error = "Username Taken" });
return;
}
sessionGuid = GenerateSessionGuid();
var password = Secret.DecryptString(register.Password, register.Nonce);
var newUserData = new PlayerData
{
ID = Guid.NewGuid(),
Email = email,
PasswordHash = Argon2.Hash(password, memoryCost: 16384),
Username = name
};
await _database.AddAsync(newUserData); // Immediate cache + async file write
_sessions.TryAdd(sessionGuid, new Session { Data = newUserData, LastUpdate = DateTime.Now });
_sessionToUserId[sessionGuid] = peer.Id;
var nonce = Secret.NewNonce;
peer.Send(new LoginSuccessMessage { Nonce = nonce, Session = Secret.EncryptBytes(sessionGuid.ToByteArray(), nonce) });
if (_users.TryGetValue(peer.Id, out var u1))
u1.SessionGuid = sessionGuid;
break;
}
case VerifyMessage verify:
var session = new Guid(Secret.DecryptBytes(verify.Session, verify.Nonce));
if (!_sessions.ContainsKey(session))
{
peer.Send(new ErrorMessage { Error = "Session Not Found" });
return;
}
if (_users.TryGetValue(peer.Id, out var u2))
{
u2.SessionGuid = session;
var nonce = Secret.NewNonce;
peer.Send(new LoginSuccessMessage { Nonce = nonce, Session = Secret.EncryptBytes(session.ToByteArray(), nonce) });
}
break;
case LoginMessage login:
{
var auth = Secret.DecryptString(login.Auth, login.Nonce);
var isEmail = IsValidEmail(auth);
var userData = isEmail ?
_database.GetByIndex<PlayerData>("Email", auth) :
_database.GetByName<PlayerData>(auth);
if (userData == null)
{
peer.Send(new ErrorMessage { Error = isEmail ? "Email Not Found" : "Username Not Found" });
return;
}
var password = Secret.DecryptString(login.Password, login.Nonce);
if (!Argon2.Verify(userData.PasswordHash, password))
{
peer.Send(new ErrorMessage { Error = "Password Incorrect" });
return;
}
sessionGuid = GenerateSessionGuid();
_sessions.TryAdd(sessionGuid, new Session { Data = userData, LastUpdate = DateTime.Now });
_sessionToUserId[sessionGuid] = peer.Id;
var nonce = Secret.NewNonce;
peer.Send(new LoginSuccessMessage { Nonce = nonce, Session = Secret.EncryptBytes(sessionGuid.ToByteArray(), nonce) });
if (_users.TryGetValue(peer.Id, out var u3))
u3.SessionGuid = sessionGuid;
break;
}
}
}
else if (IsVerified(user))
{
// Zero-reflection dispatch via cached multicast delegate
if (_messageDelegates.TryGetValue(message.GetType(), out var del) && del != null)
del.DynamicInvoke(message);
else
Logger.LogWarning($"No listener for {message.GetType().Name}");
if (_sessions.TryGetValue(user.SessionGuid, out var session))
session.LastUpdate = DateTime.Now;
}
else
{
peer.Send(new ErrorMessage { Error = "User Not Verified" });
}
}
catch (Exception ex)
{
Logger.LogError($"Message processing failed: {ex.Message}");
}
};
AddMessageListener<ChangeNameMessage>(async (message) =>
{
if (!IsValidUsername(message.Name))
{
message.Peer.Send(new ErrorMessage { Error = "Username Invalid" });
return;
}
if (_database.GetIdByName<PlayerData>(message.Name) != null)
{
message.Peer.Send(new ErrorMessage { Error = "Username Taken" });
return;
}
if (_users.TryGetValue(message.Peer.Id, out var user))
{
var data = SessionData(user);
data.Username = message.Name;
await _database.AddAsync(data);
}
});
Logger.LogInfo($"Server started on port {ServerPort}.");
}
private bool IsVerified(User u) => u != null && _sessions.ContainsKey(u.SessionGuid);
private PlayerData SessionData(User user) =>
IsVerified(user) ? _sessions[user.SessionGuid].Data : null;
private bool CheckRateLimit(string ip)
{
var now = DateTime.Now;
var windowStart = now.AddMinutes(-1);
_loginAttempts.AddOrUpdate(
ip,
_ => new Queue<DateTime>(new[] { now }),
(_, queue) =>
{
// Remove attempts older than 1 minute
while (queue.Count > 0 && queue.Peek() < windowStart)
queue.Dequeue();
queue.Enqueue(now);
return queue;
});
return _loginAttempts[ip].Count <= MaxLoginAttemptsPerMinute;
}
private Guid GenerateSessionGuid()
{
var guid = Guid.NewGuid();
// TODO: Implement HMAC signing for session validation
return guid;
}
private void CleanupExpiredSessions()
{
foreach (var session in _sessions.ToArray())
{
if ((DateTime.Now - session.Value.LastUpdate).TotalSeconds > SessionTimeoutSeconds)
{
_sessions.TryRemove(session.Key, out _);
if (_sessionToUserId.TryRemove(session.Key, out var userId) && _users.TryGetValue(userId, out var user))
{
user.Peer.Disconnect();
_users.TryRemove(userId, out _);
}
}
}
}
}
public class Session
{
public DateTime LastUpdate;
public PlayerData Data;
}
public class User
{
public NetPeer Peer;
public int Latency;
public Guid SessionGuid;
}
}