1
0
mirror of https://github.com/chylex/Minecraft-Phantom-Panel.git synced 2026-01-14 05:50:30 +01:00

2 Commits

Author SHA1 Message Date
feb0612124 WIP 2025-12-27 13:29:00 +01:00
25babbbc29 WIP 2025-12-27 13:16:28 +01:00
35 changed files with 513 additions and 219 deletions

View File

@@ -7,12 +7,12 @@ namespace Phantom.Common.Data.Web.Agent;
[MemoryPackable(GenerateType.VersionTolerant)] [MemoryPackable(GenerateType.VersionTolerant)]
public sealed partial record Agent( public sealed partial record Agent(
[property: MemoryPackOrder(0)] Guid AgentGuid, [property: MemoryPackOrder(0)] Guid AgentGuid,
[property: MemoryPackOrder(1)] string Name, [property: MemoryPackOrder(1)] AgentConfiguration Configuration,
[property: MemoryPackOrder(2)] ImmutableArray<byte> ConnectionKey, [property: MemoryPackOrder(2)] ImmutableArray<byte> ConnectionKey,
[property: MemoryPackOrder(3)] AgentConfiguration Configuration, [property: MemoryPackOrder(3)] AgentRuntimeInfo? RuntimeInfo,
[property: MemoryPackOrder(4)] AgentStats? Stats, [property: MemoryPackOrder(4)] AgentStats? Stats,
[property: MemoryPackOrder(5)] IAgentConnectionStatus ConnectionStatus [property: MemoryPackOrder(5)] IAgentConnectionStatus ConnectionStatus
) { ) {
[MemoryPackIgnore] [MemoryPackIgnore]
public RamAllocationUnits? AvailableMemory => Configuration.MaxMemory - Stats?.RunningInstanceMemory; public RamAllocationUnits? AvailableMemory => RuntimeInfo?.MaxMemory - Stats?.RunningInstanceMemory;
} }

View File

@@ -1,18 +1,8 @@
using MemoryPack; using MemoryPack;
using Phantom.Common.Data.Agent;
namespace Phantom.Common.Data.Web.Agent; namespace Phantom.Common.Data.Web.Agent;
[MemoryPackable(GenerateType.VersionTolerant)] [MemoryPackable(GenerateType.VersionTolerant)]
public sealed partial record AgentConfiguration( public sealed partial record AgentConfiguration(
[property: MemoryPackOrder(0)] ushort ProtocolVersion, [property: MemoryPackOrder(0)] string AgentName
[property: MemoryPackOrder(1)] string BuildVersion, );
[property: MemoryPackOrder(2)] ushort MaxInstances,
[property: MemoryPackOrder(3)] RamAllocationUnits MaxMemory,
[property: MemoryPackOrder(4)] AllowedPorts? AllowedServerPorts = null,
[property: MemoryPackOrder(5)] AllowedPorts? AllowedRconPorts = null
) {
public static AgentConfiguration From(AgentInfo agentInfo) {
return new AgentConfiguration(agentInfo.ProtocolVersion, agentInfo.BuildVersion, agentInfo.MaxInstances, agentInfo.MaxMemory, agentInfo.AllowedServerPorts, agentInfo.AllowedRconPorts);
}
}

View File

@@ -0,0 +1,18 @@
using MemoryPack;
using Phantom.Common.Data.Agent;
namespace Phantom.Common.Data.Web.Agent;
[MemoryPackable(GenerateType.VersionTolerant)]
public sealed partial record AgentRuntimeInfo(
[property: MemoryPackOrder(0)] ushort ProtocolVersion,
[property: MemoryPackOrder(1)] string BuildVersion,
[property: MemoryPackOrder(2)] ushort MaxInstances,
[property: MemoryPackOrder(3)] RamAllocationUnits MaxMemory,
[property: MemoryPackOrder(4)] AllowedPorts? AllowedServerPorts = null,
[property: MemoryPackOrder(5)] AllowedPorts? AllowedRconPorts = null
) {
public static AgentRuntimeInfo From(AgentInfo agentInfo) {
return new AgentRuntimeInfo(agentInfo.ProtocolVersion, agentInfo.BuildVersion, agentInfo.MaxInstances, agentInfo.MaxMemory, agentInfo.AllowedServerPorts, agentInfo.AllowedRconPorts);
}
}

View File

@@ -0,0 +1,17 @@
namespace Phantom.Common.Data.Web.Agent;
public enum CreateOrUpdateAgentResult : byte {
UnknownError,
Success,
AgentNameMustNotBeEmpty,
}
public static class CreateOrUpdateAgentResultExtensions {
public static string ToSentence(this CreateOrUpdateAgentResult reason) {
return reason switch {
CreateOrUpdateAgentResult.Success => "Success.",
CreateOrUpdateAgentResult.AgentNameMustNotBeEmpty => "Agent name must not be empty.",
_ => "Unknown error.",
};
}
}

View File

@@ -9,6 +9,8 @@ public enum AuditLogEventType {
UserPasswordChanged, UserPasswordChanged,
UserRolesChanged, UserRolesChanged,
UserDeleted, UserDeleted,
AgentCreated,
AgentEdited,
InstanceCreated, InstanceCreated,
InstanceEdited, InstanceEdited,
InstanceLaunched, InstanceLaunched,
@@ -26,6 +28,8 @@ public static class AuditLogEventTypeExtensions {
{ AuditLogEventType.UserPasswordChanged, AuditLogSubjectType.User }, { AuditLogEventType.UserPasswordChanged, AuditLogSubjectType.User },
{ AuditLogEventType.UserRolesChanged, AuditLogSubjectType.User }, { AuditLogEventType.UserRolesChanged, AuditLogSubjectType.User },
{ AuditLogEventType.UserDeleted, AuditLogSubjectType.User }, { AuditLogEventType.UserDeleted, AuditLogSubjectType.User },
{ AuditLogEventType.AgentCreated, AuditLogSubjectType.Agent },
{ AuditLogEventType.AgentEdited, AuditLogSubjectType.Agent },
{ AuditLogEventType.InstanceCreated, AuditLogSubjectType.Instance }, { AuditLogEventType.InstanceCreated, AuditLogSubjectType.Instance },
{ AuditLogEventType.InstanceEdited, AuditLogSubjectType.Instance }, { AuditLogEventType.InstanceEdited, AuditLogSubjectType.Instance },
{ AuditLogEventType.InstanceLaunched, AuditLogSubjectType.Instance }, { AuditLogEventType.InstanceLaunched, AuditLogSubjectType.Instance },

View File

@@ -2,5 +2,6 @@
public enum AuditLogSubjectType { public enum AuditLogSubjectType {
User, User,
Agent,
Instance, Instance,
} }

View File

@@ -18,10 +18,10 @@ public static class AgentMessageRegistries {
ToAgent.Add<StopInstanceMessage, Result<StopInstanceResult, InstanceActionFailure>>(); ToAgent.Add<StopInstanceMessage, Result<StopInstanceResult, InstanceActionFailure>>();
ToAgent.Add<SendCommandToInstanceMessage, Result<SendCommandToInstanceResult, InstanceActionFailure>>(); ToAgent.Add<SendCommandToInstanceMessage, Result<SendCommandToInstanceResult, InstanceActionFailure>>();
ToController.Add<ReportInstanceStatusMessage>();
ToController.Add<InstanceOutputMessage>();
ToController.Add<ReportAgentStatusMessage>(); ToController.Add<ReportAgentStatusMessage>();
ToController.Add<ReportInstanceEventMessage>(); ToController.Add<ReportInstanceStatusMessage>();
ToController.Add<ReportInstancePlayerCountsMessage>(); ToController.Add<ReportInstancePlayerCountsMessage>();
ToController.Add<ReportInstanceEventMessage>();
ToController.Add<InstanceOutputMessage>();
} }
} }

View File

@@ -0,0 +1,15 @@
using System.Collections.Immutable;
using MemoryPack;
using Phantom.Common.Data;
using Phantom.Common.Data.Web.Agent;
using Phantom.Common.Data.Web.Users;
using Phantom.Utils.Actor;
namespace Phantom.Common.Messages.Web.ToController;
[MemoryPackable(GenerateType.VersionTolerant)]
public sealed partial record CreateOrUpdateAgentMessage(
[property: MemoryPackOrder(0)] ImmutableArray<byte> AuthToken,
[property: MemoryPackOrder(1)] Guid AgentGuid,
[property: MemoryPackOrder(2)] AgentConfiguration Configuration
) : IMessageToController, ICanReply<Result<CreateOrUpdateAgentResult, UserActionFailure>>;

View File

@@ -3,6 +3,7 @@ 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;
using Phantom.Common.Data.Web.Agent;
using Phantom.Common.Data.Web.AuditLog; using Phantom.Common.Data.Web.AuditLog;
using Phantom.Common.Data.Web.EventLog; using Phantom.Common.Data.Web.EventLog;
using Phantom.Common.Data.Web.Instance; using Phantom.Common.Data.Web.Instance;
@@ -25,17 +26,18 @@ public static class WebMessageRegistries {
ToController.Add<GetAuthenticatedUser, Optional<AuthenticatedUserInfo>>(); ToController.Add<GetAuthenticatedUser, Optional<AuthenticatedUserInfo>>();
ToController.Add<CreateOrUpdateAdministratorUserMessage, CreateOrUpdateAdministratorUserResult>(); ToController.Add<CreateOrUpdateAdministratorUserMessage, CreateOrUpdateAdministratorUserResult>();
ToController.Add<CreateUserMessage, Result<CreateUserResult, UserActionFailure>>(); ToController.Add<CreateUserMessage, Result<CreateUserResult, UserActionFailure>>();
ToController.Add<DeleteUserMessage, Result<DeleteUserResult, UserActionFailure>>();
ToController.Add<GetUsersMessage, ImmutableArray<UserInfo>>(); ToController.Add<GetUsersMessage, ImmutableArray<UserInfo>>();
ToController.Add<GetRolesMessage, ImmutableArray<RoleInfo>>(); ToController.Add<GetRolesMessage, ImmutableArray<RoleInfo>>();
ToController.Add<GetUserRolesMessage, ImmutableDictionary<Guid, ImmutableArray<Guid>>>(); ToController.Add<GetUserRolesMessage, ImmutableDictionary<Guid, ImmutableArray<Guid>>>();
ToController.Add<ChangeUserRolesMessage, Result<ChangeUserRolesResult, UserActionFailure>>(); ToController.Add<ChangeUserRolesMessage, Result<ChangeUserRolesResult, UserActionFailure>>();
ToController.Add<DeleteUserMessage, Result<DeleteUserResult, UserActionFailure>>();
ToController.Add<CreateOrUpdateAgentMessage, Result<CreateOrUpdateAgentResult, UserActionFailure>>();
ToController.Add<GetAgentJavaRuntimesMessage, ImmutableDictionary<Guid, ImmutableArray<TaggedJavaRuntime>>>();
ToController.Add<CreateOrUpdateInstanceMessage, Result<CreateOrUpdateInstanceResult, UserInstanceActionFailure>>(); ToController.Add<CreateOrUpdateInstanceMessage, Result<CreateOrUpdateInstanceResult, UserInstanceActionFailure>>();
ToController.Add<LaunchInstanceMessage, Result<LaunchInstanceResult, UserInstanceActionFailure>>(); ToController.Add<LaunchInstanceMessage, Result<LaunchInstanceResult, UserInstanceActionFailure>>();
ToController.Add<StopInstanceMessage, Result<StopInstanceResult, UserInstanceActionFailure>>(); ToController.Add<StopInstanceMessage, Result<StopInstanceResult, UserInstanceActionFailure>>();
ToController.Add<SendCommandToInstanceMessage, Result<SendCommandToInstanceResult, UserInstanceActionFailure>>(); ToController.Add<SendCommandToInstanceMessage, Result<SendCommandToInstanceResult, UserInstanceActionFailure>>();
ToController.Add<GetMinecraftVersionsMessage, ImmutableArray<MinecraftVersion>>(); ToController.Add<GetMinecraftVersionsMessage, ImmutableArray<MinecraftVersion>>();
ToController.Add<GetAgentJavaRuntimesMessage, ImmutableDictionary<Guid, ImmutableArray<TaggedJavaRuntime>>>();
ToController.Add<GetAuditLogMessage, Result<ImmutableArray<AuditLogItem>, UserActionFailure>>(); ToController.Add<GetAuditLogMessage, Result<ImmutableArray<AuditLogItem>, UserActionFailure>>();
ToController.Add<GetEventLogMessage, Result<ImmutableArray<EventLogItem>, UserActionFailure>>(); ToController.Add<GetEventLogMessage, Result<ImmutableArray<EventLogItem>, UserActionFailure>>();

View File

@@ -61,6 +61,14 @@ sealed partial class AuditLogRepository {
}); });
} }
public void AgentCreated(Guid agentGuid) {
AddItem(AuditLogEventType.AgentCreated, agentGuid.ToString());
}
public void AgentEdited(Guid agentGuid) {
AddItem(AuditLogEventType.AgentEdited, agentGuid.ToString());
}
public void InstanceCreated(Guid instanceGuid) { public void InstanceCreated(Guid instanceGuid) {
AddItem(AuditLogEventType.InstanceCreated, instanceGuid.ToString()); AddItem(AuditLogEventType.InstanceCreated, instanceGuid.ToString());
} }

