baseline: OpenMU upstream b5a0961 (fresh source)
This commit is contained in:
283
src/ChatServer/ChatClient.cs
Normal file
283
src/ChatServer/ChatClient.cs
Normal file
@@ -0,0 +1,283 @@
|
||||
// <copyright file="ChatClient.cs" company="MUnique">
|
||||
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
|
||||
// </copyright>
|
||||
|
||||
namespace MUnique.OpenMU.ChatServer;
|
||||
|
||||
using System.Buffers;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using MUnique.OpenMU.Network;
|
||||
using MUnique.OpenMU.Network.Packets;
|
||||
using MUnique.OpenMU.Network.Packets.ChatServer;
|
||||
using MUnique.OpenMU.Network.Xor;
|
||||
|
||||
/// <summary>
|
||||
/// ChatClient implementation, uses socket connections.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Messages are decrypted and encrypted again with the same XOR3 key - in theory we could optimize this (and
|
||||
/// the conversion to a string) away. However, we'll leave it for easier debugging.
|
||||
/// </remarks>
|
||||
internal class ChatClient : IChatClient
|
||||
{
|
||||
private const int TokenOffset = 6;
|
||||
private const int MessageOffset = 5;
|
||||
private static readonly ISpanDecryptor TokenDecryptor = new Xor3Decryptor(TokenOffset);
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="ISpanDecryptor"/> for chat messages. The incoming chat messages are "encrypted" with the commonly known XOR-3 encryption for reasons we don't know ;).
|
||||
/// </summary>
|
||||
private static readonly ISpanDecryptor MessageDecryptor = new Xor3Decryptor(MessageOffset);
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="ISpanEncryptor"/> for chat messages. The outgoing chat messages are "encrypted" with the commonly known XOR-3 encryption for reasons we don't know ;).
|
||||
/// </summary>
|
||||
private static readonly ISpanEncryptor MessageEncryptor = new Xor3Encryptor(MessageOffset);
|
||||
|
||||
private readonly ChatRoomManager _manager;
|
||||
private readonly ILogger<ChatClient> _logger;
|
||||
private readonly byte[] _packetBuffer = new byte[0xFF];
|
||||
private IConnection? _connection;
|
||||
private ChatRoom? _room;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ChatClient" /> class.
|
||||
/// </summary>
|
||||
/// <param name="connection">The connection.</param>
|
||||
/// <param name="manager">The manager.</param>
|
||||
/// <param name="logger">The logger.</param>
|
||||
public ChatClient(IConnection connection, ChatRoomManager manager, ILogger<ChatClient> logger)
|
||||
{
|
||||
this._manager = manager;
|
||||
this._logger = logger;
|
||||
this._connection = connection;
|
||||
this._connection.PacketReceived += this.ReadPacketAsync;
|
||||
this._connection.Disconnected += this.LogOffAsync;
|
||||
|
||||
this.LastActivity = DateTime.Now;
|
||||
_ = this._connection.BeginReceiveAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Occurs when the client has been disconnected.
|
||||
/// </summary>
|
||||
public event EventHandler? Disconnected;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public byte Index
|
||||
{
|
||||
get;
|
||||
set;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string? AuthenticationToken { get; private set; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public string? Nickname { get; set; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public DateTime LastActivity { get; private set; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async ValueTask SendMessageAsync(byte senderId, string message)
|
||||
{
|
||||
if (this._connection is not { } connection)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
int WritePacket()
|
||||
{
|
||||
var messageLength = (byte)Encoding.UTF8.GetByteCount(message);
|
||||
var length = ChatMessageRef.GetRequiredSize(messageLength);
|
||||
var packet = new ChatMessageRef(connection.Output.GetSpan(length)[..length]);
|
||||
packet.SenderIndex = senderId;
|
||||
packet.MessageLength = messageLength;
|
||||
Encoding.UTF8.GetBytes(message, packet.Message);
|
||||
MessageEncryptor.Encrypt(packet);
|
||||
return length;
|
||||
}
|
||||
|
||||
await this._connection.SendAsync(WritePacket).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async ValueTask SendChatRoomClientListAsync(IReadOnlyCollection<IChatClient> clients)
|
||||
{
|
||||
if (this._connection is not { } connection)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
int WritePacket()
|
||||
{
|
||||
var length = ChatRoomClientsRef.GetRequiredSize(clients.Count);
|
||||
var packet = new ChatRoomClientsRef(connection.Output.GetSpan(length)[..length]);
|
||||
packet.ClientCount = (byte)clients.Count;
|
||||
int i = 0;
|
||||
foreach (var client in clients)
|
||||
{
|
||||
var clientBlock = packet[i];
|
||||
clientBlock.Index = client.Index;
|
||||
clientBlock.Name = client.Nickname ?? string.Empty;
|
||||
i++;
|
||||
}
|
||||
|
||||
return length;
|
||||
}
|
||||
|
||||
await this._connection.SendAsync(WritePacket).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async ValueTask SendChatRoomClientUpdateAsync(byte updatedClientId, string updatedClientName, ChatRoomClientUpdateType updateType)
|
||||
{
|
||||
if (this._connection is null || !this._connection.Connected)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (updateType == ChatRoomClientUpdateType.Joined)
|
||||
{
|
||||
await this._connection.SendChatRoomClientJoinedAsync(updatedClientId, updatedClientName).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
await this._connection.SendChatRoomClientLeftAsync(updatedClientId, updatedClientName).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
this._logger.LogError(ex, "Error sending room update");
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async ValueTask LogOffAsync()
|
||||
{
|
||||
if (this._connection is null)
|
||||
{
|
||||
this._logger.LogDebug("Client {Nickname} is already disconnected.", this.Nickname);
|
||||
return;
|
||||
}
|
||||
|
||||
this._logger.LogDebug("Client {Connection} is going to be disconnected.", this._connection);
|
||||
if (this._room != null)
|
||||
{
|
||||
await this._room.LeaveAsync(this).ConfigureAwait(false);
|
||||
this._room = null;
|
||||
}
|
||||
|
||||
if (this._connection is { } connection)
|
||||
{
|
||||
await connection.DisconnectAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
this._connection = null;
|
||||
this.Disconnected?.Invoke(this, EventArgs.Empty);
|
||||
this.Disconnected = null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a <see cref="string" /> that represents this instance.
|
||||
/// </summary>
|
||||
/// <returns>
|
||||
/// A <see cref="string" /> that represents this instance.
|
||||
/// </returns>
|
||||
public override string ToString()
|
||||
{
|
||||
return $"Connection:{this._connection}, Client name:{this.Nickname}, Room-ID:{this._room?.RoomId}, Index: {this.Index}";
|
||||
}
|
||||
|
||||
private async ValueTask ReadPacketAsync(ReadOnlySequence<byte> sequence)
|
||||
{
|
||||
if (sequence.Length < 3)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
sequence.CopyTo(this._packetBuffer);
|
||||
var packet = this._packetBuffer.AsMemory(0, (int)sequence.Length);
|
||||
if (this._packetBuffer[0] != Authenticate.HeaderType)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
this.LastActivity = DateTime.Now;
|
||||
switch (this._packetBuffer[2])
|
||||
{
|
||||
case 0:
|
||||
await this.AuthenticateAsync(packet).ConfigureAwait(false);
|
||||
break;
|
||||
case 1:
|
||||
case 2:
|
||||
case 3:
|
||||
// We did never capture such packets, but they don't seem to be wrong (next is 4), so do nothing.
|
||||
break;
|
||||
case 4:
|
||||
if (this._room != null && this.CheckMessage(packet))
|
||||
{
|
||||
MessageDecryptor.Decrypt(packet.Span);
|
||||
var message = packet.Span.ExtractString(5, int.MaxValue, Encoding.UTF8);
|
||||
if (this._logger.IsEnabled(LogLevel.Debug))
|
||||
{
|
||||
this._logger.LogDebug("Message received from {Index}: \"{message}\"", this.Index, message);
|
||||
}
|
||||
|
||||
await this._room.SendMessageAsync(this.Index, message).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case 5:
|
||||
// This is something like a keep-connection-alive packet.
|
||||
// Last activity is always set, so we have to do nothing here.
|
||||
this._logger.LogDebug("Keep-alive received");
|
||||
break;
|
||||
|
||||
case var value:
|
||||
this._logger.LogError("Received unknown packet of type {PacketType}: {PacketSpan}", value, packet.Span.AsString());
|
||||
await this.LogOffAsync().ConfigureAwait(false);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private bool CheckMessage(Memory<byte> packet)
|
||||
{
|
||||
return packet.Length > 4 && (packet.Span[4] + 5) <= packet.Length;
|
||||
}
|
||||
|
||||
private async ValueTask AuthenticateAsync(Memory<byte> packet)
|
||||
{
|
||||
var roomId = NumberConversionExtensions.MakeWord(packet.Span[4], packet.Span[5]);
|
||||
var requestedRoom = this._manager.GetChatRoom(roomId);
|
||||
if (requestedRoom is null)
|
||||
{
|
||||
this._logger.LogError("Requested room {RoomId} has not been registered before.", roomId);
|
||||
await this.LogOffAsync().ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
TokenDecryptor.Decrypt(packet.Span);
|
||||
var tokenAsString = packet.Span.ExtractString(TokenOffset, 10, Encoding.UTF8);
|
||||
if (!uint.TryParse(tokenAsString, out uint _))
|
||||
{
|
||||
this._logger.LogError("Token '{TokenAsString}' is not a parseable integer.", tokenAsString);
|
||||
await this.LogOffAsync().ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
this.AuthenticationToken = tokenAsString;
|
||||
if (await requestedRoom.TryJoinAsync(this).ConfigureAwait(false))
|
||||
{
|
||||
this._room = requestedRoom;
|
||||
}
|
||||
else
|
||||
{
|
||||
await this.LogOffAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
285
src/ChatServer/ChatRoom.cs
Normal file
285
src/ChatServer/ChatRoom.cs
Normal file
@@ -0,0 +1,285 @@
|
||||
// <copyright file="ChatRoom.cs" company="MUnique">
|
||||
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
|
||||
// </copyright>
|
||||
|
||||
namespace MUnique.OpenMU.ChatServer;
|
||||
|
||||
using System.Threading;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using MUnique.OpenMU.Interfaces;
|
||||
using Nito.AsyncEx.Synchronous;
|
||||
|
||||
/// <summary>
|
||||
/// This class represents a Chat Room.
|
||||
/// </summary>
|
||||
internal sealed class ChatRoom : IDisposable
|
||||
{
|
||||
private readonly ILogger<ChatRoom> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Nicknames of the registered Clients.
|
||||
/// </summary>
|
||||
private readonly IList<ChatServerAuthenticationInfo> _registeredClients;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the <see cref="IChatClient"/>s which are currently connected to the ChatRoom.
|
||||
/// </summary>
|
||||
private readonly List<IChatClient> _connectedClients;
|
||||
|
||||
private ReaderWriterLockSlim? _lockSlim = new();
|
||||
|
||||
private int _lastUsedClientIndex = -1;
|
||||
|
||||
private bool _isClosing;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ChatRoom" /> class.
|
||||
/// </summary>
|
||||
/// <param name="roomId">The room identifier.</param>
|
||||
/// <param name="logger">The logger.</param>
|
||||
public ChatRoom(ushort roomId, ILogger<ChatRoom> logger)
|
||||
{
|
||||
this._logger = logger;
|
||||
this._logger.LogDebug("Creating room {RoomId}", roomId);
|
||||
this._connectedClients = new List<IChatClient>(2);
|
||||
this._registeredClients = new List<ChatServerAuthenticationInfo>(2);
|
||||
this.RoomId = roomId;
|
||||
this.AuthenticationRequiredUntil = DateTime.Now.AddSeconds(10);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the currently connected clients.
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<IChatClient> ConnectedClients => this._connectedClients;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the id of the Chat Room.
|
||||
/// </summary>
|
||||
public ushort RoomId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a datetime indicating until a authentication is required.
|
||||
/// </summary>
|
||||
public DateTime AuthenticationRequiredUntil { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the room closed event handler.
|
||||
/// </summary>
|
||||
public EventHandler<ChatRoomClosedEventArgs>? RoomClosed { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Registers a chat client to the chatroom. this is only called
|
||||
/// by the game server which will send id to the participants
|
||||
/// over the games connection.
|
||||
/// </summary>
|
||||
/// <param name="authenticationInfo">Authentication information of the participant.</param>
|
||||
public void RegisterClient(ChatServerAuthenticationInfo authenticationInfo)
|
||||
{
|
||||
if (this._isClosing)
|
||||
{
|
||||
throw new ObjectDisposedException("Chat room is already disposed.");
|
||||
}
|
||||
|
||||
if (authenticationInfo.RoomId != this.RoomId)
|
||||
{
|
||||
throw new ArgumentException(
|
||||
$"The RoomId of the authentication info ({authenticationInfo.RoomId}) does not match with this RoomId ({this.RoomId}).");
|
||||
}
|
||||
|
||||
this.AuthenticationRequiredUntil = authenticationInfo.AuthenticationRequiredUntil;
|
||||
this._registeredClients.Add(authenticationInfo);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the index of the next client.
|
||||
/// </summary>
|
||||
/// <returns>The index of the next client.</returns>
|
||||
public byte GetNextClientIndex()
|
||||
{
|
||||
var clientIndex = Interlocked.Increment(ref this._lastUsedClientIndex);
|
||||
return (byte)clientIndex;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Closes this chat room by disconnecting all clients.
|
||||
/// </summary>
|
||||
public void Close()
|
||||
{
|
||||
this.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.
|
||||
/// </summary>
|
||||
[System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Usage", "CA2213:DisposableFieldsShouldBeDisposed", MessageId = "lockSlim", Justification = "Null-conditional confuses the code analysis.")]
|
||||
public void Dispose()
|
||||
{
|
||||
var localLockSlim = this._lockSlim;
|
||||
if (this._isClosing || localLockSlim is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
this._isClosing = true;
|
||||
this._lockSlim = null;
|
||||
this._logger.LogDebug("Disposing room {RoomId}...", this.RoomId);
|
||||
this._registeredClients.Clear();
|
||||
localLockSlim.EnterWriteLock();
|
||||
try
|
||||
{
|
||||
Task.Run(async () =>
|
||||
{
|
||||
foreach (var connectedClient in this._connectedClients)
|
||||
{
|
||||
await connectedClient.LogOffAsync().ConfigureAwait(false);
|
||||
}
|
||||
}).WaitAndUnwrapException();
|
||||
this._connectedClients.Clear();
|
||||
this.RoomClosed?.Invoke(this, new ChatRoomClosedEventArgs(this));
|
||||
this.RoomClosed = null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
localLockSlim.ExitWriteLock();
|
||||
}
|
||||
|
||||
localLockSlim.Dispose();
|
||||
this._logger.LogDebug("Room {RoomId} disposed.", this.RoomId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The specified client will join the chatroom, if its registered. The Nickname is set to the clients object.
|
||||
/// </summary>
|
||||
/// <param name="chatClient">The chat client.</param>
|
||||
/// <returns>True, if the <paramref name="chatClient"/> provides the correct registered id with it's token.</returns>
|
||||
internal async ValueTask<bool> TryJoinAsync(IChatClient chatClient)
|
||||
{
|
||||
if (chatClient is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(chatClient));
|
||||
}
|
||||
|
||||
if (this._isClosing)
|
||||
{
|
||||
throw new ObjectDisposedException("Chat room is already disposed.");
|
||||
}
|
||||
|
||||
this._logger.LogDebug("Client {ChatClientIndex} is trying to join the room {RoomId} with token '{AuthenticationToken}'", chatClient.Index, this.RoomId, chatClient.AuthenticationToken);
|
||||
|
||||
this._lockSlim?.EnterWriteLock();
|
||||
try
|
||||
{
|
||||
var authenticationInformation = this._registeredClients.FirstOrDefault(info => string.Equals(info.AuthenticationToken, chatClient.AuthenticationToken));
|
||||
if (authenticationInformation != null)
|
||||
{
|
||||
if (authenticationInformation.AuthenticationRequiredUntil < DateTime.Now)
|
||||
{
|
||||
this._logger.LogInformation(
|
||||
"Client {ChatClientIndex} has tried to join the room {RoomId} with token '{AuthenticationToken}', but was too late. It was valid until {AuthenticationRequiredUntil}.",
|
||||
chatClient.Index,
|
||||
this.RoomId,
|
||||
chatClient.AuthenticationToken,
|
||||
authenticationInformation.AuthenticationRequiredUntil);
|
||||
}
|
||||
else
|
||||
{
|
||||
chatClient.Nickname = authenticationInformation.ClientName;
|
||||
chatClient.Index = authenticationInformation.Index;
|
||||
this._registeredClients.Remove(authenticationInformation);
|
||||
await this.SendChatRoomClientUpdateAsync(chatClient, ChatRoomClientUpdateType.Joined).ConfigureAwait(false);
|
||||
this._connectedClients.Add(chatClient);
|
||||
await chatClient.SendChatRoomClientListAsync(this._connectedClients).ConfigureAwait(false);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
this._logger.LogInformation("Client {ChatClientIndex} has tried to join the room {RoomId} with token '{AuthenticationToken}', but was not registered.", chatClient.Index, this.RoomId, chatClient.AuthenticationToken);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
this._lockSlim?.ExitWriteLock();
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The specified client will leave the chatroom.
|
||||
/// If the chatroom is empty then, it will be removed from the manager.
|
||||
/// </summary>
|
||||
/// <param name="chatClient">The chat client.</param>
|
||||
internal async ValueTask LeaveAsync(IChatClient chatClient)
|
||||
{
|
||||
if (this._isClosing)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
this._logger.LogDebug($"Chat client ({chatClient}) is leaving.");
|
||||
this._lockSlim?.EnterWriteLock();
|
||||
try
|
||||
{
|
||||
this._connectedClients.Remove(chatClient);
|
||||
}
|
||||
finally
|
||||
{
|
||||
this._lockSlim?.ExitWriteLock();
|
||||
}
|
||||
|
||||
bool roomIsEmpty;
|
||||
this._lockSlim?.EnterReadLock();
|
||||
try
|
||||
{
|
||||
roomIsEmpty = this._connectedClients.Count < 1;
|
||||
if (!roomIsEmpty)
|
||||
{
|
||||
await this.SendChatRoomClientUpdateAsync(chatClient, ChatRoomClientUpdateType.Left).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
this._lockSlim?.ExitReadLock();
|
||||
}
|
||||
|
||||
if (roomIsEmpty)
|
||||
{
|
||||
this.Close();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends a Message to all chat clients.
|
||||
/// </summary>
|
||||
/// <param name="senderId">The sender identifier.</param>
|
||||
/// <param name="message">The message.</param>
|
||||
internal async ValueTask SendMessageAsync(byte senderId, string message)
|
||||
{
|
||||
if (this._isClosing)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
this._lockSlim?.EnterReadLock();
|
||||
try
|
||||
{
|
||||
foreach (var connectedClient in this._connectedClients)
|
||||
{
|
||||
await connectedClient.SendMessageAsync(senderId, message).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
this._lockSlim?.ExitReadLock();
|
||||
}
|
||||
}
|
||||
|
||||
private async ValueTask SendChatRoomClientUpdateAsync(IChatClient updatedClient, ChatRoomClientUpdateType updateType)
|
||||
{
|
||||
foreach (var client in this._connectedClients)
|
||||
{
|
||||
await client.SendChatRoomClientUpdateAsync(updatedClient.Index, updatedClient.Nickname ?? string.Empty, updateType).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
26
src/ChatServer/ChatRoomClosedEventArgs.cs
Normal file
26
src/ChatServer/ChatRoomClosedEventArgs.cs
Normal file
@@ -0,0 +1,26 @@
|
||||
// <copyright file="ChatRoomClosedEventArgs.cs" company="MUnique">
|
||||
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
|
||||
// </copyright>
|
||||
|
||||
namespace MUnique.OpenMU.ChatServer;
|
||||
|
||||
/// <summary>
|
||||
/// Event arguments which contains the chat room which has been closed.
|
||||
/// </summary>
|
||||
/// <seealso cref="System.EventArgs" />
|
||||
internal class ChatRoomClosedEventArgs : EventArgs
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ChatRoomClosedEventArgs"/> class.
|
||||
/// </summary>
|
||||
/// <param name="room">The chat room.</param>
|
||||
public ChatRoomClosedEventArgs(ChatRoom room)
|
||||
{
|
||||
this.ChatRoom = room;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the chat room which has been closed.
|
||||
/// </summary>
|
||||
public ChatRoom ChatRoom { get; }
|
||||
}
|
||||
78
src/ChatServer/ChatRoomManager.cs
Normal file
78
src/ChatServer/ChatRoomManager.cs
Normal file
@@ -0,0 +1,78 @@
|
||||
// <copyright file="ChatRoomManager.cs" company="MUnique">
|
||||
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
|
||||
// </copyright>
|
||||
|
||||
namespace MUnique.OpenMU.ChatServer;
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
/// <summary>
|
||||
/// The Chat Room Manager manages the creation and destruction of chat rooms.
|
||||
/// </summary>
|
||||
internal class ChatRoomManager
|
||||
{
|
||||
private readonly ILoggerFactory _loggerFactory;
|
||||
|
||||
/// <summary>
|
||||
/// All currently used chat rooms.
|
||||
/// </summary>
|
||||
private readonly IDictionary<ushort, ChatRoom> _rooms = new ConcurrentDictionary<ushort, ChatRoom>();
|
||||
|
||||
private readonly ConcurrentBag<ushort> _freeRoomIds = new();
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ChatRoomManager" /> class.
|
||||
/// </summary>
|
||||
/// <param name="loggerFactory">The logger factory.</param>
|
||||
public ChatRoomManager(ILoggerFactory loggerFactory)
|
||||
{
|
||||
this._loggerFactory = loggerFactory;
|
||||
for (ushort i = 0; i < ushort.MaxValue; ++i)
|
||||
{
|
||||
this._freeRoomIds.Add(i);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the opened rooms.
|
||||
/// </summary>
|
||||
public ICollection<ChatRoom> OpenedRooms => this._rooms.Values;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new ChatRoom and returns its Room-ID.
|
||||
/// </summary>
|
||||
/// <returns>The Room-ID of the new room. Returns ushort.MaxValue, if there is no free chat room available.</returns>
|
||||
public ushort CreateChatRoom()
|
||||
{
|
||||
if (!this._freeRoomIds.TryTake(out ushort roomId))
|
||||
{
|
||||
throw new InvalidOperationException("There is no free room id, so the chat room couldn't be created.");
|
||||
}
|
||||
|
||||
var room = new ChatRoom(roomId, this._loggerFactory.CreateLogger<ChatRoom>());
|
||||
room.RoomClosed += this.OnChatRoomClosed;
|
||||
this._rooms.Add(roomId, room);
|
||||
return roomId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the chat room with the corresponding Room-ID.
|
||||
/// Returns null, if ChatRoom wasn't found.
|
||||
/// </summary>
|
||||
/// <param name="roomId">Room-ID.</param>
|
||||
/// <returns>ChatRoom or null.</returns>
|
||||
internal ChatRoom? GetChatRoom(ushort roomId)
|
||||
{
|
||||
this._rooms.TryGetValue(roomId, out var room);
|
||||
return room;
|
||||
}
|
||||
|
||||
private void OnChatRoomClosed(object? sender, ChatRoomClosedEventArgs eventArgs)
|
||||
{
|
||||
var room = eventArgs.ChatRoom;
|
||||
this._rooms.Remove(room.RoomId);
|
||||
this._freeRoomIds.Add(room.RoomId);
|
||||
room.Dispose();
|
||||
}
|
||||
}
|
||||
361
src/ChatServer/ChatServer.cs
Normal file
361
src/ChatServer/ChatServer.cs
Normal file
@@ -0,0 +1,361 @@
|
||||
// <copyright file="ChatServer.cs" company="MUnique">
|
||||
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
|
||||
// </copyright>
|
||||
|
||||
namespace MUnique.OpenMU.ChatServer;
|
||||
|
||||
using System.ComponentModel;
|
||||
using System.Net;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Security.Cryptography;
|
||||
using System.Threading;
|
||||
using System.Timers;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using MUnique.OpenMU.Interfaces;
|
||||
using MUnique.OpenMU.Network;
|
||||
using MUnique.OpenMU.PlugIns;
|
||||
using Timer = System.Timers.Timer;
|
||||
|
||||
/// <summary>
|
||||
/// Chat Server Listener that accepts incoming connections.
|
||||
/// </summary>
|
||||
public sealed class ChatServer : IChatServer, IDisposable
|
||||
{
|
||||
private readonly ChatRoomManager _manager;
|
||||
private readonly ILogger<ChatServer> _logger;
|
||||
private readonly IIpAddressResolver _addressResolver;
|
||||
private readonly ILoggerFactory _loggerFactory;
|
||||
private readonly PlugInManager _plugInManager;
|
||||
|
||||
private readonly RandomNumberGenerator _randomNumberGenerator;
|
||||
|
||||
private readonly IList<IChatClient> _connectedClients = new List<IChatClient>();
|
||||
|
||||
private readonly IList<ChatServerListener> _listeners = new List<ChatServerListener>();
|
||||
|
||||
private Timer? _clientCleanupTimer;
|
||||
private Timer? _roomCleanupTimer;
|
||||
|
||||
private ChatServerSettings? _settings;
|
||||
|
||||
private bool _isDisposed;
|
||||
|
||||
private ServerState _serverState;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ChatServer" /> class.
|
||||
/// </summary>
|
||||
/// <param name="addressResolver">The address resolver which returns the address on which the listener will be bound to.</param>
|
||||
/// <param name="loggerFactory">The logger factory.</param>
|
||||
/// <param name="plugInManager">The plug in manager.</param>
|
||||
public ChatServer(IIpAddressResolver addressResolver, ILoggerFactory loggerFactory, PlugInManager plugInManager)
|
||||
{
|
||||
this._addressResolver = addressResolver;
|
||||
this._loggerFactory = loggerFactory;
|
||||
this._plugInManager = plugInManager;
|
||||
this._logger = loggerFactory.CreateLogger<ChatServer>();
|
||||
this._manager = new ChatRoomManager(loggerFactory);
|
||||
this._randomNumberGenerator = RandomNumberGenerator.Create();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public event PropertyChangedEventHandler? PropertyChanged;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public string Description => this._settings?.Description ?? string.Empty;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public int Id => this.Settings?.ServerId ?? SpecialServerIds.ChatServer;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Guid ConfigurationId => this._settings?.Id ?? Guid.Empty;
|
||||
|
||||
/// <inheritdoc />
|
||||
public ServerType Type => ServerType.ChatServer;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public ServerState ServerState
|
||||
{
|
||||
get => this._serverState;
|
||||
private set
|
||||
{
|
||||
if (value != this._serverState)
|
||||
{
|
||||
this._serverState = value;
|
||||
this.RaisePropertyChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public int MaximumConnections => this.Settings.MaximumConnections;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public int CurrentConnections => this._connectedClients.Count;
|
||||
|
||||
private ChatServerSettings Settings => this._settings ?? throw new InvalidOperationException("The server was not initialized before");
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async ValueTask<ChatServerAuthenticationInfo?> RegisterClientAsync(ushort roomId, string clientName)
|
||||
{
|
||||
var room = this._manager.GetChatRoom(roomId);
|
||||
if (room is null)
|
||||
{
|
||||
var errorMessage = $"RegisterClient: Could not find chat room with id {roomId} for '{clientName}'.";
|
||||
this._logger.LogError(errorMessage);
|
||||
throw new ArgumentException(errorMessage, nameof(roomId));
|
||||
}
|
||||
|
||||
var ipAddress = await this._addressResolver.ResolveIPv4Async().ConfigureAwait(false);
|
||||
var index = room.GetNextClientIndex();
|
||||
var authenticationInfo = new ChatServerAuthenticationInfo(index, roomId, clientName, ipAddress.ToString(), this.GetRandomAuthenticationToken(index));
|
||||
room.RegisterClient(authenticationInfo);
|
||||
return authenticationInfo;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public ValueTask<ushort> CreateChatRoomAsync()
|
||||
{
|
||||
return ValueTask.FromResult(this._manager.CreateChatRoom());
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await this.StartAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts the listener of this chat server instance.
|
||||
/// </summary>
|
||||
public async ValueTask StartAsync()
|
||||
{
|
||||
if (this.ServerState != ServerState.Stopped)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
this._logger.LogInformation("Begin starting");
|
||||
var oldState = this.ServerState;
|
||||
this.ServerState = OpenMU.Interfaces.ServerState.Starting;
|
||||
try
|
||||
{
|
||||
this.CreateListeners();
|
||||
foreach (var listener in this._listeners)
|
||||
{
|
||||
listener.Start();
|
||||
}
|
||||
|
||||
this.CreateCleanupTimers();
|
||||
|
||||
this.ServerState = OpenMU.Interfaces.ServerState.Started;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
this._logger.LogError(ex, "Error while starting");
|
||||
this.ServerState = oldState;
|
||||
}
|
||||
|
||||
this._logger.LogInformation("Finished starting");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await this.ShutdownAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes the server with the specified settings.
|
||||
/// </summary>
|
||||
/// <param name="settings">The settings.</param>
|
||||
/// <exception cref="System.InvalidOperationException">Can only initialize when server is stopped.</exception>
|
||||
public void Initialize(ChatServerSettings settings)
|
||||
{
|
||||
if (this.ServerState != ServerState.Stopped)
|
||||
{
|
||||
throw new InvalidOperationException("Can only initialize when server is stopped.");
|
||||
}
|
||||
|
||||
this._settings = settings;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async ValueTask ShutdownAsync()
|
||||
{
|
||||
if (this.ServerState != ServerState.Started)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
this._logger.LogInformation("Begin shutdown");
|
||||
this.ServerState = OpenMU.Interfaces.ServerState.Stopping;
|
||||
this.RemoveCleanupTimers();
|
||||
foreach (var listener in this._listeners)
|
||||
{
|
||||
listener.Stop();
|
||||
}
|
||||
|
||||
this._listeners.Clear();
|
||||
|
||||
this._logger.LogDebug("Disconnecting all clients");
|
||||
var clients = this._connectedClients.ToList();
|
||||
foreach (var client in clients)
|
||||
{
|
||||
try
|
||||
{
|
||||
await client.LogOffAsync().ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
this._logger.LogError(ex, "Error logging client off.");
|
||||
}
|
||||
}
|
||||
|
||||
this.ServerState = OpenMU.Interfaces.ServerState.Stopped;
|
||||
this._logger.LogInformation("Finished shutdown");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
if (!this._isDisposed)
|
||||
{
|
||||
this._isDisposed = true;
|
||||
this._randomNumberGenerator.Dispose();
|
||||
this._clientCleanupTimer?.Dispose();
|
||||
this._roomCleanupTimer?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private void CreateCleanupTimers()
|
||||
{
|
||||
this._clientCleanupTimer = new Timer(this.Settings.ClientCleanUpInterval.TotalMilliseconds);
|
||||
this._clientCleanupTimer.Elapsed += this.ClientCleanupInactiveClients;
|
||||
this._clientCleanupTimer.Start();
|
||||
this._roomCleanupTimer = new Timer(this.Settings.RoomCleanUpInterval.TotalMilliseconds);
|
||||
this._roomCleanupTimer.Elapsed += this.ClientCleanupUnusedRooms;
|
||||
this._roomCleanupTimer.Start();
|
||||
}
|
||||
|
||||
private void RemoveCleanupTimers()
|
||||
{
|
||||
this._clientCleanupTimer?.Stop();
|
||||
this._clientCleanupTimer?.Dispose();
|
||||
this._clientCleanupTimer = null;
|
||||
|
||||
this._roomCleanupTimer?.Stop();
|
||||
this._roomCleanupTimer?.Dispose();
|
||||
this._roomCleanupTimer = null;
|
||||
}
|
||||
|
||||
private void CreateListeners()
|
||||
{
|
||||
foreach (var endpoint in this.Settings.Endpoints)
|
||||
{
|
||||
var listener = new ChatServerListener(endpoint, this._plugInManager, this._loggerFactory);
|
||||
listener.ClientAccepted += this.ChatClientAcceptedAsync;
|
||||
listener.ClientAccepting += this.ChatClientAcceptingAsync;
|
||||
this._listeners.Add(listener);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a random authentication token.
|
||||
/// </summary>
|
||||
/// <param name="clientIndex">Index of the client.</param>
|
||||
/// <returns>The random authentication token as a string.</returns>
|
||||
/// <remarks>
|
||||
/// This is the original way of generating the token - not especially secure, but to keep it simple, I leave it that way.
|
||||
/// </remarks>
|
||||
private string GetRandomAuthenticationToken(byte clientIndex)
|
||||
{
|
||||
var authenticationToken = new byte[] { clientIndex, 0, 0, 0 };
|
||||
this._randomNumberGenerator.GetBytes(authenticationToken, 2, 2);
|
||||
var tokenAsString = authenticationToken.MakeDwordBigEndian(0).ToString();
|
||||
return tokenAsString;
|
||||
}
|
||||
|
||||
private async ValueTask ChatClientAcceptingAsync(CancelEventArgs e)
|
||||
{
|
||||
if (this.Settings.MaximumConnections == int.MaxValue)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
e.Cancel = this.CurrentConnections >= this.Settings.MaximumConnections;
|
||||
}
|
||||
|
||||
private async ValueTask ChatClientAcceptedAsync(ClientAcceptedEventArgs e)
|
||||
{
|
||||
var chatClient = new ChatClient(e.AcceptedConnection, this._manager, this._loggerFactory.CreateLogger<ChatClient>());
|
||||
this._connectedClients.Add(chatClient);
|
||||
this.RaisePropertyChanged(nameof(this.CurrentConnections));
|
||||
chatClient.Disconnected += this.ChatClientDisconnected;
|
||||
}
|
||||
|
||||
private void ChatClientDisconnected(object? sender, EventArgs e)
|
||||
{
|
||||
if (sender is IChatClient client)
|
||||
{
|
||||
this._connectedClients.Remove(client);
|
||||
}
|
||||
|
||||
this.RaisePropertyChanged(nameof(this.CurrentConnections));
|
||||
}
|
||||
|
||||
[System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "VSTHRD100:Avoid async void methods", Justification = "Catching all Exceptions.")]
|
||||
private async void ClientCleanupInactiveClients(object? sender, ElapsedEventArgs e)
|
||||
{
|
||||
try
|
||||
{
|
||||
var bottomDateTimeMargin = DateTime.Now.Subtract(this.Settings.ClientTimeout);
|
||||
|
||||
for (int i = this._connectedClients.Count - 1; i >= 0; i--)
|
||||
{
|
||||
var client = this._connectedClients[i];
|
||||
if (client.LastActivity >= bottomDateTimeMargin)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
this._logger.LogDebug(
|
||||
"Disconnecting client {Client}, because of activity timeout. LastActivity: {ClientLastActivity}", client, client.LastActivity);
|
||||
|
||||
await client.LogOffAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
this._logger.LogError(ex, "Error during checking for inactive clients");
|
||||
}
|
||||
}
|
||||
|
||||
private void ClientCleanupUnusedRooms(object? sender, ElapsedEventArgs e)
|
||||
{
|
||||
try
|
||||
{
|
||||
var rooms = this._manager.OpenedRooms.Where(room => room.AuthenticationRequiredUntil < DateTime.Now && room.ConnectedClients.Count < 2).ToList();
|
||||
foreach (var room in rooms)
|
||||
{
|
||||
this._logger.LogInformation($"Cleaning up room {room.RoomId}");
|
||||
room.Close();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
this._logger.LogError(ex, "Error during cleanup of unused rooms");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called when a property changed.
|
||||
/// </summary>
|
||||
/// <param name="propertyName">Name of the property.</param>
|
||||
private void RaisePropertyChanged([CallerMemberName] string? propertyName = null)
|
||||
{
|
||||
this.PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
|
||||
}
|
||||
}
|
||||
23
src/ChatServer/ChatServerEndpoint.cs
Normal file
23
src/ChatServer/ChatServerEndpoint.cs
Normal file
@@ -0,0 +1,23 @@
|
||||
// <copyright file="ChatServerEndpoint.cs" company="MUnique">
|
||||
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
|
||||
// </copyright>
|
||||
|
||||
namespace MUnique.OpenMU.ChatServer;
|
||||
|
||||
using MUnique.OpenMU.Network.PlugIns;
|
||||
|
||||
/// <summary>
|
||||
/// A client-version-specific endpoint for a chat server.
|
||||
/// </summary>
|
||||
public class ChatServerEndpoint
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the tcp network port under which the server is listening for new clients.
|
||||
/// </summary>
|
||||
public int NetworkPort { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the client version for which the endpoint is meant for.
|
||||
/// </summary>
|
||||
public ClientVersion ClientVersion { get; set; }
|
||||
}
|
||||
72
src/ChatServer/ChatServerListener.cs
Normal file
72
src/ChatServer/ChatServerListener.cs
Normal file
@@ -0,0 +1,72 @@
|
||||
// <copyright file="ChatServerListener.cs" company="MUnique">
|
||||
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
|
||||
// </copyright>
|
||||
|
||||
namespace MUnique.OpenMU.ChatServer;
|
||||
|
||||
using System.ComponentModel;
|
||||
using System.IO.Pipelines;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using MUnique.OpenMU.Network;
|
||||
using MUnique.OpenMU.Network.PlugIns;
|
||||
using MUnique.OpenMU.PlugIns;
|
||||
|
||||
/// <summary>
|
||||
/// A listener which listens to the specified endpoint and provides the initialized <see cref="IConnection"/> by the event <see cref="ClientAccepted"/>.
|
||||
/// </summary>
|
||||
public class ChatServerListener
|
||||
{
|
||||
private readonly ChatServerEndpoint _endpoint;
|
||||
private readonly PlugInManager _plugInManager;
|
||||
private readonly ILoggerFactory _loggerFactory;
|
||||
private Listener? _chatClientListener;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ChatServerListener" /> class.
|
||||
/// </summary>
|
||||
/// <param name="endpoint">The endpoint.</param>
|
||||
/// <param name="plugInManager">The plug in manager.</param>
|
||||
/// <param name="loggerFactory">The logger factory.</param>
|
||||
public ChatServerListener(ChatServerEndpoint endpoint, PlugInManager plugInManager, ILoggerFactory loggerFactory)
|
||||
{
|
||||
this._endpoint = endpoint;
|
||||
this._plugInManager = plugInManager;
|
||||
this._loggerFactory = loggerFactory;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Occurs when a new client was accepted.
|
||||
/// </summary>
|
||||
public event AsyncEventHandler<ClientAcceptedEventArgs>? ClientAccepted;
|
||||
|
||||
/// <summary>
|
||||
/// Occurs when a client has been accepted by the tcp listener, but before a <see cref="Connection"/> is created.
|
||||
/// </summary>
|
||||
public event AsyncEventHandler<CancelEventArgs>? ClientAccepting;
|
||||
|
||||
/// <summary>
|
||||
/// Starts the tcp listener of this instance.
|
||||
/// </summary>
|
||||
public void Start()
|
||||
{
|
||||
this._chatClientListener = new Listener(this._endpoint.NetworkPort, this.CreateDecryptor, _ => null, this._loggerFactory);
|
||||
this._chatClientListener.ClientAccepted += async args => await this.ClientAccepted.SafeInvokeAsync(args).ConfigureAwait(false);
|
||||
this._chatClientListener.ClientAccepting += async args => await this.ClientAccepting.SafeInvokeAsync(args).ConfigureAwait(false);
|
||||
this._chatClientListener.Start();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stops this instance.
|
||||
/// </summary>
|
||||
public void Stop()
|
||||
{
|
||||
this._chatClientListener?.Stop();
|
||||
}
|
||||
|
||||
private IPipelinedDecryptor? CreateDecryptor(PipeReader pipeReader)
|
||||
{
|
||||
var encryptionFactoryPlugIn = this._plugInManager.GetStrategy<ClientVersion, INetworkEncryptionFactoryPlugIn>(this._endpoint.ClientVersion)
|
||||
?? this._plugInManager.GetStrategy<ClientVersion, INetworkEncryptionFactoryPlugIn>(default);
|
||||
return encryptionFactoryPlugIn?.CreateDecryptor(pipeReader, DataDirection.ClientToServer);
|
||||
}
|
||||
}
|
||||
57
src/ChatServer/ChatServerSettings.cs
Normal file
57
src/ChatServer/ChatServerSettings.cs
Normal file
@@ -0,0 +1,57 @@
|
||||
// <copyright file="ChatServerSettings.cs" company="MUnique">
|
||||
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
|
||||
// </copyright>
|
||||
|
||||
namespace MUnique.OpenMU.ChatServer;
|
||||
|
||||
using MUnique.OpenMU.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Settings for the <see cref="ChatServer"/>.
|
||||
/// </summary>
|
||||
public class ChatServerSettings
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the identifier for this configuration.
|
||||
/// </summary>
|
||||
public Guid Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the server identifier.
|
||||
/// </summary>
|
||||
public int ServerId { get; set; } = SpecialServerIds.ChatServer;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the description.
|
||||
/// </summary>
|
||||
public string Description { get; set; } = "Chat Server";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the maximum connections.
|
||||
/// </summary>
|
||||
public int MaximumConnections { get; set; } = int.MaxValue;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the client timeout. When a client did not send any data in this timespan, it's automatically disconnected.
|
||||
/// </summary>
|
||||
/// <value>
|
||||
/// The client timeout.
|
||||
/// </value>
|
||||
public TimeSpan ClientTimeout { get; set; } = TimeSpan.FromMinutes(1);
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the interval in which a client clean up takes place.
|
||||
/// For all connected clients it's checked whether or not the <see cref="ClientTimeout"/> has been reached.
|
||||
/// </summary>
|
||||
public TimeSpan ClientCleanUpInterval { get; set; } = TimeSpan.FromMinutes(1);
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the interval in which empty chat rooms are cleaned up.
|
||||
/// </summary>
|
||||
public TimeSpan RoomCleanUpInterval { get; set; } = TimeSpan.FromSeconds(5);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the endpoints under which the chat server is available for specific game clients.
|
||||
/// </summary>
|
||||
public ICollection<ChatServerEndpoint> Endpoints { get; } = new List<ChatServerEndpoint>();
|
||||
}
|
||||
17
src/ChatServer/ExDbConnector/ChatServer.cfg
Normal file
17
src/ChatServer/ExDbConnector/ChatServer.cfg
Normal file
@@ -0,0 +1,17 @@
|
||||
##############################
|
||||
# ChatServer Configuration #
|
||||
##############################
|
||||
|
||||
# The following values are defaults and are even applied if this file or single configuration value-pairs are missing or are in the wrong format:
|
||||
# ChatServerListenerPort=55980
|
||||
# ExDbHost=127.0.0.1
|
||||
# ExDbPort=55906
|
||||
# Xor32Key=AB 11 CD FE 18 23 C5 A3 CA 33 C1 CC 66 67 21 F3 32 12 15 35 29 FF FE 1D 44 EF CD 41 26 3C 4E 4D
|
||||
|
||||
|
||||
ChatServerListenerPort=55980
|
||||
|
||||
ExDbHost=127.0.0.1
|
||||
ExDbPort=55906
|
||||
|
||||
Xor32Key=AB 11 CD FE 18 23 C5 A3 CA 33 C1 CC 66 67 21 F3 32 12 15 35 29 FF FE 1D 44 EF CD 41 26 3C 4E 4D
|
||||
@@ -0,0 +1,55 @@
|
||||
// <copyright file="ConfigurableNetworkEncryptionPlugIn.cs" company="MUnique">
|
||||
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
|
||||
// </copyright>
|
||||
|
||||
namespace MUnique.OpenMU.ChatServer.ExDbConnector;
|
||||
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.IO.Pipelines;
|
||||
using System.Runtime.InteropServices;
|
||||
using MUnique.OpenMU.Network;
|
||||
using MUnique.OpenMU.Network.PlugIns;
|
||||
using MUnique.OpenMU.Network.Xor;
|
||||
using MUnique.OpenMU.PlugIns;
|
||||
|
||||
/// <summary>
|
||||
/// A configurable network encryption factory plugin which reads the Xor32 key from the ChatServer.cfg file. Only used by the ExDbConnector project.
|
||||
/// </summary>
|
||||
[PlugIn]
|
||||
[Display(Name = "Configurable encryption plugin", Description = "A configurable network encryption factory plugin which reads the Xor32 key from the ChatServer.cfg file. Only used by the ExDbConnector project.")]
|
||||
[Guid("890997B2-9334-4E9E-8C82-4492A831BCE3")]
|
||||
public class ConfigurableNetworkEncryptionPlugIn : INetworkEncryptionFactoryPlugIn
|
||||
{
|
||||
private readonly byte[] _xor32Key;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ConfigurableNetworkEncryptionPlugIn"/> class.
|
||||
/// </summary>
|
||||
public ConfigurableNetworkEncryptionPlugIn()
|
||||
{
|
||||
var settings = new Settings("ChatServer.cfg");
|
||||
this._xor32Key = settings.Xor32Key ?? new byte[32];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the version for which this plugin is available.
|
||||
/// </summary>
|
||||
public static ClientVersion Version { get; } = new(byte.MaxValue, byte.MaxValue, ClientLanguage.Invariant);
|
||||
|
||||
/// <inheritdoc />
|
||||
public ClientVersion Key => Version;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IPipelinedDecryptor? CreateDecryptor(PipeReader source, DataDirection direction)
|
||||
{
|
||||
return new PipelinedXor32Decryptor(source, this._xor32Key);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IPipelinedEncryptor? CreateEncryptor(PipeWriter target, DataDirection direction)
|
||||
{
|
||||
// At least until season 6, there is no encryption from server to client.
|
||||
// ex700 may require packet twister here.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
275
src/ChatServer/ExDbConnector/ExDbClient.cs
Normal file
275
src/ChatServer/ExDbConnector/ExDbClient.cs
Normal file
@@ -0,0 +1,275 @@
|
||||
// <copyright file="ExDbClient.cs" company="MUnique">
|
||||
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
|
||||
// </copyright>
|
||||
|
||||
namespace MUnique.OpenMU.ChatServer.ExDbConnector;
|
||||
|
||||
using System.Buffers;
|
||||
using System.Net.Sockets;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using MUnique.OpenMU.Interfaces;
|
||||
using MUnique.OpenMU.Network;
|
||||
using MUnique.OpenMU.Network.Packets;
|
||||
using Pipelines.Sockets.Unofficial;
|
||||
using static System.Buffers.Binary.BinaryPrimitives;
|
||||
|
||||
/// <summary>
|
||||
/// The connected exDB server. This class includes the communication implementation between chat server and exDB server.
|
||||
/// It registers clients for the chat server and hands back their authentication details.
|
||||
/// </summary>
|
||||
public class ExDbClient
|
||||
{
|
||||
private readonly ILogger<ExDbClient> _logger;
|
||||
|
||||
private readonly string _host;
|
||||
private readonly int _port;
|
||||
private readonly IChatServer _chatServer;
|
||||
private readonly ILoggerFactory _loggerFactory;
|
||||
private readonly ushort _chatServerPort;
|
||||
private readonly byte[] _packetBuffer = new byte[0xFF];
|
||||
|
||||
private IConnection? _connection;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ExDbClient" /> class.
|
||||
/// </summary>
|
||||
/// <param name="host">The host address of the exDB server.</param>
|
||||
/// <param name="port">The host port of the exDB server.</param>
|
||||
/// <param name="chatServer">The chat server.</param>
|
||||
/// <param name="chatServerPort">The chat server port.</param>
|
||||
/// <param name="loggerFactory">The logger factory.</param>
|
||||
public ExDbClient(string host, int port, IChatServer chatServer, int chatServerPort, ILoggerFactory loggerFactory)
|
||||
{
|
||||
this._host = host;
|
||||
this._port = port;
|
||||
this._chatServer = chatServer;
|
||||
this._loggerFactory = loggerFactory;
|
||||
this._chatServerPort = (ushort)chatServerPort;
|
||||
this._logger = this._loggerFactory.CreateLogger<ExDbClient>();
|
||||
_ = Task.Run(this.ConnectAsync);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Disconnects the exDB server.
|
||||
/// </summary>
|
||||
public async ValueTask DisconnectAsync()
|
||||
{
|
||||
if (this._connection is { } connection)
|
||||
{
|
||||
await connection.DisconnectAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private async ValueTask ConnectAsync()
|
||||
{
|
||||
var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||
|
||||
while (!socket.Connected)
|
||||
{
|
||||
try
|
||||
{
|
||||
await socket.ConnectAsync(this._host, this._port).ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
this._logger.LogWarning($"Connection to ExDB-Server ({this._host}:{this._port}) failed, trying again in 10 Seconds...");
|
||||
await Task.Delay(10000).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
this._logger.LogInformation("Connection to ExDB-Server established");
|
||||
|
||||
this._connection = new Connection(SocketConnection.Create(socket), null, null, this._loggerFactory.CreateLogger<Connection>());
|
||||
this._connection.PacketReceived += this.ExDbPacketReceivedAsync;
|
||||
this._connection.Disconnected += this.ConnectAsync;
|
||||
await this.SendHelloAsync().ConfigureAwait(false);
|
||||
await this._connection!.BeginReceiveAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async ValueTask SendHelloAsync()
|
||||
{
|
||||
// C1 3A 00 02 AC DA 43 68 61 74 53 65 72 76 65 72 00 ...
|
||||
int Write()
|
||||
{
|
||||
var length = 0x3A;
|
||||
var span = this._connection!.Output.GetSpan(length)[..length];
|
||||
var packet = span;
|
||||
packet[0] = 0xC1;
|
||||
packet[1] = 0x3A;
|
||||
packet[3] = 0x02;
|
||||
packet[4] = this._chatServerPort.GetLowByte();
|
||||
packet[5] = this._chatServerPort.GetHighByte();
|
||||
packet.Slice(6).WriteString("ChatServer", Encoding.UTF8);
|
||||
return length;
|
||||
}
|
||||
|
||||
await this._connection!.SendAsync(Write).ConfigureAwait(false);
|
||||
this._logger.LogInformation("Sent registration packet to ExDB-Server");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Is called when a packet is received from the exDB-Server.
|
||||
/// </summary>
|
||||
/// <param name="sequence">The packet.</param>
|
||||
private async ValueTask ExDbPacketReceivedAsync(ReadOnlySequence<byte> sequence)
|
||||
{
|
||||
try
|
||||
{
|
||||
sequence.CopyTo(this._packetBuffer);
|
||||
var packet = this._packetBuffer.AsMemory(0, (int)sequence.Length);
|
||||
var type = packet.Span[0];
|
||||
if (type != 0xC1)
|
||||
{
|
||||
this._logger.LogWarning($"Unknown packet received from ExDB-Server, type: {type}");
|
||||
return;
|
||||
}
|
||||
|
||||
var code = packet.Span[2];
|
||||
switch (code)
|
||||
{
|
||||
case 0xA0:
|
||||
await this.ReadChatRoomCreationAsync(packet).ConfigureAwait(false);
|
||||
break;
|
||||
case 0xA1:
|
||||
await this.ReadChatRoomInvitationAsync(packet).ConfigureAwait(false);
|
||||
break;
|
||||
default:
|
||||
this._logger.LogWarning($"Unknown packet received from ExDB-Server, code: {code}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
this._logger.LogError(exception, $"An error occurred while processing an incoming packet from ExDB: {this._packetBuffer.AsString()}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the invitation to an existing chat room and registers the invited client.
|
||||
/// </summary>
|
||||
/// <param name="packet">The packet.</param>
|
||||
/// <remarks>
|
||||
/// Example: C1 15 A1 00 00 00 61 62 63 64 65 66 67 68 69 6F 20 01 00 01 57
|
||||
/// Index 4 and 5 is the room id, the next 10 bytes is the client name, after that the player id, game server id and a "type".
|
||||
/// The chat server answers this with the same packets as above(ticket 96862210):
|
||||
/// C1 2C A0 01 00 00 61 62 63 64 65 66 67 68 69 6F CC CC CC CC CC CC CC CC CC CC 53 54 55 56 CC CC 02 00 C6 05 CC CC CC CC 57 CC CC CC.
|
||||
/// </remarks>
|
||||
private async ValueTask ReadChatRoomInvitationAsync(Memory<byte> packet)
|
||||
{
|
||||
ushort roomId = 0;
|
||||
string clientName = string.Empty;
|
||||
ushort clientPlayerId = 0;
|
||||
ushort clientServerId = 0;
|
||||
byte type = 0;
|
||||
|
||||
void Extract(Span<byte> packet)
|
||||
{
|
||||
roomId = NumberConversionExtensions.MakeWord(packet[4], packet[5]);
|
||||
clientName = packet.ExtractString(6, 10, Encoding.UTF8);
|
||||
clientPlayerId = packet.TryMakeWordBigEndian(16);
|
||||
clientServerId = packet.TryMakeWordBigEndian(18);
|
||||
type = packet.Length > 20 ? packet[20] : (byte)0x57;
|
||||
}
|
||||
|
||||
Extract(packet.Span);
|
||||
this._logger.LogDebug($"Received request to invite {clientName} to chat room {roomId}, Client-ID: {clientPlayerId}, Server-ID: {clientServerId}");
|
||||
if (await this._chatServer.RegisterClientAsync(roomId, clientName).ConfigureAwait(false) is { } authentication)
|
||||
{
|
||||
await this.SendAuthenticationAsync(authentication, null, clientPlayerId, clientServerId, type).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the chat room creation message, creates a new chat room and registers the clients.
|
||||
/// </summary>
|
||||
/// <param name="packet">The packet.</param>
|
||||
/// <remarks>
|
||||
/// For example, we get here the following packet in:
|
||||
/// C1 20 A0 41 42 43 44 45 46 47 48 49 4A 50 51 52 53 54 55 56 57 58 59 00 E0 2E 01 00 E1 2E 01 00
|
||||
/// This packet includes the header and both names of the creator and the invited chat partner (each 10 bytes long).
|
||||
/// The server should then send the following data back to the exDB-Server:
|
||||
/// s | rid ||-----client name-----------||---------other client name-||plid| |svid||---| |-ticket--| |--------???----------|
|
||||
/// C1 2C A0 01 00 00 41 42 43 44 45 46 47 48 49 4A 50 51 52 53 54 55 56 57 58 59 00 00 00 00 CC CC 00 00 11 04 CC CC CC CC 00 CC CC CC
|
||||
/// C1 2C A0 01 00 00 50 51 52 53 54 55 56 57 58 59 41 42 43 44 45 46 47 48 49 4A 00 00 00 00 CC CC 01 00 BB 05 CC CC CC CC 01 CC CC CC.
|
||||
/// </remarks>
|
||||
private async ValueTask ReadChatRoomCreationAsync(Memory<byte> packet)
|
||||
{
|
||||
string clientName = string.Empty;
|
||||
string friendName = string.Empty;
|
||||
ushort clientPlayerId = 0;
|
||||
ushort clientServerId = 0;
|
||||
ushort friendPlayerId = 0;
|
||||
ushort friendServerId = 0;
|
||||
|
||||
void Extract(Span<byte> packet)
|
||||
{
|
||||
clientName = packet.ExtractString(3, 10, Encoding.UTF8);
|
||||
friendName = packet.ExtractString(13, 10, Encoding.UTF8);
|
||||
clientPlayerId = packet.TryMakeWordBigEndian(24);
|
||||
clientServerId = packet.TryMakeWordBigEndian(26);
|
||||
friendPlayerId = packet.TryMakeWordBigEndian(28);
|
||||
friendServerId = packet.TryMakeWordBigEndian(30);
|
||||
}
|
||||
|
||||
Extract(packet.Span);
|
||||
var roomId = await this._chatServer.CreateChatRoomAsync().ConfigureAwait(false);
|
||||
this._logger.LogDebug($"Received request to create chat room for {clientName} and {friendName}; Room-ID: {roomId}; Client-ID: {clientPlayerId}; Server-ID: {clientServerId}; Friend-ID: {friendPlayerId}; Friend-Server: {friendServerId}");
|
||||
var requesterAuthentication = await this._chatServer.RegisterClientAsync(roomId, clientName).ConfigureAwait(false);
|
||||
var friendAuthentication = await this._chatServer.RegisterClientAsync(roomId, friendName).ConfigureAwait(false);
|
||||
if (requesterAuthentication is not null)
|
||||
{
|
||||
await this.SendAuthenticationAsync(requesterAuthentication, friendAuthentication, clientPlayerId, clientServerId, requesterAuthentication.Index).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (friendAuthentication is not null)
|
||||
{
|
||||
await this.SendAuthenticationAsync(friendAuthentication, requesterAuthentication, friendPlayerId, friendServerId, friendAuthentication.Index).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends the authentication information back to the ExDB-Server.
|
||||
/// </summary>
|
||||
/// <param name="authenticationInfo">The authentication information.</param>
|
||||
/// <param name="friendAuthenticationInfo">The friend authentication information.</param>
|
||||
/// <param name="clientId">The client identifier on the server where the client plays on.</param>
|
||||
/// <param name="serverId">The server identifier where the client plays on.</param>
|
||||
/// <param name="type">The type. Usually 0 for the player who requested the chat and 1 for the other player.</param>
|
||||
private async ValueTask SendAuthenticationAsync(ChatServerAuthenticationInfo authenticationInfo, ChatServerAuthenticationInfo? friendAuthenticationInfo, ushort clientId, ushort serverId, byte type)
|
||||
{
|
||||
this._logger.LogDebug($"Registered client {authenticationInfo.ClientName} with index {authenticationInfo.Index} and token {authenticationInfo.AuthenticationToken}");
|
||||
var token = uint.Parse(authenticationInfo.AuthenticationToken);
|
||||
uint friendToken = 0;
|
||||
if (friendAuthenticationInfo != null)
|
||||
{
|
||||
friendToken = uint.Parse(friendAuthenticationInfo.AuthenticationToken);
|
||||
}
|
||||
|
||||
var roomId = authenticationInfo.RoomId;
|
||||
|
||||
int Write()
|
||||
{
|
||||
var length = 0x2C;
|
||||
var packet = this._connection!.Output.GetSpan(length);
|
||||
packet[0] = 0xC1;
|
||||
packet[1] = 0x2C;
|
||||
packet[2] = 0xA0;
|
||||
packet[3] = 0x01;
|
||||
WriteUInt16LittleEndian(packet.Slice(4), roomId);
|
||||
packet.Slice(6).WriteString(authenticationInfo.ClientName, Encoding.UTF8);
|
||||
if (friendAuthenticationInfo != null)
|
||||
{
|
||||
packet.Slice(16).WriteString(friendAuthenticationInfo.ClientName, Encoding.UTF8);
|
||||
}
|
||||
|
||||
WriteUInt16LittleEndian(packet.Slice(26), clientId);
|
||||
WriteUInt16LittleEndian(packet.Slice(28), serverId);
|
||||
WriteUInt32LittleEndian(packet.Slice(32), token);
|
||||
WriteUInt32LittleEndian(packet.Slice(36), friendToken);
|
||||
packet[40] = type;
|
||||
return length;
|
||||
}
|
||||
|
||||
await this._connection!.SendAsync(Write).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<WarningsAsErrors>nullable;CS4014;VSTHRD110;VSTHRD100</WarningsAsErrors>
|
||||
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
|
||||
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
|
||||
<AssemblyName>ChatServer</AssemblyName>
|
||||
<PlatformTarget>AnyCPU</PlatformTarget>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
|
||||
<OutputPath>bin\Debug\</OutputPath>
|
||||
<DocumentationFile>bin\Debug\MUnique.OpenMU.ChatServer.ExDbConnector.xml</DocumentationFile>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
|
||||
<OutputPath>bin\Release\</OutputPath>
|
||||
<DocumentationFile>bin\Release\MUnique.OpenMU.ChatServer.ExDbConnector.xml</DocumentationFile>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.FileExtensions" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="8.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.FileProviders.Physical" Version="8.0.0" />
|
||||
<PackageReference Include="Serilog" Version="3.1.1" />
|
||||
<PackageReference Include="Serilog.Extensions.Hosting" Version="8.0.0" />
|
||||
<PackageReference Include="Serilog.Settings.Configuration" Version="3.4.0" />
|
||||
<PackageReference Include="Serilog.Sinks.Console" Version="4.1.0" />
|
||||
<PackageReference Include="Serilog.Sinks.File" Version="5.0.0" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\PlugIns\MUnique.OpenMU.PlugIns.csproj" />
|
||||
<ProjectReference Include="..\MUnique.OpenMU.ChatServer.csproj" />
|
||||
<ProjectReference Include="..\..\Network\MUnique.OpenMU.Network.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<None Update="appsettings.json">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Update="ChatServer.cfg">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,43 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<WarningsAsErrors>nullable;CS4014;VSTHRD110;VSTHRD100</WarningsAsErrors>
|
||||
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
|
||||
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
|
||||
<AssemblyName>ChatServer</AssemblyName>
|
||||
<PlatformTarget>AnyCPU</PlatformTarget>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
|
||||
<OutputPath>bin\Debug\</OutputPath>
|
||||
<DocumentationFile>bin\Debug\MUnique.OpenMU.ChatServer.ExDbConnector.xml</DocumentationFile>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
|
||||
<OutputPath>bin\Release\</OutputPath>
|
||||
<DocumentationFile>bin\Release\MUnique.OpenMU.ChatServer.ExDbConnector.xml</DocumentationFile>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.FileExtensions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" />
|
||||
<PackageReference Include="Microsoft.Extensions.FileProviders.Physical" />
|
||||
<PackageReference Include="Serilog" />
|
||||
<PackageReference Include="Serilog.Extensions.Hosting" />
|
||||
<PackageReference Include="Serilog.Settings.Configuration" />
|
||||
<PackageReference Include="Serilog.Sinks.Console" />
|
||||
<PackageReference Include="Serilog.Sinks.File" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\PlugIns\MUnique.OpenMU.PlugIns.csproj" />
|
||||
<ProjectReference Include="..\MUnique.OpenMU.ChatServer.csproj" />
|
||||
<ProjectReference Include="..\..\Network\MUnique.OpenMU.Network.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<None Update="appsettings.json">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Update="ChatServer.cfg">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
79
src/ChatServer/ExDbConnector/Program.cs
Normal file
79
src/ChatServer/ExDbConnector/Program.cs
Normal file
@@ -0,0 +1,79 @@
|
||||
// <copyright file="Program.cs" company="MUnique">
|
||||
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
|
||||
// </copyright>
|
||||
|
||||
namespace MUnique.OpenMU.ChatServer.ExDbConnector;
|
||||
|
||||
using System.ComponentModel.Design;
|
||||
using System.IO;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using MUnique.OpenMU.ChatServer;
|
||||
using MUnique.OpenMU.Network;
|
||||
using MUnique.OpenMU.Network.PlugIns;
|
||||
using MUnique.OpenMU.PlugIns;
|
||||
using Serilog;
|
||||
using Serilog.Debugging;
|
||||
|
||||
/// <summary>
|
||||
/// The main entry class of the application.
|
||||
/// </summary>
|
||||
internal class Program
|
||||
{
|
||||
private static ILogger<Program> _logger = NullLogger<Program>.Instance;
|
||||
|
||||
/// <summary>
|
||||
/// The main entry point for the application.
|
||||
/// </summary>
|
||||
/// <param name="args">The arguments. </param>
|
||||
internal static async Task Main(string[] args)
|
||||
{
|
||||
SelfLog.Enable(Console.Error);
|
||||
var logConfiguration = new ConfigurationBuilder()
|
||||
.SetBasePath(Directory.GetCurrentDirectory())
|
||||
.AddJsonFile("appsettings.json", false, true)
|
||||
.Build();
|
||||
|
||||
var logger = new LoggerConfiguration()
|
||||
.ReadFrom.Configuration(logConfiguration)
|
||||
.CreateLogger();
|
||||
|
||||
var loggerFactory = new LoggerFactory().AddSerilog(logger);
|
||||
_logger = loggerFactory.CreateLogger<Program>();
|
||||
|
||||
var addressResolver = IpAddressResolverFactory.CreateIpResolver(args, null, loggerFactory);
|
||||
var settings = new Settings("ChatServer.cfg");
|
||||
var serviceContainer = new ServiceContainer();
|
||||
serviceContainer.AddService(typeof(ILoggerFactory), loggerFactory);
|
||||
|
||||
int chatServerListenerPort = settings.ChatServerListenerPort ?? 55980;
|
||||
int exDbPort = settings.ExDbPort ?? 55906;
|
||||
string exDbHost = settings.ExDbHost ?? "127.0.0.1";
|
||||
|
||||
try
|
||||
{
|
||||
// To make the chat server use our configured encryption key, we need to trick a bit. We add an endpoint with a special client version which is defined in the plugin.
|
||||
var configuration = new ChatServerSettings();
|
||||
configuration.Endpoints.Add(new ChatServerEndpoint { ClientVersion = ConfigurableNetworkEncryptionPlugIn.Version, NetworkPort = chatServerListenerPort });
|
||||
var pluginManager = new PlugInManager(null, loggerFactory, serviceContainer, null);
|
||||
pluginManager.DiscoverAndRegisterPlugInsOf<INetworkEncryptionFactoryPlugIn>();
|
||||
var chatServer = new ChatServer(addressResolver, loggerFactory, pluginManager);
|
||||
chatServer.Initialize(configuration);
|
||||
await chatServer.StartAsync().ConfigureAwait(false);
|
||||
var exDbClient = new ExDbClient(exDbHost, exDbPort, chatServer, chatServerListenerPort, loggerFactory);
|
||||
_logger.LogInformation("ChatServer started and ready");
|
||||
while (Console.ReadLine() != "exit")
|
||||
{
|
||||
// keep application running
|
||||
}
|
||||
|
||||
await exDbClient.DisconnectAsync().ConfigureAwait(false);
|
||||
await chatServer.ShutdownAsync().ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogCritical(ex, "Unexpected error occured");
|
||||
}
|
||||
}
|
||||
}
|
||||
12
src/ChatServer/ExDbConnector/Properties/AssemblyInfo.cs
Normal file
12
src/ChatServer/ExDbConnector/Properties/AssemblyInfo.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
// <copyright file="AssemblyInfo.cs" company="MUnique">
|
||||
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
|
||||
// </copyright>
|
||||
|
||||
using System.Reflection;
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
// General Information about an assembly is controlled through the following
|
||||
// set of attributes. Change these attribute values to modify the information
|
||||
// associated with an assembly.
|
||||
[assembly: AssemblyTitle("MUnique.OpenMU.ChatServer.ExDbConnector")]
|
||||
[assembly: InternalsVisibleTo("MUnique.OpenMU.ChatServer.ExDbConnector.Tests")]
|
||||
156
src/ChatServer/ExDbConnector/Readme.md
Normal file
156
src/ChatServer/ExDbConnector/Readme.md
Normal file
@@ -0,0 +1,156 @@
|
||||
# ChatServer ExDB Connector
|
||||
|
||||
This isn't directly a part of the OpenMU project. It's more like a side product
|
||||
to make the ChatServer available to users of the 'classical' private MU Servers.
|
||||
They have - or maybe had :) - the problem that they are bound to use the
|
||||
original closed source ChatServer of Webzen, if they get it working at all.
|
||||
|
||||
So to offer an open source alternative to the original ChatServer of Webzen,
|
||||
you can use this project to connect the OpenMU-ChatServer with your 'classic'
|
||||
ExDB server.
|
||||
|
||||
## Configuration
|
||||
|
||||
To make this work correctly with an existing ExDB-Server, some might do some
|
||||
minor adjustments in the configuration.
|
||||
|
||||
It's all configured in the ChatServer.cfg an should be self-explanatory.
|
||||
|
||||
### ChatServerListenerPort
|
||||
|
||||
It's the port to which the game clients should connect. Default is 55980,
|
||||
but I'm not sure if it can be changed without modifying the client.
|
||||
|
||||
### ExDbHost and Port
|
||||
|
||||
The host and tcp port of the ExDB server. Usually it's on the same server, so
|
||||
127.0.0.1 on port 55906.
|
||||
|
||||
### Xor32Key
|
||||
|
||||
This one is actually very important to get right. Otherwise, the game clients
|
||||
will not be able to connect.
|
||||
It's the same XOR32 key which is used for the 0xC1 packet encryption from game
|
||||
client to game server.
|
||||
|
||||
You can't edit this key at the original ChatServer of Webzen, that's the reason
|
||||
why it's pretty hard to get the ChatServer working on a private server.
|
||||
|
||||
## Communication between ExDB-Server and ChatServer
|
||||
|
||||
The ExDB server usually leaves the tcp port 55906 open, so that the ChatServer
|
||||
(and maybe other kind of subservers?) can connect to it.
|
||||
|
||||
### Registration
|
||||
|
||||
When the ChatServer connects to the ExDB server, it sends a data packet to
|
||||
register itself. It has the following struture:
|
||||
|
||||
| Length | Data type | Value | Description |
|
||||
|----------|---------|-------------|---------|
|
||||
| 1 | byte | 0xC1 | Packet header - type |
|
||||
| 1 | byte | 0x3A | Packet header - length of the packet |
|
||||
| 1 | byte | 0x00 | Packet Type "server registration" |
|
||||
| 1 | byte | 0x02 | Id for "ChatServer" |
|
||||
| 2 | ushort | 0xDAAC | ChatServer client port (default: 55980) |
|
||||
| 11 | string | "ChatServer" | ChatServer name |
|
||||
|
||||
Example: C1 3A 00 02 AC DA 43 68 61 74 53 65 72 76 65 72 00
|
||||
|
||||
From now, the ChatServer will receive chat room creation and invitation
|
||||
requests from the ExDB Server, which were previously requested by the players.
|
||||
|
||||
### Chat Room Creation Request
|
||||
|
||||
When a client requests to create a new chat room, the following data packet is
|
||||
sent from the ExDB Server to the ChatServer.
|
||||
|
||||
| Length | Data type | Value | Description |
|
||||
|----------|---------|-------------|---------|
|
||||
| 1 | byte | 0xC1 | Packet header - type |
|
||||
| 1 | byte | 0x25 | Packet header - length of the packet |
|
||||
| 1 | byte | 0xA0 | Packet Type 'chat room creation' |
|
||||
| 10 | string | | Name of the character who wants to create the room |
|
||||
| 10 | string | | Name of the character who should be invited to the room |
|
||||
| 1 | byte | 0x01 | "Type", not relevant? |
|
||||
| 2 | ushort | | Player id of the character who wants to create the room, big endian |
|
||||
| 2 | ushort | | Server id of the character who wants to create the room, big endian |
|
||||
| 2 | ushort | | Player id of the character who should be invited, big endian |
|
||||
| 2 | ushort | | Server id of the character who should be invited, big endian |
|
||||
|
||||
Example:
|
||||
C1 25 A0 41 42 43 44 45
|
||||
46 47 48 49 4A 50 51 52
|
||||
53 54 55 56 57 58 59 01
|
||||
20 01 00 01 20 02 00 01
|
||||
|
||||
### Chat Room Creation Responses
|
||||
|
||||
For each of both players, there is one data packet sent back to the ExDB Server:
|
||||
|
||||
| Length | Data type | Value | Description |
|
||||
|----------|---------|-------------|---------|
|
||||
| 1 | byte | 0xC1 | Packet header - type |
|
||||
| 1 | byte | 0x2C | Packet header - length of the packet |
|
||||
| 1 | byte | 0xA0 | Packet Type 'chat room creation' |
|
||||
| 1 | byte | 0x01 | Success flag |
|
||||
| 2 | ushort | | Chat room id, big endian |
|
||||
| 10 | string | | Name of the character to which a chat room invitation should be sent |
|
||||
| 10 | string | | Name of the chat partner character |
|
||||
| 2 | ushort | | Player id of the character to which a chat room invitation should be sent, big endian |
|
||||
| 2 | ushort | | Server id of the character to which a chat room invitation should be sent, big endian |
|
||||
| 2 | byte | | Padding bytes for the alignment of the following authentication token |
|
||||
| 4 | uint | | Authentication token of the character to which a chat room invitation should be sent, big endian |
|
||||
| 4 | uint | | Authentication token of the chat partner, big endian |
|
||||
| 1 | byte | | 'Type' |
|
||||
| 3 | byte | | Don't know - padding?|
|
||||
|
||||
Example First Player:
|
||||
C1 2C A0 01 00 00 41 42
|
||||
43 44 45 46 47 48 49 4A
|
||||
50 51 52 53 54 55 56 57
|
||||
58 59 00 00 00 00 CC CC
|
||||
00 00 11 04 01 00 BB 05
|
||||
00 CC CC CC
|
||||
|
||||
Example Second Player:
|
||||
C1 2C A0 01 00 00 50 51
|
||||
52 53 54 55 56 57 58 59
|
||||
41 42 43 44 45 46 47 48
|
||||
49 4A 00 00 00 00 CC CC
|
||||
01 00 BB 05 00 00 11 04
|
||||
01 CC CC CC
|
||||
|
||||
### Chat Room Invitation Request
|
||||
|
||||
When a client requests to invite another friend to an existing chat room, the
|
||||
following data packet is sent from the ExDB Server to the ChatServer.
|
||||
|
||||
| Length | Data type | Value | Description |
|
||||
|----------|---------|-------------|---------|
|
||||
| 1 | byte | 0xC1 | Packet header - type |
|
||||
| 1 | byte | 0x16 | Packet header - length of the packet |
|
||||
| 1 | byte | 0xA1 | Packet Type 'chat room invitation' |
|
||||
| 1 | byte | 0x00 | Padding |
|
||||
| 2 | ushort | | Chat room id, big endian |
|
||||
| 10 | string | | Name of the character who should be invited to the room |
|
||||
| 2 | ushort | | Player id of the character to which a chat room invitation should be sent, big endian |
|
||||
| 2 | ushort | | Server id of the character to which a chat room invitation should be sent, big endian |
|
||||
| 1 | byte | | 'Type' |
|
||||
|
||||
Example:
|
||||
C1 15 A1 00 00 00 61 62
|
||||
63 64 65 66 67 68 69 6F
|
||||
01 20 01 00 57
|
||||
|
||||
The ChatServer answers this with the same packet as above, but without filling
|
||||
the second character name - no wonder, there is more than one player in the
|
||||
room already.
|
||||
|
||||
Example:
|
||||
C1 2C A0 01 00 00 61 62
|
||||
63 64 65 66 67 68 69 6F
|
||||
CC CC CC CC CC CC CC CC
|
||||
CC CC 01 20 01 00 CC CC
|
||||
02 00 C6 05 CC CC CC CC
|
||||
57 CC CC CC
|
||||
121
src/ChatServer/ExDbConnector/Settings.cs
Normal file
121
src/ChatServer/ExDbConnector/Settings.cs
Normal file
@@ -0,0 +1,121 @@
|
||||
// <copyright file="Settings.cs" company="MUnique">
|
||||
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
|
||||
// </copyright>
|
||||
|
||||
namespace MUnique.OpenMU.ChatServer.ExDbConnector;
|
||||
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
|
||||
/// <summary>
|
||||
/// A class which reads settings from a file.
|
||||
/// Line Format:
|
||||
/// [Key]=[Value]
|
||||
/// Line comments can be added by starting with "#".
|
||||
/// </summary>
|
||||
internal class Settings
|
||||
{
|
||||
private readonly IDictionary<string, string> _settingsDictionary = new Dictionary<string, string>();
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="Settings"/> class.
|
||||
/// Reads the file contents in, if the file is available.
|
||||
/// </summary>
|
||||
/// <param name="file">The file.</param>
|
||||
public Settings(string file)
|
||||
{
|
||||
if (!File.Exists(file))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var line in File.ReadAllLines(file))
|
||||
{
|
||||
var elements = line.Split('=');
|
||||
if (elements.Length > 1
|
||||
&& !elements[0].StartsWith("#", StringComparison.InvariantCulture)
|
||||
&& !this._settingsDictionary.ContainsKey(elements[0]))
|
||||
{
|
||||
this._settingsDictionary.Add(elements[0], elements[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the configured chat server listener port.
|
||||
/// </summary>
|
||||
public int? ChatServerListenerPort
|
||||
{
|
||||
get
|
||||
{
|
||||
if (this["ChatServerListenerPort"] != null && int.TryParse(this["ChatServerListenerPort"], out var result))
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
return default;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the configured exDb server port.
|
||||
/// </summary>
|
||||
public int? ExDbPort
|
||||
{
|
||||
get
|
||||
{
|
||||
if (this["ExDbPort"] != null)
|
||||
{
|
||||
if (int.TryParse(this["ExDbPort"], out int result))
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
return default;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the configured exDb server host.
|
||||
/// </summary>
|
||||
public string? ExDbHost => this["ExDbHost"];
|
||||
|
||||
/// <summary>
|
||||
/// Gets the configured xor32 key.
|
||||
/// </summary>
|
||||
public byte[]? Xor32Key
|
||||
{
|
||||
get
|
||||
{
|
||||
if (this["Xor32Key"] != null)
|
||||
{
|
||||
var customXor32KeyList = new List<byte>();
|
||||
var keyAsString = this["Xor32Key"];
|
||||
if (keyAsString is not null)
|
||||
{
|
||||
var bytesAsString = keyAsString.Split(' ');
|
||||
foreach (var byteString in bytesAsString)
|
||||
{
|
||||
customXor32KeyList.Add(byte.Parse(byteString, NumberStyles.HexNumber, CultureInfo.InvariantCulture));
|
||||
}
|
||||
}
|
||||
|
||||
return customXor32KeyList.ToArray();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private string? this[string key]
|
||||
{
|
||||
get
|
||||
{
|
||||
this._settingsDictionary.TryGetValue(key, out var value);
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
38
src/ChatServer/ExDbConnector/appsettings.json
Normal file
38
src/ChatServer/ExDbConnector/appsettings.json
Normal file
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"Serilog": {
|
||||
"Using": [ "Serilog.Sinks.Console", "Serilog.Sinks.File" ],
|
||||
"MinimumLevel": {
|
||||
"Default": "Debug",
|
||||
"Override": {
|
||||
"Microsoft": "Fatal",
|
||||
"System": "Fatal",
|
||||
"Npgsql": "Information",
|
||||
"MUnique.OpenMU.Network.Connection": "Error",
|
||||
"MUnique": "Information"
|
||||
}
|
||||
},
|
||||
"WriteTo": [
|
||||
{
|
||||
"Name": "Console",
|
||||
"Args": {
|
||||
"outputTemplate": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level}] [{SourceContext}] {Message}{NewLine}{Exception}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"Name": "File",
|
||||
"Args": {
|
||||
"path": "logs/log.txt",
|
||||
"outputTemplate": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level}] [{SourceContext}] [{EventId}] {Message}{NewLine}{Exception}",
|
||||
"rollOnFileSizeLimit": true,
|
||||
"fileSizeLimitBytes": 4194304,
|
||||
"retainedFileCountLimit": 48,
|
||||
"rollingInterval": "Hour"
|
||||
}
|
||||
}
|
||||
],
|
||||
"Enrich": [ "FromLogContext" ],
|
||||
"Properties": {
|
||||
"Application": "MUnique.OpenMU.ChatServer.ExDbConnector"
|
||||
}
|
||||
}
|
||||
}
|
||||
73
src/ChatServer/IChatClient.cs
Normal file
73
src/ChatServer/IChatClient.cs
Normal file
@@ -0,0 +1,73 @@
|
||||
// <copyright file="IChatClient.cs" company="MUnique">
|
||||
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
|
||||
// </copyright>
|
||||
|
||||
namespace MUnique.OpenMU.ChatServer;
|
||||
|
||||
/// <summary>
|
||||
/// Type of the chat room client update message.
|
||||
/// </summary>
|
||||
public enum ChatRoomClientUpdateType : byte
|
||||
{
|
||||
/// <summary>
|
||||
/// A client joined the chat room. Then the client which receives the message adds this client to it's local chat room client list.
|
||||
/// </summary>
|
||||
Joined = 0,
|
||||
|
||||
/// <summary>
|
||||
/// A client left the chat room. Then the client which receives the message removes this client from it's local chat room client list.
|
||||
/// </summary>
|
||||
Left = 1,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Interface for a chat client.
|
||||
/// </summary>
|
||||
public interface IChatClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the index of the client in the room.
|
||||
/// </summary>
|
||||
byte Index { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the authentication token which was sent by the client.
|
||||
/// </summary>
|
||||
string? AuthenticationToken { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the nickname.
|
||||
/// </summary>
|
||||
string? Nickname { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the last activity.
|
||||
/// </summary>
|
||||
DateTime LastActivity { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Logs the chat client off, which means it removes it from it's current chat room and closes the connection.
|
||||
/// </summary>
|
||||
ValueTask LogOffAsync();
|
||||
|
||||
/// <summary>
|
||||
/// Sends the message to this chat client.
|
||||
/// </summary>
|
||||
/// <param name="senderId">The sender identifier.</param>
|
||||
/// <param name="message">The message.</param>
|
||||
ValueTask SendMessageAsync(byte senderId, string message);
|
||||
|
||||
/// <summary>
|
||||
/// Sends the client list of the chat room to this client.
|
||||
/// </summary>
|
||||
/// <param name="clients">The chat room clients.</param>
|
||||
ValueTask SendChatRoomClientListAsync(IReadOnlyCollection<IChatClient> clients);
|
||||
|
||||
/// <summary>
|
||||
/// Notifies the client that another client has joined the chat room.
|
||||
/// </summary>
|
||||
/// <param name="updatedClientId">The joined client identifier.</param>
|
||||
/// <param name="updatedClientName">Name of the joined client.</param>
|
||||
/// <param name="updateType">Type of the update (join or leave).</param>
|
||||
ValueTask SendChatRoomClientUpdateAsync(byte updatedClientId, string updatedClientName, ChatRoomClientUpdateType updateType);
|
||||
}
|
||||
31
src/ChatServer/MUnique.OpenMU.ChatServer.csproj
Normal file
31
src/ChatServer/MUnique.OpenMU.ChatServer.csproj
Normal file
@@ -0,0 +1,31 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<WarningsAsErrors>nullable;CS4014;VSTHRD110;VSTHRD100</WarningsAsErrors>
|
||||
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
|
||||
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
|
||||
<OutputPath>..\..\bin\Debug\</OutputPath>
|
||||
<DocumentationFile>..\..\bin\Debug\MUnique.OpenMU.ChatServer.xml</DocumentationFile>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
|
||||
<OutputPath>..\..\bin\Release\</OutputPath>
|
||||
<DocumentationFile>..\..\bin\Release\MUnique.OpenMU.ChatServer.xml</DocumentationFile>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<Compile Remove="ExDbConnector\**" />
|
||||
<EmbeddedResource Remove="ExDbConnector\**" />
|
||||
<None Remove="ExDbConnector\**" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Interfaces\MUnique.OpenMU.Interfaces.csproj" />
|
||||
<ProjectReference Include="..\Network\MUnique.OpenMU.Network.csproj" />
|
||||
<ProjectReference Include="..\Network\Packets\MUnique.OpenMU.Network.Packets.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
12
src/ChatServer/Properties/AssemblyInfo.cs
Normal file
12
src/ChatServer/Properties/AssemblyInfo.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
// <copyright file="AssemblyInfo.cs" company="MUnique">
|
||||
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
|
||||
// </copyright>
|
||||
|
||||
using System.Reflection;
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
// General Information about an assembly is controlled through the following
|
||||
// set of attributes. Change these attribute values to modify the information
|
||||
// associated with an assembly.
|
||||
[assembly: AssemblyTitle("MUnique.OpenMU.ChatServer")]
|
||||
[assembly: InternalsVisibleTo("MUnique.OpenMU.ChatServer.Tests")]
|
||||
Reference in New Issue
Block a user