1
0
mirror of https://github.com/chylex/Minecraft-Phantom-Panel.git synced 2025-09-13 09:53:08 +02:00

1 Commits

Author SHA1 Message Date
0cf2975acd Automatically generate Agent names if not configured 2022-10-07 17:22:46 +02:00
489 changed files with 4674 additions and 16064 deletions

View File

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

View File

@@ -1,18 +1,18 @@
<component name="ProjectRunConfigurationManager"> <component name="ProjectRunConfigurationManager">
<configuration default="false" name="Agent 1" type="DotNetProject" factoryName=".NET Project"> <configuration default="false" name="Agent 1" type="DotNetProject" factoryName=".NET Project">
<option name="EXE_PATH" value="$PROJECT_DIR$/.artifacts/bin/Phantom.Agent/debug/Phantom.Agent.exe" /> <option name="EXE_PATH" value="$PROJECT_DIR$/Agent/Phantom.Agent/bin/Debug/net7.0/Phantom.Agent.exe" />
<option name="PROGRAM_PARAMETERS" value="" /> <option name="PROGRAM_PARAMETERS" value="" />
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/.workdir/Agent1" /> <option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/.workdir/Agent1" />
<option name="PASS_PARENT_ENVS" value="1" /> <option name="PASS_PARENT_ENVS" value="1" />
<envs> <envs>
<env name="AGENT_KEY" value="JXBQQYG5T267RQS75MXWBTCJZY5CKTCCGQY22MCZPHSQQSJYCHH2NG2TCNXQY6TBSXM9NQDRS2CMX" />
<env name="AGENT_NAME" value="Agent 1" /> <env name="AGENT_NAME" value="Agent 1" />
<env name="ALLOWED_RCON_PORTS" value="25575,27000,27001" /> <env name="ALLOWED_RCON_PORTS" value="25575,27000,27001" />
<env name="ALLOWED_SERVER_PORTS" value="25565,26000,26001" /> <env name="ALLOWED_SERVER_PORTS" value="25565,26000,26001" />
<env name="CONTROLLER_HOST" value="localhost" />
<env name="JAVA_SEARCH_PATH" value="~/.jdks" /> <env name="JAVA_SEARCH_PATH" value="~/.jdks" />
<env name="MAX_INSTANCES" value="3" /> <env name="MAX_INSTANCES" value="3" />
<env name="MAX_MEMORY" value="12G" /> <env name="MAX_MEMORY" value="12G" />
<env name="SERVER_AUTH_TOKEN_FILE" value="./secrets/agent.token" />
<env name="SERVER_HOST" value="localhost" />
</envs> </envs>
<option name="USE_EXTERNAL_CONSOLE" value="0" /> <option name="USE_EXTERNAL_CONSOLE" value="0" />
<option name="USE_MONO" value="0" /> <option name="USE_MONO" value="0" />
@@ -22,7 +22,7 @@
<option name="PROJECT_ARGUMENTS_TRACKING" value="1" /> <option name="PROJECT_ARGUMENTS_TRACKING" value="1" />
<option name="PROJECT_WORKING_DIRECTORY_TRACKING" value="0" /> <option name="PROJECT_WORKING_DIRECTORY_TRACKING" value="0" />
<option name="PROJECT_KIND" value="DotNetCore" /> <option name="PROJECT_KIND" value="DotNetCore" />
<option name="PROJECT_TFM" value="net8.0" /> <option name="PROJECT_TFM" value="net7.0" />
<method v="2"> <method v="2">
<option name="Build" /> <option name="Build" />
</method> </method>

View File

@@ -1,18 +1,18 @@
<component name="ProjectRunConfigurationManager"> <component name="ProjectRunConfigurationManager">
<configuration default="false" name="Agent 2" type="DotNetProject" factoryName=".NET Project"> <configuration default="false" name="Agent 2" type="DotNetProject" factoryName=".NET Project">
<option name="EXE_PATH" value="$PROJECT_DIR$/.artifacts/bin/Phantom.Agent/debug/Phantom.Agent.exe" /> <option name="EXE_PATH" value="$PROJECT_DIR$/Agent/Phantom.Agent/bin/Debug/net7.0/Phantom.Agent.exe" />
<option name="PROGRAM_PARAMETERS" value="" /> <option name="PROGRAM_PARAMETERS" value="" />
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/.workdir/Agent2" /> <option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/.workdir/Agent2" />
<option name="PASS_PARENT_ENVS" value="1" /> <option name="PASS_PARENT_ENVS" value="1" />
<envs> <envs>
<env name="AGENT_KEY" value="JXBQQYG5T267RQS75MXWBTCJZY5CKTCCGQY22MCZPHSQQSJYCHH2NG2TCNXQY6TBSXM9NQDRS2CMX" />
<env name="AGENT_NAME" value="Agent 2" /> <env name="AGENT_NAME" value="Agent 2" />
<env name="ALLOWED_RCON_PORTS" value="27002-27006" /> <env name="ALLOWED_RCON_PORTS" value="27002-27006" />
<env name="ALLOWED_SERVER_PORTS" value="26002-26006" /> <env name="ALLOWED_SERVER_PORTS" value="26002-26006" />
<env name="CONTROLLER_HOST" value="localhost" />
<env name="JAVA_SEARCH_PATH" value="~/.jdks" /> <env name="JAVA_SEARCH_PATH" value="~/.jdks" />
<env name="MAX_INSTANCES" value="5" /> <env name="MAX_INSTANCES" value="5" />
<env name="MAX_MEMORY" value="10G" /> <env name="MAX_MEMORY" value="10G" />
<env name="SERVER_AUTH_TOKEN_FILE" value="./secrets/agent.token" />
<env name="SERVER_HOST" value="localhost" />
</envs> </envs>
<option name="USE_EXTERNAL_CONSOLE" value="0" /> <option name="USE_EXTERNAL_CONSOLE" value="0" />
<option name="USE_MONO" value="0" /> <option name="USE_MONO" value="0" />
@@ -22,7 +22,7 @@
<option name="PROJECT_ARGUMENTS_TRACKING" value="1" /> <option name="PROJECT_ARGUMENTS_TRACKING" value="1" />
<option name="PROJECT_WORKING_DIRECTORY_TRACKING" value="0" /> <option name="PROJECT_WORKING_DIRECTORY_TRACKING" value="0" />
<option name="PROJECT_KIND" value="DotNetCore" /> <option name="PROJECT_KIND" value="DotNetCore" />
<option name="PROJECT_TFM" value="net8.0" /> <option name="PROJECT_TFM" value="net7.0" />
<method v="2"> <method v="2">
<option name="Build" /> <option name="Build" />
</method> </method>

View File

@@ -1,18 +1,18 @@
<component name="ProjectRunConfigurationManager"> <component name="ProjectRunConfigurationManager">
<configuration default="false" name="Agent 3" type="DotNetProject" factoryName=".NET Project"> <configuration default="false" name="Agent 3" type="DotNetProject" factoryName=".NET Project">
<option name="EXE_PATH" value="$PROJECT_DIR$/.artifacts/bin/Phantom.Agent/debug/Phantom.Agent.exe" /> <option name="EXE_PATH" value="$PROJECT_DIR$/Agent/Phantom.Agent/bin/Debug/net7.0/Phantom.Agent.exe" />
<option name="PROGRAM_PARAMETERS" value="" /> <option name="PROGRAM_PARAMETERS" value="" />
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/.workdir/Agent3" /> <option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/.workdir/Agent3" />
<option name="PASS_PARENT_ENVS" value="1" /> <option name="PASS_PARENT_ENVS" value="1" />
<envs> <envs>
<env name="AGENT_KEY" value="JXBQQYG5T267RQS75MXWBTCJZY5CKTCCGQY22MCZPHSQQSJYCHH2NG2TCNXQY6TBSXM9NQDRS2CMX" />
<env name="AGENT_NAME" value="Agent 3" /> <env name="AGENT_NAME" value="Agent 3" />
<env name="ALLOWED_RCON_PORTS" value="27007" /> <env name="ALLOWED_RCON_PORTS" value="27007" />
<env name="ALLOWED_SERVER_PORTS" value="26007" /> <env name="ALLOWED_SERVER_PORTS" value="26007" />
<env name="CONTROLLER_HOST" value="localhost" />
<env name="JAVA_SEARCH_PATH" value="~/.jdks" /> <env name="JAVA_SEARCH_PATH" value="~/.jdks" />
<env name="MAX_INSTANCES" value="1" /> <env name="MAX_INSTANCES" value="1" />
<env name="MAX_MEMORY" value="2560M" /> <env name="MAX_MEMORY" value="2560M" />
<env name="SERVER_AUTH_TOKEN_FILE" value="./secrets/agent.token" />
<env name="SERVER_HOST" value="localhost" />
</envs> </envs>
<option name="USE_EXTERNAL_CONSOLE" value="0" /> <option name="USE_EXTERNAL_CONSOLE" value="0" />
<option name="USE_MONO" value="0" /> <option name="USE_MONO" value="0" />
@@ -22,7 +22,7 @@
<option name="PROJECT_ARGUMENTS_TRACKING" value="1" /> <option name="PROJECT_ARGUMENTS_TRACKING" value="1" />
<option name="PROJECT_WORKING_DIRECTORY_TRACKING" value="0" /> <option name="PROJECT_WORKING_DIRECTORY_TRACKING" value="0" />
<option name="PROJECT_KIND" value="DotNetCore" /> <option name="PROJECT_KIND" value="DotNetCore" />
<option name="PROJECT_TFM" value="net8.0" /> <option name="PROJECT_TFM" value="net7.0" />
<method v="2"> <method v="2">
<option name="Build" /> <option name="Build" />
</method> </method>

View File

@@ -1,6 +1,6 @@
<component name="ProjectRunConfigurationManager"> <component name="ProjectRunConfigurationManager">
<configuration default="false" name="Compile Bootstrap" type="js.build_tools.npm"> <configuration default="false" name="Compile Bootstrap" type="js.build_tools.npm">
<package-json value="$PROJECT_DIR$/Web/Phantom.Web.Bootstrap/package.json" /> <package-json value="$PROJECT_DIR$/Server/Phantom.Server.Web.Bootstrap/package.json" />
<command value="run" /> <command value="run" />
<scripts> <scripts>
<script value="compile" /> <script value="compile" />

View File

@@ -1,27 +1,28 @@
<component name="ProjectRunConfigurationManager"> <component name="ProjectRunConfigurationManager">
<configuration default="false" name="Controller" type="DotNetProject" factoryName=".NET Project"> <configuration default="false" name="Server" type="DotNetProject" factoryName=".NET Project">
<option name="EXE_PATH" value="$PROJECT_DIR$/.artifacts/bin/Phantom.Controller/debug/Phantom.Controller.exe" /> <option name="EXE_PATH" value="$PROJECT_DIR$/Server/Phantom.Server/bin/Debug/net7.0/Phantom.Server.exe" />
<option name="PROGRAM_PARAMETERS" value="" /> <option name="PROGRAM_PARAMETERS" value="" />
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/.workdir/Controller" /> <option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/.workdir/Server" />
<option name="PASS_PARENT_ENVS" value="1" /> <option name="PASS_PARENT_ENVS" value="1" />
<envs> <envs>
<env name="AGENT_RPC_SERVER_HOST" value="localhost" /> <env name="ASPNETCORE_ENVIRONMENT" value="Development" />
<env name="PG_DATABASE" value="postgres" /> <env name="PG_DATABASE" value="postgres" />
<env name="PG_HOST" value="localhost" /> <env name="PG_HOST" value="localhost" />
<env name="PG_PASS" value="development" /> <env name="PG_PASS" value="development" />
<env name="PG_PORT" value="9403" /> <env name="PG_PORT" value="9402" />
<env name="PG_USER" value="postgres" /> <env name="PG_USER" value="postgres" />
<env name="WEB_RPC_SERVER_HOST" value="localhost" /> <env name="RPC_SERVER_HOST" value="localhost" />
<env name="WEB_SERVER_HOST" value="localhost" />
</envs> </envs>
<option name="USE_EXTERNAL_CONSOLE" value="0" /> <option name="USE_EXTERNAL_CONSOLE" value="0" />
<option name="USE_MONO" value="0" /> <option name="USE_MONO" value="0" />
<option name="RUNTIME_ARGUMENTS" value="" /> <option name="RUNTIME_ARGUMENTS" value="" />
<option name="PROJECT_PATH" value="$PROJECT_DIR$/Controller/Phantom.Controller/Phantom.Controller.csproj" /> <option name="PROJECT_PATH" value="$PROJECT_DIR$/Server/Phantom.Server/Phantom.Server.csproj" />
<option name="PROJECT_EXE_PATH_TRACKING" value="1" /> <option name="PROJECT_EXE_PATH_TRACKING" value="1" />
<option name="PROJECT_ARGUMENTS_TRACKING" value="1" /> <option name="PROJECT_ARGUMENTS_TRACKING" value="1" />
<option name="PROJECT_WORKING_DIRECTORY_TRACKING" value="0" /> <option name="PROJECT_WORKING_DIRECTORY_TRACKING" value="0" />
<option name="PROJECT_KIND" value="DotNetCore" /> <option name="PROJECT_KIND" value="DotNetCore" />
<option name="PROJECT_TFM" value="net8.0" /> <option name="PROJECT_TFM" value="net7.0" />
<method v="2"> <method v="2">
<option name="Build" /> <option name="Build" />
</method> </method>

View File

@@ -1,24 +0,0 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Web" type="DotNetProject" factoryName=".NET Project">
<option name="EXE_PATH" value="$PROJECT_DIR$/.artifacts/bin/Phantom.Web/debug/Phantom.Web.exe" />
<option name="PROGRAM_PARAMETERS" value="" />
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/.workdir/Web" />
<option name="PASS_PARENT_ENVS" value="1" />
<envs>
<env name="ASPNETCORE_ENVIRONMENT" value="Development" />
<env name="WEB_SERVER_HOST" value="localhost" />
</envs>
<option name="USE_EXTERNAL_CONSOLE" value="0" />
<option name="USE_MONO" value="0" />
<option name="RUNTIME_ARGUMENTS" value="" />
<option name="PROJECT_PATH" value="$PROJECT_DIR$/Web/Phantom.Web/Phantom.Web.csproj" />
<option name="PROJECT_EXE_PATH_TRACKING" value="1" />
<option name="PROJECT_ARGUMENTS_TRACKING" value="1" />
<option name="PROJECT_WORKING_DIRECTORY_TRACKING" value="0" />
<option name="PROJECT_KIND" value="DotNetCore" />
<option name="PROJECT_TFM" value="net8.0" />
<method v="2">
<option name="Build" />
</method>
</configuration>
</component>

2
.workdir/.gitignore vendored Normal file
View File

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

View File

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

View File

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

View File

@@ -0,0 +1 @@
TH7Z2MJKMR975N6HFBKQP9WQFMWWN5

View File

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

View File

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

View File

@@ -0,0 +1 @@
TH7Z2MJKMR975N6HFBKQP9WQFMWWN5

View File

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

View File

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

View File

@@ -0,0 +1 @@
TH7Z2MJKMR975N6HFBKQP9WQFMWWN5

View File

@@ -1 +0,0 @@


View File

@@ -1 +0,0 @@
<EFBFBD>Z<EFBFBD>t<>MPI<49>GMZ<4D><5A><EFBFBD><EFBFBD>kN<6B>VF1X<><58>p

View File

@@ -1,2 +0,0 @@
<EFBFBD><EFBFBD>h?Ο<05>Bx
<02>

View File

@@ -1 +0,0 @@
T<EFBFBD>./g<11><>N<EFBFBD><4E>t<EFBFBD>$<24>!<21>(<28><>#<23>~<7E><>}<14><:

View File

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

View File

@@ -0,0 +1 @@
TH7Z2MJKMR975N6HFBKQP9WQFMWWN5

View File

@@ -0,0 +1 @@
+<2B><><EFBFBD><EFBFBD><<3C>f:<3A>bJ"e<18>׸ބ<D7B8><1F><><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD>

View File

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

View File

@@ -5,4 +5,4 @@ if [%1]==[] (
exit exit
) )
dotnet ef migrations add %~1 --project Controller/Phantom.Controller.Database.Postgres --msbuildprojectextensionspath .artifacts/obj/Phantom.Controller.Database.Postgres dotnet ef migrations add %~1 --project Server/Phantom.Server.Database.Postgres

View File

@@ -3,4 +3,4 @@ if [ -z "$1" ]; then
exit 1 exit 1
fi fi
dotnet ef migrations add "$1" --project Controller/Phantom.Controller.Database.Postgres --msbuildprojectextensionspath .artifacts/obj/Phantom.Controller.Database.Postgres dotnet ef migrations add "$1" --project Server/Phantom.Server.Database.Postgres

View File

@@ -1,15 +0,0 @@
namespace Phantom.Agent.Minecraft.Command;
public static class MinecraftCommand {
public const string SaveOn = "save-on";
public const string SaveOff = "save-off";
public const string Stop = "stop";
public static string Say(string message) {
return "say " + message;
}
public static string SaveAll(bool flush) {
return flush ? "save-all flush" : "save-all";
}
}

View File

@@ -1,17 +1,12 @@
using System.Collections.Immutable; using Phantom.Agent.Minecraft.Java;
using Phantom.Agent.Minecraft.Java;
using Phantom.Agent.Minecraft.Properties; using Phantom.Agent.Minecraft.Properties;
using Phantom.Common.Data.Instance;
namespace Phantom.Agent.Minecraft.Instance; namespace Phantom.Agent.Minecraft.Instance;
public sealed record InstanceProperties( public sealed record InstanceProperties(
Guid InstanceGuid,
Guid JavaRuntimeGuid, Guid JavaRuntimeGuid,
JvmProperties JvmProperties, JvmProperties JvmProperties,
ImmutableArray<string> JvmArguments,
string InstanceFolder, string InstanceFolder,
string ServerVersion, string ServerVersion,
ServerProperties ServerProperties, ServerProperties ServerProperties
InstanceLaunchProperties LaunchProperties
); );

View File

