Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/GameServer/GameServer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -439,7 +439,7 @@ public async ValueTask<IReadOnlyList<ICapturedConnectionInfo>> GetConnectionsAsy
return players
.OfType<RemotePlayer>()
.Select(player => player.Connection is { } connection
? new RemotePlayerConnectionInfo(player, connection, this.Id, this.Description)
? new RemotePlayerConnectionInfo(player, connection, this.Id, this.Description, this._observationHandler)
: null)
.Where(info => info is not null)
.Select(info => (ICapturedConnectionInfo)info!)
Expand Down
24 changes: 24 additions & 0 deletions src/GameServer/NetworkObservationHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,25 @@ public void Watch(Player player)
player.PlayerDisconnected += this.OnPlayerDisconnectedAsync;
}

/// <summary>
/// Applies a change of the observation to the running session of the player: it starts to
/// archive the traffic, or finishes the archived session.
/// </summary>
/// <param name="player">The player whose account has been (un)observed.</param>
/// <param name="isActive">If set to <c>true</c>, the traffic is observed.</param>
/// <returns>The async task.</returns>
public async ValueTask ApplyObservationAsync(Player player, bool isActive)
{
if (isActive)
{
await this.OnPlayerLoggedInAsync(player).ConfigureAwait(false);
}
else
{
await this.StopSessionAsync(player).ConfigureAwait(false);
}
}

private async ValueTask OnPlayerLoggedInAsync(Player player)
{
try
Expand Down Expand Up @@ -123,6 +142,11 @@ private async ValueTask OnPlayerDisconnectedAsync(Player player)
player.PlayerEnteredWorld -= this.OnPlayerEnteredWorldAsync;
player.PlayerDisconnected -= this.OnPlayerDisconnectedAsync;

await this.StopSessionAsync(player).ConfigureAwait(false);
}

private async ValueTask StopSessionAsync(Player player)
{
if (!this._sessions.TryRemove(player, out var session))
{
return;
Expand Down
40 changes: 39 additions & 1 deletion src/GameServer/RemotePlayerConnectionInfo.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,19 +19,29 @@ internal sealed class RemotePlayerConnectionInfo : ICapturedConnectionInfo

private readonly IConnection _connection;

private readonly NetworkObservationHandler? _observationHandler;

/// <summary>
/// Initializes a new instance of the <see cref="RemotePlayerConnectionInfo"/> class.
/// </summary>
/// <param name="player">The player.</param>
/// <param name="connection">The connection of the player.</param>
/// <param name="serverId">The identifier of the game server.</param>
/// <param name="serverDescription">The description of the game server.</param>
public RemotePlayerConnectionInfo(RemotePlayer player, IConnection connection, int serverId, string serverDescription)
/// <param name="observationHandler">The handler which archives the traffic of the observed
/// accounts, if the observation is configured.</param>
public RemotePlayerConnectionInfo(
RemotePlayer player,
IConnection connection,
int serverId,
string serverDescription,
NetworkObservationHandler? observationHandler = null)
{
this._player = player;
this._connection = connection;
this.ServerId = serverId;
this.ServerDescription = serverDescription;
this._observationHandler = observationHandler;
}

/// <inheritdoc />
Expand Down Expand Up @@ -67,12 +77,40 @@ public RemotePlayerConnectionInfo(RemotePlayer player, IConnection connection, i
/// <inheritdoc />
public string DisplayName => this.CharacterName ?? this.AccountName ?? this.RemoteEndPoint ?? this.Id.ToString();

/// <inheritdoc />
public bool IsObserved => this._player.Account?.IsNetworkObservationActive is true;

/// <inheritdoc />
public void AddCaptureSink(IPacketCaptureSink sink) => this._connection.AddCaptureSink(sink);

/// <inheritdoc />
public void RemoveCaptureSink(IPacketCaptureSink sink) => this._connection.RemoveCaptureSink(sink);

/// <inheritdoc />
public async ValueTask<bool> SetObservationAsync(bool isActive)
{
if (this._player.Account is not { } account)
{
return false;
}

if (account.IsNetworkObservationActive != isActive)
{
account.IsNetworkObservationActive = isActive;

// The flag belongs to the account, so it survives the session - the player owns
// the account object and its context, so it's saved through the player.
await this._player.SaveProgressAsync().ConfigureAwait(false);
}

if (this._observationHandler is { } observationHandler)
{
await observationHandler.ApplyObservationAsync(this._player, isActive).ConfigureAwait(false);
}

return true;
}

/// <inheritdoc />
public ValueTask DisconnectAsync() => this._player.DisconnectAsync();
}
18 changes: 18 additions & 0 deletions src/Network/Analyzer/ICapturedConnectionInfo.cs
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,24 @@ public interface ICapturedConnectionInfo
/// </summary>
string DisplayName { get; }

/// <summary>
/// Gets a value indicating whether the traffic of the account of this connection is
/// observed, so that it's archived for each of its sessions.
/// </summary>
/// <remarks>
/// The observation is an account setting, so only a connection which knows its account -
/// a game server connection - can be observed.
/// </remarks>
bool IsObserved => false;

/// <summary>
/// Sets whether the traffic of the account of this connection is observed. It's applied
/// to the running session as well, so it doesn't need a reconnect.
/// </summary>
/// <param name="isActive">If set to <c>true</c>, the traffic is observed.</param>
/// <returns><see langword="true"/>, if it has been applied.</returns>
ValueTask<bool> SetObservationAsync(bool isActive) => ValueTask.FromResult(false);

/// <summary>
/// Adds a sink which gets the data packets of this connection.
/// </summary>
Expand Down
9 changes: 9 additions & 0 deletions src/Network/Analyzer/IPacketCaptureService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,15 @@ public interface IPacketCaptureService
/// <param name="connectionId">The identifier of the connection.</param>
void StopCapture(Guid connectionId);

/// <summary>
/// Sets whether the traffic of the account of the specified connection is observed, so
/// that it's archived for each of its sessions.
/// </summary>
/// <param name="connectionId">The identifier of the connection.</param>
/// <param name="isActive">If set to <c>true</c>, the traffic is observed.</param>
/// <returns><see langword="true"/>, if it has been applied.</returns>
ValueTask<bool> SetObservationAsync(Guid connectionId, bool isActive);

/// <summary>
/// Gets the currently running capture of the specified connection.
/// </summary>
Expand Down
11 changes: 11 additions & 0 deletions src/Network/Analyzer/PacketCaptureService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,17 @@ public void StopCapture(Guid connectionId)
}
}

