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

2 Commits

Author SHA1 Message Date
93fde594a4 WIP 2025-12-27 00:01:25 +01:00
e4dbb18584 WIP 2025-12-26 12:55:12 +01:00
59 changed files with 1152 additions and 468 deletions

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" />

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" />

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" />

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" />

View File

@@ -1 +0,0 @@
<EFBFBD>H<EFBFBD>c<EFBFBD>og<EFBFBD>

View File

@@ -1 +1,2 @@
<07>U<EFBFBD>/<2F><04><EFBFBD><EFBFBD>q q<EFBFBD><EFBFBD>h4<EFBFBD><EFBFBD>H<EFBFBD><18>7<EFBFBD><37><EFBFBD><EFBFBD>H`<EFBFBD><EFBFBD>W
<EFBFBD>4u`G

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) {

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

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

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,

View File

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

View File

@@ -5,15 +5,14 @@ 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)] ushort ProtocolVersion,
[property: MemoryPackOrder(1)] ushort ProtocolVersion, [property: MemoryPackOrder(1)] string BuildVersion,
[property: MemoryPackOrder(2)] string BuildVersion, [property: MemoryPackOrder(2)] ushort MaxInstances,
[property: MemoryPackOrder(3)] ushort MaxInstances, [property: MemoryPackOrder(3)] RamAllocationUnits MaxMemory,
[property: MemoryPackOrder(4)] RamAllocationUnits MaxMemory, [property: MemoryPackOrder(4)] AllowedPorts? AllowedServerPorts = null,
[property: MemoryPackOrder(5)] AllowedPorts? AllowedServerPorts = null, [property: MemoryPackOrder(5)] AllowedPorts? AllowedRconPorts = null
[property: MemoryPackOrder(6)] AllowedPorts? AllowedRconPorts = null
) { ) {
public static AgentConfiguration From(AgentInfo agentInfo) { public static AgentConfiguration From(AgentInfo agentInfo) {
return new AgentConfiguration(agentInfo.AgentName, agentInfo.ProtocolVersion, agentInfo.BuildVersion, agentInfo.MaxInstances, agentInfo.MaxMemory, agentInfo.AllowedServerPorts, agentInfo.AllowedRconPorts); return new AgentConfiguration(agentInfo.ProtocolVersion, agentInfo.BuildVersion, agentInfo.MaxInstances, agentInfo.MaxMemory, agentInfo.AllowedServerPorts, agentInfo.AllowedRconPorts);
} }
} }

View File

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

View File

@@ -1,20 +1,21 @@
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;
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);
} }

View File

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

View File

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

View File