@@ -1,24 +1,23 @@
using Phantom.Utils.Collections; using System.Diagnostics;
using Phantom.Utils.Processes; using Phantom.Utils.Collections;
namespace Phantom.Agent.Minecraft.Instance; namespace Phantom.Agent.Minecraft.Instance;
public sealed class InstanceProcess : IDisposable { public sealed class InstanceSession : IDisposable {
public InstanceProperties InstanceProperties { get; } private readonly RingBuffer<string> outputBuffer = new (10000);
private readonly RingBuffer<string> outputBuffer = new (100);
private event EventHandler<string>? OutputEvent; private event EventHandler<string>? OutputEvent;
public event EventHandler? Ended; public event EventHandler? SessionEnded;
public bool HasEnded { get; private set; } public bool HasEnded { get; private set; }
private readonly Process process; private readonly Process process;
internal InstanceProcess(InstanceProperties instanceProperties, Process process) { internal InstanceSession(Process process) {
this.InstanceProperties = instanceProperties;
this.process = process; this.process = process;
this.process.EnableRaisingEvents = true;
this.process.Exited += ProcessOnExited; this.process.Exited += ProcessOnExited;
this.process.OutputReceived += ProcessOutputReceived; this.process.OutputDataReceived += HandleOutputLine;
this.process.ErrorDataReceived += HandleOutputLine;
} }
public async Task SendCommand(string command, CancellationToken cancellationToken) { public async Task SendCommand(string command, CancellationToken cancellationToken) {
@@ -37,15 +36,17 @@ public sealed class InstanceProcess : IDisposable {
OutputEvent -= listener; OutputEvent -= listener;
} }
private void ProcessOutputReceived(object? sender, Process.Output output) { private void HandleOutputLine(object sender, DataReceivedEventArgs args) {
outputBuffer.Add(output.Line); if (args.Data is {} line) {
OutputEvent?.Invoke(this, output.Line); outputBuffer.Add(line);
OutputEvent?.Invoke(this, line);
}
} }
private void ProcessOnExited(object? sender, EventArgs e) { private void ProcessOnExited(object? sender, EventArgs e) {
OutputEvent = null; OutputEvent = null;
HasEnded = true; HasEnded = true;
Ended?.Invoke(this, EventArgs.Empty); SessionEnded?.Invoke(this, EventArgs.Empty);
} }
public void Kill() { public void Kill() {
@@ -61,6 +62,6 @@ public sealed class InstanceProcess : IDisposable {
public void Dispose() { public void Dispose() {
process.Dispose(); process.Dispose();
OutputEvent = null; OutputEvent = null;
Ended = null; SessionEnded = null;
} }
} }

View File

@@ -1,92 +0,0 @@
using System.Text;
using Kajabity.Tools.Java;
namespace Phantom.Agent.Minecraft.Java;
sealed class JavaPropertiesFileEditor {
private static readonly Encoding Encoding = Encoding.GetEncoding("ISO-8859-1");
private readonly Dictionary<string, string> overriddenProperties = new ();
public void Set(string key, string value) {
overriddenProperties[key] = value;
}
public async Task EditOrCreate(string filePath) {
if (File.Exists(filePath)) {
string tmpFilePath = filePath + ".tmp";
File.Copy(filePath, tmpFilePath, overwrite: true);
await EditFromCopyOrCreate(filePath, tmpFilePath);
File.Move(tmpFilePath, filePath, overwrite: true);
}
else {
await EditFromCopyOrCreate(null, filePath);
}
}
private async Task EditFromCopyOrCreate(string? sourceFilePath, string targetFilePath) {
var properties = new JavaProperties();
if (sourceFilePath != null) {
// TODO replace with custom async parser
await using var sourceStream = new FileStream(sourceFilePath, FileMode.Open, FileAccess.Read, FileShare.Read);
properties.Load(sourceStream, Encoding);
}
foreach (var (key, value) in overriddenProperties) {
properties[key] = value;
}
await using var targetStream = new FileStream(targetFilePath, FileMode.Create, FileAccess.Write, FileShare.Read);
await using var targetWriter = new StreamWriter(targetStream, Encoding);
await targetWriter.WriteLineAsync("# Properties");
foreach (var (key, value) in properties) {
await WriteProperty(targetWriter, key, value);
}
}
private static async Task WriteProperty(StreamWriter writer, string key, string value) {
await WritePropertyComponent(writer, key, escapeSpaces: true);
await writer.WriteAsync('=');
await WritePropertyComponent(writer, value, escapeSpaces: false);
await writer.WriteLineAsync();
}
private static async Task WritePropertyComponent(TextWriter writer, string component, bool escapeSpaces) {
for (int index = 0; index < component.Length; index++) {
var c = component[index];
switch (c) {
case '\\':
case '#':
case '!':
case '=':
case ':':
case ' ' when escapeSpaces || index == 0:
await writer.WriteAsync('\\');
await writer.WriteAsync(c);
break;
case var _ when c > 31 && c < 127:
await writer.WriteAsync(c);
break;
case '\t':
await writer.WriteAsync("\\t");
break;
case '\n':
await writer.WriteAsync("\\n");
break;
case '\r':
await writer.WriteAsync("\\r");
break;
case '\f':
await writer.WriteAsync("\\f");
break;
default:
await writer.WriteAsync("\\u");
await writer.WriteAsync(((int) c).ToString("X4"));
break;
}
}
}
}

View File

@@ -7,7 +7,7 @@ using Serilog;
namespace Phantom.Agent.Minecraft.Java; namespace Phantom.Agent.Minecraft.Java;
public sealed class JavaRuntimeDiscovery { public sealed class JavaRuntimeDiscovery {
private static readonly ILogger Logger = PhantomLogger.Create(nameof(JavaRuntimeDiscovery)); private static readonly ILogger Logger = PhantomLogger.Create(typeof(JavaRuntimeDiscovery));
public static string? GetSystemSearchPath() { public static string? GetSystemSearchPath() {
const string LinuxJavaPath = "/usr/lib/jvm"; const string LinuxJavaPath = "/usr/lib/jvm";
@@ -38,38 +38,28 @@ public sealed class JavaRuntimeDiscovery {
AttributesToSkip = FileAttributes.Hidden | FileAttributes.ReparsePoint | FileAttributes.System AttributesToSkip = FileAttributes.Hidden | FileAttributes.ReparsePoint | FileAttributes.System
}).Order()) { }).Order()) {
var javaExecutablePath = Paths.NormalizeSlashes(Path.Combine(binFolderPath, javaExecutableName)); var javaExecutablePath = Paths.NormalizeSlashes(Path.Combine(binFolderPath, javaExecutableName));
if (File.Exists(javaExecutablePath)) {
Logger.Information("Found candidate Java executable: {JavaExecutablePath}", javaExecutablePath);
FileAttributes javaExecutableAttributes; JavaRuntime? foundRuntime;
try { try {
javaExecutableAttributes = File.GetAttributes(javaExecutablePath); foundRuntime = await TryReadJavaRuntimeInformationFromProcess(javaExecutablePath);
} catch (Exception) { } catch (OperationCanceledException) {
continue; 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);
} }
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);
} }
} }
@@ -79,9 +69,10 @@ public sealed class JavaRuntimeDiscovery {
WorkingDirectory = Path.GetDirectoryName(javaExecutablePath), WorkingDirectory = Path.GetDirectoryName(javaExecutablePath),
Arguments = "-XshowSettings:properties -version", Arguments = "-XshowSettings:properties -version",
RedirectStandardInput = false, RedirectStandardInput = false,
RedirectStandardOutput = false, RedirectStandardOutput = true,
RedirectStandardError = true, RedirectStandardError = true,
UseShellExecute = false UseShellExecute = false,
CreateNoWindow = false
}; };
var process = new Process { StartInfo = startInfo }; var process = new Process { StartInfo = startInfo };

View File

@@ -4,27 +4,22 @@ namespace Phantom.Agent.Minecraft.Java;
sealed class JvmArgumentBuilder { sealed class JvmArgumentBuilder {
private readonly JvmProperties basicProperties; private readonly JvmProperties basicProperties;
private readonly List<string> customArguments = new (); private readonly List<string> customProperties = new ();
public JvmArgumentBuilder(JvmProperties basicProperties) { public JvmArgumentBuilder(JvmProperties basicProperties) {
this.basicProperties = basicProperties; this.basicProperties = basicProperties;
} }
public void Add(string argument) {
customArguments.Add(argument);
}
public void AddProperty(string key, string value) { public void AddProperty(string key, string value) {
customArguments.Add("-D" + key + "=\"" + value + "\""); // TODO test quoting? customProperties.Add("-D" + key + "=\"" + value + "\""); // TODO test quoting?
} }
public void Build(Collection<string> target) { public void Build(Collection<string> target) {
foreach (var property in customArguments) {
target.Add(property);
}
// In case of duplicate JVM arguments, typically the last one wins.
target.Add("-Xms" + basicProperties.InitialHeapMegabytes + "M"); target.Add("-Xms" + basicProperties.InitialHeapMegabytes + "M");
target.Add("-Xmx" + basicProperties.MaximumHeapMegabytes + "M"); target.Add("-Xmx" + basicProperties.MaximumHeapMegabytes + "M");
foreach (var property in customProperties) {
target.Add(property);
}
} }
} }

View File

