240 lines
8.4 KiB
C#
240 lines
8.4 KiB
C#
using Fleck;
|
|
using SharpChat.Auth;
|
|
using SharpChat.Bans;
|
|
using SharpChat.C2SPacketHandlers;
|
|
using SharpChat.ClientCommands;
|
|
using SharpChat.Configuration;
|
|
using SharpChat.SockChat.S2CPackets;
|
|
using System.Net;
|
|
|
|
namespace SharpChat;
|
|
|
|
public class SockChatServer : IDisposable {
|
|
public const ushort DEFAULT_PORT = 6770;
|
|
public const int DEFAULT_MSG_LENGTH_MAX = 5000;
|
|
public const int DEFAULT_MAX_CONNECTIONS = 5;
|
|
public const int DEFAULT_FLOOD_KICK_LENGTH = 30;
|
|
public const int DEFAULT_FLOOD_KICK_EXEMPT_RANK = 9;
|
|
|
|
public IWebSocketServer Server { get; }
|
|
public Context Context { get; }
|
|
|
|
private readonly BansClient BansClient;
|
|
|
|
private readonly CachedValue<int> MaxMessageLength;
|
|
private readonly CachedValue<int> MaxConnections;
|
|
private readonly CachedValue<int> FloodKickLength;
|
|
private readonly CachedValue<int> FloodKickExemptRank;
|
|
|
|
private readonly List<C2SPacketHandler> GuestHandlers = [];
|
|
private readonly List<C2SPacketHandler> AuthedHandlers = [];
|
|
private readonly SendMessageC2SPacketHandler SendMessageHandler;
|
|
|
|
private bool IsShuttingDown = false;
|
|
|
|
private static readonly string[] DEFAULT_CHANNELS = ["lounge"];
|
|
|
|
private Channel DefaultChannel { get; set; }
|
|
|
|
public SockChatServer(
|
|
AuthClient authClient,
|
|
BansClient bansClient,
|
|
EventStorage.EventStorage evtStore,
|
|
Config config
|
|
) {
|
|
Logger.Write("Initialising Sock Chat server...");
|
|
|
|
BansClient = bansClient;
|
|
|
|
MaxMessageLength = config.ReadCached("msgMaxLength", DEFAULT_MSG_LENGTH_MAX);
|
|
MaxConnections = config.ReadCached("connMaxCount", DEFAULT_MAX_CONNECTIONS);
|
|
FloodKickLength = config.ReadCached("floodKickLength", DEFAULT_FLOOD_KICK_LENGTH);
|
|
FloodKickExemptRank = config.ReadCached("floodKickExemptRank", DEFAULT_FLOOD_KICK_EXEMPT_RANK);
|
|
|
|
Context = new Context(evtStore);
|
|
|
|
string[]? channelNames = config.ReadValue("channels", DEFAULT_CHANNELS);
|
|
if(channelNames is not null)
|
|
foreach(string channelName in channelNames) {
|
|
Config channelCfg = config.ScopeTo($"channels:{channelName}");
|
|
|
|
string name = channelCfg.SafeReadValue("name", string.Empty)!;
|
|
if(string.IsNullOrWhiteSpace(name))
|
|
name = channelName;
|
|
|
|
Channel channelInfo = new(
|
|
name,
|
|
channelCfg.SafeReadValue("password", string.Empty)!,
|
|
rank: channelCfg.SafeReadValue("minRank", 0)
|
|
);
|
|
|
|
Context.Channels.Add(channelInfo);
|
|
DefaultChannel ??= channelInfo;
|
|
}
|
|
|
|
if(DefaultChannel is null)
|
|
throw new Exception("The default channel could not be determined.");
|
|
|
|
GuestHandlers.Add(new AuthC2SPacketHandler(authClient, bansClient, DefaultChannel, MaxMessageLength, MaxConnections));
|
|
|
|
AuthedHandlers.AddRange([
|
|
new PingC2SPacketHandler(authClient),
|
|
SendMessageHandler = new SendMessageC2SPacketHandler(Context.RandomSnowflake, MaxMessageLength),
|
|
]);
|
|
|
|
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(bansClient),
|
|
new PardonUserClientCommand(bansClient),
|
|
new PardonAddressClientCommand(bansClient),
|
|
new BanListClientCommand(bansClient),
|
|
new RemoteAddressClientCommand(),
|
|
]);
|
|
|
|
ushort port = config.SafeReadValue("port", DEFAULT_PORT);
|
|
Server = new SharpChatWebSocketServer($"ws://0.0.0.0:{port}");
|
|
}
|
|
|
|
public void Listen(ManualResetEvent waitHandle) {
|
|
if(waitHandle != null)
|
|
SendMessageHandler.AddCommand(new ShutdownRestartClientCommand(waitHandle, () => !IsShuttingDown && (IsShuttingDown = true)));
|
|
|
|
Server.Start(sock => {
|
|
if(IsShuttingDown) {
|
|
sock.Close(1013);
|
|
return;
|
|
}
|
|
|
|
Connection conn = new(sock);
|
|
Context.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.Write("Listening...");
|
|
}
|
|
|
|
private async Task OnOpen(Connection conn) {
|
|
Logger.Write($"Connection opened from {conn.RemoteAddress}:{conn.RemotePort}");
|
|
await Context.SafeUpdate();
|
|
}
|
|
|
|
private async Task OnError(Connection conn, Exception ex) {
|
|
Logger.Write($"[{conn.Id} {conn.RemoteAddress}] {ex}");
|
|
await Context.SafeUpdate();
|
|
}
|
|
|
|
private async Task OnClose(Connection conn) {
|
|
Logger.Write($"Connection closed from {conn.RemoteAddress}:{conn.RemotePort}");
|
|
|
|
Context.ContextAccess.Wait();
|
|
try {
|
|
Context.Connections.Remove(conn);
|
|
|
|
if(conn.User != null && !Context.Connections.Any(c => c.User == conn.User))
|
|
await Context.HandleDisconnect(conn.User);
|
|
|
|
await Context.Update();
|
|
} finally {
|
|
Context.ContextAccess.Release();
|
|
}
|
|
}
|
|
|
|
private async Task OnMessage(Connection conn, string msg) {
|
|
await Context.SafeUpdate();
|
|
|
|
// this doesn't affect non-authed connections?????
|
|
if(conn.User is not null && conn.User.Rank < FloodKickExemptRank) {
|
|
User? banUser = null;
|
|
string banAddr = string.Empty;
|
|
TimeSpan banDuration = TimeSpan.MinValue;
|
|
|
|
Context.ContextAccess.Wait();
|
|
try {
|
|
if(!Context.UserRateLimiters.TryGetValue(conn.User.UserId, out RateLimiter? rateLimiter))
|
|
Context.UserRateLimiters.Add(conn.User.UserId, rateLimiter = new RateLimiter(
|
|
User.DEFAULT_SIZE,
|
|
User.DEFAULT_MINIMUM_DELAY,
|
|
User.DEFAULT_RISKY_OFFSET
|
|
));
|
|
|
|
rateLimiter.Update();
|
|
|
|
if(rateLimiter.IsExceeded) {
|
|
banDuration = TimeSpan.FromSeconds(FloodKickLength);
|
|
banUser = conn.User;
|
|
banAddr = conn.RemoteAddress.ToString();
|
|
} else if(rateLimiter.IsRisky) {
|
|
banUser = conn.User;
|
|
}
|
|
|
|
if(banUser is not null) {
|
|
if(banDuration == TimeSpan.MinValue) {
|
|
await Context.SendTo(conn.User, new CommandResponseS2CPacket(Context.RandomSnowflake.Next(), LCR.FLOOD_WARN, false));
|
|
} else {
|
|
await Context.BanUser(conn.User, banDuration, UserDisconnectS2CPacket.Reason.Flood);
|
|
|
|
if(banDuration > TimeSpan.Zero)
|
|
await BansClient.BanCreateAsync(
|
|
BanKind.User,
|
|
banDuration,
|
|
conn.RemoteAddress,
|
|
conn.User.UserId,
|
|
"Kicked from chat for flood protection.",
|
|
IPAddress.IPv6Loopback
|
|
);
|
|
|
|
return;
|
|
}
|
|
}
|
|
} finally {
|
|
Context.ContextAccess.Release();
|
|
}
|
|
}
|
|
|
|
C2SPacketHandlerContext context = new(msg, Context, conn);
|
|
C2SPacketHandler? handler = conn.User is null
|
|
? GuestHandlers.FirstOrDefault(h => h.IsMatch(context))
|
|
: AuthedHandlers.FirstOrDefault(h => h.IsMatch(context));
|
|
|
|
if(handler is not null)
|
|
await handler.Handle(context);
|
|
}
|
|
|
|
private bool IsDisposed;
|
|
|
|
~SockChatServer() {
|
|
DoDispose();
|
|
}
|
|
|
|
public void Dispose() {
|
|
DoDispose();
|
|
GC.SuppressFinalize(this);
|
|
}
|
|
|
|
private void DoDispose() {
|
|
if(IsDisposed)
|
|
return;
|
|
IsDisposed = true;
|
|
IsShuttingDown = true;
|
|
|
|
foreach(Connection conn in Context.Connections)
|
|
conn.Dispose();
|
|
|
|
Server?.Dispose();
|
|
}
|
|
}
|