@@ -18,7 +18,7 @@ namespace Phantom.Controller.Database.Postgres.Migrations
{ {
#pragma warning disable 612, 618 #pragma warning disable 612, 618
modelBuilder modelBuilder
.HasAnnotation("ProductVersion", "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")

View File

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

View File

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

View File

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

View File

@@ -2,6 +2,7 @@
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.Utils.Rpc;
namespace Phantom.Controller.Database.Entities; namespace Phantom.Controller.Database.Entities;
@@ -17,6 +18,9 @@ public sealed class AgentEntity {
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; }
internal AgentEntity(Guid agentGuid) { internal AgentEntity(Guid agentGuid) {
AgentGuid = agentGuid; AgentGuid = agentGuid;
Name = null!; Name = null!;

View File

@@ -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,17 @@ 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 AgentGuid,
string AgentName,
AuthSecret AuthSecret,
AgentConfiguration AgentConfiguration,
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,12 +51,15 @@ 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 string agentName;
private readonly AuthInfo authInfo;
private AgentConfiguration configuration; private AgentConfiguration configuration;
private AgentStats? stats; private AgentStats? stats;
@@ -76,14 +90,18 @@ 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.agentName = init.AgentName;
this.authInfo = new AuthInfo(this, init.AuthSecret);
this.configuration = init.AgentConfiguration; this.configuration = init.AgentConfiguration;
this.connection = new AgentConnection(agentGuid, configuration.AgentName); this.connection = new AgentConnection(agentGuid, 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");
@@ -93,6 +111,7 @@ sealed class AgentActor : ReceiveActor<AgentActor.ICommand>, IWithTimers {
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 +125,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, agentName, authInfo.ConnectionKey, configuration, stats, ConnectionStatus));
} }
protected override void PreStart() { protected override void PreStart() {
@@ -180,6 +199,8 @@ sealed class AgentActor : ReceiveActor<AgentActor.ICommand>, IWithTimers {
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;
@@ -231,15 +252,15 @@ sealed class AgentActor : ReceiveActor<AgentActor.ICommand>, IWithTimers {
var configurationMessages = await PrepareInitialConfigurationMessages(); var configurationMessages = await PrepareInitialConfigurationMessages();
configuration = command.Configuration; configuration = command.Configuration;
connection.SetAgentName(configuration.AgentName); connection.SetAgentName(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 \"{Name}\" (GUID {Guid}).", agentName, agentGuid);
databaseStorageActor.Tell(new AgentDatabaseStorageActor.StoreAgentConfigurationCommand(configuration)); databaseStorageActor.Tell(new AgentDatabaseStorageActor.StoreAgentConfigurationCommand(agentName, authInfo.Secret, configuration));
javaRuntimes = command.JavaRuntimes; javaRuntimes = command.JavaRuntimes;
controllerState.UpdateAgentJavaRuntimes(agentGuid, javaRuntimes); controllerState.UpdateAgentJavaRuntimes(agentGuid, javaRuntimes);
@@ -261,7 +282,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 \"{Name}\" (GUID {Guid}).", agentName, agentGuid);
}
private AuthSecret GetAuthSecret(GetAuthSecretCommand command) {
return authInfo.Secret;
} }
private void RefreshConnectionStatus(RefreshConnectionStatusCommand command) { private void RefreshConnectionStatus(RefreshConnectionStatusCommand command) {
@@ -269,7 +294,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 \"{Name}\" (GUID {Guid}).", agentName, agentGuid);
} }
} }
@@ -280,7 +305,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 \"{Name}\" (GUID {Guid}).", agentName, agentGuid);
} }
} }
@@ -332,7 +357,7 @@ sealed class AgentActor : ReceiveActor<AgentActor.ICommand>, IWithTimers {
string action = isCreating ? "Added" : "Edited"; string action = isCreating ? "Added" : "Edited";
string relation = isCreating ? "to agent" : "in agent"; string relation = isCreating ? "to agent" : "in agent";
Logger.Information(action + " instance \"{InstanceName}\" (GUID {InstanceGuid}) " + relation + " \"{AgentName}\".", instanceName, instanceGuid, configuration.AgentName); Logger.Information(action + " instance \"{InstanceName}\" (GUID {InstanceGuid}) " + relation + " \"{AgentName}\".", instanceName, instanceGuid, agentName);
return CreateOrUpdateInstanceResult.Success; return CreateOrUpdateInstanceResult.Success;
} }
@@ -341,7 +366,7 @@ sealed class AgentActor : ReceiveActor<AgentActor.ICommand>, IWithTimers {
string relation = isCreating ? "to agent" : "in agent"; string relation = isCreating ? "to agent" : "in agent";
string reason = result.Into(ConfigureInstanceResultExtensions.ToSentence, InstanceActionFailureExtensions.ToSentence); string reason = result.Into(ConfigureInstanceResultExtensions.ToSentence, InstanceActionFailureExtensions.ToSentence);
Logger.Information("Failed " + action + " instance \"{InstanceName}\" (GUID {InstanceGuid}) " + relation + " \"{AgentName}\". {ErrorMessage}", instanceName, instanceGuid, configuration.AgentName, reason); Logger.Information("Failed " + action + " instance \"{InstanceName}\" (GUID {InstanceGuid}) " + relation + " \"{AgentName}\". {ErrorMessage}", instanceName, instanceGuid, agentName, reason);
return CreateOrUpdateInstanceResult.UnknownError; return CreateOrUpdateInstanceResult.UnknownError;
} }
@@ -370,4 +395,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();
}
}
} }

View File

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

View File