@@ -1,106 +1,66 @@
using System.Text; using System.Diagnostics;
using System.Text;
using Kajabity.Tools.Java;
using Phantom.Agent.Minecraft.Instance; using Phantom.Agent.Minecraft.Instance;
using Phantom.Agent.Minecraft.Java; using Phantom.Agent.Minecraft.Java;
using Phantom.Agent.Minecraft.Server; using Phantom.Agent.Minecraft.Server;
using Phantom.Utils.Processes;
using Serilog;
namespace Phantom.Agent.Minecraft.Launcher; namespace Phantom.Agent.Minecraft.Launcher;
public abstract class BaseLauncher : IServerLauncher { public abstract class BaseLauncher {
private readonly InstanceProperties instanceProperties; private readonly InstanceProperties instanceProperties;
protected string MinecraftVersion => instanceProperties.ServerVersion;
private protected BaseLauncher(InstanceProperties instanceProperties) { private protected BaseLauncher(InstanceProperties instanceProperties) {
this.instanceProperties = instanceProperties; this.instanceProperties = instanceProperties;
} }
public async Task<LaunchResult> Launch(ILogger logger, LaunchServices services, EventHandler<DownloadProgressEventArgs> downloadProgressEventHandler, CancellationToken cancellationToken) { public async Task<LaunchResult> Launch(LaunchServices services, EventHandler<DownloadProgressEventArgs> downloadProgressEventHandler, CancellationToken cancellationToken) {
if (!services.JavaRuntimeRepository.TryGetByGuid(instanceProperties.JavaRuntimeGuid, out var javaRuntimeExecutable)) { if (!services.JavaRuntimeRepository.TryGetByGuid(instanceProperties.JavaRuntimeGuid, out var javaRuntimeExecutable)) {
return new LaunchResult.InvalidJavaRuntime(); return new LaunchResult.InvalidJavaRuntime();
} }
var vanillaServerJarPath = await services.ServerExecutables.DownloadAndGetPath(instanceProperties.LaunchProperties.ServerDownloadInfo, MinecraftVersion, downloadProgressEventHandler, cancellationToken); var vanillaServerJarPath = await services.ServerExecutables.DownloadAndGetPath(instanceProperties.ServerVersion, downloadProgressEventHandler, cancellationToken);
if (vanillaServerJarPath == null) { if (vanillaServerJarPath == null) {
return new LaunchResult.CouldNotDownloadMinecraftServer(); return new LaunchResult.CouldNotDownloadMinecraftServer();
} }
ServerJarInfo? serverJar; var startInfo = new ProcessStartInfo {
try {
serverJar = await PrepareServerJar(logger, vanillaServerJarPath, cancellationToken);
} catch (OperationCanceledException) {
throw;
} catch (Exception e) {
logger.Error(e, "Caught exception while preparing the server jar.");
return new LaunchResult.CouldNotPrepareMinecraftServerLauncher();
}
if (!File.Exists(serverJar.FilePath)) {
logger.Error("Missing prepared server or launcher jar: {FilePath}", serverJar.FilePath);
return new LaunchResult.CouldNotPrepareMinecraftServerLauncher();
}
try {
await AcceptEula(instanceProperties);
await UpdateServerProperties(instanceProperties);
} catch (Exception e) {
logger.Error(e, "Caught exception while configuring the server.");
return new LaunchResult.CouldNotConfigureMinecraftServer();
}
var processConfigurator = new ProcessConfigurator {
FileName = javaRuntimeExecutable.ExecutablePath, FileName = javaRuntimeExecutable.ExecutablePath,
WorkingDirectory = instanceProperties.InstanceFolder, WorkingDirectory = instanceProperties.InstanceFolder,
RedirectInput = true, RedirectStandardInput = true,
UseShellExecute = false RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = false
}; };
var processArguments = processConfigurator.ArgumentList; var jvmArguments = new JvmArgumentBuilder(instanceProperties.JvmProperties);
PrepareJvmArguments(serverJar).Build(processArguments); CustomizeJvmArguments(jvmArguments);
var serverJarPath = await PrepareServerJar(vanillaServerJarPath, instanceProperties.InstanceFolder, cancellationToken);
var processArguments = startInfo.ArgumentList;
jvmArguments.Build(processArguments);
processArguments.Add("-jar"); processArguments.Add("-jar");
processArguments.Add(serverJar.FilePath); processArguments.Add(serverJarPath);
processArguments.Add("nogui"); processArguments.Add("nogui");
var process = processConfigurator.CreateProcess(); var process = new Process { StartInfo = startInfo };
var instanceProcess = new InstanceProcess(instanceProperties, process); var session = new InstanceSession(process);
try { await AcceptEula(instanceProperties);
process.Start(); await UpdateServerProperties(instanceProperties);
} catch (Exception launchException) {
logger.Error(launchException, "Caught exception launching the server process.");
try { process.Start();
process.Kill(); process.BeginOutputReadLine();
} catch (Exception killException) { process.BeginErrorReadLine();
logger.Error(killException, "Caught exception trying to kill the server process after a failed launch.");
}
return new LaunchResult.CouldNotStartMinecraftServer(); return new LaunchResult.Success(session);
}
return new LaunchResult.Success(instanceProcess);
}
private JvmArgumentBuilder PrepareJvmArguments(ServerJarInfo serverJar) {
var builder = new JvmArgumentBuilder(instanceProperties.JvmProperties);
foreach (string argument in instanceProperties.JvmArguments) {
builder.Add(argument);
}
foreach (var argument in serverJar.ExtraArgs) {
builder.Add(argument);
}
CustomizeJvmArguments(builder);
return builder;
} }
private protected virtual void CustomizeJvmArguments(JvmArgumentBuilder arguments) {} private protected virtual void CustomizeJvmArguments(JvmArgumentBuilder arguments) {}
private protected virtual Task<ServerJarInfo> PrepareServerJar(ILogger logger, string serverJarPath, CancellationToken cancellationToken) { private protected virtual Task<string> PrepareServerJar(string serverJarPath, string instanceFolderPath, CancellationToken cancellationToken) {
return Task.FromResult(new ServerJarInfo(serverJarPath)); return Task.FromResult(serverJarPath);
} }
private static async Task AcceptEula(InstanceProperties instanceProperties) { private static async Task AcceptEula(InstanceProperties instanceProperties) {
@@ -109,8 +69,19 @@ public abstract class BaseLauncher : IServerLauncher {
} }
private static async Task UpdateServerProperties(InstanceProperties instanceProperties) { private static async Task UpdateServerProperties(InstanceProperties instanceProperties) {
var serverPropertiesEditor = new JavaPropertiesFileEditor(); var serverPropertiesFilePath = Path.Combine(instanceProperties.InstanceFolder, "server.properties");
instanceProperties.ServerProperties.SetTo(serverPropertiesEditor); var serverPropertiesData = new JavaProperties();
await serverPropertiesEditor.EditOrCreate(Path.Combine(instanceProperties.InstanceFolder, "server.properties"));
try {
await using var readStream = new FileStream(serverPropertiesFilePath, FileMode.Open, FileAccess.Read, FileShare.Read);
serverPropertiesData.Load(readStream);
} catch (FileNotFoundException) {
// ignore
}
instanceProperties.ServerProperties.SetTo(serverPropertiesData);
await using var writeStream = new FileStream(serverPropertiesFilePath, FileMode.OpenOrCreate, FileAccess.Write, FileShare.None);
serverPropertiesData.Store(writeStream, true);
} }
} }

View File

@@ -1,8 +0,0 @@
using Phantom.Agent.Minecraft.Server;
using Serilog;
namespace Phantom.Agent.Minecraft.Launcher;
public interface IServerLauncher {
Task<LaunchResult> Launch(ILogger logger, LaunchServices services, EventHandler<DownloadProgressEventArgs> downloadProgressEventHandler, CancellationToken cancellationToken);
}

View File

@@ -5,15 +5,9 @@ namespace Phantom.Agent.Minecraft.Launcher;
public abstract record LaunchResult { public abstract record LaunchResult {
private LaunchResult() {} private LaunchResult() {}
public sealed record Success(InstanceProcess Process) : LaunchResult; public sealed record Success(InstanceSession Session) : LaunchResult;
public sealed record InvalidJavaRuntime : LaunchResult; public sealed record InvalidJavaRuntime : LaunchResult;
public sealed record CouldNotDownloadMinecraftServer : LaunchResult; public sealed record CouldNotDownloadMinecraftServer : LaunchResult;
public sealed record CouldNotPrepareMinecraftServerLauncher : LaunchResult;
public sealed record CouldNotConfigureMinecraftServer : LaunchResult;
public sealed record CouldNotStartMinecraftServer : LaunchResult;
} }

View File

@@ -1,7 +0,0 @@
using System.Collections.Immutable;
namespace Phantom.Agent.Minecraft.Launcher;
sealed record ServerJarInfo(string FilePath, ImmutableArray<string> ExtraArgs) {
public ServerJarInfo(string filePath) : this(filePath, ImmutableArray<string>.Empty) {}
}

View File

@@ -1,55 +0,0 @@
using System.Collections.Immutable;
using Phantom.Agent.Minecraft.Instance;
using Phantom.Utils.IO;
using Serilog;
namespace Phantom.Agent.Minecraft.Launcher.Types;
public sealed class FabricLauncher : BaseLauncher {
public FabricLauncher(InstanceProperties instanceProperties) : base(instanceProperties) {}
private protected override async Task<ServerJarInfo> PrepareServerJar(ILogger logger, string serverJarPath, CancellationToken cancellationToken) {
var serverJarParentFolderPath = Directory.GetParent(serverJarPath);
if (serverJarParentFolderPath == null) {
throw new ArgumentException("Could not get parent folder from: " + serverJarPath, nameof(serverJarPath));
}
var launcherJarPath = Path.Combine(serverJarParentFolderPath.FullName, "fabric.jar");
if (!File.Exists(launcherJarPath)) {
await DownloadLauncher(logger, launcherJarPath, cancellationToken);
}
return new ServerJarInfo(launcherJarPath, ImmutableArray.Create("-Dfabric.installer.server.gameJar=" + Paths.NormalizeSlashes(serverJarPath)));
}
private async Task DownloadLauncher(ILogger logger, string targetFilePath, CancellationToken cancellationToken) {
// TODO customizable loader version, probably with a dedicated temporary folder
string installerUrl = $"https://meta.fabricmc.net/v2/versions/loader/{MinecraftVersion}/stable/stable/server/jar";
logger.Information("Downloading Fabric launcher from: {Url}", installerUrl);
using var http = new HttpClient();
var response = await http.GetAsync(installerUrl, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
response.EnsureSuccessStatusCode();
try {
await using var fileStream = new FileStream(targetFilePath, FileMode.CreateNew, FileAccess.Write, FileShare.Read);
await using var responseStream = await response.Content.ReadAsStreamAsync(cancellationToken);
await responseStream.CopyToAsync(fileStream, cancellationToken);
} catch (Exception) {
TryDeleteLauncherAfterFailure(logger, targetFilePath);
throw;
}
}
private static void TryDeleteLauncherAfterFailure(ILogger logger, string filePath) {
if (File.Exists(filePath)) {
try {
File.Delete(filePath);
} catch (Exception e) {
logger.Warning(e, "Could not clean up partially downloaded Fabric launcher: {FilePath}", filePath);
}
}
}
}

View File

@@ -1,14 +0,0 @@
using Phantom.Agent.Minecraft.Server;
using Serilog;
namespace Phantom.Agent.Minecraft.Launcher.Types;
public sealed class InvalidLauncher : IServerLauncher {
public static InvalidLauncher Instance { get; } = new ();
private InvalidLauncher() {}
public Task<LaunchResult> Launch(ILogger logger, LaunchServices services, EventHandler<DownloadProgressEventArgs> downloadProgressEventHandler, CancellationToken cancellationToken) {
return Task.FromResult<LaunchResult>(new LaunchResult.CouldNotPrepareMinecraftServerLauncher());
}
}

View File

@@ -1,18 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Kajabity.Tools.Java" /> <PackageReference Include="Kajabity.Tools.Java" Version="0.3.7879.40798" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\Common\Phantom.Common.Data\Phantom.Common.Data.csproj" /> <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.Logging\Phantom.Common.Logging.csproj" />
<ProjectReference Include="..\..\Utils\Phantom.Utils\Phantom.Utils.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" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -1,4 +1,4 @@
using Phantom.Agent.Minecraft.Java; using Kajabity.Tools.Java;
namespace Phantom.Agent.Minecraft.Properties; namespace Phantom.Agent.Minecraft.Properties;
@@ -12,7 +12,7 @@ abstract class MinecraftServerProperty<T> {
protected abstract T Read(string value); protected abstract T Read(string value);
protected abstract string Write(T value); protected abstract string Write(T value);
public void Set(JavaPropertiesFileEditor properties, T value) { public void Set(JavaProperties properties, T value) {
properties.Set(key, Write(value)); properties.SetProperty(key, Write(value));
} }
} }

View File

@@ -1,4 +1,4 @@
using Phantom.Agent.Minecraft.Java; using Kajabity.Tools.Java;
namespace Phantom.Agent.Minecraft.Properties; namespace Phantom.Agent.Minecraft.Properties;
@@ -7,7 +7,7 @@ public sealed record ServerProperties(
ushort RconPort, ushort RconPort,
bool EnableRcon = true bool EnableRcon = true
) { ) {
internal void SetTo(JavaPropertiesFileEditor properties) { internal void SetTo(JavaProperties properties) {
MinecraftServerProperties.ServerPort.Set(properties, ServerPort); MinecraftServerProperties.ServerPort.Set(properties, ServerPort);
MinecraftServerProperties.RconPort.Set(properties, RconPort); MinecraftServerProperties.RconPort.Set(properties, RconPort);
MinecraftServerProperties.EnableRcon.Set(properties, EnableRcon); MinecraftServerProperties.EnableRcon.Set(properties, EnableRcon);

View File

@@ -1,9 +1,9 @@
using System.Security.Cryptography; using System.Net.Http.Json;
using Phantom.Common.Data.Minecraft; using System.Security.Cryptography;
using System.Text.Json;
using Phantom.Common.Logging; using Phantom.Common.Logging;
using Phantom.Utils.Cryptography; using Phantom.Utils.Cryptography;
using Phantom.Utils.IO; using Phantom.Utils.IO;
using Phantom.Utils.Runtime;
using Serilog; using Serilog;
namespace Phantom.Agent.Minecraft.Server; namespace Phantom.Agent.Minecraft.Server;
@@ -11,6 +11,8 @@ namespace Phantom.Agent.Minecraft.Server;
sealed class MinecraftServerExecutableDownloader { sealed class MinecraftServerExecutableDownloader {
private static readonly ILogger Logger = PhantomLogger.Create<MinecraftServerExecutableDownloader>(); private static readonly ILogger Logger = PhantomLogger.Create<MinecraftServerExecutableDownloader>();
private const string VersionManifestUrl = "https://launchermeta.mojang.com/mc/game/version_manifest.json";
public Task<string?> Task { get; } public Task<string?> Task { get; }
public event EventHandler<DownloadProgressEventArgs>? DownloadProgress; public event EventHandler<DownloadProgressEventArgs>? DownloadProgress;
public event EventHandler? Completed; public event EventHandler? Completed;
@@ -18,15 +20,15 @@ sealed class MinecraftServerExecutableDownloader {
private readonly CancellationTokenSource cancellationTokenSource = new (); private readonly CancellationTokenSource cancellationTokenSource = new ();
private int listeners = 0; private int listeners = 0;
public MinecraftServerExecutableDownloader(FileDownloadInfo fileDownloadInfo, string minecraftVersion, string filePath, MinecraftServerExecutableDownloadListener listener) { public MinecraftServerExecutableDownloader(string version, string filePath, MinecraftServerExecutableDownloadListener listener) {
Register(listener); Register(listener);
Task = DownloadAndGetPath(fileDownloadInfo, minecraftVersion, filePath); Task = DownloadAndGetPath(version, filePath);
Task.ContinueWith(OnCompleted, TaskScheduler.Default); Task.ContinueWith(OnCompleted, TaskScheduler.Default);
} }
public void Register(MinecraftServerExecutableDownloadListener listener) { public void Register(MinecraftServerExecutableDownloadListener listener) {
++listeners; ++listeners;
Logger.Debug("Registered download listener, current listener count: {Listeners}", listeners); Logger.Verbose("Registered download listener, current listener count: {Listeners}", listeners);
DownloadProgress += listener.DownloadProgressEventHandler; DownloadProgress += listener.DownloadProgressEventHandler;
listener.CancellationToken.Register(Unregister, listener); listener.CancellationToken.Register(Unregister, listener);
@@ -37,11 +39,11 @@ sealed class MinecraftServerExecutableDownloader {
DownloadProgress -= listener.DownloadProgressEventHandler; DownloadProgress -= listener.DownloadProgressEventHandler;
if (--listeners <= 0) { if (--listeners <= 0) {
Logger.Debug("Unregistered last download listener, cancelling download."); Logger.Verbose("Unregistered last download listener, cancelling download.");
cancellationTokenSource.Cancel(); cancellationTokenSource.Cancel();
} }
else { else {
Logger.Debug("Unregistered download listener, current listener count: {Listeners}", listeners); Logger.Verbose("Unregistered download listener, current listener count: {Listeners}", listeners);
} }
} }
@@ -50,7 +52,7 @@ sealed class MinecraftServerExecutableDownloader {
} }
private void OnCompleted(Task task) { private void OnCompleted(Task task) {
Logger.Debug("Download task completed."); Logger.Verbose("Download task completed.");
Completed?.Invoke(this, EventArgs.Empty); Completed?.Invoke(this, EventArgs.Empty);
Completed = null; Completed = null;
DownloadProgress = null; DownloadProgress = null;
@@ -68,26 +70,36 @@ sealed class MinecraftServerExecutableDownloader {
} }
} }
private async Task<string?> DownloadAndGetPath(FileDownloadInfo fileDownloadInfo, string minecraftVersion, string filePath) { private async Task<string?> DownloadAndGetPath(string version, string filePath) {
Logger.Information("Downloading server version {Version}...", version);
HttpClient http = new HttpClient();
string tmpFilePath = filePath + ".tmp"; string tmpFilePath = filePath + ".tmp";
var cancellationToken = cancellationTokenSource.Token; var cancellationToken = cancellationTokenSource.Token;
try { try {
Logger.Information("Downloading server version {Version} from: {Url} ({Size})", minecraftVersion, fileDownloadInfo.DownloadUrl, fileDownloadInfo.Size.ToHumanReadable(decimalPlaces: 1)); 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);
Logger.Information("Downloading server executable from: {Url} ({Size})", serverExecutableInfo.DownloadUrl, serverExecutableInfo.Size.ToHumanReadable(decimalPlaces: 1));
try { try {
using var http = new HttpClient(); await FetchServerExecutableFile(http, new DownloadProgressCallback(this), serverExecutableInfo, tmpFilePath, cancellationToken);
await FetchServerExecutableFile(http, new DownloadProgressCallback(this), fileDownloadInfo, tmpFilePath, cancellationToken);
} catch (Exception) { } catch (Exception) {
TryDeleteExecutableAfterFailure(tmpFilePath); TryDeleteExecutableAfterFailure(tmpFilePath);
throw; throw;
} }
File.Move(tmpFilePath, filePath, true); File.Move(tmpFilePath, filePath, true);
Logger.Information("Server version {Version} downloaded.", minecraftVersion); Logger.Information("Server version {Version} downloaded.", version);
return filePath; return filePath;
} catch (OperationCanceledException) { } catch (OperationCanceledException) {
Logger.Information("Download for server version {Version} was cancelled.", minecraftVersion); Logger.Information("Download for server version {Version} was cancelled.", version);
throw; throw;
} catch (StopProcedureException) { } catch (StopProcedureException) {
return null; return null;
@@ -99,17 +111,41 @@ sealed class MinecraftServerExecutableDownloader {
} }
} }
private static async Task FetchServerExecutableFile(HttpClient http, DownloadProgressCallback progressCallback, FileDownloadInfo fileDownloadInfo, string filePath, CancellationToken cancellationToken) { 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) {
Sha1String downloadedFileHash; Sha1String downloadedFileHash;
try { try {
var response = await http.GetAsync(fileDownloadInfo.DownloadUrl, HttpCompletionOption.ResponseHeadersRead, cancellationToken); var response = await http.GetAsync(info.DownloadUrl, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
response.EnsureSuccessStatusCode(); response.EnsureSuccessStatusCode();
await using var fileStream = new FileStream(filePath, FileMode.CreateNew, FileAccess.Write, FileShare.Read); await using var fileStream = new FileStream(filePath, FileMode.CreateNew, FileAccess.Write, FileShare.Read);
await using var responseStream = await response.Content.ReadAsStreamAsync(cancellationToken); await using var responseStream = await response.Content.ReadAsStreamAsync(cancellationToken);
using var streamCopier = new MinecraftServerDownloadStreamCopier(progressCallback, fileDownloadInfo.Size.Bytes); using var streamCopier = new MinecraftServerDownloadStreamCopier(progressCallback, info.Size.Bytes);
downloadedFileHash = await streamCopier.Copy(responseStream, fileStream, cancellationToken); downloadedFileHash = await streamCopier.Copy(responseStream, fileStream, cancellationToken);
} catch (OperationCanceledException) { } catch (OperationCanceledException) {
throw; throw;
@@ -118,8 +154,8 @@ sealed class MinecraftServerExecutableDownloader {
throw StopProcedureException.Instance; throw StopProcedureException.Instance;
} }
if (!downloadedFileHash.Equals(fileDownloadInfo.Hash)) { if (!downloadedFileHash.Equals(info.Hash)) {
Logger.Error("Downloaded server executable has mismatched SHA1 hash. Expected {Expected}, got {Actual}.", fileDownloadInfo.Hash, downloadedFileHash); Logger.Error("Downloaded server executable has mismatched SHA1 hash. Expected {Expected}, got {Actual}.", info.Hash, downloadedFileHash);
throw StopProcedureException.Instance; throw StopProcedureException.Instance;
} }
} }
@@ -134,6 +170,83 @@ 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 sealed class MinecraftServerDownloadStreamCopier : IDisposable {
private readonly StreamCopier streamCopier = new (); private readonly StreamCopier streamCopier = new ();
private readonly IncrementalHash sha1 = IncrementalHash.CreateHash(HashAlgorithmName.SHA1); private readonly IncrementalHash sha1 = IncrementalHash.CreateHash(HashAlgorithmName.SHA1);
@@ -165,4 +278,10 @@ sealed class MinecraftServerExecutableDownloader {
streamCopier.Dispose(); streamCopier.Dispose();
} }
} }
private sealed class StopProcedureException : Exception {
public static StopProcedureException Instance { get; } = new ();
private StopProcedureException() {}
}
} }

View File

@@ -1,16 +1,13 @@
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using Phantom.Common.Data.Minecraft;
using Phantom.Common.Logging; using Phantom.Common.Logging;
using Phantom.Utils.IO;
using Serilog; using Serilog;
namespace Phantom.Agent.Minecraft.Server; namespace Phantom.Agent.Minecraft.Server;
public sealed partial class MinecraftServerExecutables { public sealed class MinecraftServerExecutables {
private static readonly ILogger Logger = PhantomLogger.Create<MinecraftServerExecutables>(); private static readonly ILogger Logger = PhantomLogger.Create<MinecraftServerExecutables>();
[GeneratedRegex(@"[^a-zA-Z0-9_\-\.]", RegexOptions.Compiled)] private static readonly Regex VersionFolderSanitizeRegex = new (@"[^a-zA-Z0-9_\-\.]", RegexOptions.Compiled);
private static partial Regex SanitizePathRegex();
private readonly string basePath; private readonly string basePath;
private readonly Dictionary<string, MinecraftServerExecutableDownloader> runningDownloadersByVersion = new (); private readonly Dictionary<string, MinecraftServerExecutableDownloader> runningDownloadersByVersion = new ();
@@ -19,21 +16,16 @@ public sealed partial class MinecraftServerExecutables {
this.basePath = basePath; this.basePath = basePath;
} }
internal async Task<string?> DownloadAndGetPath(FileDownloadInfo? fileDownloadInfo, string minecraftVersion, EventHandler<DownloadProgressEventArgs> progressEventHandler, CancellationToken cancellationToken) { internal async Task<string?> DownloadAndGetPath(string version, EventHandler<DownloadProgressEventArgs> progressEventHandler, CancellationToken cancellationToken) {
string serverExecutableFolderPath = Path.Combine(basePath, SanitizePathRegex().IsMatch(minecraftVersion) ? SanitizePathRegex().Replace(minecraftVersion, "_") : minecraftVersion); string serverExecutableFolderPath = Path.Combine(basePath, VersionFolderSanitizeRegex.Replace(version, "_"));
string serverExecutableFilePath = Path.Combine(serverExecutableFolderPath, "server.jar"); string serverExecutableFilePath = Path.Combine(serverExecutableFolderPath, "server.jar");
if (File.Exists(serverExecutableFilePath)) { if (File.Exists(serverExecutableFilePath)) {
return serverExecutableFilePath; return serverExecutableFilePath;
} }
if (fileDownloadInfo == null) {
Logger.Error("Unable to download server executable for version {Version} because no download info was provided.", minecraftVersion);
return null;
}
try { try {
Directories.Create(serverExecutableFolderPath, Chmod.URWX_GRX); Directory.CreateDirectory(serverExecutableFolderPath);
} catch (Exception e) { } catch (Exception e) {
Logger.Error(e, "Unable to create folder for server executable: {ServerExecutableFolderPath}", serverExecutableFolderPath); Logger.Error(e, "Unable to create folder for server executable: {ServerExecutableFolderPath}", serverExecutableFolderPath);
return null; return null;
@@ -43,19 +35,19 @@ public sealed partial class MinecraftServerExecutables {
MinecraftServerExecutableDownloadListener listener = new (progressEventHandler, cancellationToken); MinecraftServerExecutableDownloadListener listener = new (progressEventHandler, cancellationToken);
lock (this) { lock (this) {
if (runningDownloadersByVersion.TryGetValue(minecraftVersion, out downloader)) { if (runningDownloadersByVersion.TryGetValue(version, out downloader)) {
Logger.Information("A download for server version {Version} is already running, waiting for it to finish...", minecraftVersion); Logger.Information("A download for server version {Version} is already running, waiting for it to finish...", version);
downloader.Register(listener); downloader.Register(listener);
} }
else { else {
downloader = new MinecraftServerExecutableDownloader(fileDownloadInfo, minecraftVersion, serverExecutableFilePath, listener); downloader = new MinecraftServerExecutableDownloader(version, serverExecutableFilePath, listener);
downloader.Completed += (_, _) => { downloader.Completed += (_, _) => {
lock (this) { lock (this) {
runningDownloadersByVersion.Remove(minecraftVersion); runningDownloadersByVersion.Remove(version);
} }
}; };
runningDownloadersByVersion[minecraftVersion] = downloader; runningDownloadersByVersion[version] = downloader;
} }
} }

View File

@@ -0,0 +1,10 @@
using Phantom.Utils.Cryptography;
using Phantom.Utils.IO;
namespace Phantom.Agent.Minecraft.Server;
sealed record ServerExecutableInfo(
string DownloadUrl,
Sha1String Hash,
FileSize Size
);

View File

@@ -1,93 +0,0 @@
using System.Buffers;
using System.Buffers.Binary;
using System.Net;
using System.Net.Sockets;
using System.Text;
using Phantom.Common.Logging;
using Serilog;
namespace Phantom.Agent.Minecraft.Server;
public sealed class ServerStatusProtocol {
private readonly ILogger logger;
public ServerStatusProtocol(string loggerName) {
this.logger = PhantomLogger.Create<ServerStatusProtocol>(loggerName);
}
public async Task<int?> GetOnlinePlayerCount(int serverPort, CancellationToken cancellationToken) {
try {
return await GetOnlinePlayerCountOrThrow(serverPort, cancellationToken);
} catch (Exception e) {
logger.Error(e, "Caught exception while checking if players are online.");
return null;
}
}
private async Task<int?> GetOnlinePlayerCountOrThrow(int serverPort, CancellationToken cancellationToken) {
using var tcpClient = new TcpClient();
await tcpClient.ConnectAsync(IPAddress.Loopback, serverPort, cancellationToken);
var tcpStream = tcpClient.GetStream();
// https://wiki.vg/Server_List_Ping
tcpStream.WriteByte(0xFE);
await tcpStream.FlushAsync(cancellationToken);
short? messageLength = await ReadStreamHeader(tcpStream, cancellationToken);
return messageLength == null ? null : await ReadOnlinePlayerCount(tcpStream, messageLength.Value * 2, cancellationToken);
}
private async Task<short?> ReadStreamHeader(NetworkStream tcpStream, CancellationToken cancellationToken) {
var headerBuffer = ArrayPool<byte>.Shared.Rent(3);
try {
await tcpStream.ReadExactlyAsync(headerBuffer, 0, 3, cancellationToken);
if (headerBuffer[0] != 0xFF) {
logger.Error("Unexpected first byte in response from server: {FirstByte}.", headerBuffer[0]);
return null;
}
short messageLength = BinaryPrimitives.ReadInt16BigEndian(headerBuffer.AsSpan(1));
if (messageLength <= 0) {
logger.Error("Unexpected message length in response from server: {MessageLength}.", messageLength);
return null;
}
return messageLength;
} finally {
ArrayPool<byte>.Shared.Return(headerBuffer);
}
}
private async Task<int?> ReadOnlinePlayerCount(NetworkStream tcpStream, int messageLength, CancellationToken cancellationToken) {
var messageBuffer = ArrayPool<byte>.Shared.Rent(messageLength);
try {
await tcpStream.ReadExactlyAsync(messageBuffer, 0, messageLength, cancellationToken);
// Valid response separator encoded in UTF-16BE is 0x00 0xA7 (§).
const byte SeparatorSecondByte = 0xA7;
static bool IsValidSeparator(ReadOnlySpan<byte> buffer, int index) {
return index > 0 && buffer[index - 1] == 0x00;
}
int separator2 = Array.LastIndexOf(messageBuffer, SeparatorSecondByte);
int separator1 = separator2 == -1 ? -1 : Array.LastIndexOf(messageBuffer, SeparatorSecondByte, separator2 - 1);
if (!IsValidSeparator(messageBuffer, separator1) || !IsValidSeparator(messageBuffer, separator2)) {
logger.Error("Could not find message separators in response from server.");
return null;
}
string onlinePlayerCountStr = Encoding.BigEndianUnicode.GetString(messageBuffer.AsSpan((separator1 + 1)..(separator2 - 1)));
if (!int.TryParse(onlinePlayerCountStr, out int onlinePlayerCount)) {
logger.Error("Could not parse online player count in response from server: {OnlinePlayerCount}.", onlinePlayerCountStr);
return null;
}
logger.Debug("Detected {OnlinePlayerCount} online player(s).", onlinePlayerCount);
return onlinePlayerCount;
} finally {
ArrayPool<byte>.Shared.Return(messageBuffer);
}
}
}

View File

@@ -1,42 +0,0 @@
using Phantom.Common.Logging;
using Phantom.Common.Messages.Agent;
using Phantom.Common.Messages.Agent.ToController;
using Phantom.Utils.Rpc;
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 RpcConnectionToServer<IMessageToControllerListener> connection;
private readonly CancellationTokenSource cancellationTokenSource = new ();
public KeepAliveLoop(RpcConnectionToServer<IMessageToControllerListener> 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

@@ -1,12 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\Common\Phantom.Common.Messages.Agent\Phantom.Common.Messages.Agent.csproj" /> <PackageReference Include="Serilog" Version="2.12.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Common\Phantom.Common.Messages\Phantom.Common.Messages.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -0,0 +1,19 @@
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

@@ -1,46 +1,37 @@
using NetMQ; using NetMQ;
using NetMQ.Sockets; using NetMQ.Sockets;
using Phantom.Common.Data.Agent; using Phantom.Common.Data.Agent;
using Phantom.Common.Messages.Agent; using Phantom.Common.Messages;
using Phantom.Common.Messages.Agent.BiDirectional; using Phantom.Common.Messages.ToServer;
using Phantom.Common.Messages.Agent.ToController;
using Phantom.Utils.Rpc; using Phantom.Utils.Rpc;
using Phantom.Utils.Rpc.Message;
using Phantom.Utils.Tasks;
using Serilog;
using Serilog.Events; using Serilog.Events;
namespace Phantom.Agent.Rpc; namespace Phantom.Agent.Rpc;
public sealed class RpcLauncher : RpcRuntime<ClientSocket> { public sealed class RpcLauncher : RpcRuntime<ClientSocket> {
public static Task Launch(RpcConfiguration config, AuthToken authToken, AgentInfo agentInfo, Func<RpcConnectionToServer<IMessageToControllerListener>, IMessageToAgentListener> listenerFactory, SemaphoreSlim disconnectSemaphore, CancellationToken receiveCancellationToken) { public static async Task Launch(RpcConfiguration config, AgentAuthToken authToken, AgentInfo agentInfo, Func<ClientSocket, IMessageToAgentListener> listenerFactory) {
var socket = new ClientSocket(); var socket = new ClientSocket();
var options = socket.Options; var options = socket.Options;
options.CurveServerCertificate = config.ServerCertificate; options.CurveServerCertificate = config.ServerCertificate;
options.CurveCertificate = new NetMQCertificate(); options.CurveCertificate = new NetMQCertificate();
options.HelloMessage = AgentMessageRegistries.ToController.Write(new RegisterAgentMessage(authToken, agentInfo)).ToArray(); options.HelloMessage = MessageRegistries.ToServer.Write(new RegisterAgentMessage(authToken, agentInfo)).ToArray();
return new RpcLauncher(config, socket, agentInfo.Guid, listenerFactory, disconnectSemaphore, receiveCancellationToken).Launch(); await new RpcLauncher(config, socket, agentInfo.Guid, listenerFactory).Launch();
} }
private readonly RpcConfiguration config; private readonly RpcConfiguration config;
private readonly Guid agentGuid; private readonly Guid agentGuid;
private readonly Func<RpcConnectionToServer<IMessageToControllerListener>, IMessageToAgentListener> messageListenerFactory; private readonly Func<ClientSocket, IMessageToAgentListener> messageListenerFactory;
private readonly SemaphoreSlim disconnectSemaphore; private RpcLauncher(RpcConfiguration config, ClientSocket socket, Guid agentGuid, Func<ClientSocket, IMessageToAgentListener> messageListenerFactory) : base(socket, config.CancellationToken) {
private readonly CancellationToken receiveCancellationToken;
private RpcLauncher(RpcConfiguration config, ClientSocket socket, Guid agentGuid, Func<RpcConnectionToServer<IMessageToControllerListener>, IMessageToAgentListener> messageListenerFactory, SemaphoreSlim disconnectSemaphore, CancellationToken receiveCancellationToken) : base(config, socket) {
this.config = config; this.config = config;
this.agentGuid = agentGuid; this.agentGuid = agentGuid;
this.messageListenerFactory = messageListenerFactory; this.messageListenerFactory = messageListenerFactory;
this.disconnectSemaphore = disconnectSemaphore;
this.receiveCancellationToken = receiveCancellationToken;
} }
protected override void Connect(ClientSocket socket) { protected override void Connect(ClientSocket socket) {
var logger = config.RuntimeLogger; var logger = config.Logger;
var url = config.TcpUrl; var url = config.TcpUrl;
logger.Information("Starting ZeroMQ client and connecting to {Url}...", url); logger.Information("Starting ZeroMQ client and connecting to {Url}...", url);
@@ -48,60 +39,35 @@ public sealed class RpcLauncher : RpcRuntime<ClientSocket> {
logger.Information("ZeroMQ client ready."); logger.Information("ZeroMQ client ready.");
} }
protected override void Run(ClientSocket socket, MessageReplyTracker replyTracker, TaskManager taskManager) { protected override async Task Run(ClientSocket socket, CancellationToken cancellationToken) {
var connection = new RpcConnectionToServer<IMessageToControllerListener>(socket, AgentMessageRegistries.ToController, replyTracker); var logger = config.Logger;
ServerMessaging.SetCurrentConnection(connection);
var logger = config.RuntimeLogger; var listener = messageListenerFactory(socket);
var handler = new MessageToAgentHandler(messageListenerFactory(connection), logger, taskManager, receiveCancellationToken);
var keepAliveLoop = new KeepAliveLoop(connection);
try { ServerMessaging.SetCurrentSocket(socket, cancellationToken);
while (!receiveCancellationToken.IsCancellationRequested) {
var data = socket.Receive(receiveCancellationToken);
LogMessageType(logger, data); // TODO optimize msg
await foreach (var bytes in socket.ReceiveBytesAsyncEnumerable(cancellationToken)) {
if (data.Length > 0) { if (logger.IsEnabled(LogEventLevel.Verbose)) {
AgentMessageRegistries.ToAgent.Handle(data, handler); 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);
} }
} }
} catch (OperationCanceledException) {
// Ignore.
} finally {
logger.Debug("ZeroMQ client stopped receiving messages.");
disconnectSemaphore.Wait(CancellationToken.None); if (bytes.Length > 0) {
keepAliveLoop.Cancel(); MessageRegistries.ToAgent.Handle(bytes, listener, cancellationToken);
}
} }
} }
private static void LogMessageType(ILogger logger, ReadOnlyMemory<byte> data) { protected override async Task Disconnect(ClientSocket socket) {
if (!logger.IsEnabled(LogEventLevel.Verbose)) { var timeoutTask = Task.Delay(TimeSpan.FromSeconds(5));
return; 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.");
if (data.Length > 0 && AgentMessageRegistries.ToAgent.TryGetType(data, out var type)) {
logger.Verbose("Received {MessageType} ({Bytes} B) from controller.", type.Name, data.Length);
}
else {
logger.Verbose("Received {Bytes} B message from controller.", 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 controller.");
}
}
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

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

View File

@@ -9,7 +9,6 @@ public sealed class AgentFolders {
public string DataFolderPath { get; } public string DataFolderPath { get; }
public string InstancesFolderPath { get; } public string InstancesFolderPath { get; }
public string BackupsFolderPath { get; }
public string TemporaryFolderPath { get; } public string TemporaryFolderPath { get; }
public string ServerExecutableFolderPath { get; } public string ServerExecutableFolderPath { get; }
@@ -19,7 +18,6 @@ public sealed class AgentFolders {
public AgentFolders(string dataFolderPath, string temporaryFolderPath, string javaSearchFolderPath) { public AgentFolders(string dataFolderPath, string temporaryFolderPath, string javaSearchFolderPath) {
this.DataFolderPath = Path.GetFullPath(dataFolderPath); this.DataFolderPath = Path.GetFullPath(dataFolderPath);
this.InstancesFolderPath = Path.Combine(DataFolderPath, "instances"); this.InstancesFolderPath = Path.Combine(DataFolderPath, "instances");
this.BackupsFolderPath = Path.Combine(DataFolderPath, "backups");
this.TemporaryFolderPath = Path.GetFullPath(temporaryFolderPath); this.TemporaryFolderPath = Path.GetFullPath(temporaryFolderPath);
this.ServerExecutableFolderPath = Path.Combine(TemporaryFolderPath, "servers"); this.ServerExecutableFolderPath = Path.Combine(TemporaryFolderPath, "servers");
@@ -30,7 +28,6 @@ public sealed class AgentFolders {
public bool TryCreate() { public bool TryCreate() {
return TryCreateFolder(DataFolderPath) && return TryCreateFolder(DataFolderPath) &&
TryCreateFolder(InstancesFolderPath) && TryCreateFolder(InstancesFolderPath) &&
TryCreateFolder(BackupsFolderPath) &&
TryCreateFolder(TemporaryFolderPath) && TryCreateFolder(TemporaryFolderPath) &&
TryCreateFolder(ServerExecutableFolderPath); TryCreateFolder(ServerExecutableFolderPath);
} }

View File

@@ -1,3 +0,0 @@
namespace Phantom.Agent.Services;
public readonly record struct AgentServiceConfiguration(int MaxConcurrentCompressionTasks);

View File

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

View File

@@ -1,180 +0,0 @@
using System.Collections.Immutable;
using System.Diagnostics.CodeAnalysis;
using System.Formats.Tar;
using Phantom.Agent.Minecraft.Instance;
using Phantom.Common.Data.Backups;
using Phantom.Common.Logging;
using Phantom.Utils.IO;
using Serilog;
namespace Phantom.Agent.Services.Backups;
sealed class BackupArchiver {
private readonly string destinationBasePath;
private readonly string temporaryBasePath;
private readonly ILogger logger;
private readonly InstanceProperties instanceProperties;
private readonly CancellationToken cancellationToken;
public BackupArchiver(string destinationBasePath, string temporaryBasePath, string loggerName, InstanceProperties instanceProperties, CancellationToken cancellationToken) {
this.destinationBasePath = destinationBasePath;
this.temporaryBasePath = temporaryBasePath;
this.logger = PhantomLogger.Create<BackupArchiver>(loggerName);
this.instanceProperties = instanceProperties;
this.cancellationToken = cancellationToken;
}
private bool IsFolderSkipped(ImmutableList<string> relativePath) {
return relativePath is ["cache" or "crash-reports" or "debug" or "libraries" or "logs" or "mods" or "versions"];
}
[SuppressMessage("ReSharper", "ConvertIfStatementToReturnStatement")]
private bool IsFileSkipped(ImmutableList<string> relativePath) {
var name = relativePath[^1];
if (relativePath.Count == 2 && name == "session.lock") {
return true;
}
var extension = Path.GetExtension(name);
if (extension is ".jar" or ".zip") {
return true;
}
return false;
}
public async Task<string?> ArchiveWorld(BackupCreationResult.Builder resultBuilder) {
string guid = instanceProperties.InstanceGuid.ToString();
string currentDateTime = DateTime.Now.ToString("yyyyMMdd-HHmmss");
string backupFolderPath = Path.Combine(destinationBasePath, guid);
string backupFilePath = Path.Combine(backupFolderPath, currentDateTime + ".tar");
if (File.Exists(backupFilePath)) {
resultBuilder.Kind = BackupCreationResultKind.BackupFileAlreadyExists;
logger.Warning("Skipping backup, file already exists: {File}", backupFilePath);
return null;
}
try {
Directories.Create(backupFolderPath, Chmod.URWX_GRX);
} catch (Exception e) {
resultBuilder.Kind = BackupCreationResultKind.CouldNotCreateBackupFolder;
logger.Error(e, "Could not create backup folder: {Folder}", backupFolderPath);
return null;
}
string temporaryFolderPath = Path.Combine(temporaryBasePath, guid + "_" + currentDateTime);
if (!await CopyWorldAndCreateTarArchive(temporaryFolderPath, backupFilePath, resultBuilder)) {
return null;
}
logger.Debug("Created world backup: {FilePath}", backupFilePath);
return backupFilePath;
}
private async Task<bool> CopyWorldAndCreateTarArchive(string temporaryFolderPath, string backupFilePath, BackupCreationResult.Builder resultBuilder) {
try {
if (!await CopyWorldToTemporaryFolder(temporaryFolderPath)) {
resultBuilder.Kind = BackupCreationResultKind.CouldNotCopyWorldToTemporaryFolder;
return false;
}
if (!await CreateTarArchive(temporaryFolderPath, backupFilePath)) {
resultBuilder.Kind = BackupCreationResultKind.CouldNotCreateWorldArchive;
return false;
}
return true;
} finally {
try {
Directory.Delete(temporaryFolderPath, recursive: true);
} catch (Exception e) {
resultBuilder.Warnings |= BackupCreationWarnings.CouldNotDeleteTemporaryFolder;
logger.Error(e, "Could not delete temporary world folder: {Folder}", temporaryFolderPath);
}
}
}
private async Task<bool> CopyWorldToTemporaryFolder(string temporaryFolderPath) {
try {
await CopyDirectory(new DirectoryInfo(instanceProperties.InstanceFolder), temporaryFolderPath, ImmutableList<string>.Empty);
return true;
} catch (Exception e) {
logger.Error(e, "Could not copy world to temporary folder.");
return false;
}
}
private async Task<bool> CreateTarArchive(string sourceFolderPath, string backupFilePath) {
try {
await TarFile.CreateFromDirectoryAsync(sourceFolderPath, backupFilePath, false, cancellationToken);
return true;
} catch (Exception e) {
logger.Error(e, "Could not create archive.");
DeleteBrokenArchiveFile(backupFilePath);
return false;
}
}
private void DeleteBrokenArchiveFile(string filePath) {
if (File.Exists(filePath)) {
try {
File.Delete(filePath);
} catch (Exception e) {
logger.Error(e, "Could not delete broken archive: {File}", filePath);
}
}
}
private async Task CopyDirectory(DirectoryInfo sourceFolder, string destinationFolderPath, ImmutableList<string> relativePath) {
cancellationToken.ThrowIfCancellationRequested();
bool needsToCreateFolder = true;
foreach (FileInfo file in sourceFolder.EnumerateFiles()) {
var filePath = relativePath.Add(file.Name);
if (IsFileSkipped(filePath)) {
logger.Debug("Skipping file: {File}", string.Join('/', filePath));
continue;
}
if (needsToCreateFolder) {
needsToCreateFolder = false;
Directories.Create(destinationFolderPath, Chmod.URWX);
}
await CopyFileWithRetries(file, destinationFolderPath);
}
foreach (DirectoryInfo directory in sourceFolder.EnumerateDirectories()) {
var folderPath = relativePath.Add(directory.Name);
if (IsFolderSkipped(folderPath)) {
logger.Debug("Skipping folder: {Folder}", string.Join('/', folderPath));
continue;
}
await CopyDirectory(directory, Path.Join(destinationFolderPath, directory.Name), folderPath);
}
}
private async Task CopyFileWithRetries(FileInfo sourceFile, string destinationFolderPath) {
var destinationFilePath = Path.Combine(destinationFolderPath, sourceFile.Name);
const int TotalAttempts = 10;
for (int attempt = 1; attempt <= TotalAttempts; attempt++) {
try {
sourceFile.CopyTo(destinationFilePath);
return;
} catch (IOException) {
if (attempt == TotalAttempts) {
throw;
}
else {
logger.Warning("Failed copying file {File}, retrying...", sourceFile.FullName);
await Task.Delay(200, cancellationToken);
}
}
}
}
}

View File

@@ -1,67 +0,0 @@
using Phantom.Common.Logging;
using Phantom.Utils.Processes;
using Serilog;
namespace Phantom.Agent.Services.Backups;
static class BackupCompressor {
private static ILogger Logger { get; } = PhantomLogger.Create(nameof(BackupCompressor));
private static ILogger ZstdLogger { get; } = PhantomLogger.Create(nameof(BackupCompressor), "Zstd");
private const string Quality = "-10";
private const string Memory = "--long=26";
private const string Threads = "-T3";
public static async Task<string?> Compress(string sourceFilePath, CancellationToken cancellationToken) {
if (sourceFilePath.Contains('"')) {
Logger.Error("Could not compress backup, archive path contains quotes: {Path}", sourceFilePath);
return null;
}
var destinationFilePath = sourceFilePath + ".zst";
if (!await TryCompressFile(sourceFilePath, destinationFilePath, cancellationToken)) {
try {
File.Delete(destinationFilePath);
} catch (Exception e) {
Logger.Error(e, "Could not delete compresed archive after unsuccessful compression: {Path}", destinationFilePath);
}
return null;
}
return destinationFilePath;
}
private static async Task<bool> TryCompressFile(string sourceFilePath, string destinationFilePath, CancellationToken cancellationToken) {
var workingDirectory = Path.GetDirectoryName(sourceFilePath);
if (string.IsNullOrEmpty(workingDirectory)) {
Logger.Error("Invalid destination path: {Path}", destinationFilePath);
return false;
}
var launcher = new ProcessConfigurator {
FileName = "zstd",
WorkingDirectory = workingDirectory,
ArgumentList = {
Quality,
Memory,
Threads,
"--rm",
"--no-progress",
"-o", destinationFilePath,
"--", sourceFilePath
}
};
static void OnZstdOutput(object? sender, Process.Output output) {
if (!string.IsNullOrWhiteSpace(output.Line)) {
ZstdLogger.Debug("[Output] {Line}", output.Line);
}
}
var process = new OneShotProcess(ZstdLogger, launcher);
process.OutputReceived += OnZstdOutput;
return await process.Run(cancellationToken);
}
}

View File

@@ -1,132 +0,0 @@
using Phantom.Agent.Minecraft.Instance;
using Phantom.Common.Data.Backups;
using Phantom.Common.Logging;
using Serilog;
namespace Phantom.Agent.Services.Backups;
sealed class BackupManager : IDisposable {
private readonly string destinationBasePath;
private readonly string temporaryBasePath;
private readonly SemaphoreSlim compressionSemaphore;
public BackupManager(AgentFolders agentFolders, int maxConcurrentCompressionTasks) {
this.destinationBasePath = agentFolders.BackupsFolderPath;
this.temporaryBasePath = Path.Combine(agentFolders.TemporaryFolderPath, "backups");
this.compressionSemaphore = new SemaphoreSlim(maxConcurrentCompressionTasks, maxConcurrentCompressionTasks);
}
public Task<BackupCreationResult> CreateBackup(string loggerName, InstanceProcess process, CancellationToken cancellationToken) {
return new BackupCreator(this, loggerName, process, cancellationToken).CreateBackup();
}
public void Dispose() {
compressionSemaphore.Dispose();
}
private sealed class BackupCreator {
private readonly BackupManager manager;
private readonly string loggerName;
private readonly ILogger logger;
private readonly InstanceProcess process;
private readonly CancellationToken cancellationToken;
public BackupCreator(BackupManager manager, string loggerName, InstanceProcess process, CancellationToken cancellationToken) {
this.manager = manager;
this.loggerName = loggerName;
this.logger = PhantomLogger.Create<BackupManager>(loggerName);
this.process = process;
this.cancellationToken = cancellationToken;
}
public async Task<BackupCreationResult> CreateBackup() {
logger.Information("Backup started.");
var resultBuilder = new BackupCreationResult.Builder();
string? backupFilePath;
using (var dispatcher = new BackupServerCommandDispatcher(logger, process, cancellationToken)) {
backupFilePath = await CreateWorldArchive(dispatcher, resultBuilder);
}
if (backupFilePath != null) {
await CompressWorldArchive(backupFilePath, resultBuilder);
}
var result = resultBuilder.Build();
LogBackupResult(result);
return result;
}
private async Task<string?> CreateWorldArchive(BackupServerCommandDispatcher dispatcher, BackupCreationResult.Builder resultBuilder) {
try {
await dispatcher.DisableAutomaticSaving();
await dispatcher.SaveAllChunks();
return await new BackupArchiver(manager.destinationBasePath, manager.temporaryBasePath, loggerName, process.InstanceProperties, cancellationToken).ArchiveWorld(resultBuilder);
} catch (OperationCanceledException) {
resultBuilder.Kind = BackupCreationResultKind.BackupCancelled;
logger.Warning("Backup creation was cancelled.");
return null;
} catch (Exception e) {
resultBuilder.Kind = BackupCreationResultKind.UnknownError;
logger.Error(e, "Caught exception while creating an instance backup.");
return null;
} finally {
try {
await dispatcher.EnableAutomaticSaving();
} catch (OperationCanceledException) {
// Ignore.
} catch (Exception e) {
resultBuilder.Warnings |= BackupCreationWarnings.CouldNotRestoreAutomaticSaving;
logger.Error(e, "Caught exception while enabling automatic saving after creating an instance backup.");
}
}
}
private async Task CompressWorldArchive(string filePath, BackupCreationResult.Builder resultBuilder) {
if (!await manager.compressionSemaphore.WaitAsync(TimeSpan.FromSeconds(1), cancellationToken)) {
logger.Information("Too many compression tasks running, waiting for one of them to complete...");
await manager.compressionSemaphore.WaitAsync(cancellationToken);
}
logger.Information("Compressing backup...");
try {
var compressedFilePath = await BackupCompressor.Compress(filePath, cancellationToken);
if (compressedFilePath == null) {
resultBuilder.Warnings |= BackupCreationWarnings.CouldNotCompressWorldArchive;
}
} finally {
manager.compressionSemaphore.Release();
}
}
private void LogBackupResult(BackupCreationResult result) {
if (result.Kind != BackupCreationResultKind.Success) {
logger.Warning("Backup failed: {Reason}", DescribeResult(result.Kind));
return;
}
var warningCount = result.Warnings.Count();
if (warningCount > 0) {
logger.Warning("Backup finished with {Warnings} warning(s).", warningCount);
}
else {
logger.Information("Backup finished successfully.");
}
}
private static string DescribeResult(BackupCreationResultKind kind) {
return kind switch {
BackupCreationResultKind.Success => "Backup created successfully.",
BackupCreationResultKind.InstanceNotRunning => "Instance is not running.",
BackupCreationResultKind.BackupCancelled => "Backup cancelled.",
BackupCreationResultKind.BackupAlreadyRunning => "A backup is already being created.",
BackupCreationResultKind.BackupFileAlreadyExists => "Backup with the same name already exists.",
BackupCreationResultKind.CouldNotCreateBackupFolder => "Could not create backup folder.",
BackupCreationResultKind.CouldNotCopyWorldToTemporaryFolder => "Could not copy world to temporary folder.",
BackupCreationResultKind.CouldNotCreateWorldArchive => "Could not create world archive.",
_ => "Unknown error."
};
}
}
}

View File

@@ -1,116 +0,0 @@
using Phantom.Agent.Minecraft.Instance;
using Phantom.Agent.Minecraft.Server;
using Phantom.Agent.Services.Instances;
using Phantom.Agent.Services.Instances.Procedures;
using Phantom.Common.Data.Backups;
using Phantom.Common.Logging;
using Phantom.Utils.Tasks;
using Phantom.Utils.Threading;
namespace Phantom.Agent.Services.Backups;
sealed class BackupScheduler : CancellableBackgroundTask {
// TODO make configurable
private static readonly TimeSpan InitialDelay = TimeSpan.FromMinutes(2);
private static readonly TimeSpan BackupInterval = TimeSpan.FromMinutes(30);
private static readonly TimeSpan BackupFailureRetryDelay = TimeSpan.FromMinutes(5);
private readonly BackupManager backupManager;
private readonly InstanceProcess process;
private readonly IInstanceContext context;
private readonly SemaphoreSlim backupSemaphore = new (1, 1);
private readonly int serverPort;
private readonly ServerStatusProtocol serverStatusProtocol;
private readonly ManualResetEventSlim serverOutputWhileWaitingForOnlinePlayers = new ();
public event EventHandler<BackupCreationResult>? BackupCompleted;
public BackupScheduler(TaskManager taskManager, BackupManager backupManager, InstanceProcess process, IInstanceContext context, int serverPort) : base(PhantomLogger.Create<BackupScheduler>(context.ShortName), taskManager, "Backup scheduler for " + context.ShortName) {
this.backupManager = backupManager;
this.process = process;
this.context = context;
this.serverPort = serverPort;
this.serverStatusProtocol = new ServerStatusProtocol(context.ShortName);
Start();
}
protected override async Task RunTask() {
await Task.Delay(InitialDelay, CancellationToken);
Logger.Information("Starting a new backup after server launched.");
while (!CancellationToken.IsCancellationRequested) {
var result = await CreateBackup();
BackupCompleted?.Invoke(this, result);
if (result.Kind.ShouldRetry()) {
Logger.Warning("Scheduled backup failed, retrying in {Minutes} minutes.", BackupFailureRetryDelay.TotalMinutes);
await Task.Delay(BackupFailureRetryDelay, CancellationToken);
}
else {
Logger.Information("Scheduling next backup in {Minutes} minutes.", BackupInterval.TotalMinutes);
await Task.Delay(BackupInterval, CancellationToken);
await WaitForOnlinePlayers();
}
}
}
private async Task<BackupCreationResult> CreateBackup() {
if (!await backupSemaphore.WaitAsync(TimeSpan.FromSeconds(1))) {
return new BackupCreationResult(BackupCreationResultKind.BackupAlreadyRunning);
}
try {
var procedure = new BackupInstanceProcedure(backupManager);
context.EnqueueProcedure(procedure);
return await procedure.Result;
} finally {
backupSemaphore.Release();
}
}
private async Task WaitForOnlinePlayers() {
bool needsToLogOfflinePlayersMessage = true;
process.AddOutputListener(ServerOutputListener, maxLinesToReadFromHistory: 0);
try {
while (!CancellationToken.IsCancellationRequested) {
serverOutputWhileWaitingForOnlinePlayers.Reset();
var onlinePlayerCount = await serverStatusProtocol.GetOnlinePlayerCount(serverPort, CancellationToken);
if (onlinePlayerCount == null) {
Logger.Warning("Could not detect whether any players are online, starting a new backup.");
break;
}
if (onlinePlayerCount > 0) {
Logger.Information("Players are online, starting a new backup.");
break;
}
if (needsToLogOfflinePlayersMessage) {
needsToLogOfflinePlayersMessage = false;
Logger.Information("No players are online, waiting for someone to join before starting a new backup.");
}
await Task.Delay(TimeSpan.FromSeconds(10), CancellationToken);
Logger.Debug("Waiting for server output before checking for online players again...");
await serverOutputWhileWaitingForOnlinePlayers.WaitHandle.WaitOneAsync(CancellationToken);
}
} finally {
process.RemoveOutputListener(ServerOutputListener);
}
}
private void ServerOutputListener(object? sender, string line) {
if (!serverOutputWhileWaitingForOnlinePlayers.IsSet) {
serverOutputWhileWaitingForOnlinePlayers.Set();
Logger.Debug("Detected server output, signalling to check for online players again.");
}
}
protected override void Dispose() {
backupSemaphore.Dispose();
serverOutputWhileWaitingForOnlinePlayers.Dispose();
}
}

View File

@@ -1,80 +0,0 @@
using System.Text.RegularExpressions;
using Phantom.Agent.Minecraft.Command;
using Phantom.Agent.Minecraft.Instance;
using Phantom.Utils.Tasks;
using Serilog;
namespace Phantom.Agent.Services.Backups;
sealed partial class BackupServerCommandDispatcher : IDisposable {
[GeneratedRegex(@"^\[(?:.*?)\] \[Server thread/INFO\]: (.*?)$", RegexOptions.NonBacktracking)]
private static partial Regex ServerThreadInfoRegex();
private readonly ILogger logger;
private readonly InstanceProcess process;
private readonly CancellationToken cancellationToken;
private readonly TaskCompletionSource automaticSavingDisabled = AsyncTasks.CreateCompletionSource();
private readonly TaskCompletionSource savedTheGame = AsyncTasks.CreateCompletionSource();
private readonly TaskCompletionSource automaticSavingEnabled = AsyncTasks.CreateCompletionSource();
public BackupServerCommandDispatcher(ILogger logger, InstanceProcess process, CancellationToken cancellationToken) {
this.logger = logger;
this.process = process;
this.cancellationToken = cancellationToken;
this.process.AddOutputListener(OnOutput, maxLinesToReadFromHistory: 0);
}
void IDisposable.Dispose() {
process.RemoveOutputListener(OnOutput);
}
public async Task DisableAutomaticSaving() {
await process.SendCommand(MinecraftCommand.SaveOff, cancellationToken);
await automaticSavingDisabled.Task.WaitAsync(cancellationToken);
}
public async Task SaveAllChunks() {
// TODO Try if not flushing and waiting a few seconds before flushing reduces lag.
await process.SendCommand(MinecraftCommand.SaveAll(flush: true), cancellationToken);
await savedTheGame.Task.WaitAsync(cancellationToken);
}
public async Task EnableAutomaticSaving() {
await process.SendCommand(MinecraftCommand.SaveOn, cancellationToken);
await automaticSavingEnabled.Task.WaitAsync(cancellationToken);
}
private void OnOutput(object? sender, string? line) {
if (line == null) {
return;
}
var match = ServerThreadInfoRegex().Match(line);
if (!match.Success) {
return;
}
string info = match.Groups[1].Value;
if (!automaticSavingDisabled.Task.IsCompleted) {
if (info == "Automatic saving is now disabled") {
logger.Debug("Detected that automatic saving is disabled.");
automaticSavingDisabled.SetResult();
}
}
else if (!savedTheGame.Task.IsCompleted) {
if (info == "Saved the game") {
logger.Debug("Detected that the game is saved.");
savedTheGame.SetResult();
}
}
else if (!automaticSavingEnabled.Task.IsCompleted) {
if (info == "Automatic saving is now enabled") {
logger.Debug("Detected that automatic saving is enabled.");
automaticSavingEnabled.SetResult();
}
}
}
}

View File

@@ -1,25 +0,0 @@
using Phantom.Agent.Services.Instances.Procedures;
using Phantom.Agent.Services.Instances.States;
using Phantom.Common.Data.Instance;
using Serilog;
namespace Phantom.Agent.Services.Instances;
interface IInstanceContext {
string ShortName { get; }
ILogger Logger { get; }
InstanceServices Services { get; }
IInstanceState CurrentState { get; }
void SetStatus(IInstanceStatus newStatus);
void ReportEvent(IInstanceEvent instanceEvent);
void EnqueueProcedure(IInstanceProcedure procedure, bool immediate = false);
}
static class InstanceContextExtensions {
public static void SetLaunchFailedStatusAndReportEvent(this IInstanceContext context, InstanceLaunchFailReason reason) {
context.SetStatus(InstanceStatus.Failed(reason));
context.ReportEvent(new InstanceLaunchFailedEvent(reason));
}
}

View File

@@ -1,188 +1,172 @@
using Phantom.Agent.Minecraft.Launcher; using Phantom.Agent.Minecraft.Launcher;
using Phantom.Agent.Rpc; using Phantom.Agent.Rpc;
using Phantom.Agent.Services.Instances.Procedures;
using Phantom.Agent.Services.Instances.States; using Phantom.Agent.Services.Instances.States;
using Phantom.Common.Data.Instance; using Phantom.Common.Data.Instance;
using Phantom.Common.Data.Minecraft;
using Phantom.Common.Data.Replies; using Phantom.Common.Data.Replies;
using Phantom.Common.Logging; using Phantom.Common.Logging;
using Phantom.Common.Messages.Agent.ToController; using Phantom.Common.Messages.ToServer;
using Serilog; using Serilog;
namespace Phantom.Agent.Services.Instances; namespace Phantom.Agent.Services.Instances;
sealed class Instance : IAsyncDisposable { sealed class Instance : IDisposable {
private InstanceServices Services { get; } private static uint loggerSequenceId = 0;
private static string GetLoggerName(Guid guid) {
var prefix = guid.ToString();
return prefix[..prefix.IndexOf('-')] + "/" + Interlocked.Increment(ref loggerSequenceId);
}
public static async Task<Instance> Create(InstanceConfiguration configuration, BaseLauncher launcher, LaunchServices launchServices, PortManager portManager) {
var instance = new Instance(configuration, launcher, launchServices, portManager);
await instance.ReportLastStatus();
return instance;
}
public InstanceConfiguration Configuration { get; private set; } public InstanceConfiguration Configuration { get; private set; }
private IServerLauncher Launcher { get; set; } private BaseLauncher Launcher { get; set; }
private readonly SemaphoreSlim configurationSemaphore = new (1, 1);
private readonly string shortName; private readonly string shortName;
private readonly ILogger logger; private readonly ILogger logger;
private IInstanceStatus currentStatus; private readonly LaunchServices launchServices;
private int statusUpdateCounter; private readonly PortManager portManager;
private InstanceStatus currentStatus;
private IInstanceState currentState; private IInstanceState currentState;
public bool IsRunning => currentState is not InstanceNotRunningState; private readonly SemaphoreSlim stateTransitioningActionSemaphore = new (1, 1);
public event EventHandler? IsRunningChanged; private Instance(InstanceConfiguration configuration, BaseLauncher launcher, LaunchServices launchServices, PortManager portManager) {
this.shortName = GetLoggerName(configuration.InstanceGuid);
private readonly InstanceProcedureManager procedureManager;
public Instance(string shortName, InstanceServices services, InstanceConfiguration configuration, IServerLauncher launcher) {
this.shortName = shortName;
this.logger = PhantomLogger.Create<Instance>(shortName); this.logger = PhantomLogger.Create<Instance>(shortName);
this.Services = services;
this.Configuration = configuration; this.Configuration = configuration;
this.Launcher = launcher; this.Launcher = launcher;
this.launchServices = launchServices;
this.portManager = portManager;
this.currentState = new InstanceNotRunningState(); this.currentState = new InstanceNotRunningState();
this.currentStatus = InstanceStatus.NotRunning; this.currentStatus = InstanceStatus.IsNotRunning;
this.procedureManager = new InstanceProcedureManager(this, new Context(this), services.TaskManager);
} }
private void TryUpdateStatus(string taskName, Func<Task> getUpdateTask) { private async Task ReportLastStatus() {
int myStatusUpdateCounter = Interlocked.Increment(ref statusUpdateCounter); await ServerMessaging.SendMessage(new ReportInstanceStatusMessage(Configuration.InstanceGuid, currentStatus));
Services.TaskManager.Run(taskName, async () => {
if (myStatusUpdateCounter == statusUpdateCounter) {
await getUpdateTask();
}
});
} }
public void ReportLastStatus() { private bool TransitionState(IInstanceState newState) {
TryUpdateStatus("Report last status of instance " + shortName, async () => {
await ServerMessaging.Send(new ReportInstanceStatusMessage(Configuration.InstanceGuid, currentStatus));
});
}
private void ReportAndSetStatus(IInstanceStatus status) {
TryUpdateStatus("Report status of instance " + shortName + " as " + status.GetType().Name, async () => {
if (status != currentStatus) {
currentStatus = status;
await ServerMessaging.Send(new ReportInstanceStatusMessage(Configuration.InstanceGuid, status));
}
});
}
private void ReportEvent(IInstanceEvent instanceEvent) {
var message = new ReportInstanceEventMessage(Guid.NewGuid(), DateTime.UtcNow, Configuration.InstanceGuid, instanceEvent);
Services.TaskManager.Run("Report event for instance " + shortName, async () => await ServerMessaging.Send(message));
}
internal void TransitionState(IInstanceState newState) {
if (currentState == newState) { if (currentState == newState) {
return; return false;
} }
if (currentState is IDisposable disposable) { if (currentState is IDisposable disposable) {
disposable.Dispose(); disposable.Dispose();
} }
logger.Debug("Transitioning instance state to: {NewState}", newState.GetType().Name);
var wasRunning = IsRunning;
currentState = newState; currentState = newState;
currentState.Initialize(); return true;
if (IsRunning != wasRunning) {
IsRunningChanged?.Invoke(this, EventArgs.Empty);
}
} }
public async Task Reconfigure(InstanceConfiguration configuration, IServerLauncher launcher, CancellationToken cancellationToken) { public async Task Reconfigure(InstanceConfiguration configuration, BaseLauncher launcher, CancellationToken cancellationToken) {
await configurationSemaphore.WaitAsync(cancellationToken); await stateTransitioningActionSemaphore.WaitAsync(cancellationToken);
try { try {
Configuration = configuration; Configuration = configuration;
Launcher = launcher; Launcher = launcher;
await ReportLastStatus();
} finally { } finally {
configurationSemaphore.Release(); stateTransitioningActionSemaphore.Release();
} }
} }
public async Task<LaunchInstanceResult> Launch(CancellationToken cancellationToken) { public async Task<LaunchInstanceResult> Launch(CancellationToken cancellationToken) {
if (IsRunning) { await stateTransitioningActionSemaphore.WaitAsync(cancellationToken);
return LaunchInstanceResult.InstanceAlreadyRunning;
}
if (await procedureManager.GetCurrentProcedure(cancellationToken) is LaunchInstanceProcedure) {
return LaunchInstanceResult.InstanceAlreadyLaunching;
}
LaunchInstanceProcedure procedure;
await configurationSemaphore.WaitAsync(cancellationToken);
try { try {
procedure = new LaunchInstanceProcedure(Configuration, Launcher); if (TransitionState(currentState.Launch(new InstanceContextImpl(this)))) {
} finally { return LaunchInstanceResult.LaunchInitiated;
configurationSemaphore.Release(); }
}
ReportAndSetStatus(InstanceStatus.Launching); return currentState switch {
await procedureManager.Enqueue(procedure); InstanceLaunchingState => LaunchInstanceResult.InstanceAlreadyLaunching,
return LaunchInstanceResult.LaunchInitiated; InstanceRunningState => LaunchInstanceResult.InstanceAlreadyRunning,
InstanceStoppingState => LaunchInstanceResult.InstanceIsStopping,
_ => LaunchInstanceResult.UnknownError
};
} finally {
stateTransitioningActionSemaphore.Release();
}
} }
public async Task<StopInstanceResult> Stop(MinecraftStopStrategy stopStrategy, CancellationToken cancellationToken) { public async Task<StopInstanceResult> Stop() {
if (!IsRunning) { await stateTransitioningActionSemaphore.WaitAsync();
return StopInstanceResult.InstanceAlreadyStopped; try {
} if (TransitionState(currentState.Stop())) {
return StopInstanceResult.StopInitiated;
}
if (await procedureManager.GetCurrentProcedure(cancellationToken) is StopInstanceProcedure) { return currentState switch {
return StopInstanceResult.InstanceAlreadyStopping; InstanceNotRunningState => StopInstanceResult.InstanceAlreadyStopped,
InstanceLaunchingState => StopInstanceResult.StopInitiated,
InstanceStoppingState => StopInstanceResult.InstanceAlreadyStopping,
_ => StopInstanceResult.UnknownError
};
} finally {
stateTransitioningActionSemaphore.Release();
} }
}
ReportAndSetStatus(InstanceStatus.Stopping); public async Task StopAndWait(TimeSpan waitTime) {
await procedureManager.Enqueue(new StopInstanceProcedure(stopStrategy)); await Stop();
return StopInstanceResult.StopInitiated;
using var waitTokenSource = new CancellationTokenSource(waitTime);
var waitToken = waitTokenSource.Token;
while (currentState is not InstanceNotRunningState) {
await Task.Delay(TimeSpan.FromMilliseconds(250), waitToken);
}
} }
public async Task<bool> SendCommand(string command, CancellationToken cancellationToken) { public async Task<bool> SendCommand(string command, CancellationToken cancellationToken) {
return await currentState.SendCommand(command, cancellationToken); return await currentState.SendCommand(command, cancellationToken);
} }
public async ValueTask DisposeAsync() { private sealed class InstanceContextImpl : InstanceContext {
await procedureManager.DisposeAsync(); private readonly Instance instance;
private int statusUpdateCounter;
while (currentState is not InstanceNotRunningState) { public InstanceContextImpl(Instance instance) : base(instance.Configuration, instance.Launcher) {
await Task.Delay(TimeSpan.FromMilliseconds(250), CancellationToken.None); this.instance = instance;
} }
public override LaunchServices LaunchServices => instance.launchServices;
public override PortManager PortManager => instance.portManager;
public override ILogger Logger => instance.logger;
public override string ShortName => instance.shortName;
public override void ReportStatus(InstanceStatus newStatus) {
int myStatusUpdateCounter = Interlocked.Increment(ref statusUpdateCounter);
Task.Run(async () => {
if (myStatusUpdateCounter == statusUpdateCounter) {
instance.currentStatus = newStatus;
await ServerMessaging.SendMessage(new ReportInstanceStatusMessage(Configuration.InstanceGuid, newStatus));
}
});
}
public override void TransitionState(Func<IInstanceState> newState) {
instance.stateTransitioningActionSemaphore.Wait();
try {
instance.TransitionState(newState());
} finally {
instance.stateTransitioningActionSemaphore.Release();
}
}
}
public void Dispose() {
if (currentState is IDisposable disposable) { if (currentState is IDisposable disposable) {
disposable.Dispose(); disposable.Dispose();
} }
configurationSemaphore.Dispose(); stateTransitioningActionSemaphore.Dispose();
}
private sealed class Context : IInstanceContext {
public string ShortName => instance.shortName;
public ILogger Logger => instance.logger;
public InstanceServices Services => instance.Services;
public IInstanceState CurrentState => instance.currentState;
private readonly Instance instance;
public Context(Instance instance) {
this.instance = instance;
}
public void SetStatus(IInstanceStatus newStatus) {
instance.ReportAndSetStatus(newStatus);
}
public void ReportEvent(IInstanceEvent instanceEvent) {
instance.ReportEvent(instanceEvent);
}
public void EnqueueProcedure(IInstanceProcedure procedure, bool immediate) {
Services.TaskManager.Run("Enqueue procedure for instance " + instance.shortName, () => instance.procedureManager.Enqueue(procedure, immediate));
}
} }
} }

View File

@@ -0,0 +1,28 @@
using Phantom.Agent.Minecraft.Launcher;
using Phantom.Agent.Services.Instances.States;
using Phantom.Common.Data.Instance;
using Serilog;
namespace Phantom.Agent.Services.Instances;
abstract class InstanceContext {
public InstanceConfiguration Configuration { get; }
public BaseLauncher Launcher { get; }
public abstract LaunchServices LaunchServices { get; }
public abstract PortManager PortManager { get; }
public abstract ILogger Logger { get; }
public abstract string ShortName { get; }
protected InstanceContext(InstanceConfiguration configuration, BaseLauncher launcher) {
Configuration = configuration;
Launcher = launcher;
}
public abstract void ReportStatus(InstanceStatus newStatus);
public abstract void TransitionState(Func<IInstanceState> newState);
public void TransitionState(IInstanceState newState) {
TransitionState(() => newState);
}
}

View File

@@ -1,82 +0,0 @@
using System.Collections.Immutable;
using System.Threading.Channels;
using Phantom.Agent.Rpc;
using Phantom.Common.Logging;
using Phantom.Common.Messages.Agent.ToController;
using Phantom.Utils.Tasks;
namespace Phantom.Agent.Services.Instances;
sealed class InstanceLogSender : CancellableBackgroundTask {
private static readonly BoundedChannelOptions BufferOptions = new (capacity: 64) {
SingleReader = true,
SingleWriter = true,
FullMode = BoundedChannelFullMode.DropNewest
};
private static readonly TimeSpan SendDelay = TimeSpan.FromMilliseconds(200);
private readonly Guid instanceGuid;
private readonly Channel<string> outputChannel;
private int droppedLinesSinceLastSend;
public InstanceLogSender(TaskManager taskManager, Guid instanceGuid, string loggerName) : base(PhantomLogger.Create<InstanceLogSender>(loggerName), taskManager, "Instance log sender for " + loggerName) {
this.instanceGuid = instanceGuid;
this.outputChannel = Channel.CreateBounded<string>(BufferOptions, OnLineDropped);
Start();
}
protected override async Task RunTask() {
var lineReader = outputChannel.Reader;
var lineBuilder = ImmutableArray.CreateBuilder<string>();
try {
while (await lineReader.WaitToReadAsync(CancellationToken)) {
await Task.Delay(SendDelay, CancellationToken);
await SendOutputToServer(ReadLinesFromChannel(lineReader, lineBuilder));
}
} catch (OperationCanceledException) {
// Ignore.
}
// Flush remaining lines.
await SendOutputToServer(ReadLinesFromChannel(lineReader, lineBuilder));
}
private ImmutableArray<string> ReadLinesFromChannel(ChannelReader<string> reader, ImmutableArray<string>.Builder builder) {
builder.Clear();
while (reader.TryRead(out string? line)) {
builder.Add(line);
}
int droppedLines = Interlocked.Exchange(ref droppedLinesSinceLastSend, 0);
if (droppedLines > 0) {
builder.Add($"Dropped {droppedLines} {(droppedLines == 1 ? "line" : "lines")} due to buffer overflow.");
}
return builder.ToImmutable();
}
private async Task SendOutputToServer(ImmutableArray<string> lines) {
if (!lines.IsEmpty) {
await ServerMessaging.Send(new InstanceOutputMessage(instanceGuid, lines));
}
}
private void OnLineDropped(string line) {
Logger.Warning("Buffer is full, dropped line: {Line}", line);
Interlocked.Increment(ref droppedLinesSinceLastSend);
}
public void Enqueue(string line) {
outputChannel.Writer.TryWrite(line);
}
protected override void Dispose() {
if (!outputChannel.Writer.TryComplete()) {
Logger.Error("Could not mark channel as completed.");
}
}
}

View File

@@ -0,0 +1,84 @@
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,85 +0,0 @@
using Phantom.Agent.Services.Instances.Procedures;
using Phantom.Common.Data.Minecraft;
using Phantom.Utils.Tasks;
using Phantom.Utils.Threading;
namespace Phantom.Agent.Services.Instances;
sealed class InstanceProcedureManager : IAsyncDisposable {
private readonly record struct CurrentProcedure(IInstanceProcedure Procedure, CancellationTokenSource CancellationTokenSource);
private readonly ThreadSafeStructRef<CurrentProcedure> currentProcedure = new ();
private readonly ThreadSafeLinkedList<IInstanceProcedure> procedureQueue = new ();
private readonly AutoResetEvent procedureQueueReady = new (false);
private readonly ManualResetEventSlim procedureQueueFinished = new (false);
private readonly Instance instance;
private readonly IInstanceContext context;
private readonly CancellationTokenSource shutdownCancellationTokenSource = new ();
public InstanceProcedureManager(Instance instance, IInstanceContext context, TaskManager taskManager) {
this.instance = instance;
this.context = context;
taskManager.Run("Procedure manager for instance " + context.ShortName, Run);
}
public async Task Enqueue(IInstanceProcedure procedure, bool immediate = false) {
await procedureQueue.Add(procedure, toFront: immediate, shutdownCancellationTokenSource.Token);
procedureQueueReady.Set();
}
public async Task<IInstanceProcedure?> GetCurrentProcedure(CancellationToken cancellationToken) {
return (await currentProcedure.Get(cancellationToken))?.Procedure;
}
private async Task Run() {
try {
var shutdownCancellationToken = shutdownCancellationTokenSource.Token;
while (true) {
await procedureQueueReady.WaitOneAsync(shutdownCancellationToken);
while (await procedureQueue.TryTakeFromFront(shutdownCancellationToken) is {} nextProcedure) {
using var procedureCancellationTokenSource = new CancellationTokenSource();
await currentProcedure.Set(new CurrentProcedure(nextProcedure, procedureCancellationTokenSource), shutdownCancellationToken);
await RunProcedure(nextProcedure, procedureCancellationTokenSource.Token);
await currentProcedure.Set(null, shutdownCancellationToken);
}
}
} catch (OperationCanceledException) {
// Ignore.
}
await RunProcedure(new StopInstanceProcedure(MinecraftStopStrategy.Instant), CancellationToken.None);
procedureQueueFinished.Set();
}
private async Task RunProcedure(IInstanceProcedure procedure, CancellationToken cancellationToken) {
var procedureName = procedure.GetType().Name;
context.Logger.Debug("Started procedure: {Procedure}", procedureName);
try {
var newState = await procedure.Run(context, cancellationToken);
context.Logger.Debug("Finished procedure: {Procedure}", procedureName);
if (newState != null) {
instance.TransitionState(newState);
}
} catch (OperationCanceledException) {
context.Logger.Debug("Cancelled procedure: {Procedure}", procedureName);
} catch (Exception e) {
context.Logger.Error(e, "Caught exception while running procedure: {Procedure}", procedureName);
}
}
public async ValueTask DisposeAsync() {
shutdownCancellationTokenSource.Cancel();
(await currentProcedure.Get(CancellationToken.None))?.CancellationTokenSource.Cancel();
await procedureQueueFinished.WaitHandle.WaitOneAsync();
currentProcedure.Dispose();
procedureQueue.Dispose();
procedureQueueReady.Dispose();
procedureQueueFinished.Dispose();
shutdownCancellationTokenSource.Dispose();
}
}

View File

@@ -1,7 +0,0 @@
using Phantom.Agent.Minecraft.Launcher;
using Phantom.Agent.Services.Backups;
using Phantom.Utils.Tasks;
namespace Phantom.Agent.Services.Instances;
sealed record InstanceServices(TaskManager TaskManager, PortManager PortManager, BackupManager BackupManager, LaunchServices LaunchServices);

View File

@@ -1,83 +1,59 @@
using System.Collections.Immutable; using Phantom.Agent.Minecraft.Instance;
using System.Diagnostics.CodeAnalysis;
using Phantom.Agent.Minecraft.Instance;
using Phantom.Agent.Minecraft.Java; using Phantom.Agent.Minecraft.Java;
using Phantom.Agent.Minecraft.Launcher; using Phantom.Agent.Minecraft.Launcher;
using Phantom.Agent.Minecraft.Launcher.Types; using Phantom.Agent.Minecraft.Launcher.Types;
using Phantom.Agent.Minecraft.Properties; using Phantom.Agent.Minecraft.Properties;
using Phantom.Agent.Minecraft.Server; using Phantom.Agent.Minecraft.Server;
using Phantom.Agent.Rpc;
using Phantom.Agent.Services.Backups;
using Phantom.Common.Data; using Phantom.Common.Data;
using Phantom.Common.Data.Agent; using Phantom.Common.Data.Agent;
using Phantom.Common.Data.Instance; using Phantom.Common.Data.Instance;
using Phantom.Common.Data.Minecraft;
using Phantom.Common.Data.Replies; using Phantom.Common.Data.Replies;
using Phantom.Common.Logging; using Phantom.Common.Logging;
using Phantom.Common.Messages.Agent.ToController;
using Phantom.Utils.IO;
using Phantom.Utils.Tasks;
using Serilog; using Serilog;
namespace Phantom.Agent.Services.Instances; namespace Phantom.Agent.Services.Instances;
sealed class InstanceSessionManager : IAsyncDisposable { sealed class InstanceSessionManager : IDisposable {
private static readonly ILogger Logger = PhantomLogger.Create<InstanceSessionManager>(); private static readonly ILogger Logger = PhantomLogger.Create<InstanceSessionManager>();
private readonly AgentInfo agentInfo; private readonly AgentInfo agentInfo;
private readonly string basePath; private readonly string basePath;
private readonly InstanceServices instanceServices; private readonly LaunchServices launchServices;
private readonly PortManager portManager;
private readonly Dictionary<Guid, Instance> instances = new (); private readonly Dictionary<Guid, Instance> instances = new ();
private readonly CancellationTokenSource shutdownCancellationTokenSource = new (); private readonly CancellationTokenSource shutdownCancellationTokenSource = new ();
private readonly CancellationToken shutdownCancellationToken; private readonly CancellationToken shutdownCancellationToken;
private readonly SemaphoreSlim semaphore = new (1, 1); private readonly SemaphoreSlim semaphore = new (1, 1);
private uint instanceLoggerSequenceId = 0; public InstanceSessionManager(AgentInfo agentInfo, AgentFolders agentFolders, JavaRuntimeRepository javaRuntimeRepository) {
public InstanceSessionManager(AgentInfo agentInfo, AgentFolders agentFolders, JavaRuntimeRepository javaRuntimeRepository, TaskManager taskManager, BackupManager backupManager) {
this.agentInfo = agentInfo; this.agentInfo = agentInfo;
this.basePath = agentFolders.InstancesFolderPath; this.basePath = agentFolders.InstancesFolderPath;
this.launchServices = new LaunchServices(new MinecraftServerExecutables(agentFolders.ServerExecutableFolderPath), javaRuntimeRepository);
this.portManager = new PortManager(agentInfo.AllowedServerPorts, agentInfo.AllowedRconPorts);
this.shutdownCancellationToken = shutdownCancellationTokenSource.Token; this.shutdownCancellationToken = shutdownCancellationTokenSource.Token;
var minecraftServerExecutables = new MinecraftServerExecutables(agentFolders.ServerExecutableFolderPath);
var launchServices = new LaunchServices(minecraftServerExecutables, javaRuntimeRepository);
var portManager = new PortManager(agentInfo.AllowedServerPorts, agentInfo.AllowedRconPorts);
this.instanceServices = new InstanceServices(taskManager, portManager, backupManager, launchServices);
} }
private async Task<InstanceActionResult<T>> AcquireSemaphoreAndRun<T>(Func<Task<InstanceActionResult<T>>> func) { public async Task<ConfigureInstanceResult> Configure(InstanceConfiguration configuration) {
try { try {
await semaphore.WaitAsync(shutdownCancellationToken); await semaphore.WaitAsync(shutdownCancellationToken);
try {
return await func();
} finally {
semaphore.Release();
}
} catch (OperationCanceledException) { } catch (OperationCanceledException) {
return InstanceActionResult.General<T>(InstanceActionGeneralResult.AgentShuttingDown); return ConfigureInstanceResult.AgentShuttingDown;
} }
}
[SuppressMessage("ReSharper", "ConvertIfStatementToReturnStatement")] var instanceGuid = configuration.InstanceGuid;
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, InstanceLaunchProperties launchProperties, bool launchNow, bool alwaysReportStatus) { try {
return await AcquireSemaphoreAndRun(async () => { var otherInstances = instances.Values.Where(inst => inst.Configuration.InstanceGuid != instanceGuid).ToArray();
var instanceGuid = configuration.InstanceGuid; if (otherInstances.Length + 1 > agentInfo.MaxInstances) {
var instanceFolder = Path.Combine(basePath, instanceGuid.ToString()); return ConfigureInstanceResult.InstanceLimitExceeded;
Directories.Create(instanceFolder, Chmod.URWX_GRX); }
var availableMemory = agentInfo.MaxMemory - otherInstances.Aggregate(RamAllocationUnits.Zero, static (total, instance) => total + instance.Configuration.MemoryAllocation);
if (availableMemory < configuration.MemoryAllocation) {
return ConfigureInstanceResult.MemoryLimitExceeded;
}
var heapMegabytes = configuration.MemoryAllocation.InMegabytes; var heapMegabytes = configuration.MemoryAllocation.InMegabytes;
var jvmProperties = new JvmProperties( var jvmProperties = new JvmProperties(
@@ -85,111 +61,111 @@ sealed class InstanceSessionManager : IAsyncDisposable {
MaximumHeapMegabytes: heapMegabytes MaximumHeapMegabytes: heapMegabytes
); );
var instanceFolder = Path.Combine(basePath, instanceGuid.ToString());
Directory.CreateDirectory(instanceFolder);
var properties = new InstanceProperties( var properties = new InstanceProperties(
instanceGuid,
configuration.JavaRuntimeGuid, configuration.JavaRuntimeGuid,
jvmProperties, jvmProperties,
configuration.JvmArguments,
instanceFolder, instanceFolder,
configuration.MinecraftVersion, configuration.MinecraftVersion,
new ServerProperties(configuration.ServerPort, configuration.RconPort), new ServerProperties(configuration.ServerPort, configuration.RconPort)
launchProperties
); );
IServerLauncher launcher = configuration.MinecraftServerKind switch { BaseLauncher launcher = new VanillaLauncher(properties);
MinecraftServerKind.Vanilla => new VanillaLauncher(properties),
MinecraftServerKind.Fabric => new FabricLauncher(properties),
_ => InvalidLauncher.Instance
};
if (instances.TryGetValue(instanceGuid, out var instance)) { if (instances.TryGetValue(instanceGuid, out var instance)) {
await instance.Reconfigure(configuration, launcher, shutdownCancellationToken); await instance.Reconfigure(configuration, launcher, shutdownCancellationToken);
Logger.Information("Reconfigured instance \"{Name}\" (GUID {Guid}).", configuration.InstanceName, configuration.InstanceGuid); Logger.Information("Reconfigured instance \"{Name}\" (GUID {Guid}).", configuration.InstanceName, configuration.InstanceGuid);
if (alwaysReportStatus) {
instance.ReportLastStatus();
}
} }
else { else {
instances[instanceGuid] = instance = new Instance(GetInstanceLoggerName(instanceGuid), instanceServices, configuration, launcher); instances[instanceGuid] = instance = await Instance.Create(configuration, launcher, launchServices, portManager);
Logger.Information("Created instance \"{Name}\" (GUID {Guid}).", configuration.InstanceName, configuration.InstanceGuid); Logger.Information("Created instance \"{Name}\" (GUID {Guid}).", configuration.InstanceName, configuration.InstanceGuid);
instance.ReportLastStatus();
instance.IsRunningChanged += OnInstanceIsRunningChanged;
} }
if (launchNow) { if (configuration.LaunchAutomatically) {
await LaunchInternal(instance); await instance.Launch(shutdownCancellationToken);
} }
return InstanceActionResult.Concrete(ConfigureInstanceResult.Success); return ConfigureInstanceResult.Success;
}); } finally {
semaphore.Release();
}
} }
private string GetInstanceLoggerName(Guid guid) { public async Task<LaunchInstanceResult> Launch(Guid instanceGuid) {
var prefix = guid.ToString();
return prefix[..prefix.IndexOf('-')] + "/" + Interlocked.Increment(ref instanceLoggerSequenceId);
}
private ImmutableArray<Instance> GetRunningInstancesInternal() {
return instances.Values.Where(static instance => instance.IsRunning).ToImmutableArray();
}
private void OnInstanceIsRunningChanged(object? sender, EventArgs e) {
instanceServices.TaskManager.Run("Handle instance running state changed event", RefreshAgentStatus);
}
public async Task RefreshAgentStatus() {
try { try {
await semaphore.WaitAsync(shutdownCancellationToken); await semaphore.WaitAsync(shutdownCancellationToken);
try {
var runningInstances = GetRunningInstancesInternal();
var runningInstanceCount = runningInstances.Length;
var runningInstanceMemory = runningInstances.Aggregate(RamAllocationUnits.Zero, static (total, instance) => total + instance.Configuration.MemoryAllocation);
await ServerMessaging.Send(new ReportAgentStatusMessage(runningInstanceCount, runningInstanceMemory));
} finally {
semaphore.Release();
}
} catch (OperationCanceledException) { } catch (OperationCanceledException) {
// ignore 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) { public async Task<StopInstanceResult> Stop(Guid instanceGuid) {
return AcquireSemaphoreAndRunWithInstance(instanceGuid, LaunchInternal); try {
} await semaphore.WaitAsync(shutdownCancellationToken);
} catch (OperationCanceledException) {
private async Task<LaunchInstanceResult> LaunchInternal(Instance instance) { return StopInstanceResult.AgentShuttingDown;
var runningInstances = GetRunningInstancesInternal();
if (runningInstances.Length + 1 > agentInfo.MaxInstances) {
return LaunchInstanceResult.InstanceLimitExceeded;
} }
var availableMemory = agentInfo.MaxMemory - runningInstances.Aggregate(RamAllocationUnits.Zero, static (total, instance) => total + instance.Configuration.MemoryAllocation); try {
if (availableMemory < instance.Configuration.MemoryAllocation) { if (!instances.TryGetValue(instanceGuid, out var instance)) {
return LaunchInstanceResult.MemoryLimitExceeded; return StopInstanceResult.InstanceDoesNotExist;
}
else {
return await instance.Stop();
}
} finally {
semaphore.Release();
}
}
public async Task<SendCommandToInstanceResult> SendCommand(Guid instanceGuid, string command) {
try {
await semaphore.WaitAsync(shutdownCancellationToken);
} catch (OperationCanceledException) {
return SendCommandToInstanceResult.AgentShuttingDown;
} }
return await instance.Launch(shutdownCancellationToken); 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<StopInstanceResult>> Stop(Guid instanceGuid, MinecraftStopStrategy stopStrategy) { public async Task StopAll() {
return AcquireSemaphoreAndRunWithInstance(instanceGuid, instance => instance.Stop(stopStrategy, shutdownCancellationToken));
}
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 ValueTask DisposeAsync() {
Logger.Information("Stopping all instances...");
shutdownCancellationTokenSource.Cancel(); shutdownCancellationTokenSource.Cancel();
await semaphore.WaitAsync(CancellationToken.None); await semaphore.WaitAsync(CancellationToken.None);
await Task.WhenAll(instances.Values.Select(static instance => instance.DisposeAsync().AsTask())); try {
instances.Clear(); await Task.WhenAll(instances.Values.Select(static instance => instance.StopAndWait(TimeSpan.FromSeconds(30))));
instances.Clear();
} finally {
semaphore.Release();
}
}
public void Dispose() {
shutdownCancellationTokenSource.Dispose(); shutdownCancellationTokenSource.Dispose();
semaphore.Dispose(); semaphore.Dispose();
} }

View File

@@ -14,28 +14,17 @@ sealed class PortManager {
} }
public Result Reserve(InstanceConfiguration configuration) { 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) { lock (usedPorts) {
if (usedPorts.Contains(serverPort)) { if (usedPorts.Contains(configuration.ServerPort)) {
return Result.ServerPortAlreadyInUse; return Result.ServerPortAlreadyInUse;
} }
if (usedPorts.Contains(rconPort)) { if (usedPorts.Contains(configuration.RconPort)) {
return Result.RconPortAlreadyInUse; return Result.RconPortAlreadyInUse;
} }
usedPorts.Add(serverPort); usedPorts.Add(configuration.ServerPort);
usedPorts.Add(rconPort); usedPorts.Add(configuration.RconPort);
} }
return Result.Success; return Result.Success;
@@ -53,6 +42,6 @@ sealed class PortManager {
ServerPortNotAllowed, ServerPortNotAllowed,
ServerPortAlreadyInUse, ServerPortAlreadyInUse,
RconPortNotAllowed, RconPortNotAllowed,
RconPortAlreadyInUse RconPortAlreadyInUse,
} }
} }

View File

@@ -1,29 +0,0 @@
using Phantom.Agent.Services.Backups;
using Phantom.Agent.Services.Instances.States;
using Phantom.Common.Data.Backups;
namespace Phantom.Agent.Services.Instances.Procedures;
sealed record BackupInstanceProcedure(BackupManager BackupManager) : IInstanceProcedure {
private readonly TaskCompletionSource<BackupCreationResult> resultCompletionSource = new ();
public Task<BackupCreationResult> Result => resultCompletionSource.Task;
public async Task<IInstanceState?> Run(IInstanceContext context, CancellationToken cancellationToken) {
if (context.CurrentState is not InstanceRunningState runningState || runningState.Process.HasEnded) {
resultCompletionSource.SetResult(new BackupCreationResult(BackupCreationResultKind.InstanceNotRunning));
return null;
}
try {
var result = await BackupManager.CreateBackup(context.ShortName, runningState.Process, cancellationToken);
resultCompletionSource.SetResult(result);
} catch (OperationCanceledException) {
resultCompletionSource.SetCanceled(cancellationToken);
} catch (Exception e) {
resultCompletionSource.SetException(e);
}
return null;
}
}

View File

@@ -1,7 +0,0 @@
using Phantom.Agent.Services.Instances.States;
namespace Phantom.Agent.Services.Instances.Procedures;
interface IInstanceProcedure {
Task<IInstanceState?> Run(IInstanceContext context, CancellationToken cancellationToken);
}

View File

@@ -1,97 +0,0 @@
using Phantom.Agent.Minecraft.Instance;
using Phantom.Agent.Minecraft.Launcher;
using Phantom.Agent.Minecraft.Server;
using Phantom.Agent.Services.Instances.States;
using Phantom.Common.Data.Instance;
namespace Phantom.Agent.Services.Instances.Procedures;
sealed record LaunchInstanceProcedure(InstanceConfiguration Configuration, IServerLauncher Launcher, bool IsRestarting = false) : IInstanceProcedure {
public async Task<IInstanceState?> Run(IInstanceContext context, CancellationToken cancellationToken) {
if (!IsRestarting && context.CurrentState is InstanceRunningState) {
return null;
}
context.SetStatus(IsRestarting ? InstanceStatus.Restarting : InstanceStatus.Launching);
InstanceLaunchFailReason? failReason = context.Services.PortManager.Reserve(Configuration) switch {
PortManager.Result.ServerPortNotAllowed => InstanceLaunchFailReason.ServerPortNotAllowed,
PortManager.Result.ServerPortAlreadyInUse => InstanceLaunchFailReason.ServerPortAlreadyInUse,
PortManager.Result.RconPortNotAllowed => InstanceLaunchFailReason.RconPortNotAllowed,
PortManager.Result.RconPortAlreadyInUse => InstanceLaunchFailReason.RconPortAlreadyInUse,
_ => null
};
if (failReason is {} reason) {
context.SetLaunchFailedStatusAndReportEvent(reason);
return new InstanceNotRunningState();
}
context.Logger.Information("Session starting...");
try {
InstanceProcess process = await DoLaunch(context, cancellationToken);
return new InstanceRunningState(Configuration, Launcher, process, context);
} catch (OperationCanceledException) {
context.SetStatus(InstanceStatus.NotRunning);
} catch (LaunchFailureException e) {
context.Logger.Error(e.LogMessage);
context.SetLaunchFailedStatusAndReportEvent(e.Reason);
} catch (Exception e) {
context.Logger.Error(e, "Caught exception while launching instance.");
context.SetLaunchFailedStatusAndReportEvent(InstanceLaunchFailReason.UnknownError);
}
context.Services.PortManager.Release(Configuration);
return new InstanceNotRunningState();
}
private async Task<InstanceProcess> DoLaunch(IInstanceContext context, CancellationToken cancellationToken) {
cancellationToken.ThrowIfCancellationRequested();
byte lastDownloadProgress = byte.MaxValue;
void OnDownloadProgress(object? sender, DownloadProgressEventArgs args) {
byte progress = (byte) Math.Min(args.DownloadedBytes * 100 / args.TotalBytes, 100);
if (lastDownloadProgress != progress) {
lastDownloadProgress = progress;
context.SetStatus(InstanceStatus.Downloading(progress));
}
}
var launchResult = await Launcher.Launch(context.Logger, context.Services.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.CouldNotDownloadMinecraftServer) {
throw new LaunchFailureException(InstanceLaunchFailReason.CouldNotDownloadMinecraftServer, "Session failed to launch, could not download Minecraft server.");
}
else if (launchResult is LaunchResult.CouldNotPrepareMinecraftServerLauncher) {
throw new LaunchFailureException(InstanceLaunchFailReason.CouldNotPrepareMinecraftServerLauncher, "Session failed to launch, could not prepare Minecraft server launcher.");
}
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.SetStatus(InstanceStatus.Running);
context.ReportEvent(InstanceEvent.LaunchSucceded);
return launchSuccess.Process;
}
private sealed class LaunchFailureException : Exception {
public InstanceLaunchFailReason Reason { get; }
public string LogMessage { get; }
public LaunchFailureException(InstanceLaunchFailReason reason, string logMessage) {
this.Reason = reason;
this.LogMessage = logMessage;
}
}
}

View File

@@ -1,17 +0,0 @@
using Phantom.Agent.Services.Instances.States;
using Phantom.Common.Data.Instance;
namespace Phantom.Agent.Services.Instances.Procedures;
sealed record SetInstanceToNotRunningStateProcedure(IInstanceStatus Status) : IInstanceProcedure {
public Task<IInstanceState?> Run(IInstanceContext context, CancellationToken cancellationToken) {
if (context.CurrentState is InstanceRunningState { Process.HasEnded: true }) {
context.SetStatus(Status);
context.ReportEvent(InstanceEvent.Stopped);
return Task.FromResult<IInstanceState?>(new InstanceNotRunningState());
}
else {
return Task.FromResult<IInstanceState?>(null);
}
}
}

View File

@@ -1,103 +0,0 @@
using System.Diagnostics;
using Phantom.Agent.Minecraft.Command;
using Phantom.Agent.Minecraft.Instance;
using Phantom.Agent.Services.Instances.States;
using Phantom.Common.Data.Instance;
using Phantom.Common.Data.Minecraft;
namespace Phantom.Agent.Services.Instances.Procedures;
sealed record StopInstanceProcedure(MinecraftStopStrategy StopStrategy) : IInstanceProcedure {
private static readonly ushort[] Stops = { 60, 30, 10, 5, 4, 3, 2, 1, 0 };
public async Task<IInstanceState?> Run(IInstanceContext context, CancellationToken cancellationToken) {
if (context.CurrentState is not InstanceRunningState runningState) {
return null;
}
var process = runningState.Process;
runningState.IsStopping = true;
context.SetStatus(InstanceStatus.Stopping);
var seconds = StopStrategy.Seconds;
if (seconds > 0) {
try {
await CountDownWithAnnouncements(context, process, seconds, cancellationToken);
} catch (OperationCanceledException) {
runningState.IsStopping = false;
return null;
}
}
try {
// Too late to cancel the stop procedure now.
if (!process.HasEnded) {
context.Logger.Information("Session stopping now.");
await DoStop(context, process);
}
} finally {
context.Logger.Information("Session stopped.");
context.SetStatus(InstanceStatus.NotRunning);
context.ReportEvent(InstanceEvent.Stopped);
}
return new InstanceNotRunningState();
}
private async Task CountDownWithAnnouncements(IInstanceContext context, InstanceProcess process, ushort seconds, CancellationToken cancellationToken) {
context.Logger.Information("Session stopping in {Seconds} seconds.", seconds);
foreach (var stop in Stops) {
// TODO change to event-based cancellation
if (process.HasEnded) {
return;
}
if (seconds > stop) {
await process.SendCommand(GetCountDownAnnouncementCommand(seconds), cancellationToken);
await Task.Delay(TimeSpan.FromSeconds(seconds - stop), cancellationToken);
seconds = stop;
}
}
}
private static string GetCountDownAnnouncementCommand(ushort seconds) {
return MinecraftCommand.Say("Server shutting down in " + seconds + (seconds == 1 ? " second." : " seconds."));
}
private async Task DoStop(IInstanceContext context, InstanceProcess process) {
context.Logger.Information("Sending stop command...");
await TrySendStopCommand(context, process);
context.Logger.Information("Waiting for session to end...");
await WaitForSessionToEnd(context, process);
}
private async Task TrySendStopCommand(IInstanceContext context, InstanceProcess process) {
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
try {
await process.SendCommand(MinecraftCommand.Stop, timeout.Token);
} catch (OperationCanceledException) {
// Ignore.
} catch (ObjectDisposedException e) when (e.ObjectName == typeof(Process).FullName && process.HasEnded) {
// Ignore.
} catch (Exception e) {
context.Logger.Warning(e, "Caught exception while sending stop command.");
}
}
private async Task WaitForSessionToEnd(IInstanceContext context, InstanceProcess process) {
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(55));
try {
await process.WaitForExit(timeout.Token);
} catch (OperationCanceledException) {
try {
context.Logger.Warning("Waiting timed out, killing session...");
process.Kill();
} catch (Exception e) {
context.Logger.Error(e, "Caught exception while killing session.");
}
}
}
}

View File

@@ -1,6 +1,7 @@
namespace Phantom.Agent.Services.Instances.States; namespace Phantom.Agent.Services.Instances.States;
interface IInstanceState { interface IInstanceState {
void Initialize(); IInstanceState Launch(InstanceContext context);
IInstanceState Stop();
Task<bool> SendCommand(string command, CancellationToken cancellationToken); Task<bool> SendCommand(string command, CancellationToken cancellationToken);
} }

View File

@@ -0,0 +1,104 @@
using Phantom.Agent.Minecraft.Instance;
using Phantom.Agent.Minecraft.Launcher;
using Phantom.Agent.Minecraft.Server;
using Phantom.Common.Data.Instance;
namespace Phantom.Agent.Services.Instances.States;
sealed class InstanceLaunchingState : IInstanceState, IDisposable {
private readonly InstanceContext context;
private readonly CancellationTokenSource cancellationTokenSource = new ();
private byte lastDownloadProgress = byte.MaxValue;
public InstanceLaunchingState(InstanceContext context) {
this.context = context;
this.context.Logger.Information("Session starting...");
this.context.ReportStatus(InstanceStatus.IsLaunching);
var launchTask = Task.Run(DoLaunch);
launchTask.ContinueWith(OnLaunchSuccess, CancellationToken.None, TaskContinuationOptions.OnlyOnRanToCompletion, TaskScheduler.Default);
launchTask.ContinueWith(OnLaunchFailure, CancellationToken.None, TaskContinuationOptions.NotOnRanToCompletion, TaskScheduler.Default);
}
private async Task<InstanceSession> DoLaunch() {
var cancellationToken = cancellationTokenSource.Token;
cancellationToken.ThrowIfCancellationRequested();
void OnDownloadProgress(object? sender, DownloadProgressEventArgs args) {
byte progress = (byte) Math.Min(args.DownloadedBytes * 100 / args.TotalBytes, 100);
if (lastDownloadProgress != progress) {
lastDownloadProgress = progress;
context.ReportStatus(new InstanceStatus.Downloading(progress));
}
}
var launchResult = await context.Launcher.Launch(context.LaunchServices, OnDownloadProgress, cancellationToken);
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.");
}
if (launchResult is not LaunchResult.Success launchSuccess) {
throw new LaunchFailureException(InstanceLaunchFailReason.UnknownError, "Session failed to launch.");
}
context.ReportStatus(InstanceStatus.IsLaunching);
return launchSuccess.Session;
}
private void OnLaunchSuccess(Task<InstanceSession> task) {
context.TransitionState(() => {
if (cancellationTokenSource.IsCancellationRequested) {
context.PortManager.Release(context.Configuration);
context.ReportStatus(InstanceStatus.IsNotRunning);
return new InstanceNotRunningState();
}
else {
return new InstanceRunningState(context, task.Result);
}
});
}
private void OnLaunchFailure(Task task) {
if (task.Exception is { InnerException: LaunchFailureException e }) {
context.Logger.Error(e.LogMessage);
context.ReportStatus(new InstanceStatus.Failed(e.Reason));
}
else {
context.ReportStatus(new InstanceStatus.Failed(InstanceLaunchFailReason.UnknownError));
}
context.PortManager.Release(context.Configuration);
context.TransitionState(new InstanceNotRunningState());
}
private sealed class LaunchFailureException : Exception {
public InstanceLaunchFailReason Reason { get; }
public string LogMessage { get; }
public LaunchFailureException(InstanceLaunchFailReason reason, string logMessage) {
this.Reason = reason;
this.LogMessage = logMessage;
}
}
public IInstanceState Launch(InstanceContext context) {
return this;
}
public IInstanceState Stop() {
cancellationTokenSource.Cancel();
return this;
}
public Task<bool> SendCommand(string command, CancellationToken cancellationToken) {
return Task.FromResult(false);
}
public void Dispose() {
cancellationTokenSource.Dispose();
}
}

View File

@@ -1,7 +1,28 @@
namespace Phantom.Agent.Services.Instances.States; using Phantom.Common.Data.Instance;
namespace Phantom.Agent.Services.Instances.States;
sealed class InstanceNotRunningState : IInstanceState { sealed class InstanceNotRunningState : IInstanceState {
public void Initialize() {} public IInstanceState Launch(InstanceContext context) {
InstanceLaunchFailReason? failReason = context.PortManager.Reserve(context.Configuration) switch {
PortManager.Result.ServerPortNotAllowed => InstanceLaunchFailReason.ServerPortNotAllowed,
PortManager.Result.ServerPortAlreadyInUse => InstanceLaunchFailReason.ServerPortAlreadyInUse,
PortManager.Result.RconPortNotAllowed => InstanceLaunchFailReason.RconPortNotAllowed,
PortManager.Result.RconPortAlreadyInUse => InstanceLaunchFailReason.RconPortAlreadyInUse,
_ => null
};
if (failReason != null) {
context.ReportStatus(new InstanceStatus.Failed(failReason.Value));
return this;
}
return new InstanceLaunchingState(context);
}
public IInstanceState Stop() {
return this;
}
public Task<bool> SendCommand(string command, CancellationToken cancellationToken) { public Task<bool> SendCommand(string command, CancellationToken cancellationToken) {
return Task.FromResult(false); return Task.FromResult(false);

View File

@@ -1,81 +1,62 @@
using Phantom.Agent.Minecraft.Instance; using Phantom.Agent.Minecraft.Instance;
using Phantom.Agent.Minecraft.Launcher;
using Phantom.Agent.Services.Backups;
using Phantom.Agent.Services.Instances.Procedures;
using Phantom.Common.Data.Backups;
using Phantom.Common.Data.Instance; using Phantom.Common.Data.Instance;
namespace Phantom.Agent.Services.Instances.States; namespace Phantom.Agent.Services.Instances.States;
sealed class InstanceRunningState : IInstanceState, IDisposable { sealed class InstanceRunningState : IInstanceState {
public InstanceProcess Process { get; } private readonly InstanceContext context;
private readonly InstanceSession session;
private readonly InstanceLogSenderThread logSenderThread;
private readonly SessionObjects sessionObjects;
internal bool IsStopping { get; set; } public InstanceRunningState(InstanceContext context, InstanceSession session) {
private readonly InstanceConfiguration configuration;
private readonly IServerLauncher launcher;
private readonly IInstanceContext context;
private readonly InstanceLogSender logSender;
private readonly BackupScheduler backupScheduler;
private bool isDisposed;
public InstanceRunningState(InstanceConfiguration configuration, IServerLauncher launcher, InstanceProcess process, IInstanceContext context) {
this.configuration = configuration;
this.launcher = launcher;
this.context = context; this.context = context;
this.Process = process; this.session = session;
this.logSenderThread = new InstanceLogSenderThread(context.Configuration.InstanceGuid, context.ShortName);
this.sessionObjects = new SessionObjects(context, session, logSenderThread);
this.logSender = new InstanceLogSender(context.Services.TaskManager, configuration.InstanceGuid, context.ShortName); this.session.AddOutputListener(SessionOutput);
this.session.SessionEnded += SessionEnded;
this.backupScheduler = new BackupScheduler(context.Services.TaskManager, context.Services.BackupManager, process, context, configuration.ServerPort); if (session.HasEnded) {
this.backupScheduler.BackupCompleted += OnScheduledBackupCompleted; if (sessionObjects.Dispose()) {
}
public void Initialize() {
Process.Ended += ProcessEnded;
if (Process.HasEnded) {
if (TryDispose()) {
context.Logger.Warning("Session ended immediately after it was started."); context.Logger.Warning("Session ended immediately after it was started.");
context.EnqueueProcedure(new SetInstanceToNotRunningStateProcedure(InstanceStatus.Failed(InstanceLaunchFailReason.UnknownError)), immediate: true); context.ReportStatus(new InstanceStatus.Failed(InstanceLaunchFailReason.UnknownError));
Task.Run(() => context.TransitionState(new InstanceNotRunningState()));
} }
} }
else { else {
context.ReportStatus(InstanceStatus.IsRunning);
context.Logger.Information("Session started."); context.Logger.Information("Session started.");
Process.AddOutputListener(SessionOutput);
} }
} }
private void SessionOutput(object? sender, string line) { private void SessionOutput(object? sender, string e) {
context.Logger.Debug("[Server] {Line}", line); context.Logger.Verbose("[Server] {Line}", e);
logSender.Enqueue(line); logSenderThread.Enqueue(e);
} }
private void ProcessEnded(object? sender, EventArgs e) { private void SessionEnded(object? sender, EventArgs e) {
if (!TryDispose()) { if (sessionObjects.Dispose()) {
return; context.Logger.Information("Session ended.");
} context.ReportStatus(InstanceStatus.IsNotRunning);
context.TransitionState(new InstanceNotRunningState());
if (IsStopping) {
context.EnqueueProcedure(new SetInstanceToNotRunningStateProcedure(InstanceStatus.NotRunning), immediate: true);
}
else {
context.Logger.Information("Session ended unexpectedly, restarting...");
context.ReportEvent(InstanceEvent.Crashed);
context.EnqueueProcedure(new LaunchInstanceProcedure(configuration, launcher, IsRestarting: true));
} }
} }
private void OnScheduledBackupCompleted(object? sender, BackupCreationResult e) { public IInstanceState Launch(InstanceContext context) {
context.ReportEvent(new InstanceBackupCompletedEvent(e.Kind, e.Warnings)); return this;
}
public IInstanceState Stop() {
session.SessionEnded -= SessionEnded;
return new InstanceStoppingState(context, session, sessionObjects);
} }
public async Task<bool> SendCommand(string command, CancellationToken cancellationToken) { public async Task<bool> SendCommand(string command, CancellationToken cancellationToken) {
try { try {
context.Logger.Information("Sending command: {Command}", command); context.Logger.Information("Sending command: {Command}", command);
await Process.SendCommand(command, cancellationToken); await session.SendCommand(command, cancellationToken);
return true; return true;
} catch (OperationCanceledException) { } catch (OperationCanceledException) {
return false; return false;
@@ -85,25 +66,31 @@ sealed class InstanceRunningState : IInstanceState, IDisposable {
} }
} }
private bool TryDispose() { public sealed class SessionObjects {
lock (this) { private readonly InstanceContext context;
if (isDisposed) { private readonly InstanceSession session;
return false; private readonly InstanceLogSenderThread logSenderThread;
} private bool isDisposed;
isDisposed = true; public SessionObjects(InstanceContext context, InstanceSession session, InstanceLogSenderThread logSenderThread) {
this.context = context;
this.session = session;
this.logSenderThread = logSenderThread;
} }
logSender.Stop(); public bool Dispose() {
backupScheduler.Stop(); lock (this) {
if (isDisposed) {
return false;
}
Process.Dispose(); isDisposed = true;
context.Services.PortManager.Release(configuration); }
return true; logSenderThread.Cancel();
} session.Dispose();
context.PortManager.Release(context.Configuration);
public void Dispose() { return true;
TryDispose(); }
} }
} }

View File

@@ -0,0 +1,75 @@
using Phantom.Agent.Minecraft.Instance;
using Phantom.Common.Data.Instance;
namespace Phantom.Agent.Services.Instances.States;
sealed class InstanceStoppingState : IInstanceState, IDisposable {
private readonly InstanceContext context;
private readonly InstanceSession session;
private readonly InstanceRunningState.SessionObjects sessionObjects;
public InstanceStoppingState(InstanceContext context, InstanceSession session, InstanceRunningState.SessionObjects sessionObjects) {
this.sessionObjects = sessionObjects;
this.session = session;
this.context = context;
this.context.Logger.Information("Session stopping.");
this.context.ReportStatus(InstanceStatus.IsStopping);
Task.Run(DoStop);
}
private async Task DoStop() {
try {
context.Logger.Information("Sending stop command...");
await DoSendStopCommand();
context.Logger.Information("Waiting for session to end...");
await DoWaitForSessionToEnd();
} finally {
context.Logger.Information("Session stopped.");
context.ReportStatus(InstanceStatus.IsNotRunning);
context.TransitionState(new InstanceNotRunningState());
}
}
private async Task DoSendStopCommand() {
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
try {
await session.SendCommand("stop", cts.Token);
} catch (OperationCanceledException) {
// 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));
try {
await session.WaitForExit(cts.Token);
} catch (OperationCanceledException) {
try {
context.Logger.Warning("Waiting timed out, killing session...");
session.Kill();
} catch (Exception e) {
context.Logger.Error(e, "Caught exception while killing session.");
}
}
}
public IInstanceState Launch(InstanceContext context) {
return this;
}
public IInstanceState Stop() {
return this; // TODO maybe provide a way to kill?
}
public Task<bool> SendCommand(string command, CancellationToken cancellationToken) {
return Task.FromResult(false);
}
public void Dispose() {
sessionObjects.Dispose();
}
}

View File

@@ -1,12 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\Common\Phantom.Common.Messages.Agent\Phantom.Common.Messages.Agent.csproj" /> <ProjectReference Include="..\..\Common\Phantom.Common.Messages\Phantom.Common.Messages.csproj" />
<ProjectReference Include="..\Phantom.Agent.Minecraft\Phantom.Agent.Minecraft.csproj" /> <ProjectReference Include="..\Phantom.Agent.Minecraft\Phantom.Agent.Minecraft.csproj" />
<ProjectReference Include="..\Phantom.Agent.Rpc\Phantom.Agent.Rpc.csproj" /> <ProjectReference Include="..\Phantom.Agent.Rpc\Phantom.Agent.Rpc.csproj" />
</ItemGroup> </ItemGroup>

View File

@@ -1,13 +1,10 @@
using Phantom.Agent.Rpc; using NetMQ.Sockets;
using Phantom.Common.Data.Instance; using Phantom.Agent.Rpc;
using Phantom.Common.Data.Replies; using Phantom.Common.Data.Replies;
using Phantom.Common.Logging; using Phantom.Common.Logging;
using Phantom.Common.Messages.Agent; using Phantom.Common.Messages;
using Phantom.Common.Messages.Agent.BiDirectional; using Phantom.Common.Messages.ToAgent;
using Phantom.Common.Messages.Agent.ToAgent; using Phantom.Common.Messages.ToServer;
using Phantom.Common.Messages.Agent.ToController;
using Phantom.Utils.Rpc;
using Phantom.Utils.Rpc.Message;
using Serilog; using Serilog;
namespace Phantom.Agent.Services.Rpc; namespace Phantom.Agent.Services.Rpc;
@@ -15,39 +12,32 @@ namespace Phantom.Agent.Services.Rpc;
public sealed class MessageListener : IMessageToAgentListener { public sealed class MessageListener : IMessageToAgentListener {
private static ILogger Logger { get; } = PhantomLogger.Create<MessageListener>(); private static ILogger Logger { get; } = PhantomLogger.Create<MessageListener>();
private readonly RpcConnectionToServer<IMessageToControllerListener> connection; private readonly ClientSocket socket;
private readonly AgentServices agent; private readonly AgentServices agent;
private readonly CancellationTokenSource shutdownTokenSource; private readonly CancellationTokenSource shutdownTokenSource;
public MessageListener(RpcConnectionToServer<IMessageToControllerListener> connection, AgentServices agent, CancellationTokenSource shutdownTokenSource) { public MessageListener(ClientSocket socket, AgentServices agent, CancellationTokenSource shutdownTokenSource) {
this.connection = connection; this.socket = socket;
this.agent = agent; this.agent = agent;
this.shutdownTokenSource = shutdownTokenSource; this.shutdownTokenSource = shutdownTokenSource;
} }
public async Task<NoReply> HandleRegisterAgentSuccess(RegisterAgentSuccessMessage message) { public async Task HandleRegisterAgentSuccessResult(RegisterAgentSuccessMessage message) {
Logger.Information("Agent authentication successful."); Logger.Information("Agent authentication successful.");
void ShutdownAfterConfigurationFailed(InstanceConfiguration configuration) { foreach (var instanceInfo in message.InitialInstances) {
Logger.Fatal("Unable to configure instance \"{Name}\" (GUID {Guid}), shutting down.", configuration.InstanceName, configuration.InstanceGuid); if (await agent.InstanceSessionManager.Configure(instanceInfo) != ConfigureInstanceResult.Success) {
shutdownTokenSource.Cancel(); Logger.Fatal("Unable to configure instance \"{Name}\" (GUID {Guid}), shutting down.", instanceInfo.InstanceName, instanceInfo.InstanceGuid);
}
foreach (var configureInstanceMessage in message.InitialInstanceConfigurations) { shutdownTokenSource.Cancel();
var result = await HandleConfigureInstance(configureInstanceMessage, alwaysReportStatus: true); return;
if (!result.Is(ConfigureInstanceResult.Success)) {
ShutdownAfterConfigurationFailed(configureInstanceMessage.Configuration);
return NoReply.Instance;
} }
} }
await ServerMessaging.Send(new AdvertiseJavaRuntimesMessage(agent.JavaRuntimeRepository.All)); await ServerMessaging.SendMessage(new AdvertiseJavaRuntimesMessage(agent.JavaRuntimeRepository.All));
await agent.InstanceSessionManager.RefreshAgentStatus();
return NoReply.Instance;
} }
public Task<NoReply> HandleRegisterAgentFailure(RegisterAgentFailureMessage message) { public Task HandleRegisterAgentFailureResult(RegisterAgentFailureMessage message) {
string errorMessage = message.FailureKind switch { string errorMessage = message.FailureKind switch {
RegisterAgentFailure.ConnectionAlreadyHasAnAgent => "This connection already has an associated agent.", RegisterAgentFailure.ConnectionAlreadyHasAnAgent => "This connection already has an associated agent.",
RegisterAgentFailure.InvalidToken => "Invalid token.", RegisterAgentFailure.InvalidToken => "Invalid token.",
@@ -55,35 +45,24 @@ public sealed class MessageListener : IMessageToAgentListener {
}; };
Logger.Fatal("Agent authentication failed: {Error}", errorMessage); Logger.Fatal("Agent authentication failed: {Error}", errorMessage);
PhantomLogger.Dispose();
Environment.Exit(1); Environment.Exit(1);
return Task.FromResult(NoReply.Instance); return Task.CompletedTask;
} }
private Task<InstanceActionResult<ConfigureInstanceResult>> HandleConfigureInstance(ConfigureInstanceMessage message, bool alwaysReportStatus) { public async Task HandleConfigureInstance(ConfigureInstanceMessage message) {
return agent.InstanceSessionManager.Configure(message.Configuration, message.LaunchProperties, message.LaunchNow, alwaysReportStatus); await socket.SendSimpleReply(message, await agent.InstanceSessionManager.Configure(message.Configuration));
} }
public async Task<InstanceActionResult<ConfigureInstanceResult>> HandleConfigureInstance(ConfigureInstanceMessage message) { public async Task HandleLaunchInstance(LaunchInstanceMessage message) {
return await HandleConfigureInstance(message, alwaysReportStatus: false); await socket.SendSimpleReply(message, await agent.InstanceSessionManager.Launch(message.InstanceGuid));
} }
public async Task<InstanceActionResult<LaunchInstanceResult>> HandleLaunchInstance(LaunchInstanceMessage message) { public async Task HandleStopInstance(StopInstanceMessage message) {
return await agent.InstanceSessionManager.Launch(message.InstanceGuid); await socket.SendSimpleReply(message, await agent.InstanceSessionManager.Stop(message.InstanceGuid));
} }
public async Task<InstanceActionResult<StopInstanceResult>> HandleStopInstance(StopInstanceMessage message) { public async Task HandleSendCommandToInstance(SendCommandToInstanceMessage message) {
return await agent.InstanceSessionManager.Stop(message.InstanceGuid, message.StopStrategy); 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

@@ -1,60 +0,0 @@
using NetMQ;
using Phantom.Common.Data.Agent;
using Phantom.Common.Logging;
using Phantom.Utils.Cryptography;
using Phantom.Utils.IO;
using Serilog;
namespace Phantom.Agent;
static class AgentKey {
private static ILogger Logger { get; } = PhantomLogger.Create(nameof(AgentKey));
public static Task<(NetMQCertificate, AuthToken)?> 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, AuthToken)?> 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, AuthToken)? LoadFromToken(string agentKey) {
try {
return LoadFromBytes(TokenGenerator.DecodeBytes(agentKey));
} catch (Exception) {
Logger.Fatal("Invalid agent key: {AgentKey}", agentKey);
return null;
}
}
private static (NetMQCertificate, AuthToken)? LoadFromBytes(byte[] agentKey) {
var (publicKey, agentToken) = ConnectionCommonKey.FromBytes(agentKey);
var controllerCertificate = NetMQCertificate.FromPublicKey(publicKey);
Logger.Information("Loaded agent key.");
return (controllerCertificate, agentToken);
}
}

View File

@@ -0,0 +1,476 @@
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

@@ -0,0 +1,32 @@
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; namespace Phantom.Agent;
static class GuidFile { static class GuidFile {
private static ILogger Logger { get; } = PhantomLogger.Create(nameof(GuidFile)); private static ILogger Logger { get; } = PhantomLogger.Create(typeof(GuidFile));
private const string GuidFileName = "agent.guid"; private const string GuidFileName = "agent.guid";

View File

@@ -1,17 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
</PropertyGroup> </PropertyGroup>
<PropertyGroup> <PropertyGroup>
<OutputType>Exe</OutputType> <OutputType>Exe</OutputType>
<SatelliteResourceLanguages>en</SatelliteResourceLanguages>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\Utils\Phantom.Utils\Phantom.Utils.csproj" /> <ProjectReference Include="..\..\Utils\Phantom.Utils.IO\Phantom.Utils.IO.csproj" />
<ProjectReference Include="..\..\Utils\Phantom.Utils.Runtime\Phantom.Utils.Runtime.csproj" />
<ProjectReference Include="..\Phantom.Agent.Minecraft\Phantom.Agent.Minecraft.csproj" /> <ProjectReference Include="..\Phantom.Agent.Minecraft\Phantom.Agent.Minecraft.csproj" />
<ProjectReference Include="..\Phantom.Agent.Services\Phantom.Agent.Services.csproj" /> <ProjectReference Include="..\Phantom.Agent.Services\Phantom.Agent.Services.csproj" />
</ItemGroup> </ItemGroup>

View File

@@ -1,86 +1,64 @@
using System.Reflection; using Phantom.Agent;
using Phantom.Agent;
using Phantom.Agent.Rpc; using Phantom.Agent.Rpc;
using Phantom.Agent.Services; using Phantom.Agent.Services;
using Phantom.Agent.Services.Rpc; using Phantom.Agent.Services.Rpc;
using Phantom.Common.Data.Agent; using Phantom.Common.Data.Agent;
using Phantom.Common.Logging; using Phantom.Common.Logging;
using Phantom.Common.Messages.Agent;
using Phantom.Utils.Rpc; using Phantom.Utils.Rpc;
using Phantom.Utils.Runtime; using Phantom.Utils.Runtime;
using Phantom.Utils.Tasks;
const int ProtocolVersion = 1; const int AgentVersion = 1;
var shutdownCancellationTokenSource = new CancellationTokenSource(); var cancellationTokenSource = new CancellationTokenSource();
var shutdownCancellationToken = shutdownCancellationTokenSource.Token;
PosixSignals.RegisterCancellation(shutdownCancellationTokenSource, static () => { PosixSignals.RegisterCancellation(cancellationTokenSource, static () => {
PhantomLogger.Root.InformationHeading("Stopping Phantom Panel agent..."); PhantomLogger.Root.InformationHeading("Stopping Phantom Panel agent...");
}); });
ThreadPool.SetMinThreads(workerThreads: 2, completionPortThreads: 1);
try { try {
var fullVersion = AssemblyAttributes.GetFullVersion(Assembly.GetExecutingAssembly());
PhantomLogger.Root.InformationHeading("Initializing Phantom Panel agent..."); PhantomLogger.Root.InformationHeading("Initializing Phantom Panel agent...");
PhantomLogger.Root.Information("Agent version: {Version}", fullVersion);
var (controllerHost, controllerPort, javaSearchPath, agentKeyToken, agentKeyFilePath, agentName, maxInstances, maxMemory, allowedServerPorts, allowedRconPorts, maxConcurrentBackupCompressionTasks) = Variables.LoadOrStop(); var (serverHost, serverPort, javaSearchPath, authToken, authTokenFilePath, agentNameOrEmpty, maxInstances, maxMemory, allowedServerPorts, allowedRconPorts) = Variables.LoadOrExit();
var agentKey = await AgentKey.Load(agentKeyToken, agentKeyFilePath); AgentAuthToken agentAuthToken;
if (agentKey == null) { try {
return 1; 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) {
Environment.Exit(1);
} }
var folders = new AgentFolders("./data", "./temp", javaSearchPath); var folders = new AgentFolders("./data", "./temp", javaSearchPath);
if (!folders.TryCreate()) { if (!folders.TryCreate()) {
return 1; Environment.Exit(1);
} }
var agentGuid = await GuidFile.CreateOrLoad(folders.DataFolderPath); var agentGuid = await GuidFile.CreateOrLoad(folders.DataFolderPath);
if (agentGuid == null) { if (agentGuid == null) {
return 1; Environment.Exit(1);
return;
} }
var (controllerCertificate, agentToken) = agentKey.Value; var agentName = string.IsNullOrEmpty(agentNameOrEmpty) ? AgentNameGenerator.GenerateFrom(agentGuid.Value) : agentNameOrEmpty;
var agentInfo = new AgentInfo(agentGuid.Value, agentName, ProtocolVersion, fullVersion, maxInstances, maxMemory, allowedServerPorts, allowedRconPorts); var agentInfo = new AgentInfo(agentGuid.Value, agentName, AgentVersion, maxInstances, maxMemory, allowedServerPorts, allowedRconPorts);
var agentServices = new AgentServices(agentInfo, folders, new AgentServiceConfiguration(maxConcurrentBackupCompressionTasks)); var agentServices = new AgentServices(agentInfo, folders);
MessageListener MessageListenerFactory(RpcConnectionToServer<IMessageToControllerListener> connection) {
return new MessageListener(connection, agentServices, shutdownCancellationTokenSource);
}
PhantomLogger.Root.InformationHeading("Launching Phantom Panel agent..."); PhantomLogger.Root.InformationHeading("Launching Phantom Panel agent...");
await agentServices.Initialize(); await agentServices.Initialize();
await RpcLauncher.Launch(new RpcConfiguration(PhantomLogger.Create("Rpc"), serverHost, serverPort, serverCertificate, cancellationTokenSource.Token), agentAuthToken, agentInfo, socket => new MessageListener(socket, agentServices, cancellationTokenSource));
var rpcDisconnectSemaphore = new SemaphoreSlim(0, 1); await agentServices.Shutdown();
var rpcConfiguration = new RpcConfiguration(PhantomLogger.Create("Rpc"), PhantomLogger.Create<TaskManager>("Rpc"), controllerHost, controllerPort, controllerCertificate);
var rpcTask = RpcLauncher.Launch(rpcConfiguration, agentToken, agentInfo, MessageListenerFactory, rpcDisconnectSemaphore, shutdownCancellationToken);
try {
await rpcTask.WaitAsync(shutdownCancellationToken);
} finally {
shutdownCancellationTokenSource.Cancel();
await agentServices.Shutdown();
rpcDisconnectSemaphore.Release();
await rpcTask;
rpcDisconnectSemaphore.Dispose();
}
return 0;
} catch (OperationCanceledException) { } catch (OperationCanceledException) {
return 0; // Ignore.
} catch (StopProcedureException) {
return 1;
} catch (Exception e) {
PhantomLogger.Root.Fatal(e, "Caught exception in entry point.");
return 1;
} finally { } finally {
shutdownCancellationTokenSource.Dispose(); cancellationTokenSource.Dispose();
PhantomLogger.Root.Information("Bye!"); PhantomLogger.Root.Information("Bye!");
PhantomLogger.Dispose(); PhantomLogger.Dispose();
} }

View File

@@ -6,34 +6,32 @@ using Phantom.Utils.Runtime;
namespace Phantom.Agent; namespace Phantom.Agent;
sealed record Variables( sealed record Variables(
string ControllerHost, string ServerHost,
ushort ControllerPort, ushort ServerPort,
string JavaSearchPath, string JavaSearchPath,
string? AgentKeyToken, string? AuthToken,
string? AgentKeyFilePath, string? AuthTokenFilePath,
string AgentName, string AgentNameOrEmpty,
ushort MaxInstances, ushort MaxInstances,
RamAllocationUnits MaxMemory, RamAllocationUnits MaxMemory,
AllowedPorts AllowedServerPorts, AllowedPorts AllowedServerPorts,
AllowedPorts AllowedRconPorts, AllowedPorts AllowedRconPorts
ushort MaxConcurrentBackupCompressionTasks
) { ) {
private static Variables LoadOrThrow() { private static Variables LoadOrThrow() {
var (agentKeyToken, agentKeyFilePath) = EnvironmentVariables.GetEitherString("AGENT_KEY", "AGENT_KEY_FILE").Require; var (authToken, authTokenFilePath) = EnvironmentVariables.GetEitherString("SERVER_AUTH_TOKEN", "SERVER_AUTH_TOKEN_FILE").OrThrow;
var javaSearchPath = EnvironmentVariables.GetString("JAVA_SEARCH_PATH").WithDefaultGetter(GetDefaultJavaSearchPath); var javaSearchPath = EnvironmentVariables.GetString("JAVA_SEARCH_PATH").OrGetDefault(GetDefaultJavaSearchPath);
return new Variables( return new Variables(
EnvironmentVariables.GetString("CONTROLLER_HOST").Require, EnvironmentVariables.GetString("SERVER_HOST").OrThrow,
EnvironmentVariables.GetPortNumber("CONTROLLER_PORT").WithDefault(9401), EnvironmentVariables.GetPortNumber("SERVER_PORT").OrDefault(9401),
javaSearchPath, javaSearchPath,
agentKeyToken, authToken,
agentKeyFilePath, authTokenFilePath,
EnvironmentVariables.GetString("AGENT_NAME").Require, EnvironmentVariables.GetString("AGENT_NAME").OrDefault(string.Empty),
(ushort) EnvironmentVariables.GetInteger("MAX_INSTANCES", min: 1, max: 10000).Require, (ushort) EnvironmentVariables.GetInteger("MAX_INSTANCES", min: 1, max: 10000).OrThrow,
EnvironmentVariables.GetString("MAX_MEMORY").MapParse(RamAllocationUnits.FromString).Require, EnvironmentVariables.GetString("MAX_MEMORY").MapParse(RamAllocationUnits.FromString).OrThrow,
EnvironmentVariables.GetString("ALLOWED_SERVER_PORTS").MapParse(AllowedPorts.FromString).Require, EnvironmentVariables.GetString("ALLOWED_SERVER_PORTS").MapParse(AllowedPorts.FromString).OrThrow,
EnvironmentVariables.GetString("ALLOWED_RCON_PORTS").MapParse(AllowedPorts.FromString).Require, EnvironmentVariables.GetString("ALLOWED_RCON_PORTS").MapParse(AllowedPorts.FromString).OrThrow
(ushort) EnvironmentVariables.GetInteger("MAX_CONCURRENT_BACKUP_COMPRESSION_TASKS", min: 1, max: 10000).WithDefault(1)
); );
} }
@@ -41,12 +39,13 @@ sealed record Variables(
return JavaRuntimeDiscovery.GetSystemSearchPath() ?? throw new Exception("Could not automatically determine the path to Java installations on this system. Please set the JAVA_SEARCH_PATH environment variable to the folder containing Java installations."); return JavaRuntimeDiscovery.GetSystemSearchPath() ?? throw new Exception("Could not automatically determine the path to Java installations on this system. Please set the JAVA_SEARCH_PATH environment variable to the folder containing Java installations.");
} }
public static Variables LoadOrStop() { public static Variables LoadOrExit() {
try { try {
return LoadOrThrow(); return LoadOrThrow();
} catch (Exception e) { } catch (Exception e) {
PhantomLogger.Root.Fatal(e.Message); PhantomLogger.Root.Fatal(e.Message);
throw StopProcedureException.Instance; Environment.Exit(1);
throw;
} }
} }
} }

View File

@@ -1,6 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
</PropertyGroup> </PropertyGroup>
@@ -10,10 +11,11 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.3.2" />
<PackageReference Include="NUnit" /> <PackageReference Include="NUnit" Version="3.13.3" />
<PackageReference Include="NUnit3TestAdapter" /> <PackageReference Include="NUnit3TestAdapter" Version="4.2.1" />
<PackageReference Include="NUnit.Analyzers" /> <PackageReference Include="NUnit.Analyzers" Version="3.3.0" />
<PackageReference Include="coverlet.collector" Version="3.1.2" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View File

@@ -1,47 +0,0 @@
namespace Phantom.Controller.Database.Enums;
public enum AuditLogEventType {
AdministratorUserCreated,
AdministratorUserModified,
UserLoggedIn,
UserLoggedOut,
UserCreated,
UserPasswordChanged,
UserRolesChanged,
UserDeleted,
InstanceCreated,
InstanceEdited,
InstanceLaunched,
InstanceStopped,
InstanceCommandExecuted
}
static class AuditLogEventTypeExtensions {
private static readonly Dictionary<AuditLogEventType, AuditLogSubjectType> SubjectTypes = new () {
{ AuditLogEventType.AdministratorUserCreated, AuditLogSubjectType.User },
{ AuditLogEventType.AdministratorUserModified, AuditLogSubjectType.User },
{ AuditLogEventType.UserLoggedIn, AuditLogSubjectType.User },
{ AuditLogEventType.UserLoggedOut, AuditLogSubjectType.User },
{ AuditLogEventType.UserCreated, AuditLogSubjectType.User },
{ AuditLogEventType.UserPasswordChanged, AuditLogSubjectType.User },
{ AuditLogEventType.UserRolesChanged, AuditLogSubjectType.User },
{ AuditLogEventType.UserDeleted, AuditLogSubjectType.User },
{ AuditLogEventType.InstanceCreated, AuditLogSubjectType.Instance },
{ AuditLogEventType.InstanceEdited, AuditLogSubjectType.Instance },
{ AuditLogEventType.InstanceLaunched, AuditLogSubjectType.Instance },
{ AuditLogEventType.InstanceStopped, AuditLogSubjectType.Instance },
{ AuditLogEventType.InstanceCommandExecuted, AuditLogSubjectType.Instance }
};
static AuditLogEventTypeExtensions() {
foreach (var eventType in Enum.GetValues<AuditLogEventType>()) {
if (!SubjectTypes.ContainsKey(eventType)) {
throw new Exception("Missing mapping from " + eventType + " to a subject type.");
}
}
}
internal static AuditLogSubjectType GetSubjectType(this AuditLogEventType type) {
return SubjectTypes[type];
}
}

View File

@@ -1,6 +0,0 @@
using System.Text.Json;
using Phantom.Controller.Database.Enums;
namespace Phantom.Controller.Services.Audit;
public sealed record AuditLogItem(DateTime UtcTime, Guid? UserGuid, string? UserName, AuditLogEventType EventType, AuditLogSubjectType SubjectType, string? SubjectId, JsonDocument? Data);

View File

@@ -1,6 +0,0 @@
namespace Phantom.Controller.Database.Enums;
public enum AuditLogSubjectType {
User,
Instance
}

View File

@@ -1,40 +0,0 @@
using System.Collections.Immutable;
namespace Phantom.Common.Data.Web.Minecraft;
public static class JvmArgumentsHelper {
public static ImmutableArray<string> Split(string arguments) {
return arguments.Split('\n', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries).ToImmutableArray();
}
public static string Join(ImmutableArray<string> arguments) {
return string.Join('\n', arguments);
}
public static ValidationError? Validate(string arguments) {
return Validate(Split(arguments));
}
private 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 enum ValidationError {
InvalidFormat,
XmxNotAllowed,
XmsNotAllowed
}
}

View File

@@ -1,8 +0,0 @@
namespace Phantom.Controller.Services.Users.Roles;
public enum AddRoleError : byte {
NameIsEmpty,
NameIsTooLong,
NameAlreadyExists,
UnknownError
}

View File

@@ -1,25 +0,0 @@
using System.Collections.Immutable;
using MemoryPack;
namespace Phantom.Common.Data.Web.Users;
[MemoryPackable]
[MemoryPackUnion(0, typeof(NameIsInvalid))]
[MemoryPackUnion(1, typeof(PasswordIsInvalid))]
[MemoryPackUnion(2, typeof(NameAlreadyExists))]
[MemoryPackUnion(3, typeof(UnknownError))]
public abstract record AddUserError {
private AddUserError() {}
[MemoryPackable(GenerateType.VersionTolerant)]
public sealed record NameIsInvalid([property: MemoryPackOrder(0)] UsernameRequirementViolation Violation) : AddUserError;
[MemoryPackable(GenerateType.VersionTolerant)]
public sealed record PasswordIsInvalid([property: MemoryPackOrder(0)] ImmutableArray<PasswordRequirementViolation> Violations) : AddUserError;
[MemoryPackable(GenerateType.VersionTolerant)]
public sealed record NameAlreadyExists : AddUserError;
[MemoryPackable(GenerateType.VersionTolerant)]
public sealed record UnknownError : AddUserError;
}

View File

@@ -1,28 +0,0 @@
using MemoryPack;
namespace Phantom.Common.Data.Web.Users;
[MemoryPackable]
[MemoryPackUnion(0, typeof(Success))]
[MemoryPackUnion(1, typeof(CreationFailed))]
[MemoryPackUnion(2, typeof(UpdatingFailed))]
[MemoryPackUnion(3, typeof(AddingToRoleFailed))]
[MemoryPackUnion(4, typeof(UnknownError))]
public abstract partial record CreateOrUpdateAdministratorUserResult {
private CreateOrUpdateAdministratorUserResult() {}
[MemoryPackable(GenerateType.VersionTolerant)]
public sealed partial record Success([property: MemoryPackOrder(0)] UserInfo User) : CreateOrUpdateAdministratorUserResult;
[MemoryPackable(GenerateType.VersionTolerant)]
public sealed partial record CreationFailed([property: MemoryPackOrder(0)] AddUserError Error) : CreateOrUpdateAdministratorUserResult;
[MemoryPackable(GenerateType.VersionTolerant)]
public sealed partial record UpdatingFailed([property: MemoryPackOrder(0)] SetUserPasswordError Error) : CreateOrUpdateAdministratorUserResult;
[MemoryPackable(GenerateType.VersionTolerant)]
public sealed partial record AddingToRoleFailed : CreateOrUpdateAdministratorUserResult;
[MemoryPackable(GenerateType.VersionTolerant)]
public sealed partial record UnknownError : CreateOrUpdateAdministratorUserResult;
}

View File

@@ -1,7 +0,0 @@
namespace Phantom.Common.Data.Web.Users;
public enum DeleteUserResult : byte {
Deleted,
NotFound,
Failed
}

View File

@@ -1,25 +0,0 @@
using System.Collections.Immutable;
namespace Phantom.Common.Data.Web.Users.Permissions;
public sealed class IdentityPermissions {
public static IdentityPermissions None { get; } = new (ImmutableHashSet<string>.Empty);
private readonly ImmutableHashSet<string> permissionIds;
public IdentityPermissions(ImmutableHashSet<string> permissionIdsQuery) {
this.permissionIds = permissionIdsQuery;
}
public bool Check(Permission? permission) {
while (permission != null) {
if (!permissionIds.Contains(permission.Id)) {
return false;
}
permission = permission.Parent;
}
return true;
}
}

View File

@@ -1,24 +0,0 @@
using MemoryPack;
namespace Phantom.Common.Data.Web.Users;
[MemoryPackable]
[MemoryPackUnion(0, typeof(TooShort))]
[MemoryPackUnion(1, typeof(LowercaseLetterRequired))]
[MemoryPackUnion(2, typeof(UppercaseLetterRequired))]
[MemoryPackUnion(3, typeof(DigitRequired))]
public abstract record PasswordRequirementViolation {
private PasswordRequirementViolation() {}
[MemoryPackable(GenerateType.VersionTolerant)]
public sealed record TooShort([property: MemoryPackOrder(0)] int MinimumLength) : PasswordRequirementViolation;
[MemoryPackable(GenerateType.VersionTolerant)]
public sealed record LowercaseLetterRequired : PasswordRequirementViolation;
[MemoryPackable(GenerateType.VersionTolerant)]
public sealed record UppercaseLetterRequired : PasswordRequirementViolation;
[MemoryPackable(GenerateType.VersionTolerant)]
public sealed record DigitRequired : PasswordRequirementViolation;
}

View File

@@ -1,40 +0,0 @@
namespace Phantom.Common.Data.Web.Users.Permissions;
public sealed record Permission(string Id, Permission? Parent) {
private static readonly List<Permission> AllPermissions = new ();
public static IEnumerable<Permission> All => AllPermissions;
private static Permission Register(string id, Permission? parent = null) {
var permission = new Permission(id, parent);
AllPermissions.Add(permission);
return permission;
}
private Permission RegisterChild(string id) {
return Register(id, this);
}
public const string ViewInstancesPolicy = "Instances.View";
public static readonly Permission ViewInstances = Register(ViewInstancesPolicy);
public const string ViewInstanceLogsPolicy = "Instances.Logs.View";
public static readonly Permission ViewInstanceLogs = ViewInstances.RegisterChild(ViewInstanceLogsPolicy);
public const string CreateInstancesPolicy = "Instances.Create";
public static readonly Permission CreateInstances = ViewInstances.RegisterChild(CreateInstancesPolicy);
public const string ControlInstancesPolicy = "Instances.Control";
public static readonly Permission ControlInstances = ViewInstances.RegisterChild(ControlInstancesPolicy);
public const string ViewUsersPolicy = "Users.View";
public static readonly Permission ViewUsers = Register(ViewUsersPolicy);
public const string EditUsersPolicy = "Users.Edit";
public static readonly Permission EditUsers = ViewUsers.RegisterChild(EditUsersPolicy);
public const string ViewAuditPolicy = "Audit.View";
public static readonly Permission ViewAudit = Register(ViewAuditPolicy);
public const string ViewEventsPolicy = "Events.View";
public static readonly Permission ViewEvents = Register(ViewEventsPolicy);
}

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