1 Commits
Author SHA1 Message Date
chylex d63089ead2 WIP 2025-12-28 04:03:24 +01:00
79 changed files with 1677 additions and 652 deletions
+2 -5
View File
@@ -5,8 +5,7 @@
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/.workdir/Agent1" /> <option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/.workdir/Agent1" />
<option name="PASS_PARENT_ENVS" value="1" /> <option name="PASS_PARENT_ENVS" value="1" />
<envs> <envs>
<env name="AGENT_KEY" value="K5ZRZYYJ9GWM2FS6XH5N5QQ7WZRPNDGHYMN5QP7RP6PPY27KRPMSYGCN" /> <env name="AGENT_KEY_FILE" value="./key" />
<env name="AGENT_NAME" value="Agent 1" />
<env name="ALLOWED_RCON_PORTS" value="25575,27000,27001" /> <env name="ALLOWED_RCON_PORTS" value="25575,27000,27001" />
<env name="ALLOWED_SERVER_PORTS" value="25565,26000,26001" /> <env name="ALLOWED_SERVER_PORTS" value="25565,26000,26001" />
<env name="CONTROLLER_HOST" value="localhost" /> <env name="CONTROLLER_HOST" value="localhost" />
@@ -14,14 +13,12 @@
<env name="MAX_INSTANCES" value="3" /> <env name="MAX_INSTANCES" value="3" />
<env name="MAX_MEMORY" value="12G" /> <env name="MAX_MEMORY" value="12G" />
</envs> </envs>
<option name="USE_EXTERNAL_CONSOLE" value="0" />
<option name="ENV_FILE_PATHS" value="" /> <option name="ENV_FILE_PATHS" value="" />
<option name="REDIRECT_INPUT_PATH" value="" /> <option name="REDIRECT_INPUT_PATH" value="" />
<option name="PTY_MODE" value="Auto" /> <option name="MIXED_MODE_DEBUG" value="0" />
<option name="USE_MONO" value="0" /> <option name="USE_MONO" value="0" />
<option name="RUNTIME_ARGUMENTS" value="" /> <option name="RUNTIME_ARGUMENTS" value="" />
<option name="AUTO_ATTACH_CHILDREN" value="0" /> <option name="AUTO_ATTACH_CHILDREN" value="0" />
<option name="MIXED_MODE_DEBUG" value="0" />
<option name="PROJECT_PATH" value="$PROJECT_DIR$/Agent/Phantom.Agent/Phantom.Agent.csproj" /> <option name="PROJECT_PATH" value="$PROJECT_DIR$/Agent/Phantom.Agent/Phantom.Agent.csproj" />
<option name="PROJECT_EXE_PATH_TRACKING" value="1" /> <option name="PROJECT_EXE_PATH_TRACKING" value="1" />
<option name="PROJECT_ARGUMENTS_TRACKING" value="1" /> <option name="PROJECT_ARGUMENTS_TRACKING" value="1" />
+2 -5
View File
@@ -5,8 +5,7 @@
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/.workdir/Agent2" /> <option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/.workdir/Agent2" />
<option name="PASS_PARENT_ENVS" value="1" /> <option name="PASS_PARENT_ENVS" value="1" />
<envs> <envs>
<env name="AGENT_KEY" value="K5ZRZYYJ9GWM2FS6XH5N5QQ7WZRPNDGHYMN5QP7RP6PPY27KRPMSYGCN" /> <env name="AGENT_KEY_FILE" value="./key" />
<env name="AGENT_NAME" value="Agent 2" />
<env name="ALLOWED_RCON_PORTS" value="27002-27006" /> <env name="ALLOWED_RCON_PORTS" value="27002-27006" />
<env name="ALLOWED_SERVER_PORTS" value="26002-26006" /> <env name="ALLOWED_SERVER_PORTS" value="26002-26006" />
<env name="CONTROLLER_HOST" value="localhost" /> <env name="CONTROLLER_HOST" value="localhost" />
@@ -14,14 +13,12 @@
<env name="MAX_INSTANCES" value="5" /> <env name="MAX_INSTANCES" value="5" />
<env name="MAX_MEMORY" value="10G" /> <env name="MAX_MEMORY" value="10G" />
</envs> </envs>
<option name="USE_EXTERNAL_CONSOLE" value="0" />
<option name="ENV_FILE_PATHS" value="" /> <option name="ENV_FILE_PATHS" value="" />
<option name="REDIRECT_INPUT_PATH" value="" /> <option name="REDIRECT_INPUT_PATH" value="" />
<option name="PTY_MODE" value="Auto" /> <option name="MIXED_MODE_DEBUG" value="0" />
<option name="USE_MONO" value="0" /> <option name="USE_MONO" value="0" />
<option name="RUNTIME_ARGUMENTS" value="" /> <option name="RUNTIME_ARGUMENTS" value="" />
<option name="AUTO_ATTACH_CHILDREN" value="0" /> <option name="AUTO_ATTACH_CHILDREN" value="0" />
<option name="MIXED_MODE_DEBUG" value="0" />
<option name="PROJECT_PATH" value="$PROJECT_DIR$/Agent/Phantom.Agent/Phantom.Agent.csproj" /> <option name="PROJECT_PATH" value="$PROJECT_DIR$/Agent/Phantom.Agent/Phantom.Agent.csproj" />
<option name="PROJECT_EXE_PATH_TRACKING" value="1" /> <option name="PROJECT_EXE_PATH_TRACKING" value="1" />
<option name="PROJECT_ARGUMENTS_TRACKING" value="1" /> <option name="PROJECT_ARGUMENTS_TRACKING" value="1" />
+2 -5
View File
@@ -5,8 +5,7 @@
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/.workdir/Agent3" /> <option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/.workdir/Agent3" />
<option name="PASS_PARENT_ENVS" value="1" /> <option name="PASS_PARENT_ENVS" value="1" />
<envs> <envs>
<env name="AGENT_KEY" value="K5ZRZYYJ9GWM2FS6XH5N5QQ7WZRPNDGHYMN5QP7RP6PPY27KRPMSYGCN" /> <env name="AGENT_KEY_FILE" value="./key" />
<env name="AGENT_NAME" value="Agent 3" />
<env name="ALLOWED_RCON_PORTS" value="27007" /> <env name="ALLOWED_RCON_PORTS" value="27007" />
<env name="ALLOWED_SERVER_PORTS" value="26007" /> <env name="ALLOWED_SERVER_PORTS" value="26007" />
<env name="CONTROLLER_HOST" value="localhost" /> <env name="CONTROLLER_HOST" value="localhost" />
@@ -14,14 +13,12 @@
<env name="MAX_INSTANCES" value="1" /> <env name="MAX_INSTANCES" value="1" />
<env name="MAX_MEMORY" value="2560M" /> <env name="MAX_MEMORY" value="2560M" />
</envs> </envs>
<option name="USE_EXTERNAL_CONSOLE" value="0" />
<option name="ENV_FILE_PATHS" value="" /> <option name="ENV_FILE_PATHS" value="" />
<option name="REDIRECT_INPUT_PATH" value="" /> <option name="REDIRECT_INPUT_PATH" value="" />
<option name="PTY_MODE" value="Auto" /> <option name="MIXED_MODE_DEBUG" value="0" />
<option name="USE_MONO" value="0" /> <option name="USE_MONO" value="0" />
<option name="RUNTIME_ARGUMENTS" value="" /> <option name="RUNTIME_ARGUMENTS" value="" />
<option name="AUTO_ATTACH_CHILDREN" value="0" /> <option name="AUTO_ATTACH_CHILDREN" value="0" />
<option name="MIXED_MODE_DEBUG" value="0" />
<option name="PROJECT_PATH" value="$PROJECT_DIR$/Agent/Phantom.Agent/Phantom.Agent.csproj" /> <option name="PROJECT_PATH" value="$PROJECT_DIR$/Agent/Phantom.Agent/Phantom.Agent.csproj" />
<option name="PROJECT_EXE_PATH_TRACKING" value="1" /> <option name="PROJECT_EXE_PATH_TRACKING" value="1" />
<option name="PROJECT_ARGUMENTS_TRACKING" value="1" /> <option name="PROJECT_ARGUMENTS_TRACKING" value="1" />
+2 -4
View File
@@ -7,17 +7,15 @@
<envs> <envs>
<env name="ASPNETCORE_ENVIRONMENT" value="Development" /> <env name="ASPNETCORE_ENVIRONMENT" value="Development" />
<env name="CONTROLLER_HOST" value="localhost" /> <env name="CONTROLLER_HOST" value="localhost" />
<env name="WEB_KEY" value="T5Y722D2GZBXT2H27QS95P2YQRFB2GCTKHSWT5CZFDTFKW52TCM9GDRW" /> <env name="WEB_KEY" value="G9WXPDGCGHJD9W9XBPMNYWN6YTK7NKRWHT29P2XKNDCBWKHWXP2YQRFB2GCTKHSWT5CZFDTFKW52TCM9GDRW" />
<env name="WEB_SERVER_HOST" value="localhost" /> <env name="WEB_SERVER_HOST" value="localhost" />
</envs> </envs>
<option name="USE_EXTERNAL_CONSOLE" value="0" />
<option name="ENV_FILE_PATHS" value="" /> <option name="ENV_FILE_PATHS" value="" />
<option name="REDIRECT_INPUT_PATH" value="" /> <option name="REDIRECT_INPUT_PATH" value="" />
<option name="PTY_MODE" value="Auto" /> <option name="MIXED_MODE_DEBUG" value="0" />
<option name="USE_MONO" value="0" /> <option name="USE_MONO" value="0" />
<option name="RUNTIME_ARGUMENTS" value="" /> <option name="RUNTIME_ARGUMENTS" value="" />
<option name="AUTO_ATTACH_CHILDREN" value="0" /> <option name="AUTO_ATTACH_CHILDREN" value="0" />
<option name="MIXED_MODE_DEBUG" value="0" />
<option name="PROJECT_PATH" value="$PROJECT_DIR$/Web/Phantom.Web/Phantom.Web.csproj" /> <option name="PROJECT_PATH" value="$PROJECT_DIR$/Web/Phantom.Web/Phantom.Web.csproj" />
<option name="PROJECT_EXE_PATH_TRACKING" value="1" /> <option name="PROJECT_EXE_PATH_TRACKING" value="1" />
<option name="PROJECT_ARGUMENTS_TRACKING" value="1" /> <option name="PROJECT_ARGUMENTS_TRACKING" value="1" />
-1
View File
@@ -1 +0,0 @@
H嶗鰂g
+2 -1
View File
@@ -1 +1,2 @@
Uב¸תq qミモh4ぴHヲ、7胥H`├W
ウ4u`G
+1 -1
View File
@@ -29,7 +29,7 @@ static class AgentKey {
} }
try { try {
Files.RequireMaximumFileSize(agentKeyFilePath, maximumBytes: 64); Files.RequireMaximumFileSize(agentKeyFilePath, maximumBytes: 128);
string[] lines = await File.ReadAllLinesAsync(agentKeyFilePath, Encoding.UTF8); string[] lines = await File.ReadAllLinesAsync(agentKeyFilePath, Encoding.UTF8);
return LoadFromToken(lines[0]); return LoadFromToken(lines[0]);
} catch (IOException e) { } catch (IOException e) {
-44
View File
@@ -1,44 +0,0 @@
using System.Text;
using Phantom.Utils.IO;
using Phantom.Utils.Logging;
using Serilog;
namespace Phantom.Agent;
static class GuidFile {
private static ILogger Logger { get; } = PhantomLogger.Create(nameof(GuidFile));
private const string GuidFileName = "agent.guid";
public static async Task<Guid?> CreateOrLoad(string folderPath) {
string filePath = Path.Combine(folderPath, GuidFileName);
if (File.Exists(filePath)) {
try {
var guid = await LoadGuidFromFile(filePath);
Logger.Information("Loaded existing agent GUID file.");
return guid;
} catch (Exception e) {
Logger.Fatal("Error reading agent GUID file: {Message}", e.Message);
return null;
}
}
Logger.Information("Creating agent GUID file: {FilePath}", filePath);
try {
var guid = Guid.NewGuid();
await File.WriteAllTextAsync(filePath, guid.ToString(), Encoding.ASCII);
return guid;
} catch (Exception e) {
Logger.Fatal("Error creating agent GUID file: {Message}", e.Message);
return null;
}
}
private static async Task<Guid> LoadGuidFromFile(string filePath) {
Files.RequireMaximumFileSize(filePath, maximumBytes: 128);
string contents = await File.ReadAllTextAsync(filePath, Encoding.ASCII);
return Guid.Parse(contents.Trim());
}
}
+2 -7
View File
@@ -30,7 +30,7 @@ try {
PhantomLogger.Root.InformationHeading("Initializing Phantom Panel agent..."); PhantomLogger.Root.InformationHeading("Initializing Phantom Panel agent...");
PhantomLogger.Root.Information("Agent version: {Version}", fullVersion); PhantomLogger.Root.Information("Agent version: {Version}", fullVersion);
var (controllerHost, controllerPort, javaSearchPath, agentKeyToken, agentKeyFilePath, agentName, maxInstances, maxMemory, allowedServerPorts, allowedRconPorts, maxConcurrentBackupCompressionTasks) = Variables.LoadOrStop(); var (controllerHost, controllerPort, javaSearchPath, agentKeyToken, agentKeyFilePath, maxInstances, maxMemory, allowedServerPorts, allowedRconPorts, maxConcurrentBackupCompressionTasks) = Variables.LoadOrStop();
var agentKey = await AgentKey.Load(agentKeyToken, agentKeyFilePath); var agentKey = await AgentKey.Load(agentKeyToken, agentKeyFilePath);
if (agentKey == null) { if (agentKey == null) {
@@ -42,12 +42,7 @@ try {
return 1; return 1;
} }
var agentGuid = await GuidFile.CreateOrLoad(folders.DataFolderPath); var agentInfo = new AgentInfo(ProtocolVersion, fullVersion, maxInstances, maxMemory, allowedServerPorts, allowedRconPorts);
if (agentGuid == null) {
return 1;
}
var agentInfo = new AgentInfo(agentGuid.Value, agentName, ProtocolVersion, fullVersion, maxInstances, maxMemory, allowedServerPorts, allowedRconPorts);
var javaRuntimeRepository = await JavaRuntimeDiscovery.Scan(folders.JavaSearchFolderPath, shutdownCancellationToken); var javaRuntimeRepository = await JavaRuntimeDiscovery.Scan(folders.JavaSearchFolderPath, shutdownCancellationToken);
var agentRegistrationHandler = new AgentRegistrationHandler(); var agentRegistrationHandler = new AgentRegistrationHandler();
-2
View File
@@ -11,7 +11,6 @@ sealed record Variables(
string JavaSearchPath, string JavaSearchPath,
string? AgentKeyToken, string? AgentKeyToken,
string? AgentKeyFilePath, string? AgentKeyFilePath,
string AgentName,
ushort MaxInstances, ushort MaxInstances,
RamAllocationUnits MaxMemory, RamAllocationUnits MaxMemory,
AllowedPorts AllowedServerPorts, AllowedPorts AllowedServerPorts,
@@ -28,7 +27,6 @@ sealed record Variables(
javaSearchPath, javaSearchPath,
agentKeyToken, agentKeyToken,
agentKeyFilePath, agentKeyFilePath,
EnvironmentVariables.GetString("AGENT_NAME").Require,
(ushort) EnvironmentVariables.GetInteger("MAX_INSTANCES", min: 1, max: 10000).Require, (ushort) EnvironmentVariables.GetInteger("MAX_INSTANCES", min: 1, max: 10000).Require,
EnvironmentVariables.GetString("MAX_MEMORY").MapParse(RamAllocationUnits.FromString).Require, EnvironmentVariables.GetString("MAX_MEMORY").MapParse(RamAllocationUnits.FromString).Require,
EnvironmentVariables.GetString("ALLOWED_SERVER_PORTS").MapParse(AllowedPorts.FromString).Require, EnvironmentVariables.GetString("ALLOWED_SERVER_PORTS").MapParse(AllowedPorts.FromString).Require,
@@ -1,4 +1,5 @@
using MemoryPack; using System.Collections.Immutable;
using MemoryPack;
using Phantom.Common.Data.Agent; using Phantom.Common.Data.Agent;
namespace Phantom.Common.Data.Web.Agent; namespace Phantom.Common.Data.Web.Agent;
@@ -7,9 +8,11 @@ namespace Phantom.Common.Data.Web.Agent;
public sealed partial record Agent( public sealed partial record Agent(
[property: MemoryPackOrder(0)] Guid AgentGuid, [property: MemoryPackOrder(0)] Guid AgentGuid,
[property: MemoryPackOrder(1)] AgentConfiguration Configuration, [property: MemoryPackOrder(1)] AgentConfiguration Configuration,
[property: MemoryPackOrder(2)] AgentStats? Stats, [property: MemoryPackOrder(2)] ImmutableArray<byte> ConnectionKey,
[property: MemoryPackOrder(3)] IAgentConnectionStatus ConnectionStatus [property: MemoryPackOrder(3)] AgentRuntimeInfo RuntimeInfo,
[property: MemoryPackOrder(4)] AgentStats? Stats,
[property: MemoryPackOrder(5)] IAgentConnectionStatus ConnectionStatus
) { ) {
[MemoryPackIgnore] [MemoryPackIgnore]
public RamAllocationUnits? AvailableMemory => Configuration.MaxMemory - Stats?.RunningInstanceMemory; public RamAllocationUnits? AvailableMemory => RuntimeInfo.MaxMemory - Stats?.RunningInstanceMemory;
} }
@@ -1,19 +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)] string AgentName, [property: MemoryPackOrder(0)] string AgentName
[property: MemoryPackOrder(1)] ushort ProtocolVersion, );
[property: MemoryPackOrder(2)] string BuildVersion,
[property: MemoryPackOrder(3)] ushort MaxInstances,
[property: MemoryPackOrder(4)] RamAllocationUnits MaxMemory,
[property: MemoryPackOrder(5)] AllowedPorts? AllowedServerPorts = null,
[property: MemoryPackOrder(6)] AllowedPorts? AllowedRconPorts = null
) {
public static AgentConfiguration From(AgentInfo agentInfo) {
return new AgentConfiguration(agentInfo.AgentName, agentInfo.ProtocolVersion, agentInfo.BuildVersion, agentInfo.MaxInstances, agentInfo.MaxMemory, agentInfo.AllowedServerPorts, agentInfo.AllowedRconPorts);
}
}
@@ -0,0 +1,17 @@
using MemoryPack;
using Phantom.Common.Data.Agent;
namespace Phantom.Common.Data.Web.Agent;
[MemoryPackable(GenerateType.VersionTolerant)]
public sealed partial record AgentRuntimeInfo(
[property: MemoryPackOrder(0)] AgentVersionInfo? VersionInfo = null,
[property: MemoryPackOrder(1)] ushort? MaxInstances = null,
[property: MemoryPackOrder(2)] RamAllocationUnits? MaxMemory = null,
[property: MemoryPackOrder(3)] AllowedPorts? AllowedServerPorts = null,
[property: MemoryPackOrder(4)] AllowedPorts? AllowedRconPorts = null
) {
public static AgentRuntimeInfo From(AgentInfo agentInfo) {
return new AgentRuntimeInfo(new AgentVersionInfo(agentInfo.ProtocolVersion, agentInfo.BuildVersion), agentInfo.MaxInstances, agentInfo.MaxMemory, agentInfo.AllowedServerPorts, agentInfo.AllowedRconPorts);
}
}
@@ -0,0 +1,9 @@
using MemoryPack;
namespace Phantom.Common.Data.Web.Agent;
[MemoryPackable(GenerateType.VersionTolerant)]
public readonly partial record struct AgentVersionInfo(
[property: MemoryPackOrder(0)] ushort ProtocolVersion,
[property: MemoryPackOrder(1)] string BuildVersion
);
@@ -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.",
};
}
}
@@ -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 },
@@ -2,5 +2,6 @@
public enum AuditLogSubjectType { public enum AuditLogSubjectType {
User, User,
Agent,
Instance, Instance,
} }
@@ -4,12 +4,10 @@ namespace Phantom.Common.Data.Agent;
[MemoryPackable(GenerateType.VersionTolerant)] [MemoryPackable(GenerateType.VersionTolerant)]
public sealed partial record AgentInfo( public sealed partial record AgentInfo(
[property: MemoryPackOrder(0)] Guid AgentGuid, [property: MemoryPackOrder(0)] ushort ProtocolVersion,
[property: MemoryPackOrder(1)] string AgentName, [property: MemoryPackOrder(1)] string BuildVersion,
[property: MemoryPackOrder(2)] ushort ProtocolVersion, [property: MemoryPackOrder(2)] ushort MaxInstances,
[property: MemoryPackOrder(3)] string BuildVersion, [property: MemoryPackOrder(3)] RamAllocationUnits MaxMemory,
[property: MemoryPackOrder(4)] ushort MaxInstances, [property: MemoryPackOrder(4)] AllowedPorts AllowedServerPorts,
[property: MemoryPackOrder(5)] RamAllocationUnits MaxMemory, [property: MemoryPackOrder(5)] AllowedPorts AllowedRconPorts
[property: MemoryPackOrder(6)] AllowedPorts AllowedServerPorts,
[property: MemoryPackOrder(7)] AllowedPorts AllowedRconPorts
); );
+6 -5
View File
@@ -1,4 +1,5 @@
using Phantom.Utils.Rpc; using System.Collections.Immutable;
using Phantom.Utils.Rpc;
using Phantom.Utils.Rpc.Runtime.Tls; using Phantom.Utils.Rpc.Runtime.Tls;
namespace Phantom.Common.Data; namespace Phantom.Common.Data;
@@ -6,15 +7,15 @@ namespace Phantom.Common.Data;
public readonly record struct ConnectionKey(RpcCertificateThumbprint CertificateThumbprint, AuthToken AuthToken) { public readonly record struct ConnectionKey(RpcCertificateThumbprint CertificateThumbprint, AuthToken AuthToken) {
private const byte TokenLength = AuthToken.Length; private const byte TokenLength = AuthToken.Length;
public byte[] ToBytes() { public ImmutableArray<byte> ToBytes() {
Span<byte> result = stackalloc byte[TokenLength + CertificateThumbprint.Bytes.Length]; Span<byte> result = stackalloc byte[TokenLength + CertificateThumbprint.Bytes.Length];
AuthToken.Bytes.CopyTo(result[..TokenLength]); AuthToken.ToBytes(result[..TokenLength]);
CertificateThumbprint.Bytes.CopyTo(result[TokenLength..]); CertificateThumbprint.Bytes.CopyTo(result[TokenLength..]);
return result.ToArray(); return [..result];
} }
public static ConnectionKey FromBytes(ReadOnlySpan<byte> data) { public static ConnectionKey FromBytes(ReadOnlySpan<byte> data) {
var authToken = new AuthToken([..data[..TokenLength]]); var authToken = AuthToken.FromBytes(data[..TokenLength]);
var certificateThumbprint = RpcCertificateThumbprint.From(data[TokenLength..]); var certificateThumbprint = RpcCertificateThumbprint.From(data[TokenLength..]);
return new ConnectionKey(certificateThumbprint, authToken); return new ConnectionKey(certificateThumbprint, authToken);
} }
@@ -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>();
} }
} }
@@ -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>>;
@@ -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>>();
@@ -0,0 +1,357 @@
// <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("20251225053921_AgentAuthSecret")]
partial class AgentAuthSecret
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "9.0.9")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Phantom.Controller.Database.Entities.AgentEntity", b =>
{
b.Property<Guid>("AgentGuid")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<byte[]>("AuthSecret")
.HasMaxLength(12)
.HasColumnType("bytea");
b.Property<string>("BuildVersion")
.IsRequired()
.HasColumnType("text");
b.Property<int>("MaxInstances")
.HasColumnType("integer");
b.Property<int>("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<int>("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
}
}
}
@@ -0,0 +1,31 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Phantom.Controller.Database.Postgres.Migrations
{
/// <inheritdoc />
public partial class AgentAuthSecret : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<byte[]>(
name: "AuthSecret",
schema: "agents",
table: "Agents",
type: "bytea",
maxLength: 12,
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "AuthSecret",
schema: "agents",
table: "Agents");
}
}
}
@@ -18,7 +18,7 @@ namespace Phantom.Controller.Database.Postgres.Migrations
{ {
#pragma warning disable 612, 618 #pragma warning disable 612, 618
modelBuilder modelBuilder
.HasAnnotation("ProductVersion", "8.0.0") .HasAnnotation("ProductVersion", "9.0.9")
.HasAnnotation("Relational:MaxIdentifierLength", 63); .HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
@@ -29,6 +29,10 @@ namespace Phantom.Controller.Database.Postgres.Migrations
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("uuid"); .HasColumnType("uuid");
b.Property<byte[]>("AuthSecret")
.HasMaxLength(12)
.HasColumnType("bytea");
b.Property<string>("BuildVersion") b.Property<string>("BuildVersion")
.IsRequired() .IsRequired()
.HasColumnType("text"); .HasColumnType("text");
@@ -36,7 +40,7 @@ namespace Phantom.Controller.Database.Postgres.Migrations
b.Property<int>("MaxInstances") b.Property<int>("MaxInstances")
.HasColumnType("integer"); .HasColumnType("integer");
b.Property<ushort>("MaxMemory") b.Property<int>("MaxMemory")
.HasColumnType("integer"); .HasColumnType("integer");
b.Property<string>("Name") b.Property<string>("Name")
@@ -142,7 +146,7 @@ namespace Phantom.Controller.Database.Postgres.Migrations
b.Property<bool>("LaunchAutomatically") b.Property<bool>("LaunchAutomatically")
.HasColumnType("boolean"); .HasColumnType("boolean");
b.Property<ushort>("MemoryAllocation") b.Property<int>("MemoryAllocation")
.HasColumnType("integer"); .HasColumnType("integer");
b.Property<string>("MinecraftServerKind") b.Property<string>("MinecraftServerKind")
@@ -8,6 +8,7 @@ using Phantom.Common.Data.Web.EventLog;
using Phantom.Controller.Database.Converters; using Phantom.Controller.Database.Converters;
using Phantom.Controller.Database.Entities; using Phantom.Controller.Database.Entities;
using Phantom.Controller.Database.Factories; using Phantom.Controller.Database.Factories;
using Phantom.Utils.Rpc;
namespace Phantom.Controller.Database; namespace Phantom.Controller.Database;
@@ -79,6 +80,8 @@ public class ApplicationDbContext : DbContext {
builder.Properties<EventLogEventType>().HaveConversion<EnumToStringConverter<EventLogEventType>>(); builder.Properties<EventLogEventType>().HaveConversion<EnumToStringConverter<EventLogEventType>>();
builder.Properties<EventLogSubjectType>().HaveConversion<EnumToStringConverter<EventLogSubjectType>>(); builder.Properties<EventLogSubjectType>().HaveConversion<EnumToStringConverter<EventLogSubjectType>>();
builder.Properties<MinecraftServerKind>().HaveConversion<EnumToStringConverter<MinecraftServerKind>>(); builder.Properties<MinecraftServerKind>().HaveConversion<EnumToStringConverter<MinecraftServerKind>>();
builder.Properties<AuthSecret>().HaveConversion<AuthSecretConverter>();
builder.Properties<RamAllocationUnits>().HaveConversion<RamAllocationUnitsConverter>(); builder.Properties<RamAllocationUnits>().HaveConversion<RamAllocationUnitsConverter>();
} }
} }
@@ -0,0 +1,12 @@
using System.Collections.Immutable;
using System.Diagnostics.CodeAnalysis;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Phantom.Utils.Rpc;
namespace Phantom.Controller.Database.Converters;
[SuppressMessage("ReSharper", "ClassNeverInstantiated.Global")]
sealed class AuthSecretConverter() : ValueConverter<AuthSecret, byte[]>(
static units => units.Bytes.ToArray(),
static value => new AuthSecret(ImmutableArray.Create(value))
);
@@ -5,9 +5,7 @@ using Phantom.Common.Data;
namespace Phantom.Controller.Database.Converters; namespace Phantom.Controller.Database.Converters;
[SuppressMessage("ReSharper", "ClassNeverInstantiated.Global")] [SuppressMessage("ReSharper", "ClassNeverInstantiated.Global")]
sealed class RamAllocationUnitsConverter : ValueConverter<RamAllocationUnits, ushort> { sealed class RamAllocationUnitsConverter() : ValueConverter<RamAllocationUnits, ushort>(
public RamAllocationUnitsConverter() : base(
static units => units.RawValue, static units => units.RawValue,
static value => new RamAllocationUnits(value) static value => new RamAllocationUnits(value)
) {} );
}
@@ -2,6 +2,8 @@
using System.ComponentModel.DataAnnotations.Schema; using System.ComponentModel.DataAnnotations.Schema;
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using Phantom.Common.Data; using Phantom.Common.Data;
using Phantom.Common.Data.Web.Agent;
using Phantom.Utils.Rpc;
namespace Phantom.Controller.Database.Entities; namespace Phantom.Controller.Database.Entities;
@@ -12,10 +14,17 @@ public sealed class AgentEntity {
public Guid AgentGuid { get; init; } public Guid AgentGuid { get; init; }
public string Name { get; set; } public string Name { get; set; }
public ushort ProtocolVersion { get; set; } public ushort? ProtocolVersion { get; set; }
public string BuildVersion { get; set; } public string? BuildVersion { get; set; }
public ushort MaxInstances { get; set; } public ushort? MaxInstances { get; set; }
public RamAllocationUnits MaxMemory { get; set; } public RamAllocationUnits? MaxMemory { get; set; }
[MaxLength(AuthSecret.Length)]
public AuthSecret? AuthSecret { get; set; }
public AgentConfiguration Configuration => new (Name);
public AgentVersionInfo? VersionInfo => ProtocolVersion is null || BuildVersion is null ? null : new AgentVersionInfo(ProtocolVersion, BuildVersion);
public AgentRuntimeInfo RuntimeInfo => new (VersionInfo, MaxInstances, MaxMemory);
internal AgentEntity(Guid agentGuid) { internal AgentEntity(Guid agentGuid) {
AgentGuid = agentGuid; AgentGuid = agentGuid;
@@ -14,15 +14,21 @@ public abstract class AbstractUpsertHelper<T> where T : class {
private protected abstract T Construct(Guid guid); private protected abstract T Construct(Guid guid);
public T Fetch(Guid guid) { public T Fetch(Guid guid) {
return Fetch(guid, out _);
}
public T Fetch(Guid guid, out bool wasCreated) {
DbSet<T> set = Set; DbSet<T> set = Set;
T? entity = set.Find(guid); T? entity = set.Find(guid);
if (entity == null) { if (entity == null) {
entity = Construct(guid); entity = Construct(guid);
set.Add(entity); set.Add(entity);
wasCreated = true;
} }
else { else {
set.Update(entity); set.Update(entity);
wasCreated = false;
} }
return entity; return entity;
@@ -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());
} }
@@ -21,6 +21,7 @@ using Phantom.Utils.Actor.Mailbox;
using Phantom.Utils.Actor.Tasks; using Phantom.Utils.Actor.Tasks;
using Phantom.Utils.Collections; using Phantom.Utils.Collections;
using Phantom.Utils.Logging; using Phantom.Utils.Logging;
using Phantom.Utils.Rpc;
using Phantom.Utils.Rpc.Runtime.Server; using Phantom.Utils.Rpc.Runtime.Server;
using Serilog; using Serilog;
@@ -32,7 +33,18 @@ sealed class AgentActor : ReceiveActor<AgentActor.ICommand>, IWithTimers {
private static readonly TimeSpan DisconnectionRecheckInterval = TimeSpan.FromSeconds(5); private static readonly TimeSpan DisconnectionRecheckInterval = TimeSpan.FromSeconds(5);
private static readonly TimeSpan DisconnectionThreshold = TimeSpan.FromSeconds(12); private static readonly TimeSpan DisconnectionThreshold = TimeSpan.FromSeconds(12);
public readonly record struct Init(Guid AgentGuid, AgentConfiguration AgentConfiguration, ControllerState ControllerState, MinecraftVersions MinecraftVersions, IDbContextProvider DbProvider, CancellationToken CancellationToken); public readonly record struct Init(
Guid? LoggedInUserGuid,
Guid AgentGuid,
AgentConfiguration AgentConfiguration,
AuthSecret AuthSecret,
AgentRuntimeInfo AgentRuntimeInfo,
AgentConnectionKeys AgentConnectionKeys,
ControllerState ControllerState,
MinecraftVersions MinecraftVersions,
IDbContextProvider DbProvider,
CancellationToken CancellationToken
);
public static Props<ICommand> Factory(Init init) { public static Props<ICommand> Factory(Init init) {
return Props<ICommand>.Create(() => new AgentActor(init), new ActorConfiguration { SupervisorStrategy = SupervisorStrategies.Resume, MailboxType = UnboundedJumpAheadMailbox.Name }); return Props<ICommand>.Create(() => new AgentActor(init), new ActorConfiguration { SupervisorStrategy = SupervisorStrategies.Resume, MailboxType = UnboundedJumpAheadMailbox.Name });
@@ -40,17 +52,22 @@ sealed class AgentActor : ReceiveActor<AgentActor.ICommand>, IWithTimers {
public ITimerScheduler Timers { get; set; } = null!; public ITimerScheduler Timers { get; set; } = null!;
private readonly AgentConnectionKeys agentConnectionKeys;
private readonly ControllerState controllerState; private readonly ControllerState controllerState;
private readonly MinecraftVersions minecraftVersions; private readonly MinecraftVersions minecraftVersions;
private readonly IDbContextProvider dbProvider; private readonly IDbContextProvider dbProvider;
private readonly CancellationToken cancellationToken; private readonly CancellationToken cancellationToken;
private readonly Guid agentGuid; private readonly Guid agentGuid;
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;
@@ -76,23 +93,33 @@ sealed class AgentActor : ReceiveActor<AgentActor.ICommand>, IWithTimers {
private readonly Dictionary<Guid, Instance> instanceDataByGuid = new (); private readonly Dictionary<Guid, Instance> instanceDataByGuid = new ();
private AgentActor(Init init) { private AgentActor(Init init) {
this.agentConnectionKeys = init.AgentConnectionKeys;
this.controllerState = init.ControllerState; this.controllerState = init.ControllerState;
this.minecraftVersions = init.MinecraftVersions; this.minecraftVersions = init.MinecraftVersions;
this.dbProvider = init.DbProvider; this.dbProvider = init.DbProvider;
this.cancellationToken = init.CancellationToken; this.cancellationToken = init.CancellationToken;
this.agentGuid = init.AgentGuid; this.agentGuid = init.AgentGuid;
this.authInfo = new AuthInfo(this, init.AuthSecret);
this.configuration = init.AgentConfiguration; this.configuration = init.AgentConfiguration;
this.runtimeInfo = init.AgentRuntimeInfo;
this.connection = new AgentConnection(agentGuid, configuration.AgentName); 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");
if (init.LoggedInUserGuid is {} loggedInUserGuid) {
databaseStorageActor.Tell(new AgentDatabaseStorageActor.StoreAgentConfigurationCommand(loggedInUserGuid, configuration));
}
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);
ReceiveAndReply<GetAuthSecretCommand, AuthSecret>(GetAuthSecret);
Receive<RefreshConnectionStatusCommand>(RefreshConnectionStatus); Receive<RefreshConnectionStatusCommand>(RefreshConnectionStatus);
Receive<NotifyIsAliveCommand>(NotifyIsAlive); Receive<NotifyIsAliveCommand>(NotifyIsAlive);
Receive<UpdateStatsCommand>(UpdateStats); Receive<UpdateStatsCommand>(UpdateStats);
@@ -106,7 +133,7 @@ sealed class AgentActor : ReceiveActor<AgentActor.ICommand>, IWithTimers {
} }
private void NotifyAgentUpdated() { private void NotifyAgentUpdated() {
controllerState.UpdateAgent(new Agent(agentGuid, configuration, stats, ConnectionStatus)); controllerState.UpdateAgent(new Agent(agentGuid, configuration, authInfo.ConnectionKey, runtimeInfo, stats, ConnectionStatus));
} }
protected override void PreStart() { protected override void PreStart() {
@@ -174,12 +201,16 @@ 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;
public sealed record UnregisterCommand : ICommand; public sealed record UnregisterCommand : ICommand;
public sealed record GetAuthSecretCommand : ICommand, ICanReply<AuthSecret>;
private sealed record RefreshConnectionStatusCommand : ICommand; private sealed record RefreshConnectionStatusCommand : ICommand;
public sealed record NotifyIsAliveCommand : ICommand; public sealed record NotifyIsAliveCommand : ICommand;
@@ -227,19 +258,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(configuration.AgentName);
lastPingTime = DateTimeOffset.Now; lastPingTime = DateTimeOffset.Now;
isOnline = true; isOnline = true;
NotifyAgentUpdated(); NotifyAgentUpdated();
Logger.Information("Registered agent \"{Name}\" (GUID {Guid}).", configuration.AgentName, agentGuid); Logger.Information("Registered agent \"{AgentName}\" (GUID {AgentGuid}).", AgentName, agentGuid);
databaseStorageActor.Tell(new AgentDatabaseStorageActor.StoreAgentConfigurationCommand(configuration)); databaseStorageActor.Tell(new AgentDatabaseStorageActor.StoreAgentRuntimeInfoCommand(runtimeInfo));
javaRuntimes = command.JavaRuntimes; javaRuntimes = command.JavaRuntimes;
controllerState.UpdateAgentJavaRuntimes(agentGuid, javaRuntimes); controllerState.UpdateAgentJavaRuntimes(agentGuid, javaRuntimes);
@@ -261,7 +297,11 @@ 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}).", configuration.AgentName, agentGuid); Logger.Information("Unregistered agent \"{AgentName}\" (GUID {AgentGuid}).", AgentName, agentGuid);
}
private AuthSecret GetAuthSecret(GetAuthSecretCommand command) {
return authInfo.Secret;
} }
private void RefreshConnectionStatus(RefreshConnectionStatusCommand command) { private void RefreshConnectionStatus(RefreshConnectionStatusCommand command) {
@@ -269,7 +309,7 @@ sealed class AgentActor : ReceiveActor<AgentActor.ICommand>, IWithTimers {
isOnline = false; isOnline = false;
NotifyAgentUpdated(); NotifyAgentUpdated();
Logger.Warning("Lost connection to agent \"{Name}\" (GUID {Guid}).", configuration.AgentName, agentGuid); Logger.Warning("Lost connection to agent \"{AgentName}\" (GUID {AgentGuid}).", AgentName, agentGuid);
} }
} }
@@ -280,7 +320,7 @@ sealed class AgentActor : ReceiveActor<AgentActor.ICommand>, IWithTimers {
isOnline = true; isOnline = true;
NotifyAgentUpdated(); NotifyAgentUpdated();
Logger.Warning("Restored connection to agent \"{Name}\" (GUID {Guid}).", configuration.AgentName, agentGuid); Logger.Warning("Restored connection to agent \"{AgentName}\" (GUID {AgentGuid}).", AgentName, agentGuid);
} }
} }
@@ -329,19 +369,15 @@ sealed class AgentActor : ReceiveActor<AgentActor.ICommand>, IWithTimers {
var isCreating = command.IsCreatingInstance; var isCreating = command.IsCreatingInstance;
if (result.Is(ConfigureInstanceResult.Success)) { if (result.Is(ConfigureInstanceResult.Success)) {
string action = isCreating ? "Added" : "Edited"; string action = isCreating ? "Created" : "Edited";
string relation = isCreating ? "to agent" : "in agent"; Logger.Information(action + " instance \"{InstanceName}\" (GUID {InstanceGuid}) in agent \"{AgentName}\".", instanceName, instanceGuid, AgentName);
Logger.Information(action + " instance \"{InstanceName}\" (GUID {InstanceGuid}) " + relation + " \"{AgentName}\".", instanceName, instanceGuid, configuration.AgentName);
return CreateOrUpdateInstanceResult.Success; return CreateOrUpdateInstanceResult.Success;
} }
else { else {
string action = isCreating ? "adding" : "editing"; string action = isCreating ? "creating" : "editing";
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}) in agent \"{AgentName}\". {ErrorMessage}", instanceName, instanceGuid, AgentName, reason);
Logger.Information("Failed " + action + " instance \"{InstanceName}\" (GUID {InstanceGuid}) " + relation + " \"{AgentName}\". {ErrorMessage}", instanceName, instanceGuid, configuration.AgentName, reason);
return CreateOrUpdateInstanceResult.UnknownError; return CreateOrUpdateInstanceResult.UnknownError;
} }
@@ -370,4 +406,26 @@ sealed class AgentActor : ReceiveActor<AgentActor.ICommand>, IWithTimers {
private void ReceiveInstanceData(ReceiveInstanceDataCommand command) { private void ReceiveInstanceData(ReceiveInstanceDataCommand command) {
UpdateInstanceData(command.Instance); UpdateInstanceData(command.Instance);
} }
private sealed class AuthInfo {
private readonly AgentActor actor;
public AuthSecret Secret { get; private set; }
public ImmutableArray<byte> ConnectionKey { get; private set; }
public AuthInfo(AgentActor actor, AuthSecret authSecret) {
this.actor = actor;
this.Secret = authSecret;
this.ConnectionKey = CreateConnectionKey(authSecret);
}
public void UpdateSecret(AuthSecret newSecret) {
this.Secret = newSecret;
this.ConnectionKey = CreateConnectionKey(newSecret);
}
private ImmutableArray<byte> CreateConnectionKey(AuthSecret authSecret) {
return actor.agentConnectionKeys.Get(new AuthToken(actor.agentGuid, authSecret)).ToBytes();
}
}
} }
@@ -0,0 +1,11 @@
using Phantom.Common.Data;
using Phantom.Utils.Rpc;
using Phantom.Utils.Rpc.Runtime.Tls;
namespace Phantom.Controller.Services.Agents;
sealed class AgentConnectionKeys(RpcCertificateThumbprint certificateThumbprint) {
public ConnectionKey Get(AuthToken authToken) {
return new ConnectionKey(certificateThumbprint, authToken);
}
}
@@ -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 Serilog; using Serilog;
@@ -22,62 +24,102 @@ sealed class AgentDatabaseStorageActor : ReceiveActor<AgentDatabaseStorageActor.
private readonly IDbContextProvider dbProvider; private readonly IDbContextProvider dbProvider;
private readonly CancellationToken cancellationToken; private readonly CancellationToken cancellationToken;
private AgentConfiguration? configurationToStore; private StoreAgentRuntimeInfoCommand? storeRuntimeInfoCommand;
private bool hasScheduledFlush;
private AgentDatabaseStorageActor(Init init) { private AgentDatabaseStorageActor(Init init) {
this.agentGuid = init.AgentGuid; this.agentGuid = init.AgentGuid;
this.dbProvider = init.DbProvider; this.dbProvider = init.DbProvider;
this.cancellationToken = init.CancellationToken; this.cancellationToken = init.CancellationToken;
Receive<StoreAgentConfigurationCommand>(StoreAgentConfiguration); Receive<StoreAgentRuntimeInfoCommand>(StoreAgentRuntimeInfo);
ReceiveAsync<FlushChangesCommand>(FlushChanges); ReceiveAsync<StoreAgentConfigurationCommand>(StoreAgentConfiguration);
ReceiveAsync<FlushAgentRuntimeInfoCommand>(FlushAgentRuntimeInfo);
}
private ValueTask<AgentEntity?> FindAgentEntity(ILazyDbContext db) {
return db.Ctx.Agents.FindAsync([agentGuid], cancellationToken);
} }
public interface ICommand; public interface ICommand;
public sealed record StoreAgentConfigurationCommand(AgentConfiguration Configuration) : ICommand; public sealed record StoreAgentConfigurationCommand(Guid AuditLogUserGuid, AgentConfiguration Configuration) : ICommand;
private sealed record FlushChangesCommand : ICommand; public sealed record StoreAgentRuntimeInfoCommand(AgentRuntimeInfo RuntimeInfo) : ICommand;
private void StoreAgentConfiguration(StoreAgentConfigurationCommand command) { private sealed record FlushAgentRuntimeInfoCommand : ICommand;
configurationToStore = command.Configuration;
private async Task StoreAgentConfiguration(StoreAgentConfigurationCommand command) {
await FlushAgentRuntimeInfo();
bool wasCreated;
string agentName;
await using (var db = dbProvider.Lazy()) {
var entity = db.Ctx.AgentUpsert.Fetch(agentGuid, out wasCreated);
agentName = entity.Name = command.Configuration.AgentName;
var auditLogWriter = new AuditLogRepository(db).Writer(command.AuditLogUserGuid);
if (wasCreated) {
auditLogWriter.AgentCreated(agentGuid);
}
else {
auditLogWriter.AgentEdited(agentGuid);
}
await db.Ctx.SaveChangesAsync(cancellationToken);
}
string action = wasCreated ? "Created" : "Edited";
Logger.Information(action + " agent \"{AgentName}\" (GUID {AgentGuid}) in database.", agentName, agentGuid);
}
private void StoreAgentRuntimeInfo(StoreAgentRuntimeInfoCommand command) {
storeRuntimeInfoCommand = command;
ScheduleFlush(TimeSpan.FromSeconds(2)); ScheduleFlush(TimeSpan.FromSeconds(2));
} }
private async Task FlushChanges(FlushChangesCommand command) { private void ScheduleFlush(TimeSpan delay) {
hasScheduledFlush = false; if (storeRuntimeInfoCommand != null) {
Timers.StartSingleTimer("FlushChanges", new FlushAgentRuntimeInfoCommand(), delay, Self);
}
}
if (configurationToStore == null) { private Task FlushAgentRuntimeInfo(FlushAgentRuntimeInfoCommand command) {
return FlushAgentRuntimeInfo();
}
private async Task FlushAgentRuntimeInfo() {
if (storeRuntimeInfoCommand == null) {
return; return;
} }
string agentName;
await using (var db = dbProvider.Lazy()) {
var entity = await FindAgentEntity(db);
if (entity == null) {
return;
}
agentName = entity.Name;
try { try {
await using var ctx = dbProvider.Eager(); entity.ProtocolVersion = storeRuntimeInfoCommand.RuntimeInfo.VersionInfo?.ProtocolVersion;
var entity = ctx.AgentUpsert.Fetch(agentGuid); entity.BuildVersion = storeRuntimeInfoCommand.RuntimeInfo.VersionInfo?.BuildVersion;
entity.MaxInstances = storeRuntimeInfoCommand.RuntimeInfo.MaxInstances;
entity.MaxMemory = storeRuntimeInfoCommand.RuntimeInfo.MaxMemory;
entity.Name = configurationToStore.AgentName; await db.Ctx.SaveChangesAsync(cancellationToken);
entity.ProtocolVersion = configurationToStore.ProtocolVersion;
entity.BuildVersion = configurationToStore.BuildVersion;
entity.MaxInstances = configurationToStore.MaxInstances;
entity.MaxMemory = configurationToStore.MaxMemory;
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.", configurationToStore.AgentName, agentGuid); Logger.Error(e, "Could not update agent \"{AgentName}\" (GUID {AgentGuid}) in database.", entity.Name, agentGuid);
return; return;
} }
Logger.Information("Stored agent \"{AgentName}\" (GUID {AgentGuid}) in database.", configurationToStore.AgentName, agentGuid);
configurationToStore = null;
} }
private void ScheduleFlush(TimeSpan delay) { Logger.Information("Updated agent \"{AgentName}\" (GUID {AgentGuid}) in database.", agentName, agentGuid);
if (!hasScheduledFlush) {
hasScheduledFlush = true; storeRuntimeInfoCommand = null;
Timers.StartSingleTimer("FlushChanges", new FlushChangesCommand(), delay, Self);
}
} }
} }
@@ -1,6 +1,7 @@
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Collections.Immutable; using System.Collections.Immutable;
using Akka.Actor; using Akka.Actor;
using Microsoft.EntityFrameworkCore;
using Phantom.Common.Data; using Phantom.Common.Data;
using Phantom.Common.Data.Replies; using Phantom.Common.Data.Replies;
using Phantom.Common.Data.Web.Agent; using Phantom.Common.Data.Web.Agent;
@@ -8,61 +9,76 @@ using Phantom.Common.Data.Web.Users;
using Phantom.Common.Messages.Agent.Handshake; using Phantom.Common.Messages.Agent.Handshake;
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.Users.Sessions; 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;
using Serilog; using Serilog;
namespace Phantom.Controller.Services.Agents; namespace Phantom.Controller.Services.Agents;
sealed class AgentManager { sealed class AgentManager(
IActorRefFactory actorSystem,
AgentConnectionKeys agentConnectionKeys,
ControllerState controllerState,
MinecraftVersions minecraftVersions,
IDbContextProvider dbProvider,
CancellationToken cancellationToken
) {
private static readonly ILogger Logger = PhantomLogger.Create<AgentManager>(); private static readonly ILogger Logger = PhantomLogger.Create<AgentManager>();
private readonly IActorRefFactory actorSystem;
private readonly ControllerState controllerState;
private readonly MinecraftVersions minecraftVersions;
private readonly UserLoginManager userLoginManager;
private readonly IDbContextProvider dbProvider;
private readonly CancellationToken cancellationToken;
private readonly ConcurrentDictionary<Guid, ActorRef<AgentActor.ICommand>> agentsByAgentGuid = new (); private readonly ConcurrentDictionary<Guid, ActorRef<AgentActor.ICommand>> agentsByAgentGuid = new ();
private readonly Func<Guid, AgentConfiguration, ActorRef<AgentActor.ICommand>> addAgentActorFactory;
public AgentManager(IActorRefFactory actorSystem, ControllerState controllerState, MinecraftVersions minecraftVersions, UserLoginManager userLoginManager, IDbContextProvider dbProvider, CancellationToken cancellationToken) {
this.actorSystem = actorSystem;
this.controllerState = controllerState;
this.minecraftVersions = minecraftVersions;
this.userLoginManager = userLoginManager;
this.dbProvider = dbProvider;
this.cancellationToken = cancellationToken;
this.addAgentActorFactory = CreateAgentActor;
}
private ActorRef<AgentActor.ICommand> CreateAgentActor(Guid agentGuid, AgentConfiguration agentConfiguration) {
var init = new AgentActor.Init(agentGuid, agentConfiguration, 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);
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.Name, entity.ProtocolVersion, entity.BuildVersion, entity.MaxInstances, entity.MaxMemory);
if (agentsByAgentGuid.TryAdd(agentGuid, CreateAgentActor(agentGuid, agentConfiguration))) { if (AddAgent(loggedInUserGuid: null, agentGuid, entity.Configuration, entity.AuthSecret!, entity.RuntimeInfo)) {
Logger.Information("Loaded agent \"{AgentName}\" (GUID {AgentGuid}) from database.", agentConfiguration.AgentName, agentGuid); Logger.Information("Loaded agent \"{AgentName}\" (GUID {AgentGuid}) from database.", entity.Name, agentGuid);
} }
} }
} }
public async Task<ImmutableArray<ConfigureInstanceMessage>> RegisterAgent(AgentRegistration registration) { private bool AddAgent(Guid? loggedInUserGuid, Guid agentGuid, AgentConfiguration configuration, AuthSecret authSecret, AgentRuntimeInfo runtimeInfo) {
var agentConfiguration = AgentConfiguration.From(registration.AgentInfo); var init = new AgentActor.Init(loggedInUserGuid, agentGuid, configuration, authSecret, runtimeInfo, agentConnectionKeys, controllerState, minecraftVersions, dbProvider, cancellationToken);
var agentActor = agentsByAgentGuid.GetOrAdd(registration.AgentInfo.AgentGuid, addAgentActorFactory, agentConfiguration); var name = "Agent:" + agentGuid;
return await agentActor.Request(new AgentActor.RegisterCommand(agentConfiguration, registration.JavaRuntimes), cancellationToken); 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) {
if (!agentsByAgentGuid.TryGetValue(agentGuid, out var agentActor)) {
return null;
}
var runtimeInfo = AgentRuntimeInfo.From(registration.AgentInfo);
return await agentActor.Request(new AgentActor.RegisterCommand(runtimeInfo, registration.JavaRuntimes), cancellationToken);
}
public async Task<AuthSecret?> GetAgentAuthSecret(Guid agentGuid) {
if (agentsByAgentGuid.TryGetValue(agentGuid, out var agent)) {
return await agent.Request(new AgentActor.GetAuthSecretCommand(), cancellationToken);
}
else {
return null;
}
} }
public bool TellAgent(Guid agentGuid, AgentActor.ICommand command) { public bool TellAgent(Guid agentGuid, AgentActor.ICommand command) {
@@ -71,13 +87,31 @@ 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 unknown agent {AgentGuid}.", command.GetType().Name, agentGuid);
return false; return false;
} }
} }
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>> { public Result<CreateOrUpdateAgentResult, UserActionFailure> CreateOrUpdateAgent(LoggedInUser loggedInUser, Guid agentGuid, AgentConfiguration configuration) {
var loggedInUser = userLoginManager.GetLoggedInUser(authToken); if (!loggedInUser.CheckPermission(Permission.ManageAllAgents)) {
return UserActionFailure.NotAuthorized;
}
if (configuration.AgentName.Length == 0) {
return CreateOrUpdateAgentResult.AgentNameMustNotBeEmpty;
}
if (agentsByAgentGuid.TryGetValue(agentGuid, out var agent)) {
agent.Tell(new AgentActor.ConfigureAgentCommand(loggedInUser.Guid!.Value, configuration));
}
else {
AddAgent(loggedInUser.Guid!.Value, agentGuid, configuration, AuthSecret.Generate(), new AgentRuntimeInfo());
}
return CreateOrUpdateAgentResult.Success;
}
public async Task<Result<TReply, UserInstanceActionFailure>> DoInstanceAction<TCommand, TReply>(LoggedInUser loggedInUser, Permission requiredPermission, Guid agentGuid, Func<Guid, TCommand> commandFactoryFromLoggedInUserGuid) where TCommand : class, AgentActor.ICommand, ICanReply<Result<TReply, InstanceActionFailure>> {
if (!loggedInUser.HasAccessToAgent(agentGuid) || !loggedInUser.CheckPermission(requiredPermission)) { if (!loggedInUser.HasAccessToAgent(agentGuid) || !loggedInUser.CheckPermission(requiredPermission)) {
return (UserInstanceActionFailure) UserActionFailure.NotAuthorized; return (UserInstanceActionFailure) UserActionFailure.NotAuthorized;
} }
@@ -8,8 +8,10 @@ using Phantom.Controller.Services.Rpc;
using Phantom.Controller.Services.Users; using Phantom.Controller.Services.Users;
using Phantom.Controller.Services.Users.Sessions; using Phantom.Controller.Services.Users.Sessions;
using Phantom.Utils.Actor; using Phantom.Utils.Actor;
using IRpcAgentRegistrar = Phantom.Utils.Rpc.Runtime.Server.IRpcServerClientRegistrar<Phantom.Common.Messages.Agent.IMessageToController, Phantom.Common.Messages.Agent.IMessageToAgent, Phantom.Common.Data.Agent.AgentInfo>; using Phantom.Utils.Rpc.Runtime.Server;
using IRpcWebRegistrar = Phantom.Utils.Rpc.Runtime.Server.IRpcServerClientRegistrar<Phantom.Common.Messages.Web.IMessageToController, Phantom.Common.Messages.Web.IMessageToWeb, Phantom.Utils.Rpc.Runtime.Server.RpcServerClientHandshake.NoValue>; using Phantom.Utils.Rpc.Runtime.Tls;
using IRpcAgentRegistrar = Phantom.Utils.Rpc.Runtime.Server.IRpcServerClientRegistrar<Phantom.Common.Messages.Agent.IMessageToController, Phantom.Common.Messages.Agent.IMessageToAgent>;
using IRpcWebRegistrar = Phantom.Utils.Rpc.Runtime.Server.IRpcServerClientRegistrar<Phantom.Common.Messages.Web.IMessageToController, Phantom.Common.Messages.Web.IMessageToWeb>;
namespace Phantom.Controller.Services; namespace Phantom.Controller.Services;
@@ -32,14 +34,15 @@ public sealed class ControllerServices : IDisposable {
private AuditLogManager AuditLogManager { get; } private AuditLogManager AuditLogManager { get; }
private EventLogManager EventLogManager { get; } private EventLogManager EventLogManager { get; }
public IRpcServerClientAuthProvider AgentAuthProvider { get; }
public IRpcServerClientHandshake AgentHandshake { get; }
public IRpcAgentRegistrar AgentRegistrar { get; } public IRpcAgentRegistrar AgentRegistrar { get; }
public AgentClientHandshake AgentHandshake { get; }
public IRpcWebRegistrar WebRegistrar { get; } public IRpcWebRegistrar WebRegistrar { get; }
private readonly IDbContextProvider dbProvider; private readonly IDbContextProvider dbProvider;
private readonly CancellationToken cancellationToken; private readonly CancellationToken cancellationToken;
public ControllerServices(IDbContextProvider dbProvider, CancellationToken shutdownCancellationToken) { public ControllerServices(IDbContextProvider dbProvider, RpcCertificateThumbprint agentCertificateThumbprint, CancellationToken shutdownCancellationToken) {
this.dbProvider = dbProvider; this.dbProvider = dbProvider;
this.cancellationToken = shutdownCancellationToken; this.cancellationToken = shutdownCancellationToken;
@@ -55,14 +58,15 @@ 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, 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);
this.EventLogManager = new EventLogManager(ControllerState, ActorSystem, dbProvider, shutdownCancellationToken); this.EventLogManager = new EventLogManager(ControllerState, ActorSystem, dbProvider, shutdownCancellationToken);
this.AgentRegistrar = new AgentClientRegistrar(ActorSystem, AgentManager, InstanceLogManager, EventLogManager); this.AgentAuthProvider = new AgentClientAuthProvider(AgentManager);
this.AgentHandshake = new AgentClientHandshake(AgentManager); this.AgentHandshake = new AgentClientHandshake(AgentManager);
this.AgentRegistrar = new AgentClientRegistrar(ActorSystem, AgentManager, InstanceLogManager, EventLogManager);
this.WebRegistrar = new WebClientRegistrar(ActorSystem, ControllerState, InstanceLogManager, UserManager, RoleManager, UserRoleManager, UserLoginManager, AuditLogManager, AgentManager, MinecraftVersions, EventLogManager); this.WebRegistrar = new WebClientRegistrar(ActorSystem, ControllerState, InstanceLogManager, UserManager, RoleManager, UserRoleManager, UserLoginManager, AuditLogManager, AgentManager, MinecraftVersions, EventLogManager);
} }
@@ -0,0 +1,11 @@
using Phantom.Controller.Services.Agents;
using Phantom.Utils.Rpc;
using Phantom.Utils.Rpc.Runtime.Server;
namespace Phantom.Controller.Services.Rpc;
sealed class AgentClientAuthProvider(AgentManager agentManager) : IRpcServerClientAuthProvider {
public Task<AuthSecret?> GetAuthSecret(Guid agentGuid) {
return agentManager.GetAgentAuthSecret(agentGuid);
}
}
@@ -1,5 +1,4 @@
using System.Collections.Immutable; using System.Collections.Immutable;
using Phantom.Common.Data.Agent;
using Phantom.Common.Messages.Agent.Handshake; using Phantom.Common.Messages.Agent.Handshake;
using Phantom.Common.Messages.Agent.ToAgent; using Phantom.Common.Messages.Agent.ToAgent;
using Phantom.Controller.Services.Agents; using Phantom.Controller.Services.Agents;
@@ -10,7 +9,7 @@ using Phantom.Utils.Rpc.Runtime.Server;
namespace Phantom.Controller.Services.Rpc; namespace Phantom.Controller.Services.Rpc;
public sealed class AgentClientHandshake : IRpcServerClientHandshake<AgentInfo> { sealed class AgentClientHandshake : IRpcServerClientHandshake {
private const int MaxRegistrationBytes = 1024 * 1024 * 8; private const int MaxRegistrationBytes = 1024 * 1024 * 8;
private readonly AgentManager agentManager; private readonly AgentManager agentManager;
@@ -19,9 +18,9 @@ public sealed class AgentClientHandshake : IRpcServerClientHandshake<AgentInfo>
this.agentManager = agentManager; this.agentManager = agentManager;
} }
public async Task<Either<AgentInfo, Exception>> Perform(bool isNewSession, RpcStream stream, CancellationToken cancellationToken) { public async Task Perform(bool isNewSession, RpcStream stream, Guid agentGuid, CancellationToken cancellationToken) {
RegistrationResult registrationResult; RegistrationResult registrationResult;
switch (await RegisterAgent(stream, cancellationToken)) { switch (await RegisterAgent(stream, agentGuid, cancellationToken)) {
case Left<RegistrationResult, Exception>(var result): case Left<RegistrationResult, Exception>(var result):
await stream.WriteByte(value: 1, cancellationToken); await stream.WriteByte(value: 1, cancellationToken);
registrationResult = result; registrationResult = result;
@@ -29,11 +28,11 @@ public sealed class AgentClientHandshake : IRpcServerClientHandshake<AgentInfo>
case Right<RegistrationResult, Exception>(var exception): case Right<RegistrationResult, Exception>(var exception):
await stream.WriteByte(value: 0, cancellationToken); await stream.WriteByte(value: 0, cancellationToken);
return Either.Right(exception); throw exception;
default: default:
await stream.WriteByte(value: 0, cancellationToken); await stream.WriteByte(value: 0, cancellationToken);
return Either.Right<Exception>(new InvalidOperationException("Invalid result type.")); throw new InvalidOperationException("Invalid result type.");
} }
if (isNewSession) { if (isNewSession) {
@@ -50,11 +49,9 @@ public sealed class AgentClientHandshake : IRpcServerClientHandshake<AgentInfo>
} }
await stream.Flush(cancellationToken); await stream.Flush(cancellationToken);
return Either.Left(registrationResult.AgentInfo);
} }
private async Task<Either<RegistrationResult, Exception>> RegisterAgent(RpcStream stream, CancellationToken cancellationToken) { private async Task<Either<RegistrationResult, Exception>> RegisterAgent(RpcStream stream, Guid agentGuid, CancellationToken cancellationToken) {
int serializedRegistrationLength = await stream.ReadSignedInt(cancellationToken); int serializedRegistrationLength = await stream.ReadSignedInt(cancellationToken);
if (serializedRegistrationLength is < 0 or > MaxRegistrationBytes) { if (serializedRegistrationLength is < 0 or > MaxRegistrationBytes) {
return Either.Right<Exception>(new InvalidOperationException("Registration must be between 0 and " + MaxRegistrationBytes + " bytes.")); return Either.Right<Exception>(new InvalidOperationException("Registration must be between 0 and " + MaxRegistrationBytes + " bytes."));
@@ -69,9 +66,13 @@ public sealed class AgentClientHandshake : IRpcServerClientHandshake<AgentInfo>
return Either.Right<Exception>(new InvalidOperationException("Caught exception during deserialization.", e)); return Either.Right<Exception>(new InvalidOperationException("Caught exception during deserialization.", e));
} }
var configureInstanceMessages = await agentManager.RegisterAgent(registration); var configureInstanceMessages = await agentManager.RegisterAgent(agentGuid, registration);
return Either.Left(new RegistrationResult(registration.AgentInfo, configureInstanceMessages)); if (configureInstanceMessages == null) {
return Either.Right<Exception>(new InvalidOperationException("Could not register agent."));
} }
private readonly record struct RegistrationResult(AgentInfo AgentInfo, ImmutableArray<ConfigureInstanceMessage> ConfigureInstanceMessages); return Either.Left(new RegistrationResult(configureInstanceMessages.Value));
}
private readonly record struct RegistrationResult(ImmutableArray<ConfigureInstanceMessage> ConfigureInstanceMessages);
} }
@@ -1,7 +1,5 @@
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Diagnostics.CodeAnalysis;
using Akka.Actor; using Akka.Actor;
using Phantom.Common.Data.Agent;
using Phantom.Common.Messages.Agent; using Phantom.Common.Messages.Agent;
using Phantom.Controller.Services.Agents; using Phantom.Controller.Services.Agents;
using Phantom.Controller.Services.Events; using Phantom.Controller.Services.Events;
@@ -12,20 +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, AgentInfo> {
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, AgentInfo handshakeResult) { this.actorSystem = actorSystem;
var agentGuid = handshakeResult.AgentGuid; 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.SessionId, 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!");
} }
@@ -33,8 +40,8 @@ sealed class AgentClientRegistrar(
return receiver; return receiver;
} }
private Receiver CreateReceiver(Guid sessionId, Guid agentGuid) { private Receiver CreateReceiver(Guid sessionGuid, Guid agentGuid) {
var name = "AgentClient-" + sessionId; var name = "AgentClient-" + sessionGuid;
var init = new AgentMessageHandlerActor.Init(agentGuid, agentManager, instanceLogManager, eventLogManager); var init = new AgentMessageHandlerActor.Init(agentGuid, agentManager, instanceLogManager, eventLogManager);
return new Receiver(agentGuid, agentManager, actorSystem.ActorOf(AgentMessageHandlerActor.Factory(init), name)); return new Receiver(agentGuid, agentManager, actorSystem.ActorOf(AgentMessageHandlerActor.Factory(init), name));
} }
@@ -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);
} }
} }
@@ -0,0 +1,10 @@
using Phantom.Utils.Rpc;
using Phantom.Utils.Rpc.Runtime.Server;
namespace Phantom.Controller.Services.Rpc;
public sealed class WebClientAuthProvider(AuthToken webAuthToken) : IRpcServerClientAuthProvider {
public Task<AuthSecret?> GetAuthSecret(Guid clientGuid) {
return Task.FromResult(clientGuid == webAuthToken.Guid ? webAuthToken.Secret : null);
}
}
@@ -24,9 +24,9 @@ sealed class WebClientRegistrar(
AgentManager agentManager, AgentManager agentManager,
MinecraftVersions minecraftVersions, MinecraftVersions minecraftVersions,
EventLogManager eventLogManager EventLogManager eventLogManager
) : IRpcServerClientRegistrar<IMessageToController, IMessageToWeb, RpcServerClientHandshake.NoValue> { ) : IRpcServerClientRegistrar<IMessageToController, IMessageToWeb> {
public IMessageReceiver<IMessageToController> Register(RpcServerToClientConnection<IMessageToController, IMessageToWeb> connection, RpcServerClientHandshake.NoValue handshakeResult) { public IMessageReceiver<IMessageToController> Register(RpcServerToClientConnection<IMessageToController, IMessageToWeb> connection) {
var name = "WebClient-" + connection.SessionId; 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));
} }
@@ -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); ReceiveAndReply<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 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);
} }
} }
@@ -0,0 +1,77 @@
using Phantom.Common.Data;
using Phantom.Utils.Cryptography;
using Phantom.Utils.IO;
using Phantom.Utils.Logging;
using Phantom.Utils.Rpc;
using Phantom.Utils.Rpc.Runtime.Tls;
using Serilog;
namespace Phantom.Controller;
abstract class AuthTokenFile {
private static ILogger Logger { get; } = PhantomLogger.Create<AuthTokenFile>();
private readonly string fileName;
private readonly RpcServerCertificate certificate;
private AuthTokenFile(string name, RpcServerCertificate certificate) {
this.fileName = name + ".auth";
this.certificate = certificate;
}
public async Task<ConnectionKey?> CreateOrLoad(string folderPath) {
string filePath = Path.Combine(folderPath, fileName);
if (File.Exists(filePath)) {
try {
return await ReadKeyFiles(filePath);
} catch (IOException e) {
Logger.Fatal(e, "Error reading auth token file: {FileName}", fileName);
return null;
} catch (Exception) {
Logger.Fatal("Auth token file contains invalid data: {FileName}", fileName);
return null;
}
}
try {
return await GenerateKeyFiles(filePath);
} catch (Exception e) {
Logger.Fatal(e, "Error creating auth token file: {FileName}", fileName);
return null;
}
}
private async Task<ConnectionKey?> ReadKeyFiles(string filePath) {
var authToken = AuthToken.FromBytes(await ReadKeyFile(filePath));
Logger.Information("Loaded auth token file: {FileName}", fileName);
var connectionKey = new ConnectionKey(certificate.Thumbprint, authToken);
LogConnectionKey(TokenGenerator.EncodeBytes(connectionKey.ToBytes().AsSpan()));
return connectionKey;
}
private static Task<byte[]> ReadKeyFile(string filePath) {
Files.RequireMaximumFileSize(filePath, maximumBytes: 64);
return File.ReadAllBytesAsync(filePath);
}
private async Task<ConnectionKey> GenerateKeyFiles(string filePath) {
var authToken = AuthToken.Generate();
await Files.WriteBytesAsync(filePath, authToken.ToBytes().AsMemory(), FileMode.Create, Chmod.URW_GR);
Logger.Information("Created auth token file: {FileName}", fileName);
var connectionKey = new ConnectionKey(certificate.Thumbprint, authToken);
LogConnectionKey(TokenGenerator.EncodeBytes(connectionKey.ToBytes().AsSpan()));
return connectionKey;
}
protected abstract void LogConnectionKey(string commonKeyEncoded);
internal sealed class Web(string name, RpcServerCertificate certificate) : AuthTokenFile(name, certificate) {
protected override void LogConnectionKey(string commonKeyEncoded) {
Logger.Information("Web key: {WebKey}", commonKeyEncoded);
}
}
}
@@ -0,0 +1,60 @@
using Phantom.Utils.IO;
using Phantom.Utils.Logging;
using Phantom.Utils.Monads;
using Phantom.Utils.Rpc.Runtime.Tls;
using Serilog;
namespace Phantom.Controller;
sealed class CertificateFile(string name) {
private static ILogger Logger { get; } = PhantomLogger.Create<CertificateFile>();
private readonly string fileName = name + ".pfx";
public async Task<RpcServerCertificate?> CreateOrLoad(string folderPath) {
string filePath = Path.Combine(folderPath, fileName);
if (File.Exists(filePath)) {
try {
return Read(filePath);
} catch (IOException e) {
Logger.Fatal(e, "Error reading certificate file: {FileName}", fileName);
return null;
} catch (Exception) {
Logger.Fatal("Certificate file contains invalid data: {FileName}", fileName);
return null;
}
}
try {
return await Generate(filePath);
} catch (Exception e) {
Logger.Fatal(e, "Error creating certificate file: {FileName}", fileName);
return null;
}
}
private RpcServerCertificate? Read(string filePath) {
switch (RpcServerCertificate.Load(filePath)) {
case Left<RpcServerCertificate, DisallowedAlgorithmError>(var rpcServerCertificate):
Logger.Information("Loaded certificate file: {FileName}", fileName);
return rpcServerCertificate;
case Right<RpcServerCertificate, DisallowedAlgorithmError>(var error):
Logger.Fatal("Certificate file {FileName} was expected to use {ExpectedAlgorithmName}, instead it uses {ActualAlgorithmName}.", fileName, error.ExpectedAlgorithmName, error.ActualAlgorithmName);
return null;
}
Logger.Fatal("Certificate file could not be loaded: {FileName}", fileName);
return null;
}
private async Task<RpcServerCertificate> Generate(string filePath) {
byte[] certificateBytes = RpcServerCertificate.CreateAndExport("phantom-controller");
await Files.WriteBytesAsync(filePath, certificateBytes, FileMode.Create, Chmod.URW_GR);
Logger.Information("Created certificate file: {FileName}", fileName);
return RpcServerCertificate.Load(filePath).RequireLeft;
}
}
@@ -1,6 +0,0 @@
using Phantom.Utils.Rpc;
using Phantom.Utils.Rpc.Runtime.Tls;
namespace Phantom.Controller;
readonly record struct ConnectionKeyData(RpcServerCertificate Certificate, AuthToken AuthToken);
@@ -1,114 +0,0 @@
using Phantom.Common.Data;
using Phantom.Utils.Cryptography;
using Phantom.Utils.IO;
using Phantom.Utils.Logging;
using Phantom.Utils.Monads;
using Phantom.Utils.Rpc;
using Phantom.Utils.Rpc.Runtime.Tls;
using Serilog;
namespace Phantom.Controller;
abstract class ConnectionKeyFiles {
private readonly ILogger logger;
private readonly string certificateFileName;
private readonly string authTokenFileName;
private ConnectionKeyFiles(ILogger logger, string name) {
this.logger = logger;
this.certificateFileName = name + ".pfx";
this.authTokenFileName = name + ".auth";
}
public async Task<ConnectionKeyData?> CreateOrLoad(string folderPath) {
string certificateFilePath = Path.Combine(folderPath, certificateFileName);
string authTokenFilePath = Path.Combine(folderPath, authTokenFileName);
bool certificateFileExists = File.Exists(certificateFilePath);
bool authTokenFileExists = File.Exists(authTokenFilePath);
if (certificateFileExists && authTokenFileExists) {
try {
return await ReadKeyFiles(certificateFilePath, authTokenFilePath);
} catch (IOException e) {
logger.Fatal(e, "Error reading connection key files.");
return null;
} catch (Exception) {
logger.Fatal("Connection key files contain invalid data.");
return null;
}
}
if (certificateFileExists || authTokenFileExists) {
string existingKeyFilePath = certificateFileExists ? certificateFilePath : authTokenFilePath;
string missingKeyFileName = certificateFileExists ? authTokenFileName : certificateFileName;
logger.Fatal("Connection key file {ExistingKeyFilePath} exists but {MissingKeyFileName} does not. Please delete it to regenerate both files.", existingKeyFilePath, missingKeyFileName);
return null;
}
logger.Information("Creating connection key files in: {FolderPath}", folderPath);
try {
return await GenerateKeyFiles(certificateFilePath, authTokenFilePath);
} catch (Exception e) {
logger.Fatal(e, "Error creating connection key files.");
return null;
}
}
private async Task<ConnectionKeyData?> ReadKeyFiles(string certificateFilePath, string authTokenFilePath) {
RpcServerCertificate certificate = null!;
switch (RpcServerCertificate.Load(certificateFilePath)) {
case Left<RpcServerCertificate, DisallowedAlgorithmError>(var rpcServerCertificate):
certificate = rpcServerCertificate;
break;
case Right<RpcServerCertificate, DisallowedAlgorithmError>(var error):
logger.Fatal("Certificate {CertificateFilePath} was expected to use {ExpectedAlgorithmName}, instead it uses {ActualAlgorithmName}.", certificateFilePath, error.ExpectedAlgorithmName, error.ActualAlgorithmName);
return null;
}
var authToken = new AuthToken([..await ReadKeyFile(authTokenFilePath)]);
logger.Information("Loaded connection key files.");
var connectionKey = new ConnectionKey(certificate.Thumbprint, authToken);
LogCommonKey(TokenGenerator.EncodeBytes(connectionKey.ToBytes()));
return new ConnectionKeyData(certificate, authToken);
}
private static Task<byte[]> ReadKeyFile(string filePath) {
Files.RequireMaximumFileSize(filePath, maximumBytes: 64);
return File.ReadAllBytesAsync(filePath);
}
private async Task<ConnectionKeyData> GenerateKeyFiles(string certificateFilePath, string authTokenFilePath) {
var certificateBytes = RpcServerCertificate.CreateAndExport("phantom-controller");
var authToken = AuthToken.Generate();
await Files.WriteBytesAsync(certificateFilePath, certificateBytes, FileMode.Create, Chmod.URW_GR);
await Files.WriteBytesAsync(authTokenFilePath, authToken.Bytes.ToArray(), FileMode.Create, Chmod.URW_GR);
logger.Information("Created new connection key files.");
var certificate = RpcServerCertificate.Load(certificateFilePath).RequireLeft;
var connectionKey = new ConnectionKey(certificate.Thumbprint, authToken);
LogCommonKey(TokenGenerator.EncodeBytes(connectionKey.ToBytes()));
return new ConnectionKeyData(certificate, authToken);
}
protected abstract void LogCommonKey(string commonKeyEncoded);
internal sealed class Agent() : ConnectionKeyFiles(PhantomLogger.Create<ConnectionKeyFiles, Agent>(), "agent") {
protected override void LogCommonKey(string commonKeyEncoded) {
logger.Information("Agent key: {AgentKey}", commonKeyEncoded);
}
}
internal sealed class Web() : ConnectionKeyFiles(PhantomLogger.Create<ConnectionKeyFiles, Web>(), "web") {
protected override void LogCommonKey(string commonKeyEncoded) {
logger.Information("Web key: {WebKey}", commonKeyEncoded);
}
}
}
+18 -12
View File
@@ -4,13 +4,14 @@ using Phantom.Common.Messages.Web;
using Phantom.Controller; using Phantom.Controller;
using Phantom.Controller.Database.Postgres; using Phantom.Controller.Database.Postgres;
using Phantom.Controller.Services; using Phantom.Controller.Services;
using Phantom.Controller.Services.Rpc;
using Phantom.Utils.IO; using Phantom.Utils.IO;
using Phantom.Utils.Logging; using Phantom.Utils.Logging;
using Phantom.Utils.Rpc.Runtime.Server; using Phantom.Utils.Rpc.Runtime.Server;
using Phantom.Utils.Runtime; using Phantom.Utils.Runtime;
using Phantom.Utils.Tasks; using Phantom.Utils.Tasks;
using RpcAgentServer = Phantom.Utils.Rpc.Runtime.Server.RpcServer<Phantom.Common.Messages.Agent.IMessageToController, Phantom.Common.Messages.Agent.IMessageToAgent, Phantom.Common.Data.Agent.AgentInfo>; using RpcAgentServer = Phantom.Utils.Rpc.Runtime.Server.RpcServer<Phantom.Common.Messages.Agent.IMessageToController, Phantom.Common.Messages.Agent.IMessageToAgent>;
using RpcWebServer = Phantom.Utils.Rpc.Runtime.Server.RpcServer<Phantom.Common.Messages.Web.IMessageToController, Phantom.Common.Messages.Web.IMessageToWeb, Phantom.Utils.Rpc.Runtime.Server.RpcServerClientHandshake.NoValue>; using RpcWebServer = Phantom.Utils.Rpc.Runtime.Server.RpcServer<Phantom.Common.Messages.Web.IMessageToController, Phantom.Common.Messages.Web.IMessageToWeb>;
var shutdownCancellationTokenSource = new CancellationTokenSource(); var shutdownCancellationTokenSource = new CancellationTokenSource();
var shutdownCancellationToken = shutdownCancellationTokenSource.Token; var shutdownCancellationToken = shutdownCancellationTokenSource.Token;
@@ -43,12 +44,17 @@ try {
string secretsPath = Path.GetFullPath("./secrets"); string secretsPath = Path.GetFullPath("./secrets");
CreateFolderOrStop(secretsPath, Chmod.URWX_GRX); CreateFolderOrStop(secretsPath, Chmod.URWX_GRX);
var agentKeyDataResult = await new ConnectionKeyFiles.Agent().CreateOrLoad(secretsPath); var agentCertificate = await new CertificateFile("agent").CreateOrLoad(secretsPath);
if (agentKeyDataResult is not {} agentKeyData) { if (agentCertificate == null) {
return 1; return 1;
} }
var webKeyDataResult = await new ConnectionKeyFiles.Web().CreateOrLoad(secretsPath); var webCertificate = await new CertificateFile("web").CreateOrLoad(secretsPath);
if (webCertificate == null) {
return 1;
}
var webKeyDataResult = await new AuthTokenFile.Web("web", webCertificate).CreateOrLoad(secretsPath);
if (webKeyDataResult is not {} webKeyData) { if (webKeyDataResult is not {} webKeyData) {
return 1; return 1;
} }
@@ -57,13 +63,12 @@ try {
var dbContextFactory = new ApplicationDbContextFactory(sqlConnectionString); var dbContextFactory = new ApplicationDbContextFactory(sqlConnectionString);
using var controllerServices = new ControllerServices(dbContextFactory, shutdownCancellationToken); using var controllerServices = new ControllerServices(dbContextFactory, agentCertificate.Thumbprint, shutdownCancellationToken);
await controllerServices.Initialize(); await controllerServices.Initialize();
var agentConnectionParameters = new RpcServerConnectionParameters( var agentConnectionParameters = new RpcServerConnectionParameters(
EndPoint: agentRpcServerHost, EndPoint: agentRpcServerHost,
Certificate: agentKeyData.Certificate, Certificate: agentCertificate,
AuthToken: agentKeyData.AuthToken,
PingIntervalSeconds: 10, PingIntervalSeconds: 10,
MessageQueueCapacity: 50, MessageQueueCapacity: 50,
FrameQueueCapacity: 100, FrameQueueCapacity: 100,
@@ -72,17 +77,18 @@ try {
var webConnectionParameters = new RpcServerConnectionParameters( var webConnectionParameters = new RpcServerConnectionParameters(
EndPoint: webRpcServerHost, EndPoint: webRpcServerHost,
Certificate: webKeyData.Certificate, Certificate: webCertificate,
AuthToken: webKeyData.AuthToken,
PingIntervalSeconds: 60, PingIntervalSeconds: 60,
MessageQueueCapacity: 250, MessageQueueCapacity: 250,
FrameQueueCapacity: 500, FrameQueueCapacity: 500,
MaxConcurrentlyHandledMessages: 100 MaxConcurrentlyHandledMessages: 100
); );
var webClientAuthProvider = new WebClientAuthProvider(webKeyData.AuthToken);
var rpcServerTasks = new LinkedTasks<bool>([ var rpcServerTasks = new LinkedTasks<bool>([
new RpcAgentServer("Agent", agentConnectionParameters, AgentMessageRegistries.Registries, controllerServices.AgentHandshake, controllerServices.AgentRegistrar).Run(shutdownCancellationToken), new RpcAgentServer("Agent", agentConnectionParameters, AgentMessageRegistries.Registries, controllerServices.AgentAuthProvider, controllerServices.AgentHandshake, controllerServices.AgentRegistrar).Run(shutdownCancellationToken),
new RpcWebServer("Web", webConnectionParameters, WebMessageRegistries.Registries, new RpcServerClientHandshake.NoOp(), controllerServices.WebRegistrar).Run(shutdownCancellationToken), new RpcWebServer("Web", webConnectionParameters, WebMessageRegistries.Registries, webClientAuthProvider, new IRpcServerClientHandshake.NoOp(), controllerServices.WebRegistrar).Run(shutdownCancellationToken),
]); ]);
// If either RPC server crashes, stop the whole process. // If either RPC server crashes, stop the whole process.
+11 -11
View File
@@ -45,11 +45,13 @@ The Controller comprises 3 key areas:
The configuration for these is set via environment variables. The configuration for these is set via environment variables.
### Agent & Web Keys ### Secrets
When the Controller starts for the first time, it will generate two certificate files (`agent.pfx` and `web.pfx`), which are used for TLS communication, and two authentication token files (`agent.auth` and `web.auth`). These files must only be accessible to the Controller itself. Each Agent requires its own **Agent Key**, and the Web server requires a **Web Key**. These must be passed to the services in an environment variable or a file.
On every start, the Controller prints the **Agent Key** and **Web Key** to standard output. These keys contain the authentication token, which lets the Controller validate the identity of the connecting service, and a certificate signature, which lets the connecting service validate the identity of the Controller. The keys must be passed to the Agent and Web services using an environment variable or a file. When the Controller starts for the first time, it will generate two certificate files (`agent.pfx` and `web.pfx`), which are used for TLS communication, and a Web authentication token file (`web.auth`). These files must only be accessible to the Controller itself.
Since there is only one Web server, there is only one **Web Key**, which is generated from the Web certificate and authentication token files. The Controller prints the **Web Key** to standard output on every start. Agents and their **Agent Keys** are managed through the Web interface, and their authentication tokens are stored in the database.
### Storage ### Storage
@@ -86,7 +88,6 @@ Use volumes to persist either the whole `/data` folder, or just `/data/data` if
* **Controller Communication** * **Controller Communication**
- `CONTROLLER_HOST` is the hostname of the Controller. - `CONTROLLER_HOST` is the hostname of the Controller.
- `CONTROLLER_PORT` is the Agent RPC port of the Controller. Default: `9401` - `CONTROLLER_PORT` is the Agent RPC port of the Controller. Default: `9401`
- `AGENT_NAME` is the display name of the Agent. Emoji are allowed.
- `AGENT_KEY` is the [Agent Key](#agent--web-keys). Mutually exclusive with `AGENT_KEY_FILE`. - `AGENT_KEY` is the [Agent Key](#agent--web-keys). Mutually exclusive with `AGENT_KEY_FILE`.
- `AGENT_KEY_FILE` is a path to a file containing the [Agent Key](#agent--web-keys). Mutually exclusive with `AGENT_KEY`. - `AGENT_KEY_FILE` is a path to a file containing the [Agent Key](#agent--web-keys). Mutually exclusive with `AGENT_KEY`.
* **Agent Configuration** * **Agent Configuration**
@@ -130,7 +131,7 @@ If the environment variable is omitted, the log level is set to `VERBOSE` for De
# Development # Development
The repository includes a [Rider](https://www.jetbrains.com/rider/) projects with several run configurations. The `.workdir` folder in the root of the repository is used for storage. Here's how to get started: The repository includes a [Rider](https://www.jetbrains.com/rider/) projects with several run configurations. The `.workdir` folder in the root of the repository is used for storage, including secret files intended for development use only. Here's how to get started:
1. You will need a local PostgreSQL instance. If you have [Docker](https://www.docker.com/), you can enter the `Docker` folder in this repository, and run `docker compose up`. Otherwise, you will need to set it up manually with the following configuration: 1. You will need a local PostgreSQL instance. If you have [Docker](https://www.docker.com/), you can enter the `Docker` folder in this repository, and run `docker compose up`. Otherwise, you will need to set it up manually with the following configuration:
- Host: `localhost` - Host: `localhost`
@@ -139,12 +140,11 @@ The repository includes a [Rider](https://www.jetbrains.com/rider/) projects wit
- Password: `development` - Password: `development`
- Database: `postgres` - Database: `postgres`
2. Install one or more Java versions into the `~/.jdks` folder (`%USERPROFILE%\.jdks` on Windows). 2. Install one or more Java versions into the `~/.jdks` folder (`%USERPROFILE%\.jdks` on Windows).
3. Open the project in [Rider](https://www.jetbrains.com/rider/) and use one of the provided run configurations: 3. Open the project in [Rider](https://www.jetbrains.com/rider/).
- `Controller` starts the Controller. 4. Launch the `Controller` and `Web` run configurations.
- `Web` starts the Web server. 5. Open the website and create an account.
- `Agent 1`, `Agent 2`, `Agent 3` start one of the Agents. 6. Create 1-3 Agents on the website. For each, create a `.workdir/AgentX/key` file containing the respective Agent Key.
- `Controller + Web + Agent` starts the Controller and Agent 1. 7. Launch any of the `Agent 1`, `Agent 2`, `Agent 3` run configurations.
- `Controller + Web + Agent x3` starts the Controller and Agent 1, 2, and 3.
## Bootstrap ## Bootstrap
+30
View File
@@ -0,0 +1,30 @@
using System.Collections.Immutable;
using System.Security.Cryptography;
namespace Phantom.Utils.Rpc;
public sealed class AuthSecret {
public const int Length = 12;
public ImmutableArray<byte> Bytes { get; }
public AuthSecret(ImmutableArray<byte> bytes) {
if (bytes.Length != Length) {
throw new ArgumentOutOfRangeException(nameof(bytes), "Invalid auth secret length: " + bytes.Length + ". Auth secret must be exactly " + Length + " bytes.");
}
this.Bytes = bytes;
}
internal bool FixedTimeEquals(AuthSecret provided) {
return FixedTimeEquals(provided.Bytes.AsSpan());
}
internal bool FixedTimeEquals(ReadOnlySpan<byte> other) {
return CryptographicOperations.FixedTimeEquals(Bytes.AsSpan(), other);
}
public static AuthSecret Generate() {
return new AuthSecret([..RandomNumberGenerator.GetBytes(Length)]);
}
}
+20 -15
View File
@@ -1,30 +1,35 @@
using System.Collections.Immutable; using System.Collections.Immutable;
using System.Security.Cryptography;
namespace Phantom.Utils.Rpc; namespace Phantom.Utils.Rpc;
public sealed class AuthToken { public sealed record AuthToken(Guid Guid, AuthSecret Secret) {
public const int Length = 12; public const int Length = Serialization.GuidBytes + AuthSecret.Length;
public ImmutableArray<byte> Bytes { get; } public ImmutableArray<byte> ToBytes() {
Span<byte> buffer = stackalloc byte[Length];
ToBytes(buffer);
return [..buffer];
}
public AuthToken(ImmutableArray<byte> bytes) { public void ToBytes(Span<byte> buffer) {
Serialization.WriteGuid(buffer, Guid);
Secret.Bytes.CopyTo(buffer[Serialization.GuidBytes..]);
}
public static AuthToken FromBytes(ReadOnlySpan<byte> bytes) {
if (bytes.Length != Length) { if (bytes.Length != Length) {
throw new ArgumentOutOfRangeException(nameof(bytes), "Invalid token length: " + bytes.Length + ". Token length must be exactly " + Length + " bytes."); throw new ArgumentOutOfRangeException(nameof(bytes), "Invalid auth token length: " + bytes.Length + ". Auth token must be exactly " + Length + " bytes.");
} }
this.Bytes = bytes; var guidSpan = bytes[..Serialization.GuidBytes];
} var secretSpan = bytes[Serialization.GuidBytes..];
internal bool FixedTimeEquals(AuthToken providedAuthToken) { var guid = new Guid(guidSpan);
return FixedTimeEquals(providedAuthToken.Bytes.AsSpan()); var secret = new AuthSecret([..secretSpan]);
} return new AuthToken(guid, secret);
public bool FixedTimeEquals(ReadOnlySpan<byte> other) {
return CryptographicOperations.FixedTimeEquals(Bytes.AsSpan(), other);
} }
public static AuthToken Generate() { public static AuthToken Generate() {
return new AuthToken([..RandomNumberGenerator.GetBytes(Length)]); return new AuthToken(Guid.NewGuid(), AuthSecret.Generate());
} }
} }
@@ -0,0 +1,7 @@
namespace Phantom.Utils.Rpc.Handshake;
enum RpcAuthResult : byte {
UnknownClient = 0,
InvalidSecret = 1,
Success = 255,
}
@@ -1,4 +1,4 @@
namespace Phantom.Utils.Rpc.Runtime; namespace Phantom.Utils.Rpc.Handshake;
enum RpcFinalHandshakeResult : byte { enum RpcFinalHandshakeResult : byte {
Error = 0, Error = 0,
@@ -0,0 +1,6 @@
namespace Phantom.Utils.Rpc.Handshake;
enum RpcSessionRegistrationResult : byte {
AlreadyClosed = 0,
Success = 255,
}
@@ -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) {
if (timeout != TimeSpan.Zero) {
logger.Warning("Could not finish processing message queue before timeout, forcibly shutting it down."); 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) {
@@ -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.");
} }
@@ -5,6 +5,7 @@ using System.Security.Authentication;
using System.Security.Cryptography.X509Certificates; using System.Security.Cryptography.X509Certificates;
using Phantom.Utils.Collections; using Phantom.Utils.Collections;
using Phantom.Utils.Logging; using Phantom.Utils.Logging;
using Phantom.Utils.Rpc.Handshake;
using Phantom.Utils.Rpc.Message; using Phantom.Utils.Rpc.Message;
using Phantom.Utils.Rpc.Runtime.Tls; using Phantom.Utils.Rpc.Runtime.Tls;
using Serilog; using Serilog;
@@ -21,16 +22,19 @@ sealed class RpcClientToServerConnector<TClientToServerMessage, TServerToClientM
private readonly ILogger logger; private readonly ILogger logger;
private readonly RpcClientConnectionParameters parameters; private readonly RpcClientConnectionParameters parameters;
private readonly MessageRegistries<TClientToServerMessage, TServerToClientMessage> messageRegistries; private readonly MessageRegistries<TClientToServerMessage, TServerToClientMessage> messageRegistries;
private readonly Guid sessionId; private readonly Guid sessionGuid;
private readonly SslClientAuthenticationOptions sslOptions; private readonly SslClientAuthenticationOptions sslOptions;
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;
this.messageRegistries = messageRegistries; this.messageRegistries = messageRegistries;
this.sessionId = Guid.NewGuid(); this.sessionGuid = Guid.NewGuid();
this.sslOptions = new SslClientAuthenticationOptions { this.sslOptions = new SslClientAuthenticationOptions {
AllowRenegotiation = false, AllowRenegotiation = false,
@@ -57,7 +61,7 @@ sealed class RpcClientToServerConnector<TClientToServerMessage, TServerToClientM
cancellationToken.ThrowIfCancellationRequested(); cancellationToken.ThrowIfCancellationRequested();
if (attempt >= maxAttempts) { if (attempt >= maxAttempts || wasRejectedDueToClosedSession) {
break; break;
} }
@@ -83,6 +87,11 @@ sealed class RpcClientToServerConnector<TClientToServerMessage, TServerToClientM
cancellationToken.ThrowIfCancellationRequested(); cancellationToken.ThrowIfCancellationRequested();
if (wasRejectedDueToClosedSession) {
logger.Warning("A restart will be required to start a new session!");
await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
}
logger.Warning("Retrying in {Seconds}s.", nextAttemptDelay.TotalSeconds.ToString("F1")); logger.Warning("Retrying in {Seconds}s.", nextAttemptDelay.TotalSeconds.ToString("F1"));
nextAttemptDelay = await WaitForRetry(nextAttemptDelay, cancellationToken); nextAttemptDelay = await WaitForRetry(nextAttemptDelay, cancellationToken);
} }
@@ -136,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;
@@ -165,14 +198,42 @@ sealed class RpcClientToServerConnector<TClientToServerMessage, TServerToClientM
await stream.WriteAuthToken(parameters.AuthToken, cancellationToken); await stream.WriteAuthToken(parameters.AuthToken, cancellationToken);
await stream.Flush(cancellationToken); await stream.Flush(cancellationToken);
if (await stream.ReadByte(cancellationToken) != 1) { var authResult = (RpcAuthResult) await stream.ReadByte(cancellationToken);
logger.Error("Server rejected authorization token."); switch (authResult) {
case RpcAuthResult.Success:
break;
case RpcAuthResult.UnknownClient:
logger.Error("Server rejected unknown client.");
return null;
case RpcAuthResult.InvalidSecret:
logger.Error("Server rejected unauthorized client.");
return null;
default:
logger.Error("Server rejected client authorization with unknown error code: {ErrorCode}", authResult);
return null; return null;
} }
await stream.WriteGuid(sessionId, cancellationToken); await stream.WriteGuid(sessionGuid, cancellationToken);
await stream.Flush(cancellationToken); await stream.Flush(cancellationToken);
var sessionRegistrationResult = (RpcSessionRegistrationResult) await stream.ReadByte(cancellationToken);
switch (sessionRegistrationResult) {
case RpcSessionRegistrationResult.Success:
break;
case RpcSessionRegistrationResult.AlreadyClosed:
wasRejectedDueToClosedSession = true;
logger.Fatal("Server rejected client session because it was already closed.");
return null;
default:
logger.Error("Server rejected client session with unknown error code: {ErrorCode}", sessionRegistrationResult);
return null;
}
var pingInterval = await ReadPingInterval(stream, cancellationToken); var pingInterval = await ReadPingInterval(stream, cancellationToken);
if (pingInterval == null) { if (pingInterval == null) {
return null; return null;
@@ -183,12 +244,15 @@ sealed class RpcClientToServerConnector<TClientToServerMessage, TServerToClientM
await parameters.Handshake.Perform(stream, cancellationToken); await parameters.Handshake.Perform(stream, cancellationToken);
var finalHandshakeResult = (RpcFinalHandshakeResult) await stream.ReadByte(cancellationToken); var finalHandshakeResult = (RpcFinalHandshakeResult) await stream.ReadByte(cancellationToken);
if (finalHandshakeResult == RpcFinalHandshakeResult.Error) { switch (finalHandshakeResult) {
logger.Error("Server rejected client due to unknown error."); case RpcFinalHandshakeResult.NewSession:
case RpcFinalHandshakeResult.ReusedSession:
return new ConnectionResult(finalHandshakeResult == RpcFinalHandshakeResult.NewSession, pingInterval.Value, mappedMessageDefinitions);
default:
logger.Error("Server rejected client handshake with unknown error code: {ErrorCode}", finalHandshakeResult);
return null; return null;
} }
return new ConnectionResult(finalHandshakeResult == RpcFinalHandshakeResult.NewSession, pingInterval.Value, mappedMessageDefinitions);
} }
private async Task<TimeSpan?> ReadPingInterval(RpcStream stream, CancellationToken cancellationToken) { private async Task<TimeSpan?> ReadPingInterval(RpcStream stream, CancellationToken cancellationToken) {
@@ -225,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();
+4 -12
View File
@@ -7,8 +7,6 @@ namespace Phantom.Utils.Rpc.Runtime;
[SuppressMessage("ReSharper", "MemberCanBeInternal")] [SuppressMessage("ReSharper", "MemberCanBeInternal")]
public sealed class RpcStream : IAsyncDisposable { public sealed class RpcStream : IAsyncDisposable {
private const int GuidBytes = 16;
private readonly SslStream stream; private readonly SslStream stream;
internal RpcStream(SslStream stream) { internal RpcStream(SslStream stream) {
@@ -76,25 +74,19 @@ public sealed class RpcStream : IAsyncDisposable {
} }
public ValueTask WriteGuid(Guid guid, CancellationToken cancellationToken) { public ValueTask WriteGuid(Guid guid, CancellationToken cancellationToken) {
static void Write(Span<byte> span, Guid guid) { return WriteValue(guid, Serialization.GuidBytes, Serialization.WriteGuid, cancellationToken);
if (!guid.TryWriteBytes(span)) {
throw new ArgumentException("Span is not large enough to write a GUID.", nameof(span));
}
}
return WriteValue(guid, size: GuidBytes, Write, cancellationToken);
} }
public ValueTask<Guid> ReadGuid(CancellationToken cancellationToken) { public ValueTask<Guid> ReadGuid(CancellationToken cancellationToken) {
return ReadValue(static span => new Guid(span), size: GuidBytes, cancellationToken); return ReadValue(static span => new Guid(span), Serialization.GuidBytes, cancellationToken);
} }
public ValueTask WriteAuthToken(AuthToken authToken, CancellationToken cancellationToken) { public ValueTask WriteAuthToken(AuthToken authToken, CancellationToken cancellationToken) {
return stream.WriteAsync(authToken.Bytes.AsMemory(), cancellationToken); return WriteValue(authToken, AuthToken.Length, static (span, value) => value.ToBytes(span), cancellationToken);
} }
public ValueTask<AuthToken> ReadAuthToken(CancellationToken cancellationToken) { public ValueTask<AuthToken> ReadAuthToken(CancellationToken cancellationToken) {
return ReadValue(static span => new AuthToken([..span]), AuthToken.Length, cancellationToken); return ReadValue(AuthToken.FromBytes, AuthToken.Length, cancellationToken);
} }
public ValueTask WriteBytes(ReadOnlyMemory<byte> bytes, CancellationToken cancellationToken) { public ValueTask WriteBytes(ReadOnlyMemory<byte> bytes, CancellationToken cancellationToken) {
@@ -0,0 +1,5 @@
namespace Phantom.Utils.Rpc.Runtime.Server;
public interface IRpcServerClientAuthProvider {
Task<AuthSecret?> GetAuthSecret(Guid clientGuid);
}
@@ -1,17 +1,11 @@
using Phantom.Utils.Monads; namespace Phantom.Utils.Rpc.Runtime.Server;
namespace Phantom.Utils.Rpc.Runtime.Server; public interface IRpcServerClientHandshake {
Task Perform(bool isNewSession, RpcStream stream, Guid clientGuid, CancellationToken cancellationToken);
public interface IRpcServerClientHandshake<T> { sealed record NoOp : IRpcServerClientHandshake {
Task<Either<T, Exception>> Perform(bool isNewSession, RpcStream stream, CancellationToken cancellationToken); public Task Perform(bool isNewSession, RpcStream stream, Guid clientGuid, CancellationToken cancellationToken) {
} return Task.CompletedTask;
public static class RpcServerClientHandshake {
public readonly record struct NoValue;
public sealed record NoOp : IRpcServerClientHandshake<NoValue> {
public Task<Either<NoValue, Exception>> Perform(bool isNewSession, RpcStream stream, CancellationToken cancellationToken) {
return Task.FromResult<Either<NoValue, Exception>>(Either.Left(new NoValue()));
} }
} }
} }
@@ -2,6 +2,6 @@
namespace Phantom.Utils.Rpc.Runtime.Server; namespace Phantom.Utils.Rpc.Runtime.Server;
public interface IRpcServerClientRegistrar<TClientToServerMessage, TServerToClientMessage, THandshakeResult> { public interface IRpcServerClientRegistrar<TClientToServerMessage, TServerToClientMessage> {
IMessageReceiver<TClientToServerMessage> Register(RpcServerToClientConnection<TClientToServerMessage, TServerToClientMessage> connection, THandshakeResult handshakeResult); IMessageReceiver<TClientToServerMessage> Register(RpcServerToClientConnection<TClientToServerMessage, TServerToClientMessage> connection);
} }
@@ -3,20 +3,21 @@ using System.Net.Security;
using System.Net.Sockets; using System.Net.Sockets;
using System.Security.Cryptography.X509Certificates; using System.Security.Cryptography.X509Certificates;
using Phantom.Utils.Logging; using Phantom.Utils.Logging;
using Phantom.Utils.Monads; using Phantom.Utils.Rpc.Handshake;
using Phantom.Utils.Rpc.Message; using Phantom.Utils.Rpc.Message;
using Phantom.Utils.Rpc.Runtime.Tls; using Phantom.Utils.Rpc.Runtime.Tls;
using Serilog; using Serilog;
namespace Phantom.Utils.Rpc.Runtime.Server; namespace Phantom.Utils.Rpc.Runtime.Server;
public sealed class RpcServer<TClientToServerMessage, TServerToClientMessage, THandshakeResult> { public sealed class RpcServer<TClientToServerMessage, TServerToClientMessage> {
private readonly string loggerName; private readonly string loggerName;
private readonly ILogger logger; private readonly ILogger logger;
private readonly RpcServerConnectionParameters connectionParameters; private readonly RpcServerConnectionParameters connectionParameters;
private readonly MessageRegistries<TClientToServerMessage, TServerToClientMessage>.WithMapping messageRegistries; private readonly MessageRegistries<TClientToServerMessage, TServerToClientMessage>.WithMapping messageRegistries;
private readonly IRpcServerClientHandshake<THandshakeResult> clientHandshake; private readonly IRpcServerClientAuthProvider clientAuthProvider;
private readonly IRpcServerClientRegistrar<TClientToServerMessage, TServerToClientMessage, THandshakeResult> clientRegistrar; private readonly IRpcServerClientHandshake clientHandshake;
private readonly IRpcServerClientRegistrar<TClientToServerMessage, TServerToClientMessage> clientRegistrar;
private readonly RpcServerClientSessions<TServerToClientMessage> clientSessions; private readonly RpcServerClientSessions<TServerToClientMessage> clientSessions;
private readonly List<Client> clients = []; private readonly List<Client> clients = [];
@@ -25,13 +26,15 @@ public sealed class RpcServer<TClientToServerMessage, TServerToClientMessage, TH
string loggerName, string loggerName,
RpcServerConnectionParameters connectionParameters, RpcServerConnectionParameters connectionParameters,
MessageRegistries<TClientToServerMessage, TServerToClientMessage> messageRegistries, MessageRegistries<TClientToServerMessage, TServerToClientMessage> messageRegistries,
IRpcServerClientHandshake<THandshakeResult> clientHandshake, IRpcServerClientAuthProvider clientAuthProvider,
IRpcServerClientRegistrar<TClientToServerMessage, TServerToClientMessage, THandshakeResult> clientRegistrar IRpcServerClientHandshake clientHandshake,
IRpcServerClientRegistrar<TClientToServerMessage, TServerToClientMessage> clientRegistrar
) { ) {
this.loggerName = loggerName; this.loggerName = loggerName;
this.logger = PhantomLogger.Create<RpcServer<TClientToServerMessage, TServerToClientMessage, THandshakeResult>>(loggerName); this.logger = PhantomLogger.Create<RpcServer<TClientToServerMessage, TServerToClientMessage>>(loggerName);
this.connectionParameters = connectionParameters; this.connectionParameters = connectionParameters;
this.messageRegistries = messageRegistries.CreateMapping(); this.messageRegistries = messageRegistries.CreateMapping();
this.clientAuthProvider = clientAuthProvider;
this.clientHandshake = clientHandshake; this.clientHandshake = clientHandshake;
this.clientRegistrar = clientRegistrar; this.clientRegistrar = clientRegistrar;
this.clientSessions = new RpcServerClientSessions<TServerToClientMessage>(loggerName, connectionParameters, this.messageRegistries.ToClient.Mapping); this.clientSessions = new RpcServerClientSessions<TServerToClientMessage>(loggerName, connectionParameters, this.messageRegistries.ToClient.Mapping);
@@ -53,6 +56,7 @@ public sealed class RpcServer<TClientToServerMessage, TServerToClientMessage, TH
var serverData = new SharedData( var serverData = new SharedData(
connectionParameters, connectionParameters,
messageRegistries, messageRegistries,
clientAuthProvider,
clientHandshake, clientHandshake,
clientRegistrar, clientRegistrar,
clientSessions clientSessions
@@ -111,8 +115,9 @@ public sealed class RpcServer<TClientToServerMessage, TServerToClientMessage, TH
private readonly record struct SharedData( private readonly record struct SharedData(
RpcServerConnectionParameters ConnectionParameters, RpcServerConnectionParameters ConnectionParameters,
MessageRegistries<TClientToServerMessage, TServerToClientMessage>.WithMapping MessageDefinitions, MessageRegistries<TClientToServerMessage, TServerToClientMessage>.WithMapping MessageDefinitions,
IRpcServerClientHandshake<THandshakeResult> ClientHandshake, IRpcServerClientAuthProvider ClientAuthProvider,
IRpcServerClientRegistrar<TClientToServerMessage, TServerToClientMessage, THandshakeResult> ClientRegistrar, IRpcServerClientHandshake ClientHandshake,
IRpcServerClientRegistrar<TClientToServerMessage, TServerToClientMessage> ClientRegistrar,
RpcServerClientSessions<TServerToClientMessage> ClientSessions RpcServerClientSessions<TServerToClientMessage> ClientSessions
); );
@@ -144,7 +149,7 @@ public sealed class RpcServer<TClientToServerMessage, TServerToClientMessage, TH
SslServerAuthenticationOptions sslOptions, SslServerAuthenticationOptions sslOptions,
CancellationToken shutdownToken CancellationToken shutdownToken
) { ) {
this.logger = PhantomLogger.Create<RpcServer<TClientToServerMessage, TServerToClientMessage, THandshakeResult>, Client>(PhantomLogger.ConcatNames(serverLoggerName, GetAddressDescriptor(socket))); this.logger = PhantomLogger.Create<RpcServer<TClientToServerMessage, TServerToClientMessage>, Client>(PhantomLogger.ConcatNames(serverLoggerName, GetAddressDescriptor(socket)));
this.sharedData = sharedData; this.sharedData = sharedData;
this.socket = socket; this.socket = socket;
this.sslOptions = sslOptions; this.sslOptions = sslOptions;
@@ -229,16 +234,26 @@ public sealed class RpcServer<TClientToServerMessage, TServerToClientMessage, TH
} }
try { try {
var suppliedAuthToken = await stream.ReadAuthToken(cancellationToken); var clientAuthToken = await stream.ReadAuthToken(cancellationToken);
if (!sharedData.ConnectionParameters.AuthToken.FixedTimeEquals(suppliedAuthToken)) {
logger.Warning("Rejected client, invalid authorization token."); RpcAuthResult authResult = await CheckAuthorization(clientAuthToken);
await stream.WriteByte(value: 0, cancellationToken); await stream.WriteByte(value: (byte) authResult, cancellationToken);
await stream.Flush(cancellationToken); await stream.Flush(cancellationToken);
if (authResult != RpcAuthResult.Success) {
return null; return null;
} }
else {
await stream.WriteByte(value: 1, cancellationToken); var clientGuid = clientAuthToken.Guid;
var sessionGuid = await stream.ReadGuid(cancellationToken);
var session = await sharedData.ClientSessions.GetOrCreateSession(clientGuid, sessionGuid);
RpcSessionRegistrationResult sessionRegistrationResult = session == null ? RpcSessionRegistrationResult.AlreadyClosed : RpcSessionRegistrationResult.Success;
await stream.WriteByte(value: (byte) sessionRegistrationResult, cancellationToken);
await stream.Flush(cancellationToken); await stream.Flush(cancellationToken);
if (session == null) {
return null;
} }
await stream.WriteUnsignedShort(sharedData.ConnectionParameters.PingIntervalSeconds, cancellationToken); await stream.WriteUnsignedShort(sharedData.ConnectionParameters.PingIntervalSeconds, cancellationToken);
@@ -246,11 +261,9 @@ public sealed class RpcServer<TClientToServerMessage, TServerToClientMessage, TH
await sharedData.MessageDefinitions.ToServer.Write(stream, cancellationToken); await sharedData.MessageDefinitions.ToServer.Write(stream, cancellationToken);
await stream.Flush(cancellationToken); await stream.Flush(cancellationToken);
var sessionId = await stream.ReadGuid(cancellationToken);
var session = sharedData.ClientSessions.GetOrCreateSession(sessionId);
EstablishedConnection? establishedConnection = await FinalizeHandshake(stream, session, cancellationToken);
RpcFinalHandshakeResult finalHandshakeResult; RpcFinalHandshakeResult finalHandshakeResult;
var establishedConnection = await FinalizeHandshake(stream, clientGuid, session, cancellationToken);
if (establishedConnection == null) { if (establishedConnection == null) {
finalHandshakeResult = RpcFinalHandshakeResult.Error; finalHandshakeResult = RpcFinalHandshakeResult.Error;
} }
@@ -274,29 +287,43 @@ public sealed class RpcServer<TClientToServerMessage, TServerToClientMessage, TH
} }
} }
private async Task<EstablishedConnection?> FinalizeHandshake(RpcStream stream, RpcServerClientSession<TServerToClientMessage> session, CancellationToken cancellationToken) { private async Task<RpcAuthResult> CheckAuthorization(AuthToken clientAuthToken) {
logger.Information("Client connected with session {SessionId}, new logger name: {LoggerName}", session.SessionId, session.LoggerName); var clientGuid = clientAuthToken.Guid;
logger = PhantomLogger.Create<RpcServer<TClientToServerMessage, TServerToClientMessage, THandshakeResult>, Client>(session.LoggerName);
var expectedAuthSecret = await sharedData.ClientAuthProvider.GetAuthSecret(clientGuid);
if (expectedAuthSecret == null) {
logger.Warning("Rejected client, unknown client: {ClientGuid}", clientGuid);
return RpcAuthResult.UnknownClient;
}
else if (!expectedAuthSecret.FixedTimeEquals(clientAuthToken.Secret)) {
logger.Warning("Rejected client, invalid authorization secret.");
return RpcAuthResult.InvalidSecret;
}
else {
return RpcAuthResult.Success;
}
}
private async Task<EstablishedConnection?> FinalizeHandshake(RpcStream stream, Guid clientGuid, RpcServerClientSession<TServerToClientMessage> session, CancellationToken cancellationToken) {
logger.Information("Client {ClientGuid} connected with session {SessionGuid}, new logger name: {LoggerName}", clientGuid, session.SessionGuid, session.LoggerName);
logger = PhantomLogger.Create<RpcServer<TClientToServerMessage, TServerToClientMessage>, Client>(session.LoggerName);
try {
await sharedData.ClientHandshake.Perform(session.IsNew, stream, clientGuid, cancellationToken);
} catch (Exception e) {
logger.Error(e, "Could not finish application handshake.");
return null;
}
switch (await sharedData.ClientHandshake.Perform(session.IsNew, stream, cancellationToken)) {
case Left<THandshakeResult, Exception>(var handshakeResult):
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, handshakeResult); var messageReceiver = sharedData.ClientRegistrar.Register(connection);
return new EstablishedConnection(session, connection, messageReceiver); return new EstablishedConnection(session, connection, messageReceiver);
} catch (Exception e) { } catch (Exception e) {
logger.Error(e, "Could not register client."); logger.Error(e, "Could not register client.");
return null; return null;
} }
case Right<THandshakeResult, Exception>(var exception):
logger.Error(exception, "Could not finish application handshake.");
return null;
default:
return null;
}
} }
private sealed record EstablishedConnection( private sealed record EstablishedConnection(
@@ -13,7 +13,8 @@ sealed class RpcServerClientSession<TServerToClientMessage> : IRpcConnectionProv
private readonly RpcServerClientSessions<TServerToClientMessage> sessions; private readonly RpcServerClientSessions<TServerToClientMessage> sessions;
public string LoggerName { get; } public string LoggerName { get; }
public Guid SessionId { get; } public Guid ClientGuid { get; }
public Guid SessionGuid { get; }
public MessageSender<TServerToClientMessage> MessageSender { get; } public MessageSender<TServerToClientMessage> MessageSender { get; }
public RpcFrameSender<TServerToClientMessage> FrameSender { get; } public RpcFrameSender<TServerToClientMessage> FrameSender { get; }
@@ -28,11 +29,13 @@ sealed class RpcServerClientSession<TServerToClientMessage> : IRpcConnectionProv
public CancellationToken CloseCancellationToken => closeCancellationTokenSource.Token; public CancellationToken CloseCancellationToken => closeCancellationTokenSource.Token;
public RpcServerClientSession(string loggerName, RpcServerConnectionParameters connectionParameters, MessageTypeMapping<TServerToClientMessage> messageTypeMapping, RpcServerClientSessions<TServerToClientMessage> sessions, Guid sessionId) { public RpcServerClientSession(string loggerName, RpcServerConnectionParameters connectionParameters, MessageTypeMapping<TServerToClientMessage> messageTypeMapping, RpcServerClientSessions<TServerToClientMessage> sessions, Guid clientGuid, Guid sessionGuid) {
this.logger = PhantomLogger.Create<RpcServerClientSession<TServerToClientMessage>>(loggerName); this.logger = PhantomLogger.Create<RpcServerClientSession<TServerToClientMessage>>(loggerName);
this.LoggerName = loggerName;
this.sessions = sessions; this.sessions = sessions;
this.SessionId = sessionId;
this.LoggerName = loggerName;
this.ClientGuid = clientGuid;
this.SessionGuid = sessionGuid;
this.FrameSender = new RpcFrameSender<TServerToClientMessage>(loggerName, connectionParameters, this, messageTypeMapping, connectionParameters.PingInterval); this.FrameSender = new RpcFrameSender<TServerToClientMessage>(loggerName, connectionParameters, this, messageTypeMapping, connectionParameters.PingInterval);
this.MessageSender = new MessageSender<TServerToClientMessage>(loggerName, connectionParameters, new IRpcFrameSenderProvider<TServerToClientMessage>.Constant(FrameSender)); this.MessageSender = new MessageSender<TServerToClientMessage>(loggerName, connectionParameters, new IRpcFrameSenderProvider<TServerToClientMessage>.Constant(FrameSender));
@@ -105,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.");
} }
@@ -1,54 +1,112 @@
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Diagnostics.CodeAnalysis;
using Akka.Util;
using Phantom.Utils.Logging; using Phantom.Utils.Logging;
using Phantom.Utils.Rpc.Message; using Phantom.Utils.Rpc.Message;
namespace Phantom.Utils.Rpc.Runtime.Server; namespace Phantom.Utils.Rpc.Runtime.Server;
sealed class RpcServerClientSessions<TServerToClientMessage> { sealed class RpcServerClientSessions<TServerToClientMessage>(
private readonly string loggerName; string loggerName,
private readonly RpcServerConnectionParameters connectionParameters; RpcServerConnectionParameters connectionParameters,
private readonly MessageTypeMapping<TServerToClientMessage> messageTypeMapping; MessageTypeMapping<TServerToClientMessage> messageTypeMapping
) {
private readonly ConcurrentDictionary<Guid, SessionHolder> sessionsByClientGuid = new ();
private readonly ConcurrentSet<Guid> closedSessions = [];
private readonly ConcurrentDictionary<Guid, RpcServerClientSession<TServerToClientMessage>> sessionsById = new (); public int Count => sessionsByClientGuid.Count(static kvp => kvp.Value.IsActive);
private readonly Func<Guid, RpcServerClientSession<TServerToClientMessage>> createSessionFunction;
private int nextSessionSequenceId; private int nextSessionSequenceId;
public int Count => sessionsById.Count; public async Task<RpcServerClientSession<TServerToClientMessage>?> GetOrCreateSession(Guid clientGuid, Guid sessionGuid) {
if (closedSessions.Contains(sessionGuid)) {
public RpcServerClientSessions(string loggerName, RpcServerConnectionParameters connectionParameters, MessageTypeMapping<TServerToClientMessage> messageTypeMapping) { return null;
this.loggerName = loggerName;
this.connectionParameters = connectionParameters;
this.messageTypeMapping = messageTypeMapping;
this.createSessionFunction = CreateSession;
} }
public RpcServerClientSession<TServerToClientMessage> GetOrCreateSession(Guid sessionId) { var sessionHolder = sessionsByClientGuid.GetOrAdd(clientGuid, static (clientGuid, sessions) => new SessionHolder(clientGuid, sessions), this);
return sessionsById.GetOrAdd(sessionId, createSessionFunction); return await sessionHolder.GetOrReplaceSession(sessionGuid);
} }
private RpcServerClientSession<TServerToClientMessage> CreateSession(Guid sessionId) { private RpcServerClientSession<TServerToClientMessage> CreateSession(Guid clientGuid, Guid sessionGuid) {
return new RpcServerClientSession<TServerToClientMessage>(NextLoggerName(sessionId), connectionParameters, messageTypeMapping, this, sessionId); return new RpcServerClientSession<TServerToClientMessage>(NextLoggerName(clientGuid), connectionParameters, messageTypeMapping, this, clientGuid, sessionGuid);
} }
private string NextLoggerName(Guid sessionId) { private string NextLoggerName(Guid sessionGuid) {
string name = PhantomLogger.ShortenGuid(sessionId); string name = PhantomLogger.ShortenGuid(sessionGuid);
return PhantomLogger.ConcatNames(loggerName, name + "/" + Interlocked.Increment(ref nextSessionSequenceId)); return PhantomLogger.ConcatNames(loggerName, name + "/" + Interlocked.Increment(ref nextSessionSequenceId));
} }
public void Remove(RpcServerClientSession<TServerToClientMessage> session) { public void Remove(RpcServerClientSession<TServerToClientMessage> session) {
sessionsById.TryRemove(new KeyValuePair<Guid, RpcServerClientSession<TServerToClientMessage>>(session.SessionId, session)); if (sessionsByClientGuid.TryGetValue(session.ClientGuid, out var sessionHolder)) {
closedSessions.TryAdd(session.SessionGuid);
sessionHolder.ForgetSession(session.SessionGuid);
}
} }
public async Task CloseAll() { public async Task CloseAll() {
List<Task> tasks = []; List<Task> tasks = [];
foreach (Guid sessionId in sessionsById.Keys) { foreach (Guid sessionGuid in sessionsByClientGuid.Keys) {
if (sessionsById.Remove(sessionId, out var session)) { if (sessionsByClientGuid.Remove(sessionGuid, out var sessionHolder)) {
tasks.Add(session.Close(closedByClient: false)); tasks.Add(sessionHolder.CloseSession());
} }
} }
await Task.WhenAll(tasks).ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing); await Task.WhenAll(tasks).ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing);
} }
private sealed class SessionHolder(Guid clientGuid, RpcServerClientSessions<TServerToClientMessage> sessions) {
private readonly Lock @lock = new ();
private RpcServerClientSession<TServerToClientMessage>? session;
[SuppressMessage("ReSharper", "InconsistentlySynchronizedField")]
public bool IsActive => Volatile.Read(ref session) != null;
public async Task<RpcServerClientSession<TServerToClientMessage>> GetOrReplaceSession(Guid sessionGuid) {
RpcServerClientSession<TServerToClientMessage>? createdSession;
RpcServerClientSession<TServerToClientMessage>? replacedSession;
lock (@lock) {
if (session != null && session.SessionGuid == sessionGuid) {
return session;
}
else {
replacedSession = session;
}
createdSession = sessions.CreateSession(clientGuid, sessionGuid);
session = createdSession;
}
if (replacedSession != null) {
await CloseSession(replacedSession);
}
return createdSession;
}
public void ForgetSession(Guid sessionGuid) {
lock (@lock) {
if (session != null && session.SessionGuid == sessionGuid) {
session = null;
}
}
}
public async Task CloseSession() {
RpcServerClientSession<TServerToClientMessage>? sessionToClose;
lock (@lock) {
sessionToClose = session;
session = null;
}
if (sessionToClose != null) {
await CloseSession(sessionToClose);
}
}
private static async Task CloseSession(RpcServerClientSession<TServerToClientMessage> session) {
await session.Close(closedByClient: false).ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing);
}
}
} }
@@ -6,7 +6,6 @@ namespace Phantom.Utils.Rpc.Runtime.Server;
public sealed record RpcServerConnectionParameters( public sealed record RpcServerConnectionParameters(
EndPoint EndPoint, EndPoint EndPoint,
RpcServerCertificate Certificate, RpcServerCertificate Certificate,
AuthToken AuthToken,
ushort PingIntervalSeconds, ushort PingIntervalSeconds,
ushort MessageQueueCapacity, ushort MessageQueueCapacity,
ushort FrameQueueCapacity, ushort FrameQueueCapacity,
@@ -12,7 +12,8 @@ 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 SessionId => session.SessionId; public Guid ClientGuid => session.ClientGuid;
public Guid SessionGuid => session.SessionGuid;
public MessageSender<TServerToClientMessage> MessageSender => session.MessageSender; public MessageSender<TServerToClientMessage> MessageSender => session.MessageSender;
internal RpcServerToClientConnection( internal RpcServerToClientConnection(
+11
View File
@@ -0,0 +1,11 @@
namespace Phantom.Utils.Rpc;
static class Serialization {
public const int GuidBytes = 16;
public static void WriteGuid(Span<byte> buffer, Guid guid) {
if (!guid.TryWriteBytes(buffer)) {
throw new InvalidOperationException("Span is not large enough to write a GUID.");
}
}
}
@@ -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;
}
} }
} }
@@ -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) {
+6
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" />
+37
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;
}
}
+38 -10
View File
@@ -1,13 +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.Web.Services.Agents @using Phantom.Web.Services.Agents
@inherits Phantom.Web.Components.PhantomComponent @using Phantom.Web.Services.Authorization
@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>
@@ -16,31 +23,48 @@
<Column>Version</Column> <Column>Version</Column>
<Column Class="text-center">Status</Column> <Column Class="text-center">Status</Column>
<Column Class="text-end" MinWidth="200px">Last Ping</Column> <Column Class="text-end" MinWidth="200px">Last Ping</Column>
<Column>Actions</Column>
</HeaderRow> </HeaderRow>
<ItemRow Context="agent"> <ItemRow Context="agent">
@{ @{
var configuration = agent.Configuration; var connectionKey = TokenGenerator.EncodeBytes(agent.ConnectionKey.AsSpan());
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">@configuration.AgentName</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.MaxInstances is {} maxInstances) {
@(usedInstances?.ToString() ?? "?") / @configuration.MaxInstances.ToString() <ProgressBar Value="@(usedInstances ?? 0)" Maximum="@maxInstances">
@(usedInstances?.ToString() ?? "?") / @maxInstances.ToString()
</ProgressBar> </ProgressBar>
}
else {
@:N/A
}
</Cell> </Cell>
<Cell class="text-end"> <Cell class="text-end">
<ProgressBar Value="@(usedMemory ?? 0)" Maximum="@configuration.MaxMemory.InMegabytes"> @if (runtimeInfo.MaxMemory is {} maxMemory) {
@(usedMemory?.ToString() ?? "?") / @configuration.MaxMemory.InMegabytes.ToString() MB <ProgressBar Value="@(usedMemory ?? 0)" Maximum="@maxMemory.InMegabytes">
@(usedMemory?.ToString() ?? "?") / @maxMemory.InMegabytes.ToString() MB
</ProgressBar> </ProgressBar>
}
else {
@:N/A
}
</Cell> </Cell>
<Cell class="text-condensed"> <Cell class="text-condensed">
Build: <span class="font-monospace">@configuration.BuildVersion</span> @if (runtimeInfo.VersionInfo is {} versionInfo) {
<text>Build: <span class="font-monospace">@versionInfo.BuildVersion</span></text>
<br> <br>
Protocol: <span class="font-monospace">v@(configuration.ProtocolVersion.ToString())</span> <text>Protocol: <span class="font-monospace">v@(versionInfo.ProtocolVersion.ToString())</span></text>
}
else {
@:N/A
}
</Cell> </Cell>
@switch (agent.ConnectionStatus) { @switch (agent.ConnectionStatus) {
case AgentIsOnline: case AgentIsOnline:
@@ -64,6 +88,9 @@
<Cell class="fw-semibold text-center">N/A</Cell> <Cell class="fw-semibold text-center">N/A</Cell>
break; break;
} }
<Cell>
<button type="button" class="btn btn-danger btn-sm" data-clipboard="@connectionKey" onclick="copyToClipboard(this);">Copy Agent Key</button>
</Cell>
</ItemRow> </ItemRow>
<NoItemsRow> <NoItemsRow>
No agents found. No agents found.
@@ -81,7 +108,8 @@
} }
AgentManager.AgentsChanged.Subscribe(this, agents => { AgentManager.AgentsChanged.Subscribe(this, agents => {
var sortedAgents = agents.Where(agent => authenticatedUser.Info.HasAccessToAgent(agent.AgentGuid)) var sortedAgents = agents.Values
.Where(agent => authenticatedUser.Info.HasAccessToAgent(agent.AgentGuid))
.OrderBy(static agent => agent.Configuration.AgentName) .OrderBy(static agent => agent.Configuration.AgentName)
.ToImmutableArray(); .ToImmutableArray();
+6 -1
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,
}; };
} }
+1 -1
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.Configuration.AgentName); 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 {
+1 -1
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.Configuration.AgentName); this.agentNamesByGuid = agents.Select(static kvp => KeyValuePair.Create(kvp.Key, kvp.Value.Configuration.AgentName)).ToImmutableDictionary();
InvokeAsync(StateHasChanged); InvokeAsync(StateHasChanged);
}); });
} }
@@ -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;
}
}
}
@@ -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,14 +29,16 @@
<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">
@configuration.AgentName @agent.Configuration.AgentName
&bullet; @if (runtimeInfo.MaxInstances is not null && runtimeInfo.MaxMemory is not 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.Value.InMegabytes) MB RAM</text>
}
</option>; </option>;
} }
} }
@@ -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.MaxMemory is not {} maxMemory) {
<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) / @(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?> {
+1 -1
View File
@@ -28,7 +28,7 @@ static class WebKey {
} }
try { try {
Files.RequireMaximumFileSize(webKeyFilePath, maximumBytes: 64); Files.RequireMaximumFileSize(webKeyFilePath, maximumBytes: 128);
return LoadFromBytes(await File.ReadAllBytesAsync(webKeyFilePath)); return LoadFromBytes(await File.ReadAllBytesAsync(webKeyFilePath));
} catch (IOException e) { } catch (IOException e) {
Logger.Fatal("Error loading web key from file: {WebKeyFilePath}", webKeyFilePath); Logger.Fatal("Error loading web key from file: {WebKeyFilePath}", webKeyFilePath);
+35
View File
@@ -7,3 +7,38 @@ function showModal(id) {
function closeModal(id) { function closeModal(id) {
bootstrap.Modal.getInstance(document.getElementById(id)).hide(); bootstrap.Modal.getInstance(document.getElementById(id)).hide();
} }
/**
* @param {HTMLButtonElement} button
*/
async function copyToClipboard(button) {
if (button.getAttribute("data-clipboard-copying") !== null) {
return;
}
button.setAttribute("data-clipboard-copying", "");
try {
const toCopy = button.getAttribute("data-clipboard");
const originalText = button.textContent;
const originalMinWidth = button.style.minWidth;
try {
await navigator.clipboard.writeText(toCopy);
} catch (e) {
console.error(e);
alert("Could not copy to clipboard.");
return;
}
button.style.minWidth = button.offsetWidth + "px";
button.textContent = "Copied!";
await new Promise(resolve => setTimeout(resolve, 2000));
button.textContent = originalText;
button.style.minWidth = originalMinWidth;
} finally {
button.removeAttribute("data-clipboard-copying");
}
}