/// <inheritdoc />
public async ValueTask<bool> SetObservationAsync(Guid connectionId, bool isActive)
{
if (await this.FindConnectionAsync(connectionId).ConfigureAwait(false) is not { } connectionInfo)
{
return false;
}

return await connectionInfo.SetObservationAsync(isActive).ConfigureAwait(false);
}

/// <inheritdoc />
public ILiveCapturedConnection? GetRunningCapture(Guid connectionId)
{
Expand Down
109 changes: 109 additions & 0 deletions src/Web/AdminPanel/API/NetworkArchiveController.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
// <copyright file="NetworkArchiveController.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>

namespace MUnique.OpenMU.Web.API;

using System.Globalization;
using System.IO;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using MUnique.OpenMU.Network.Analyzer.Archive;

/// <summary>
/// Controller which offers the archived sessions of the observed accounts as a download.
/// </summary>
/// <remarks>
/// The archived files are not served statically: they contain the traffic of a player in plain
/// text, including its login packet. Like every other controller of the admin panel, this one
/// requires an authenticated user.
/// </remarks>
[Route("api/network-archive/")]
public class NetworkArchiveController : Controller
{
private readonly IServiceProvider _serviceProvider;

private readonly ILogger<NetworkArchiveController> _logger;

/// <summary>
/// Initializes a new instance of the <see cref="NetworkArchiveController"/> class.
/// </summary>
/// <param name="serviceProvider">The service provider, used to resolve the archive
/// optionally - it's only registered when the network observation is configured.</param>
/// <param name="logger">The logger.</param>
public NetworkArchiveController(IServiceProvider serviceProvider, ILogger<NetworkArchiveController> logger)
{
this._serviceProvider = serviceProvider;
this._logger = logger;
}

/// <summary>
/// Downloads the specified archived session as one capture file, which can be opened by
/// the analyzer tool.
/// </summary>
/// <param name="sessionId">The identifier of the session.</param>
/// <returns>The async task.</returns>
[HttpGet("{**sessionId}")]
public async Task DownloadAsync(string sessionId)
{
if (this._serviceProvider.GetService(typeof(IPacketArchive)) is not IPacketArchive archive
|| await archive.GetSessionAsync(sessionId).ConfigureAwait(false) is not { } session)
{
this.Response.StatusCode = StatusCodes.Status404NotFound;
return;
}

// Downloading the traffic of a player is as intrusive as observing it, so it leaves a
// trace as well.
this._logger.LogInformation("The archived session {SessionId} has been downloaded.", session.Id);

this.Response.ContentType = "text/csv";
this.Response.Headers.ContentDisposition = $"attachment; filename=\"{GetFileName(session)}\"";

// The parts of the session are concatenated to one file: they only differ by the point
// in time at which the previous one got too big, and the timestamps of the packets are
// relative to the start of the session anyway.
await using var writer = new StreamWriter(this.Response.Body);
await writer.WriteLineAsync(session.Metadata.StartTimestamp.ToString("O", CultureInfo.InvariantCulture)).ConfigureAwait(false);
foreach (var part in session.Metadata.Parts)
{
await WritePartAsync(session, part, writer).ConfigureAwait(false);
}
}

private static async Task WritePartAsync(ArchivedSessionInfo session, string part, StreamWriter writer)
{
var path = Path.Combine(session.DirectoryPath, Path.GetFileName(part));
if (!System.IO.File.Exists(path))
{
return;
}

// The session may still be running, so the file is shared with its writer.
await using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete);
using var reader = new StreamReader(stream);

// The first line of each part is the start timestamp, which is already written.
_ = await reader.ReadLineAsync().ConfigureAwait(false);
while (await reader.ReadLineAsync().ConfigureAwait(false) is { } line)
{
await writer.WriteLineAsync(line).ConfigureAwait(false);
}
}

private static string GetFileName(ArchivedSessionInfo session)
{
var accountName = new string(session.Metadata.AccountName
.Where(character => char.IsLetterOrDigit(character) || character is '-' or '_')
.ToArray());
if (string.IsNullOrEmpty(accountName))
{
accountName = "session";
}

return string.Create(
CultureInfo.InvariantCulture,
$"{accountName}_{session.Metadata.StartTimestamp:yyyy-MM-dd_HH-mm-ss}.mucap");
}
}
65 changes: 65 additions & 0 deletions src/Web/AdminPanel/Components/NetworkAnalyzer/ArchiveList.razor
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
@using MUnique.OpenMU.Network.Analyzer.Archive
@using MUnique.OpenMU.Web.AdminPanel.Properties

