diff --git a/.run/Agent 1.run.xml b/.run/Agent 1.run.xml index 757278b..6d348c7 100644 --- a/.run/Agent 1.run.xml +++ b/.run/Agent 1.run.xml @@ -9,10 +9,10 @@ <env name="AGENT_NAME" value="Agent 1" /> <env name="ALLOWED_RCON_PORTS" value="25575,27000,27001" /> <env name="ALLOWED_SERVER_PORTS" value="25565,26000,26001" /> + <env name="CONTROLLER_HOST" value="localhost" /> <env name="JAVA_SEARCH_PATH" value="~/.jdks" /> <env name="MAX_INSTANCES" value="3" /> <env name="MAX_MEMORY" value="12G" /> - <env name="SERVER_HOST" value="localhost" /> </envs> <option name="USE_EXTERNAL_CONSOLE" value="0" /> <option name="USE_MONO" value="0" /> diff --git a/.run/Agent 2.run.xml b/.run/Agent 2.run.xml index 72dbc3d..593758f 100644 --- a/.run/Agent 2.run.xml +++ b/.run/Agent 2.run.xml @@ -9,10 +9,10 @@ <env name="AGENT_NAME" value="Agent 2" /> <env name="ALLOWED_RCON_PORTS" value="27002-27006" /> <env name="ALLOWED_SERVER_PORTS" value="26002-26006" /> + <env name="CONTROLLER_HOST" value="localhost" /> <env name="JAVA_SEARCH_PATH" value="~/.jdks" /> <env name="MAX_INSTANCES" value="5" /> <env name="MAX_MEMORY" value="10G" /> - <env name="SERVER_HOST" value="localhost" /> </envs> <option name="USE_EXTERNAL_CONSOLE" value="0" /> <option name="USE_MONO" value="0" /> diff --git a/.run/Agent 3.run.xml b/.run/Agent 3.run.xml index 7c4c26f..e7c3fbb 100644 --- a/.run/Agent 3.run.xml +++ b/.run/Agent 3.run.xml @@ -9,10 +9,10 @@ <env name="AGENT_NAME" value="Agent 3" /> <env name="ALLOWED_RCON_PORTS" value="27007" /> <env name="ALLOWED_SERVER_PORTS" value="26007" /> + <env name="CONTROLLER_HOST" value="localhost" /> <env name="JAVA_SEARCH_PATH" value="~/.jdks" /> <env name="MAX_INSTANCES" value="1" /> <env name="MAX_MEMORY" value="2560M" /> - <env name="SERVER_HOST" value="localhost" /> </envs> <option name="USE_EXTERNAL_CONSOLE" value="0" /> <option name="USE_MONO" value="0" /> diff --git a/.run/Server + Agent x3.run.xml b/.run/Controller + Agent x3.run.xml similarity index 59% rename from .run/Server + Agent x3.run.xml rename to .run/Controller + Agent x3.run.xml index 667da24..2fe0300 100644 --- a/.run/Server + Agent x3.run.xml +++ b/.run/Controller + Agent x3.run.xml @@ -1,9 +1,9 @@ <component name="ProjectRunConfigurationManager"> - <configuration default="false" name="Server + Agent x3" type="CompoundRunConfigurationType"> + <configuration default="false" name="Controller + Agent x3" type="CompoundRunConfigurationType"> <toRun name="Agent 1" type="DotNetProject" /> <toRun name="Agent 2" type="DotNetProject" /> <toRun name="Agent 3" type="DotNetProject" /> - <toRun name="Server" type="DotNetProject" /> + <toRun name="Controller" type="DotNetProject" /> <method v="2" /> </configuration> -</component> +</component> \ No newline at end of file diff --git a/.run/Controller + Agent.run.xml b/.run/Controller + Agent.run.xml new file mode 100644 index 0000000..2e299db --- /dev/null +++ b/.run/Controller + Agent.run.xml @@ -0,0 +1,7 @@ +<component name="ProjectRunConfigurationManager"> + <configuration default="false" name="Controller + Agent" type="CompoundRunConfigurationType"> + <toRun name="Agent 1" type="DotNetProject" /> + <toRun name="Controller" type="DotNetProject" /> + <method v="2" /> + </configuration> +</component> \ No newline at end of file diff --git a/.run/Server.run.xml b/.run/Controller.run.xml similarity index 78% rename from .run/Server.run.xml rename to .run/Controller.run.xml index 49286b8..bbb20f5 100644 --- a/.run/Server.run.xml +++ b/.run/Controller.run.xml @@ -1,18 +1,17 @@ <component name="ProjectRunConfigurationManager"> - <configuration default="false" name="Server" type="DotNetProject" factoryName=".NET Project"> + <configuration default="false" name="Controller" type="DotNetProject" factoryName=".NET Project"> <option name="EXE_PATH" value="$PROJECT_DIR$/.artifacts/bin/Phantom.Controller/debug/Phantom.Controller.exe" /> <option name="PROGRAM_PARAMETERS" value="" /> - <option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/.workdir/Server" /> + <option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/.workdir/Controller" /> <option name="PASS_PARENT_ENVS" value="1" /> <envs> - <env name="ASPNETCORE_ENVIRONMENT" value="Development" /> + <env name="AGENT_RPC_SERVER_HOST" value="localhost" /> <env name="PG_DATABASE" value="postgres" /> <env name="PG_HOST" value="localhost" /> <env name="PG_PASS" value="development" /> - <env name="PG_PORT" value="9402" /> + <env name="PG_PORT" value="9403" /> <env name="PG_USER" value="postgres" /> - <env name="RPC_SERVER_HOST" value="localhost" /> - <env name="WEB_SERVER_HOST" value="localhost" /> + <env name="WEB_RPC_SERVER_HOST" value="localhost" /> </envs> <option name="USE_EXTERNAL_CONSOLE" value="0" /> <option name="USE_MONO" value="0" /> diff --git a/.run/Server + Agent.run.xml b/.run/Server + Agent.run.xml deleted file mode 100644 index 9845ce9..0000000 --- a/.run/Server + Agent.run.xml +++ /dev/null @@ -1,7 +0,0 @@ -<component name="ProjectRunConfigurationManager"> - <configuration default="false" name="Server + Agent" type="CompoundRunConfigurationType"> - <toRun name="Agent 1" type="DotNetProject" /> - <toRun name="Server" type="DotNetProject" /> - <method v="2" /> - </configuration> -</component> diff --git a/.workdir/Controller/.gitignore b/.workdir/Controller/.gitignore new file mode 100644 index 0000000..5f28270 --- /dev/null +++ b/.workdir/Controller/.gitignore @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/.workdir/Server/secrets/agent.key b/.workdir/Controller/secrets/agent.key similarity index 100% rename from .workdir/Server/secrets/agent.key rename to .workdir/Controller/secrets/agent.key diff --git a/.workdir/Server/secrets/secret.key b/.workdir/Controller/secrets/agent.secret similarity index 100% rename from .workdir/Server/secrets/secret.key rename to .workdir/Controller/secrets/agent.secret diff --git a/.workdir/Controller/secrets/web.key b/.workdir/Controller/secrets/web.key new file mode 100644 index 0000000..4377690 --- /dev/null +++ b/.workdir/Controller/secrets/web.key @@ -0,0 +1,2 @@ +��h?�Bx +� �f-�������"8���J���Jn/�da \ No newline at end of file diff --git a/.workdir/Controller/secrets/web.secret b/.workdir/Controller/secrets/web.secret new file mode 100644 index 0000000..b2ce0cd --- /dev/null +++ b/.workdir/Controller/secrets/web.secret @@ -0,0 +1 @@ +T�./g��N�t�$�!�(��#�~��}�<: \ No newline at end of file diff --git a/.workdir/Server/.gitignore b/.workdir/Web/.gitignore similarity index 100% rename from .workdir/Server/.gitignore rename to .workdir/Web/.gitignore diff --git a/Agent/Phantom.Agent.Rpc/KeepAliveLoop.cs b/Agent/Phantom.Agent.Rpc/KeepAliveLoop.cs index dfc4635..350a05a 100644 --- a/Agent/Phantom.Agent.Rpc/KeepAliveLoop.cs +++ b/Agent/Phantom.Agent.Rpc/KeepAliveLoop.cs @@ -1,5 +1,5 @@ using Phantom.Common.Logging; -using Phantom.Common.Messages.ToServer; +using Phantom.Common.Messages.Agent.ToController; using Serilog; namespace Phantom.Agent.Rpc; diff --git a/Agent/Phantom.Agent.Rpc/Phantom.Agent.Rpc.csproj b/Agent/Phantom.Agent.Rpc/Phantom.Agent.Rpc.csproj index 67af0e7..f8d805b 100644 --- a/Agent/Phantom.Agent.Rpc/Phantom.Agent.Rpc.csproj +++ b/Agent/Phantom.Agent.Rpc/Phantom.Agent.Rpc.csproj @@ -6,7 +6,7 @@ </PropertyGroup> <ItemGroup> - <ProjectReference Include="..\..\Common\Phantom.Common.Messages\Phantom.Common.Messages.csproj" /> + <ProjectReference Include="..\..\Common\Phantom.Common.Messages.Agent\Phantom.Common.Messages.Agent.csproj" /> </ItemGroup> </Project> diff --git a/Agent/Phantom.Agent.Rpc/RpcLauncher.cs b/Agent/Phantom.Agent.Rpc/RpcLauncher.cs index 763b502..4e17a67 100644 --- a/Agent/Phantom.Agent.Rpc/RpcLauncher.cs +++ b/Agent/Phantom.Agent.Rpc/RpcLauncher.cs @@ -1,9 +1,9 @@ using NetMQ; using NetMQ.Sockets; using Phantom.Common.Data.Agent; -using Phantom.Common.Messages; -using Phantom.Common.Messages.BiDirectional; -using Phantom.Common.Messages.ToServer; +using Phantom.Common.Messages.Agent; +using Phantom.Common.Messages.Agent.BiDirectional; +using Phantom.Common.Messages.Agent.ToController; using Phantom.Utils.Rpc; using Phantom.Utils.Rpc.Message; using Phantom.Utils.Tasks; @@ -13,13 +13,13 @@ using Serilog.Events; namespace Phantom.Agent.Rpc; public sealed class RpcLauncher : RpcRuntime<ClientSocket> { - public static Task Launch(RpcConfiguration config, AgentAuthToken authToken, AgentInfo agentInfo, Func<RpcServerConnection, IMessageToAgentListener> listenerFactory, SemaphoreSlim disconnectSemaphore, CancellationToken receiveCancellationToken) { + public static Task Launch(RpcConfiguration config, AuthToken authToken, AgentInfo agentInfo, Func<RpcServerConnection, IMessageToAgentListener> listenerFactory, SemaphoreSlim disconnectSemaphore, CancellationToken receiveCancellationToken) { var socket = new ClientSocket(); var options = socket.Options; options.CurveServerCertificate = config.ServerCertificate; options.CurveCertificate = new NetMQCertificate(); - options.HelloMessage = MessageRegistries.ToServer.Write(new RegisterAgentMessage(authToken, agentInfo)).ToArray(); + options.HelloMessage = AgentMessageRegistries.ToController.Write(new RegisterAgentMessage(authToken, agentInfo)).ToArray(); return new RpcLauncher(config, socket, agentInfo.Guid, listenerFactory, disconnectSemaphore, receiveCancellationToken).Launch(); } @@ -63,7 +63,7 @@ public sealed class RpcLauncher : RpcRuntime<ClientSocket> { LogMessageType(logger, data); if (data.Length > 0) { - MessageRegistries.ToAgent.Handle(data, handler); + AgentMessageRegistries.ToAgent.Handle(data, handler); } } } catch (OperationCanceledException) { @@ -81,11 +81,11 @@ public sealed class RpcLauncher : RpcRuntime<ClientSocket> { return; } - if (data.Length > 0 && MessageRegistries.ToAgent.TryGetType(data, out var type)) { - logger.Verbose("Received {MessageType} ({Bytes} B) from server.", type.Name, data.Length); + if (data.Length > 0 && AgentMessageRegistries.ToAgent.TryGetType(data, out var type)) { + logger.Verbose("Received {MessageType} ({Bytes} B) from controller.", type.Name, data.Length); } else { - logger.Verbose("Received {Bytes} B message from server.", data.Length); + logger.Verbose("Received {Bytes} B message from controller.", data.Length); } } @@ -93,7 +93,7 @@ public sealed class RpcLauncher : RpcRuntime<ClientSocket> { var unregisterTimeoutTask = Task.Delay(TimeSpan.FromSeconds(5), CancellationToken.None); var finishedTask = await Task.WhenAny(ServerMessaging.Send(new UnregisterAgentMessage(agentGuid)), unregisterTimeoutTask); if (finishedTask == unregisterTimeoutTask) { - config.RuntimeLogger.Error("Timed out communicating agent shutdown with the server."); + config.RuntimeLogger.Error("Timed out communicating agent shutdown with the controller."); } } diff --git a/Agent/Phantom.Agent.Rpc/RpcServerConnection.cs b/Agent/Phantom.Agent.Rpc/RpcServerConnection.cs index da9e13e..62e6e45 100644 --- a/Agent/Phantom.Agent.Rpc/RpcServerConnection.cs +++ b/Agent/Phantom.Agent.Rpc/RpcServerConnection.cs @@ -1,7 +1,7 @@ using NetMQ; using NetMQ.Sockets; -using Phantom.Common.Messages; -using Phantom.Common.Messages.BiDirectional; +using Phantom.Common.Messages.Agent; +using Phantom.Common.Messages.Agent.BiDirectional; using Phantom.Utils.Rpc.Message; namespace Phantom.Agent.Rpc; @@ -15,17 +15,17 @@ public sealed class RpcServerConnection { this.replyTracker = replyTracker; } - internal async Task Send<TMessage>(TMessage message) where TMessage : IMessageToServer { - var bytes = MessageRegistries.ToServer.Write(message).ToArray(); + internal async Task Send<TMessage>(TMessage message) where TMessage : IMessageToController { + var bytes = AgentMessageRegistries.ToController.Write(message).ToArray(); if (bytes.Length > 0) { await socket.SendAsync(bytes); } } - internal async Task<TReply?> Send<TMessage, TReply>(TMessage message, TimeSpan waitForReplyTime, CancellationToken waitForReplyCancellationToken) where TMessage : IMessageToServer<TReply> where TReply : class { + internal async Task<TReply?> Send<TMessage, TReply>(TMessage message, TimeSpan waitForReplyTime, CancellationToken waitForReplyCancellationToken) where TMessage : IMessageToController<TReply> where TReply : class { var sequenceId = replyTracker.RegisterReply(); - var bytes = MessageRegistries.ToServer.Write<TMessage, TReply>(sequenceId, message).ToArray(); + var bytes = AgentMessageRegistries.ToController.Write<TMessage, TReply>(sequenceId, message).ToArray(); if (bytes.Length == 0) { replyTracker.ForgetReply(sequenceId); return null; diff --git a/Agent/Phantom.Agent.Rpc/ServerMessaging.cs b/Agent/Phantom.Agent.Rpc/ServerMessaging.cs index 82a299d..7466e39 100644 --- a/Agent/Phantom.Agent.Rpc/ServerMessaging.cs +++ b/Agent/Phantom.Agent.Rpc/ServerMessaging.cs @@ -1,5 +1,5 @@ using Phantom.Common.Logging; -using Phantom.Common.Messages; +using Phantom.Common.Messages.Agent; using Serilog; namespace Phantom.Agent.Rpc; @@ -24,11 +24,11 @@ public static class ServerMessaging { Logger.Information("Server connection ready."); } - public static Task Send<TMessage>(TMessage message) where TMessage : IMessageToServer { + public static Task Send<TMessage>(TMessage message) where TMessage : IMessageToController { return CurrentConnectionOrThrow.Send(message); } - public static Task<TReply?> Send<TMessage, TReply>(TMessage message, TimeSpan waitForReplyTime, CancellationToken waitForReplyCancellationToken) where TMessage : IMessageToServer<TReply> where TReply : class { + public static Task<TReply?> Send<TMessage, TReply>(TMessage message, TimeSpan waitForReplyTime, CancellationToken waitForReplyCancellationToken) where TMessage : IMessageToController<TReply> where TReply : class { return CurrentConnectionOrThrow.Send<TMessage, TReply>(message, waitForReplyTime, waitForReplyCancellationToken); } } diff --git a/Agent/Phantom.Agent.Services/Instances/Instance.cs b/Agent/Phantom.Agent.Services/Instances/Instance.cs index 6e12e9c..5aa2b78 100644 --- a/Agent/Phantom.Agent.Services/Instances/Instance.cs +++ b/Agent/Phantom.Agent.Services/Instances/Instance.cs @@ -6,7 +6,7 @@ using Phantom.Common.Data.Instance; using Phantom.Common.Data.Minecraft; using Phantom.Common.Data.Replies; using Phantom.Common.Logging; -using Phantom.Common.Messages.ToServer; +using Phantom.Common.Messages.Agent.ToController; using Serilog; namespace Phantom.Agent.Services.Instances; diff --git a/Agent/Phantom.Agent.Services/Instances/InstanceLogSender.cs b/Agent/Phantom.Agent.Services/Instances/InstanceLogSender.cs index 0f23707..2d7b781 100644 --- a/Agent/Phantom.Agent.Services/Instances/InstanceLogSender.cs +++ b/Agent/Phantom.Agent.Services/Instances/InstanceLogSender.cs @@ -2,7 +2,7 @@ using System.Collections.Immutable; using System.Threading.Channels; using Phantom.Agent.Rpc; using Phantom.Common.Logging; -using Phantom.Common.Messages.ToServer; +using Phantom.Common.Messages.Agent.ToController; using Phantom.Utils.Tasks; namespace Phantom.Agent.Services.Instances; diff --git a/Agent/Phantom.Agent.Services/Instances/InstanceSessionManager.cs b/Agent/Phantom.Agent.Services/Instances/InstanceSessionManager.cs index 3a24ccb..ad0a507 100644 --- a/Agent/Phantom.Agent.Services/Instances/InstanceSessionManager.cs +++ b/Agent/Phantom.Agent.Services/Instances/InstanceSessionManager.cs @@ -14,7 +14,7 @@ using Phantom.Common.Data.Instance; using Phantom.Common.Data.Minecraft; using Phantom.Common.Data.Replies; using Phantom.Common.Logging; -using Phantom.Common.Messages.ToServer; +using Phantom.Common.Messages.Agent.ToController; using Phantom.Utils.IO; using Phantom.Utils.Tasks; using Serilog; diff --git a/Agent/Phantom.Agent.Services/Phantom.Agent.Services.csproj b/Agent/Phantom.Agent.Services/Phantom.Agent.Services.csproj index e23555d..6295392 100644 --- a/Agent/Phantom.Agent.Services/Phantom.Agent.Services.csproj +++ b/Agent/Phantom.Agent.Services/Phantom.Agent.Services.csproj @@ -6,7 +6,7 @@ </PropertyGroup> <ItemGroup> - <ProjectReference Include="..\..\Common\Phantom.Common.Messages\Phantom.Common.Messages.csproj" /> + <ProjectReference Include="..\..\Common\Phantom.Common.Messages.Agent\Phantom.Common.Messages.Agent.csproj" /> <ProjectReference Include="..\Phantom.Agent.Minecraft\Phantom.Agent.Minecraft.csproj" /> <ProjectReference Include="..\Phantom.Agent.Rpc\Phantom.Agent.Rpc.csproj" /> </ItemGroup> diff --git a/Agent/Phantom.Agent.Services/Rpc/MessageListener.cs b/Agent/Phantom.Agent.Services/Rpc/MessageListener.cs index d6fa4f0..a83ae77 100644 --- a/Agent/Phantom.Agent.Services/Rpc/MessageListener.cs +++ b/Agent/Phantom.Agent.Services/Rpc/MessageListener.cs @@ -2,10 +2,10 @@ using Phantom.Common.Data.Instance; using Phantom.Common.Data.Replies; using Phantom.Common.Logging; -using Phantom.Common.Messages; -using Phantom.Common.Messages.BiDirectional; -using Phantom.Common.Messages.ToAgent; -using Phantom.Common.Messages.ToServer; +using Phantom.Common.Messages.Agent; +using Phantom.Common.Messages.Agent.BiDirectional; +using Phantom.Common.Messages.Agent.ToAgent; +using Phantom.Common.Messages.Agent.ToController; using Phantom.Utils.Rpc.Message; using Serilog; diff --git a/Agent/Phantom.Agent/AgentKey.cs b/Agent/Phantom.Agent/AgentKey.cs index c852820..dd2ed1c 100644 --- a/Agent/Phantom.Agent/AgentKey.cs +++ b/Agent/Phantom.Agent/AgentKey.cs @@ -10,7 +10,7 @@ namespace Phantom.Agent; static class AgentKey { private static ILogger Logger { get; } = PhantomLogger.Create(nameof(AgentKey)); - public static Task<(NetMQCertificate, AgentAuthToken)?> Load(string? agentKeyToken, string? agentKeyFilePath) { + public static Task<(NetMQCertificate, AuthToken)?> Load(string? agentKeyToken, string? agentKeyFilePath) { if (agentKeyFilePath != null) { return LoadFromFile(agentKeyFilePath); } @@ -22,7 +22,7 @@ static class AgentKey { } } - private static async Task<(NetMQCertificate, AgentAuthToken)?> LoadFromFile(string agentKeyFilePath) { + private static async Task<(NetMQCertificate, AuthToken)?> LoadFromFile(string agentKeyFilePath) { if (!File.Exists(agentKeyFilePath)) { Logger.Fatal("Missing agent key file: {AgentKeyFilePath}", agentKeyFilePath); return null; @@ -41,7 +41,7 @@ static class AgentKey { } } - private static (NetMQCertificate, AgentAuthToken)? LoadFromToken(string agentKey) { + private static (NetMQCertificate, AuthToken)? LoadFromToken(string agentKey) { try { return LoadFromBytes(TokenGenerator.DecodeBytes(agentKey)); } catch (Exception) { @@ -50,11 +50,11 @@ static class AgentKey { } } - private static (NetMQCertificate, AgentAuthToken)? LoadFromBytes(byte[] agentKey) { - var (publicKey, agentToken) = AgentKeyData.FromBytes(agentKey); - var serverCertificate = NetMQCertificate.FromPublicKey(publicKey); + private static (NetMQCertificate, AuthToken)? LoadFromBytes(byte[] agentKey) { + var (publicKey, agentToken) = ConnectionCommonKey.FromBytes(agentKey); + var controllerCertificate = NetMQCertificate.FromPublicKey(publicKey); Logger.Information("Loaded agent key."); - return (serverCertificate, agentToken); + return (controllerCertificate, agentToken); } } diff --git a/Agent/Phantom.Agent/Program.cs b/Agent/Phantom.Agent/Program.cs index d14c267..988ea7e 100644 --- a/Agent/Phantom.Agent/Program.cs +++ b/Agent/Phantom.Agent/Program.cs @@ -26,7 +26,7 @@ try { PhantomLogger.Root.InformationHeading("Initializing Phantom Panel agent..."); PhantomLogger.Root.Information("Agent version: {Version}", fullVersion); - var (serverHost, serverPort, javaSearchPath, agentKeyToken, agentKeyFilePath, agentName, maxInstances, maxMemory, allowedServerPorts, allowedRconPorts, maxConcurrentBackupCompressionTasks) = Variables.LoadOrStop(); + var (controllerHost, controllerPort, javaSearchPath, agentKeyToken, agentKeyFilePath, agentName, maxInstances, maxMemory, allowedServerPorts, allowedRconPorts, maxConcurrentBackupCompressionTasks) = Variables.LoadOrStop(); var agentKey = await AgentKey.Load(agentKeyToken, agentKeyFilePath); if (agentKey == null) { @@ -43,7 +43,7 @@ try { return 1; } - var (serverCertificate, agentToken) = agentKey.Value; + var (controllerCertificate, agentToken) = agentKey.Value; var agentInfo = new AgentInfo(agentGuid.Value, agentName, ProtocolVersion, fullVersion, maxInstances, maxMemory, allowedServerPorts, allowedRconPorts); var agentServices = new AgentServices(agentInfo, folders, new AgentServiceConfiguration(maxConcurrentBackupCompressionTasks)); @@ -56,7 +56,7 @@ try { await agentServices.Initialize(); var rpcDisconnectSemaphore = new SemaphoreSlim(0, 1); - var rpcConfiguration = new RpcConfiguration(PhantomLogger.Create("Rpc"), PhantomLogger.Create<TaskManager>("Rpc"), serverHost, serverPort, serverCertificate); + var rpcConfiguration = new RpcConfiguration(PhantomLogger.Create("Rpc"), PhantomLogger.Create<TaskManager>("Rpc"), controllerHost, controllerPort, controllerCertificate); var rpcTask = RpcLauncher.Launch(rpcConfiguration, agentToken, agentInfo, MessageListenerFactory, rpcDisconnectSemaphore, shutdownCancellationToken); try { await rpcTask.WaitAsync(shutdownCancellationToken); diff --git a/Agent/Phantom.Agent/Variables.cs b/Agent/Phantom.Agent/Variables.cs index c71174d..774c6ce 100644 --- a/Agent/Phantom.Agent/Variables.cs +++ b/Agent/Phantom.Agent/Variables.cs @@ -6,8 +6,8 @@ using Phantom.Utils.Runtime; namespace Phantom.Agent; sealed record Variables( - string ServerHost, - ushort ServerPort, + string ControllerHost, + ushort ControllerPort, string JavaSearchPath, string? AgentKeyToken, string? AgentKeyFilePath, @@ -23,8 +23,8 @@ sealed record Variables( var javaSearchPath = EnvironmentVariables.GetString("JAVA_SEARCH_PATH").WithDefaultGetter(GetDefaultJavaSearchPath); return new Variables( - EnvironmentVariables.GetString("SERVER_HOST").Require, - EnvironmentVariables.GetPortNumber("SERVER_PORT").WithDefault(9401), + EnvironmentVariables.GetString("CONTROLLER_HOST").Require, + EnvironmentVariables.GetPortNumber("CONTROLLER_PORT").WithDefault(9401), javaSearchPath, agentKeyToken, agentKeyFilePath, diff --git a/Common/Phantom.Common.Data/Agent/AgentKeyData.cs b/Common/Phantom.Common.Data/Agent/AgentKeyData.cs deleted file mode 100644 index 2533860..0000000 --- a/Common/Phantom.Common.Data/Agent/AgentKeyData.cs +++ /dev/null @@ -1,18 +0,0 @@ -namespace Phantom.Common.Data.Agent; - -public static class AgentKeyData { - private const byte TokenLength = AgentAuthToken.Length; - - public static byte[] ToBytes(byte[] publicKey, AgentAuthToken agentToken) { - Span<byte> agentKey = stackalloc byte[TokenLength + publicKey.Length]; - agentToken.WriteTo(agentKey[..TokenLength]); - publicKey.CopyTo(agentKey[TokenLength..]); - return agentKey.ToArray(); - } - - public static (byte[] PublicKey, AgentAuthToken AgentToken) FromBytes(byte[] agentKey) { - var token = new AgentAuthToken(agentKey[..TokenLength]); - var publicKey = agentKey[TokenLength..]; - return (publicKey, token); - } -} diff --git a/Common/Phantom.Common.Data/Agent/AgentAuthToken.cs b/Common/Phantom.Common.Data/Agent/AuthToken.cs similarity index 75% rename from Common/Phantom.Common.Data/Agent/AgentAuthToken.cs rename to Common/Phantom.Common.Data/Agent/AuthToken.cs index 7a7cc08..20aec72 100644 --- a/Common/Phantom.Common.Data/Agent/AgentAuthToken.cs +++ b/Common/Phantom.Common.Data/Agent/AuthToken.cs @@ -6,14 +6,14 @@ namespace Phantom.Common.Data.Agent; [MemoryPackable(GenerateType.VersionTolerant)] [SuppressMessage("ReSharper", "MemberCanBePrivate.Global")] -public sealed partial class AgentAuthToken { +public sealed partial class AuthToken { internal const int Length = 12; [MemoryPackOrder(0)] [MemoryPackInclude] private readonly byte[] bytes; - internal AgentAuthToken(byte[]? bytes) { + internal AuthToken(byte[]? bytes) { if (bytes == null) { throw new ArgumentNullException(nameof(bytes)); } @@ -25,7 +25,7 @@ public sealed partial class AgentAuthToken { this.bytes = bytes; } - public bool FixedTimeEquals(AgentAuthToken providedAuthToken) { + public bool FixedTimeEquals(AuthToken providedAuthToken) { return CryptographicOperations.FixedTimeEquals(bytes, providedAuthToken.bytes); } @@ -33,7 +33,7 @@ public sealed partial class AgentAuthToken { bytes.CopyTo(span); } - public static AgentAuthToken Generate() { - return new AgentAuthToken(RandomNumberGenerator.GetBytes(Length)); + public static AuthToken Generate() { + return new AuthToken(RandomNumberGenerator.GetBytes(Length)); } } diff --git a/Common/Phantom.Common.Data/Agent/ConnectionCommonKey.cs b/Common/Phantom.Common.Data/Agent/ConnectionCommonKey.cs new file mode 100644 index 0000000..8eaea10 --- /dev/null +++ b/Common/Phantom.Common.Data/Agent/ConnectionCommonKey.cs @@ -0,0 +1,18 @@ +namespace Phantom.Common.Data.Agent; + +public readonly record struct ConnectionCommonKey(byte[] CertificatePublicKey, AuthToken AuthToken) { + private const byte TokenLength = AuthToken.Length; + + public byte[] ToBytes() { + Span<byte> result = stackalloc byte[TokenLength + CertificatePublicKey.Length]; + AuthToken.WriteTo(result[..TokenLength]); + CertificatePublicKey.CopyTo(result[TokenLength..]); + return result.ToArray(); + } + + public static ConnectionCommonKey FromBytes(byte[] agentKey) { + var authToken = new AuthToken(agentKey[..TokenLength]); + var certificatePublicKey = agentKey[TokenLength..]; + return new ConnectionCommonKey(certificatePublicKey, authToken); + } +} diff --git a/Common/Phantom.Common.Logging/PhantomLogger.cs b/Common/Phantom.Common.Logging/PhantomLogger.cs index 6314e40..01939e8 100644 --- a/Common/Phantom.Common.Logging/PhantomLogger.cs +++ b/Common/Phantom.Common.Logging/PhantomLogger.cs @@ -27,7 +27,7 @@ public static class PhantomLogger { } public static ILogger Create(string name1, string name2) { - return Create(name1 + ":" + name2); + return Create(ConcatNames(name1, name2)); } public static ILogger Create<T>() { @@ -37,11 +37,19 @@ public static class PhantomLogger { public static ILogger Create<T>(string name) { return Create(typeof(T).Name, name); } + + public static ILogger Create<T>(string name1, string name2) { + return Create(typeof(T).Name, ConcatNames(name1, name2)); + } public static ILogger Create<T1, T2>() { return Create(typeof(T1).Name, typeof(T2).Name); } + private static string ConcatNames(string name1, string name2) { + return name1 + ":" + name2; + } + public static void Dispose() { Root.Dispose(); Base.Dispose(); diff --git a/Common/Phantom.Common.Messages.Agent/AgentMessageRegistries.cs b/Common/Phantom.Common.Messages.Agent/AgentMessageRegistries.cs new file mode 100644 index 0000000..3023d71 --- /dev/null +++ b/Common/Phantom.Common.Messages.Agent/AgentMessageRegistries.cs @@ -0,0 +1,48 @@ +using Phantom.Common.Data.Replies; +using Phantom.Common.Logging; +using Phantom.Common.Messages.Agent.BiDirectional; +using Phantom.Common.Messages.Agent.ToAgent; +using Phantom.Common.Messages.Agent.ToController; +using Phantom.Utils.Rpc.Message; + +namespace Phantom.Common.Messages.Agent; + +public static class AgentMessageRegistries { + public static MessageRegistry<IMessageToAgentListener> ToAgent { get; } = new (PhantomLogger.Create("MessageRegistry", nameof(ToAgent))); + public static MessageRegistry<IMessageToControllerListener> ToController { get; } = new (PhantomLogger.Create("MessageRegistry", nameof(ToController))); + + public static IMessageDefinitions<IMessageToAgentListener, IMessageToControllerListener, ReplyMessage> Definitions { get; } = new MessageDefinitions(); + + static AgentMessageRegistries() { + ToAgent.Add<RegisterAgentSuccessMessage>(0); + ToAgent.Add<RegisterAgentFailureMessage>(1); + ToAgent.Add<ConfigureInstanceMessage, InstanceActionResult<ConfigureInstanceResult>>(2); + ToAgent.Add<LaunchInstanceMessage, InstanceActionResult<LaunchInstanceResult>>(3); + ToAgent.Add<StopInstanceMessage, InstanceActionResult<StopInstanceResult>>(4); + ToAgent.Add<SendCommandToInstanceMessage, InstanceActionResult<SendCommandToInstanceResult>>(5); + ToAgent.Add<ReplyMessage>(127); + + ToController.Add<RegisterAgentMessage>(0); + ToController.Add<UnregisterAgentMessage>(1); + ToController.Add<AgentIsAliveMessage>(2); + ToController.Add<AdvertiseJavaRuntimesMessage>(3); + ToController.Add<ReportInstanceStatusMessage>(4); + ToController.Add<InstanceOutputMessage>(5); + ToController.Add<ReportAgentStatusMessage>(6); + ToController.Add<ReportInstanceEventMessage>(7); + ToController.Add<ReplyMessage>(127); + } + + private sealed class MessageDefinitions : IMessageDefinitions<IMessageToAgentListener, IMessageToControllerListener, ReplyMessage> { + public MessageRegistry<IMessageToAgentListener> Outgoing => ToAgent; + public MessageRegistry<IMessageToControllerListener> Incoming => ToController; + + public bool IsRegistrationMessage(Type messageType) { + return messageType == typeof(RegisterAgentMessage); + } + + public ReplyMessage CreateReplyMessage( uint sequenceId, byte[] serializedReply) { + return new ReplyMessage(sequenceId, serializedReply); + } + } +} diff --git a/Common/Phantom.Common.Messages/BiDirectional/ReplyMessage.cs b/Common/Phantom.Common.Messages.Agent/BiDirectional/ReplyMessage.cs similarity index 69% rename from Common/Phantom.Common.Messages/BiDirectional/ReplyMessage.cs rename to Common/Phantom.Common.Messages.Agent/BiDirectional/ReplyMessage.cs index 256afd9..99e782b 100644 --- a/Common/Phantom.Common.Messages/BiDirectional/ReplyMessage.cs +++ b/Common/Phantom.Common.Messages.Agent/BiDirectional/ReplyMessage.cs @@ -1,14 +1,14 @@ using MemoryPack; using Phantom.Utils.Rpc.Message; -namespace Phantom.Common.Messages.BiDirectional; +namespace Phantom.Common.Messages.Agent.BiDirectional; [MemoryPackable(GenerateType.VersionTolerant)] public sealed partial record ReplyMessage( [property: MemoryPackOrder(0)] uint SequenceId, [property: MemoryPackOrder(1)] byte[] SerializedReply -) : IMessageToServer, IMessageToAgent { - public Task<NoReply> Accept(IMessageToServerListener listener) { +) : IMessageToController, IMessageToAgent, IReply { + public Task<NoReply> Accept(IMessageToControllerListener listener) { return listener.HandleReply(this); } diff --git a/Common/Phantom.Common.Messages/IMessageToAgent.cs b/Common/Phantom.Common.Messages.Agent/IMessageToAgent.cs similarity index 82% rename from Common/Phantom.Common.Messages/IMessageToAgent.cs rename to Common/Phantom.Common.Messages.Agent/IMessageToAgent.cs index 0cbafa8..b38e13e 100644 --- a/Common/Phantom.Common.Messages/IMessageToAgent.cs +++ b/Common/Phantom.Common.Messages.Agent/IMessageToAgent.cs @@ -1,6 +1,6 @@ using Phantom.Utils.Rpc.Message; -namespace Phantom.Common.Messages; +namespace Phantom.Common.Messages.Agent; public interface IMessageToAgent<TReply> : IMessage<IMessageToAgentListener, TReply> {} diff --git a/Common/Phantom.Common.Messages/IMessageToAgentListener.cs b/Common/Phantom.Common.Messages.Agent/IMessageToAgentListener.cs similarity index 84% rename from Common/Phantom.Common.Messages/IMessageToAgentListener.cs rename to Common/Phantom.Common.Messages.Agent/IMessageToAgentListener.cs index ebe5a57..5ee7b51 100644 --- a/Common/Phantom.Common.Messages/IMessageToAgentListener.cs +++ b/Common/Phantom.Common.Messages.Agent/IMessageToAgentListener.cs @@ -1,9 +1,9 @@ using Phantom.Common.Data.Replies; -using Phantom.Common.Messages.BiDirectional; -using Phantom.Common.Messages.ToAgent; +using Phantom.Common.Messages.Agent.BiDirectional; +using Phantom.Common.Messages.Agent.ToAgent; using Phantom.Utils.Rpc.Message; -namespace Phantom.Common.Messages; +namespace Phantom.Common.Messages.Agent; public interface IMessageToAgentListener { Task<NoReply> HandleRegisterAgentSuccess(RegisterAgentSuccessMessage message); diff --git a/Common/Phantom.Common.Messages.Agent/IMessageToController.cs b/Common/Phantom.Common.Messages.Agent/IMessageToController.cs new file mode 100644 index 0000000..75889d1 --- /dev/null +++ b/Common/Phantom.Common.Messages.Agent/IMessageToController.cs @@ -0,0 +1,7 @@ +using Phantom.Utils.Rpc.Message; + +namespace Phantom.Common.Messages.Agent; + +public interface IMessageToController<TReply> : IMessage<IMessageToControllerListener, TReply> {} + +public interface IMessageToController : IMessageToController<NoReply> {} diff --git a/Common/Phantom.Common.Messages/IMessageToServerListener.cs b/Common/Phantom.Common.Messages.Agent/IMessageToControllerListener.cs similarity index 77% rename from Common/Phantom.Common.Messages/IMessageToServerListener.cs rename to Common/Phantom.Common.Messages.Agent/IMessageToControllerListener.cs index d4f17e3..76ebf8d 100644 --- a/Common/Phantom.Common.Messages/IMessageToServerListener.cs +++ b/Common/Phantom.Common.Messages.Agent/IMessageToControllerListener.cs @@ -1,11 +1,10 @@ -using Phantom.Common.Messages.BiDirectional; -using Phantom.Common.Messages.ToServer; +using Phantom.Common.Messages.Agent.BiDirectional; +using Phantom.Common.Messages.Agent.ToController; using Phantom.Utils.Rpc.Message; -namespace Phantom.Common.Messages; +namespace Phantom.Common.Messages.Agent; -public interface IMessageToServerListener { - bool IsDisposed { get; } +public interface IMessageToControllerListener { Task<NoReply> HandleRegisterAgent(RegisterAgentMessage message); Task<NoReply> HandleUnregisterAgent(UnregisterAgentMessage message); Task<NoReply> HandleAgentIsAlive(AgentIsAliveMessage message); diff --git a/Common/Phantom.Common.Messages/Phantom.Common.Messages.csproj b/Common/Phantom.Common.Messages.Agent/Phantom.Common.Messages.Agent.csproj similarity index 100% rename from Common/Phantom.Common.Messages/Phantom.Common.Messages.csproj rename to Common/Phantom.Common.Messages.Agent/Phantom.Common.Messages.Agent.csproj diff --git a/Common/Phantom.Common.Messages/ToAgent/ConfigureInstanceMessage.cs b/Common/Phantom.Common.Messages.Agent/ToAgent/ConfigureInstanceMessage.cs similarity index 92% rename from Common/Phantom.Common.Messages/ToAgent/ConfigureInstanceMessage.cs rename to Common/Phantom.Common.Messages.Agent/ToAgent/ConfigureInstanceMessage.cs index ab98d2e..66ef533 100644 --- a/Common/Phantom.Common.Messages/ToAgent/ConfigureInstanceMessage.cs +++ b/Common/Phantom.Common.Messages.Agent/ToAgent/ConfigureInstanceMessage.cs @@ -2,7 +2,7 @@ using Phantom.Common.Data.Instance; using Phantom.Common.Data.Replies; -namespace Phantom.Common.Messages.ToAgent; +namespace Phantom.Common.Messages.Agent.ToAgent; [MemoryPackable(GenerateType.VersionTolerant)] public sealed partial record ConfigureInstanceMessage( diff --git a/Common/Phantom.Common.Messages/ToAgent/LaunchInstanceMessage.cs b/Common/Phantom.Common.Messages.Agent/ToAgent/LaunchInstanceMessage.cs similarity index 89% rename from Common/Phantom.Common.Messages/ToAgent/LaunchInstanceMessage.cs rename to Common/Phantom.Common.Messages.Agent/ToAgent/LaunchInstanceMessage.cs index 9e369d4..3e45552 100644 --- a/Common/Phantom.Common.Messages/ToAgent/LaunchInstanceMessage.cs +++ b/Common/Phantom.Common.Messages.Agent/ToAgent/LaunchInstanceMessage.cs @@ -1,7 +1,7 @@ using MemoryPack; using Phantom.Common.Data.Replies; -namespace Phantom.Common.Messages.ToAgent; +namespace Phantom.Common.Messages.Agent.ToAgent; [MemoryPackable(GenerateType.VersionTolerant)] public sealed partial record LaunchInstanceMessage( diff --git a/Common/Phantom.Common.Messages/ToAgent/RegisterAgentFailureMessage.cs b/Common/Phantom.Common.Messages.Agent/ToAgent/RegisterAgentFailureMessage.cs similarity index 89% rename from Common/Phantom.Common.Messages/ToAgent/RegisterAgentFailureMessage.cs rename to Common/Phantom.Common.Messages.Agent/ToAgent/RegisterAgentFailureMessage.cs index 5602edc..7779df0 100644 --- a/Common/Phantom.Common.Messages/ToAgent/RegisterAgentFailureMessage.cs +++ b/Common/Phantom.Common.Messages.Agent/ToAgent/RegisterAgentFailureMessage.cs @@ -2,7 +2,7 @@ using Phantom.Common.Data.Replies; using Phantom.Utils.Rpc.Message; -namespace Phantom.Common.Messages.ToAgent; +namespace Phantom.Common.Messages.Agent.ToAgent; [MemoryPackable(GenerateType.VersionTolerant)] public sealed partial record RegisterAgentFailureMessage( diff --git a/Common/Phantom.Common.Messages/ToAgent/RegisterAgentSuccessMessage.cs b/Common/Phantom.Common.Messages.Agent/ToAgent/RegisterAgentSuccessMessage.cs similarity index 90% rename from Common/Phantom.Common.Messages/ToAgent/RegisterAgentSuccessMessage.cs rename to Common/Phantom.Common.Messages.Agent/ToAgent/RegisterAgentSuccessMessage.cs index 72bf316..95cbf0d 100644 --- a/Common/Phantom.Common.Messages/ToAgent/RegisterAgentSuccessMessage.cs +++ b/Common/Phantom.Common.Messages.Agent/ToAgent/RegisterAgentSuccessMessage.cs @@ -2,7 +2,7 @@ using MemoryPack; using Phantom.Utils.Rpc.Message; -namespace Phantom.Common.Messages.ToAgent; +namespace Phantom.Common.Messages.Agent.ToAgent; [MemoryPackable(GenerateType.VersionTolerant)] public sealed partial record RegisterAgentSuccessMessage( diff --git a/Common/Phantom.Common.Messages/ToAgent/SendCommandToInstanceMessage.cs b/Common/Phantom.Common.Messages.Agent/ToAgent/SendCommandToInstanceMessage.cs similarity index 91% rename from Common/Phantom.Common.Messages/ToAgent/SendCommandToInstanceMessage.cs rename to Common/Phantom.Common.Messages.Agent/ToAgent/SendCommandToInstanceMessage.cs index b7b3487..4322e1c 100644 --- a/Common/Phantom.Common.Messages/ToAgent/SendCommandToInstanceMessage.cs +++ b/Common/Phantom.Common.Messages.Agent/ToAgent/SendCommandToInstanceMessage.cs @@ -1,7 +1,7 @@ using MemoryPack; using Phantom.Common.Data.Replies; -namespace Phantom.Common.Messages.ToAgent; +namespace Phantom.Common.Messages.Agent.ToAgent; [MemoryPackable(GenerateType.VersionTolerant)] public sealed partial record SendCommandToInstanceMessage( diff --git a/Common/Phantom.Common.Messages/ToAgent/StopInstanceMessage.cs b/Common/Phantom.Common.Messages.Agent/ToAgent/StopInstanceMessage.cs similarity index 91% rename from Common/Phantom.Common.Messages/ToAgent/StopInstanceMessage.cs rename to Common/Phantom.Common.Messages.Agent/ToAgent/StopInstanceMessage.cs index 9cfe6a9..d268992 100644 --- a/Common/Phantom.Common.Messages/ToAgent/StopInstanceMessage.cs +++ b/Common/Phantom.Common.Messages.Agent/ToAgent/StopInstanceMessage.cs @@ -2,7 +2,7 @@ using Phantom.Common.Data.Minecraft; using Phantom.Common.Data.Replies; -namespace Phantom.Common.Messages.ToAgent; +namespace Phantom.Common.Messages.Agent.ToAgent; [MemoryPackable(GenerateType.VersionTolerant)] public sealed partial record StopInstanceMessage( diff --git a/Common/Phantom.Common.Messages/ToServer/AdvertiseJavaRuntimesMessage.cs b/Common/Phantom.Common.Messages.Agent/ToController/AdvertiseJavaRuntimesMessage.cs similarity index 70% rename from Common/Phantom.Common.Messages/ToServer/AdvertiseJavaRuntimesMessage.cs rename to Common/Phantom.Common.Messages.Agent/ToController/AdvertiseJavaRuntimesMessage.cs index 2d23a9a..83bb5d6 100644 --- a/Common/Phantom.Common.Messages/ToServer/AdvertiseJavaRuntimesMessage.cs +++ b/Common/Phantom.Common.Messages.Agent/ToController/AdvertiseJavaRuntimesMessage.cs @@ -3,13 +3,13 @@ using MemoryPack; using Phantom.Common.Data.Java; using Phantom.Utils.Rpc.Message; -namespace Phantom.Common.Messages.ToServer; +namespace Phantom.Common.Messages.Agent.ToController; [MemoryPackable(GenerateType.VersionTolerant)] public sealed partial record AdvertiseJavaRuntimesMessage( [property: MemoryPackOrder(0)] ImmutableArray<TaggedJavaRuntime> Runtimes -) : IMessageToServer { - public Task<NoReply> Accept(IMessageToServerListener listener) { +) : IMessageToController { + public Task<NoReply> Accept(IMessageToControllerListener listener) { return listener.HandleAdvertiseJavaRuntimes(this); } } diff --git a/Common/Phantom.Common.Messages.Agent/ToController/AgentIsAliveMessage.cs b/Common/Phantom.Common.Messages.Agent/ToController/AgentIsAliveMessage.cs new file mode 100644 index 0000000..4923c9f --- /dev/null +++ b/Common/Phantom.Common.Messages.Agent/ToController/AgentIsAliveMessage.cs @@ -0,0 +1,11 @@ +using MemoryPack; +using Phantom.Utils.Rpc.Message; + +namespace Phantom.Common.Messages.Agent.ToController; + +[MemoryPackable(GenerateType.VersionTolerant)] +public sealed partial record AgentIsAliveMessage : IMessageToController { + public Task<NoReply> Accept(IMessageToControllerListener listener) { + return listener.HandleAgentIsAlive(this); + } +} diff --git a/Common/Phantom.Common.Messages/ToServer/InstanceOutputMessage.cs b/Common/Phantom.Common.Messages.Agent/ToController/InstanceOutputMessage.cs similarity index 69% rename from Common/Phantom.Common.Messages/ToServer/InstanceOutputMessage.cs rename to Common/Phantom.Common.Messages.Agent/ToController/InstanceOutputMessage.cs index 41e99a7..81be51f 100644 --- a/Common/Phantom.Common.Messages/ToServer/InstanceOutputMessage.cs +++ b/Common/Phantom.Common.Messages.Agent/ToController/InstanceOutputMessage.cs @@ -2,14 +2,14 @@ using System.Collections.Immutable; using MemoryPack; using Phantom.Utils.Rpc.Message; -namespace Phantom.Common.Messages.ToServer; +namespace Phantom.Common.Messages.Agent.ToController; [MemoryPackable(GenerateType.VersionTolerant)] public sealed partial record InstanceOutputMessage( [property: MemoryPackOrder(0)] Guid InstanceGuid, [property: MemoryPackOrder(1)] ImmutableArray<string> Lines -) : IMessageToServer { - public Task<NoReply> Accept(IMessageToServerListener listener) { +) : IMessageToController { + public Task<NoReply> Accept(IMessageToControllerListener listener) { return listener.HandleInstanceOutput(this); } } diff --git a/Common/Phantom.Common.Messages/ToServer/RegisterAgentMessage.cs b/Common/Phantom.Common.Messages.Agent/ToController/RegisterAgentMessage.cs similarity index 58% rename from Common/Phantom.Common.Messages/ToServer/RegisterAgentMessage.cs rename to Common/Phantom.Common.Messages.Agent/ToController/RegisterAgentMessage.cs index 6dd4e42..d3fbcac 100644 --- a/Common/Phantom.Common.Messages/ToServer/RegisterAgentMessage.cs +++ b/Common/Phantom.Common.Messages.Agent/ToController/RegisterAgentMessage.cs @@ -2,14 +2,14 @@ using Phantom.Common.Data.Agent; using Phantom.Utils.Rpc.Message; -namespace Phantom.Common.Messages.ToServer; +namespace Phantom.Common.Messages.Agent.ToController; [MemoryPackable(GenerateType.VersionTolerant)] public sealed partial record RegisterAgentMessage( - [property: MemoryPackOrder(0)] AgentAuthToken AuthToken, + [property: MemoryPackOrder(0)] AuthToken AuthToken, [property: MemoryPackOrder(1)] AgentInfo AgentInfo -) : IMessageToServer { - public Task<NoReply> Accept(IMessageToServerListener listener) { +) : IMessageToController { + public Task<NoReply> Accept(IMessageToControllerListener listener) { return listener.HandleRegisterAgent(this); } } diff --git a/Common/Phantom.Common.Messages/ToServer/ReportAgentStatusMessage.cs b/Common/Phantom.Common.Messages.Agent/ToController/ReportAgentStatusMessage.cs similarity index 71% rename from Common/Phantom.Common.Messages/ToServer/ReportAgentStatusMessage.cs rename to Common/Phantom.Common.Messages.Agent/ToController/ReportAgentStatusMessage.cs index 6459d1c..2092c4f 100644 --- a/Common/Phantom.Common.Messages/ToServer/ReportAgentStatusMessage.cs +++ b/Common/Phantom.Common.Messages.Agent/ToController/ReportAgentStatusMessage.cs @@ -2,14 +2,14 @@ using Phantom.Common.Data; using Phantom.Utils.Rpc.Message; -namespace Phantom.Common.Messages.ToServer; +namespace Phantom.Common.Messages.Agent.ToController; [MemoryPackable(GenerateType.VersionTolerant)] public sealed partial record ReportAgentStatusMessage( [property: MemoryPackOrder(0)] int RunningInstanceCount, [property: MemoryPackOrder(1)] RamAllocationUnits RunningInstanceMemory -) : IMessageToServer { - public Task<NoReply> Accept(IMessageToServerListener listener) { +) : IMessageToController { + public Task<NoReply> Accept(IMessageToControllerListener listener) { return listener.HandleReportAgentStatus(this); } } diff --git a/Common/Phantom.Common.Messages/ToServer/ReportInstanceEventMessage.cs b/Common/Phantom.Common.Messages.Agent/ToController/ReportInstanceEventMessage.cs similarity index 75% rename from Common/Phantom.Common.Messages/ToServer/ReportInstanceEventMessage.cs rename to Common/Phantom.Common.Messages.Agent/ToController/ReportInstanceEventMessage.cs index b76c8bb..2aba0c3 100644 --- a/Common/Phantom.Common.Messages/ToServer/ReportInstanceEventMessage.cs +++ b/Common/Phantom.Common.Messages.Agent/ToController/ReportInstanceEventMessage.cs @@ -2,7 +2,7 @@ using Phantom.Common.Data.Instance; using Phantom.Utils.Rpc.Message; -namespace Phantom.Common.Messages.ToServer; +namespace Phantom.Common.Messages.Agent.ToController; [MemoryPackable(GenerateType.VersionTolerant)] public sealed partial record ReportInstanceEventMessage( @@ -10,8 +10,8 @@ public sealed partial record ReportInstanceEventMessage( [property: MemoryPackOrder(1)] DateTime UtcTime, [property: MemoryPackOrder(2)] Guid InstanceGuid, [property: MemoryPackOrder(3)] IInstanceEvent Event -) : IMessageToServer { - public Task<NoReply> Accept(IMessageToServerListener listener) { +) : IMessageToController { + public Task<NoReply> Accept(IMessageToControllerListener listener) { return listener.HandleReportInstanceEvent(this); } } diff --git a/Common/Phantom.Common.Messages/ToServer/ReportInstanceStatusMessage.cs b/Common/Phantom.Common.Messages.Agent/ToController/ReportInstanceStatusMessage.cs similarity index 70% rename from Common/Phantom.Common.Messages/ToServer/ReportInstanceStatusMessage.cs rename to Common/Phantom.Common.Messages.Agent/ToController/ReportInstanceStatusMessage.cs index c668424..ca763a0 100644 --- a/Common/Phantom.Common.Messages/ToServer/ReportInstanceStatusMessage.cs +++ b/Common/Phantom.Common.Messages.Agent/ToController/ReportInstanceStatusMessage.cs @@ -2,14 +2,14 @@ using Phantom.Common.Data.Instance; using Phantom.Utils.Rpc.Message; -namespace Phantom.Common.Messages.ToServer; +namespace Phantom.Common.Messages.Agent.ToController; [MemoryPackable(GenerateType.VersionTolerant)] public sealed partial record ReportInstanceStatusMessage( [property: MemoryPackOrder(0)] Guid InstanceGuid, [property: MemoryPackOrder(1)] IInstanceStatus InstanceStatus -) : IMessageToServer { - public Task<NoReply> Accept(IMessageToServerListener listener) { +) : IMessageToController { + public Task<NoReply> Accept(IMessageToControllerListener listener) { return listener.HandleReportInstanceStatus(this); } } diff --git a/Common/Phantom.Common.Messages/ToServer/UnregisterAgentMessage.cs b/Common/Phantom.Common.Messages.Agent/ToController/UnregisterAgentMessage.cs similarity index 62% rename from Common/Phantom.Common.Messages/ToServer/UnregisterAgentMessage.cs rename to Common/Phantom.Common.Messages.Agent/ToController/UnregisterAgentMessage.cs index fbea9c7..2afc720 100644 --- a/Common/Phantom.Common.Messages/ToServer/UnregisterAgentMessage.cs +++ b/Common/Phantom.Common.Messages.Agent/ToController/UnregisterAgentMessage.cs @@ -1,13 +1,13 @@ using MemoryPack; using Phantom.Utils.Rpc.Message; -namespace Phantom.Common.Messages.ToServer; +namespace Phantom.Common.Messages.Agent.ToController; [MemoryPackable(GenerateType.VersionTolerant)] public sealed partial record UnregisterAgentMessage( [property: MemoryPackOrder(0)] Guid AgentGuid -) : IMessageToServer { - public Task<NoReply> Accept(IMessageToServerListener listener) { +) : IMessageToController { + public Task<NoReply> Accept(IMessageToControllerListener listener) { return listener.HandleUnregisterAgent(this); } } diff --git a/Common/Phantom.Common.Messages.Web/BiDirectional/ReplyMessage.cs b/Common/Phantom.Common.Messages.Web/BiDirectional/ReplyMessage.cs new file mode 100644 index 0000000..a0e9ca1 --- /dev/null +++ b/Common/Phantom.Common.Messages.Web/BiDirectional/ReplyMessage.cs @@ -0,0 +1,18 @@ +using MemoryPack; +using Phantom.Utils.Rpc.Message; + +namespace Phantom.Common.Messages.Web.BiDirectional; + +[MemoryPackable(GenerateType.VersionTolerant)] +public sealed partial record ReplyMessage( + [property: MemoryPackOrder(0)] uint SequenceId, + [property: MemoryPackOrder(1)] byte[] SerializedReply +) : IMessageToController, IMessageToWeb, IReply { + public Task<NoReply> Accept(IMessageToControllerListener listener) { + return listener.HandleReply(this); + } + + public Task<NoReply> Accept(IMessageToWebListener listener) { + return listener.HandleReply(this); + } +} diff --git a/Common/Phantom.Common.Messages.Web/IMessageToController.cs b/Common/Phantom.Common.Messages.Web/IMessageToController.cs new file mode 100644 index 0000000..cbefc1a --- /dev/null +++ b/Common/Phantom.Common.Messages.Web/IMessageToController.cs @@ -0,0 +1,7 @@ +using Phantom.Utils.Rpc.Message; + +namespace Phantom.Common.Messages.Web; + +public interface IMessageToController<TReply> : IMessage<IMessageToControllerListener, TReply> {} + +public interface IMessageToController : IMessageToController<NoReply> {} diff --git a/Common/Phantom.Common.Messages.Web/IMessageToControllerListener.cs b/Common/Phantom.Common.Messages.Web/IMessageToControllerListener.cs new file mode 100644 index 0000000..b265869 --- /dev/null +++ b/Common/Phantom.Common.Messages.Web/IMessageToControllerListener.cs @@ -0,0 +1,8 @@ +using Phantom.Common.Messages.Web.BiDirectional; +using Phantom.Utils.Rpc.Message; + +namespace Phantom.Common.Messages.Web; + +public interface IMessageToControllerListener { + Task<NoReply> HandleReply(ReplyMessage message); +} diff --git a/Common/Phantom.Common.Messages.Web/IMessageToWeb.cs b/Common/Phantom.Common.Messages.Web/IMessageToWeb.cs new file mode 100644 index 0000000..d26640b --- /dev/null +++ b/Common/Phantom.Common.Messages.Web/IMessageToWeb.cs @@ -0,0 +1,7 @@ +using Phantom.Utils.Rpc.Message; + +namespace Phantom.Common.Messages.Web; + +public interface IMessageToWeb<TReply> : IMessage<IMessageToWebListener, TReply> {} + +public interface IMessageToWeb : IMessageToWeb<NoReply> {} diff --git a/Common/Phantom.Common.Messages.Web/IMessageToWebListener.cs b/Common/Phantom.Common.Messages.Web/IMessageToWebListener.cs new file mode 100644 index 0000000..18822c6 --- /dev/null +++ b/Common/Phantom.Common.Messages.Web/IMessageToWebListener.cs @@ -0,0 +1,8 @@ +using Phantom.Common.Messages.Web.BiDirectional; +using Phantom.Utils.Rpc.Message; + +namespace Phantom.Common.Messages.Web; + +public interface IMessageToWebListener { + Task<NoReply> HandleReply(ReplyMessage message); +} diff --git a/Common/Phantom.Common.Messages.Web/Phantom.Common.Messages.Web.csproj b/Common/Phantom.Common.Messages.Web/Phantom.Common.Messages.Web.csproj new file mode 100644 index 0000000..cad9d69 --- /dev/null +++ b/Common/Phantom.Common.Messages.Web/Phantom.Common.Messages.Web.csproj @@ -0,0 +1,14 @@ +<Project Sdk="Microsoft.NET.Sdk"> + + <PropertyGroup> + <ImplicitUsings>enable</ImplicitUsings> + <Nullable>enable</Nullable> + </PropertyGroup> + + <ItemGroup> + <ProjectReference Include="..\Phantom.Common.Logging\Phantom.Common.Logging.csproj" /> + <ProjectReference Include="..\Phantom.Common.Data\Phantom.Common.Data.csproj" /> + <ProjectReference Include="..\..\Utils\Phantom.Utils.Rpc\Phantom.Utils.Rpc.csproj" /> + </ItemGroup> + +</Project> diff --git a/Common/Phantom.Common.Messages.Web/WebMessageRegistries.cs b/Common/Phantom.Common.Messages.Web/WebMessageRegistries.cs new file mode 100644 index 0000000..b94d236 --- /dev/null +++ b/Common/Phantom.Common.Messages.Web/WebMessageRegistries.cs @@ -0,0 +1,31 @@ +using Phantom.Common.Logging; +using Phantom.Common.Messages.Web.BiDirectional; +using Phantom.Utils.Rpc.Message; + +namespace Phantom.Common.Messages.Web; + +public static class WebMessageRegistries { + public static MessageRegistry<IMessageToControllerListener> ToController { get; } = new (PhantomLogger.Create("MessageRegistry", nameof(ToController))); + public static MessageRegistry<IMessageToWebListener> ToWeb { get; } = new (PhantomLogger.Create("MessageRegistry", nameof(ToWeb))); + + public static IMessageDefinitions<IMessageToWebListener, IMessageToControllerListener, ReplyMessage> Definitions { get; } = new MessageDefinitions(); + + static WebMessageRegistries() { + ToController.Add<ReplyMessage>(127); + + ToWeb.Add<ReplyMessage>(127); + } + + private sealed class MessageDefinitions : IMessageDefinitions<IMessageToWebListener, IMessageToControllerListener, ReplyMessage> { + public MessageRegistry<IMessageToWebListener> Outgoing => ToWeb; + public MessageRegistry<IMessageToControllerListener> Incoming => ToController; + + public bool IsRegistrationMessage(Type messageType) { + return false; + } + + public ReplyMessage CreateReplyMessage( uint sequenceId, byte[] serializedReply) { + return new ReplyMessage(sequenceId, serializedReply); + } + } +} diff --git a/Common/Phantom.Common.Messages/IMessageToServer.cs b/Common/Phantom.Common.Messages/IMessageToServer.cs deleted file mode 100644 index c4fc08d..0000000 --- a/Common/Phantom.Common.Messages/IMessageToServer.cs +++ /dev/null @@ -1,7 +0,0 @@ -using Phantom.Utils.Rpc.Message; - -namespace Phantom.Common.Messages; - -public interface IMessageToServer<TReply> : IMessage<IMessageToServerListener, TReply> {} - -public interface IMessageToServer : IMessageToServer<NoReply> {} diff --git a/Common/Phantom.Common.Messages/MessageRegistries.cs b/Common/Phantom.Common.Messages/MessageRegistries.cs deleted file mode 100644 index 4aef188..0000000 --- a/Common/Phantom.Common.Messages/MessageRegistries.cs +++ /dev/null @@ -1,33 +0,0 @@ -using Phantom.Common.Data.Replies; -using Phantom.Common.Logging; -using Phantom.Common.Messages.BiDirectional; -using Phantom.Common.Messages.ToAgent; -using Phantom.Common.Messages.ToServer; -using Phantom.Utils.Rpc.Message; - -namespace Phantom.Common.Messages; - -public static class MessageRegistries { - public static MessageRegistry<IMessageToAgentListener> ToAgent { get; } = new (PhantomLogger.Create("MessageRegistry:ToAgent")); - public static MessageRegistry<IMessageToServerListener> ToServer { get; } = new (PhantomLogger.Create("MessageRegistry:ToServer")); - - static MessageRegistries() { - ToAgent.Add<RegisterAgentSuccessMessage>(0); - ToAgent.Add<RegisterAgentFailureMessage>(1); - ToAgent.Add<ConfigureInstanceMessage, InstanceActionResult<ConfigureInstanceResult>>(2); - ToAgent.Add<LaunchInstanceMessage, InstanceActionResult<LaunchInstanceResult>>(3); - ToAgent.Add<StopInstanceMessage, InstanceActionResult<StopInstanceResult>>(4); - ToAgent.Add<SendCommandToInstanceMessage, InstanceActionResult<SendCommandToInstanceResult>>(5); - ToAgent.Add<ReplyMessage>(127); - - ToServer.Add<RegisterAgentMessage>(0); - ToServer.Add<UnregisterAgentMessage>(1); - ToServer.Add<AgentIsAliveMessage>(2); - ToServer.Add<AdvertiseJavaRuntimesMessage>(3); - ToServer.Add<ReportInstanceStatusMessage>(4); - ToServer.Add<InstanceOutputMessage>(5); - ToServer.Add<ReportAgentStatusMessage>(6); - ToServer.Add<ReportInstanceEventMessage>(7); - ToServer.Add<ReplyMessage>(127); - } -} diff --git a/Common/Phantom.Common.Messages/ToServer/AgentIsAliveMessage.cs b/Common/Phantom.Common.Messages/ToServer/AgentIsAliveMessage.cs deleted file mode 100644 index d2850e1..0000000 --- a/Common/Phantom.Common.Messages/ToServer/AgentIsAliveMessage.cs +++ /dev/null @@ -1,11 +0,0 @@ -using MemoryPack; -using Phantom.Utils.Rpc.Message; - -namespace Phantom.Common.Messages.ToServer; - -[MemoryPackable(GenerateType.VersionTolerant)] -public sealed partial record AgentIsAliveMessage : IMessageToServer { - public Task<NoReply> Accept(IMessageToServerListener listener) { - return listener.HandleAgentIsAlive(this); - } -} diff --git a/Controller/Phantom.Controller.Database.Postgres/ApplicationDbContextFactory.cs b/Controller/Phantom.Controller.Database.Postgres/ApplicationDbContextFactory.cs new file mode 100644 index 0000000..3f471a6 --- /dev/null +++ b/Controller/Phantom.Controller.Database.Postgres/ApplicationDbContextFactory.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Npgsql.EntityFrameworkCore.PostgreSQL.Infrastructure; + +namespace Phantom.Controller.Database.Postgres; + +public sealed class ApplicationDbContextFactory : IDatabaseProvider { + private readonly PooledDbContextFactory<ApplicationDbContext> factory; + + public ApplicationDbContextFactory(string connectionString) { + this.factory = new PooledDbContextFactory<ApplicationDbContext>(CreateOptions(connectionString), poolSize: 32); + } + + public ApplicationDbContext Provide() { + return factory.CreateDbContext(); + } + + private static DbContextOptions<ApplicationDbContext> CreateOptions(string connectionString) { + var builder = new DbContextOptionsBuilder<ApplicationDbContext>(); + builder.UseNpgsql(connectionString, ConfigureOptions); + return builder.Options; + } + + private static void ConfigureOptions(NpgsqlDbContextOptionsBuilder options) { + options.CommandTimeout(10); + options.MigrationsAssembly(typeof(ApplicationDbContextDesignFactory).Assembly.FullName); + } +} diff --git a/Controller/Phantom.Controller.Database/DatabaseMigrator.cs b/Controller/Phantom.Controller.Database/DatabaseMigrator.cs new file mode 100644 index 0000000..8a1ed1b --- /dev/null +++ b/Controller/Phantom.Controller.Database/DatabaseMigrator.cs @@ -0,0 +1,25 @@ +using Microsoft.EntityFrameworkCore; +using Phantom.Common.Logging; +using Phantom.Utils.Tasks; +using Serilog; + +namespace Phantom.Controller.Database; + +public static class DatabaseMigrator { + private static readonly ILogger Logger = PhantomLogger.Create(nameof(DatabaseMigrator)); + + public static async Task Run(IDatabaseProvider databaseProvider, CancellationToken cancellationToken) { + await using var ctx = databaseProvider.Provide(); + + Logger.Information("Connecting to database..."); + + var retryConnection = new Throttler(TimeSpan.FromSeconds(10)); + while (!await ctx.Database.CanConnectAsync(cancellationToken)) { + Logger.Warning("Cannot connect to database, retrying..."); + await retryConnection.Wait(); + } + + Logger.Information("Running migrations..."); + await ctx.Database.MigrateAsync(CancellationToken.None); + } +} diff --git a/Controller/Phantom.Controller.Database/DatabaseProvider.cs b/Controller/Phantom.Controller.Database/DatabaseProvider.cs deleted file mode 100644 index 6d2270e..0000000 --- a/Controller/Phantom.Controller.Database/DatabaseProvider.cs +++ /dev/null @@ -1,30 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; - -namespace Phantom.Controller.Database; - -public sealed class DatabaseProvider { - private readonly IServiceScopeFactory serviceScopeFactory; - - public DatabaseProvider(IServiceScopeFactory serviceScopeFactory) { - this.serviceScopeFactory = serviceScopeFactory; - } - - public Scope CreateScope() { - return new Scope(serviceScopeFactory.CreateScope()); - } - - public readonly struct Scope : IDisposable { - private readonly IServiceScope scope; - - public ApplicationDbContext Ctx { get; } - - internal Scope(IServiceScope scope) { - this.scope = scope; - this.Ctx = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>(); - } - - public void Dispose() { - scope.Dispose(); - } - } -} diff --git a/Controller/Phantom.Controller.Database/IDatabaseProvider.cs b/Controller/Phantom.Controller.Database/IDatabaseProvider.cs new file mode 100644 index 0000000..bb8a395 --- /dev/null +++ b/Controller/Phantom.Controller.Database/IDatabaseProvider.cs @@ -0,0 +1,5 @@ +namespace Phantom.Controller.Database; + +public interface IDatabaseProvider { + ApplicationDbContext Provide(); +} diff --git a/Controller/Phantom.Controller.Database/Phantom.Controller.Database.csproj b/Controller/Phantom.Controller.Database/Phantom.Controller.Database.csproj index 9dd853e..bf57ab9 100644 --- a/Controller/Phantom.Controller.Database/Phantom.Controller.Database.csproj +++ b/Controller/Phantom.Controller.Database/Phantom.Controller.Database.csproj @@ -15,6 +15,7 @@ <ItemGroup> <ProjectReference Include="..\..\Common\Phantom.Common.Data\Phantom.Common.Data.csproj" /> + <ProjectReference Include="..\..\Common\Phantom.Common.Logging\Phantom.Common.Logging.csproj" /> </ItemGroup> </Project> diff --git a/Controller/Phantom.Controller.Rpc/Phantom.Controller.Rpc.csproj b/Controller/Phantom.Controller.Rpc/Phantom.Controller.Rpc.csproj index ec346cb..6b83469 100644 --- a/Controller/Phantom.Controller.Rpc/Phantom.Controller.Rpc.csproj +++ b/Controller/Phantom.Controller.Rpc/Phantom.Controller.Rpc.csproj @@ -6,7 +6,8 @@ </PropertyGroup> <ItemGroup> - <ProjectReference Include="..\..\Common\Phantom.Common.Messages\Phantom.Common.Messages.csproj" /> + <ProjectReference Include="..\..\Common\Phantom.Common.Messages.Agent\Phantom.Common.Messages.Agent.csproj" /> + <ProjectReference Include="..\..\Common\Phantom.Common.Messages.Web\Phantom.Common.Messages.Web.csproj" /> </ItemGroup> </Project> diff --git a/Controller/Phantom.Controller.Rpc/RpcClientConnection.cs b/Controller/Phantom.Controller.Rpc/RpcClientConnection.cs index 97837aa..1307481 100644 --- a/Controller/Phantom.Controller.Rpc/RpcClientConnection.cs +++ b/Controller/Phantom.Controller.Rpc/RpcClientConnection.cs @@ -1,28 +1,28 @@ using NetMQ; using NetMQ.Sockets; -using Phantom.Common.Messages; -using Phantom.Common.Messages.BiDirectional; using Phantom.Utils.Rpc.Message; namespace Phantom.Controller.Rpc; -public sealed class RpcClientConnection { +public sealed class RpcClientConnection<TListener> { private readonly ServerSocket socket; private readonly uint routingId; + private readonly MessageRegistry<TListener> messageRegistry; private readonly MessageReplyTracker messageReplyTracker; internal event EventHandler<RpcClientConnectionClosedEventArgs>? Closed; private bool isClosed; - internal RpcClientConnection(ServerSocket socket, uint routingId, MessageReplyTracker messageReplyTracker) { + internal RpcClientConnection(ServerSocket socket, uint routingId, MessageRegistry<TListener> messageRegistry, MessageReplyTracker messageReplyTracker) { this.socket = socket; this.routingId = routingId; + this.messageRegistry = messageRegistry; this.messageReplyTracker = messageReplyTracker; } - public bool IsSame(RpcClientConnection other) { - return this.routingId == other.routingId; + public bool IsSame(RpcClientConnection<TListener> other) { + return this.routingId == other.routingId && this.socket == other.socket; } public void Close() { @@ -34,25 +34,25 @@ public sealed class RpcClientConnection { } } - public async Task Send<TMessage>(TMessage message) where TMessage : IMessageToAgent { + public async Task Send<TMessage>(TMessage message) where TMessage : IMessage<TListener, NoReply> { if (isClosed) { return; } - var bytes = MessageRegistries.ToAgent.Write(message).ToArray(); + var bytes = messageRegistry.Write(message).ToArray(); if (bytes.Length > 0) { await socket.SendAsync(routingId, bytes); } } - public async Task<TReply?> Send<TMessage, TReply>(TMessage message, TimeSpan waitForReplyTime, CancellationToken waitForReplyCancellationToken) where TMessage : IMessageToAgent<TReply> where TReply : class { + public async Task<TReply?> Send<TMessage, TReply>(TMessage message, TimeSpan waitForReplyTime, CancellationToken waitForReplyCancellationToken) where TMessage : IMessage<TListener, TReply> where TReply : class { if (isClosed) { return null; } var sequenceId = messageReplyTracker.RegisterReply(); - var bytes = MessageRegistries.ToAgent.Write<TMessage, TReply>(sequenceId, message).ToArray(); + var bytes = messageRegistry.Write<TMessage, TReply>(sequenceId, message).ToArray(); if (bytes.Length == 0) { messageReplyTracker.ForgetReply(sequenceId); return null; @@ -62,7 +62,7 @@ public sealed class RpcClientConnection { return await messageReplyTracker.WaitForReply<TReply>(sequenceId, waitForReplyTime, waitForReplyCancellationToken); } - public void Receive(ReplyMessage message) { + public void Receive(IReply message) { messageReplyTracker.ReceiveReply(message.SequenceId, message.SerializedReply); } } diff --git a/Controller/Phantom.Controller.Rpc/RpcLauncher.cs b/Controller/Phantom.Controller.Rpc/RpcLauncher.cs deleted file mode 100644 index 9c48566..0000000 --- a/Controller/Phantom.Controller.Rpc/RpcLauncher.cs +++ /dev/null @@ -1,122 +0,0 @@ -using NetMQ.Sockets; -using Phantom.Common.Messages; -using Phantom.Common.Messages.BiDirectional; -using Phantom.Common.Messages.ToServer; -using Phantom.Utils.Rpc; -using Phantom.Utils.Rpc.Message; -using Phantom.Utils.Tasks; -using Serilog; -using Serilog.Events; - -namespace Phantom.Controller.Rpc; - -public sealed class RpcLauncher : RpcRuntime<ServerSocket> { - public static Task Launch(RpcConfiguration config, Func<RpcClientConnection, IMessageToServerListener> listenerFactory, CancellationToken cancellationToken) { - var socket = new ServerSocket(); - var options = socket.Options; - - options.CurveServer = true; - options.CurveCertificate = config.ServerCertificate; - - return new RpcLauncher(config, socket, listenerFactory, cancellationToken).Launch(); - } - - private readonly RpcConfiguration config; - private readonly Func<RpcClientConnection, IMessageToServerListener> listenerFactory; - private readonly CancellationToken cancellationToken; - - private RpcLauncher(RpcConfiguration config, ServerSocket socket, Func<RpcClientConnection, IMessageToServerListener> listenerFactory, CancellationToken cancellationToken) : base(config, socket) { - this.config = config; - this.listenerFactory = listenerFactory; - this.cancellationToken = cancellationToken; - } - - protected override void Connect(ServerSocket socket) { - var logger = config.RuntimeLogger; - var url = config.TcpUrl; - - logger.Information("Starting ZeroMQ server on {Url}...", url); - socket.Bind(url); - logger.Information("ZeroMQ server initialized, listening for agent connections on port {Port}.", config.Port); - } - - protected override void Run(ServerSocket socket, MessageReplyTracker replyTracker, TaskManager taskManager) { - var logger = config.RuntimeLogger; - var clients = new Dictionary<ulong, Client>(); - - void OnConnectionClosed(object? sender, RpcClientConnectionClosedEventArgs e) { - clients.Remove(e.RoutingId); - logger.Debug("Closed connection to {RoutingId}.", e.RoutingId); - } - - while (!cancellationToken.IsCancellationRequested) { - var (routingId, data) = socket.Receive(cancellationToken); - - if (data.Length == 0) { - LogMessageType(logger, routingId, data); - continue; - } - - if (!clients.TryGetValue(routingId, out var client)) { - if (!CheckIsAgentRegistrationMessage(data, logger, routingId)) { - continue; - } - - var connection = new RpcClientConnection(socket, routingId, replyTracker); - connection.Closed += OnConnectionClosed; - - client = new Client(connection, listenerFactory, logger, taskManager, cancellationToken); - clients[routingId] = client; - } - - LogMessageType(logger, routingId, data); - MessageRegistries.ToServer.Handle(data, client); - - client.CloseIfDisposed(); - } - - foreach (var client in clients.Values) { - client.Connection.Closed -= OnConnectionClosed; - } - } - - private static void LogMessageType(ILogger logger, uint routingId, ReadOnlyMemory<byte> data) { - if (!logger.IsEnabled(LogEventLevel.Verbose)) { - return; - } - - if (data.Length > 0 && MessageRegistries.ToServer.TryGetType(data, out var type)) { - logger.Verbose("Received {MessageType} ({Bytes} B) from {RoutingId}.", type.Name, data.Length, routingId); - } - else { - logger.Verbose("Received {Bytes} B message from {RoutingId}.", data.Length, routingId); - } - } - - private static bool CheckIsAgentRegistrationMessage(ReadOnlyMemory<byte> data, ILogger logger, uint routingId) { - if (MessageRegistries.ToServer.TryGetType(data, out var type) && type == typeof(RegisterAgentMessage)) { - return true; - } - - logger.Warning("Received {MessageType} from a non-registered agent {RoutingId}.", type?.Name ?? "unknown message", routingId); - return false; - } - - private sealed class Client : MessageHandler<IMessageToServerListener> { - public RpcClientConnection Connection { get; } - - public Client(RpcClientConnection connection, Func<RpcClientConnection, IMessageToServerListener> listenerFactory, ILogger logger, TaskManager taskManager, CancellationToken cancellationToken) : base(listenerFactory(connection), logger, taskManager, cancellationToken) { - Connection = connection; - } - - protected override Task SendReply(uint sequenceId, byte[] serializedReply) { - return Connection.Send(new ReplyMessage(sequenceId, serializedReply)); - } - - public void CloseIfDisposed() { - if (Listener.IsDisposed) { - Connection.Close(); - } - } - } -} diff --git a/Controller/Phantom.Controller.Rpc/RpcRuntime.cs b/Controller/Phantom.Controller.Rpc/RpcRuntime.cs new file mode 100644 index 0000000..1638bd8 --- /dev/null +++ b/Controller/Phantom.Controller.Rpc/RpcRuntime.cs @@ -0,0 +1,126 @@ +using NetMQ.Sockets; +using Phantom.Utils.Rpc; +using Phantom.Utils.Rpc.Message; +using Phantom.Utils.Tasks; +using Serilog; +using Serilog.Events; + +namespace Phantom.Controller.Rpc; + +public static class RpcRuntime { + public static Task Launch<TOutgoingListener, TIncomingListener, TReplyMessage>(RpcConfiguration config, IMessageDefinitions<TOutgoingListener, TIncomingListener, TReplyMessage> messageDefinitions, Func<RpcClientConnection<TOutgoingListener>, TIncomingListener> listenerFactory, CancellationToken cancellationToken) where TReplyMessage : IMessage<TOutgoingListener, NoReply>, IMessage<TIncomingListener, NoReply> { + return RpcRuntime<TOutgoingListener, TIncomingListener, TReplyMessage>.Launch(config, messageDefinitions, listenerFactory, cancellationToken); + } +} + +internal sealed class RpcRuntime<TOutgoingListener, TIncomingListener, TReplyMessage> : RpcRuntime<ServerSocket> where TReplyMessage : IMessage<TOutgoingListener, NoReply>, IMessage<TIncomingListener, NoReply> { + internal static Task Launch(RpcConfiguration config, IMessageDefinitions<TOutgoingListener, TIncomingListener, TReplyMessage> messageDefinitions, Func<RpcClientConnection<TOutgoingListener>, TIncomingListener> listenerFactory, CancellationToken cancellationToken) { + return new RpcRuntime<TOutgoingListener, TIncomingListener, TReplyMessage>(config, messageDefinitions, listenerFactory, cancellationToken).Launch(); + } + + private static ServerSocket CreateSocket(RpcConfiguration config) { + var socket = new ServerSocket(); + var options = socket.Options; + + options.CurveServer = true; + options.CurveCertificate = config.ServerCertificate; + + return socket; + } + + private readonly RpcConfiguration config; + private readonly IMessageDefinitions<TOutgoingListener, TIncomingListener, TReplyMessage> messageDefinitions; + private readonly Func<RpcClientConnection<TOutgoingListener>, TIncomingListener> listenerFactory; + private readonly CancellationToken cancellationToken; + + private RpcRuntime(RpcConfiguration config, IMessageDefinitions<TOutgoingListener, TIncomingListener, TReplyMessage> messageDefinitions, Func<RpcClientConnection<TOutgoingListener>, TIncomingListener> listenerFactory, CancellationToken cancellationToken) : base(config, CreateSocket(config)) { + this.config = config; + this.messageDefinitions = messageDefinitions; + this.listenerFactory = listenerFactory; + this.cancellationToken = cancellationToken; + } + + protected override void Connect(ServerSocket socket) { + var logger = config.RuntimeLogger; + var url = config.TcpUrl; + + logger.Information("Starting ZeroMQ server on {Url}...", url); + socket.Bind(url); + logger.Information("ZeroMQ server initialized, listening for connections on port {Port}.", config.Port); + } + + protected override void Run(ServerSocket socket, MessageReplyTracker replyTracker, TaskManager taskManager) { + var logger = config.RuntimeLogger; + var clients = new Dictionary<ulong, Client>(); + + void OnConnectionClosed(object? sender, RpcClientConnectionClosedEventArgs e) { + clients.Remove(e.RoutingId); + logger.Debug("Closed connection to {RoutingId}.", e.RoutingId); + } + + while (!cancellationToken.IsCancellationRequested) { + var (routingId, data) = socket.Receive(cancellationToken); + + if (data.Length == 0) { + LogMessageType(logger, routingId, data); + continue; + } + + if (!clients.TryGetValue(routingId, out var client)) { + if (!CheckIsRegistrationMessage(data, logger, routingId)) { + continue; + } + + var connection = new RpcClientConnection<TOutgoingListener>(socket, routingId, messageDefinitions.Outgoing, replyTracker); + connection.Closed += OnConnectionClosed; + + client = new Client(connection, messageDefinitions, listenerFactory(connection), logger, taskManager, cancellationToken); + clients[routingId] = client; + } + + LogMessageType(logger, routingId, data); + messageDefinitions.Incoming.Handle(data, client); + } + + foreach (var client in clients.Values) { + client.Connection.Closed -= OnConnectionClosed; + } + } + + private void LogMessageType(ILogger logger, uint routingId, ReadOnlyMemory<byte> data) { + if (!logger.IsEnabled(LogEventLevel.Verbose)) { + return; + } + + if (data.Length > 0 && messageDefinitions.Incoming.TryGetType(data, out var type)) { + logger.Verbose("Received {MessageType} ({Bytes} B) from {RoutingId}.", type.Name, data.Length, routingId); + } + else { + logger.Verbose("Received {Bytes} B message from {RoutingId}.", data.Length, routingId); + } + } + + private bool CheckIsRegistrationMessage(ReadOnlyMemory<byte> data, ILogger logger, uint routingId) { + if (messageDefinitions.Incoming.TryGetType(data, out var type) && messageDefinitions.IsRegistrationMessage(type)) { + return true; + } + + logger.Warning("Received {MessageType} from {RoutingId} who is not registered.", type?.Name ?? "unknown message", routingId); + return false; + } + + private sealed class Client : MessageHandler<TIncomingListener> { + public RpcClientConnection<TOutgoingListener> Connection { get; } + + private readonly IMessageDefinitions<TOutgoingListener, TIncomingListener, TReplyMessage> messageDefinitions; + + public Client(RpcClientConnection<TOutgoingListener> connection, IMessageDefinitions<TOutgoingListener, TIncomingListener, TReplyMessage> messageDefinitions, TIncomingListener listener, ILogger logger, TaskManager taskManager, CancellationToken cancellationToken) : base(listener, logger, taskManager, cancellationToken) { + this.Connection = connection; + this.messageDefinitions = messageDefinitions; + } + + protected override Task SendReply(uint sequenceId, byte[] serializedReply) { + return Connection.Send(messageDefinitions.CreateReplyMessage(sequenceId, serializedReply)); + } + } +} diff --git a/Controller/Phantom.Controller.Services/Agents/AgentConnection.cs b/Controller/Phantom.Controller.Services/Agents/AgentConnection.cs index b590343..30d04ce 100644 --- a/Controller/Phantom.Controller.Services/Agents/AgentConnection.cs +++ b/Controller/Phantom.Controller.Services/Agents/AgentConnection.cs @@ -1,16 +1,16 @@ -using Phantom.Common.Messages; +using Phantom.Common.Messages.Agent; using Phantom.Controller.Rpc; namespace Phantom.Controller.Services.Agents; sealed class AgentConnection { - private readonly RpcClientConnection connection; + private readonly RpcClientConnection<IMessageToAgentListener> connection; - internal AgentConnection(RpcClientConnection connection) { + internal AgentConnection(RpcClientConnection<IMessageToAgentListener> connection) { this.connection = connection; } - public bool IsSame(RpcClientConnection connection) { + public bool IsSame(RpcClientConnection<IMessageToAgentListener> connection) { return this.connection.IsSame(connection); } diff --git a/Controller/Phantom.Controller.Services/Agents/AgentManager.cs b/Controller/Phantom.Controller.Services/Agents/AgentManager.cs index 8efebfa..e4bf9bc 100644 --- a/Controller/Phantom.Controller.Services/Agents/AgentManager.cs +++ b/Controller/Phantom.Controller.Services/Agents/AgentManager.cs @@ -3,8 +3,8 @@ using Phantom.Common.Data; using Phantom.Common.Data.Agent; using Phantom.Common.Data.Replies; using Phantom.Common.Logging; -using Phantom.Common.Messages; -using Phantom.Common.Messages.ToAgent; +using Phantom.Common.Messages.Agent; +using Phantom.Common.Messages.Agent.ToAgent; using Phantom.Controller.Database; using Phantom.Controller.Rpc; using Phantom.Controller.Services.Instances; @@ -26,20 +26,20 @@ public sealed class AgentManager { public EventSubscribers<ImmutableArray<Agent>> AgentsChanged => agents.Subs; private readonly CancellationToken cancellationToken; - private readonly AgentAuthToken authToken; - private readonly DatabaseProvider databaseProvider; + private readonly AuthToken authToken; + private readonly IDatabaseProvider databaseProvider; - public AgentManager(ServiceConfiguration configuration, AgentAuthToken authToken, DatabaseProvider databaseProvider, TaskManager taskManager) { - this.cancellationToken = configuration.CancellationToken; + public AgentManager(AuthToken authToken, IDatabaseProvider databaseProvider, TaskManager taskManager, CancellationToken cancellationToken) { this.authToken = authToken; this.databaseProvider = databaseProvider; + this.cancellationToken = cancellationToken; taskManager.Run("Refresh agent status loop", RefreshAgentStatus); } - public async Task Initialize() { - using var scope = databaseProvider.CreateScope(); + internal async Task Initialize() { + await using var ctx = databaseProvider.Provide(); - await foreach (var entity in scope.Ctx.Agents.AsAsyncEnumerable().WithCancellation(cancellationToken)) { + await foreach (var entity in ctx.Agents.AsAsyncEnumerable().WithCancellation(cancellationToken)) { var agent = new Agent(entity.AgentGuid, entity.Name, entity.ProtocolVersion, entity.BuildVersion, entity.MaxInstances, entity.MaxMemory); if (!agents.ByGuid.AddOrReplaceIf(agent.Guid, agent, static oldAgent => oldAgent.IsOffline)) { // TODO @@ -52,7 +52,7 @@ public sealed class AgentManager { return agents.ByGuid.ToImmutable(); } - internal async Task<bool> RegisterAgent(AgentAuthToken authToken, AgentInfo agentInfo, InstanceManager instanceManager, RpcClientConnection connection) { + internal async Task<bool> RegisterAgent(AuthToken authToken, AgentInfo agentInfo, InstanceManager instanceManager, RpcClientConnection<IMessageToAgentListener> connection) { if (!this.authToken.FixedTimeEquals(authToken)) { await connection.Send(new RegisterAgentFailureMessage(RegisterAgentFailure.InvalidToken)); return false; @@ -68,8 +68,8 @@ public sealed class AgentManager { oldAgent.Connection?.Close(); } - using (var scope = databaseProvider.CreateScope()) { - var entity = scope.Ctx.AgentUpsert.Fetch(agent.Guid); + await using (var ctx = databaseProvider.Provide()) { + var entity = ctx.AgentUpsert.Fetch(agent.Guid); entity.Name = agent.Name; entity.ProtocolVersion = agent.ProtocolVersion; @@ -77,7 +77,7 @@ public sealed class AgentManager { entity.MaxInstances = agent.MaxInstances; entity.MaxMemory = agent.MaxMemory; - await scope.Ctx.SaveChangesAsync(cancellationToken); + await ctx.SaveChangesAsync(cancellationToken); } Logger.Information("Registered agent \"{Name}\" (GUID {Guid}).", agent.Name, agent.Guid); @@ -88,7 +88,7 @@ public sealed class AgentManager { return true; } - internal bool UnregisterAgent(Guid agentGuid, RpcClientConnection connection) { + internal bool UnregisterAgent(Guid agentGuid, RpcClientConnection<IMessageToAgentListener> connection) { if (agents.ByGuid.TryReplaceIf(agentGuid, static oldAgent => oldAgent.AsOffline(), oldAgent => oldAgent.Connection?.IsSame(connection) == true)) { Logger.Information("Unregistered agent with GUID {Guid}.", agentGuid); return true; diff --git a/Controller/Phantom.Controller.Services/Audit/AuditLog.cs b/Controller/Phantom.Controller.Services/Audit/AuditLog.cs index abd3d99..5b98386 100644 --- a/Controller/Phantom.Controller.Services/Audit/AuditLog.cs +++ b/Controller/Phantom.Controller.Services/Audit/AuditLog.cs @@ -1,35 +1,30 @@ -using Microsoft.AspNetCore.Components.Authorization; -using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; using Phantom.Controller.Database; using Phantom.Controller.Database.Entities; using Phantom.Controller.Database.Enums; -using Phantom.Controller.Services.Users; using Phantom.Utils.Tasks; namespace Phantom.Controller.Services.Audit; public sealed partial class AuditLog { - private readonly CancellationToken cancellationToken; - private readonly DatabaseProvider databaseProvider; - private readonly AuthenticationStateProvider authenticationStateProvider; + private readonly IDatabaseProvider databaseProvider; private readonly TaskManager taskManager; + private readonly CancellationToken cancellationToken; - public AuditLog(ServiceConfiguration serviceConfiguration, DatabaseProvider databaseProvider, AuthenticationStateProvider authenticationStateProvider, TaskManager taskManager) { - this.cancellationToken = serviceConfiguration.CancellationToken; + public AuditLog(IDatabaseProvider databaseProvider, TaskManager taskManager, CancellationToken cancellationToken) { this.databaseProvider = databaseProvider; - this.authenticationStateProvider = authenticationStateProvider; this.taskManager = taskManager; + this.cancellationToken = cancellationToken; } - private async Task<Guid?> GetCurrentAuthenticatedUserId() { - var authenticationState = await authenticationStateProvider.GetAuthenticationStateAsync(); - return UserManager.GetAuthenticatedUserId(authenticationState.User); + private Task<Guid?> GetCurrentAuthenticatedUserId() { + return Task.FromResult<Guid?>(null); // TODO } private async Task AddEntityToDatabase(AuditLogEntity logEntity) { - using var scope = databaseProvider.CreateScope(); - scope.Ctx.AuditLog.Add(logEntity); - await scope.Ctx.SaveChangesAsync(cancellationToken); + await using var ctx = databaseProvider.Provide(); + ctx.AuditLog.Add(logEntity); + await ctx.SaveChangesAsync(cancellationToken); } private void AddItem(Guid? userGuid, AuditLogEventType eventType, string subjectId, Dictionary<string, object?>? extra = null) { @@ -42,13 +37,13 @@ public sealed partial class AuditLog { } public async Task<AuditLogItem[]> GetItems(int count, CancellationToken cancellationToken) { - using var scope = databaseProvider.CreateScope(); - return await scope.Ctx.AuditLog - .Include(static entity => entity.User) - .AsQueryable() - .OrderByDescending(static entity => entity.UtcTime) - .Take(count) - .Select(static entity => new AuditLogItem(entity.UtcTime, entity.UserGuid, entity.User == null ? null : entity.User.Name, entity.EventType, entity.SubjectType, entity.SubjectId, entity.Data)) - .ToArrayAsync(cancellationToken); + await using var ctx = databaseProvider.Provide(); + return await ctx.AuditLog + .Include(static entity => entity.User) + .AsQueryable() + .OrderByDescending(static entity => entity.UtcTime) + .Take(count) + .Select(static entity => new AuditLogItem(entity.UtcTime, entity.UserGuid, entity.User == null ? null : entity.User.Name, entity.EventType, entity.SubjectType, entity.SubjectId, entity.Data)) + .ToArrayAsync(cancellationToken); } } diff --git a/Controller/Phantom.Controller.Services/ControllerServices.cs b/Controller/Phantom.Controller.Services/ControllerServices.cs new file mode 100644 index 0000000..e3a4ebe --- /dev/null +++ b/Controller/Phantom.Controller.Services/ControllerServices.cs @@ -0,0 +1,71 @@ +using Phantom.Common.Data.Agent; +using Phantom.Common.Logging; +using Phantom.Common.Messages.Agent; +using Phantom.Common.Messages.Web; +using Phantom.Controller.Database; +using Phantom.Controller.Minecraft; +using Phantom.Controller.Rpc; +using Phantom.Controller.Services.Agents; +using Phantom.Controller.Services.Events; +using Phantom.Controller.Services.Instances; +using Phantom.Controller.Services.Rpc; +using Phantom.Controller.Services.Users; +using Phantom.Controller.Services.Users.Permissions; +using Phantom.Controller.Services.Users.Roles; +using Phantom.Utils.Tasks; + +namespace Phantom.Controller.Services; + +public sealed class ControllerServices { + private TaskManager TaskManager { get; } + private MinecraftVersions MinecraftVersions { get; } + + private AgentManager AgentManager { get; } + private AgentJavaRuntimesManager AgentJavaRuntimesManager { get; } + private EventLog EventLog { get; } + private InstanceManager InstanceManager { get; } + private InstanceLogManager InstanceLogManager { get; } + + private UserManager UserManager { get; } + private RoleManager RoleManager { get; } + private UserRoleManager UserRoleManager { get; } + private PermissionManager PermissionManager { get; } + + private readonly IDatabaseProvider databaseProvider; + private readonly CancellationToken cancellationToken; + + public ControllerServices(IDatabaseProvider databaseProvider, AuthToken agentAuthToken, CancellationToken shutdownCancellationToken) { + this.TaskManager = new TaskManager(PhantomLogger.Create<TaskManager, ControllerServices>()); + this.MinecraftVersions = new MinecraftVersions(); + + this.AgentManager = new AgentManager(agentAuthToken, databaseProvider, TaskManager, shutdownCancellationToken); + this.AgentJavaRuntimesManager = new AgentJavaRuntimesManager(); + this.EventLog = new EventLog(databaseProvider, TaskManager, shutdownCancellationToken); + this.InstanceManager = new InstanceManager(AgentManager, MinecraftVersions, databaseProvider, shutdownCancellationToken); + this.InstanceLogManager = new InstanceLogManager(); + + this.UserManager = new UserManager(databaseProvider); + this.RoleManager = new RoleManager(databaseProvider); + this.UserRoleManager = new UserRoleManager(databaseProvider); + this.PermissionManager = new PermissionManager(databaseProvider); + + this.databaseProvider = databaseProvider; + this.cancellationToken = shutdownCancellationToken; + } + + public AgentMessageListener CreateAgentMessageListener(RpcClientConnection<IMessageToAgentListener> connection) { + return new AgentMessageListener(connection, AgentManager, AgentJavaRuntimesManager, InstanceManager, InstanceLogManager, EventLog, cancellationToken); + } + + public WebMessageListener CreateWebMessageListener(RpcClientConnection<IMessageToWebListener> connection) { + return new WebMessageListener(connection); + } + + public async Task Initialize() { + await DatabaseMigrator.Run(databaseProvider, cancellationToken); + await PermissionManager.Initialize(); + await RoleManager.Initialize(); + await AgentManager.Initialize(); + await InstanceManager.Initialize(); + } +} diff --git a/Controller/Phantom.Controller.Services/Events/EventLog.cs b/Controller/Phantom.Controller.Services/Events/EventLog.cs index 0c26526..f2e86b9 100644 --- a/Controller/Phantom.Controller.Services/Events/EventLog.cs +++ b/Controller/Phantom.Controller.Services/Events/EventLog.cs @@ -1,26 +1,28 @@ -using Microsoft.EntityFrameworkCore; +using System.Collections.Immutable; +using Microsoft.EntityFrameworkCore; using Phantom.Controller.Database; using Phantom.Controller.Database.Entities; using Phantom.Controller.Database.Enums; +using Phantom.Utils.Collections; using Phantom.Utils.Tasks; namespace Phantom.Controller.Services.Events; public sealed partial class EventLog { - private readonly CancellationToken cancellationToken; - private readonly DatabaseProvider databaseProvider; + private readonly IDatabaseProvider databaseProvider; private readonly TaskManager taskManager; + private readonly CancellationToken cancellationToken; - public EventLog(ServiceConfiguration serviceConfiguration, DatabaseProvider databaseProvider, TaskManager taskManager) { - this.cancellationToken = serviceConfiguration.CancellationToken; + public EventLog(IDatabaseProvider databaseProvider, TaskManager taskManager, CancellationToken cancellationToken) { this.databaseProvider = databaseProvider; this.taskManager = taskManager; + this.cancellationToken = cancellationToken; } private async Task AddEntityToDatabase(EventLogEntity logEntity) { - using var scope = databaseProvider.CreateScope(); - scope.Ctx.EventLog.Add(logEntity); - await scope.Ctx.SaveChangesAsync(cancellationToken); + await using var ctx = databaseProvider.Provide(); + ctx.EventLog.Add(logEntity); + await ctx.SaveChangesAsync(cancellationToken); } private void AddItem(Guid eventGuid, DateTime utcTime, Guid? agentGuid, EventLogEventType eventType, string subjectId, Dictionary<string, object?>? extra = null) { @@ -28,13 +30,14 @@ public sealed partial class EventLog { taskManager.Run("Store event log item to database", () => AddEntityToDatabase(logEntity)); } - public async Task<EventLogItem[]> GetItems(int count, CancellationToken cancellationToken) { - using var scope = databaseProvider.CreateScope(); - return await scope.Ctx.EventLog - .AsQueryable() - .OrderByDescending(static entity => entity.UtcTime) - .Take(count) - .Select(static entity => new EventLogItem(entity.UtcTime, entity.AgentGuid, entity.EventType, entity.SubjectType, entity.SubjectId, entity.Data)) - .ToArrayAsync(cancellationToken); + public async Task<ImmutableArray<EventLogItem>> GetItems(int count, CancellationToken cancellationToken) { + await using var ctx = databaseProvider.Provide(); + return await ctx.EventLog + .AsQueryable() + .OrderByDescending(static entity => entity.UtcTime) + .Take(count) + .Select(static entity => new EventLogItem(entity.UtcTime, entity.AgentGuid, entity.EventType, entity.SubjectType, entity.SubjectId, entity.Data)) + .AsAsyncEnumerable() + .ToImmutableArrayAsync(cancellationToken); } } diff --git a/Controller/Phantom.Controller.Services/Instances/InstanceManager.cs b/Controller/Phantom.Controller.Services/Instances/InstanceManager.cs index bbc498b..cb042be 100644 --- a/Controller/Phantom.Controller.Services/Instances/InstanceManager.cs +++ b/Controller/Phantom.Controller.Services/Instances/InstanceManager.cs @@ -5,8 +5,8 @@ using Phantom.Common.Data.Instance; using Phantom.Common.Data.Minecraft; using Phantom.Common.Data.Replies; using Phantom.Common.Logging; -using Phantom.Common.Messages; -using Phantom.Common.Messages.ToAgent; +using Phantom.Common.Messages.Agent; +using Phantom.Common.Messages.Agent.ToAgent; using Phantom.Controller.Database; using Phantom.Controller.Database.Entities; using Phantom.Controller.Minecraft; @@ -24,23 +24,23 @@ public sealed class InstanceManager { public EventSubscribers<ImmutableDictionary<Guid, Instance>> InstancesChanged => instances.Subs; - private readonly CancellationToken cancellationToken; private readonly AgentManager agentManager; private readonly MinecraftVersions minecraftVersions; - private readonly DatabaseProvider databaseProvider; + private readonly IDatabaseProvider databaseProvider; + private readonly CancellationToken cancellationToken; + private readonly SemaphoreSlim modifyInstancesSemaphore = new (1, 1); - public InstanceManager(ServiceConfiguration configuration, AgentManager agentManager, MinecraftVersions minecraftVersions, DatabaseProvider databaseProvider) { - this.cancellationToken = configuration.CancellationToken; + public InstanceManager(AgentManager agentManager, MinecraftVersions minecraftVersions, IDatabaseProvider databaseProvider, CancellationToken cancellationToken) { this.agentManager = agentManager; this.minecraftVersions = minecraftVersions; this.databaseProvider = databaseProvider; + this.cancellationToken = cancellationToken; } public async Task Initialize() { - using var scope = databaseProvider.CreateScope(); - - await foreach (var entity in scope.Ctx.Instances.AsAsyncEnumerable().WithCancellation(cancellationToken)) { + await using var ctx = databaseProvider.Provide(); + await foreach (var entity in ctx.Instances.AsAsyncEnumerable().WithCancellation(cancellationToken)) { var configuration = new InstanceConfiguration( entity.AgentGuid, entity.InstanceGuid, @@ -98,8 +98,8 @@ public sealed class InstanceManager { }); if (result.Is(AddOrEditInstanceResult.Success)) { - using var scope = databaseProvider.CreateScope(); - InstanceEntity entity = scope.Ctx.InstanceUpsert.Fetch(configuration.InstanceGuid); + await using var ctx = databaseProvider.Provide(); + InstanceEntity entity = ctx.InstanceUpsert.Fetch(configuration.InstanceGuid); entity.AgentGuid = configuration.AgentGuid; entity.InstanceName = configuration.InstanceName; @@ -111,7 +111,7 @@ public sealed class InstanceManager { entity.JavaRuntimeGuid = configuration.JavaRuntimeGuid; entity.JvmArguments = JvmArgumentsHelper.Join(configuration.JvmArguments); - await scope.Ctx.SaveChangesAsync(cancellationToken); + await ctx.SaveChangesAsync(cancellationToken); } else if (isNewInstance) { instances.ByGuid.Remove(configuration.InstanceGuid); @@ -188,11 +188,11 @@ public sealed class InstanceManager { try { instances.ByGuid.TryReplace(instanceGuid, instance => instance with { LaunchAutomatically = shouldLaunchAutomatically }); - using var scope = databaseProvider.CreateScope(); - var entity = await scope.Ctx.Instances.FindAsync(instanceGuid, cancellationToken); + await using var ctx = databaseProvider.Provide(); + var entity = await ctx.Instances.FindAsync(instanceGuid, cancellationToken); if (entity != null) { entity.LaunchAutomatically = shouldLaunchAutomatically; - await scope.Ctx.SaveChangesAsync(cancellationToken); + await ctx.SaveChangesAsync(cancellationToken); } } finally { modifyInstancesSemaphore.Release(); diff --git a/Controller/Phantom.Controller.Services/Phantom.Controller.Services.csproj b/Controller/Phantom.Controller.Services/Phantom.Controller.Services.csproj index b528ef6..f227ef4 100644 --- a/Controller/Phantom.Controller.Services/Phantom.Controller.Services.csproj +++ b/Controller/Phantom.Controller.Services/Phantom.Controller.Services.csproj @@ -1,13 +1,13 @@ -<Project Sdk="Microsoft.NET.Sdk.Web"> +<Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <ImplicitUsings>enable</ImplicitUsings> <Nullable>enable</Nullable> </PropertyGroup> - <PropertyGroup> - <OutputType>Library</OutputType> - </PropertyGroup> + <ItemGroup> + <PackageReference Include="BCrypt.Net-Next.StrongName" /> + </ItemGroup> <ItemGroup> <ProjectReference Include="..\..\Common\Phantom.Common.Data\Phantom.Common.Data.csproj" /> diff --git a/Controller/Phantom.Controller.Services/Rpc/MessageToServerListener.cs b/Controller/Phantom.Controller.Services/Rpc/AgentMessageListener.cs similarity index 82% rename from Controller/Phantom.Controller.Services/Rpc/MessageToServerListener.cs rename to Controller/Phantom.Controller.Services/Rpc/AgentMessageListener.cs index e11e47a..3d8347a 100644 --- a/Controller/Phantom.Controller.Services/Rpc/MessageToServerListener.cs +++ b/Controller/Phantom.Controller.Services/Rpc/AgentMessageListener.cs @@ -1,9 +1,9 @@ using Phantom.Common.Data.Instance; using Phantom.Common.Data.Replies; -using Phantom.Common.Messages; -using Phantom.Common.Messages.BiDirectional; -using Phantom.Common.Messages.ToAgent; -using Phantom.Common.Messages.ToServer; +using Phantom.Common.Messages.Agent; +using Phantom.Common.Messages.Agent.BiDirectional; +using Phantom.Common.Messages.Agent.ToAgent; +using Phantom.Common.Messages.Agent.ToController; using Phantom.Controller.Rpc; using Phantom.Controller.Services.Agents; using Phantom.Controller.Services.Events; @@ -13,27 +13,25 @@ using Phantom.Utils.Tasks; namespace Phantom.Controller.Services.Rpc; -public sealed class MessageToServerListener : IMessageToServerListener { - private readonly RpcClientConnection connection; - private readonly CancellationToken cancellationToken; +public sealed class AgentMessageListener : IMessageToControllerListener { + private readonly RpcClientConnection<IMessageToAgentListener> connection; private readonly AgentManager agentManager; private readonly AgentJavaRuntimesManager agentJavaRuntimesManager; private readonly InstanceManager instanceManager; private readonly InstanceLogManager instanceLogManager; private readonly EventLog eventLog; + private readonly CancellationToken cancellationToken; private readonly TaskCompletionSource<Guid> agentGuidWaiter = AsyncTasks.CreateCompletionSource<Guid>(); - public bool IsDisposed { get; private set; } - - internal MessageToServerListener(RpcClientConnection connection, ServiceConfiguration configuration, AgentManager agentManager, AgentJavaRuntimesManager agentJavaRuntimesManager, InstanceManager instanceManager, InstanceLogManager instanceLogManager, EventLog eventLog) { + internal AgentMessageListener(RpcClientConnection<IMessageToAgentListener> connection, AgentManager agentManager, AgentJavaRuntimesManager agentJavaRuntimesManager, InstanceManager instanceManager, InstanceLogManager instanceLogManager, EventLog eventLog, CancellationToken cancellationToken) { this.connection = connection; - this.cancellationToken = configuration.CancellationToken; this.agentManager = agentManager; this.agentJavaRuntimesManager = agentJavaRuntimesManager; this.instanceManager = instanceManager; this.instanceLogManager = instanceLogManager; this.eventLog = eventLog; + this.cancellationToken = cancellationToken; } public async Task<NoReply> HandleRegisterAgent(RegisterAgentMessage message) { @@ -53,12 +51,11 @@ public sealed class MessageToServerListener : IMessageToServerListener { } public Task<NoReply> HandleUnregisterAgent(UnregisterAgentMessage message) { - IsDisposed = true; - if (agentManager.UnregisterAgent(message.AgentGuid, connection)) { instanceManager.SetInstanceStatesForAgent(message.AgentGuid, InstanceStatus.Offline); } + connection.Close(); return Task.FromResult(NoReply.Instance); } diff --git a/Controller/Phantom.Controller.Services/Rpc/MessageToServerListenerFactory.cs b/Controller/Phantom.Controller.Services/Rpc/MessageToServerListenerFactory.cs deleted file mode 100644 index 0a36223..0000000 --- a/Controller/Phantom.Controller.Services/Rpc/MessageToServerListenerFactory.cs +++ /dev/null @@ -1,28 +0,0 @@ -using Phantom.Controller.Rpc; -using Phantom.Controller.Services.Agents; -using Phantom.Controller.Services.Events; -using Phantom.Controller.Services.Instances; - -namespace Phantom.Controller.Services.Rpc; - -public sealed class MessageToServerListenerFactory { - private readonly ServiceConfiguration configuration; - private readonly AgentManager agentManager; - private readonly AgentJavaRuntimesManager agentJavaRuntimesManager; - private readonly InstanceManager instanceManager; - private readonly InstanceLogManager instanceLogManager; - private readonly EventLog eventLog; - - public MessageToServerListenerFactory(ServiceConfiguration configuration, AgentManager agentManager, AgentJavaRuntimesManager agentJavaRuntimesManager, InstanceManager instanceManager, InstanceLogManager instanceLogManager, EventLog eventLog) { - this.configuration = configuration; - this.agentManager = agentManager; - this.agentJavaRuntimesManager = agentJavaRuntimesManager; - this.instanceManager = instanceManager; - this.instanceLogManager = instanceLogManager; - this.eventLog = eventLog; - } - - public MessageToServerListener CreateListener(RpcClientConnection connection) { - return new MessageToServerListener(connection, configuration, agentManager, agentJavaRuntimesManager, instanceManager, instanceLogManager, eventLog); - } -} diff --git a/Controller/Phantom.Controller.Services/Rpc/WebMessageListener.cs b/Controller/Phantom.Controller.Services/Rpc/WebMessageListener.cs new file mode 100644 index 0000000..6b1a5eb --- /dev/null +++ b/Controller/Phantom.Controller.Services/Rpc/WebMessageListener.cs @@ -0,0 +1,19 @@ +using Phantom.Common.Messages.Web; +using Phantom.Common.Messages.Web.BiDirectional; +using Phantom.Controller.Rpc; +using Phantom.Utils.Rpc.Message; + +namespace Phantom.Controller.Services.Rpc; + +public sealed class WebMessageListener : IMessageToControllerListener { + private readonly RpcClientConnection<IMessageToWebListener> connection; + + internal WebMessageListener(RpcClientConnection<IMessageToWebListener> connection) { + this.connection = connection; + } + + public Task<NoReply> HandleReply(ReplyMessage message) { + connection.Receive(message); + return Task.FromResult(NoReply.Instance); + } +} diff --git a/Controller/Phantom.Controller.Services/ServiceConfiguration.cs b/Controller/Phantom.Controller.Services/ServiceConfiguration.cs deleted file mode 100644 index c85730e..0000000 --- a/Controller/Phantom.Controller.Services/ServiceConfiguration.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace Phantom.Controller.Services; - -public sealed record ServiceConfiguration( - string Version, - byte[] AdministratorToken, - CancellationToken CancellationToken -); diff --git a/Web/Phantom.Web.Identity/Authorization/IdentityPermissions.cs b/Controller/Phantom.Controller.Services/Users/Permissions/IdentityPermissions.cs similarity index 89% rename from Web/Phantom.Web.Identity/Authorization/IdentityPermissions.cs rename to Controller/Phantom.Controller.Services/Users/Permissions/IdentityPermissions.cs index 264a2ff..9d4ad14 100644 --- a/Web/Phantom.Web.Identity/Authorization/IdentityPermissions.cs +++ b/Controller/Phantom.Controller.Services/Users/Permissions/IdentityPermissions.cs @@ -1,7 +1,6 @@ using System.Collections.Immutable; -using Phantom.Web.Identity.Data; -namespace Phantom.Web.Identity.Authorization; +namespace Phantom.Controller.Services.Users.Permissions; public sealed class IdentityPermissions { internal static IdentityPermissions None { get; } = new (); diff --git a/Web/Phantom.Web.Identity/Data/Permission.cs b/Controller/Phantom.Controller.Services/Users/Permissions/Permission.cs similarity index 96% rename from Web/Phantom.Web.Identity/Data/Permission.cs rename to Controller/Phantom.Controller.Services/Users/Permissions/Permission.cs index 056c351..3489f6d 100644 --- a/Web/Phantom.Web.Identity/Data/Permission.cs +++ b/Controller/Phantom.Controller.Services/Users/Permissions/Permission.cs @@ -1,4 +1,4 @@ -namespace Phantom.Web.Identity.Data; +namespace Phantom.Controller.Services.Users.Permissions; public sealed record Permission(string Id, Permission? Parent) { private static readonly List<Permission> AllPermissions = new (); diff --git a/Controller/Phantom.Controller.Services/Users/Permissions/PermissionManager.cs b/Controller/Phantom.Controller.Services/Users/Permissions/PermissionManager.cs new file mode 100644 index 0000000..f8560d0 --- /dev/null +++ b/Controller/Phantom.Controller.Services/Users/Permissions/PermissionManager.cs @@ -0,0 +1,68 @@ +using System.Collections.Immutable; +using System.Security.Claims; +using Microsoft.EntityFrameworkCore; +using Phantom.Common.Logging; +using Phantom.Controller.Database; +using Phantom.Controller.Database.Entities; +using Phantom.Utils.Collections; +using ILogger = Serilog.ILogger; + +namespace Phantom.Controller.Services.Users.Permissions; + +public sealed class PermissionManager { + private static readonly ILogger Logger = PhantomLogger.Create<PermissionManager>(); + + private readonly IDatabaseProvider databaseProvider; + private readonly Dictionary<Guid, IdentityPermissions> userIdsToPermissionIds = new (); + + public PermissionManager(IDatabaseProvider databaseProvider) { + this.databaseProvider = databaseProvider; + } + + internal async Task Initialize() { + Logger.Information("Adding default permissions to database."); + + await using var ctx = databaseProvider.Provide(); + + var existingPermissionIds = await ctx.Permissions.Select(static p => p.Id).AsAsyncEnumerable().ToImmutableSetAsync(); + var missingPermissionIds = GetMissingPermissionsOrdered(Permission.All, existingPermissionIds); + if (!missingPermissionIds.IsEmpty) { + Logger.Information("Adding default permissions: {Permissions}", string.Join(", ", missingPermissionIds)); + + foreach (var permissionId in missingPermissionIds) { + ctx.Permissions.Add(new PermissionEntity(permissionId)); + } + + await ctx.SaveChangesAsync(); + } + } + + internal static ImmutableArray<string> GetMissingPermissionsOrdered(IEnumerable<Permission> allPermissions, ImmutableHashSet<string> existingPermissionIds) { + return allPermissions.Select(static permission => permission.Id).Except(existingPermissionIds).Order().ToImmutableArray(); + } + + private IdentityPermissions FetchPermissionsForUserId(Guid userId) { + using var ctx = databaseProvider.Provide(); + var userPermissions = ctx.UserPermissions.Where(up => up.UserGuid == userId).Select(static up => up.PermissionId); + var rolePermissions = ctx.UserRoles.Where(ur => ur.UserGuid == userId).Join(ctx.RolePermissions, static ur => ur.RoleGuid, static rp => rp.RoleGuid, static (ur, rp) => rp.PermissionId); + return new IdentityPermissions(userPermissions.Union(rolePermissions)); + } + + private IdentityPermissions GetPermissionsForUserId(Guid userId, bool refreshCache) { + if (!refreshCache && userIdsToPermissionIds.TryGetValue(userId, out var userPermissions)) { + return userPermissions; + } + else { + return userIdsToPermissionIds[userId] = FetchPermissionsForUserId(userId); + } + } + + public IdentityPermissions GetPermissions(ClaimsPrincipal user, bool refreshCache = false) { + Guid? userId = UserManager.GetAuthenticatedUserId(user); + return userId == null ? IdentityPermissions.None : GetPermissionsForUserId(userId.Value, refreshCache); + } + + public bool CheckPermission(ClaimsPrincipal user, Permission permission, bool refreshCache = false) { + return GetPermissions(user, refreshCache).Check(permission); + } +} diff --git a/Controller/Phantom.Controller.Services/Users/RoleManager.cs b/Controller/Phantom.Controller.Services/Users/RoleManager.cs deleted file mode 100644 index 317b841..0000000 --- a/Controller/Phantom.Controller.Services/Users/RoleManager.cs +++ /dev/null @@ -1,60 +0,0 @@ -using System.Collections.Immutable; -using Microsoft.EntityFrameworkCore; -using Phantom.Common.Logging; -using Phantom.Controller.Database; -using Phantom.Controller.Database.Entities; -using Phantom.Utils.Collections; -using Phantom.Utils.Tasks; -using ILogger = Serilog.ILogger; - -namespace Phantom.Controller.Services.Users; - -public sealed class RoleManager { - private static readonly ILogger Logger = PhantomLogger.Create<RoleManager>(); - - private const int MaxRoleNameLength = 40; - - private readonly ApplicationDbContext db; - - public RoleManager(ApplicationDbContext db) { - this.db = db; - } - - public Task<List<RoleEntity>> GetAll() { - return db.Roles.ToListAsync(); - } - - public Task<ImmutableHashSet<string>> GetAllNames() { - return db.Roles.Select(static role => role.Name).AsAsyncEnumerable().ToImmutableSetAsync(); - } - - public ValueTask<RoleEntity?> GetByGuid(Guid guid) { - return db.Roles.FindAsync(guid); - } - - public async Task<Result<RoleEntity, AddRoleError>> Create(Guid guid, string name) { - if (string.IsNullOrWhiteSpace(name)) { - return Result.Fail<RoleEntity, AddRoleError>(AddRoleError.NameIsEmpty); - } - else if (name.Length > MaxRoleNameLength) { - return Result.Fail<RoleEntity, AddRoleError>(AddRoleError.NameIsTooLong); - } - - try { - if (await db.Roles.AnyAsync(role => role.Name == name)) { - return Result.Fail<RoleEntity, AddRoleError>(AddRoleError.NameAlreadyExists); - } - - var role = new RoleEntity(guid, name); - - db.Roles.Add(role); - await db.SaveChangesAsync(); - - Logger.Information("Created role \"{Name}\" (GUID {Guid}).", name, guid); - return Result.Ok<RoleEntity, AddRoleError>(role); - } catch (Exception e) { - Logger.Error(e, "Could not create role \"{Name}\" (GUID {Guid}).", name, guid); - return Result.Fail<RoleEntity, AddRoleError>(AddRoleError.UnknownError); - } - } -} diff --git a/Controller/Phantom.Controller.Services/Users/AddRoleError.cs b/Controller/Phantom.Controller.Services/Users/Roles/AddRoleError.cs similarity index 65% rename from Controller/Phantom.Controller.Services/Users/AddRoleError.cs rename to Controller/Phantom.Controller.Services/Users/Roles/AddRoleError.cs index e9fbdb1..df1d8d5 100644 --- a/Controller/Phantom.Controller.Services/Users/AddRoleError.cs +++ b/Controller/Phantom.Controller.Services/Users/Roles/AddRoleError.cs @@ -1,4 +1,4 @@ -namespace Phantom.Controller.Services.Users; +namespace Phantom.Controller.Services.Users.Roles; public enum AddRoleError : byte { NameIsEmpty, diff --git a/Web/Phantom.Web.Identity/Data/Role.cs b/Controller/Phantom.Controller.Services/Users/Roles/Role.cs similarity index 89% rename from Web/Phantom.Web.Identity/Data/Role.cs rename to Controller/Phantom.Controller.Services/Users/Roles/Role.cs index 5934b6f..eb2bbd9 100644 --- a/Web/Phantom.Web.Identity/Data/Role.cs +++ b/Controller/Phantom.Controller.Services/Users/Roles/Role.cs @@ -1,6 +1,7 @@ using System.Collections.Immutable; +using Phantom.Controller.Services.Users.Permissions; -namespace Phantom.Web.Identity.Data; +namespace Phantom.Controller.Services.Users.Roles; public sealed record Role(Guid Guid, string Name, ImmutableArray<Permission> Permissions) { private static readonly List<Role> AllRoles = new (); diff --git a/Controller/Phantom.Controller.Services/Users/Roles/RoleManager.cs b/Controller/Phantom.Controller.Services/Users/Roles/RoleManager.cs new file mode 100644 index 0000000..39e5aac --- /dev/null +++ b/Controller/Phantom.Controller.Services/Users/Roles/RoleManager.cs @@ -0,0 +1,99 @@ +using System.Collections.Immutable; +using Microsoft.EntityFrameworkCore; +using Phantom.Common.Logging; +using Phantom.Controller.Database; +using Phantom.Controller.Database.Entities; +using Phantom.Controller.Services.Users.Permissions; +using Phantom.Utils.Collections; +using Phantom.Utils.Tasks; +using ILogger = Serilog.ILogger; + +namespace Phantom.Controller.Services.Users.Roles; + +public sealed class RoleManager { + private static readonly ILogger Logger = PhantomLogger.Create<RoleManager>(); + + private const int MaxRoleNameLength = 40; + + private readonly IDatabaseProvider databaseProvider; + + public RoleManager(IDatabaseProvider databaseProvider) { + this.databaseProvider = databaseProvider; + } + + internal async Task Initialize() { + Logger.Information("Adding default roles to database."); + + await using var ctx = databaseProvider.Provide(); + + var existingRoleNames = await ctx.Roles + .Select(static role => role.Name) + .AsAsyncEnumerable() + .ToImmutableSetAsync(); + + var existingPermissionIdsByRoleGuid = await ctx.RolePermissions + .GroupBy(static rp => rp.RoleGuid, static rp => rp.PermissionId) + .ToDictionaryAsync(static g => g.Key, static g => g.ToImmutableHashSet()); + + foreach (var role in Role.All) { + if (!existingRoleNames.Contains(role.Name)) { + Logger.Information("Adding default role \"{Name}\".", role.Name); + ctx.Roles.Add(new RoleEntity(role.Guid, role.Name)); + } + + var existingPermissionIds = existingPermissionIdsByRoleGuid.TryGetValue(role.Guid, out var ids) ? ids : ImmutableHashSet<string>.Empty; + var missingPermissionIds = PermissionManager.GetMissingPermissionsOrdered(role.Permissions, existingPermissionIds); + if (!missingPermissionIds.IsEmpty) { + Logger.Information("Assigning default permission to role \"{Name}\": {Permissions}", role.Name, string.Join(", ", missingPermissionIds)); + foreach (var permissionId in missingPermissionIds) { + ctx.RolePermissions.Add(new RolePermissionEntity(role.Guid, permissionId)); + } + } + } + + await ctx.SaveChangesAsync(); + } + + public async Task<List<RoleEntity>> GetAll() { + await using var ctx = databaseProvider.Provide(); + return await ctx.Roles.ToListAsync(); + } + + public async Task<ImmutableHashSet<string>> GetAllNames() { + await using var ctx = databaseProvider.Provide(); + return await ctx.Roles.Select(static role => role.Name).AsAsyncEnumerable().ToImmutableSetAsync(); + } + + public async ValueTask<RoleEntity?> GetByGuid(Guid guid) { + await using var ctx = databaseProvider.Provide(); + return await ctx.Roles.FindAsync(guid); + } + + public async Task<Result<RoleEntity, AddRoleError>> Create(string name) { + if (string.IsNullOrWhiteSpace(name)) { + return Result.Fail<RoleEntity, AddRoleError>(AddRoleError.NameIsEmpty); + } + else if (name.Length > MaxRoleNameLength) { + return Result.Fail<RoleEntity, AddRoleError>(AddRoleError.NameIsTooLong); + } + + RoleEntity newRole; + try { + await using var ctx = databaseProvider.Provide(); + + if (await ctx.Roles.AnyAsync(role => role.Name == name)) { + return Result.Fail<RoleEntity, AddRoleError>(AddRoleError.NameAlreadyExists); + } + + newRole = new RoleEntity(Guid.NewGuid(), name); + ctx.Roles.Add(newRole); + await ctx.SaveChangesAsync(); + } catch (Exception e) { + Logger.Error(e, "Could not create role \"{Name}\".", name); + return Result.Fail<RoleEntity, AddRoleError>(AddRoleError.UnknownError); + } + + Logger.Information("Created role \"{Name}\" (GUID {Guid}).", name, newRole.RoleGuid); + return Result.Ok<RoleEntity, AddRoleError>(newRole); + } +} diff --git a/Controller/Phantom.Controller.Services/Users/Roles/UserRoleManager.cs b/Controller/Phantom.Controller.Services/Users/Roles/UserRoleManager.cs new file mode 100644 index 0000000..579fab1 --- /dev/null +++ b/Controller/Phantom.Controller.Services/Users/Roles/UserRoleManager.cs @@ -0,0 +1,83 @@ +using System.Collections.Immutable; +using Microsoft.EntityFrameworkCore; +using Phantom.Common.Logging; +using Phantom.Controller.Database; +using Phantom.Controller.Database.Entities; +using Phantom.Utils.Collections; +using ILogger = Serilog.ILogger; + +namespace Phantom.Controller.Services.Users.Roles; + +public sealed class UserRoleManager { + private static readonly ILogger Logger = PhantomLogger.Create<UserRoleManager>(); + + private readonly IDatabaseProvider databaseProvider; + + public UserRoleManager(IDatabaseProvider databaseProvider) { + this.databaseProvider = databaseProvider; + } + + public async Task<Dictionary<Guid, ImmutableArray<RoleEntity>>> GetAllByUserGuid() { + await using var ctx = databaseProvider.Provide(); + return await ctx.UserRoles + .Include(static ur => ur.Role) + .GroupBy(static ur => ur.UserGuid, static ur => ur.Role) + .ToDictionaryAsync(static group => group.Key, static group => group.ToImmutableArray()); + } + + public async Task<ImmutableArray<RoleEntity>> GetUserRoles(UserEntity user) { + await using var ctx = databaseProvider.Provide(); + return await ctx.UserRoles + .Include(static ur => ur.Role) + .Where(ur => ur.UserGuid == user.UserGuid) + .Select(static ur => ur.Role) + .AsAsyncEnumerable() + .ToImmutableArrayAsync(); + } + + public async Task<ImmutableHashSet<Guid>> GetUserRoleGuids(UserEntity user) { + await using var ctx = databaseProvider.Provide(); + return await ctx.UserRoles + .Where(ur => ur.UserGuid == user.UserGuid) + .Select(static ur => ur.RoleGuid) + .AsAsyncEnumerable() + .ToImmutableSetAsync(); + } + + public async Task<bool> Add(UserEntity user, RoleEntity role) { + try { + await using var ctx = databaseProvider.Provide(); + + var userRole = await ctx.UserRoles.FindAsync(user.UserGuid, role.RoleGuid); + if (userRole == null) { + userRole = new UserRoleEntity(user.UserGuid, role.RoleGuid); + ctx.UserRoles.Add(userRole); + await ctx.SaveChangesAsync(); + } + } catch (Exception e) { + Logger.Error(e, "Could not add user \"{UserName}\" (GUID {UserGuid}) to role \"{RoleName}\" (GUID {RoleGuid}).", user.Name, user.UserGuid, role.Name, role.RoleGuid); + return false; + } + + Logger.Information("Added user \"{UserName}\" (GUID {UserGuid}) to role \"{RoleName}\" (GUID {RoleGuid}).", user.Name, user.UserGuid, role.Name, role.RoleGuid); + return true; + } + + public async Task<bool> Remove(UserEntity user, RoleEntity role) { + try { + await using var ctx = databaseProvider.Provide(); + + var userRole = await ctx.UserRoles.FindAsync(user.UserGuid, role.RoleGuid); + if (userRole != null) { + ctx.UserRoles.Remove(userRole); + await ctx.SaveChangesAsync(); + } + } catch (Exception e) { + Logger.Error(e, "Could not remove user \"{UserName}\" (GUID {UserGuid}) from role \"{RoleName}\" (GUID {RoleGuid}).", user.Name, user.UserGuid, role.Name, role.RoleGuid); + return false; + } + + Logger.Information("Removed user \"{UserName}\" (GUID {UserGuid}) from role \"{RoleName}\" (GUID {RoleGuid}).", user.Name, user.UserGuid, role.Name, role.RoleGuid); + return true; + } +} diff --git a/Controller/Phantom.Controller.Services/Users/UserManager.cs b/Controller/Phantom.Controller.Services/Users/UserManager.cs index 2d9bc55..7866933 100644 --- a/Controller/Phantom.Controller.Services/Users/UserManager.cs +++ b/Controller/Phantom.Controller.Services/Users/UserManager.cs @@ -1,6 +1,5 @@ using System.Collections.Immutable; using System.Security.Claims; -using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; using Phantom.Common.Logging; using Phantom.Controller.Database; @@ -16,10 +15,10 @@ public sealed class UserManager { private const int MaxUserNameLength = 40; - private readonly ApplicationDbContext db; + private readonly IDatabaseProvider databaseProvider; - public UserManager(ApplicationDbContext db) { - this.db = db; + public UserManager(IDatabaseProvider databaseProvider) { + this.databaseProvider = databaseProvider; } public static Guid? GetAuthenticatedUserId(ClaimsPrincipal user) { @@ -35,43 +34,25 @@ public sealed class UserManager { return Guid.TryParse(claim.Value, out var guid) ? guid : null; } - public Task<ImmutableArray<UserEntity>> GetAll() { - return db.Users.AsAsyncEnumerable().ToImmutableArrayAsync(); + public async Task<ImmutableArray<UserEntity>> GetAll() { + await using var ctx = databaseProvider.Provide(); + return await ctx.Users.AsAsyncEnumerable().ToImmutableArrayAsync(); } - public Task<Dictionary<Guid, T>> GetAllByGuid<T>(Func<UserEntity, T> valueSelector, CancellationToken cancellationToken = default) { - return db.Users.ToDictionaryAsync(static user => user.UserGuid, valueSelector, cancellationToken); + public async Task<Dictionary<Guid, T>> GetAllByGuid<T>(Func<UserEntity, T> valueSelector, CancellationToken cancellationToken = default) { + await using var ctx = databaseProvider.Provide(); + return await ctx.Users.ToDictionaryAsync(static user => user.UserGuid, valueSelector, cancellationToken); } - public Task<UserEntity?> GetByName(string username) { - return db.Users.FirstOrDefaultAsync(user => user.Name == username); + public async Task<UserEntity?> GetByName(string username) { + await using var ctx = databaseProvider.Provide(); + return await ctx.Users.FirstOrDefaultAsync(user => user.Name == username); } public async Task<UserEntity?> GetAuthenticated(string username, string password) { - var user = await db.Users.FirstOrDefaultAsync(user => user.Name == username); - if (user == null) { - return null; - } - - switch (UserPasswords.Verify(user, password)) { - case PasswordVerificationResult.SuccessRehashNeeded: - try { - UserPasswords.Set(user, password); - await db.SaveChangesAsync(); - } catch (Exception e) { - Logger.Warning(e, "Could not rehash password for \"{Username}\".", user.Name); - } - - goto case PasswordVerificationResult.Success; - - case PasswordVerificationResult.Success: - return user; - - case PasswordVerificationResult.Failed: - return null; - } - - throw new InvalidOperationException(); + await using var ctx = databaseProvider.Provide(); + var user = await ctx.Users.FirstOrDefaultAsync(user => user.Name == username); + return user != null && UserPasswords.Verify(user, password) ? user : null; } public async Task<Result<UserEntity, AddUserError>> CreateUser(string username, string password) { @@ -87,58 +68,66 @@ public sealed class UserManager { return Result.Fail<UserEntity, AddUserError>(new AddUserError.PasswordIsInvalid(requirementViolations)); } + UserEntity newUser; try { - if (await db.Users.AnyAsync(user => user.Name == username)) { + await using var ctx = databaseProvider.Provide(); + + if (await ctx.Users.AnyAsync(user => user.Name == username)) { return Result.Fail<UserEntity, AddUserError>(new AddUserError.NameAlreadyExists()); } - var guid = Guid.NewGuid(); - var user = new UserEntity(guid, username); - UserPasswords.Set(user, password); + newUser = new UserEntity(Guid.NewGuid(), username); + UserPasswords.Set(newUser, password); - db.Users.Add(user); - await db.SaveChangesAsync(); - - Logger.Information("Created user \"{Name}\" (GUID {Guid}).", username, guid); - return Result.Ok<UserEntity, AddUserError>(user); + ctx.Users.Add(newUser); + await ctx.SaveChangesAsync(); } catch (Exception e) { Logger.Error(e, "Could not create user \"{Name}\".", username); return Result.Fail<UserEntity, AddUserError>(new AddUserError.UnknownError()); } + + Logger.Information("Created user \"{Name}\" (GUID {Guid}).", username, newUser.UserGuid); + return Result.Ok<UserEntity, AddUserError>(newUser); } public async Task<Result<SetUserPasswordError>> SetUserPassword(Guid guid, string password) { - var user = await db.Users.FindAsync(guid); - if (user == null) { - return Result.Fail<SetUserPasswordError>(new SetUserPasswordError.UserNotFound()); - } - - try { - var requirementViolations = UserPasswords.CheckRequirements(password); - if (!requirementViolations.IsEmpty) { - return Result.Fail<SetUserPasswordError>(new SetUserPasswordError.PasswordIsInvalid(requirementViolations)); + UserEntity foundUser; + + await using (var ctx = databaseProvider.Provide()) { + var user = await ctx.Users.FindAsync(guid); + if (user == null) { + return Result.Fail<SetUserPasswordError>(new SetUserPasswordError.UserNotFound()); } - UserPasswords.Set(user, password); - await db.SaveChangesAsync(); + foundUser = user; + try { + var requirementViolations = UserPasswords.CheckRequirements(password); + if (!requirementViolations.IsEmpty) { + return Result.Fail<SetUserPasswordError>(new SetUserPasswordError.PasswordIsInvalid(requirementViolations)); + } - Logger.Information("Changed password for user \"{Name}\" (GUID {Guid}).", user.Name, user.UserGuid); - return Result.Ok<SetUserPasswordError>(); - } catch (Exception e) { - Logger.Error(e, "Could not change password for user \"{Name}\" (GUID {Guid}).", user.Name, user.UserGuid); - return Result.Fail<SetUserPasswordError>(new SetUserPasswordError.UnknownError()); + UserPasswords.Set(user, password); + await ctx.SaveChangesAsync(); + } catch (Exception e) { + Logger.Error(e, "Could not change password for user \"{Name}\" (GUID {Guid}).", user.Name, user.UserGuid); + return Result.Fail<SetUserPasswordError>(new SetUserPasswordError.UnknownError()); + } } + + Logger.Information("Changed password for user \"{Name}\" (GUID {Guid}).", foundUser.Name, foundUser.UserGuid); + return Result.Ok<SetUserPasswordError>(); } public async Task<DeleteUserResult> DeleteByGuid(Guid guid) { - var user = await db.Users.FindAsync(guid); + await using var ctx = databaseProvider.Provide(); + var user = await ctx.Users.FindAsync(guid); if (user == null) { return DeleteUserResult.NotFound; } try { - db.Users.Remove(user); - await db.SaveChangesAsync(); + ctx.Users.Remove(user); + await ctx.SaveChangesAsync(); return DeleteUserResult.Deleted; } catch (Exception e) { Logger.Error(e, "Could not delete user \"{Name}\" (GUID {Guid}).", user.Name, user.UserGuid); diff --git a/Controller/Phantom.Controller.Services/Users/UserPasswords.cs b/Controller/Phantom.Controller.Services/Users/UserPasswords.cs index 4082cf8..1618395 100644 --- a/Controller/Phantom.Controller.Services/Users/UserPasswords.cs +++ b/Controller/Phantom.Controller.Services/Users/UserPasswords.cs @@ -1,12 +1,9 @@ using System.Collections.Immutable; -using Microsoft.AspNetCore.Identity; using Phantom.Controller.Database.Entities; namespace Phantom.Controller.Services.Users; internal static class UserPasswords { - private static PasswordHasher<UserEntity> Hasher { get; } = new (); - private const int MinimumLength = 16; public static ImmutableArray<PasswordRequirementViolation> CheckRequirements(string password) { @@ -32,10 +29,10 @@ internal static class UserPasswords { } public static void Set(UserEntity user, string password) { - user.PasswordHash = Hasher.HashPassword(user, password); + user.PasswordHash = BCrypt.Net.BCrypt.HashPassword(password); } - public static PasswordVerificationResult Verify(UserEntity user, string password) { - return Hasher.VerifyHashedPassword(user, user.PasswordHash, password); + public static bool Verify(UserEntity user, string password) { + return BCrypt.Net.BCrypt.Verify(password, user.PasswordHash); } } diff --git a/Controller/Phantom.Controller.Services/Users/UserRoleManager.cs b/Controller/Phantom.Controller.Services/Users/UserRoleManager.cs deleted file mode 100644 index 7579291..0000000 --- a/Controller/Phantom.Controller.Services/Users/UserRoleManager.cs +++ /dev/null @@ -1,76 +0,0 @@ -using System.Collections.Immutable; -using Microsoft.EntityFrameworkCore; -using Phantom.Common.Logging; -using Phantom.Controller.Database; -using Phantom.Controller.Database.Entities; -using Phantom.Utils.Collections; -using ILogger = Serilog.ILogger; - -namespace Phantom.Controller.Services.Users; - -public sealed class UserRoleManager { - private static readonly ILogger Logger = PhantomLogger.Create<UserRoleManager>(); - - private readonly ApplicationDbContext db; - - public UserRoleManager(ApplicationDbContext db) { - this.db = db; - } - - public Task<Dictionary<Guid, ImmutableArray<RoleEntity>>> GetAllByUserGuid() { - return db.UserRoles - .Include(static ur => ur.Role) - .GroupBy(static ur => ur.UserGuid, static ur => ur.Role) - .ToDictionaryAsync(static group => group.Key, static group => group.ToImmutableArray()); - } - - public Task<ImmutableArray<RoleEntity>> GetUserRoles(UserEntity user) { - return db.UserRoles - .Include(static ur => ur.Role) - .Where(ur => ur.UserGuid == user.UserGuid) - .Select(static ur => ur.Role) - .AsAsyncEnumerable() - .ToImmutableArrayAsync(); - } - - public Task<ImmutableHashSet<Guid>> GetUserRoleGuids(UserEntity user) { - return db.UserRoles - .Where(ur => ur.UserGuid == user.UserGuid) - .Select(static ur => ur.RoleGuid) - .AsAsyncEnumerable() - .ToImmutableSetAsync(); - } - - public async Task<bool> Add(UserEntity user, RoleEntity role) { - try { - var userRole = await db.UserRoles.FindAsync(user.UserGuid, role.RoleGuid); - if (userRole == null) { - userRole = new UserRoleEntity(user.UserGuid, role.RoleGuid); - db.UserRoles.Add(userRole); - await db.SaveChangesAsync(); - } - - Logger.Information("Added user \"{UserName}\" (GUID {UserGuid}) to role \"{RoleName}\" (GUID {RoleGuid}).", user.Name, user.UserGuid, role.Name, role.RoleGuid); - return true; - } catch (Exception e) { - Logger.Error(e, "Could not add user \"{UserName}\" (GUID {UserGuid}) to role \"{RoleName}\" (GUID {RoleGuid}).", user.Name, user.UserGuid, role.Name, role.RoleGuid); - return false; - } - } - - public async Task<bool> Remove(UserEntity user, RoleEntity role) { - try { - var userRole = await db.UserRoles.FindAsync(user.UserGuid, role.RoleGuid); - if (userRole != null) { - db.UserRoles.Remove(userRole); - await db.SaveChangesAsync(); - } - - Logger.Information("Removed user \"{UserName}\" (GUID {UserGuid}) from role \"{RoleName}\" (GUID {RoleGuid}).", user.Name, user.UserGuid, role.Name, role.RoleGuid); - return true; - } catch (Exception e) { - Logger.Error(e, "Could not remove user \"{UserName}\" (GUID {UserGuid}) from role \"{RoleName}\" (GUID {RoleGuid}).", user.Name, user.UserGuid, role.Name, role.RoleGuid); - return false; - } - } -} diff --git a/Controller/Phantom.Controller/CertificateFiles.cs b/Controller/Phantom.Controller/CertificateFiles.cs deleted file mode 100644 index ae489cf..0000000 --- a/Controller/Phantom.Controller/CertificateFiles.cs +++ /dev/null @@ -1,87 +0,0 @@ -using NetMQ; -using Phantom.Common.Data.Agent; -using Phantom.Common.Logging; -using Phantom.Utils.Cryptography; -using Phantom.Utils.IO; -using Serilog; - -namespace Phantom.Controller; - -static class CertificateFiles { - private static ILogger Logger { get; } = PhantomLogger.Create(nameof(CertificateFiles)); - - private const string SecretKeyFileName = "secret.key"; - private const string AgentKeyFileName = "agent.key"; - - public static async Task<(NetMQCertificate, AgentAuthToken)?> CreateOrLoad(string folderPath) { - string secretKeyFilePath = Path.Combine(folderPath, SecretKeyFileName); - string agentKeyFilePath = Path.Combine(folderPath, AgentKeyFileName); - - bool secretKeyFileExists = File.Exists(secretKeyFilePath); - bool agentKeyFileExists = File.Exists(agentKeyFilePath); - - if (secretKeyFileExists && agentKeyFileExists) { - try { - return await LoadCertificatesFromFiles(secretKeyFilePath, agentKeyFilePath); - } catch (IOException e) { - Logger.Fatal("Error reading certificate files."); - Logger.Fatal(e.Message); - return null; - } catch (Exception) { - Logger.Fatal("Certificate files contain invalid data."); - return null; - } - } - - if (secretKeyFileExists || agentKeyFileExists) { - string existingKeyFilePath = secretKeyFileExists ? secretKeyFilePath : agentKeyFilePath; - string missingKeyFileName = secretKeyFileExists ? AgentKeyFileName : SecretKeyFileName; - Logger.Fatal("The certificate file {ExistingKeyFilePath} exists but {MissingKeyFileName} does not. Please delete it to regenerate both certificate files.", existingKeyFilePath, missingKeyFileName); - return null; - } - - Logger.Information("Creating certificate files in: {FolderPath}", folderPath); - - try { - return await GenerateCertificateFiles(secretKeyFilePath, agentKeyFilePath); - } catch (Exception e) { - Logger.Fatal("Error creating certificate files."); - Logger.Fatal(e.Message); - return null; - } - } - - private static async Task<(NetMQCertificate, AgentAuthToken)?> LoadCertificatesFromFiles(string secretKeyFilePath, string agentKeyFilePath) { - byte[] secretKey = await ReadCertificateFile(secretKeyFilePath); - byte[] agentKey = await ReadCertificateFile(agentKeyFilePath); - - var (publicKey, agentToken) = AgentKeyData.FromBytes(agentKey); - var certificate = new NetMQCertificate(secretKey, publicKey); - - LogAgentConnectionInfo("Loaded existing certificate files.", agentKeyFilePath, agentKey); - return (certificate, agentToken); - } - - private static Task<byte[]> ReadCertificateFile(string filePath) { - Files.RequireMaximumFileSize(filePath, 64); - return File.ReadAllBytesAsync(filePath); - } - - private static async Task<(NetMQCertificate, AgentAuthToken)> GenerateCertificateFiles(string secretKeyFilePath, string agentKeyFilePath) { - var certificate = new NetMQCertificate(); - var agentToken = AgentAuthToken.Generate(); - var agentKey = AgentKeyData.ToBytes(certificate.PublicKey, agentToken); - - await Files.WriteBytesAsync(secretKeyFilePath, certificate.SecretKey, FileMode.Create, Chmod.URW_GR); - await Files.WriteBytesAsync(agentKeyFilePath, agentKey, FileMode.Create, Chmod.URW_GR); - - LogAgentConnectionInfo("Created new certificate files.", agentKeyFilePath, agentKey); - return (certificate, agentToken); - } - - private static void LogAgentConnectionInfo(string message, string agentKeyFilePath, byte[] agentKey) { - Logger.Information(message + " Agents will need the agent key to connect."); - Logger.Information("Agent key file: {AgentKeyFilePath}", agentKeyFilePath); - Logger.Information("Agent key: {AgentKey}", TokenGenerator.EncodeBytes(agentKey)); - } -} diff --git a/Controller/Phantom.Controller/ConnectionKeyData.cs b/Controller/Phantom.Controller/ConnectionKeyData.cs new file mode 100644 index 0000000..c9d3f3e --- /dev/null +++ b/Controller/Phantom.Controller/ConnectionKeyData.cs @@ -0,0 +1,6 @@ +using NetMQ; +using Phantom.Common.Data.Agent; + +namespace Phantom.Controller; + +readonly record struct ConnectionKeyData(NetMQCertificate Certificate, AuthToken AuthToken); diff --git a/Controller/Phantom.Controller/ConnectionKeyFiles.cs b/Controller/Phantom.Controller/ConnectionKeyFiles.cs new file mode 100644 index 0000000..d2856c5 --- /dev/null +++ b/Controller/Phantom.Controller/ConnectionKeyFiles.cs @@ -0,0 +1,113 @@ +using NetMQ; +using Phantom.Common.Data.Agent; +using Phantom.Common.Logging; +using Phantom.Utils.Cryptography; +using Phantom.Utils.IO; +using Serilog; + +namespace Phantom.Controller; + +abstract class ConnectionKeyFiles { + private const string CommonKeyFileExtension = ".key"; + private const string SecretKeyFileExtension = ".secret"; + + private readonly ILogger logger; + private readonly string commonKeyFileName; + private readonly string secretKeyFileName; + + private ConnectionKeyFiles(ILogger logger, string name) { + this.logger = logger; + this.commonKeyFileName = name + CommonKeyFileExtension; + this.secretKeyFileName = name + SecretKeyFileExtension; + } + + public async Task<ConnectionKeyData?> CreateOrLoad(string folderPath) { + string commonKeyFilePath = Path.Combine(folderPath, commonKeyFileName); + string secretKeyFilePath = Path.Combine(folderPath, secretKeyFileName); + + bool commonKeyFileExists = File.Exists(commonKeyFilePath); + bool secretKeyFileExists = File.Exists(secretKeyFilePath); + + if (commonKeyFileExists && secretKeyFileExists) { + try { + return await ReadKeyFiles(commonKeyFilePath, secretKeyFilePath); + } catch (IOException e) { + logger.Fatal("Error reading connection key files."); + logger.Fatal(e.Message); + return null; + } catch (Exception) { + logger.Fatal("Connection key files contain invalid data."); + return null; + } + } + + if (commonKeyFileExists || secretKeyFileExists) { + string existingKeyFilePath = commonKeyFileExists ? commonKeyFilePath : secretKeyFilePath; + string missingKeyFileName = commonKeyFileExists ? secretKeyFileName : commonKeyFileName; + logger.Fatal("The 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(commonKeyFilePath, secretKeyFilePath); + } catch (Exception e) { + logger.Fatal("Error creating connection key files."); + logger.Fatal(e.Message); + return null; + } + } + + private async Task<ConnectionKeyData?> ReadKeyFiles(string commonKeyFilePath, string secretKeyFilePath) { + byte[] commonKeyBytes = await ReadKeyFile(commonKeyFilePath); + byte[] secretKeyBytes = await ReadKeyFile(secretKeyFilePath); + + var (publicKey, authToken) = ConnectionCommonKey.FromBytes(commonKeyBytes); + var certificate = new NetMQCertificate(secretKeyBytes, publicKey); + + logger.Information("Loaded connection key files."); + LogCommonKey(commonKeyFilePath, TokenGenerator.EncodeBytes(commonKeyBytes)); + + return new ConnectionKeyData(certificate, authToken); + } + + private static Task<byte[]> ReadKeyFile(string filePath) { + Files.RequireMaximumFileSize(filePath, 64); + return File.ReadAllBytesAsync(filePath); + } + + private async Task<ConnectionKeyData> GenerateKeyFiles(string commonKeyFilePath, string secretKeyFilePath) { + var certificate = new NetMQCertificate(); + var authToken = AuthToken.Generate(); + var commonKey = new ConnectionCommonKey(certificate.PublicKey, authToken).ToBytes(); + + await Files.WriteBytesAsync(secretKeyFilePath, certificate.SecretKey, FileMode.Create, Chmod.URW_GR); + await Files.WriteBytesAsync(commonKeyFilePath, commonKey, FileMode.Create, Chmod.URW_GR); + + logger.Information("Created new connection key files."); + LogCommonKey(commonKeyFilePath, TokenGenerator.EncodeBytes(commonKey)); + + return new ConnectionKeyData(certificate, authToken); + } + + protected abstract void LogCommonKey(string commonKeyFilePath, string commonKeyEncoded); + + internal sealed class Agent : ConnectionKeyFiles { + public Agent() : base(PhantomLogger.Create<ConnectionKeyFiles, Agent>(), "agent") {} + + protected override void LogCommonKey(string commonKeyFilePath, string commonKeyEncoded) { + logger.Information("Agent key file: {AgentKeyFilePath}", commonKeyFilePath); + logger.Information("Agent key: {AgentKey}", commonKeyEncoded); + } + } + + internal sealed class Web : ConnectionKeyFiles { + public Web() : base(PhantomLogger.Create<ConnectionKeyFiles, Web>(), "web") {} + + protected override void LogCommonKey(string commonKeyFilePath, string commonKeyEncoded) { + logger.Information("Web key file: {WebKeyFilePath}", commonKeyFilePath); + logger.Information("Web key: {WebKey}", commonKeyEncoded); + } + } +} diff --git a/Controller/Phantom.Controller/Phantom.Controller.csproj b/Controller/Phantom.Controller/Phantom.Controller.csproj index 22d4680..8bdff84 100644 --- a/Controller/Phantom.Controller/Phantom.Controller.csproj +++ b/Controller/Phantom.Controller/Phantom.Controller.csproj @@ -18,7 +18,6 @@ <ProjectReference Include="..\Phantom.Controller.Minecraft\Phantom.Controller.Minecraft.csproj" /> <ProjectReference Include="..\Phantom.Controller.Rpc\Phantom.Controller.Rpc.csproj" /> <ProjectReference Include="..\Phantom.Controller.Services\Phantom.Controller.Services.csproj" /> - <ProjectReference Include="..\..\Web\Phantom.Web\Phantom.Web.csproj" /> </ItemGroup> </Project> diff --git a/Controller/Phantom.Controller/Program.cs b/Controller/Phantom.Controller/Program.cs index deaf28a..1cc2086 100644 --- a/Controller/Phantom.Controller/Program.cs +++ b/Controller/Phantom.Controller/Program.cs @@ -1,24 +1,21 @@ using System.Reflection; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; using Phantom.Common.Logging; +using Phantom.Common.Messages.Agent; +using Phantom.Common.Messages.Web; using Phantom.Controller; using Phantom.Controller.Database.Postgres; using Phantom.Controller.Rpc; using Phantom.Controller.Services; -using Phantom.Controller.Services.Rpc; -using Phantom.Utils.Cryptography; using Phantom.Utils.IO; using Phantom.Utils.Rpc; using Phantom.Utils.Runtime; using Phantom.Utils.Tasks; -using WebConfiguration = Phantom.Web.Configuration; -using WebLauncher = Phantom.Web.Launcher; -var cancellationTokenSource = new CancellationTokenSource(); +var shutdownCancellationTokenSource = new CancellationTokenSource(); +var shutdownCancellationToken = shutdownCancellationTokenSource.Token; -PosixSignals.RegisterCancellation(cancellationTokenSource, static () => { - PhantomLogger.Root.InformationHeading("Stopping Phantom Panel server..."); +PosixSignals.RegisterCancellation(shutdownCancellationTokenSource, static () => { + PhantomLogger.Root.InformationHeading("Stopping Phantom Panel controller..."); }); static void CreateFolderOrStop(string path, UnixFileMode chmod) { @@ -35,50 +32,40 @@ static void CreateFolderOrStop(string path, UnixFileMode chmod) { try { var fullVersion = AssemblyAttributes.GetFullVersion(Assembly.GetExecutingAssembly()); - PhantomLogger.Root.InformationHeading("Initializing Phantom Panel server..."); - PhantomLogger.Root.Information("Server version: {Version}", fullVersion); + PhantomLogger.Root.InformationHeading("Initializing Phantom Panel controller..."); + PhantomLogger.Root.Information("Controller version: {Version}", fullVersion); - var (webServerHost, webServerPort, webBasePath, rpcServerHost, rpcServerPort, sqlConnectionString) = Variables.LoadOrStop(); + var (agentRpcServerHost, agentRpcServerPort, webRpcServerHost, webRpcServerPort, sqlConnectionString) = Variables.LoadOrStop(); string secretsPath = Path.GetFullPath("./secrets"); CreateFolderOrStop(secretsPath, Chmod.URWX_GRX); - string webKeysPath = Path.GetFullPath("./keys"); - CreateFolderOrStop(webKeysPath, Chmod.URWX); - - var certificateData = await CertificateFiles.CreateOrLoad(secretsPath); - if (certificateData == null) { + var agentKeyDataResult = await new ConnectionKeyFiles.Agent().CreateOrLoad(secretsPath); + if (agentKeyDataResult is not {} agentKeyData) { return 1; } - var (certificate, agentToken) = certificateData.Value; + var webKeyDataResult = await new ConnectionKeyFiles.Web().CreateOrLoad(secretsPath); + if (webKeyDataResult is not {} webKeyData) { + return 1; + } + + var dbContextFactory = new ApplicationDbContextFactory(sqlConnectionString); + var controllerServices = new ControllerServices(dbContextFactory, agentKeyData.AuthToken, shutdownCancellationToken); PhantomLogger.Root.InformationHeading("Launching Phantom Panel server..."); - var taskManager = new TaskManager(PhantomLogger.Create<TaskManager>("Server")); - try { - var rpcConfiguration = new RpcConfiguration(PhantomLogger.Create("Rpc"), PhantomLogger.Create<TaskManager>("Rpc"), rpcServerHost, rpcServerPort, certificate); - var webConfiguration = new WebConfiguration(PhantomLogger.Create("Web"), webServerHost, webServerPort, webBasePath, webKeysPath, cancellationTokenSource.Token); + await controllerServices.Initialize(); - var administratorToken = TokenGenerator.Create(60); - PhantomLogger.Root.Information("Your administrator token is: {AdministratorToken}", administratorToken); - PhantomLogger.Root.Information("For administrator setup, visit: {HttpUrl}{SetupPath}", webConfiguration.HttpUrl, webConfiguration.BasePath + "setup"); - - var serviceConfiguration = new ServiceConfiguration(fullVersion, TokenGenerator.GetBytesOrThrow(administratorToken), cancellationTokenSource.Token); - var webConfigurator = new WebConfigurator(serviceConfiguration, taskManager, agentToken); - var webApplication = await WebLauncher.CreateApplication(webConfiguration, webConfigurator, options => options.UseNpgsql(sqlConnectionString, static options => { - options.CommandTimeout(10).MigrationsAssembly(typeof(ApplicationDbContextDesignFactory).Assembly.FullName); - })); - - await Task.WhenAll( - RpcLauncher.Launch(rpcConfiguration, webApplication.Services.GetRequiredService<MessageToServerListenerFactory>().CreateListener, cancellationTokenSource.Token), - WebLauncher.Launch(webConfiguration, webApplication) - ); - } finally { - cancellationTokenSource.Cancel(); - await taskManager.Stop(); + static RpcConfiguration ConfigureRpc(string serviceName, string host, ushort port, ConnectionKeyData connectionKey) { + return new RpcConfiguration(PhantomLogger.Create("Rpc", serviceName), PhantomLogger.Create<TaskManager>("Rpc", serviceName), host, port, connectionKey.Certificate); } + await Task.WhenAll( + RpcRuntime.Launch(ConfigureRpc("Agent", agentRpcServerHost, agentRpcServerPort, agentKeyData), AgentMessageRegistries.Definitions, controllerServices.CreateAgentMessageListener, shutdownCancellationToken), + RpcRuntime.Launch(ConfigureRpc("Web", webRpcServerHost, webRpcServerPort, webKeyData), WebMessageRegistries.Definitions, controllerServices.CreateWebMessageListener, shutdownCancellationToken) + ); + return 0; } catch (OperationCanceledException) { return 0; @@ -88,7 +75,8 @@ try { PhantomLogger.Root.Fatal(e, "Caught exception in entry point."); return 1; } finally { - cancellationTokenSource.Dispose(); + shutdownCancellationTokenSource.Dispose(); + PhantomLogger.Root.Information("Bye!"); PhantomLogger.Dispose(); } diff --git a/Controller/Phantom.Controller/Variables.cs b/Controller/Phantom.Controller/Variables.cs index 4486a4d..13b64ab 100644 --- a/Controller/Phantom.Controller/Variables.cs +++ b/Controller/Phantom.Controller/Variables.cs @@ -5,11 +5,10 @@ using Phantom.Utils.Runtime; namespace Phantom.Controller; sealed record Variables( - string WebServerHost, - ushort WebServerPort, - string WebBasePath, - string RpcServerHost, - ushort RpcServerPort, + string AgentRpcServerHost, + ushort AgentRpcServerPort, + string WebRpcServerHost, + ushort WebRpcServerPort, string SqlConnectionString ) { private static Variables LoadOrThrow() { @@ -22,11 +21,10 @@ sealed record Variables( }; return new Variables( - EnvironmentVariables.GetString("WEB_SERVER_HOST").WithDefault("0.0.0.0"), - EnvironmentVariables.GetPortNumber("WEB_SERVER_PORT").WithDefault(9400), - EnvironmentVariables.GetString("WEB_BASE_PATH").Validate(static value => value.StartsWith('/') && value.EndsWith('/'), "Environment variable must begin and end with '/'").WithDefault("/"), - EnvironmentVariables.GetString("RPC_SERVER_HOST").WithDefault("0.0.0.0"), - EnvironmentVariables.GetPortNumber("RPC_SERVER_PORT").WithDefault(9401), + EnvironmentVariables.GetString("AGENT_RPC_SERVER_HOST").WithDefault("0.0.0.0"), + EnvironmentVariables.GetPortNumber("AGENT_RPC_SERVER_PORT").WithDefault(9401), + EnvironmentVariables.GetString("WEB_RPC_SERVER_HOST").WithDefault("0.0.0.0"), + EnvironmentVariables.GetPortNumber("WEB_RPC_SERVER_PORT").WithDefault(9402), connectionStringBuilder.ToString() ); } diff --git a/Controller/Phantom.Controller/WebConfigurator.cs b/Controller/Phantom.Controller/WebConfigurator.cs deleted file mode 100644 index 76fefe2..0000000 --- a/Controller/Phantom.Controller/WebConfigurator.cs +++ /dev/null @@ -1,45 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; -using Phantom.Common.Data.Agent; -using Phantom.Controller.Minecraft; -using Phantom.Controller.Services; -using Phantom.Controller.Services.Agents; -using Phantom.Controller.Services.Audit; -using Phantom.Controller.Services.Events; -using Phantom.Controller.Services.Instances; -using Phantom.Controller.Services.Rpc; -using Phantom.Utils.Tasks; -using WebLauncher = Phantom.Web.Launcher; - -namespace Phantom.Controller; - -sealed class WebConfigurator : WebLauncher.IConfigurator { - private readonly ServiceConfiguration serviceConfiguration; - private readonly TaskManager taskManager; - private readonly AgentAuthToken agentToken; - - public WebConfigurator(ServiceConfiguration serviceConfiguration, TaskManager taskManager, AgentAuthToken agentToken) { - this.serviceConfiguration = serviceConfiguration; - this.taskManager = taskManager; - this.agentToken = agentToken; - } - - public void ConfigureServices(IServiceCollection services) { - services.AddSingleton(serviceConfiguration); - services.AddSingleton(taskManager); - services.AddSingleton(agentToken); - services.AddSingleton<AgentManager>(); - services.AddSingleton<AgentJavaRuntimesManager>(); - services.AddSingleton<EventLog>(); - services.AddSingleton<InstanceManager>(); - services.AddSingleton<InstanceLogManager>(); - services.AddSingleton<MinecraftVersions>(); - services.AddSingleton<MessageToServerListenerFactory>(); - - services.AddScoped<AuditLog>(); - } - - public async Task LoadFromDatabase(IServiceProvider serviceProvider) { - await serviceProvider.GetRequiredService<AgentManager>().Initialize(); - await serviceProvider.GetRequiredService<InstanceManager>().Initialize(); - } -} diff --git a/Docker/docker-compose.yml b/Docker/docker-compose.yml index 42d36d5..2a0beb9 100644 --- a/Docker/docker-compose.yml +++ b/Docker/docker-compose.yml @@ -5,7 +5,7 @@ services: image: postgres:14 container_name: "phantom-panel-postgres" ports: - - "127.0.0.1:9402:5432" + - "127.0.0.1:9403:5432" volumes: - postgres:/var/lib/postgresql/data environment: diff --git a/Dockerfile b/Dockerfile index e8b8e0d..7f0f811 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,6 +7,7 @@ ARG TARGETARCH ADD . /app WORKDIR /app +RUN mkdir /data && chmod 777 /data RUN dotnet restore --arch "$TARGETARCH" @@ -24,25 +25,31 @@ RUN dotnet publish Agent/Phantom.Agent/Phantom.Agent.csproj \ --output /app/out -# +----------------------+ -# | Build Phantom Server | -# +----------------------+ -FROM phantom-base-builder AS phantom-server-builder - -RUN dotnet publish Web/Phantom.Web/Phantom.Web.csproj \ - /p:DebugType=None \ - /p:DebugSymbols=false \ - --no-restore \ - --arch "$TARGETARCH" \ - --configuration Release \ - --output /app/out +# +--------------------------+ +# | Build Phantom Controller | +# +--------------------------+ +FROM phantom-base-builder AS phantom-controller-builder RUN dotnet publish Controller/Phantom.Controller/Phantom.Controller.csproj \ - /p:DebugType=None \ - /p:DebugSymbols=false \ - --no-restore \ - --arch "$TARGETARCH" \ - --configuration Release \ + /p:DebugType=None \ + /p:DebugSymbols=false \ + --no-restore \ + --arch "$TARGETARCH" \ + --configuration Release \ + --output /app/out + + +# +-------------------+ +# | Build Phantom Web | +# +-------------------+ +FROM phantom-base-builder AS phantom-controller-builder + +RUN dotnet publish Web/Phantom.Web/Phantom.Web.csproj \ + /p:DebugType=None \ + /p:DebugSymbols=false \ + --no-restore \ + --arch "$TARGETARCH" \ + --configuration Release \ --output /app/out @@ -51,7 +58,6 @@ RUN dotnet publish Controller/Phantom.Controller/Phantom.Controller.csproj \ # +------------------------------+ FROM mcr.microsoft.com/dotnet/nightly/runtime:8.0-preview AS phantom-agent -RUN mkdir /data && chmod 777 /data WORKDIR /data COPY --from=eclipse-temurin:8-jre /opt/java/openjdk /opt/java/8 @@ -73,14 +79,25 @@ COPY --from=phantom-agent-builder --chmod=755 /app/out /app ENTRYPOINT ["dotnet", "/app/Phantom.Agent.dll"] -# +-------------------------------+ -# | Finalize Phantom Server image | -# +-------------------------------+ -FROM mcr.microsoft.com/dotnet/nightly/aspnet:8.0-preview AS phantom-server +# +-----------------------------------+ +# | Finalize Phantom Controller image | +# +-----------------------------------+ +FROM mcr.microsoft.com/dotnet/nightly/runtime:8.0-preview AS phantom-controller -RUN mkdir /data && chmod 777 /data WORKDIR /data -COPY --from=phantom-server-builder --chmod=755 /app/out /app +COPY --from=phantom-controller-builder --chmod=755 /app/out /app ENTRYPOINT ["dotnet", "/app/Phantom.Controller.dll"] + + +# +----------------------------+ +# | Finalize Phantom Web image | +# +----------------------------+ +FROM mcr.microsoft.com/dotnet/nightly/aspnet:8.0-preview AS phantom-web + +WORKDIR /data + +COPY --from=phantom-web-builder --chmod=755 /app/out /app + +ENTRYPOINT ["dotnet", "/app/Phantom.Web.dll"] diff --git a/Packages.props b/Packages.props index 64d247b..18f9205 100644 --- a/Packages.props +++ b/Packages.props @@ -12,6 +12,10 @@ <PackageReference Update="Kajabity.Tools.Java" Version="0.3.8607.38728" /> </ItemGroup> + <ItemGroup> + <PackageReference Update="BCrypt.Net-Next.StrongName" Version="4.0.3" /> + </ItemGroup> + <ItemGroup> <PackageReference Update="MemoryPack" Version="1.9.16" /> <PackageReference Update="NetMQ" Version="4.0.1.13" /> diff --git a/PhantomPanel.sln b/PhantomPanel.sln index cba54f9..12c0428 100644 --- a/PhantomPanel.sln +++ b/PhantomPanel.sln @@ -28,7 +28,9 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Phantom.Common.Data.Tests", EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Phantom.Common.Logging", "Common\Phantom.Common.Logging\Phantom.Common.Logging.csproj", "{D7F55010-B3ED-42A5-8D83-E754FFC5F2A2}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Phantom.Common.Messages", "Common\Phantom.Common.Messages\Phantom.Common.Messages.csproj", "{95B55357-F8F0-48C2-A1C2-5EA997651783}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Phantom.Common.Messages.Agent", "Common\Phantom.Common.Messages.Agent\Phantom.Common.Messages.Agent.csproj", "{95B55357-F8F0-48C2-A1C2-5EA997651783}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Phantom.Common.Messages.Web", "Common\Phantom.Common.Messages.Web\Phantom.Common.Messages.Web.csproj", "{6E798DEB-8921-41A2-8AFB-E4416A9E0704}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Phantom.Controller", "Controller\Phantom.Controller\Phantom.Controller.csproj", "{A0F1C595-96B6-4DBF-8C16-6B99223F8F35}" EndProject @@ -96,6 +98,10 @@ Global {95B55357-F8F0-48C2-A1C2-5EA997651783}.Debug|Any CPU.Build.0 = Debug|Any CPU {95B55357-F8F0-48C2-A1C2-5EA997651783}.Release|Any CPU.ActiveCfg = Release|Any CPU {95B55357-F8F0-48C2-A1C2-5EA997651783}.Release|Any CPU.Build.0 = Release|Any CPU + {6E798DEB-8921-41A2-8AFB-E4416A9E0704}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6E798DEB-8921-41A2-8AFB-E4416A9E0704}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6E798DEB-8921-41A2-8AFB-E4416A9E0704}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6E798DEB-8921-41A2-8AFB-E4416A9E0704}.Release|Any CPU.Build.0 = Release|Any CPU {A0F1C595-96B6-4DBF-8C16-6B99223F8F35}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {A0F1C595-96B6-4DBF-8C16-6B99223F8F35}.Debug|Any CPU.Build.0 = Debug|Any CPU {A0F1C595-96B6-4DBF-8C16-6B99223F8F35}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -157,6 +163,7 @@ Global {6C3DB1E5-F695-4D70-8F3A-78C2957274BE} = {01CB1A81-8950-471C-BFDF-F135FDDB2C18} {D7F55010-B3ED-42A5-8D83-E754FFC5F2A2} = {01CB1A81-8950-471C-BFDF-F135FDDB2C18} {95B55357-F8F0-48C2-A1C2-5EA997651783} = {01CB1A81-8950-471C-BFDF-F135FDDB2C18} + {6E798DEB-8921-41A2-8AFB-E4416A9E0704} = {01CB1A81-8950-471C-BFDF-F135FDDB2C18} {435D7981-DFDA-46A0-8CD8-CD8C117935D7} = {D781E00D-8563-4102-A0CD-477A679193B5} {A0F1C595-96B6-4DBF-8C16-6B99223F8F35} = {0AB9471E-6228-4EB7-802E-3102B3952AAD} {E3AD566F-384A-489A-A3BB-EA3BA400C18C} = {0AB9471E-6228-4EB7-802E-3102B3952AAD} diff --git a/README.md b/README.md index e36e252..9cd823e 100644 --- a/README.md +++ b/README.md @@ -4,20 +4,21 @@ Phantom Panel is a **work-in-progress** web interface for managing Minecraft ser # Architecture -Phantom Panel is built on what I'm calling a **Server-Agent architecture**: +Phantom Panel has 3 types of services: -* The **Server** is provides a web interface, persists data in a database, and sends commands to the **Agents**. -* One or more **Agents** receive commands from the **Server**, manage the Minecraft server processes, and report on their status. +* The **Web** provides a web interface for the **Controller**. +* The **Controller** manages all state and persists it in a database, and communicates with **Agents**. +* One or more **Agents** receive commands from the **Controller**, manage the Minecraft server processes, and report on their status. This architecture has several goals and benefits: -1. The Server and Agents can run on separate computers, in separate containers, or a mixture of both. -2. The Server and Agents can be updated independently. - - The Server can receive new features, bug fixes, and security updates without the need to shutdown every Minecraft server. +1. The services can run on separate computers, in separate containers, or a mixture of both. +2. The services can be updated independently. + - The Controller or Web can receive new features, bug fixes, and security updates without the need to shutdown every Minecraft server. - Agent updates can be staggered or delayed. For example, if you have Agents in different geographical locations, you could schedule around timezones and update them at times when people are unlikely to be online. 3. Agents are lightweight processes which should have minimal impact on the performance of Minecraft servers. -When an official Server update is released, it will work with older versions of Agents. There is no guarantee it will also work in reverse (updated Agents and an older Server), but if there is an Agent update that is compatible with older Servers, it will be mentioned in the release notes. +When an official Controller update is released, it will work with older versions of Agents. There is no guarantee it will also work in reverse (updated Agents and an older Controller), but if there is an Agent update that is compatible with an older Controller, it will be mentioned in the release notes. Note that compatibility is only guaranteed when using official releases. If you build the project from a version of the source between two official releases, you have to understand which changes break compatibility. @@ -25,30 +26,36 @@ Note that compatibility is only guaranteed when using official releases. If you This project is **work-in-progress**, and currently has no official releases. Feel free to try it and experiment, but there will be missing features, bugs, and breaking changes. -For a quick start, I recommend using [Docker](https://www.docker.com/) or another containerization platform. The `Dockerfile` in the root of the repository can build two target images: `phantom-server` and `phantom-agent`. +For a quick start, I recommend using [Docker](https://www.docker.com/) or another containerization platform. The `Dockerfile` in the root of the repository can build three target images: `phantom-web`, `phantom-controller`, and `phantom-agent`. -Both images put the built application into the `/app` folder. The Agent image also installs Java 8, 16, 17, and 18. +All images put the built application into the `/app` folder. The Agent image also installs Java 8, 16, 17, and 18. Files are stored relative to the working directory. In the provided images, the working directory is set to `/data`. -## Server +## Controller -The Server comprises 3 key areas: +The Controller comprises 3 key areas: -* **Web server** that provides the web interface. -* **RPC server** that Agents connect to. -* **Database connection** that requires a PostgreSQL database server in order to persist data. +* **Agent RPC server** that Agents connect to. +* **Web RPC server** that Web connects to. +* **PostgreSQL database connection** to persist data. The configuration for these is set via environment variables. -### Agent Key +### Agent & Web Keys -When the Server starts for the first time, it will generate and an **Agent Key**. The Agent Key contains an encryption certificate and an authorization token, which are needed for the Agents to connect to the Server. +When the Controller starts for the first time, it will generate two pairs of key files. Each pair consists of a **common** and a **secret** key file. One pair is generated for **Agents**, and one for the **Web**. -The Agent Key has two forms: +The **common keys** contain encryption certificates and authorization tokens, which are needed to connect to the Controller. Both the Controller and the connecting Agent or Web must have access to the appropriate **common key**. -* A binary file stored in `/data/secrets/agent.key` that the Agents can read. -* A plaintext-encoded version the Server outputs into the logs on every startup, that can be passed to the Agents in an environemnt variable. +The **secret keys** contain information the Controller needs to establish an encrypted communication channel. These files should only be accessible by the Controller itself. + +The **common keys** have two forms: + +* A binary file `/data/secrets/agent.key` or `/data/secrets/web.key` that can be distributed to the other services. +* A plaintext-encoded version printed into the logs on every startup, that can be passed to the other services in an environment variable. + +The **secret keys** are stored as binary files `/data/secrets/agent.secret` and `/data/secrets/web.secret`. ### Storage @@ -56,14 +63,13 @@ Use volumes to persist the whole `/data` folder. ### Environment variables -* **Web Server** - - `WEB_SERVER_HOST` is the host. Default: `0.0.0.0` - - `WEB_SERVER_PORT` is the port. Default: `9400` - - `WEB_BASE_PATH` is the base path of every URL. Must begin with a slash. Default: `/` -* **RPC Server** - - `RPC_SERVER_HOST` is the host. Default: `0.0.0.0` - - `RPC_SERVER_PORT` is the port. Default: `9401` -* **PostgreSQL Database Server** +* **Agent RPC Server** + - `AGENT_RPC_SERVER_HOST` is the host. Default: `0.0.0.0` + - `AGENT_RPC_SERVER_PORT` is the port. Default: `9401` +* **Web RPC Server** + - `WEB_RPC_SERVER_HOST` is the host. Default: `0.0.0.0` + - `WEB_RPC_SERVER_PORT` is the port. Default: `9402` +* **PostgreSQL Database Connection** - `PG_HOST` is the hostname. - `PG_PORT` is the port. - `PG_USER` is the username. @@ -81,14 +87,14 @@ The `/data` folder will contain two folders: Use volumes to persist either the whole `/data` folder, or just `/data/data` if you don't want to persist the volatile files. -### Environment variables: +### Environment variables -* **Server Communication** - - `SERVER_HOST` is the hostname of the Server. - - `SERVER_PORT` is the RPC port of the Server. Default: `9401` +* **Controller Communication** + - `CONTROLLER_HOST` is the hostname of the Controller. + - `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 plaintext-encoded version of [Agent Key](#agent-key). - - `AGENT_KEY_FILE` is a path to the [Agent Key](#agent-key) binary file. + - `AGENT_KEY` is the plaintext-encoded version of [Agent Key](#agent--web-keys). + - `AGENT_KEY_FILE` is a path to the [Agent Key](#agent--web-keys) binary file. * **Agent Configuration** - `MAX_INSTANCES` is the number of instances that can be created. - `MAX_MEMORY` is the maximum amount of RAM that can be distributed among all instances. Use a positive integer with an optional suffix 'M' for MB, or 'G' for GB. Examples: `4096M`, `16G` @@ -98,9 +104,27 @@ Use volumes to persist either the whole `/data` folder, or just `/data/data` if - `ALLOWED_SERVER_PORTS` is a comma-separated list of ports and port ranges that can be used as Minecraft Server ports. Example: `25565,25900,26000-27000` - `ALLOWED_RCON_PORTS` is a comma-separated list of ports and port ranges that can be used as Minecraft RCON ports. Example: `25575,25901,36000-37000` +## Web + +### Storage + +Use volumes to persist the whole `/data` folder. + +### Environment variables + +* **Controller Communication** + - `CONTROLLER_HOST` is the hostname of the Controller. + - `CONTROLLER_PORT` is the Web RPC port of the Controller. Default: `9402` + - `WEB_KEY` is the plaintext-encoded version of [Web Key](#agent--web-keys). + - `WEB_KEY_FILE` is a path to the [Web Key](#agent--web-keys) binary file. +* **Web Server** + - `WEB_SERVER_HOST` is the host. Default: `0.0.0.0` + - `WEB_SERVER_PORT` is the port. Default: `9400` + - `WEB_BASE_PATH` is the base path of every URL. Must begin with a slash. Default: `/` + ## Logging -Both the Server and Agent support a `LOG_LEVEL` environment variable to set the minimum log level. Possible values: +All services support a `LOG_LEVEL` environment variable to set the minimum log level. Possible values: * `VERBOSE` * `DEBUG` @@ -116,16 +140,17 @@ The repository includes a [Rider](https://www.jetbrains.com/rider/) projects wit 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` - - Port: `9402` + - Port: `9403` - User: `postgres` - Password: `development` - Database: `postgres` 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: - - `Server` starts the Server. + - `Controller` starts the Controller. + - `Web` starts the Web server. - `Agent 1`, `Agent 2`, `Agent 3` start one of the Agents. - - `Server + Agent` starts the Server and Agent 1. - - `Server + Agent x3` starts the Server and Agent 1, 2, and 3. + - `Controller + Agent` starts the Controller and Agent 1. + - `Controller + Agent x3` starts the Controller and Agent 1, 2, and 3. ## Bootstrap diff --git a/Utils/Phantom.Utils.Rpc/Message/IMessageDefinitions.cs b/Utils/Phantom.Utils.Rpc/Message/IMessageDefinitions.cs new file mode 100644 index 0000000..e006f4e --- /dev/null +++ b/Utils/Phantom.Utils.Rpc/Message/IMessageDefinitions.cs @@ -0,0 +1,9 @@ +namespace Phantom.Utils.Rpc.Message; + +public interface IMessageDefinitions<TOutgoingListener, TIncomingListener, TReplyMessage> where TReplyMessage : IMessage<TOutgoingListener, NoReply>, IMessage<TIncomingListener, NoReply> { + MessageRegistry<TOutgoingListener> Outgoing { get; } + MessageRegistry<TIncomingListener> Incoming { get; } + + bool IsRegistrationMessage(Type messageType); + TReplyMessage CreateReplyMessage(uint sequenceId, byte[] serializedReply); +} diff --git a/Utils/Phantom.Utils.Rpc/Message/IReply.cs b/Utils/Phantom.Utils.Rpc/Message/IReply.cs new file mode 100644 index 0000000..de29f5e --- /dev/null +++ b/Utils/Phantom.Utils.Rpc/Message/IReply.cs @@ -0,0 +1,6 @@ +namespace Phantom.Utils.Rpc.Message; + +public interface IReply { + uint SequenceId { get; } + byte[] SerializedReply { get; } +} diff --git a/Utils/Phantom.Utils.Rpc/RpcRuntime.cs b/Utils/Phantom.Utils.Rpc/RpcRuntime.cs index 0a86fdf..3f252a5 100644 --- a/Utils/Phantom.Utils.Rpc/RpcRuntime.cs +++ b/Utils/Phantom.Utils.Rpc/RpcRuntime.cs @@ -6,16 +6,6 @@ using Serilog; namespace Phantom.Utils.Rpc; static class RpcRuntime { - private static bool HasRuntime { get; set; } - - internal static void MarkRuntimeCreated() { - if (HasRuntime) { - throw new InvalidOperationException("Only one instance of RpcRuntime can be created."); - } - - HasRuntime = true; - } - internal static void SetDefaultSocketOptions(ThreadSafeSocketOptions options) { // TODO test behavior when either agent or server are offline for a very long time options.DelayAttachOnConnect = true; @@ -24,14 +14,13 @@ static class RpcRuntime { } } -public abstract class RpcRuntime<TSocket> where TSocket : ThreadSafeSocket, new() { +public abstract class RpcRuntime<TSocket> where TSocket : ThreadSafeSocket { private readonly TSocket socket; private readonly ILogger runtimeLogger; private readonly MessageReplyTracker replyTracker; private readonly TaskManager taskManager; protected RpcRuntime(RpcConfiguration configuration, TSocket socket) { - RpcRuntime.MarkRuntimeCreated(); RpcRuntime.SetDefaultSocketOptions(socket.Options); this.socket = socket; this.runtimeLogger = configuration.RuntimeLogger; diff --git a/Web/Phantom.Web.Components/Utils/Throttler.cs b/Utils/Phantom.Utils/Tasks/Throttler.cs similarity index 93% rename from Web/Phantom.Web.Components/Utils/Throttler.cs rename to Utils/Phantom.Utils/Tasks/Throttler.cs index aa5c2a0..b0cf288 100644 --- a/Web/Phantom.Web.Components/Utils/Throttler.cs +++ b/Utils/Phantom.Utils/Tasks/Throttler.cs @@ -1,4 +1,4 @@ -namespace Phantom.Web.Components.Utils; +namespace Phantom.Utils.Tasks; public sealed class Throttler { private readonly TimeSpan interval; diff --git a/Web/Phantom.Web.Identity/Authorization/PermissionManager.cs b/Web/Phantom.Web.Identity/Authorization/PermissionManager.cs deleted file mode 100644 index cfb7147..0000000 --- a/Web/Phantom.Web.Identity/Authorization/PermissionManager.cs +++ /dev/null @@ -1,40 +0,0 @@ -using System.Security.Claims; -using Phantom.Controller.Database; -using Phantom.Controller.Services.Users; -using Phantom.Web.Identity.Data; - -namespace Phantom.Web.Identity.Authorization; - -public sealed class PermissionManager { - private readonly DatabaseProvider databaseProvider; - private readonly Dictionary<Guid, IdentityPermissions> userIdsToPermissionIds = new (); - - public PermissionManager(DatabaseProvider databaseProvider) { - this.databaseProvider = databaseProvider; - } - - private IdentityPermissions FetchPermissionsForUserId(Guid userId) { - using var scope = databaseProvider.CreateScope(); - var userPermissions = scope.Ctx.UserPermissions.Where(up => up.UserGuid == userId).Select(static up => up.PermissionId); - var rolePermissions = scope.Ctx.UserRoles.Where(ur => ur.UserGuid == userId).Join(scope.Ctx.RolePermissions, static ur => ur.RoleGuid, static rp => rp.RoleGuid, static (ur, rp) => rp.PermissionId); - return new IdentityPermissions(userPermissions.Union(rolePermissions)); - } - - private IdentityPermissions GetPermissionsForUserId(Guid userId, bool refreshCache) { - if (!refreshCache && userIdsToPermissionIds.TryGetValue(userId, out var userPermissions)) { - return userPermissions; - } - else { - return userIdsToPermissionIds[userId] = FetchPermissionsForUserId(userId); - } - } - - public IdentityPermissions GetPermissions(ClaimsPrincipal user, bool refreshCache = false) { - Guid? userId = UserManager.GetAuthenticatedUserId(user); - return userId == null ? IdentityPermissions.None : GetPermissionsForUserId(userId.Value, refreshCache); - } - - public bool CheckPermission(ClaimsPrincipal user, Permission permission, bool refreshCache = false) { - return GetPermissions(user, refreshCache).Check(permission); - } -} diff --git a/Web/Phantom.Web.Identity/Phantom.Web.Identity.csproj b/Web/Phantom.Web.Identity/Phantom.Web.Identity.csproj index c99a64e..32fc9d8 100644 --- a/Web/Phantom.Web.Identity/Phantom.Web.Identity.csproj +++ b/Web/Phantom.Web.Identity/Phantom.Web.Identity.csproj @@ -20,8 +20,6 @@ <ItemGroup> <ProjectReference Include="..\..\Common\Phantom.Common.Logging\Phantom.Common.Logging.csproj" /> - <ProjectReference Include="..\..\Controller\Phantom.Controller.Database\Phantom.Controller.Database.csproj" /> - <ProjectReference Include="..\..\Controller\Phantom.Controller.Services\Phantom.Controller.Services.csproj" /> <ProjectReference Include="..\..\Utils\Phantom.Utils\Phantom.Utils.csproj" /> </ItemGroup> diff --git a/Web/Phantom.Web.Identity/PhantomIdentityConfigurator.cs b/Web/Phantom.Web.Identity/PhantomIdentityConfigurator.cs deleted file mode 100644 index be8dae5..0000000 --- a/Web/Phantom.Web.Identity/PhantomIdentityConfigurator.cs +++ /dev/null @@ -1,112 +0,0 @@ -using System.Collections.Immutable; -using Microsoft.EntityFrameworkCore; -using Phantom.Common.Logging; -using Phantom.Controller.Database; -using Phantom.Controller.Database.Entities; -using Phantom.Controller.Services.Users; -using Phantom.Utils.Collections; -using Phantom.Utils.Runtime; -using Phantom.Utils.Tasks; -using Phantom.Web.Identity.Data; -using ILogger = Serilog.ILogger; - -namespace Phantom.Web.Identity; - -public sealed class PhantomIdentityConfigurator { - private static readonly ILogger Logger = PhantomLogger.Create<PhantomIdentityConfigurator>(); - - public static async Task MigrateDatabase(IServiceProvider serviceProvider) { - await using var scope = serviceProvider.CreateAsyncScope(); - await scope.ServiceProvider.GetRequiredService<PhantomIdentityConfigurator>().Initialize(); - } - - private readonly ApplicationDbContext db; - private readonly RoleManager roleManager; - - public PhantomIdentityConfigurator(ApplicationDbContext db, RoleManager roleManager) { - this.db = db; - this.roleManager = roleManager; - } - - private async Task Initialize() { - await CreatePermissions(); - await CreateDefaultRoles(); - await AssignDefaultRolePermissions(); - await db.SaveChangesAsync(); - } - - private async Task CreatePermissions() { - var existingPermissionIds = await db.Permissions.Select(static p => p.Id).AsAsyncEnumerable().ToImmutableSetAsync(); - var missingPermissionIds = GetMissingPermissionsOrdered(Permission.All, existingPermissionIds); - - if (!missingPermissionIds.IsEmpty) { - Logger.Information("Adding permissions: {Permissions}", string.Join(", ", missingPermissionIds)); - foreach (var permissionId in missingPermissionIds) { - db.Permissions.Add(new PermissionEntity(permissionId)); - } - } - } - - private async Task CreateDefaultRoles() { - Logger.Information("Creating default roles."); - - var allRoleNames = await roleManager.GetAllNames(); - - foreach (var (guid, name, _) in Role.All) { - if (allRoleNames.Contains(name)) { - continue; - } - - var result = await roleManager.Create(guid, name); - if (result is Result<RoleEntity, AddRoleError>.Fail fail) { - switch (fail.Error) { - case AddRoleError.NameIsEmpty: - Logger.Fatal("Error creating default role \"{Name}\", name is empty!", name); - throw StopProcedureException.Instance; - - case AddRoleError.NameIsTooLong: - Logger.Fatal("Error creating default role \"{Name}\", name is too long!", name); - throw StopProcedureException.Instance; - - case AddRoleError.NameAlreadyExists: - Logger.Warning("Error creating default role \"{Name}\", a role with this name already exists!", name); - throw StopProcedureException.Instance; - - default: - Logger.Fatal("Error creating default role \"{Name}\", unknown error!", name); - throw StopProcedureException.Instance; - } - } - } - } - - private async Task AssignDefaultRolePermissions() { - Logger.Information("Assigning default role permissions."); - - foreach (var role in Role.All) { - var roleEntity = await roleManager.GetByGuid(role.Guid); - if (roleEntity == null) { - Logger.Fatal("Error assigning default role permissions, role \"{Name}\" with GUID {Guid} not found.", role.Name, role.Guid); - throw StopProcedureException.Instance; - } - - var existingPermissionIds = await db.RolePermissions - .Where(rp => rp.RoleGuid == roleEntity.RoleGuid) - .Select(static rp => rp.PermissionId) - .AsAsyncEnumerable() - .ToImmutableSetAsync(); - - var missingPermissionIds = GetMissingPermissionsOrdered(role.Permissions, existingPermissionIds); - if (!missingPermissionIds.IsEmpty) { - Logger.Information("Assigning default permission to role \"{Name}\": {Permissions}", role.Name, string.Join(", ", missingPermissionIds)); - foreach (var permissionId in missingPermissionIds) { - db.RolePermissions.Add(new RolePermissionEntity(roleEntity.RoleGuid, permissionId)); - } - } - } - } - - private static ImmutableArray<string> GetMissingPermissionsOrdered(IEnumerable<Permission> allPermissions, ImmutableHashSet<string> existingPermissionIds) { - return allPermissions.Select(static permission => permission.Id).Except(existingPermissionIds).Order().ToImmutableArray(); - } -} diff --git a/Web/Phantom.Web.Identity/PhantomIdentityExtensions.cs b/Web/Phantom.Web.Identity/PhantomIdentityExtensions.cs index bde7f01..8159bd9 100644 --- a/Web/Phantom.Web.Identity/PhantomIdentityExtensions.cs +++ b/Web/Phantom.Web.Identity/PhantomIdentityExtensions.cs @@ -2,10 +2,8 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Components.Authorization; using Microsoft.AspNetCore.Components.Server; -using Phantom.Controller.Services.Users; using Phantom.Web.Identity.Authentication; using Phantom.Web.Identity.Authorization; -using Phantom.Web.Identity.Data; namespace Phantom.Web.Identity; @@ -17,14 +15,8 @@ public static class PhantomIdentityExtensions { services.AddSingleton(PhantomLoginStore.Create(cancellationToken)); services.AddScoped<PhantomLoginManager>(); - services.AddScoped<PhantomIdentityConfigurator>(); services.AddScoped<IAuthorizationHandler, PermissionBasedPolicyHandler>(); services.AddScoped<AuthenticationStateProvider, ServerAuthenticationStateProvider>(); - - services.AddScoped<UserManager>(); - services.AddScoped<RoleManager>(); - services.AddScoped<UserRoleManager>(); - services.AddTransient<PermissionManager>(); } public static void UsePhantomIdentity(this IApplicationBuilder application) { diff --git a/Web/Phantom.Web/Launcher.cs b/Web/Phantom.Web/Launcher.cs index 06de163..58beb4c 100644 --- a/Web/Phantom.Web/Launcher.cs +++ b/Web/Phantom.Web/Launcher.cs @@ -1,8 +1,7 @@ using Microsoft.AspNetCore.DataProtection; -using Microsoft.EntityFrameworkCore; -using Phantom.Controller.Database; +using Phantom.Controller.Services; +using Phantom.Utils.Tasks; using Phantom.Web.Base; -using Phantom.Web.Components.Utils; using Phantom.Web.Identity; using Phantom.Web.Identity.Interfaces; using Serilog; @@ -10,7 +9,7 @@ using Serilog; namespace Phantom.Web; public static class Launcher { - public static async Task<WebApplication> CreateApplication(Configuration config, IConfigurator configurator, Action<DbContextOptionsBuilder> dbOptionsBuilder) { + public static WebApplication CreateApplication(Configuration config, ServiceConfiguration serviceConfiguration, TaskManager taskManager) { var assembly = typeof(Launcher).Assembly; var builder = WebApplication.CreateBuilder(new WebApplicationOptions { ApplicationName = assembly.GetName().Name, @@ -26,32 +25,24 @@ public static class Launcher { builder.WebHost.UseStaticWebAssets(); } - configurator.ConfigureServices(builder.Services); + builder.Services.AddSingleton(serviceConfiguration); + builder.Services.AddSingleton(taskManager); builder.Services.AddSingleton<IHostLifetime>(new NullLifetime()); builder.Services.AddScoped<INavigation>(Navigation.Create(config.BasePath)); builder.Services.AddDataProtection().PersistKeysToFileSystem(new DirectoryInfo(config.KeyFolderPath)); - builder.Services.AddDbContext<ApplicationDbContext>(dbOptionsBuilder, ServiceLifetime.Transient); - builder.Services.AddSingleton<DatabaseProvider>(); - builder.Services.AddPhantomIdentity(config.CancellationToken); builder.Services.AddScoped<ILoginEvents, LoginEvents>(); builder.Services.AddRazorPages(static options => options.RootDirectory = "/Layout"); builder.Services.AddServerSideBlazor(); - var application = builder.Build(); - - await MigrateDatabase(config, application.Services.GetRequiredService<DatabaseProvider>()); - await PhantomIdentityConfigurator.MigrateDatabase(application.Services); - await configurator.LoadFromDatabase(application.Services); - - return application; + return builder.Build(); } - public static async Task Launch(Configuration config, WebApplication application) { + public static Task Launch(Configuration config, WebApplication application) { var logger = config.Logger; application.UseSerilogRequestLogging(); @@ -70,7 +61,7 @@ public static class Launcher { application.MapFallbackToPage("/_Host"); logger.Information("Starting Web server on port {Port}...", config.Port); - await application.RunAsync(config.CancellationToken); + return application.RunAsync(config.CancellationToken); } private sealed class NullLifetime : IHostLifetime { @@ -82,27 +73,4 @@ public static class Launcher { return Task.CompletedTask; } } - - private static async Task MigrateDatabase(Configuration config, DatabaseProvider databaseProvider) { - var logger = config.Logger; - - using var scope = databaseProvider.CreateScope(); - var database = scope.Ctx.Database; - - logger.Information("Connecting to database..."); - - var retryConnection = new Throttler(TimeSpan.FromSeconds(10)); - while (!await database.CanConnectAsync(config.CancellationToken)) { - logger.Warning("Cannot connect to database, retrying..."); - await retryConnection.Wait(); - } - - logger.Information("Running database migrations..."); - await database.MigrateAsync(); // Do not allow cancellation. - } - - public interface IConfigurator { - void ConfigureServices(IServiceCollection services); - Task LoadFromDatabase(IServiceProvider serviceProvider); - } } diff --git a/Web/Phantom.Web/Phantom.Web.csproj b/Web/Phantom.Web/Phantom.Web.csproj index ad2a4b2..f37ccd2 100644 --- a/Web/Phantom.Web/Phantom.Web.csproj +++ b/Web/Phantom.Web/Phantom.Web.csproj @@ -6,7 +6,8 @@ </PropertyGroup> <PropertyGroup> - <OutputType>Library</OutputType> + <OutputType>Exe</OutputType> + <SatelliteResourceLanguages>en</SatelliteResourceLanguages> </PropertyGroup> <PropertyGroup> @@ -19,8 +20,6 @@ </ItemGroup> <ItemGroup> - <ProjectReference Include="..\..\Controller\Phantom.Controller.Database\Phantom.Controller.Database.csproj" /> - <ProjectReference Include="..\..\Controller\Phantom.Controller.Services\Phantom.Controller.Services.csproj" /> <ProjectReference Include="..\Phantom.Web.Components\Phantom.Web.Components.csproj" /> <ProjectReference Include="..\Phantom.Web.Identity\Phantom.Web.Identity.csproj" /> </ItemGroup>