@@ -3,6 +3,7 @@ using Phantom.Common.Data.Web.Agent;
using Phantom.Controller.Database; using Phantom.Controller.Database;
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;
@@ -22,7 +23,7 @@ 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 StoreAgentConfigurationCommand? storeCommand;
private bool hasScheduledFlush; private bool hasScheduledFlush;
private AgentDatabaseStorageActor(Init init) { private AgentDatabaseStorageActor(Init init) {
@@ -36,19 +37,19 @@ sealed class AgentDatabaseStorageActor : ReceiveActor<AgentDatabaseStorageActor.
public interface ICommand; public interface ICommand;
public sealed record StoreAgentConfigurationCommand(AgentConfiguration Configuration) : ICommand; public sealed record StoreAgentConfigurationCommand(string Name, AuthSecret AuthSecret, AgentConfiguration Configuration) : ICommand;
private sealed record FlushChangesCommand : ICommand; private sealed record FlushChangesCommand : ICommand;
private void StoreAgentConfiguration(StoreAgentConfigurationCommand command) { private void StoreAgentConfiguration(StoreAgentConfigurationCommand command) {
configurationToStore = command.Configuration; storeCommand = command;
ScheduleFlush(TimeSpan.FromSeconds(2)); ScheduleFlush(TimeSpan.FromSeconds(2));
} }
private async Task FlushChanges(FlushChangesCommand command) { private async Task FlushChanges(FlushChangesCommand command) {
hasScheduledFlush = false; hasScheduledFlush = false;
if (configurationToStore == null) { if (storeCommand == null) {
return; return;
} }
@@ -56,22 +57,23 @@ sealed class AgentDatabaseStorageActor : ReceiveActor<AgentDatabaseStorageActor.
await using var ctx = dbProvider.Eager(); await using var ctx = dbProvider.Eager();
var entity = ctx.AgentUpsert.Fetch(agentGuid); var entity = ctx.AgentUpsert.Fetch(agentGuid);
entity.Name = configurationToStore.AgentName; entity.Name = storeCommand.Name;
entity.ProtocolVersion = configurationToStore.ProtocolVersion; entity.AuthSecret = storeCommand.AuthSecret;
entity.BuildVersion = configurationToStore.BuildVersion; entity.ProtocolVersion = storeCommand.Configuration.ProtocolVersion;
entity.MaxInstances = configurationToStore.MaxInstances; entity.BuildVersion = storeCommand.Configuration.BuildVersion;
entity.MaxMemory = configurationToStore.MaxMemory; entity.MaxInstances = storeCommand.Configuration.MaxInstances;
entity.MaxMemory = storeCommand.Configuration.MaxMemory;
await ctx.SaveChangesAsync(cancellationToken); await ctx.SaveChangesAsync(cancellationToken);
} catch (Exception e) { } catch (Exception e) {
ScheduleFlush(TimeSpan.FromSeconds(10)); ScheduleFlush(TimeSpan.FromSeconds(10));
Logger.Error(e, "Could not store agent \"{AgentName}\" (GUID {AgentGuid}) in database.", configurationToStore.AgentName, agentGuid); Logger.Error(e, "Could not store agent \"{AgentName}\" (GUID {AgentGuid}) in database.", storeCommand.Name, agentGuid);
return; return;
} }
Logger.Information("Stored agent \"{AgentName}\" (GUID {AgentGuid}) in database.", configurationToStore.AgentName, agentGuid); Logger.Information("Stored agent \"{AgentName}\" (GUID {AgentGuid}) in database.", storeCommand.Name, agentGuid);
configurationToStore = null; storeCommand = null;
} }
private void ScheduleFlush(TimeSpan delay) { private void ScheduleFlush(TimeSpan delay) {

View File

@@ -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,40 +9,31 @@ 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,
UserLoginManager userLoginManager,
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) { private ActorRef<AgentActor.ICommand> CreateAgentActor(Guid agentGuid, string agentName, AuthSecret authSecret, AgentConfiguration agentConfiguration) {
this.actorSystem = actorSystem; var init = new AgentActor.Init(agentGuid, agentName, authSecret, agentConfiguration, agentConnectionKeys, controllerState, minecraftVersions, dbProvider, cancellationToken);
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; var name = "Agent:" + agentGuid;
return actorSystem.ActorOf(AgentActor.Factory(init), name); return actorSystem.ActorOf(AgentActor.Factory(init), name);
} }
@@ -49,22 +41,43 @@ sealed class AgentManager {
public async Task Initialize() { public async Task Initialize() {
await using var ctx = dbProvider.Eager(); await using var ctx = dbProvider.Eager();
List<AgentEntity> agentsWithoutSecrets = await ctx.Agents.Where(static entity => entity.AuthSecret == null).ToListAsync(cancellationToken);
if (agentsWithoutSecrets.Count > 0) {
foreach (var entity in agentsWithoutSecrets) {
entity.AuthSecret = AuthSecret.Generate();
}
await ctx.SaveChangesAsync(cancellationToken);
}
await foreach (var entity in ctx.Agents.AsAsyncEnumerable().WithCancellation(cancellationToken)) { await foreach (var entity in ctx.Agents.AsAsyncEnumerable().WithCancellation(cancellationToken)) {
var agentGuid = entity.AgentGuid; var agentGuid = entity.AgentGuid;
var agentConfiguration = new AgentConfiguration(entity.Name, entity.ProtocolVersion, entity.BuildVersion, entity.MaxInstances, entity.MaxMemory); var agentConfiguration = new AgentConfiguration(entity.ProtocolVersion, entity.BuildVersion, entity.MaxInstances, entity.MaxMemory);
if (agentsByAgentGuid.TryAdd(agentGuid, CreateAgentActor(agentGuid, agentConfiguration))) { if (agentsByAgentGuid.TryAdd(agentGuid, CreateAgentActor(agentGuid, entity.Name, entity.AuthSecret!, agentConfiguration))) {
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) { public async Task<ImmutableArray<ConfigureInstanceMessage>?> RegisterAgent(Guid agentGuid, AgentRegistration registration) {
if (!agentsByAgentGuid.TryGetValue(agentGuid, out var agentActor)) {
return null;
}
var agentConfiguration = AgentConfiguration.From(registration.AgentInfo); var agentConfiguration = AgentConfiguration.From(registration.AgentInfo);
var agentActor = agentsByAgentGuid.GetOrAdd(registration.AgentInfo.AgentGuid, addAgentActorFactory, agentConfiguration);
return await agentActor.Request(new AgentActor.RegisterCommand(agentConfiguration, registration.JavaRuntimes), cancellationToken); return await agentActor.Request(new AgentActor.RegisterCommand(agentConfiguration, 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) {
if (agentsByAgentGuid.TryGetValue(agentGuid, out var agent)) { if (agentsByAgentGuid.TryGetValue(agentGuid, out var agent)) {
agent.Tell(command); agent.Tell(command);

View File

@@ -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, UserLoginManager, 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);
} }

View File

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

View File

@@ -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."));
}
return Either.Left(new RegistrationResult(configureInstanceMessages.Value));
} }
private readonly record struct RegistrationResult(AgentInfo AgentInfo, ImmutableArray<ConfigureInstanceMessage> ConfigureInstanceMessages); private readonly record struct RegistrationResult(ImmutableArray<ConfigureInstanceMessage> ConfigureInstanceMessages);
} }

View File

@@ -1,7 +1,6 @@
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Diagnostics.CodeAnalysis; 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;
@@ -17,15 +16,14 @@ sealed class AgentClientRegistrar(
AgentManager agentManager, AgentManager agentManager,
InstanceLogManager instanceLogManager, InstanceLogManager instanceLogManager,
EventLogManager eventLogManager EventLogManager eventLogManager
) : IRpcServerClientRegistrar<IMessageToController, IMessageToAgent, AgentInfo> { ) : IRpcServerClientRegistrar<IMessageToController, IMessageToAgent> {
private readonly ConcurrentDictionary<Guid, Receiver> receiversBySessionGuid = new (); private readonly ConcurrentDictionary<Guid, Receiver> receiversBySessionGuid = new ();
[SuppressMessage("ReSharper", "LambdaShouldNotCaptureContext")] [SuppressMessage("ReSharper", "LambdaShouldNotCaptureContext")]
public IMessageReceiver<IMessageToController> Register(RpcServerToClientConnection<IMessageToController, IMessageToAgent> connection, AgentInfo handshakeResult) { public IMessageReceiver<IMessageToController> Register(RpcServerToClientConnection<IMessageToController, IMessageToAgent> connection, Guid agentGuid) {
var agentGuid = handshakeResult.AgentGuid;
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, CreateReceiver, 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 +31,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));
} }

View File

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

View File

@@ -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, Guid clientGuid) {
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));
} }