View File

@@ -35,9 +35,9 @@ sealed class AgentActor : ReceiveActor<AgentActor.ICommand>, IWithTimers {
public readonly record struct Init( public readonly record struct Init(
Guid AgentGuid, Guid AgentGuid,
string AgentName,
AuthSecret AuthSecret,
AgentConfiguration AgentConfiguration, AgentConfiguration AgentConfiguration,
AuthSecret AuthSecret,
AgentRuntimeInfo AgentRuntimeInfo,
AgentConnectionKeys AgentConnectionKeys, AgentConnectionKeys AgentConnectionKeys,
ControllerState ControllerState, ControllerState ControllerState,
MinecraftVersions MinecraftVersions, MinecraftVersions MinecraftVersions,
@@ -58,13 +58,15 @@ sealed class AgentActor : ReceiveActor<AgentActor.ICommand>, IWithTimers {
private readonly CancellationToken cancellationToken; private readonly CancellationToken cancellationToken;
private readonly Guid agentGuid; private readonly Guid agentGuid;
private readonly string agentName;
private readonly AuthInfo authInfo; private readonly AuthInfo authInfo;
private AgentConfiguration configuration; private AgentConfiguration configuration;
private AgentRuntimeInfo runtimeInfo;
private AgentStats? stats; private AgentStats? stats;
private ImmutableArray<TaggedJavaRuntime> javaRuntimes = ImmutableArray<TaggedJavaRuntime>.Empty; private ImmutableArray<TaggedJavaRuntime> javaRuntimes = ImmutableArray<TaggedJavaRuntime>.Empty;
private string AgentName => configuration.AgentName;
private readonly AgentConnection connection; private readonly AgentConnection connection;
private DateTimeOffset? lastPingTime; private DateTimeOffset? lastPingTime;
@@ -97,17 +99,18 @@ sealed class AgentActor : ReceiveActor<AgentActor.ICommand>, IWithTimers {
this.cancellationToken = init.CancellationToken; this.cancellationToken = init.CancellationToken;
this.agentGuid = init.AgentGuid; this.agentGuid = init.AgentGuid;
this.agentName = init.AgentName;
this.authInfo = new AuthInfo(this, init.AuthSecret); this.authInfo = new AuthInfo(this, init.AuthSecret);
this.configuration = init.AgentConfiguration; this.configuration = init.AgentConfiguration;
this.connection = new AgentConnection(agentGuid, agentName); this.runtimeInfo = init.AgentRuntimeInfo;
this.connection = new AgentConnection(agentGuid, configuration.AgentName);
this.databaseStorageActor = Context.ActorOf(AgentDatabaseStorageActor.Factory(new AgentDatabaseStorageActor.Init(agentGuid, init.DbProvider, init.CancellationToken)), "DatabaseStorage"); this.databaseStorageActor = Context.ActorOf(AgentDatabaseStorageActor.Factory(new AgentDatabaseStorageActor.Init(agentGuid, init.DbProvider, init.CancellationToken)), "DatabaseStorage");
NotifyAgentUpdated(); NotifyAgentUpdated();
ReceiveAsync<InitializeCommand>(Initialize); ReceiveAsync<InitializeCommand>(Initialize);
Receive<ConfigureAgentCommand>(ConfigureAgent);
ReceiveAsyncAndReply<RegisterCommand, ImmutableArray<ConfigureInstanceMessage>>(Register); ReceiveAsyncAndReply<RegisterCommand, ImmutableArray<ConfigureInstanceMessage>>(Register);
Receive<SetConnectionCommand>(SetConnection); Receive<SetConnectionCommand>(SetConnection);
Receive<UnregisterCommand>(Unregister); Receive<UnregisterCommand>(Unregister);
@@ -125,7 +128,7 @@ sealed class AgentActor : ReceiveActor<AgentActor.ICommand>, IWithTimers {
} }
private void NotifyAgentUpdated() { private void NotifyAgentUpdated() {
controllerState.UpdateAgent(new Agent(agentGuid, agentName, authInfo.ConnectionKey, configuration, stats, ConnectionStatus)); controllerState.UpdateAgent(new Agent(agentGuid, configuration, authInfo.ConnectionKey, runtimeInfo, stats, ConnectionStatus));
} }
protected override void PreStart() { protected override void PreStart() {
@@ -193,7 +196,9 @@ sealed class AgentActor : ReceiveActor<AgentActor.ICommand>, IWithTimers {
private sealed record InitializeCommand : ICommand; private sealed record InitializeCommand : ICommand;
public sealed record RegisterCommand(AgentConfiguration Configuration, ImmutableArray<TaggedJavaRuntime> JavaRuntimes) : ICommand, ICanReply<ImmutableArray<ConfigureInstanceMessage>>; public sealed record ConfigureAgentCommand(Guid LoggedInUserGuid, AgentConfiguration Configuration) : ICommand;
public sealed record RegisterCommand(AgentRuntimeInfo RuntimeInfo, ImmutableArray<TaggedJavaRuntime> JavaRuntimes) : ICommand, ICanReply<ImmutableArray<ConfigureInstanceMessage>>;
public sealed record SetConnectionCommand(RpcServerToClientConnection<IMessageToController, IMessageToAgent> Connection) : ICommand; public sealed record SetConnectionCommand(RpcServerToClientConnection<IMessageToController, IMessageToAgent> Connection) : ICommand;
@@ -248,19 +253,24 @@ sealed class AgentActor : ReceiveActor<AgentActor.ICommand>, IWithTimers {
} }
} }
private void ConfigureAgent(ConfigureAgentCommand message) {
configuration = message.Configuration;
NotifyAgentUpdated();
databaseStorageActor.Tell(new AgentDatabaseStorageActor.StoreAgentConfigurationCommand(message.LoggedInUserGuid, configuration));
}
private async Task<ImmutableArray<ConfigureInstanceMessage>> Register(RegisterCommand command) { private async Task<ImmutableArray<ConfigureInstanceMessage>> Register(RegisterCommand command) {
var configurationMessages = await PrepareInitialConfigurationMessages(); var configurationMessages = await PrepareInitialConfigurationMessages();
configuration = command.Configuration; runtimeInfo = command.RuntimeInfo;
connection.SetAgentName(agentName);
lastPingTime = DateTimeOffset.Now; lastPingTime = DateTimeOffset.Now;
isOnline = true; isOnline = true;
NotifyAgentUpdated(); NotifyAgentUpdated();
Logger.Information("Registered agent \"{Name}\" (GUID {Guid}).", agentName, agentGuid); Logger.Information("Registered agent \"{AgentName}\" (GUID {AgentGuid}).", AgentName, agentGuid);
databaseStorageActor.Tell(new AgentDatabaseStorageActor.StoreAgentConfigurationCommand(agentName, authInfo.Secret, configuration)); databaseStorageActor.Tell(new AgentDatabaseStorageActor.StoreAgentDataCommand(configuration, authInfo.Secret, runtimeInfo));
javaRuntimes = command.JavaRuntimes; javaRuntimes = command.JavaRuntimes;
controllerState.UpdateAgentJavaRuntimes(agentGuid, javaRuntimes); controllerState.UpdateAgentJavaRuntimes(agentGuid, javaRuntimes);
@@ -282,7 +292,7 @@ sealed class AgentActor : ReceiveActor<AgentActor.ICommand>, IWithTimers {
TellAllInstances(new InstanceActor.SetStatusCommand(InstanceStatus.Offline)); TellAllInstances(new InstanceActor.SetStatusCommand(InstanceStatus.Offline));
Logger.Information("Unregistered agent \"{Name}\" (GUID {Guid}).", agentName, agentGuid); Logger.Information("Unregistered agent \"{AgentName}\" (GUID {AgentGuid}).", AgentName, agentGuid);
} }
private AuthSecret GetAuthSecret(GetAuthSecretCommand command) { private AuthSecret GetAuthSecret(GetAuthSecretCommand command) {
@@ -294,7 +304,7 @@ sealed class AgentActor : ReceiveActor<AgentActor.ICommand>, IWithTimers {
isOnline = false; isOnline = false;
NotifyAgentUpdated(); NotifyAgentUpdated();
Logger.Warning("Lost connection to agent \"{Name}\" (GUID {Guid}).", agentName, agentGuid); Logger.Warning("Lost connection to agent \"{AgentName}\" (GUID {AgentGuid}).", AgentName, agentGuid);
} }
} }
@@ -305,7 +315,7 @@ sealed class AgentActor : ReceiveActor<AgentActor.ICommand>, IWithTimers {
isOnline = true; isOnline = true;
NotifyAgentUpdated(); NotifyAgentUpdated();
Logger.Warning("Restored connection to agent \"{Name}\" (GUID {Guid}).", agentName, agentGuid); Logger.Warning("Restored connection to agent \"{AgentName}\" (GUID {AgentGuid}).", AgentName, agentGuid);
} }
} }
@@ -357,7 +367,7 @@ sealed class AgentActor : ReceiveActor<AgentActor.ICommand>, IWithTimers {
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, agentName); Logger.Information(action + " instance \"{InstanceName}\" (GUID {InstanceGuid}) " + relation + " \"{AgentName}\".", instanceName, instanceGuid, AgentName);
return CreateOrUpdateInstanceResult.Success; return CreateOrUpdateInstanceResult.Success;
} }
@@ -366,7 +376,7 @@ sealed class AgentActor : ReceiveActor<AgentActor.ICommand>, IWithTimers {
string relation = isCreating ? "to agent" : "in agent"; string relation = isCreating ? "to agent" : "in agent";
string reason = result.Into(ConfigureInstanceResultExtensions.ToSentence, InstanceActionFailureExtensions.ToSentence); string reason = result.Into(ConfigureInstanceResultExtensions.ToSentence, InstanceActionFailureExtensions.ToSentence);
Logger.Information("Failed " + action + " instance \"{InstanceName}\" (GUID {InstanceGuid}) " + relation + " \"{AgentName}\". {ErrorMessage}", instanceName, instanceGuid, agentName, reason); Logger.Information("Failed " + action + " instance \"{InstanceName}\" (GUID {InstanceGuid}) " + relation + " \"{AgentName}\". {ErrorMessage}", instanceName, instanceGuid, AgentName, reason);
return CreateOrUpdateInstanceResult.UnknownError; return CreateOrUpdateInstanceResult.UnknownError;
} }

View File