<div class="d-flex flex-column mt-3 archive-list">
<div class="d-flex justify-content-between align-items-center mb-1">
<strong class="small">@Resources.ArchivedSessions</strong>
<button type="button" class="btn btn-outline-secondary btn-sm py-0" title="@Resources.Refresh" @onclick="this.OnRefresh">
<span class="oi oi-reload" aria-hidden="true"></span>
</button>
</div>

@if (!this.AccountGroups.Any())
{
<p class="text-muted small"><em>@Resources.NoArchivedSessions</em></p>
}
else
{
<div class="accordion accordion-flush overflow-auto">
@foreach (var group in this.AccountGroups)
{
<div class="accordion-item">
<h2 class="accordion-header">
<button class="accordion-button @(this.IsCollapsed(group.Key) ? "collapsed" : string.Empty) py-2"
type="button" @onclick="() => this.ToggleGroup(group.Key)">
<span class="text-truncate">@group.Key</span>
<span class="badge text-bg-secondary ms-2">@group.Count()</span>
</button>
</h2>
<div class="accordion-collapse collapse @(this.IsCollapsed(group.Key) ? string.Empty : "show")">
<div class="list-group list-group-flush">
@foreach (var session in group)
{
<button type="button"
class="list-group-item list-group-item-action py-1 @(this.SelectedSessionId == session.Id ? "active" : string.Empty)"
@onclick="() => this.OnSelect.InvokeAsync(session)">
<div class="d-flex justify-content-between align-items-center">
<span class="text-truncate">
@session.Metadata.StartTimestamp.ToString("yyyy-MM-dd HH:mm")
@if (session.IsRunning)
{
<span class="oi oi-media-record text-danger ms-1" aria-hidden="true"
title="@Resources.SessionIsRunning"></span>
}
</span>
<span class="text-nowrap">
<a class="oi oi-data-transfer-download ms-1" role="button"
href="@($"{this.DownloadRoute}{EscapeSessionId(session.Id)}")" target="_blank"
title="@Resources.DownloadArchivedSession" @onclick:stopPropagation="true"></a>
<span class="oi oi-trash ms-1" role="button" title="@Resources.Delete"
@onclick:stopPropagation="true"
@onclick="() => this.OnDelete.InvokeAsync(session)"></span>
</span>
</div>
<small class="text-truncate d-block @(this.SelectedSessionId == session.Id ? string.Empty : "text-muted")">
@GetDescription(session)
</small>
</button>
}
</div>
</div>
</div>
}
</div>
}
</div>
Loading
Loading