View File

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

View File

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

View File

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

View File

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

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.

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

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

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

View File

@@ -0,0 +1,7 @@
namespace Phantom.Utils.Rpc.Handshake;
enum RpcAuthResult : byte {
UnknownClient = 0,
InvalidSecret = 1,
Success = 255,
}

View File

@@ -1,4 +1,4 @@
namespace Phantom.Utils.Rpc.Runtime; namespace Phantom.Utils.Rpc.Handshake;
enum RpcFinalHandshakeResult : byte { enum RpcFinalHandshakeResult : byte {
Error = 0, Error = 0,

View File

@@ -0,0 +1,6 @@
namespace Phantom.Utils.Rpc.Handshake;
enum RpcSessionRegistrationResult : byte {
AlreadyClosed = 0,
Success = 255,
}

View File

@@ -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,17 @@ 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;
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 +59,7 @@ sealed class RpcClientToServerConnector<TClientToServerMessage, TServerToClientM
cancellationToken.ThrowIfCancellationRequested(); cancellationToken.ThrowIfCancellationRequested();
if (attempt >= maxAttempts) { if (attempt >= maxAttempts || wasRejectedDueToClosedSession) {
break; break;
} }
@@ -83,6 +85,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);
} }
@@ -165,14 +172,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) {
return null; 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;
} }
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 +218,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:
return null; case RpcFinalHandshakeResult.ReusedSession:
return new ConnectionResult(finalHandshakeResult == RpcFinalHandshakeResult.NewSession, pingInterval.Value, mappedMessageDefinitions);
default:
logger.Error("Server rejected client due to unknown error.");
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) {

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) {

View File

@@ -0,0 +1,5 @@
namespace Phantom.Utils.Rpc.Runtime.Server;
public interface IRpcServerClientAuthProvider {
Task<AuthSecret?> GetAuthSecret(Guid clientGuid);
}

View File

@@ -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> {
Task<Either<T, Exception>> Perform(bool isNewSession, RpcStream stream, CancellationToken cancellationToken);
}
public static class RpcServerClientHandshake {
public readonly record struct NoValue;
public sealed record NoOp : IRpcServerClientHandshake<NoValue> { sealed record NoOp : IRpcServerClientHandshake {
public Task<Either<NoValue, Exception>> Perform(bool isNewSession, RpcStream stream, CancellationToken cancellationToken) { public Task Perform(bool isNewSession, RpcStream stream, Guid clientGuid, CancellationToken cancellationToken) {
return Task.FromResult<Either<NoValue, Exception>>(Either.Left(new NoValue())); return Task.CompletedTask;
} }
} }
} }