@@ -1,6 +1,8 @@
using Akka.Actor; using Akka.Actor;
using Phantom.Common.Data.Web.Agent; using Phantom.Common.Data.Web.Agent;
using Phantom.Controller.Database; using Phantom.Controller.Database;
using Phantom.Controller.Database.Entities;
using Phantom.Controller.Database.Repositories;
using Phantom.Utils.Actor; using Phantom.Utils.Actor;
using Phantom.Utils.Logging; using Phantom.Utils.Logging;
using Phantom.Utils.Rpc; using Phantom.Utils.Rpc;
@@ -23,7 +25,7 @@ sealed class AgentDatabaseStorageActor : ReceiveActor<AgentDatabaseStorageActor.
private readonly IDbContextProvider dbProvider; private readonly IDbContextProvider dbProvider;
private readonly CancellationToken cancellationToken; private readonly CancellationToken cancellationToken;
private StoreAgentConfigurationCommand? storeCommand; private StoreAgentDataCommand? storeCommand;
private bool hasScheduledFlush; private bool hasScheduledFlush;
private AgentDatabaseStorageActor(Init init) { private AgentDatabaseStorageActor(Init init) {
@@ -31,22 +33,40 @@ sealed class AgentDatabaseStorageActor : ReceiveActor<AgentDatabaseStorageActor.
this.dbProvider = init.DbProvider; this.dbProvider = init.DbProvider;
this.cancellationToken = init.CancellationToken; this.cancellationToken = init.CancellationToken;
Receive<StoreAgentConfigurationCommand>(StoreAgentConfiguration); Receive<StoreAgentDataCommand>(StoreAgentData);
ReceiveAsync<FlushChangesCommand>(FlushChanges); ReceiveAsync<FlushAgentDataCommand>(FlushAgentData);
ReceiveAsync<StoreAgentConfigurationCommand>(StoreAgentConfiguration);
}
private ValueTask<AgentEntity?> FindAgentEntity(ILazyDbContext db) {
return db.Ctx.Agents.FindAsync([agentGuid], cancellationToken);
} }
public interface ICommand; public interface ICommand;
public sealed record StoreAgentConfigurationCommand(string Name, AuthSecret AuthSecret, AgentConfiguration Configuration) : ICommand; public sealed record StoreAgentDataCommand(AgentConfiguration Configuration, AuthSecret AuthSecret, AgentRuntimeInfo RuntimeInfo) : ICommand;
private sealed record FlushChangesCommand : ICommand; private sealed record FlushAgentDataCommand : ICommand;
private void StoreAgentConfiguration(StoreAgentConfigurationCommand command) { public sealed record StoreAgentConfigurationCommand(Guid AuditLogUserGuid, AgentConfiguration Configuration) : ICommand;
private void StoreAgentData(StoreAgentDataCommand command) {
storeCommand = command; storeCommand = command;
ScheduleFlush(TimeSpan.FromSeconds(2)); ScheduleFlush(TimeSpan.FromSeconds(2));
} }
private async Task FlushChanges(FlushChangesCommand command) { private void ScheduleFlush(TimeSpan delay) {
if (!hasScheduledFlush) {
hasScheduledFlush = true;
Timers.StartSingleTimer("FlushChanges", new FlushAgentDataCommand(), delay, Self);
}
}
private Task FlushAgentData(FlushAgentDataCommand command) {
return FlushAgentData();
}
private async Task FlushAgentData() {
hasScheduledFlush = false; hasScheduledFlush = false;
if (storeCommand == null) { if (storeCommand == null) {
@@ -57,29 +77,38 @@ sealed class AgentDatabaseStorageActor : ReceiveActor<AgentDatabaseStorageActor.
await using var ctx = dbProvider.Eager(); await using var ctx = dbProvider.Eager();
var entity = ctx.AgentUpsert.Fetch(agentGuid); var entity = ctx.AgentUpsert.Fetch(agentGuid);
entity.Name = storeCommand.Name; entity.Name = storeCommand.Configuration.AgentName;
entity.ProtocolVersion = storeCommand.RuntimeInfo.ProtocolVersion;
entity.BuildVersion = storeCommand.RuntimeInfo.BuildVersion;
entity.MaxInstances = storeCommand.RuntimeInfo.MaxInstances;
entity.MaxMemory = storeCommand.RuntimeInfo.MaxMemory;
entity.AuthSecret = storeCommand.AuthSecret; entity.AuthSecret = storeCommand.AuthSecret;
entity.ProtocolVersion = storeCommand.Configuration.ProtocolVersion;
entity.BuildVersion = storeCommand.Configuration.BuildVersion;
entity.MaxInstances = storeCommand.Configuration.MaxInstances;
entity.MaxMemory = storeCommand.Configuration.MaxMemory;
await ctx.SaveChangesAsync(cancellationToken); await ctx.SaveChangesAsync(cancellationToken);
} catch (Exception e) { } catch (Exception e) {
ScheduleFlush(TimeSpan.FromSeconds(10)); ScheduleFlush(TimeSpan.FromSeconds(10));
Logger.Error(e, "Could not store agent \"{AgentName}\" (GUID {AgentGuid}) in database.", storeCommand.Name, agentGuid); Logger.Error(e, "Could not store agent \"{AgentName}\" (GUID {AgentGuid}) in database.", storeCommand.Configuration.AgentName, agentGuid);
return; return;
} }
Logger.Information("Stored agent \"{AgentName}\" (GUID {AgentGuid}) in database.", storeCommand.Name, agentGuid); Logger.Information("Stored agent \"{AgentName}\" (GUID {AgentGuid}) in database.", storeCommand.Configuration.AgentName, agentGuid);
storeCommand = null; storeCommand = null;
} }
private void ScheduleFlush(TimeSpan delay) { private async Task StoreAgentConfiguration(StoreAgentConfigurationCommand command) {
if (!hasScheduledFlush) { await FlushAgentData();
hasScheduledFlush = true;
Timers.StartSingleTimer("FlushChanges", new FlushChangesCommand(), delay, Self); await using var db = dbProvider.Lazy();
var entity = await FindAgentEntity(db);
if (entity != null) {
entity.Name = command.Configuration.AgentName;
} }
var auditLogWriter = new AuditLogRepository(db).Writer(command.AuditLogUserGuid);
auditLogWriter.AgentEdited(agentGuid);
await db.Ctx.SaveChangesAsync(cancellationToken);
} }
} }

View File

