baseline: OpenMU upstream b5a0961 (fresh source)

This commit is contained in:
Acentech Dev
2026-07-14 19:00:35 +03:00
parent 450402e47d
commit 36fc125d5c
11968 changed files with 748705 additions and 0 deletions

View 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
View 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);
}
}
}

View 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; }
}

View 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();
}
}

View 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));
}
}

View 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; }
}

View 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);
}
}

View 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>();
}

View 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

View File

@@ -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;
}
}

View 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);
}
}

View File

@@ -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>

View File

@@ -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>

View 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");
}
}
}

View 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")]

View 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

View 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;
}
}
}

View 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"
}
}
}

View 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);
}

View 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>

View 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")]