1
0
mirror of https://github.com/chylex/Minecraft-Phantom-Panel.git synced 2025-09-16 18:24:48 +02:00

26 Commits

Author SHA1 Message Date
591a6a62ab Reformat code 2025-08-21 20:31:21 +02:00
ae32537d8c Update to .NET 9 and C# 13 2025-08-08 22:04:41 +02:00
8149d31d51 Update Java versions in Dockerfile 2024-07-14 10:40:36 +02:00
9873f8779f Show online and maximum player count on Instances page on Web 2024-04-12 16:29:50 +02:00
31e101b21e Retrieve both online and maximum player count from Minecraft servers 2024-04-12 13:30:48 +02:00
398bb14742 Refactor online player count detection in Agent 2024-04-12 05:28:15 +02:00
4e89d7b12f Fix instance stop timeout not killing the process
Caused by 72ddaf91.
2024-04-12 05:28:15 +02:00
3b313bf0a8 Disable sync chunk writes to avoid lag during backups 2024-04-12 05:28:15 +02:00
21c90cb7c2 Fix null in Web login message reply 2024-04-08 00:03:41 +02:00
7b68b5d50d Implement backend logic for per-agent user access control 2024-04-07 23:53:27 +02:00
2d70d2b7d1 Update dotnet-ef to 8.0.3 2024-04-07 23:20:14 +02:00
8c623171f3 Add missing ARIA role to error messages on Web 2024-04-07 21:38:02 +02:00
62f8c685f0 Update Home page on Web 2024-04-07 21:21:05 +02:00
4a110db078 Fix label and spacing around instance command input field on Web 2024-04-07 17:08:10 +02:00
f683a1f700 Invalidate Web authorization when a user is updated/deleted 2024-04-07 17:05:24 +02:00
3ffb37529c Add user permission checking to Controller 2024-04-06 23:26:04 +02:00
4c3b81c54a Fix not re-focusing instance command input after sending a command on Web 2024-04-05 13:37:49 +02:00
8e2b019aa1 Fix exception due to disposed CancellationTokenSource in Minecraft server downloader 2024-04-05 13:34:42 +02:00
9a2c13c1e0 Add support for backups on older Minecraft versions 2024-04-05 12:56:32 +02:00
991b32032c Add "Backing Up" instance status 2024-04-05 11:37:55 +02:00
875fd9a766 Add timeouts while waiting for server output during backups 2024-04-05 02:41:46 +02:00
f7f08ec55c Fix backups not working with Forge log format 2024-04-04 20:44:17 +02:00
1b12fd9c3b Guard loading instances from database from exceptions 2024-04-04 20:37:37 +02:00
424dccb14e Migrate InstanceActionResult to Result type 2024-04-01 11:22:45 +02:00
d03f532996 Make Result type serializable 2024-03-31 18:24:22 +02:00
c99f5bc6bf Remove login session management logic from Web 2024-03-31 12:07:33 +02:00
302 changed files with 3895 additions and 2535 deletions

View File

