1
0
mirror of https://github.com/chylex/Minecraft-Phantom-Panel.git synced 2025-09-15 15:32:10 +02:00

76 Commits

Author SHA1 Message Date
d2e7f4f876 Add TaskManager shutdown logging of remaining tasks 2023-01-25 12:08:53 +01:00
c4cf45776d Refactor PhantomLogger overloads 2023-01-25 05:04:28 +01:00
3c10e1a8f9 Fix race conditions when transitioning instance states during Agent shutdown 2023-01-25 04:10:14 +01:00
f4aec6f11d Refactor sequence IDs in message replies 2023-01-24 01:09:25 +01:00
c8a2a539e8 Move Agent keep-alive loop into an unmanaged task to ensure unreachable Server does not prevent Agent shutdown 2022-12-31 23:53:13 +01:00
b1758fb2bb Minor code and dependency cleanup 2022-12-31 21:45:17 +01:00
2cc7975193 Rework message replies 2022-12-30 03:26:06 +01:00
6472134f9a Update all NuGet packages 2022-12-29 18:20:24 +01:00
c0243dc749 Add README 2022-10-28 14:48:17 +02:00
d57546bb71 Refactor and tweak design of Users table and edit dialogs on web 2022-10-28 04:41:36 +02:00
ab20e46af2 Add LICENSE 2022-10-28 04:41:17 +02:00
d4f244a3db Add an "Instance Manager" role 2022-10-28 03:44:41 +02:00
e2ed060044 Add user role management to web 2022-10-27 06:24:54 +02:00
e62bd430b9 Add finer permissions for instances on web 2022-10-26 05:31:18 +02:00
c618a8d045 Fix web not checking permissions in events 2022-10-26 05:23:57 +02:00
8a87d7aff6 Extract instance command input form into a separate component on web 2022-10-26 04:36:43 +02:00
8ac8971f7f Minor web design fixes 2022-10-25 05:24:51 +02:00
c582aefb05 Minor web form refactoring and fixes 2022-10-25 05:15:41 +02:00
fd0097214b Refactor web form validation and yielding after submitting 2022-10-25 04:55:02 +02:00
1c5940dd23 Add basic user management to web 2022-10-25 04:42:26 +02:00
55b643c513 Improve exception handling when configuring and starting the Minecraft server 2022-10-24 14:15:26 +02:00
36dbc6f984 Fix Agents mangling server.properties file 2022-10-24 14:03:54 +02:00
205b1f0697 Optimize web identity middleware to only run on login and logout pages 2022-10-22 21:06:26 +02:00
1c2c32c2e6 Add permissions for existing web pages 2022-10-22 20:42:23 +02:00
0e6d506cb4 Add user and role permissions on web 2022-10-22 20:02:46 +02:00
8d3e4442d7 Move StopProcedureException into Phantom.Utils.Runtime project 2022-10-21 13:59:57 +02:00
59cf71e3e1 Add option to create instances with Minecraft snapshots 2022-10-20 19:42:04 +02:00
98ec0e001c Add FormValidationMessage web component 2022-10-20 07:11:01 +02:00
4728820b0f Remove satellite resource assemblies from builds 2022-10-20 07:10:25 +02:00
663aa8fded Add Dockerfile for building Agent and Server 2022-10-20 04:36:03 +02:00
bcb53528b9 Refactor RPC to use a single long running task 2022-10-19 15:24:40 +02:00
69f3fbcbf4 Fix or suppress several ReSharper warnings 2022-10-19 13:11:40 +02:00
f5e01716ed Fix Agent not checking allowed ports during instance launch 2022-10-19 13:07:06 +02:00
751d914d12 Update to C# 11 and use generic attributes in form validation 2022-10-19 13:06:43 +02:00
bcfc2c8240 Rewrite Agent shutdown procedure to delay RPC disconnection until main services are stopped 2022-10-19 06:36:31 +02:00
e699513036 Add JVM argument to disable signal handling to prevent instant termination of Minecraft processes 2022-10-19 05:30:16 +02:00
5f4e7f0280 Fix silently discarding exceptions in form submit events on web 2022-10-19 03:07:53 +02:00
4725ce27dd Fix missing mapping between audit event types and subject types 2022-10-19 03:06:21 +02:00
dbd57a1ee0 Fix Server handling messages from non-registered Agents 2022-10-19 02:06:06 +02:00
3b19cbd985 Add Agent build version to Agents table 2022-10-19 01:47:55 +02:00
24e08f1943 Migrate from MessagePack to MemoryPack for RPC serialization 2022-10-19 00:26:17 +02:00
ff5d31bf05 Make it possible to use condensed agent key via environment variables 2022-10-18 03:04:00 +02:00
de22e5695f Condense Agent certificate and token into a single file 2022-10-18 02:44:56 +02:00
dbba829e21 Persist ASP.NET keys in the working directory 2022-10-17 00:48:55 +02:00
4fc5214418 Add audit log 2022-10-17 00:25:30 +02:00
524b0a4cd9 Tweak website design and fix several design issues 2022-10-17 00:25:29 +02:00
0018b1f9b4 Reduce duration of progress bar animation 2022-10-16 17:25:54 +02:00
3d2b0d5373 Move TaskManager to Phantom.Utils.Runtime project 2022-10-16 16:54:34 +02:00
02e121d6ed Move web identity services to a separate project 2022-10-15 18:30:08 +02:00
f10a754efb Refactor RwLockedDictionary and introduce RwLockedObservableDictionary 2022-10-15 16:27:25 +02:00
e51844d798 Add version and git hash to assemblies & website menu 2022-10-14 22:09:24 +02:00
1c96afaa3c Mark Agents as disconnected if the Server does not receive keep-alive messages for too long 2022-10-14 21:03:35 +02:00
cde29e990d Make Agent ignore Java executables that are symlinks 2022-10-14 19:58:24 +02:00
7a495888aa Fix Server not automatically launching instances when Agent restarts unless instances are reloaded from database 2022-10-14 17:41:44 +02:00
c4b1d3c920 Fix graceful shutdown issues 2022-10-14 17:20:41 +02:00
2b661fd170 Use chmod 750 for instance and server folders created by Agent 2022-10-14 14:04:19 +02:00
ae4f4af2eb Add environment variable for web server's base path 2022-10-13 15:34:33 +02:00
bfb60219ea Change environment variables to throw on error instead of returning default value 2022-10-13 15:26:23 +02:00
3497f73d59 Fix Blazor trying to find 'wwwroot' in working directory 2022-10-13 14:49:52 +02:00
e41be61945 Add header to disable caching due to websocket disconnections 2022-10-13 13:16:29 +02:00
8c9925921c Migrate solution to centralized NuGet package versions 2022-10-12 19:19:19 +02:00
315f6b181c Automatically restart instances if they stopped unexpectedly 2022-10-12 19:19:19 +02:00
46446ea5d5 Add customizable JVM arguments to instances 2022-10-11 21:30:09 +02:00
0b51a4509e Change instance log sender from dedicated thread to async task 2022-10-11 21:26:44 +02:00
f880a46887 Redact IP addresses in instance logs 2022-10-11 16:34:22 +02:00
3b34ae1eca Tweak Bootstrap breakpoints 2022-10-10 20:16:42 +02:00
d2b085ec15 Add Minecraft versions to instance creation form 2022-10-10 19:41:57 +02:00
e1cfb36bd1 Add administrator user role 2022-10-09 12:45:31 +02:00
e229e3dccf Fix debounced form inputs having old values if form is submitted too soon 2022-10-09 10:02:23 +02:00
adf0dd6853 Add administrator account creation and user login 2022-10-09 08:33:36 +02:00
adea2021ba Fix instances being added to database despite agent reporting an error 2022-10-08 12:37:28 +02:00
9e47351799 Add modal dialog for stopping instances with customizable stop delay 2022-10-08 12:36:34 +02:00
6ded2575cb Refactor Blazor form context 2022-10-08 12:23:15 +02:00
7b39ff2b2e Refactor instance state initialization and result reporting 2022-10-08 12:17:19 +02:00
32ec2cc9db Update Bootstrap JS to 5.2.2 & fix script tag 2022-10-08 09:13:14 +02:00
640731634b Add custom task manager for tracking running tasks 2022-10-07 20:16:45 +02:00
264 changed files with 8867 additions and 2342 deletions

9
.dockerignore Normal file
View File

@@ -0,0 +1,9 @@
# Ignore hidden files
.*
# Include .git for build version information
!.git
# Not needed for building
AddMigration.*
*.DotSettings.user

View File

@@ -5,13 +5,13 @@
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/.workdir/Agent1" />
<option name="PASS_PARENT_ENVS" value="1" />
<envs>
<env name="AGENT_KEY" value="JXBQQYG5T267RQS75MXWBTCJZY5CKTCCGQY22MCZPHSQQSJYCHH2NG2TCNXQY6TBSXM9NQDRS2CMX" />
<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="JAVA_SEARCH_PATH" value="~/.jdks" />
<env name="MAX_INSTANCES" value="3" />
<env name="MAX_MEMORY" value="12G" />
<env name="SERVER_AUTH_TOKEN_FILE" value="./secrets/agent.token" />
<env name="SERVER_HOST" value="localhost" />
</envs>
<option name="USE_EXTERNAL_CONSOLE" value="0" />

View File

@@ -5,13 +5,13 @@
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/.workdir/Agent2" />
<option name="PASS_PARENT_ENVS" value="1" />
<envs>
<env name="AGENT_KEY" value="JXBQQYG5T267RQS75MXWBTCJZY5CKTCCGQY22MCZPHSQQSJYCHH2NG2TCNXQY6TBSXM9NQDRS2CMX" />
<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="JAVA_SEARCH_PATH" value="~/.jdks" />
<env name="MAX_INSTANCES" value="5" />
<env name="MAX_MEMORY" value="10G" />
<env name="SERVER_AUTH_TOKEN_FILE" value="./secrets/agent.token" />
<env name="SERVER_HOST" value="localhost" />
</envs>
<option name="USE_EXTERNAL_CONSOLE" value="0" />

View File

@@ -5,13 +5,13 @@
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/.workdir/Agent3" />
<option name="PASS_PARENT_ENVS" value="1" />
<envs>
<env name="AGENT_KEY" value="JXBQQYG5T267RQS75MXWBTCJZY5CKTCCGQY22MCZPHSQQSJYCHH2NG2TCNXQY6TBSXM9NQDRS2CMX" />
<env name="AGENT_NAME" value="Agent 3" />
<env name="ALLOWED_RCON_PORTS" value="27007" />
<env name="ALLOWED_SERVER_PORTS" value="26007" />
<env name="JAVA_SEARCH_PATH" value="~/.jdks" />
<env name="MAX_INSTANCES" value="1" />
<env name="MAX_MEMORY" value="2560M" />
<env name="SERVER_AUTH_TOKEN_FILE" value="./secrets/agent.token" />
<env name="SERVER_HOST" value="localhost" />
</envs>
<option name="USE_EXTERNAL_CONSOLE" value="0" />

2
.workdir/.gitignore vendored
View File

@@ -1,2 +0,0 @@
/Agent*/data
/Agent*/temp

2
.workdir/Agent1/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
*
!.gitignore

View File

@@ -1 +0,0 @@
tLkn<EFBFBD>Z<EFBFBD><EFBFBD><><D18B>|~2<><32>><3E><>р<EFBFBD><D180>O*<2A> p

View File

@@ -1 +0,0 @@
TH7Z2MJKMR975N6HFBKQP9WQFMWWN5

2
.workdir/Agent2/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
*
!.gitignore

View File

@@ -1 +0,0 @@
tLkn<EFBFBD>Z<EFBFBD><EFBFBD><><D18B>|~2<><32>><3E><>р<EFBFBD><D180>O*<2A> p

View File

@@ -1 +0,0 @@
TH7Z2MJKMR975N6HFBKQP9WQFMWWN5

2
.workdir/Agent3/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
*
!.gitignore

View File

@@ -1 +0,0 @@
tLkn<EFBFBD>Z<EFBFBD><EFBFBD><><D18B>|~2<><32>><3E><>р<EFBFBD><D180>O*<2A> p

View File

@@ -1 +0,0 @@
TH7Z2MJKMR975N6HFBKQP9WQFMWWN5

1
.workdir/Server/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/keys

Binary file not shown.

View File

@@ -1 +0,0 @@
TH7Z2MJKMR975N6HFBKQP9WQFMWWN5

View File

@@ -1 +1 @@
+<2B><><EFBFBD><EFBFBD><<3C>f:<3A>bJ"e<18>׸ބ<D7B8><1F><><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD>
<EFBFBD>Z<EFBFBD>t<>MPI<49>GMZ<4D><5A><EFBFBD><EFBFBD>kN<6B>VF1X<EFBFBD><EFBFBD>p

View File

@@ -0,0 +1,9 @@
namespace Phantom.Agent.Minecraft.Command;
public static class MinecraftCommand {
public const string Stop = "stop";
public static string Say(string message) {
return "say " + message;
}
}

View File

