sharp-chat/SharpChat/SockChatServer.cs
flashwave 5a7756894b
First bits of the Context overhaul.
Reintroduces separate contexts for users, channels, connections (now split into sessions and connections) and user-channel associations.
It builds which is as much assurance as I can give about the stability of this commit, but its also the bare minimum of what i like to commit sooooo
A lot of things still need to be broadcast through events throughout the application in order to keep states consistent but we'll cross that bridge when we get to it.
I really need to stop using that phrase thingy, I'm overusing it.
2025-05-03 02:49:51 +00:00

238 lines
9.4 KiB
C#

using Microsoft.Extensions.Logging;
using SharpChat.Bans;
using SharpChat.C2SPacketHandlers;
using SharpChat.ClientCommands;
using SharpChat.Configuration;
using SharpChat.Sessions;
using SharpChat.SockChat;
using SharpChat.SockChat.S2CPackets;
using SharpChat.Users;
using System.Net;
using ZLogger;
namespace SharpChat;
public class SockChatServer {
public const ushort DEFAULT_PORT = 6770;
public Context Context { get; }
public bool IsRestarting { get; set; }
private readonly ILogger Logger;
private readonly CachedValue<ushort> Port;
private readonly Lock ConnectionsLock = new();
private readonly HashSet<SockChatConnection> Connections = [];
private readonly List<C2SPacketHandler> GuestHandlers = [];
private readonly List<C2SPacketHandler> AuthedHandlers = [];
private readonly SendMessageC2SPacketHandler SendMessageHandler;
public SockChatServer(
CancellationTokenSource cancellationTokenSource,
Context ctx,
Config config
) {
Logger = ctx.LoggerFactory.CreateLogger("sockchat");
Logger.ZLogInformation($"Initialising Sock Chat server...");
Context = ctx;
Logger.ZLogDebug($"Fetching configuration values...");
Port = config.ReadCached("port", DEFAULT_PORT);
Logger.ZLogDebug($"Registering unauthenticated handlers...");
GuestHandlers.Add(new AuthC2SPacketHandler(
Context.Auth,
Context.Bans,
Context.Channels,
Context.RandomSnowflake,
Context.MaxMessageLength,
Context.MaxConnections
));
Logger.ZLogDebug($"Registering authenticated handlers...");
AuthedHandlers.AddRange([
new PingC2SPacketHandler(Context.Auth),
SendMessageHandler = new SendMessageC2SPacketHandler(Context.RandomSnowflake, Context.MaxMessageLength),
]);
Logger.ZLogDebug($"Registering client commands...");
SendMessageHandler.AddCommands([
new AFKClientCommand(),
new NickClientCommand(),
new WhisperClientCommand(),
new ActionClientCommand(),
new WhoClientCommand(),
new JoinChannelClientCommand(),
new CreateChannelClientCommand(),
new DeleteChannelClientCommand(),
new PasswordChannelClientCommand(),
new RankChannelClientCommand(),
new BroadcastClientCommand(),
new DeleteMessageClientCommand(),
new KickBanClientCommand(Context.Bans),
new PardonUserClientCommand(Context.Bans),
new PardonAddressClientCommand(Context.Bans),
new BanListClientCommand(Context.Bans),
new RemoteAddressClientCommand(),
new ShutdownRestartClientCommand(this, cancellationTokenSource)
]);
}
public async Task Listen(CancellationToken cancellationToken) {
// TODO: protocol servers are now responsible of timing out unauthed connections by themselves
using SharpChatWebSocketServer server = new(Context.LoggerFactory.CreateLogger("sockchat:server"), $"ws://0.0.0.0:{Port}");
server.Start(sock => {
if(!IPAddress.TryParse(sock.ConnectionInfo.ClientIpAddress, out IPAddress? addr)) {
Logger.ZLogError($@"A client attempted to connect with an invalid IP address: ""{sock.ConnectionInfo.ClientIpAddress}""");
sock.Close(WebSocketCloseCode.InternalError);
return;
}
if(IPAddress.IsLoopback(addr) && sock.ConnectionInfo.Headers.TryGetValue("X-Real-IP", out string? addrStr)) {
if(IPAddress.TryParse(addrStr, out IPAddress? realAddr))
addr = realAddr;
else
Logger.ZLogWarning($@"Connection originated from loopback and supplied an X-Real-IP header, but it could not be parsed: ""{addrStr}""");
}
IPEndPoint endPoint = new(addr, sock.ConnectionInfo.ClientPort);
ILogger logger = Context.LoggerFactory.CreateLogger($"sockchat:({endPoint})");
if(cancellationToken.IsCancellationRequested) {
logger.ZLogInformation($"{endPoint} attempted to connect after shutdown was requested. Connection will be dropped.");
sock.Close(WebSocketCloseCode.TryAgainLater);
return;
}
lock(ConnectionsLock) {
SockChatConnection conn = new(sock, endPoint, logger);
Connections.Add(conn);
sock.OnOpen = () => OnOpen(conn).Wait();
sock.OnClose = () => OnClose(conn).Wait();
sock.OnError = err => OnError(conn, err).Wait();
sock.OnMessage = msg => OnMessage(conn, msg).Wait();
}
});
Logger.ZLogInformation($"Listening...");
await Task.Delay(Timeout.Infinite, cancellationToken).ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing);
lock(ConnectionsLock) {
Logger.ZLogDebug($"Disposing all clients...");
foreach(SockChatConnection conn in Connections)
conn.Socket.Close(IsRestarting ? WebSocketCloseCode.ServiceRestart : WebSocketCloseCode.GoingAway);
}
}
private async Task OnOpen(SockChatConnection conn) {
conn.Logger.ZLogInformation($"Connection opened.");
await Context.SafeUpdate();
}
private async Task OnError(SockChatConnection conn, Exception ex) {
// TODO: detect timeouts and suspend the session
conn.Logger.ZLogError($"Error: {ex.Message}");
conn.Logger.ZLogDebug($"{ex}");
await Context.SafeUpdate();
}
private async Task OnClose(SockChatConnection conn) {
conn.Logger.ZLogInformation($"Connection closed.");
User? noMoreSessionsUser = null;
lock(ConnectionsLock) {
Connections.Remove(conn);
Session? session = Context.Sessions.GetSession(conn);
if(session is not null) {
if(!Context.Sessions.IsSuspendedSession(session))
Context.Sessions.DestroySession(session);
if(Context.Sessions.CountActiveSessions(session.UserId) < 1)
noMoreSessionsUser = Context.Users.GetUser(session.UserId);
}
}
Context.ContextAccess.Wait();
try {
if(noMoreSessionsUser is not null)
await Context.HandleDisconnect(noMoreSessionsUser);
await Context.Update();
} finally {
Context.ContextAccess.Release();
}
}
private async Task OnMessage(SockChatConnection conn, string msg) {
conn.Logger.ZLogTrace($"Received: {msg}");
await Context.SafeUpdate();
Session? session = Context.Sessions.GetSession(conn);
User? user = session is null ? null : Context.Users.GetUser(session.UserId);
// this doesn't affect non-authed connections?????
if(user is not null && user.Rank < Context.FloodKickExemptRank) {
bool rusticate = false;
string banAddr = string.Empty;
TimeSpan banDuration = TimeSpan.MinValue;
Context.ContextAccess.Wait();
try {
if(!Context.UserRateLimiters.TryGetValue(user.UserId, out RateLimiter? rateLimiter))
Context.UserRateLimiters.Add(user.UserId, rateLimiter = new RateLimiter());
rateLimiter.Update();
if(rateLimiter.IsExceeded) {
banDuration = TimeSpan.FromSeconds(Context.FloodKickLength);
rusticate = true;
banAddr = conn.RemoteEndPoint.Address.ToString();
conn.Logger.ZLogWarning($"Exceeded flood limit! Issuing ban with duration {banDuration} on {banAddr}/{user.UserId}...");
} else if(rateLimiter.IsRisky) {
rusticate = true;
banAddr = conn.RemoteEndPoint.Address.ToString();
conn.Logger.ZLogWarning($"About to exceed flood limit! Issueing warning to {banAddr}/{user.UserId}...");
}
if(rusticate) {
if(banDuration == TimeSpan.MinValue) {
await Context.SendTo(user, new CommandResponseS2CPacket(Context.RandomSnowflake.Next(), LCR.FLOOD_WARN, false));
} else {
await Context.BanUser(user, banDuration, UserDisconnectS2CPacket.Reason.Flood);
if(banDuration > TimeSpan.Zero)
await Context.Bans.BanCreate(
BanKind.User,
banDuration,
conn.RemoteEndPoint.Address,
user.UserId,
"Kicked from chat for flood protection.",
IPAddress.IPv6Loopback
);
return;
}
}
} finally {
Context.ContextAccess.Release();
}
}
C2SPacketHandlerContext context = new(msg, Context, conn, session, session?.Logger ?? conn.Logger);
C2SPacketHandler? handler = user is null
? GuestHandlers.FirstOrDefault(h => h.IsMatch(context))
: AuthedHandlers.FirstOrDefault(h => h.IsMatch(context));
if(handler is not null)
await handler.Handle(context);
}
}