@@ -3,7 +3,7 @@
"isRoot": true, "isRoot": true,
"tools": { "tools": {
"dotnet-ef": { "dotnet-ef": {
"version": "7.0.0-rc.1.22426.7", "version": "8.0.3",
"commands": [ "commands": [
"dotnet-ef" "dotnet-ef"
] ]

View File

@@ -105,7 +105,7 @@ public abstract class BaseLauncher : IServerLauncher {
private static async Task AcceptEula(InstanceProperties instanceProperties) { private static async Task AcceptEula(InstanceProperties instanceProperties) {
var eulaFilePath = Path.Combine(instanceProperties.InstanceFolder, "eula.txt"); var eulaFilePath = Path.Combine(instanceProperties.InstanceFolder, "eula.txt");
await File.WriteAllLinesAsync(eulaFilePath, new [] { "# EULA", "eula=true" }, Encoding.UTF8); await File.WriteAllLinesAsync(eulaFilePath, new[] { "# EULA", "eula=true" }, Encoding.UTF8);
} }
private static async Task UpdateServerProperties(InstanceProperties instanceProperties) { private static async Task UpdateServerProperties(InstanceProperties instanceProperties) {

View File

@@ -18,4 +18,5 @@ static class MinecraftServerProperties {
public static readonly MinecraftServerProperty<ushort> ServerPort = new UnsignedShort("server-port"); public static readonly MinecraftServerProperty<ushort> ServerPort = new UnsignedShort("server-port");
public static readonly MinecraftServerProperty<ushort> RconPort = new UnsignedShort("rcon.port"); public static readonly MinecraftServerProperty<ushort> RconPort = new UnsignedShort("rcon.port");
public static readonly MinecraftServerProperty<bool> EnableRcon = new Boolean("enable-rcon"); public static readonly MinecraftServerProperty<bool> EnableRcon = new Boolean("enable-rcon");
public static readonly MinecraftServerProperty<bool> SyncChunkWrites = new Boolean("sync-chunk-writes");
} }

View File

@@ -5,11 +5,13 @@ namespace Phantom.Agent.Minecraft.Properties;
public sealed record ServerProperties( public sealed record ServerProperties(
ushort ServerPort, ushort ServerPort,
ushort RconPort, ushort RconPort,
bool EnableRcon = true bool EnableRcon = true,
bool SyncChunkWrites = false
) { ) {
internal void SetTo(JavaPropertiesFileEditor properties) { internal void SetTo(JavaPropertiesFileEditor properties) {
MinecraftServerProperties.ServerPort.Set(properties, ServerPort); MinecraftServerProperties.ServerPort.Set(properties, ServerPort);
MinecraftServerProperties.RconPort.Set(properties, RconPort); MinecraftServerProperties.RconPort.Set(properties, RconPort);
MinecraftServerProperties.EnableRcon.Set(properties, EnableRcon); MinecraftServerProperties.EnableRcon.Set(properties, EnableRcon);
MinecraftServerProperties.SyncChunkWrites.Set(properties, SyncChunkWrites);
} }
} }

View File

@@ -16,32 +16,47 @@ sealed class MinecraftServerExecutableDownloader {
public event EventHandler? Completed; public event EventHandler? Completed;
private readonly CancellationTokenSource cancellationTokenSource = new (); private readonly CancellationTokenSource cancellationTokenSource = new ();
private int listeners = 0;
private readonly List<CancellationTokenRegistration> listenerCancellationRegistrations = new ();
private int listenerCount = 0;
public MinecraftServerExecutableDownloader(FileDownloadInfo fileDownloadInfo, string minecraftVersion, string filePath, MinecraftServerExecutableDownloadListener listener) { public MinecraftServerExecutableDownloader(FileDownloadInfo fileDownloadInfo, string minecraftVersion, string filePath, MinecraftServerExecutableDownloadListener listener) {
Register(listener); Register(listener);
Task = DownloadAndGetPath(fileDownloadInfo, minecraftVersion, filePath); Task = DownloadAndGetPath(fileDownloadInfo, minecraftVersion, filePath, new DownloadProgressCallback(this), cancellationTokenSource.Token);
Task.ContinueWith(OnCompleted, TaskScheduler.Default); Task.ContinueWith(OnCompleted, TaskScheduler.Default);
} }
public void Register(MinecraftServerExecutableDownloadListener listener) { public void Register(MinecraftServerExecutableDownloadListener listener) {
++listeners; int newListenerCount;
Logger.Debug("Registered download listener, current listener count: {Listeners}", listeners);
lock (this) {
newListenerCount = ++listenerCount;
DownloadProgress += listener.DownloadProgressEventHandler; DownloadProgress += listener.DownloadProgressEventHandler;
listener.CancellationToken.Register(Unregister, listener); listenerCancellationRegistrations.Add(listener.CancellationToken.Register(Unregister, listener));
}
Logger.Debug("Registered download listener, current listener count: {Listeners}", newListenerCount);
} }
private void Unregister(object? listenerObject) { private void Unregister(object? listenerObject) {
int newListenerCount;
lock (this) {
MinecraftServerExecutableDownloadListener listener = (MinecraftServerExecutableDownloadListener) listenerObject!; MinecraftServerExecutableDownloadListener listener = (MinecraftServerExecutableDownloadListener) listenerObject!;
DownloadProgress -= listener.DownloadProgressEventHandler; DownloadProgress -= listener.DownloadProgressEventHandler;
if (--listeners <= 0) { newListenerCount = --listenerCount;
Logger.Debug("Unregistered last download listener, cancelling download."); if (newListenerCount <= 0) {
cancellationTokenSource.Cancel(); cancellationTokenSource.Cancel();
} }
}
if (newListenerCount <= 0) {
Logger.Debug("Unregistered last download listener, cancelling download.");
}
else { else {
Logger.Debug("Unregistered download listener, current listener count: {Listeners}", listeners); Logger.Debug("Unregistered download listener, current listener count: {Listeners}", newListenerCount);
} }
} }
@@ -51,9 +66,19 @@ sealed class MinecraftServerExecutableDownloader {
private void OnCompleted(Task task) { private void OnCompleted(Task task) {
Logger.Debug("Download task completed."); Logger.Debug("Download task completed.");
lock (this) {
Completed?.Invoke(this, EventArgs.Empty); Completed?.Invoke(this, EventArgs.Empty);
Completed = null; Completed = null;
DownloadProgress = null; DownloadProgress = null;
foreach (var registration in listenerCancellationRegistrations) {
registration.Dispose();
}
listenerCancellationRegistrations.Clear();
cancellationTokenSource.Dispose();
}
} }
private sealed class DownloadProgressCallback { private sealed class DownloadProgressCallback {
@@ -68,15 +93,14 @@ sealed class MinecraftServerExecutableDownloader {
} }
} }
private async Task<string?> DownloadAndGetPath(FileDownloadInfo fileDownloadInfo, string minecraftVersion, string filePath) { private static async Task<string?> DownloadAndGetPath(FileDownloadInfo fileDownloadInfo, string minecraftVersion, string filePath, DownloadProgressCallback progressCallback, CancellationToken cancellationToken) {
string tmpFilePath = filePath + ".tmp"; string tmpFilePath = filePath + ".tmp";
var cancellationToken = cancellationTokenSource.Token;
try { try {
Logger.Information("Downloading server version {Version} from: {Url} ({Size})", minecraftVersion, fileDownloadInfo.DownloadUrl, fileDownloadInfo.Size.ToHumanReadable(decimalPlaces: 1)); Logger.Information("Downloading server version {Version} from: {Url} ({Size})", minecraftVersion, fileDownloadInfo.DownloadUrl, fileDownloadInfo.Size.ToHumanReadable(decimalPlaces: 1));
try { try {
using var http = new HttpClient(); using var http = new HttpClient();
await FetchServerExecutableFile(http, new DownloadProgressCallback(this), fileDownloadInfo, tmpFilePath, cancellationToken); await FetchServerExecutableFile(http, progressCallback, fileDownloadInfo, tmpFilePath, cancellationToken);
} catch (Exception) { } catch (Exception) {
TryDeleteExecutableAfterFailure(tmpFilePath); TryDeleteExecutableAfterFailure(tmpFilePath);
throw; throw;
@@ -94,8 +118,6 @@ sealed class MinecraftServerExecutableDownloader {
} catch (Exception e) { } catch (Exception e) {
Logger.Error(e, "An unexpected error occurred."); Logger.Error(e, "An unexpected error occurred.");
return null; return null;
} finally {
cancellationTokenSource.Dispose();
} }
} }

View File

@@ -3,28 +3,12 @@ using System.Buffers.Binary;
using System.Net; using System.Net;
using System.Net.Sockets; using System.Net.Sockets;
using System.Text; using System.Text;
using Phantom.Utils.Logging; using Phantom.Common.Data.Instance;
using Serilog;
namespace Phantom.Agent.Minecraft.Server; namespace Phantom.Agent.Minecraft.Server;
public sealed class ServerStatusProtocol { public static class ServerStatusProtocol {
private readonly ILogger logger; public static async Task<InstancePlayerCounts> GetPlayerCounts(ushort serverPort, CancellationToken cancellationToken) {
public ServerStatusProtocol(string loggerName) {
this.logger = PhantomLogger.Create<ServerStatusProtocol>(loggerName);
}
public async Task<int?> GetOnlinePlayerCount(int serverPort, CancellationToken cancellationToken) {
try {
return await GetOnlinePlayerCountOrThrow(serverPort, cancellationToken);
} catch (Exception e) {
logger.Error(e, "Caught exception while checking if players are online.");
return null;
}
}
private async Task<int?> GetOnlinePlayerCountOrThrow(int serverPort, CancellationToken cancellationToken) {
using var tcpClient = new TcpClient(); using var tcpClient = new TcpClient();
await tcpClient.ConnectAsync(IPAddress.Loopback, serverPort, cancellationToken); await tcpClient.ConnectAsync(IPAddress.Loopback, serverPort, cancellationToken);
var tcpStream = tcpClient.GetStream(); var tcpStream = tcpClient.GetStream();
@@ -33,24 +17,22 @@ public sealed class ServerStatusProtocol {
tcpStream.WriteByte(0xFE); tcpStream.WriteByte(0xFE);
await tcpStream.FlushAsync(cancellationToken); await tcpStream.FlushAsync(cancellationToken);
short? messageLength = await ReadStreamHeader(tcpStream, cancellationToken); short messageLength = await ReadStreamHeader(tcpStream, cancellationToken);
return messageLength == null ? null : await ReadOnlinePlayerCount(tcpStream, messageLength.Value * 2, cancellationToken); return await ReadPlayerCounts(tcpStream, messageLength * 2, cancellationToken);
} }
private async Task<short?> ReadStreamHeader(NetworkStream tcpStream, CancellationToken cancellationToken) { private static async Task<short> ReadStreamHeader(NetworkStream tcpStream, CancellationToken cancellationToken) {
var headerBuffer = ArrayPool<byte>.Shared.Rent(3); var headerBuffer = ArrayPool<byte>.Shared.Rent(3);
try { try {
await tcpStream.ReadExactlyAsync(headerBuffer, 0, 3, cancellationToken); await tcpStream.ReadExactlyAsync(headerBuffer, 0, 3, cancellationToken);
if (headerBuffer[0] != 0xFF) { if (headerBuffer[0] != 0xFF) {
logger.Error("Unexpected first byte in response from server: {FirstByte}.", headerBuffer[0]); throw new ProtocolException("Unexpected first byte in response from server: " + headerBuffer[0]);
return null;
} }
short messageLength = BinaryPrimitives.ReadInt16BigEndian(headerBuffer.AsSpan(1)); short messageLength = BinaryPrimitives.ReadInt16BigEndian(headerBuffer.AsSpan(1));
if (messageLength <= 0) { if (messageLength <= 0) {
logger.Error("Unexpected message length in response from server: {MessageLength}.", messageLength); throw new ProtocolException("Unexpected message length in response from server: " + messageLength);
return null;
} }
return messageLength; return messageLength;
@@ -59,35 +41,54 @@ public sealed class ServerStatusProtocol {
} }
} }
private async Task<int?> ReadOnlinePlayerCount(NetworkStream tcpStream, int messageLength, CancellationToken cancellationToken) { private static async Task<InstancePlayerCounts> ReadPlayerCounts(NetworkStream tcpStream, int messageLength, CancellationToken cancellationToken) {
var messageBuffer = ArrayPool<byte>.Shared.Rent(messageLength); var messageBuffer = ArrayPool<byte>.Shared.Rent(messageLength);
try { try {
await tcpStream.ReadExactlyAsync(messageBuffer, 0, messageLength, cancellationToken); await tcpStream.ReadExactlyAsync(messageBuffer, 0, messageLength, cancellationToken);
return ReadPlayerCountsFromResponse(messageBuffer.AsSpan(0, messageLength));
// Valid response separator encoded in UTF-16BE is 0x00 0xA7 (§).
const byte SeparatorSecondByte = 0xA7;
static bool IsValidSeparator(ReadOnlySpan<byte> buffer, int index) {
return index > 0 && buffer[index - 1] == 0x00;
}
int separator2 = Array.LastIndexOf(messageBuffer, SeparatorSecondByte);
int separator1 = separator2 == -1 ? -1 : Array.LastIndexOf(messageBuffer, SeparatorSecondByte, separator2 - 1);
if (!IsValidSeparator(messageBuffer, separator1) || !IsValidSeparator(messageBuffer, separator2)) {
logger.Error("Could not find message separators in response from server.");
return null;
}
string onlinePlayerCountStr = Encoding.BigEndianUnicode.GetString(messageBuffer.AsSpan((separator1 + 1)..(separator2 - 1)));
if (!int.TryParse(onlinePlayerCountStr, out int onlinePlayerCount)) {
logger.Error("Could not parse online player count in response from server: {OnlinePlayerCount}.", onlinePlayerCountStr);
return null;
}
logger.Debug("Detected {OnlinePlayerCount} online player(s).", onlinePlayerCount);
return onlinePlayerCount;
} finally { } finally {
ArrayPool<byte>.Shared.Return(messageBuffer); ArrayPool<byte>.Shared.Return(messageBuffer);
} }
} }
/// <summary>
/// Legacy query protocol uses the paragraph symbol (§) as separator encoded in UTF-16BE.
/// </summary>
private static readonly byte[] Separator = { 0x00, 0xA7 };
private static InstancePlayerCounts ReadPlayerCountsFromResponse(ReadOnlySpan<byte> messageBuffer) {
int lastSeparator = messageBuffer.LastIndexOf(Separator);
int middleSeparator = messageBuffer[..lastSeparator].LastIndexOf(Separator);
if (lastSeparator == -1 || middleSeparator == -1) {
throw new ProtocolException("Could not find message separators in response from server.");
}
var onlinePlayerCountBuffer = messageBuffer[(middleSeparator + Separator.Length)..lastSeparator];
var maximumPlayerCountBuffer = messageBuffer[(lastSeparator + Separator.Length)..];
// Player counts are integers, whose maximum string length is 10 characters.
Span<char> integerStringBuffer = stackalloc char[10];
return new InstancePlayerCounts(
DecodeAndParsePlayerCount(onlinePlayerCountBuffer, integerStringBuffer, "online"),
DecodeAndParsePlayerCount(maximumPlayerCountBuffer, integerStringBuffer, "maximum")
);
}
private static int DecodeAndParsePlayerCount(ReadOnlySpan<byte> inputBuffer, Span<char> tempCharBuffer, string countType) {
if (!Encoding.BigEndianUnicode.TryGetChars(inputBuffer, tempCharBuffer, out int charCount)) {
throw new ProtocolException("Could not decode " + countType + " player count in response from server.");
}
if (!int.TryParse(tempCharBuffer, out int playerCount)) {
throw new ProtocolException("Could not parse " + countType + " player count in response from server: " + tempCharBuffer[..charCount].ToString());
}
return playerCount;
}
public sealed class ProtocolException : Exception {
internal ProtocolException(string message) : base(message) {}
}
} }

View File

@@ -67,6 +67,10 @@ sealed class BackupManager : IDisposable {
resultBuilder.Kind = BackupCreationResultKind.BackupCancelled; resultBuilder.Kind = BackupCreationResultKind.BackupCancelled;
logger.Warning("Backup creation was cancelled."); logger.Warning("Backup creation was cancelled.");
return null; return null;
} catch (TimeoutException) {
resultBuilder.Kind = BackupCreationResultKind.BackupTimedOut;
logger.Warning("Backup creation timed out.");
return null;
} catch (Exception e) { } catch (Exception e) {
resultBuilder.Kind = BackupCreationResultKind.UnknownError; resultBuilder.Kind = BackupCreationResultKind.UnknownError;
logger.Error(e, "Caught exception while creating an instance backup."); logger.Error(e, "Caught exception while creating an instance backup.");
@@ -76,6 +80,9 @@ sealed class BackupManager : IDisposable {
await dispatcher.EnableAutomaticSaving(); await dispatcher.EnableAutomaticSaving();
} catch (OperationCanceledException) { } catch (OperationCanceledException) {
// Ignore. // Ignore.
} catch (TimeoutException) {
resultBuilder.Warnings |= BackupCreationWarnings.CouldNotRestoreAutomaticSaving;
logger.Warning("Timed out waiting for automatic saving to be re-enabled.");
} catch (Exception e) { } catch (Exception e) {
resultBuilder.Warnings |= BackupCreationWarnings.CouldNotRestoreAutomaticSaving; resultBuilder.Warnings |= BackupCreationWarnings.CouldNotRestoreAutomaticSaving;
logger.Error(e, "Caught exception while enabling automatic saving after creating an instance backup."); logger.Error(e, "Caught exception while enabling automatic saving after creating an instance backup.");
@@ -120,6 +127,7 @@ sealed class BackupManager : IDisposable {
BackupCreationResultKind.Success => "Backup created successfully.", BackupCreationResultKind.Success => "Backup created successfully.",
BackupCreationResultKind.InstanceNotRunning => "Instance is not running.", BackupCreationResultKind.InstanceNotRunning => "Instance is not running.",
BackupCreationResultKind.BackupCancelled => "Backup cancelled.", BackupCreationResultKind.BackupCancelled => "Backup cancelled.",
BackupCreationResultKind.BackupTimedOut => "Backup timed out.",
BackupCreationResultKind.BackupAlreadyRunning => "A backup is already being created.", BackupCreationResultKind.BackupAlreadyRunning => "A backup is already being created.",
BackupCreationResultKind.BackupFileAlreadyExists => "Backup with the same name already exists.", BackupCreationResultKind.BackupFileAlreadyExists => "Backup with the same name already exists.",
BackupCreationResultKind.CouldNotCreateBackupFolder => "Could not create backup folder.", BackupCreationResultKind.CouldNotCreateBackupFolder => "Could not create backup folder.",

View File

@@ -1,10 +1,8 @@
using Phantom.Agent.Minecraft.Instance; using Phantom.Agent.Services.Instances;
using Phantom.Agent.Minecraft.Server; using Phantom.Agent.Services.Instances.State;
using Phantom.Agent.Services.Instances;
using Phantom.Common.Data.Backups; using Phantom.Common.Data.Backups;
using Phantom.Utils.Logging; using Phantom.Utils.Logging;
using Phantom.Utils.Tasks; using Phantom.Utils.Tasks;
using Phantom.Utils.Threading;
namespace Phantom.Agent.Services.Backups; namespace Phantom.Agent.Services.Backups;
@@ -16,20 +14,16 @@ sealed class BackupScheduler : CancellableBackgroundTask {
private readonly BackupManager backupManager; private readonly BackupManager backupManager;
private readonly InstanceContext context; private readonly InstanceContext context;
private readonly InstanceProcess process;
private readonly SemaphoreSlim backupSemaphore = new (1, 1); private readonly SemaphoreSlim backupSemaphore = new (1, 1);
private readonly int serverPort;
private readonly ServerStatusProtocol serverStatusProtocol;
private readonly ManualResetEventSlim serverOutputWhileWaitingForOnlinePlayers = new (); private readonly ManualResetEventSlim serverOutputWhileWaitingForOnlinePlayers = new ();
private readonly InstancePlayerCountTracker playerCountTracker;
public event EventHandler<BackupCreationResult>? BackupCompleted; public event EventHandler<BackupCreationResult>? BackupCompleted;
public BackupScheduler(InstanceContext context, InstanceProcess process, int serverPort) : base(PhantomLogger.Create<BackupScheduler>(context.ShortName)) { public BackupScheduler(InstanceContext context, InstancePlayerCountTracker playerCountTracker) : base(PhantomLogger.Create<BackupScheduler>(context.ShortName)) {
this.backupManager = context.Services.BackupManager; this.backupManager = context.Services.BackupManager;
this.context = context; this.context = context;
this.process = process; this.playerCountTracker = playerCountTracker;
this.serverPort = serverPort;
this.serverStatusProtocol = new ServerStatusProtocol(context.ShortName);
Start(); Start();
} }
@@ -69,43 +63,18 @@ sealed class BackupScheduler : CancellableBackgroundTask {
} }
private async Task WaitForOnlinePlayers() { private async Task WaitForOnlinePlayers() {
bool needsToLogOfflinePlayersMessage = true; var task = playerCountTracker.WaitForOnlinePlayers(CancellationToken);
if (!task.IsCompleted) {
Logger.Information("Waiting for someone to join before starting a new backup.");
}
process.AddOutputListener(ServerOutputListener, maxLinesToReadFromHistory: 0);
try { try {
while (!CancellationToken.IsCancellationRequested) { await task;
serverOutputWhileWaitingForOnlinePlayers.Reset();
var onlinePlayerCount = await serverStatusProtocol.GetOnlinePlayerCount(serverPort, CancellationToken);
if (onlinePlayerCount == null) {
Logger.Warning("Could not detect whether any players are online, starting a new backup.");
break;
}
if (onlinePlayerCount > 0) {
Logger.Information("Players are online, starting a new backup."); Logger.Information("Players are online, starting a new backup.");
break; } catch (OperationCanceledException) {
} throw;
} catch (Exception) {
if (needsToLogOfflinePlayersMessage) { Logger.Warning("Could not detect whether any players are online, starting a new backup.");
needsToLogOfflinePlayersMessage = false;
Logger.Information("No players are online, waiting for someone to join before starting a new backup.");
}
await Task.Delay(TimeSpan.FromSeconds(10), CancellationToken);
Logger.Debug("Waiting for server output before checking for online players again...");
await serverOutputWhileWaitingForOnlinePlayers.WaitHandle.WaitOneAsync(CancellationToken);
}
} finally {
process.RemoveOutputListener(ServerOutputListener);
}
}
private void ServerOutputListener(object? sender, string line) {
if (!serverOutputWhileWaitingForOnlinePlayers.IsSet) {
serverOutputWhileWaitingForOnlinePlayers.Set();
Logger.Debug("Detected server output, signalling to check for online players again.");
} }
} }

View File

@@ -1,4 +1,5 @@
using System.Text.RegularExpressions; using System.Collections.Immutable;
using System.Text.RegularExpressions;
using Phantom.Agent.Minecraft.Command; using Phantom.Agent.Minecraft.Command;
using Phantom.Agent.Minecraft.Instance; using Phantom.Agent.Minecraft.Instance;
using Phantom.Utils.Tasks; using Phantom.Utils.Tasks;
@@ -7,9 +8,27 @@ using Serilog;
namespace Phantom.Agent.Services.Backups; namespace Phantom.Agent.Services.Backups;
sealed partial class BackupServerCommandDispatcher : IDisposable { sealed partial class BackupServerCommandDispatcher : IDisposable {
[GeneratedRegex(@"^\[(?:.*?)\] \[Server thread/INFO\]: (.*?)$", RegexOptions.NonBacktracking)] [GeneratedRegex(@"^(?:(?:\[.*?\] \[Server thread/INFO\].*?:)|(?:[\d-]+? [\d:]+? \[INFO\])) (.*?)$", RegexOptions.NonBacktracking)]
private static partial Regex ServerThreadInfoRegex(); private static partial Regex ServerThreadInfoRegex();
private static readonly ImmutableHashSet<string> AutomaticSavingDisabledMessages = ImmutableHashSet.Create(
"Automatic saving is now disabled",
"Turned off world auto-saving",
"CONSOLE: Disabling level saving.."
);
private static readonly ImmutableHashSet<string> SavedTheGameMessages = ImmutableHashSet.Create(
"Saved the game",
"Saved the world",
"CONSOLE: Save complete."
);
private static readonly ImmutableHashSet<string> AutomaticSavingEnabledMessages = ImmutableHashSet.Create(
"Automatic saving is now enabled",
"Turned on world auto-saving",
"CONSOLE: Enabling level saving.."
);
private readonly ILogger logger; private readonly ILogger logger;
private readonly InstanceProcess process; private readonly InstanceProcess process;
private readonly CancellationToken cancellationToken; private readonly CancellationToken cancellationToken;
@@ -32,18 +51,17 @@ sealed partial class BackupServerCommandDispatcher : IDisposable {
public async Task DisableAutomaticSaving() { public async Task DisableAutomaticSaving() {
await process.SendCommand(MinecraftCommand.SaveOff, cancellationToken); await process.SendCommand(MinecraftCommand.SaveOff, cancellationToken);
await automaticSavingDisabled.Task.WaitAsync(cancellationToken); await automaticSavingDisabled.Task.WaitAsync(TimeSpan.FromSeconds(30), cancellationToken);
} }
public async Task SaveAllChunks() { public async Task SaveAllChunks() {
// TODO Try if not flushing and waiting a few seconds before flushing reduces lag.
await process.SendCommand(MinecraftCommand.SaveAll(flush: true), cancellationToken); await process.SendCommand(MinecraftCommand.SaveAll(flush: true), cancellationToken);
await savedTheGame.Task.WaitAsync(cancellationToken); await savedTheGame.Task.WaitAsync(TimeSpan.FromMinutes(1), cancellationToken);
} }
public async Task EnableAutomaticSaving() { public async Task EnableAutomaticSaving() {
await process.SendCommand(MinecraftCommand.SaveOn, cancellationToken); await process.SendCommand(MinecraftCommand.SaveOn, cancellationToken);
await automaticSavingEnabled.Task.WaitAsync(cancellationToken); await automaticSavingEnabled.Task.WaitAsync(TimeSpan.FromMinutes(1), cancellationToken);
} }
private void OnOutput(object? sender, string? line) { private void OnOutput(object? sender, string? line) {
@@ -59,19 +77,19 @@ sealed partial class BackupServerCommandDispatcher : IDisposable {
string info = match.Groups[1].Value; string info = match.Groups[1].Value;
if (!automaticSavingDisabled.Task.IsCompleted) { if (!automaticSavingDisabled.Task.IsCompleted) {
if (info == "Automatic saving is now disabled") { if (AutomaticSavingDisabledMessages.Contains(info)) {
logger.Debug("Detected that automatic saving is disabled."); logger.Debug("Detected that automatic saving is disabled.");
automaticSavingDisabled.SetResult(); automaticSavingDisabled.SetResult();
} }
} }
else if (!savedTheGame.Task.IsCompleted) { else if (!savedTheGame.Task.IsCompleted) {
if (info == "Saved the game") { if (SavedTheGameMessages.Contains(info)) {
logger.Debug("Detected that the game is saved."); logger.Debug("Detected that the game is saved.");
savedTheGame.SetResult(); savedTheGame.SetResult();
} }
} }
else if (!automaticSavingEnabled.Task.IsCompleted) { else if (!automaticSavingEnabled.Task.IsCompleted) {
if (info == "Automatic saving is now enabled") { if (AutomaticSavingEnabledMessages.Contains(info)) {
logger.Debug("Detected that automatic saving is enabled."); logger.Debug("Detected that automatic saving is enabled.");
automaticSavingEnabled.SetResult(); automaticSavingEnabled.SetResult();
} }

View File

@@ -135,7 +135,12 @@ sealed class InstanceActor : ReceiveActor<InstanceActor.ICommand> {
return new BackupCreationResult(BackupCreationResultKind.InstanceNotRunning); return new BackupCreationResult(BackupCreationResultKind.InstanceNotRunning);
} }
else { else {
SetAndReportStatus(InstanceStatus.BackingUp);
try {
return await command.BackupManager.CreateBackup(context.ShortName, runningState.Process, shutdownCancellationToken); return await command.BackupManager.CreateBackup(context.ShortName, runningState.Process, shutdownCancellationToken);
} finally {
SetAndReportStatus(InstanceStatus.Running);
}
} }
} }

View File

@@ -6,13 +6,13 @@ using Phantom.Agent.Minecraft.Properties;
using Phantom.Agent.Minecraft.Server; using Phantom.Agent.Minecraft.Server;
using Phantom.Agent.Rpc; using Phantom.Agent.Rpc;
using Phantom.Agent.Services.Backups; using Phantom.Agent.Services.Backups;
using Phantom.Common.Data;
using Phantom.Common.Data.Instance; using Phantom.Common.Data.Instance;
using Phantom.Common.Data.Minecraft; using Phantom.Common.Data.Minecraft;
using Phantom.Common.Data.Replies; using Phantom.Common.Data.Replies;
using Phantom.Utils.Actor; using Phantom.Utils.Actor;
using Phantom.Utils.IO; using Phantom.Utils.IO;
using Phantom.Utils.Logging; using Phantom.Utils.Logging;
using Phantom.Utils.Tasks;
using Serilog; using Serilog;
namespace Phantom.Agent.Services.Instances; namespace Phantom.Agent.Services.Instances;
@@ -49,10 +49,10 @@ sealed class InstanceManagerActor : ReceiveActor<InstanceManagerActor.ICommand>
this.instanceServices = new InstanceServices(init.ControllerConnection, init.BackupManager, launchServices); this.instanceServices = new InstanceServices(init.ControllerConnection, init.BackupManager, launchServices);
ReceiveAndReply<ConfigureInstanceCommand, InstanceActionResult<ConfigureInstanceResult>>(ConfigureInstance); ReceiveAndReply<ConfigureInstanceCommand, Result<ConfigureInstanceResult, InstanceActionFailure>>(ConfigureInstance);
ReceiveAndReply<LaunchInstanceCommand, InstanceActionResult<LaunchInstanceResult>>(LaunchInstance); ReceiveAndReply<LaunchInstanceCommand, Result<LaunchInstanceResult, InstanceActionFailure>>(LaunchInstance);
ReceiveAndReply<StopInstanceCommand, InstanceActionResult<StopInstanceResult>>(StopInstance); ReceiveAndReply<StopInstanceCommand, Result<StopInstanceResult, InstanceActionFailure>>(StopInstance);
ReceiveAsyncAndReply<SendCommandToInstanceCommand, InstanceActionResult<SendCommandToInstanceResult>>(SendCommandToInstance); ReceiveAsyncAndReply<SendCommandToInstanceCommand, Result<SendCommandToInstanceResult, InstanceActionFailure>>(SendCommandToInstance);
ReceiveAsync<ShutdownCommand>(Shutdown); ReceiveAsync<ShutdownCommand>(Shutdown);
} }
@@ -65,17 +65,17 @@ sealed class InstanceManagerActor : ReceiveActor<InstanceManagerActor.ICommand>
public interface ICommand {} public interface ICommand {}
public sealed record ConfigureInstanceCommand(Guid InstanceGuid, InstanceConfiguration Configuration, InstanceLaunchProperties LaunchProperties, bool LaunchNow, bool AlwaysReportStatus) : ICommand, ICanReply<InstanceActionResult<ConfigureInstanceResult>>; public sealed record ConfigureInstanceCommand(Guid InstanceGuid, InstanceConfiguration Configuration, InstanceLaunchProperties LaunchProperties, bool LaunchNow, bool AlwaysReportStatus) : ICommand, ICanReply<Result<ConfigureInstanceResult, InstanceActionFailure>>;
public sealed record LaunchInstanceCommand(Guid InstanceGuid) : ICommand, ICanReply<InstanceActionResult<LaunchInstanceResult>>; public sealed record LaunchInstanceCommand(Guid InstanceGuid) : ICommand, ICanReply<Result<LaunchInstanceResult, InstanceActionFailure>>;
public sealed record StopInstanceCommand(Guid InstanceGuid, MinecraftStopStrategy StopStrategy) : ICommand, ICanReply<InstanceActionResult<StopInstanceResult>>; public sealed record StopInstanceCommand(Guid InstanceGuid, MinecraftStopStrategy StopStrategy) : ICommand, ICanReply<Result<StopInstanceResult, InstanceActionFailure>>;
public sealed record SendCommandToInstanceCommand(Guid InstanceGuid, string Command) : ICommand, ICanReply<InstanceActionResult<SendCommandToInstanceResult>>; public sealed record SendCommandToInstanceCommand(Guid InstanceGuid, string Command) : ICommand, ICanReply<Result<SendCommandToInstanceResult, InstanceActionFailure>>;
public sealed record ShutdownCommand : ICommand; public sealed record ShutdownCommand : ICommand;
private InstanceActionResult<ConfigureInstanceResult> ConfigureInstance(ConfigureInstanceCommand command) { private Result<ConfigureInstanceResult, InstanceActionFailure> ConfigureInstance(ConfigureInstanceCommand command) {
var instanceGuid = command.InstanceGuid; var instanceGuid = command.InstanceGuid;
var configuration = command.Configuration; var configuration = command.Configuration;
@@ -130,64 +130,64 @@ sealed class InstanceManagerActor : ReceiveActor<InstanceManagerActor.ICommand>
LaunchInstance(new LaunchInstanceCommand(instanceGuid)); LaunchInstance(new LaunchInstanceCommand(instanceGuid));
} }
return InstanceActionResult.Concrete(ConfigureInstanceResult.Success); return ConfigureInstanceResult.Success;
} }
private InstanceActionResult<LaunchInstanceResult> LaunchInstance(LaunchInstanceCommand command) { private Result<LaunchInstanceResult, InstanceActionFailure> LaunchInstance(LaunchInstanceCommand command) {
var instanceGuid = command.InstanceGuid; var instanceGuid = command.InstanceGuid;
if (!instances.TryGetValue(instanceGuid, out var instanceInfo)) { if (!instances.TryGetValue(instanceGuid, out var instanceInfo)) {
return InstanceActionResult.General<LaunchInstanceResult>(InstanceActionGeneralResult.InstanceDoesNotExist); return InstanceActionFailure.InstanceDoesNotExist;
} }
var ticket = instanceTicketManager.Reserve(instanceInfo.Configuration); var ticket = instanceTicketManager.Reserve(instanceInfo.Configuration);
if (ticket is Result<InstanceTicketManager.Ticket, LaunchInstanceResult>.Fail fail) { if (!ticket) {
return InstanceActionResult.Concrete(fail.Error); return ticket.Error;
} }
if (agentState.InstancesByGuid.TryGetValue(instanceGuid, out var instance)) { if (agentState.InstancesByGuid.TryGetValue(instanceGuid, out var instance)) {
var status = instance.Status; var status = instance.Status;
if (status.IsRunning()) { if (status.IsRunning()) {
return InstanceActionResult.Concrete(LaunchInstanceResult.InstanceAlreadyRunning); return LaunchInstanceResult.InstanceAlreadyRunning;
} }
else if (status.IsLaunching()) { else if (status.IsLaunching()) {
return InstanceActionResult.Concrete(LaunchInstanceResult.InstanceAlreadyLaunching); return LaunchInstanceResult.InstanceAlreadyLaunching;
} }
} }
instanceInfo.Actor.Tell(new InstanceActor.LaunchInstanceCommand(instanceInfo.Configuration, instanceInfo.Launcher, ticket.Value, IsRestarting: false)); instanceInfo.Actor.Tell(new InstanceActor.LaunchInstanceCommand(instanceInfo.Configuration, instanceInfo.Launcher, ticket.Value, IsRestarting: false));
return InstanceActionResult.Concrete(LaunchInstanceResult.LaunchInitiated); return LaunchInstanceResult.LaunchInitiated;
} }
private InstanceActionResult<StopInstanceResult> StopInstance(StopInstanceCommand command) { private Result<StopInstanceResult, InstanceActionFailure> StopInstance(StopInstanceCommand command) {
var instanceGuid = command.InstanceGuid; var instanceGuid = command.InstanceGuid;
if (!instances.TryGetValue(instanceGuid, out var instanceInfo)) { if (!instances.TryGetValue(instanceGuid, out var instanceInfo)) {
return InstanceActionResult.General<StopInstanceResult>(InstanceActionGeneralResult.InstanceDoesNotExist); return InstanceActionFailure.InstanceDoesNotExist;
} }
if (agentState.InstancesByGuid.TryGetValue(instanceGuid, out var instance)) { if (agentState.InstancesByGuid.TryGetValue(instanceGuid, out var instance)) {
var status = instance.Status; var status = instance.Status;
if (status.IsStopping()) { if (status.IsStopping()) {
return InstanceActionResult.Concrete(StopInstanceResult.InstanceAlreadyStopping); return StopInstanceResult.InstanceAlreadyStopping;
} }
else if (!status.CanStop()) { else if (!status.CanStop()) {
return InstanceActionResult.Concrete(StopInstanceResult.InstanceAlreadyStopped); return StopInstanceResult.InstanceAlreadyStopped;
} }
} }
instanceInfo.Actor.Tell(new InstanceActor.StopInstanceCommand(command.StopStrategy)); instanceInfo.Actor.Tell(new InstanceActor.StopInstanceCommand(command.StopStrategy));
return InstanceActionResult.Concrete(StopInstanceResult.StopInitiated); return StopInstanceResult.StopInitiated;
} }
private async Task<InstanceActionResult<SendCommandToInstanceResult>> SendCommandToInstance(SendCommandToInstanceCommand command) { private async Task<Result<SendCommandToInstanceResult, InstanceActionFailure>> SendCommandToInstance(SendCommandToInstanceCommand command) {
var instanceGuid = command.InstanceGuid; var instanceGuid = command.InstanceGuid;
if (!instances.TryGetValue(instanceGuid, out var instanceInfo)) { if (!instances.TryGetValue(instanceGuid, out var instanceInfo)) {
return InstanceActionResult.General<SendCommandToInstanceResult>(InstanceActionGeneralResult.InstanceDoesNotExist); return InstanceActionFailure.InstanceDoesNotExist;
} }
try { try {
return InstanceActionResult.Concrete(await instanceInfo.Actor.Request(new InstanceActor.SendCommandToInstanceCommand(command.Command), shutdownCancellationToken)); return await instanceInfo.Actor.Request(new InstanceActor.SendCommandToInstanceCommand(command.Command), shutdownCancellationToken);
} catch (OperationCanceledException) { } catch (OperationCanceledException) {
return InstanceActionResult.General<SendCommandToInstanceResult>(InstanceActionGeneralResult.AgentShuttingDown); return InstanceActionFailure.AgentShuttingDown;
} }
} }

View File

@@ -5,7 +5,6 @@ using Phantom.Common.Data.Instance;
using Phantom.Common.Data.Replies; using Phantom.Common.Data.Replies;
using Phantom.Common.Messages.Agent.ToController; using Phantom.Common.Messages.Agent.ToController;
using Phantom.Utils.Logging; using Phantom.Utils.Logging;
using Phantom.Utils.Tasks;
using Serilog; using Serilog;
namespace Phantom.Agent.Services.Instances; namespace Phantom.Agent.Services.Instances;

View File

@@ -1,8 +1,8 @@
using Phantom.Agent.Minecraft.Instance; using Phantom.Agent.Minecraft.Instance;
using Phantom.Agent.Minecraft.Launcher; using Phantom.Agent.Minecraft.Launcher;
using Phantom.Agent.Minecraft.Server; using Phantom.Agent.Minecraft.Server;
using Phantom.Common.Data;
using Phantom.Common.Data.Instance; using Phantom.Common.Data.Instance;
using Phantom.Utils.Tasks;
namespace Phantom.Agent.Services.Instances.State; namespace Phantom.Agent.Services.Instances.State;

View File

@@ -0,0 +1,140 @@
using Phantom.Agent.Minecraft.Instance;
using Phantom.Agent.Minecraft.Server;
using Phantom.Agent.Rpc;
using Phantom.Common.Data.Instance;
using Phantom.Common.Messages.Agent.ToController;
using Phantom.Utils.Logging;
using Phantom.Utils.Tasks;
using Phantom.Utils.Threading;
namespace Phantom.Agent.Services.Instances.State;
sealed class InstancePlayerCountTracker : CancellableBackgroundTask {
private readonly ControllerConnection controllerConnection;
private readonly Guid instanceGuid;
private readonly ushort serverPort;
private readonly InstanceProcess process;
private readonly TaskCompletionSource firstDetection = AsyncTasks.CreateCompletionSource();
private readonly ManualResetEventSlim serverOutputEvent = new ();
private InstancePlayerCounts? playerCounts;
public InstancePlayerCounts? PlayerCounts {
get {
lock (this) {
return playerCounts;
}
}
private set {
EventHandler<int?>? onlinePlayerCountChanged;
lock (this) {
if (playerCounts == value) {
return;
}
playerCounts = value;
onlinePlayerCountChanged = OnlinePlayerCountChanged;
}
onlinePlayerCountChanged?.Invoke(this, value?.Online);
controllerConnection.Send(new ReportInstancePlayerCountsMessage(instanceGuid, value));
}
}
private event EventHandler<int?>? OnlinePlayerCountChanged;
private bool isDisposed = false;
public InstancePlayerCountTracker(InstanceContext context, InstanceProcess process, ushort serverPort) : base(PhantomLogger.Create<InstancePlayerCountTracker>(context.ShortName)) {
this.controllerConnection = context.Services.ControllerConnection;
this.instanceGuid = context.InstanceGuid;
this.process = process;
this.serverPort = serverPort;
Start();
}
protected override async Task RunTask() {
// Give the server time to start accepting connections.
await Task.Delay(TimeSpan.FromSeconds(10), CancellationToken);
serverOutputEvent.Set();
process.AddOutputListener(OnOutput, maxLinesToReadFromHistory: 0);
while (!CancellationToken.IsCancellationRequested) {
serverOutputEvent.Reset();
PlayerCounts = await TryGetPlayerCounts();
if (!firstDetection.Task.IsCompleted) {
firstDetection.SetResult();
}
await Task.Delay(TimeSpan.FromSeconds(10), CancellationToken);
await serverOutputEvent.WaitHandle.WaitOneAsync(CancellationToken);
await Task.Delay(TimeSpan.FromSeconds(1), CancellationToken);
}
}
private async Task<InstancePlayerCounts?> TryGetPlayerCounts() {
try {
var result = await ServerStatusProtocol.GetPlayerCounts(serverPort, CancellationToken);
Logger.Debug("Detected {OnlinePlayerCount} / {MaximumPlayerCount} online player(s).", result.Online, result.Maximum);
return result;
} catch (ServerStatusProtocol.ProtocolException e) {
Logger.Error(e.Message);
return null;
} catch (Exception e) {
Logger.Error(e, "Caught exception while checking online player count.");
return null;
}
}
public async Task WaitForOnlinePlayers(CancellationToken cancellationToken) {
await firstDetection.Task.WaitAsync(cancellationToken);
var onlinePlayersDetected = AsyncTasks.CreateCompletionSource();
lock (this) {
if (playerCounts is { Online: > 0 }) {
return;
}
else if (playerCounts == null) {
throw new InvalidOperationException();
}
OnlinePlayerCountChanged += OnOnlinePlayerCountChanged;
void OnOnlinePlayerCountChanged(object? sender, int? newPlayerCount) {
if (newPlayerCount == null) {
onlinePlayersDetected.TrySetException(new InvalidOperationException());
OnlinePlayerCountChanged -= OnOnlinePlayerCountChanged;
}
else if (newPlayerCount > 0) {
onlinePlayersDetected.TrySetResult();
OnlinePlayerCountChanged -= OnOnlinePlayerCountChanged;
}
}
}
await onlinePlayersDetected.Task;
}
private void OnOutput(object? sender, string? line) {
lock (this) {
if (!isDisposed) {
serverOutputEvent.Set();
}
}
}
protected override void Dispose() {
lock (this) {
isDisposed = true;
playerCounts = null;
}
process.RemoveOutputListener(OnOutput);
serverOutputEvent.Dispose();
}
}

View File

@@ -19,6 +19,7 @@ sealed class InstanceRunningState : IDisposable {
private readonly CancellationToken cancellationToken; private readonly CancellationToken cancellationToken;
private readonly InstanceLogSender logSender; private readonly InstanceLogSender logSender;
private readonly InstancePlayerCountTracker playerCountTracker;
private readonly BackupScheduler backupScheduler; private readonly BackupScheduler backupScheduler;
private bool isDisposed; private bool isDisposed;
@@ -32,8 +33,9 @@ sealed class InstanceRunningState : IDisposable {
this.cancellationToken = cancellationToken; this.cancellationToken = cancellationToken;
this.logSender = new InstanceLogSender(context.Services.ControllerConnection, context.InstanceGuid, context.ShortName); this.logSender = new InstanceLogSender(context.Services.ControllerConnection, context.InstanceGuid, context.ShortName);
this.playerCountTracker = new InstancePlayerCountTracker(context, process, configuration.ServerPort);
this.backupScheduler = new BackupScheduler(context, process, configuration.ServerPort); this.backupScheduler = new BackupScheduler(context, playerCountTracker);
this.backupScheduler.BackupCompleted += OnScheduledBackupCompleted; this.backupScheduler.BackupCompleted += OnScheduledBackupCompleted;
} }
@@ -93,6 +95,11 @@ sealed class InstanceRunningState : IDisposable {
} }
} }
public void OnStopInitiated() {
backupScheduler.Stop();
playerCountTracker.Stop();
}
private bool TryDispose() { private bool TryDispose() {
lock (this) { lock (this) {
if (isDisposed) { if (isDisposed) {
@@ -102,8 +109,8 @@ sealed class InstanceRunningState : IDisposable {
isDisposed = true; isDisposed = true;
} }
OnStopInitiated();
logSender.Stop(); logSender.Stop();
backupScheduler.Stop();
Process.Dispose(); Process.Dispose();

View File

@@ -25,6 +25,8 @@ static class InstanceStopProcedure {
try { try {
// Too late to cancel the stop procedure now. // Too late to cancel the stop procedure now.
runningState.OnStopInitiated();
if (!process.HasEnded) { if (!process.HasEnded) {
context.Logger.Information("Session stopping now."); context.Logger.Information("Session stopping now.");
await DoStop(context, process); await DoStop(context, process);
@@ -85,7 +87,7 @@ static class InstanceStopProcedure {
private static async Task WaitForSessionToEnd(InstanceContext context, InstanceProcess process) { private static async Task WaitForSessionToEnd(InstanceContext context, InstanceProcess process) {
try { try {
await process.WaitForExit(TimeSpan.FromSeconds(55)); await process.WaitForExit(TimeSpan.FromSeconds(55));
} catch (OperationCanceledException) { } catch (TimeoutException) {
try { try {
context.Logger.Warning("Waiting timed out, killing session..."); context.Logger.Warning("Waiting timed out, killing session...");
process.Kill(); process.Kill();

View File

@@ -1,4 +1,5 @@
using Phantom.Agent.Services.Instances; using Phantom.Agent.Services.Instances;
using Phantom.Common.Data;
using Phantom.Common.Data.Instance; using Phantom.Common.Data.Instance;
using Phantom.Common.Data.Replies; using Phantom.Common.Data.Replies;
using Phantom.Common.Messages.Agent; using Phantom.Common.Messages.Agent;
@@ -32,10 +33,10 @@ public sealed class ControllerMessageHandlerActor : ReceiveActor<IMessageToAgent
ReceiveAsync<RegisterAgentSuccessMessage>(HandleRegisterAgentSuccess); ReceiveAsync<RegisterAgentSuccessMessage>(HandleRegisterAgentSuccess);
Receive<RegisterAgentFailureMessage>(HandleRegisterAgentFailure); Receive<RegisterAgentFailureMessage>(HandleRegisterAgentFailure);
ReceiveAndReplyLater<ConfigureInstanceMessage, InstanceActionResult<ConfigureInstanceResult>>(HandleConfigureInstance); ReceiveAndReplyLater<ConfigureInstanceMessage, Result<ConfigureInstanceResult, InstanceActionFailure>>(HandleConfigureInstance);
ReceiveAndReplyLater<LaunchInstanceMessage, InstanceActionResult<LaunchInstanceResult>>(HandleLaunchInstance); ReceiveAndReplyLater<LaunchInstanceMessage, Result<LaunchInstanceResult, InstanceActionFailure>>(HandleLaunchInstance);
ReceiveAndReplyLater<StopInstanceMessage, InstanceActionResult<StopInstanceResult>>(HandleStopInstance); ReceiveAndReplyLater<StopInstanceMessage, Result<StopInstanceResult, InstanceActionFailure>>(HandleStopInstance);
ReceiveAndReplyLater<SendCommandToInstanceMessage, InstanceActionResult<SendCommandToInstanceResult>>(HandleSendCommandToInstance); ReceiveAndReplyLater<SendCommandToInstanceMessage, Result<SendCommandToInstanceResult, InstanceActionFailure>>(HandleSendCommandToInstance);
Receive<ReplyMessage>(HandleReply); Receive<ReplyMessage>(HandleReply);
} }
@@ -74,23 +75,23 @@ public sealed class ControllerMessageHandlerActor : ReceiveActor<IMessageToAgent
Environment.Exit(1); Environment.Exit(1);
} }
private Task<InstanceActionResult<ConfigureInstanceResult>> HandleConfigureInstance(ConfigureInstanceMessage message, bool alwaysReportStatus) { private Task<Result<ConfigureInstanceResult, InstanceActionFailure>> HandleConfigureInstance(ConfigureInstanceMessage message, bool alwaysReportStatus) {
return agent.InstanceManager.Request(new InstanceManagerActor.ConfigureInstanceCommand(message.InstanceGuid, message.Configuration, message.LaunchProperties, message.LaunchNow, alwaysReportStatus)); return agent.InstanceManager.Request(new InstanceManagerActor.ConfigureInstanceCommand(message.InstanceGuid, message.Configuration, message.LaunchProperties, message.LaunchNow, alwaysReportStatus));
} }
private async Task<InstanceActionResult<ConfigureInstanceResult>> HandleConfigureInstance(ConfigureInstanceMessage message) { private async Task<Result<ConfigureInstanceResult, InstanceActionFailure>> HandleConfigureInstance(ConfigureInstanceMessage message) {
return await HandleConfigureInstance(message, alwaysReportStatus: false); return await HandleConfigureInstance(message, alwaysReportStatus: false);
} }
private async Task<InstanceActionResult<LaunchInstanceResult>> HandleLaunchInstance(LaunchInstanceMessage message) { private async Task<Result<LaunchInstanceResult, InstanceActionFailure>> HandleLaunchInstance(LaunchInstanceMessage message) {
return await agent.InstanceManager.Request(new InstanceManagerActor.LaunchInstanceCommand(message.InstanceGuid)); return await agent.InstanceManager.Request(new InstanceManagerActor.LaunchInstanceCommand(message.InstanceGuid));
} }
private async Task<InstanceActionResult<StopInstanceResult>> HandleStopInstance(StopInstanceMessage message) { private async Task<Result<StopInstanceResult, InstanceActionFailure>> HandleStopInstance(StopInstanceMessage message) {
return await agent.InstanceManager.Request(new InstanceManagerActor.StopInstanceCommand(message.InstanceGuid, message.StopStrategy)); return await agent.InstanceManager.Request(new InstanceManagerActor.StopInstanceCommand(message.InstanceGuid, message.StopStrategy));
} }
private async Task<InstanceActionResult<SendCommandToInstanceResult>> HandleSendCommandToInstance(SendCommandToInstanceMessage message) { private async Task<Result<SendCommandToInstanceResult, InstanceActionFailure>> HandleSendCommandToInstance(SendCommandToInstanceMessage message) {
return await agent.InstanceManager.Request(new InstanceManagerActor.SendCommandToInstanceCommand(message.InstanceGuid, message.Command)); return await agent.InstanceManager.Request(new InstanceManagerActor.SendCommandToInstanceCommand(message.InstanceGuid, message.Command));
} }

View File

@@ -8,9 +8,10 @@ public sealed partial record Instance(
[property: MemoryPackOrder(0)] Guid InstanceGuid, [property: MemoryPackOrder(0)] Guid InstanceGuid,
[property: MemoryPackOrder(1)] InstanceConfiguration Configuration, [property: MemoryPackOrder(1)] InstanceConfiguration Configuration,
[property: MemoryPackOrder(2)] IInstanceStatus Status, [property: MemoryPackOrder(2)] IInstanceStatus Status,
[property: MemoryPackOrder(3)] bool LaunchAutomatically [property: MemoryPackOrder(3)] InstancePlayerCounts? PlayerCounts,
[property: MemoryPackOrder(4)] bool LaunchAutomatically
) { ) {
public static Instance Offline(Guid instanceGuid, InstanceConfiguration configuration, bool launchAutomatically = false) { public static Instance Offline(Guid instanceGuid, InstanceConfiguration configuration, bool launchAutomatically = false) {
return new Instance(instanceGuid, configuration, InstanceStatus.Offline, launchAutomatically); return new Instance(instanceGuid, configuration, InstanceStatus.Offline, PlayerCounts: null, launchAutomatically);
} }
} }

View File

@@ -0,0 +1,24 @@
using System.Collections.Immutable;
using MemoryPack;
namespace Phantom.Common.Data.Web.Users;
[MemoryPackable(GenerateType.VersionTolerant)]
public sealed partial record AuthenticatedUserInfo(
[property: MemoryPackOrder(0)] Guid Guid,
[property: MemoryPackOrder(1)] string Name,
[property: MemoryPackOrder(2)] PermissionSet Permissions,
[property: MemoryPackOrder(3)] ImmutableHashSet<Guid> ManagedAgentGuids
) {
public bool CheckPermission(Permission permission) {
return Permissions.Check(permission);
}
public bool HasAccessToAgent(Guid agentGuid) {
return ManagedAgentGuids.Contains(agentGuid) || Permissions.Check(Permission.ManageAllAgents);
}
public ImmutableHashSet<Guid> FilterAccessibleAgentGuids(ImmutableHashSet<Guid> agentGuids) {
return Permissions.Check(Permission.ManageAllAgents) ? agentGuids : agentGuids.Intersect(ManagedAgentGuids);
}
}

View File

@@ -5,7 +5,6 @@ namespace Phantom.Common.Data.Web.Users;
[MemoryPackable(GenerateType.VersionTolerant)] [MemoryPackable(GenerateType.VersionTolerant)]
public sealed partial record LogInSuccess( public sealed partial record LogInSuccess(
[property: MemoryPackOrder(0)] Guid UserGuid, [property: MemoryPackOrder(0)] AuthenticatedUserInfo UserInfo,
[property: MemoryPackOrder(1)] PermissionSet Permissions, [property: MemoryPackOrder(1)] ImmutableArray<byte> AuthToken
[property: MemoryPackOrder(2)] ImmutableArray<byte> Token
); );

View File

@@ -14,6 +14,9 @@ public sealed record Permission(string Id, Permission? Parent) {
return Register(id, this); return Register(id, this);
} }
public const string ManageAllAgentsPolicy = "Agents.ManageAll";
public static readonly Permission ManageAllAgents = Register(ManageAllAgentsPolicy);
public const string ViewInstancesPolicy = "Instances.View"; public const string ViewInstancesPolicy = "Instances.View";
public static readonly Permission ViewInstances = Register(ViewInstancesPolicy); public static readonly Permission ViewInstances = Register(ViewInstancesPolicy);

View File

@@ -0,0 +1,5 @@
namespace Phantom.Common.Data.Web.Users;
public enum UserActionFailure {
NotAuthorized
}

View File

@@ -0,0 +1,25 @@
using MemoryPack;
using Phantom.Common.Data.Replies;
namespace Phantom.Common.Data.Web.Users;
[MemoryPackable]
[MemoryPackUnion(0, typeof(OfUserActionFailure))]
[MemoryPackUnion(1, typeof(OfInstanceActionFailure))]
public abstract partial record UserInstanceActionFailure {
internal UserInstanceActionFailure() {}
public static implicit operator UserInstanceActionFailure(UserActionFailure failure) {
return new OfUserActionFailure(failure);
}
public static implicit operator UserInstanceActionFailure(InstanceActionFailure failure) {
return new OfInstanceActionFailure(failure);
}
}
[MemoryPackable(GenerateType.VersionTolerant)]
public sealed partial record OfUserActionFailure([property: MemoryPackOrder(0)] UserActionFailure Failure) : UserInstanceActionFailure;
[MemoryPackable(GenerateType.VersionTolerant)]
public sealed partial record OfInstanceActionFailure([property: MemoryPackOrder(0)] InstanceActionFailure Failure) : UserInstanceActionFailure;

View File

@@ -1,15 +1,16 @@
namespace Phantom.Common.Data.Backups; namespace Phantom.Common.Data.Backups;
public enum BackupCreationResultKind : byte { public enum BackupCreationResultKind : byte {
UnknownError, UnknownError = 0,
Success, Success = 1,
InstanceNotRunning, InstanceNotRunning = 2,
BackupCancelled, BackupTimedOut = 3,
BackupAlreadyRunning, BackupCancelled = 4,
BackupFileAlreadyExists, BackupAlreadyRunning = 5,
CouldNotCreateBackupFolder, BackupFileAlreadyExists = 6,
CouldNotCopyWorldToTemporaryFolder, CouldNotCreateBackupFolder = 7,
CouldNotCreateWorldArchive CouldNotCopyWorldToTemporaryFolder = 8,
CouldNotCreateWorldArchive = 9
} }
public static class BackupCreationResultSummaryExtensions { public static class BackupCreationResultSummaryExtensions {

View File

@@ -9,9 +9,10 @@ namespace Phantom.Common.Data.Instance;
[MemoryPackUnion(3, typeof(InstanceIsDownloading))] [MemoryPackUnion(3, typeof(InstanceIsDownloading))]
[MemoryPackUnion(4, typeof(InstanceIsLaunching))] [MemoryPackUnion(4, typeof(InstanceIsLaunching))]
[MemoryPackUnion(5, typeof(InstanceIsRunning))] [MemoryPackUnion(5, typeof(InstanceIsRunning))]
[MemoryPackUnion(6, typeof(InstanceIsRestarting))] [MemoryPackUnion(6, typeof(InstanceIsBackingUp))]
[MemoryPackUnion(7, typeof(InstanceIsStopping))] [MemoryPackUnion(7, typeof(InstanceIsRestarting))]
[MemoryPackUnion(8, typeof(InstanceIsFailed))] [MemoryPackUnion(8, typeof(InstanceIsStopping))]
[MemoryPackUnion(9, typeof(InstanceIsFailed))]
public partial interface IInstanceStatus {} public partial interface IInstanceStatus {}
[MemoryPackable(GenerateType.VersionTolerant)] [MemoryPackable(GenerateType.VersionTolerant)]
@@ -32,6 +33,9 @@ public sealed partial record InstanceIsLaunching : IInstanceStatus;
[MemoryPackable(GenerateType.VersionTolerant)] [MemoryPackable(GenerateType.VersionTolerant)]
public sealed partial record InstanceIsRunning : IInstanceStatus; public sealed partial record InstanceIsRunning : IInstanceStatus;
[MemoryPackable(GenerateType.VersionTolerant)]
public sealed partial record InstanceIsBackingUp : IInstanceStatus;
[MemoryPackable(GenerateType.VersionTolerant)] [MemoryPackable(GenerateType.VersionTolerant)]
public sealed partial record InstanceIsRestarting : IInstanceStatus; public sealed partial record InstanceIsRestarting : IInstanceStatus;
@@ -46,6 +50,7 @@ public static class InstanceStatus {
public static readonly IInstanceStatus NotRunning = new InstanceIsNotRunning(); public static readonly IInstanceStatus NotRunning = new InstanceIsNotRunning();
public static readonly IInstanceStatus Launching = new InstanceIsLaunching(); public static readonly IInstanceStatus Launching = new InstanceIsLaunching();
public static readonly IInstanceStatus Running = new InstanceIsRunning(); public static readonly IInstanceStatus Running = new InstanceIsRunning();
public static readonly IInstanceStatus BackingUp = new InstanceIsBackingUp();
public static readonly IInstanceStatus Restarting = new InstanceIsRestarting(); public static readonly IInstanceStatus Restarting = new InstanceIsRestarting();
public static readonly IInstanceStatus Stopping = new InstanceIsStopping(); public static readonly IInstanceStatus Stopping = new InstanceIsStopping();
@@ -58,7 +63,7 @@ public static class InstanceStatus {
} }
public static bool IsRunning(this IInstanceStatus status) { public static bool IsRunning(this IInstanceStatus status) {
return status is InstanceIsRunning; return status is InstanceIsRunning or InstanceIsBackingUp;
} }
public static bool IsStopping(this IInstanceStatus status) { public static bool IsStopping(this IInstanceStatus status) {
@@ -70,10 +75,10 @@ public static class InstanceStatus {
} }
public static bool CanStop(this IInstanceStatus status) { public static bool CanStop(this IInstanceStatus status) {
return status is InstanceIsDownloading or InstanceIsLaunching or InstanceIsRunning; return status.IsRunning() || status.IsLaunching();
} }
public static bool CanSendCommand(this IInstanceStatus status) { public static bool CanSendCommand(this IInstanceStatus status) {
return status is InstanceIsRunning; return status.IsRunning();
} }
} }

View File

@@ -0,0 +1,9 @@
using MemoryPack;
namespace Phantom.Common.Data.Instance;
[MemoryPackable(GenerateType.VersionTolerant)]
public readonly partial record struct InstancePlayerCounts(
[property: MemoryPackOrder(0)] int Online,
[property: MemoryPackOrder(1)] int Maximum
);

View File

@@ -0,0 +1,10 @@
using MemoryPack;
namespace Phantom.Common.Data;
[MemoryPackable]
public readonly partial record struct Optional<T>(T? Value) {
public static implicit operator Optional<T>(T? value) {
return new Optional<T>(value);
}
}

View File

@@ -0,0 +1,20 @@
namespace Phantom.Common.Data.Replies;
public enum InstanceActionFailure : byte {
AgentDoesNotExist,
AgentShuttingDown,
AgentIsNotResponding,
InstanceDoesNotExist
}
public static class InstanceActionFailureExtensions {
public static string ToSentence(this InstanceActionFailure failure) {
return failure switch {
InstanceActionFailure.AgentDoesNotExist => "Agent does not exist.",
InstanceActionFailure.AgentShuttingDown => "Agent is shutting down.",
InstanceActionFailure.AgentIsNotResponding => "Agent is not responding.",
InstanceActionFailure.InstanceDoesNotExist => "Instance does not exist.",
_ => "Unknown error."
};
}
}

View File

@@ -1,9 +0,0 @@
namespace Phantom.Common.Data.Replies;
public enum InstanceActionGeneralResult : byte {
None,
AgentDoesNotExist,
AgentShuttingDown,
AgentIsNotResponding,
InstanceDoesNotExist
}

View File

@@ -1,42 +0,0 @@
using MemoryPack;
namespace Phantom.Common.Data.Replies;
[MemoryPackable(GenerateType.VersionTolerant)]
public sealed partial record InstanceActionResult<T>(
[property: MemoryPackOrder(0)] InstanceActionGeneralResult GeneralResult,
[property: MemoryPackOrder(1)] T? ConcreteResult
) {
public bool Is(T? concreteResult) {
return GeneralResult == InstanceActionGeneralResult.None && EqualityComparer<T>.Default.Equals(ConcreteResult, concreteResult);
}
public InstanceActionResult<T2> Map<T2>(Func<T, T2> mapper) {
return new InstanceActionResult<T2>(GeneralResult, ConcreteResult is not null ? mapper(ConcreteResult) : default);
}
public string ToSentence(Func<T, string> concreteResultToSentence) {
return GeneralResult switch {
InstanceActionGeneralResult.None => concreteResultToSentence(ConcreteResult!),
InstanceActionGeneralResult.AgentDoesNotExist => "Agent does not exist.",
InstanceActionGeneralResult.AgentShuttingDown => "Agent is shutting down.",
InstanceActionGeneralResult.AgentIsNotResponding => "Agent is not responding.",
InstanceActionGeneralResult.InstanceDoesNotExist => "Instance does not exist.",
_ => "Unknown result."
};
}
}
public static class InstanceActionResult {
public static InstanceActionResult<T> General<T>(InstanceActionGeneralResult generalResult) {
return new InstanceActionResult<T>(generalResult, default);
}
public static InstanceActionResult<T> Concrete<T>(T? concreteResult) {
return new InstanceActionResult<T>(InstanceActionGeneralResult.None, concreteResult);
}
public static InstanceActionResult<T> DidNotReplyIfNull<T>(this InstanceActionResult<T>? result) {
return result ?? General<T>(InstanceActionGeneralResult.AgentIsNotResponding);
}
}

View File

@@ -0,0 +1,108 @@
using System.Diagnostics.CodeAnalysis;
using MemoryPack;
using Phantom.Utils.Result;
namespace Phantom.Common.Data;
[MemoryPackable(GenerateType.VersionTolerant)]
public sealed partial class Result<TValue, TError> {
[MemoryPackOrder(0)]
[MemoryPackInclude]
private readonly bool hasValue;
[MemoryPackOrder(1)]
[MemoryPackInclude]
private readonly TValue? value;
[MemoryPackOrder(2)]
[MemoryPackInclude]
private readonly TError? error;
[MemoryPackIgnore]
public TValue Value => hasValue ? value! : throw new InvalidOperationException("Attempted to get value from an error result.");
[MemoryPackIgnore]
public TError Error => !hasValue ? error! : throw new InvalidOperationException("Attempted to get error from a success result.");
private Result(bool hasValue, TValue? value, TError? error) {
this.hasValue = hasValue;
this.value = value;
this.error = error;
}
public bool Is(TValue expectedValue) {
return hasValue && EqualityComparer<TValue>.Default.Equals(value, expectedValue);
}
public TOutput Into<TOutput>(Func<TValue, TOutput> valueConverter, Func<TError, TOutput> errorConverter) {
return hasValue ? valueConverter(value!) : errorConverter(error!);
}
public Result<TValue, TNewError> MapError<TNewError>(Func<TError, TNewError> errorConverter) {
return hasValue ? value! : errorConverter(error!);
}
public Utils.Result.Result Variant() {
return hasValue ? new Ok<TValue>(Value) : new Err<TError>(Error);
}
public static implicit operator Result<TValue, TError>(TValue value) {
return new Result<TValue, TError>(hasValue: true, value, default);
}
public static implicit operator Result<TValue, TError>(TError error) {
return new Result<TValue, TError>(hasValue: false, default, error);
}
public static implicit operator bool(Result<TValue, TError> result) {
return result.hasValue;
}
}
[MemoryPackable(GenerateType.VersionTolerant)]
public sealed partial class Result<TError> {
[MemoryPackOrder(0)]
[MemoryPackInclude]
private readonly bool hasValue;
[MemoryPackOrder(1)]
[MemoryPackInclude]
private readonly TError? error;
[MemoryPackIgnore]
public TError Error => !hasValue ? error! : throw new InvalidOperationException("Attempted to get error from a success result.");
private Result(bool hasValue, TError? error) {
this.hasValue = hasValue;
this.error = error;
}
public bool TryGetError([MaybeNullWhen(false)] out TError error) {
if (hasValue) {
error = default;
return false;
}
else {
error = this.error!;
return true;
}
}
public static implicit operator Result<TError>([SuppressMessage("ReSharper", "UnusedParameter.Global")] Result.OkType _) {
return new Result<TError>(hasValue: true, default);
}
public static implicit operator Result<TError>(TError error) {
return new Result<TError>(hasValue: false, error);
}
public static implicit operator bool(Result<TError> result) {
return result.hasValue;
}
}
public static class Result {
public static OkType Ok { get; } = new ();
public readonly record struct OkType;
}

View File

@@ -1,4 +1,5 @@
using Phantom.Common.Data.Replies; using Phantom.Common.Data;
using Phantom.Common.Data.Replies;
using Phantom.Common.Messages.Agent.BiDirectional; using Phantom.Common.Messages.Agent.BiDirectional;
using Phantom.Common.Messages.Agent.ToAgent; using Phantom.Common.Messages.Agent.ToAgent;
using Phantom.Common.Messages.Agent.ToController; using Phantom.Common.Messages.Agent.ToController;
@@ -16,10 +17,10 @@ public static class AgentMessageRegistries {
static AgentMessageRegistries() { static AgentMessageRegistries() {
ToAgent.Add<RegisterAgentSuccessMessage>(0); ToAgent.Add<RegisterAgentSuccessMessage>(0);
ToAgent.Add<RegisterAgentFailureMessage>(1); ToAgent.Add<RegisterAgentFailureMessage>(1);
ToAgent.Add<ConfigureInstanceMessage, InstanceActionResult<ConfigureInstanceResult>>(2); ToAgent.Add<ConfigureInstanceMessage, Result<ConfigureInstanceResult, InstanceActionFailure>>(2);
ToAgent.Add<LaunchInstanceMessage, InstanceActionResult<LaunchInstanceResult>>(3); ToAgent.Add<LaunchInstanceMessage, Result<LaunchInstanceResult, InstanceActionFailure>>(3);
ToAgent.Add<StopInstanceMessage, InstanceActionResult<StopInstanceResult>>(4); ToAgent.Add<StopInstanceMessage, Result<StopInstanceResult, InstanceActionFailure>>(4);
ToAgent.Add<SendCommandToInstanceMessage, InstanceActionResult<SendCommandToInstanceResult>>(5); ToAgent.Add<SendCommandToInstanceMessage, Result<SendCommandToInstanceResult, InstanceActionFailure>>(5);
ToAgent.Add<ReplyMessage>(127); ToAgent.Add<ReplyMessage>(127);
ToController.Add<RegisterAgentMessage>(0); ToController.Add<RegisterAgentMessage>(0);
@@ -30,6 +31,7 @@ public static class AgentMessageRegistries {
ToController.Add<InstanceOutputMessage>(5); ToController.Add<InstanceOutputMessage>(5);
ToController.Add<ReportAgentStatusMessage>(6); ToController.Add<ReportAgentStatusMessage>(6);
ToController.Add<ReportInstanceEventMessage>(7); ToController.Add<ReportInstanceEventMessage>(7);
ToController.Add<ReportInstancePlayerCountsMessage>(8);
ToController.Add<ReplyMessage>(127); ToController.Add<ReplyMessage>(127);
} }

View File

@@ -1,4 +1,5 @@
using MemoryPack; using MemoryPack;
using Phantom.Common.Data;
using Phantom.Common.Data.Instance; using Phantom.Common.Data.Instance;
using Phantom.Common.Data.Replies; using Phantom.Common.Data.Replies;
using Phantom.Utils.Actor; using Phantom.Utils.Actor;
@@ -11,4 +12,4 @@ public sealed partial record ConfigureInstanceMessage(
[property: MemoryPackOrder(1)] InstanceConfiguration Configuration, [property: MemoryPackOrder(1)] InstanceConfiguration Configuration,
[property: MemoryPackOrder(2)] InstanceLaunchProperties LaunchProperties, [property: MemoryPackOrder(2)] InstanceLaunchProperties LaunchProperties,
[property: MemoryPackOrder(3)] bool LaunchNow = false [property: MemoryPackOrder(3)] bool LaunchNow = false
) : IMessageToAgent, ICanReply<InstanceActionResult<ConfigureInstanceResult>>; ) : IMessageToAgent, ICanReply<Result<ConfigureInstanceResult, InstanceActionFailure>>;

View File

@@ -1,4 +1,5 @@
using MemoryPack; using MemoryPack;
using Phantom.Common.Data;
using Phantom.Common.Data.Replies; using Phantom.Common.Data.Replies;
using Phantom.Utils.Actor; using Phantom.Utils.Actor;
@@ -7,4 +8,4 @@ namespace Phantom.Common.Messages.Agent.ToAgent;
[MemoryPackable(GenerateType.VersionTolerant)] [MemoryPackable(GenerateType.VersionTolerant)]
public sealed partial record LaunchInstanceMessage( public sealed partial record LaunchInstanceMessage(
[property: MemoryPackOrder(0)] Guid InstanceGuid [property: MemoryPackOrder(0)] Guid InstanceGuid
) : IMessageToAgent, ICanReply<InstanceActionResult<LaunchInstanceResult>>; ) : IMessageToAgent, ICanReply<Result<LaunchInstanceResult, InstanceActionFailure>>;

View File

@@ -1,4 +1,5 @@
using MemoryPack; using MemoryPack;
using Phantom.Common.Data;
using Phantom.Common.Data.Replies; using Phantom.Common.Data.Replies;
using Phantom.Utils.Actor; using Phantom.Utils.Actor;
@@ -8,4 +9,4 @@ namespace Phantom.Common.Messages.Agent.ToAgent;
public sealed partial record SendCommandToInstanceMessage( public sealed partial record SendCommandToInstanceMessage(
[property: MemoryPackOrder(0)] Guid InstanceGuid, [property: MemoryPackOrder(0)] Guid InstanceGuid,
[property: MemoryPackOrder(1)] string Command [property: MemoryPackOrder(1)] string Command
) : IMessageToAgent, ICanReply<InstanceActionResult<SendCommandToInstanceResult>>; ) : IMessageToAgent, ICanReply<Result<SendCommandToInstanceResult, InstanceActionFailure>>;

View File

@@ -1,4 +1,5 @@
using MemoryPack; using MemoryPack;
using Phantom.Common.Data;
using Phantom.Common.Data.Minecraft; using Phantom.Common.Data.Minecraft;
using Phantom.Common.Data.Replies; using Phantom.Common.Data.Replies;
using Phantom.Utils.Actor; using Phantom.Utils.Actor;
@@ -9,4 +10,4 @@ namespace Phantom.Common.Messages.Agent.ToAgent;
public sealed partial record StopInstanceMessage( public sealed partial record StopInstanceMessage(
[property: MemoryPackOrder(0)] Guid InstanceGuid, [property: MemoryPackOrder(0)] Guid InstanceGuid,
[property: MemoryPackOrder(1)] MinecraftStopStrategy StopStrategy [property: MemoryPackOrder(1)] MinecraftStopStrategy StopStrategy
) : IMessageToAgent, ICanReply<InstanceActionResult<StopInstanceResult>>; ) : IMessageToAgent, ICanReply<Result<StopInstanceResult, InstanceActionFailure>>;

View File

@@ -0,0 +1,10 @@
using MemoryPack;
using Phantom.Common.Data.Instance;
namespace Phantom.Common.Messages.Agent.ToController;
[MemoryPackable(GenerateType.VersionTolerant)]
public sealed partial record ReportInstancePlayerCountsMessage(
[property: MemoryPackOrder(0)] Guid InstanceGuid,
[property: MemoryPackOrder(1)] InstancePlayerCounts? PlayerCounts
) : IMessageToController;

View File

@@ -1,5 +1,6 @@
using System.Collections.Immutable; using System.Collections.Immutable;
using MemoryPack; using MemoryPack;
using Phantom.Common.Data;
using Phantom.Common.Data.Web.Users; using Phantom.Common.Data.Web.Users;
using Phantom.Utils.Actor; using Phantom.Utils.Actor;
@@ -7,8 +8,8 @@ namespace Phantom.Common.Messages.Web.ToController;
[MemoryPackable(GenerateType.VersionTolerant)] [MemoryPackable(GenerateType.VersionTolerant)]
public sealed partial record ChangeUserRolesMessage( public sealed partial record ChangeUserRolesMessage(
[property: MemoryPackOrder(0)] Guid LoggedInUserGuid, [property: MemoryPackOrder(0)] ImmutableArray<byte> AuthToken,
[property: MemoryPackOrder(1)] Guid SubjectUserGuid, [property: MemoryPackOrder(1)] Guid SubjectUserGuid,
[property: MemoryPackOrder(2)] ImmutableHashSet<Guid> AddToRoleGuids, [property: MemoryPackOrder(2)] ImmutableHashSet<Guid> AddToRoleGuids,
[property: MemoryPackOrder(3)] ImmutableHashSet<Guid> RemoveFromRoleGuids [property: MemoryPackOrder(3)] ImmutableHashSet<Guid> RemoveFromRoleGuids
) : IMessageToController, ICanReply<ChangeUserRolesResult>; ) : IMessageToController, ICanReply<Result<ChangeUserRolesResult, UserActionFailure>>;

View File

@@ -1,14 +1,16 @@
using MemoryPack; using System.Collections.Immutable;
using MemoryPack;
using Phantom.Common.Data;
using Phantom.Common.Data.Instance; using Phantom.Common.Data.Instance;
using Phantom.Common.Data.Replies;
using Phantom.Common.Data.Web.Instance; using Phantom.Common.Data.Web.Instance;
using Phantom.Common.Data.Web.Users;
using Phantom.Utils.Actor; using Phantom.Utils.Actor;
namespace Phantom.Common.Messages.Web.ToController; namespace Phantom.Common.Messages.Web.ToController;
[MemoryPackable(GenerateType.VersionTolerant)] [MemoryPackable(GenerateType.VersionTolerant)]
public sealed partial record CreateOrUpdateInstanceMessage( public sealed partial record CreateOrUpdateInstanceMessage(
[property: MemoryPackOrder(0)] Guid LoggedInUserGuid, [property: MemoryPackOrder(0)] ImmutableArray<byte> AuthToken,
[property: MemoryPackOrder(1)] Guid InstanceGuid, [property: MemoryPackOrder(1)] Guid InstanceGuid,
[property: MemoryPackOrder(2)] InstanceConfiguration Configuration [property: MemoryPackOrder(2)] InstanceConfiguration Configuration
) : IMessageToController, ICanReply<InstanceActionResult<CreateOrUpdateInstanceResult>>; ) : IMessageToController, ICanReply<Result<CreateOrUpdateInstanceResult, UserInstanceActionFailure>>;

View File

@@ -1,4 +1,6 @@
using MemoryPack; using System.Collections.Immutable;
using MemoryPack;
using Phantom.Common.Data;
using Phantom.Common.Data.Web.Users; using Phantom.Common.Data.Web.Users;
using Phantom.Utils.Actor; using Phantom.Utils.Actor;
@@ -6,7 +8,7 @@ namespace Phantom.Common.Messages.Web.ToController;
[MemoryPackable(GenerateType.VersionTolerant)] [MemoryPackable(GenerateType.VersionTolerant)]
public sealed partial record CreateUserMessage( public sealed partial record CreateUserMessage(
[property: MemoryPackOrder(0)] Guid LoggedInUserGuid, [property: MemoryPackOrder(0)] ImmutableArray<byte> AuthToken,
[property: MemoryPackOrder(1)] string Username, [property: MemoryPackOrder(1)] string Username,
[property: MemoryPackOrder(2)] string Password [property: MemoryPackOrder(2)] string Password
) : IMessageToController, ICanReply<CreateUserResult>; ) : IMessageToController, ICanReply<Result<CreateUserResult, UserActionFailure>>;

View File

@@ -1,4 +1,6 @@
using MemoryPack; using System.Collections.Immutable;
using MemoryPack;
using Phantom.Common.Data;
using Phantom.Common.Data.Web.Users; using Phantom.Common.Data.Web.Users;
using Phantom.Utils.Actor; using Phantom.Utils.Actor;
@@ -6,6 +8,6 @@ namespace Phantom.Common.Messages.Web.ToController;
[MemoryPackable(GenerateType.VersionTolerant)] [MemoryPackable(GenerateType.VersionTolerant)]
public sealed partial record DeleteUserMessage( public sealed partial record DeleteUserMessage(
[property: MemoryPackOrder(0)] Guid LoggedInUserGuid, [property: MemoryPackOrder(0)] ImmutableArray<byte> AuthToken,
[property: MemoryPackOrder(1)] Guid SubjectUserGuid [property: MemoryPackOrder(1)] Guid SubjectUserGuid
) : IMessageToController, ICanReply<DeleteUserResult>; ) : IMessageToController, ICanReply<Result<DeleteUserResult, UserActionFailure>>;

View File

@@ -1,11 +1,14 @@
using System.Collections.Immutable; using System.Collections.Immutable;
using MemoryPack; using MemoryPack;
using Phantom.Common.Data;
using Phantom.Common.Data.Web.AuditLog; using Phantom.Common.Data.Web.AuditLog;
using Phantom.Common.Data.Web.Users;
using Phantom.Utils.Actor; using Phantom.Utils.Actor;
namespace Phantom.Common.Messages.Web.ToController; namespace Phantom.Common.Messages.Web.ToController;
[MemoryPackable(GenerateType.VersionTolerant)] [MemoryPackable(GenerateType.VersionTolerant)]
public sealed partial record GetAuditLogMessage( public sealed partial record GetAuditLogMessage(
[property: MemoryPackOrder(0)] int Count [property: MemoryPackOrder(0)] ImmutableArray<byte> AuthToken,
) : IMessageToController, ICanReply<ImmutableArray<AuditLogItem>>; [property: MemoryPackOrder(1)] int Count
) : IMessageToController, ICanReply<Result<ImmutableArray<AuditLogItem>, UserActionFailure>>;

View File

@@ -0,0 +1,13 @@
using System.Collections.Immutable;
using MemoryPack;
using Phantom.Common.Data;
using Phantom.Common.Data.Web.Users;
using Phantom.Utils.Actor;
namespace Phantom.Common.Messages.Web.ToController;
[MemoryPackable(GenerateType.VersionTolerant)]
public sealed partial record GetAuthenticatedUser(
[property: MemoryPackOrder(0)] Guid UserGuid,
[property: MemoryPackOrder(1)] ImmutableArray<byte> AuthToken
) : IMessageToController, ICanReply<Optional<AuthenticatedUserInfo>>;

View File

@@ -1,11 +1,14 @@
using System.Collections.Immutable; using System.Collections.Immutable;
using MemoryPack; using MemoryPack;
using Phantom.Common.Data;
using Phantom.Common.Data.Web.EventLog; using Phantom.Common.Data.Web.EventLog;
using Phantom.Common.Data.Web.Users;
using Phantom.Utils.Actor; using Phantom.Utils.Actor;
namespace Phantom.Common.Messages.Web.ToController; namespace Phantom.Common.Messages.Web.ToController;
[MemoryPackable(GenerateType.VersionTolerant)] [MemoryPackable(GenerateType.VersionTolerant)]
public sealed partial record GetEventLogMessage( public sealed partial record GetEventLogMessage(
[property: MemoryPackOrder(0)] int Count [property: MemoryPackOrder(0)] ImmutableArray<byte> AuthToken,
) : IMessageToController, ICanReply<ImmutableArray<EventLogItem>>; [property: MemoryPackOrder(1)] int Count
) : IMessageToController, ICanReply<Result<ImmutableArray<EventLogItem>, UserActionFailure>>;

View File

@@ -1,12 +1,15 @@
using MemoryPack; using System.Collections.Immutable;
using MemoryPack;
using Phantom.Common.Data;
using Phantom.Common.Data.Replies; using Phantom.Common.Data.Replies;
using Phantom.Common.Data.Web.Users;
using Phantom.Utils.Actor; using Phantom.Utils.Actor;
namespace Phantom.Common.Messages.Web.ToController; namespace Phantom.Common.Messages.Web.ToController;
[MemoryPackable(GenerateType.VersionTolerant)] [MemoryPackable(GenerateType.VersionTolerant)]
public sealed partial record LaunchInstanceMessage( public sealed partial record LaunchInstanceMessage(
[property: MemoryPackOrder(0)] Guid LoggedInUserGuid, [property: MemoryPackOrder(0)] ImmutableArray<byte> AuthToken,
[property: MemoryPackOrder(1)] Guid AgentGuid, [property: MemoryPackOrder(1)] Guid AgentGuid,
[property: MemoryPackOrder(2)] Guid InstanceGuid [property: MemoryPackOrder(2)] Guid InstanceGuid
) : IMessageToController, ICanReply<InstanceActionResult<LaunchInstanceResult>>; ) : IMessageToController, ICanReply<Result<LaunchInstanceResult, UserInstanceActionFailure>>;

View File

@@ -1,4 +1,5 @@
using MemoryPack; using MemoryPack;
using Phantom.Common.Data;
using Phantom.Common.Data.Web.Users; using Phantom.Common.Data.Web.Users;
using Phantom.Utils.Actor; using Phantom.Utils.Actor;
@@ -8,4 +9,4 @@ namespace Phantom.Common.Messages.Web.ToController;
public sealed partial record LogInMessage( public sealed partial record LogInMessage(
[property: MemoryPackOrder(0)] string Username, [property: MemoryPackOrder(0)] string Username,
[property: MemoryPackOrder(1)] string Password [property: MemoryPackOrder(1)] string Password
) : IMessageToController, ICanReply<LogInSuccess?>; ) : IMessageToController, ICanReply<Optional<LogInSuccess>>;

View File

@@ -1,13 +1,16 @@
using MemoryPack; using System.Collections.Immutable;
using MemoryPack;
using Phantom.Common.Data;
using Phantom.Common.Data.Replies; using Phantom.Common.Data.Replies;
using Phantom.Common.Data.Web.Users;
using Phantom.Utils.Actor; using Phantom.Utils.Actor;
namespace Phantom.Common.Messages.Web.ToController; namespace Phantom.Common.Messages.Web.ToController;
[MemoryPackable(GenerateType.VersionTolerant)] [MemoryPackable(GenerateType.VersionTolerant)]
public sealed partial record SendCommandToInstanceMessage( public sealed partial record SendCommandToInstanceMessage(
[property: MemoryPackOrder(0)] Guid LoggedInUserGuid, [property: MemoryPackOrder(0)] ImmutableArray<byte> AuthToken,
[property: MemoryPackOrder(1)] Guid AgentGuid, [property: MemoryPackOrder(1)] Guid AgentGuid,
[property: MemoryPackOrder(2)] Guid InstanceGuid, [property: MemoryPackOrder(2)] Guid InstanceGuid,
[property: MemoryPackOrder(3)] string Command [property: MemoryPackOrder(3)] string Command
) : IMessageToController, ICanReply<InstanceActionResult<SendCommandToInstanceResult>>; ) : IMessageToController, ICanReply<Result<SendCommandToInstanceResult, UserInstanceActionFailure>>;

View File

@@ -1,14 +1,17 @@
using MemoryPack; using System.Collections.Immutable;
using MemoryPack;
using Phantom.Common.Data;
using Phantom.Common.Data.Minecraft; using Phantom.Common.Data.Minecraft;
using Phantom.Common.Data.Replies; using Phantom.Common.Data.Replies;
using Phantom.Common.Data.Web.Users;
using Phantom.Utils.Actor; using Phantom.Utils.Actor;
namespace Phantom.Common.Messages.Web.ToController; namespace Phantom.Common.Messages.Web.ToController;
[MemoryPackable(GenerateType.VersionTolerant)] [MemoryPackable(GenerateType.VersionTolerant)]
public sealed partial record StopInstanceMessage( public sealed partial record StopInstanceMessage(
[property: MemoryPackOrder(0)] Guid LoggedInUserGuid, [property: MemoryPackOrder(0)] ImmutableArray<byte> AuthToken,
[property: MemoryPackOrder(1)] Guid AgentGuid, [property: MemoryPackOrder(1)] Guid AgentGuid,
[property: MemoryPackOrder(2)] Guid InstanceGuid, [property: MemoryPackOrder(2)] Guid InstanceGuid,
[property: MemoryPackOrder(3)] MinecraftStopStrategy StopStrategy [property: MemoryPackOrder(3)] MinecraftStopStrategy StopStrategy
) : IMessageToController, ICanReply<InstanceActionResult<StopInstanceResult>>; ) : IMessageToController, ICanReply<Result<StopInstanceResult, UserInstanceActionFailure>>;

View File

@@ -0,0 +1,8 @@
using MemoryPack;
namespace Phantom.Common.Messages.Web.ToWeb;
[MemoryPackable(GenerateType.VersionTolerant)]
public sealed partial record RefreshUserSessionMessage(
[property: MemoryPackOrder(0)] Guid UserGuid
) : IMessageToWeb;

View File

@@ -1,4 +1,5 @@
using System.Collections.Immutable; using System.Collections.Immutable;
using Phantom.Common.Data;
using Phantom.Common.Data.Java; using Phantom.Common.Data.Java;
using Phantom.Common.Data.Minecraft; using Phantom.Common.Data.Minecraft;
using Phantom.Common.Data.Replies; using Phantom.Common.Data.Replies;
@@ -23,29 +24,31 @@ public static class WebMessageRegistries {
static WebMessageRegistries() { static WebMessageRegistries() {
ToController.Add<RegisterWebMessage>(0); ToController.Add<RegisterWebMessage>(0);
ToController.Add<UnregisterWebMessage>(1); ToController.Add<UnregisterWebMessage>(1);
ToController.Add<LogInMessage, LogInSuccess?>(2); ToController.Add<LogInMessage, Optional<LogInSuccess>>(2);
ToController.Add<LogOutMessage>(3); ToController.Add<LogOutMessage>(3);
ToController.Add<CreateOrUpdateAdministratorUserMessage, CreateOrUpdateAdministratorUserResult>(4); ToController.Add<GetAuthenticatedUser, Optional<AuthenticatedUserInfo>>(4);
ToController.Add<CreateUserMessage, CreateUserResult>(5); ToController.Add<CreateOrUpdateAdministratorUserMessage, CreateOrUpdateAdministratorUserResult>(5);
ToController.Add<DeleteUserMessage, DeleteUserResult>(6); ToController.Add<CreateUserMessage, Result<CreateUserResult, UserActionFailure>>(6);
ToController.Add<GetUsersMessage, ImmutableArray<UserInfo>>(7); ToController.Add<DeleteUserMessage, Result<DeleteUserResult, UserActionFailure>>(7);
ToController.Add<GetRolesMessage, ImmutableArray<RoleInfo>>(8); ToController.Add<GetUsersMessage, ImmutableArray<UserInfo>>(8);
ToController.Add<GetUserRolesMessage, ImmutableDictionary<Guid, ImmutableArray<Guid>>>(9); ToController.Add<GetRolesMessage, ImmutableArray<RoleInfo>>(9);
ToController.Add<ChangeUserRolesMessage, ChangeUserRolesResult>(10); ToController.Add<GetUserRolesMessage, ImmutableDictionary<Guid, ImmutableArray<Guid>>>(10);
ToController.Add<CreateOrUpdateInstanceMessage, InstanceActionResult<CreateOrUpdateInstanceResult>>(11); ToController.Add<ChangeUserRolesMessage, Result<ChangeUserRolesResult, UserActionFailure>>(11);
ToController.Add<LaunchInstanceMessage, InstanceActionResult<LaunchInstanceResult>>(12); ToController.Add<CreateOrUpdateInstanceMessage, Result<CreateOrUpdateInstanceResult, UserInstanceActionFailure>>(12);
ToController.Add<StopInstanceMessage, InstanceActionResult<StopInstanceResult>>(13); ToController.Add<LaunchInstanceMessage, Result<LaunchInstanceResult, UserInstanceActionFailure>>(13);
ToController.Add<SendCommandToInstanceMessage, InstanceActionResult<SendCommandToInstanceResult>>(14); ToController.Add<StopInstanceMessage, Result<StopInstanceResult, UserInstanceActionFailure>>(14);
ToController.Add<GetMinecraftVersionsMessage, ImmutableArray<MinecraftVersion>>(15); ToController.Add<SendCommandToInstanceMessage, Result<SendCommandToInstanceResult, UserInstanceActionFailure>>(15);
ToController.Add<GetAgentJavaRuntimesMessage, ImmutableDictionary<Guid, ImmutableArray<TaggedJavaRuntime>>>(16); ToController.Add<GetMinecraftVersionsMessage, ImmutableArray<MinecraftVersion>>(16);
ToController.Add<GetAuditLogMessage, ImmutableArray<AuditLogItem>>(17); ToController.Add<GetAgentJavaRuntimesMessage, ImmutableDictionary<Guid, ImmutableArray<TaggedJavaRuntime>>>(17);
ToController.Add<GetEventLogMessage, ImmutableArray<EventLogItem>>(18); ToController.Add<GetAuditLogMessage, Result<ImmutableArray<AuditLogItem>, UserActionFailure>>(18);
ToController.Add<GetEventLogMessage, Result<ImmutableArray<EventLogItem>, UserActionFailure>>(19);
ToController.Add<ReplyMessage>(127); ToController.Add<ReplyMessage>(127);
ToWeb.Add<RegisterWebResultMessage>(0); ToWeb.Add<RegisterWebResultMessage>(0);
ToWeb.Add<RefreshAgentsMessage>(1); ToWeb.Add<RefreshAgentsMessage>(1);
ToWeb.Add<RefreshInstancesMessage>(2); ToWeb.Add<RefreshInstancesMessage>(2);
ToWeb.Add<InstanceOutputMessage>(3); ToWeb.Add<InstanceOutputMessage>(3);
ToWeb.Add<RefreshUserSessionMessage>(4);
ToWeb.Add<ReplyMessage>(127); ToWeb.Add<ReplyMessage>(127);
} }

View File

@@ -0,0 +1,353 @@
// <auto-generated />
using System;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using Phantom.Controller.Database;
#nullable disable
namespace Phantom.Controller.Database.Postgres.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
[Migration("20240407211636_UserAgentAccess")]
partial class UserAgentAccess
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "8.0.0")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Phantom.Controller.Database.Entities.AgentEntity", b =>
{
b.Property<Guid>("AgentGuid")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("BuildVersion")
.IsRequired()
.HasColumnType("text");
b.Property<int>("MaxInstances")
.HasColumnType("integer");
b.Property<ushort>("MaxMemory")
.HasColumnType("integer");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<int>("ProtocolVersion")
.HasColumnType("integer");
b.HasKey("AgentGuid");
b.ToTable("Agents", "agents");
});
modelBuilder.Entity("Phantom.Controller.Database.Entities.AuditLogEntity", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<JsonDocument>("Data")
.HasColumnType("jsonb");
b.Property<string>("EventType")
.IsRequired()
.HasColumnType("text");
b.Property<string>("SubjectId")
.IsRequired()
.HasColumnType("text");
b.Property<string>("SubjectType")
.IsRequired()
.HasColumnType("text");
b.Property<Guid?>("UserGuid")
.HasColumnType("uuid");
b.Property<DateTime>("UtcTime")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("UserGuid");
b.ToTable("AuditLog", "system");
});
modelBuilder.Entity("Phantom.Controller.Database.Entities.EventLogEntity", b =>
{
b.Property<Guid>("EventGuid")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid?>("AgentGuid")
.HasColumnType("uuid");
b.Property<JsonDocument>("Data")
.HasColumnType("jsonb");
b.Property<string>("EventType")
.IsRequired()
.HasColumnType("text");
b.Property<string>("SubjectId")
.IsRequired()
.HasColumnType("text");
b.Property<string>("SubjectType")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("UtcTime")
.HasColumnType("timestamp with time zone");
b.HasKey("EventGuid");
b.ToTable("EventLog", "system");
});
modelBuilder.Entity("Phantom.Controller.Database.Entities.InstanceEntity", b =>
{
b.Property<Guid>("InstanceGuid")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("AgentGuid")
.HasColumnType("uuid");
b.Property<string>("InstanceName")
.IsRequired()
.HasColumnType("text");
b.Property<Guid>("JavaRuntimeGuid")
.HasColumnType("uuid");
b.Property<string>("JvmArguments")
.IsRequired()
.HasColumnType("text");
b.Property<bool>("LaunchAutomatically")
.HasColumnType("boolean");
b.Property<ushort>("MemoryAllocation")
.HasColumnType("integer");
b.Property<string>("MinecraftServerKind")
.IsRequired()
.HasColumnType("text");
b.Property<string>("MinecraftVersion")
.IsRequired()
.HasColumnType("text");
b.Property<int>("RconPort")
.HasColumnType("integer");
b.Property<int>("ServerPort")
.HasColumnType("integer");
b.HasKey("InstanceGuid");
b.ToTable("Instances", "agents");
});
modelBuilder.Entity("Phantom.Controller.Database.Entities.PermissionEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Permissions", "identity");
});
modelBuilder.Entity("Phantom.Controller.Database.Entities.RoleEntity", b =>
{
b.Property<Guid>("RoleGuid")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.HasKey("RoleGuid");
b.ToTable("Roles", "identity");
});
modelBuilder.Entity("Phantom.Controller.Database.Entities.RolePermissionEntity", b =>
{
b.Property<Guid>("RoleGuid")
.HasColumnType("uuid");
b.Property<string>("PermissionId")
.HasColumnType("text");
b.HasKey("RoleGuid", "PermissionId");
b.HasIndex("PermissionId");
b.ToTable("RolePermissions", "identity");
});
modelBuilder.Entity("Phantom.Controller.Database.Entities.UserAgentAccessEntity", b =>
{
b.Property<Guid>("UserGuid")
.HasColumnType("uuid");
b.Property<Guid>("AgentGuid")
.HasColumnType("uuid");
b.HasKey("UserGuid", "AgentGuid");
b.HasIndex("AgentGuid");
b.ToTable("UserAgentAccess", "identity");
});
modelBuilder.Entity("Phantom.Controller.Database.Entities.UserEntity", b =>
{
b.Property<Guid>("UserGuid")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("text");
b.HasKey("UserGuid");
b.HasIndex("Name")
.IsUnique();
b.ToTable("Users", "identity");
});
modelBuilder.Entity("Phantom.Controller.Database.Entities.UserPermissionEntity", b =>
{
b.Property<Guid>("UserGuid")
.HasColumnType("uuid");
b.Property<string>("PermissionId")
.HasColumnType("text");
b.HasKey("UserGuid", "PermissionId");
b.HasIndex("PermissionId");
b.ToTable("UserPermissions", "identity");
});
modelBuilder.Entity("Phantom.Controller.Database.Entities.UserRoleEntity", b =>
{
b.Property<Guid>("UserGuid")
.HasColumnType("uuid");
b.Property<Guid>("RoleGuid")
.HasColumnType("uuid");
b.HasKey("UserGuid", "RoleGuid");
b.HasIndex("RoleGuid");
b.ToTable("UserRoles", "identity");
});
modelBuilder.Entity("Phantom.Controller.Database.Entities.AuditLogEntity", b =>
{
b.HasOne("Phantom.Controller.Database.Entities.UserEntity", "User")
.WithMany()
.HasForeignKey("UserGuid")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("User");
});
modelBuilder.Entity("Phantom.Controller.Database.Entities.RolePermissionEntity", b =>
{
b.HasOne("Phantom.Controller.Database.Entities.PermissionEntity", null)
.WithMany()
.HasForeignKey("PermissionId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Phantom.Controller.Database.Entities.RoleEntity", null)
.WithMany()
.HasForeignKey("RoleGuid")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Phantom.Controller.Database.Entities.UserAgentAccessEntity", b =>
{
b.HasOne("Phantom.Controller.Database.Entities.AgentEntity", null)
.WithMany()
.HasForeignKey("AgentGuid")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Phantom.Controller.Database.Entities.UserEntity", null)
.WithMany()
.HasForeignKey("UserGuid")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Phantom.Controller.Database.Entities.UserPermissionEntity", b =>
{
b.HasOne("Phantom.Controller.Database.Entities.PermissionEntity", null)
.WithMany()
.HasForeignKey("PermissionId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Phantom.Controller.Database.Entities.UserEntity", null)
.WithMany()
.HasForeignKey("UserGuid")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Phantom.Controller.Database.Entities.UserRoleEntity", b =>
{
b.HasOne("Phantom.Controller.Database.Entities.RoleEntity", "Role")
.WithMany()
.HasForeignKey("RoleGuid")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Phantom.Controller.Database.Entities.UserEntity", "User")
.WithMany()
.HasForeignKey("UserGuid")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Role");
b.Navigation("User");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,56 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Phantom.Controller.Database.Postgres.Migrations
{
/// <inheritdoc />
public partial class UserAgentAccess : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "UserAgentAccess",
schema: "identity",
columns: table => new
{
UserGuid = table.Column<Guid>(type: "uuid", nullable: false),
AgentGuid = table.Column<Guid>(type: "uuid", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_UserAgentAccess", x => new { x.UserGuid, x.AgentGuid });
table.ForeignKey(
name: "FK_UserAgentAccess_Agents_AgentGuid",
column: x => x.AgentGuid,
principalSchema: "agents",
principalTable: "Agents",
principalColumn: "AgentGuid",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_UserAgentAccess_Users_UserGuid",
column: x => x.UserGuid,
principalSchema: "identity",
principalTable: "Users",
principalColumn: "UserGuid",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_UserAgentAccess_AgentGuid",
schema: "identity",
table: "UserAgentAccess",
column: "AgentGuid");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "UserAgentAccess",
schema: "identity");
}
}
}

View File

@@ -18,7 +18,7 @@ namespace Phantom.Controller.Database.Postgres.Migrations
{ {
#pragma warning disable 612, 618 #pragma warning disable 612, 618
modelBuilder modelBuilder
.HasAnnotation("ProductVersion", "7.0.11") .HasAnnotation("ProductVersion", "8.0.0")
.HasAnnotation("Relational:MaxIdentifierLength", 63); .HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
@@ -204,6 +204,21 @@ namespace Phantom.Controller.Database.Postgres.Migrations
b.ToTable("RolePermissions", "identity"); b.ToTable("RolePermissions", "identity");
}); });
modelBuilder.Entity("Phantom.Controller.Database.Entities.UserAgentAccessEntity", b =>
{
b.Property<Guid>("UserGuid")
.HasColumnType("uuid");
b.Property<Guid>("AgentGuid")
.HasColumnType("uuid");
b.HasKey("UserGuid", "AgentGuid");
b.HasIndex("AgentGuid");
b.ToTable("UserAgentAccess", "identity");
});
modelBuilder.Entity("Phantom.Controller.Database.Entities.UserEntity", b => modelBuilder.Entity("Phantom.Controller.Database.Entities.UserEntity", b =>
{ {
b.Property<Guid>("UserGuid") b.Property<Guid>("UserGuid")
@@ -281,6 +296,21 @@ namespace Phantom.Controller.Database.Postgres.Migrations
.IsRequired(); .IsRequired();
}); });
modelBuilder.Entity("Phantom.Controller.Database.Entities.UserAgentAccessEntity", b =>
{
b.HasOne("Phantom.Controller.Database.Entities.AgentEntity", null)
.WithMany()
.HasForeignKey("AgentGuid")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Phantom.Controller.Database.Entities.UserEntity", null)
.WithMany()
.HasForeignKey("UserGuid")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Phantom.Controller.Database.Entities.UserPermissionEntity", b => modelBuilder.Entity("Phantom.Controller.Database.Entities.UserPermissionEntity", b =>
{ {
b.HasOne("Phantom.Controller.Database.Entities.PermissionEntity", null) b.HasOne("Phantom.Controller.Database.Entities.PermissionEntity", null)

View File

@@ -20,6 +20,7 @@ public class ApplicationDbContext : DbContext {
public DbSet<UserRoleEntity> UserRoles { get; init; } = null!; public DbSet<UserRoleEntity> UserRoles { get; init; } = null!;
public DbSet<UserPermissionEntity> UserPermissions { get; init; } = null!; public DbSet<UserPermissionEntity> UserPermissions { get; init; } = null!;
public DbSet<RolePermissionEntity> RolePermissions { get; init; } = null!; public DbSet<RolePermissionEntity> RolePermissions { get; init; } = null!;
public DbSet<UserAgentAccessEntity> UserAgentAccess { get; init; } = null!;
public DbSet<AgentEntity> Agents { get; init; } = null!; public DbSet<AgentEntity> Agents { get; init; } = null!;
public DbSet<InstanceEntity> Instances { get; init; } = null!; public DbSet<InstanceEntity> Instances { get; init; } = null!;
@@ -62,6 +63,12 @@ public class ApplicationDbContext : DbContext {
b.HasOne<RoleEntity>().WithMany().HasForeignKey(static e => e.RoleGuid).IsRequired().OnDelete(DeleteBehavior.Cascade); b.HasOne<RoleEntity>().WithMany().HasForeignKey(static e => e.RoleGuid).IsRequired().OnDelete(DeleteBehavior.Cascade);
b.HasOne<PermissionEntity>().WithMany().HasForeignKey(static e => e.PermissionId).IsRequired().OnDelete(DeleteBehavior.Cascade); b.HasOne<PermissionEntity>().WithMany().HasForeignKey(static e => e.PermissionId).IsRequired().OnDelete(DeleteBehavior.Cascade);
}); });
builder.Entity<UserAgentAccessEntity>(static b => {
b.HasKey(static e => new { UserId = e.UserGuid, AgentId = e.AgentGuid });
b.HasOne<UserEntity>().WithMany().HasForeignKey(static e => e.UserGuid).IsRequired().OnDelete(DeleteBehavior.Cascade);
b.HasOne<AgentEntity>().WithMany().HasForeignKey(static e => e.AgentGuid).IsRequired().OnDelete(DeleteBehavior.Cascade);
});
} }
protected override void ConfigureConventions(ModelConfigurationBuilder builder) { protected override void ConfigureConventions(ModelConfigurationBuilder builder) {

View File

@@ -0,0 +1,14 @@
using System.ComponentModel.DataAnnotations.Schema;
namespace Phantom.Controller.Database.Entities;
[Table("UserAgentAccess", Schema = "identity")]
public sealed class UserAgentAccessEntity {
public Guid UserGuid { get; init; }
public Guid AgentGuid { get; init; }
public UserAgentAccessEntity(Guid userGuid, Guid agentGuid) {
UserGuid = userGuid;
AgentGuid = agentGuid;
}
}

View File

@@ -17,11 +17,12 @@ public sealed class EventLogRepository {
db.Ctx.EventLog.Add(new EventLogEntity(eventGuid, utcTime, agentGuid, eventType, subjectId, extra)); db.Ctx.EventLog.Add(new EventLogEntity(eventGuid, utcTime, agentGuid, eventType, subjectId, extra));
} }
public Task<ImmutableArray<EventLogItem>> GetMostRecentItems(int count, CancellationToken cancellationToken) { public Task<ImmutableArray<EventLogItem>> GetMostRecentItems(ImmutableHashSet<Guid> agentGuids, int count, CancellationToken cancellationToken) {
return db.Ctx return db.Ctx
.EventLog .EventLog
.AsQueryable() .AsQueryable()
.OrderByDescending(static entity => entity.UtcTime) .OrderByDescending(static entity => entity.UtcTime)
.Where(entity => entity.AgentGuid == null || agentGuids.Contains(entity.AgentGuid.Value))
.Take(count) .Take(count)
.AsAsyncEnumerable() .AsAsyncEnumerable()
.Select(static entity => new EventLogItem(entity.UtcTime, entity.AgentGuid, entity.EventType, entity.SubjectType, entity.SubjectId, entity.Data?.RootElement.ToString())) .Select(static entity => new EventLogItem(entity.UtcTime, entity.AgentGuid, entity.EventType, entity.SubjectType, entity.SubjectId, entity.Data?.RootElement.ToString()))

View File

@@ -0,0 +1,35 @@
using System.Collections.Immutable;
using Microsoft.EntityFrameworkCore;
using Phantom.Common.Data.Web.Users;
using Phantom.Controller.Database.Entities;
using Phantom.Utils.Collections;
namespace Phantom.Controller.Database.Repositories;
public sealed class PermissionRepository {
private readonly ILazyDbContext db;
public PermissionRepository(ILazyDbContext db) {
this.db = db;
}
public async Task<PermissionSet> GetAllUserPermissions(UserEntity user) {
var userPermissions = db.Ctx.UserPermissions
.Where(up => up.UserGuid == user.UserGuid)
.Select(static up => up.PermissionId);
var rolePermissions = db.Ctx.UserRoles
.Where(ur => ur.UserGuid == user.UserGuid)
.Join(db.Ctx.RolePermissions, static ur => ur.RoleGuid, static rp => rp.RoleGuid, static (ur, rp) => rp.PermissionId);
return new PermissionSet(await userPermissions.Union(rolePermissions).AsAsyncEnumerable().ToImmutableSetAsync());
}
public Task<ImmutableHashSet<Guid>> GetManagedAgentGuids(UserEntity user) {
return db.Ctx.UserAgentAccess
.Where(ua => ua.UserGuid == user.UserGuid)
.Select(static ua => ua.AgentGuid)
.AsAsyncEnumerable()
.ToImmutableSetAsync();
}
}

View File

@@ -1,9 +1,9 @@
using System.Collections.Immutable; using System.Collections.Immutable;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Phantom.Common.Data;
using Phantom.Common.Data.Web.Users; using Phantom.Common.Data.Web.Users;
using Phantom.Controller.Database.Entities; using Phantom.Controller.Database.Entities;
using Phantom.Utils.Collections; using Phantom.Utils.Collections;
using Phantom.Utils.Tasks;
namespace Phantom.Controller.Database.Repositories; namespace Phantom.Controller.Database.Repositories;

View File

@@ -1,12 +1,12 @@
using System.Collections.Immutable; using System.Collections.Immutable;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Phantom.Common.Data;
using Phantom.Common.Data.Web.Users; using Phantom.Common.Data.Web.Users;
using Phantom.Common.Data.Web.Users.AddUserErrors; using Phantom.Common.Data.Web.Users.AddUserErrors;
using Phantom.Common.Data.Web.Users.PasswordRequirementViolations; using Phantom.Common.Data.Web.Users.PasswordRequirementViolations;
using Phantom.Common.Data.Web.Users.UsernameRequirementViolations; using Phantom.Common.Data.Web.Users.UsernameRequirementViolations;
using Phantom.Controller.Database.Entities; using Phantom.Controller.Database.Entities;
using Phantom.Utils.Collections; using Phantom.Utils.Collections;
using Phantom.Utils.Tasks;
namespace Phantom.Controller.Database.Repositories; namespace Phantom.Controller.Database.Repositories;

View File

@@ -13,11 +13,13 @@ using Phantom.Common.Data.Web.Minecraft;
using Phantom.Common.Messages.Agent; using Phantom.Common.Messages.Agent;
using Phantom.Common.Messages.Agent.ToAgent; using Phantom.Common.Messages.Agent.ToAgent;
using Phantom.Controller.Database; using Phantom.Controller.Database;
using Phantom.Controller.Database.Entities;
using Phantom.Controller.Minecraft; using Phantom.Controller.Minecraft;
using Phantom.Controller.Services.Instances; using Phantom.Controller.Services.Instances;
using Phantom.Utils.Actor; using Phantom.Utils.Actor;
using Phantom.Utils.Actor.Mailbox; using Phantom.Utils.Actor.Mailbox;
using Phantom.Utils.Actor.Tasks; using Phantom.Utils.Actor.Tasks;
using Phantom.Utils.Collections;
using Phantom.Utils.Logging; using Phantom.Utils.Logging;
using Phantom.Utils.Rpc.Runtime; using Phantom.Utils.Rpc.Runtime;
using Serilog; using Serilog;
@@ -92,11 +94,12 @@ sealed class AgentActor : ReceiveActor<AgentActor.ICommand> {
Receive<NotifyIsAliveCommand>(NotifyIsAlive); Receive<NotifyIsAliveCommand>(NotifyIsAlive);
Receive<UpdateStatsCommand>(UpdateStats); Receive<UpdateStatsCommand>(UpdateStats);
Receive<UpdateJavaRuntimesCommand>(UpdateJavaRuntimes); Receive<UpdateJavaRuntimesCommand>(UpdateJavaRuntimes);
ReceiveAndReplyLater<CreateOrUpdateInstanceCommand, InstanceActionResult<CreateOrUpdateInstanceResult>>(CreateOrUpdateInstance); ReceiveAndReplyLater<CreateOrUpdateInstanceCommand, Result<CreateOrUpdateInstanceResult, InstanceActionFailure>>(CreateOrUpdateInstance);
Receive<UpdateInstanceStatusCommand>(UpdateInstanceStatus); Receive<UpdateInstanceStatusCommand>(UpdateInstanceStatus);
ReceiveAndReplyLater<LaunchInstanceCommand, InstanceActionResult<LaunchInstanceResult>>(LaunchInstance); Receive<UpdateInstancePlayerCountsCommand>(UpdateInstancePlayerCounts);
ReceiveAndReplyLater<StopInstanceCommand, InstanceActionResult<StopInstanceResult>>(StopInstance); ReceiveAndReplyLater<LaunchInstanceCommand, Result<LaunchInstanceResult, InstanceActionFailure>>(LaunchInstance);
ReceiveAndReplyLater<SendCommandToInstanceCommand, InstanceActionResult<SendCommandToInstanceResult>>(SendMinecraftCommand); ReceiveAndReplyLater<StopInstanceCommand, Result<StopInstanceResult, InstanceActionFailure>>(StopInstance);
ReceiveAndReplyLater<SendCommandToInstanceCommand, Result<SendCommandToInstanceResult, InstanceActionFailure>>(SendMinecraftCommand);
Receive<ReceiveInstanceDataCommand>(ReceiveInstanceData); Receive<ReceiveInstanceDataCommand>(ReceiveInstanceData);
} }
@@ -144,20 +147,20 @@ sealed class AgentActor : ReceiveActor<AgentActor.ICommand> {
} }
} }
private Task<InstanceActionResult<TReply>> RequestInstance<TCommand, TReply>(Guid instanceGuid, TCommand command) where TCommand : InstanceActor.ICommand, ICanReply<InstanceActionResult<TReply>> { private async Task<Result<TReply, InstanceActionFailure>> RequestInstance<TCommand, TReply>(Guid instanceGuid, TCommand command) where TCommand : InstanceActor.ICommand, ICanReply<Result<TReply, InstanceActionFailure>> {
if (instanceActorByGuid.TryGetValue(instanceGuid, out var instance)) { if (instanceActorByGuid.TryGetValue(instanceGuid, out var instance)) {
return instance.Request(command, cancellationToken); return await instance.Request(command, cancellationToken);
} }
else { else {
Logger.Warning("Could not deliver command {CommandType} to instance {InstanceGuid}, instance not found.", command.GetType().Name, instanceGuid); Logger.Warning("Could not deliver command {CommandType} to instance {InstanceGuid}, instance not found.", command.GetType().Name, instanceGuid);
return Task.FromResult(InstanceActionResult.General<TReply>(InstanceActionGeneralResult.InstanceDoesNotExist)); return InstanceActionFailure.InstanceDoesNotExist;
} }
} }
private async Task<ImmutableArray<ConfigureInstanceMessage>> PrepareInitialConfigurationMessages() { private async Task<ImmutableArray<ConfigureInstanceMessage>> PrepareInitialConfigurationMessages() {
var configurationMessages = ImmutableArray.CreateBuilder<ConfigureInstanceMessage>(); var configurationMessages = ImmutableArray.CreateBuilder<ConfigureInstanceMessage>();
foreach (var (instanceGuid, instanceConfiguration, _, launchAutomatically) in instanceDataByGuid.Values.ToImmutableArray()) { foreach (var (instanceGuid, instanceConfiguration, _, _, launchAutomatically) in instanceDataByGuid.Values.ToImmutableArray()) {
var serverExecutableInfo = await minecraftVersions.GetServerExecutableInfo(instanceConfiguration.MinecraftVersion, cancellationToken); var serverExecutableInfo = await minecraftVersions.GetServerExecutableInfo(instanceConfiguration.MinecraftVersion, cancellationToken);
configurationMessages.Add(new ConfigureInstanceMessage(instanceGuid, instanceConfiguration, new InstanceLaunchProperties(serverExecutableInfo), launchAutomatically)); configurationMessages.Add(new ConfigureInstanceMessage(instanceGuid, instanceConfiguration, new InstanceLaunchProperties(serverExecutableInfo), launchAutomatically));
} }
@@ -181,34 +184,44 @@ sealed class AgentActor : ReceiveActor<AgentActor.ICommand> {
public sealed record UpdateJavaRuntimesCommand(ImmutableArray<TaggedJavaRuntime> JavaRuntimes) : ICommand; public sealed record UpdateJavaRuntimesCommand(ImmutableArray<TaggedJavaRuntime> JavaRuntimes) : ICommand;
public sealed record CreateOrUpdateInstanceCommand(Guid AuditLogUserGuid, Guid InstanceGuid, InstanceConfiguration Configuration) : ICommand, ICanReply<InstanceActionResult<CreateOrUpdateInstanceResult>>; public sealed record CreateOrUpdateInstanceCommand(Guid LoggedInUserGuid, Guid InstanceGuid, InstanceConfiguration Configuration) : ICommand, ICanReply<Result<CreateOrUpdateInstanceResult, InstanceActionFailure>>;
public sealed record UpdateInstanceStatusCommand(Guid InstanceGuid, IInstanceStatus Status) : ICommand; public sealed record UpdateInstanceStatusCommand(Guid InstanceGuid, IInstanceStatus Status) : ICommand;
public sealed record LaunchInstanceCommand(Guid InstanceGuid, Guid AuditLogUserGuid) : ICommand, ICanReply<InstanceActionResult<LaunchInstanceResult>>; public sealed record UpdateInstancePlayerCountsCommand(Guid InstanceGuid, InstancePlayerCounts? PlayerCounts) : ICommand;
public sealed record StopInstanceCommand(Guid InstanceGuid, Guid AuditLogUserGuid, MinecraftStopStrategy StopStrategy) : ICommand, ICanReply<InstanceActionResult<StopInstanceResult>>; public sealed record LaunchInstanceCommand(Guid LoggedInUserGuid, Guid InstanceGuid) : ICommand, ICanReply<Result<LaunchInstanceResult, InstanceActionFailure>>;
public sealed record SendCommandToInstanceCommand(Guid InstanceGuid, Guid AuditLogUserGuid, string Command) : ICommand, ICanReply<InstanceActionResult<SendCommandToInstanceResult>>; public sealed record StopInstanceCommand(Guid LoggedInUserGuid, Guid InstanceGuid, MinecraftStopStrategy StopStrategy) : ICommand, ICanReply<Result<StopInstanceResult, InstanceActionFailure>>;
public sealed record SendCommandToInstanceCommand(Guid LoggedInUserGuid, Guid InstanceGuid, string Command) : ICommand, ICanReply<Result<SendCommandToInstanceResult, InstanceActionFailure>>;
public sealed record ReceiveInstanceDataCommand(Instance Instance) : ICommand, IJumpAhead; public sealed record ReceiveInstanceDataCommand(Instance Instance) : ICommand, IJumpAhead;
private async Task Initialize(InitializeCommand command) { private async Task Initialize(InitializeCommand command) {
await using var ctx = dbProvider.Eager(); ImmutableArray<InstanceEntity> instanceEntities;
await foreach (var entity in ctx.Instances.Where(instance => instance.AgentGuid == agentGuid).AsAsyncEnumerable().WithCancellation(cancellationToken)) { await using (var ctx = dbProvider.Eager()) {
instanceEntities = await ctx.Instances.Where(instance => instance.AgentGuid == agentGuid).AsAsyncEnumerable().ToImmutableArrayCatchingExceptionsAsync(OnException, cancellationToken);
}
static void OnException(Exception e) {
Logger.Error(e, "Could not load instance from database.");
}
foreach (var instanceEntity in instanceEntities) {
var instanceConfiguration = new InstanceConfiguration( var instanceConfiguration = new InstanceConfiguration(
entity.AgentGuid, instanceEntity.AgentGuid,
entity.InstanceName, instanceEntity.InstanceName,
entity.ServerPort, instanceEntity.ServerPort,
entity.RconPort, instanceEntity.RconPort,
entity.MinecraftVersion, instanceEntity.MinecraftVersion,
entity.MinecraftServerKind, instanceEntity.MinecraftServerKind,
entity.MemoryAllocation, instanceEntity.MemoryAllocation,
entity.JavaRuntimeGuid, instanceEntity.JavaRuntimeGuid,
JvmArgumentsHelper.Split(entity.JvmArguments) JvmArgumentsHelper.Split(instanceEntity.JvmArguments)
); );
CreateNewInstance(Instance.Offline(entity.InstanceGuid, instanceConfiguration, entity.LaunchAutomatically)); CreateNewInstance(Instance.Offline(instanceEntity.InstanceGuid, instanceConfiguration, instanceEntity.LaunchAutomatically));
} }
} }
@@ -270,15 +283,15 @@ sealed class AgentActor : ReceiveActor<AgentActor.ICommand> {
controllerState.UpdateAgentJavaRuntimes(agentGuid, javaRuntimes); controllerState.UpdateAgentJavaRuntimes(agentGuid, javaRuntimes);
} }
private Task<InstanceActionResult<CreateOrUpdateInstanceResult>> CreateOrUpdateInstance(CreateOrUpdateInstanceCommand command) { private Task<Result<CreateOrUpdateInstanceResult, InstanceActionFailure>> CreateOrUpdateInstance(CreateOrUpdateInstanceCommand command) {
var instanceConfiguration = command.Configuration; var instanceConfiguration = command.Configuration;
if (string.IsNullOrWhiteSpace(instanceConfiguration.InstanceName)) { if (string.IsNullOrWhiteSpace(instanceConfiguration.InstanceName)) {
return Task.FromResult(InstanceActionResult.Concrete(CreateOrUpdateInstanceResult.InstanceNameMustNotBeEmpty)); return Task.FromResult<Result<CreateOrUpdateInstanceResult, InstanceActionFailure>>(CreateOrUpdateInstanceResult.InstanceNameMustNotBeEmpty);
} }
if (instanceConfiguration.MemoryAllocation <= RamAllocationUnits.Zero) { if (instanceConfiguration.MemoryAllocation <= RamAllocationUnits.Zero) {
return Task.FromResult(InstanceActionResult.Concrete(CreateOrUpdateInstanceResult.InstanceMemoryMustNotBeZero)); return Task.FromResult<Result<CreateOrUpdateInstanceResult, InstanceActionFailure>>(CreateOrUpdateInstanceResult.InstanceMemoryMustNotBeZero);
} }
return minecraftVersions.GetServerExecutableInfo(instanceConfiguration.MinecraftVersion, cancellationToken) return minecraftVersions.GetServerExecutableInfo(instanceConfiguration.MinecraftVersion, cancellationToken)
@@ -286,9 +299,9 @@ sealed class AgentActor : ReceiveActor<AgentActor.ICommand> {
.Unwrap(); .Unwrap();
} }
private Task<InstanceActionResult<CreateOrUpdateInstanceResult>> CreateOrUpdateInstance1(FileDownloadInfo? serverExecutableInfo, CreateOrUpdateInstanceCommand command) { private Task<Result<CreateOrUpdateInstanceResult, InstanceActionFailure>> CreateOrUpdateInstance1(FileDownloadInfo? serverExecutableInfo, CreateOrUpdateInstanceCommand command) {
if (serverExecutableInfo == null) { if (serverExecutableInfo == null) {
return Task.FromResult(InstanceActionResult.Concrete(CreateOrUpdateInstanceResult.MinecraftVersionDownloadInfoNotFound)); return Task.FromResult<Result<CreateOrUpdateInstanceResult, InstanceActionFailure>>(CreateOrUpdateInstanceResult.MinecraftVersionDownloadInfoNotFound);
} }
var instanceConfiguration = command.Configuration; var instanceConfiguration = command.Configuration;
@@ -298,13 +311,13 @@ sealed class AgentActor : ReceiveActor<AgentActor.ICommand> {
instanceActorRef = CreateNewInstance(Instance.Offline(command.InstanceGuid, instanceConfiguration)); instanceActorRef = CreateNewInstance(Instance.Offline(command.InstanceGuid, instanceConfiguration));
} }
var configureInstanceCommand = new InstanceActor.ConfigureInstanceCommand(command.AuditLogUserGuid, command.InstanceGuid, instanceConfiguration, new InstanceLaunchProperties(serverExecutableInfo), isCreatingInstance); var configureInstanceCommand = new InstanceActor.ConfigureInstanceCommand(command.LoggedInUserGuid, command.InstanceGuid, instanceConfiguration, new InstanceLaunchProperties(serverExecutableInfo), isCreatingInstance);
return instanceActorRef.Request(configureInstanceCommand, cancellationToken) return instanceActorRef.Request(configureInstanceCommand, cancellationToken)
.ContinueOnActor(CreateOrUpdateInstance2, configureInstanceCommand); .ContinueOnActor(CreateOrUpdateInstance2, configureInstanceCommand);
} }
private InstanceActionResult<CreateOrUpdateInstanceResult> CreateOrUpdateInstance2(InstanceActionResult<ConfigureInstanceResult> result, InstanceActor.ConfigureInstanceCommand command) { private Result<CreateOrUpdateInstanceResult, InstanceActionFailure> CreateOrUpdateInstance2(Result<ConfigureInstanceResult, InstanceActionFailure> result, InstanceActor.ConfigureInstanceCommand command) {
var instanceGuid = command.InstanceGuid; var instanceGuid = command.InstanceGuid;
var instanceName = command.Configuration.InstanceName; var instanceName = command.Configuration.InstanceName;
var isCreating = command.IsCreatingInstance; var isCreating = command.IsCreatingInstance;
@@ -312,34 +325,40 @@ sealed class AgentActor : ReceiveActor<AgentActor.ICommand> {
if (result.Is(ConfigureInstanceResult.Success)) { if (result.Is(ConfigureInstanceResult.Success)) {
string action = isCreating ? "Added" : "Edited"; string action = isCreating ? "Added" : "Edited";
string relation = isCreating ? "to agent" : "in agent"; string relation = isCreating ? "to agent" : "in agent";
Logger.Information(action + " instance \"{InstanceName}\" (GUID {InstanceGuid}) " + relation + " \"{AgentName}\".", instanceName, instanceGuid, configuration.AgentName); Logger.Information(action + " instance \"{InstanceName}\" (GUID {InstanceGuid}) " + relation + " \"{AgentName}\".", instanceName, instanceGuid, configuration.AgentName);
return CreateOrUpdateInstanceResult.Success;
} }
else { else {
string action = isCreating ? "adding" : "editing"; string action = isCreating ? "adding" : "editing";
string relation = isCreating ? "to agent" : "in agent"; string relation = isCreating ? "to agent" : "in agent";
Logger.Information("Failed " + action + " instance \"{InstanceName}\" (GUID {InstanceGuid}) " + relation + " \"{AgentName}\". {ErrorMessage}", instanceName, instanceGuid, configuration.AgentName, result.ToSentence(ConfigureInstanceResultExtensions.ToSentence)); string reason = result.Into(ConfigureInstanceResultExtensions.ToSentence, InstanceActionFailureExtensions.ToSentence);
}
return result.Map(static result => result switch { Logger.Information("Failed " + action + " instance \"{InstanceName}\" (GUID {InstanceGuid}) " + relation + " \"{AgentName}\". {ErrorMessage}", instanceName, instanceGuid, configuration.AgentName, reason);
ConfigureInstanceResult.Success => CreateOrUpdateInstanceResult.Success,
_ => CreateOrUpdateInstanceResult.UnknownError return CreateOrUpdateInstanceResult.UnknownError;
}); }
} }
private void UpdateInstanceStatus(UpdateInstanceStatusCommand command) { private void UpdateInstanceStatus(UpdateInstanceStatusCommand command) {
TellInstance(command.InstanceGuid, new InstanceActor.SetStatusCommand(command.Status)); TellInstance(command.InstanceGuid, new InstanceActor.SetStatusCommand(command.Status));
} }
private Task<InstanceActionResult<LaunchInstanceResult>> LaunchInstance(LaunchInstanceCommand command) { private void UpdateInstancePlayerCounts(UpdateInstancePlayerCountsCommand command) {
return RequestInstance<InstanceActor.LaunchInstanceCommand, LaunchInstanceResult>(command.InstanceGuid, new InstanceActor.LaunchInstanceCommand(command.AuditLogUserGuid)); TellInstance(command.InstanceGuid, new InstanceActor.SetPlayerCountsCommand(command.PlayerCounts));
} }
private Task<InstanceActionResult<StopInstanceResult>> StopInstance(StopInstanceCommand command) { private Task<Result<LaunchInstanceResult, InstanceActionFailure>> LaunchInstance(LaunchInstanceCommand command) {
return RequestInstance<InstanceActor.StopInstanceCommand, StopInstanceResult>(command.InstanceGuid, new InstanceActor.StopInstanceCommand(command.AuditLogUserGuid, command.StopStrategy)); return RequestInstance<InstanceActor.LaunchInstanceCommand, LaunchInstanceResult>(command.InstanceGuid, new InstanceActor.LaunchInstanceCommand(command.LoggedInUserGuid));
} }
private Task<InstanceActionResult<SendCommandToInstanceResult>> SendMinecraftCommand(SendCommandToInstanceCommand command) { private Task<Result<StopInstanceResult, InstanceActionFailure>> StopInstance(StopInstanceCommand command) {
return RequestInstance<InstanceActor.SendCommandToInstanceCommand, SendCommandToInstanceResult>(command.InstanceGuid, new InstanceActor.SendCommandToInstanceCommand(command.AuditLogUserGuid, command.Command)); return RequestInstance<InstanceActor.StopInstanceCommand, StopInstanceResult>(command.InstanceGuid, new InstanceActor.StopInstanceCommand(command.LoggedInUserGuid, command.StopStrategy));
}
private Task<Result<SendCommandToInstanceResult, InstanceActionFailure>> SendMinecraftCommand(SendCommandToInstanceCommand command) {
return RequestInstance<InstanceActor.SendCommandToInstanceCommand, SendCommandToInstanceResult>(command.InstanceGuid, new InstanceActor.SendCommandToInstanceCommand(command.LoggedInUserGuid, command.Command));
} }
private void ReceiveInstanceData(ReceiveInstanceDataCommand command) { private void ReceiveInstanceData(ReceiveInstanceDataCommand command) {

View File

@@ -1,13 +1,16 @@
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Collections.Immutable;
using Akka.Actor; using Akka.Actor;
using Phantom.Common.Data; using Phantom.Common.Data;
using Phantom.Common.Data.Agent; using Phantom.Common.Data.Agent;
using Phantom.Common.Data.Replies; using Phantom.Common.Data.Replies;
using Phantom.Common.Data.Web.Agent; using Phantom.Common.Data.Web.Agent;
using Phantom.Common.Data.Web.Users;
using Phantom.Common.Messages.Agent; using Phantom.Common.Messages.Agent;
using Phantom.Common.Messages.Agent.ToAgent; using Phantom.Common.Messages.Agent.ToAgent;
using Phantom.Controller.Database; using Phantom.Controller.Database;
using Phantom.Controller.Minecraft; using Phantom.Controller.Minecraft;
using Phantom.Controller.Services.Users.Sessions;
using Phantom.Utils.Actor; using Phantom.Utils.Actor;
using Phantom.Utils.Logging; using Phantom.Utils.Logging;
using Phantom.Utils.Rpc.Runtime; using Phantom.Utils.Rpc.Runtime;
@@ -22,17 +25,19 @@ sealed class AgentManager {
private readonly AuthToken authToken; private readonly AuthToken authToken;
private readonly ControllerState controllerState; private readonly ControllerState controllerState;
private readonly MinecraftVersions minecraftVersions; private readonly MinecraftVersions minecraftVersions;
private readonly UserLoginManager userLoginManager;
private readonly IDbContextProvider dbProvider; private readonly IDbContextProvider dbProvider;
private readonly CancellationToken cancellationToken; private readonly CancellationToken cancellationToken;
private readonly ConcurrentDictionary<Guid, ActorRef<AgentActor.ICommand>> agentsByGuid = new (); private readonly ConcurrentDictionary<Guid, ActorRef<AgentActor.ICommand>> agentsByGuid = new ();
private readonly Func<Guid, AgentConfiguration, ActorRef<AgentActor.ICommand>> addAgentActorFactory; private readonly Func<Guid, AgentConfiguration, ActorRef<AgentActor.ICommand>> addAgentActorFactory;
public AgentManager(IActorRefFactory actorSystem, AuthToken authToken, ControllerState controllerState, MinecraftVersions minecraftVersions, IDbContextProvider dbProvider, CancellationToken cancellationToken) { public AgentManager(IActorRefFactory actorSystem, AuthToken authToken, ControllerState controllerState, MinecraftVersions minecraftVersions, UserLoginManager userLoginManager, IDbContextProvider dbProvider, CancellationToken cancellationToken) {
this.actorSystem = actorSystem; this.actorSystem = actorSystem;
this.authToken = authToken; this.authToken = authToken;
this.controllerState = controllerState; this.controllerState = controllerState;
this.minecraftVersions = minecraftVersions; this.minecraftVersions = minecraftVersions;
this.userLoginManager = userLoginManager;
this.dbProvider = dbProvider; this.dbProvider = dbProvider;
this.cancellationToken = cancellationToken; this.cancellationToken = cancellationToken;
@@ -83,12 +88,18 @@ sealed class AgentManager {
} }
} }
public async Task<InstanceActionResult<TReply>> DoInstanceAction<TCommand, TReply>(Guid agentGuid, TCommand command) where TCommand : class, AgentActor.ICommand, ICanReply<InstanceActionResult<TReply>> { public async Task<Result<TReply, UserInstanceActionFailure>> DoInstanceAction<TCommand, TReply>(Permission requiredPermission, ImmutableArray<byte> authToken, Guid agentGuid, Func<Guid, TCommand> commandFactoryFromLoggedInUserGuid) where TCommand : class, AgentActor.ICommand, ICanReply<Result<TReply, InstanceActionFailure>> {
if (agentsByGuid.TryGetValue(agentGuid, out var agent)) { var loggedInUser = userLoginManager.GetLoggedInUser(authToken);
return await agent.Request(command, cancellationToken); if (!loggedInUser.HasAccessToAgent(agentGuid) || !loggedInUser.CheckPermission(requiredPermission)) {
return (UserInstanceActionFailure) UserActionFailure.NotAuthorized;
} }
else {
return InstanceActionResult.General<TReply>(InstanceActionGeneralResult.AgentDoesNotExist); if (!agentsByGuid.TryGetValue(agentGuid, out var agent)) {
return (UserInstanceActionFailure) InstanceActionFailure.AgentDoesNotExist;
} }
var command = commandFactoryFromLoggedInUserGuid(loggedInUser.Guid!.Value);
var result = await agent.Request(command, cancellationToken);
return result.MapError(static error => (UserInstanceActionFailure) error);
} }
} }

View File

@@ -11,6 +11,7 @@ using Phantom.Controller.Services.Events;
using Phantom.Controller.Services.Instances; using Phantom.Controller.Services.Instances;
using Phantom.Controller.Services.Rpc; using Phantom.Controller.Services.Rpc;
using Phantom.Controller.Services.Users; using Phantom.Controller.Services.Users;
using Phantom.Controller.Services.Users.Sessions;
using Phantom.Utils.Actor; using Phantom.Utils.Actor;
using Phantom.Utils.Rpc.Runtime; using Phantom.Utils.Rpc.Runtime;
using IMessageFromAgentToController = Phantom.Common.Messages.Agent.IMessageToController; using IMessageFromAgentToController = Phantom.Common.Messages.Agent.IMessageToController;
@@ -24,17 +25,18 @@ public sealed class ControllerServices : IDisposable {
private ControllerState ControllerState { get; } private ControllerState ControllerState { get; }
private MinecraftVersions MinecraftVersions { get; } private MinecraftVersions MinecraftVersions { get; }
private AgentManager AgentManager { get; } private AuthenticatedUserCache AuthenticatedUserCache { get; }
private InstanceLogManager InstanceLogManager { get; }
private EventLogManager EventLogManager { get; }
private UserManager UserManager { get; } private UserManager UserManager { get; }
private RoleManager RoleManager { get; } private RoleManager RoleManager { get; }
private PermissionManager PermissionManager { get; }
private UserRoleManager UserRoleManager { get; } private UserRoleManager UserRoleManager { get; }
private UserLoginManager UserLoginManager { get; } private UserLoginManager UserLoginManager { get; }
private PermissionManager PermissionManager { get; }
private AgentManager AgentManager { get; }
private InstanceLogManager InstanceLogManager { get; }
private AuditLogManager AuditLogManager { get; } private AuditLogManager AuditLogManager { get; }
private EventLogManager EventLogManager { get; }
public IRegistrationHandler<IMessageToAgent, IMessageFromAgentToController, RegisterAgentMessage> AgentRegistrationHandler { get; } public IRegistrationHandler<IMessageToAgent, IMessageFromAgentToController, RegisterAgentMessage> AgentRegistrationHandler { get; }
public IRegistrationHandler<IMessageToWeb, IMessageFromWebToController, RegisterWebMessage> WebRegistrationHandler { get; } public IRegistrationHandler<IMessageToWeb, IMessageFromWebToController, RegisterWebMessage> WebRegistrationHandler { get; }
@@ -51,17 +53,18 @@ public sealed class ControllerServices : IDisposable {
this.ControllerState = new ControllerState(); this.ControllerState = new ControllerState();
this.MinecraftVersions = new MinecraftVersions(); this.MinecraftVersions = new MinecraftVersions();
this.AgentManager = new AgentManager(ActorSystem, agentAuthToken, ControllerState, MinecraftVersions, dbProvider, cancellationToken); this.AuthenticatedUserCache = new AuthenticatedUserCache();
this.InstanceLogManager = new InstanceLogManager(); this.UserManager = new UserManager(AuthenticatedUserCache, ControllerState, dbProvider);
this.UserManager = new UserManager(dbProvider);
this.RoleManager = new RoleManager(dbProvider); this.RoleManager = new RoleManager(dbProvider);
this.UserRoleManager = new UserRoleManager(AuthenticatedUserCache, ControllerState, dbProvider);
this.UserLoginManager = new UserLoginManager(AuthenticatedUserCache, UserManager, dbProvider);
this.PermissionManager = new PermissionManager(dbProvider); this.PermissionManager = new PermissionManager(dbProvider);
this.UserRoleManager = new UserRoleManager(dbProvider); this.AgentManager = new AgentManager(ActorSystem, agentAuthToken, ControllerState, MinecraftVersions, UserLoginManager, dbProvider, cancellationToken);
this.UserLoginManager = new UserLoginManager(UserManager, PermissionManager, dbProvider); this.InstanceLogManager = new InstanceLogManager();
this.AuditLogManager = new AuditLogManager(dbProvider); this.AuditLogManager = new AuditLogManager(dbProvider);
this.EventLogManager = new EventLogManager(ActorSystem, dbProvider, shutdownCancellationToken); this.EventLogManager = new EventLogManager(ControllerState, ActorSystem, dbProvider, shutdownCancellationToken);
this.AgentRegistrationHandler = new AgentRegistrationHandler(AgentManager, InstanceLogManager, EventLogManager); this.AgentRegistrationHandler = new AgentRegistrationHandler(AgentManager, InstanceLogManager, EventLogManager);
this.WebRegistrationHandler = new WebRegistrationHandler(webAuthToken, ControllerState, InstanceLogManager, UserManager, RoleManager, UserRoleManager, UserLoginManager, AuditLogManager, AgentManager, MinecraftVersions, EventLogManager); this.WebRegistrationHandler = new WebRegistrationHandler(webAuthToken, ControllerState, InstanceLogManager, UserManager, RoleManager, UserRoleManager, UserLoginManager, AuditLogManager, AgentManager, MinecraftVersions, EventLogManager);

View File

@@ -19,6 +19,8 @@ sealed class ControllerState {
public ObservableState<ImmutableDictionary<Guid, ImmutableArray<TaggedJavaRuntime>>>.Receiver AgentJavaRuntimesByGuidReceiver => agentJavaRuntimesByGuid.ReceiverSide; public ObservableState<ImmutableDictionary<Guid, ImmutableArray<TaggedJavaRuntime>>>.Receiver AgentJavaRuntimesByGuidReceiver => agentJavaRuntimesByGuid.ReceiverSide;
public ObservableState<ImmutableDictionary<Guid, Instance>>.Receiver InstancesByGuidReceiver => instancesByGuid.ReceiverSide; public ObservableState<ImmutableDictionary<Guid, Instance>>.Receiver InstancesByGuidReceiver => instancesByGuid.ReceiverSide;
public event EventHandler<Guid>? UserUpdatedOrDeleted;
public void UpdateAgent(Agent agent) { public void UpdateAgent(Agent agent) {
agentsByGuid.PublisherSide.Publish(static (agentsByGuid, agent) => agentsByGuid.SetItem(agent.AgentGuid, agent), agent); agentsByGuid.PublisherSide.Publish(static (agentsByGuid, agent) => agentsByGuid.SetItem(agent.AgentGuid, agent), agent);
} }
@@ -30,4 +32,8 @@ sealed class ControllerState {
public void UpdateInstance(Instance instance) { public void UpdateInstance(Instance instance) {
instancesByGuid.PublisherSide.Publish(static (instancesByGuid, instance) => instancesByGuid.SetItem(instance.InstanceGuid, instance), instance); instancesByGuid.PublisherSide.Publish(static (instancesByGuid, instance) => instancesByGuid.SetItem(instance.InstanceGuid, instance), instance);
} }
public void UpdateOrDeleteUser(Guid userGuid) {
UserUpdatedOrDeleted?.Invoke(null, userGuid);
}
} }

View File

@@ -1,18 +1,23 @@
using System.Collections.Immutable; using System.Collections.Immutable;
using Akka.Actor; using Akka.Actor;
using Phantom.Common.Data;
using Phantom.Common.Data.Web.EventLog; using Phantom.Common.Data.Web.EventLog;
using Phantom.Common.Data.Web.Users;
using Phantom.Controller.Database; using Phantom.Controller.Database;
using Phantom.Controller.Database.Repositories; using Phantom.Controller.Database.Repositories;
using Phantom.Controller.Services.Users.Sessions;
using Phantom.Utils.Actor; using Phantom.Utils.Actor;
namespace Phantom.Controller.Services.Events; namespace Phantom.Controller.Services.Events;
sealed partial class EventLogManager { sealed partial class EventLogManager {
private readonly ControllerState controllerState;
private readonly ActorRef<EventLogDatabaseStorageActor.ICommand> databaseStorageActor; private readonly ActorRef<EventLogDatabaseStorageActor.ICommand> databaseStorageActor;
private readonly IDbContextProvider dbProvider; private readonly IDbContextProvider dbProvider;
private readonly CancellationToken cancellationToken; private readonly CancellationToken cancellationToken;
public EventLogManager(IActorRefFactory actorSystem, IDbContextProvider dbProvider, CancellationToken cancellationToken) { public EventLogManager(ControllerState controllerState, IActorRefFactory actorSystem, IDbContextProvider dbProvider, CancellationToken cancellationToken) {
this.controllerState = controllerState;
this.databaseStorageActor = actorSystem.ActorOf(EventLogDatabaseStorageActor.Factory(new EventLogDatabaseStorageActor.Init(dbProvider, cancellationToken)), "EventLogDatabaseStorage"); this.databaseStorageActor = actorSystem.ActorOf(EventLogDatabaseStorageActor.Factory(new EventLogDatabaseStorageActor.Init(dbProvider, cancellationToken)), "EventLogDatabaseStorage");
this.dbProvider = dbProvider; this.dbProvider = dbProvider;
this.cancellationToken = cancellationToken; this.cancellationToken = cancellationToken;
@@ -22,8 +27,14 @@ sealed partial class EventLogManager {
databaseStorageActor.Tell(new EventLogDatabaseStorageActor.StoreEventCommand(eventGuid, utcTime, agentGuid, eventType, subjectId, extra)); databaseStorageActor.Tell(new EventLogDatabaseStorageActor.StoreEventCommand(eventGuid, utcTime, agentGuid, eventType, subjectId, extra));
} }
public async Task<ImmutableArray<EventLogItem>> GetMostRecentItems(int count) { public async Task<Result<ImmutableArray<EventLogItem>, UserActionFailure>> GetMostRecentItems(LoggedInUser loggedInUser, int count) {
if (!loggedInUser.CheckPermission(Permission.ViewEvents)) {
return UserActionFailure.NotAuthorized;
}
var accessibleAgentGuids = loggedInUser.FilterAccessibleAgentGuids(controllerState.AgentsByGuid.Keys.ToImmutableHashSet());
await using var db = dbProvider.Lazy(); await using var db = dbProvider.Lazy();
return await new EventLogRepository(db).GetMostRecentItems(count, cancellationToken); return await new EventLogRepository(db).GetMostRecentItems(accessibleAgentGuids, count, cancellationToken);
} }
} }

View File

@@ -1,4 +1,5 @@
using Phantom.Common.Data.Instance; using Phantom.Common.Data;
using Phantom.Common.Data.Instance;
using Phantom.Common.Data.Minecraft; using Phantom.Common.Data.Minecraft;
using Phantom.Common.Data.Replies; using Phantom.Common.Data.Replies;
using Phantom.Common.Data.Web.Instance; using Phantom.Common.Data.Web.Instance;
@@ -25,6 +26,7 @@ sealed class InstanceActor : ReceiveActor<InstanceActor.ICommand> {
private InstanceConfiguration configuration; private InstanceConfiguration configuration;
private IInstanceStatus status; private IInstanceStatus status;
private InstancePlayerCounts? playerCounts;
private bool launchAutomatically; private bool launchAutomatically;
private readonly ActorRef<InstanceDatabaseStorageActor.ICommand> databaseStorageActor; private readonly ActorRef<InstanceDatabaseStorageActor.ICommand> databaseStorageActor;
@@ -34,19 +36,20 @@ sealed class InstanceActor : ReceiveActor<InstanceActor.ICommand> {
this.agentConnection = init.AgentConnection; this.agentConnection = init.AgentConnection;
this.cancellationToken = init.CancellationToken; this.cancellationToken = init.CancellationToken;
(this.instanceGuid, this.configuration, this.status, this.launchAutomatically) = init.Instance; (this.instanceGuid, this.configuration, this.status, this.playerCounts, this.launchAutomatically) = init.Instance;
this.databaseStorageActor = Context.ActorOf(InstanceDatabaseStorageActor.Factory(new InstanceDatabaseStorageActor.Init(instanceGuid, init.DbProvider, init.CancellationToken)), "DatabaseStorage"); this.databaseStorageActor = Context.ActorOf(InstanceDatabaseStorageActor.Factory(new InstanceDatabaseStorageActor.Init(instanceGuid, init.DbProvider, init.CancellationToken)), "DatabaseStorage");
Receive<SetStatusCommand>(SetStatus); Receive<SetStatusCommand>(SetStatus);
ReceiveAsyncAndReply<ConfigureInstanceCommand, InstanceActionResult<ConfigureInstanceResult>>(ConfigureInstance); Receive<SetPlayerCountsCommand>(SetPlayerCounts);
ReceiveAsyncAndReply<LaunchInstanceCommand, InstanceActionResult<LaunchInstanceResult>>(LaunchInstance); ReceiveAsyncAndReply<ConfigureInstanceCommand, Result<ConfigureInstanceResult, InstanceActionFailure>>(ConfigureInstance);
ReceiveAsyncAndReply<StopInstanceCommand, InstanceActionResult<StopInstanceResult>>(StopInstance); ReceiveAsyncAndReply<LaunchInstanceCommand, Result<LaunchInstanceResult, InstanceActionFailure>>(LaunchInstance);
ReceiveAsyncAndReply<SendCommandToInstanceCommand, InstanceActionResult<SendCommandToInstanceResult>>(SendMinecraftCommand); ReceiveAsyncAndReply<StopInstanceCommand, Result<StopInstanceResult, InstanceActionFailure>>(StopInstance);
ReceiveAsyncAndReply<SendCommandToInstanceCommand, Result<SendCommandToInstanceResult, InstanceActionFailure>>(SendMinecraftCommand);
} }
private void NotifyInstanceUpdated() { private void NotifyInstanceUpdated() {
agentActor.Tell(new AgentActor.ReceiveInstanceDataCommand(new Instance(instanceGuid, configuration, status, launchAutomatically))); agentActor.Tell(new AgentActor.ReceiveInstanceDataCommand(new Instance(instanceGuid, configuration, status, playerCounts, launchAutomatically)));
} }
private void SetLaunchAutomatically(bool newValue) { private void SetLaunchAutomatically(bool newValue) {
@@ -56,29 +59,41 @@ sealed class InstanceActor : ReceiveActor<InstanceActor.ICommand> {
} }
} }
private async Task<InstanceActionResult<TReply>> SendInstanceActionMessage<TMessage, TReply>(TMessage message) where TMessage : IMessageToAgent, ICanReply<InstanceActionResult<TReply>> { private async Task<Result<TReply, InstanceActionFailure>> SendInstanceActionMessage<TMessage, TReply>(TMessage message) where TMessage : IMessageToAgent, ICanReply<Result<TReply, InstanceActionFailure>> {
var reply = await agentConnection.Send<TMessage, InstanceActionResult<TReply>>(message, TimeSpan.FromSeconds(10), cancellationToken); var reply = await agentConnection.Send<TMessage, Result<TReply, InstanceActionFailure>>(message, TimeSpan.FromSeconds(10), cancellationToken);
return reply.DidNotReplyIfNull(); return reply ?? InstanceActionFailure.AgentIsNotResponding;
} }
public interface ICommand {} public interface ICommand {}
public sealed record SetStatusCommand(IInstanceStatus Status) : ICommand; public sealed record SetStatusCommand(IInstanceStatus Status) : ICommand;
public sealed record ConfigureInstanceCommand(Guid AuditLogUserGuid, Guid InstanceGuid, InstanceConfiguration Configuration, InstanceLaunchProperties LaunchProperties, bool IsCreatingInstance) : ICommand, ICanReply<InstanceActionResult<ConfigureInstanceResult>>; public sealed record SetPlayerCountsCommand(InstancePlayerCounts? PlayerCounts) : ICommand;
public sealed record LaunchInstanceCommand(Guid AuditLogUserGuid) : ICommand, ICanReply<InstanceActionResult<LaunchInstanceResult>>; public sealed record ConfigureInstanceCommand(Guid AuditLogUserGuid, Guid InstanceGuid, InstanceConfiguration Configuration, InstanceLaunchProperties LaunchProperties, bool IsCreatingInstance) : ICommand, ICanReply<Result<ConfigureInstanceResult, InstanceActionFailure>>;
public sealed record StopInstanceCommand(Guid AuditLogUserGuid, MinecraftStopStrategy StopStrategy) : ICommand, ICanReply<InstanceActionResult<StopInstanceResult>>; public sealed record LaunchInstanceCommand(Guid AuditLogUserGuid) : ICommand, ICanReply<Result<LaunchInstanceResult, InstanceActionFailure>>;
public sealed record SendCommandToInstanceCommand(Guid AuditLogUserGuid, string Command) : ICommand, ICanReply<InstanceActionResult<SendCommandToInstanceResult>>; public sealed record StopInstanceCommand(Guid AuditLogUserGuid, MinecraftStopStrategy StopStrategy) : ICommand, ICanReply<Result<StopInstanceResult, InstanceActionFailure>>;
public sealed record SendCommandToInstanceCommand(Guid AuditLogUserGuid, string Command) : ICommand, ICanReply<Result<SendCommandToInstanceResult, InstanceActionFailure>>;
private void SetStatus(SetStatusCommand command) { private void SetStatus(SetStatusCommand command) {
status = command.Status; status = command.Status;
if (!status.IsRunning() && status != InstanceStatus.Offline /* Guard against temporary disconnects */) {
playerCounts = null;
}
NotifyInstanceUpdated(); NotifyInstanceUpdated();
} }
private async Task<InstanceActionResult<ConfigureInstanceResult>> ConfigureInstance(ConfigureInstanceCommand command) { private void SetPlayerCounts(SetPlayerCountsCommand command) {
playerCounts = command.PlayerCounts;
NotifyInstanceUpdated();
}
private async Task<Result<ConfigureInstanceResult, InstanceActionFailure>> ConfigureInstance(ConfigureInstanceCommand command) {
var message = new ConfigureInstanceMessage(command.InstanceGuid, command.Configuration, command.LaunchProperties); var message = new ConfigureInstanceMessage(command.InstanceGuid, command.Configuration, command.LaunchProperties);
var result = await SendInstanceActionMessage<ConfigureInstanceMessage, ConfigureInstanceResult>(message); var result = await SendInstanceActionMessage<ConfigureInstanceMessage, ConfigureInstanceResult>(message);
@@ -98,7 +113,7 @@ sealed class InstanceActor : ReceiveActor<InstanceActor.ICommand> {
return result; return result;
} }
private async Task<InstanceActionResult<LaunchInstanceResult>> LaunchInstance(LaunchInstanceCommand command) { private async Task<Result<LaunchInstanceResult, InstanceActionFailure>> LaunchInstance(LaunchInstanceCommand command) {
var message = new LaunchInstanceMessage(instanceGuid); var message = new LaunchInstanceMessage(instanceGuid);
var result = await SendInstanceActionMessage<LaunchInstanceMessage, LaunchInstanceResult>(message); var result = await SendInstanceActionMessage<LaunchInstanceMessage, LaunchInstanceResult>(message);
@@ -110,7 +125,7 @@ sealed class InstanceActor : ReceiveActor<InstanceActor.ICommand> {
return result; return result;
} }
private async Task<InstanceActionResult<StopInstanceResult>> StopInstance(StopInstanceCommand command) { private async Task<Result<StopInstanceResult, InstanceActionFailure>> StopInstance(StopInstanceCommand command) {
var message = new StopInstanceMessage(instanceGuid, command.StopStrategy); var message = new StopInstanceMessage(instanceGuid, command.StopStrategy);
var result = await SendInstanceActionMessage<StopInstanceMessage, StopInstanceResult>(message); var result = await SendInstanceActionMessage<StopInstanceMessage, StopInstanceResult>(message);
@@ -122,7 +137,7 @@ sealed class InstanceActor : ReceiveActor<InstanceActor.ICommand> {
return result; return result;
} }
private async Task<InstanceActionResult<SendCommandToInstanceResult>> SendMinecraftCommand(SendCommandToInstanceCommand command) { private async Task<Result<SendCommandToInstanceResult, InstanceActionFailure>> SendMinecraftCommand(SendCommandToInstanceCommand command) {
var message = new SendCommandToInstanceMessage(instanceGuid, command.Command); var message = new SendCommandToInstanceMessage(instanceGuid, command.Command);
var result = await SendInstanceActionMessage<SendCommandToInstanceMessage, SendCommandToInstanceResult>(message); var result = await SendInstanceActionMessage<SendCommandToInstanceMessage, SendCommandToInstanceResult>(message);

View File

@@ -39,6 +39,7 @@ sealed class AgentMessageHandlerActor : ReceiveActor<IMessageToController> {
Receive<AdvertiseJavaRuntimesMessage>(HandleAdvertiseJavaRuntimes); Receive<AdvertiseJavaRuntimesMessage>(HandleAdvertiseJavaRuntimes);
Receive<ReportAgentStatusMessage>(HandleReportAgentStatus); Receive<ReportAgentStatusMessage>(HandleReportAgentStatus);
Receive<ReportInstanceStatusMessage>(HandleReportInstanceStatus); Receive<ReportInstanceStatusMessage>(HandleReportInstanceStatus);
Receive<ReportInstancePlayerCountsMessage>(HandleReportInstancePlayerCounts);
Receive<ReportInstanceEventMessage>(HandleReportInstanceEvent); Receive<ReportInstanceEventMessage>(HandleReportInstanceEvent);
Receive<InstanceOutputMessage>(HandleInstanceOutput); Receive<InstanceOutputMessage>(HandleInstanceOutput);
Receive<ReplyMessage>(HandleReply); Receive<ReplyMessage>(HandleReply);
@@ -74,6 +75,10 @@ sealed class AgentMessageHandlerActor : ReceiveActor<IMessageToController> {
agentManager.TellAgent(agentGuid, new AgentActor.UpdateInstanceStatusCommand(message.InstanceGuid, message.InstanceStatus)); agentManager.TellAgent(agentGuid, new AgentActor.UpdateInstanceStatusCommand(message.InstanceGuid, message.InstanceStatus));
} }
private void HandleReportInstancePlayerCounts(ReportInstancePlayerCountsMessage message) {
agentManager.TellAgent(agentGuid, new AgentActor.UpdateInstancePlayerCountsCommand(message.InstanceGuid, message.PlayerCounts));
}
private void HandleReportInstanceEvent(ReportInstanceEventMessage message) { private void HandleReportInstanceEvent(ReportInstanceEventMessage message) {
message.Event.Accept(eventLogManager.CreateInstanceEventVisitor(message.EventGuid, message.UtcTime, agentGuid, message.InstanceGuid)); message.Event.Accept(eventLogManager.CreateInstanceEventVisitor(message.EventGuid, message.UtcTime, agentGuid, message.InstanceGuid));
} }

View File

@@ -30,22 +30,31 @@ sealed class WebMessageDataUpdateSenderActor : ReceiveActor<WebMessageDataUpdate
ReceiveAsync<RefreshAgentsCommand>(RefreshAgents); ReceiveAsync<RefreshAgentsCommand>(RefreshAgents);
ReceiveAsync<RefreshInstancesCommand>(RefreshInstances); ReceiveAsync<RefreshInstancesCommand>(RefreshInstances);
ReceiveAsync<ReceiveInstanceLogsCommand>(ReceiveInstanceLogs); ReceiveAsync<ReceiveInstanceLogsCommand>(ReceiveInstanceLogs);
ReceiveAsync<RefreshUserSessionCommand>(RefreshUserSession);
} }
protected override void PreStart() { protected override void PreStart() {
controllerState.AgentsByGuidReceiver.Register(SelfTyped, static state => new RefreshAgentsCommand(state)); controllerState.AgentsByGuidReceiver.Register(SelfTyped, static state => new RefreshAgentsCommand(state));
controllerState.InstancesByGuidReceiver.Register(SelfTyped, static state => new RefreshInstancesCommand(state)); controllerState.InstancesByGuidReceiver.Register(SelfTyped, static state => new RefreshInstancesCommand(state));
controllerState.UserUpdatedOrDeleted += OnUserUpdatedOrDeleted;
instanceLogManager.LogsReceived += OnInstanceLogsReceived; instanceLogManager.LogsReceived += OnInstanceLogsReceived;
} }
protected override void PostStop() { protected override void PostStop() {
instanceLogManager.LogsReceived -= OnInstanceLogsReceived; instanceLogManager.LogsReceived -= OnInstanceLogsReceived;
controllerState.UserUpdatedOrDeleted -= OnUserUpdatedOrDeleted;
controllerState.AgentsByGuidReceiver.Unregister(SelfTyped); controllerState.AgentsByGuidReceiver.Unregister(SelfTyped);
controllerState.InstancesByGuidReceiver.Unregister(SelfTyped); controllerState.InstancesByGuidReceiver.Unregister(SelfTyped);
} }
private void OnUserUpdatedOrDeleted(object? sender, Guid userGuid) {
selfCached.Tell(new RefreshUserSessionCommand(userGuid));
}
private void OnInstanceLogsReceived(object? sender, InstanceLogManager.Event e) { private void OnInstanceLogsReceived(object? sender, InstanceLogManager.Event e) {
selfCached.Tell(new ReceiveInstanceLogsCommand(e.InstanceGuid, e.Lines)); selfCached.Tell(new ReceiveInstanceLogsCommand(e.InstanceGuid, e.Lines));
} }
@@ -58,6 +67,8 @@ sealed class WebMessageDataUpdateSenderActor : ReceiveActor<WebMessageDataUpdate
private sealed record ReceiveInstanceLogsCommand(Guid InstanceGuid, ImmutableArray<string> Lines) : ICommand; private sealed record ReceiveInstanceLogsCommand(Guid InstanceGuid, ImmutableArray<string> Lines) : ICommand;
private sealed record RefreshUserSessionCommand(Guid UserGuid) : ICommand;
private Task RefreshAgents(RefreshAgentsCommand command) { private Task RefreshAgents(RefreshAgentsCommand command) {
return connection.Send(new RefreshAgentsMessage(command.Agents.Values.ToImmutableArray())); return connection.Send(new RefreshAgentsMessage(command.Agents.Values.ToImmutableArray()));
} }
@@ -69,4 +80,8 @@ sealed class WebMessageDataUpdateSenderActor : ReceiveActor<WebMessageDataUpdate
private Task ReceiveInstanceLogs(ReceiveInstanceLogsCommand command) { private Task ReceiveInstanceLogs(ReceiveInstanceLogsCommand command) {
return connection.Send(new InstanceOutputMessage(command.InstanceGuid, command.Lines)); return connection.Send(new InstanceOutputMessage(command.InstanceGuid, command.Lines));
} }
private Task RefreshUserSession(RefreshUserSessionCommand command) {
return connection.Send(new RefreshUserSessionMessage(command.UserGuid));
}
} }

View File

@@ -1,4 +1,5 @@
using System.Collections.Immutable; using System.Collections.Immutable;
using Phantom.Common.Data;
using Phantom.Common.Data.Java; using Phantom.Common.Data.Java;
using Phantom.Common.Data.Minecraft; using Phantom.Common.Data.Minecraft;
using Phantom.Common.Data.Replies; using Phantom.Common.Data.Replies;
@@ -14,6 +15,7 @@ using Phantom.Controller.Services.Agents;
using Phantom.Controller.Services.Events; using Phantom.Controller.Services.Events;
using Phantom.Controller.Services.Instances; using Phantom.Controller.Services.Instances;
using Phantom.Controller.Services.Users; using Phantom.Controller.Services.Users;
using Phantom.Controller.Services.Users.Sessions;
using Phantom.Utils.Actor; using Phantom.Utils.Actor;
using Phantom.Utils.Rpc.Runtime; using Phantom.Utils.Rpc.Runtime;
@@ -69,23 +71,24 @@ sealed class WebMessageHandlerActor : ReceiveActor<IMessageToController> {
ReceiveAsync<RegisterWebMessage>(HandleRegisterWeb); ReceiveAsync<RegisterWebMessage>(HandleRegisterWeb);
Receive<UnregisterWebMessage>(HandleUnregisterWeb); Receive<UnregisterWebMessage>(HandleUnregisterWeb);
ReceiveAndReplyLater<LogInMessage, LogInSuccess?>(HandleLogIn); ReceiveAndReplyLater<LogInMessage, Optional<LogInSuccess>>(HandleLogIn);
Receive<LogOutMessage>(HandleLogOut); Receive<LogOutMessage>(HandleLogOut);
ReceiveAndReply<GetAuthenticatedUser, Optional<AuthenticatedUserInfo>>(GetAuthenticatedUser);
ReceiveAndReplyLater<CreateOrUpdateAdministratorUserMessage, CreateOrUpdateAdministratorUserResult>(HandleCreateOrUpdateAdministratorUser); ReceiveAndReplyLater<CreateOrUpdateAdministratorUserMessage, CreateOrUpdateAdministratorUserResult>(HandleCreateOrUpdateAdministratorUser);
ReceiveAndReplyLater<CreateUserMessage, CreateUserResult>(HandleCreateUser); ReceiveAndReplyLater<CreateUserMessage, Result<CreateUserResult, UserActionFailure>>(HandleCreateUser);
ReceiveAndReplyLater<GetUsersMessage, ImmutableArray<UserInfo>>(HandleGetUsers); ReceiveAndReplyLater<GetUsersMessage, ImmutableArray<UserInfo>>(HandleGetUsers);
ReceiveAndReplyLater<GetRolesMessage, ImmutableArray<RoleInfo>>(HandleGetRoles); ReceiveAndReplyLater<GetRolesMessage, ImmutableArray<RoleInfo>>(HandleGetRoles);
ReceiveAndReplyLater<GetUserRolesMessage, ImmutableDictionary<Guid, ImmutableArray<Guid>>>(HandleGetUserRoles); ReceiveAndReplyLater<GetUserRolesMessage, ImmutableDictionary<Guid, ImmutableArray<Guid>>>(HandleGetUserRoles);
ReceiveAndReplyLater<ChangeUserRolesMessage, ChangeUserRolesResult>(HandleChangeUserRoles); ReceiveAndReplyLater<ChangeUserRolesMessage, Result<ChangeUserRolesResult, UserActionFailure>>(HandleChangeUserRoles);
ReceiveAndReplyLater<DeleteUserMessage, DeleteUserResult>(HandleDeleteUser); ReceiveAndReplyLater<DeleteUserMessage, Result<DeleteUserResult, UserActionFailure>>(HandleDeleteUser);
ReceiveAndReplyLater<CreateOrUpdateInstanceMessage, InstanceActionResult<CreateOrUpdateInstanceResult>>(HandleCreateOrUpdateInstance); ReceiveAndReplyLater<CreateOrUpdateInstanceMessage, Result<CreateOrUpdateInstanceResult, UserInstanceActionFailure>>(HandleCreateOrUpdateInstance);
ReceiveAndReplyLater<LaunchInstanceMessage, InstanceActionResult<LaunchInstanceResult>>(HandleLaunchInstance); ReceiveAndReplyLater<LaunchInstanceMessage, Result<LaunchInstanceResult, UserInstanceActionFailure>>(HandleLaunchInstance);
ReceiveAndReplyLater<StopInstanceMessage, InstanceActionResult<StopInstanceResult>>(HandleStopInstance); ReceiveAndReplyLater<StopInstanceMessage, Result<StopInstanceResult, UserInstanceActionFailure>>(HandleStopInstance);
ReceiveAndReplyLater<SendCommandToInstanceMessage, InstanceActionResult<SendCommandToInstanceResult>>(HandleSendCommandToInstance); ReceiveAndReplyLater<SendCommandToInstanceMessage, Result<SendCommandToInstanceResult, UserInstanceActionFailure>>(HandleSendCommandToInstance);
ReceiveAndReplyLater<GetMinecraftVersionsMessage, ImmutableArray<MinecraftVersion>>(HandleGetMinecraftVersions); ReceiveAndReplyLater<GetMinecraftVersionsMessage, ImmutableArray<MinecraftVersion>>(HandleGetMinecraftVersions);
ReceiveAndReply<GetAgentJavaRuntimesMessage, ImmutableDictionary<Guid, ImmutableArray<TaggedJavaRuntime>>>(HandleGetAgentJavaRuntimes); ReceiveAndReply<GetAgentJavaRuntimesMessage, ImmutableDictionary<Guid, ImmutableArray<TaggedJavaRuntime>>>(HandleGetAgentJavaRuntimes);
ReceiveAndReplyLater<GetAuditLogMessage, ImmutableArray<AuditLogItem>>(HandleGetAuditLog); ReceiveAndReplyLater<GetAuditLogMessage, Result<ImmutableArray<AuditLogItem>, UserActionFailure>>(HandleGetAuditLog);
ReceiveAndReplyLater<GetEventLogMessage, ImmutableArray<EventLogItem>>(HandleGetEventLog); ReceiveAndReplyLater<GetEventLogMessage, Result<ImmutableArray<EventLogItem>, UserActionFailure>>(HandleGetEventLog);
Receive<ReplyMessage>(HandleReply); Receive<ReplyMessage>(HandleReply);
} }
@@ -97,7 +100,7 @@ sealed class WebMessageHandlerActor : ReceiveActor<IMessageToController> {
connection.Close(); connection.Close();
} }
private Task<LogInSuccess?> HandleLogIn(LogInMessage message) { private Task<Optional<LogInSuccess>> HandleLogIn(LogInMessage message) {
return userLoginManager.LogIn(message.Username, message.Password); return userLoginManager.LogIn(message.Username, message.Password);
} }
@@ -105,12 +108,16 @@ sealed class WebMessageHandlerActor : ReceiveActor<IMessageToController> {
_ = userLoginManager.LogOut(message.UserGuid, message.SessionToken); _ = userLoginManager.LogOut(message.UserGuid, message.SessionToken);
} }
private Optional<AuthenticatedUserInfo> GetAuthenticatedUser(GetAuthenticatedUser message) {
return userLoginManager.GetAuthenticatedUser(message.UserGuid, message.AuthToken);
}
private Task<CreateOrUpdateAdministratorUserResult> HandleCreateOrUpdateAdministratorUser(CreateOrUpdateAdministratorUserMessage message) { private Task<CreateOrUpdateAdministratorUserResult> HandleCreateOrUpdateAdministratorUser(CreateOrUpdateAdministratorUserMessage message) {
return userManager.CreateOrUpdateAdministrator(message.Username, message.Password); return userManager.CreateOrUpdateAdministrator(message.Username, message.Password);
} }
private Task<CreateUserResult> HandleCreateUser(CreateUserMessage message) { private Task<Result<CreateUserResult, UserActionFailure>> HandleCreateUser(CreateUserMessage message) {
return userManager.Create(message.LoggedInUserGuid, message.Username, message.Password); return userManager.Create(userLoginManager.GetLoggedInUser(message.AuthToken), message.Username, message.Password);
} }
private Task<ImmutableArray<UserInfo>> HandleGetUsers(GetUsersMessage message) { private Task<ImmutableArray<UserInfo>> HandleGetUsers(GetUsersMessage message) {
@@ -125,28 +132,48 @@ sealed class WebMessageHandlerActor : ReceiveActor<IMessageToController> {
return userRoleManager.GetUserRoles(message.UserGuids); return userRoleManager.GetUserRoles(message.UserGuids);
} }
private Task<ChangeUserRolesResult> HandleChangeUserRoles(ChangeUserRolesMessage message) { private Task<Result<ChangeUserRolesResult, UserActionFailure>> HandleChangeUserRoles(ChangeUserRolesMessage message) {
return userRoleManager.ChangeUserRoles(message.LoggedInUserGuid, message.SubjectUserGuid, message.AddToRoleGuids, message.RemoveFromRoleGuids); return userRoleManager.ChangeUserRoles(userLoginManager.GetLoggedInUser(message.AuthToken), message.SubjectUserGuid, message.AddToRoleGuids, message.RemoveFromRoleGuids);
} }
private Task<DeleteUserResult> HandleDeleteUser(DeleteUserMessage message) { private Task<Result<DeleteUserResult, UserActionFailure>> HandleDeleteUser(DeleteUserMessage message) {
return userManager.DeleteByGuid(message.LoggedInUserGuid, message.SubjectUserGuid); return userManager.DeleteByGuid(userLoginManager.GetLoggedInUser(message.AuthToken), message.SubjectUserGuid);
} }
private Task<InstanceActionResult<CreateOrUpdateInstanceResult>> HandleCreateOrUpdateInstance(CreateOrUpdateInstanceMessage message) { private Task<Result<CreateOrUpdateInstanceResult, UserInstanceActionFailure>> HandleCreateOrUpdateInstance(CreateOrUpdateInstanceMessage message) {
return agentManager.DoInstanceAction<AgentActor.CreateOrUpdateInstanceCommand, CreateOrUpdateInstanceResult>(message.Configuration.AgentGuid, new AgentActor.CreateOrUpdateInstanceCommand(message.LoggedInUserGuid, message.InstanceGuid, message.Configuration)); return agentManager.DoInstanceAction<AgentActor.CreateOrUpdateInstanceCommand, CreateOrUpdateInstanceResult>(
Permission.CreateInstances,
message.AuthToken,
message.Configuration.AgentGuid,
loggedInUserGuid => new AgentActor.CreateOrUpdateInstanceCommand(loggedInUserGuid, message.InstanceGuid, message.Configuration)
);
} }
private Task<InstanceActionResult<LaunchInstanceResult>> HandleLaunchInstance(LaunchInstanceMessage message) { private Task<Result<LaunchInstanceResult, UserInstanceActionFailure>> HandleLaunchInstance(LaunchInstanceMessage message) {
return agentManager.DoInstanceAction<AgentActor.LaunchInstanceCommand, LaunchInstanceResult>(message.AgentGuid, new AgentActor.LaunchInstanceCommand(message.InstanceGuid, message.LoggedInUserGuid)); return agentManager.DoInstanceAction<AgentActor.LaunchInstanceCommand, LaunchInstanceResult>(
Permission.ControlInstances,
message.AuthToken,
message.AgentGuid,
loggedInUserGuid => new AgentActor.LaunchInstanceCommand(loggedInUserGuid, message.InstanceGuid)
);
} }
private Task<InstanceActionResult<StopInstanceResult>> HandleStopInstance(StopInstanceMessage message) { private Task<Result<StopInstanceResult, UserInstanceActionFailure>> HandleStopInstance(StopInstanceMessage message) {
return agentManager.DoInstanceAction<AgentActor.StopInstanceCommand, StopInstanceResult>(message.AgentGuid, new AgentActor.StopInstanceCommand(message.InstanceGuid, message.LoggedInUserGuid, message.StopStrategy)); return agentManager.DoInstanceAction<AgentActor.StopInstanceCommand, StopInstanceResult>(
Permission.ControlInstances,
message.AuthToken,
message.AgentGuid,
loggedInUserGuid => new AgentActor.StopInstanceCommand(loggedInUserGuid, message.InstanceGuid, message.StopStrategy)
);
} }
private Task<InstanceActionResult<SendCommandToInstanceResult>> HandleSendCommandToInstance(SendCommandToInstanceMessage message) { private Task<Result<SendCommandToInstanceResult, UserInstanceActionFailure>> HandleSendCommandToInstance(SendCommandToInstanceMessage message) {
return agentManager.DoInstanceAction<AgentActor.SendCommandToInstanceCommand, SendCommandToInstanceResult>(message.AgentGuid, new AgentActor.SendCommandToInstanceCommand(message.InstanceGuid, message.LoggedInUserGuid, message.Command)); return agentManager.DoInstanceAction<AgentActor.SendCommandToInstanceCommand, SendCommandToInstanceResult>(
Permission.ControlInstances,
message.AuthToken,
message.AgentGuid,
loggedInUserGuid => new AgentActor.SendCommandToInstanceCommand(loggedInUserGuid, message.InstanceGuid, message.Command)
);
} }
private Task<ImmutableArray<MinecraftVersion>> HandleGetMinecraftVersions(GetMinecraftVersionsMessage message) { private Task<ImmutableArray<MinecraftVersion>> HandleGetMinecraftVersions(GetMinecraftVersionsMessage message) {
@@ -157,12 +184,12 @@ sealed class WebMessageHandlerActor : ReceiveActor<IMessageToController> {
return controllerState.AgentJavaRuntimesByGuid; return controllerState.AgentJavaRuntimesByGuid;
} }
private Task<ImmutableArray<AuditLogItem>> HandleGetAuditLog(GetAuditLogMessage message) { private Task<Result<ImmutableArray<AuditLogItem>, UserActionFailure>> HandleGetAuditLog(GetAuditLogMessage message) {
return auditLogManager.GetMostRecentItems(message.Count); return auditLogManager.GetMostRecentItems(userLoginManager.GetLoggedInUser(message.AuthToken), message.Count);
} }
private Task<ImmutableArray<EventLogItem>> HandleGetEventLog(GetEventLogMessage message) { private Task<Result<ImmutableArray<EventLogItem>, UserActionFailure>> HandleGetEventLog(GetEventLogMessage message) {
return eventLogManager.GetMostRecentItems(message.Count); return eventLogManager.GetMostRecentItems(userLoginManager.GetLoggedInUser(message.AuthToken), message.Count);
} }
private void HandleReply(ReplyMessage message) { private void HandleReply(ReplyMessage message) {

View File

@@ -7,6 +7,7 @@ using Phantom.Controller.Services.Agents;
using Phantom.Controller.Services.Events; using Phantom.Controller.Services.Events;
using Phantom.Controller.Services.Instances; using Phantom.Controller.Services.Instances;
using Phantom.Controller.Services.Users; using Phantom.Controller.Services.Users;
using Phantom.Controller.Services.Users.Sessions;
using Phantom.Utils.Actor; using Phantom.Utils.Actor;
using Phantom.Utils.Logging; using Phantom.Utils.Logging;
using Phantom.Utils.Rpc.Runtime; using Phantom.Utils.Rpc.Runtime;

View File

@@ -1,7 +1,10 @@
using System.Collections.Immutable; using System.Collections.Immutable;
using Phantom.Common.Data;
using Phantom.Common.Data.Web.AuditLog; using Phantom.Common.Data.Web.AuditLog;
using Phantom.Common.Data.Web.Users;
using Phantom.Controller.Database; using Phantom.Controller.Database;
using Phantom.Controller.Database.Repositories; using Phantom.Controller.Database.Repositories;
using Phantom.Controller.Services.Users.Sessions;
namespace Phantom.Controller.Services.Users; namespace Phantom.Controller.Services.Users;
@@ -12,7 +15,11 @@ sealed class AuditLogManager {
this.dbProvider = dbProvider; this.dbProvider = dbProvider;
} }
public async Task<ImmutableArray<AuditLogItem>> GetMostRecentItems(int count) { public async Task<Result<ImmutableArray<AuditLogItem>, UserActionFailure>> GetMostRecentItems(LoggedInUser loggedInUser, int count) {
if (!loggedInUser.CheckPermission(Permission.ViewAudit)) {
return UserActionFailure.NotAuthorized;
}
await using var db = dbProvider.Lazy(); await using var db = dbProvider.Lazy();
return await new AuditLogRepository(db).GetMostRecentItems(count, CancellationToken.None); return await new AuditLogRepository(db).GetMostRecentItems(count, CancellationToken.None);
} }

View File

@@ -36,34 +36,6 @@ sealed class PermissionManager {
} }
} }
public async Task<PermissionSet> FetchPermissionsForAllUsers(Guid userId) {
await using var ctx = dbProvider.Eager();
var userPermissions = ctx.UserPermissions
.Where(up => up.UserGuid == userId)
.Select(static up => up.PermissionId);
var rolePermissions = ctx.UserRoles
.Where(ur => ur.UserGuid == userId)
.Join(ctx.RolePermissions, static ur => ur.RoleGuid, static rp => rp.RoleGuid, static (ur, rp) => rp.PermissionId);
return new PermissionSet(await userPermissions.Union(rolePermissions).AsAsyncEnumerable().ToImmutableSetAsync());
}
public async Task<PermissionSet> FetchPermissionsForUserId(Guid userId) {
await using var ctx = dbProvider.Eager();
var userPermissions = ctx.UserPermissions
.Where(up => up.UserGuid == userId)
.Select(static up => up.PermissionId);
var rolePermissions = ctx.UserRoles
.Where(ur => ur.UserGuid == userId)
.Join(ctx.RolePermissions, static ur => ur.RoleGuid, static rp => rp.RoleGuid, static (ur, rp) => rp.PermissionId);
return new PermissionSet(await userPermissions.Union(rolePermissions).AsAsyncEnumerable().ToImmutableSetAsync());
}
public static ImmutableArray<string> GetMissingPermissionsOrdered(IEnumerable<Permission> allPermissions, ImmutableHashSet<string> existingPermissionIds) { public static ImmutableArray<string> GetMissingPermissionsOrdered(IEnumerable<Permission> allPermissions, ImmutableHashSet<string> existingPermissionIds) {
return allPermissions.Select(static permission => permission.Id).Except(existingPermissionIds).Order().ToImmutableArray(); return allPermissions.Select(static permission => permission.Id).Except(existingPermissionIds).Order().ToImmutableArray();
} }

View File

@@ -0,0 +1,29 @@
using System.Collections.Concurrent;
using Phantom.Common.Data.Web.Users;
using Phantom.Controller.Database;
using Phantom.Controller.Database.Entities;
using Phantom.Controller.Database.Repositories;
namespace Phantom.Controller.Services.Users.Sessions;
sealed class AuthenticatedUserCache {
private readonly ConcurrentDictionary<Guid, AuthenticatedUserInfo> authenticatedUsersByGuid = new ();
public bool TryGet(Guid userGuid, out AuthenticatedUserInfo? userInfo) {
return authenticatedUsersByGuid.TryGetValue(userGuid, out userInfo);
}
public async Task<AuthenticatedUserInfo?> Update(UserEntity user, ILazyDbContext db) {
var permissionRepository = new PermissionRepository(db);
var userPermissions = await permissionRepository.GetAllUserPermissions(user);
var userManagedAgentGuids = await permissionRepository.GetManagedAgentGuids(user);
var userGuid = user.UserGuid;
var userInfo = new AuthenticatedUserInfo(userGuid, user.Name, userPermissions, userManagedAgentGuids);
return authenticatedUsersByGuid[userGuid] = userInfo;
}
public void Remove(Guid userGuid) {
authenticatedUsersByGuid.Remove(userGuid, out _);
}
}

View File

@@ -0,0 +1,20 @@
using System.Collections.Immutable;
using Phantom.Common.Data.Web.Users;
namespace Phantom.Controller.Services.Users.Sessions;
readonly record struct LoggedInUser(AuthenticatedUserInfo? AuthenticatedUserInfo) {
public Guid? Guid => AuthenticatedUserInfo?.Guid;
public bool CheckPermission(Permission permission) {
return AuthenticatedUserInfo is {} info && info.CheckPermission(permission);
}
public bool HasAccessToAgent(Guid agentGuid) {
return AuthenticatedUserInfo is {} info && info.HasAccessToAgent(agentGuid);
}
public ImmutableHashSet<Guid> FilterAccessibleAgentGuids(ImmutableHashSet<Guid> agentGuids) {
return AuthenticatedUserInfo is {} info ? info.FilterAccessibleAgentGuids(agentGuids) : ImmutableHashSet<Guid>.Empty;
}
}

View File

@@ -0,0 +1,140 @@
using System.Collections.Immutable;
using System.Security.Cryptography;
using Phantom.Common.Data;
using Phantom.Common.Data.Web.Users;
using Phantom.Controller.Database;
using Phantom.Controller.Database.Repositories;
namespace Phantom.Controller.Services.Users.Sessions;
sealed class UserLoginManager {
private const int SessionIdBytes = 20;
private readonly AuthenticatedUserCache authenticatedUserCache;
private readonly UserManager userManager;
private readonly IDbContextProvider dbProvider;
private readonly UserSessionBucket[] sessionBuckets = new UserSessionBucket[256];
public UserLoginManager(AuthenticatedUserCache authenticatedUserCache, UserManager userManager, IDbContextProvider dbProvider) {
this.authenticatedUserCache = authenticatedUserCache;
this.userManager = userManager;
this.dbProvider = dbProvider;
for (int i = 0; i < sessionBuckets.GetLength(0); i++) {
sessionBuckets[i] = new UserSessionBucket();
}
}
private UserSessionBucket GetSessionBucket(ImmutableArray<byte> token) {
return sessionBuckets[token[0]];
}
public async Task<Optional<LogInSuccess>> LogIn(string username, string password) {
Guid userGuid;
AuthenticatedUserInfo? authenticatedUserInfo;
await using (var db = dbProvider.Lazy()) {
var userRepository = new UserRepository(db);
var user = await userRepository.GetByName(username);
if (user == null || !UserPasswords.Verify(password, user.PasswordHash)) {
return default;
}
authenticatedUserInfo = await authenticatedUserCache.Update(user, db);
if (authenticatedUserInfo == null) {
return default;
}
userGuid = user.UserGuid;
var auditLogWriter = new AuditLogRepository(db).Writer(userGuid);
auditLogWriter.UserLoggedIn(user);
await db.Ctx.SaveChangesAsync();
}
var authToken = ImmutableArray.Create(RandomNumberGenerator.GetBytes(SessionIdBytes));
GetSessionBucket(authToken).Add(userGuid, authToken);
return new LogInSuccess(authenticatedUserInfo, authToken);
}
public async Task LogOut(Guid userGuid, ImmutableArray<byte> authToken) {
if (!GetSessionBucket(authToken).Remove(userGuid, authToken)) {
return;
}
await using var db = dbProvider.Lazy();
var auditLogWriter = new AuditLogRepository(db).Writer(userGuid);
auditLogWriter.UserLoggedOut(userGuid);
await db.Ctx.SaveChangesAsync();
}
public LoggedInUser GetLoggedInUser(ImmutableArray<byte> authToken) {
var userGuid = GetSessionBucket(authToken).FindUserGuid(authToken);
return userGuid != null && authenticatedUserCache.TryGet(userGuid.Value, out var userInfo) ? new LoggedInUser(userInfo) : default;
}
public AuthenticatedUserInfo? GetAuthenticatedUser(Guid userGuid, ImmutableArray<byte> authToken) {
return authenticatedUserCache.TryGet(userGuid, out var userInfo) && GetSessionBucket(authToken).Contains(userGuid, authToken) ? userInfo : null;
}
private sealed class UserSessionBucket {
private ImmutableList<UserSession> sessions = ImmutableList<UserSession>.Empty;
public void Add(Guid userGuid, ImmutableArray<byte> authToken) {
lock (this) {
var session = new UserSession(userGuid, authToken);
if (!sessions.Contains(session)) {
sessions = sessions.Add(session);
}
}
}
public bool Contains(Guid userGuid, ImmutableArray<byte> authToken) {
lock (this) {
return sessions.Contains(new UserSession(userGuid, authToken));
}
}
public Guid? FindUserGuid(ImmutableArray<byte> authToken) {
lock (this) {
return sessions.Find(session => session.AuthTokenEquals(authToken))?.UserGuid;
}
}
public bool Remove(Guid userGuid, ImmutableArray<byte> authToken) {
lock (this) {
int index = sessions.IndexOf(new UserSession(userGuid, authToken));
if (index == -1) {
return false;
}
sessions = sessions.RemoveAt(index);
return true;
}
}
}
private sealed record UserSession(Guid UserGuid, ImmutableArray<byte> AuthToken) {
public bool AuthTokenEquals(ImmutableArray<byte> other) {
return CryptographicOperations.FixedTimeEquals(AuthToken.AsSpan(), other.AsSpan());
}
public bool Equals(UserSession? other) {
if (ReferenceEquals(null, other)) {
return false;
}
return UserGuid.Equals(other.UserGuid) && AuthTokenEquals(other.AuthToken);
}
public override int GetHashCode() {
throw new NotImplementedException();
}
}
}

View File

@@ -1,64 +0,0 @@
using System.Collections.Concurrent;
using System.Collections.Immutable;
using System.Security.Cryptography;
using Phantom.Common.Data.Web.Users;
using Phantom.Controller.Database;
using Phantom.Controller.Database.Repositories;
namespace Phantom.Controller.Services.Users;
sealed class UserLoginManager {
private const int SessionIdBytes = 20;
private readonly ConcurrentDictionary<Guid, List<ImmutableArray<byte>>> sessionTokensByUserGuid = new ();
private readonly UserManager userManager;
private readonly PermissionManager permissionManager;
private readonly IDbContextProvider dbProvider;
public UserLoginManager(UserManager userManager, PermissionManager permissionManager, IDbContextProvider dbProvider) {
this.userManager = userManager;
this.permissionManager = permissionManager;
this.dbProvider = dbProvider;
}
public async Task<LogInSuccess?> LogIn(string username, string password) {
var user = await userManager.GetAuthenticated(username, password);
if (user == null) {
return null;
}
var token = ImmutableArray.Create(RandomNumberGenerator.GetBytes(SessionIdBytes));
var sessionTokens = sessionTokensByUserGuid.GetOrAdd(user.UserGuid, static _ => new List<ImmutableArray<byte>>());
lock (sessionTokens) {
sessionTokens.Add(token);
}
await using (var db = dbProvider.Lazy()) {
var auditLogWriter = new AuditLogRepository(db).Writer(user.UserGuid);
auditLogWriter.UserLoggedIn(user);
await db.Ctx.SaveChangesAsync();
}
return new LogInSuccess(user.UserGuid, await permissionManager.FetchPermissionsForUserId(user.UserGuid), token);
}
public async Task LogOut(Guid userGuid, ImmutableArray<byte> sessionToken) {
if (!sessionTokensByUserGuid.TryGetValue(userGuid, out var sessionTokens)) {
return;
}
lock (sessionTokens) {
if (sessionTokens.RemoveAll(token => token.SequenceEqual(sessionToken)) == 0) {
return;
}
}
await using var db = dbProvider.Lazy();
var auditLogWriter = new AuditLogRepository(db).Writer(userGuid);
auditLogWriter.UserLoggedOut(userGuid);
await db.Ctx.SaveChangesAsync();
}
}

View File

@@ -1,8 +1,11 @@
using System.Collections.Immutable; using System.Collections.Immutable;
using Phantom.Common.Data;
using Phantom.Common.Data.Web.Users; using Phantom.Common.Data.Web.Users;
using Phantom.Common.Data.Web.Users.CreateOrUpdateAdministratorUserResults;
using Phantom.Controller.Database; using Phantom.Controller.Database;
using Phantom.Controller.Database.Entities; using Phantom.Controller.Database.Entities;
using Phantom.Controller.Database.Repositories; using Phantom.Controller.Database.Repositories;
using Phantom.Controller.Services.Users.Sessions;
using Phantom.Utils.Logging; using Phantom.Utils.Logging;
using Serilog; using Serilog;
@@ -11,9 +14,13 @@ namespace Phantom.Controller.Services.Users;
sealed class UserManager { sealed class UserManager {
private static readonly ILogger Logger = PhantomLogger.Create<UserManager>(); private static readonly ILogger Logger = PhantomLogger.Create<UserManager>();
private readonly AuthenticatedUserCache authenticatedUserCache;
private readonly ControllerState controllerState;
private readonly IDbContextProvider dbProvider; private readonly IDbContextProvider dbProvider;
public UserManager(IDbContextProvider dbProvider) { public UserManager(AuthenticatedUserCache authenticatedUserCache, ControllerState controllerState, IDbContextProvider dbProvider) {
this.authenticatedUserCache = authenticatedUserCache;
this.controllerState = controllerState;
this.dbProvider = dbProvider; this.dbProvider = dbProvider;
} }
@@ -50,13 +57,12 @@ sealed class UserManager {
wasCreated = true; wasCreated = true;
} }
else { else {
return new Common.Data.Web.Users.CreateOrUpdateAdministratorUserResults.CreationFailed(result.Error); return new CreationFailed(result.Error);
} }
} }
else { else {
var result = userRepository.SetUserPassword(user, password); if (userRepository.SetUserPassword(user, password).TryGetError(out var error)) {
if (!result) { return new UpdatingFailed(error);
return new Common.Data.Web.Users.CreateOrUpdateAdministratorUserResults.UpdatingFailed(result.Error);
} }
auditLogWriter.AdministratorUserModified(user); auditLogWriter.AdministratorUserModified(user);
@@ -65,7 +71,7 @@ sealed class UserManager {
var role = await new RoleRepository(db).GetByGuid(Role.Administrator.Guid); var role = await new RoleRepository(db).GetByGuid(Role.Administrator.Guid);
if (role == null) { if (role == null) {
return new Common.Data.Web.Users.CreateOrUpdateAdministratorUserResults.AddingToRoleFailed(); return new AddingToRoleFailed();
} }
await new UserRoleRepository(db).Add(user, role); await new UserRoleRepository(db).Add(user, role);
@@ -79,17 +85,21 @@ sealed class UserManager {
Logger.Information("Updated administrator user \"{Username}\" (GUID {Guid}).", username, user.UserGuid); Logger.Information("Updated administrator user \"{Username}\" (GUID {Guid}).", username, user.UserGuid);
} }
return new Common.Data.Web.Users.CreateOrUpdateAdministratorUserResults.Success(user.ToUserInfo()); return new Success(user.ToUserInfo());
} catch (Exception e) { } catch (Exception e) {
Logger.Error(e, "Could not create or update administrator user \"{Username}\".", username); Logger.Error(e, "Could not create or update administrator user \"{Username}\".", username);
return new Common.Data.Web.Users.CreateOrUpdateAdministratorUserResults.UnknownError(); return new UnknownError();
} }
} }
public async Task<CreateUserResult> Create(Guid loggedInUserGuid, string username, string password) { public async Task<Result<CreateUserResult, UserActionFailure>> Create(LoggedInUser loggedInUser, string username, string password) {
if (!loggedInUser.CheckPermission(Permission.EditUsers)) {
return UserActionFailure.NotAuthorized;
}
await using var db = dbProvider.Lazy(); await using var db = dbProvider.Lazy();
var userRepository = new UserRepository(db); var userRepository = new UserRepository(db);
var auditLogWriter = new AuditLogRepository(db).Writer(loggedInUserGuid); var auditLogWriter = new AuditLogRepository(db).Writer(loggedInUser.Guid);
try { try {
var result = await userRepository.CreateUser(username, password); var result = await userRepository.CreateUser(username, password);
@@ -110,7 +120,11 @@ sealed class UserManager {
} }
} }
public async Task<DeleteUserResult> DeleteByGuid(Guid loggedInUserGuid, Guid userGuid) { public async Task<Result<DeleteUserResult, UserActionFailure>> DeleteByGuid(LoggedInUser loggedInUser, Guid userGuid) {
if (!loggedInUser.CheckPermission(Permission.EditUsers)) {
return UserActionFailure.NotAuthorized;
}
await using var db = dbProvider.Lazy(); await using var db = dbProvider.Lazy();
var userRepository = new UserRepository(db); var userRepository = new UserRepository(db);
@@ -119,12 +133,18 @@ sealed class UserManager {
return DeleteUserResult.NotFound; return DeleteUserResult.NotFound;
} }
var auditLogWriter = new AuditLogRepository(db).Writer(loggedInUserGuid); authenticatedUserCache.Remove(userGuid);
var auditLogWriter = new AuditLogRepository(db).Writer(loggedInUser.Guid);
try { try {
userRepository.DeleteUser(user); userRepository.DeleteUser(user);
auditLogWriter.UserDeleted(user); auditLogWriter.UserDeleted(user);
await db.Ctx.SaveChangesAsync(); await db.Ctx.SaveChangesAsync();
// In case the user logged in during deletion.
authenticatedUserCache.Remove(userGuid);
controllerState.UpdateOrDeleteUser(userGuid);
Logger.Information("Deleted user \"{Username}\" (GUID {Guid}).", user.Name, user.UserGuid); Logger.Information("Deleted user \"{Username}\" (GUID {Guid}).", user.Name, user.UserGuid);
return DeleteUserResult.Deleted; return DeleteUserResult.Deleted;
} catch (Exception e) { } catch (Exception e) {

View File

@@ -1,7 +1,9 @@
using System.Collections.Immutable; using System.Collections.Immutable;
using Phantom.Common.Data;
using Phantom.Common.Data.Web.Users; using Phantom.Common.Data.Web.Users;
using Phantom.Controller.Database; using Phantom.Controller.Database;
using Phantom.Controller.Database.Repositories; using Phantom.Controller.Database.Repositories;
using Phantom.Controller.Services.Users.Sessions;
using Phantom.Utils.Logging; using Phantom.Utils.Logging;
using Serilog; using Serilog;
@@ -10,9 +12,13 @@ namespace Phantom.Controller.Services.Users;
sealed class UserRoleManager { sealed class UserRoleManager {
private static readonly ILogger Logger = PhantomLogger.Create<UserRoleManager>(); private static readonly ILogger Logger = PhantomLogger.Create<UserRoleManager>();
private readonly AuthenticatedUserCache authenticatedUserCache;
private readonly ControllerState controllerState;
private readonly IDbContextProvider dbProvider; private readonly IDbContextProvider dbProvider;
public UserRoleManager(IDbContextProvider dbProvider) { public UserRoleManager(AuthenticatedUserCache authenticatedUserCache, ControllerState controllerState, IDbContextProvider dbProvider) {
this.authenticatedUserCache = authenticatedUserCache;
this.controllerState = controllerState;
this.dbProvider = dbProvider; this.dbProvider = dbProvider;
} }
@@ -21,7 +27,11 @@ sealed class UserRoleManager {
return await new UserRoleRepository(db).GetRoleGuidsByUserGuid(userGuids); return await new UserRoleRepository(db).GetRoleGuidsByUserGuid(userGuids);
} }
public async Task<ChangeUserRolesResult> ChangeUserRoles(Guid loggedInUserGuid, Guid subjectUserGuid, ImmutableHashSet<Guid> addToRoleGuids, ImmutableHashSet<Guid> removeFromRoleGuids) { public async Task<Result<ChangeUserRolesResult, UserActionFailure>> ChangeUserRoles(LoggedInUser loggedInUser, Guid subjectUserGuid, ImmutableHashSet<Guid> addToRoleGuids, ImmutableHashSet<Guid> removeFromRoleGuids) {
if (!loggedInUser.CheckPermission(Permission.EditUsers)) {
return UserActionFailure.NotAuthorized;
}
await using var db = dbProvider.Lazy(); await using var db = dbProvider.Lazy();
var userRepository = new UserRepository(db); var userRepository = new UserRepository(db);
@@ -32,7 +42,7 @@ sealed class UserRoleManager {
var roleRepository = new RoleRepository(db); var roleRepository = new RoleRepository(db);
var userRoleRepository = new UserRoleRepository(db); var userRoleRepository = new UserRoleRepository(db);
var auditLogWriter = new AuditLogRepository(db).Writer(loggedInUserGuid); var auditLogWriter = new AuditLogRepository(db).Writer(loggedInUser.Guid);
var rolesByGuid = await roleRepository.GetByGuids(addToRoleGuids.Union(removeFromRoleGuids)); var rolesByGuid = await roleRepository.GetByGuids(addToRoleGuids.Union(removeFromRoleGuids));
@@ -62,6 +72,9 @@ sealed class UserRoleManager {
auditLogWriter.UserRolesChanged(user, addedToRoleNames, removedFromRoleNames); auditLogWriter.UserRolesChanged(user, addedToRoleNames, removedFromRoleNames);
await db.Ctx.SaveChangesAsync(); await db.Ctx.SaveChangesAsync();
await authenticatedUserCache.Update(user, db);
controllerState.UpdateOrDeleteUser(user.UserGuid);
Logger.Information("Changed roles for user \"{Username}\" (GUID {Guid}).", user.Name, user.UserGuid); Logger.Information("Changed roles for user \"{Username}\" (GUID {Guid}).", user.Name, user.UserGuid);
return new ChangeUserRolesResult(addedToRoleGuids.ToImmutable(), removedFromRoleGuids.ToImmutable()); return new ChangeUserRolesResult(addedToRoleGuids.ToImmutable(), removedFromRoleGuids.ToImmutable());
} catch (Exception e) { } catch (Exception e) {

View File

@@ -1,8 +1,8 @@
<Project> <Project>
<PropertyGroup> <PropertyGroup>
<TargetFramework>net8.0</TargetFramework> <TargetFramework>net9.0</TargetFramework>
<LangVersion>11</LangVersion> <LangVersion>13</LangVersion>
</PropertyGroup> </PropertyGroup>
<PropertyGroup> <PropertyGroup>

View File

@@ -1,7 +1,7 @@
# +---------------+ # +---------------+
# | Prepare build | # | Prepare build |
# +---------------+ # +---------------+
FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/nightly/sdk:8.0 AS phantom-builder FROM --platform=$BUILDPLATFORM mcr.microsoft.com/dotnet/nightly/sdk:9.0 AS phantom-builder
ARG TARGETARCH ARG TARGETARCH
ADD . /app ADD . /app
@@ -19,7 +19,7 @@ RUN find .artifacts/publish/*/* -maxdepth 0 -execdir mv '{}' 'release' \;
# +---------------------+ # +---------------------+
# | Phantom Agent image | # | Phantom Agent image |
# +---------------------+ # +---------------------+
FROM mcr.microsoft.com/dotnet/nightly/runtime:8.0 AS phantom-agent FROM mcr.microsoft.com/dotnet/nightly/runtime:9.0 AS phantom-agent
RUN mkdir /data && chmod 777 /data RUN mkdir /data && chmod 777 /data
WORKDIR /data WORKDIR /data
@@ -27,7 +27,7 @@ WORKDIR /data
COPY --from=eclipse-temurin:8-jre /opt/java/openjdk /opt/java/8 COPY --from=eclipse-temurin:8-jre /opt/java/openjdk /opt/java/8
COPY --from=eclipse-temurin:16-jdk /opt/java/openjdk /opt/java/16 COPY --from=eclipse-temurin:16-jdk /opt/java/openjdk /opt/java/16
COPY --from=eclipse-temurin:17-jre /opt/java/openjdk /opt/java/17 COPY --from=eclipse-temurin:17-jre /opt/java/openjdk /opt/java/17
COPY --from=eclipse-temurin:20-jre /opt/java/openjdk /opt/java/20 COPY --from=eclipse-temurin:21-jre /opt/java/openjdk /opt/java/21
ARG DEBIAN_FRONTEND=noninteractive ARG DEBIAN_FRONTEND=noninteractive
@@ -46,7 +46,7 @@ ENTRYPOINT ["dotnet", "/app/Phantom.Agent.dll"]
# +--------------------------+ # +--------------------------+
# | Phantom Controller image | # | Phantom Controller image |
# +--------------------------+ # +--------------------------+
FROM mcr.microsoft.com/dotnet/nightly/runtime:8.0 AS phantom-controller FROM mcr.microsoft.com/dotnet/nightly/runtime:9.0 AS phantom-controller
RUN mkdir /data && chmod 777 /data RUN mkdir /data && chmod 777 /data
WORKDIR /data WORKDIR /data
@@ -59,7 +59,7 @@ ENTRYPOINT ["dotnet", "/app/Phantom.Controller.dll"]
# +-------------------+ # +-------------------+
# | Phantom Web image | # | Phantom Web image |
# +-------------------+ # +-------------------+
FROM mcr.microsoft.com/dotnet/nightly/aspnet:8.0 AS phantom-web FROM mcr.microsoft.com/dotnet/nightly/aspnet:9.0 AS phantom-web
RUN mkdir /data && chmod 777 /data RUN mkdir /data && chmod 777 /data
WORKDIR /data WORKDIR /data

View File

@@ -17,6 +17,10 @@ public static class TaskExtensions {
return task.ContinueOnActor(result => mapper(result, arg)); return task.ContinueOnActor(result => mapper(result, arg));
} }
public static Task<TResult> ContinueOnActor<TSource, TArg1, TArg2, TResult>(this Task<TSource> task, Func<TSource, TArg1, TArg2, TResult> mapper, TArg1 arg1, TArg2 arg2) {
return task.ContinueOnActor(result => mapper(result, arg1, arg2));
}
private static Task<TResult> MapResult<TSource, TResult>(Task<TSource> task, Func<TSource, TResult> mapper, TaskCompletionSource<TResult> completionSource) { private static Task<TResult> MapResult<TSource, TResult>(Task<TSource> task, Func<TSource, TResult> mapper, TaskCompletionSource<TResult> completionSource) {
if (task.IsFaulted) { if (task.IsFaulted) {
completionSource.SetException(task.Exception.InnerExceptions); completionSource.SetException(task.Exception.InnerExceptions);

View File

@@ -59,25 +59,25 @@ public sealed class RingBufferTests {
[Test] [Test]
public void AddOneItemAndEnumerateOne() { public void AddOneItemAndEnumerateOne() {
var buffer = PrepareRingBuffer(10, "a"); var buffer = PrepareRingBuffer(10, "a");
Assert.That(buffer.EnumerateLast(1), Is.EquivalentTo(new [] { "a" })); Assert.That(buffer.EnumerateLast(1), Is.EquivalentTo(new[] { "a" }));
} }
[Test] [Test]
public void AddOneItemAndEnumerateMaxValue() { public void AddOneItemAndEnumerateMaxValue() {
var buffer = PrepareRingBuffer(10, "a"); var buffer = PrepareRingBuffer(10, "a");
Assert.That(buffer.EnumerateLast(uint.MaxValue), Is.EquivalentTo(new [] { "a" })); Assert.That(buffer.EnumerateLast(uint.MaxValue), Is.EquivalentTo(new[] { "a" }));
} }
[Test] [Test]
public void AddMultipleItemsWithinCapacityAndEnumerateFewer() { public void AddMultipleItemsWithinCapacityAndEnumerateFewer() {
var buffer = PrepareRingBuffer(10, "a", "b", "c"); var buffer = PrepareRingBuffer(10, "a", "b", "c");
Assert.That(buffer.EnumerateLast(2), Is.EquivalentTo(new [] { "b", "c" })); Assert.That(buffer.EnumerateLast(2), Is.EquivalentTo(new[] { "b", "c" }));
} }
[Test] [Test]
public void AddMultipleItemsWithinCapacityAndEnumerateMaxValue() { public void AddMultipleItemsWithinCapacityAndEnumerateMaxValue() {
var buffer = PrepareRingBuffer(10, "a", "b", "c"); var buffer = PrepareRingBuffer(10, "a", "b", "c");
Assert.That(buffer.EnumerateLast(uint.MaxValue), Is.EquivalentTo(new [] { "a", "b", "c" })); Assert.That(buffer.EnumerateLast(uint.MaxValue), Is.EquivalentTo(new[] { "a", "b", "c" }));
} }
[TestCase(3)] [TestCase(3)]
@@ -85,12 +85,12 @@ public sealed class RingBufferTests {
[TestCase(5)] [TestCase(5)]
public void AddMultipleItemsOverflowingCapacityAndEnumerateFewer(int capacity) { public void AddMultipleItemsOverflowingCapacityAndEnumerateFewer(int capacity) {
var buffer = PrepareRingBuffer(capacity, "a", "b", "c", "d", "e", "f"); var buffer = PrepareRingBuffer(capacity, "a", "b", "c", "d", "e", "f");
Assert.That(buffer.EnumerateLast(2), Is.EquivalentTo(new [] { "e", "f" })); Assert.That(buffer.EnumerateLast(2), Is.EquivalentTo(new[] { "e", "f" }));
} }
[TestCase(3, ExpectedResult = new [] { "d", "e", "f" })] [TestCase(3, ExpectedResult = new[] { "d", "e", "f" })]
[TestCase(4, ExpectedResult = new [] { "c", "d", "e", "f" })] [TestCase(4, ExpectedResult = new[] { "c", "d", "e", "f" })]
[TestCase(5, ExpectedResult = new [] { "b", "c", "d", "e", "f" })] [TestCase(5, ExpectedResult = new[] { "b", "c", "d", "e", "f" })]
public string[] AddMultipleItemsOverflowingCapacityAndEnumerateMaxValue(int capacity) { public string[] AddMultipleItemsOverflowingCapacityAndEnumerateMaxValue(int capacity) {
var buffer = PrepareRingBuffer(capacity, "a", "b", "c", "d", "e", "f"); var buffer = PrepareRingBuffer(capacity, "a", "b", "c", "d", "e", "f");
return buffer.EnumerateLast(uint.MaxValue).ToArray(); return buffer.EnumerateLast(uint.MaxValue).ToArray();

View File

@@ -13,6 +13,27 @@ public static class EnumerableExtensions {
return builder.ToImmutable(); return builder.ToImmutable();
} }
public static async Task<ImmutableArray<TSource>> ToImmutableArrayCatchingExceptionsAsync<TSource>(this IAsyncEnumerable<TSource> source, Action<Exception> onException, CancellationToken cancellationToken = default) {
var builder = ImmutableArray.CreateBuilder<TSource>();
await using (var enumerator = source.GetAsyncEnumerator(cancellationToken)) {
while (true) {
try {
if (!await enumerator.MoveNextAsync()) {
break;
}
} catch (Exception e) {
onException(e);
continue;
}
builder.Add(enumerator.Current);
}
}
return builder.ToImmutable();
}
public static async Task<ImmutableHashSet<TSource>> ToImmutableSetAsync<TSource>(this IAsyncEnumerable<TSource> source, CancellationToken cancellationToken = default) { public static async Task<ImmutableHashSet<TSource>> ToImmutableSetAsync<TSource>(this IAsyncEnumerable<TSource> source, CancellationToken cancellationToken = default) {
var builder = ImmutableHashSet.CreateBuilder<TSource>(); var builder = ImmutableHashSet.CreateBuilder<TSource>();

View File

@@ -0,0 +1,3 @@
namespace Phantom.Utils.Result;
public sealed record Err<T>(T Error) : Result;

View File

@@ -0,0 +1,3 @@
namespace Phantom.Utils.Result;
public sealed record Ok<T>(T Value) : Result;

View File

@@ -0,0 +1,5 @@
namespace Phantom.Utils.Result;
public abstract record Result {
private protected Result() {}
}

View File

@@ -1,69 +0,0 @@
using System.Diagnostics.CodeAnalysis;
namespace Phantom.Utils.Tasks;
public abstract record Result<TValue, TError> {
private Result() {}
public abstract TValue Value { get; init; }
public abstract TError Error { get; init; }
public static implicit operator Result<TValue, TError>(TValue value) {
return new Ok(value);
}
public static implicit operator Result<TValue, TError>(TError error) {
return new Fail(error);
}
public static implicit operator bool(Result<TValue, TError> result) {
return result is Ok;
}
public sealed record Ok(TValue Value) : Result<TValue, TError> {
public override TError Error {
get => throw new InvalidOperationException("Attempted to get error from Ok result.");
init {}
}
}
public sealed record Fail(TError Error) : Result<TValue, TError> {
public override TValue Value {
get => throw new InvalidOperationException("Attempted to get value from Fail result.");
init {}
}
}
}
public abstract record Result<TError> {
private Result() {}
public abstract TError Error { get; init; }
public static implicit operator Result<TError>(TError error) {
return new Fail(error);
}
public static implicit operator Result<TError>([SuppressMessage("ReSharper", "UnusedParameter.Global")] Result.OkType _) {
return new Ok();
}
public static implicit operator bool(Result<TError> result) {
return result is Ok;
}
public sealed record Ok : Result<TError> {
public override TError Error {
get => throw new InvalidOperationException("Attempted to get error from Ok result.");
init {}
}
}
public sealed record Fail(TError Error) : Result<TError>;
}
public static class Result {
public static OkType Ok { get; } = new ();
public readonly record struct OkType;
}

View File

@@ -1,6 +1,6 @@
@using Phantom.Web.Components.Utils @using Phantom.Web.Components.Utils
@if (messageLines.Length > 0) { @if (messageLines.Length > 0) {
<div class="form-submit-errors text-danger"> <div class="form-submit-errors text-danger" role="alert">
@for (int i = 0; i < messageLines.Length; i++) { @for (int i = 0; i < messageLines.Length; i++) {
@messageLines[i] @messageLines[i]
if (i < messageLines.Length - 1) { if (i < messageLines.Length - 1) {

View File

@@ -2,9 +2,8 @@
using Microsoft.AspNetCore.Components.Authorization; using Microsoft.AspNetCore.Components.Authorization;
using Phantom.Common.Data.Web.Users; using Phantom.Common.Data.Web.Users;
using Phantom.Utils.Logging; using Phantom.Utils.Logging;
using Phantom.Web.Services.Authorization; using Phantom.Web.Services.Authentication;
using ILogger = Serilog.ILogger; using ILogger = Serilog.ILogger;
using UserInfo = Phantom.Web.Services.Authentication.UserInfo;
namespace Phantom.Web.Components; namespace Phantom.Web.Components;
@@ -14,21 +13,18 @@ public abstract class PhantomComponent : ComponentBase, IDisposable {
[CascadingParameter] [CascadingParameter]
public Task<AuthenticationState> AuthenticationStateTask { get; set; } = null!; public Task<AuthenticationState> AuthenticationStateTask { get; set; } = null!;
[Inject]
public PermissionManager PermissionManager { get; set; } = null!;
private readonly CancellationTokenSource cancellationTokenSource = new (); private readonly CancellationTokenSource cancellationTokenSource = new ();
protected CancellationToken CancellationToken => cancellationTokenSource.Token; protected CancellationToken CancellationToken => cancellationTokenSource.Token;
protected async Task<Guid?> GetUserGuid() { protected async Task<AuthenticatedUser?> GetAuthenticatedUser() {
var authenticationState = await AuthenticationStateTask; var authenticationState = await AuthenticationStateTask;
return UserInfo.TryGetGuid(authenticationState.User); return authenticationState.GetAuthenticatedUser();
} }
protected async Task<bool> CheckPermission(Permission permission) { protected async Task<bool> CheckPermission(Permission permission) {
var authenticationState = await AuthenticationStateTask; var authenticationState = await AuthenticationStateTask;
return PermissionManager.CheckPermission(authenticationState.User, permission); return authenticationState.CheckPermission(permission);
} }
protected void InvokeAsyncChecked(Func<Task> task) { protected void InvokeAsyncChecked(Func<Task> task) {

View File

@@ -9,12 +9,12 @@ public abstract class FormCustomValidationAttribute<TModel, TValue> : Validation
protected sealed override ValidationResult? IsValid(object? value, ValidationContext validationContext) { protected sealed override ValidationResult? IsValid(object? value, ValidationContext validationContext) {
if (value is not TValue typedValue) { if (value is not TValue typedValue) {
return new ValidationResult(null, new [] { FieldName }); return new ValidationResult(null, new[] { FieldName });
} }
var model = (TModel) validationContext.ObjectInstance; var model = (TModel) validationContext.ObjectInstance;
var result = Validate(model, typedValue); var result = Validate(model, typedValue);
return result == ValidationResult.Success ? result : new ValidationResult(result?.ErrorMessage, new [] { FieldName }); return result == ValidationResult.Success ? result : new ValidationResult(result?.ErrorMessage, new[] { FieldName });
} }
protected abstract string FieldName { get; } protected abstract string FieldName { get; }

View File

@@ -9,7 +9,7 @@ public abstract class FormValidationAttribute<TModel, TValue> : ValidationAttrib
protected sealed override ValidationResult? IsValid(object? value, ValidationContext validationContext) { protected sealed override ValidationResult? IsValid(object? value, ValidationContext validationContext) {
var model = (TModel) validationContext.ObjectInstance; var model = (TModel) validationContext.ObjectInstance;
return value is TValue typedValue && IsValid(model, typedValue) ? ValidationResult.Success : new ValidationResult(null, new [] { FieldName }); return value is TValue typedValue && IsValid(model, typedValue) ? ValidationResult.Success : new ValidationResult(null, new[] { FieldName });
} }
protected abstract string FieldName { get; } protected abstract string FieldName { get; }

View File

@@ -2,6 +2,7 @@
using Phantom.Common.Data.Web.Agent; using Phantom.Common.Data.Web.Agent;
using Phantom.Utils.Events; using Phantom.Utils.Events;
using Phantom.Utils.Logging; using Phantom.Utils.Logging;
using Phantom.Web.Services.Authentication;
namespace Phantom.Web.Services.Agents; namespace Phantom.Web.Services.Agents;
@@ -18,7 +19,13 @@ public sealed class AgentManager {
return agents.Value; return agents.Value;
} }
public ImmutableDictionary<Guid, Agent> ToDictionaryByGuid() { public ImmutableDictionary<Guid, Agent> ToDictionaryByGuid(AuthenticatedUser? authenticatedUser) {
return agents.Value.ToImmutableDictionary(static agent => agent.AgentGuid); if (authenticatedUser == null) {
return ImmutableDictionary<Guid, Agent>.Empty;
}
return agents.Value
.Where(agent => authenticatedUser.Info.HasAccessToAgent(agent.AgentGuid))
.ToImmutableDictionary(static agent => agent.AgentGuid);
} }
} }

View File

@@ -0,0 +1,6 @@
using System.Collections.Immutable;
using Phantom.Common.Data.Web.Users;
namespace Phantom.Web.Services.Authentication;
public sealed record AuthenticatedUser(AuthenticatedUserInfo Info, ImmutableArray<byte> Token);

View File

@@ -0,0 +1,31 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Components.Authorization;
using Phantom.Common.Data.Web.Users;
namespace Phantom.Web.Services.Authentication;
public static class AuthenticationStateExtensions {
public static AuthenticatedUser? GetAuthenticatedUser(this AuthenticationState authenticationState) {
return authenticationState.User.GetAuthenticatedUser();
}
public static AuthenticatedUser? GetAuthenticatedUser(this ClaimsPrincipal claimsPrincipal) {
return claimsPrincipal is CustomClaimsPrincipal principal ? principal.User : null;
}
public static PermissionSet GetPermissions(this AuthenticationState authenticationState) {
return authenticationState.User.GetPermissions();
}
public static PermissionSet GetPermissions(this ClaimsPrincipal claimsPrincipal) {
return claimsPrincipal.GetAuthenticatedUser() is {} user ? user.Info.Permissions : PermissionSet.None;
}
public static bool CheckPermission(this AuthenticationState authenticationState, Permission permission) {
return authenticationState.User.CheckPermission(permission);
}
public static bool CheckPermission(this ClaimsPrincipal claimsPrincipal, Permission permission) {
return claimsPrincipal.GetPermissions().Check(permission);
}
}

View File

@@ -1,40 +1,121 @@
using System.Security.Claims; using System.Security.Claims;
using Microsoft.AspNetCore.Components.Authorization; using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Components.Server; using Microsoft.AspNetCore.Components.Server;
using Phantom.Common.Data;
using Phantom.Common.Data.Web.Users;
using Phantom.Common.Messages.Web.ToController;
using Phantom.Utils.Logging;
using Phantom.Web.Services.Rpc;
using ILogger = Serilog.ILogger;
namespace Phantom.Web.Services.Authentication; namespace Phantom.Web.Services.Authentication;
public sealed class CustomAuthenticationStateProvider : ServerAuthenticationStateProvider { public sealed class CustomAuthenticationStateProvider : ServerAuthenticationStateProvider, IAsyncDisposable {
private readonly UserSessionManager sessionManager; private static readonly ILogger Logger = PhantomLogger.Create<CustomAuthenticationStateProvider>();
private readonly UserSessionBrowserStorage sessionBrowserStorage;
private bool isLoaded;
public CustomAuthenticationStateProvider(UserSessionManager sessionManager, UserSessionBrowserStorage sessionBrowserStorage) { private readonly UserSessionRefreshManager sessionRefreshManager;
this.sessionManager = sessionManager; private readonly UserSessionBrowserStorage sessionBrowserStorage;
private readonly ControllerConnection controllerConnection;
private readonly SemaphoreSlim loadSemaphore = new (1);
private bool isLoaded = false;
private CancellationTokenSource? loadCancellationTokenSource;
private UserSessionRefreshManager.EventHolder? userRefreshEventHolder;
public CustomAuthenticationStateProvider(UserSessionRefreshManager sessionRefreshManager, UserSessionBrowserStorage sessionBrowserStorage, ControllerConnection controllerConnection) {
this.sessionRefreshManager = sessionRefreshManager;
this.sessionBrowserStorage = sessionBrowserStorage; this.sessionBrowserStorage = sessionBrowserStorage;
this.controllerConnection = controllerConnection;
} }
public override async Task<AuthenticationState> GetAuthenticationStateAsync() { public override async Task<AuthenticationState> GetAuthenticationStateAsync() {
if (!isLoaded) { if (!isLoaded) {
var stored = await sessionBrowserStorage.Get(); await LoadSession();
if (stored != null) {
var session = sessionManager.FindWithToken(stored.UserGuid, stored.Token);
if (session != null) {
SetLoadedSession(session);
}
}
} }
return await base.GetAuthenticationStateAsync(); return await base.GetAuthenticationStateAsync();
} }
internal void SetLoadedSession(UserInfo user) { private async Task LoadSession() {
await CancelCurrentLoad();
await loadSemaphore.WaitAsync(CancellationToken.None);
loadCancellationTokenSource = new CancellationTokenSource();
CancellationToken cancellationToken = loadCancellationTokenSource.Token;
try {
var authenticatedUser = await TryGetSession(cancellationToken);
if (authenticatedUser != null) {
SetLoadedSession(authenticatedUser);
}
else {
SetUnloadedSession();
}
} catch (OperationCanceledException) {
SetUnloadedSession();
} catch (Exception e) {
SetUnloadedSession();
Logger.Error(e, "Could not load user session.");
} finally {
loadCancellationTokenSource.Dispose();
loadCancellationTokenSource = null;
loadSemaphore.Release();
}
}
private async Task CancelCurrentLoad() {
var cancellationTokenSource = loadCancellationTokenSource;
if (cancellationTokenSource != null) {
await cancellationTokenSource.CancelAsync();
}
}
private async Task<AuthenticatedUser?> TryGetSession(CancellationToken cancellationToken) {
var stored = await sessionBrowserStorage.Get();
if (stored == null) {
return null;
}
cancellationToken.ThrowIfCancellationRequested();
var userGuid = stored.UserGuid;
var authToken = stored.Token;
if (userRefreshEventHolder == null) {
userRefreshEventHolder = sessionRefreshManager.GetEventHolder(userGuid);
userRefreshEventHolder.UserNeedsRefresh += OnUserNeedsRefresh;
}
var session = await controllerConnection.Send<GetAuthenticatedUser, Optional<AuthenticatedUserInfo>>(new GetAuthenticatedUser(userGuid, authToken), TimeSpan.FromSeconds(30), cancellationToken);
if (session.Value is {} userInfo) {
return new AuthenticatedUser(userInfo, authToken);
}
else {
return null;
}
}
private void SetLoadedSession(AuthenticatedUser authenticatedUser) {
SetAuthenticationState(Task.FromResult(new AuthenticationState(new CustomClaimsPrincipal(authenticatedUser))));
isLoaded = true; isLoaded = true;
SetAuthenticationState(Task.FromResult(new AuthenticationState(user.AsClaimsPrincipal)));
} }
internal void SetUnloadedSession() { internal void SetUnloadedSession() {
isLoaded = false;
SetAuthenticationState(Task.FromResult(new AuthenticationState(new ClaimsPrincipal()))); SetAuthenticationState(Task.FromResult(new AuthenticationState(new ClaimsPrincipal())));
isLoaded = false;
}
private void OnUserNeedsRefresh(object? sender, EventArgs args) {
_ = LoadSession();
}
public async ValueTask DisposeAsync() {
if (userRefreshEventHolder != null) {
userRefreshEventHolder.UserNeedsRefresh -= OnUserNeedsRefresh;
userRefreshEventHolder = null;
}
await CancelCurrentLoad();
loadSemaphore.Dispose();
} }
} }

View File

@@ -0,0 +1,18 @@
using System.Security.Claims;
using Phantom.Common.Data.Web.Users;
namespace Phantom.Web.Services.Authentication;
sealed class CustomClaimsPrincipal : ClaimsPrincipal {
internal AuthenticatedUser User { get; }
internal CustomClaimsPrincipal(AuthenticatedUser user) : base(GetIdentity(user.Info)) {
User = user;
}
private static ClaimsIdentity GetIdentity(AuthenticatedUserInfo userInfo) {
var identity = new ClaimsIdentity("Phantom");
identity.AddClaim(new Claim(ClaimTypes.Name, userInfo.Name));
return identity;
}
}

View File

@@ -1,23 +0,0 @@
using System.Security.Claims;
using Phantom.Common.Data.Web.Users;
namespace Phantom.Web.Services.Authentication;
public sealed record UserInfo(Guid UserGuid, string Username, PermissionSet Permissions) {
private const string AuthenticationType = "Phantom";
internal ClaimsPrincipal AsClaimsPrincipal {
get {
var identity = new ClaimsIdentity(AuthenticationType);
identity.AddClaim(new Claim(ClaimTypes.Name, Username));
identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, UserGuid.ToString()));
return new ClaimsPrincipal(identity);
}
}
public static Guid? TryGetGuid(ClaimsPrincipal principal) {
return principal.Identity is { IsAuthenticated: true, AuthenticationType: AuthenticationType } && principal.FindFirstValue(ClaimTypes.NameIdentifier) is {} guidStr && Guid.TryParse(guidStr, out var guid) ? guid : null;
}
}

View File

@@ -1,4 +1,5 @@
using Phantom.Common.Data.Web.Users; using Phantom.Common.Data;
using Phantom.Common.Data.Web.Users;
using Phantom.Common.Messages.Web.ToController; using Phantom.Common.Messages.Web.ToController;
using Phantom.Utils.Logging; using Phantom.Utils.Logging;
using Phantom.Web.Services.Rpc; using Phantom.Web.Services.Rpc;
@@ -10,42 +11,35 @@ public sealed class UserLoginManager {
private static readonly ILogger Logger = PhantomLogger.Create<UserLoginManager>(); private static readonly ILogger Logger = PhantomLogger.Create<UserLoginManager>();
private readonly Navigation navigation; private readonly Navigation navigation;
private readonly UserSessionManager sessionManager;
private readonly UserSessionBrowserStorage sessionBrowserStorage; private readonly UserSessionBrowserStorage sessionBrowserStorage;
private readonly CustomAuthenticationStateProvider authenticationStateProvider; private readonly CustomAuthenticationStateProvider authenticationStateProvider;
private readonly ControllerConnection controllerConnection; private readonly ControllerConnection controllerConnection;
public UserLoginManager(Navigation navigation, UserSessionManager sessionManager, UserSessionBrowserStorage sessionBrowserStorage, CustomAuthenticationStateProvider authenticationStateProvider, ControllerConnection controllerConnection) { public UserLoginManager(Navigation navigation, UserSessionBrowserStorage sessionBrowserStorage, CustomAuthenticationStateProvider authenticationStateProvider, ControllerConnection controllerConnection) {
this.navigation = navigation; this.navigation = navigation;
this.sessionManager = sessionManager;
this.sessionBrowserStorage = sessionBrowserStorage; this.sessionBrowserStorage = sessionBrowserStorage;
this.authenticationStateProvider = authenticationStateProvider; this.authenticationStateProvider = authenticationStateProvider;
this.controllerConnection = controllerConnection; this.controllerConnection = controllerConnection;
} }
public async Task<bool> LogIn(string username, string password, string? returnUrl = null) { public async Task<bool> LogIn(string username, string password, string? returnUrl = null) {
LogInSuccess? success; Optional<LogInSuccess> result;
try { try {
success = await controllerConnection.Send<LogInMessage, LogInSuccess?>(new LogInMessage(username, password), TimeSpan.FromSeconds(30)); result = await controllerConnection.Send<LogInMessage, Optional<LogInSuccess>>(new LogInMessage(username, password), TimeSpan.FromSeconds(30));
} catch (Exception e) { } catch (Exception e) {
Logger.Error(e, "Could not log in {Username}.", username); Logger.Error(e, "Could not log in {Username}.", username);
return false; return false;
} }
if (success == null) { if (result.Value is not var (userInfo, authToken)) {
return false; return false;
} }
Logger.Information("Successfully logged in {Username}.", username); Logger.Information("Successfully logged in {Username}.", username);
var userGuid = success.UserGuid; authenticationStateProvider.SetUnloadedSession();
var userInfo = new UserInfo(userGuid, username, success.Permissions); await sessionBrowserStorage.Store(userInfo.Guid, authToken);
var token = success.Token; await authenticationStateProvider.GetAuthenticationStateAsync();
await sessionBrowserStorage.Store(userGuid, token);
sessionManager.Add(userInfo, token);
authenticationStateProvider.SetLoadedSession(userInfo);
await navigation.NavigateTo(returnUrl ?? string.Empty); await navigation.NavigateTo(returnUrl ?? string.Empty);
return true; return true;
@@ -53,7 +47,7 @@ public sealed class UserLoginManager {
public async Task LogOut() { public async Task LogOut() {
var stored = await sessionBrowserStorage.Delete(); var stored = await sessionBrowserStorage.Delete();
if (stored != null && sessionManager.Remove(stored.UserGuid, stored.Token)) { if (stored != null) {
await controllerConnection.Send(new LogOutMessage(stored.UserGuid, stored.Token)); await controllerConnection.Send(new LogOutMessage(stored.UserGuid, stored.Token));
} }

View File

@@ -1,35 +0,0 @@
using System.Collections.Concurrent;
using System.Collections.Immutable;
namespace Phantom.Web.Services.Authentication;
public sealed class UserSessionManager {
private readonly ConcurrentDictionary<Guid, UserSessions> userSessions = new ();
internal void Add(UserInfo user, ImmutableArray<byte> token) {
userSessions.AddOrUpdate(
user.UserGuid,
static (_, u) => new UserSessions(u),
static (_, sessions, u) => sessions.WithUserInfo(u),
user
).AddToken(token);
}
internal UserInfo? Find(Guid userGuid) {
return userSessions.TryGetValue(userGuid, out var sessions) ? sessions.UserInfo : null;
}
internal UserInfo? FindWithToken(Guid userGuid, ImmutableArray<byte> token) {
return userSessions.TryGetValue(userGuid, out var sessions) && sessions.HasToken(token) ? sessions.UserInfo : null;
}
internal bool Remove(Guid userGuid, ImmutableArray<byte> token) {
if (userSessions.TryGetValue(userGuid, out var sessions)) {
sessions.RemoveToken(token);
return true;
}
else {
return false;
}
}
}

View File

@@ -0,0 +1,25 @@
using System.Collections.Concurrent;
namespace Phantom.Web.Services.Authentication;
public sealed class UserSessionRefreshManager {
private readonly ConcurrentDictionary<Guid, EventHolder> userUpdateEventHoldersByUserGuid = new ();
internal EventHolder GetEventHolder(Guid userGuid) {
return userUpdateEventHoldersByUserGuid.GetOrAdd(userGuid, static _ => new EventHolder());
}
internal void RefreshUser(Guid userGuid) {
if (userUpdateEventHoldersByUserGuid.TryGetValue(userGuid, out var eventHolder)) {
eventHolder.Notify();
}
}
internal sealed class EventHolder {
public event EventHandler? UserNeedsRefresh;
internal void Notify() {
UserNeedsRefresh?.Invoke(null, EventArgs.Empty);
}
}
}

View File

@@ -1,54 +0,0 @@
using System.Collections.Immutable;
using System.Security.Cryptography;
namespace Phantom.Web.Services.Authentication;
sealed class UserSessions {
public UserInfo UserInfo { get; }
private readonly List<ImmutableArray<byte>> tokens = new ();
public UserSessions(UserInfo userInfo) {
UserInfo = userInfo;
}
private UserSessions(UserInfo userInfo, List<ImmutableArray<byte>> tokens) : this(userInfo) {
this.tokens.AddRange(tokens);
}
public UserSessions WithUserInfo(UserInfo user) {
List<ImmutableArray<byte>> tokensCopy;
lock (tokens) {
tokensCopy = new List<ImmutableArray<byte>>(tokens);
}
return new UserSessions(user, tokensCopy);
}
public void AddToken(ImmutableArray<byte> token) {
lock (tokens) {
if (!HasToken(token)) {
tokens.Add(token);
}
}
}
public bool HasToken(ImmutableArray<byte> token) {
return FindTokenIndex(token) != -1;
}
private int FindTokenIndex(ImmutableArray<byte> token) {
lock (tokens) {
return tokens.FindIndex(t => CryptographicOperations.FixedTimeEquals(t.AsSpan(), token.AsSpan()));
}
}
public void RemoveToken(ImmutableArray<byte> token) {
lock (tokens) {
int index = FindTokenIndex(token);
if (index != -1) {
tokens.RemoveAt(index);
}
}
}
}

Some files were not shown because too many files have changed in this diff Show More