@@ -1,4 +1,5 @@
using Phantom.Agent.Minecraft.Java;
using System.Collections.Immutable;
using Phantom.Agent.Minecraft.Java;
using Phantom.Agent.Minecraft.Properties;
namespace Phantom.Agent.Minecraft.Instance;
@@ -6,6 +7,7 @@ namespace Phantom.Agent.Minecraft.Instance;
public sealed record InstanceProperties(
Guid JavaRuntimeGuid,
JvmProperties JvmProperties,
ImmutableArray<string> JvmArguments,
string InstanceFolder,
string ServerVersion,
ServerProperties ServerProperties

View File

@@ -7,7 +7,7 @@ using Serilog;
namespace Phantom.Agent.Minecraft.Java;
public sealed class JavaRuntimeDiscovery {
private static readonly ILogger Logger = PhantomLogger.Create(typeof(JavaRuntimeDiscovery));
private static readonly ILogger Logger = PhantomLogger.Create(nameof(JavaRuntimeDiscovery));
public static string? GetSystemSearchPath() {
const string LinuxJavaPath = "/usr/lib/jvm";
@@ -38,28 +38,38 @@ public sealed class JavaRuntimeDiscovery {
AttributesToSkip = FileAttributes.Hidden | FileAttributes.ReparsePoint | FileAttributes.System
}).Order()) {
var javaExecutablePath = Paths.NormalizeSlashes(Path.Combine(binFolderPath, javaExecutableName));
if (File.Exists(javaExecutablePath)) {
Logger.Information("Found candidate Java executable: {JavaExecutablePath}", javaExecutablePath);
JavaRuntime? foundRuntime;
try {
foundRuntime = await TryReadJavaRuntimeInformationFromProcess(javaExecutablePath);
} catch (OperationCanceledException) {
Logger.Error("Java process did not exit in time.");
continue;
} catch (Exception e) {
Logger.Error(e, "Caught exception while reading Java version information.");
continue;
}
if (foundRuntime == null) {
Logger.Error("Java executable did not output version information.");
continue;
}
Logger.Information("Found Java {DisplayName} at: {Path}", foundRuntime.DisplayName, javaExecutablePath);
yield return new JavaRuntimeExecutable(javaExecutablePath, foundRuntime);
FileAttributes javaExecutableAttributes;
try {
javaExecutableAttributes = File.GetAttributes(javaExecutablePath);
} catch (Exception) {
continue;
}
if (javaExecutableAttributes.HasFlag(FileAttributes.ReparsePoint)) {
continue;
}
Logger.Information("Found candidate Java executable: {JavaExecutablePath}", javaExecutablePath);
JavaRuntime? foundRuntime;
try {
foundRuntime = await TryReadJavaRuntimeInformationFromProcess(javaExecutablePath);
} catch (OperationCanceledException) {
Logger.Error("Java process did not exit in time.");
continue;
} catch (Exception e) {
Logger.Error(e, "Caught exception while reading Java version information.");
continue;
}
if (foundRuntime == null) {
Logger.Error("Java executable did not output version information.");
continue;
}
Logger.Information("Found Java {DisplayName} at: {Path}", foundRuntime.DisplayName, javaExecutablePath);
yield return new JavaRuntimeExecutable(javaExecutablePath, foundRuntime);
}
}

View File

@@ -1,25 +1,31 @@
using System.Collections.ObjectModel;
using System.Collections.Immutable;
using System.Collections.ObjectModel;
namespace Phantom.Agent.Minecraft.Java;
sealed class JvmArgumentBuilder {
private readonly JvmProperties basicProperties;
private readonly List<string> customProperties = new ();
private readonly List<string> customArguments = new ();
public JvmArgumentBuilder(JvmProperties basicProperties) {
public JvmArgumentBuilder(JvmProperties basicProperties, ImmutableArray<string> customArguments) {
this.basicProperties = basicProperties;
foreach (var jvmArgument in customArguments) {
this.customArguments.Add(jvmArgument);
}
}
public void AddProperty(string key, string value) {
customProperties.Add("-D" + key + "=\"" + value + "\""); // TODO test quoting?
customArguments.Add("-D" + key + "=\"" + value + "\""); // TODO test quoting?
}
public void Build(Collection<string> target) {
target.Add("-Xms" + basicProperties.InitialHeapMegabytes + "M");
target.Add("-Xmx" + basicProperties.MaximumHeapMegabytes + "M");
foreach (var property in customProperties) {
foreach (var property in customArguments) {
target.Add(property);
}
target.Add("-Xms" + basicProperties.InitialHeapMegabytes + "M");
target.Add("-Xmx" + basicProperties.MaximumHeapMegabytes + "M");
target.Add("-Xrs");
}
}

View File

@@ -4,6 +4,8 @@ using Kajabity.Tools.Java;
using Phantom.Agent.Minecraft.Instance;
using Phantom.Agent.Minecraft.Java;
using Phantom.Agent.Minecraft.Server;
using Phantom.Common.Minecraft;
using Serilog;
namespace Phantom.Agent.Minecraft.Launcher;
@@ -14,11 +16,15 @@ public abstract class BaseLauncher {
this.instanceProperties = instanceProperties;
}
public async Task<LaunchResult> Launch(LaunchServices services, EventHandler<DownloadProgressEventArgs> downloadProgressEventHandler, CancellationToken cancellationToken) {
public async Task<LaunchResult> Launch(ILogger logger, LaunchServices services, EventHandler<DownloadProgressEventArgs> downloadProgressEventHandler, CancellationToken cancellationToken) {
if (!services.JavaRuntimeRepository.TryGetByGuid(instanceProperties.JavaRuntimeGuid, out var javaRuntimeExecutable)) {
return new LaunchResult.InvalidJavaRuntime();
}
if (JvmArgumentsHelper.Validate(instanceProperties.JvmArguments) != null) {
return new LaunchResult.InvalidJvmArguments();
}
var vanillaServerJarPath = await services.ServerExecutables.DownloadAndGetPath(instanceProperties.ServerVersion, downloadProgressEventHandler, cancellationToken);
if (vanillaServerJarPath == null) {
return new LaunchResult.CouldNotDownloadMinecraftServer();
@@ -34,7 +40,7 @@ public abstract class BaseLauncher {
CreateNoWindow = false
};
var jvmArguments = new JvmArgumentBuilder(instanceProperties.JvmProperties);
var jvmArguments = new JvmArgumentBuilder(instanceProperties.JvmProperties, instanceProperties.JvmArguments);
CustomizeJvmArguments(jvmArguments);
var serverJarPath = await PrepareServerJar(vanillaServerJarPath, instanceProperties.InstanceFolder, cancellationToken);
@@ -47,12 +53,29 @@ public abstract class BaseLauncher {
var process = new Process { StartInfo = startInfo };
var session = new InstanceSession(process);
await AcceptEula(instanceProperties);
await UpdateServerProperties(instanceProperties);
try {
await AcceptEula(instanceProperties);
await UpdateServerProperties(instanceProperties);
} catch (Exception e) {
logger.Error(e, "Caught exception while configuring the server.");
return new LaunchResult.CouldNotConfigureMinecraftServer();
}
process.Start();
process.BeginOutputReadLine();
process.BeginErrorReadLine();
try {
process.Start();
process.BeginOutputReadLine();
process.BeginErrorReadLine();
} catch (Exception launchException) {
logger.Error(launchException, "Caught exception launching the server process.");
try {
process.Kill();
} catch (Exception killException) {
logger.Error(killException, "Caught exception trying to kill the server process after a failed launch.");
}
return new LaunchResult.CouldNotStartMinecraftServer();
}
return new LaunchResult.Success(session);
}
@@ -72,16 +95,18 @@ public abstract class BaseLauncher {
var serverPropertiesFilePath = Path.Combine(instanceProperties.InstanceFolder, "server.properties");
var serverPropertiesData = new JavaProperties();
await using var fileStream = new FileStream(serverPropertiesFilePath, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None);
try {
await using var readStream = new FileStream(serverPropertiesFilePath, FileMode.Open, FileAccess.Read, FileShare.Read);
serverPropertiesData.Load(readStream);
} catch (FileNotFoundException) {
// ignore
serverPropertiesData.Load(fileStream);
} catch (ParseException e) {
throw new Exception("Could not parse server.properties file: " + serverPropertiesFilePath, e);
}
instanceProperties.ServerProperties.SetTo(serverPropertiesData);
await using var writeStream = new FileStream(serverPropertiesFilePath, FileMode.OpenOrCreate, FileAccess.Write, FileShare.None);
serverPropertiesData.Store(writeStream, true);
fileStream.Seek(0L, SeekOrigin.Begin);
fileStream.SetLength(0L);
serverPropertiesData.Store(fileStream, true);
}
}

View File

@@ -9,5 +9,11 @@ public abstract record LaunchResult {
public sealed record InvalidJavaRuntime : LaunchResult;
public sealed record InvalidJvmArguments : LaunchResult;
public sealed record CouldNotDownloadMinecraftServer : LaunchResult;
public sealed record CouldNotConfigureMinecraftServer : LaunchResult;
public sealed record CouldNotStartMinecraftServer : LaunchResult;
}

View File

@@ -1,6 +1,7 @@
using Phantom.Agent.Minecraft.Java;
using Phantom.Agent.Minecraft.Server;
using Phantom.Utils.Runtime;
namespace Phantom.Agent.Minecraft.Launcher;
public sealed record LaunchServices(MinecraftServerExecutables ServerExecutables, JavaRuntimeRepository JavaRuntimeRepository);
public sealed record LaunchServices(TaskManager TaskManager, MinecraftServerExecutables ServerExecutables, JavaRuntimeRepository JavaRuntimeRepository);

View File

@@ -7,15 +7,17 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Kajabity.Tools.Java" Version="0.3.7879.40798" />
<PackageReference Include="Kajabity.Tools.Java" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Common\Phantom.Common.Data\Phantom.Common.Data.csproj" />
<ProjectReference Include="..\..\Common\Phantom.Common.Logging\Phantom.Common.Logging.csproj" />
<ProjectReference Include="..\..\Common\Phantom.Common.Minecraft\Phantom.Common.Minecraft.csproj" />
<ProjectReference Include="..\..\Utils\Phantom.Utils.Collections\Phantom.Utils.Collections.csproj" />
<ProjectReference Include="..\..\Utils\Phantom.Utils.Cryptography\Phantom.Utils.Cryptography.csproj" />
<ProjectReference Include="..\..\Utils\Phantom.Utils.IO\Phantom.Utils.IO.csproj" />
<ProjectReference Include="..\..\Utils\Phantom.Utils.Runtime\Phantom.Utils.Runtime.csproj" />
</ItemGroup>
</Project>

View File

@@ -1,9 +1,9 @@
using System.Net.Http.Json;
using System.Security.Cryptography;
using System.Text.Json;
using System.Security.Cryptography;
using Phantom.Common.Logging;
using Phantom.Common.Minecraft;
using Phantom.Utils.Cryptography;
using Phantom.Utils.IO;
using Phantom.Utils.Runtime;
using Serilog;
namespace Phantom.Agent.Minecraft.Server;
@@ -11,7 +11,7 @@ namespace Phantom.Agent.Minecraft.Server;
sealed class MinecraftServerExecutableDownloader {
private static readonly ILogger Logger = PhantomLogger.Create<MinecraftServerExecutableDownloader>();
private const string VersionManifestUrl = "https://launchermeta.mojang.com/mc/game/version_manifest.json";
private readonly MinecraftVersions minecraftVersions;
public Task<string?> Task { get; }
public event EventHandler<DownloadProgressEventArgs>? DownloadProgress;
@@ -20,7 +20,9 @@ sealed class MinecraftServerExecutableDownloader {
private readonly CancellationTokenSource cancellationTokenSource = new ();
private int listeners = 0;
public MinecraftServerExecutableDownloader(string version, string filePath, MinecraftServerExecutableDownloadListener listener) {
public MinecraftServerExecutableDownloader(MinecraftVersions minecraftVersions, string version, string filePath, MinecraftServerExecutableDownloadListener listener) {
this.minecraftVersions = minecraftVersions;
Register(listener);
Task = DownloadAndGetPath(version, filePath);
Task.ContinueWith(OnCompleted, TaskScheduler.Default);
@@ -73,21 +75,18 @@ sealed class MinecraftServerExecutableDownloader {
private async Task<string?> DownloadAndGetPath(string version, string filePath) {
Logger.Information("Downloading server version {Version}...", version);
HttpClient http = new HttpClient();
string tmpFilePath = filePath + ".tmp";
var cancellationToken = cancellationTokenSource.Token;
try {
Logger.Information("Fetching version manifest from: {Url}", VersionManifestUrl);
var versionManifest = await FetchVersionManifest(http, cancellationToken);
var metadataUrl = GetVersionMetadataUrlFromManifest(version, versionManifest);
Logger.Information("Fetching metadata for version {Version} from: {Url}", version, metadataUrl);
var versionMetadata = await FetchVersionMetadata(http, metadataUrl, cancellationToken);
var serverExecutableInfo = GetServerExecutableUrlFromMetadata(versionMetadata);
var serverExecutableInfo = await minecraftVersions.GetServerExecutableInfo(version, cancellationToken);
if (serverExecutableInfo == null) {
return null;
}
Logger.Information("Downloading server executable from: {Url} ({Size})", serverExecutableInfo.DownloadUrl, serverExecutableInfo.Size.ToHumanReadable(decimalPlaces: 1));
try {
using var http = new HttpClient();
await FetchServerExecutableFile(http, new DownloadProgressCallback(this), serverExecutableInfo, tmpFilePath, cancellationToken);
} catch (Exception) {
TryDeleteExecutableAfterFailure(tmpFilePath);
@@ -111,31 +110,7 @@ sealed class MinecraftServerExecutableDownloader {
}
}
private static async Task<JsonElement> FetchVersionManifest(HttpClient http, CancellationToken cancellationToken) {
try {
return await http.GetFromJsonAsync<JsonElement>(VersionManifestUrl, cancellationToken);
} catch (HttpRequestException e) {
Logger.Error(e, "Unable to download version manifest.");
throw StopProcedureException.Instance;
} catch (Exception e) {
Logger.Error(e, "Unable to parse version manifest as JSON.");
throw StopProcedureException.Instance;
}
}
private static async Task<JsonElement> FetchVersionMetadata(HttpClient http, string metadataUrl, CancellationToken cancellationToken) {
try {
return await http.GetFromJsonAsync<JsonElement>(metadataUrl, cancellationToken);
} catch (HttpRequestException e) {
Logger.Error(e, "Unable to download version metadata.");
throw StopProcedureException.Instance;
} catch (Exception e) {
Logger.Error(e, "Unable to parse version metadata as JSON.");
throw StopProcedureException.Instance;
}
}
private static async Task FetchServerExecutableFile(HttpClient http, DownloadProgressCallback progressCallback, ServerExecutableInfo info, string filePath, CancellationToken cancellationToken) {
private static async Task FetchServerExecutableFile(HttpClient http, DownloadProgressCallback progressCallback, MinecraftServerExecutableInfo info, string filePath, CancellationToken cancellationToken) {
Sha1String downloadedFileHash;
try {
@@ -170,83 +145,6 @@ sealed class MinecraftServerExecutableDownloader {
}
}
private static string GetVersionMetadataUrlFromManifest(string serverVersion, JsonElement versionManifest) {
JsonElement versionsElement = GetJsonPropertyOrThrow(versionManifest, "versions", JsonValueKind.Array, "version manifest");
JsonElement versionElement;
try {
versionElement = versionsElement.EnumerateArray().Single(ele => ele.TryGetProperty("id", out var id) && id.ValueKind == JsonValueKind.String && id.GetString() == serverVersion);
} catch (Exception) {
Logger.Error("Version {Version} was not found in version manifest.", serverVersion);
throw StopProcedureException.Instance;
}
JsonElement urlElement = GetJsonPropertyOrThrow(versionElement, "url", JsonValueKind.String, "version entry in version manifest");
string? url = urlElement.GetString();
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) {
Logger.Error("The \"url\" key in version entry in version manifest does not contain a valid URL: {Url}", url);
throw StopProcedureException.Instance;
}
if (uri.Scheme != "https" || !uri.AbsolutePath.EndsWith(".json", StringComparison.OrdinalIgnoreCase)) {
Logger.Error("The \"url\" key in version entry in version manifest does not contain a accepted URL: {Url}", url);
throw StopProcedureException.Instance;
}
return url;
}
private static ServerExecutableInfo GetServerExecutableUrlFromMetadata(JsonElement versionMetadata) {
JsonElement downloadsElement = GetJsonPropertyOrThrow(versionMetadata, "downloads", JsonValueKind.Object, "version metadata");
JsonElement serverElement = GetJsonPropertyOrThrow(downloadsElement, "server", JsonValueKind.Object, "downloads object in version metadata");
JsonElement urlElement = GetJsonPropertyOrThrow(serverElement, "url", JsonValueKind.String, "downloads.server object in version metadata");
string? url = urlElement.GetString();
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) {
Logger.Error("The \"url\" key in downloads.server object in version metadata does not contain a valid URL: {Url}", url);
throw StopProcedureException.Instance;
}
if (uri.Scheme != "https" || !uri.AbsolutePath.EndsWith(".jar", StringComparison.OrdinalIgnoreCase)) {
Logger.Error("The \"url\" key in downloads.server object in version metadata does not contain a accepted URL: {Url}", url);
throw StopProcedureException.Instance;
}
JsonElement sizeElement = GetJsonPropertyOrThrow(serverElement, "size", JsonValueKind.Number, "downloads.server object in version metadata");
ulong size;
try {
size = sizeElement.GetUInt64();
} catch (FormatException) {
Logger.Error("The \"size\" key in downloads.server object in version metadata contains an invalid file size: {Size}", sizeElement);
throw StopProcedureException.Instance;
}
JsonElement sha1Element = GetJsonPropertyOrThrow(serverElement, "sha1", JsonValueKind.String, "downloads.server object in version metadata");
Sha1String hash;
try {
hash = Sha1String.FromString(sha1Element.GetString());
} catch (Exception) {
Logger.Error("The \"sha1\" key in downloads.server object in version metadata does not contain a valid SHA-1 hash: {Sha1}", sha1Element.GetString());
throw StopProcedureException.Instance;
}
return new ServerExecutableInfo(url, hash, new FileSize(size));
}
private static JsonElement GetJsonPropertyOrThrow(JsonElement parentElement, string propertyKey, JsonValueKind expectedKind, string location) {
if (!parentElement.TryGetProperty(propertyKey, out var valueElement)) {
Logger.Error("Missing \"{Property}\" key in " + location + ".", propertyKey);
throw StopProcedureException.Instance;
}
if (valueElement.ValueKind != expectedKind) {
Logger.Error("The \"{Property}\" key in " + location + " does not contain a JSON {ExpectedType}. Actual type: {ActualType}", propertyKey, expectedKind, valueElement.ValueKind);
throw StopProcedureException.Instance;
}
return valueElement;
}
private sealed class MinecraftServerDownloadStreamCopier : IDisposable {
private readonly StreamCopier streamCopier = new ();
private readonly IncrementalHash sha1 = IncrementalHash.CreateHash(HashAlgorithmName.SHA1);
@@ -278,10 +176,4 @@ sealed class MinecraftServerExecutableDownloader {
streamCopier.Dispose();
}
}
private sealed class StopProcedureException : Exception {
public static StopProcedureException Instance { get; } = new ();
private StopProcedureException() {}
}
}

View File

@@ -1,15 +1,19 @@
using System.Text.RegularExpressions;
using Phantom.Common.Logging;
using Phantom.Common.Minecraft;
using Phantom.Utils.IO;
using Serilog;
namespace Phantom.Agent.Minecraft.Server;
public sealed class MinecraftServerExecutables {
public sealed partial class MinecraftServerExecutables : IDisposable {
private static readonly ILogger Logger = PhantomLogger.Create<MinecraftServerExecutables>();
private static readonly Regex VersionFolderSanitizeRegex = new (@"[^a-zA-Z0-9_\-\.]", RegexOptions.Compiled);
[GeneratedRegex(@"[^a-zA-Z0-9_\-\.]", RegexOptions.Compiled)]
private static partial Regex VersionFolderSanitizeRegex();
private readonly string basePath;
private readonly MinecraftVersions minecraftVersions = new ();
private readonly Dictionary<string, MinecraftServerExecutableDownloader> runningDownloadersByVersion = new ();
public MinecraftServerExecutables(string basePath) {
@@ -17,7 +21,7 @@ public sealed class MinecraftServerExecutables {
}
internal async Task<string?> DownloadAndGetPath(string version, EventHandler<DownloadProgressEventArgs> progressEventHandler, CancellationToken cancellationToken) {
string serverExecutableFolderPath = Path.Combine(basePath, VersionFolderSanitizeRegex.Replace(version, "_"));
string serverExecutableFolderPath = Path.Combine(basePath, VersionFolderSanitizeRegex().Replace(version, "_"));
string serverExecutableFilePath = Path.Combine(serverExecutableFolderPath, "server.jar");
if (File.Exists(serverExecutableFilePath)) {
@@ -25,7 +29,7 @@ public sealed class MinecraftServerExecutables {
}
try {
Directory.CreateDirectory(serverExecutableFolderPath);
Directories.Create(serverExecutableFolderPath, Chmod.URWX_GRX);
} catch (Exception e) {
Logger.Error(e, "Unable to create folder for server executable: {ServerExecutableFolderPath}", serverExecutableFolderPath);
return null;
@@ -40,7 +44,7 @@ public sealed class MinecraftServerExecutables {
downloader.Register(listener);
}
else {
downloader = new MinecraftServerExecutableDownloader(version, serverExecutableFilePath, listener);
downloader = new MinecraftServerExecutableDownloader(minecraftVersions, version, serverExecutableFilePath, listener);
downloader.Completed += (_, _) => {
lock (this) {
runningDownloadersByVersion.Remove(version);
@@ -53,4 +57,8 @@ public sealed class MinecraftServerExecutables {
return await downloader.Task.WaitAsync(cancellationToken);
}
public void Dispose() {
minecraftVersions.Dispose();
}
}

View File

@@ -0,0 +1,40 @@
using Phantom.Common.Logging;
using Phantom.Common.Messages.ToServer;
using Serilog;
namespace Phantom.Agent.Rpc;
sealed class KeepAliveLoop {
private static readonly ILogger Logger = PhantomLogger.Create<KeepAliveLoop>();
private static readonly TimeSpan KeepAliveInterval = TimeSpan.FromSeconds(10);
private readonly RpcServerConnection connection;
private readonly CancellationTokenSource cancellationTokenSource = new ();
public KeepAliveLoop(RpcServerConnection connection) {
this.connection = connection;
Task.Run(Run);
}
private async Task Run() {
var cancellationToken = cancellationTokenSource.Token;
Logger.Information("Started keep-alive loop.");
try {
while (true) {
await Task.Delay(KeepAliveInterval, cancellationToken);
await connection.Send(new AgentIsAliveMessage());
}
} catch (OperationCanceledException) {
// Ignore.
} finally {
cancellationTokenSource.Dispose();
Logger.Information("Stopped keep-alive loop.");
}
}
public void Cancel() {
cancellationTokenSource.Cancel();
}
}

View File

@@ -6,10 +6,6 @@
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Serilog" Version="2.12.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Common\Phantom.Common.Messages\Phantom.Common.Messages.csproj" />
</ItemGroup>

View File

@@ -1,19 +0,0 @@
using NetMQ;
using NetMQ.Sockets;
using Phantom.Common.Messages;
using Phantom.Common.Messages.ToServer;
namespace Phantom.Agent.Rpc;
public static class RpcExtensions {
internal static async Task SendMessage<TMessage>(this ClientSocket socket, TMessage message) where TMessage : IMessageToServer {
byte[] bytes = MessageRegistries.ToServer.Write(message).ToArray();
if (bytes.Length > 0) {
await socket.SendAsync(bytes);
}
}
public static Task SendSimpleReply<TMessage, TReplyEnum>(this ClientSocket socket, TMessage message, TReplyEnum reply) where TMessage : IMessageWithReply where TReplyEnum : Enum {
return SendMessage(socket, SimpleReplyMessage.FromEnum(message.SequenceId, reply));
}
}

View File

@@ -2,14 +2,18 @@
using NetMQ.Sockets;
using Phantom.Common.Data.Agent;
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.Runtime;
using Serilog;
using Serilog.Events;
namespace Phantom.Agent.Rpc;
public sealed class RpcLauncher : RpcRuntime<ClientSocket> {
public static async Task Launch(RpcConfiguration config, AgentAuthToken authToken, AgentInfo agentInfo, Func<ClientSocket, IMessageToAgentListener> listenerFactory) {
public static async Task Launch(RpcConfiguration config, AgentAuthToken authToken, AgentInfo agentInfo, Func<RpcServerConnection, IMessageToAgentListener> listenerFactory, SemaphoreSlim disconnectSemaphore, CancellationToken receiveCancellationToken) {
var socket = new ClientSocket();
var options = socket.Options;
@@ -17,21 +21,26 @@ public sealed class RpcLauncher : RpcRuntime<ClientSocket> {
options.CurveCertificate = new NetMQCertificate();
options.HelloMessage = MessageRegistries.ToServer.Write(new RegisterAgentMessage(authToken, agentInfo)).ToArray();
await new RpcLauncher(config, socket, agentInfo.Guid, listenerFactory).Launch();
await new RpcLauncher(config, socket, agentInfo.Guid, listenerFactory, disconnectSemaphore, receiveCancellationToken).Launch();
}
private readonly RpcConfiguration config;
private readonly Guid agentGuid;
private readonly Func<ClientSocket, IMessageToAgentListener> messageListenerFactory;
private readonly Func<RpcServerConnection, IMessageToAgentListener> messageListenerFactory;
private RpcLauncher(RpcConfiguration config, ClientSocket socket, Guid agentGuid, Func<ClientSocket, IMessageToAgentListener> messageListenerFactory) : base(socket, config.CancellationToken) {
private readonly SemaphoreSlim disconnectSemaphore;
private readonly CancellationToken receiveCancellationToken;
private RpcLauncher(RpcConfiguration config, ClientSocket socket, Guid agentGuid, Func<RpcServerConnection, IMessageToAgentListener> messageListenerFactory, SemaphoreSlim disconnectSemaphore, CancellationToken receiveCancellationToken) : base(config, socket) {
this.config = config;
this.agentGuid = agentGuid;
this.messageListenerFactory = messageListenerFactory;
this.disconnectSemaphore = disconnectSemaphore;
this.receiveCancellationToken = receiveCancellationToken;
}
protected override void Connect(ClientSocket socket) {
var logger = config.Logger;
var logger = config.RuntimeLogger;
var url = config.TcpUrl;
logger.Information("Starting ZeroMQ client and connecting to {Url}...", url);
@@ -39,35 +48,60 @@ public sealed class RpcLauncher : RpcRuntime<ClientSocket> {
logger.Information("ZeroMQ client ready.");
}
protected override async Task Run(ClientSocket socket, CancellationToken cancellationToken) {
var logger = config.Logger;
protected override void Run(ClientSocket socket, MessageReplyTracker replyTracker, TaskManager taskManager) {
var connection = new RpcServerConnection(socket, replyTracker);
ServerMessaging.SetCurrentConnection(connection);
var listener = messageListenerFactory(socket);
var logger = config.RuntimeLogger;
var handler = new MessageToAgentHandler(messageListenerFactory(connection), logger, taskManager, receiveCancellationToken);
var keepAliveLoop = new KeepAliveLoop(connection);
ServerMessaging.SetCurrentSocket(socket, cancellationToken);
try {
while (!receiveCancellationToken.IsCancellationRequested) {
var data = socket.Receive(receiveCancellationToken);
// TODO optimize msg
await foreach (var bytes in socket.ReceiveBytesAsyncEnumerable(cancellationToken)) {
if (logger.IsEnabled(LogEventLevel.Verbose)) {
if (bytes.Length > 0 && MessageRegistries.ToAgent.TryGetType(bytes, out var type)) {
logger.Verbose("Received {MessageType} ({Bytes} B) from server.", type.Name, bytes.Length);
}
else {
logger.Verbose("Received {Bytes} B message from server.", bytes.Length);
LogMessageType(logger, data);
if (data.Length > 0) {
MessageRegistries.ToAgent.Handle(data, handler);
}
}
} catch (OperationCanceledException) {
// Ignore.
} finally {
logger.Verbose("ZeroMQ client stopped receiving messages.");
if (bytes.Length > 0) {
MessageRegistries.ToAgent.Handle(bytes, listener, cancellationToken);
}
disconnectSemaphore.Wait(CancellationToken.None);
keepAliveLoop.Cancel();
}
}
protected override async Task Disconnect(ClientSocket socket) {
var timeoutTask = Task.Delay(TimeSpan.FromSeconds(5));
var finishedTask = await Task.WhenAny(socket.SendMessage(new UnregisterAgentMessage(agentGuid)), timeoutTask);
if (finishedTask == timeoutTask) {
config.Logger.Error("Timed out communicating agent shutdown with the server.");
private static void LogMessageType(ILogger logger, ReadOnlyMemory<byte> data) {
if (!logger.IsEnabled(LogEventLevel.Verbose)) {
return;
}
if (data.Length > 0 && MessageRegistries.ToAgent.TryGetType(data, out var type)) {
logger.Verbose("Received {MessageType} ({Bytes} B) from server.", type.Name, data.Length);
}
else {
logger.Verbose("Received {Bytes} B message from server.", data.Length);
}
}
protected override async Task Disconnect() {
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.");
}
}
private sealed class MessageToAgentHandler : MessageHandler<IMessageToAgentListener> {
public MessageToAgentHandler(IMessageToAgentListener listener, ILogger logger, TaskManager taskManager, CancellationToken cancellationToken) : base(listener, logger, taskManager, cancellationToken) {}
protected override Task SendReply(uint sequenceId, byte[] serializedReply) {
return ServerMessaging.Send(new ReplyMessage(sequenceId, serializedReply));
}
}
}

View File

@@ -0,0 +1,41 @@
using NetMQ;
using NetMQ.Sockets;
using Phantom.Common.Messages;
using Phantom.Common.Messages.BiDirectional;
using Phantom.Utils.Rpc.Message;
namespace Phantom.Agent.Rpc;
public sealed class RpcServerConnection {
private readonly ClientSocket socket;
private readonly MessageReplyTracker replyTracker;
internal RpcServerConnection(ClientSocket socket, MessageReplyTracker replyTracker) {
this.socket = socket;
this.replyTracker = replyTracker;
}
internal async Task Send<TMessage>(TMessage message) where TMessage : IMessageToServer {
var bytes = MessageRegistries.ToServer.Write(message).ToArray();
if (bytes.Length > 0) {
await socket.SendAsync(bytes);
}
}
internal async Task<TReply?> Send<TMessage, TReply>(TMessage message, TimeSpan waitForReplyTime, CancellationToken cancellationToken) where TMessage : IMessageToServer<TReply> where TReply : class {
var sequenceId = replyTracker.RegisterReply();
var bytes = MessageRegistries.ToServer.Write<TMessage, TReply>(sequenceId, message).ToArray();
if (bytes.Length == 0) {
replyTracker.ForgetReply(sequenceId);
return null;
}
await socket.SendAsync(bytes);
return await replyTracker.WaitForReply<TReply>(sequenceId, waitForReplyTime, cancellationToken);
}
public void Receive(ReplyMessage message) {
replyTracker.ReceiveReply(message.SequenceId, message.SerializedReply);
}
}

View File

@@ -1,55 +1,34 @@
using NetMQ.Sockets;
using Phantom.Common.Logging;
using Phantom.Common.Logging;
using Phantom.Common.Messages;
using Phantom.Common.Messages.ToServer;
using Serilog;
namespace Phantom.Agent.Rpc;
public static class ServerMessaging {
private static readonly ILogger Logger = PhantomLogger.Create(typeof(ServerMessaging));
private static readonly ILogger Logger = PhantomLogger.Create(nameof(ServerMessaging));
private static readonly TimeSpan KeepAliveInterval = TimeSpan.FromSeconds(10);
private static RpcServerConnection? CurrentConnection { get; set; }
private static RpcServerConnection CurrentConnectionOrThrow => CurrentConnection ?? throw new InvalidOperationException("Server connection not ready.");
private static ClientSocket? CurrentSocket { get; set; }
private static readonly object SetCurrentSocketLock = new ();
private static readonly object SetCurrentConnectionLock = new ();
internal static void SetCurrentSocket(ClientSocket socket, CancellationToken cancellationToken) {
Logger.Information("Server socket ready.");
bool isFirstSet = false;
lock (SetCurrentSocketLock) {
if (CurrentSocket == null) {
isFirstSet = true;
internal static void SetCurrentConnection(RpcServerConnection connection) {
lock (SetCurrentConnectionLock) {
if (CurrentConnection != null) {
throw new InvalidOperationException("Server connection can only be set once.");
}
CurrentSocket = socket;
CurrentConnection = connection;
}
if (isFirstSet) {
Task.Factory.StartNew(static o => SendKeepAliveLoop((CancellationToken) o!), cancellationToken, cancellationToken, TaskCreationOptions.LongRunning, TaskScheduler.Default);
}
Logger.Information("Server connection ready.");
}
public static async Task SendMessage<TMessage>(TMessage message) where TMessage : IMessageToServer {
var currentSocket = CurrentSocket ?? throw new InvalidOperationException("Server socket not ready.");
await currentSocket.SendMessage(message);
public static Task Send<TMessage>(TMessage message) where TMessage : IMessageToServer {
return CurrentConnectionOrThrow.Send(message);
}
private static async Task SendKeepAliveLoop(CancellationToken cancellationToken) {
try {
while (true) {
await Task.Delay(KeepAliveInterval, cancellationToken);
var currentSocket = CurrentSocket;
if (currentSocket != null) {
await currentSocket.SendMessage(new AgentIsAliveMessage());
}
}
} catch (OperationCanceledException) {
// Ignore.
} finally {
Logger.Information("Stopped keep-alive loop.");
}
public static Task<TReply?> Send<TMessage, TReply>(TMessage message, TimeSpan waitForReplyTime, CancellationToken cancellationToken) where TMessage : IMessageToServer<TReply> where TReply : class {
return CurrentConnectionOrThrow.Send<TMessage, TReply>(message, waitForReplyTime, cancellationToken);
}
}

View File

@@ -1,19 +1,26 @@
using Phantom.Agent.Minecraft.Java;
using Phantom.Agent.Services.Instances;
using Phantom.Common.Data.Agent;
using Phantom.Common.Logging;
using Phantom.Utils.Runtime;
using Serilog;
namespace Phantom.Agent.Services;
public sealed class AgentServices {
private static readonly ILogger Logger = PhantomLogger.Create<AgentServices>();
private AgentFolders AgentFolders { get; }
private TaskManager TaskManager { get; }
internal JavaRuntimeRepository JavaRuntimeRepository { get; }
internal InstanceSessionManager InstanceSessionManager { get; }
public AgentServices(AgentInfo agentInfo, AgentFolders agentFolders) {
this.AgentFolders = agentFolders;
this.TaskManager = new TaskManager(PhantomLogger.Create<TaskManager, AgentServices>());
this.JavaRuntimeRepository = new JavaRuntimeRepository();
this.InstanceSessionManager = new InstanceSessionManager(agentInfo, agentFolders, JavaRuntimeRepository);
this.InstanceSessionManager = new InstanceSessionManager(agentInfo, agentFolders, JavaRuntimeRepository, TaskManager);
}
public async Task Initialize() {
@@ -23,6 +30,11 @@ public sealed class AgentServices {
}
public async Task Shutdown() {
Logger.Information("Stopping services...");
await InstanceSessionManager.StopAll();
await TaskManager.Stop();
Logger.Information("Services stopped.");
}
}

View File

@@ -2,6 +2,7 @@
using Phantom.Agent.Rpc;
using Phantom.Agent.Services.Instances.States;
using Phantom.Common.Data.Instance;
using Phantom.Common.Data.Minecraft;
using Phantom.Common.Data.Replies;
using Phantom.Common.Logging;
using Phantom.Common.Messages.ToServer;
@@ -32,7 +33,7 @@ sealed class Instance : IDisposable {
private readonly LaunchServices launchServices;
private readonly PortManager portManager;
private InstanceStatus currentStatus;
private IInstanceStatus currentStatus;
private IInstanceState currentState;
private readonly SemaphoreSlim stateTransitioningActionSemaphore = new (1, 1);
@@ -46,24 +47,31 @@ sealed class Instance : IDisposable {
this.launchServices = launchServices;
this.portManager = portManager;
this.currentState = new InstanceNotRunningState();
this.currentStatus = InstanceStatus.IsNotRunning;
this.currentStatus = InstanceStatus.NotRunning;
}
private async Task ReportLastStatus() {
await ServerMessaging.SendMessage(new ReportInstanceStatusMessage(Configuration.InstanceGuid, currentStatus));
await ServerMessaging.Send(new ReportInstanceStatusMessage(Configuration.InstanceGuid, currentStatus));
}
private bool TransitionState(IInstanceState newState) {
private void TransitionState(IInstanceState newState) {
if (currentState == newState) {
return false;
return;
}
if (currentState is IDisposable disposable) {
disposable.Dispose();
}
logger.Verbose("Transitioning instance state to: {NewState}", newState.GetType().Name);
currentState = newState;
return true;
currentState.Initialize();
}
private T TransitionStateAndReturn<T>((IInstanceState State, T Result) newStateAndResult) {
TransitionState(newStateAndResult.State);
return newStateAndResult.Result;
}
public async Task Reconfigure(InstanceConfiguration configuration, BaseLauncher launcher, CancellationToken cancellationToken) {
@@ -77,44 +85,32 @@ sealed class Instance : IDisposable {
}
}
public async Task<LaunchInstanceResult> Launch(CancellationToken cancellationToken) {
await stateTransitioningActionSemaphore.WaitAsync(cancellationToken);
public async Task<LaunchInstanceResult> Launch(CancellationToken shutdownCancellationToken) {
await stateTransitioningActionSemaphore.WaitAsync(shutdownCancellationToken);
try {
if (TransitionState(currentState.Launch(new InstanceContextImpl(this)))) {
return LaunchInstanceResult.LaunchInitiated;
}
return currentState switch {
InstanceLaunchingState => LaunchInstanceResult.InstanceAlreadyLaunching,
InstanceRunningState => LaunchInstanceResult.InstanceAlreadyRunning,
InstanceStoppingState => LaunchInstanceResult.InstanceIsStopping,
_ => LaunchInstanceResult.UnknownError
};
return TransitionStateAndReturn(currentState.Launch(new InstanceContextImpl(this, shutdownCancellationToken)));
} catch (Exception e) {
logger.Error(e, "Caught exception while launching instance.");
return LaunchInstanceResult.UnknownError;
} finally {
stateTransitioningActionSemaphore.Release();
}
}
public async Task<StopInstanceResult> Stop() {
public async Task<StopInstanceResult> Stop(MinecraftStopStrategy stopStrategy) {
await stateTransitioningActionSemaphore.WaitAsync();
try {
if (TransitionState(currentState.Stop())) {
return StopInstanceResult.StopInitiated;
}
return currentState switch {
InstanceNotRunningState => StopInstanceResult.InstanceAlreadyStopped,
InstanceLaunchingState => StopInstanceResult.StopInitiated,
InstanceStoppingState => StopInstanceResult.InstanceAlreadyStopping,
_ => StopInstanceResult.UnknownError
};
return TransitionStateAndReturn(currentState.Stop(stopStrategy));
} catch (Exception e) {
logger.Error(e, "Caught exception while stopping instance.");
return StopInstanceResult.UnknownError;
} finally {
stateTransitioningActionSemaphore.Release();
}
}
public async Task StopAndWait(TimeSpan waitTime) {
await Stop();
await Stop(MinecraftStopStrategy.Instant);
using var waitTokenSource = new CancellationTokenSource(waitTime);
var waitToken = waitTokenSource.Token;
@@ -130,10 +126,13 @@ sealed class Instance : IDisposable {
private sealed class InstanceContextImpl : InstanceContext {
private readonly Instance instance;
private readonly CancellationToken shutdownCancellationToken;
private int statusUpdateCounter;
public InstanceContextImpl(Instance instance) : base(instance.Configuration, instance.Launcher) {
public InstanceContextImpl(Instance instance, CancellationToken shutdownCancellationToken) : base(instance.Configuration, instance.Launcher) {
this.instance = instance;
this.shutdownCancellationToken = shutdownCancellationToken;
}
public override LaunchServices LaunchServices => instance.launchServices;
@@ -141,21 +140,33 @@ sealed class Instance : IDisposable {
public override ILogger Logger => instance.logger;
public override string ShortName => instance.shortName;
public override void ReportStatus(InstanceStatus newStatus) {
public override void ReportStatus(IInstanceStatus newStatus) {
int myStatusUpdateCounter = Interlocked.Increment(ref statusUpdateCounter);
Task.Run(async () => {
instance.launchServices.TaskManager.Run("Report status of instance " + instance.shortName + " as " + newStatus.GetType().Name, async () => {
if (myStatusUpdateCounter == statusUpdateCounter) {
instance.currentStatus = newStatus;
await ServerMessaging.SendMessage(new ReportInstanceStatusMessage(Configuration.InstanceGuid, newStatus));
await ServerMessaging.Send(new ReportInstanceStatusMessage(Configuration.InstanceGuid, newStatus));
}
});
}
public override void TransitionState(Func<IInstanceState> newState) {
instance.stateTransitioningActionSemaphore.Wait();
public override void TransitionState(Func<(IInstanceState, IInstanceStatus?)> newStateAndStatus) {
instance.stateTransitioningActionSemaphore.Wait(CancellationToken.None);
try {
instance.TransitionState(newState());
var (state, status) = newStateAndStatus();
if (state is not InstanceNotRunningState && shutdownCancellationToken.IsCancellationRequested) {
instance.logger.Verbose("Cancelled state transition to {State} due to Agent shutdown.", state.GetType().Name);
return;
}
if (status != null) {
ReportStatus(status);
}
instance.TransitionState(state);
} catch (Exception e) {
instance.logger.Error(e, "Caught exception during state transition.");
} finally {
instance.stateTransitioningActionSemaphore.Release();
}

View File

@@ -19,10 +19,10 @@ abstract class InstanceContext {
Launcher = launcher;
}
public abstract void ReportStatus(InstanceStatus newStatus);
public abstract void TransitionState(Func<IInstanceState> newState);
public abstract void ReportStatus(IInstanceStatus newStatus);
public abstract void TransitionState(Func<(IInstanceState, IInstanceStatus?)> newStateAndStatus);
public void TransitionState(IInstanceState newState) {
TransitionState(() => newState);
public void TransitionState(IInstanceState newState, IInstanceStatus? newStatus = null) {
TransitionState(() => (newState, newStatus));
}
}

View File

@@ -0,0 +1,96 @@
using System.Collections.Immutable;
using Phantom.Agent.Rpc;
using Phantom.Common.Logging;
using Phantom.Common.Messages.ToServer;
using Phantom.Utils.Collections;
using Phantom.Utils.Runtime;
using Serilog;
namespace Phantom.Agent.Services.Instances;
sealed class InstanceLogSender {
private static readonly TimeSpan SendDelay = TimeSpan.FromMilliseconds(200);
private readonly Guid instanceGuid;
private readonly ILogger logger;
private readonly CancellationTokenSource cancellationTokenSource;
private readonly CancellationToken cancellationToken;
private readonly SemaphoreSlim semaphore = new (1, 1);
private readonly RingBuffer<string> buffer = new (1000);
public InstanceLogSender(TaskManager taskManager, Guid instanceGuid, string name) {
this.instanceGuid = instanceGuid;
this.logger = PhantomLogger.Create<InstanceLogSender>(name);
this.cancellationTokenSource = new CancellationTokenSource();
this.cancellationToken = cancellationTokenSource.Token;
taskManager.Run("Instance log sender for " + name, Run);
}
private async Task Run() {
logger.Verbose("Task started.");
try {
try {
while (!cancellationToken.IsCancellationRequested) {
await SendOutputToServer(await DequeueOrThrow());
await Task.Delay(SendDelay, cancellationToken);
}
} catch (OperationCanceledException) {
// Ignore.
}
// Flush remaining lines.
await SendOutputToServer(DequeueWithoutSemaphore());
} catch (Exception e) {
logger.Error(e, "Caught exception in task.");
} finally {
cancellationTokenSource.Dispose();
logger.Verbose("Task stopped.");
}
}
private async Task SendOutputToServer(ImmutableArray<string> lines) {
if (!lines.IsEmpty) {
await ServerMessaging.Send(new InstanceOutputMessage(instanceGuid, lines));
}
}
private ImmutableArray<string> DequeueWithoutSemaphore() {
ImmutableArray<string> lines = buffer.Count > 0 ? buffer.EnumerateLast(uint.MaxValue).ToImmutableArray() : ImmutableArray<string>.Empty;
buffer.Clear();
return lines;
}
private async Task<ImmutableArray<string>> DequeueOrThrow() {
await semaphore.WaitAsync(cancellationToken);
try {
return DequeueWithoutSemaphore();
} finally {
semaphore.Release();
}
}
public void Enqueue(string line) {
try {
semaphore.Wait(cancellationToken);
} catch (Exception) {
return;
}
try {
buffer.Add(line);
} finally {
semaphore.Release();
}
}
public void Cancel() {
try {
cancellationTokenSource.Cancel();
} catch (ObjectDisposedException) {
// Ignore.
}
}
}

View File

@@ -1,84 +0,0 @@
using System.Collections.Immutable;
using System.Diagnostics.CodeAnalysis;
using Phantom.Agent.Rpc;
using Phantom.Common.Logging;
using Phantom.Common.Messages.ToServer;
using Phantom.Utils.Collections;
using Serilog;
namespace Phantom.Agent.Services.Instances;
sealed class InstanceLogSenderThread {
private readonly Guid instanceGuid;
private readonly ILogger logger;
private readonly CancellationTokenSource cancellationTokenSource;
private readonly CancellationToken cancellationToken;
private readonly SemaphoreSlim semaphore = new (1, 1);
private readonly RingBuffer<string> buffer = new (1000);
public InstanceLogSenderThread(Guid instanceGuid, string name) {
this.instanceGuid = instanceGuid;
this.logger = PhantomLogger.Create<InstanceLogSenderThread>(name);
this.cancellationTokenSource = new CancellationTokenSource();
this.cancellationToken = cancellationTokenSource.Token;
var thread = new Thread(Run) {
IsBackground = true,
Name = "Instance Log Sender (" + name + ")"
};
thread.Start();
}
[SuppressMessage("ReSharper", "LocalVariableHidesMember")]
private async void Run() {
logger.Verbose("Thread started.");
try {
while (!cancellationToken.IsCancellationRequested) {
await semaphore.WaitAsync(cancellationToken);
ImmutableArray<string> lines;
try {
lines = buffer.Count > 0 ? buffer.EnumerateLast(uint.MaxValue).ToImmutableArray() : ImmutableArray<string>.Empty;
buffer.Clear();
} finally {
semaphore.Release();
}
if (lines.Length > 0) {
await ServerMessaging.SendMessage(new InstanceOutputMessage(instanceGuid, lines));
}
await Task.Delay(TimeSpan.FromMilliseconds(200), cancellationToken);
}
} catch (OperationCanceledException) {
// Ignore.
} catch (Exception e) {
logger.Error(e, "Caught exception in thread.");
} finally {
cancellationTokenSource.Dispose();
logger.Verbose("Thread stopped.");
}
}
public void Enqueue(string line) {
try {
semaphore.Wait(cancellationToken);
} catch (Exception) {
return;
}
try {
buffer.Add(line);
} finally {
semaphore.Release();
}
}
public void Cancel() {
cancellationTokenSource.Cancel();
}
}

View File

@@ -1,4 +1,5 @@
using Phantom.Agent.Minecraft.Instance;
using System.Diagnostics.CodeAnalysis;
using Phantom.Agent.Minecraft.Instance;
using Phantom.Agent.Minecraft.Java;
using Phantom.Agent.Minecraft.Launcher;
using Phantom.Agent.Minecraft.Launcher.Types;
@@ -7,8 +8,11 @@ using Phantom.Agent.Minecraft.Server;
using Phantom.Common.Data;
using Phantom.Common.Data.Agent;
using Phantom.Common.Data.Instance;
using Phantom.Common.Data.Minecraft;
using Phantom.Common.Data.Replies;
using Phantom.Common.Logging;
using Phantom.Utils.IO;
using Phantom.Utils.Runtime;
using Serilog;
namespace Phantom.Agent.Services.Instances;
@@ -19,6 +23,7 @@ sealed class InstanceSessionManager : IDisposable {
private readonly AgentInfo agentInfo;
private readonly string basePath;
private readonly MinecraftServerExecutables minecraftServerExecutables;
private readonly LaunchServices launchServices;
private readonly PortManager portManager;
private readonly Dictionary<Guid, Instance> instances = new ();
@@ -27,32 +32,53 @@ sealed class InstanceSessionManager : IDisposable {
private readonly CancellationToken shutdownCancellationToken;
private readonly SemaphoreSlim semaphore = new (1, 1);
public InstanceSessionManager(AgentInfo agentInfo, AgentFolders agentFolders, JavaRuntimeRepository javaRuntimeRepository) {
public InstanceSessionManager(AgentInfo agentInfo, AgentFolders agentFolders, JavaRuntimeRepository javaRuntimeRepository, TaskManager taskManager) {
this.agentInfo = agentInfo;
this.basePath = agentFolders.InstancesFolderPath;
this.launchServices = new LaunchServices(new MinecraftServerExecutables(agentFolders.ServerExecutableFolderPath), javaRuntimeRepository);
this.minecraftServerExecutables = new MinecraftServerExecutables(agentFolders.ServerExecutableFolderPath);
this.launchServices = new LaunchServices(taskManager, minecraftServerExecutables, javaRuntimeRepository);
this.portManager = new PortManager(agentInfo.AllowedServerPorts, agentInfo.AllowedRconPorts);
this.shutdownCancellationToken = shutdownCancellationTokenSource.Token;
}
public async Task<ConfigureInstanceResult> Configure(InstanceConfiguration configuration) {
private async Task<InstanceActionResult<T>> AcquireSemaphoreAndRun<T>(Func<Task<InstanceActionResult<T>>> func) {
try {
await semaphore.WaitAsync(shutdownCancellationToken);
try {
return await func();
} finally {
semaphore.Release();
}
} catch (OperationCanceledException) {
return ConfigureInstanceResult.AgentShuttingDown;
return InstanceActionResult.General<T>(InstanceActionGeneralResult.AgentShuttingDown);
}
}
var instanceGuid = configuration.InstanceGuid;
[SuppressMessage("ReSharper", "ConvertIfStatementToReturnStatement")]
private Task<InstanceActionResult<T>> AcquireSemaphoreAndRunWithInstance<T>(Guid instanceGuid, Func<Instance, Task<T>> func) {
return AcquireSemaphoreAndRun(async () => {
if (instances.TryGetValue(instanceGuid, out var instance)) {
return InstanceActionResult.Concrete(await func(instance));
}
else {
return InstanceActionResult.General<T>(InstanceActionGeneralResult.InstanceDoesNotExist);
}
});
}
public async Task<InstanceActionResult<ConfigureInstanceResult>> Configure(InstanceConfiguration configuration) {
return await AcquireSemaphoreAndRun(async () => {
var instanceGuid = configuration.InstanceGuid;
try {
var otherInstances = instances.Values.Where(inst => inst.Configuration.InstanceGuid != instanceGuid).ToArray();
if (otherInstances.Length + 1 > agentInfo.MaxInstances) {
return ConfigureInstanceResult.InstanceLimitExceeded;
return InstanceActionResult.Concrete(ConfigureInstanceResult.InstanceLimitExceeded);
}
var availableMemory = agentInfo.MaxMemory - otherInstances.Aggregate(RamAllocationUnits.Zero, static (total, instance) => total + instance.Configuration.MemoryAllocation);
if (availableMemory < configuration.MemoryAllocation) {
return ConfigureInstanceResult.MemoryLimitExceeded;
return InstanceActionResult.Concrete(ConfigureInstanceResult.MemoryLimitExceeded);
}
var heapMegabytes = configuration.MemoryAllocation.InMegabytes;
@@ -62,11 +88,12 @@ sealed class InstanceSessionManager : IDisposable {
);
var instanceFolder = Path.Combine(basePath, instanceGuid.ToString());
Directory.CreateDirectory(instanceFolder);
Directories.Create(instanceFolder, Chmod.URWX_GRX);
var properties = new InstanceProperties(
configuration.JavaRuntimeGuid,
jvmProperties,
configuration.JvmArguments,
instanceFolder,
configuration.MinecraftVersion,
new ServerProperties(configuration.ServerPort, configuration.RconPort)
@@ -87,75 +114,27 @@ sealed class InstanceSessionManager : IDisposable {
await instance.Launch(shutdownCancellationToken);
}
return ConfigureInstanceResult.Success;
} finally {
semaphore.Release();
}
return InstanceActionResult.Concrete(ConfigureInstanceResult.Success);
});
}
public async Task<LaunchInstanceResult> Launch(Guid instanceGuid) {
try {
await semaphore.WaitAsync(shutdownCancellationToken);
} catch (OperationCanceledException) {
return LaunchInstanceResult.AgentShuttingDown;
}
try {
if (!instances.TryGetValue(instanceGuid, out var instance)) {
return LaunchInstanceResult.InstanceDoesNotExist;
}
else {
return await instance.Launch(shutdownCancellationToken);
}
} finally {
semaphore.Release();
}
public Task<InstanceActionResult<LaunchInstanceResult>> Launch(Guid instanceGuid) {
return AcquireSemaphoreAndRunWithInstance(instanceGuid, instance => instance.Launch(shutdownCancellationToken));
}
public async Task<StopInstanceResult> Stop(Guid instanceGuid) {
try {
await semaphore.WaitAsync(shutdownCancellationToken);
} catch (OperationCanceledException) {
return StopInstanceResult.AgentShuttingDown;
}
try {
if (!instances.TryGetValue(instanceGuid, out var instance)) {
return StopInstanceResult.InstanceDoesNotExist;
}
else {
return await instance.Stop();
}
} finally {
semaphore.Release();
}
public Task<InstanceActionResult<StopInstanceResult>> Stop(Guid instanceGuid, MinecraftStopStrategy stopStrategy) {
return AcquireSemaphoreAndRunWithInstance(instanceGuid, instance => instance.Stop(stopStrategy));
}
public async Task<SendCommandToInstanceResult> SendCommand(Guid instanceGuid, string command) {
try {
await semaphore.WaitAsync(shutdownCancellationToken);
} catch (OperationCanceledException) {
return SendCommandToInstanceResult.AgentShuttingDown;
}
try {
if (!instances.TryGetValue(instanceGuid, out var instance)) {
return SendCommandToInstanceResult.InstanceDoesNotExist;
}
if (!await instance.SendCommand(command, shutdownCancellationToken)) {
return SendCommandToInstanceResult.UnknownError;
}
return SendCommandToInstanceResult.Success;
} finally {
semaphore.Release();
}
public Task<InstanceActionResult<SendCommandToInstanceResult>> SendCommand(Guid instanceGuid, string command) {
return AcquireSemaphoreAndRunWithInstance(instanceGuid, async instance => await instance.SendCommand(command, shutdownCancellationToken) ? SendCommandToInstanceResult.Success : SendCommandToInstanceResult.UnknownError);
}
public async Task StopAll() {
shutdownCancellationTokenSource.Cancel();
Logger.Information("Stopping all instances...");
await semaphore.WaitAsync(CancellationToken.None);
try {
await Task.WhenAll(instances.Values.Select(static instance => instance.StopAndWait(TimeSpan.FromSeconds(30))));
@@ -166,6 +145,7 @@ sealed class InstanceSessionManager : IDisposable {
}
public void Dispose() {
minecraftServerExecutables.Dispose();
shutdownCancellationTokenSource.Dispose();
semaphore.Dispose();
}

View File

@@ -14,17 +14,28 @@ sealed class PortManager {
}
public Result Reserve(InstanceConfiguration configuration) {
var serverPort = configuration.ServerPort;
var rconPort = configuration.RconPort;
if (!allowedServerPorts.Contains(serverPort)) {
return Result.ServerPortNotAllowed;
}
if (!allowedRconPorts.Contains(rconPort)) {
return Result.RconPortNotAllowed;
}
lock (usedPorts) {
if (usedPorts.Contains(configuration.ServerPort)) {
if (usedPorts.Contains(serverPort)) {
return Result.ServerPortAlreadyInUse;
}
if (usedPorts.Contains(configuration.RconPort)) {
if (usedPorts.Contains(rconPort)) {
return Result.RconPortAlreadyInUse;
}
usedPorts.Add(configuration.ServerPort);
usedPorts.Add(configuration.RconPort);
usedPorts.Add(serverPort);
usedPorts.Add(rconPort);
}
return Result.Success;
@@ -42,6 +53,6 @@ sealed class PortManager {
ServerPortNotAllowed,
ServerPortAlreadyInUse,
RconPortNotAllowed,
RconPortAlreadyInUse,
RconPortAlreadyInUse
}
}

View File

@@ -1,7 +1,11 @@
namespace Phantom.Agent.Services.Instances.States;
using Phantom.Common.Data.Minecraft;
using Phantom.Common.Data.Replies;
namespace Phantom.Agent.Services.Instances.States;
interface IInstanceState {
IInstanceState Launch(InstanceContext context);
IInstanceState Stop();
void Initialize();
(IInstanceState, LaunchInstanceResult) Launch(InstanceContext context);
(IInstanceState, StopInstanceResult) Stop(MinecraftStopStrategy stopStrategy);
Task<bool> SendCommand(string command, CancellationToken cancellationToken);
}

View File

@@ -2,6 +2,8 @@
using Phantom.Agent.Minecraft.Launcher;
using Phantom.Agent.Minecraft.Server;
using Phantom.Common.Data.Instance;
using Phantom.Common.Data.Minecraft;
using Phantom.Common.Data.Replies;
namespace Phantom.Agent.Services.Instances.States;
@@ -12,10 +14,12 @@ sealed class InstanceLaunchingState : IInstanceState, IDisposable {
public InstanceLaunchingState(InstanceContext context) {
this.context = context;
this.context.Logger.Information("Session starting...");
this.context.ReportStatus(InstanceStatus.IsLaunching);
}
var launchTask = Task.Run(DoLaunch);
public void Initialize() {
context.Logger.Information("Session starting...");
var launchTask = context.LaunchServices.TaskManager.Run("Launch procedure for instance " + context.ShortName, DoLaunch);
launchTask.ContinueWith(OnLaunchSuccess, CancellationToken.None, TaskContinuationOptions.OnlyOnRanToCompletion, TaskScheduler.Default);
launchTask.ContinueWith(OnLaunchFailure, CancellationToken.None, TaskContinuationOptions.NotOnRanToCompletion, TaskScheduler.Default);
}
@@ -29,23 +33,32 @@ sealed class InstanceLaunchingState : IInstanceState, IDisposable {
if (lastDownloadProgress != progress) {
lastDownloadProgress = progress;
context.ReportStatus(new InstanceStatus.Downloading(progress));
context.ReportStatus(InstanceStatus.Downloading(progress));
}
}
var launchResult = await context.Launcher.Launch(context.LaunchServices, OnDownloadProgress, cancellationToken);
if (launchResult is LaunchResult.CouldNotDownloadMinecraftServer) {
var launchResult = await context.Launcher.Launch(context.Logger, context.LaunchServices, OnDownloadProgress, cancellationToken);
if (launchResult is LaunchResult.InvalidJavaRuntime) {
throw new LaunchFailureException(InstanceLaunchFailReason.JavaRuntimeNotFound, "Session failed to launch, invalid Java runtime.");
}
else if (launchResult is LaunchResult.InvalidJvmArguments) {
throw new LaunchFailureException(InstanceLaunchFailReason.InvalidJvmArguments, "Session failed to launch, invalid JVM arguments.");
}
else if (launchResult is LaunchResult.CouldNotDownloadMinecraftServer) {
throw new LaunchFailureException(InstanceLaunchFailReason.CouldNotDownloadMinecraftServer, "Session failed to launch, could not download Minecraft server.");
}
else if (launchResult is LaunchResult.InvalidJavaRuntime) {
throw new LaunchFailureException(InstanceLaunchFailReason.JavaRuntimeNotFound, "Session failed to launch, invalid Java runtime.");
else if (launchResult is LaunchResult.CouldNotConfigureMinecraftServer) {
throw new LaunchFailureException(InstanceLaunchFailReason.CouldNotConfigureMinecraftServer, "Session failed to launch, could not configure Minecraft server.");
}
else if (launchResult is LaunchResult.CouldNotStartMinecraftServer) {
throw new LaunchFailureException(InstanceLaunchFailReason.CouldNotStartMinecraftServer, "Session failed to launch, could not start Minecraft server.");
}
if (launchResult is not LaunchResult.Success launchSuccess) {
throw new LaunchFailureException(InstanceLaunchFailReason.UnknownError, "Session failed to launch.");
}
context.ReportStatus(InstanceStatus.IsLaunching);
context.ReportStatus(InstanceStatus.Launching);
return launchSuccess.Session;
}
@@ -53,11 +66,10 @@ sealed class InstanceLaunchingState : IInstanceState, IDisposable {
context.TransitionState(() => {
if (cancellationTokenSource.IsCancellationRequested) {
context.PortManager.Release(context.Configuration);
context.ReportStatus(InstanceStatus.IsNotRunning);
return new InstanceNotRunningState();
return (new InstanceNotRunningState(), InstanceStatus.NotRunning);
}
else {
return new InstanceRunningState(context, task.Result);
return (new InstanceRunningState(context, task.Result), null);
}
});
}
@@ -65,10 +77,10 @@ sealed class InstanceLaunchingState : IInstanceState, IDisposable {
private void OnLaunchFailure(Task task) {
if (task.Exception is { InnerException: LaunchFailureException e }) {
context.Logger.Error(e.LogMessage);
context.ReportStatus(new InstanceStatus.Failed(e.Reason));
context.ReportStatus(InstanceStatus.Failed(e.Reason));
}
else {
context.ReportStatus(new InstanceStatus.Failed(InstanceLaunchFailReason.UnknownError));
context.ReportStatus(InstanceStatus.Failed(InstanceLaunchFailReason.UnknownError));
}
context.PortManager.Release(context.Configuration);
@@ -85,13 +97,13 @@ sealed class InstanceLaunchingState : IInstanceState, IDisposable {
}
}
public IInstanceState Launch(InstanceContext context) {
return this;
public (IInstanceState, LaunchInstanceResult) Launch(InstanceContext context) {
return (this, LaunchInstanceResult.InstanceAlreadyLaunching);
}
public IInstanceState Stop() {
public (IInstanceState, StopInstanceResult) Stop(MinecraftStopStrategy stopStrategy) {
cancellationTokenSource.Cancel();
return this;
return (this, StopInstanceResult.StopInitiated);
}
public Task<bool> SendCommand(string command, CancellationToken cancellationToken) {

View File

@@ -1,9 +1,13 @@
using Phantom.Common.Data.Instance;
using Phantom.Common.Data.Minecraft;
using Phantom.Common.Data.Replies;
namespace Phantom.Agent.Services.Instances.States;
sealed class InstanceNotRunningState : IInstanceState {
public IInstanceState Launch(InstanceContext context) {
public void Initialize() {}
public (IInstanceState, LaunchInstanceResult) Launch(InstanceContext context) {
InstanceLaunchFailReason? failReason = context.PortManager.Reserve(context.Configuration) switch {
PortManager.Result.ServerPortNotAllowed => InstanceLaunchFailReason.ServerPortNotAllowed,
PortManager.Result.ServerPortAlreadyInUse => InstanceLaunchFailReason.ServerPortAlreadyInUse,
@@ -13,15 +17,16 @@ sealed class InstanceNotRunningState : IInstanceState {
};
if (failReason != null) {
context.ReportStatus(new InstanceStatus.Failed(failReason.Value));
return this;
context.ReportStatus(InstanceStatus.Failed(failReason.Value));
return (this, LaunchInstanceResult.LaunchInitiated);
}
return new InstanceLaunchingState(context);
context.ReportStatus(InstanceStatus.Launching);
return (new InstanceLaunchingState(context), LaunchInstanceResult.LaunchInitiated);
}
public IInstanceState Stop() {
return this;
public (IInstanceState, StopInstanceResult) Stop(MinecraftStopStrategy stopStrategy) {
return (this, StopInstanceResult.InstanceAlreadyStopped);
}
public Task<bool> SendCommand(string command, CancellationToken cancellationToken) {

View File

@@ -1,58 +1,127 @@
using Phantom.Agent.Minecraft.Instance;
using Phantom.Agent.Minecraft.Command;
using Phantom.Agent.Minecraft.Instance;
using Phantom.Common.Data.Instance;
using Phantom.Common.Data.Minecraft;
using Phantom.Common.Data.Replies;
namespace Phantom.Agent.Services.Instances.States;
sealed class InstanceRunningState : IInstanceState {
private readonly InstanceContext context;
private readonly InstanceSession session;
private readonly InstanceLogSenderThread logSenderThread;
private readonly InstanceLogSender logSender;
private readonly SessionObjects sessionObjects;
private readonly CancellationTokenSource delayedStopCancellationTokenSource = new ();
private bool stateOwnsDelayedStopCancellationTokenSource = true;
private bool isStopping;
public InstanceRunningState(InstanceContext context, InstanceSession session) {
this.context = context;
this.session = session;
this.logSenderThread = new InstanceLogSenderThread(context.Configuration.InstanceGuid, context.ShortName);
this.sessionObjects = new SessionObjects(context, session, logSenderThread);
this.logSender = new InstanceLogSender(context.LaunchServices.TaskManager, context.Configuration.InstanceGuid, context.ShortName);
this.sessionObjects = new SessionObjects(this);
}
this.session.AddOutputListener(SessionOutput);
this.session.SessionEnded += SessionEnded;
public void Initialize() {
session.AddOutputListener(SessionOutput);
session.SessionEnded += SessionEnded;
if (session.HasEnded) {
if (sessionObjects.Dispose()) {
context.Logger.Warning("Session ended immediately after it was started.");
context.ReportStatus(new InstanceStatus.Failed(InstanceLaunchFailReason.UnknownError));
Task.Run(() => context.TransitionState(new InstanceNotRunningState()));
context.LaunchServices.TaskManager.Run("Transition state of instance " + context.ShortName + " to not running", () => context.TransitionState(new InstanceNotRunningState(), InstanceStatus.Failed(InstanceLaunchFailReason.UnknownError)));
}
}
else {
context.ReportStatus(InstanceStatus.IsRunning);
context.ReportStatus(InstanceStatus.Running);
context.Logger.Information("Session started.");
}
}
private void SessionOutput(object? sender, string e) {
context.Logger.Verbose("[Server] {Line}", e);
logSenderThread.Enqueue(e);
logSender.Enqueue(e);
}
private void SessionEnded(object? sender, EventArgs e) {
if (sessionObjects.Dispose()) {
if (!sessionObjects.Dispose()) {
return;
}
if (isStopping) {
context.Logger.Information("Session ended.");
context.ReportStatus(InstanceStatus.IsNotRunning);
context.TransitionState(new InstanceNotRunningState());
context.TransitionState(new InstanceNotRunningState(), InstanceStatus.NotRunning);
}
else {
context.Logger.Information("Session ended unexpectedly, restarting...");
context.TransitionState(new InstanceLaunchingState(context), InstanceStatus.Restarting);
}
}
public IInstanceState Launch(InstanceContext context) {
return this;
public (IInstanceState, LaunchInstanceResult) Launch(InstanceContext context) {
return (this, LaunchInstanceResult.InstanceAlreadyRunning);
}
public IInstanceState Stop() {
public (IInstanceState, StopInstanceResult) Stop(MinecraftStopStrategy stopStrategy) {
if (stopStrategy == MinecraftStopStrategy.Instant) {
CancelDelayedStop();
return (PrepareStoppedState(), StopInstanceResult.StopInitiated);
}
if (isStopping) {
// TODO change delay or something
return (this, StopInstanceResult.InstanceAlreadyStopping);
}
isStopping = true;
context.LaunchServices.TaskManager.Run("Delayed stop timer for instance " + context.ShortName, () => StopLater(stopStrategy.Seconds));
return (this, StopInstanceResult.StopInitiated);
}
private IInstanceState PrepareStoppedState() {
session.SessionEnded -= SessionEnded;
return new InstanceStoppingState(context, session, sessionObjects);
}
private void CancelDelayedStop() {
try {
delayedStopCancellationTokenSource.Cancel();
} catch (ObjectDisposedException) {
// ignore
}
}
private async Task StopLater(int seconds) {
var cancellationToken = delayedStopCancellationTokenSource.Token;
try {
stateOwnsDelayedStopCancellationTokenSource = false;
int[] stops = { 60, 30, 10, 5, 4, 3, 2, 1, 0 };
foreach (var stop in stops) {
if (seconds > stop) {
await SendCommand(MinecraftCommand.Say("Server shutting down in " + seconds + (seconds == 1 ? " second." : " seconds.")), cancellationToken);
await Task.Delay(TimeSpan.FromSeconds(seconds - stop), cancellationToken);
seconds = stop;
}
}
} catch (OperationCanceledException) {
context.Logger.Verbose("Cancelled delayed stop.");
return;
} catch (ObjectDisposedException) {
return;
} catch (Exception e) {
context.Logger.Warning(e, "Caught exception during delayed stop.");
return;
} finally {
delayedStopCancellationTokenSource.Dispose();
}
context.TransitionState(PrepareStoppedState());
}
public async Task<bool> SendCommand(string command, CancellationToken cancellationToken) {
try {
context.Logger.Information("Sending command: {Command}", command);
@@ -67,15 +136,11 @@ sealed class InstanceRunningState : IInstanceState {
}
public sealed class SessionObjects {
private readonly InstanceContext context;
private readonly InstanceSession session;
private readonly InstanceLogSenderThread logSenderThread;
private readonly InstanceRunningState state;
private bool isDisposed;
public SessionObjects(InstanceContext context, InstanceSession session, InstanceLogSenderThread logSenderThread) {
this.context = context;
this.session = session;
this.logSenderThread = logSenderThread;
public SessionObjects(InstanceRunningState state) {
this.state = state;
}
public bool Dispose() {
@@ -87,9 +152,16 @@ sealed class InstanceRunningState : IInstanceState {
isDisposed = true;
}
logSenderThread.Cancel();
session.Dispose();
context.PortManager.Release(context.Configuration);
if (state.stateOwnsDelayedStopCancellationTokenSource) {
state.delayedStopCancellationTokenSource.Dispose();
}
else {
state.CancelDelayedStop();
}
state.logSender.Cancel();
state.session.Dispose();
state.context.PortManager.Release(state.context.Configuration);
return true;
}
}

View File

@@ -1,5 +1,9 @@
using Phantom.Agent.Minecraft.Instance;
using System.Diagnostics;
using Phantom.Agent.Minecraft.Command;
using Phantom.Agent.Minecraft.Instance;
using Phantom.Common.Data.Instance;
using Phantom.Common.Data.Minecraft;
using Phantom.Common.Data.Replies;
namespace Phantom.Agent.Services.Instances.States;
@@ -12,10 +16,12 @@ sealed class InstanceStoppingState : IInstanceState, IDisposable {
this.sessionObjects = sessionObjects;
this.session = session;
this.context = context;
this.context.Logger.Information("Session stopping.");
this.context.ReportStatus(InstanceStatus.IsStopping);
}
Task.Run(DoStop);
public void Initialize() {
context.Logger.Information("Session stopping.");
context.ReportStatus(InstanceStatus.Stopping);
context.LaunchServices.TaskManager.Run("Stop procedure for instance " + context.ShortName, DoStop);
}
private async Task DoStop() {
@@ -27,26 +33,27 @@ sealed class InstanceStoppingState : IInstanceState, IDisposable {
await DoWaitForSessionToEnd();
} finally {
context.Logger.Information("Session stopped.");
context.ReportStatus(InstanceStatus.IsNotRunning);
context.TransitionState(new InstanceNotRunningState());
context.TransitionState(new InstanceNotRunningState(), InstanceStatus.NotRunning);
}
}
private async Task DoSendStopCommand() {
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
try {
await session.SendCommand("stop", cts.Token);
await session.SendCommand(MinecraftCommand.Stop, timeout.Token);
} catch (OperationCanceledException) {
// ignore
} catch (ObjectDisposedException e) when (e.ObjectName == typeof(Process).FullName && session.HasEnded) {
// ignore
} catch (Exception e) {
context.Logger.Warning(e, "Caught exception while sending stop command.");
}
}
private async Task DoWaitForSessionToEnd() {
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(55));
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(55));
try {
await session.WaitForExit(cts.Token);
await session.WaitForExit(timeout.Token);
} catch (OperationCanceledException) {
try {
context.Logger.Warning("Waiting timed out, killing session...");
@@ -57,12 +64,12 @@ sealed class InstanceStoppingState : IInstanceState, IDisposable {
}
}
public IInstanceState Launch(InstanceContext context) {
return this;
public (IInstanceState, LaunchInstanceResult) Launch(InstanceContext context) {
return (this, LaunchInstanceResult.InstanceIsStopping);
}
public IInstanceState Stop() {
return this; // TODO maybe provide a way to kill?
public (IInstanceState, StopInstanceResult) Stop(MinecraftStopStrategy stopStrategy) {
return (this, StopInstanceResult.InstanceAlreadyStopping); // TODO maybe provide a way to kill?
}
public Task<bool> SendCommand(string command, CancellationToken cancellationToken) {

View File

@@ -1,10 +1,11 @@
using NetMQ.Sockets;
using Phantom.Agent.Rpc;
using Phantom.Agent.Rpc;
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.Utils.Rpc.Message;
using Serilog;
namespace Phantom.Agent.Services.Rpc;
@@ -12,32 +13,34 @@ namespace Phantom.Agent.Services.Rpc;
public sealed class MessageListener : IMessageToAgentListener {
private static ILogger Logger { get; } = PhantomLogger.Create<MessageListener>();
private readonly ClientSocket socket;
private readonly RpcServerConnection connection;
private readonly AgentServices agent;
private readonly CancellationTokenSource shutdownTokenSource;
public MessageListener(ClientSocket socket, AgentServices agent, CancellationTokenSource shutdownTokenSource) {
this.socket = socket;
public MessageListener(RpcServerConnection connection, AgentServices agent, CancellationTokenSource shutdownTokenSource) {
this.connection = connection;
this.agent = agent;
this.shutdownTokenSource = shutdownTokenSource;
}
public async Task HandleRegisterAgentSuccessResult(RegisterAgentSuccessMessage message) {
public async Task<NoReply> HandleRegisterAgentSuccess(RegisterAgentSuccessMessage message) {
Logger.Information("Agent authentication successful.");
foreach (var instanceInfo in message.InitialInstances) {
if (await agent.InstanceSessionManager.Configure(instanceInfo) != ConfigureInstanceResult.Success) {
var result = await agent.InstanceSessionManager.Configure(instanceInfo);
if (!result.Is(ConfigureInstanceResult.Success)) {
Logger.Fatal("Unable to configure instance \"{Name}\" (GUID {Guid}), shutting down.", instanceInfo.InstanceName, instanceInfo.InstanceGuid);
shutdownTokenSource.Cancel();
return;
return NoReply.Instance;
}
}
await ServerMessaging.SendMessage(new AdvertiseJavaRuntimesMessage(agent.JavaRuntimeRepository.All));
await ServerMessaging.Send(new AdvertiseJavaRuntimesMessage(agent.JavaRuntimeRepository.All));
return NoReply.Instance;
}
public Task HandleRegisterAgentFailureResult(RegisterAgentFailureMessage message) {
public Task<NoReply> HandleRegisterAgentFailure(RegisterAgentFailureMessage message) {
string errorMessage = message.FailureKind switch {
RegisterAgentFailure.ConnectionAlreadyHasAnAgent => "This connection already has an associated agent.",
RegisterAgentFailure.InvalidToken => "Invalid token.",
@@ -47,22 +50,27 @@ public sealed class MessageListener : IMessageToAgentListener {
Logger.Fatal("Agent authentication failed: {Error}", errorMessage);
Environment.Exit(1);
return Task.CompletedTask;
return Task.FromResult(NoReply.Instance);
}
public async Task HandleConfigureInstance(ConfigureInstanceMessage message) {
await socket.SendSimpleReply(message, await agent.InstanceSessionManager.Configure(message.Configuration));
public async Task<InstanceActionResult<ConfigureInstanceResult>> HandleConfigureInstance(ConfigureInstanceMessage message) {
return await agent.InstanceSessionManager.Configure(message.Configuration);
}
public async Task HandleLaunchInstance(LaunchInstanceMessage message) {
await socket.SendSimpleReply(message, await agent.InstanceSessionManager.Launch(message.InstanceGuid));
public async Task<InstanceActionResult<LaunchInstanceResult>> HandleLaunchInstance(LaunchInstanceMessage message) {
return await agent.InstanceSessionManager.Launch(message.InstanceGuid);
}
public async Task HandleStopInstance(StopInstanceMessage message) {
await socket.SendSimpleReply(message, await agent.InstanceSessionManager.Stop(message.InstanceGuid));
public async Task<InstanceActionResult<StopInstanceResult>> HandleStopInstance(StopInstanceMessage message) {
return await agent.InstanceSessionManager.Stop(message.InstanceGuid, message.StopStrategy);
}
public async Task HandleSendCommandToInstance(SendCommandToInstanceMessage message) {
await socket.SendSimpleReply(message, await agent.InstanceSessionManager.SendCommand(message.InstanceGuid, message.Command));
public async Task<InstanceActionResult<SendCommandToInstanceResult>> HandleSendCommandToInstance(SendCommandToInstanceMessage message) {
return await agent.InstanceSessionManager.SendCommand(message.InstanceGuid, message.Command);
}
public Task<NoReply> HandleReply(ReplyMessage message) {
connection.Receive(message);
return Task.FromResult(NoReply.Instance);
}
}

View File

@@ -0,0 +1,60 @@
using NetMQ;
using Phantom.Common.Data.Agent;
using Phantom.Common.Logging;
using Phantom.Utils.Cryptography;
using Phantom.Utils.IO;
using Serilog;
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) {
if (agentKeyFilePath != null) {
return LoadFromFile(agentKeyFilePath);
}
else if (agentKeyToken != null) {
return Task.FromResult(LoadFromToken(agentKeyToken));
}
else {
throw new InvalidOperationException();
}
}
private static async Task<(NetMQCertificate, AgentAuthToken)?> LoadFromFile(string agentKeyFilePath) {
if (!File.Exists(agentKeyFilePath)) {
Logger.Fatal("Missing agent key file: {AgentKeyFilePath}", agentKeyFilePath);
return null;
}
try {
Files.RequireMaximumFileSize(agentKeyFilePath, 64);
return LoadFromBytes(await File.ReadAllBytesAsync(agentKeyFilePath));
} catch (IOException e) {
Logger.Fatal("Error loading agent key from file: {AgentKeyFilePath}", agentKeyFilePath);
Logger.Fatal(e.Message);
return null;
} catch (Exception) {
Logger.Fatal("File does not contain a valid agent key: {AgentKeyFilePath}", agentKeyFilePath);
return null;
}
}
private static (NetMQCertificate, AgentAuthToken)? LoadFromToken(string agentKey) {
try {
return LoadFromBytes(TokenGenerator.DecodeBytes(agentKey));
} catch (Exception) {
Logger.Fatal("Invalid agent key: {AgentKey}", agentKey);
return null;
}
}
private static (NetMQCertificate, AgentAuthToken)? LoadFromBytes(byte[] agentKey) {
var (publicKey, agentToken) = AgentKeyData.FromBytes(agentKey);
var serverCertificate = NetMQCertificate.FromPublicKey(publicKey);
Logger.Information("Loaded agent key.");
return (serverCertificate, agentToken);
}
}

View File

@@ -1,476 +0,0 @@
using Phantom.Utils.Cryptography;
namespace Phantom.Agent;
static class AgentNameGenerator {
private static readonly string[] Prefixes = {
"Abundant",
"Ancient",
"Broken",
"Crushed",
"Damaged",
"Dark",
"Divine",
"Enchanted",
"Flaming",
"Frozen",
"Gilded",
"Glowing",
"Immaculate",
"Infested",
"Invisible",
"Luxurious",
"Mossy",
"Mysterious",
"Opulent",
"Perplexing",
"Possessed",
"Pristine",
"Rotting",
"Sophisticated",
"Spectacular",
"Stunning",
"Tenacious",
"Twisted",
"Unique",
"Venerable",
"Withering"
};
private static readonly string[] Suffixes = {
"Acacia Button",
"Acacia Door",
"Acacia Fence",
"Acacia Leaves",
"Acacia Log",
"Acacia Planks",
"Acacia Sapling",
"Acacia Sign",
"Acacia Slab",
"Acacia Stairs",
"Acacia Trapdoor",
"Acacia Wood",
"Activator Rail",
"Allium",
"Ancient Debris",
"Andesite Slab",
"Andesite Stairs",
"Andesite Wall",
"Andesite",
"Anvil",
"Azalea Leaves",
"Azalea",
"Azure Bluet",
"Bamboo",
"Barrel",
"Basalt",
"Beacon",
"Bedrock",
"Bee Nest",
"Beehive",
"Beetroots",
"Bell",
"Birch Button",
"Birch Door",
"Birch Fence",
"Birch Leaves",
"Birch Log",
"Birch Planks",
"Birch Sapling",
"Birch Sign",
"Birch Slab",
"Birch Stairs",
"Birch Trapdoor",
"Birch Wood",
"Black Candle",
"Black Carpet",
"Black Concrete",
"Black Terracotta",
"Black Wool",
"Blackstone Slab",
"Blackstone Stairs",
"Blackstone Wall",
"Blackstone",
"Blast Furnace",
"Blue Candle",
"Blue Carpet",
"Blue Concrete",
"Blue Ice",
"Blue Orchid",
"Blue Terracotta",
"Blue Wool",
"Bone Block",
"Bookshelf",
"Brain Coral",
"Brewing Stand",
"Brick Slab",
"Brick Stairs",
"Brick Wall",
"Bricks",
"Brown Candle",
"Brown Carpet",
"Brown Concrete",
"Brown Mushroom",
"Brown Terracotta",
"Brown Wool",
"Bubble Column",
"Bubble Coral",
"Cactus",
"Cake",
"Calcite",
"Campfire",
"Candle",
"Carrots",
"Cartography Table",
"Carved Pumpkin",
"Cauldron",
"Cave Vines",
"Chain",
"Chest",
"Chorus Flower",
"Chorus Plant",
"Clay",
"Coal Ore",
"Coarse Dirt",
"Cobbled Deepslate",
"Cobblestone Slab",
"Cobblestone Stairs",
"Cobblestone Wall",
"Cobblestone",
"Cobweb",
"Cocoa",
"Composter",
"Conduit",
"Copper Ore",
"Cornflower",
"Crafting Table",
"Creeper Head",
"Crimson Button",
"Crimson Door",
"Crimson Fence",
"Crimson Fungus",
"Crimson Hyphae",
"Crimson Nylium",
"Crimson Planks",
"Crimson Roots",
"Crimson Sign",
"Crimson Slab",
"Crimson Stairs",
"Crimson Stem",
"Crimson Trapdoor",
"Crying Obsidian",
"Cyan Candle",
"Cyan Carpet",
"Cyan Concrete",
"Cyan Terracotta",
"Cyan Wool",
"Dandelion",
"Dead Bush",
"Deepslate Bricks",
"Deepslate Tiles",
"Deepslate",
"Detector Rail",
"Diamond Ore",
"Diorite Slab",
"Diorite Stairs",
"Diorite Wall",
"Diorite",
"Dirt",
"Dispenser",
"Dragon Egg",
"Dragon Head",
"Dripstone Block",
"Dropper",
"Emerald Ore",
"Enchanting Table",
"End Gateway",
"End Portal",
"End Rod",
"End Stone",
"Ender Chest",
"Farmland",
"Fern",
"Fire Coral",
"Fletching Table",
"Flower Pot",
"Flowering Azalea",
"Frosted Ice",
"Furnace",
"Glass Pane",
"Glass",
"Glowstone",
"Gold Ore",
"Granite Slab",
"Granite Stairs",
"Granite Wall",
"Granite",
"Grass Block",
"Grass",
"Gravel",
"Gray Candle",
"Gray Carpet",
"Gray Concrete",
"Gray Terracotta",
"Gray Wool",
"Green Candle",
"Green Carpet",
"Green Concrete",
"Green Terracotta",
"Green Wool",
"Grindstone",
"Hay Bale",
"Honey Block",
"Hopper",
"Horn Coral",
"Ice",
"Iron Bars",
"Iron Door",
"Iron Ore",
"Iron Trapdoor",
"Jack o'Lantern",
"Jigsaw Block",
"Jukebox",
"Jungle Button",
"Jungle Door",
"Jungle Fence",
"Jungle Leaves",
"Jungle Log",
"Jungle Planks",
"Jungle Sapling",
"Jungle Sign",
"Jungle Slab",
"Jungle Stairs",
"Jungle Trapdoor",
"Jungle Wood",
"Kelp Plant",
"Kelp",
"Ladder",
"Lantern",
"Large Fern",
"Lava",
"Lectern",
"Lever",
"Lightning Rod",
"Lilac",
"Lily Pad",
"Lime Candle",
"Lime Carpet",
"Lime Concrete",
"Lime Terracotta",
"Lime Wool",
"Lodestone",
"Loom",
"Magenta Candle",
"Magenta Carpet",
"Magenta Concrete",
"Magenta Terracotta",
"Magenta Wool",
"Magma Block",
"Mangrove Button",
"Mangrove Door",
"Mangrove Fence",
"Mangrove Leaves",
"Mangrove Log",
"Mangrove Planks",
"Mangrove Propagule",
"Mangrove Roots",
"Mangrove Sign",
"Mangrove Slab",
"Mangrove Stairs",
"Mangrove Trapdoor",
"Mangrove Wood",
"Melon Stem",
"Melon",
"Moss Block",
"Moss Carpet",
"Mossy Cobblestone",
"Mud Bricks",
"Mud",
"Mushroom Stem",
"Mycelium",
"Nether Bricks",
"Nether Portal",
"Nether Sprouts",
"Nether Wart",
"Netherrack",
"Note Block",
"Oak Button",
"Oak Door",
"Oak Fence",
"Oak Leaves",
"Oak Log",
"Oak Planks",
"Oak Sapling",
"Oak Sign",
"Oak Slab",
"Oak Stairs",
"Oak Trapdoor",
"Oak Wood",
"Observer",
"Obsidian",
"Ominous Banner",
"Orange Candle",
"Orange Carpet",
"Orange Concrete",
"Orange Terracotta",
"Orange Tulip",
"Orange Wool",
"Oxeye Daisy",
"Oxidized Copper",
"Packed Ice",
"Packed Mud",
"Peony",
"Pink Candle",
"Pink Carpet",
"Pink Concrete",
"Pink Terracotta",
"Pink Tulip",
"Pink Wool",
"Piston",
"Podzol",
"Pointed Dripstone",
"Polished Andesite",
"Polished Basalt",
"Polished Blackstone",
"Polished Deepslate",
"Polished Diorite",
"Polished Granite",
"Poppy",
"Potatoes",
"Powder Snow",
"Powered Rail",
"Prismarine Bricks",
"Prismarine Slab",
"Prismarine Stairs",
"Prismarine Wall",
"Prismarine",
"Pumpkin Stem",
"Pumpkin",
"Purple Candle",
"Purple Carpet",
"Purple Concrete",
"Purple Terracotta",
"Purple Wool",
"Purpur Block",
"Purpur Pillar",
"Purpur Slab",
"Purpur Stairs",
"Quartz Bricks",
"Quartz Pillar",
"Quartz Slab",
"Quartz Stairs",
"Rail",
"Red Candle",
"Red Carpet",
"Red Concrete",
"Red Mushroom",
"Red Sand",
"Red Sandstone",
"Red Terracotta",
"Red Tulip",
"Red Wool",
"Redstone Lamp",
"Redstone Ore",
"Redstone Torch",
"Redstone Wire",
"Reinforced Deepslate",
"Rooted Dirt",
"Rose Bush",
"Sand",
"Sandstone Slab",
"Sandstone Stairs",
"Sandstone Wall",
"Sandstone",
"Scaffolding",
"Sea Lantern",
"Sea Pickle",
"Seagrass",
"Shroomlight",
"Shulker Box",
"Skeleton Skull",
"Slime Block",
"Smithing Table",
"Smoker",
"Smooth Basalt",
"Smooth Sandstone",
"Smooth Stone",
"Snow Block",
"Snow",
"Soul Lantern",
"Soul Sand",
"Soul Torch",
"Sponge",
"Spore Blossom",
"Spruce Button",
"Spruce Door",
"Spruce Fence",
"Spruce Leaves",
"Spruce Log",
"Spruce Planks",
"Spruce Sapling",
"Spruce Sign",
"Spruce Slab",
"Spruce Stairs",
"Spruce Trapdoor",
"Spruce Wood",
"Sticky Piston",
"Stone Bricks",
"Stone Button",
"Stone Slab",
"Stone Stairs",
"Stone",
"Stonecutter",
"Sugar Cane",
"Sunflower",
"TNT",
"Target",
"Terracotta",
"Tinted Glass",
"Torch",
"Trapped Chest",
"Tripwire Hook",
"Tripwire",
"Tube Coral",
"Tuff",
"Turtle Egg",
"Twisting Vines",
"Verdant Froglight",
"Vines",
"Warped Button",
"Warped Door",
"Warped Fence",
"Warped Fungus",
"Warped Hyphae",
"Warped Nylium",
"Warped Planks",
"Warped Roots",
"Warped Sign",
"Warped Slab",
"Warped Stairs",
"Warped Stem",
"Warped Trapdoor",
"Water",
"Weeping Vines",
"Wheat Crops",
"White Candle",
"White Carpet",
"White Concrete",
"White Terracotta",
"White Tulip",
"White Wool",
"Wither Rose",
"Yellow Candle",
"Yellow Carpet",
"Yellow Concrete",
"Yellow Terracotta",
"Yellow Wool",
"Zombie Head"
};
public static string GenerateFrom(Guid guid) {
var rand = new Random(StableHashCode.ForString(guid.ToString()));
string prefix = Prefixes[rand.Next(Prefixes.Length)];
string suffix = Suffixes[rand.Next(Suffixes.Length)];
return string.Concat(prefix, " ", suffix);
}
}

View File

@@ -1,32 +0,0 @@
using NetMQ;
using Phantom.Common.Logging;
using Phantom.Utils.IO;
using Serilog;
namespace Phantom.Agent;
static class CertificateFile {
private static ILogger Logger { get; } = PhantomLogger.Create(typeof(CertificateFile));
public static async Task<NetMQCertificate?> LoadPublicKey(string publicKeyFilePath) {
if (!File.Exists(publicKeyFilePath)) {
Logger.Fatal("Cannot load server certificate, missing key file: {PublicKeyFilePath}", publicKeyFilePath);
return null;
}
try {
var publicKey = await LoadPublicKeyFromFile(publicKeyFilePath);
Logger.Information("Loaded server certificate.");
return publicKey;
} catch (Exception e) {
Logger.Fatal(e, "Error loading server certificate from key file: {PublicKeyFilePath}", publicKeyFilePath);
return null;
}
}
private static async Task<NetMQCertificate> LoadPublicKeyFromFile(string filePath) {
Files.RequireMaximumFileSize(filePath, 1024);
byte[] publicKey = await File.ReadAllBytesAsync(filePath);
return NetMQCertificate.FromPublicKey(publicKey);
}
}

View File

@@ -6,7 +6,7 @@ using Serilog;
namespace Phantom.Agent;
static class GuidFile {
private static ILogger Logger { get; } = PhantomLogger.Create(typeof(GuidFile));
private static ILogger Logger { get; } = PhantomLogger.Create(nameof(GuidFile));
private const string GuidFileName = "agent.guid";

View File

@@ -8,6 +8,7 @@
<PropertyGroup>
<OutputType>Exe</OutputType>
<SatelliteResourceLanguages>en</SatelliteResourceLanguages>
</PropertyGroup>
<ItemGroup>

View File

@@ -1,4 +1,5 @@
using Phantom.Agent;
using System.Reflection;
using Phantom.Agent;
using Phantom.Agent.Rpc;
using Phantom.Agent.Services;
using Phantom.Agent.Services.Rpc;
@@ -7,31 +8,25 @@ using Phantom.Common.Logging;
using Phantom.Utils.Rpc;
using Phantom.Utils.Runtime;
const int AgentVersion = 1;
const int ProtocolVersion = 1;
var cancellationTokenSource = new CancellationTokenSource();
var shutdownCancellationTokenSource = new CancellationTokenSource();
var shutdownCancellationToken = shutdownCancellationTokenSource.Token;
PosixSignals.RegisterCancellation(cancellationTokenSource, static () => {
PosixSignals.RegisterCancellation(shutdownCancellationTokenSource, static () => {
PhantomLogger.Root.InformationHeading("Stopping Phantom Panel agent...");
});
try {
var fullVersion = AssemblyAttributes.GetFullVersion(Assembly.GetExecutingAssembly());
PhantomLogger.Root.InformationHeading("Initializing Phantom Panel agent...");
PhantomLogger.Root.Information("Agent version: {Version}", fullVersion);
var (serverHost, serverPort, javaSearchPath, authToken, authTokenFilePath, agentNameOrEmpty, maxInstances, maxMemory, allowedServerPorts, allowedRconPorts) = Variables.LoadOrExit();
var (serverHost, serverPort, javaSearchPath, agentKeyToken, agentKeyFilePath, agentName, maxInstances, maxMemory, allowedServerPorts, allowedRconPorts) = Variables.LoadOrExit();
AgentAuthToken agentAuthToken;
try {
agentAuthToken = authTokenFilePath == null ? new AgentAuthToken(authToken) : await AgentAuthToken.ReadFromFile(authTokenFilePath);
} catch (Exception e) {
PhantomLogger.Root.Fatal(e, "Error reading auth token.");
Environment.Exit(1);
return;
}
string serverPublicKeyPath = Path.GetFullPath("./secrets/agent.key");
var serverCertificate = await CertificateFile.LoadPublicKey(serverPublicKeyPath);
if (serverCertificate == null) {
var agentKey = await AgentKey.Load(agentKeyToken, agentKeyFilePath);
if (agentKey == null) {
Environment.Exit(1);
}
@@ -43,22 +38,40 @@ try {
var agentGuid = await GuidFile.CreateOrLoad(folders.DataFolderPath);
if (agentGuid == null) {
Environment.Exit(1);
return;
}
var agentName = string.IsNullOrEmpty(agentNameOrEmpty) ? AgentNameGenerator.GenerateFrom(agentGuid.Value) : agentNameOrEmpty;
var agentInfo = new AgentInfo(agentGuid.Value, agentName, AgentVersion, maxInstances, maxMemory, allowedServerPorts, allowedRconPorts);
var (serverCertificate, agentToken) = agentKey.Value;
var agentInfo = new AgentInfo(agentGuid.Value, agentName, ProtocolVersion, fullVersion, maxInstances, maxMemory, allowedServerPorts, allowedRconPorts);
var agentServices = new AgentServices(agentInfo, folders);
MessageListener MessageListenerFactory(RpcServerConnection connection) {
return new MessageListener(connection, agentServices, shutdownCancellationTokenSource);
}
PhantomLogger.Root.InformationHeading("Launching Phantom Panel agent...");
await agentServices.Initialize();
await RpcLauncher.Launch(new RpcConfiguration(PhantomLogger.Create("Rpc"), serverHost, serverPort, serverCertificate, cancellationTokenSource.Token), agentAuthToken, agentInfo, socket => new MessageListener(socket, agentServices, cancellationTokenSource));
await agentServices.Shutdown();
var rpcDisconnectSemaphore = new SemaphoreSlim(0, 1);
var rpcConfiguration = new RpcConfiguration(PhantomLogger.Create("Rpc"), PhantomLogger.Create<TaskManager>("Rpc"), serverHost, serverPort, serverCertificate);
var rpcTask = RpcLauncher.Launch(rpcConfiguration, agentToken, agentInfo, MessageListenerFactory, rpcDisconnectSemaphore, shutdownCancellationToken);
try {
await rpcTask.WaitAsync(shutdownCancellationToken);
} finally {
shutdownCancellationTokenSource.Cancel();
await agentServices.Shutdown();
rpcDisconnectSemaphore.Release();
await rpcTask;
rpcDisconnectSemaphore.Dispose();
}
} catch (OperationCanceledException) {
// Ignore.
} catch (Exception e) {
PhantomLogger.Root.Fatal(e, "Caught exception in entry point.");
} finally {
cancellationTokenSource.Dispose();
shutdownCancellationTokenSource.Dispose();
PhantomLogger.Root.Information("Bye!");
PhantomLogger.Dispose();
}

View File

@@ -9,29 +9,29 @@ sealed record Variables(
string ServerHost,
ushort ServerPort,
string JavaSearchPath,
string? AuthToken,
string? AuthTokenFilePath,
string AgentNameOrEmpty,
string? AgentKeyToken,
string? AgentKeyFilePath,
string AgentName,
ushort MaxInstances,
RamAllocationUnits MaxMemory,
AllowedPorts AllowedServerPorts,
AllowedPorts AllowedRconPorts
) {
private static Variables LoadOrThrow() {
var (authToken, authTokenFilePath) = EnvironmentVariables.GetEitherString("SERVER_AUTH_TOKEN", "SERVER_AUTH_TOKEN_FILE").OrThrow;
var javaSearchPath = EnvironmentVariables.GetString("JAVA_SEARCH_PATH").OrGetDefault(GetDefaultJavaSearchPath);
var (agentKeyToken, agentKeyFilePath) = EnvironmentVariables.GetEitherString("AGENT_KEY", "AGENT_KEY_FILE").Require;
var javaSearchPath = EnvironmentVariables.GetString("JAVA_SEARCH_PATH").WithDefaultGetter(GetDefaultJavaSearchPath);
return new Variables(
EnvironmentVariables.GetString("SERVER_HOST").OrThrow,
EnvironmentVariables.GetPortNumber("SERVER_PORT").OrDefault(9401),
EnvironmentVariables.GetString("SERVER_HOST").Require,
EnvironmentVariables.GetPortNumber("SERVER_PORT").WithDefault(9401),
javaSearchPath,
authToken,
authTokenFilePath,
EnvironmentVariables.GetString("AGENT_NAME").OrDefault(string.Empty),
(ushort) EnvironmentVariables.GetInteger("MAX_INSTANCES", min: 1, max: 10000).OrThrow,
EnvironmentVariables.GetString("MAX_MEMORY").MapParse(RamAllocationUnits.FromString).OrThrow,
EnvironmentVariables.GetString("ALLOWED_SERVER_PORTS").MapParse(AllowedPorts.FromString).OrThrow,
EnvironmentVariables.GetString("ALLOWED_RCON_PORTS").MapParse(AllowedPorts.FromString).OrThrow
agentKeyToken,
agentKeyFilePath,
EnvironmentVariables.GetString("AGENT_NAME").Require,
(ushort) EnvironmentVariables.GetInteger("MAX_INSTANCES", min: 1, max: 10000).Require,
EnvironmentVariables.GetString("MAX_MEMORY").MapParse(RamAllocationUnits.FromString).Require,
EnvironmentVariables.GetString("ALLOWED_SERVER_PORTS").MapParse(AllowedPorts.FromString).Require,
EnvironmentVariables.GetString("ALLOWED_RCON_PORTS").MapParse(AllowedPorts.FromString).Require
);
}

View File

@@ -11,11 +11,11 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.3.2" />
<PackageReference Include="NUnit" Version="3.13.3" />
<PackageReference Include="NUnit3TestAdapter" Version="4.2.1" />
<PackageReference Include="NUnit.Analyzers" Version="3.3.0" />
<PackageReference Include="coverlet.collector" Version="3.1.2" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="NUnit" />
<PackageReference Include="NUnit3TestAdapter" />
<PackageReference Include="NUnit.Analyzers" />
<PackageReference Include="coverlet.collector" />
</ItemGroup>
<ItemGroup>

View File

@@ -1,56 +1,39 @@
using System.Diagnostics.CodeAnalysis;
using System.Security.Cryptography;
using System.Text;
using MessagePack;
using Phantom.Utils.Cryptography;
using Phantom.Utils.IO;
using MemoryPack;
namespace Phantom.Common.Data.Agent;
[MessagePackObject]
[MemoryPackable]
[SuppressMessage("ReSharper", "MemberCanBePrivate.Global")]
public sealed class AgentAuthToken {
private const int MinimumTokenLength = 30;
private const int MaximumTokenLength = 100;
public sealed partial class AgentAuthToken {
internal const int Length = 12;
[Key(0)]
public string Value { get; }
[IgnoreMember]
[MemoryPackOrder(0)]
[MemoryPackInclude]
private readonly byte[] bytes;
public AgentAuthToken(string? value) {
if (value == null) {
throw new ArgumentNullException(nameof(value));
internal AgentAuthToken(byte[]? bytes) {
if (bytes == null) {
throw new ArgumentNullException(nameof(bytes));
}
if (value.Length is < MinimumTokenLength or > MaximumTokenLength) {
throw new ArgumentOutOfRangeException(nameof(value), "Invalid token length: " + value.Length + ". Token length must be between " + MinimumTokenLength + " and " + MaximumTokenLength + ".");
if (bytes.Length != Length) {
throw new ArgumentOutOfRangeException(nameof(bytes), "Invalid token length: " + bytes.Length + ". Token length must be exactly " + Length + " bytes.");
}
this.Value = value;
this.bytes = TokenGenerator.GetBytesOrThrow(value);
this.bytes = bytes;
}
public bool FixedTimeEquals(AgentAuthToken providedAuthToken) {
return CryptographicOperations.FixedTimeEquals(bytes, providedAuthToken.bytes);
}
public override string ToString() {
return Value;
}
public async Task WriteToFile(string filePath) {
await Files.WriteBytesAsync(filePath, bytes, FileMode.Create, Chmod.URW_GR);
}
public static async Task<AgentAuthToken> ReadFromFile(string filePath) {
Files.RequireMaximumFileSize(filePath, MaximumTokenLength + 1);
string contents = await File.ReadAllTextAsync(filePath, Encoding.ASCII);
return new AgentAuthToken(contents.Trim());
internal void WriteTo(Span<byte> span) {
bytes.CopyTo(span);
}
public static AgentAuthToken Generate() {
return new AgentAuthToken(TokenGenerator.Create(MinimumTokenLength));
return new AgentAuthToken(RandomNumberGenerator.GetBytes(Length));
}
}

View File

@@ -1,14 +1,15 @@
using MessagePack;
using MemoryPack;
namespace Phantom.Common.Data.Agent;
[MessagePackObject]
public sealed record AgentInfo(
[property: Key(0)] Guid Guid,
[property: Key(1)] string Name,
[property: Key(2)] ushort Version,
[property: Key(3)] ushort MaxInstances,
[property: Key(4)] RamAllocationUnits MaxMemory,
[property: Key(5)] AllowedPorts AllowedServerPorts,
[property: Key(6)] AllowedPorts AllowedRconPorts
[MemoryPackable]
public sealed partial record AgentInfo(
[property: MemoryPackOrder(0)] Guid Guid,
[property: MemoryPackOrder(1)] string Name,
[property: MemoryPackOrder(2)] ushort ProtocolVersion,
[property: MemoryPackOrder(3)] string BuildVersion,
[property: MemoryPackOrder(4)] ushort MaxInstances,
[property: MemoryPackOrder(5)] RamAllocationUnits MaxMemory,
[property: MemoryPackOrder(6)] AllowedPorts AllowedServerPorts,
[property: MemoryPackOrder(7)] AllowedPorts AllowedRconPorts
);

View File

@@ -0,0 +1,18 @@
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);
}
}

View File

@@ -1,29 +1,28 @@
using System.Collections.Immutable;
using System.Diagnostics.CodeAnalysis;
using System.Text;
using MessagePack;
using MemoryPack;
namespace Phantom.Common.Data;
[MessagePackObject]
[SuppressMessage("ReSharper", "MemberCanBePrivate.Global")]
public sealed class AllowedPorts {
[Key(0)]
public ImmutableArray<PortRange> AllDefinitions { get; }
[MemoryPackable]
public sealed partial class AllowedPorts {
[MemoryPackOrder(0)]
[MemoryPackInclude]
private readonly ImmutableArray<PortRange> allDefinitions;
public AllowedPorts(ImmutableArray<PortRange> allDefinitions) {
private AllowedPorts(ImmutableArray<PortRange> allDefinitions) {
// TODO normalize and deduplicate ranges
this.AllDefinitions = allDefinitions.Sort(static (def1, def2) => def1.FirstPort - def2.FirstPort);
this.allDefinitions = allDefinitions.Sort(static (def1, def2) => def1.FirstPort - def2.FirstPort);
}
public bool Contains(ushort port) {
return AllDefinitions.Any(definition => definition.Contains(port));
return allDefinitions.Any(definition => definition.Contains(port));
}
public override string ToString() {
var builder = new StringBuilder();
foreach (var definition in AllDefinitions) {
foreach (var definition in allDefinitions) {
definition.ToString(builder);
builder.Append(',');
}
@@ -35,53 +34,7 @@ public sealed class AllowedPorts {
return builder.ToString();
}
[MessagePackObject]
public readonly record struct PortRange(
[property: Key(0)] ushort FirstPort,
[property: Key(1)] ushort LastPort
) {
private PortRange(ushort port) : this(port, port) {}
internal bool Contains(ushort port) {
return port >= FirstPort && port <= LastPort;
}
internal void ToString(StringBuilder builder) {
builder.Append(FirstPort);
if (LastPort != FirstPort) {
builder.Append('-');
builder.Append(LastPort);
}
}
internal static PortRange Parse(ReadOnlySpan<char> definition) {
int separatorIndex = definition.IndexOf('-');
if (separatorIndex == -1) {
var port = ParsePort(definition.Trim());
return new PortRange(port);
}
var firstPort = ParsePort(definition[..separatorIndex].Trim());
var lastPort = ParsePort(definition[(separatorIndex + 1)..].Trim());
if (lastPort < firstPort) {
throw new FormatException("Invalid port range '" + firstPort + "-" + lastPort + "'.");
}
else {
return new PortRange(firstPort, lastPort);
}
}
private static ushort ParsePort(ReadOnlySpan<char> port) {
try {
return ushort.Parse(port);
} catch (Exception) {
throw new FormatException("Invalid port '" + port.ToString() + "'.");
}
}
}
public static AllowedPorts FromString(ReadOnlySpan<char> definitions) {
private static AllowedPorts FromString(ReadOnlySpan<char> definitions) {
List<PortRange> parsedDefinitions = new ();
while (!definitions.IsEmpty) {

View File

@@ -0,0 +1,67 @@
using MemoryPack;
namespace Phantom.Common.Data.Instance;
[MemoryPackable]
[MemoryPackUnion(0, typeof(InstanceIsOffline))]
[MemoryPackUnion(1, typeof(InstanceIsInvalid))]
[MemoryPackUnion(2, typeof(InstanceIsNotRunning))]
[MemoryPackUnion(3, typeof(InstanceIsDownloading))]
[MemoryPackUnion(4, typeof(InstanceIsLaunching))]
[MemoryPackUnion(5, typeof(InstanceIsRunning))]
[MemoryPackUnion(6, typeof(InstanceIsRestarting))]
[MemoryPackUnion(7, typeof(InstanceIsStopping))]
[MemoryPackUnion(8, typeof(InstanceIsFailed))]
public partial interface IInstanceStatus {}
[MemoryPackable]
public sealed partial record InstanceIsOffline : IInstanceStatus;
[MemoryPackable]
public sealed partial record InstanceIsInvalid([property: MemoryPackOrder(0)] string Reason) : IInstanceStatus;
[MemoryPackable]
public sealed partial record InstanceIsNotRunning : IInstanceStatus;
[MemoryPackable]
public sealed partial record InstanceIsDownloading([property: MemoryPackOrder(0)] byte Progress) : IInstanceStatus;
[MemoryPackable]
public sealed partial record InstanceIsLaunching : IInstanceStatus;
[MemoryPackable]
public sealed partial record InstanceIsRunning : IInstanceStatus;
[MemoryPackable]
public sealed partial record InstanceIsRestarting : IInstanceStatus;
[MemoryPackable]
public sealed partial record InstanceIsStopping : IInstanceStatus;
[MemoryPackable]
public sealed partial record InstanceIsFailed([property: MemoryPackOrder(0)] InstanceLaunchFailReason Reason) : IInstanceStatus;
public static class InstanceStatus {
public static readonly IInstanceStatus Offline = new InstanceIsOffline();
public static readonly IInstanceStatus NotRunning = new InstanceIsNotRunning();
public static readonly IInstanceStatus Launching = new InstanceIsLaunching();
public static readonly IInstanceStatus Running = new InstanceIsRunning();
public static readonly IInstanceStatus Restarting = new InstanceIsRestarting();
public static readonly IInstanceStatus Stopping = new InstanceIsStopping();
public static IInstanceStatus Invalid(string reason) => new InstanceIsInvalid(reason);
public static IInstanceStatus Downloading(byte progress) => new InstanceIsDownloading(progress);
public static IInstanceStatus Failed(InstanceLaunchFailReason reason) => new InstanceIsFailed(reason);
public static bool CanLaunch(this IInstanceStatus status) {
return status is InstanceIsNotRunning or InstanceIsFailed;
}
public static bool CanStop(this IInstanceStatus status) {
return status is InstanceIsDownloading or InstanceIsLaunching or InstanceIsRunning;
}
public static bool CanSendCommand(this IInstanceStatus status) {
return status is InstanceIsRunning;
}
}

View File

@@ -1,18 +1,20 @@
using MessagePack;
using System.Collections.Immutable;
using MemoryPack;
using Phantom.Common.Data.Minecraft;
namespace Phantom.Common.Data.Instance;
[MessagePackObject]
public sealed record InstanceConfiguration(
[property: Key(0)] Guid AgentGuid,
[property: Key(1)] Guid InstanceGuid,
[property: Key(2)] string InstanceName,
[property: Key(3)] ushort ServerPort,
[property: Key(4)] ushort RconPort,
[property: Key(5)] string MinecraftVersion,
[property: Key(6)] MinecraftServerKind MinecraftServerKind,
[property: Key(7)] RamAllocationUnits MemoryAllocation,
[property: Key(8)] Guid JavaRuntimeGuid,
[property: Key(9)] bool LaunchAutomatically
[MemoryPackable]
public sealed partial record InstanceConfiguration(
[property: MemoryPackOrder(0)] Guid AgentGuid,
[property: MemoryPackOrder(1)] Guid InstanceGuid,
[property: MemoryPackOrder(2)] string InstanceName,
[property: MemoryPackOrder(3)] ushort ServerPort,
[property: MemoryPackOrder(4)] ushort RconPort,
[property: MemoryPackOrder(5)] string MinecraftVersion,
[property: MemoryPackOrder(6)] MinecraftServerKind MinecraftServerKind,
[property: MemoryPackOrder(7)] RamAllocationUnits MemoryAllocation,
[property: MemoryPackOrder(8)] Guid JavaRuntimeGuid,
[property: MemoryPackOrder(9)] ImmutableArray<string> JvmArguments,
[property: MemoryPackOrder(10)] bool LaunchAutomatically
);

View File

@@ -1,25 +1,31 @@
namespace Phantom.Common.Data.Instance;
public enum InstanceLaunchFailReason {
UnknownError,
ServerPortNotAllowed,
ServerPortAlreadyInUse,
RconPortNotAllowed,
RconPortAlreadyInUse,
JavaRuntimeNotFound,
InvalidJvmArguments,
CouldNotDownloadMinecraftServer,
UnknownError
CouldNotConfigureMinecraftServer,
CouldNotStartMinecraftServer
}
public static class InstanceLaunchFailReasonExtensions {
public static string ToSentence(this InstanceLaunchFailReason reason) {
return reason switch {
InstanceLaunchFailReason.ServerPortNotAllowed => "Server port not allowed.",
InstanceLaunchFailReason.ServerPortAlreadyInUse => "Server port already in use.",
InstanceLaunchFailReason.RconPortNotAllowed => "Rcon port not allowed.",
InstanceLaunchFailReason.RconPortAlreadyInUse => "Rcon port already in use.",
InstanceLaunchFailReason.JavaRuntimeNotFound => "Java runtime not found.",
InstanceLaunchFailReason.CouldNotDownloadMinecraftServer => "Could not download Minecraft server.",
_ => "Unknown error."
InstanceLaunchFailReason.ServerPortNotAllowed => "Server port not allowed.",
InstanceLaunchFailReason.ServerPortAlreadyInUse => "Server port already in use.",
InstanceLaunchFailReason.RconPortNotAllowed => "Rcon port not allowed.",
InstanceLaunchFailReason.RconPortAlreadyInUse => "Rcon port already in use.",
InstanceLaunchFailReason.JavaRuntimeNotFound => "Java runtime not found.",
InstanceLaunchFailReason.InvalidJvmArguments => "Invalid JVM arguments.",
InstanceLaunchFailReason.CouldNotDownloadMinecraftServer => "Could not download Minecraft server.",
InstanceLaunchFailReason.CouldNotConfigureMinecraftServer => "Could not configure Minecraft server.",
InstanceLaunchFailReason.CouldNotStartMinecraftServer => "Could not start Minecraft server.",
_ => "Unknown error."
};
}
}

View File

@@ -1,63 +0,0 @@
using MessagePack;
namespace Phantom.Common.Data.Instance;
[Union(0, typeof(Offline))]
[Union(1, typeof(Invalid))]
[Union(2, typeof(NotRunning))]
[Union(3, typeof(Downloading))]
[Union(4, typeof(Launching))]
[Union(5, typeof(Running))]
[Union(6, typeof(Stopping))]
[Union(7, typeof(Failed))]
public abstract record InstanceStatus {
public static readonly InstanceStatus IsOffline = new Offline();
public static readonly InstanceStatus IsNotRunning = new NotRunning();
public static readonly InstanceStatus IsLaunching = new Launching();
public static readonly InstanceStatus IsRunning = new Running();
public static readonly InstanceStatus IsStopping = new Stopping();
[MessagePackObject]
public sealed record Offline : InstanceStatus;
[MessagePackObject]
public sealed record Invalid(
[property: Key(0)] string Reason
) : InstanceStatus;
[MessagePackObject]
public sealed record NotRunning : InstanceStatus;
[MessagePackObject]
public sealed record Downloading(
[property: Key(0)] byte Progress
) : InstanceStatus;
[MessagePackObject]
public sealed record Launching : InstanceStatus;
[MessagePackObject]
public sealed record Running : InstanceStatus;
[MessagePackObject]
public sealed record Stopping : InstanceStatus;
[MessagePackObject]
public sealed record Failed(
[property: Key(0)] InstanceLaunchFailReason Reason
) : InstanceStatus;
}
public static class InstanceStatusExtensions {
public static bool CanLaunch(this InstanceStatus status) {
return status is InstanceStatus.NotRunning or InstanceStatus.Failed;
}
public static bool CanStop(this InstanceStatus status) {
return status is InstanceStatus.Downloading or InstanceStatus.Launching or InstanceStatus.Running;
}
public static bool CanSendCommand(this InstanceStatus status) {
return status is InstanceStatus.Running;
}
}

View File

@@ -1,13 +1,13 @@
using System.Diagnostics.CodeAnalysis;
using MessagePack;
using MemoryPack;
namespace Phantom.Common.Data.Java;
[MessagePackObject]
public sealed record JavaRuntime(
[property: Key(0)] string MainVersion,
[property: Key(1)] string FullVersion,
[property: Key(2)] string DisplayName
[MemoryPackable]
public sealed partial record JavaRuntime(
[property: MemoryPackOrder(0)] string MainVersion,
[property: MemoryPackOrder(1)] string FullVersion,
[property: MemoryPackOrder(2)] string DisplayName
) : IComparable<JavaRuntime> {
public int CompareTo(JavaRuntime? other) {
if (ReferenceEquals(this, other)) {

View File

@@ -1,9 +1,9 @@
using MessagePack;
using MemoryPack;
namespace Phantom.Common.Data.Java;
[MessagePackObject]
public sealed record TaggedJavaRuntime(
[property: Key(0)] Guid Guid,
[property: Key(1)] JavaRuntime Runtime
[MemoryPackable]
public sealed partial record TaggedJavaRuntime(
[property: MemoryPackOrder(0)] Guid Guid,
[property: MemoryPackOrder(1)] JavaRuntime Runtime
);

View File

@@ -0,0 +1,10 @@
using MemoryPack;
namespace Phantom.Common.Data.Minecraft;
[MemoryPackable]
public readonly partial record struct MinecraftStopStrategy(
[property: MemoryPackOrder(0)] ushort Seconds
) {
public static MinecraftStopStrategy Instant => new (0);
}

View File

@@ -0,0 +1,7 @@
namespace Phantom.Common.Data.Minecraft;
public sealed record MinecraftVersion(
string Id,
MinecraftVersionType Type,
string MetadataUrl
);

View File

@@ -0,0 +1,38 @@
using System.Collections.Immutable;
namespace Phantom.Common.Data.Minecraft;
public enum MinecraftVersionType : ushort {
Other = 0,
Release = 1,
Snapshot = 2,
OldBeta = 3,
OldAlpha = 4
}
public static class MinecraftVersionTypes {
public static readonly ImmutableArray<MinecraftVersionType> WithServerJars = ImmutableArray.Create(
MinecraftVersionType.Release,
MinecraftVersionType.Snapshot
);
public static MinecraftVersionType FromString(string? type) {
return type switch {
"release" => MinecraftVersionType.Release,
"snapshot" => MinecraftVersionType.Snapshot,
"old_beta" => MinecraftVersionType.OldBeta,
"old_alpha" => MinecraftVersionType.OldAlpha,
_ => MinecraftVersionType.Other
};
}
public static string ToNiceNamePlural(this MinecraftVersionType type) {
return type switch {
MinecraftVersionType.Release => "Releases",
MinecraftVersionType.Snapshot => "Snapshots",
MinecraftVersionType.OldBeta => "Beta",
MinecraftVersionType.OldAlpha => "Alpha",
_ => "Unknown"
};
}
}

View File

@@ -7,7 +7,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MessagePack.Annotations" Version="2.4.35" />
<PackageReference Include="MemoryPack" />
</ItemGroup>
<ItemGroup>

View File

@@ -0,0 +1,48 @@
using System.Text;
using MemoryPack;
namespace Phantom.Common.Data;
[MemoryPackable]
readonly partial record struct PortRange(
[property: MemoryPackOrder(0)] ushort FirstPort,
[property: MemoryPackOrder(1)] ushort LastPort
) {
internal bool Contains(ushort port) {
return port >= FirstPort && port <= LastPort;
}
internal void ToString(StringBuilder builder) {
builder.Append(FirstPort);
if (LastPort != FirstPort) {
builder.Append('-');
builder.Append(LastPort);
}
}
internal static PortRange Parse(ReadOnlySpan<char> definition) {
int separatorIndex = definition.IndexOf('-');
if (separatorIndex == -1) {
var port = ParsePort(definition.Trim());
return new PortRange(port, port);
}
var firstPort = ParsePort(definition[..separatorIndex].Trim());
var lastPort = ParsePort(definition[(separatorIndex + 1)..].Trim());
if (lastPort < firstPort) {
throw new FormatException("Invalid port range '" + firstPort + "-" + lastPort + "'.");
}
else {
return new PortRange(firstPort, lastPort);
}
}
private static ushort ParsePort(ReadOnlySpan<char> port) {
try {
return ushort.Parse(port);
} catch (Exception) {
throw new FormatException("Invalid port '" + port.ToString() + "'.");
}
}
}

View File

@@ -1,17 +1,17 @@
using System.Diagnostics.CodeAnalysis;
using MessagePack;
using MemoryPack;
namespace Phantom.Common.Data;
/// <summary>
/// Represents a number of RAM allocation units, using the conversion factor of 256 MB per unit. Supports allocations up to 16 TB minus 256 MB (65535 units).
/// </summary>
[MessagePackObject]
[MemoryPackable]
[SuppressMessage("ReSharper", "MemberCanBePrivate.Global")]
public readonly record struct RamAllocationUnits(
[property: Key(0)] ushort RawValue
public readonly partial record struct RamAllocationUnits(
[property: MemoryPackOrder(0)] ushort RawValue
) : IComparable<RamAllocationUnits> {
[IgnoreMember]
[MemoryPackIgnore]
public uint InMegabytes => (uint) RawValue * MegabytesPerUnit;
public int CompareTo(RamAllocationUnits other) {

View File

@@ -1,8 +1,7 @@
namespace Phantom.Common.Data.Replies;
public enum ConfigureInstanceResult {
public enum ConfigureInstanceResult : byte {
Success,
AgentShuttingDown,
InstanceLimitExceeded,
MemoryLimitExceeded
}

View File

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

View File

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

View File

@@ -1,13 +1,10 @@
namespace Phantom.Common.Data.Replies;
public enum LaunchInstanceResult {
public enum LaunchInstanceResult : byte {
LaunchInitiated,
AgentShuttingDown,
InstanceDoesNotExist,
InstanceAlreadyLaunching,
InstanceAlreadyRunning,
InstanceIsStopping,
CommunicationError,
UnknownError
}
@@ -15,12 +12,9 @@ public static class LaunchInstanceResultExtensions {
public static string ToSentence(this LaunchInstanceResult reason) {
return reason switch {
LaunchInstanceResult.LaunchInitiated => "Launch initiated.",
LaunchInstanceResult.AgentShuttingDown => "Agent is shutting down.",
LaunchInstanceResult.InstanceDoesNotExist => "Instance does not exist.",
LaunchInstanceResult.InstanceAlreadyLaunching => "Instance is already launching.",
LaunchInstanceResult.InstanceAlreadyRunning => "Instance is already running.",
LaunchInstanceResult.InstanceIsStopping => "Instance is stopping.",
LaunchInstanceResult.CommunicationError => "Communication error.",
_ => "Unknown error."
};
}

View File

@@ -1,21 +1,15 @@
namespace Phantom.Common.Data.Replies;
public enum SendCommandToInstanceResult {
public enum SendCommandToInstanceResult : byte {
Success,
InstanceDoesNotExist,
AgentShuttingDown,
AgentCommunicationError,
UnknownError
}
public static class SendCommandToInstanceResultExtensions {
public static string ToSentence(this SendCommandToInstanceResult reason) {
return reason switch {
SendCommandToInstanceResult.Success => "Command sent.",
SendCommandToInstanceResult.InstanceDoesNotExist => "Instance does not exist.",
SendCommandToInstanceResult.AgentShuttingDown => "Agent is shutting down.",
SendCommandToInstanceResult.AgentCommunicationError => "Agent did not reply in time.",
_ => "Unknown error."
SendCommandToInstanceResult.Success => "Command sent.",
_ => "Unknown error."
};
}
}

View File

@@ -1,12 +1,9 @@
namespace Phantom.Common.Data.Replies;
public enum StopInstanceResult {
public enum StopInstanceResult : byte {
StopInitiated,
AgentShuttingDown,
InstanceDoesNotExist,
InstanceAlreadyStopping,
InstanceAlreadyStopped,
CommunicationError,
UnknownError
}
@@ -14,11 +11,8 @@ public static class StopInstanceResultExtensions {
public static string ToSentence(this StopInstanceResult reason) {
return reason switch {
StopInstanceResult.StopInitiated => "Stopping initiated.",
StopInstanceResult.AgentShuttingDown => "Agent is shutting down.",
StopInstanceResult.InstanceDoesNotExist => "Instance does not exist.",
StopInstanceResult.InstanceAlreadyStopping => "Instance is already stopping.",
StopInstanceResult.InstanceAlreadyStopped => "Instance is already stopped.",
StopInstanceResult.CommunicationError => "Communication error.",
_ => "Unknown error."
};
}

View File

@@ -7,8 +7,8 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Serilog" Version="2.12.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="4.1.0" />
<PackageReference Include="Serilog" />
<PackageReference Include="Serilog.Sinks.Console" />
</ItemGroup>
</Project>

View File

@@ -33,8 +33,8 @@ public static class PhantomLogger {
return Base.ForContext("Category", name);
}
public static ILogger Create(Type type) {
return Create(type.Name);
public static ILogger Create(string name1, string name2) {
return Create(name1 + ":" + name2);
}
public static ILogger Create<T>() {
@@ -42,11 +42,11 @@ public static class PhantomLogger {
}
public static ILogger Create<T>(string name) {
return Create(typeof(T).Name + ":" + name);
return Create(typeof(T).Name, name);
}
public static ILogger Create<T1, T2>() {
return Create(typeof(T1).Name + ":" + typeof(T2).Name);
return Create(typeof(T1).Name, typeof(T2).Name);
}
public static void Dispose() {

View File

@@ -0,0 +1,18 @@
using MemoryPack;
using Phantom.Utils.Rpc.Message;
namespace Phantom.Common.Messages.BiDirectional;
[MemoryPackable]
public sealed partial record ReplyMessage(
[property: MemoryPackOrder(0)] uint SequenceId,
[property: MemoryPackOrder(1)] byte[] SerializedReply
) : IMessageToServer, IMessageToAgent {
public Task<NoReply> Accept(IMessageToServerListener listener) {
return listener.HandleReply(this);
}
public Task<NoReply> Accept(IMessageToAgentListener listener) {
return listener.HandleReply(this);
}
}

View File

@@ -2,4 +2,6 @@
namespace Phantom.Common.Messages;
public interface IMessageToAgent : IMessage<IMessageToAgentListener> {}
public interface IMessageToAgent<TReply> : IMessage<IMessageToAgentListener, TReply> {}
public interface IMessageToAgent : IMessageToAgent<NoReply> {}

View File

@@ -1,12 +1,16 @@
using Phantom.Common.Messages.ToAgent;
using Phantom.Common.Data.Replies;
using Phantom.Common.Messages.BiDirectional;
using Phantom.Common.Messages.ToAgent;
using Phantom.Utils.Rpc.Message;
namespace Phantom.Common.Messages;
public interface IMessageToAgentListener {
Task HandleRegisterAgentSuccessResult(RegisterAgentSuccessMessage message);
Task HandleRegisterAgentFailureResult(RegisterAgentFailureMessage message);
Task HandleConfigureInstance(ConfigureInstanceMessage message);
Task HandleLaunchInstance(LaunchInstanceMessage message);
Task HandleStopInstance(StopInstanceMessage message);
Task HandleSendCommandToInstance(SendCommandToInstanceMessage message);
Task<NoReply> HandleRegisterAgentSuccess(RegisterAgentSuccessMessage message);
Task<NoReply> HandleRegisterAgentFailure(RegisterAgentFailureMessage message);
Task<InstanceActionResult<ConfigureInstanceResult>> HandleConfigureInstance(ConfigureInstanceMessage message);
Task<InstanceActionResult<LaunchInstanceResult>> HandleLaunchInstance(LaunchInstanceMessage message);
Task<InstanceActionResult<StopInstanceResult>> HandleStopInstance(StopInstanceMessage message);
Task<InstanceActionResult<SendCommandToInstanceResult>> HandleSendCommandToInstance(SendCommandToInstanceMessage message);
Task<NoReply> HandleReply(ReplyMessage message);
}

View File

@@ -2,4 +2,6 @@
namespace Phantom.Common.Messages;
public interface IMessageToServer : IMessage<IMessageToServerListener> {}
public interface IMessageToServer<TReply> : IMessage<IMessageToServerListener, TReply> {}
public interface IMessageToServer : IMessageToServer<NoReply> {}

View File

@@ -1,14 +1,16 @@
using Phantom.Common.Messages.ToServer;
using Phantom.Common.Messages.BiDirectional;
using Phantom.Common.Messages.ToServer;
using Phantom.Utils.Rpc.Message;
namespace Phantom.Common.Messages;
public interface IMessageToServerListener {
bool IsDisposed { get; }
Task HandleRegisterAgent(RegisterAgentMessage message);
Task HandleUnregisterAgent(UnregisterAgentMessage message);
Task HandleAgentIsAlive(AgentIsAliveMessage message);
Task HandleAdvertiseJavaRuntimes(AdvertiseJavaRuntimesMessage message);
Task HandleReportInstanceStatus(ReportInstanceStatusMessage message);
Task HandleInstanceOutput(InstanceOutputMessage message);
Task HandleSimpleReply(SimpleReplyMessage message);
Task<NoReply> HandleRegisterAgent(RegisterAgentMessage message);
Task<NoReply> HandleUnregisterAgent(UnregisterAgentMessage message);
Task<NoReply> HandleAgentIsAlive(AgentIsAliveMessage message);
Task<NoReply> HandleAdvertiseJavaRuntimes(AdvertiseJavaRuntimesMessage message);
Task<NoReply> HandleReportInstanceStatus(ReportInstanceStatusMessage message);
Task<NoReply> HandleInstanceOutput(InstanceOutputMessage message);
Task<NoReply> HandleReply(ReplyMessage message);
}

View File

@@ -1,5 +0,0 @@
namespace Phantom.Common.Messages;
public interface IMessageWithReply {
public uint SequenceId { get; }
}

View File

@@ -1,4 +1,6 @@
using Phantom.Common.Logging;
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;
@@ -6,16 +8,17 @@ using Phantom.Utils.Rpc.Message;
namespace Phantom.Common.Messages;
public static class MessageRegistries {
public static MessageRegistry<IMessageToAgentListener, IMessageToAgent> ToAgent { get; } = new (PhantomLogger.Create("MessageRegistry:ToAgent"));
public static MessageRegistry<IMessageToServerListener, IMessageToServer> ToServer { get; } = new (PhantomLogger.Create("MessageRegistry:ToServer"));
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>(2);
ToAgent.Add<LaunchInstanceMessage>(3);
ToAgent.Add<StopInstanceMessage>(4);
ToAgent.Add<SendCommandToInstanceMessage>(5);
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);
@@ -23,6 +26,6 @@ public static class MessageRegistries {
ToServer.Add<AdvertiseJavaRuntimesMessage>(3);
ToServer.Add<ReportInstanceStatusMessage>(4);
ToServer.Add<InstanceOutputMessage>(5);
ToServer.Add<SimpleReplyMessage>(127);
ToServer.Add<ReplyMessage>(127);
}
}

View File

@@ -1,14 +1,14 @@
using MessagePack;
using MemoryPack;
using Phantom.Common.Data.Instance;
using Phantom.Common.Data.Replies;
namespace Phantom.Common.Messages.ToAgent;
[MessagePackObject]
public sealed record ConfigureInstanceMessage(
[property: Key(0)] uint SequenceId,
[property: Key(1)] InstanceConfiguration Configuration
) : IMessageToAgent, IMessageWithReply {
public Task Accept(IMessageToAgentListener listener) {
[MemoryPackable]
public sealed partial record ConfigureInstanceMessage(
[property: MemoryPackOrder(0)] InstanceConfiguration Configuration
) : IMessageToAgent<InstanceActionResult<ConfigureInstanceResult>> {
public Task<InstanceActionResult<ConfigureInstanceResult>> Accept(IMessageToAgentListener listener) {
return listener.HandleConfigureInstance(this);
}
}

View File

@@ -1,13 +1,13 @@
using MessagePack;
using MemoryPack;
using Phantom.Common.Data.Replies;
namespace Phantom.Common.Messages.ToAgent;
[MessagePackObject]
public sealed record LaunchInstanceMessage(
[property: Key(0)] uint SequenceId,
[property: Key(1)] Guid InstanceGuid
) : IMessageToAgent, IMessageWithReply {
public Task Accept(IMessageToAgentListener listener) {
[MemoryPackable]
public sealed partial record LaunchInstanceMessage(
[property: MemoryPackOrder(0)] Guid InstanceGuid
) : IMessageToAgent<InstanceActionResult<LaunchInstanceResult>> {
public Task<InstanceActionResult<LaunchInstanceResult>> Accept(IMessageToAgentListener listener) {
return listener.HandleLaunchInstance(this);
}
}

View File

@@ -1,13 +1,14 @@
using MessagePack;
using MemoryPack;
using Phantom.Common.Data.Replies;
using Phantom.Utils.Rpc.Message;
namespace Phantom.Common.Messages.ToAgent;
[MessagePackObject]
public sealed record RegisterAgentFailureMessage(
[property: Key(0)] RegisterAgentFailure FailureKind
[MemoryPackable]
public sealed partial record RegisterAgentFailureMessage(
[property: MemoryPackOrder(0)] RegisterAgentFailure FailureKind
) : IMessageToAgent {
public Task Accept(IMessageToAgentListener listener) {
return listener.HandleRegisterAgentFailureResult(this);
public Task<NoReply> Accept(IMessageToAgentListener listener) {
return listener.HandleRegisterAgentFailure(this);
}
}

View File

@@ -1,14 +1,15 @@
using System.Collections.Immutable;
using MessagePack;
using MemoryPack;
using Phantom.Common.Data.Instance;
using Phantom.Utils.Rpc.Message;
namespace Phantom.Common.Messages.ToAgent;
[MessagePackObject]
public sealed record RegisterAgentSuccessMessage(
[property: Key(0)] ImmutableArray<InstanceConfiguration> InitialInstances
[MemoryPackable]
public sealed partial record RegisterAgentSuccessMessage(
[property: MemoryPackOrder(0)] ImmutableArray<InstanceConfiguration> InitialInstances
) : IMessageToAgent {
public Task Accept(IMessageToAgentListener listener) {
return listener.HandleRegisterAgentSuccessResult(this);
public Task<NoReply> Accept(IMessageToAgentListener listener) {
return listener.HandleRegisterAgentSuccess(this);
}
}

View File

@@ -1,14 +1,14 @@
using MessagePack;
using MemoryPack;
using Phantom.Common.Data.Replies;
namespace Phantom.Common.Messages.ToAgent;
[MessagePackObject]
public sealed record SendCommandToInstanceMessage(
[property: Key(0)] uint SequenceId,
[property: Key(1)] Guid InstanceGuid,
[property: Key(2)] string Command
) : IMessageToAgent, IMessageWithReply {
public Task Accept(IMessageToAgentListener listener) {
[MemoryPackable]
public sealed partial record SendCommandToInstanceMessage(
[property: MemoryPackOrder(0)] Guid InstanceGuid,
[property: MemoryPackOrder(1)] string Command
) : IMessageToAgent<InstanceActionResult<SendCommandToInstanceResult>> {
public Task<InstanceActionResult<SendCommandToInstanceResult>> Accept(IMessageToAgentListener listener) {
return listener.HandleSendCommandToInstance(this);
}
}

View File

@@ -1,13 +1,15 @@
using MessagePack;
using MemoryPack;
using Phantom.Common.Data.Minecraft;
using Phantom.Common.Data.Replies;
namespace Phantom.Common.Messages.ToAgent;
[MessagePackObject]
public sealed record StopInstanceMessage(
[property: Key(0)] uint SequenceId,
[property: Key(1)] Guid InstanceGuid
) : IMessageToAgent, IMessageWithReply {
public Task Accept(IMessageToAgentListener listener) {
[MemoryPackable]
public sealed partial record StopInstanceMessage(
[property: MemoryPackOrder(0)] Guid InstanceGuid,
[property: MemoryPackOrder(1)] MinecraftStopStrategy StopStrategy
) : IMessageToAgent<InstanceActionResult<StopInstanceResult>> {
public Task<InstanceActionResult<StopInstanceResult>> Accept(IMessageToAgentListener listener) {
return listener.HandleStopInstance(this);
}
}

View File

@@ -1,14 +1,15 @@
using System.Collections.Immutable;
using MessagePack;
using MemoryPack;
using Phantom.Common.Data.Java;
using Phantom.Utils.Rpc.Message;
namespace Phantom.Common.Messages.ToServer;
[MessagePackObject]
public sealed record AdvertiseJavaRuntimesMessage(
[property: Key(0)] ImmutableArray<TaggedJavaRuntime> Runtimes
[MemoryPackable]
public sealed partial record AdvertiseJavaRuntimesMessage(
[property: MemoryPackOrder(0)] ImmutableArray<TaggedJavaRuntime> Runtimes
) : IMessageToServer {
public Task Accept(IMessageToServerListener listener) {
public Task<NoReply> Accept(IMessageToServerListener listener) {
return listener.HandleAdvertiseJavaRuntimes(this);
}
}

View File

@@ -1,10 +1,11 @@
using MessagePack;
using MemoryPack;
using Phantom.Utils.Rpc.Message;
namespace Phantom.Common.Messages.ToServer;
[MessagePackObject]
public sealed record AgentIsAliveMessage : IMessageToServer {
public Task Accept(IMessageToServerListener listener) {
[MemoryPackable]
public sealed partial record AgentIsAliveMessage : IMessageToServer {
public Task<NoReply> Accept(IMessageToServerListener listener) {
return listener.HandleAgentIsAlive(this);
}
}

View File

@@ -1,14 +1,15 @@
using System.Collections.Immutable;
using MessagePack;
using MemoryPack;
using Phantom.Utils.Rpc.Message;
namespace Phantom.Common.Messages.ToServer;
[MessagePackObject]
public sealed record InstanceOutputMessage(
[property: Key(0)] Guid InstanceGuid,
[property: Key(1)] ImmutableArray<string> Lines
[MemoryPackable]
public sealed partial record InstanceOutputMessage(
[property: MemoryPackOrder(0)] Guid InstanceGuid,
[property: MemoryPackOrder(1)] ImmutableArray<string> Lines
) : IMessageToServer {
public Task Accept(IMessageToServerListener listener) {
public Task<NoReply> Accept(IMessageToServerListener listener) {
return listener.HandleInstanceOutput(this);
}
}

View File

@@ -1,14 +1,15 @@
using MessagePack;
using MemoryPack;
using Phantom.Common.Data.Agent;
using Phantom.Utils.Rpc.Message;
namespace Phantom.Common.Messages.ToServer;
[MessagePackObject]
public sealed record RegisterAgentMessage(
[property: Key(0)] AgentAuthToken AuthToken,
[property: Key(1)] AgentInfo AgentInfo
[MemoryPackable]
public sealed partial record RegisterAgentMessage(
[property: MemoryPackOrder(0)] AgentAuthToken AuthToken,
[property: MemoryPackOrder(1)] AgentInfo AgentInfo
) : IMessageToServer {
public Task Accept(IMessageToServerListener listener) {
public Task<NoReply> Accept(IMessageToServerListener listener) {
return listener.HandleRegisterAgent(this);
}
}

View File

@@ -1,14 +1,15 @@
using MessagePack;
using MemoryPack;
using Phantom.Common.Data.Instance;
using Phantom.Utils.Rpc.Message;
namespace Phantom.Common.Messages.ToServer;
[MessagePackObject]
public sealed record ReportInstanceStatusMessage(
[property: Key(0)] Guid InstanceGuid,
[property: Key(1)] InstanceStatus InstanceStatus
[MemoryPackable]
public sealed partial record ReportInstanceStatusMessage(
[property: MemoryPackOrder(0)] Guid InstanceGuid,
[property: MemoryPackOrder(1)] IInstanceStatus InstanceStatus
) : IMessageToServer {
public Task Accept(IMessageToServerListener listener) {
public Task<NoReply> Accept(IMessageToServerListener listener) {
return listener.HandleReportInstanceStatus(this);
}
}

View File

@@ -1,22 +0,0 @@
using System.Runtime.CompilerServices;
using MessagePack;
namespace Phantom.Common.Messages.ToServer;
[MessagePackObject]
public sealed record SimpleReplyMessage(
[property: Key(0)] uint SequenceId,
[property: Key(1)] int EnumValue
) : IMessageToServer {
public static SimpleReplyMessage FromEnum<TEnum>(uint sequenceId, TEnum enumValue) where TEnum : Enum {
if (Unsafe.SizeOf<TEnum>() != Unsafe.SizeOf<int>()) {
throw new ArgumentException("Enum type " + typeof(TEnum).Name + " is not compatible with int.", nameof(TEnum));
}
return new SimpleReplyMessage(sequenceId, Unsafe.As<TEnum, int>(ref enumValue));
}
public Task Accept(IMessageToServerListener listener) {
return listener.HandleSimpleReply(this);
}
}

View File

@@ -1,12 +1,13 @@
using MessagePack;
using MemoryPack;
using Phantom.Utils.Rpc.Message;
namespace Phantom.Common.Messages.ToServer;
[MessagePackObject]
public sealed record UnregisterAgentMessage(
[property: Key(0)] Guid AgentGuid
[MemoryPackable]
public sealed partial record UnregisterAgentMessage(
[property: MemoryPackOrder(0)] Guid AgentGuid
) : IMessageToServer {
public Task Accept(IMessageToServerListener listener) {
public Task<NoReply> Accept(IMessageToServerListener listener) {
return listener.HandleUnregisterAgent(this);
}
}

View File

@@ -0,0 +1,49 @@
using System.Collections.Immutable;
namespace Phantom.Common.Minecraft;
public static class JvmArgumentsHelper {
public static ImmutableArray<string> Split(string arguments) {
return arguments.Split('\n', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).ToImmutableArray();
}
public static ValidationError? Validate(string arguments) {
return Validate(Split(arguments));
}
public static ValidationError? Validate(ImmutableArray<string> arguments) {
if (!arguments.All(static argument => argument.StartsWith('-'))) {
return ValidationError.InvalidFormat;
}
// TODO not perfect, but good enough
if (arguments.Any(static argument => argument.Contains("-Xmx"))) {
return ValidationError.XmxNotAllowed;
}
if (arguments.Any(static argument => argument.Contains("-Xms"))) {
return ValidationError.XmsNotAllowed;
}
return null;
}
public static string Join(ImmutableArray<string> arguments) {
return string.Join('\n', arguments);
}
public enum ValidationError {
InvalidFormat,
XmxNotAllowed,
XmsNotAllowed
}
public static string ToSentence(this ValidationError? result) {
return result switch {
ValidationError.InvalidFormat => "Invalid format.",
ValidationError.XmxNotAllowed => "The -Xmx argument must not be specified manually.",
ValidationError.XmsNotAllowed => "The -Xms argument must not be specified manually.",
_ => throw new ArgumentOutOfRangeException(nameof(result), result, null)
};
}
}

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