@@ -24,7 +24,6 @@ sealed class AgentManager(
AgentConnectionKeys agentConnectionKeys, AgentConnectionKeys agentConnectionKeys,
ControllerState controllerState, ControllerState controllerState,
MinecraftVersions minecraftVersions, MinecraftVersions minecraftVersions,
UserLoginManager userLoginManager,
IDbContextProvider dbProvider, IDbContextProvider dbProvider,
CancellationToken cancellationToken CancellationToken cancellationToken
) { ) {
@@ -32,41 +31,47 @@ sealed class AgentManager(
private readonly ConcurrentDictionary<Guid, ActorRef<AgentActor.ICommand>> agentsByAgentGuid = new (); private readonly ConcurrentDictionary<Guid, ActorRef<AgentActor.ICommand>> agentsByAgentGuid = new ();
private ActorRef<AgentActor.ICommand> CreateAgentActor(Guid agentGuid, string agentName, AuthSecret authSecret, AgentConfiguration agentConfiguration) {
var init = new AgentActor.Init(agentGuid, agentName, authSecret, agentConfiguration, agentConnectionKeys, controllerState, minecraftVersions, dbProvider, cancellationToken);
var name = "Agent:" + agentGuid;
return actorSystem.ActorOf(AgentActor.Factory(init), name);
}
public async Task Initialize() { public async Task Initialize() {
await using var ctx = dbProvider.Eager(); await using var ctx = dbProvider.Eager();
await Migrate(ctx);
List<AgentEntity> agentsWithoutSecrets = await ctx.Agents.Where(static entity => entity.AuthSecret == null).ToListAsync(cancellationToken);
if (agentsWithoutSecrets.Count > 0) {
foreach (var entity in agentsWithoutSecrets) {
entity.AuthSecret = AuthSecret.Generate();
}
await ctx.SaveChangesAsync(cancellationToken);
}
await foreach (var entity in ctx.Agents.AsAsyncEnumerable().WithCancellation(cancellationToken)) { await foreach (var entity in ctx.Agents.AsAsyncEnumerable().WithCancellation(cancellationToken)) {
var agentGuid = entity.AgentGuid; var agentGuid = entity.AgentGuid;
var agentConfiguration = new AgentConfiguration(entity.ProtocolVersion, entity.BuildVersion, entity.MaxInstances, entity.MaxMemory); var configuration = new AgentConfiguration(entity.Name);
var runtimeInfo = new AgentRuntimeInfo(entity.ProtocolVersion, entity.BuildVersion, entity.MaxInstances, entity.MaxMemory);
if (agentsByAgentGuid.TryAdd(agentGuid, CreateAgentActor(agentGuid, entity.Name, entity.AuthSecret!, agentConfiguration))) { if (AddAgent(agentGuid, configuration, entity.AuthSecret!, runtimeInfo)) {
Logger.Information("Loaded agent \"{AgentName}\" (GUID {AgentGuid}) from database.", entity.Name, agentGuid); Logger.Information("Loaded agent \"{AgentName}\" (GUID {AgentGuid}) from database.", entity.Name, agentGuid);
} }
} }
} }
private bool AddAgent(Guid agentGuid, AgentConfiguration configuration, AuthSecret authSecret, AgentRuntimeInfo runtimeInfo) {
var init = new AgentActor.Init(agentGuid, configuration, authSecret, runtimeInfo, agentConnectionKeys, controllerState, minecraftVersions, dbProvider, cancellationToken);
var name = "Agent:" + agentGuid;
return agentsByAgentGuid.TryAdd(agentGuid, actorSystem.ActorOf(AgentActor.Factory(init), name));
}
private async Task Migrate(ApplicationDbContext ctx) {
List<AgentEntity> agentsWithoutSecrets = await ctx.Agents.Where(static entity => entity.AuthSecret == null).ToListAsync(cancellationToken);
if (agentsWithoutSecrets.Count == 0) {
return;
}
foreach (var entity in agentsWithoutSecrets) {
entity.AuthSecret = AuthSecret.Generate();
}
await ctx.SaveChangesAsync(cancellationToken);
}
public async Task<ImmutableArray<ConfigureInstanceMessage>?> RegisterAgent(Guid agentGuid, AgentRegistration registration) { public async Task<ImmutableArray<ConfigureInstanceMessage>?> RegisterAgent(Guid agentGuid, AgentRegistration registration) {
if (!agentsByAgentGuid.TryGetValue(agentGuid, out var agentActor)) { if (!agentsByAgentGuid.TryGetValue(agentGuid, out var agentActor)) {
return null; return null;
} }
var agentConfiguration = AgentConfiguration.From(registration.AgentInfo); var runtimeInfo = AgentRuntimeInfo.From(registration.AgentInfo);
return await agentActor.Request(new AgentActor.RegisterCommand(agentConfiguration, registration.JavaRuntimes), cancellationToken); return await agentActor.Request(new AgentActor.RegisterCommand(runtimeInfo, registration.JavaRuntimes), cancellationToken);
} }
public async Task<AuthSecret?> GetAgentAuthSecret(Guid agentGuid) { public async Task<AuthSecret?> GetAgentAuthSecret(Guid agentGuid) {
@@ -84,7 +89,7 @@ sealed class AgentManager(
return true; return true;
} }
else { else {
Logger.Warning("Could not deliver command {CommandType} to agent {AgentGuid}, agent not registered.", command.GetType().Name, agentGuid); Logger.Warning("Could not deliver command {CommandType} to non-existent agent {AgentGuid}.", command.GetType().Name, agentGuid);
return false; return false;
} }
} }

View File

@@ -58,7 +58,7 @@ public sealed class ControllerServices : IDisposable {
this.UserLoginManager = new UserLoginManager(AuthenticatedUserCache, dbProvider); this.UserLoginManager = new UserLoginManager(AuthenticatedUserCache, dbProvider);
this.PermissionManager = new PermissionManager(dbProvider); this.PermissionManager = new PermissionManager(dbProvider);
this.AgentManager = new AgentManager(ActorSystem, new AgentConnectionKeys(agentCertificateThumbprint), ControllerState, MinecraftVersions, UserLoginManager, dbProvider, cancellationToken); this.AgentManager = new AgentManager(ActorSystem, new AgentConnectionKeys(agentCertificateThumbprint), ControllerState, MinecraftVersions, dbProvider, cancellationToken);
this.InstanceLogManager = new InstanceLogManager(); this.InstanceLogManager = new InstanceLogManager();
this.AuditLogManager = new AuditLogManager(dbProvider); this.AuditLogManager = new AuditLogManager(dbProvider);

View File

@@ -1,5 +1,4 @@
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Diagnostics.CodeAnalysis;
using Akka.Actor; using Akka.Actor;
using Phantom.Common.Messages.Agent; using Phantom.Common.Messages.Agent;
using Phantom.Controller.Services.Agents; using Phantom.Controller.Services.Agents;
@@ -11,19 +10,29 @@ using Phantom.Utils.Rpc.Runtime.Server;
namespace Phantom.Controller.Services.Rpc; namespace Phantom.Controller.Services.Rpc;
sealed class AgentClientRegistrar( sealed class AgentClientRegistrar : IRpcServerClientRegistrar<IMessageToController, IMessageToAgent> {
IActorRefFactory actorSystem, private readonly IActorRefFactory actorSystem;
AgentManager agentManager, private readonly AgentManager agentManager;
InstanceLogManager instanceLogManager, private readonly InstanceLogManager instanceLogManager;
EventLogManager eventLogManager private readonly EventLogManager eventLogManager;
) : IRpcServerClientRegistrar<IMessageToController, IMessageToAgent> {
private readonly Func<Guid, Guid, Receiver> receiverFactory;
private readonly ConcurrentDictionary<Guid, Receiver> receiversBySessionGuid = new (); private readonly ConcurrentDictionary<Guid, Receiver> receiversBySessionGuid = new ();
[SuppressMessage("ReSharper", "LambdaShouldNotCaptureContext")] public AgentClientRegistrar(IActorRefFactory actorSystem, AgentManager agentManager, InstanceLogManager instanceLogManager, EventLogManager eventLogManager) {
public IMessageReceiver<IMessageToController> Register(RpcServerToClientConnection<IMessageToController, IMessageToAgent> connection, Guid agentGuid) { this.actorSystem = actorSystem;
this.agentManager = agentManager;
this.instanceLogManager = instanceLogManager;
this.eventLogManager = eventLogManager;
this.receiverFactory = CreateReceiver;
}
public IMessageReceiver<IMessageToController> Register(RpcServerToClientConnection<IMessageToController, IMessageToAgent> connection) {
Guid agentGuid = connection.ClientGuid;
agentManager.TellAgent(agentGuid, new AgentActor.SetConnectionCommand(connection)); agentManager.TellAgent(agentGuid, new AgentActor.SetConnectionCommand(connection));
var receiver = receiversBySessionGuid.GetOrAdd(connection.SessionGuid, CreateReceiver, agentGuid); var receiver = receiversBySessionGuid.GetOrAdd(connection.SessionGuid, receiverFactory, agentGuid);
if (receiver.AgentGuid != agentGuid) { if (receiver.AgentGuid != agentGuid) {
throw new InvalidOperationException("Cannot register two agents to the same session!"); throw new InvalidOperationException("Cannot register two agents to the same session!");
} }

View File

@@ -25,30 +25,30 @@ sealed class AgentMessageHandlerActor : ReceiveActor<IMessageToController> {
this.instanceLogManager = init.InstanceLogManager; this.instanceLogManager = init.InstanceLogManager;
this.eventLogManager = init.EventLogManager; this.eventLogManager = init.EventLogManager;
Receive<ReportAgentStatusMessage>(HandleReportAgentStatus); Receive<ReportAgentStatusMessage>(ReportAgentStatus);
Receive<ReportInstanceStatusMessage>(HandleReportInstanceStatus); Receive<ReportInstanceStatusMessage>(ReportInstanceStatus);
Receive<ReportInstancePlayerCountsMessage>(HandleReportInstancePlayerCounts); Receive<ReportInstancePlayerCountsMessage>(ReportInstancePlayerCounts);
Receive<ReportInstanceEventMessage>(HandleReportInstanceEvent); Receive<ReportInstanceEventMessage>(ReportInstanceEvent);
Receive<InstanceOutputMessage>(HandleInstanceOutput); Receive<InstanceOutputMessage>(InstanceOutput);
} }
private void HandleReportAgentStatus(ReportAgentStatusMessage message) { private void ReportAgentStatus(ReportAgentStatusMessage message) {
agentManager.TellAgent(agentGuid, new AgentActor.UpdateStatsCommand(message.RunningInstanceCount, message.RunningInstanceMemory)); agentManager.TellAgent(agentGuid, new AgentActor.UpdateStatsCommand(message.RunningInstanceCount, message.RunningInstanceMemory));
} }
private void HandleReportInstanceStatus(ReportInstanceStatusMessage message) { private void ReportInstanceStatus(ReportInstanceStatusMessage message) {
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) { private void ReportInstancePlayerCounts(ReportInstancePlayerCountsMessage message) {
agentManager.TellAgent(agentGuid, new AgentActor.UpdateInstancePlayerCountsCommand(message.InstanceGuid, message.PlayerCounts)); agentManager.TellAgent(agentGuid, new AgentActor.UpdateInstancePlayerCountsCommand(message.InstanceGuid, message.PlayerCounts));
} }
private void HandleReportInstanceEvent(ReportInstanceEventMessage message) { private void ReportInstanceEvent(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));
} }
private void HandleInstanceOutput(InstanceOutputMessage message) { private void InstanceOutput(InstanceOutputMessage message) {
instanceLogManager.ReceiveLines(message.InstanceGuid, message.Lines); instanceLogManager.ReceiveLines(message.InstanceGuid, message.Lines);
} }
} }

View File

@@ -25,7 +25,7 @@ sealed class WebClientRegistrar(
MinecraftVersions minecraftVersions, MinecraftVersions minecraftVersions,
EventLogManager eventLogManager EventLogManager eventLogManager
) : IRpcServerClientRegistrar<IMessageToController, IMessageToWeb> { ) : IRpcServerClientRegistrar<IMessageToController, IMessageToWeb> {
public IMessageReceiver<IMessageToController> Register(RpcServerToClientConnection<IMessageToController, IMessageToWeb> connection, Guid clientGuid) { public IMessageReceiver<IMessageToController> Register(RpcServerToClientConnection<IMessageToController, IMessageToWeb> connection) {
var name = "WebClient-" + connection.SessionGuid; var name = "WebClient-" + connection.SessionGuid;
var init = new WebMessageHandlerActor.Init(connection, controllerState, instanceLogManager, userManager, roleManager, userRoleManager, userLoginManager, auditLogManager, agentManager, minecraftVersions, eventLogManager); var init = new WebMessageHandlerActor.Init(connection, controllerState, instanceLogManager, userManager, roleManager, userRoleManager, userLoginManager, auditLogManager, agentManager, minecraftVersions, eventLogManager);
return new IMessageReceiver<IMessageToController>.Actor(actorSystem.ActorOf(WebMessageHandlerActor.Factory(init), name)); return new IMessageReceiver<IMessageToController>.Actor(actorSystem.ActorOf(WebMessageHandlerActor.Factory(init), name));

View File

@@ -3,6 +3,7 @@ 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;
using Phantom.Common.Data.Web.Agent;
using Phantom.Common.Data.Web.AuditLog; using Phantom.Common.Data.Web.AuditLog;
using Phantom.Common.Data.Web.EventLog; using Phantom.Common.Data.Web.EventLog;
using Phantom.Common.Data.Web.Instance; using Phantom.Common.Data.Web.Instance;
@@ -63,31 +64,32 @@ sealed class WebMessageHandlerActor : ReceiveActor<IMessageToController> {
var senderActorInit = new WebMessageDataUpdateSenderActor.Init(init.Connection.MessageSender, controllerState, init.InstanceLogManager); var senderActorInit = new WebMessageDataUpdateSenderActor.Init(init.Connection.MessageSender, controllerState, init.InstanceLogManager);
Context.ActorOf(WebMessageDataUpdateSenderActor.Factory(senderActorInit), "DataUpdateSender"); Context.ActorOf(WebMessageDataUpdateSenderActor.Factory(senderActorInit), "DataUpdateSender");
ReceiveAndReplyLater<LogInMessage, Optional<LogInSuccess>>(HandleLogIn); ReceiveAndReplyLater<LogInMessage, Optional<LogInSuccess>>(LogIn);
Receive<LogOutMessage>(HandleLogOut); Receive<LogOutMessage>(LogOut);
ReceiveAndReply<GetAuthenticatedUser, Optional<AuthenticatedUserInfo>>(GetAuthenticatedUser); ReceiveAndReply<GetAuthenticatedUser, Optional<AuthenticatedUserInfo>>(GetAuthenticatedUser);
ReceiveAndReplyLater<CreateOrUpdateAdministratorUserMessage, CreateOrUpdateAdministratorUserResult>(HandleCreateOrUpdateAdministratorUser); ReceiveAndReplyLater<CreateOrUpdateAdministratorUserMessage, CreateOrUpdateAdministratorUserResult>(CreateOrUpdateAdministratorUser);
ReceiveAndReplyLater<CreateUserMessage, Result<CreateUserResult, UserActionFailure>>(HandleCreateUser); ReceiveAndReplyLater<CreateUserMessage, Result<CreateUserResult, UserActionFailure>>(CreateUser);
ReceiveAndReplyLater<GetUsersMessage, ImmutableArray<UserInfo>>(HandleGetUsers); ReceiveAndReplyLater<GetUsersMessage, ImmutableArray<UserInfo>>(GetUsers);
ReceiveAndReplyLater<GetRolesMessage, ImmutableArray<RoleInfo>>(HandleGetRoles); ReceiveAndReplyLater<GetRolesMessage, ImmutableArray<RoleInfo>>(GetRoles);
ReceiveAndReplyLater<GetUserRolesMessage, ImmutableDictionary<Guid, ImmutableArray<Guid>>>(HandleGetUserRoles); ReceiveAndReplyLater<GetUserRolesMessage, ImmutableDictionary<Guid, ImmutableArray<Guid>>>(GetUserRoles);
ReceiveAndReplyLater<ChangeUserRolesMessage, Result<ChangeUserRolesResult, UserActionFailure>>(HandleChangeUserRoles); ReceiveAndReplyLater<ChangeUserRolesMessage, Result<ChangeUserRolesResult, UserActionFailure>>(ChangeUserRoles);
ReceiveAndReplyLater<DeleteUserMessage, Result<DeleteUserResult, UserActionFailure>>(HandleDeleteUser); ReceiveAndReplyLater<DeleteUserMessage, Result<DeleteUserResult, UserActionFailure>>(DeleteUser);
ReceiveAndReplyLater<CreateOrUpdateInstanceMessage, Result<CreateOrUpdateInstanceResult, UserInstanceActionFailure>>(HandleCreateOrUpdateInstance); ReceiveAndReplyLater<CreateOrUpdateAgentMessage, Result<CreateOrUpdateAgentResult, UserActionFailure>>(CreateOrUpdateAgentMessage);
ReceiveAndReplyLater<LaunchInstanceMessage, Result<LaunchInstanceResult, UserInstanceActionFailure>>(HandleLaunchInstance); ReceiveAndReply<GetAgentJavaRuntimesMessage, ImmutableDictionary<Guid, ImmutableArray<TaggedJavaRuntime>>>(GetAgentJavaRuntimes);
ReceiveAndReplyLater<StopInstanceMessage, Result<StopInstanceResult, UserInstanceActionFailure>>(HandleStopInstance); ReceiveAndReplyLater<CreateOrUpdateInstanceMessage, Result<CreateOrUpdateInstanceResult, UserInstanceActionFailure>>(CreateOrUpdateInstance);
ReceiveAndReplyLater<SendCommandToInstanceMessage, Result<SendCommandToInstanceResult, UserInstanceActionFailure>>(HandleSendCommandToInstance); ReceiveAndReplyLater<LaunchInstanceMessage, Result<LaunchInstanceResult, UserInstanceActionFailure>>(LaunchInstance);
ReceiveAndReplyLater<GetMinecraftVersionsMessage, ImmutableArray<MinecraftVersion>>(HandleGetMinecraftVersions); ReceiveAndReplyLater<StopInstanceMessage, Result<StopInstanceResult, UserInstanceActionFailure>>(StopInstance);
ReceiveAndReply<GetAgentJavaRuntimesMessage, ImmutableDictionary<Guid, ImmutableArray<TaggedJavaRuntime>>>(HandleGetAgentJavaRuntimes); ReceiveAndReplyLater<SendCommandToInstanceMessage, Result<SendCommandToInstanceResult, UserInstanceActionFailure>>(SendCommandToInstance);
ReceiveAndReplyLater<GetAuditLogMessage, Result<ImmutableArray<AuditLogItem>, UserActionFailure>>(HandleGetAuditLog); ReceiveAndReplyLater<GetMinecraftVersionsMessage, ImmutableArray<MinecraftVersion>>(GetMinecraftVersions);
ReceiveAndReplyLater<GetEventLogMessage, Result<ImmutableArray<EventLogItem>, UserActionFailure>>(HandleGetEventLog); ReceiveAndReplyLater<GetAuditLogMessage, Result<ImmutableArray<AuditLogItem>, UserActionFailure>>(GetAuditLog);
ReceiveAndReplyLater<GetEventLogMessage, Result<ImmutableArray<EventLogItem>, UserActionFailure>>(GetEventLog);
} }
private Task<Optional<LogInSuccess>> HandleLogIn(LogInMessage message) { private Task<Optional<LogInSuccess>> LogIn(LogInMessage message) {
return userLoginManager.LogIn(message.Username, message.Password); return userLoginManager.LogIn(message.Username, message.Password);
} }
private void HandleLogOut(LogOutMessage message) { private void LogOut(LogOutMessage message) {
_ = userLoginManager.LogOut(message.UserGuid, message.SessionToken); _ = userLoginManager.LogOut(message.UserGuid, message.SessionToken);
} }
@@ -95,83 +97,87 @@ sealed class WebMessageHandlerActor : ReceiveActor<IMessageToController> {
return userLoginManager.GetAuthenticatedUser(message.UserGuid, message.AuthToken); return userLoginManager.GetAuthenticatedUser(message.UserGuid, message.AuthToken);
} }
private Task<CreateOrUpdateAdministratorUserResult> HandleCreateOrUpdateAdministratorUser(CreateOrUpdateAdministratorUserMessage message) { private Task<CreateOrUpdateAdministratorUserResult> CreateOrUpdateAdministratorUser(CreateOrUpdateAdministratorUserMessage message) {
return userManager.CreateOrUpdateAdministrator(message.Username, message.Password); return userManager.CreateOrUpdateAdministrator(message.Username, message.Password);
} }
private Task<Result<CreateUserResult, UserActionFailure>> HandleCreateUser(CreateUserMessage message) { private Task<Result<CreateUserResult, UserActionFailure>> CreateUser(CreateUserMessage message) {
return userManager.Create(userLoginManager.GetLoggedInUser(message.AuthToken), 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>> GetUsers(GetUsersMessage message) {
return userManager.GetAll(); return userManager.GetAll();
} }
private Task<ImmutableArray<RoleInfo>> HandleGetRoles(GetRolesMessage message) { private Task<ImmutableArray<RoleInfo>> GetRoles(GetRolesMessage message) {
return roleManager.GetAll(); return roleManager.GetAll();
} }
private Task<ImmutableDictionary<Guid, ImmutableArray<Guid>>> HandleGetUserRoles(GetUserRolesMessage message) { private Task<ImmutableDictionary<Guid, ImmutableArray<Guid>>> GetUserRoles(GetUserRolesMessage message) {
return userRoleManager.GetUserRoles(message.UserGuids); return userRoleManager.GetUserRoles(message.UserGuids);
} }
private Task<Result<ChangeUserRolesResult, UserActionFailure>> HandleChangeUserRoles(ChangeUserRolesMessage message) { private Task<Result<ChangeUserRolesResult, UserActionFailure>> ChangeUserRoles(ChangeUserRolesMessage message) {
return userRoleManager.ChangeUserRoles(userLoginManager.GetLoggedInUser(message.AuthToken), message.SubjectUserGuid, message.AddToRoleGuids, message.RemoveFromRoleGuids); return userRoleManager.ChangeUserRoles(userLoginManager.GetLoggedInUser(message.AuthToken), message.SubjectUserGuid, message.AddToRoleGuids, message.RemoveFromRoleGuids);
} }
private Task<Result<DeleteUserResult, UserActionFailure>> HandleDeleteUser(DeleteUserMessage message) { private Task<Result<DeleteUserResult, UserActionFailure>> DeleteUser(DeleteUserMessage message) {
return userManager.DeleteByGuid(userLoginManager.GetLoggedInUser(message.AuthToken), message.SubjectUserGuid); return userManager.DeleteByGuid(userLoginManager.GetLoggedInUser(message.AuthToken), message.SubjectUserGuid);
} }
private Task<Result<CreateOrUpdateInstanceResult, UserInstanceActionFailure>> HandleCreateOrUpdateInstance(CreateOrUpdateInstanceMessage message) { private Task<Result<CreateOrUpdateAgentResult, UserActionFailure>> CreateOrUpdateAgentMessage(CreateOrUpdateAgentMessage message) {
return agentManager.CreateOrUpdateAgent(userLoginManager.GetLoggedInUser(message.AuthToken), message.AgentGuid, message.Configuration);
}
private ImmutableDictionary<Guid, ImmutableArray<TaggedJavaRuntime>> GetAgentJavaRuntimes(GetAgentJavaRuntimesMessage message) {
return controllerState.AgentJavaRuntimesByGuid;
}
private Task<Result<CreateOrUpdateInstanceResult, UserInstanceActionFailure>> CreateOrUpdateInstance(CreateOrUpdateInstanceMessage message) {
return agentManager.DoInstanceAction<AgentActor.CreateOrUpdateInstanceCommand, CreateOrUpdateInstanceResult>( return agentManager.DoInstanceAction<AgentActor.CreateOrUpdateInstanceCommand, CreateOrUpdateInstanceResult>(
userLoginManager.GetLoggedInUser(message.AuthToken),
Permission.CreateInstances, Permission.CreateInstances,
message.AuthToken,
message.Configuration.AgentGuid, message.Configuration.AgentGuid,
loggedInUserGuid => new AgentActor.CreateOrUpdateInstanceCommand(loggedInUserGuid, message.InstanceGuid, message.Configuration) loggedInUserGuid => new AgentActor.CreateOrUpdateInstanceCommand(loggedInUserGuid, message.InstanceGuid, message.Configuration)
); );
} }
private Task<Result<LaunchInstanceResult, UserInstanceActionFailure>> HandleLaunchInstance(LaunchInstanceMessage message) { private Task<Result<LaunchInstanceResult, UserInstanceActionFailure>> LaunchInstance(LaunchInstanceMessage message) {
return agentManager.DoInstanceAction<AgentActor.LaunchInstanceCommand, LaunchInstanceResult>( return agentManager.DoInstanceAction<AgentActor.LaunchInstanceCommand, LaunchInstanceResult>(
userLoginManager.GetLoggedInUser(message.AuthToken),
Permission.ControlInstances, Permission.ControlInstances,
message.AuthToken,
message.AgentGuid, message.AgentGuid,
loggedInUserGuid => new AgentActor.LaunchInstanceCommand(loggedInUserGuid, message.InstanceGuid) loggedInUserGuid => new AgentActor.LaunchInstanceCommand(loggedInUserGuid, message.InstanceGuid)
); );
} }
private Task<Result<StopInstanceResult, UserInstanceActionFailure>> HandleStopInstance(StopInstanceMessage message) { private Task<Result<StopInstanceResult, UserInstanceActionFailure>> StopInstance(StopInstanceMessage message) {
return agentManager.DoInstanceAction<AgentActor.StopInstanceCommand, StopInstanceResult>( return agentManager.DoInstanceAction<AgentActor.StopInstanceCommand, StopInstanceResult>(
userLoginManager.GetLoggedInUser(message.AuthToken),
Permission.ControlInstances, Permission.ControlInstances,
message.AuthToken,
message.AgentGuid, message.AgentGuid,
loggedInUserGuid => new AgentActor.StopInstanceCommand(loggedInUserGuid, message.InstanceGuid, message.StopStrategy) loggedInUserGuid => new AgentActor.StopInstanceCommand(loggedInUserGuid, message.InstanceGuid, message.StopStrategy)
); );
} }
private Task<Result<SendCommandToInstanceResult, UserInstanceActionFailure>> HandleSendCommandToInstance(SendCommandToInstanceMessage message) { private Task<Result<SendCommandToInstanceResult, UserInstanceActionFailure>> SendCommandToInstance(SendCommandToInstanceMessage message) {
return agentManager.DoInstanceAction<AgentActor.SendCommandToInstanceCommand, SendCommandToInstanceResult>( return agentManager.DoInstanceAction<AgentActor.SendCommandToInstanceCommand, SendCommandToInstanceResult>(
userLoginManager.GetLoggedInUser(message.AuthToken),
Permission.ControlInstances, Permission.ControlInstances,
message.AuthToken,
message.AgentGuid, message.AgentGuid,
loggedInUserGuid => new AgentActor.SendCommandToInstanceCommand(loggedInUserGuid, message.InstanceGuid, message.Command) loggedInUserGuid => new AgentActor.SendCommandToInstanceCommand(loggedInUserGuid, message.InstanceGuid, message.Command)
); );
} }
private Task<ImmutableArray<MinecraftVersion>> HandleGetMinecraftVersions(GetMinecraftVersionsMessage message) { private Task<ImmutableArray<MinecraftVersion>> GetMinecraftVersions(GetMinecraftVersionsMessage message) {
return minecraftVersions.GetVersions(CancellationToken.None); return minecraftVersions.GetVersions(CancellationToken.None);
} }
private ImmutableDictionary<Guid, ImmutableArray<TaggedJavaRuntime>> HandleGetAgentJavaRuntimes(GetAgentJavaRuntimesMessage message) { private Task<Result<ImmutableArray<AuditLogItem>, UserActionFailure>> GetAuditLog(GetAuditLogMessage message) {
return controllerState.AgentJavaRuntimesByGuid;
}
private Task<Result<ImmutableArray<AuditLogItem>, UserActionFailure>> HandleGetAuditLog(GetAuditLogMessage message) {
return auditLogManager.GetMostRecentItems(userLoginManager.GetLoggedInUser(message.AuthToken), message.Count); return auditLogManager.GetMostRecentItems(userLoginManager.GetLoggedInUser(message.AuthToken), message.Count);
} }
private Task<Result<ImmutableArray<EventLogItem>, UserActionFailure>> HandleGetEventLog(GetEventLogMessage message) { private Task<Result<ImmutableArray<EventLogItem>, UserActionFailure>> GetEventLog(GetEventLogMessage message) {
return eventLogManager.GetMostRecentItems(userLoginManager.GetLoggedInUser(message.AuthToken), message.Count); return eventLogManager.GetMostRecentItems(userLoginManager.GetLoggedInUser(message.AuthToken), message.Count);
} }
} }

View File

@@ -142,13 +142,16 @@ public sealed class MessageSender<TMessageBase> {
messageReplyTracker.FailReply(frame.ReplyingToMessageId, MessageErrorException.From(frame.Error)); messageReplyTracker.FailReply(frame.ReplyingToMessageId, MessageErrorException.From(frame.Error));
} }
internal async Task Close() { internal async Task Close(TimeSpan timeout) {
messageQueue.Writer.TryComplete(); messageQueue.Writer.TryComplete();
try { try {
await messageQueueTask.WaitAsync(TimeSpan.FromSeconds(15)); await messageQueueTask.WaitAsync(timeout);
} catch (TimeoutException) { } catch (TimeoutException) {
logger.Warning("Could not finish processing message queue before timeout, forcibly shutting it down."); if (timeout != TimeSpan.Zero) {
logger.Warning("Could not finish processing message queue before timeout, forcibly shutting it down.");
}
await shutdownCancellationTokenSource.CancelAsync(); await shutdownCancellationTokenSource.CancelAsync();
await messageQueueTask.ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing); await messageQueueTask.ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing);
} catch (Exception) { } catch (Exception) {

View File

@@ -133,7 +133,7 @@ public sealed class RpcClient<TClientToServerMessage, TServerToClientMessage> :
} }
} finally { } finally {
if (sessionState.HasValue) { if (sessionState.HasValue) {
await sessionState.Value.TryShutdown(logger, sendSessionTermination: cancellationToken.IsCancellationRequested); await ShutdownSessionState(sessionState.Value);
} }
if (connection != null) { if (connection != null) {
@@ -157,6 +157,15 @@ public sealed class RpcClient<TClientToServerMessage, TServerToClientMessage> :
return new SessionState(frameSender, frameReader); return new SessionState(frameSender, frameReader);
} }
private async Task ShutdownSessionState(SessionState sessionState) {
if (connector.IsEnabled) {
await sessionState.TryShutdown(logger, sendSessionTermination: shutdownCancellationTokenSource.IsCancellationRequested);
}
else {
await sessionState.TryShutdownNow(logger);
}
}
private readonly record struct SessionState(RpcFrameSender<TClientToServerMessage> FrameSender, RpcFrameReader<TClientToServerMessage, TServerToClientMessage> FrameReader) { private readonly record struct SessionState(RpcFrameSender<TClientToServerMessage> FrameSender, RpcFrameReader<TClientToServerMessage, TServerToClientMessage> FrameReader) {
public void Update(ILogger logger, RpcClientToServerConnector<TClientToServerMessage, TServerToClientMessage>.Connection connection) { public void Update(ILogger logger, RpcClientToServerConnector<TClientToServerMessage, TServerToClientMessage>.Connection connection) {
TimeSpan currentPingInterval = FrameSender.PingInterval; TimeSpan currentPingInterval = FrameSender.PingInterval;
@@ -186,7 +195,7 @@ public sealed class RpcClient<TClientToServerMessage, TServerToClientMessage> :
logger.Information("Shutting down client..."); logger.Information("Shutting down client...");
try { try {
await MessageSender.Close(); await MessageSender.Close(connector.IsEnabled ? TimeSpan.FromSeconds(15) : TimeSpan.Zero);
} catch (Exception e) { } catch (Exception e) {
logger.Error(e, "Caught exception while closing message sender."); logger.Error(e, "Caught exception while closing message sender.");
} }

View File

@@ -28,6 +28,8 @@ sealed class RpcClientToServerConnector<TClientToServerMessage, TServerToClientM
private bool wasRejectedDueToClosedSession = false; private bool wasRejectedDueToClosedSession = false;
private bool loggedCertificateValidationError = false; private bool loggedCertificateValidationError = false;
internal bool IsEnabled => !wasRejectedDueToClosedSession;
public RpcClientToServerConnector(string loggerName, RpcClientConnectionParameters parameters, MessageRegistries<TClientToServerMessage, TServerToClientMessage> messageRegistries) { public RpcClientToServerConnector(string loggerName, RpcClientConnectionParameters parameters, MessageRegistries<TClientToServerMessage, TServerToClientMessage> messageRegistries) {
this.logger = PhantomLogger.Create<RpcClientToServerConnector<TClientToServerMessage, TServerToClientMessage>>(loggerName); this.logger = PhantomLogger.Create<RpcClientToServerConnector<TClientToServerMessage, TServerToClientMessage>>(loggerName);
this.parameters = parameters; this.parameters = parameters;
@@ -143,6 +145,30 @@ sealed class RpcClientToServerConnector<TClientToServerMessage, TServerToClientM
return null; return null;
} }
private bool ValidateServerCertificate(object sender, X509Certificate? certificate, X509Chain? chain, SslPolicyErrors sslPolicyErrors) {
if (certificate == null || sslPolicyErrors.HasFlag(SslPolicyErrors.RemoteCertificateNotAvailable)) {
logger.Error("Could not establish a secure connection, server did not provide a certificate.");
}
else if (sslPolicyErrors.HasFlag(SslPolicyErrors.RemoteCertificateNameMismatch)) {
logger.Error("Could not establish a secure connection, server certificate has the wrong name: {Name}", certificate.Subject);
}
else if (!parameters.CertificateThumbprint.Check(certificate)) {
logger.Error("Could not establish a secure connection, server certificate does not match.");
}
else if (TlsSupport.CheckAlgorithm((X509Certificate2) certificate) is {} error) {
logger.Error("Could not establish a secure connection, server certificate rejected because it uses {ActualAlgorithmName} instead of {ExpectedAlgorithmName}.", error.ActualAlgorithmName, error.ExpectedAlgorithmName);
}
else if ((sslPolicyErrors & ~SslPolicyErrors.RemoteCertificateChainErrors) != SslPolicyErrors.None) {
logger.Error("Could not establish a secure connection, server certificate validation failed.");
}
else {
return true;
}
loggedCertificateValidationError = true;
return false;
}
private async Task<ConnectionResult?> AuthenticateAndPerformHandshake(RpcStream stream, CancellationToken cancellationToken) { private async Task<ConnectionResult?> AuthenticateAndPerformHandshake(RpcStream stream, CancellationToken cancellationToken) {
try { try {
loggedCertificateValidationError = false; loggedCertificateValidationError = false;
@@ -224,7 +250,7 @@ sealed class RpcClientToServerConnector<TClientToServerMessage, TServerToClientM
return new ConnectionResult(finalHandshakeResult == RpcFinalHandshakeResult.NewSession, pingInterval.Value, mappedMessageDefinitions); return new ConnectionResult(finalHandshakeResult == RpcFinalHandshakeResult.NewSession, pingInterval.Value, mappedMessageDefinitions);
default: default:
logger.Error("Server rejected client due to unknown error."); logger.Error("Server rejected client handshake with unknown error code: {ErrorCode}", finalHandshakeResult);
return null; return null;
} }
} }
@@ -263,30 +289,6 @@ sealed class RpcClientToServerConnector<TClientToServerMessage, TServerToClientM
return result.TypeMapping; return result.TypeMapping;
} }
private bool ValidateServerCertificate(object sender, X509Certificate? certificate, X509Chain? chain, SslPolicyErrors sslPolicyErrors) {
if (certificate == null || sslPolicyErrors.HasFlag(SslPolicyErrors.RemoteCertificateNotAvailable)) {
logger.Error("Could not establish a secure connection, server did not provide a certificate.");
}
else if (sslPolicyErrors.HasFlag(SslPolicyErrors.RemoteCertificateNameMismatch)) {
logger.Error("Could not establish a secure connection, server certificate has the wrong name: {Name}", certificate.Subject);
}
else if (!parameters.CertificateThumbprint.Check(certificate)) {
logger.Error("Could not establish a secure connection, server certificate does not match.");
}
else if (TlsSupport.CheckAlgorithm((X509Certificate2) certificate) is {} error) {
logger.Error("Could not establish a secure connection, server certificate rejected because it uses {ActualAlgorithmName} instead of {ExpectedAlgorithmName}.", error.ActualAlgorithmName, error.ExpectedAlgorithmName);
}
else if ((sslPolicyErrors & ~SslPolicyErrors.RemoteCertificateChainErrors) != SslPolicyErrors.None) {
logger.Error("Could not establish a secure connection, server certificate validation failed.");
}
else {
return true;
}
loggedCertificateValidationError = true;
return false;
}
private static async Task DisconnectSocket(Socket socket, RpcStream? stream) { private static async Task DisconnectSocket(Socket socket, RpcStream? stream) {
if (stream != null) { if (stream != null) {
await stream.DisposeAsync(); await stream.DisposeAsync();

View File

@@ -3,5 +3,5 @@
namespace Phantom.Utils.Rpc.Runtime.Server; namespace Phantom.Utils.Rpc.Runtime.Server;
public interface IRpcServerClientRegistrar<TClientToServerMessage, TServerToClientMessage> { public interface IRpcServerClientRegistrar<TClientToServerMessage, TServerToClientMessage> {
IMessageReceiver<TClientToServerMessage> Register(RpcServerToClientConnection<TClientToServerMessage, TServerToClientMessage> connection, Guid clientGuid); IMessageReceiver<TClientToServerMessage> Register(RpcServerToClientConnection<TClientToServerMessage, TServerToClientMessage> connection);
} }

View File

@@ -317,7 +317,7 @@ public sealed class RpcServer<TClientToServerMessage, TServerToClientMessage> {
try { try {
var connection = new RpcServerToClientConnection<TClientToServerMessage, TServerToClientMessage>(sharedData.ConnectionParameters, sharedData.MessageDefinitions.ToServer.Mapping, session, stream); var connection = new RpcServerToClientConnection<TClientToServerMessage, TServerToClientMessage>(sharedData.ConnectionParameters, sharedData.MessageDefinitions.ToServer.Mapping, session, stream);
var messageReceiver = sharedData.ClientRegistrar.Register(connection, clientGuid); var messageReceiver = sharedData.ClientRegistrar.Register(connection);
return new EstablishedConnection(session, connection, messageReceiver); return new EstablishedConnection(session, connection, messageReceiver);
} catch (Exception e) { } catch (Exception e) {

View File

@@ -108,7 +108,7 @@ sealed class RpcServerClientSession<TServerToClientMessage> : IRpcConnectionProv
} }
try { try {
await MessageSender.Close(); await MessageSender.Close(TimeSpan.FromSeconds(15));
} catch (Exception e) { } catch (Exception e) {
logger.Error(e, "Caught exception while closing message sender."); logger.Error(e, "Caught exception while closing message sender.");
} }

View File

@@ -12,6 +12,7 @@ public sealed class RpcServerToClientConnection<TClientToServerMessage, TServerT
private readonly RpcServerClientSession<TServerToClientMessage> session; private readonly RpcServerClientSession<TServerToClientMessage> session;
private readonly RpcStream stream; private readonly RpcStream stream;
public Guid ClientGuid => session.ClientGuid;
public Guid SessionGuid => session.SessionGuid; public Guid SessionGuid => session.SessionGuid;
public MessageSender<TServerToClientMessage> MessageSender => session.MessageSender; public MessageSender<TServerToClientMessage> MessageSender => session.MessageSender;

View File

@@ -1,31 +1,56 @@
using System.Collections.Immutable; using System.Collections.Immutable;
using Phantom.Common.Data;
using Phantom.Common.Data.Web.Agent; using Phantom.Common.Data.Web.Agent;
using Phantom.Common.Data.Web.Users;
using Phantom.Common.Messages.Web.ToController;
using Phantom.Utils.Events; using Phantom.Utils.Events;
using Phantom.Utils.Logging; using Phantom.Utils.Logging;
using Phantom.Web.Services.Authentication; using Phantom.Web.Services.Authentication;
using Phantom.Web.Services.Rpc;
namespace Phantom.Web.Services.Agents; namespace Phantom.Web.Services.Agents;
public sealed class AgentManager { using AgentDictionary = ImmutableDictionary<Guid, Agent>;
private readonly SimpleObservableState<ImmutableArray<Agent>> agents = new (PhantomLogger.Create<AgentManager>("Agents"), ImmutableArray<Agent>.Empty);
public EventSubscribers<ImmutableArray<Agent>> AgentsChanged => agents.Subs; public sealed class AgentManager(ControllerConnection controllerConnection) {
private readonly SimpleObservableState<AgentDictionary> agents = new (PhantomLogger.Create<AgentManager>("Agents"), AgentDictionary.Empty);
public EventSubscribers<AgentDictionary> AgentsChanged => agents.Subs;
internal void RefreshAgents(ImmutableArray<Agent> newAgents) { internal void RefreshAgents(ImmutableArray<Agent> newAgents) {
agents.SetTo(newAgents); agents.SetTo(newAgents.ToImmutableDictionary(static agent => agent.AgentGuid));
} }
public ImmutableArray<Agent> GetAll() { public AgentDictionary GetAll() {
return agents.Value; return agents.Value;
} }
public ImmutableDictionary<Guid, Agent> ToDictionaryByGuid(AuthenticatedUser? authenticatedUser) { public Agent? GetByGuid(AuthenticatedUser? authenticatedUser, Guid agentGuid) {
if (authenticatedUser == null) { if (authenticatedUser == null) {
return ImmutableDictionary<Guid, Agent>.Empty; return null;
}
var agent = agents.Value.GetValueOrDefault(agentGuid);
return agent != null && authenticatedUser.Info.HasAccessToAgent(agent.AgentGuid) ? agent : null;
}
public AgentDictionary ToDictionaryByGuid(AuthenticatedUser? authenticatedUser) {
if (authenticatedUser == null) {
return AgentDictionary.Empty;
} }
return agents.Value return agents.Value
.Where(agent => authenticatedUser.Info.HasAccessToAgent(agent.AgentGuid)) .Where(kvp => authenticatedUser.Info.HasAccessToAgent(kvp.Key))
.ToImmutableDictionary(static agent => agent.AgentGuid); .ToImmutableDictionary();
}
public async Task<Result<CreateOrUpdateAgentResult, UserActionFailure>> CreateOrUpdateAgent(AuthenticatedUser? authenticatedUser, Guid agentGuid, AgentConfiguration configuration, CancellationToken cancellationToken) {
if (authenticatedUser != null && authenticatedUser.Info.CheckPermission(Permission.ManageAllAgents)) {
var message = new CreateOrUpdateAgentMessage(authenticatedUser.Token, agentGuid, configuration);
return await controllerConnection.Send<CreateOrUpdateAgentMessage, Result<CreateOrUpdateAgentResult, UserActionFailure>>(message, cancellationToken);
}
else {
return UserActionFailure.NotAuthorized;
}
} }
} }

View File

@@ -15,14 +15,9 @@ namespace Phantom.Web.Services.Instances;
using InstanceDictionary = ImmutableDictionary<Guid, Instance>; using InstanceDictionary = ImmutableDictionary<Guid, Instance>;
public sealed class InstanceManager { public sealed class InstanceManager(ControllerConnection controllerConnection) {
private readonly ControllerConnection controllerConnection;
private readonly SimpleObservableState<InstanceDictionary> instances = new (PhantomLogger.Create<InstanceManager>("Instances"), InstanceDictionary.Empty); private readonly SimpleObservableState<InstanceDictionary> instances = new (PhantomLogger.Create<InstanceManager>("Instances"), InstanceDictionary.Empty);
public InstanceManager(ControllerConnection controllerConnection) {
this.controllerConnection = controllerConnection;
}
public EventSubscribers<InstanceDictionary> InstancesChanged => instances.Subs; public EventSubscribers<InstanceDictionary> InstancesChanged => instances.Subs;
internal void RefreshInstances(ImmutableArray<Instance> newInstances) { internal void RefreshInstances(ImmutableArray<Instance> newInstances) {

View File

@@ -0,0 +1,6 @@
@page "/agents/create"
@using Phantom.Common.Data.Web.Users
@attribute [Authorize(Permission.ManageAllAgentsPolicy)]
<h1>New Agent</h1>
<AgentAddOrEditForm EditedAgent="null" />

View File

@@ -0,0 +1,37 @@
@page "/agents/{AgentGuid:guid}/edit"
@attribute [Authorize(Permission.ManageAllAgentsPolicy)]
@using Phantom.Common.Data.Web.Agent
@using Phantom.Common.Data.Web.Users
@using Phantom.Web.Services.Agents
@inherits PhantomComponent
@inject AgentManager AgentManager
@if (isLoading) {
<h1>Edit Agent</h1>
<p>Loading...</p>
return;
}
@if (Agent == null) {
<h1>Agent Not Found</h1>
<p>Return to <a href="agents">all agents</a>.</p>
return;
}
<h1>Edit Agent: @Agent.Configuration.AgentName</h1>
<AgentAddOrEditForm EditedAgent="Agent" />
@code {
[Parameter]
public Guid AgentGuid { get; init; }
private Agent? Agent { get; set; }
private bool isLoading = true;
protected override async Task OnInitializedAsync() {
Agent = AgentManager.GetByGuid(await GetAuthenticatedUser(), AgentGuid);
isLoading = false;
}
}

View File

@@ -1,14 +1,20 @@
@page "/agents" @page "/agents"
@using System.Collections.Immutable @using System.Collections.Immutable
@using Phantom.Common.Data.Web.Agent @using Phantom.Common.Data.Web.Agent
@using Phantom.Common.Data.Web.Users
@using Phantom.Utils.Collections @using Phantom.Utils.Collections
@using Phantom.Utils.Cryptography @using Phantom.Utils.Cryptography
@using Phantom.Web.Services.Agents @using Phantom.Web.Services.Agents
@using Phantom.Web.Services.Authorization
@inherits PhantomComponent @inherits PhantomComponent
@inject AgentManager AgentManager @inject AgentManager AgentManager
<h1>Agents</h1> <h1>Agents</h1>
<PermissionView Permission="Permission.ManageAllAgents">
<a href="agents/create" class="btn btn-primary" role="button">New Agent</a>
</PermissionView>
<Table Items="agentTable"> <Table Items="agentTable">
<HeaderRow> <HeaderRow>
<Column Width="50%">Name</Column> <Column Width="50%">Name</Column>
@@ -22,28 +28,43 @@
<ItemRow Context="agent"> <ItemRow Context="agent">
@{ @{
var connectionKey = TokenGenerator.EncodeBytes(agent.ConnectionKey.AsSpan()); var connectionKey = TokenGenerator.EncodeBytes(agent.ConnectionKey.AsSpan());
var configuration = agent.Configuration; var runtimeInfo = agent.RuntimeInfo;
var usedInstances = agent.Stats?.RunningInstanceCount; var usedInstances = agent.Stats?.RunningInstanceCount;
var usedMemory = agent.Stats?.RunningInstanceMemory.InMegabytes; var usedMemory = agent.Stats?.RunningInstanceMemory.InMegabytes;
} }
<Cell> <Cell>
<p class="fw-semibold">@agent.Name</p> <p class="fw-semibold">@agent.Configuration.AgentName</p>
<small class="font-monospace text-uppercase">@agent.AgentGuid.ToString()</small> <small class="font-monospace text-uppercase">@agent.AgentGuid.ToString()</small>
</Cell> </Cell>
<Cell class="text-end"> <Cell class="text-end">
<ProgressBar Value="@(usedInstances ?? 0)" Maximum="@configuration.MaxInstances"> @if (runtimeInfo == null) {
@(usedInstances?.ToString() ?? "?") / @configuration.MaxInstances.ToString() <text>N/A</text>
</ProgressBar> }
else {
<ProgressBar Value="@(usedInstances ?? 0)" Maximum="@runtimeInfo.MaxInstances">
@(usedInstances?.ToString() ?? "?") / @runtimeInfo.MaxInstances.ToString()
</ProgressBar>
}
</Cell> </Cell>
<Cell class="text-end"> <Cell class="text-end">
<ProgressBar Value="@(usedMemory ?? 0)" Maximum="@configuration.MaxMemory.InMegabytes"> @if (runtimeInfo == null) {
@(usedMemory?.ToString() ?? "?") / @configuration.MaxMemory.InMegabytes.ToString() MB <text>N/A</text>
</ProgressBar> }
else {
<ProgressBar Value="@(usedMemory ?? 0)" Maximum="@runtimeInfo.MaxMemory.InMegabytes">
@(usedMemory?.ToString() ?? "?") / @runtimeInfo.MaxMemory.InMegabytes.ToString() MB
</ProgressBar>
}
</Cell> </Cell>
<Cell class="text-condensed"> <Cell class="text-condensed">
Build: <span class="font-monospace">@configuration.BuildVersion</span> @if (runtimeInfo == null) {
<br> <text>N/A</text>
Protocol: <span class="font-monospace">v@(configuration.ProtocolVersion.ToString())</span> }
else {
<text>Build: <span class="font-monospace">@runtimeInfo.BuildVersion</span></text>
<br>
<text>Protocol: <span class="font-monospace">v@(runtimeInfo.ProtocolVersion.ToString())</span></text>
}
</Cell> </Cell>
@switch (agent.ConnectionStatus) { @switch (agent.ConnectionStatus) {
case AgentIsOnline: case AgentIsOnline:
@@ -87,8 +108,9 @@
} }
AgentManager.AgentsChanged.Subscribe(this, agents => { AgentManager.AgentsChanged.Subscribe(this, agents => {
var sortedAgents = agents.Where(agent => authenticatedUser.Info.HasAccessToAgent(agent.AgentGuid)) var sortedAgents = agents.Values
.OrderBy(static agent => agent.Name) .Where(agent => authenticatedUser.Info.HasAccessToAgent(agent.AgentGuid))
.OrderBy(static agent => agent.Configuration.AgentName)
.ToImmutableArray(); .ToImmutableArray();
agentTable ??= new TableData<Agent, Guid>(); agentTable ??= new TableData<Agent, Guid>();

View File

@@ -3,9 +3,11 @@
@using System.Collections.Immutable @using System.Collections.Immutable
@using Phantom.Common.Data.Web.AuditLog @using Phantom.Common.Data.Web.AuditLog
@using Phantom.Common.Data.Web.Users @using Phantom.Common.Data.Web.Users
@using Phantom.Web.Services.Agents
@using Phantom.Web.Services.Instances @using Phantom.Web.Services.Instances
@using Phantom.Web.Services.Users @using Phantom.Web.Services.Users
@inherits PhantomComponent @inherits PhantomComponent
@inject AgentManager AgentManager
@inject AuditLogManager AuditLogManager @inject AuditLogManager AuditLogManager
@inject InstanceManager InstanceManager @inject InstanceManager InstanceManager
@inject UserManager UserManager @inject UserManager UserManager
@@ -55,6 +57,7 @@
private string? loadError; private string? loadError;
private ImmutableDictionary<Guid, string>? userNamesByGuid; private ImmutableDictionary<Guid, string>? userNamesByGuid;
private ImmutableDictionary<Guid, string> agentNamesByGuid = ImmutableDictionary<Guid, string>.Empty;
private ImmutableDictionary<Guid, string> instanceNamesByGuid = ImmutableDictionary<Guid, string>.Empty; private ImmutableDictionary<Guid, string> instanceNamesByGuid = ImmutableDictionary<Guid, string>.Empty;
protected override async Task OnInitializedAsync() { protected override async Task OnInitializedAsync() {
@@ -62,6 +65,7 @@
if (result) { if (result) {
logItems = result.Value; logItems = result.Value;
userNamesByGuid = (await UserManager.GetAll(CancellationToken)).ToImmutableDictionary(static user => user.Guid, static user => user.Name); userNamesByGuid = (await UserManager.GetAll(CancellationToken)).ToImmutableDictionary(static user => user.Guid, static user => user.Name);
agentNamesByGuid = AgentManager.GetAll().Values.ToImmutableDictionary(static agent => agent.AgentGuid, static agent => agent.Configuration.AgentName);
instanceNamesByGuid = InstanceManager.GetAll().Values.ToImmutableDictionary(static instance => instance.InstanceGuid, static instance => instance.Configuration.InstanceName); instanceNamesByGuid = InstanceManager.GetAll().Values.ToImmutableDictionary(static instance => instance.InstanceGuid, static instance => instance.Configuration.InstanceName);
} }
else { else {
@@ -75,8 +79,9 @@
private string? GetSubjectName(AuditLogSubjectType type, string id) { private string? GetSubjectName(AuditLogSubjectType type, string id) {
return type switch { return type switch {
AuditLogSubjectType.Instance => instanceNamesByGuid.TryGetValue(Guid.Parse(id), out var name) ? name : null,
AuditLogSubjectType.User => userNamesByGuid != null && userNamesByGuid.TryGetValue(Guid.Parse(id), out var name) ? name : null, AuditLogSubjectType.User => userNamesByGuid != null && userNamesByGuid.TryGetValue(Guid.Parse(id), out var name) ? name : null,
AuditLogSubjectType.Agent => agentNamesByGuid.TryGetValue(Guid.Parse(id), out var name) ? name : null,
AuditLogSubjectType.Instance => instanceNamesByGuid.TryGetValue(Guid.Parse(id), out var name) ? name : null,
_ => null, _ => null,
}; };
} }

View File

@@ -65,7 +65,7 @@
var result = await EventLogManager.GetMostRecentItems(await GetAuthenticatedUser(), count: 50, CancellationToken); var result = await EventLogManager.GetMostRecentItems(await GetAuthenticatedUser(), count: 50, CancellationToken);
if (result) { if (result) {
logItems = result.Value; logItems = result.Value;
agentNamesByGuid = AgentManager.GetAll().ToImmutableDictionary(static kvp => kvp.AgentGuid, static kvp => kvp.Name); agentNamesByGuid = AgentManager.GetAll().Values.ToImmutableDictionary(static kvp => kvp.AgentGuid, static kvp => kvp.Configuration.AgentName);
instanceNamesByGuid = InstanceManager.GetAll().Values.ToImmutableDictionary(static instance => instance.InstanceGuid, static instance => instance.Configuration.InstanceName); instanceNamesByGuid = InstanceManager.GetAll().Values.ToImmutableDictionary(static instance => instance.InstanceGuid, static instance => instance.Configuration.InstanceName);
} }
else { else {

View File

@@ -76,7 +76,7 @@
protected override void OnInitialized() { protected override void OnInitialized() {
AgentManager.AgentsChanged.Subscribe(this, agents => { AgentManager.AgentsChanged.Subscribe(this, agents => {
this.agentNamesByGuid = agents.ToImmutableDictionary(static agent => agent.AgentGuid, static agent => agent.Name); this.agentNamesByGuid = agents.Select(static kvp => KeyValuePair.Create(kvp.Key, kvp.Value.Configuration.AgentName)).ToImmutableDictionary();
InvokeAsync(StateHasChanged); InvokeAsync(StateHasChanged);
}); });
} }

View File

@@ -0,0 +1,73 @@
@using System.ComponentModel.DataAnnotations
@using Phantom.Common.Data.Web.Agent
@using Phantom.Common.Data.Web.Users
@using Phantom.Utils.Result
@using Phantom.Web.Services
@using Phantom.Web.Services.Agents
@inherits PhantomComponent
@inject AgentManager AgentManager
@inject Navigation Navigation
<Form Model="form" OnSubmit="AddOrEditAgent">
<div class="row">
<div class="col-xl-5 mb-3">
<FormTextInput Id="agent-name" Label="Agent Name" @bind-Value="form.AgentName" />
</div>
</div>
<FormButtonSubmit Label="@(EditedAgent == null ? "Create Agent" : "Edit Agent")" class="btn btn-primary" />
<FormSubmitError />
</Form>
@code {
[Parameter, EditorRequired]
public Agent? EditedAgent { get; init; }
private ConfigureAgentFormModel form = null!;
private sealed class ConfigureAgentFormModel : FormModel {
[Required(ErrorMessage = "Agent name is required.")]
[StringLength(100, ErrorMessage = "Agent name must be at most 100 characters.")]
public string AgentName { get; set; } = string.Empty;
}
protected override void OnInitialized() {
form = new ConfigureAgentFormModel();
if (EditedAgent != null) {
var configuration = EditedAgent.Configuration;
form.AgentName = configuration.AgentName;
}
}
private async Task AddOrEditAgent(EditContext context) {
await form.SubmitModel.StartSubmitting();
var agentGuid = EditedAgent?.AgentGuid ?? Guid.NewGuid();
var agentConfiguration = new AgentConfiguration(
form.AgentName
);
var result = await AgentManager.CreateOrUpdateAgent(await GetAuthenticatedUser(), agentGuid, agentConfiguration, CancellationToken);
switch (result.Variant()) {
case Ok<CreateOrUpdateAgentResult>(CreateOrUpdateAgentResult.Success):
await Navigation.NavigateTo("agents");
break;
case Ok<CreateOrUpdateAgentResult>(var createOrUpdateAgentResult):
form.SubmitModel.StopSubmitting(createOrUpdateAgentResult.ToSentence());
break;
case Err<UserActionFailure>(UserActionFailure.NotAuthorized):
form.SubmitModel.StopSubmitting("You do not have permission to create or edit agents.");
break;
default:
form.SubmitModel.StopSubmitting("Unknown error.");
break;
}
}
}

View File

@@ -18,10 +18,10 @@
@using Phantom.Web.Services.Instances @using Phantom.Web.Services.Instances
@using Phantom.Web.Services.Rpc @using Phantom.Web.Services.Rpc
@inherits PhantomComponent @inherits PhantomComponent
@inject Navigation Navigation
@inject ControllerConnection ControllerConnection
@inject AgentManager AgentManager @inject AgentManager AgentManager
@inject ControllerConnection ControllerConnection
@inject InstanceManager InstanceManager @inject InstanceManager InstanceManager
@inject Navigation Navigation
<Form Model="form" OnSubmit="AddOrEditInstance"> <Form Model="form" OnSubmit="AddOrEditInstance">
@{ var selectedAgent = form.SelectedAgent; } @{ var selectedAgent = form.SelectedAgent; }
@@ -29,21 +29,23 @@
<div class="col-xl-7 mb-3"> <div class="col-xl-7 mb-3">
@{ @{
static RenderFragment GetAgentOption(Agent agent) { static RenderFragment GetAgentOption(Agent agent) {
var configuration = agent.Configuration; var runtimeInfo = agent.RuntimeInfo;
return return
@<option value="@agent.AgentGuid"> @<option value="@agent.AgentGuid">
@agent.Name @agent.Configuration.AgentName
&bullet; @if (runtimeInfo != null) {
@(agent.Stats?.RunningInstanceCount.ToString() ?? "?")/@(configuration.MaxInstances) @(configuration.MaxInstances == 1 ? "Instance" : "Instances") <text>&bullet;</text>
&bullet; <text>@(agent.Stats?.RunningInstanceCount.ToString() ?? "?")/@(runtimeInfo.MaxInstances) @(runtimeInfo.MaxInstances == 1 ? "Instance" : "Instances")</text>
@(agent.Stats?.RunningInstanceMemory.InMegabytes.ToString() ?? "?")/@(configuration.MaxMemory.InMegabytes) MB RAM <text>&bullet;</text>
<text>@(agent.Stats?.RunningInstanceMemory.InMegabytes.ToString() ?? "?")/@(runtimeInfo.MaxMemory.InMegabytes) MB RAM</text>
}
</option>; </option>;
} }
} }
@if (EditedInstance == null) { @if (EditedInstance == null) {
<FormSelectInput Id="instance-agent" Label="Agent" @bind-Value="form.SelectedAgentGuid"> <FormSelectInput Id="instance-agent" Label="Agent" @bind-Value="form.SelectedAgentGuid">
<option value="" selected>Select which agent will run the instance...</option> <option value="" selected>Select which agent will run the instance...</option>
@foreach (var agent in allAgentsByGuid.Values.Where(static agent => agent.ConnectionStatus is AgentIsOnline).OrderBy(static agent => agent.Name)) { @foreach (var agent in allAgentsByGuid.Values.Where(static agent => agent.ConnectionStatus is AgentIsOnline).OrderBy(static agent => agent.Configuration.AgentName)) {
@GetAgentOption(agent) @GetAgentOption(agent)
} }
</FormSelectInput> </FormSelectInput>
@@ -101,8 +103,8 @@
</div> </div>
@{ @{
string? allowedServerPorts = selectedAgent?.Configuration.AllowedServerPorts?.ToString(); string? allowedServerPorts = selectedAgent?.RuntimeInfo?.AllowedServerPorts?.ToString();
string? allowedRconPorts = selectedAgent?.Configuration.AllowedRconPorts?.ToString(); string? allowedRconPorts = selectedAgent?.RuntimeInfo?.AllowedRconPorts?.ToString();
} }
<div class="col-sm-6 col-xl-2 mb-3"> <div class="col-sm-6 col-xl-2 mb-3">
<FormNumberInput Id="instance-server-port" @bind-Value="form.ServerPort" min="0" max="65535"> <FormNumberInput Id="instance-server-port" @bind-Value="form.ServerPort" min="0" max="65535">
@@ -141,11 +143,11 @@
} }
<FormNumberInput Id="instance-memory" Type="FormNumberInputType.Range" DebounceMillis="0" DisableTwoWayBinding="true" @bind-Value="form.MemoryUnits" min="@MinimumMemoryUnits" max="@maximumMemoryUnits" disabled="@(maximumMemoryUnits == 0)" class="form-range split-danger" style="@memoryInputSplitVar"> <FormNumberInput Id="instance-memory" Type="FormNumberInputType.Range" DebounceMillis="0" DisableTwoWayBinding="true" @bind-Value="form.MemoryUnits" min="@MinimumMemoryUnits" max="@maximumMemoryUnits" disabled="@(maximumMemoryUnits == 0)" class="form-range split-danger" style="@memoryInputSplitVar">
<LabelFragment> <LabelFragment>
@if (maximumMemoryUnits == 0) { @if (maximumMemoryUnits == 0 || selectedAgent?.RuntimeInfo == null) {
<text>RAM</text> <text>RAM</text>
} }
else { else {
<text>RAM &bullet; <code>@(form.MemoryAllocation?.InMegabytes ?? 0) / @(selectedAgent?.Configuration.MaxMemory.InMegabytes) MB</code></text> <text>RAM &bullet; <code>@(form.MemoryAllocation?.InMegabytes ?? 0) / @(selectedAgent.RuntimeInfo.MaxMemory.InMegabytes) MB</code></text>
} }
</LabelFragment> </LabelFragment>
</FormNumberInput> </FormNumberInput>
@@ -209,7 +211,7 @@
public ImmutableArray<TaggedJavaRuntime> JavaRuntimesForSelectedAgent => TryGet(page.allAgentJavaRuntimes, SelectedAgentGuid, out var javaRuntimes) ? javaRuntimes : ImmutableArray<TaggedJavaRuntime>.Empty; public ImmutableArray<TaggedJavaRuntime> JavaRuntimesForSelectedAgent => TryGet(page.allAgentJavaRuntimes, SelectedAgentGuid, out var javaRuntimes) ? javaRuntimes : ImmutableArray<TaggedJavaRuntime>.Empty;
public ushort MaximumMemoryUnits => SelectedAgent?.Configuration.MaxMemory.RawValue ?? 0; public ushort MaximumMemoryUnits => SelectedAgent?.RuntimeInfo?.MaxMemory.RawValue ?? 0;
public ushort AvailableMemoryUnits => Math.Min((SelectedAgent?.AvailableMemory + editedInstanceRamAllocation)?.RawValue ?? MaximumMemoryUnits, MaximumMemoryUnits); public ushort AvailableMemoryUnits => Math.Min((SelectedAgent?.AvailableMemory + editedInstanceRamAllocation)?.RawValue ?? MaximumMemoryUnits, MaximumMemoryUnits);
private ushort selectedMemoryUnits = 4; private ushort selectedMemoryUnits = 4;
@@ -250,12 +252,12 @@
public sealed class ServerPortMustBeAllowedAttribute : FormValidationAttribute<ConfigureInstanceFormModel, int> { public sealed class ServerPortMustBeAllowedAttribute : FormValidationAttribute<ConfigureInstanceFormModel, int> {
protected override string FieldName => nameof(ServerPort); protected override string FieldName => nameof(ServerPort);
protected override bool IsValid(ConfigureInstanceFormModel model, int value) => model.SelectedAgent is not {} agent || agent.Configuration.AllowedServerPorts?.Contains((ushort) value) == true; protected override bool IsValid(ConfigureInstanceFormModel model, int value) => model.SelectedAgent is not {} agent || agent.RuntimeInfo?.AllowedServerPorts?.Contains((ushort) value) == true;
} }
public sealed class RconPortMustBeAllowedAttribute : FormValidationAttribute<ConfigureInstanceFormModel, int> { public sealed class RconPortMustBeAllowedAttribute : FormValidationAttribute<ConfigureInstanceFormModel, int> {
protected override string FieldName => nameof(RconPort); protected override string FieldName => nameof(RconPort);
protected override bool IsValid(ConfigureInstanceFormModel model, int value) => model.SelectedAgent is not {} agent || agent.Configuration.AllowedRconPorts?.Contains((ushort) value) == true; protected override bool IsValid(ConfigureInstanceFormModel model, int value) => model.SelectedAgent is not {} agent || agent.RuntimeInfo?.AllowedRconPorts?.Contains((ushort) value) == true;
} }
public sealed class RconPortMustDifferFromServerPortAttribute : FormValidationAttribute<ConfigureInstanceFormModel, int?> { public sealed class RconPortMustDifferFromServerPortAttribute : FormValidationAttribute<ConfigureInstanceFormModel, int?> {