View File

@@ -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, Guid clientGuid);
} }

View File

@@ -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;
await stream.Flush(cancellationToken); 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);
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,28 +287,42 @@ 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);
switch (await sharedData.ClientHandshake.Perform(session.IsNew, stream, cancellationToken)) { var expectedAuthSecret = await sharedData.ClientAuthProvider.GetAuthSecret(clientGuid);
case Left<THandshakeResult, Exception>(var handshakeResult): if (expectedAuthSecret == null) {
try { logger.Warning("Rejected client, unknown client: {ClientGuid}", clientGuid);
var connection = new RpcServerToClientConnection<TClientToServerMessage, TServerToClientMessage>(sharedData.ConnectionParameters, sharedData.MessageDefinitions.ToServer.Mapping, session, stream); return RpcAuthResult.UnknownClient;
var messageReceiver = sharedData.ClientRegistrar.Register(connection, handshakeResult); }
else if (!expectedAuthSecret.FixedTimeEquals(clientAuthToken.Secret)) {
return new EstablishedConnection(session, connection, messageReceiver); logger.Warning("Rejected client, invalid authorization secret.");
} catch (Exception e) { return RpcAuthResult.InvalidSecret;
logger.Error(e, "Could not register client."); }
return null; 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;
}
try {
var connection = new RpcServerToClientConnection<TClientToServerMessage, TServerToClientMessage>(sharedData.ConnectionParameters, sharedData.MessageDefinitions.ToServer.Mapping, session, stream);
var messageReceiver = sharedData.ClientRegistrar.Register(connection, clientGuid);
case Right<THandshakeResult, Exception>(var exception): return new EstablishedConnection(session, connection, messageReceiver);
logger.Error(exception, "Could not finish application handshake."); } catch (Exception e) {
return null; logger.Error(e, "Could not register client.");
return null;
default:
return null;
} }
} }

View File

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

View File

@@ -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; var sessionHolder = sessionsByClientGuid.GetOrAdd(clientGuid, static (clientGuid, sessions) => new SessionHolder(clientGuid, sessions), this);
this.createSessionFunction = CreateSession; return await sessionHolder.GetOrReplaceSession(sessionGuid);
} }
public RpcServerClientSession<TServerToClientMessage> GetOrCreateSession(Guid sessionId) { private RpcServerClientSession<TServerToClientMessage> CreateSession(Guid clientGuid, Guid sessionGuid) {
return sessionsById.GetOrAdd(sessionId, createSessionFunction); return new RpcServerClientSession<TServerToClientMessage>(NextLoggerName(clientGuid), connectionParameters, messageTypeMapping, this, clientGuid, sessionGuid);
} }
private RpcServerClientSession<TServerToClientMessage> CreateSession(Guid sessionId) { private string NextLoggerName(Guid sessionGuid) {
return new RpcServerClientSession<TServerToClientMessage>(NextLoggerName(sessionId), connectionParameters, messageTypeMapping, this, sessionId); string name = PhantomLogger.ShortenGuid(sessionGuid);
}
private string NextLoggerName(Guid sessionId) {
string name = PhantomLogger.ShortenGuid(sessionId);
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);
}
}
} }

View File

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

View File

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

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

View File

@@ -2,8 +2,9 @@
@using System.Collections.Immutable @using System.Collections.Immutable
@using Phantom.Common.Data.Web.Agent @using Phantom.Common.Data.Web.Agent
@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 @inherits PhantomComponent
@inject AgentManager AgentManager @inject AgentManager AgentManager
<h1>Agents</h1> <h1>Agents</h1>
@@ -16,15 +17,17 @@
<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 connectionKey = TokenGenerator.EncodeBytes(agent.ConnectionKey.AsSpan());
var configuration = agent.Configuration; var configuration = agent.Configuration;
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.Name</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">
@@ -64,6 +67,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.
@@ -82,7 +88,7 @@
AgentManager.AgentsChanged.Subscribe(this, agents => { AgentManager.AgentsChanged.Subscribe(this, agents => {
var sortedAgents = agents.Where(agent => authenticatedUser.Info.HasAccessToAgent(agent.AgentGuid)) var sortedAgents = agents.Where(agent => authenticatedUser.Info.HasAccessToAgent(agent.AgentGuid))
.OrderBy(static agent => agent.Configuration.AgentName) .OrderBy(static agent => agent.Name)
.ToImmutableArray(); .ToImmutableArray();
agentTable ??= new TableData<Agent, Guid>(); agentTable ??= new TableData<Agent, Guid>();

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().ToImmutableDictionary(static kvp => kvp.AgentGuid, static kvp => kvp.Name);
instanceNamesByGuid = InstanceManager.GetAll().Values.ToImmutableDictionary(static instance => instance.InstanceGuid, static instance => instance.Configuration.InstanceName); instanceNamesByGuid = InstanceManager.GetAll().Values.ToImmutableDictionary(static instance => instance.InstanceGuid, static instance => instance.Configuration.InstanceName);
} }
else { else {

View File

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

View File

@@ -32,7 +32,7 @@
var configuration = agent.Configuration; var configuration = agent.Configuration;
return return
@<option value="@agent.AgentGuid"> @<option value="@agent.AgentGuid">
@configuration.AgentName @agent.Name
&bullet; &bullet;
@(agent.Stats?.RunningInstanceCount.ToString() ?? "?")/@(configuration.MaxInstances) @(configuration.MaxInstances == 1 ? "Instance" : "Instances") @(agent.Stats?.RunningInstanceCount.ToString() ?? "?")/@(configuration.MaxInstances) @(configuration.MaxInstances == 1 ? "Instance" : "Instances")
&bullet; &bullet;
@@ -43,7 +43,7 @@
@if (EditedInstance == null) { @if (EditedInstance == null) {
<FormSelectInput Id="instance-agent" Label="Agent" @bind-Value="form.SelectedAgentGuid"> <FormSelectInput Id="instance-agent" Label="Agent" @bind-Value="form.SelectedAgentGuid">
<option value="" selected>Select which agent will run the instance...</option> <option value="" selected>Select which agent will run the instance...</option>
@foreach (var agent in allAgentsByGuid.Values.Where(static agent => agent.ConnectionStatus is AgentIsOnline).OrderBy(static agent => agent.Configuration.AgentName)) { @foreach (var agent in allAgentsByGuid.Values.Where(static agent => agent.ConnectionStatus is AgentIsOnline).OrderBy(static agent => agent.Name)) {
@GetAgentOption(agent) @GetAgentOption(agent)
} }
</FormSelectInput> </FormSelectInput>

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

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